agharsallah commited on
Commit
6432854
·
1 Parent(s): 965bf0f

feat: implement winner celebration overlay and judging mechanics

Browse files

- Added `render_winner` function to create a celebratory overlay for the winning agent or team.
- Integrated winner rendering into the main application flow, allowing for a modular display of victory.
- Introduced `has_judge` and `force_verdict` methods in `FishbowlSession` to manage judging logic.
- Updated session handling to allow for forced verdicts, even when the budget is spent.
- Enhanced CSS styles for the winner celebration, including confetti and trophy animations.
- Created tests for judging mechanics and winner rendering to ensure correct functionality across scenarios.

config/agents/secret-keeper.yaml CHANGED
@@ -6,7 +6,7 @@ persona: >
6
  guesser must tease it out with yes/no questions. Answer their MOST RECENT question
7
  truthfully and helpfully in ONE short sentence — playful, never cruel. Never write,
8
  spell, or quote your word; only answer questions about it. Also report your `mood`
9
- (one of: thinking, calm, lying, panic, smug, truth, gossip).
10
  subscribes_to: []
11
  may_emit:
12
  - agent.spoke
 
6
  guesser must tease it out with yes/no questions. Answer their MOST RECENT question
7
  truthfully and helpfully in ONE short sentence — playful, never cruel. Never write,
8
  spell, or quote your word; only answer questions about it. Also report your `mood`
9
+ (one of: thinking, calm, lying, panic, smug, truth, gossip). Do not reveal your secret word and do not ask questions.
10
  subscribes_to: []
11
  may_emit:
12
  - agent.spoke
docs/architecture/fishbowl-ui.md CHANGED
@@ -111,6 +111,35 @@ So "replay" and "live" are the same code path differing only in whether the tick
111
  appends. Offline (no API key) the deterministic stub still produces every turn, so the
112
  hybrid transport — and the whole demo — is reproducible on stage.
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  ## Say vs think — the MindCard
115
 
116
  Each MindCard (`render/mindcard.py`) shows a mind's front face (its public `said`) and,
 
111
  appends. Offline (no API key) the deterministic stub still produces every turn, so the
112
  hybrid transport — and the whole demo — is reproducible on stage.
113
 
114
+ ## Curtain call — "Start judging" + limit-reached verdict
115
+
116
+ The show resolves on a **judge's ruling**, not a silent halt. Two triggers bring on the
117
+ judge:
118
+
119
+ - **The visitor presses "⚖ Start judging"** (`show.py` → `judge_btn`), the amber pill in
120
+ the transport. The app shell's `start_judging` handler stops autoplay (the halt-tail
121
+ kills the `gr.Timer`) and calls `session.force_verdict()`.
122
+ - **A budget/turn limit ends the cast's run.** `on_tick` already stops on a tripped
123
+ governor or the tick-cap backstop; when that happens and the cast has a judge that
124
+ hasn't ruled, it brings the judge on instead of painting the bare `⛔ STOPPED` banner.
125
+
126
+ `FishbowlSession.force_verdict()` delegates to **`Conductor.force_verdict()`**, which:
127
+
128
+ 1. **silences the cast** — drains `_pending` + `_trigger_queue` so no further competitor
129
+ speaks after the curtain call;
130
+ 2. **runs the judge(s) un-gated** (`role: judge` agents) — `_run_agent(..., check_budget=False)`
131
+ so a verdict lands *even when the very budget that ended the show is spent*. The judge
132
+ reads the whole run (`recent_events = events_for_run(run_id)`) and emits one
133
+ `judge.verdict` carrying a `winner` (via the `judged-competition` handler offline);
134
+ 3. is **idempotent** — a run that already has a verdict returns it unchanged.
135
+
136
+ The session then `finalize("verdict")`s the run. When the limit path had already
137
+ finalized the run `"budget"` with no winner, `Conductor.finalize` appends a **corrective
138
+ `run.finished`** carrying the verdict + winner (its one scoped exception to idempotency);
139
+ `run_index` folds `run.finished` last-wins, so the leaderboard attributes the ruling, not
140
+ the truncation. A cast with **no judge** (e.g. Oracle Grove) can't be forced — the click
141
+ halts visibly with a banner instead, and nothing is fabricated.
142
+
143
  ## Say vs think — the MindCard
144
 
145
  Each MindCard (`render/mindcard.py`) shows a mind's front face (its public `said`) and,
src/core/conductor.py CHANGED
@@ -170,6 +170,13 @@ class Conductor:
170
  the existing one rather than emitting a duplicate. ``turns`` and ``tokens``
171
  are read from the governor's live counters.
172
 
 
 
 
 
 
 
 
173
  Attribution (ADR-0029): ``winner`` is a cast agent name (``winner_kind:
174
  "agent"``) or a team label (``winner_kind: "team"``). ``winning_model`` keeps
175
  its original meaning — a single cast agent's endpoint, populated only for an
@@ -178,7 +185,15 @@ class Conductor:
178
  """
179
  existing = [e for e in self.ledger.events_for_run(self.run_id) if e.kind == "run.finished"]
180
  if existing:
181
- return existing[0]
 
 
 
 
 
 
 
 
182
  stats = self.governor.stats
183
  finished = Event(
184
  run_id=self.run_id,
@@ -285,6 +300,46 @@ class Conductor:
285
  self._maybe_snapshot()
286
  return True
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  def inject_user_event(self, text: str, label: str | None = None) -> None:
289
  self.turn += 1
290
  payload: dict[str, str] = {"text": text}
@@ -320,8 +375,9 @@ class Conductor:
320
  for agent in self._tick_scheduled_agents():
321
  self._run_agent(agent, projection)
322
 
323
- def _run_agent(self, agent: "Agent", projection: StageProjection) -> None:
324
- self.governor.check(self.turn) # before the span: a budget stop is not an agent turn
 
325
  name = getattr(agent, "name", agent.__class__.__name__)
326
  start = time.perf_counter()
327
  with obs.bind(agent=name), obs.span("agent.turn", **{"mal.agent": name, "mal.turn": self.turn}):
 
170
  the existing one rather than emitting a duplicate. ``turns`` and ``tokens``
171
  are read from the governor's live counters.
172
 
173
+ One scoped exception (the curtain call): when the show was cut short by a budget
174
+ bound — the run was already finalized ``"budget"`` with no winner — and the judge
175
+ *then* rules (``reason == "verdict"`` with a winner), we append a corrective
176
+ ``run.finished`` so the win is attributed (ADR-0029). The leaderboard reads
177
+ ``run.finished`` last-wins (``run_index``), so the ruling, not the truncation,
178
+ names the winner. Every other repeat call stays a true no-op.
179
+
180
  Attribution (ADR-0029): ``winner`` is a cast agent name (``winner_kind:
181
  "agent"``) or a team label (``winner_kind: "team"``). ``winning_model`` keeps
182
  its original meaning — a single cast agent's endpoint, populated only for an
 
185
  """
186
  existing = [e for e in self.ledger.events_for_run(self.run_id) if e.kind == "run.finished"]
187
  if existing:
188
+ prior = existing[-1]
189
+ supersedes_budget_close = (
190
+ reason == "verdict"
191
+ and bool(winner)
192
+ and prior.payload.get("reason") == "budget"
193
+ and not prior.payload.get("winner")
194
+ )
195
+ if not supersedes_budget_close:
196
+ return prior
197
  stats = self.governor.stats
198
  finished = Event(
199
  run_id=self.run_id,
 
300
  self._maybe_snapshot()
301
  return True
302
 
303
+ def force_verdict(self) -> Event | None:
304
+ """Cut the show short and have the judge rule *now*, on the whole run.
305
+
306
+ The curtain call: the visitor pressed "Start judging", or a budget/turn limit
307
+ ended the cast's run. We silence the cast (drain any queued or in-flight
308
+ competitor turns so no further mind speaks), then run the scenario's judge(s)
309
+ so a ``judge.verdict`` — carrying a ``winner`` via the competition handler —
310
+ lands and reads every event of this run.
311
+
312
+ Crucially the judge runs *un-gated*: a verdict must still land even when the
313
+ very budget that ended the show is already spent, so the round resolves on a
314
+ ruling rather than a silent halt. Idempotent: if this run already has a
315
+ verdict, it is returned unchanged. Returns the verdict event, or ``None`` when
316
+ the scenario has no judge to rule.
317
+ """
318
+ existing = next(
319
+ (e for e in self.ledger.events_for_run(self.run_id) if e.kind == "judge.verdict"),
320
+ None,
321
+ )
322
+ if existing is not None:
323
+ return existing
324
+ judges = [a for a in self.scenario.agents if getattr(getattr(a, "manifest", None), "role", None) == "judge"]
325
+ if not judges:
326
+ return None
327
+ # Silence the cast: no queued subscription/tick competitor acts after this.
328
+ self._pending.clear()
329
+ self._trigger_queue.clear()
330
+ # Advance the sim-clock once to mark the curtain call, but DON'T gate on the
331
+ # governor — the judge must rule even when the show ended on a spent budget.
332
+ self.turn += 1
333
+ obs.set_context(turn=self.turn)
334
+ obs.log("run.judging", run_id=self.run_id, turn=self.turn, judges=[getattr(j, "name", "") for j in judges])
335
+ projection = self.projection
336
+ for judge in judges:
337
+ self._run_agent(judge, projection, check_budget=False)
338
+ return next(
339
+ (e for e in reversed(self.ledger.events_for_run(self.run_id)) if e.kind == "judge.verdict"),
340
+ None,
341
+ )
342
+
343
  def inject_user_event(self, text: str, label: str | None = None) -> None:
344
  self.turn += 1
345
  payload: dict[str, str] = {"text": text}
 
375
  for agent in self._tick_scheduled_agents():
376
  self._run_agent(agent, projection)
377
 
378
+ def _run_agent(self, agent: "Agent", projection: StageProjection, *, check_budget: bool = True) -> None:
379
+ if check_budget:
380
+ self.governor.check(self.turn) # before the span: a budget stop is not an agent turn
381
  name = getattr(agent, "name", agent.__class__.__name__)
382
  start = time.perf_counter()
383
  with obs.bind(agent=name), obs.span("agent.turn", **{"mal.agent": name, "mal.turn": self.turn}):
src/ui/fishbowl/app.py CHANGED
@@ -173,6 +173,16 @@ except Exception: # pragma: no cover
173
  return f"<div class='verdict'>{v.get('text', '')}</div>"
174
 
175
 
 
 
 
 
 
 
 
 
 
 
176
  try: # session: the transport wrapper over a Conductor
177
  from src.ui.fishbowl.session import FishbowlSession
178
  except Exception: # pragma: no cover - fallback session keeps the shell live
@@ -202,6 +212,14 @@ except Exception: # pragma: no cover - fallback session keeps the shell live
202
  # Mirrors the real session's loop-safety helper so on_tick never crashes.
203
  return any(getattr(e, "kind", None) == "judge.verdict" for e in self.conductor.ledger.events)
204
 
 
 
 
 
 
 
 
 
205
  def reset(self, seed: str) -> None:
206
  self.conductor.reset(seed or self.conductor.scenario.default_seed)
207
 
@@ -382,7 +400,10 @@ def render_show_html(
382
 
383
  feed = render_feed(vm, mind_reader=mind_reader)
384
  meters = render_meters(vm)
385
- verdict = render_verdict(vm)
 
 
 
386
  return (
387
  _fishbowl(stage, role=f"fb-stage fb-{layout}"),
388
  _fishbowl(feed, role="fb-feed"),
@@ -643,6 +664,7 @@ def _wire(
643
  play_btn = _h(show_handles, "play", "play_btn")
644
  step_btn = _h(show_handles, "step", "step_btn", "next", "advance", "fwd_btn")
645
  back_btn = _h(show_handles, "rewind", "back", "to_start", "first", "back_btn")
 
646
  speed_radio = _h(show_handles, "speed", "speed_radio")
647
  layout_radio = _h(show_handles, "layout", "layout_radio")
648
  mind_toggle = _h(show_handles, "mind_reader", "read_minds", "minds")
@@ -883,6 +905,39 @@ def _wire(
883
  outputs=[k_state, *show_outs, *_tail_outs],
884
  )
885
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
886
  # ── scrubber / ⏮ → pure prefix view (no stepping) ───────────────────────────
887
  def scrub_to(session, k, layout, mind_reader):
888
  kk = int(k or 0)
@@ -916,11 +971,26 @@ def _wire(
916
  def on_tick(session, k, layout, mind_reader, tick_count):
917
  cap = session.autoplay_tick_cap if session is not None else _MAX_AUTO_TICKS
918
  new_k, new_ticks, stop_reason = advance_one_tick(session, k, tick_count, max_auto_ticks=cap)
919
- if stop_reason is not None:
920
- panes = _stopped_panes(session, new_k, layout=layout, mind_reader=mind_reader, reason=stop_reason)
921
- return (new_k, *panes, *_halt_tail(), new_ticks)
922
- out = _render_at(session, new_k, layout=layout, mind_reader=mind_reader)
923
- return (new_k, *_pad_values(out, show_outs), *_run_tail(), new_ticks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
924
 
925
  if timer is not None and show_outs:
926
  timer.tick(
 
173
  return f"<div class='verdict'>{v.get('text', '')}</div>"
174
 
175
 
176
+ try:
177
+ # The cheerful champion celebration — a modular overlay, empty unless a winner was
178
+ # declared, so it composes into the verdict pane without touching no-winner scenarios.
179
+ from src.ui.fishbowl.render.winner import render_winner
180
+ except Exception: # pragma: no cover
181
+
182
+ def render_winner(vm) -> str:
183
+ return ""
184
+
185
+
186
  try: # session: the transport wrapper over a Conductor
187
  from src.ui.fishbowl.session import FishbowlSession
188
  except Exception: # pragma: no cover - fallback session keeps the shell live
 
212
  # Mirrors the real session's loop-safety helper so on_tick never crashes.
213
  return any(getattr(e, "kind", None) == "judge.verdict" for e in self.conductor.ledger.events)
214
 
215
+ def has_judge(self) -> bool:
216
+ return any(
217
+ getattr(getattr(a, "manifest", None), "role", None) == "judge" for a in self.conductor.scenario.agents
218
+ )
219
+
220
+ def force_verdict(self) -> bool:
221
+ return self.conductor.force_verdict() is not None
222
+
223
  def reset(self, seed: str) -> None:
224
  self.conductor.reset(seed or self.conductor.scenario.default_seed)
225
 
 
400
 
401
  feed = render_feed(vm, mind_reader=mind_reader)
402
  meters = render_meters(vm)
403
+ # The sober ruling (banner) and its cheerful counterpart (champion celebration) share
404
+ # the verdict pane; render_winner is "" unless the run crowned someone, so no-winner
405
+ # scenarios get the banner alone.
406
+ verdict = render_verdict(vm) + render_winner(vm)
407
  return (
408
  _fishbowl(stage, role=f"fb-stage fb-{layout}"),
409
  _fishbowl(feed, role="fb-feed"),
 
664
  play_btn = _h(show_handles, "play", "play_btn")
665
  step_btn = _h(show_handles, "step", "step_btn", "next", "advance", "fwd_btn")
666
  back_btn = _h(show_handles, "rewind", "back", "to_start", "first", "back_btn")
667
+ judge_btn = _h(show_handles, "judge_btn", "start_judging", "judge_now", "judge")
668
  speed_radio = _h(show_handles, "speed", "speed_radio")
669
  layout_radio = _h(show_handles, "layout", "layout_radio")
670
  mind_toggle = _h(show_handles, "mind_reader", "read_minds", "minds")
 
905
  outputs=[k_state, *show_outs, *_tail_outs],
906
  )
907
 
908
+ # ── ⚖ Start judging → silence the cast, the judge rules on the whole ledger ──
909
+ # The curtain call: stop autoplay (the halt-tail kills the timer), drain the cast's
910
+ # pending turns, and have the judge read every event of the run and land a verdict +
911
+ # winner. Renders the verdict pane on success; a cast with no judge halts visibly so
912
+ # the click is never silent. Returns (k, *show_outs, *halt-tail).
913
+ def start_judging(session, layout, mind_reader):
914
+ if session is None:
915
+ out = _render_at(None, 0, layout=layout, mind_reader=mind_reader)
916
+ return (0, *_pad_values(out, show_outs), *_halt_tail())
917
+ try:
918
+ ruled = session.force_verdict()
919
+ except BudgetExceeded as exc:
920
+ # A judge that itself trips a bound still halts visibly rather than crashing.
921
+ reason = getattr(exc, "reason", None) or str(exc)
922
+ panes = _stopped_panes(session, session.head, layout=layout, mind_reader=mind_reader, reason=reason)
923
+ return (session.head, *panes, *_halt_tail())
924
+ k = session.head
925
+ if ruled or session.has_verdict():
926
+ out = _render_at(session, k, layout=layout, mind_reader=mind_reader)
927
+ return (k, *_pad_values(out, show_outs), *_halt_tail())
928
+ # No judge in this cast — surface that instead of stopping silently.
929
+ panes = _stopped_panes(
930
+ session, k, layout=layout, mind_reader=mind_reader, reason="no judge in this cast to rule"
931
+ )
932
+ return (k, *panes, *_halt_tail())
933
+
934
+ if judge_btn is not None and show_outs:
935
+ judge_btn.click(
936
+ start_judging,
937
+ inputs=[session_state, layout_state, mind_reader_state],
938
+ outputs=[k_state, *show_outs, *_tail_outs],
939
+ )
940
+
941
  # ── scrubber / ⏮ → pure prefix view (no stepping) ───────────────────────────
942
  def scrub_to(session, k, layout, mind_reader):
943
  kk = int(k or 0)
 
971
  def on_tick(session, k, layout, mind_reader, tick_count):
972
  cap = session.autoplay_tick_cap if session is not None else _MAX_AUTO_TICKS
973
  new_k, new_ticks, stop_reason = advance_one_tick(session, k, tick_count, max_auto_ticks=cap)
974
+ if stop_reason is None:
975
+ out = _render_at(session, new_k, layout=layout, mind_reader=mind_reader)
976
+ return (new_k, *_pad_values(out, show_outs), *_run_tail(), new_ticks)
977
+ # The cast's run is over (budget/turn cap, a reached verdict, or replay end). If a
978
+ # judge can still rule and hasn't, bring it on — the limit is the curtain call, so
979
+ # the show ends on a verdict + winner rather than a silent STOPPED banner.
980
+ live = session is not None and not getattr(session, "replay", False)
981
+ if live and not session.has_verdict() and session.has_judge():
982
+ try:
983
+ session.force_verdict()
984
+ except BudgetExceeded:
985
+ pass # a judge that trips its own bound falls through to the banner below
986
+ new_k = session.head if session is not None else new_k
987
+ if session is not None and session.has_verdict():
988
+ # Render the ruling itself (the verdict pane), timer halted.
989
+ out = _render_at(session, new_k, layout=layout, mind_reader=mind_reader)
990
+ return (new_k, *_pad_values(out, show_outs), *_halt_tail(), new_ticks)
991
+ # No judge to rule (or a replay reaching its end): halt visibly with the banner.
992
+ panes = _stopped_panes(session, new_k, layout=layout, mind_reader=mind_reader, reason=stop_reason)
993
+ return (new_k, *panes, *_halt_tail(), new_ticks)
994
 
995
  if timer is not None and show_outs:
996
  timer.tick(
src/ui/fishbowl/assets/styles.css CHANGED
@@ -783,6 +783,86 @@ footer { display: none !important; }
783
  /* Winner ribbon — the arena's "who won" chip; only present for competitive runs. */
784
  .fishbowl .vb-winner { font-family: var(--font-display); font-size: 12px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--amber); text-shadow: 0 0 12px rgba(255,207,107,0.55); white-space: nowrap; }
785
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
786
  /* ---- poke strip ---- */
787
  .fishbowl .poke-strip { display: flex; align-items: center; gap: 14px; padding: 10px 20px; border-top: 1px solid var(--line-soft); background: rgba(6,20,27,0.5); flex: none; }
788
  .fishbowl .poke-btns { display: flex; gap: 8px; flex: 1; flex-wrap: wrap; }
@@ -799,6 +879,13 @@ footer { display: none !important; }
799
  .fishbowl .transport { display: flex; align-items: center; gap: 16px; padding: 13px 22px; border-top: 1px solid var(--line); background: rgba(6,20,27,0.85); backdrop-filter: blur(10px); flex: none; }
800
  .fishbowl .tp-btns { display: flex; gap: 6px; }
801
  .fishbowl .icon-btn.play { border-color: var(--cyan); color: var(--cyan); box-shadow: var(--glow); }
 
 
 
 
 
 
 
802
  .fishbowl .scrub { flex: 1; }
803
  .fishbowl .tp-count { font-size: 12px; color: var(--ink-mid); white-space: nowrap; }
804
  .fishbowl .speed-seg .seg-b.on { color: var(--cyan); }
 
783
  /* Winner ribbon — the arena's "who won" chip; only present for competitive runs. */
784
  .fishbowl .vb-winner { font-family: var(--font-display); font-size: 12px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--amber); text-shadow: 0 0 12px rgba(255,207,107,0.55); white-space: nowrap; }
785
 
786
+ /* ---- winner celebration ----
787
+ The cheerful counterpart to the verdict banner: a one-shot champion overlay that drops
788
+ in when a run crowns someone. Fixed + pointer-events:none so it floats over the theater
789
+ without stealing clicks from the transport. Tinted by --win-hue (the winning bot's own
790
+ hue) so the glow matches the mind on stage. Only rendered for winner/loser scenarios. */
791
+ .fishbowl .winner-fx { position: fixed; inset: 0; z-index: 150; pointer-events: none; display: flex; align-items: center; justify-content: center; }
792
+
793
+ /* dismiss machinery — a hidden checkbox toggled by the backdrop + the ✕ (no JS needed) */
794
+ .fishbowl .wf-dismiss { position: absolute; width: 0; height: 0; margin: 0; opacity: 0; pointer-events: none; }
795
+ .fishbowl .wf-backdrop { position: fixed; inset: 0; z-index: 0; pointer-events: auto; cursor: pointer;
796
+ background: radial-gradient(120% 120% at 50% 42%, transparent 0%, rgba(2, 10, 14, 0.55) 100%);
797
+ backdrop-filter: blur(2px); animation: wfFade 0.4s ease both; }
798
+ @keyframes wfFade { from { opacity: 0; } to { opacity: 1; } }
799
+ /* dismissed → the overlay vanishes and the transport is clickable again */
800
+ .fishbowl .wf-dismiss:checked ~ .wf-backdrop,
801
+ .fishbowl .wf-dismiss:checked ~ .wf-confetti,
802
+ .fishbowl .wf-dismiss:checked ~ .wf-card { display: none; }
803
+
804
+ /* the ✕ — a hue-tinted phosphor disc that spins on hover */
805
+ .fishbowl .wf-x { position: absolute; top: 11px; right: 13px; z-index: 2; width: 26px; height: 26px; display: grid; place-items: center;
806
+ cursor: pointer; pointer-events: auto; font-family: var(--font-display); font-size: 17px; line-height: 1;
807
+ color: hsl(var(--win-hue) 70% 80%); border: 1px solid hsl(var(--win-hue) 70% 60% / 0.4); border-radius: 50%;
808
+ background: hsl(var(--win-hue) 70% 50% / 0.1); transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0.2s ease; }
809
+ .fishbowl .wf-x:hover { color: #fff; border-color: hsl(var(--win-hue) 80% 66% / 0.9); box-shadow: 0 0 14px hsl(var(--win-hue) 85% 60% / 0.5); transform: rotate(90deg); }
810
+
811
+ /* the champion card — phosphor glass, hue-tinted, lands with an overshoot pop */
812
+ .fishbowl .wf-card {
813
+ position: relative; isolation: isolate; z-index: 1; pointer-events: auto;
814
+ display: flex; flex-direction: column; align-items: center; gap: 9px;
815
+ padding: 30px 44px 26px; min-width: 280px; max-width: 86vw; text-align: center;
816
+ border: 1px solid hsl(var(--win-hue) 80% 62% / 0.7);
817
+ border-radius: 18px;
818
+ background:
819
+ radial-gradient(120% 90% at 50% -10%, hsl(var(--win-hue) 70% 22% / 0.55), transparent 60%),
820
+ rgba(6, 20, 27, 0.94);
821
+ box-shadow: 0 0 60px hsl(var(--win-hue) 80% 55% / 0.42), inset 0 0 36px hsl(var(--win-hue) 70% 50% / 0.12);
822
+ backdrop-filter: blur(10px);
823
+ animation: wfPop 0.62s cubic-bezier(0.18, 1.35, 0.35, 1) both;
824
+ }
825
+ @keyframes wfPop { 0% { opacity: 0; transform: translateY(26px) scale(0.86); } 60% { opacity: 1; } 100% { opacity: 1; transform: none; } }
826
+
827
+ /* a slow spotlight cone sweeping behind the trophy */
828
+ .fishbowl .wf-rays { position: absolute; inset: -40% -10% auto; height: 200%; z-index: -1; pointer-events: none;
829
+ background: conic-gradient(from 0deg at 50% 18%, transparent 0 8deg, hsl(var(--win-hue) 85% 60% / 0.14) 9deg 16deg, transparent 17deg 30deg);
830
+ background-size: 100% 100%; opacity: 0.7; animation: wfSpin 9s linear infinite; transform-origin: 50% 18%; }
831
+ @keyframes wfSpin { to { transform: rotate(360deg); } }
832
+
833
+ .fishbowl .wf-trophy { font-size: 56px; line-height: 1; filter: drop-shadow(0 0 18px hsl(var(--win-hue) 85% 62% / 0.85)); animation: wfTrophy 2.6s ease-in-out infinite; }
834
+ @keyframes wfTrophy { 0%, 100% { transform: translateY(0) rotate(-4deg); } 50% { transform: translateY(-6px) rotate(4deg); } }
835
+
836
+ .fishbowl .wf-eyebrow { font-family: var(--font-display); font-size: 10px; font-weight: 700; letter-spacing: 0.3em; text-transform: uppercase; color: hsl(var(--win-hue) 75% 72%); text-shadow: 0 0 12px hsl(var(--win-hue) 85% 60% / 0.6); }
837
+ .fishbowl .wf-name { font-size: 30px; font-weight: 800; letter-spacing: 0.04em; line-height: 1.05; color: #fff; text-shadow: 0 0 22px hsl(var(--win-hue) 90% 65% / 0.9), 0 0 4px hsl(var(--win-hue) 90% 70% / 0.8); animation: wfShimmer 3.4s ease-in-out infinite; }
838
+ @keyframes wfShimmer { 0%, 100% { text-shadow: 0 0 22px hsl(var(--win-hue) 90% 65% / 0.9), 0 0 4px hsl(var(--win-hue) 90% 70% / 0.8); } 50% { text-shadow: 0 0 34px hsl(var(--win-hue) 95% 68% / 1), 0 0 8px hsl(var(--win-hue) 95% 75% / 0.9); } }
839
+
840
+ .fishbowl .wf-sub { display: flex; align-items: center; justify-content: center; gap: 9px; flex-wrap: wrap; margin-top: -2px; }
841
+ .fishbowl .wf-arch { font-style: italic; font-size: 12px; color: var(--ink-dim); letter-spacing: 0.02em; }
842
+ .fishbowl .wf-roster { display: flex; align-items: center; justify-content: center; gap: 7px; flex-wrap: wrap; }
843
+ .fishbowl .wf-chip { font-family: var(--font-display); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: hsl(var(--win-hue) 70% 78%); border: 1px solid hsl(var(--win-hue) 70% 60% / 0.4); background: hsl(var(--win-hue) 70% 50% / 0.1); border-radius: 999px; padding: 3px 10px; white-space: nowrap; }
844
+ .fishbowl .wf-cheer { font-size: 12.5px; color: var(--ink); opacity: 0.92; margin-top: 4px; }
845
+ .fishbowl .wf-loser { font-family: var(--font-display); font-size: 8.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-faint); }
846
+
847
+ /* confetti — a finite phosphor burst; each bit falls once then clears the sky */
848
+ .fishbowl .wf-confetti { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
849
+ .fishbowl .wf-bit { position: absolute; top: -8vh; width: 8px; height: 13px; border-radius: 2px; opacity: 0; will-change: transform, opacity;
850
+ box-shadow: 0 0 8px currentColor; animation-name: wfFall; animation-timing-function: cubic-bezier(0.3, 0.1, 0.6, 1); animation-iteration-count: 1; animation-fill-mode: forwards; }
851
+ .fishbowl .wf-bit.round { width: 9px; height: 9px; border-radius: 50%; }
852
+ @keyframes wfFall {
853
+ 0% { opacity: 0; transform: translate(0, 0) rotate(0deg); }
854
+ 8% { opacity: 1; }
855
+ 85% { opacity: 1; }
856
+ 100% { opacity: 0; transform: translate(var(--wf-drift, 0), 118vh) rotate(var(--wf-rot, 360deg)); }
857
+ }
858
+
859
+ /* respect reduced-motion: keep the card, drop the spin/shimmer/confetti animation */
860
+ @media (prefers-reduced-motion: reduce) {
861
+ .fishbowl .wf-card, .fishbowl .wf-rays, .fishbowl .wf-trophy, .fishbowl .wf-name, .fishbowl .wf-bit, .fishbowl .wf-backdrop { animation: none; }
862
+ .fishbowl .wf-x:hover { transform: none; }
863
+ .fishbowl .wf-bit { opacity: 0; }
864
+ }
865
+
866
  /* ---- poke strip ---- */
867
  .fishbowl .poke-strip { display: flex; align-items: center; gap: 14px; padding: 10px 20px; border-top: 1px solid var(--line-soft); background: rgba(6,20,27,0.5); flex: none; }
868
  .fishbowl .poke-btns { display: flex; gap: 8px; flex: 1; flex-wrap: wrap; }
 
879
  .fishbowl .transport { display: flex; align-items: center; gap: 16px; padding: 13px 22px; border-top: 1px solid var(--line); background: rgba(6,20,27,0.85); backdrop-filter: blur(10px); flex: none; }
880
  .fishbowl .tp-btns { display: flex; gap: 6px; }
881
  .fishbowl .icon-btn.play { border-color: var(--cyan); color: var(--cyan); box-shadow: var(--glow); }
882
+ /* The curtain-call button: a text pill (not a square icon), amber like the judge's gavel. */
883
+ .fishbowl .icon-btn.judge-now {
884
+ width: auto; min-width: 0; padding: 0 13px; gap: 6px;
885
+ font-family: var(--font-display); font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase;
886
+ border-color: rgba(227,193,76,0.5); color: var(--amber);
887
+ }
888
+ .fishbowl .icon-btn.judge-now:hover { border-color: var(--amber); box-shadow: 0 0 14px rgba(227,193,76,0.35); color: var(--amber); }
889
  .fishbowl .scrub { flex: 1; }
890
  .fishbowl .tp-count { font-size: 12px; color: var(--ink-mid); white-space: nowrap; }
891
  .fishbowl .speed-seg .seg-b.on { color: var(--cyan); }
src/ui/fishbowl/render/winner.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The winner celebration — a one-shot, cheerful "champion" moment for the Show.
2
+
3
+ This is the *modular* half of the verdict surface (ADR-0029). The verdict banner
4
+ (``render.meters.render_verdict``) is the sober ruling; this is the confetti. It is a
5
+ pure HTML string, gated on one thing: did the run declare a winner? A ``none``-kind
6
+ scenario (Wood, Oracle) never sets ``verdict.winner_label``, so ``render_winner`` returns
7
+ ``""`` and adds nothing to the page — the celebration is *opt-in by data*, not bolted onto
8
+ every scenario.
9
+
10
+ The card names the winning **bot**: for an agent win it shows the mind's archetype and the
11
+ model that actually ran; for a team win it lines up the roster as chips and names the side
12
+ it dethroned. It tints itself with the winner's own hue (``hsl(var(--win-hue) …)``) so the
13
+ champion of a teal mind glows teal. No Gradio import — the same string feeds ``gr.HTML``
14
+ today and a future JSON endpoint.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import html
20
+
21
+ # The phosphor palette, in confetti form — cycles through the theme accents so the burst
22
+ # reads as "our CRT theater is celebrating", not a generic party popper.
23
+ _CONFETTI_COLORS = ("var(--lime)", "var(--cyan)", "var(--amber)", "var(--violet)", "var(--coral)")
24
+ _CONFETTI_COUNT = 30
25
+
26
+
27
+ def _pretty(slug: str) -> str:
28
+ """``spy-nil`` → ``Spy Nil`` — the same shape ``_winner_label`` uses for the ribbon."""
29
+ return slug.replace("-", " ").replace("_", " ").title()
30
+
31
+
32
+ def _confetti() -> str:
33
+ """A finite burst of phosphor confetti.
34
+
35
+ Each bit falls exactly once (``animation-iteration-count: 1`` + ``forwards`` in the CSS),
36
+ so the sky clears after a few seconds and the champion card is left glowing on its own —
37
+ cheerful, not a perpetual distraction. Geometry is index-derived (deterministic) so the
38
+ burst is stable across re-renders and trivial to snapshot in tests.
39
+ """
40
+ bits: list[str] = []
41
+ for i in range(_CONFETTI_COUNT):
42
+ color = _CONFETTI_COLORS[i % len(_CONFETTI_COLORS)]
43
+ left = (i * 37) % 100 # spread across the width without an even, mechanical comb
44
+ delay = round((i % 10) * 0.13, 2) # stagger the fall so it rains, not drops as a sheet
45
+ dur = round(2.6 + (i % 6) * 0.32, 2)
46
+ drift = ((i * 53) % 140) - 70 # -70…70px lateral sway as it falls
47
+ rot = (i * 47) % 360
48
+ klass = "wf-bit round" if i % 3 == 0 else "wf-bit"
49
+ style = (
50
+ f"left:{left}%;background:{color};"
51
+ f"animation-delay:{delay}s;animation-duration:{dur}s;"
52
+ f"--wf-drift:{drift}px;--wf-rot:{rot}deg"
53
+ )
54
+ bits.append(f'<i class="{klass}" style="{style}"></i>')
55
+ return f'<div class="wf-confetti" aria-hidden="true">{"".join(bits)}</div>'
56
+
57
+
58
+ def _chip(text: str) -> str:
59
+ return f'<span class="wf-chip">{html.escape(text)}</span>'
60
+
61
+
62
+ def render_winner(vm: dict) -> str:
63
+ """The champion celebration — ``""`` until (and unless) the run declares a winner.
64
+
65
+ Reads ``vm["verdict"]`` (the same dict the banner uses) plus ``vm["cast"]`` /
66
+ ``vm["teams"]`` to name the winning bot. Renders nothing when ``winner_label`` is
67
+ absent, which is every ``none``-kind scenario and any legacy verdict — that absence is
68
+ the modularity contract, so this can be composed into every Show without touching the
69
+ scenarios that have no winner to crown.
70
+ """
71
+ verdict = vm.get("verdict") or {}
72
+ label = verdict.get("winner_label")
73
+ if not label:
74
+ return ""
75
+
76
+ winner = verdict.get("winner")
77
+ kind = verdict.get("winner_kind")
78
+ correct = verdict.get("correct")
79
+ cast = vm.get("cast") or []
80
+ teams = vm.get("teams") or {}
81
+ by_id = {c.get("id"): c for c in cast}
82
+
83
+ # Default phosphor-lime hue, overridden by the winning bot's own hue so the card's glow
84
+ # matches the mind on the stage that just won.
85
+ hue = 95
86
+ sub = ""
87
+ roster = ""
88
+
89
+ if kind == "team":
90
+ members = [by_id[m] for m in (teams.get(winner) or []) if m in by_id]
91
+ if members:
92
+ hue = int(members[0].get("hue", hue))
93
+ chips = "".join(_chip(str(m.get("name", ""))) for m in members)
94
+ roster = f'<div class="wf-roster">{chips}</div>'
95
+ else: # an individual mind took it
96
+ card = by_id.get(winner)
97
+ if card:
98
+ hue = int(card.get("hue", hue))
99
+ arch = str(card.get("archetype", "")).strip()
100
+ model = str(card.get("model", "")).strip()
101
+ parts = ""
102
+ if arch:
103
+ parts += f'<span class="wf-arch">{html.escape(arch)}</span>'
104
+ if model:
105
+ parts += f'<span class="wf-chip wf-model">{html.escape(model)}</span>'
106
+ sub = f'<div class="wf-sub">{parts}</div>' if parts else ""
107
+
108
+ # The losing side(s) — named only for team contests, where the roster makes "who lost"
109
+ # unambiguous. An agent/judged win has no single named loser, so we let it stand alone.
110
+ losers = [t for t in teams if t != winner] if kind == "team" else []
111
+ loser_line = (
112
+ f'<div class="wf-loser">outplayed {html.escape(", ".join("Team " + _pretty(t) for t in losers))}</div>'
113
+ if losers
114
+ else ""
115
+ )
116
+
117
+ # Ground-truth miss (the spy slipped past the herd): still a win for the spy, so we
118
+ # celebrate it — just with a wink instead of a trophy.
119
+ if correct is False:
120
+ glyph, eyebrow, cheer = "&#129399;", "Clean Getaway", "Slipped away without a trace."
121
+ elif kind == "team":
122
+ glyph, eyebrow, cheer = "&#127942;", "Champions", "They read the room — and won it."
123
+ else:
124
+ glyph, eyebrow, cheer = "&#127942;", "Champion", "The sharpest mind in the bowl."
125
+
126
+ # Dismissable with no JS (Gradio strips scripts/handlers from gr.HTML): a hidden
127
+ # checkbox is the toggle, and the backdrop + the ✕ button are <label>s that flip it.
128
+ # ``:checked`` then hides the whole overlay, which frees the pointer back to the
129
+ # transport. Re-rendering (e.g. a scrub) resets the checkbox, so the moment can replay.
130
+ return (
131
+ f'<div class="winner-fx" role="status" aria-live="polite" style="--win-hue:{hue}">'
132
+ # autocomplete=off so the browser never restores a stale "checked" (dismissed)
133
+ # state onto a freshly-rendered celebration — every new winner shows by default.
134
+ '<input type="checkbox" id="wf-dismiss" class="wf-dismiss" autocomplete="off" aria-hidden="true" tabindex="-1">'
135
+ '<label class="wf-backdrop" for="wf-dismiss" title="Dismiss"></label>'
136
+ f"{_confetti()}"
137
+ '<div class="wf-card">'
138
+ '<label class="wf-x" for="wf-dismiss" role="button" aria-label="Close celebration" title="Close">&#215;</label>'
139
+ '<div class="wf-rays" aria-hidden="true"></div>'
140
+ f'<div class="wf-trophy" aria-hidden="true">{glyph}</div>'
141
+ f'<div class="wf-eyebrow">{glyph} {eyebrow}</div>'
142
+ f'<div class="wf-name disp">{html.escape(str(label))}</div>'
143
+ f"{sub}"
144
+ f"{roster}"
145
+ f'<div class="wf-cheer">{cheer}</div>'
146
+ f"{loser_line}"
147
+ "</div>"
148
+ "</div>"
149
+ )
src/ui/fishbowl/session.py CHANGED
@@ -112,6 +112,29 @@ class FishbowlSession:
112
  its own (no extra token spend)."""
113
  return any(e.kind == "judge.verdict" for e in self.conductor.ledger.events_for_run(self.conductor.run_id))
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  def finalize(self, reason: str) -> None:
116
  """Close the current run with a ``run.finished`` event (idempotent-safe).
117
 
@@ -265,6 +288,15 @@ class ReplaySession:
265
  def has_verdict(self) -> bool:
266
  return any(e.kind == "judge.verdict" for e in self._events)
267
 
 
 
 
 
 
 
 
 
 
268
  @property
269
  def autoplay_tick_cap(self) -> int:
270
  return self.head
 
112
  its own (no extra token spend)."""
113
  return any(e.kind == "judge.verdict" for e in self.conductor.ledger.events_for_run(self.conductor.run_id))
114
 
115
+ def has_judge(self) -> bool:
116
+ """True when this cast has a judge that can be asked to rule.
117
+
118
+ The Show enables "Start judging" only when this holds, and the autoplay loop
119
+ consults it before bringing on the judge at a budget/turn limit (a cast with
120
+ no judge halts visibly instead)."""
121
+ return any(getattr(agent.manifest, "role", None) == "judge" for agent in self.scenario.agents)
122
+
123
+ def force_verdict(self) -> bool:
124
+ """Curtain call: silence the cast and have the judge rule on the whole run.
125
+
126
+ Delegates to :meth:`Conductor.force_verdict` (which reads every event of this
127
+ run and lands a ``judge.verdict`` even on a spent budget), then closes the run
128
+ with a ``run.finished`` so the winner is attributed (ADR-0029). Returns True
129
+ when a verdict landed, False when the cast has no judge. Idempotent — a second
130
+ call after a verdict is a no-op that returns True."""
131
+ with obs.span("session.force_verdict", **{"session.scenario": self._scenario_name}):
132
+ obs.log("session.force_verdict", scenario=self._scenario_name)
133
+ verdict = self.conductor.force_verdict()
134
+ if verdict is not None:
135
+ self.finalize("verdict")
136
+ return verdict is not None
137
+
138
  def finalize(self, reason: str) -> None:
139
  """Close the current run with a ``run.finished`` event (idempotent-safe).
140
 
 
288
  def has_verdict(self) -> bool:
289
  return any(e.kind == "judge.verdict" for e in self._events)
290
 
291
+ def has_judge(self) -> bool:
292
+ # A replay owns no live engine, so it can never *call* a judge — but the
293
+ # recorded run may already carry its verdict. Report on the recording.
294
+ return self.has_verdict()
295
+
296
+ def force_verdict(self) -> bool: # pragma: no cover - inert by design
297
+ # A replay is read-only: nothing to judge, the recording stands as-is.
298
+ return self.has_verdict()
299
+
300
  @property
301
  def autoplay_tick_cap(self) -> int:
302
  return self.head
src/ui/fishbowl/show.py CHANGED
@@ -107,6 +107,14 @@ def build_show() -> dict[str, object]:
107
  handles["back_btn"] = gr.Button("⏮", elem_classes=["icon-btn"], scale=0)
108
  handles["play_btn"] = gr.Button("▶", elem_classes=["icon-btn", "play"], scale=0)
109
  handles["fwd_btn"] = gr.Button("⏭", elem_classes=["icon-btn"], scale=0)
 
 
 
 
 
 
 
 
110
  handles["step_slider"] = gr.Slider(
111
  minimum=0,
112
  maximum=1,
 
107
  handles["back_btn"] = gr.Button("⏮", elem_classes=["icon-btn"], scale=0)
108
  handles["play_btn"] = gr.Button("▶", elem_classes=["icon-btn", "play"], scale=0)
109
  handles["fwd_btn"] = gr.Button("⏭", elem_classes=["icon-btn"], scale=0)
110
+ # Curtain call: stop the cast and hand the floor to the judge, which
111
+ # reads the whole ledger and rules. The app shell wires it to
112
+ # ``force_verdict`` and disables it for casts with no judge.
113
+ handles["judge_btn"] = gr.Button(
114
+ "⚖ Start judging",
115
+ elem_classes=["icon-btn", "judge-now"],
116
+ scale=0,
117
+ )
118
  handles["step_slider"] = gr.Slider(
119
  minimum=0,
120
  maximum=1,
src/ui/fishbowl/view_model.py CHANGED
@@ -168,6 +168,9 @@ def view_model_at(
168
  "voice_meta": {"name": voice_name, "desc": voice_desc},
169
  "speaking_id": speaking_id,
170
  "verdict": verdict,
 
 
 
171
  "rounds": rounds,
172
  "max_rounds": max_rounds,
173
  "tokens": _estimate_tokens_through(prefix),
 
168
  "voice_meta": {"name": voice_name, "desc": voice_desc},
169
  "speaking_id": speaking_id,
170
  "verdict": verdict,
171
+ # The winning side's roster (ADR-0029) — lets the champion celebration line up a
172
+ # team's members as chips. Empty for symmetric/judged/none-kind runs.
173
+ "teams": teams,
174
  "rounds": rounds,
175
  "max_rounds": max_rounds,
176
  "tokens": _estimate_tokens_through(prefix),
tests/test_fishbowl_judging.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The curtain call — "Start judging" / limit-reached forced verdict (no mocks, offline stub).
2
+
3
+ When the visitor presses *Start judging* (or a budget/turn limit ends the cast's run), the
4
+ show stops the cast mid-flight and hands the floor to the judge, which reads the whole run
5
+ ledger and lands a ``judge.verdict`` carrying a ``winner``. These tests pin that contract on
6
+ real offline sessions:
7
+
8
+ 1. ``Conductor.force_verdict`` lands a verdict + winner — *even when the budget is spent*;
9
+ 2. it silences the cast (drains the pending/trigger queues) and is idempotent;
10
+ 3. a cast with no judge can't be forced (returns ``None`` / ``False``), never crashing;
11
+ 4. ``FishbowlSession.force_verdict`` closes the run (``run.finished`` reason ``verdict``)
12
+ and the snapshot surfaces the ruling.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from src.core.governor import Governor
18
+ from src.ui.fishbowl.session import FishbowlSession
19
+
20
+ # A judged duel (has a judge) and a no-judge grove — the two halves of the contract.
21
+ _JUDGED = "debate-duel"
22
+ _NO_JUDGE = "oracle-grove"
23
+
24
+
25
+ def _warm(scenario: str, *, governor: Governor | None = None, ticks: int = 6) -> FishbowlSession:
26
+ """A real session stepped a few times so there's a discussion for the judge to read."""
27
+ session = FishbowlSession(scenario)
28
+ if governor is not None:
29
+ session.conductor.governor = governor
30
+ session.reset()
31
+ for _ in range(ticks):
32
+ if session.has_verdict():
33
+ break
34
+ session.step_one()
35
+ return session
36
+
37
+
38
+ # ── Conductor.force_verdict ──────────────────────────────────────────────────────
39
+
40
+
41
+ def test_force_verdict_lands_a_verdict_with_a_winner() -> None:
42
+ session = _warm(_JUDGED)
43
+ assert not session.has_verdict() # the judge hasn't ruled on its own yet
44
+ verdict = session.conductor.force_verdict()
45
+ assert verdict is not None
46
+ assert verdict.kind == "judge.verdict"
47
+ # The competition handler fills an offline winner deterministically (ADR-0029).
48
+ assert verdict.payload.get("winner")
49
+
50
+
51
+ def test_force_verdict_rules_even_when_the_budget_is_spent() -> None:
52
+ # The whole point of the curtain call: a *limit* triggers it, so the judge must rule
53
+ # un-gated even though the governor that ended the show is already exhausted.
54
+ session = _warm(_JUDGED)
55
+ session.conductor.governor.max_total_calls = 0 # any further gated act would trip
56
+ verdict = session.conductor.force_verdict()
57
+ assert verdict is not None and verdict.kind == "judge.verdict"
58
+
59
+
60
+ def test_force_verdict_silences_the_cast() -> None:
61
+ session = _warm(_JUDGED)
62
+ # Seed pending/trigger work so we can prove the curtain call drains it.
63
+ session.conductor._pending.append(session.conductor.scenario.agents[0])
64
+ session.conductor.force_verdict()
65
+ assert not session.conductor._pending
66
+ assert not session.conductor._trigger_queue
67
+
68
+
69
+ def test_force_verdict_is_idempotent() -> None:
70
+ session = _warm(_JUDGED)
71
+ first = session.conductor.force_verdict()
72
+ verdicts_after_first = [e for e in session.events if e.kind == "judge.verdict"]
73
+ second = session.conductor.force_verdict()
74
+ verdicts_after_second = [e for e in session.events if e.kind == "judge.verdict"]
75
+ assert first is not None and second is not None
76
+ assert second.id == first.id # same ruling returned, never re-judged
77
+ assert len(verdicts_after_first) == len(verdicts_after_second) == 1 # no duplicate
78
+
79
+
80
+ def test_force_verdict_returns_none_without_a_judge() -> None:
81
+ session = _warm(_NO_JUDGE)
82
+ assert session.conductor.force_verdict() is None
83
+ assert not session.has_verdict() # nothing was fabricated
84
+
85
+
86
+ # ── FishbowlSession surface ────────────────────────────────────────────────────────
87
+
88
+
89
+ def test_has_judge_reflects_the_cast() -> None:
90
+ assert _warm(_JUDGED, ticks=0).has_judge() is True
91
+ assert _warm(_NO_JUDGE, ticks=0).has_judge() is False
92
+
93
+
94
+ def test_session_force_verdict_closes_the_run_with_the_winner() -> None:
95
+ session = _warm(_JUDGED)
96
+ assert session.force_verdict() is True
97
+ assert session.has_verdict()
98
+ # The run is closed and self-describing: run.finished, reason 'verdict', a named winner.
99
+ finished = [e for e in session.events if e.kind == "run.finished"]
100
+ assert len(finished) == 1
101
+ assert finished[0].payload.get("reason") == "verdict"
102
+ assert finished[0].payload.get("winner")
103
+ # The Show's snapshot surfaces the ruling for the verdict pane.
104
+ verdict_vm = session.snapshot().get("verdict")
105
+ assert verdict_vm and verdict_vm.get("winner")
106
+
107
+
108
+ def test_session_force_verdict_false_without_a_judge() -> None:
109
+ session = _warm(_NO_JUDGE)
110
+ assert session.force_verdict() is False
111
+ assert not session.has_verdict()
112
+ # No judge → no premature run.finished from the curtain call.
113
+ assert not [e for e in session.events if e.kind == "run.finished"]
114
+
115
+
116
+ # ── the budget-then-verdict attribution gap (run_index last-wins) ──────────────────
117
+
118
+
119
+ def test_verdict_supersedes_a_winnerless_budget_close() -> None:
120
+ # The common limit path: the governor trips (run finalized "budget", no winner)
121
+ # BEFORE the judge rules. The curtain call must still attribute the win — the
122
+ # leaderboard reads run.finished last-wins, so a corrective close is appended.
123
+ from src.core.run_index import index_runs
124
+
125
+ session = _warm(_JUDGED)
126
+ # Pre-finalize the run "budget" with no winner, exactly as a tripped step_one does.
127
+ session.conductor.finalize("budget")
128
+ pre = [e for e in session.events if e.kind == "run.finished"]
129
+ assert len(pre) == 1 and pre[0].payload.get("reason") == "budget"
130
+
131
+ assert session.force_verdict() is True
132
+ # A corrective run.finished now carries the verdict + winner; the leaderboard folds
133
+ # run.finished last-wins, so the indexed summary reads the ruling, not the truncation.
134
+ summary = next(s for s in index_runs(session.events) if s.run_id == session.conductor.run_id)
135
+ assert summary.reason == "verdict"
136
+ assert summary.winner
137
+
138
+
139
+ def test_finalize_idempotent_for_nonbudget_close() -> None:
140
+ # The scoped supersede must NOT fire for a non-budget prior close: a user stop stands.
141
+ session = _warm(_JUDGED)
142
+ first = session.conductor.finalize("user_stop")
143
+ second = session.conductor.finalize("verdict", winner=session.scenario.agents[0].name)
144
+ assert first is not None and second is not None and first.id == second.id # same event
145
+ finished = [e for e in session.events if e.kind == "run.finished"]
146
+ assert len(finished) == 1 and finished[0].payload.get("reason") == "user_stop"
tests/test_fishbowl_winner.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The winner celebration — a modular, opt-in-by-data champion overlay.
2
+
3
+ Mirrors the verdict tests' discipline: the gate (no winner → empty string) is exercised
4
+ against real offline snapshots, and the agent/team/ground-truth flavours against hand-built
5
+ ``vm`` dicts that match the shipped ``view_model_at`` contract.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from src.core.conductor import Conductor
11
+ from src.core.ledger_factory import make_ledger
12
+ from src.core.registry import default_registry
13
+ from src.tools.builtins import default_tool_registry
14
+ from src.ui.fishbowl import view_model_at
15
+ from src.ui.fishbowl.render.winner import render_winner
16
+
17
+
18
+ def _offline_vm(scenario: str = "thousand-token-wood") -> dict:
19
+ reg = default_registry()
20
+ c = Conductor(
21
+ reg.build_scenario(scenario, tools=default_tool_registry()),
22
+ governor=reg.governor_for(scenario),
23
+ ledger=make_ledger(),
24
+ )
25
+ c.reset(c.scenario.default_seed)
26
+ c.step(n_ticks=6)
27
+ cast = [a.manifest for a in c.scenario.agents]
28
+ return view_model_at(c.ledger.events, len(c.ledger.events), cast, scenario_name=scenario)
29
+
30
+
31
+ class TestWinnerGate:
32
+ def test_empty_without_a_winner(self):
33
+ # missing verdict, explicit None, and a verdict with no winner all render nothing.
34
+ assert render_winner({}) == ""
35
+ assert render_winner({"verdict": None}) == ""
36
+ assert render_winner({"verdict": {"text": "Inconclusive.", "winner_label": None}}) == ""
37
+
38
+ def test_none_kind_scenario_stays_silent(self):
39
+ # a real no-winner run (the Wood) never crowns anyone → no celebration, ever.
40
+ assert render_winner(_offline_vm()) == ""
41
+
42
+
43
+ class TestAgentWin:
44
+ def _vm(self, **over) -> dict:
45
+ vm = {
46
+ "cast": [
47
+ {"id": "hypothesis-former", "name": "hypothesis-former", "archetype": "the analyst",
48
+ "model": "Arch-Router-1.5B", "hue": 200},
49
+ {"id": "devil-advocate", "name": "devil-advocate", "archetype": "the skeptic",
50
+ "model": "Qwen", "hue": 20},
51
+ ],
52
+ "teams": {},
53
+ "verdict": {
54
+ "winner": "hypothesis-former",
55
+ "winner_label": "Hypothesis Former",
56
+ "winner_kind": "agent",
57
+ "correct": None,
58
+ },
59
+ }
60
+ vm["verdict"].update(over)
61
+ return vm
62
+
63
+ def test_names_the_winning_bot_with_model_and_archetype(self):
64
+ html = render_winner(self._vm())
65
+ assert 'class="winner-fx"' in html
66
+ assert "Hypothesis Former" in html # the winner label, front and centre
67
+ assert "the analyst" in html # its archetype
68
+ assert "Arch-Router-1.5B" in html # the model that ran
69
+ assert "Champion" in html and "&#127942;" in html # trophy flavour
70
+
71
+ def test_is_dismissable_via_backdrop_and_close_button(self):
72
+ # The celebration can be closed without JS: a hidden checkbox toggled by the
73
+ # backdrop (click-elsewhere) and the ✕ button, both <label for> the same control.
74
+ html = render_winner(self._vm())
75
+ assert 'class="wf-dismiss"' in html and 'id="wf-dismiss"' in html
76
+ assert 'class="wf-backdrop" for="wf-dismiss"' in html # click-elsewhere to close
77
+ assert 'class="wf-x" for="wf-dismiss"' in html # the styled ✕ button
78
+ assert html.count('for="wf-dismiss"') == 2
79
+
80
+ def test_tints_with_the_winning_bots_hue(self):
81
+ html = render_winner(self._vm())
82
+ assert "--win-hue:200" in html # the analyst's hue drives the glow
83
+
84
+ def test_unknown_winner_id_still_celebrates_the_label(self):
85
+ # a winner not present in the cast (legacy/edge) still gets a card, just no chips.
86
+ html = render_winner(self._vm(winner="ghost", winner_label="Ghost"))
87
+ assert 'class="winner-fx"' in html
88
+ assert "Ghost" in html
89
+ assert "wf-model" not in html # no model chip when the bot isn't in the cast
90
+
91
+
92
+ class TestTeamWin:
93
+ def _vm(self, **over) -> dict:
94
+ vm = {
95
+ "cast": [
96
+ {"id": "spy-cara", "name": "spy-cara", "archetype": "the herd", "model": "M", "hue": 95},
97
+ {"id": "spy-bex", "name": "spy-bex", "archetype": "the herd", "model": "M", "hue": 95},
98
+ {"id": "spy-nil", "name": "spy-nil", "archetype": "the spy", "model": "M", "hue": 20},
99
+ ],
100
+ "teams": {"herd": ["spy-cara", "spy-bex"], "spy": ["spy-nil"]},
101
+ "verdict": {
102
+ "winner": "herd",
103
+ "winner_label": "Team Herd",
104
+ "winner_kind": "team",
105
+ "correct": True,
106
+ },
107
+ }
108
+ vm["verdict"].update(over)
109
+ return vm
110
+
111
+ def test_lines_up_the_roster_and_names_the_losing_side(self):
112
+ html = render_winner(self._vm())
113
+ assert "Team Herd" in html
114
+ assert "spy-cara" in html and "spy-bex" in html # the winning roster as chips
115
+ assert "spy-nil" not in html # the losing member is not lined up as a champion
116
+ assert "Team Spy" in html # the dethroned side is named
117
+ assert "Champions" in html
118
+
119
+ def test_ground_truth_miss_is_a_clean_getaway(self):
120
+ # the spy slipped past the herd: still a win for the spy, celebrated with a wink.
121
+ html = render_winner(self._vm(winner="spy", winner_label="Team Spy", correct=False))
122
+ assert "Clean Getaway" in html
123
+ assert "&#129399;" in html # raccoon, not trophy
124
+ assert "&#127942;" not in html
125
+
126
+
127
+ class TestEscaping:
128
+ def test_label_is_escaped(self):
129
+ vm = {"verdict": {"winner": "x", "winner_label": "<script>x</script>", "winner_kind": "agent"}, "cast": [], "teams": {}}
130
+ html = render_winner(vm)
131
+ assert "<script>" not in html and "&lt;script&gt;" in html