Spaces:
Running
feat: complete the eval engine — real worker + OEB-aware queue reader
Browse filesThe arena's submission loop was open: nothing produced leaderboard results.
- run_eval_worker.py is now a complete one-shot worker (run on external
GPU/SLURM infra, not the Space): PENDING -> mark RUNNING -> oeb.benchmark()
-> map_oeb_results_to_contents -> append to public braindecode/contents
(single train.parquet, deduped by model+adapter) -> mark FINISHED/FAILED.
Supports --device, --dry-run, --slurm, --infra-folder, --limit. Writes
parquet via pandas + upload_file to stay robust across datasets/pyarrow.
- _refresh_models_cache now parses the OpenEEGBench request schema
(model_name/submitter/benchmark_kwargs) instead of the legacy LLM fields
(model/revision/sender), which previously KeyError'd on every entry so
submissions never appeared in the queue UI. Legacy fields kept as fallback.
- worker-requirements.txt pins open-eeg-bench for the worker env.
Verified end-to-end against throwaway HF repos: publish() round-trips through
load_dataset(...)['train'] (what the leaderboard reads), dedup works, and the
reader surfaces OEB submissions as pending.
|
@@ -214,25 +214,42 @@ class ModelService(HuggingFaceService):
|
|
| 214 |
|
| 215 |
# Calculate wait time
|
| 216 |
try:
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
if submit_time.tzinfo is None:
|
| 219 |
submit_time = submit_time.replace(tzinfo=timezone.utc)
|
| 220 |
current_time = datetime.now(timezone.utc)
|
| 221 |
wait_time = current_time - submit_time
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
model_info = {
|
| 224 |
-
"name":
|
| 225 |
-
"submitter":
|
| 226 |
-
"revision":
|
| 227 |
"wait_time": f"{wait_time.total_seconds():.1f}s",
|
| 228 |
-
"submission_time":
|
| 229 |
"status": target_status,
|
| 230 |
-
"precision":
|
| 231 |
}
|
| 232 |
|
| 233 |
-
#
|
| 234 |
-
key = (
|
| 235 |
-
|
|
|
|
| 236 |
model_submissions[key] = model_info
|
| 237 |
|
| 238 |
except (ValueError, TypeError) as e:
|
|
|
|
| 214 |
|
| 215 |
# Calculate wait time
|
| 216 |
try:
|
| 217 |
+
submitted_time = content.get("submitted_time")
|
| 218 |
+
if not submitted_time:
|
| 219 |
+
progress.update()
|
| 220 |
+
continue
|
| 221 |
+
submit_time = datetime.fromisoformat(submitted_time.replace("Z", "+00:00"))
|
| 222 |
if submit_time.tzinfo is None:
|
| 223 |
submit_time = submit_time.replace(tzinfo=timezone.utc)
|
| 224 |
current_time = datetime.now(timezone.utc)
|
| 225 |
wait_time = current_time - submit_time
|
| 226 |
|
| 227 |
+
# OpenEEGBench request schema, with a fallback to the
|
| 228 |
+
# legacy LLM-leaderboard fields for any old entries.
|
| 229 |
+
bk = content.get("benchmark_kwargs", {}) or {}
|
| 230 |
+
name = content.get("model_name") or content.get("model") or "Unknown"
|
| 231 |
+
submitter = content.get("submitter") or content.get("sender") or "Unknown"
|
| 232 |
+
weights = (
|
| 233 |
+
bk.get("hub_repo") or bk.get("checkpoint_url")
|
| 234 |
+
or content.get("revision") or "—"
|
| 235 |
+
)
|
| 236 |
+
strategies = bk.get("finetuning_strategies") or []
|
| 237 |
+
adapter = strategies[0] if strategies else content.get("precision", "—")
|
| 238 |
+
|
| 239 |
model_info = {
|
| 240 |
+
"name": name,
|
| 241 |
+
"submitter": submitter,
|
| 242 |
+
"revision": weights,
|
| 243 |
"wait_time": f"{wait_time.total_seconds():.1f}s",
|
| 244 |
+
"submission_time": submitted_time,
|
| 245 |
"status": target_status,
|
| 246 |
+
"precision": adapter,
|
| 247 |
}
|
| 248 |
|
| 249 |
+
# One entry per (model, submitter, weights) — keep the latest.
|
| 250 |
+
key = (name, submitter, weights)
|
| 251 |
+
prev = model_submissions.get(key)
|
| 252 |
+
if prev is None or submit_time > datetime.fromisoformat(prev["submission_time"].replace("Z", "+00:00")):
|
| 253 |
model_submissions[key] = model_info
|
| 254 |
|
| 255 |
except (ValueError, TypeError) as e:
|
|
@@ -1,68 +1,178 @@
|
|
| 1 |
-
"""OpenEEGBench eval worker
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
imports without the heavy dependency installed.
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
python -m scripts.run_eval_worker --run
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
import argparse
|
| 13 |
import json
|
| 14 |
import logging
|
| 15 |
-
import
|
| 16 |
from pathlib import Path
|
| 17 |
|
| 18 |
from app.config.base import HF_TOKEN
|
| 19 |
from app.config.hf_config import API as hf_api, QUEUE_REPO, AGGREGATED_REPO
|
| 20 |
from app.services.results_mapping import map_oeb_results_to_contents
|
| 21 |
|
| 22 |
-
logging.basicConfig(level=logging.INFO)
|
| 23 |
-
logger = logging.getLogger(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
def load_pending():
|
| 27 |
-
local_dir = hf_api.snapshot_download(repo_id=QUEUE_REPO, repo_type="dataset", token=HF_TOKEN)
|
| 28 |
pending = []
|
| 29 |
-
for p in Path(local_dir).glob("**/*.json"):
|
| 30 |
try:
|
| 31 |
entry = json.loads(p.read_text())
|
| 32 |
except Exception:
|
| 33 |
continue
|
| 34 |
-
if entry.get("
|
| 35 |
-
pending.append((p, entry))
|
| 36 |
-
return pending
|
| 37 |
|
| 38 |
|
| 39 |
-
def
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
return map_oeb_results_to_contents(df, entry)
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def main():
|
| 46 |
-
ap = argparse.ArgumentParser(
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
ap.add_argument("--
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
args = ap.parse_args()
|
| 51 |
|
| 52 |
-
pending = load_pending()
|
| 53 |
-
logger.info("Found %d pending
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
try:
|
| 59 |
-
rows =
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
"""OpenEEGBench eval worker — the engine behind the arena.
|
| 2 |
|
| 3 |
+
This is NOT run by the Space (a CPU web server). Run it on a machine with a GPU
|
| 4 |
+
(or SLURM access) that has ``open-eeg-bench`` installed and an ``HF_TOKEN`` with
|
| 5 |
+
write access to the ``braindecode`` org:
|
|
|
|
| 6 |
|
| 7 |
+
pip install -r scripts/worker-requirements.txt # open-eeg-bench (+ torch, braindecode)
|
| 8 |
+
export HF_TOKEN=hf_xxx
|
| 9 |
+
python -m scripts.run_eval_worker --dry-run # list pending, do nothing
|
| 10 |
+
python -m scripts.run_eval_worker --device cuda # process the queue once
|
| 11 |
+
python -m scripts.run_eval_worker --slurm --infra-folder ./oeb-cache --device cuda
|
| 12 |
+
|
| 13 |
+
For each PENDING request it:
|
| 14 |
+
1. marks the request RUNNING (in braindecode/requests),
|
| 15 |
+
2. runs ``oeb.benchmark(**benchmark_kwargs)``,
|
| 16 |
+
3. maps the result DataFrame to the leaderboard schema (results_mapping),
|
| 17 |
+
4. appends the row(s) to the public braindecode/contents dataset,
|
| 18 |
+
5. marks the request FINISHED (or FAILED with the error).
|
| 19 |
+
|
| 20 |
+
It is one-shot by design (process the current queue, then exit) so it can be run
|
| 21 |
+
from cron / a systemd timer / a SLURM job. ``open_eeg_bench`` is imported lazily,
|
| 22 |
+
so this module imports fine without it (e.g. for --dry-run or tests).
|
| 23 |
"""
|
| 24 |
import argparse
|
| 25 |
import json
|
| 26 |
import logging
|
| 27 |
+
from datetime import datetime, timezone
|
| 28 |
from pathlib import Path
|
| 29 |
|
| 30 |
from app.config.base import HF_TOKEN
|
| 31 |
from app.config.hf_config import API as hf_api, QUEUE_REPO, AGGREGATED_REPO
|
| 32 |
from app.services.results_mapping import map_oeb_results_to_contents
|
| 33 |
|
| 34 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 35 |
+
logger = logging.getLogger("eval_worker")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _now() -> str:
|
| 39 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load_pending(limit=None):
|
| 43 |
+
"""Return ``[(path_in_repo, entry), ...]`` for PENDING open-eeg-bench requests.
|
| 44 |
|
| 45 |
+
A missing queue dataset (no submissions yet) is treated as empty.
|
| 46 |
+
"""
|
| 47 |
+
try:
|
| 48 |
+
local_dir = hf_api.snapshot_download(
|
| 49 |
+
repo_id=QUEUE_REPO, repo_type="dataset", token=HF_TOKEN
|
| 50 |
+
)
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.info("Queue %s not available (%s); nothing to do.", QUEUE_REPO, e)
|
| 53 |
+
return []
|
| 54 |
|
|
|
|
|
|
|
| 55 |
pending = []
|
| 56 |
+
for p in sorted(Path(local_dir).glob("**/*.json")):
|
| 57 |
try:
|
| 58 |
entry = json.loads(p.read_text())
|
| 59 |
except Exception:
|
| 60 |
continue
|
| 61 |
+
if entry.get("framework") == "open-eeg-bench" and entry.get("status") == "PENDING":
|
| 62 |
+
pending.append((str(p.relative_to(local_dir)), entry))
|
| 63 |
+
return pending[:limit] if limit else pending
|
| 64 |
|
| 65 |
|
| 66 |
+
def set_status(path_in_repo, entry, status, **extra):
|
| 67 |
+
"""Re-upload the request JSON with an updated status (RUNNING/FINISHED/FAILED)."""
|
| 68 |
+
updated = {**entry, "status": status, **extra}
|
| 69 |
+
hf_api.upload_file(
|
| 70 |
+
path_or_fileobj=json.dumps(updated, indent=2).encode("utf-8"),
|
| 71 |
+
path_in_repo=path_in_repo,
|
| 72 |
+
repo_id=QUEUE_REPO,
|
| 73 |
+
repo_type="dataset",
|
| 74 |
+
token=HF_TOKEN,
|
| 75 |
+
commit_message=f"{updated.get('model_name', '?')}: {status}",
|
| 76 |
+
)
|
| 77 |
+
return updated
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def run_benchmark(entry, device, infra):
|
| 81 |
+
"""Run oeb.benchmark() for a request and map the result to contents rows."""
|
| 82 |
+
import open_eeg_bench as oeb # heavy, lazy import (torch/braindecode)
|
| 83 |
+
|
| 84 |
+
df = oeb.benchmark(device=device, infra=infra, **entry["benchmark_kwargs"])
|
| 85 |
return map_oeb_results_to_contents(df, entry)
|
| 86 |
|
| 87 |
|
| 88 |
+
def _load_contents_df():
|
| 89 |
+
"""Read the current contents (the single ``train.parquet``); empty if absent."""
|
| 90 |
+
import pandas as pd
|
| 91 |
+
from huggingface_hub import hf_hub_download
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
path = hf_hub_download(
|
| 95 |
+
repo_id=AGGREGATED_REPO, filename="train.parquet",
|
| 96 |
+
repo_type="dataset", token=HF_TOKEN,
|
| 97 |
+
)
|
| 98 |
+
return pd.read_parquet(path)
|
| 99 |
+
except Exception:
|
| 100 |
+
return pd.DataFrame()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def publish(rows):
|
| 104 |
+
"""Append rows to the public contents dataset (create it if needed).
|
| 105 |
+
|
| 106 |
+
Dedupes by (model, adapter) keeping the latest, so re-submissions update in
|
| 107 |
+
place. Stored as a single ``train.parquet`` that ``load_dataset(repo)["train"]``
|
| 108 |
+
reads — written with pandas to stay robust across datasets/pyarrow versions.
|
| 109 |
+
"""
|
| 110 |
+
import os
|
| 111 |
+
import tempfile
|
| 112 |
+
import pandas as pd
|
| 113 |
+
|
| 114 |
+
combined = pd.concat([_load_contents_df(), pd.DataFrame(rows)], ignore_index=True)
|
| 115 |
+
if {"fullname", "adapter"}.issubset(combined.columns):
|
| 116 |
+
combined = combined.drop_duplicates(subset=["fullname", "adapter"], keep="last")
|
| 117 |
+
|
| 118 |
+
hf_api.create_repo(
|
| 119 |
+
repo_id=AGGREGATED_REPO, repo_type="dataset",
|
| 120 |
+
private=False, exist_ok=True, token=HF_TOKEN,
|
| 121 |
+
)
|
| 122 |
+
with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as f:
|
| 123 |
+
tmp = f.name
|
| 124 |
+
try:
|
| 125 |
+
combined.to_parquet(tmp, index=False)
|
| 126 |
+
hf_api.upload_file(
|
| 127 |
+
path_or_fileobj=tmp, path_in_repo="train.parquet",
|
| 128 |
+
repo_id=AGGREGATED_REPO, repo_type="dataset", token=HF_TOKEN,
|
| 129 |
+
commit_message="Update leaderboard contents",
|
| 130 |
+
)
|
| 131 |
+
finally:
|
| 132 |
+
os.unlink(tmp)
|
| 133 |
+
return len(combined)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
def main():
|
| 137 |
+
ap = argparse.ArgumentParser(
|
| 138 |
+
description="Run queued OpenEEGBench submissions and publish results."
|
| 139 |
+
)
|
| 140 |
+
ap.add_argument("--dry-run", action="store_true", help="List pending submissions and exit.")
|
| 141 |
+
ap.add_argument("--device", default="cuda", help="Torch device for benchmark() (default: cuda).")
|
| 142 |
+
ap.add_argument("--limit", type=int, default=None, help="Max submissions to process this run.")
|
| 143 |
+
ap.add_argument("--infra-folder", default=None, help="oeb cache/results folder (enables caching + SLURM).")
|
| 144 |
+
ap.add_argument("--slurm", action="store_true", help="Submit experiments via SLURM (oeb infra cluster=slurm).")
|
| 145 |
args = ap.parse_args()
|
| 146 |
|
| 147 |
+
pending = load_pending(args.limit)
|
| 148 |
+
logger.info("Found %d pending submission(s).", len(pending))
|
| 149 |
+
|
| 150 |
+
if args.dry_run:
|
| 151 |
+
for _, e in pending:
|
| 152 |
+
logger.info("PENDING %s — %s", e.get("model_name"), (e.get("benchmark_kwargs") or {}).get("model_cls"))
|
| 153 |
+
return
|
| 154 |
+
|
| 155 |
+
infra = {}
|
| 156 |
+
if args.infra_folder:
|
| 157 |
+
infra["folder"] = args.infra_folder
|
| 158 |
+
if args.slurm:
|
| 159 |
+
infra["cluster"] = "slurm"
|
| 160 |
+
infra = infra or None
|
| 161 |
+
|
| 162 |
+
for path_in_repo, entry in pending:
|
| 163 |
+
name = entry.get("model_name", "?")
|
| 164 |
+
logger.info("RUNNING %s", name)
|
| 165 |
+
set_status(path_in_repo, entry, "RUNNING", started_time=_now())
|
| 166 |
try:
|
| 167 |
+
rows = run_benchmark(entry, args.device, infra)
|
| 168 |
+
if not rows:
|
| 169 |
+
raise RuntimeError("benchmark produced no completed results")
|
| 170 |
+
total = publish(rows)
|
| 171 |
+
set_status(path_in_repo, entry, "FINISHED", finished_time=_now(), n_rows=len(rows))
|
| 172 |
+
logger.info("FINISHED %s — published %d row(s); contents now has %d.", name, len(rows), total)
|
| 173 |
+
except Exception as e: # noqa: BLE001 — record the failure on the request and move on
|
| 174 |
+
logger.exception("FAILED %s", name)
|
| 175 |
+
set_status(path_in_repo, entry, "FAILED", finished_time=_now(), error=str(e)[:500])
|
| 176 |
|
| 177 |
|
| 178 |
if __name__ == "__main__":
|
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Extra dependency for the eval worker (scripts/run_eval_worker.py).
|
| 2 |
+
# The worker runs OUTSIDE the Space, on a GPU/SLURM machine. Install it on top
|
| 3 |
+
# of the backend deps (the worker imports app.config.* / app.services.*):
|
| 4 |
+
#
|
| 5 |
+
# cd backend && pip install -e . -r scripts/worker-requirements.txt
|
| 6 |
+
# # or: poetry install && pip install -r scripts/worker-requirements.txt
|
| 7 |
+
#
|
| 8 |
+
# open-eeg-bench pulls torch + braindecode transitively.
|
| 9 |
+
open-eeg-bench>=0.6.0
|