Cyprien Claude Opus 5 (1M context) commited on
Commit
538f6c3
·
1 Parent(s): 71a9867

Move both actions together, take over on a new pick, show it in the panel

Browse files

Three things, all the same underlying point: the picker panel is where the model
changes, so that is where the state of the model belongs.

Both buttons now say what is happening. _actions took a label for Classify and
hardcoded Evaluate's, so only one of the two ever reported the wait even though
they gate on the same weights; it now takes one busy label and applies it to
both, and hands each its own label back when ready.

Picking again mid-download takes over instead of being ignored. Gradio drops a
submission made while one is pending -- trigger_mode is "once" for everything
except .change, so the .then(warm) chain was silently discarding the new pick,
and the buttons would then unlock for a model nobody had selected. With
"multiple" and no concurrency limit the new pick starts at once, and warm records
what the session is waiting on: a load that finds itself superseded returns
without yielding, leaving the buttons to whoever is current. Keyed by
session_hash, so two visitors picking different models do not strand each
other's interface. The abandoned download still finishes in its thread, since a
blocking hf_hub_download cannot be interrupted, but it only fills the cache.

A status line next to the repo date carries Gradio's own progress animation via
show_progress_on, and then reports "Ready . N ms per call" -- an info that cannot
exist before the weights do, which is exactly why the component reads as loading
until it can. It measures a second inference, not the first: the first absorbs
torch's lazy init and read 138 ms where a real click costs tens, so reporting it
would have restated the bug this all started with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Files changed (4) hide show
  1. README.md +29 -11
  2. app/handlers.py +41 -10
  3. app/text.py +10 -0
  4. app/ui.py +35 -13
README.md CHANGED
@@ -80,17 +80,35 @@ then the cases one by one, errors first.
80
  ## Clicking Classify never downloads
81
 
82
  Both action buttons start disabled, and the picked revision is fetched up front:
83
- on page load, on picking a model, and on picking a commit. While that runs,
84
- Classify reads *Downloading the model…* and is greyed out. So a click is only
85
- ever inference, and the `ms` reading next to the prediction is measured after the
86
- load it is inference alone, not a download that happened to be first.
87
-
88
- Warming is wired so it runs once per pick. `model.change` updates the revision
89
- menu and *then* warms, so it reads the revision the switch just chose rather than
90
- the previous repo's commit; the revision menu warms on `.input`, which is
91
- user-only, so the programmatic update that `model.change` just made does not warm
92
- a second time. A revision that fails to load hands the buttons back and reports
93
- why, instead of leaving a dead interface.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  ⇧ Enter still submits while a model is loading — you can paste a transcript
96
  during the download, and an early keypress simply waits on the same load.
 
80
  ## Clicking Classify never downloads
81
 
82
  Both action buttons start disabled, and the picked revision is fetched up front:
83
+ on page load, on picking a model, and on picking a commit. While that runs, both
84
+ buttons read *Downloading the model…* and are greyed out they wait on the same
85
+ weights, so they move together. A status line in the picker panel says the same
86
+ thing, and Gradio's own progress animation sits on it via `show_progress_on`, so
87
+ the feedback appears where the change was made rather than down in a tab.
88
+
89
+ That line then reports `Ready · N ms per call`, which is the point of putting it
90
+ there: the number cannot exist until the weights do, so until then the component
91
+ is simply loading. It comes from two inference calls at warm time — the first
92
+ absorbs torch's lazy init, the second is the one measured so it matches what a
93
+ click will actually cost instead of reporting the init as if it were latency. For
94
+ the same reason the `ms` beside a prediction is timed after `load()`: inference
95
+ alone, not a download that happened to be first.
96
+
97
+ Picking again mid-download takes over. Gradio would otherwise drop the new pick
98
+ (`trigger_mode` defaults to `"once"` everywhere except `.change`) or queue it
99
+ behind the running download (`concurrency_limit` defaults to 1), so both are set
100
+ explicitly. `warm` then decides which pick still owns the buttons: it records what
101
+ the session is waiting on and, if that changed while it was loading, returns
102
+ without touching anything and leaves the newer pick in charge. The abandoned
103
+ download does finish in its thread — a blocking `hf_hub_download` cannot be
104
+ interrupted — but it only fills the cache, and nothing stale reaches the screen.
105
+
106
+ Warming runs once per pick. `model.change` updates the revision menu and *then*
107
+ warms, so it reads the revision the switch just chose rather than the previous
108
+ repo's commit; the revision menu warms on `.input`, which is user-only, so the
109
+ programmatic update `model.change` just made does not warm a second time. A
110
+ revision that fails to load hands the buttons back and reports why, instead of
111
+ leaving a dead interface.
112
 
113
  ⇧ Enter still submits while a model is loading — you can paste a transcript
114
  during the download, and an early keypress simply waits on the same load.
app/handlers.py CHANGED
@@ -9,7 +9,18 @@ from . import evaluation
9
  from .config import display
10
  from .hub import model_repos, newest, revisions
11
  from .predictors import load
12
- from .text import CLASSIFY, EVALUATE, LOADING, NO_MODEL, SCORING, WARM_FAILED, WARMING, pushed_at
 
 
 
 
 
 
 
 
 
 
 
13
  from .turns import window
14
 
15
  EXAMPLES = json.loads((Path(__file__).parent / "examples.json").read_text(encoding="utf-8"))
@@ -29,24 +40,44 @@ def classify(repo: str, revision: str, transcript: str) -> tuple[dict[str, float
29
  return scores, text, f"`{sha[:7]}` · `{elapsed:.0f} ms`"
30
 
31
 
32
- def _actions(classify_label: str, ready: bool) -> tuple[gr.Button, gr.Button]:
33
- return gr.Button(classify_label, interactive=ready), gr.Button(EVALUATE, interactive=ready)
 
 
 
 
 
34
 
 
 
35
 
36
- def warm(repo: str, revision: str):
37
- """Fetch the picked revision up front, so Classify only ever runs inference."""
 
38
  if not repo:
39
- yield _actions(CLASSIFY, ready=False)
40
  return
41
 
42
- yield _actions(WARMING, ready=False)
 
 
 
43
  try:
44
- load(repo, revision)
 
 
 
 
 
45
  except Exception as failure:
 
 
46
  # a bad revision must hand the buttons back, not dead-end the interface
47
- yield _actions(CLASSIFY, ready=True)
48
  raise gr.Error(f"{WARM_FAILED}: {failure}") from failure
49
- yield _actions(CLASSIFY, ready=True)
 
 
50
 
51
 
52
  def summary(report: evaluation.Report) -> str:
 
9
  from .config import display
10
  from .hub import model_repos, newest, revisions
11
  from .predictors import load
12
+ from .text import (
13
+ CLASSIFY,
14
+ EVALUATE,
15
+ LOADING,
16
+ NO_MODEL,
17
+ SCORING,
18
+ WARM_FAILED,
19
+ WARMING,
20
+ pushed_at,
21
+ ready_text,
22
+ status_line,
23
+ )
24
  from .turns import window
25
 
26
  EXAMPLES = json.loads((Path(__file__).parent / "examples.json").read_text(encoding="utf-8"))
 
40
  return scores, text, f"`{sha[:7]}` · `{elapsed:.0f} ms`"
41
 
42
 
43
+ def _actions(ready: bool, busy: str = "") -> tuple[gr.Button, gr.Button]:
44
+ """Both actions at once: they wait on the same model, so they move together."""
45
+ return (
46
+ gr.Button(busy or CLASSIFY, interactive=ready),
47
+ gr.Button(busy or EVALUATE, interactive=ready),
48
+ )
49
+
50
 
51
+ # What each session is waiting on, so a pick that got superseded lets go quietly.
52
+ _awaited: dict[str, tuple[str, str]] = {}
53
 
54
+
55
+ def warm(repo: str, revision: str, request: gr.Request):
56
+ """Fetch the picked revision up front, so an action only ever runs inference."""
57
  if not repo:
58
+ yield (*_actions(ready=False), status_line(NO_MODEL))
59
  return
60
 
61
+ pick = (repo, revision)
62
+ _awaited[request.session_hash] = pick
63
+ yield (*_actions(ready=False, busy=WARMING), status_line(WARMING))
64
+
65
  try:
66
+ predictor, _ = load(repo, revision)
67
+ sample = [EXAMPLES[0]["text"]]
68
+ predictor(sample) # the first call pays torch's lazy init; pay it here
69
+ started = time.perf_counter()
70
+ predictor(sample) # so the reading matches what a click will cost
71
+ ms = (time.perf_counter() - started) * 1000
72
  except Exception as failure:
73
+ if _awaited.get(request.session_hash) != pick:
74
+ return
75
  # a bad revision must hand the buttons back, not dead-end the interface
76
+ yield (*_actions(ready=True), status_line(WARM_FAILED))
77
  raise gr.Error(f"{WARM_FAILED}: {failure}") from failure
78
+
79
+ if _awaited.get(request.session_hash) == pick: # else a newer pick owns the buttons
80
+ yield (*_actions(ready=True), status_line(ready_text(ms)))
81
 
82
 
83
  def summary(report: evaluation.Report) -> str:
app/text.py CHANGED
@@ -37,3 +37,13 @@ def pushed_at(when: datetime | None) -> str:
37
  if when is None:
38
  return ""
39
  return f"<sub>Repo last updated {stamp(when)}</sub>"
 
 
 
 
 
 
 
 
 
 
 
37
  if when is None:
38
  return ""
39
  return f"<sub>Repo last updated {stamp(when)}</sub>"
40
+
41
+
42
+ def status_line(text: str) -> str:
43
+ """Line beside the repo date: what the picked model is doing."""
44
+ return f"<sub>{text}</sub>"
45
+
46
+
47
+ def ready_text(ms: float) -> str:
48
+ """Only knowable once the weights are here, which is why it reads as loading."""
49
+ return f"Ready · {ms:.0f} ms per call"
app/ui.py CHANGED
@@ -5,12 +5,21 @@ import gradio as gr
5
  from .config import REFRESH_SECONDS, display
6
  from .handlers import EXAMPLES, classify, evaluate, pick_revision, refresh, warm
7
  from .hub import model_repos, newest, revisions
8
- from .text import CLASSIFY, EVALUATE, HEADER, PLACEHOLDER, TRANSCRIPT_INFO, pushed_at
 
 
 
 
 
 
 
 
 
9
 
10
  CMD_ENTER_JS = (Path(__file__).parent / "cmd_enter.js").read_text(encoding="utf-8")
11
 
12
 
13
- def selectors() -> tuple[gr.Dropdown, gr.Dropdown, gr.Button, gr.Markdown]:
14
  pushes = model_repos()
15
  repo = newest(pushes)
16
  revs = revisions(repo) if repo else []
@@ -23,7 +32,11 @@ def selectors() -> tuple[gr.Dropdown, gr.Dropdown, gr.Button, gr.Markdown]:
23
  scale=4,
24
  )
25
  reload_button = gr.Button("↻", scale=0, min_width=48)
26
- return model, revision, reload_button, gr.Markdown(pushed_at(pushes[repo] if repo else None))
 
 
 
 
27
 
28
 
29
  def classify_tab() -> tuple[gr.Textbox, gr.Button, list]:
@@ -81,7 +94,7 @@ def build() -> gr.Blocks:
81
  with gr.Blocks(title="Breakdown risk") as demo:
82
  gr.Markdown(HEADER)
83
 
84
- model, revision, refresh_button, pushed = selectors()
85
  selection = [model, revision]
86
 
87
  with gr.Tab("Classify"):
@@ -90,19 +103,28 @@ def build() -> gr.Blocks:
90
  with gr.Tab("Evaluation"):
91
  evaluate_button, report, progress_target = evaluation_tab()
92
 
93
- # Buttons start dead and the model is fetched up front, so a click is
94
- # never a download. .input() is user-only, so the revision update that
95
- # picking a model triggers does not warm a second time.
96
- actions = [run, evaluate_button]
97
- demo.load(warm, selection, actions, show_progress="hidden")
98
- revision.input(warm, selection, actions, show_progress="hidden")
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  timer = gr.Timer(REFRESH_SECONDS)
101
  timer.tick(refresh, selection, [*selection, pushed], show_progress="hidden")
102
  refresh_button.click(refresh, selection, [*selection, pushed])
103
- model.change(pick_revision, model, [revision, pushed]).then(
104
- warm, selection, actions, show_progress="hidden"
105
- )
106
  gr.on(
107
  [run.click, transcript.submit],
108
  classify,
 
5
  from .config import REFRESH_SECONDS, display
6
  from .handlers import EXAMPLES, classify, evaluate, pick_revision, refresh, warm
7
  from .hub import model_repos, newest, revisions
8
+ from .text import (
9
+ CLASSIFY,
10
+ EVALUATE,
11
+ HEADER,
12
+ PLACEHOLDER,
13
+ TRANSCRIPT_INFO,
14
+ WARMING,
15
+ pushed_at,
16
+ status_line,
17
+ )
18
 
19
  CMD_ENTER_JS = (Path(__file__).parent / "cmd_enter.js").read_text(encoding="utf-8")
20
 
21
 
22
+ def selectors() -> tuple[gr.Dropdown, gr.Dropdown, gr.Button, gr.Markdown, gr.Markdown]:
23
  pushes = model_repos()
24
  repo = newest(pushes)
25
  revs = revisions(repo) if repo else []
 
32
  scale=4,
33
  )
34
  reload_button = gr.Button("↻", scale=0, min_width=48)
35
+ with gr.Row():
36
+ pushed = gr.Markdown(pushed_at(pushes[repo] if repo else None))
37
+ # Never empty, so Gradio's progress animation has something to sit on.
38
+ status = gr.Markdown(status_line(WARMING))
39
+ return model, revision, reload_button, pushed, status
40
 
41
 
42
  def classify_tab() -> tuple[gr.Textbox, gr.Button, list]:
 
94
  with gr.Blocks(title="Breakdown risk") as demo:
95
  gr.Markdown(HEADER)
96
 
97
+ model, revision, refresh_button, pushed, status = selectors()
98
  selection = [model, revision]
99
 
100
  with gr.Tab("Classify"):
 
103
  with gr.Tab("Evaluation"):
104
  evaluate_button, report, progress_target = evaluation_tab()
105
 
106
+ # Buttons start dead and the model is fetched up front, so a click is never
107
+ # a download. Every pick warms the same way: "multiple" and no concurrency
108
+ # limit because Gradio otherwise drops a pick made while one is pending
109
+ # ("once") or queues it behind that download; warm() itself decides which
110
+ # pick still owns the buttons. show_progress_on puts Gradio's own animation
111
+ # on the status line, up in the picker panel where the change was made.
112
+ warming = dict(
113
+ outputs=[run, evaluate_button, status],
114
+ show_progress="full",
115
+ show_progress_on=[status],
116
+ trigger_mode="multiple",
117
+ concurrency_limit=None,
118
+ )
119
+ demo.load(warm, selection, **warming)
120
+ # .input() is user-only: the revision update that picking a model triggers
121
+ # must not warm a second time.
122
+ revision.input(warm, selection, **warming)
123
 
124
  timer = gr.Timer(REFRESH_SECONDS)
125
  timer.tick(refresh, selection, [*selection, pushed], show_progress="hidden")
126
  refresh_button.click(refresh, selection, [*selection, pushed])
127
+ model.change(pick_revision, model, [revision, pushed]).then(warm, selection, **warming)
 
 
128
  gr.on(
129
  [run.click, transcript.submit],
130
  classify,