whojavumusic commited on
Commit
c0dd455
·
1 Parent(s): 6845229

beautification, custom scripts loader, artefacts json to csv

Browse files
README.md CHANGED
@@ -2,8 +2,8 @@
2
  title: FFASR Leaderboard
3
  short_description: Far-Field ASR — clean / noisy / reverberant benchmark
4
  emoji: 🎙️
5
- colorFrom: indigo
6
- colorTo: pink
7
  sdk: gradio
8
  sdk_version: 5.29.0
9
  app_file: app.py
 
2
  title: FFASR Leaderboard
3
  short_description: Far-Field ASR — clean / noisy / reverberant benchmark
4
  emoji: 🎙️
5
+ colorFrom: teal
6
+ colorTo: green
7
  sdk: gradio
8
  sdk_version: 5.29.0
9
  app_file: app.py
analytics.py CHANGED
@@ -312,53 +312,56 @@ def plot_leaderboard_score_bars(
312
  return fig
313
 
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  def plot_robustness_radar(
316
  df: pd.DataFrame,
317
  model_ids: Sequence[str],
318
- title: str = "Robustness radar (relative strength; outward is better)",
319
  ) -> go.Figure:
320
  """
321
- Fixed-axis robustness radar: **strength** view (lines only, no fill).
322
-
323
- Every axis is phrased so farther from the centre = better. WER and model
324
- size / parameter count are inverted before normalisation; RTF is not.
325
- Strength is a per-axis score computed across **all** leaderboard rows
326
- with a log transform and global min/max, so axes are directly comparable
327
- across models. Missing or invalid raw values map to a low baseline strength for display.
328
 
329
- The Compactness axis prefers `num_params_m` (recorded for every modern
330
- eval) and falls back to `model_size_mb` only when params are unavailable;
331
- the asterisk on the axis label flags that fallback in the chart.
332
  """
333
  if df.empty:
334
  return _empty_fig("No leaderboard data yet.")
335
 
336
  d = df.copy().reset_index(drop=True)
337
-
338
- axes: list[tuple[str, np.ndarray, np.ndarray, str, bool, str, str, bool]] = []
339
- for label, primary, fallback, inverse, raw_disp, raw_unit in _RADAR_AXES:
340
- col_label = label
341
- col_name = primary
342
- using_fallback = False
343
- col = (
344
- pd.to_numeric(d[primary], errors="coerce")
345
- if primary in d.columns
346
- else pd.Series([np.nan] * len(d))
347
- )
348
- if not col.notna().any() and fallback and fallback in d.columns:
349
- col = pd.to_numeric(d[fallback], errors="coerce")
350
- col_label = f"{label}*"
351
- col_name = fallback
352
- using_fallback = True
353
- override = _RADAR_FALLBACK_DISPLAY.get(primary)
354
- if override is not None:
355
- raw_disp, raw_unit = override
356
- strength = _log_normalize(col.to_numpy(), inverse=inverse)
357
- axes.append(
358
- (col_label, strength, col.to_numpy(), col_name, inverse, raw_disp, raw_unit, using_fallback)
359
- )
360
-
361
- labels = [a[0] for a in axes]
362
  labels_closed = labels + [labels[0]]
363
 
364
  selected = [m for m in (model_ids or []) if m in set(d["model_id"])]
@@ -372,21 +375,13 @@ def plot_robustness_radar(
372
  rows = d.index[d["model_id"] == mid].tolist()
373
  if not rows:
374
  continue
375
- idx = rows[0]
376
  r_vals: list[float] = []
377
  raw_text: list[str] = []
378
- for _lab, strength, raw_series, _src, inv, raw_disp, raw_unit, using_fb in axes:
379
- s = strength[idx]
380
- rv = raw_series[idx]
381
- if np.isfinite(s):
382
- r_vals.append(float(max(s, 0.1)))
383
- else:
384
- r_vals.append(0.1)
385
- direction = "lower is better" if inv else "higher is better"
386
- if not np.isfinite(rv):
387
- raw_text.append(f"{raw_disp}: N/A ({direction})")
388
- else:
389
- raw_text.append(f"{raw_disp}: {rv:.4f}{raw_unit} ({direction})")
390
 
391
  r_closed = r_vals + [r_vals[0]]
392
  raw_closed = raw_text + [raw_text[0]]
@@ -405,7 +400,7 @@ def plot_robustness_radar(
405
  opacity=1.0,
406
  hovertemplate=(
407
  f"<b>{short}</b><br>"
408
- "%{theta} strength: %{r:.2f}<br>"
409
  "%{customdata}<extra></extra>"
410
  ),
411
  )
@@ -415,10 +410,10 @@ def plot_robustness_radar(
415
  polar=dict(
416
  radialaxis=dict(
417
  visible=True,
418
- range=[0.1, 1.0],
419
  showticklabels=True,
420
  tickformat=".2f",
421
- tickvals=[0.1, 0.25, 0.5, 0.75, 1.0],
422
  ),
423
  angularaxis=dict(direction="clockwise", rotation=90),
424
  ),
@@ -696,56 +691,32 @@ def plot_latency_vs_wer(
696
  "num_params_m": "Parameters (millions)",
697
  }.get(x_key, x_key)
698
 
699
- xs = d[x_key].to_numpy(dtype=float)
700
- ys = d[y_metric_key].to_numpy(dtype=float)
701
- mx_f = float(np.nanmedian(xs))
702
- my_f = float(np.nanmedian(ys))
703
-
704
- def _bucket(xv: float, yv: float) -> tuple[str, str]:
705
- if x_key == "eval_rtf":
706
- fast = xv >= mx_f
707
- else:
708
- fast = xv <= mx_f
709
- accurate = yv <= my_f
710
- if fast and accurate:
711
- return "Fast · accurate", "#22c55e"
712
- if fast and not accurate:
713
- return "Fast · higher WER", "#3b82f6"
714
- if not fast and accurate:
715
- return "Slower · lower WER", "#f59e0b"
716
- return "Slower · higher WER", "#ef4444"
717
-
718
- buckets = [_bucket(float(a), float(b)) for a, b in zip(xs, ys)]
719
- labels = [b[0] for b in buckets]
720
-
721
  fig = go.Figure()
722
- for lab, color in (
723
- ("Fast · accurate", "#22c55e"),
724
- ("Fast · higher WER", "#3b82f6"),
725
- ("Slower · lower WER", "#f59e0b"),
726
- ("Slower · higher WER", "#ef4444"),
727
- ):
728
- idx = [i for i, lb in enumerate(labels) if lb == lab]
729
- if not idx:
730
  continue
 
731
  fig.add_trace(
732
  go.Scatter(
733
- x=d.iloc[idx][x_key],
734
- y=d.iloc[idx][y_metric_key],
735
  mode="markers",
736
- text=d.iloc[idx]["model_id"].str.split("/").str[-1].str[:28],
737
- name=lab,
738
- legendgroup=lab,
739
  marker=dict(size=11, opacity=0.9, color=color, line=dict(width=0.6, color="white")),
740
  hovertemplate=(
741
- f"<b>%{{text}}</b><br>{lab}<br>{x_title}: %{{x:.4f}}<br>"
742
  f"{y_label}: %{{y:.4f}}<extra></extra>"
743
  ),
744
  )
745
  )
746
 
747
- fig.add_vline(x=mx_f, line_dash="dot", line_color="rgba(0,0,0,0.25)")
748
- fig.add_hline(y=my_f, line_dash="dot", line_color="rgba(0,0,0,0.25)")
749
  ttl = title or f"Speed versus accuracy: {y_label}"
750
  fig.update_layout(
751
  title=dict(text=ttl, x=0.5, xanchor="center"),
@@ -753,9 +724,9 @@ def plot_latency_vs_wer(
753
  yaxis=dict(title=y_label + " (lower is better)", rangemode="tozero"),
754
  template=_TEMPLATE,
755
  height=_FIG_HEIGHT,
756
- margin=dict(l=60, r=40, t=60, b=60),
757
- legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
758
  showlegend=True,
 
759
  )
760
  return fig
761
 
 
312
  return fig
313
 
314
 
315
+ def _radar_absolute_value(row: pd.Series, axis: str) -> tuple[float, str]:
316
+ """Map a leaderboard row to an absolute 0–1 radar coordinate (higher = better)."""
317
+ if axis == "Dry Speech":
318
+ wer = pd.to_numeric(row.get("wer_anechoic_speech"), errors="coerce")
319
+ if not np.isfinite(wer):
320
+ return 0.0, "Dry speech WER: N/A"
321
+ v = float(max(0.0, min(1.0, 1.0 - float(wer))))
322
+ return v, f"Dry speech WER: {float(wer):.4f} → accuracy {v:.3f}"
323
+ if axis == "Score":
324
+ score = pd.to_numeric(row.get("ff_score"), errors="coerce")
325
+ if not np.isfinite(score):
326
+ return 0.0, "Far-field score: N/A"
327
+ v = float(max(0.0, min(1.0, float(score) / 100.0)))
328
+ return v, f"Far-field score: {float(score):.2f} / 100 → {v:.3f}"
329
+ if axis == "Speed":
330
+ rtf = pd.to_numeric(row.get("eval_rtf"), errors="coerce")
331
+ if not np.isfinite(rtf) or float(rtf) < 0:
332
+ return 0.0, "RTF: N/A"
333
+ r = float(rtf)
334
+ v = float(max(0.0, min(1.0, r / (1.0 + r))))
335
+ return v, f"RTF: {r:.4f}× → speed {v:.3f} (RTF/(1+RTF))"
336
+ if axis == "Compactness":
337
+ pm = pd.to_numeric(row.get("num_params_m"), errors="coerce")
338
+ if not np.isfinite(pm) or float(pm) < 0:
339
+ return 0.0, "Parameters: N/A"
340
+ p = float(pm)
341
+ v = float(max(0.0, min(1.0, 1.0 / (1.0 + p / 1000.0))))
342
+ return v, f"Parameters: {p:.2f} M → compactness {v:.3f}"
343
+ return 0.0, f"{axis}: N/A"
344
+
345
+
346
+ _RADAR_ABSOLUTE_AXES: tuple[str, ...] = ("Dry Speech", "Score", "Speed", "Compactness")
347
+
348
+
349
  def plot_robustness_radar(
350
  df: pd.DataFrame,
351
  model_ids: Sequence[str],
352
+ title: str = "Robustness radar (absolute 0–1; outward is better)",
353
  ) -> go.Figure:
354
  """
355
+ Four-axis radar with **absolute** coordinates in [0, 1] (not relative to other models).
 
 
 
 
 
 
356
 
357
+ Dry Speech = 1 WER; Score = ff_score / 100; Speed = RTF / (1 + RTF);
358
+ Compactness = 1 / (1 + num_params_m / 1000). Missing values map to 0.
 
359
  """
360
  if df.empty:
361
  return _empty_fig("No leaderboard data yet.")
362
 
363
  d = df.copy().reset_index(drop=True)
364
+ labels = list(_RADAR_ABSOLUTE_AXES)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  labels_closed = labels + [labels[0]]
366
 
367
  selected = [m for m in (model_ids or []) if m in set(d["model_id"])]
 
375
  rows = d.index[d["model_id"] == mid].tolist()
376
  if not rows:
377
  continue
378
+ row = d.iloc[rows[0]]
379
  r_vals: list[float] = []
380
  raw_text: list[str] = []
381
+ for ax in labels:
382
+ v, tip = _radar_absolute_value(row, ax)
383
+ r_vals.append(v)
384
+ raw_text.append(tip)
 
 
 
 
 
 
 
 
385
 
386
  r_closed = r_vals + [r_vals[0]]
387
  raw_closed = raw_text + [raw_text[0]]
 
400
  opacity=1.0,
401
  hovertemplate=(
402
  f"<b>{short}</b><br>"
403
+ "%{theta}: %{r:.2f}<br>"
404
  "%{customdata}<extra></extra>"
405
  ),
406
  )
 
410
  polar=dict(
411
  radialaxis=dict(
412
  visible=True,
413
+ range=[0, 1],
414
  showticklabels=True,
415
  tickformat=".2f",
416
+ tickvals=[0, 0.25, 0.5, 0.75, 1.0],
417
  ),
418
  angularaxis=dict(direction="clockwise", rotation=90),
419
  ),
 
691
  "num_params_m": "Parameters (millions)",
692
  }.get(x_key, x_key)
693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  fig = go.Figure()
695
+ palette = qualitative.Alphabet * 3
696
+
697
+ for i, (_, row) in enumerate(d.iterrows()):
698
+ mid = str(row["model_id"])
699
+ short = mid.split("/")[-1][:28]
700
+ xv = pd.to_numeric(row[x_key], errors="coerce")
701
+ yv = pd.to_numeric(row[y_metric_key], errors="coerce")
702
+ if not (np.isfinite(xv) and np.isfinite(yv)):
703
  continue
704
+ color = palette[i % len(palette)]
705
  fig.add_trace(
706
  go.Scatter(
707
+ x=[float(xv)],
708
+ y=[float(yv)],
709
  mode="markers",
710
+ name=short,
711
+ legendgroup=mid,
 
712
  marker=dict(size=11, opacity=0.9, color=color, line=dict(width=0.6, color="white")),
713
  hovertemplate=(
714
+ f"<b>{mid}</b><br>{x_title}: %{{x:.4f}}<br>"
715
  f"{y_label}: %{{y:.4f}}<extra></extra>"
716
  ),
717
  )
718
  )
719
 
 
 
720
  ttl = title or f"Speed versus accuracy: {y_label}"
721
  fig.update_layout(
722
  title=dict(text=ttl, x=0.5, xanchor="center"),
 
724
  yaxis=dict(title=y_label + " (lower is better)", rangemode="tozero"),
725
  template=_TEMPLATE,
726
  height=_FIG_HEIGHT,
727
+ margin=dict(l=60, r=120, t=60, b=60),
 
728
  showlegend=True,
729
+ **_plotly_legend_config(),
730
  )
731
  return fig
732
 
app.py CHANGED
@@ -11,6 +11,7 @@ from constants import (
11
  INTRODUCTION_TEXT,
12
  LEADERBOARD_CSS,
13
  tab_label,
 
14
  )
15
  import analytics
16
  from family_registry import default_family_id
@@ -46,9 +47,9 @@ MODEL_COL = AutoEvalColumn.model.name # "Model"
46
  # Empty table at import; real data loaded on demo.load (avoids blocking Space "preparing").
47
  original_df = pd.DataFrame(columns=COLS)
48
 
49
- _QUEUE_STATUS_PLACEHOLDER = (
50
- "<div class='queue-status' style='font-size:0.9em;opacity:0.85'>"
51
- "<em>Loading queue status…</em></div>"
52
  )
53
 
54
  # ---------------------------------------------------------------------------
@@ -126,7 +127,28 @@ def _wer_cell(row: dict, key: str) -> str:
126
  return str(v).strip()
127
 
128
 
129
- def submit_model(model_id: str, submission_notes: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  """Validate and enqueue evaluation; a background worker runs approved jobs (up to 4 parallel Hub Jobs when remote mode is on).
131
 
132
  The backend always uses the "Auto" family (pipeline → Granite → universal → CTC),
@@ -156,9 +178,17 @@ def submit_model(model_id: str, submission_notes: str) -> str:
156
  if not on_hub:
157
  return styled_error(f"Model '{model_id}' {err_msg}")
158
 
159
- job_id, position, err, awaiting_mod = job_queue.enqueue(
160
- model_id, family_id, submission_notes=submission_notes or ""
161
- )
 
 
 
 
 
 
 
 
162
  if err == "remote_jobs_required":
163
  return styled_error(
164
  "Hub Jobs are required. Set Space secrets "
@@ -289,15 +319,7 @@ def _analytics_apply(models, line_keys, top_n, latency_x, latency_y):
289
  # Gradio App
290
  # ---------------------------------------------------------------------------
291
 
292
- _theme = gr.themes.Soft(
293
- primary_hue="indigo",
294
- secondary_hue="blue",
295
- neutral_hue="slate",
296
- ).set(
297
- button_primary_background_fill="*primary_500",
298
- button_primary_background_fill_hover="*primary_400",
299
- block_radius="12px",
300
- )
301
 
302
  with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
303
  gr.HTML(BANNER, elem_id="banner")
@@ -342,7 +364,7 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
342
  return original_df
343
 
344
  def _on_startup():
345
- """Load leaderboard + queue after UI is up (keeps Space prepare step fast)."""
346
  global original_df
347
  from init import invalidate_results_cache
348
 
@@ -352,20 +374,19 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
352
  table = filter_main_table("", toggleable_columns)
353
  except Exception as exc:
354
  table = original_df
355
- queue_html = (
356
- "<div class='queue-status'><p style='color:orange'>"
357
  f"<strong>Startup warning:</strong> {exc}</p></div>"
358
  )
359
- return table, queue_html
360
  try:
361
  job_queue.ensure_worker_started()
362
- queue_html = job_queue.status_html()
363
  except Exception as exc:
364
- queue_html = (
365
- "<div class='queue-status'><p style='color:orange'>"
366
- f"<strong>Queue unavailable:</strong> {exc}</p></div>"
367
- )
368
- return table, queue_html
369
 
370
  filter_inputs = [search_box, column_checkboxes]
371
  search_box.change(filter_main_table, inputs=filter_inputs, outputs=leaderboard_table)
@@ -396,30 +417,55 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
396
  )
397
  submit_btn = gr.Button("Evaluate", variant="primary", scale=1)
398
 
399
- submission_notes = gr.Textbox(
400
- label="Optional notes for moderators",
401
- placeholder="Repo link, paper, eval caveats, hardware notes…",
402
  lines=4,
403
- max_length=4000,
 
 
 
 
 
404
  )
405
  gr.Markdown(
406
- "**Custom evaluation code is not run** on this Space (security). "
407
- "Notes are stored with the submission for reviewers only. "
408
- "To propose a different metric or pipeline, open an issue or PR against the leaderboard repo."
 
 
 
 
 
 
 
 
 
409
  )
410
 
411
  status_output = gr.HTML()
412
- with gr.Row():
413
- gr.Markdown("### Queue status")
414
- queue_refresh_btn = gr.Button("Refresh", scale=0, min_width=100)
415
- queue_panel = gr.HTML(value=_QUEUE_STATUS_PLACEHOLDER, elem_classes="queue-status")
 
 
 
 
 
 
416
 
417
  submit_btn.click(
418
- fn=submit_model,
419
- inputs=[model_input, submission_notes],
420
- outputs=[status_output],
 
 
 
 
 
 
421
  )
422
- queue_refresh_btn.click(fn=job_queue.status_html, outputs=[queue_panel])
423
 
424
  with gr.TabItem(tab_label("moderate", "Moderate"), elem_id="moderate-tab", id=2):
425
  gr.Markdown("## Moderator access")
@@ -446,40 +492,56 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
446
  )
447
  mod_refresh = gr.Button("Refresh pending list")
448
  pending_table = gr.HTML(value=job_queue.moderation_locked_placeholder_html())
449
- gr.Markdown("### Recent job activity (status, errors, timestamps)")
450
- mod_recent_jobs = gr.HTML(value=job_queue.moderation_locked_placeholder_html())
451
  gr.Markdown(
452
- "**Retry / remove:** pick a job below. **Retry** re-queues **failed** jobs only. "
453
- "**Remove** drops a job from this list (not allowed while **running**)."
454
- )
455
- mod_action_job = gr.Dropdown(
456
- label="Job for retry or remove",
457
- choices=[("(Unlock required)", "")],
458
- value=None,
459
  )
460
- with gr.Row():
461
- retry_job_btn = gr.Button("Retry selected job", variant="secondary")
462
- remove_job_btn = gr.Button("Remove from list", variant="stop")
463
  job_pick = gr.Dropdown(
464
  label="Select a pending job (approve / reject)",
465
  choices=[("(Unlock required)", "")],
466
  value=None,
467
  )
 
 
 
 
468
  with gr.Row():
469
  approve_btn = gr.Button("Approve selected job", variant="primary")
470
  reject_btn = gr.Button("Reject selected job", variant="stop")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  mod_feedback = gr.HTML()
472
 
473
  _MOD_OUTS = [
474
  mod_feedback,
475
  pending_table,
476
  job_pick,
477
- mod_recent_jobs,
478
- mod_action_job,
479
  mod_progress,
480
  ]
481
 
482
- def _mod_refresh_data():
483
  try:
484
  return (
485
  job_queue.moderation_list_html(),
@@ -487,11 +549,6 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
487
  choices=job_queue.pending_job_dropdown_choices(),
488
  value=None,
489
  ),
490
- job_queue.recent_jobs_html(30, with_heading=False),
491
- gr.update(
492
- choices=job_queue.moderation_action_job_choices(),
493
- value=None,
494
- ),
495
  job_queue.progress_html(),
496
  )
497
  except Exception as e:
@@ -502,91 +559,150 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
502
  return (
503
  msg,
504
  gr.update(choices=[("(Queue unavailable)", "")], value=None),
505
- job_queue.recent_jobs_html(30, with_heading=False),
506
- gr.update(choices=[("(Queue unavailable)", "")], value=None),
507
  msg,
508
  )
509
 
510
- def _mod_refresh(secret: str, unlocked: bool):
 
 
 
 
 
 
 
 
511
  if not unlocked:
512
- return (
513
- job_queue.moderation_locked_placeholder_html(),
514
- gr.update(choices=[("(Unlock required)", "")], value=None),
515
- job_queue.moderation_locked_placeholder_html(),
516
- gr.update(choices=[("(Unlock required)", "")], value=None),
517
- job_queue.moderation_locked_placeholder_html(),
518
- )
519
  ok, msg = job_queue.verify_moderator_secret(secret)
520
  if not ok:
521
  return (
522
  f"<p style='color:red'>{msg}</p>",
523
  gr.update(choices=[("(Invalid secret)", "")], value=None),
524
- job_queue.moderation_locked_placeholder_html(),
525
- gr.update(choices=[("(Invalid secret)", "")], value=None),
526
  job_queue.moderation_locked_placeholder_html(),
527
  )
528
- return _mod_refresh_data()
529
 
530
- def _mod_bundle(note_html: str):
531
- data = _mod_refresh_data()
532
- return (note_html, *data)
533
 
534
- def _mod_run(fn, secret, jid, empty_msg: str, unlocked: bool):
535
- if not unlocked:
536
- return _mod_bundle("<p style='color:red'>Unlock moderator tools first.</p>")
537
- if not jid or not str(jid).strip():
538
- return _mod_bundle(f"<p style='color:red'>{empty_msg}</p>")
539
- ok, msg = fn(jid, secret)
540
  color = "green" if ok else "red"
541
- return _mod_bundle(f"<p style='color:{color}'>{msg}</p>")
542
 
543
- def _mod_unlock(secret: str):
544
  ok, msg = job_queue.verify_moderator_secret(secret)
545
  if not ok:
 
546
  return (
547
  False,
548
  gr.update(visible=False),
549
  f"<p style='color:red'>{msg}</p>",
550
- *(
551
- job_queue.moderation_locked_placeholder_html(),
552
- gr.update(choices=[("(Unlock required)", "")], value=None),
553
- job_queue.moderation_locked_placeholder_html(),
554
- gr.update(choices=[("(Unlock required)", "")], value=None),
555
- job_queue.moderation_locked_placeholder_html(),
556
- ),
557
  )
558
  try:
559
- data = _mod_refresh_data()
560
  except Exception as e:
561
- data = (
562
- f"<p style='color:orange'><strong>Queue unavailable:</strong> {e}</p>",
563
- gr.update(choices=[("(Queue unavailable)", "")], value=None),
564
- job_queue.recent_jobs_html(30, with_heading=False),
565
- gr.update(choices=[("(Queue unavailable)", "")], value=None),
566
- f"<p style='color:orange'><strong>Queue unavailable:</strong> {e}</p>",
567
- )
568
  return (
569
  True,
570
  gr.update(visible=True),
571
  "<p style='color:green'>Moderator tools unlocked for this session.</p>",
572
- *data,
 
 
 
573
  )
574
 
575
- def _mod_approve(secret, jid, unlocked):
576
- return _mod_run(job_queue.approve_job, secret, jid, "Select a job.", unlocked)
 
 
 
577
 
578
- def _mod_reject(secret, jid, unlocked):
579
- return _mod_run(job_queue.reject_job, secret, jid, "Select a job.", unlocked)
580
 
581
- def _mod_retry(secret, jid, unlocked):
582
- return _mod_run(
583
- job_queue.retry_failed_job, secret, jid, "Select a job in Retry / remove.", unlocked
584
- )
 
 
 
 
585
 
586
- def _mod_remove(secret, jid, unlocked):
587
- return _mod_run(
588
- job_queue.remove_job_entry, secret, jid, "Select a job in Retry / remove.", unlocked
 
 
 
 
 
 
 
589
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590
 
591
  def _mod_progress_gated(unlocked: bool):
592
  if not unlocked:
@@ -595,47 +711,42 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
595
 
596
  mod_unlock_btn.click(
597
  fn=_mod_unlock,
598
- inputs=[mod_secret],
599
  outputs=[
600
  mod_unlocked,
601
  mod_panel,
602
  mod_unlock_feedback,
603
  pending_table,
604
  job_pick,
605
- mod_recent_jobs,
606
- mod_action_job,
607
  mod_progress,
608
  ],
609
  )
610
  mod_refresh.click(
611
  fn=_mod_refresh,
612
- inputs=[mod_secret, mod_unlocked],
613
- outputs=[
614
- pending_table,
615
- job_pick,
616
- mod_recent_jobs,
617
- mod_action_job,
618
- mod_progress,
619
- ],
620
  )
621
  approve_btn.click(
622
  fn=_mod_approve,
623
- inputs=[mod_secret, job_pick, mod_unlocked],
624
  outputs=_MOD_OUTS,
625
  )
626
  reject_btn.click(
627
  fn=_mod_reject,
628
- inputs=[mod_secret, job_pick, mod_unlocked],
629
  outputs=_MOD_OUTS,
630
  )
631
- retry_job_btn.click(
632
- fn=_mod_retry,
633
- inputs=[mod_secret, mod_action_job, mod_unlocked],
634
- outputs=_MOD_OUTS,
635
- )
636
- remove_job_btn.click(
637
- fn=_mod_remove,
638
- inputs=[mod_secret, mod_action_job, mod_unlocked],
 
 
639
  outputs=_MOD_OUTS,
640
  )
641
 
@@ -647,10 +758,10 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
647
  "(weighted harmonic mean of 1/(1+WER); higher is better).\n\n"
648
  "**WER heatmap** shows WER by model and scenario for the top N models (sorted by score); lower WER "
649
  "corresponds to greener cells.\n\n"
650
- "**Speed versus accuracy** compares an inference or scale metric on the horizontal axis with "
651
- "WER on the vertical axis to illustrate accuracy and compute trade-offs.\n\n"
652
- "**Robustness radar** summarizes relative strength across scenarios plus speed (RTF) and model "
653
- "size; outward distance indicates stronger relative performance on each axis.\n\n"
654
  "**WER by scenario** compares raw WER across conditions for the selected top models."
655
  )
656
  with gr.Row():
@@ -740,12 +851,12 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
740
  # Periodic queue refresh (Timer added in Gradio 4.44). Avoid demo.load(..., every=); unsupported on some builds.
741
  if hasattr(gr, "Timer"):
742
  with gr.Row(visible=False):
743
- _queue_timer = gr.Timer(5)
744
  _progress_timer = gr.Timer(2)
745
- _queue_timer.tick(fn=job_queue.status_html, outputs=[queue_panel])
746
  _progress_timer.tick(fn=_mod_progress_gated, inputs=[mod_unlocked], outputs=[mod_progress])
747
 
748
- demo.load(fn=_on_startup, outputs=[leaderboard_table, queue_panel])
749
  demo.load(
750
  fn=_analytics_initial,
751
  outputs=[
 
11
  INTRODUCTION_TEXT,
12
  LEADERBOARD_CSS,
13
  tab_label,
14
+ treble_gradio_theme,
15
  )
16
  import analytics
17
  from family_registry import default_family_id
 
47
  # Empty table at import; real data loaded on demo.load (avoids blocking Space "preparing").
48
  original_df = pd.DataFrame(columns=COLS)
49
 
50
+ _NEXT_UP_PLACEHOLDER = (
51
+ "<div class='next-up-panel' style='font-size:0.9em;opacity:0.85'>"
52
+ "<em>Loading upcoming evaluations…</em></div>"
53
  )
54
 
55
  # ---------------------------------------------------------------------------
 
127
  return str(v).strip()
128
 
129
 
130
+ def _model_hub_page_link(model_id: str) -> str:
131
+ mid = (model_id or "").strip().replace(" ", "")
132
+ if not mid or "/" not in mid:
133
+ return (
134
+ "<p style='font-size:0.9em;opacity:0.85'>"
135
+ "Enter a model id (<code>author/name</code>) to open its Hub page.</p>"
136
+ )
137
+ url = f"https://huggingface.co/{mid}"
138
+ return (
139
+ f"<p style='font-size:0.9em'>"
140
+ f"<a href='{url}' target='_blank' rel='noopener noreferrer'>"
141
+ f"Open model page on the Hub</a> — request access if the repo is gated.</p>"
142
+ )
143
+
144
+
145
+ def submit_model(
146
+ model_id: str,
147
+ submission_notes: str,
148
+ extra_requirements: str,
149
+ custom_script: str,
150
+ is_gated: bool,
151
+ ) -> str:
152
  """Validate and enqueue evaluation; a background worker runs approved jobs (up to 4 parallel Hub Jobs when remote mode is on).
153
 
154
  The backend always uses the "Auto" family (pipeline → Granite → universal → CTC),
 
178
  if not on_hub:
179
  return styled_error(f"Model '{model_id}' {err_msg}")
180
 
181
+ try:
182
+ job_id, position, err, awaiting_mod = job_queue.enqueue(
183
+ model_id,
184
+ family_id,
185
+ submission_notes=submission_notes or "",
186
+ extra_requirements=extra_requirements or "",
187
+ custom_script=custom_script or "",
188
+ is_gated=bool(is_gated),
189
+ )
190
+ except Exception as exc:
191
+ return styled_warning(f"Could not enqueue submission: {exc}")
192
  if err == "remote_jobs_required":
193
  return styled_error(
194
  "Hub Jobs are required. Set Space secrets "
 
319
  # Gradio App
320
  # ---------------------------------------------------------------------------
321
 
322
+ _theme = treble_gradio_theme()
 
 
 
 
 
 
 
 
323
 
324
  with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
325
  gr.HTML(BANNER, elem_id="banner")
 
364
  return original_df
365
 
366
  def _on_startup():
367
+ """Load leaderboard + next-up list after UI is up (keeps Space prepare step fast)."""
368
  global original_df
369
  from init import invalidate_results_cache
370
 
 
374
  table = filter_main_table("", toggleable_columns)
375
  except Exception as exc:
376
  table = original_df
377
+ next_html = (
378
+ "<div class='next-up-panel'><p style='color:orange'>"
379
  f"<strong>Startup warning:</strong> {exc}</p></div>"
380
  )
381
+ return table, next_html
382
  try:
383
  job_queue.ensure_worker_started()
384
+ next_html = job_queue.next_up_html()
385
  except Exception as exc:
386
+ next_html = (
387
+ "<div class='next-up-panel'><p style='color:orange'>"
388
+ f"<strong>Queue unavailable:</strong> {exc}</p></motionmotionmotionmotiondiv>")
389
+ return table, next_html
 
390
 
391
  filter_inputs = [search_box, column_checkboxes]
392
  search_box.change(filter_main_table, inputs=filter_inputs, outputs=leaderboard_table)
 
417
  )
418
  submit_btn = gr.Button("Evaluate", variant="primary", scale=1)
419
 
420
+ requirements_input = gr.Textbox(
421
+ label="Optional extra Python requirements (one per line, requirements.txt format)",
422
+ placeholder="transformers @ git+https://github.com/huggingface/transformers.git\nsentencepiece",
423
  lines=4,
424
+ max_length=8000,
425
+ )
426
+ script_input = gr.Code(
427
+ label="Optional custom uv-run script (only run if a moderator approves it)",
428
+ language="python",
429
+ lines=12,
430
  )
431
  gr.Markdown(
432
+ "Custom scripts are **not** executed on this Space. They are stored with your submission "
433
+ "and run on a Hub Job **only** after a moderator explicitly approves using that script."
434
+ )
435
+ with gr.Row():
436
+ is_gated_input = gr.Checkbox(label="This is a gated repo", value=False)
437
+ gated_link = gr.HTML(value=_model_hub_page_link(""))
438
+ model_input.change(fn=_model_hub_page_link, inputs=[model_input], outputs=[gated_link])
439
+ notes_input = gr.Textbox(
440
+ label="Optional free-form notes for moderators",
441
+ placeholder="Repo link, paper, eval caveats, hardware notes…",
442
+ lines=2,
443
+ max_length=4000,
444
  )
445
 
446
  status_output = gr.HTML()
447
+ gr.Markdown("### Next models to evaluate")
448
+ next_up_panel = gr.HTML(value=_NEXT_UP_PLACEHOLDER, elem_classes="next-up-panel")
449
+
450
+ def _submit_and_refresh(model_id, notes, reqs, script, gated):
451
+ status = submit_model(model_id, notes, reqs, script, gated)
452
+ try:
453
+ nxt = job_queue.next_up_html()
454
+ except Exception as exc:
455
+ nxt = f"<p style='color:orange'>Could not load queue: {exc}</p>"
456
+ return status, nxt
457
 
458
  submit_btn.click(
459
+ fn=_submit_and_refresh,
460
+ inputs=[
461
+ model_input,
462
+ notes_input,
463
+ requirements_input,
464
+ script_input,
465
+ is_gated_input,
466
+ ],
467
+ outputs=[status_output, next_up_panel],
468
  )
 
469
 
470
  with gr.TabItem(tab_label("moderate", "Moderate"), elem_id="moderate-tab", id=2):
471
  gr.Markdown("## Moderator access")
 
492
  )
493
  mod_refresh = gr.Button("Refresh pending list")
494
  pending_table = gr.HTML(value=job_queue.moderation_locked_placeholder_html())
495
+ gr.Markdown("### Recent job activity")
 
496
  gr.Markdown(
497
+ "**Retry** re-queues **failed** jobs only. **Remove** drops a job from this list "
498
+ "(not allowed while **running**)."
 
 
 
 
 
499
  )
500
+ mod_jobs_tick = gr.State(0)
 
 
501
  job_pick = gr.Dropdown(
502
  label="Select a pending job (approve / reject)",
503
  choices=[("(Unlock required)", "")],
504
  value=None,
505
  )
506
+ mod_run_custom_script = gr.Checkbox(
507
+ label="Run submitter's custom script on approval (if provided)",
508
+ value=False,
509
+ )
510
  with gr.Row():
511
  approve_btn = gr.Button("Approve selected job", variant="primary")
512
  reject_btn = gr.Button("Reject selected job", variant="stop")
513
+ gr.Markdown("### Import result from bucket artifact")
514
+ gr.Markdown(
515
+ "If a Hub Job finished but the leaderboard CSV was not updated, paste the artifact "
516
+ "file name from <code>results/remote_artifacts/</code> (e.g. <code>a1b2c3d4.json</code>) "
517
+ "to merge WER/RTF into the CSV. The far-field score is computed automatically."
518
+ )
519
+ mod_artifact_ref = gr.Textbox(
520
+ label="Artifact JSON file name or bucket path",
521
+ placeholder="e.g. a1b2c3d4.json or results/remote_artifacts/a1b2c3d4.json",
522
+ )
523
+ mod_replace_existing = gr.Checkbox(
524
+ label="Replace existing leaderboard row for this model",
525
+ value=False,
526
+ )
527
+ mod_import_notes = gr.Textbox(
528
+ label="Optional submission notes override",
529
+ placeholder="Leave empty to use notes from the matched queue job, if any",
530
+ lines=1,
531
+ max_length=4000,
532
+ )
533
+ import_artifact_btn = gr.Button("Import artifact to CSV", variant="secondary")
534
  mod_feedback = gr.HTML()
535
 
536
  _MOD_OUTS = [
537
  mod_feedback,
538
  pending_table,
539
  job_pick,
540
+ mod_jobs_tick,
 
541
  mod_progress,
542
  ]
543
 
544
+ def _mod_refresh_core():
545
  try:
546
  return (
547
  job_queue.moderation_list_html(),
 
549
  choices=job_queue.pending_job_dropdown_choices(),
550
  value=None,
551
  ),
 
 
 
 
 
552
  job_queue.progress_html(),
553
  )
554
  except Exception as e:
 
559
  return (
560
  msg,
561
  gr.update(choices=[("(Queue unavailable)", "")], value=None),
 
 
562
  msg,
563
  )
564
 
565
+ def _mod_refresh_data(tick: int):
566
+ pending, pick, progress = _mod_refresh_core()
567
+ return pending, pick, int(tick or 0) + 1, progress
568
+
569
+ def _mod_locked_tuple(tick: int):
570
+ ph = job_queue.moderation_locked_placeholder_html()
571
+ return ph, gr.update(choices=[("(Unlock required)", "")], value=None), int(tick or 0), ph
572
+
573
+ def _mod_refresh(secret: str, unlocked: bool, tick: int):
574
  if not unlocked:
575
+ return _mod_locked_tuple(tick)
 
 
 
 
 
 
576
  ok, msg = job_queue.verify_moderator_secret(secret)
577
  if not ok:
578
  return (
579
  f"<p style='color:red'>{msg}</p>",
580
  gr.update(choices=[("(Invalid secret)", "")], value=None),
581
+ int(tick or 0),
 
582
  job_queue.moderation_locked_placeholder_html(),
583
  )
584
+ return _mod_refresh_data(tick)
585
 
586
+ def _mod_bundle(note_html: str, tick: int):
587
+ pending, pick, new_tick, progress = _mod_refresh_data(tick)
588
+ return note_html, pending, pick, new_tick, progress
589
 
590
+ def _mod_row_action(secret: str, jid: str, tick: int, action_fn):
591
+ ok, msg = action_fn(jid, secret)
 
 
 
 
592
  color = "green" if ok else "red"
593
+ return _mod_bundle(f"<p style='color:{color}'>{msg}</p>", tick)
594
 
595
+ def _mod_unlock(secret: str, tick: int):
596
  ok, msg = job_queue.verify_moderator_secret(secret)
597
  if not ok:
598
+ ph = job_queue.moderation_locked_placeholder_html()
599
  return (
600
  False,
601
  gr.update(visible=False),
602
  f"<p style='color:red'>{msg}</p>",
603
+ ph,
604
+ gr.update(choices=[("(Unlock required)", "")], value=None),
605
+ int(tick or 0),
606
+ ph,
 
 
 
607
  )
608
  try:
609
+ pending, pick, new_tick, progress = _mod_refresh_data(tick)
610
  except Exception as e:
611
+ pending = f"<p style='color:orange'><strong>Queue unavailable:</strong> {e}</p>"
612
+ pick = gr.update(choices=[("(Queue unavailable)", "")], value=None)
613
+ new_tick = int(tick or 0) + 1
614
+ progress = pending
 
 
 
615
  return (
616
  True,
617
  gr.update(visible=True),
618
  "<p style='color:green'>Moderator tools unlocked for this session.</p>",
619
+ pending,
620
+ pick,
621
+ new_tick,
622
+ progress,
623
  )
624
 
625
+ def _mod_approve(secret, jid, unlocked, run_custom, tick):
626
+ if not unlocked:
627
+ return _mod_bundle("<p style='color:red'>Unlock moderator tools first.</p>", tick)
628
+ if not jid or not str(jid).strip():
629
+ return _mod_bundle("<p style='color:red'>Select a job.</p>", tick)
630
 
631
+ def _approve(j, s):
632
+ return job_queue.approve_job(j, s, run_custom_script=bool(run_custom))
633
 
634
+ return _mod_row_action(secret, str(jid).strip(), tick, _approve)
635
+
636
+ def _mod_reject(secret, jid, unlocked, tick):
637
+ if not unlocked:
638
+ return _mod_bundle("<p style='color:red'>Unlock moderator tools first.</p>", tick)
639
+ if not jid or not str(jid).strip():
640
+ return _mod_bundle("<p style='color:red'>Select a job.</p>", tick)
641
+ return _mod_row_action(secret, str(jid).strip(), tick, job_queue.reject_job)
642
 
643
+ def _mod_import_artifact(
644
+ secret, artifact_ref, replace_existing, import_notes, unlocked, tick
645
+ ):
646
+ if not unlocked:
647
+ return _mod_bundle("<p style='color:red'>Unlock moderator tools first.</p>", tick)
648
+ ok, msg = job_queue.import_artifact_to_leaderboard(
649
+ artifact_ref,
650
+ secret,
651
+ replace_existing=bool(replace_existing),
652
+ submission_notes=import_notes or "",
653
  )
654
+ color = "green" if ok else "red"
655
+ return _mod_bundle(f"<p style='color:{color}'>{msg}</p>", tick)
656
+
657
+ @gr.render(inputs=[mod_unlocked, mod_secret, mod_jobs_tick])
658
+ def _render_job_activity_rows(unlocked: bool, secret: str, tick: int):
659
+ if not unlocked:
660
+ gr.Markdown("*Unlock moderator tools to see job activity.*")
661
+ return
662
+ try:
663
+ rows = job_queue.recent_jobs_for_render(30)
664
+ except Exception as e:
665
+ gr.Markdown(f"*Queue unavailable: {e}*")
666
+ return
667
+ if not rows:
668
+ gr.Markdown("*No job history yet.*")
669
+ return
670
+ for row in rows:
671
+ jid = row["id"]
672
+ hub = row.get("hub_link_html") or ""
673
+ hub_bit = f" · {hub}" if hub else ""
674
+ script_bit = " · custom script" if row.get("has_custom_script") else ""
675
+ summary = (
676
+ f"**{row['model_id']}** — `{row['status']}` "
677
+ f"(job `{jid}`, {row['updated_at']}){script_bit}{hub_bit}"
678
+ )
679
+ if row.get("error"):
680
+ summary += f"\n\n`{row['error']}`"
681
+ with gr.Row():
682
+ gr.Markdown(summary, scale=4)
683
+ retry_b = gr.Button(
684
+ "Retry",
685
+ variant="secondary",
686
+ scale=0,
687
+ min_width=90,
688
+ interactive=bool(row.get("can_retry")),
689
+ )
690
+ remove_b = gr.Button(
691
+ "Remove",
692
+ variant="stop",
693
+ scale=0,
694
+ min_width=90,
695
+ interactive=bool(row.get("can_remove")),
696
+ )
697
+
698
+ def _retry_fn(s=secret, j=jid, t=tick):
699
+ return _mod_row_action(s, j, t, job_queue.retry_failed_job)
700
+
701
+ def _remove_fn(s=secret, j=jid, t=tick):
702
+ return _mod_row_action(s, j, t, job_queue.remove_job_entry)
703
+
704
+ retry_b.click(fn=_retry_fn, outputs=_MOD_OUTS)
705
+ remove_b.click(fn=_remove_fn, outputs=_MOD_OUTS)
706
 
707
  def _mod_progress_gated(unlocked: bool):
708
  if not unlocked:
 
711
 
712
  mod_unlock_btn.click(
713
  fn=_mod_unlock,
714
+ inputs=[mod_secret, mod_jobs_tick],
715
  outputs=[
716
  mod_unlocked,
717
  mod_panel,
718
  mod_unlock_feedback,
719
  pending_table,
720
  job_pick,
721
+ mod_jobs_tick,
 
722
  mod_progress,
723
  ],
724
  )
725
  mod_refresh.click(
726
  fn=_mod_refresh,
727
+ inputs=[mod_secret, mod_unlocked, mod_jobs_tick],
728
+ outputs=[pending_table, job_pick, mod_jobs_tick, mod_progress],
 
 
 
 
 
 
729
  )
730
  approve_btn.click(
731
  fn=_mod_approve,
732
+ inputs=[mod_secret, job_pick, mod_unlocked, mod_run_custom_script, mod_jobs_tick],
733
  outputs=_MOD_OUTS,
734
  )
735
  reject_btn.click(
736
  fn=_mod_reject,
737
+ inputs=[mod_secret, job_pick, mod_unlocked, mod_jobs_tick],
738
  outputs=_MOD_OUTS,
739
  )
740
+ import_artifact_btn.click(
741
+ fn=_mod_import_artifact,
742
+ inputs=[
743
+ mod_secret,
744
+ mod_artifact_ref,
745
+ mod_replace_existing,
746
+ mod_import_notes,
747
+ mod_unlocked,
748
+ mod_jobs_tick,
749
+ ],
750
  outputs=_MOD_OUTS,
751
  )
752
 
 
758
  "(weighted harmonic mean of 1/(1+WER); higher is better).\n\n"
759
  "**WER heatmap** shows WER by model and scenario for the top N models (sorted by score); lower WER "
760
  "corresponds to greener cells.\n\n"
761
+ "**Speed versus accuracy** colors each model by name (toggle in the legend) on an inference or "
762
+ "scale metric versus WER.\n\n"
763
+ "**Robustness radar** uses absolute axes from 0 to 1: **Dry Speech** (1 WER), **Score** "
764
+ "(far-field / 100), **Speed** (RTF / (1 + RTF)), and **Compactness** (1 / (1 + params_M / 1000)).\n\n"
765
  "**WER by scenario** compares raw WER across conditions for the selected top models."
766
  )
767
  with gr.Row():
 
851
  # Periodic queue refresh (Timer added in Gradio 4.44). Avoid demo.load(..., every=); unsupported on some builds.
852
  if hasattr(gr, "Timer"):
853
  with gr.Row(visible=False):
854
+ _next_up_timer = gr.Timer(15)
855
  _progress_timer = gr.Timer(2)
856
+ _next_up_timer.tick(fn=job_queue.next_up_html, outputs=[next_up_panel])
857
  _progress_timer.tick(fn=_mod_progress_gated, inputs=[mod_unlocked], outputs=[mod_progress])
858
 
859
+ demo.load(fn=_on_startup, outputs=[leaderboard_table, next_up_panel])
860
  demo.load(
861
  fn=_analytics_initial,
862
  outputs=[
backends/auto.py CHANGED
@@ -108,8 +108,16 @@ def build_transcriber(
108
  except Exception as e:
109
  _record_error("universal", e)
110
 
 
111
  try:
112
- return transformers_ctc.build_transcriber(model_id, device_str)
 
 
 
 
 
 
 
113
  except Exception as e:
114
  _record_error("ctc", e)
115
 
 
108
  except Exception as e:
109
  _record_error("universal", e)
110
 
111
+ # CTC only applies to wav2vec/hubert-style checkpoints; skip when config says seq2seq.
112
  try:
113
+ from .family_resolve import infer_model_type
114
+
115
+ mt = infer_model_type(model_id)
116
+ _ctc_skip_types = frozenset(
117
+ {"cohere_asr", "whisper", "granite_speech", "speech_to_text", "moonshine_streaming"}
118
+ )
119
+ if mt not in _ctc_skip_types:
120
+ return transformers_ctc.build_transcriber(model_id, device_str)
121
  except Exception as e:
122
  _record_error("ctc", e)
123
 
backends/family_resolve.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Resolve ``family_id='auto'`` using Hub ``config.json`` ``model_type`` when possible.
3
+
4
+ Avoids running the full auto cascade (pipeline → universal → CTC) for models that
5
+ need a specific loader (e.g. Cohere ASR is seq2seq remote code, not CTC).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+
13
+ # model_type (config.json) -> backend family id
14
+ _MODEL_TYPE_TO_FAMILY: dict[str, str] = {
15
+ "granite_speech": "granite_speech",
16
+ "cohere_asr": "universal",
17
+ }
18
+
19
+ # Extra uv/pip packages commonly required by remote-code checkpoints (not always declared on Hub).
20
+ _MODEL_TYPE_EXTRA_DEPS: dict[str, list[str]] = {
21
+ "cohere_asr": ["sentencepiece"],
22
+ }
23
+
24
+
25
+ def infer_model_type(model_id: str) -> str:
26
+ """Read ``model_type`` from the Hub ``config.json`` without loading weights."""
27
+ try:
28
+ from huggingface_hub import hf_hub_download
29
+
30
+ token = os.environ.get("HF_TOKEN") or os.environ.get("ds_token")
31
+ path = hf_hub_download(
32
+ repo_id=model_id,
33
+ filename="config.json",
34
+ token=token,
35
+ )
36
+ with open(path, encoding="utf-8") as f:
37
+ cfg = json.load(f)
38
+ return str(cfg.get("model_type", "") or "")
39
+ except Exception:
40
+ return ""
41
+
42
+
43
+ def extra_deps_for_model(model_id: str) -> list[str]:
44
+ """Optional packages implied by ``model_type`` (for uv job dependency lists)."""
45
+ mt = infer_model_type(model_id)
46
+ return list(_MODEL_TYPE_EXTRA_DEPS.get(mt, []))
47
+
48
+
49
+ def resolve_auto_family(model_id: str, family_id: str) -> str:
50
+ """
51
+ If ``family_id`` is ``auto``, map known ``model_type`` values to a concrete backend.
52
+ Otherwise normalize aliases and return unchanged.
53
+ """
54
+ from family_registry import _FAMILY_ALIASES
55
+
56
+ fid = (family_id or "auto").strip().lower()
57
+ fid = _FAMILY_ALIASES.get(fid, fid)
58
+ if fid != "auto":
59
+ return fid
60
+ mt = infer_model_type(model_id)
61
+ return _MODEL_TYPE_TO_FAMILY.get(mt, "auto")
backends/registry.py CHANGED
@@ -43,7 +43,9 @@ def build_transcriber(
43
  universal,
44
  )
45
 
46
- fid = _FAMILY_ALIASES.get(family_id, family_id)
 
 
47
  if fid == "auto":
48
  return auto.build_transcriber(model_id, device_str, device_int)
49
  if fid == "transformers_pipeline":
 
43
  universal,
44
  )
45
 
46
+ from .family_resolve import resolve_auto_family
47
+
48
+ fid = resolve_auto_family(model_id, _FAMILY_ALIASES.get(family_id, family_id))
49
  if fid == "auto":
50
  return auto.build_transcriber(model_id, device_str, device_int)
51
  if fid == "transformers_pipeline":
constants.py CHANGED
@@ -197,6 +197,71 @@ AUDIO_CONDITIONS = {
197
  "reverberant": {"folder": "wavs", "label": "WER Reverberant"},
198
  }
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  LEADERBOARD_CSS = """
201
  /* Hide Gradio footer logo */
202
  footer { display: none !important; }
@@ -205,7 +270,7 @@ footer { display: none !important; }
205
  .ffasr-banner {
206
  text-align: center;
207
  padding: 1.4rem 0 0.8rem 0;
208
- border-bottom: 1px solid var(--border-color-primary, rgba(120, 120, 120, 0.18));
209
  margin-bottom: 0.6rem;
210
  }
211
  .ffasr-banner h1 {
@@ -214,7 +279,7 @@ footer { display: none !important; }
214
  margin: 0;
215
  font-weight: 800;
216
  letter-spacing: -0.02em;
217
- background: linear-gradient(90deg, #4c8bf5 0%, #7c3aed 55%, #ec4899 100%);
218
  -webkit-background-clip: text;
219
  background-clip: text;
220
  -webkit-text-fill-color: transparent;
@@ -224,6 +289,7 @@ footer { display: none !important; }
224
  margin-top: 0.35rem;
225
  font-size: 1rem;
226
  opacity: 0.72;
 
227
  }
228
  .ffasr-badges {
229
  margin-top: 0.7rem;
@@ -236,9 +302,9 @@ footer { display: none !important; }
236
  font-size: 0.78rem;
237
  padding: 0.18rem 0.65rem;
238
  border-radius: 999px;
239
- background: rgba(76, 139, 245, 0.12);
240
- color: #4c8bf5;
241
- border: 1px solid rgba(76, 139, 245, 0.30);
242
  font-weight: 500;
243
  letter-spacing: 0.01em;
244
  }
@@ -251,8 +317,9 @@ footer { display: none !important; }
251
  font-weight: 500 !important;
252
  }
253
  .tab-buttons button.selected {
254
- border-bottom: 2px solid #4c8bf5 !important;
255
- background: var(--block-background-fill, #fafbfd) !important;
 
256
  }
257
 
258
  /* ---- Leaderboard table ---- */
@@ -274,18 +341,31 @@ footer { display: none !important; }
274
  table-layout: auto !important;
275
  }
276
  #leaderboard-table tbody tr:hover td {
277
- background: rgba(76, 139, 245, 0.06);
278
  }
279
 
280
  /* ---- Card-like panels ---- */
281
  .queue-status,
 
282
  .ffasr-card {
283
  padding: 0.8rem 1rem;
284
  border-radius: 12px;
285
- background: var(--block-background-fill, #fafbfd);
286
- border: 1px solid var(--border-color-primary, rgba(120, 120, 120, 0.15));
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  }
288
- .queue-status p { margin: 0.15rem 0; }
289
 
290
  /* ---- Sub-status footer line ---- */
291
  .ffasr-footnote {
@@ -293,10 +373,17 @@ footer { display: none !important; }
293
  font-size: 0.82rem;
294
  opacity: 0.65;
295
  margin-top: 0.8rem;
 
296
  }
297
 
298
  /* ---- Primary button emphasis ---- */
299
  button.primary, .primary button {
300
  font-weight: 600 !important;
301
  }
302
- """
 
 
 
 
 
 
 
197
  "reverberant": {"folder": "wavs", "label": "WER Reverberant"},
198
  }
199
 
200
+ # Treble brand palette (dark UI — teal accent instead of purple/indigo)
201
+ TREBLE_TEAL = "#38BFA1"
202
+ TREBLE_TEAL_RGB = "56, 191, 161"
203
+ TREBLE_MINT = "#3DFFA3"
204
+ TREBLE_CYAN = "#2D9BF0"
205
+ TREBLE_BG = "#121212"
206
+ TREBLE_SURFACE = "#1A1A1E"
207
+ TREBLE_BORDER = "rgba(160, 160, 160, 0.18)"
208
+ TREBLE_TEXT_MUTED = "#A0A0A0"
209
+
210
+
211
+ def treble_gradio_theme():
212
+ """Dark Gradio theme with Treble teal primary (replaces indigo/purple)."""
213
+ import gradio as gr
214
+
215
+ treble_primary = gr.themes.colors.Color(
216
+ name="treble",
217
+ c50="#e8faf6",
218
+ c100="#c5f0e8",
219
+ c200="#9ae4d4",
220
+ c300="#6fd7c0",
221
+ c400="#52cdb0",
222
+ c500=TREBLE_TEAL,
223
+ c600="#2da88a",
224
+ c700="#238a72",
225
+ c800="#1a6c5a",
226
+ c900="#124e43",
227
+ c950="#0a3029",
228
+ )
229
+ return (
230
+ gr.themes.Base(
231
+ primary_hue=treble_primary,
232
+ secondary_hue="cyan",
233
+ neutral_hue="gray",
234
+ )
235
+ .set(
236
+ body_background_fill=TREBLE_BG,
237
+ body_background_fill_dark=TREBLE_BG,
238
+ block_background_fill=TREBLE_SURFACE,
239
+ block_background_fill_dark=TREBLE_SURFACE,
240
+ block_border_color=f"rgba({TREBLE_TEAL_RGB}, 0.14)",
241
+ block_border_color_dark=f"rgba({TREBLE_TEAL_RGB}, 0.14)",
242
+ border_color_primary=f"rgba({TREBLE_TEAL_RGB}, 0.22)",
243
+ border_color_primary_dark=f"rgba({TREBLE_TEAL_RGB}, 0.22)",
244
+ color_accent=TREBLE_TEAL,
245
+ color_accent_soft=f"rgba({TREBLE_TEAL_RGB}, 0.14)",
246
+ link_text_color=TREBLE_TEAL,
247
+ link_text_color_hover=TREBLE_MINT,
248
+ link_text_color_active=TREBLE_CYAN,
249
+ link_text_color_dark=TREBLE_TEAL,
250
+ link_text_color_hover_dark=TREBLE_MINT,
251
+ button_primary_background_fill="*primary_500",
252
+ button_primary_background_fill_hover="*primary_400",
253
+ button_primary_text_color="#0d1412",
254
+ button_primary_text_color_hover="#0d1412",
255
+ button_large_radius="999px",
256
+ button_medium_radius="999px",
257
+ button_small_radius="999px",
258
+ block_radius="12px",
259
+ input_background_fill="#141418",
260
+ input_background_fill_dark="#141418",
261
+ )
262
+ )
263
+
264
+
265
  LEADERBOARD_CSS = """
266
  /* Hide Gradio footer logo */
267
  footer { display: none !important; }
 
270
  .ffasr-banner {
271
  text-align: center;
272
  padding: 1.4rem 0 0.8rem 0;
273
+ border-bottom: 1px solid var(--border-color-primary, rgba(160, 160, 160, 0.18));
274
  margin-bottom: 0.6rem;
275
  }
276
  .ffasr-banner h1 {
 
279
  margin: 0;
280
  font-weight: 800;
281
  letter-spacing: -0.02em;
282
+ background: linear-gradient(90deg, __TREBLE_TEAL__ 0%, __TREBLE_CYAN__ 52%, __TREBLE_MINT__ 100%);
283
  -webkit-background-clip: text;
284
  background-clip: text;
285
  -webkit-text-fill-color: transparent;
 
289
  margin-top: 0.35rem;
290
  font-size: 1rem;
291
  opacity: 0.72;
292
+ color: __TREBLE_TEXT_MUTED__;
293
  }
294
  .ffasr-badges {
295
  margin-top: 0.7rem;
 
302
  font-size: 0.78rem;
303
  padding: 0.18rem 0.65rem;
304
  border-radius: 999px;
305
+ background: rgba(__TREBLE_TEAL_RGB__, 0.12);
306
+ color: __TREBLE_TEAL__;
307
+ border: 1px solid rgba(__TREBLE_TEAL_RGB__, 0.32);
308
  font-weight: 500;
309
  letter-spacing: 0.01em;
310
  }
 
317
  font-weight: 500 !important;
318
  }
319
  .tab-buttons button.selected {
320
+ border-bottom: 2px solid __TREBLE_TEAL__ !important;
321
+ background: var(--block-background-fill, __TREBLE_SURFACE__) !important;
322
+ color: __TREBLE_TEAL__ !important;
323
  }
324
 
325
  /* ---- Leaderboard table ---- */
 
341
  table-layout: auto !important;
342
  }
343
  #leaderboard-table tbody tr:hover td {
344
+ background: rgba(__TREBLE_TEAL_RGB__, 0.08);
345
  }
346
 
347
  /* ---- Card-like panels ---- */
348
  .queue-status,
349
+ .next-up-panel,
350
  .ffasr-card {
351
  padding: 0.8rem 1rem;
352
  border-radius: 12px;
353
+ background: var(--block-background-fill, __TREBLE_SURFACE__);
354
+ border: 1px solid var(--border-color-primary, rgba(__TREBLE_TEAL_RGB__, 0.15));
355
+ }
356
+ .queue-status p,
357
+ .next-up-panel p { margin: 0.15rem 0; }
358
+
359
+ /* ---- Links (markdown, footnote) ---- */
360
+ .markdown-text a,
361
+ .ffasr-footnote a {
362
+ color: __TREBLE_TEAL__;
363
+ text-decoration-color: rgba(__TREBLE_TEAL_RGB__, 0.45);
364
+ }
365
+ .markdown-text a:hover,
366
+ .ffasr-footnote a:hover {
367
+ color: __TREBLE_MINT__;
368
  }
 
369
 
370
  /* ---- Sub-status footer line ---- */
371
  .ffasr-footnote {
 
373
  font-size: 0.82rem;
374
  opacity: 0.65;
375
  margin-top: 0.8rem;
376
+ color: __TREBLE_TEXT_MUTED__;
377
  }
378
 
379
  /* ---- Primary button emphasis ---- */
380
  button.primary, .primary button {
381
  font-weight: 600 !important;
382
  }
383
+ """.replace("__TREBLE_TEAL__", TREBLE_TEAL).replace(
384
+ "__TREBLE_CYAN__", TREBLE_CYAN
385
+ ).replace("__TREBLE_MINT__", TREBLE_MINT).replace(
386
+ "__TREBLE_TEAL_RGB__", TREBLE_TEAL_RGB
387
+ ).replace("__TREBLE_SURFACE__", TREBLE_SURFACE).replace(
388
+ "__TREBLE_TEXT_MUTED__", TREBLE_TEXT_MUTED
389
+ )
evaluation/remote_artifact.py CHANGED
@@ -13,6 +13,24 @@ def default_remote_artifact_path(job_id: str) -> str:
13
  return f"results/remote_artifacts/{jid}.json"
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def build_artifact(
17
  *,
18
  model_id: str,
 
13
  return f"results/remote_artifacts/{jid}.json"
14
 
15
 
16
+ def normalize_artifact_bucket_path(artifact_ref: str) -> str:
17
+ """
18
+ Resolve a moderator-entered artifact reference to a bucket path.
19
+
20
+ Accepts ``jobid.json``, ``jobid``, or ``results/remote_artifacts/jobid.json``.
21
+ """
22
+ ref = (artifact_ref or "").strip().replace("\\", "/").lstrip("/")
23
+ if not ref:
24
+ raise ValueError("Enter a JSON artifact file name or bucket path.")
25
+ if ".." in ref.split("/"):
26
+ raise ValueError("Invalid artifact path.")
27
+ if "/" not in ref:
28
+ ref = f"results/remote_artifacts/{ref}"
29
+ if not ref.lower().endswith(".json"):
30
+ ref = f"{ref}.json"
31
+ return ref
32
+
33
+
34
  def build_artifact(
35
  *,
36
  model_id: str,
family_registry.py CHANGED
@@ -7,7 +7,8 @@ from __future__ import annotations
7
  FAMILY_META: dict[str, tuple[str, str]] = {
8
  "auto": (
9
  "Auto",
10
- "SpeechBrain (if detected) (Granite chat if id matches) pipelineuniversalCTCSpeechBrain fallback.",
 
11
  ),
12
  "transformers_pipeline": (
13
  "Transformers pipeline",
@@ -15,7 +16,9 @@ FAMILY_META: dict[str, tuple[str, str]] = {
15
  ),
16
  "universal": (
17
  "Universal speech-seq2seq",
18
- "`AutoModelForSpeechSeq2Seq` (or `AutoModel` + `trust_remote_code`) + `generate()` + `decode()`.",
 
 
19
  ),
20
  "granite_speech": (
21
  "IBM Granite speech (chat prompt)",
 
7
  FAMILY_META: dict[str, tuple[str, str]] = {
8
  "auto": (
9
  "Auto",
10
+ "Reads Hub config.model_type when possible, then: SpeechBrainGraniteNeMoQwen → "
11
+ "pipeline → universal (trust_remote_code) → CTC only if applicable.",
12
  ),
13
  "transformers_pipeline": (
14
  "Transformers pipeline",
 
16
  ),
17
  "universal": (
18
  "Universal speech-seq2seq",
19
+ "`AutoModelForSpeechSeq2Seq` (or `AutoModel` + `trust_remote_code`) + `generate()` + `decode()`. "
20
+ "Use for custom Hub code (e.g. Cohere ASR). Extra pip deps: set Space secret "
21
+ "`FFASR_REMOTE_EXTRA_DEPS` (comma-separated) for remote jobs.",
22
  ),
23
  "granite_speech": (
24
  "IBM Granite speech (chat prompt)",
job_queue.py CHANGED
@@ -62,6 +62,10 @@ class Job:
62
  error: str | None = None
63
  result: dict[str, Any] | None = None
64
  submission_notes: str = ""
 
 
 
 
65
  # Progress (not persisted; only meaningful while running)
66
  progress_done: int = 0
67
  progress_total: int = 0
@@ -85,6 +89,9 @@ _MAX_JOBS_TRACKED = 400
85
  _MAX_QUEUE_BACKLOG = 32
86
  _MAX_PENDING_MODERATION = 64
87
  _DEFAULT_REMOTE_MAX_CONCURRENT = 4
 
 
 
88
 
89
  _JOBS_CSV_FIELDS = [
90
  "job_id",
@@ -95,11 +102,70 @@ _JOBS_CSV_FIELDS = [
95
  "updated_at",
96
  "error",
97
  "submission_notes",
 
 
 
 
98
  "hf_remote_job_id",
99
  "remote_artifact_path",
100
  ]
101
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  def _now_iso() -> str:
104
  return datetime.now(timezone.utc).isoformat()
105
 
@@ -167,6 +233,10 @@ def _persist_jobs() -> None:
167
  "updated_at": j.updated_at or j.created_at,
168
  "error": (j.error or "").replace("\n", " ")[:2000],
169
  "submission_notes": (j.submission_notes or "").replace("\n", " ")[:4000],
 
 
 
 
170
  "hf_remote_job_id": (j.hf_remote_job_id or "").strip(),
171
  "remote_artifact_path": (j.remote_artifact_path or "").strip(),
172
  }
@@ -230,6 +300,10 @@ def _load_persisted_jobs_once() -> None:
230
  continue
231
  err = (row.get("error") or "").strip() or None
232
  notes = (row.get("submission_notes") or "").strip()
 
 
 
 
233
  created = (row.get("created_at") or _now_iso()).strip()
234
  updated = (row.get("updated_at") or created).strip()
235
  mid = (row.get("model_id") or "").strip()
@@ -258,6 +332,10 @@ def _load_persisted_jobs_once() -> None:
258
  updated_at=updated,
259
  error=err,
260
  submission_notes=notes,
 
 
 
 
261
  hf_remote_job_id=hf_rid,
262
  remote_artifact_path=art_path,
263
  )
@@ -400,6 +478,150 @@ def _merge_eval_result_to_leaderboard(
400
  save_raw_results(rows)
401
 
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  def _remote_collect_result(job_id: str, hf_id: str, jobs_token: str) -> dict:
404
  from evaluation.remote_artifact import extract_result_or_raise
405
  from storage import download_bucket_file
@@ -464,12 +686,21 @@ def _remote_dispatch_job(job_id: str, mid: str, fid: str, jobs_token: str) -> st
464
  _touch(j3)
465
  _persist_jobs()
466
 
 
 
 
 
 
 
467
  info = remote_jobs.submit_eval_job(
468
  model_id=mid,
469
  family_id=fid,
470
  space_job_id=job_id,
471
  artifact_path=artifact_path,
472
  token=jobs_token,
 
 
 
473
  )
474
  hf_id = info.id
475
  with _jobs_lock:
@@ -663,6 +894,10 @@ def enqueue(
663
  model_id: str,
664
  family_id: str,
665
  submission_notes: str = "",
 
 
 
 
666
  ) -> tuple[str, int, str | None, bool]:
667
  """
668
  Enqueue an evaluation job.
@@ -699,6 +934,12 @@ def enqueue(
699
  job_id = str(uuid.uuid4())[:8]
700
  created = _now_iso()
701
  notes_clean = (submission_notes or "").strip()[:4000]
 
 
 
 
 
 
702
 
703
  awaiting = moderation_active()
704
  status = JobStatus.pending_moderation if awaiting else JobStatus.queued
@@ -711,6 +952,10 @@ def enqueue(
711
  created_at=created,
712
  updated_at=created,
713
  submission_notes=notes_clean,
 
 
 
 
714
  )
715
 
716
  with _jobs_lock:
@@ -732,7 +977,9 @@ def enqueue(
732
  return job_id, position, None, False
733
 
734
 
735
- def approve_job(job_id: str, secret: str) -> tuple[bool, str]:
 
 
736
  """Move a pending job into the execution queue (moderator only)."""
737
  if not moderation_active():
738
  return False, "Moderation is not active on this Space."
@@ -750,15 +997,24 @@ def approve_job(job_id: str, secret: str) -> tuple[bool, str]:
750
  return False, "Execution queue is full; try again in a moment."
751
 
752
  job.status = JobStatus.queued
 
 
 
 
753
  _touch(job)
754
 
755
  _work_queue.put(job_id)
756
  _persist_jobs()
757
  _ensure_worker()
758
  max_n = remote_max_concurrent_jobs()
 
 
 
 
 
759
  return True, (
760
  f"Approved job {job_id}. Up to {max_n} Hub Jobs may run in parallel; "
761
- "this job starts when a slot is free."
762
  )
763
 
764
 
@@ -955,22 +1211,84 @@ def moderation_list_html() -> str:
955
  for j in jobs:
956
  note = (j.submission_notes or "").strip()
957
  note_cell = _escape_html(note[:200] + ("…" if len(note) > 200 else "")) if note else "N/A"
 
 
 
 
 
 
958
  rows.append(
959
  f"<tr><td><code>{_escape_html(j.id)}</code></td>"
960
- f"<td><code>{_escape_html(j.model_id)}</code></td>"
961
  f"<td><code>{_escape_html(j.family_id)}</code></td>"
 
962
  f"<td style='max-width:280px;font-size:0.9em'>{note_cell}</td>"
963
  f"<td>{_escape_html(j.created_at)}</td></tr>"
964
  )
965
  body = "".join(rows)
966
  return (
967
  "<table style='width:100%;border-collapse:collapse;font-size:0.95em'>"
968
- "<thead><tr><th>Job ID</th><th>Model</th><th>Family</th><th>Notes</th>"
969
  "<th>Submitted (UTC)</th></tr></thead>"
970
  f"<tbody>{body}</tbody></table>"
971
  )
972
 
973
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
974
  def recent_jobs_html(limit: int = 25, *, with_heading: bool = True) -> str:
975
  """Recent jobs with status (for spotting stuck or failed runs)."""
976
  with _jobs_lock:
@@ -1105,15 +1423,15 @@ def progress_html() -> str:
1105
 
1106
  if total <= 0:
1107
  inner = (
1108
- "<div style='width:40%;height:100%;background:#4c8bf5;"
1109
- "background-image:linear-gradient(90deg,#4c8bf5,#8ab4ff,#4c8bf5);"
1110
  "background-size:200% 100%;animation:ffasr-indet 1.6s linear infinite;'></div>"
1111
  )
1112
  pct_label = "preparing…"
1113
  else:
1114
  pct = max(0.0, min(100.0, 100.0 * done / total))
1115
  inner = (
1116
- f"<div style='height:100%;width:{pct:.1f}%;background:#4c8bf5;"
1117
  "transition:width 0.4s ease-out'></div>"
1118
  )
1119
  cond_txt = f"; current condition: <code>{_escape_html(cond)}</code>" if cond else ""
 
62
  error: str | None = None
63
  result: dict[str, Any] | None = None
64
  submission_notes: str = ""
65
+ extra_requirements: str = ""
66
+ custom_script: str = ""
67
+ is_gated: bool = False
68
+ run_custom_script: bool = False
69
  # Progress (not persisted; only meaningful while running)
70
  progress_done: int = 0
71
  progress_total: int = 0
 
89
  _MAX_QUEUE_BACKLOG = 32
90
  _MAX_PENDING_MODERATION = 64
91
  _DEFAULT_REMOTE_MAX_CONCURRENT = 4
92
+ _MAX_CUSTOM_SCRIPT_BYTES = 32 * 1024
93
+ _MAX_REQUIREMENT_LINE_LEN = 200
94
+ _MAX_REQUIREMENT_LINES = 50
95
 
96
  _JOBS_CSV_FIELDS = [
97
  "job_id",
 
102
  "updated_at",
103
  "error",
104
  "submission_notes",
105
+ "extra_requirements",
106
+ "custom_script_b64",
107
+ "is_gated",
108
+ "run_custom_script",
109
  "hf_remote_job_id",
110
  "remote_artifact_path",
111
  ]
112
 
113
 
114
+ def parse_requirements_lines(text: str) -> list[str]:
115
+ """One package spec per line (requirements.txt style); ignores blanks and # comments."""
116
+ out: list[str] = []
117
+ for raw in (text or "").splitlines():
118
+ line = raw.strip()
119
+ if not line or line.startswith("#"):
120
+ continue
121
+ if len(line) > _MAX_REQUIREMENT_LINE_LEN:
122
+ line = line[:_MAX_REQUIREMENT_LINE_LEN]
123
+ out.append(line)
124
+ if len(out) >= _MAX_REQUIREMENT_LINES:
125
+ break
126
+ return out
127
+
128
+
129
+ def sanitize_custom_script(text: str) -> str:
130
+ """Trim and cap custom script body for storage."""
131
+ s = (text or "").strip()
132
+ if not s:
133
+ return ""
134
+ enc = s.encode("utf-8")
135
+ if len(enc) > _MAX_CUSTOM_SCRIPT_BYTES:
136
+ enc = enc[:_MAX_CUSTOM_SCRIPT_BYTES]
137
+ s = enc.decode("utf-8", errors="ignore")
138
+ return s
139
+
140
+
141
+ def _bool_to_csv(flag: bool) -> str:
142
+ return "1" if flag else "0"
143
+
144
+
145
+ def _bool_from_csv(raw: str) -> bool:
146
+ return (raw or "").strip().lower() in ("1", "true", "yes", "on")
147
+
148
+
149
+ def _encode_script_b64(script: str) -> str:
150
+ if not script:
151
+ return ""
152
+ import base64
153
+
154
+ return base64.b64encode(script.encode("utf-8")).decode("ascii")
155
+
156
+
157
+ def _decode_script_b64(raw: str) -> str:
158
+ raw = (raw or "").strip()
159
+ if not raw:
160
+ return ""
161
+ import base64
162
+
163
+ try:
164
+ return base64.b64decode(raw.encode("ascii")).decode("utf-8")
165
+ except Exception:
166
+ return ""
167
+
168
+
169
  def _now_iso() -> str:
170
  return datetime.now(timezone.utc).isoformat()
171
 
 
233
  "updated_at": j.updated_at or j.created_at,
234
  "error": (j.error or "").replace("\n", " ")[:2000],
235
  "submission_notes": (j.submission_notes or "").replace("\n", " ")[:4000],
236
+ "extra_requirements": (j.extra_requirements or "").replace("\r\n", "\n")[:8000],
237
+ "custom_script_b64": _encode_script_b64(j.custom_script or ""),
238
+ "is_gated": _bool_to_csv(j.is_gated),
239
+ "run_custom_script": _bool_to_csv(j.run_custom_script),
240
  "hf_remote_job_id": (j.hf_remote_job_id or "").strip(),
241
  "remote_artifact_path": (j.remote_artifact_path or "").strip(),
242
  }
 
300
  continue
301
  err = (row.get("error") or "").strip() or None
302
  notes = (row.get("submission_notes") or "").strip()
303
+ extra_req = (row.get("extra_requirements") or "").strip()
304
+ script = _decode_script_b64(row.get("custom_script_b64") or "")
305
+ is_gated = _bool_from_csv(row.get("is_gated") or "")
306
+ run_custom = _bool_from_csv(row.get("run_custom_script") or "")
307
  created = (row.get("created_at") or _now_iso()).strip()
308
  updated = (row.get("updated_at") or created).strip()
309
  mid = (row.get("model_id") or "").strip()
 
332
  updated_at=updated,
333
  error=err,
334
  submission_notes=notes,
335
+ extra_requirements=extra_req,
336
+ custom_script=script,
337
+ is_gated=is_gated,
338
+ run_custom_script=run_custom,
339
  hf_remote_job_id=hf_rid,
340
  remote_artifact_path=art_path,
341
  )
 
478
  save_raw_results(rows)
479
 
480
 
481
+ def _load_artifact_json_from_bucket(artifact_path: str) -> dict:
482
+ """Download and parse a remote eval JSON artifact from the Hub bucket."""
483
+ from storage import STORAGE_BACKEND, download_bucket_file
484
+
485
+ if STORAGE_BACKEND != "hf_bucket":
486
+ raise RuntimeError("Artifact import requires STORAGE_BACKEND='hf_bucket'.")
487
+ local_path = download_bucket_file(artifact_path)
488
+ try:
489
+ with open(local_path, "r", encoding="utf-8") as f:
490
+ return json.load(f)
491
+ finally:
492
+ try:
493
+ os.unlink(local_path)
494
+ except Exception:
495
+ pass
496
+
497
+
498
+ def _submission_notes_for_artifact_import(
499
+ data: dict, artifact_path: str, override: str
500
+ ) -> str:
501
+ notes = (override or "").strip()
502
+ if notes:
503
+ return notes[:4000]
504
+ space_job_id = str(data.get("job_id") or "").strip()
505
+ with _jobs_lock:
506
+ for j in _jobs.values():
507
+ if space_job_id and j.id == space_job_id:
508
+ return (j.submission_notes or "").strip()[:4000]
509
+ if (j.remote_artifact_path or "").strip() == artifact_path:
510
+ return (j.submission_notes or "").strip()[:4000]
511
+ return ""
512
+
513
+
514
+ def _mark_job_done_for_artifact(
515
+ data: dict, artifact_path: str, result: dict
516
+ ) -> str | None:
517
+ """If a tracked queue job matches, mark it done. Returns matched job id or None."""
518
+ space_job_id = str(data.get("job_id") or "").strip()
519
+ matched: Job | None = None
520
+ with _jobs_lock:
521
+ for j in _jobs.values():
522
+ if space_job_id and j.id == space_job_id:
523
+ matched = j
524
+ break
525
+ if (j.remote_artifact_path or "").strip() == artifact_path:
526
+ matched = j
527
+ break
528
+ if matched is not None:
529
+ matched.status = JobStatus.done
530
+ matched.result = result
531
+ matched.error = None
532
+ if not (matched.remote_artifact_path or "").strip():
533
+ matched.remote_artifact_path = artifact_path
534
+ _touch(matched)
535
+ if matched is not None:
536
+ _persist_jobs()
537
+ return matched.id
538
+ return None
539
+
540
+
541
+ def import_artifact_to_leaderboard(
542
+ artifact_ref: str,
543
+ secret: str,
544
+ *,
545
+ replace_existing: bool = False,
546
+ submission_notes: str = "",
547
+ ) -> tuple[bool, str]:
548
+ """
549
+ Moderator-only: load a bucket JSON artifact and merge its result into leaderboard.csv.
550
+
551
+ ``artifact_ref`` may be a file name (``abc123.json``), job id (``abc123``), or full bucket path.
552
+ """
553
+ ok, msg = _moderator_secret_ok(secret)
554
+ if not ok:
555
+ return False, msg
556
+
557
+ from evaluation.remote_artifact import extract_result_or_raise, normalize_artifact_bucket_path
558
+ from init import (
559
+ invalidate_results_cache,
560
+ leaderboard_row_from_eval_result,
561
+ load_raw_results,
562
+ normalize_legacy_csv_row,
563
+ save_raw_results,
564
+ )
565
+ import analytics
566
+
567
+ try:
568
+ artifact_path = normalize_artifact_bucket_path(artifact_ref)
569
+ except ValueError as e:
570
+ return False, str(e)
571
+
572
+ try:
573
+ data = _load_artifact_json_from_bucket(artifact_path)
574
+ except Exception as e:
575
+ return False, f"Could not load artifact <code>{_escape_html(artifact_path)}</code>: {e}"
576
+
577
+ try:
578
+ result = extract_result_or_raise(data)
579
+ except Exception as e:
580
+ return False, f"Artifact is not a successful evaluation: {e}"
581
+
582
+ model_id = str(result.get("model_id", "")).strip()
583
+ if not model_id:
584
+ return False, "Artifact result is missing model_id."
585
+
586
+ notes = _submission_notes_for_artifact_import(data, artifact_path, submission_notes)
587
+ rows = load_raw_results()
588
+ existing = [i for i, r in enumerate(rows) if (r.get("model_id") or "").strip() == model_id]
589
+
590
+ if existing and not replace_existing:
591
+ row = dict(rows[existing[0]])
592
+ normalize_legacy_csv_row(row)
593
+ score = analytics.far_field_score_for_row(row)
594
+ score_txt = f"{score:.2f}" if score is not None else "N/A"
595
+ return False, (
596
+ f"Model <code>{_escape_html(model_id)}</code> is already on the leaderboard "
597
+ f"(score {score_txt}). Enable <strong>Replace existing row</strong> to overwrite."
598
+ )
599
+
600
+ if existing and replace_existing:
601
+ for i in sorted(existing, reverse=True):
602
+ rows.pop(i)
603
+
604
+ submitted_at = _now_iso()
605
+ new_row = leaderboard_row_from_eval_result(result, submitted_at, submission_notes=notes)
606
+ normalize_legacy_csv_row(new_row)
607
+ rows.append(new_row)
608
+ _leaderboard_sort_rows_inplace(rows)
609
+ save_raw_results(rows)
610
+ invalidate_results_cache()
611
+
612
+ ff_score = analytics.far_field_score_for_row(new_row)
613
+ score_txt = f"{ff_score:.2f}" if ff_score is not None else "N/A"
614
+ action = "Replaced" if existing else "Added"
615
+ matched_job = _mark_job_done_for_artifact(data, artifact_path, result)
616
+ job_bit = f" Matched queue job <code>{_escape_html(matched_job)}</code> marked done." if matched_job else ""
617
+
618
+ return True, (
619
+ f"{action} <strong>{_escape_html(model_id)}</strong> from "
620
+ f"<code>{_escape_html(artifact_path)}</code>. "
621
+ f"Far-field score: <strong>{score_txt}</strong> / 100.{job_bit}"
622
+ )
623
+
624
+
625
  def _remote_collect_result(job_id: str, hf_id: str, jobs_token: str) -> dict:
626
  from evaluation.remote_artifact import extract_result_or_raise
627
  from storage import download_bucket_file
 
686
  _touch(j3)
687
  _persist_jobs()
688
 
689
+ with _jobs_lock:
690
+ j_dispatch = _jobs.get(job_id)
691
+ extra_req = (j_dispatch.extra_requirements or "") if j_dispatch else ""
692
+ custom_script = (j_dispatch.custom_script or "") if j_dispatch else ""
693
+ run_custom = bool(j_dispatch and j_dispatch.run_custom_script)
694
+
695
  info = remote_jobs.submit_eval_job(
696
  model_id=mid,
697
  family_id=fid,
698
  space_job_id=job_id,
699
  artifact_path=artifact_path,
700
  token=jobs_token,
701
+ extra_requirements=extra_req,
702
+ custom_script=custom_script,
703
+ run_custom_script=run_custom,
704
  )
705
  hf_id = info.id
706
  with _jobs_lock:
 
894
  model_id: str,
895
  family_id: str,
896
  submission_notes: str = "",
897
+ *,
898
+ extra_requirements: str = "",
899
+ custom_script: str = "",
900
+ is_gated: bool = False,
901
  ) -> tuple[str, int, str | None, bool]:
902
  """
903
  Enqueue an evaluation job.
 
934
  job_id = str(uuid.uuid4())[:8]
935
  created = _now_iso()
936
  notes_clean = (submission_notes or "").strip()[:4000]
937
+ try:
938
+ req_lines = parse_requirements_lines(extra_requirements)
939
+ extra_req_clean = "\n".join(req_lines)
940
+ script_clean = sanitize_custom_script(custom_script)
941
+ except Exception as e:
942
+ return "", 0, f"Invalid submission fields: {e}", False
943
 
944
  awaiting = moderation_active()
945
  status = JobStatus.pending_moderation if awaiting else JobStatus.queued
 
952
  created_at=created,
953
  updated_at=created,
954
  submission_notes=notes_clean,
955
+ extra_requirements=extra_req_clean,
956
+ custom_script=script_clean,
957
+ is_gated=bool(is_gated),
958
+ run_custom_script=False,
959
  )
960
 
961
  with _jobs_lock:
 
977
  return job_id, position, None, False
978
 
979
 
980
+ def approve_job(
981
+ job_id: str, secret: str, *, run_custom_script: bool = False
982
+ ) -> tuple[bool, str]:
983
  """Move a pending job into the execution queue (moderator only)."""
984
  if not moderation_active():
985
  return False, "Moderation is not active on this Space."
 
997
  return False, "Execution queue is full; try again in a moment."
998
 
999
  job.status = JobStatus.queued
1000
+ if (job.custom_script or "").strip():
1001
+ job.run_custom_script = bool(run_custom_script)
1002
+ else:
1003
+ job.run_custom_script = False
1004
  _touch(job)
1005
 
1006
  _work_queue.put(job_id)
1007
  _persist_jobs()
1008
  _ensure_worker()
1009
  max_n = remote_max_concurrent_jobs()
1010
+ custom_note = ""
1011
+ with _jobs_lock:
1012
+ j = _jobs.get(job_id)
1013
+ if j and j.run_custom_script and (j.custom_script or "").strip():
1014
+ custom_note = " Custom script will run on the Hub Job."
1015
  return True, (
1016
  f"Approved job {job_id}. Up to {max_n} Hub Jobs may run in parallel; "
1017
+ f"this job starts when a slot is free.{custom_note}"
1018
  )
1019
 
1020
 
 
1211
  for j in jobs:
1212
  note = (j.submission_notes or "").strip()
1213
  note_cell = _escape_html(note[:200] + ("…" if len(note) > 200 else "")) if note else "N/A"
1214
+ gated = " <span style='color:#b45309'>[gated]</span>" if j.is_gated else ""
1215
+ req_n = len(parse_requirements_lines(j.extra_requirements or ""))
1216
+ req_bit = f"{req_n} extra req(s)" if req_n else ""
1217
+ script_bit = "custom script" if (j.custom_script or "").strip() else ""
1218
+ extras = ", ".join(x for x in (req_bit, script_bit) if x)
1219
+ extras_cell = _escape_html(extras) if extras else "—"
1220
  rows.append(
1221
  f"<tr><td><code>{_escape_html(j.id)}</code></td>"
1222
+ f"<td><code>{_escape_html(j.model_id)}</code>{gated}</td>"
1223
  f"<td><code>{_escape_html(j.family_id)}</code></td>"
1224
+ f"<td style='max-width:200px;font-size:0.85em'>{extras_cell}</td>"
1225
  f"<td style='max-width:280px;font-size:0.9em'>{note_cell}</td>"
1226
  f"<td>{_escape_html(j.created_at)}</td></tr>"
1227
  )
1228
  body = "".join(rows)
1229
  return (
1230
  "<table style='width:100%;border-collapse:collapse;font-size:0.95em'>"
1231
+ "<thead><tr><th>Job ID</th><th>Model</th><th>Family</th><th>Extras</th><th>Notes</th>"
1232
  "<th>Submitted (UTC)</th></tr></thead>"
1233
  f"<tbody>{body}</tbody></table>"
1234
  )
1235
 
1236
 
1237
+ def next_up_html(limit: int = 5) -> str:
1238
+ """Ordered list of models next in the approved execution queue (Submit tab)."""
1239
+ try:
1240
+ _ensure_worker()
1241
+ except Exception as e:
1242
+ return _worker_unavailable_html(e)
1243
+ with _jobs_lock:
1244
+ queued = [j for j in _jobs.values() if j.status == JobStatus.queued]
1245
+ queued.sort(key=lambda j: j.created_at)
1246
+ queued = queued[: max(1, int(limit))]
1247
+ if not queued:
1248
+ return (
1249
+ "<div class='next-up-panel' style='font-size:0.9em;opacity:0.85'>"
1250
+ "<p><em>No models are waiting in the evaluation queue.</em></p></div>"
1251
+ )
1252
+ items = "".join(
1253
+ f"<li><code>{_escape_html(j.model_id)}</code> "
1254
+ f"<span style='opacity:0.75'>(job {_escape_html(j.id)})</span></li>"
1255
+ for j in queued
1256
+ )
1257
+ return (
1258
+ "<div class='next-up-panel' style='font-size:0.9em'>"
1259
+ f"<p><strong>Next models to evaluate</strong> ({len(queued)} shown):</p>"
1260
+ f"<ol style='margin:0.25rem 0 0 1.1rem'>{items}</ol></div>"
1261
+ ).replace("</div>", "</div>")
1262
+
1263
+
1264
+ def recent_jobs_for_render(limit: int = 30) -> list[dict[str, Any]]:
1265
+ """Structured recent jobs for Gradio @gr.render rows (Retry / Remove)."""
1266
+ with _jobs_lock:
1267
+ items = list(_jobs.values())
1268
+ items.sort(key=lambda j: j.updated_at or j.created_at, reverse=True)
1269
+ out: list[dict[str, Any]] = []
1270
+ for j in items[: max(1, int(limit))]:
1271
+ err = (j.error or "").strip()
1272
+ try:
1273
+ hub = _hub_job_link_html(j.hf_remote_job_id)
1274
+ except Exception:
1275
+ hub = ""
1276
+ out.append(
1277
+ {
1278
+ "id": j.id,
1279
+ "model_id": j.model_id,
1280
+ "status": j.status.value,
1281
+ "error": err[:200] + ("…" if len(err) > 200 else ""),
1282
+ "hub_link_html": hub,
1283
+ "updated_at": (j.updated_at or j.created_at)[:19],
1284
+ "can_retry": j.status == JobStatus.failed,
1285
+ "can_remove": j.status != JobStatus.running,
1286
+ "has_custom_script": bool((j.custom_script or "").strip()),
1287
+ }
1288
+ )
1289
+ return out
1290
+
1291
+
1292
  def recent_jobs_html(limit: int = 25, *, with_heading: bool = True) -> str:
1293
  """Recent jobs with status (for spotting stuck or failed runs)."""
1294
  with _jobs_lock:
 
1423
 
1424
  if total <= 0:
1425
  inner = (
1426
+ "<div style='width:40%;height:100%;background:#38BFA1;"
1427
+ "background-image:linear-gradient(90deg,#38BFA1,#3DFFA3,#38BFA1);"
1428
  "background-size:200% 100%;animation:ffasr-indet 1.6s linear infinite;'></div>"
1429
  )
1430
  pct_label = "preparing…"
1431
  else:
1432
  pct = max(0.0, min(100.0, 100.0 * done / total))
1433
  inner = (
1434
+ f"<div style='height:100%;width:{pct:.1f}%;background:#38BFA1;"
1435
  "transition:width 0.4s ease-out'></div>"
1436
  )
1437
  cond_txt = f"; current condition: <code>{_escape_html(cond)}</code>" if cond else ""
remote_jobs.py CHANGED
@@ -44,6 +44,7 @@ _TRANSFORMERS_DEPS: list[str] = [
44
  "transformers @ git+https://github.com/huggingface/transformers.git",
45
  "torchcodec",
46
  "librosa",
 
47
  ]
48
 
49
  _NEMO_DEPS: list[str] = [
@@ -96,29 +97,49 @@ def _needs_speechbrain_stack(model_id: str) -> bool:
96
  return m.startswith("speechbrain/") or "speechbrain" in m
97
 
98
 
 
 
 
 
 
 
 
99
  def _select_deps(model_id: str, family_id: str) -> list[str]:
100
  """Return uv dependency list for this model/family (core + backend-specific)."""
101
- fid = (family_id or "auto").strip().lower()
102
- if fid in ("transformers_seq2seq", "transformers_auto_remote", "cohere_asr"):
103
- fid = "universal"
 
 
 
104
 
105
  if fid == "speechbrain":
106
- return [*CORE_DEPS, *_SPEECHBRAIN_DEPS]
107
- if fid == "nemo_asr":
108
- return [*CORE_DEPS, *_NEMO_DEPS]
109
- if fid in ("qwen_asr", "qwen3_asr"):
110
- return [*CORE_DEPS, *_QWEN_DEPS]
111
- if fid in ("transformers_pipeline", "universal", "granite_speech", "transformers_ctc"):
112
- return [*CORE_DEPS, *_TRANSFORMERS_DEPS]
113
-
114
- # auto: infer from model id
115
- if _needs_nemo_stack(model_id):
116
- return [*CORE_DEPS, *_NEMO_DEPS]
117
- if _needs_qwen_asr_stack(model_id):
118
- return [*CORE_DEPS, *_QWEN_DEPS]
119
- if _needs_speechbrain_stack(model_id):
120
- return [*CORE_DEPS, *_SPEECHBRAIN_DEPS]
121
- return [*CORE_DEPS, *_TRANSFORMERS_DEPS]
 
 
 
 
 
 
 
 
 
 
122
 
123
 
124
  def submit_eval_job(
@@ -128,6 +149,9 @@ def submit_eval_job(
128
  space_job_id: str,
129
  artifact_path: str,
130
  token: str | None,
 
 
 
131
  ) -> JobInfo:
132
  """Start a Hub UV Job; returns initial ``JobInfo`` (may already be RUNNING)."""
133
  namespace = os.environ.get("FFASR_REMOTE_JOB_NAMESPACE", "").strip() or None
@@ -162,10 +186,36 @@ def submit_eval_job(
162
  )
163
 
164
  script = _worker_script_path()
165
- if not os.path.isfile(script):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  raise RuntimeError(f"Remote worker script not found: {script}")
167
 
168
  deps = _select_deps(model_id, family_id)
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  env = {
171
  "FFASR_MODEL_ID": model_id,
@@ -186,25 +236,32 @@ def submit_eval_job(
186
 
187
  api = HfApi(token=jobs_tok)
188
  last_err: Exception | None = None
189
- for attempt in range(3):
190
- try:
191
- return api.run_uv_job(
192
- script=script,
193
- dependencies=deps,
194
- env=env,
195
- secrets=secrets,
196
- flavor=flavor,
197
- timeout=timeout,
198
- namespace=namespace,
199
- python="3.12",
200
- token=jobs_tok,
201
- )
202
- except Exception as e:
203
- last_err = e
204
- time.sleep(2.0 * (2**attempt))
205
- if last_err is not None:
206
- raise last_err
207
- raise RuntimeError("submit_eval_job: unreachable")
 
 
 
 
 
 
 
208
 
209
 
210
  def inspect_job_once(hf_job_id: str, *, token: str | None = None) -> JobInfo:
 
44
  "transformers @ git+https://github.com/huggingface/transformers.git",
45
  "torchcodec",
46
  "librosa",
47
+ "sentencepiece",
48
  ]
49
 
50
  _NEMO_DEPS: list[str] = [
 
97
  return m.startswith("speechbrain/") or "speechbrain" in m
98
 
99
 
100
+ def _parse_extra_deps_env() -> list[str]:
101
+ raw = os.environ.get("FFASR_REMOTE_EXTRA_DEPS", "").strip()
102
+ if not raw:
103
+ return []
104
+ return [p.strip() for p in raw.split(",") if p.strip()]
105
+
106
+
107
  def _select_deps(model_id: str, family_id: str) -> list[str]:
108
  """Return uv dependency list for this model/family (core + backend-specific)."""
109
+ from backends.family_resolve import extra_deps_for_model, resolve_auto_family
110
+ from family_registry import _FAMILY_ALIASES
111
+
112
+ raw = (family_id or "auto").strip().lower()
113
+ fid = _FAMILY_ALIASES.get(raw, raw)
114
+ fid = resolve_auto_family(model_id, fid)
115
 
116
  if fid == "speechbrain":
117
+ base = [*CORE_DEPS, *_SPEECHBRAIN_DEPS]
118
+ elif fid == "nemo_asr":
119
+ base = [*CORE_DEPS, *_NEMO_DEPS]
120
+ elif fid in ("qwen_asr", "qwen3_asr"):
121
+ base = [*CORE_DEPS, *_QWEN_DEPS]
122
+ elif fid in ("transformers_pipeline", "universal", "granite_speech", "transformers_ctc"):
123
+ base = [*CORE_DEPS, *_TRANSFORMERS_DEPS]
124
+ elif _needs_nemo_stack(model_id):
125
+ base = [*CORE_DEPS, *_NEMO_DEPS]
126
+ elif _needs_qwen_asr_stack(model_id):
127
+ base = [*CORE_DEPS, *_QWEN_DEPS]
128
+ elif _needs_speechbrain_stack(model_id):
129
+ base = [*CORE_DEPS, *_SPEECHBRAIN_DEPS]
130
+ else:
131
+ base = [*CORE_DEPS, *_TRANSFORMERS_DEPS]
132
+
133
+ extras = extra_deps_for_model(model_id)
134
+ env_extras = _parse_extra_deps_env()
135
+ # Preserve order, dedupe.
136
+ seen: set[str] = set()
137
+ out: list[str] = []
138
+ for pkg in [*base, *extras, *env_extras]:
139
+ if pkg not in seen:
140
+ seen.add(pkg)
141
+ out.append(pkg)
142
+ return out
143
 
144
 
145
  def submit_eval_job(
 
149
  space_job_id: str,
150
  artifact_path: str,
151
  token: str | None,
152
+ extra_requirements: str = "",
153
+ custom_script: str = "",
154
+ run_custom_script: bool = False,
155
  ) -> JobInfo:
156
  """Start a Hub UV Job; returns initial ``JobInfo`` (may already be RUNNING)."""
157
  namespace = os.environ.get("FFASR_REMOTE_JOB_NAMESPACE", "").strip() or None
 
186
  )
187
 
188
  script = _worker_script_path()
189
+ custom_script_path: str | None = None
190
+ if run_custom_script and (custom_script or "").strip():
191
+ import tempfile
192
+
193
+ fd, custom_script_path = tempfile.mkstemp(
194
+ prefix=f"ffasr_custom_{space_job_id}_", suffix=".py"
195
+ )
196
+ try:
197
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
198
+ f.write(custom_script.strip())
199
+ except Exception:
200
+ os.unlink(custom_script_path)
201
+ raise
202
+ script = custom_script_path
203
+ elif not os.path.isfile(script):
204
  raise RuntimeError(f"Remote worker script not found: {script}")
205
 
206
  deps = _select_deps(model_id, family_id)
207
+ try:
208
+ from job_queue import parse_requirements_lines
209
+
210
+ extra_lines = parse_requirements_lines(extra_requirements)
211
+ except Exception:
212
+ extra_lines = []
213
+ if extra_lines:
214
+ seen = set(deps)
215
+ for pkg in extra_lines:
216
+ if pkg not in seen:
217
+ seen.add(pkg)
218
+ deps.append(pkg)
219
 
220
  env = {
221
  "FFASR_MODEL_ID": model_id,
 
236
 
237
  api = HfApi(token=jobs_tok)
238
  last_err: Exception | None = None
239
+ try:
240
+ for attempt in range(3):
241
+ try:
242
+ return api.run_uv_job(
243
+ script=script,
244
+ dependencies=deps,
245
+ env=env,
246
+ secrets=secrets,
247
+ flavor=flavor,
248
+ timeout=timeout,
249
+ namespace=namespace,
250
+ python="3.12",
251
+ token=jobs_tok,
252
+ )
253
+ except Exception as e:
254
+ last_err = e
255
+ time.sleep(2.0 * (2**attempt))
256
+ if last_err is not None:
257
+ raise last_err
258
+ raise RuntimeError("submit_eval_job: unreachable")
259
+ finally:
260
+ if custom_script_path and os.path.isfile(custom_script_path):
261
+ try:
262
+ os.unlink(custom_script_path)
263
+ except Exception:
264
+ pass
265
 
266
 
267
  def inspect_job_once(hf_job_id: str, *, token: str | None = None) -> JobInfo: