bruAristimunha commited on
Commit
356d76f
·
1 Parent(s): f00de2f

feat: complete the eval engine — real worker + OEB-aware queue reader

Browse files

The 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.

backend/app/services/models.py CHANGED
@@ -214,25 +214,42 @@ class ModelService(HuggingFaceService):
214
 
215
  # Calculate wait time
216
  try:
217
- submit_time = datetime.fromisoformat(content["submitted_time"].replace("Z", "+00:00"))
 
 
 
 
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": content["model"],
225
- "submitter": content.get("sender", "Unknown"),
226
- "revision": content["revision"],
227
  "wait_time": f"{wait_time.total_seconds():.1f}s",
228
- "submission_time": content["submitted_time"],
229
  "status": target_status,
230
- "precision": content.get("precision", "Unknown")
231
  }
232
 
233
- # Use (model_id, revision, precision) as key to track latest submission
234
- key = (content["model"], content["revision"], content.get("precision", "Unknown"))
235
- if key not in model_submissions or submit_time > datetime.fromisoformat(model_submissions[key]["submission_time"].replace("Z", "+00:00")):
 
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:
backend/scripts/run_eval_worker.py CHANGED
@@ -1,68 +1,178 @@
1
- """OpenEEGBench eval worker (stub).
2
 
3
- Polls the requests queue for PENDING submissions, runs oeb.benchmark(), maps
4
- results to the contents schema, and writes them back. The GPU run is guarded:
5
- without --run it only previews. open_eeg_bench is imported lazily so this file
6
- imports without the heavy dependency installed.
7
 
8
- Usage:
9
- python -m scripts.run_eval_worker --dry-run
10
- python -m scripts.run_eval_worker --run --device cuda
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  """
12
  import argparse
13
  import json
14
  import logging
15
- import tempfile
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(__name__)
 
 
 
 
 
 
 
 
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("status") == "PENDING" and entry.get("framework") == "open-eeg-bench":
35
- pending.append((p, entry))
36
- return pending
37
 
38
 
39
- def run_one(entry, device):
40
- import open_eeg_bench as oeb # lazy heavy import
41
- df = oeb.benchmark(device=device, **entry["benchmark_kwargs"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  return map_oeb_results_to_contents(df, entry)
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def main():
46
- ap = argparse.ArgumentParser()
47
- ap.add_argument("--dry-run", action="store_true", help="List pending submissions only.")
48
- ap.add_argument("--run", action="store_true", help="Actually run oeb.benchmark (needs GPU + open-eeg-bench).")
49
- ap.add_argument("--device", default="cpu")
 
 
 
 
50
  args = ap.parse_args()
51
 
52
- pending = load_pending()
53
- logger.info("Found %d pending OpenEEGBench submissions.", len(pending))
54
- for path, entry in pending:
55
- logger.info("PENDING: %s (%s)", entry.get("model_name"), entry["benchmark_kwargs"].get("model_cls"))
56
- if args.dry_run or not args.run:
57
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  try:
59
- rows = run_one(entry, args.device)
60
- logger.info("Mapped %d leaderboard row(s) for %s.", len(rows), entry.get("model_name"))
61
- # NOTE: appending to AGGREGATED_REPO (contents) is intentionally left for the
62
- # production worker; print the rows here so the contract is observable.
63
- print(json.dumps(rows, indent=2, default=str))
64
- except Exception as e:
65
- logger.error("Run failed for %s: %s", entry.get("model_name"), e)
 
 
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__":
backend/scripts/worker-requirements.txt ADDED
@@ -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