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 +1 -1
- docs/architecture/fishbowl-ui.md +29 -0
- src/core/conductor.py +59 -3
- src/ui/fishbowl/app.py +76 -6
- src/ui/fishbowl/assets/styles.css +87 -0
- src/ui/fishbowl/render/winner.py +149 -0
- src/ui/fishbowl/session.py +32 -0
- src/ui/fishbowl/show.py +8 -0
- src/ui/fishbowl/view_model.py +3 -0
- tests/test_fishbowl_judging.py +146 -0
- tests/test_fishbowl_winner.py +131 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 920 |
-
|
| 921 |
-
return (new_k, *
|
| 922 |
-
|
| 923 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "🥷", "Clean Getaway", "Slipped away without a trace."
|
| 121 |
+
elif kind == "team":
|
| 122 |
+
glyph, eyebrow, cheer = "🏆", "Champions", "They read the room — and won it."
|
| 123 |
+
else:
|
| 124 |
+
glyph, eyebrow, cheer = "🏆", "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">×</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 "🏆" 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 "🥷" in html # raccoon, not trophy
|
| 124 |
+
assert "🏆" 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 "<script>" in html
|