htranx's picture
one image size cap for every division, 200 GB (#18)
4e8b91e
Raw
History Blame
12.6 kB
"""Single source of truth for the Wearable-AI leaderboard.
Shared by the Gradio Space (submit-time validation + board rendering) and the
offline eval worker (re-scoring) so the two can never drift. If you change a
schema here, both sides pick it up.
"""
from __future__ import annotations
import os
# --- HF repos -------------------------------------------------------------
ORG = os.environ.get("ORG", "facebook")
REQUESTS_REPO = f"{ORG}/wearable-ai-leaderboard-requests"
RESULTS_REPO = f"{ORG}/wearable-ai-leaderboard-results"
# Token used by the Space (submit -> requests) and worker (results write).
# Set as a Space secret; falls back to the standard HF env vars locally.
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
# --- Phase / tracks / divisions ------------------------------------------
PHASE = "val" # validation phase; test phase reuses these schemas later.
TRACKS = ["convqa", "longqa", "proactive"]
# Why a track is not offered in the TEST phase. Delete an entry here and the
# track becomes available; TEST_TRACKS is derived, and nothing else changes.
#
# This is the ONE line to change. An earlier version of this file hardcoded
# TEST_TRACKS and claimed the same thing, which was false: a test pinned
# proactive absent, so widening the list alone failed the suite. Deriving the
# list means the test asserts the DERIVATION and follows automatically.
# Tracks not offered in the TEST phase. Add an entry to block one; TEST_TRACKS
# is derived, so that is the only edit needed.
TEST_TRACKS_BLOCKED: dict[str, str] = {}
# Tracks the TEST phase can actually score. Offering one we cannot score would
# let a team register a submission that burns one of their three per-track
# slots and returns nothing, so the cost of the gap falls on the participant.
TEST_TRACKS = [t for t in TRACKS if t not in TEST_TRACKS_BLOCKED]
# Pretty names for the UI.
TRACK_LABELS = {
"convqa": "EgoConv",
"longqa": "EgoLongQA",
"proactive": "EgoProactive",
}
# Map a track to the dataset config folder used by the gold jsonls / starter_kit.
TRACK_CONFIG = {
"convqa": "egoconv",
"longqa": "egolongqa",
"proactive": "egoproactive",
}
DIVISIONS = ["small", "large"]
# Divisions are capped on TOTAL params (not active). small: 0 < x <= 2B, large: x > 2B (no upper cap).
DIVISION_CAPS = {
"small": (0, 2_000_000_000),
"large": (2_000_000_000, float("inf")),
}
# --- Test phase -----------------------------------------------------------
# The test phase does not take predictions. Shortlisted teams push a container
# image to their own ECR repository and REGISTER its digest here; the organizers'
# worker pulls that digest, runs it against the held-out split on the cluster and
# publishes the score. Registration rather than the registry is the throttle: a
# team can push all it likes, and nothing is evaluated until it is registered.
# Whether the Submit tab still accepts validation predictions. Defaults CLOSED,
# so the freeze takes effect the moment this ships and does not depend on anyone
# remembering to set a secret. Set VAL_PHASE_OPEN=1 to reopen it.
#
# The button being greyed out is not the control: submit.validate_and_submit()
# checks this too, because a disabled button is a suggestion and the endpoint is
# still reachable.
VAL_PHASE_OPEN = os.environ.get("VAL_PHASE_OPEN", "").lower() in ("1", "true", "yes")
# Rendered INSIDE the Validation sub-tab on both the Leaderboard and the Submit
# tab, never above the sub-tabs: the Test phase is not closed, and a banner
# outside them would say it was. It therefore says nothing about "below" or
# "above" either, since it has to read correctly in both places.
VAL_CLOSED_NOTICE = (
# Dated so a participant arriving later can see WHEN it closed rather than
# wondering whether the notice is stale.
"**[08/08/2026] The Validation Phase is closed.** The validation leaderboard is final "
"and no further validation predictions are accepted. Shortlisted teams "
"continue in the Test Phase, under **Test** on the Submit tab."
)
TEST_PHASE = "test"
# Whether the Submit tab accepts test-phase registrations. Off until the test
# window opens, so the tab can ship ahead of it; flip with a Space secret rather
# than a code change.
TEST_PHASE_OPEN = os.environ.get("TEST_PHASE_OPEN", "").lower() in ("1", "true", "yes")
# Scored submissions a team may register per TRACK (not per subtrack, and not per
# day as the val phase is): a team's model has one size, so track and subtrack are
# the same in practice here. This is the board-side half of the cap; the worker
# enforces the same number independently, because the board cannot be the only
# gate on work that costs GPU time.
MAX_TEST_SUBMISSIONS_PER_TRACK = 3
# Which request states hold one of those slots. A submission that was refused
# before anything ran (REJECTED) does not; one that ran and failed to score
# (FAILED) does, because it consumed the compute. Keep this identical to
# count_prior_submissions() in the organizers' test_phase_worker.py.
TEST_SLOT_STATUSES = ("PENDING", "FINISHED", "FAILED")
# The image size ceiling, one number for every division, stated as the compressed
# size in the registry (the number `docker push` reports), not the uncompressed
# size on disk. Over it, a team asks for an exception rather than being blocked
# by the form. Nothing in this Space enforces it; the organizer worker rejects
# from the ECR manifest before any pull.
#
# It used to be two numbers. A per-division dict said 10 GB for small while this
# published 100 GB to everyone, and the dict was never actually read here, so a
# small-division team could follow the published figure and still be rejected at
# scoring time. One number now, with no per-division split to drift back into.
TEST_IMAGE_ADVERTISED_GB = 200
# A registry reference we are willing to pull from. Anchored on purpose: the
# reference reaches a subprocess on the organizers' side, so anything that is not
# plainly an ECR repository path is refused here rather than escaped later. These
# two patterns are the same as _ECR_REF / _DIGEST in test_phase_worker.py; a
# reference this accepts and the worker rejects would strand the team with a
# submission that can never be scored.
ECR_REF_RE = (
r"^(?P<registry>\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com)"
r"/(?P<repository>[a-z0-9][a-z0-9._/-]*[a-z0-9])$"
)
DIGEST_RE = r"^sha256:[0-9a-f]{64}$"
# An image TAG. Docker's own rule: up to 128 of word character, dot or dash,
# not starting with a dot or dash. Anchored like the two above, because this
# value reaches a subprocess on the organizers' side.
#
# A tag is accepted as an alternative to a digest because participant
# repositories are created with --image-tag-mutability IMMUTABLE and the
# per-team policy grants no ecr:Delete*, so a tag cannot be overwritten and
# cannot be freed and re-pushed. On this setup `repo:v1` names one image
# permanently. The worker still resolves it to a digest at intake and records
# that, so provenance stays digest-based; this only changes what a participant
# has to type.
TAG_RE = r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$"
# Whether the Submit form accepts a TAG instead of a digest.
#
# OFF until the worker can resolve one. The worker validates image_digest
# against its own ^sha256:...$ and would reject a tag at intake, so accepting
# one here first would let a participant spend a submission on a reference that
# can never be scored. Flip this in the same change that teaches the worker to
# resolve tag -> digest via `aws ecr describe-images --image-ids imageTag=`,
# not before. The Space side is already written and tested behind it.
TEST_ACCEPT_IMAGE_TAG = False
# --- Gold / submission constraints ---------------------------------------
N_GOLD = 700
MAX_SUBMISSIONS_PER_DAY = 5 # per (hf_user, track, division) = "subtrack", val phase.
# Required keys each prediction row must contain, per track.
PREDICTION_KEYS = {
"longqa": {"video_path", "mcq_answer"},
"convqa": {"video_path", "answers"},
"proactive": {"video_path", "answers"},
}
# --- Score schema (enforced on submit AND emitted by worker) -------------
# set(scores.keys()) must equal REQUIRED[track]; OPTIONAL keys are allowed
# extras. Every value must be a float in [0, 1].
SCORE_SCHEMA = {
"longqa": {"required": {"accuracy"}, "optional": set()},
"convqa": {"required": {"llm_judge", "bleu"}, "optional": set()},
"proactive": {"required": {"macro_f1"}, "optional": {"gmean_f1"}},
}
# Which key the board ranks on, and the proxy column (None if no proxy).
PRIMARY_METRIC = {"longqa": "accuracy", "convqa": "llm_judge", "proactive": "macro_f1"}
PROXY_METRIC = {"longqa": None, "convqa": "bleu", "proactive": None}
# --- Self-reported scores (val phase) ------------------------------------
# ConvQA's main metric (LLM-Judge) is too costly to run on every submission on
# the HF CPU worker (Llama API ~10 RPM), so in the val phase participants run the
# judge themselves and report the score, which the board ranks on (badged
# "self-reported"); the worker still computes verified BLEU as a cross-check, and
# a future internal vLLM judge run can override the self-report. Other tracks are
# fully organizer-scored and need no self-report.
#
# CONVQA_JUDGE_MODEL is the ONE judge every team must use so self-reports are
# comparable: the official Llama-4-Maverick FP8 model, run via the starter_kit
# (starter_kit/run_evaluation.py --task convqa, _build_judge_prompt /
# _parse_judge_score, 0 / 0.5 / 1.0 rubric, temperature 0).
CONVQA_JUDGE_MODEL = "Llama-4-Maverick-17B-128E-Instruct-FP8"
# Required self-reported score keys per track (empty = none collected).
SELF_REPORT_KEYS = {"convqa": {"llm_judge"}, "longqa": set(), "proactive": set()}
_GOLD_IDS_CACHE: dict[str, set[str]] = {}
def load_gold_ids(track: str) -> set[str] | None:
"""Set of expected gold video_path ids for a track, or None if not bundled.
Shipped under gold_ids/<track>.txt (one id per line). Used to validate that a
submission covers exactly the gold set (catches wrong/duplicate/missing ids).
"""
if track in _GOLD_IDS_CACHE:
return _GOLD_IDS_CACHE[track]
path = os.path.join(os.path.dirname(__file__), "gold_ids", f"{track}.txt")
if not os.path.exists(path):
return None
with open(path, encoding="utf-8") as fh:
ids = {line.strip() for line in fh if line.strip()}
_GOLD_IDS_CACHE[track] = ids
return ids
def validate_scores(track: str, scores: dict) -> tuple[bool, str]:
"""Return (ok, reason). Shared by Space and worker so they can't drift."""
if track not in SCORE_SCHEMA:
return False, f"unknown track {track!r}"
required = SCORE_SCHEMA[track]["required"]
optional = SCORE_SCHEMA[track]["optional"]
keys = set(scores.keys())
missing = required - keys
if missing:
return False, f"missing score keys: {sorted(missing)}"
unknown = keys - required - optional
if unknown:
return False, f"unknown score keys: {sorted(unknown)}"
for k, v in scores.items():
if not isinstance(v, (int, float)) or isinstance(v, bool):
return False, f"score {k!r} is not a number"
if not (0.0 <= float(v) <= 1.0):
return False, f"score {k!r}={v} out of range [0,1]"
return True, ""
def validate_self_report(track: str, scores: dict) -> tuple[bool, str]:
"""Validate participant self-reported scores. Shared by Space and worker.
For ConvQA the LLM-Judge score is required; other tracks accept none. Every
value must be a float in [0, 1]. Returns (ok, reason).
"""
required = SELF_REPORT_KEYS.get(track, set())
scores = scores or {}
keys = set(scores.keys())
missing = required - keys
if missing:
return False, f"missing required self-reported score(s): {sorted(missing)}"
unknown = keys - required
if unknown:
return False, f"unexpected self-reported score key(s): {sorted(unknown)}"
for k, v in scores.items():
if isinstance(v, bool) or not isinstance(v, (int, float)):
return False, f"self-reported {k!r} is not a number"
if not (0.0 <= float(v) <= 1.0):
return False, f"self-reported {k!r}={v} out of range [0,1]"
return True, ""
def division_for_params(total_params: int) -> str | None:
"""Map a total param count to its division, or None if over the cap."""
for div, (lo, hi) in DIVISION_CAPS.items():
if lo < total_params <= hi:
return div
return None