bruAristimunha commited on
Commit
4db3be0
·
1 Parent(s): 8d3e1e1

feat: OpenEEGBench submission flow (arena runs the eval)

Browse files

Replace the leftover LLM-leaderboard submission with a first-class
OpenEEGBench flow. Users submit a model reference (model_cls + checkpoint
+ finetuning strategy/head/datasets); the backend validates it, enforces a
5-per-month per-user cap, and queues a request to braindecode/requests. A
worker (stub) runs oeb.benchmark() and maps results to braindecode/contents.

Backend:
- submission_config: OEB enums/defaults (single source of truth)
- submissions: pure validation, request building, monthly cap
- results_mapping: OEB DataFrame -> contents schema (balanced_accuracy)
- model_validation: EEG/OEB weights-source + license checks
- OEB-shaped submit endpoint/service; eval worker stub (run_eval_worker.py)

Frontend:
- OEB submission form (model_cls, weights source, strategies, heads, PEFT
modules, datasets, seeds, contact/visibility) with conditional fields
- rewritten submission guide (5/month, help -> OpenEEGBench issues, citation)
- queue/limit copy aligned; fixed duplicate style key in EvaluationQueues

Tests: submission validation + results mapping (16 passing).

backend/app/api/endpoints/models.py CHANGED
@@ -60,11 +60,11 @@ async def submit_model(
60
 
61
  # Log submission details
62
  submission_info = {
63
- "Model_ID": model_data.get("model_id"),
 
64
  "User": user_id,
65
- "Base_Model": model_data.get("base_model"),
66
- "Precision": model_data.get("precision"),
67
- "Model_Type": model_data.get("model_type")
68
  }
69
  for line in LogFormatter.tree(submission_info, "Submission Details"):
70
  logger.info(line)
 
60
 
61
  # Log submission details
62
  submission_info = {
63
+ "Model_Class": model_data.get("model_cls"),
64
+ "Model_Name": model_data.get("model_name"),
65
  "User": user_id,
66
+ "Strategies": model_data.get("finetuning_strategies"),
67
+ "Datasets": model_data.get("datasets"),
 
68
  }
69
  for line in LogFormatter.tree(submission_info, "Submission Details"):
70
  logger.info(line)
backend/app/config/benchmarks.py CHANGED
@@ -22,6 +22,7 @@ class BenchmarkInfo:
22
  accuracy_field: str
23
  split: str = "test"
24
  config: str = "default"
 
25
 
26
 
27
  EEG_BENCHMARKS: Dict[str, BenchmarkInfo] = {
 
22
  accuracy_field: str
23
  split: str = "test"
24
  config: str = "default"
25
+ metric: str = "balanced_accuracy" # OEB test metric; arena uses balanced_accuracy
26
 
27
 
28
  EEG_BENCHMARKS: Dict[str, BenchmarkInfo] = {
backend/app/config/submission_config.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenEEGBench submission constants — single source of truth for the
2
+ submission form, validators, and the eval worker. Mirrors the accepted
3
+ values of open_eeg_bench.benchmark()."""
4
+
5
+ from app.config.benchmarks import EEG_BENCHMARKS
6
+
7
+ # Finetuning strategies accepted by oeb.benchmark()
8
+ FINETUNING_STRATEGIES = [
9
+ "frozen", "ridge_probe", "lora", "ia3", "adalora",
10
+ "dora", "oft", "full_finetune", "two_stages",
11
+ ]
12
+ # Subset requiring peft_target_modules
13
+ PEFT_STRATEGIES = {"lora", "ia3", "adalora", "dora", "oft"}
14
+ IA3_STRATEGY = "ia3" # additionally requires peft_ff_modules
15
+
16
+ HEADS = ["linear_head", "mlp_head", "original_head"]
17
+ WEIGHTS_SOURCES = ["hub_repo", "checkpoint_url"]
18
+
19
+ # The smaller, classification-only dataset set the arena evaluates on.
20
+ DATASET_KEYS = list(EEG_BENCHMARKS.keys())
21
+ # hf_id (e.g. "braindecode/bcic2a") -> accuracy field (e.g. "bcic2a_accuracy")
22
+ DATASET_ID_TO_ACCURACY_FIELD = {
23
+ b.dataset_id: b.accuracy_field for b in EEG_BENCHMARKS.values()
24
+ }
25
+
26
+ # Defaults (linear probing)
27
+ DEFAULT_STRATEGIES = ["frozen"]
28
+ DEFAULT_HEADS = ["linear_head"]
29
+ DEFAULT_N_SEEDS = 3
30
+ DEFAULT_HEAD_MODULE_NAME = "final_layer"
31
+
32
+ # Submission cap (per user, per calendar month)
33
+ MONTHLY_QUOTA = 5
34
+
35
+ FRAMEWORK = "open-eeg-bench"
backend/app/services/models.py CHANGED
@@ -22,6 +22,7 @@ from app.config import (
22
  )
23
  from app.config.hf_config import HF_ORGANIZATION
24
  from app.services.hf_service import HuggingFaceService
 
25
  from app.utils.model_validation import ModelValidator
26
  from app.services.votes import VoteService
27
  from app.core.cache import cache_config
@@ -322,193 +323,63 @@ class ModelService(HuggingFaceService):
322
  logger.info(LogFormatter.info(f"Using cached data ({cache_age:.1f}s old)"))
323
  return self.cached_models
324
 
325
- async def submit_model(
326
- self,
327
- model_data: Dict[str, Any],
328
- user_id: str
329
- ) -> Dict[str, Any]:
330
- logger.info(LogFormatter.section("MODEL SUBMISSION"))
331
- self._log_repo_operation("write", f"{HF_ORGANIZATION}/requests", f"Submitting model {model_data['model_id']} by {user_id}")
332
- stats = {
333
- "Model": model_data["model_id"],
334
- "User": user_id,
335
- "Revision": model_data["revision"],
336
- "Precision": model_data["precision"],
337
- "Type": model_data["model_type"]
338
- }
339
- for line in LogFormatter.tree(stats, "Submission Details"):
340
- logger.info(line)
341
-
342
- # Validate required fields
343
- required_fields = [
344
- "model_id", "base_model", "revision", "precision", "model_type"
345
- ]
346
- for field in required_fields:
347
- if field not in model_data:
348
- raise ValueError(f"Missing required field: {field}")
349
-
350
- # Get model info and validate it exists on HuggingFace
351
- try:
352
- logger.info(LogFormatter.subsection("MODEL VALIDATION"))
353
-
354
- # Get the model info to check if it exists
355
- model_info = self.hf_api.model_info(
356
- model_data["model_id"],
357
- revision=model_data["revision"],
358
- token=self.token
359
- )
360
-
361
- if not model_info:
362
- raise Exception(f"Model {model_data['model_id']} not found on HuggingFace Hub")
363
-
364
- logger.info(LogFormatter.success("Model exists on HuggingFace Hub"))
365
-
366
- except Exception as e:
367
- logger.error(LogFormatter.error("Model validation failed", e))
368
- raise
369
-
370
- # Update model revision with commit sha
371
- model_data["revision"] = model_info.sha
372
-
373
- # Check if model already exists in the system
374
  try:
375
- logger.info(LogFormatter.subsection("CHECKING EXISTING SUBMISSIONS"))
376
- existing_models = await self.get_models()
377
-
378
- # Check in all statuses (pending, evaluating, finished)
379
- for status, models in existing_models.items():
380
- for model in models:
381
- if model["name"] == model_data["model_id"] and model["revision"] == model_data["revision"]:
382
- error_msg = f"Model {model_data['model_id']} revision {model_data['revision']} is already in the system with status: {status}"
383
- logger.error(LogFormatter.error("Submission rejected", error_msg))
384
- raise ValueError(error_msg)
385
-
386
- logger.info(LogFormatter.success("No existing submission found"))
387
- except ValueError:
388
- raise
389
- except Exception as e:
390
- logger.error(LogFormatter.error("Failed to check existing submissions", e))
391
- raise
392
-
393
- # Check that model on hub and valid
394
- valid, error, model_config = await self.validator.is_model_on_hub(
395
- model_data["model_id"],
396
- model_data["revision"],
397
- test_tokenizer=False
398
- )
399
- if not valid:
400
- logger.error(LogFormatter.error("Model on hub validation failed", error))
401
- raise Exception(error)
402
- logger.info(LogFormatter.success("Model on hub validation passed"))
403
-
404
- # Validate model card
405
- valid, error, model_card = await self.validator.check_model_card(
406
- model_data["model_id"]
407
- )
408
- if not valid:
409
- logger.error(LogFormatter.error("Model card validation failed", error))
410
- raise Exception(error)
411
- logger.info(LogFormatter.success("Model card validation passed"))
412
-
413
- # Check size limits
414
- model_size, error = await self.validator.get_model_size(
415
- model_info,
416
- model_data["precision"],
417
- model_data["base_model"],
418
- revision=model_data["revision"]
419
- )
420
- if model_size is None:
421
- logger.error(LogFormatter.error("Model size validation failed", error))
422
- raise Exception(error)
423
- logger.info(LogFormatter.success(f"Model size validation passed: {model_size:.1f}B"))
424
-
425
- # Size limits for EEG models (much smaller than LLMs - limit at 10B params)
426
- if model_data["precision"] in ["float16", "bfloat16"] and model_size > 10:
427
- error_msg = f"Model too large for {model_data['precision']} (limit: 10B)"
428
- logger.error(LogFormatter.error("Size limit exceeded", error_msg))
429
- raise Exception(error_msg)
430
-
431
- architectures = model_info.config.get("architectures", "")
432
- if architectures:
433
- architectures = ";".join(architectures)
434
-
435
- # Create eval entry
436
- eval_entry = {
437
- "model": model_data["model_id"],
438
- "base_model": model_data["base_model"],
439
- "revision": model_info.sha,
440
- "precision": model_data["precision"],
441
- "params": model_size,
442
- "architectures": architectures,
443
- "status": "PENDING",
444
- "submitted_time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
445
- "model_type": model_data["model_type"],
446
- "job_id": -1,
447
- "job_start_time": None,
448
- "sender": user_id
449
- }
450
-
451
- logger.info(LogFormatter.subsection("EVALUATION ENTRY"))
452
- for line in LogFormatter.tree(eval_entry):
453
- logger.info(line)
454
-
455
- # Upload to HF dataset
456
- try:
457
- logger.info(LogFormatter.subsection("UPLOADING TO HUGGINGFACE"))
458
- logger.info(LogFormatter.info(f"Uploading to {HF_ORGANIZATION}/requests..."))
459
-
460
- # Construct the path in the dataset
461
- org_or_user = model_data["model_id"].split("/")[0] if "/" in model_data["model_id"] else ""
462
- model_path = model_data["model_id"].split("/")[-1]
463
- relative_path = f"{org_or_user}/{model_path}_eval_request_False_{model_data['precision']}.json"
464
-
465
- # Create a temporary file with the request
466
- with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file:
467
- json.dump(eval_entry, temp_file, indent=2)
468
- temp_file.flush()
469
- temp_path = temp_file.name
470
-
471
- # Upload file directly
472
  self.hf_api.upload_file(
473
- path_or_fileobj=temp_path,
474
- path_in_repo=relative_path,
475
- repo_id=f"{HF_ORGANIZATION}/requests",
476
- repo_type="dataset",
477
- commit_message=f"Add {model_data['model_id']} to eval queue",
478
- token=self.token
479
  )
 
 
480
 
481
- # Clean up temp file
482
- os.unlink(temp_path)
483
 
484
- logger.info(LogFormatter.success("Upload successful"))
485
-
486
- except Exception as e:
487
- logger.error(LogFormatter.error("Upload failed", e))
488
- raise
489
-
490
- # Add automatic vote
491
  try:
492
- logger.info(LogFormatter.subsection("AUTOMATIC VOTE"))
493
- logger.info(LogFormatter.info(f"Adding upvote for {model_data['model_id']} by {user_id}"))
494
- await self.vote_service.add_vote(
495
- model_data["model_id"],
496
- user_id,
497
- "up",
498
- {
499
- "precision": model_data["precision"],
500
- "revision": model_data["revision"]
501
- }
502
- )
503
- logger.info(LogFormatter.success("Vote recorded successfully"))
504
- except Exception as e:
505
- logger.error(LogFormatter.error("Failed to record vote", e))
506
- # Don't raise here as the main submission was successful
507
-
508
- return {
509
- "status": "success",
510
- "message": "The model was submitted successfully, and the vote has been recorded"
511
- }
512
 
513
  async def get_model_status(self, model_id: str) -> Dict[str, Any]:
514
  """Get evaluation status of a model"""
 
22
  )
23
  from app.config.hf_config import HF_ORGANIZATION
24
  from app.services.hf_service import HuggingFaceService
25
+ from app.services.submissions import validate_submission, build_request_entry, monthly_quota_exceeded
26
  from app.utils.model_validation import ModelValidator
27
  from app.services.votes import VoteService
28
  from app.core.cache import cache_config
 
323
  logger.info(LogFormatter.info(f"Using cached data ({cache_age:.1f}s old)"))
324
  return self.cached_models
325
 
326
+ async def submit_model(self, model_data: Dict[str, Any], user_id: str) -> Dict[str, Any]:
327
+ logger.info(LogFormatter.section("OPENEEGBENCH SUBMISSION"))
328
+
329
+ errors = validate_submission(model_data)
330
+ if errors:
331
+ raise ValueError("; ".join(errors))
332
+
333
+ # Monthly cap: count this user's existing queue entries for the current month.
334
+ existing = await self._load_request_entries()
335
+ if monthly_quota_exceeded(existing, user_id):
336
+ raise ValueError("You have reached the limit of 5 submissions this month. Please try again next month.")
337
+
338
+ # Validate weights source (hub repo exists / url well-formed) and grab license.
339
+ kwargs = model_data
340
+ ok, err = await self.validator.validate_weights_source(
341
+ (kwargs.get("hub_repo") or "").strip(), (kwargs.get("checkpoint_url") or "").strip())
342
+ if not ok:
343
+ raise ValueError(err)
344
+
345
+ entry = build_request_entry(model_data, user_id)
346
+ hub_repo = entry["benchmark_kwargs"].get("hub_repo")
347
+ if hub_repo:
348
+ entry["hub_license"] = await self.validator.get_repo_license(hub_repo)
349
+
350
+ # Write request JSON to the queue dataset.
351
+ slug = entry["model_name"].lower().replace("/", "-").replace(" ", "-")
352
+ relative_path = f"{user_id}/{slug}_eval_request_{entry['visibility']}.json"
353
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
354
+ json.dump(entry, f, indent=2)
355
+ tmp = f.name
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  self.hf_api.upload_file(
358
+ path_or_fileobj=tmp, path_in_repo=relative_path,
359
+ repo_id=QUEUE_REPO, repo_type="dataset",
360
+ commit_message=f"Add {entry['model_name']} to OpenEEGBench eval queue",
361
+ token=self.token,
 
 
362
  )
363
+ finally:
364
+ os.unlink(tmp)
365
 
366
+ return {"status": "success", "message": "Submission queued for OpenEEGBench evaluation."}
 
367
 
368
+ async def _load_request_entries(self) -> List[Dict[str, Any]]:
369
+ """Load all queue request JSONs (for the monthly cap). Empty on failure."""
 
 
 
 
 
370
  try:
371
+ with suppress_output():
372
+ local_dir = self.hf_api.snapshot_download(
373
+ repo_id=QUEUE_REPO, repo_type="dataset", token=self.token)
374
+ out = []
375
+ for p in Path(local_dir).glob("**/*.json"):
376
+ try:
377
+ out.append(json.loads(p.read_text()))
378
+ except Exception:
379
+ continue
380
+ return out
381
+ except Exception:
382
+ return []
 
 
 
 
 
 
 
 
383
 
384
  async def get_model_status(self, model_id: str) -> Dict[str, Any]:
385
  """Get evaluation status of a model"""
backend/app/services/results_mapping.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure mapping from an OpenEEGBench results DataFrame to the
2
+ `braindecode/contents` leaderboard schema. No network, no oeb import."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ from app.config.submission_config import DATASET_ID_TO_ACCURACY_FIELD
8
+
9
+ AVERAGE_FIELD = "Average ⬆️" # "Average ⬆️"
10
+
11
+
12
+ def _submitted_date(request: Dict[str, Any]) -> str:
13
+ ts = str(request.get("submitted_time", ""))
14
+ return ts.split("T", 1)[0] if "T" in ts else ts
15
+
16
+
17
+ def map_oeb_results_to_contents(df, request: Dict[str, Any]) -> List[Dict[str, Any]]:
18
+ """One contents row per finetuning strategy present in `df`."""
19
+ if df is None or len(df) == 0:
20
+ return []
21
+
22
+ kwargs = request.get("benchmark_kwargs", {})
23
+ hub_repo = kwargs.get("hub_repo")
24
+ model_cls = kwargs.get("model_cls", "")
25
+ arch_fallback = model_cls.split(".")[-1] if model_cls else ""
26
+ date = _submitted_date(request)
27
+
28
+ completed = df[(df.get("status") == "completed") & df["test_balanced_accuracy"].notna()] \
29
+ if "status" in df.columns else df[df["test_balanced_accuracy"].notna()]
30
+
31
+ rows: List[Dict[str, Any]] = []
32
+ for strategy, grp in completed.groupby("finetuning"):
33
+ backbone = grp["backbone"].iloc[0] if "backbone" in grp.columns else arch_fallback
34
+ # mean balanced_accuracy across seeds, per dataset (hf_id)
35
+ per_dataset = grp.groupby("dataset")["test_balanced_accuracy"].mean()
36
+
37
+ accuracies: Dict[str, float] = {}
38
+ for hf_id, score in per_dataset.items():
39
+ field = DATASET_ID_TO_ACCURACY_FIELD.get(hf_id)
40
+ if field is not None:
41
+ accuracies[field] = float(score)
42
+
43
+ avg_pct = round(sum(accuracies.values()) / len(accuracies) * 100, 2) if accuracies else 0.0
44
+ n_params_m = None
45
+ if "trainable_params" in grp.columns and grp["trainable_params"].notna().any():
46
+ n_params_m = round(float(grp["trainable_params"].dropna().iloc[0]) / 1e6, 2)
47
+
48
+ row: Dict[str, Any] = {
49
+ "fullname": request.get("model_name", ""),
50
+ "adapter": str(strategy),
51
+ "Precision": "",
52
+ "Model sha": None,
53
+ "Architecture": backbone or arch_fallback,
54
+ AVERAGE_FIELD: avg_pct,
55
+ "#Params (M)": n_params_m,
56
+ "Available on the hub": bool(hub_repo),
57
+ "Upload To Hub Date": date,
58
+ "Submission Date": date,
59
+ "Base Model": backbone or arch_fallback,
60
+ "Hub License": request.get("hub_license", ""),
61
+ "Hub ❤️": 0,
62
+ "model_url": request.get("model_url", ""),
63
+ "paper_url": request.get("paper_url", ""),
64
+ }
65
+ # Initialise every benchmark field to 0, then fill the ones we have.
66
+ for field in DATASET_ID_TO_ACCURACY_FIELD.values():
67
+ row.setdefault(field, 0)
68
+ row.update(accuracies)
69
+ rows.append(row)
70
+
71
+ return rows
backend/app/services/submissions.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure helpers to validate and build OpenEEGBench eval submissions."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Dict, List
7
+
8
+ from app.config.submission_config import (
9
+ FINETUNING_STRATEGIES, PEFT_STRATEGIES, IA3_STRATEGY, HEADS,
10
+ DATASET_KEYS, DEFAULT_STRATEGIES, DEFAULT_HEADS, DEFAULT_N_SEEDS,
11
+ DEFAULT_HEAD_MODULE_NAME, MONTHLY_QUOTA, FRAMEWORK,
12
+ )
13
+
14
+ _DOTTED_RE = re.compile(r"^[A-Za-z_]\w*(\.[A-Za-z_]\w*)+$")
15
+ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
16
+
17
+
18
+ def _as_list(value) -> list:
19
+ if value is None:
20
+ return []
21
+ return value if isinstance(value, list) else [value]
22
+
23
+
24
+ def _str(payload, key) -> str:
25
+ return (payload.get(key) or "").strip()
26
+
27
+
28
+ def validate_submission(payload: Dict[str, Any]) -> List[str]:
29
+ """Return human-readable validation errors (empty list == valid)."""
30
+ errors: List[str] = []
31
+
32
+ model_cls = _str(payload, "model_cls")
33
+ if not model_cls:
34
+ errors.append("model_cls is required (dotted path, e.g. braindecode.models.BIOT).")
35
+ elif not _DOTTED_RE.match(model_cls):
36
+ errors.append(f"model_cls '{model_cls}' is not a valid dotted import path.")
37
+
38
+ if not _str(payload, "model_name"):
39
+ errors.append("model_name is required.")
40
+ if not _str(payload, "organization"):
41
+ errors.append("organization is required.")
42
+
43
+ hub_repo = _str(payload, "hub_repo")
44
+ checkpoint_url = _str(payload, "checkpoint_url")
45
+ if not hub_repo and not checkpoint_url:
46
+ errors.append("Provide a weights source: either hub_repo or checkpoint_url.")
47
+ if hub_repo and checkpoint_url:
48
+ errors.append("Provide only one weights source (hub_repo or checkpoint_url).")
49
+
50
+ strategies = _as_list(payload.get("finetuning_strategies")) or DEFAULT_STRATEGIES
51
+ bad = [s for s in strategies if s not in FINETUNING_STRATEGIES]
52
+ if bad:
53
+ errors.append(f"Unknown finetuning strategies: {bad}. Valid: {FINETUNING_STRATEGIES}.")
54
+
55
+ heads = _as_list(payload.get("heads")) or DEFAULT_HEADS
56
+ bad = [h for h in heads if h not in HEADS]
57
+ if bad:
58
+ errors.append(f"Unknown heads: {bad}. Valid: {HEADS}.")
59
+
60
+ datasets = _as_list(payload.get("datasets")) or DATASET_KEYS
61
+ bad = [d for d in datasets if d not in DATASET_KEYS]
62
+ if bad:
63
+ errors.append(f"Unknown datasets: {bad}. Valid: {DATASET_KEYS}.")
64
+
65
+ if any(s in PEFT_STRATEGIES for s in strategies) and not _as_list(payload.get("peft_target_modules")):
66
+ errors.append("peft_target_modules is required when a PEFT strategy (lora/ia3/adalora/dora/oft) is selected.")
67
+ if IA3_STRATEGY in strategies and not _as_list(payload.get("peft_ff_modules")):
68
+ errors.append("peft_ff_modules is required when the ia3 strategy is selected.")
69
+
70
+ n_seeds = payload.get("n_seeds", DEFAULT_N_SEEDS)
71
+ if not isinstance(n_seeds, int) or isinstance(n_seeds, bool) or n_seeds < 1:
72
+ errors.append("n_seeds must be an integer >= 1.")
73
+
74
+ email = _str(payload, "contact_email")
75
+ if email and not _EMAIL_RE.match(email):
76
+ errors.append("contact_email is not a valid email address.")
77
+
78
+ if payload.get("visibility", "Public") not in ("Public", "Private"):
79
+ errors.append("visibility must be 'Public' or 'Private'.")
80
+
81
+ return errors
82
+
83
+
84
+ def build_request_entry(payload: Dict[str, Any], submitter: str, now: datetime | None = None) -> Dict[str, Any]:
85
+ now = now or datetime.now(timezone.utc)
86
+ strategies = _as_list(payload.get("finetuning_strategies")) or DEFAULT_STRATEGIES
87
+ heads = _as_list(payload.get("heads")) or DEFAULT_HEADS
88
+ datasets = _as_list(payload.get("datasets")) or DATASET_KEYS
89
+ return {
90
+ "schema_version": 1,
91
+ "framework": FRAMEWORK,
92
+ "model_name": _str(payload, "model_name"),
93
+ "organization": _str(payload, "organization"),
94
+ "submitter": submitter,
95
+ "contact_email": _str(payload, "contact_email"),
96
+ "model_url": _str(payload, "model_url"),
97
+ "paper_url": _str(payload, "paper_url"),
98
+ "additional_info": _str(payload, "additional_info"),
99
+ "visibility": payload.get("visibility", "Public"),
100
+ "benchmark_kwargs": {
101
+ "model_cls": _str(payload, "model_cls"),
102
+ "hub_repo": _str(payload, "hub_repo") or None,
103
+ "checkpoint_url": _str(payload, "checkpoint_url") or None,
104
+ "model_kwargs": payload.get("model_kwargs") or {},
105
+ "head_module_name": payload.get("head_module_name") or DEFAULT_HEAD_MODULE_NAME,
106
+ "peft_target_modules": _as_list(payload.get("peft_target_modules")),
107
+ "peft_ff_modules": _as_list(payload.get("peft_ff_modules")),
108
+ "finetuning_strategies": strategies,
109
+ "heads": heads,
110
+ "datasets": datasets,
111
+ "n_seeds": int(payload.get("n_seeds", DEFAULT_N_SEEDS)),
112
+ },
113
+ "status": "PENDING",
114
+ "submitted_time": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
115
+ "job_id": -1,
116
+ }
117
+
118
+
119
+ def count_user_submissions_this_month(entries, submitter: str, now: datetime | None = None) -> int:
120
+ now = now or datetime.now(timezone.utc)
121
+ month = now.strftime("%Y-%m")
122
+ return sum(
123
+ 1 for e in entries
124
+ if e.get("submitter") == submitter and str(e.get("submitted_time", "")).startswith(month)
125
+ )
126
+
127
+
128
+ def monthly_quota_exceeded(entries, submitter: str, now: datetime | None = None) -> bool:
129
+ return count_user_submissions_this_month(entries, submitter, now) >= MONTHLY_QUOTA
backend/app/utils/model_validation.py CHANGED
@@ -166,3 +166,31 @@ class ModelValidator:
166
  if "You are trying to access a gated repo." in str(e):
167
  return True, "The model is gated and requires special access permissions.", None
168
  return False, f"The model was not found or is misconfigured on the Hub. Error: {e.args[0]}", None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  if "You are trying to access a gated repo." in str(e):
167
  return True, "The model is gated and requires special access permissions.", None
168
  return False, f"The model was not found or is misconfigured on the Hub. Error: {e.args[0]}", None
169
+
170
+ async def validate_weights_source(self, hub_repo: str, checkpoint_url: str):
171
+ """Validate the OEB weights source. Returns (ok, error)."""
172
+ if hub_repo:
173
+ try:
174
+ await asyncio.to_thread(self.api.repo_info, repo_id=hub_repo, token=self.token)
175
+ return True, ""
176
+ except Exception as e:
177
+ return False, f"hub_repo '{hub_repo}' was not found on the Hub: {e}"
178
+ if checkpoint_url:
179
+ if not str(checkpoint_url).startswith(("http://", "https://")):
180
+ return False, "checkpoint_url must be an http(s) URL."
181
+ return True, ""
182
+ return False, "No weights source provided."
183
+
184
+ async def get_repo_license(self, hub_repo: str):
185
+ """Best-effort license tag for a hub repo (empty string if unknown)."""
186
+ if not hub_repo:
187
+ return ""
188
+ try:
189
+ info = await asyncio.to_thread(self.api.repo_info, repo_id=hub_repo, token=self.token)
190
+ tags = getattr(info, "tags", None) or []
191
+ for t in tags:
192
+ if isinstance(t, str) and t.startswith("license:"):
193
+ return t.split("license:", 1)[1]
194
+ return ""
195
+ except Exception:
196
+ return ""
backend/scripts/run_eval_worker.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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__":
69
+ main()
backend/tests/__init__.py ADDED
File without changes
backend/tests/test_results_mapping.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ from app.services.results_mapping import map_oeb_results_to_contents
3
+
4
+ def _request():
5
+ return {
6
+ "model_name": "BIOT-frozen",
7
+ "organization": "braindecode",
8
+ "model_url": "https://huggingface.co/braindecode/biot",
9
+ "paper_url": "",
10
+ "submitted_time": "2026-06-15T12:00:00Z",
11
+ "benchmark_kwargs": {"hub_repo": "braindecode/biot", "model_cls": "braindecode.models.BIOT"},
12
+ }
13
+
14
+ def _df():
15
+ # two datasets x one strategy x two seeds, all completed
16
+ return pd.DataFrame([
17
+ {"backbone": "BIOT", "dataset": "braindecode/bcic2a", "finetuning": "frozen",
18
+ "head": "linear_head", "seed": 0, "status": "completed",
19
+ "test_balanced_accuracy": 0.40, "trainable_params": 3_190_000},
20
+ {"backbone": "BIOT", "dataset": "braindecode/bcic2a", "finetuning": "frozen",
21
+ "head": "linear_head", "seed": 1, "status": "completed",
22
+ "test_balanced_accuracy": 0.60, "trainable_params": 3_190_000},
23
+ {"backbone": "BIOT", "dataset": "braindecode/physionet", "finetuning": "frozen",
24
+ "head": "linear_head", "seed": 0, "status": "completed",
25
+ "test_balanced_accuracy": 0.50, "trainable_params": 3_190_000},
26
+ ])
27
+
28
+ def test_one_row_per_strategy():
29
+ rows = map_oeb_results_to_contents(_df(), _request())
30
+ assert len(rows) == 1
31
+ row = rows[0]
32
+ assert row["fullname"] == "BIOT-frozen"
33
+ assert row["adapter"] == "frozen"
34
+ assert row["Architecture"] == "BIOT"
35
+ # mean over seeds: bcic2a = (0.40+0.60)/2 = 0.50 (fraction)
36
+ assert abs(row["bcic2a_accuracy"] - 0.50) < 1e-9
37
+ assert abs(row["physionet_accuracy"] - 0.50) < 1e-9
38
+ # Average over available datasets * 100 = 50.0
39
+ assert abs(row["Average ⬆️"] - 50.0) < 1e-9
40
+ assert abs(row["#Params (M)"] - 3.19) < 1e-6
41
+ assert row["Available on the hub"] is True
42
+
43
+ def test_failed_rows_ignored():
44
+ df = _df()
45
+ df.loc[len(df)] = {"backbone": "BIOT", "dataset": "braindecode/tuab", "finetuning": "frozen",
46
+ "head": "linear_head", "seed": 0, "status": "failed",
47
+ "exception": "boom", "test_balanced_accuracy": None, "trainable_params": None}
48
+ rows = map_oeb_results_to_contents(df, _request())
49
+ assert rows[0].get("tuab_accuracy", 0) == 0 # failed dataset not scored
50
+
51
+ def test_multiple_strategies_multiple_rows():
52
+ df = _df()
53
+ extra = df.copy(); extra["finetuning"] = "lora"
54
+ rows = map_oeb_results_to_contents(pd.concat([df, extra], ignore_index=True), _request())
55
+ assert {r["adapter"] for r in rows} == {"frozen", "lora"}
backend/tests/test_submissions.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from app.services.submissions import (
4
+ validate_submission,
5
+ build_request_entry,
6
+ count_user_submissions_this_month,
7
+ monthly_quota_exceeded,
8
+ )
9
+
10
+ def _valid_payload(**overrides):
11
+ p = {
12
+ "model_cls": "braindecode.models.BIOT",
13
+ "model_name": "BIOT-frozen",
14
+ "organization": "braindecode",
15
+ "hub_repo": "braindecode/biot-weights",
16
+ "finetuning_strategies": ["frozen"],
17
+ "heads": ["linear_head"],
18
+ "datasets": ["bcic2a", "physionet"],
19
+ "n_seeds": 3,
20
+ "visibility": "Public",
21
+ }
22
+ p.update(overrides)
23
+ return p
24
+
25
+ def test_valid_payload_has_no_errors():
26
+ assert validate_submission(_valid_payload()) == []
27
+
28
+ def test_model_cls_required():
29
+ errs = validate_submission(_valid_payload(model_cls=""))
30
+ assert any("model_cls" in e for e in errs)
31
+
32
+ def test_model_cls_must_be_dotted_path():
33
+ errs = validate_submission(_valid_payload(model_cls="notdotted"))
34
+ assert any("dotted import path" in e for e in errs)
35
+
36
+ def test_weights_source_required():
37
+ errs = validate_submission(_valid_payload(hub_repo="", checkpoint_url=""))
38
+ assert any("weights source" in e for e in errs)
39
+
40
+ def test_only_one_weights_source():
41
+ errs = validate_submission(_valid_payload(checkpoint_url="https://x/w.pth"))
42
+ assert any("only one weights source" in e for e in errs)
43
+
44
+ def test_peft_strategy_requires_target_modules():
45
+ errs = validate_submission(_valid_payload(finetuning_strategies=["lora"]))
46
+ assert any("peft_target_modules" in e for e in errs)
47
+
48
+ def test_ia3_requires_ff_modules():
49
+ errs = validate_submission(_valid_payload(
50
+ finetuning_strategies=["ia3"], peft_target_modules=["to_q"]))
51
+ assert any("peft_ff_modules" in e for e in errs)
52
+
53
+ def test_unknown_strategy_rejected():
54
+ errs = validate_submission(_valid_payload(finetuning_strategies=["magic"]))
55
+ assert any("finetuning strategies" in e for e in errs)
56
+
57
+ def test_unknown_dataset_rejected():
58
+ errs = validate_submission(_valid_payload(datasets=["not_a_dataset"]))
59
+ assert any("datasets" in e for e in errs)
60
+
61
+ def test_bad_email_rejected():
62
+ errs = validate_submission(_valid_payload(contact_email="nope"))
63
+ assert any("email" in e for e in errs)
64
+
65
+ def test_n_seeds_must_be_positive_int():
66
+ errs = validate_submission(_valid_payload(n_seeds=0))
67
+ assert any("n_seeds" in e for e in errs)
68
+
69
+ def test_build_request_entry_shape():
70
+ now = datetime(2026, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
71
+ entry = build_request_entry(_valid_payload(), "alice", now=now)
72
+ assert entry["framework"] == "open-eeg-bench"
73
+ assert entry["status"] == "PENDING"
74
+ assert entry["submitter"] == "alice"
75
+ assert entry["submitted_time"] == "2026-06-15T12:00:00Z"
76
+ assert entry["benchmark_kwargs"]["model_cls"] == "braindecode.models.BIOT"
77
+ assert entry["benchmark_kwargs"]["hub_repo"] == "braindecode/biot-weights"
78
+ assert entry["benchmark_kwargs"]["checkpoint_url"] is None
79
+ assert entry["benchmark_kwargs"]["finetuning_strategies"] == ["frozen"]
80
+
81
+ def test_monthly_count_and_quota():
82
+ entries = [
83
+ {"submitter": "alice", "submitted_time": "2026-06-01T00:00:00Z"},
84
+ {"submitter": "alice", "submitted_time": "2026-06-09T00:00:00Z"},
85
+ {"submitter": "bob", "submitted_time": "2026-06-09T00:00:00Z"},
86
+ {"submitter": "alice", "submitted_time": "2026-05-30T00:00:00Z"},
87
+ ]
88
+ now = datetime(2026, 6, 15, tzinfo=timezone.utc)
89
+ assert count_user_submissions_this_month(entries, "alice", now=now) == 2
90
+ assert monthly_quota_exceeded(entries, "alice", now=now) is False
91
+ five = entries + [{"submitter": "alice", "submitted_time": "2026-06-1%dT00:00:00Z" % d} for d in range(3, 6)]
92
+ assert monthly_quota_exceeded(five, "alice", now=now) is True
frontend/src/pages/AddModelPage/components/EvaluationQueues/EvaluationQueues.js CHANGED
@@ -67,13 +67,13 @@ const columns = [
67
  {
68
  id: "wait_time",
69
  label: "Submitted",
70
- width: "12%",
71
  align: "center",
72
  },
73
  {
74
- id: "precision",
75
- label: "Precision",
76
- width: "13%",
77
  align: "center",
78
  },
79
  {
@@ -196,14 +196,16 @@ const ModelTable = ({ models, emptyMessage, status }) => {
196
  padding: 0,
197
  position: "relative",
198
  width: "100%",
199
- height: `${rowVirtualizer.getTotalSize()}px`,
200
  }}
201
  colSpan={columns.length}
202
  >
203
  <>
204
  {rowVirtualizer.getVirtualItems().map((virtualRow) => {
205
  const model = models[virtualRow.index];
206
- const waitTime = formatWaitTime(model.wait_time);
 
 
 
207
 
208
  return (
209
  <TableRow
@@ -234,7 +236,7 @@ const ModelTable = ({ models, emptyMessage, status }) => {
234
  }}
235
  >
236
  <Link
237
- href={`https://huggingface.co/${model.name}`}
238
  target="_blank"
239
  rel="noopener noreferrer"
240
  sx={{
@@ -251,7 +253,7 @@ const ModelTable = ({ models, emptyMessage, status }) => {
251
  },
252
  }}
253
  >
254
- {model.name}
255
  <OpenInNewIcon />
256
  </Link>
257
  </TableCell>
@@ -311,7 +313,7 @@ const ModelTable = ({ models, emptyMessage, status }) => {
311
  }}
312
  >
313
  <Typography variant="body2" color="text.secondary">
314
- {model.precision}
315
  </Typography>
316
  </TableCell>
317
  <TableCell
@@ -327,7 +329,7 @@ const ModelTable = ({ models, emptyMessage, status }) => {
327
  justifyContent: "center",
328
  }}
329
  >
330
- {model.revision.substring(0, 7)}
331
  </TableCell>
332
  <TableCell
333
  align={columns[5].align}
 
67
  {
68
  id: "wait_time",
69
  label: "Submitted",
70
+ width: "13%",
71
  align: "center",
72
  },
73
  {
74
+ id: "adapter",
75
+ label: "Strategy",
76
+ width: "12%",
77
  align: "center",
78
  },
79
  {
 
196
  padding: 0,
197
  position: "relative",
198
  width: "100%",
 
199
  }}
200
  colSpan={columns.length}
201
  >
202
  <>
203
  {rowVirtualizer.getVirtualItems().map((virtualRow) => {
204
  const model = models[virtualRow.index];
205
+ const modelName = model.model_name || model.name;
206
+ const waitTime = model.wait_time
207
+ ? formatWaitTime(model.wait_time)
208
+ : "";
209
 
210
  return (
211
  <TableRow
 
236
  }}
237
  >
238
  <Link
239
+ href={`https://huggingface.co/${modelName}`}
240
  target="_blank"
241
  rel="noopener noreferrer"
242
  sx={{
 
253
  },
254
  }}
255
  >
256
+ {modelName}
257
  <OpenInNewIcon />
258
  </Link>
259
  </TableCell>
 
313
  }}
314
  >
315
  <Typography variant="body2" color="text.secondary">
316
+ {model.adapter || "—"}
317
  </Typography>
318
  </TableCell>
319
  <TableCell
 
329
  justifyContent: "center",
330
  }}
331
  >
332
+ {model.revision ? model.revision.substring(0, 7) : "—"}
333
  </TableCell>
334
  <TableCell
335
  align={columns[5].align}
frontend/src/pages/AddModelPage/components/ModelSubmissionForm/ModelSubmissionForm.js CHANGED
@@ -6,6 +6,10 @@ import {
6
  TextField,
7
  Button,
8
  FormControl,
 
 
 
 
9
  InputLabel,
10
  Select,
11
  MenuItem,
@@ -18,98 +22,201 @@ import RocketLaunchIcon from "@mui/icons-material/RocketLaunch";
18
  import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
19
  import { alpha } from "@mui/material/styles";
20
  import InfoIconWithTooltip from "../../../../components/shared/InfoIconWithTooltip";
21
- import { MODEL_TYPES } from "../../../../pages/LeaderboardPage/components/Leaderboard/constants/modelTypes";
22
- import { SUBMISSION_PRECISIONS } from "../../../../pages/LeaderboardPage/components/Leaderboard/constants/defaults";
 
 
 
 
23
  import AuthContainer from "../../../../components/shared/AuthContainer";
24
 
25
  const HELP_TEXTS = {
 
 
 
 
 
 
 
 
 
 
 
 
26
  modelName: (
27
  <Box sx={{ p: 1 }}>
28
  <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
29
- Model Name on Hugging Face Hub
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  </Typography>
31
  <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
32
- Your model must be public and loadable with AutoClasses without
33
- trust_remote_code. The model should be in Safetensors format for better
34
- safety and loading performance. Example: braindecode/EEGNetv4
35
  </Typography>
36
  </Box>
37
  ),
38
- revision: (
39
  <Box sx={{ p: 1 }}>
40
  <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
41
- Model Revision
42
  </Typography>
43
  <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
44
- Git branch, tag or commit hash. The evaluation will be strictly tied to
45
- this specific commit to ensure consistency. Make sure this version is
46
- stable and contains all necessary files.
47
  </Typography>
48
  </Box>
49
  ),
50
- modelType: (
51
  <Box sx={{ p: 1 }}>
52
  <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
53
- Adapter / Fine-tuning Method
54
  </Typography>
55
  <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
56
- LoRA: Low-Rank Adaptation (~98% param reduction){" "}
57
- IA3: Scaling vectors only (~99.5% reduction){" "}
58
- AdaLoRA: Adaptive rank allocation{" "}
59
- DoRA: Weight-decomposed adaptation{" "}
60
- OFT: Orthogonal fine-tuning{" "}
61
- Probe: Linear probing (head only){" "}
62
- Full Fine-tune: All parameters updated
63
  </Typography>
64
  </Box>
65
  ),
66
- baseModel: (
67
  <Box sx={{ p: 1 }}>
68
  <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
69
- Base Model Reference
70
  </Typography>
71
  <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
72
- Required for delta weights or adapters. This information is used to
73
- identify the original model and calculate the total parameter count by
74
- combining base model and adapter/delta parameters.
75
  </Typography>
76
  </Box>
77
  ),
78
- precision: (
79
  <Box sx={{ p: 1 }}>
80
  <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
81
- Model Precision
82
  </Typography>
83
  <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
84
- Size limits vary by precision: FP16/BF16: up to 500M parameters.
85
- 8-bit: up to 1B parameters. 4-bit: up to 2B parameters.
86
- Choose carefully as incorrect precision can cause evaluation errors.
87
  </Typography>
88
  </Box>
89
  ),
90
  };
91
 
92
- // Convert MODEL_TYPES to format expected by Select component
93
- const modelTypeOptions = Object.entries(MODEL_TYPES).map(
94
- ([value, { label }]) => ({
95
- value,
96
- label,
97
- })
98
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  function ModelSubmissionForm({ user, isAuthenticated }) {
101
- const [formData, setFormData] = useState({
102
- modelName: "",
103
- revision: "main",
104
- modelType: "lora",
105
- precision: "float16",
106
- baseModel: "",
107
- });
108
  const [error, setError] = useState(null);
109
  const [submitting, setSubmitting] = useState(false);
110
  const [success, setSuccess] = useState(false);
111
  const [submittedData, setSubmittedData] = useState(null);
112
 
 
 
 
 
 
113
  const handleChange = (event) => {
114
  const { name, value, checked } = event.target;
115
  setFormData((prev) => ({
@@ -124,19 +231,37 @@ function ModelSubmissionForm({ user, isAuthenticated }) {
124
  setSubmitting(true);
125
 
126
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  const response = await fetch("/api/models/submit", {
128
  method: "POST",
129
  headers: {
130
  "Content-Type": "application/json",
131
  },
132
- body: JSON.stringify({
133
- model_id: formData.modelName,
134
- revision: formData.revision,
135
- model_type: formData.modelType,
136
- precision: formData.precision,
137
- base_model: formData.baseModel,
138
- user_id: user.username,
139
- }),
140
  });
141
 
142
  if (!response.ok) {
@@ -206,10 +331,10 @@ function ModelSubmissionForm({ user, isAuthenticated }) {
206
  color="text.secondary"
207
  sx={{ width: 120 }}
208
  >
209
- Type:
210
  </Typography>
211
- <Typography variant="body2">
212
- {submittedData.modelType}
213
  </Typography>
214
  </Stack>
215
  <Stack direction="row" spacing={2}>
@@ -218,10 +343,12 @@ function ModelSubmissionForm({ user, isAuthenticated }) {
218
  color="text.secondary"
219
  sx={{ width: 120 }}
220
  >
221
- Revision:
222
  </Typography>
223
- <Typography variant="body2" sx={{ fontFamily: "monospace" }}>
224
- {submittedData.revision}
 
 
225
  </Typography>
226
  </Stack>
227
  <Stack direction="row" spacing={2}>
@@ -230,32 +357,18 @@ function ModelSubmissionForm({ user, isAuthenticated }) {
230
  color="text.secondary"
231
  sx={{ width: 120 }}
232
  >
233
- Precision:
234
  </Typography>
235
  <Typography variant="body2">
236
- {submittedData.precision}
237
  </Typography>
238
  </Stack>
239
- {submittedData.baseModel && (
240
- <Stack direction="row" spacing={2}>
241
- <Typography
242
- variant="body2"
243
- color="text.secondary"
244
- sx={{ width: 120 }}
245
- >
246
- Base model:
247
- </Typography>
248
- <Typography variant="body2">
249
- {submittedData.baseModel}
250
- </Typography>
251
- </Stack>
252
- )}
253
  </Stack>
254
  </Paper>
255
 
256
  <Typography variant="body2" color="text.secondary">
257
- An automatic upvote has been added to your model to help with
258
- prioritization.
259
  </Typography>
260
 
261
  <Stack direction="row" spacing={2}>
@@ -265,13 +378,7 @@ function ModelSubmissionForm({ user, isAuthenticated }) {
265
  onClick={() => {
266
  setSuccess(false);
267
  setSubmittedData(null);
268
- setFormData({
269
- modelName: "",
270
- revision: "main",
271
- modelType: "lora",
272
- precision: "float16",
273
- baseModel: "",
274
- });
275
  }}
276
  >
277
  Submit another model
@@ -334,20 +441,38 @@ function ModelSubmissionForm({ user, isAuthenticated }) {
334
  <Grid item xs={12}>
335
  <Stack direction="row" spacing={1} alignItems="center">
336
  <Typography variant="h6">Model Information</Typography>
337
- <InfoIconWithTooltip tooltip={HELP_TEXTS.modelName} />
338
  </Stack>
339
  </Grid>
340
 
341
- <Grid item xs={12} sm={8}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  <TextField
343
  required
344
  fullWidth
345
  name="modelName"
346
  label="Model Name"
347
- placeholder="organization/model-name"
348
  value={formData.modelName}
349
  onChange={handleChange}
350
- helperText="Example: braindecode/EEGNetv4"
351
  InputProps={{
352
  endAdornment: (
353
  <InfoIconWithTooltip tooltip={HELP_TEXTS.modelName} />
@@ -356,47 +481,126 @@ function ModelSubmissionForm({ user, isAuthenticated }) {
356
  />
357
  </Grid>
358
 
359
- <Grid item xs={12} sm={4}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  <TextField
361
  fullWidth
362
- name="revision"
363
- label="Revision commit"
364
- value={formData.revision}
365
  onChange={handleChange}
366
- helperText="Default: main"
367
  InputProps={{
368
  endAdornment: (
369
- <InfoIconWithTooltip tooltip={HELP_TEXTS.revision} />
 
 
370
  ),
371
  }}
372
  />
373
  </Grid>
374
 
375
- {/* Model Configuration */}
376
  <Grid item xs={12}>
377
  <Stack direction="row" spacing={1} alignItems="center">
378
- <Typography variant="h6">Model Configuration</Typography>
379
  </Stack>
380
  </Grid>
381
 
382
  <Grid item xs={12} sm={6}>
383
  <FormControl fullWidth>
384
- <InputLabel>Model Type</InputLabel>
385
  <Select
386
- name="modelType"
387
- value={formData.modelType}
 
388
  onChange={handleChange}
389
- label="Model Type"
 
 
 
390
  endAdornment={
391
  <InfoIconWithTooltip
392
- tooltip={HELP_TEXTS.modelType}
393
  sx={{ mr: 2 }}
394
  />
395
  }
396
  >
397
- {modelTypeOptions.map((type) => (
398
- <MenuItem key={type.value} value={type.value}>
399
- {type.label}
400
  </MenuItem>
401
  ))}
402
  </Select>
@@ -405,44 +609,205 @@ function ModelSubmissionForm({ user, isAuthenticated }) {
405
 
406
  <Grid item xs={12} sm={6}>
407
  <FormControl fullWidth>
408
- <InputLabel>Precision</InputLabel>
409
  <Select
410
- name="precision"
411
- value={formData.precision}
 
412
  onChange={handleChange}
413
- label="Precision"
 
 
 
414
  endAdornment={
415
  <InfoIconWithTooltip
416
- tooltip={HELP_TEXTS.precision}
417
  sx={{ mr: 2 }}
418
  />
419
  }
420
  >
421
- {SUBMISSION_PRECISIONS.map((option) => (
422
- <MenuItem key={option.value} value={option.value}>
423
- {option.label}
424
  </MenuItem>
425
  ))}
426
  </Select>
427
  </FormControl>
428
  </Grid>
429
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  <Grid item xs={12}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  <TextField
432
  fullWidth
433
- name="baseModel"
434
- label="Base Model"
435
- value={formData.baseModel}
 
436
  onChange={handleChange}
437
- helperText="Optional. Required for adapter / delta submissions."
438
  InputProps={{
439
  endAdornment: (
440
- <InfoIconWithTooltip tooltip={HELP_TEXTS.baseModel} />
441
  ),
442
  }}
443
  />
444
  </Grid>
445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  {/* Submit Button */}
447
  <Grid item xs={12}>
448
  <Box
 
6
  TextField,
7
  Button,
8
  FormControl,
9
+ FormLabel,
10
+ RadioGroup,
11
+ FormControlLabel,
12
+ Radio,
13
  InputLabel,
14
  Select,
15
  MenuItem,
 
22
  import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
23
  import { alpha } from "@mui/material/styles";
24
  import InfoIconWithTooltip from "../../../../components/shared/InfoIconWithTooltip";
25
+ import {
26
+ FINETUNING_STRATEGIES,
27
+ HEADS,
28
+ PEFT_STRATEGIES,
29
+ OEB_DATASETS,
30
+ } from "../../../../pages/LeaderboardPage/components/Leaderboard/constants/modelTypes";
31
  import AuthContainer from "../../../../components/shared/AuthContainer";
32
 
33
  const HELP_TEXTS = {
34
+ modelClass: (
35
+ <Box sx={{ p: 1 }}>
36
+ <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
37
+ Model Class (dotted import path)
38
+ </Typography>
39
+ <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
40
+ Fully-qualified import path to the model class evaluated by
41
+ OpenEEGBench. The class must accept (batch, n_chans, n_times) and
42
+ return (batch, n_outputs). Example: braindecode.models.BIOT
43
+ </Typography>
44
+ </Box>
45
+ ),
46
  modelName: (
47
  <Box sx={{ p: 1 }}>
48
  <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
49
+ Model Name
50
+ </Typography>
51
+ <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
52
+ A human-readable name for your submission. This is how your model will
53
+ appear on the leaderboard.
54
+ </Typography>
55
+ </Box>
56
+ ),
57
+ weightsSource: (
58
+ <Box sx={{ p: 1 }}>
59
+ <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
60
+ Weights Source
61
+ </Typography>
62
+ <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
63
+ Provide exactly one source for the pre-trained weights: a public
64
+ Hugging Face Hub repository (with a license tag) or a direct
65
+ http(s) URL to a checkpoint.
66
+ </Typography>
67
+ </Box>
68
+ ),
69
+ headModuleName: (
70
+ <Box sx={{ p: 1 }}>
71
+ <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
72
+ Head Module Name
73
+ </Typography>
74
+ <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
75
+ Name of the model's classification head module. OpenEEGBench replaces
76
+ this module to attach evaluation heads. Default: final_layer
77
+ </Typography>
78
+ </Box>
79
+ ),
80
+ finetuningStrategies: (
81
+ <Box sx={{ p: 1 }}>
82
+ <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
83
+ Fine-tuning Strategies
84
+ </Typography>
85
+ <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
86
+ Frozen: linear probing (encoder frozen). LoRA / IA3 / AdaLoRA / DoRA /
87
+ OFT: parameter-efficient fine-tuning. Full fine-tune: all parameters
88
+ updated. Two stages: probe then fine-tune. Select one or more.
89
+ </Typography>
90
+ </Box>
91
+ ),
92
+ heads: (
93
+ <Box sx={{ p: 1 }}>
94
+ <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
95
+ Evaluation Heads
96
+ </Typography>
97
+ <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
98
+ Linear head: single linear layer. MLP head: small multi-layer
99
+ perceptron. Original head: keep the model's own head. Select one or
100
+ more.
101
+ </Typography>
102
+ </Box>
103
+ ),
104
+ peftTargetModules: (
105
+ <Box sx={{ p: 1 }}>
106
+ <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
107
+ PEFT Target Modules
108
+ </Typography>
109
+ <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
110
+ Comma-separated module names to which the PEFT adapter is applied
111
+ (e.g. to_q, to_v). Required when a PEFT strategy (LoRA, IA3, AdaLoRA,
112
+ DoRA, OFT) is selected.
113
+ </Typography>
114
+ </Box>
115
+ ),
116
+ peftFfModules: (
117
+ <Box sx={{ p: 1 }}>
118
+ <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
119
+ PEFT Feed-Forward Modules
120
  </Typography>
121
  <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
122
+ Comma-separated feed-forward module names scaled by IA3. Required when
123
+ the IA3 strategy is selected.
 
124
  </Typography>
125
  </Box>
126
  ),
127
+ datasets: (
128
  <Box sx={{ p: 1 }}>
129
  <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
130
+ Datasets
131
  </Typography>
132
  <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
133
+ The downstream datasets to evaluate on. Leave empty to run all 10
134
+ datasets in the OpenEEGBench classification suite.
 
135
  </Typography>
136
  </Box>
137
  ),
138
+ nSeeds: (
139
  <Box sx={{ p: 1 }}>
140
  <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
141
+ Number of Seeds
142
  </Typography>
143
  <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
144
+ How many random seeds to average over per dataset. More seeds give a
145
+ more stable estimate but take longer. Default: 3
 
 
 
 
 
146
  </Typography>
147
  </Box>
148
  ),
149
+ contactEmail: (
150
  <Box sx={{ p: 1 }}>
151
  <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
152
+ Contact Email
153
  </Typography>
154
  <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
155
+ Optional. Stored privately and used only to reach you about your
156
+ submission. It is never shown on the public leaderboard.
 
157
  </Typography>
158
  </Box>
159
  ),
160
+ visibility: (
161
  <Box sx={{ p: 1 }}>
162
  <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
163
+ Visibility
164
  </Typography>
165
  <Typography variant="body2" sx={{ opacity: 0.9, lineHeight: 1.4 }}>
166
+ Public submissions appear on the leaderboard. Private submissions are
167
+ evaluated but kept out of the public table.
 
168
  </Typography>
169
  </Box>
170
  ),
171
  };
172
 
173
+ const VISIBILITY_OPTIONS = ["Public", "Private"];
174
+
175
+ const INITIAL_FORM_DATA = {
176
+ modelClass: "", // dotted import path
177
+ modelName: "",
178
+ organization: "",
179
+ weightsSource: "hub_repo", // "hub_repo" | "checkpoint_url"
180
+ hubRepo: "",
181
+ checkpointUrl: "",
182
+ headModuleName: "final_layer",
183
+ finetuningStrategies: ["frozen"],
184
+ heads: ["linear_head"],
185
+ peftTargetModules: "", // comma-separated -> array on submit
186
+ peftFfModules: "", // comma-separated -> array on submit
187
+ datasets: [], // empty = all
188
+ nSeeds: 3,
189
+ modelUrl: "",
190
+ paperUrl: "",
191
+ contactEmail: "",
192
+ additionalInfo: "",
193
+ visibility: "Public",
194
+ };
195
+
196
+ const toList = (s) =>
197
+ s
198
+ .split(",")
199
+ .map((x) => x.trim())
200
+ .filter(Boolean);
201
+
202
+ const strategyLabel = (value) =>
203
+ FINETUNING_STRATEGIES.find((s) => s.value === value)?.label || value;
204
+
205
+ const headLabel = (value) =>
206
+ HEADS.find((h) => h.value === value)?.label || value;
207
 
208
  function ModelSubmissionForm({ user, isAuthenticated }) {
209
+ const [formData, setFormData] = useState(INITIAL_FORM_DATA);
 
 
 
 
 
 
210
  const [error, setError] = useState(null);
211
  const [submitting, setSubmitting] = useState(false);
212
  const [success, setSuccess] = useState(false);
213
  const [submittedData, setSubmittedData] = useState(null);
214
 
215
+ const showPeftTargetModules = formData.finetuningStrategies.some((s) =>
216
+ PEFT_STRATEGIES.includes(s)
217
+ );
218
+ const showPeftFfModules = formData.finetuningStrategies.includes("ia3");
219
+
220
  const handleChange = (event) => {
221
  const { name, value, checked } = event.target;
222
  setFormData((prev) => ({
 
231
  setSubmitting(true);
232
 
233
  try {
234
+ const body = {
235
+ user_id: user.username,
236
+ model_cls: formData.modelClass,
237
+ model_name: formData.modelName,
238
+ organization: formData.organization,
239
+ hub_repo:
240
+ formData.weightsSource === "hub_repo" ? formData.hubRepo : "",
241
+ checkpoint_url:
242
+ formData.weightsSource === "checkpoint_url"
243
+ ? formData.checkpointUrl
244
+ : "",
245
+ head_module_name: formData.headModuleName,
246
+ finetuning_strategies: formData.finetuningStrategies,
247
+ heads: formData.heads,
248
+ peft_target_modules: toList(formData.peftTargetModules),
249
+ peft_ff_modules: toList(formData.peftFfModules),
250
+ datasets: formData.datasets,
251
+ n_seeds: Number(formData.nSeeds),
252
+ model_url: formData.modelUrl,
253
+ paper_url: formData.paperUrl,
254
+ contact_email: formData.contactEmail,
255
+ additional_info: formData.additionalInfo,
256
+ visibility: formData.visibility,
257
+ };
258
+
259
  const response = await fetch("/api/models/submit", {
260
  method: "POST",
261
  headers: {
262
  "Content-Type": "application/json",
263
  },
264
+ body: JSON.stringify(body),
 
 
 
 
 
 
 
265
  });
266
 
267
  if (!response.ok) {
 
331
  color="text.secondary"
332
  sx={{ width: 120 }}
333
  >
334
+ Model class:
335
  </Typography>
336
+ <Typography variant="body2" sx={{ fontFamily: "monospace" }}>
337
+ {submittedData.modelClass}
338
  </Typography>
339
  </Stack>
340
  <Stack direction="row" spacing={2}>
 
343
  color="text.secondary"
344
  sx={{ width: 120 }}
345
  >
346
+ Strategies:
347
  </Typography>
348
+ <Typography variant="body2">
349
+ {submittedData.finetuningStrategies
350
+ .map(strategyLabel)
351
+ .join(", ")}
352
  </Typography>
353
  </Stack>
354
  <Stack direction="row" spacing={2}>
 
357
  color="text.secondary"
358
  sx={{ width: 120 }}
359
  >
360
+ Visibility:
361
  </Typography>
362
  <Typography variant="body2">
363
+ {submittedData.visibility}
364
  </Typography>
365
  </Stack>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  </Stack>
367
  </Paper>
368
 
369
  <Typography variant="body2" color="text.secondary">
370
+ A worker will run oeb.benchmark() and post the official scores to
371
+ the leaderboard.
372
  </Typography>
373
 
374
  <Stack direction="row" spacing={2}>
 
378
  onClick={() => {
379
  setSuccess(false);
380
  setSubmittedData(null);
381
+ setFormData(INITIAL_FORM_DATA);
 
 
 
 
 
 
382
  }}
383
  >
384
  Submit another model
 
441
  <Grid item xs={12}>
442
  <Stack direction="row" spacing={1} alignItems="center">
443
  <Typography variant="h6">Model Information</Typography>
444
+ <InfoIconWithTooltip tooltip={HELP_TEXTS.modelClass} />
445
  </Stack>
446
  </Grid>
447
 
448
+ <Grid item xs={12}>
449
+ <TextField
450
+ required
451
+ fullWidth
452
+ name="modelClass"
453
+ label="Model Class"
454
+ placeholder="braindecode.models.BIOT"
455
+ value={formData.modelClass}
456
+ onChange={handleChange}
457
+ helperText="e.g. braindecode.models.BIOT"
458
+ InputProps={{
459
+ endAdornment: (
460
+ <InfoIconWithTooltip tooltip={HELP_TEXTS.modelClass} />
461
+ ),
462
+ }}
463
+ />
464
+ </Grid>
465
+
466
+ <Grid item xs={12} sm={6}>
467
  <TextField
468
  required
469
  fullWidth
470
  name="modelName"
471
  label="Model Name"
472
+ placeholder="BIOT-frozen"
473
  value={formData.modelName}
474
  onChange={handleChange}
475
+ helperText="Display name on the leaderboard"
476
  InputProps={{
477
  endAdornment: (
478
  <InfoIconWithTooltip tooltip={HELP_TEXTS.modelName} />
 
481
  />
482
  </Grid>
483
 
484
+ <Grid item xs={12} sm={6}>
485
+ <TextField
486
+ required
487
+ fullWidth
488
+ name="organization"
489
+ label="Organization"
490
+ placeholder="braindecode"
491
+ value={formData.organization}
492
+ onChange={handleChange}
493
+ helperText="Author or organization name"
494
+ />
495
+ </Grid>
496
+
497
+ {/* Weights Source */}
498
+ <Grid item xs={12}>
499
+ <Stack direction="row" spacing={1} alignItems="center">
500
+ <Typography variant="h6">Weights Source</Typography>
501
+ <InfoIconWithTooltip tooltip={HELP_TEXTS.weightsSource} />
502
+ </Stack>
503
+ </Grid>
504
+
505
+ <Grid item xs={12}>
506
+ <FormControl>
507
+ <FormLabel id="weights-source-label">
508
+ Provide one weights source
509
+ </FormLabel>
510
+ <RadioGroup
511
+ row
512
+ aria-labelledby="weights-source-label"
513
+ name="weightsSource"
514
+ value={formData.weightsSource}
515
+ onChange={handleChange}
516
+ >
517
+ <FormControlLabel
518
+ value="hub_repo"
519
+ control={<Radio />}
520
+ label="Hugging Face Hub repo"
521
+ />
522
+ <FormControlLabel
523
+ value="checkpoint_url"
524
+ control={<Radio />}
525
+ label="Checkpoint URL"
526
+ />
527
+ </RadioGroup>
528
+ </FormControl>
529
+ </Grid>
530
+
531
+ {formData.weightsSource === "hub_repo" ? (
532
+ <Grid item xs={12}>
533
+ <TextField
534
+ fullWidth
535
+ name="hubRepo"
536
+ label="Hub Repository"
537
+ placeholder="organization/model-weights"
538
+ value={formData.hubRepo}
539
+ onChange={handleChange}
540
+ helperText="Public HF Hub repo containing the weights (with a license tag)"
541
+ />
542
+ </Grid>
543
+ ) : (
544
+ <Grid item xs={12}>
545
+ <TextField
546
+ fullWidth
547
+ name="checkpointUrl"
548
+ label="Checkpoint URL"
549
+ placeholder="https://example.com/weights.pth"
550
+ value={formData.checkpointUrl}
551
+ onChange={handleChange}
552
+ helperText="Direct http(s) URL to a checkpoint file"
553
+ />
554
+ </Grid>
555
+ )}
556
+
557
+ <Grid item xs={12}>
558
  <TextField
559
  fullWidth
560
+ name="headModuleName"
561
+ label="Head Module Name"
562
+ value={formData.headModuleName}
563
  onChange={handleChange}
564
+ helperText="Default: final_layer"
565
  InputProps={{
566
  endAdornment: (
567
+ <InfoIconWithTooltip
568
+ tooltip={HELP_TEXTS.headModuleName}
569
+ />
570
  ),
571
  }}
572
  />
573
  </Grid>
574
 
575
+ {/* Evaluation Configuration */}
576
  <Grid item xs={12}>
577
  <Stack direction="row" spacing={1} alignItems="center">
578
+ <Typography variant="h6">Evaluation Configuration</Typography>
579
  </Stack>
580
  </Grid>
581
 
582
  <Grid item xs={12} sm={6}>
583
  <FormControl fullWidth>
584
+ <InputLabel>Fine-tuning Strategies</InputLabel>
585
  <Select
586
+ multiple
587
+ name="finetuningStrategies"
588
+ value={formData.finetuningStrategies}
589
  onChange={handleChange}
590
+ label="Fine-tuning Strategies"
591
+ renderValue={(selected) =>
592
+ selected.map(strategyLabel).join(", ")
593
+ }
594
  endAdornment={
595
  <InfoIconWithTooltip
596
+ tooltip={HELP_TEXTS.finetuningStrategies}
597
  sx={{ mr: 2 }}
598
  />
599
  }
600
  >
601
+ {FINETUNING_STRATEGIES.map((strategy) => (
602
+ <MenuItem key={strategy.value} value={strategy.value}>
603
+ {strategy.label}
604
  </MenuItem>
605
  ))}
606
  </Select>
 
609
 
610
  <Grid item xs={12} sm={6}>
611
  <FormControl fullWidth>
612
+ <InputLabel>Heads</InputLabel>
613
  <Select
614
+ multiple
615
+ name="heads"
616
+ value={formData.heads}
617
  onChange={handleChange}
618
+ label="Heads"
619
+ renderValue={(selected) =>
620
+ selected.map(headLabel).join(", ")
621
+ }
622
  endAdornment={
623
  <InfoIconWithTooltip
624
+ tooltip={HELP_TEXTS.heads}
625
  sx={{ mr: 2 }}
626
  />
627
  }
628
  >
629
+ {HEADS.map((head) => (
630
+ <MenuItem key={head.value} value={head.value}>
631
+ {head.label}
632
  </MenuItem>
633
  ))}
634
  </Select>
635
  </FormControl>
636
  </Grid>
637
 
638
+ {showPeftTargetModules && (
639
+ <Grid item xs={12} sm={showPeftFfModules ? 6 : 12}>
640
+ <TextField
641
+ fullWidth
642
+ name="peftTargetModules"
643
+ label="PEFT Target Modules"
644
+ placeholder="to_q, to_v"
645
+ value={formData.peftTargetModules}
646
+ onChange={handleChange}
647
+ helperText="comma-separated module names"
648
+ InputProps={{
649
+ endAdornment: (
650
+ <InfoIconWithTooltip
651
+ tooltip={HELP_TEXTS.peftTargetModules}
652
+ />
653
+ ),
654
+ }}
655
+ />
656
+ </Grid>
657
+ )}
658
+
659
+ {showPeftFfModules && (
660
+ <Grid item xs={12} sm={6}>
661
+ <TextField
662
+ fullWidth
663
+ name="peftFfModules"
664
+ label="PEFT Feed-Forward Modules"
665
+ placeholder="to_ff"
666
+ value={formData.peftFfModules}
667
+ onChange={handleChange}
668
+ helperText="comma-separated module names (IA3 only)"
669
+ InputProps={{
670
+ endAdornment: (
671
+ <InfoIconWithTooltip
672
+ tooltip={HELP_TEXTS.peftFfModules}
673
+ />
674
+ ),
675
+ }}
676
+ />
677
+ </Grid>
678
+ )}
679
+
680
+ <Grid item xs={12} sm={8}>
681
+ <FormControl fullWidth>
682
+ <InputLabel>Datasets</InputLabel>
683
+ <Select
684
+ multiple
685
+ name="datasets"
686
+ value={formData.datasets}
687
+ onChange={handleChange}
688
+ label="Datasets"
689
+ renderValue={(selected) =>
690
+ selected.length ? selected.join(", ") : "All 10 datasets"
691
+ }
692
+ endAdornment={
693
+ <InfoIconWithTooltip
694
+ tooltip={HELP_TEXTS.datasets}
695
+ sx={{ mr: 2 }}
696
+ />
697
+ }
698
+ >
699
+ {OEB_DATASETS.map((dataset) => (
700
+ <MenuItem key={dataset} value={dataset}>
701
+ {dataset}
702
+ </MenuItem>
703
+ ))}
704
+ </Select>
705
+ </FormControl>
706
+ </Grid>
707
+
708
+ <Grid item xs={12} sm={4}>
709
+ <TextField
710
+ fullWidth
711
+ type="number"
712
+ name="nSeeds"
713
+ label="Number of Seeds"
714
+ value={formData.nSeeds}
715
+ onChange={handleChange}
716
+ helperText="Default: 3"
717
+ inputProps={{ min: 1 }}
718
+ InputProps={{
719
+ endAdornment: (
720
+ <InfoIconWithTooltip tooltip={HELP_TEXTS.nSeeds} />
721
+ ),
722
+ }}
723
+ />
724
+ </Grid>
725
+
726
+ {/* Additional Information */}
727
  <Grid item xs={12}>
728
+ <Stack direction="row" spacing={1} alignItems="center">
729
+ <Typography variant="h6">Additional Information</Typography>
730
+ </Stack>
731
+ </Grid>
732
+
733
+ <Grid item xs={12} sm={6}>
734
+ <TextField
735
+ fullWidth
736
+ name="modelUrl"
737
+ label="Model URL"
738
+ placeholder="https://huggingface.co/organization/model"
739
+ value={formData.modelUrl}
740
+ onChange={handleChange}
741
+ helperText="Optional. Link to the model card or repo."
742
+ />
743
+ </Grid>
744
+
745
+ <Grid item xs={12} sm={6}>
746
+ <TextField
747
+ fullWidth
748
+ name="paperUrl"
749
+ label="Paper URL"
750
+ placeholder="https://arxiv.org/abs/..."
751
+ value={formData.paperUrl}
752
+ onChange={handleChange}
753
+ helperText="Optional. Link to the related paper."
754
+ />
755
+ </Grid>
756
+
757
+ <Grid item xs={12} sm={6}>
758
  <TextField
759
  fullWidth
760
+ name="contactEmail"
761
+ label="Contact Email"
762
+ placeholder="you@example.com"
763
+ value={formData.contactEmail}
764
  onChange={handleChange}
765
+ helperText="Optional. Stored privately."
766
  InputProps={{
767
  endAdornment: (
768
+ <InfoIconWithTooltip tooltip={HELP_TEXTS.contactEmail} />
769
  ),
770
  }}
771
  />
772
  </Grid>
773
 
774
+ <Grid item xs={12} sm={6}>
775
+ <FormControl fullWidth>
776
+ <InputLabel>Visibility</InputLabel>
777
+ <Select
778
+ name="visibility"
779
+ value={formData.visibility}
780
+ onChange={handleChange}
781
+ label="Visibility"
782
+ endAdornment={
783
+ <InfoIconWithTooltip
784
+ tooltip={HELP_TEXTS.visibility}
785
+ sx={{ mr: 2 }}
786
+ />
787
+ }
788
+ >
789
+ {VISIBILITY_OPTIONS.map((option) => (
790
+ <MenuItem key={option} value={option}>
791
+ {option}
792
+ </MenuItem>
793
+ ))}
794
+ </Select>
795
+ </FormControl>
796
+ </Grid>
797
+
798
+ <Grid item xs={12}>
799
+ <TextField
800
+ fullWidth
801
+ multiline
802
+ minRows={2}
803
+ name="additionalInfo"
804
+ label="Additional Info"
805
+ value={formData.additionalInfo}
806
+ onChange={handleChange}
807
+ helperText="Optional. Anything else we should know."
808
+ />
809
+ </Grid>
810
+
811
  {/* Submit Button */}
812
  <Grid item xs={12}>
813
  <Box
frontend/src/pages/AddModelPage/components/SubmissionGuide/SubmissionGuide.js CHANGED
@@ -50,32 +50,36 @@ const StepNumber = ({ number }) => (
50
  </Box>
51
  );
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  const TUTORIAL_STEPS = [
54
  {
55
- title: "Model Information",
56
- content: (
57
- <Stack spacing={2}>
58
- <Typography variant="body2" color="text.secondary">
59
- Your model should be <strong>public</strong> on the Hub and follow the{" "}
60
- <strong>username/model-id</strong> format (e.g.
61
- braindecode/labram-lora-bcic2a). Specify the <strong>revision</strong>{" "}
62
- (commit hash or branch) and <strong>adapter method</strong> used
63
- for fine-tuning.
64
- </Typography>
65
- <DocLink href="https://huggingface.co/docs/hub/models-uploading">
66
- Model uploading guide
67
- </DocLink>
68
- </Stack>
69
- ),
70
- },
71
- {
72
- title: "Technical Details",
73
  content: (
74
  <Stack spacing={2}>
75
  <Typography variant="body2" color="text.secondary">
76
- Make sure your model can be <strong>loaded locally</strong> before
77
- submitting. The arena evaluates EEG foundation models with PEFT
78
- adapters:
 
 
79
  </Typography>
80
  <Box
81
  sx={{
@@ -94,72 +98,76 @@ const TUTORIAL_STEPS = [
94
  }}
95
  >
96
  <pre>
97
- {`from braindecode.models import LaBraM
98
- from peft import get_peft_model, LoraConfig
99
-
100
- # Load the foundation model
101
- model = LaBraM(n_chans=22, n_outputs=4, n_times=1000)
102
 
103
- # Apply a PEFT adapter (e.g., LoRA)
104
- peft_config = LoraConfig(
105
- r=16, lora_alpha=32,
106
- target_modules=["qkv"],
107
- lora_dropout=0.1,
108
  )
109
- model = get_peft_model(model, peft_config)
110
- print(model.print_trainable_parameters())`}
111
  </pre>
112
  </Box>
113
- <DocLink href="https://braindecode.org/stable/api.html">
114
- Braindecode documentation
115
  </DocLink>
116
  </Stack>
117
  ),
118
  },
119
  {
120
- title: "License Requirements",
121
  content: (
122
  <Stack spacing={2}>
123
  <Typography variant="body2" color="text.secondary">
124
- A <strong>license tag</strong> is required.{" "}
125
- <strong>Open licenses</strong> (Apache, MIT, etc) are strongly
126
- recommended.
 
127
  </Typography>
128
- <DocLink href="https://huggingface.co/docs/hub/repositories-licenses">
129
- About model licenses
130
  </DocLink>
131
  </Stack>
132
  ),
133
  },
134
  {
135
- title: "Model Card Requirements",
136
  content: (
137
  <Stack spacing={2}>
138
  <Typography variant="body2" color="text.secondary">
139
- Your model card must include: <strong>foundation model</strong> (e.g.
140
- LaBraM, EEGPT, BIOT), <strong>adapter method</strong> (LoRA, IA3, etc.),{" "}
141
- <strong>training details</strong>,{" "}
142
- <strong>dataset information</strong> (EEG paradigm, number of channels,
143
- sampling rate), and <strong>performance metrics</strong>.
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  </Typography>
145
- <DocLink href="https://huggingface.co/docs/hub/model-cards">
146
- Model cards guide
147
- </DocLink>
148
  </Stack>
149
  ),
150
  },
151
  {
152
- title: "Final Checklist",
153
  content: (
154
  <Stack spacing={2}>
155
  <Typography variant="body2" color="text.secondary">
156
- Ensure your model is <strong>public</strong>, uses{" "}
157
- <strong>safetensors</strong> format, has a{" "}
158
- <strong>license tag</strong>, and <strong>loads correctly</strong>{" "}
159
- with braindecode + PEFT.
160
  </Typography>
161
- <DocLink href="https://huggingface.co/docs/peft">
162
- HuggingFace PEFT documentation
163
  </DocLink>
164
  </Stack>
165
  ),
@@ -276,6 +284,52 @@ function SubmissionGuide() {
276
  )}
277
  </Box>
278
  ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  </Stack>
280
  </Box>
281
  </Collapse>
 
50
  </Box>
51
  );
52
 
53
+ const CITATION = `@software{open_eeg_bench,
54
+ title = {Open EEG Bench: Benchmarking parameter-efficient fine-tuning of EEG foundation models},
55
+ author = {Guetschel, Pierre and Aristimunha, Bruno and Truong, Dung and Kokate, Kuntal and Moreau, Thomas and Tangermann, Michael and Delorme, Arnaud},
56
+ year = {2026},
57
+ url = {https://github.com/braindecode/OpenEEGBench},
58
+ doi = {10.5281/zenodo.19698863}
59
+ }
60
+
61
+ @inproceedings{guetschel2026openeegbench,
62
+ title = {Toward {OpenEEG-Bench}: A Live Community-Driven Benchmark for {EEG} Foundation Models},
63
+ author = {Guetschel, Pierre and Aristimunha, Bruno and Truong, Dung and Kokate, Kuntal and Tangermann, Michael and Delorme, Arnaud},
64
+ booktitle = {Proceedings of the 34th European Signal Processing Conference (EUSIPCO 2026)},
65
+ year = {2026},
66
+ address = {Bruges, Belgium},
67
+ month = aug,
68
+ pages = {1--5},
69
+ organization = {EURASIP}
70
+ }`;
71
+
72
  const TUTORIAL_STEPS = [
73
  {
74
+ title: "Install & implement the model contract",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  content: (
76
  <Stack spacing={2}>
77
  <Typography variant="body2" color="text.secondary">
78
+ Install the benchmark with{" "}
79
+ <strong>pip install open-eeg-bench</strong>. Your model must accept
80
+ input of shape <strong>(batch, n_chans, n_times)</strong> and return{" "}
81
+ <strong>(batch, n_outputs)</strong>, exposing a named classification
82
+ head module (default <strong>final_layer</strong>).
83
  </Typography>
84
  <Box
85
  sx={{
 
98
  }}
99
  >
100
  <pre>
101
+ {`import open_eeg_bench as oeb
 
 
 
 
102
 
103
+ # Your model: (batch, n_chans, n_times) -> (batch, n_outputs)
104
+ results = oeb.benchmark(
105
+ model_cls="my_package.MyModel",
106
+ checkpoint_url="https://my-weights.pth",
107
+ head_module_name="final_layer",
108
  )
109
+ print(results) # pd.DataFrame`}
 
110
  </pre>
111
  </Box>
112
+ <DocLink href="https://github.com/braindecode/OpenEEGBench">
113
+ OpenEEGBench repository
114
  </DocLink>
115
  </Stack>
116
  ),
117
  },
118
  {
119
+ title: "Publish your weights",
120
  content: (
121
  <Stack spacing={2}>
122
  <Typography variant="body2" color="text.secondary">
123
+ Push your checkpoint to a <strong>public HuggingFace Hub repo</strong>{" "}
124
+ (with a <strong>license tag</strong>) or host it at a direct{" "}
125
+ <strong>checkpoint URL</strong>. The arena loads the weights to run
126
+ the evaluation.
127
  </Typography>
128
+ <DocLink href="https://huggingface.co/docs/hub/models-uploading">
129
+ Model uploading guide
130
  </DocLink>
131
  </Stack>
132
  ),
133
  },
134
  {
135
+ title: "Sign in & fill the form",
136
  content: (
137
  <Stack spacing={2}>
138
  <Typography variant="body2" color="text.secondary">
139
+ Provide your <strong>model class import path</strong>, the{" "}
140
+ <strong>weights source</strong>, a{" "}
141
+ <strong>fine-tuning strategy</strong> and{" "}
142
+ <strong>classification head</strong>, the{" "}
143
+ <strong>datasets</strong> (default: all 10), and the number of{" "}
144
+ <strong>seeds</strong>.
145
+ </Typography>
146
+ </Stack>
147
+ ),
148
+ },
149
+ {
150
+ title: "Submit & wait",
151
+ content: (
152
+ <Stack spacing={2}>
153
+ <Typography variant="body2" color="text.secondary">
154
+ The arena queues your model and a worker runs{" "}
155
+ <strong>oeb.benchmark()</strong> to post official scores. Limit:{" "}
156
+ <strong>5 submissions per month</strong>.
157
  </Typography>
 
 
 
158
  </Stack>
159
  ),
160
  },
161
  {
162
+ title: "Privacy & help",
163
  content: (
164
  <Stack spacing={2}>
165
  <Typography variant="body2" color="text.secondary">
166
+ Your <strong>contact email</strong> is stored privately. For help or
167
+ to request removal of a submission, open an issue.
 
 
168
  </Typography>
169
+ <DocLink href="https://github.com/braindecode/OpenEEGBench/issues">
170
+ OpenEEGBench issues
171
  </DocLink>
172
  </Stack>
173
  ),
 
284
  )}
285
  </Box>
286
  ))}
287
+ <Box
288
+ sx={{
289
+ mt: 4,
290
+ pt: 4,
291
+ px: 4,
292
+ borderTop: "1px solid",
293
+ borderColor: (theme) =>
294
+ theme.palette.mode === "dark" ? "grey.800" : "grey.100",
295
+ }}
296
+ >
297
+ <Stack spacing={2}>
298
+ <Typography
299
+ variant="subtitle1"
300
+ sx={{
301
+ fontWeight: 600,
302
+ color: "text.primary",
303
+ letterSpacing: "-0.01em",
304
+ }}
305
+ >
306
+ Citation
307
+ </Typography>
308
+ <Typography variant="body2" color="text.secondary">
309
+ If you use OpenEEGBench in your research, please cite it.
310
+ </Typography>
311
+ <Box
312
+ sx={{
313
+ p: 2,
314
+ bgcolor: (theme) =>
315
+ theme.palette.mode === "dark" ? "grey.50" : "grey.900",
316
+ borderRadius: 1,
317
+ overflowX: "auto",
318
+ "& pre": {
319
+ m: 0,
320
+ p: 0,
321
+ fontFamily: "monospace",
322
+ fontSize: "0.8125rem",
323
+ whiteSpace: "pre",
324
+ color: (theme) =>
325
+ theme.palette.mode === "dark" ? "grey.900" : "grey.50",
326
+ },
327
+ }}
328
+ >
329
+ <pre>{CITATION}</pre>
330
+ </Box>
331
+ </Stack>
332
+ </Box>
333
  </Stack>
334
  </Box>
335
  </Collapse>
frontend/src/pages/AddModelPage/components/SubmissionLimitChecker/SubmissionLimitChecker.js CHANGED
@@ -1,7 +1,7 @@
1
  import React, { useState, useEffect } from "react";
2
  import { Alert, Box, CircularProgress } from "@mui/material";
3
 
4
- const MAX_SUBMISSIONS_PER_WEEK = 10;
5
 
6
  function SubmissionLimitChecker({ user, children }) {
7
  const [loading, setLoading] = useState(true);
@@ -17,7 +17,7 @@ function SubmissionLimitChecker({ user, children }) {
17
 
18
  try {
19
  const response = await fetch(
20
- `/api/models/organization/${user.username}/submissions?days=7`
21
  );
22
  if (!response.ok) {
23
  throw new Error("Failed to fetch submission data");
@@ -25,7 +25,7 @@ function SubmissionLimitChecker({ user, children }) {
25
 
26
  const submissions = await response.json();
27
  console.log(`Recent submissions for ${user.username}:`, submissions);
28
- setReachedLimit(submissions.length >= MAX_SUBMISSIONS_PER_WEEK);
29
  setError(false);
30
  } catch (error) {
31
  console.error("Error checking submission limit:", error);
@@ -74,7 +74,8 @@ function SubmissionLimitChecker({ user, children }) {
74
  }}
75
  >
76
  For fairness reasons, you cannot submit more than{" "}
77
- {MAX_SUBMISSIONS_PER_WEEK} models per week. Please try again later.
 
78
  </Alert>
79
  );
80
  }
 
1
  import React, { useState, useEffect } from "react";
2
  import { Alert, Box, CircularProgress } from "@mui/material";
3
 
4
+ const MAX_SUBMISSIONS_PER_MONTH = 5;
5
 
6
  function SubmissionLimitChecker({ user, children }) {
7
  const [loading, setLoading] = useState(true);
 
17
 
18
  try {
19
  const response = await fetch(
20
+ `/api/models/organization/${user.username}/submissions?days=30`
21
  );
22
  if (!response.ok) {
23
  throw new Error("Failed to fetch submission data");
 
25
 
26
  const submissions = await response.json();
27
  console.log(`Recent submissions for ${user.username}:`, submissions);
28
+ setReachedLimit(submissions.length >= MAX_SUBMISSIONS_PER_MONTH);
29
  setError(false);
30
  } catch (error) {
31
  console.error("Error checking submission limit:", error);
 
74
  }}
75
  >
76
  For fairness reasons, you cannot submit more than{" "}
77
+ {MAX_SUBMISSIONS_PER_MONTH} submissions per month. Please try again next
78
+ month.
79
  </Alert>
80
  );
81
  }
frontend/src/pages/LeaderboardPage/components/Leaderboard/constants/modelTypes.js CHANGED
@@ -88,3 +88,27 @@ export const getModelTypeOrder = (type) => {
88
  );
89
  return matchedType ? matchedType[1].order : Infinity;
90
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  );
89
  return matchedType ? matchedType[1].order : Infinity;
90
  };
91
+
92
+ // OpenEEGBench submission options — fine-tuning strategies, heads, and the
93
+ // classification-only dataset set accepted by oeb.benchmark().
94
+ export const FINETUNING_STRATEGIES = [
95
+ { value: "frozen", label: "Frozen (linear probing)" },
96
+ { value: "ridge_probe", label: "Ridge probe" },
97
+ { value: "lora", label: "LoRA" },
98
+ { value: "ia3", label: "IA3" },
99
+ { value: "adalora", label: "AdaLoRA" },
100
+ { value: "dora", label: "DoRA" },
101
+ { value: "oft", label: "OFT" },
102
+ { value: "full_finetune", label: "Full fine-tune" },
103
+ { value: "two_stages", label: "Two stages" },
104
+ ];
105
+ export const HEADS = [
106
+ { value: "linear_head", label: "Linear head" },
107
+ { value: "mlp_head", label: "MLP head" },
108
+ { value: "original_head", label: "Original head" },
109
+ ];
110
+ export const PEFT_STRATEGIES = ["lora", "ia3", "adalora", "dora", "oft"];
111
+ export const OEB_DATASETS = [
112
+ "bcic2a", "physionet", "bcic2020_3", "isruc_sleep", "tuab",
113
+ "tuev", "mdd_mumtaz2016", "arithmetic_zyma2019", "faced", "seed_v",
114
+ ];