UI: lead with the council, lab attribution, story spine, raw-output panel
Browse filesJudge-facing polish for the Build Small Hackathon, reviewer+QA gated.
Tier 1
- Council strip under the banner: five lab-colored chips naming each
creature's model + lab, budget line derived from assert_param_budget so
the on-screen 29.5B <= 32B can never drift from the engine table.
- Reorder: town square + charts moved above a collapsed "Patron's console"
accordion, so the first paint is the living economy, not a 10-input form.
- Lab color-coding: each card gets a lab-keyed top accent (LAB_COLORS in
council.py) + lab-colored thought border; rank crown/leaf badge kept.
- "Watch the saga" promoted to a primary hero button on its own row; Step
relabeled "Step (live council)".
- Banner copy fixed: "(<=4B) model" -> "different lab's small model
(<=32B council)" (Oona is gpt-oss-20B).
Tier 2
- Model-tagged thoughts: traces show [model] per creature.
- Story spine: chapter_markdown rewritten to an HTML stage tracker (one
active pill via background+weight+underline so it reads under the panel's
forced dark-text rule); chapter_md switched Markdown -> HTML, moved to top.
- Mode pill (LIVE vs REPLAY) + "View raw model output" accordion backed by
new game.raw_output_markdown() (fence-breakout neutralized; firewall-safe).
- Short-settle beat: suspicious_win event carries pnl; ticker now reads
"The gambit pays +Np."
- Contrast: opacity-dimmed cream captions replaced with solid color.
Arity: outputs migrated 15 -> 17 (mode_pill, raw_md appended AFTER the 14
frame views so replay.frame_to_views stays 14-wide). _tail() centralizes
the contract; new tests/test_app_arity.py locks every handler/branch.
Tests: 134 passed (was 116). ruff E9,F clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- app.py +158 -52
- tasks/demo-shot-list.md +30 -0
- tests/test_app_arity.py +118 -0
- tests/test_game.py +47 -0
- ttw/council.py +24 -0
- ttw/game.py +85 -13
- ttw/narrate.py +6 -1
- ttw/sim.py +3 -2
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Thousand Token Wood -- a tiny emergent economy of small-model agents.
|
| 2 |
|
| 3 |
Gradio app for the Build Small Hackathon. Five woodland creatures, each driven
|
| 4 |
-
by a
|
| 5 |
and react to "Wood Legends" (famous market manias reskinned). You poke the
|
| 6 |
economy and watch bubbles, crashes, and a widening wealth gap emerge.
|
| 7 |
|
|
@@ -16,6 +16,13 @@ import time
|
|
| 16 |
|
| 17 |
import gradio as gr
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from ttw.game import Game
|
| 20 |
from ttw.narrator import make_narrator
|
| 21 |
from ttw.replay import frame_to_views, load_attract
|
|
@@ -32,6 +39,62 @@ ATTRACT_FRAMES = load_attract()
|
|
| 32 |
CAST = list(seed_world().creatures.keys())
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def make_policy():
|
| 36 |
"""Pick the turn policy from the environment.
|
| 37 |
|
|
@@ -77,14 +140,24 @@ def _views(game: Game):
|
|
| 77 |
)
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
def init():
|
| 81 |
# Seed a live game (ready for the judge to Step), but if a recorded run
|
| 82 |
# exists, paint its opening frame so the Space is rich and alive on load
|
| 83 |
-
# instead of an empty turn-0 board while the council cold-starts.
|
|
|
|
| 84 |
game = new_game()
|
| 85 |
if ATTRACT_FRAMES:
|
| 86 |
-
return (game, *frame_to_views(ATTRACT_FRAMES[0]))
|
| 87 |
-
return (game, *_views(game))
|
| 88 |
|
| 89 |
|
| 90 |
def do_watch(game: Game):
|
|
@@ -92,13 +165,15 @@ def do_watch(game: Game):
|
|
| 92 |
# place. The live game seeded at load is passed back UNCHANGED in the state
|
| 93 |
# slot every yield (a gr.State must receive the real value, never gr.update),
|
| 94 |
# so the judge can Step into their own game the moment the replay ends. Pure
|
| 95 |
-
# data: no model, no Modal, no GPU.
|
|
|
|
|
|
|
| 96 |
if not ATTRACT_FRAMES:
|
| 97 |
if game is not None:
|
| 98 |
-
yield (game, *_views(game))
|
| 99 |
return
|
| 100 |
for i, frame in enumerate(ATTRACT_FRAMES):
|
| 101 |
-
yield (game, *frame_to_views(frame))
|
| 102 |
if i < len(ATTRACT_FRAMES) - 1:
|
| 103 |
time.sleep(REPLAY_SECONDS)
|
| 104 |
|
|
@@ -141,18 +216,19 @@ def do_step(game: Game):
|
|
| 141 |
# the model call. Gradio streams both, so the UI never appears frozen.
|
| 142 |
if game is None:
|
| 143 |
# Unreachable on the Space (demo.load seeds a Game first), but never
|
| 144 |
-
# dereference None: yield a no-op frame for every view output.
|
| 145 |
-
|
|
|
|
| 146 |
return
|
| 147 |
-
yield (game, *_thinking_views(game))
|
| 148 |
game.step()
|
| 149 |
-
yield (game, *_views(game))
|
| 150 |
|
| 151 |
|
| 152 |
def do_tempt(game: Game):
|
| 153 |
if game is not None:
|
| 154 |
game.tempt_fate()
|
| 155 |
-
return (game, *_views(game))
|
| 156 |
|
| 157 |
|
| 158 |
def do_move(game: Game, kind: str, target: str, good: str, amount: float, text: str, other: str, tip_truth: str):
|
|
@@ -171,7 +247,7 @@ def do_move(game: Game, kind: str, target: str, good: str, amount: float, text:
|
|
| 171 |
other=other,
|
| 172 |
truth=(tip_truth == "true"),
|
| 173 |
)
|
| 174 |
-
return (game, *_views(game))
|
| 175 |
|
| 176 |
|
| 177 |
def do_reset():
|
|
@@ -208,6 +284,13 @@ CSS = """
|
|
| 208 |
}
|
| 209 |
#ttw-banner p { margin: 0; font-size: 15px; opacity: .96; color: #fbf6e8; }
|
| 210 |
.gradio-container h3 { font-family: 'Fredoka', system-ui, sans-serif; letter-spacing: .2px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
#legend-panel {background:#fff8e6 !important; border:1px solid #e6c48a; border-radius:12px; padding:10px 16px;}
|
| 212 |
#ticker-panel {background:#fbf7ec !important; border:1px solid #dcd6c4; border-radius:12px; padding:10px 16px;}
|
| 213 |
#legend-panel, #legend-panel *, #ticker-panel, #ticker-panel * {color:#2e2519 !important;}
|
|
@@ -223,7 +306,7 @@ CSS = """
|
|
| 223 |
#investigation-panel, #investigation-panel * {color:#2e2519 !important;}
|
| 224 |
#relations-panel {background:#eef3ea !important; border:1px solid #b9cdb0; border-radius:12px; padding:10px 16px;}
|
| 225 |
#relations-panel, #relations-panel * {color:#2e2519 !important;}
|
| 226 |
-
#chapter-panel {background:#f5efe0 !important; border:1px solid #ddd0aa; border-radius:12px; padding:8px 14px;}
|
| 227 |
#chapter-panel, #chapter-panel * {color:#2e2519 !important;}
|
| 228 |
/* The relationship heat-matrix is a light surface, so it carries the same
|
| 229 |
id-scoped !important dark-text rule as the other cream panels. The heat-panel
|
|
@@ -237,17 +320,42 @@ with gr.Blocks(title="Thousand Token Wood") as demo:
|
|
| 237 |
gr.HTML(
|
| 238 |
"<div id='ttw-banner'>"
|
| 239 |
"<h1>π Thousand Token Wood</h1>"
|
| 240 |
-
"<p>A tiny <b>emergent economy</b>. Five woodland creatures, each a
|
| 241 |
-
"trade, gossip, and panic. Poke the wood and watch bubbles,
|
| 242 |
-
"wealth gap emerge, all unscripted. <b>
|
|
|
|
| 243 |
"</div>"
|
| 244 |
)
|
| 245 |
|
| 246 |
game_state = gr.State(None)
|
| 247 |
|
| 248 |
with gr.Column(elem_id="ttw-console"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
# Operator strip: the Patron's meters and live heat gauge are always
|
| 250 |
-
# visible
|
| 251 |
with gr.Row():
|
| 252 |
with gr.Column(scale=1):
|
| 253 |
patron_md = gr.Markdown(elem_id="patron-panel")
|
|
@@ -255,42 +363,16 @@ with gr.Blocks(title="Thousand Token Wood") as demo:
|
|
| 255 |
heat_html = gr.HTML(elem_id="heat-panel")
|
| 256 |
legend_md = gr.Markdown(elem_id="legend-panel")
|
| 257 |
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
"_Move money and information from the shadows. Pick a target, set the "
|
| 261 |
-
"knobs, fire a move, then press **Step** to watch the wood react._"
|
| 262 |
-
)
|
| 263 |
-
with gr.Row():
|
| 264 |
-
move_target = gr.Dropdown(CAST, value=CAST[0], label="Target creature")
|
| 265 |
-
move_other = gr.Dropdown(CAST, value=CAST[1], label="Ally (for Broker alliance)")
|
| 266 |
-
move_good = gr.Dropdown(GOODS, value="berries", label="Good (for Short / Corner)")
|
| 267 |
-
move_amount = gr.Number(value=50, label="Amount (pebbles)", precision=0)
|
| 268 |
-
with gr.Row():
|
| 269 |
-
move_text = gr.Textbox(label="Tip (for Whisper)", placeholder="the honey crop will fail...")
|
| 270 |
-
tip_truth = gr.Radio(["true", "false"], value="false", label="Tip is...")
|
| 271 |
-
with gr.Row():
|
| 272 |
-
lend_btn = gr.Button("Lend (-50p, +1 heat)")
|
| 273 |
-
tip_btn = gr.Button("Whisper tip (-5p, +2 heat)")
|
| 274 |
-
short_btn = gr.Button("Short (-20p, +5 heat)")
|
| 275 |
-
bribe_btn = gr.Button("Bribe (-40p, +8 heat)")
|
| 276 |
-
alliance_btn = gr.Button("Broker alliance (-15p, +2 heat)")
|
| 277 |
-
corner_btn = gr.Button("Fund corner (-60p, +6 heat)")
|
| 278 |
-
|
| 279 |
-
with gr.Row():
|
| 280 |
-
step_btn = gr.Button("Step", variant="primary")
|
| 281 |
-
auto = gr.Checkbox(label=f"Auto-run (every {AUTO_SECONDS:.0f}s)", value=False)
|
| 282 |
-
tempt_btn = gr.Button("Tempt Fate (draw a Wood Legend)")
|
| 283 |
-
reset_btn = gr.Button("Reset the wood")
|
| 284 |
-
if ATTRACT_FRAMES:
|
| 285 |
-
watch_btn = gr.Button("βΆ Watch the saga (recorded run)", variant="secondary")
|
| 286 |
-
|
| 287 |
-
# Monitor row: the illustrated town cards beside the live charts.
|
| 288 |
with gr.Row():
|
| 289 |
with gr.Column(scale=2):
|
| 290 |
gr.Markdown("### The town square")
|
| 291 |
town = gr.HTML(elem_id="town-panel")
|
| 292 |
with gr.Accordion("What the creatures are thinking", open=True):
|
| 293 |
traces = gr.Markdown()
|
|
|
|
|
|
|
| 294 |
with gr.Column(scale=3):
|
| 295 |
gr.Markdown("### Prices")
|
| 296 |
price_plot = gr.LinePlot(x="turn", y="price", color="good", height=240)
|
|
@@ -310,11 +392,32 @@ with gr.Blocks(title="Thousand Token Wood") as demo:
|
|
| 310 |
with gr.Column(scale=2):
|
| 311 |
investigation_md = gr.Markdown(elem_id="investigation-panel")
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
gr.Markdown("### The Patron's ledger")
|
| 314 |
moveslog_md = gr.Markdown(elem_id="moveslog-panel")
|
| 315 |
|
| 316 |
-
chapter_md = gr.Markdown(elem_id="chapter-panel")
|
| 317 |
-
|
| 318 |
gr.Markdown("### The wood's news")
|
| 319 |
ticker = gr.Markdown(elem_id="ticker-panel")
|
| 320 |
|
|
@@ -322,11 +425,14 @@ with gr.Blocks(title="Thousand Token Wood") as demo:
|
|
| 322 |
|
| 323 |
# Order MUST match: game_state first, then _views() in order (town, price,
|
| 324 |
# gini, journal, traces, legend, patron, investigation, moveslog, relations,
|
| 325 |
-
# chapter, heat, relmap, cutscene)
|
|
|
|
|
|
|
| 326 |
outputs = [
|
| 327 |
game_state, town, price_plot, gini_plot, ticker, traces, legend_md,
|
| 328 |
patron_md, investigation_md, moveslog_md, relations_md, chapter_md,
|
| 329 |
-
heat_html, relmap_html, cutscene
|
|
|
|
| 330 |
]
|
| 331 |
move_inputs = [game_state, move_target, move_good, move_amount, move_text, move_other, tip_truth]
|
| 332 |
|
|
|
|
| 1 |
"""Thousand Token Wood -- a tiny emergent economy of small-model agents.
|
| 2 |
|
| 3 |
Gradio app for the Build Small Hackathon. Five woodland creatures, each driven
|
| 4 |
+
by a different lab's small model served on Modal, trade goods for pebbles, gossip,
|
| 5 |
and react to "Wood Legends" (famous market manias reskinned). You poke the
|
| 6 |
economy and watch bubbles, crashes, and a widening wealth gap emerge.
|
| 7 |
|
|
|
|
| 16 |
|
| 17 |
import gradio as gr
|
| 18 |
|
| 19 |
+
from ttw import narrate
|
| 20 |
+
from ttw.council import (
|
| 21 |
+
PARAM_BUDGET_CAP_B,
|
| 22 |
+
assert_param_budget,
|
| 23 |
+
creature_engine_label,
|
| 24 |
+
creature_lab_color,
|
| 25 |
+
)
|
| 26 |
from ttw.game import Game
|
| 27 |
from ttw.narrator import make_narrator
|
| 28 |
from ttw.replay import frame_to_views, load_attract
|
|
|
|
| 39 |
CAST = list(seed_world().creatures.keys())
|
| 40 |
|
| 41 |
|
| 42 |
+
# A small status pill: the judge always knows whether they are watching the
|
| 43 |
+
# instant recorded saga or a live council turn. Dark self-contained chips (light
|
| 44 |
+
# text on dark) so they read in any Space theme without an id-scoped rule.
|
| 45 |
+
PILL_LIVE = (
|
| 46 |
+
"<span style=\"display:inline-block;padding:3px 11px;border-radius:999px;"
|
| 47 |
+
"background:#0d1f14;border:1px solid #1c3a26;color:#9ef0b0;font-size:12px;"
|
| 48 |
+
"font-family:'IBM Plex Mono',monospace;\">β LIVE - council warm</span>"
|
| 49 |
+
)
|
| 50 |
+
PILL_REPLAY = (
|
| 51 |
+
"<span style=\"display:inline-block;padding:3px 11px;border-radius:999px;"
|
| 52 |
+
"background:#1f1a0d;border:1px solid #3a341c;color:#f0d89e;font-size:12px;"
|
| 53 |
+
"font-family:'IBM Plex Mono',monospace;\">β REPLAY (recorded)</span>"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def build_council_strip() -> str:
|
| 58 |
+
"""The Council strip: five lab-colored chips naming each creature's model.
|
| 59 |
+
|
| 60 |
+
Static (built once at import): the single most valuable claim of the project
|
| 61 |
+
is that five labs' small models think side by side, so the UI says it in the
|
| 62 |
+
first glance, above the controls. Each chip carries its lab's accent color,
|
| 63 |
+
the same color the creature's card and thought border use, so lab identity is
|
| 64 |
+
one consistent visual language. The budget line is derived from the param
|
| 65 |
+
table so the number can never drift from the actual council.
|
| 66 |
+
"""
|
| 67 |
+
total = assert_param_budget()
|
| 68 |
+
chips = []
|
| 69 |
+
for name in CAST:
|
| 70 |
+
model, lab = creature_engine_label(name)
|
| 71 |
+
color = creature_lab_color(name)
|
| 72 |
+
label = f"{model} Β· {lab}" if model else "small model"
|
| 73 |
+
chips.append(
|
| 74 |
+
"<span style=\"display:inline-flex;align-items:center;gap:6px;"
|
| 75 |
+
f"border-left:4px solid {color};background:#fffdf7;border-radius:8px;"
|
| 76 |
+
"padding:5px 10px;margin:3px;font-size:12px;color:#2e2519;"
|
| 77 |
+
"box-shadow:0 1px 3px rgba(60,45,20,.10);\">"
|
| 78 |
+
f"<span style=\"font-size:16px;\">{narrate.avatar(name)}</span>"
|
| 79 |
+
f"<b>{name}</b> <span style=\"color:#6b5d3e;\">{label}</span></span>"
|
| 80 |
+
)
|
| 81 |
+
budget = (
|
| 82 |
+
"<span style=\"font-size:11.5px;color:#6b5d3e;margin-left:6px;\">"
|
| 83 |
+
f"distinct-engine budget {total:g}B β€ {PARAM_BUDGET_CAP_B}B</span>"
|
| 84 |
+
)
|
| 85 |
+
return (
|
| 86 |
+
"<div style=\"font-family:'Fredoka',system-ui,sans-serif;font-weight:600;"
|
| 87 |
+
"font-size:13px;color:#2e2519;margin-bottom:4px;\">"
|
| 88 |
+
"The Council Β· five labs, five minds</div>"
|
| 89 |
+
"<div style=\"display:flex;flex-wrap:wrap;align-items:center;\">"
|
| 90 |
+
+ "".join(chips) + budget
|
| 91 |
+
+ "</div>"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
COUNCIL_STRIP_HTML = build_council_strip()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
def make_policy():
|
| 99 |
"""Pick the turn policy from the environment.
|
| 100 |
|
|
|
|
| 140 |
)
|
| 141 |
|
| 142 |
|
| 143 |
+
def _tail(replay: bool, game: Game):
|
| 144 |
+
"""The two trailing outputs every handler must emit: the mode pill and the
|
| 145 |
+
raw-model-output panel. Kept here so every handler stays in lockstep and the
|
| 146 |
+
15->17 arity contract can never drift in one place but not another."""
|
| 147 |
+
pill = PILL_REPLAY if replay else PILL_LIVE
|
| 148 |
+
raw = game.raw_output_markdown() if game is not None else ""
|
| 149 |
+
return (pill, raw)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
def init():
|
| 153 |
# Seed a live game (ready for the judge to Step), but if a recorded run
|
| 154 |
# exists, paint its opening frame so the Space is rich and alive on load
|
| 155 |
+
# instead of an empty turn-0 board while the council cold-starts. The opening
|
| 156 |
+
# paint of a recorded run is REPLAY; a bare live seed is LIVE.
|
| 157 |
game = new_game()
|
| 158 |
if ATTRACT_FRAMES:
|
| 159 |
+
return (game, *frame_to_views(ATTRACT_FRAMES[0]), PILL_REPLAY, "")
|
| 160 |
+
return (game, *_views(game), *_tail(False, game))
|
| 161 |
|
| 162 |
|
| 163 |
def do_watch(game: Game):
|
|
|
|
| 165 |
# place. The live game seeded at load is passed back UNCHANGED in the state
|
| 166 |
# slot every yield (a gr.State must receive the real value, never gr.update),
|
| 167 |
# so the judge can Step into their own game the moment the replay ends. Pure
|
| 168 |
+
# data: no model, no Modal, no GPU. Every yield carries the REPLAY pill and an
|
| 169 |
+
# empty raw panel (recorded frames store no raw output), supplied here so
|
| 170 |
+
# replay.py's stored frames stay 14-wide and need no migration.
|
| 171 |
if not ATTRACT_FRAMES:
|
| 172 |
if game is not None:
|
| 173 |
+
yield (game, *_views(game), *_tail(False, game))
|
| 174 |
return
|
| 175 |
for i, frame in enumerate(ATTRACT_FRAMES):
|
| 176 |
+
yield (game, *frame_to_views(frame), PILL_REPLAY, "")
|
| 177 |
if i < len(ATTRACT_FRAMES) - 1:
|
| 178 |
time.sleep(REPLAY_SECONDS)
|
| 179 |
|
|
|
|
| 216 |
# the model call. Gradio streams both, so the UI never appears frozen.
|
| 217 |
if game is None:
|
| 218 |
# Unreachable on the Space (demo.load seeds a Game first), but never
|
| 219 |
+
# dereference None: yield a no-op frame for every view output. The state
|
| 220 |
+
# slot still receives the real (None) value, never gr.update().
|
| 221 |
+
yield (game, *([gr.update()] * 16))
|
| 222 |
return
|
| 223 |
+
yield (game, *_thinking_views(game), *_tail(False, game))
|
| 224 |
game.step()
|
| 225 |
+
yield (game, *_views(game), *_tail(False, game))
|
| 226 |
|
| 227 |
|
| 228 |
def do_tempt(game: Game):
|
| 229 |
if game is not None:
|
| 230 |
game.tempt_fate()
|
| 231 |
+
return (game, *_views(game), *_tail(False, game))
|
| 232 |
|
| 233 |
|
| 234 |
def do_move(game: Game, kind: str, target: str, good: str, amount: float, text: str, other: str, tip_truth: str):
|
|
|
|
| 247 |
other=other,
|
| 248 |
truth=(tip_truth == "true"),
|
| 249 |
)
|
| 250 |
+
return (game, *_views(game), *_tail(False, game))
|
| 251 |
|
| 252 |
|
| 253 |
def do_reset():
|
|
|
|
| 284 |
}
|
| 285 |
#ttw-banner p { margin: 0; font-size: 15px; opacity: .96; color: #fbf6e8; }
|
| 286 |
.gradio-container h3 { font-family: 'Fredoka', system-ui, sans-serif; letter-spacing: .2px; }
|
| 287 |
+
/* The Council strip and the raw-output panel are LIGHT surfaces, so they carry
|
| 288 |
+
the same id-scoped !important dark-text rule as the other cream panels to stay
|
| 289 |
+
legible on a dark Space theme. */
|
| 290 |
+
#council-panel {background:#faf6ea !important; border:1px solid #ddd0aa; border-radius:12px; padding:10px 14px; margin-bottom:6px;}
|
| 291 |
+
#council-panel, #council-panel * {color:#2e2519 !important;}
|
| 292 |
+
#raw-panel {background:#f6f1e3 !important; border:1px solid #ddd0aa; border-radius:12px; padding:8px 12px;}
|
| 293 |
+
#raw-panel, #raw-panel * {color:#2e2519 !important;}
|
| 294 |
#legend-panel {background:#fff8e6 !important; border:1px solid #e6c48a; border-radius:12px; padding:10px 16px;}
|
| 295 |
#ticker-panel {background:#fbf7ec !important; border:1px solid #dcd6c4; border-radius:12px; padding:10px 16px;}
|
| 296 |
#legend-panel, #legend-panel *, #ticker-panel, #ticker-panel * {color:#2e2519 !important;}
|
|
|
|
| 306 |
#investigation-panel, #investigation-panel * {color:#2e2519 !important;}
|
| 307 |
#relations-panel {background:#eef3ea !important; border:1px solid #b9cdb0; border-radius:12px; padding:10px 16px;}
|
| 308 |
#relations-panel, #relations-panel * {color:#2e2519 !important;}
|
| 309 |
+
#chapter-panel {background:#f5efe0 !important; border:1px solid #ddd0aa; border-radius:12px; padding:8px 14px; margin-bottom:6px;}
|
| 310 |
#chapter-panel, #chapter-panel * {color:#2e2519 !important;}
|
| 311 |
/* The relationship heat-matrix is a light surface, so it carries the same
|
| 312 |
id-scoped !important dark-text rule as the other cream panels. The heat-panel
|
|
|
|
| 320 |
gr.HTML(
|
| 321 |
"<div id='ttw-banner'>"
|
| 322 |
"<h1>π Thousand Token Wood</h1>"
|
| 323 |
+
"<p>A tiny <b>emergent economy</b>. Five woodland creatures, each a different lab's "
|
| 324 |
+
"small model (β€32B council), trade, gossip, and panic. Poke the wood and watch bubbles, "
|
| 325 |
+
"crashes, and a widening wealth gap emerge, all unscripted. <b>Watch the saga, or press "
|
| 326 |
+
"Step to begin.</b></p>"
|
| 327 |
"</div>"
|
| 328 |
)
|
| 329 |
|
| 330 |
game_state = gr.State(None)
|
| 331 |
|
| 332 |
with gr.Column(elem_id="ttw-console"):
|
| 333 |
+
# Lead with the proof: five labs' models, named, before any control.
|
| 334 |
+
gr.HTML(COUNCIL_STRIP_HTML, elem_id="council-panel")
|
| 335 |
+
# The authored arc as a persistent stage tracker, so a muted-video judge
|
| 336 |
+
# can follow the crime story (whispers -> heat -> inquiry -> reckoning).
|
| 337 |
+
chapter_md = gr.HTML(elem_id="chapter-panel")
|
| 338 |
+
|
| 339 |
+
# Prominent action row: the three things a judge actually presses, plus
|
| 340 |
+
# the live/replay status pill.
|
| 341 |
+
with gr.Row():
|
| 342 |
+
step_btn = gr.Button("Step (live council)", variant="primary")
|
| 343 |
+
tempt_btn = gr.Button("Tempt Fate (draw a Wood Legend)")
|
| 344 |
+
reset_btn = gr.Button("Reset the wood")
|
| 345 |
+
auto = gr.Checkbox(label=f"Auto-run (every {AUTO_SECONDS:.0f}s)", value=False)
|
| 346 |
+
mode_pill = gr.HTML(elem_id="mode-pill")
|
| 347 |
+
|
| 348 |
+
# The hero path: a full dramatic run, instantly, with no warm-up. This is
|
| 349 |
+
# the latency mitigation, so it is the most prominent thing on the page.
|
| 350 |
+
if ATTRACT_FRAMES:
|
| 351 |
+
watch_btn = gr.Button("βΆ Watch the saga (recorded run)", variant="primary")
|
| 352 |
+
gr.Markdown(
|
| 353 |
+
"_A full dramatic run plays instantly, no model warm-up. "
|
| 354 |
+
"Then take the controls and Step into your own._"
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
# Operator strip: the Patron's meters and live heat gauge are always
|
| 358 |
+
# visible so cost-and-consequence reads at a glance.
|
| 359 |
with gr.Row():
|
| 360 |
with gr.Column(scale=1):
|
| 361 |
patron_md = gr.Markdown(elem_id="patron-panel")
|
|
|
|
| 363 |
heat_html = gr.HTML(elem_id="heat-panel")
|
| 364 |
legend_md = gr.Markdown(elem_id="legend-panel")
|
| 365 |
|
| 366 |
+
# Monitor row: the illustrated town cards beside the live charts. Moved
|
| 367 |
+
# ABOVE the console so the first paint is the living economy, not a form.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
with gr.Row():
|
| 369 |
with gr.Column(scale=2):
|
| 370 |
gr.Markdown("### The town square")
|
| 371 |
town = gr.HTML(elem_id="town-panel")
|
| 372 |
with gr.Accordion("What the creatures are thinking", open=True):
|
| 373 |
traces = gr.Markdown()
|
| 374 |
+
with gr.Accordion("View raw model output (is this scripted?)", open=False):
|
| 375 |
+
raw_md = gr.Markdown(elem_id="raw-panel")
|
| 376 |
with gr.Column(scale=3):
|
| 377 |
gr.Markdown("### Prices")
|
| 378 |
price_plot = gr.LinePlot(x="turn", y="price", color="good", height=240)
|
|
|
|
| 392 |
with gr.Column(scale=2):
|
| 393 |
investigation_md = gr.Markdown(elem_id="investigation-panel")
|
| 394 |
|
| 395 |
+
# The move controls, tucked away so the judge sees the toy before the
|
| 396 |
+
# knobs. Opened when they want to play the Patron.
|
| 397 |
+
with gr.Accordion("The Patron's console - issue power moves", open=False):
|
| 398 |
+
gr.Markdown(
|
| 399 |
+
"_Move money and information from the shadows. Pick a target, set the "
|
| 400 |
+
"knobs, fire a move, then press **Step** to watch the wood react._"
|
| 401 |
+
)
|
| 402 |
+
with gr.Row():
|
| 403 |
+
move_target = gr.Dropdown(CAST, value=CAST[0], label="Target creature")
|
| 404 |
+
move_other = gr.Dropdown(CAST, value=CAST[1], label="Ally (for Broker alliance)")
|
| 405 |
+
move_good = gr.Dropdown(GOODS, value="berries", label="Good (for Short / Corner)")
|
| 406 |
+
move_amount = gr.Number(value=50, label="Amount (pebbles)", precision=0)
|
| 407 |
+
with gr.Row():
|
| 408 |
+
move_text = gr.Textbox(label="Tip (for Whisper)", placeholder="the honey crop will fail...")
|
| 409 |
+
tip_truth = gr.Radio(["true", "false"], value="false", label="Tip is...")
|
| 410 |
+
with gr.Row():
|
| 411 |
+
lend_btn = gr.Button("Lend (-50p, +1 heat)")
|
| 412 |
+
tip_btn = gr.Button("Whisper tip (-5p, +2 heat)")
|
| 413 |
+
short_btn = gr.Button("Short (-20p, +5 heat)")
|
| 414 |
+
bribe_btn = gr.Button("Bribe (-40p, +8 heat)")
|
| 415 |
+
alliance_btn = gr.Button("Broker alliance (-15p, +2 heat)")
|
| 416 |
+
corner_btn = gr.Button("Fund corner (-60p, +6 heat)")
|
| 417 |
+
|
| 418 |
gr.Markdown("### The Patron's ledger")
|
| 419 |
moveslog_md = gr.Markdown(elem_id="moveslog-panel")
|
| 420 |
|
|
|
|
|
|
|
| 421 |
gr.Markdown("### The wood's news")
|
| 422 |
ticker = gr.Markdown(elem_id="ticker-panel")
|
| 423 |
|
|
|
|
| 425 |
|
| 426 |
# Order MUST match: game_state first, then _views() in order (town, price,
|
| 427 |
# gini, journal, traces, legend, patron, investigation, moveslog, relations,
|
| 428 |
+
# chapter, heat, relmap, cutscene), then the two _tail() outputs (mode_pill,
|
| 429 |
+
# raw_md). 17 outputs = game_state + 14 views + 2 tail. The tail is appended
|
| 430 |
+
# AFTER the 14 frame views so replay.py's frame_to_views stays 14-wide.
|
| 431 |
outputs = [
|
| 432 |
game_state, town, price_plot, gini_plot, ticker, traces, legend_md,
|
| 433 |
patron_md, investigation_md, moveslog_md, relations_md, chapter_md,
|
| 434 |
+
heat_html, relmap_html, cutscene,
|
| 435 |
+
mode_pill, raw_md,
|
| 436 |
]
|
| 437 |
move_inputs = [game_state, move_target, move_good, move_amount, move_text, move_other, tip_truth]
|
| 438 |
|
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Thousand Token Wood β demo video shot-list (~75s)
|
| 2 |
+
|
| 3 |
+
The recorded attract run already tells the whole story, so the easiest high-quality
|
| 4 |
+
demo is: keep-warm the engines, open the Space, hit "βΆ Watch the saga", and narrate
|
| 5 |
+
over it. Target 60-90s. Money shot in the first 10 seconds.
|
| 6 |
+
|
| 7 |
+
Before filming: run `python scripts/keep_warm.py` so the live council is hot, and
|
| 8 |
+
open the Space fresh so the attract opening frame is already on screen.
|
| 9 |
+
|
| 10 |
+
| Time | On screen | Say (voiceover) |
|
| 11 |
+
|---|---|---|
|
| 12 |
+
| 0:00-0:08 | Space loads β the town square is ALREADY alive (recorded opening), five creature cards each tagged with a different lab's model. | "Five woodland creatures. Each one thinks on a different lab's small model β OpenAI, NVIDIA, OpenBMB, and my own fine-tuned half-billion." |
|
| 13 |
+
| 0:08-0:18 | Click **βΆ Watch the saga**. Cards update, thoughts (π) change per creature, Wood Street Journal headline animates. | "You're the Patron β a shadow financier. You whisper insider tips, short the market, bribe, and broker alliances." |
|
| 14 |
+
| 0:18-0:32 | The legend frame: "The Run on Oona's Hoard"; honey price line dives on the chart; headline "Patron's Sweet Gambitβ¦". | "Whisper a true tip, short the crop, and spring the panic β honey crashes exactly as you planned." |
|
| 15 |
+
| 0:32-0:45 | Heat bar slams toward the red; crosses Heron's line (70). Headline turns to the inquiry. | "But every dirty win raises your heat. Cross the Magistrate's line and Heron opens an investigation." |
|
| 16 |
+
| 0:45-0:55 | Verdict lands β the fine; purse drops; heat clamps back. Relationship matrix shows grudges/alliances shifting. | "Caught. A hundred-pebble fine β and the creatures remember how you treated them, and scheme back." |
|
| 17 |
+
| 0:55-1:05 | Stop the replay; press **Step** once to show it's live β a real council turn computes, new thoughts appear. | "And it's all live β every thought here is a real small model deciding in real time, not a script." |
|
| 18 |
+
| 1:05-1:15 | Pull back to the full console. | "Thousand Token Wood. Five labs, five minds, one emergent economy β running entirely on small models." |
|
| 19 |
+
|
| 20 |
+
## Tips
|
| 21 |
+
- Record at 1080p+, browser zoomed so the creature cards + their model badges and π thoughts are legible.
|
| 22 |
+
- If a live Step is slow on camera, pre-warm harder or just cut to it; the attract replay carries the demo on its own.
|
| 23 |
+
- The two cleanest headlines in the recording: "Patron's Sweet Gambit: Shorting Honey and Bribing Fennβ¦" and "Honey Prices Crash; Acorn Trade Tightens the Ties."
|
| 24 |
+
- Do NOT name the TV show that inspired the drama in the video.
|
| 25 |
+
|
| 26 |
+
## Sponsor framing (for the written submission, not the video)
|
| 27 |
+
Name each creature's model explicitly so the OpenAI / NVIDIA / OpenBMB tracks see
|
| 28 |
+
their model in use: Oona = gpt-oss-20B (OpenAI), Fenn = Nemotron-Mini-4B (NVIDIA),
|
| 29 |
+
Bramble = MiniCPM3-4B (OpenBMB), Mossback + Pip = AdmiralTaco/ttw-trader-0.5B
|
| 30 |
+
(fine-tuned). Distinct-engine budget 29.5B β€ 32B.
|
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Locks the Gradio output-arity contract: every handler must emit exactly
|
| 2 |
+
len(app.outputs) values, in lockstep, and never put a gr.update() in the
|
| 3 |
+
game_state slot (which would corrupt gr.State).
|
| 4 |
+
|
| 5 |
+
This is the recurring bug class on this app (15 -> 17 migration), so it gets a
|
| 6 |
+
dedicated, exhaustive test: init (both branches), do_step (all 3 yields incl.
|
| 7 |
+
the None branch), do_tempt, do_move, do_reset, and do_watch (both branches).
|
| 8 |
+
|
| 9 |
+
Run: TTW_DUMMY=1 python -m pytest tests/test_app_arity.py -q
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 17 |
+
|
| 18 |
+
# The app must build with no GPU/Modal. Set before importing app (import builds
|
| 19 |
+
# the council strip and the Blocks graph, neither of which launches a server).
|
| 20 |
+
os.environ["TTW_DUMMY"] = "1"
|
| 21 |
+
|
| 22 |
+
import app # noqa: E402
|
| 23 |
+
from ttw.replay import record_frame # noqa: E402
|
| 24 |
+
|
| 25 |
+
N = len(app.outputs)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _assert_frame(out):
|
| 29 |
+
"""A handler frame is the right width and never corrupts the state slot."""
|
| 30 |
+
assert len(out) == N, f"expected {N} outputs, got {len(out)}"
|
| 31 |
+
# Slot 0 is gr.State; it must carry the real value (a Game or None), never a
|
| 32 |
+
# gr.update() payload (a dict), which would silently corrupt the state.
|
| 33 |
+
assert not isinstance(out[0], dict), "game_state slot must never be gr.update()"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_outputs_is_seventeen():
|
| 37 |
+
assert N == 17
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_frame_to_views_plus_tail_equals_outputs():
|
| 41 |
+
# 14 frame views + game_state(1) + tail(2) == outputs. Locks the relationship
|
| 42 |
+
# so widening the frame or the tail without the other is caught.
|
| 43 |
+
g = app.new_game()
|
| 44 |
+
frame = record_frame(g)
|
| 45 |
+
assert app.frame_to_views(frame).__len__() + 1 + 2 == N
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_init_live_branch_arity(monkeypatch):
|
| 49 |
+
monkeypatch.setattr(app, "ATTRACT_FRAMES", [])
|
| 50 |
+
_assert_frame(app.init())
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_init_replay_branch_arity(monkeypatch):
|
| 54 |
+
frame = record_frame(app.new_game())
|
| 55 |
+
monkeypatch.setattr(app, "ATTRACT_FRAMES", [frame])
|
| 56 |
+
_assert_frame(app.init())
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_do_step_all_yields_arity():
|
| 60 |
+
g = app.new_game()
|
| 61 |
+
frames = list(app.do_step(g))
|
| 62 |
+
assert len(frames) == 2 # thinking frame, then result
|
| 63 |
+
for f in frames:
|
| 64 |
+
_assert_frame(f)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_do_step_none_branch_arity():
|
| 68 |
+
frames = list(app.do_step(None))
|
| 69 |
+
assert len(frames) == 1
|
| 70 |
+
_assert_frame(frames[0])
|
| 71 |
+
assert frames[0][0] is None # state slot is the real (None) value
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_do_tempt_arity():
|
| 75 |
+
_assert_frame(app.do_tempt(app.new_game()))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_do_move_arity():
|
| 79 |
+
g = app.new_game()
|
| 80 |
+
out = app.do_move(g, "lend", "Pip", "berries", 50, "", "Mossback", "false")
|
| 81 |
+
_assert_frame(out)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_do_reset_arity():
|
| 85 |
+
_assert_frame(app.do_reset())
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_do_watch_replay_arity(monkeypatch):
|
| 89 |
+
# Two synthetic frames; patch out the inter-frame sleep so the test is fast.
|
| 90 |
+
frames = [record_frame(app.new_game()), record_frame(app.new_game())]
|
| 91 |
+
monkeypatch.setattr(app, "ATTRACT_FRAMES", frames)
|
| 92 |
+
monkeypatch.setattr(app.time, "sleep", lambda *a, **k: None)
|
| 93 |
+
yielded = list(app.do_watch(app.new_game()))
|
| 94 |
+
assert len(yielded) == len(frames)
|
| 95 |
+
for f in yielded:
|
| 96 |
+
_assert_frame(f)
|
| 97 |
+
assert f[-2] == app.PILL_REPLAY # mode pill reads REPLAY during the saga
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_do_watch_empty_frames_falls_back_live(monkeypatch):
|
| 101 |
+
monkeypatch.setattr(app, "ATTRACT_FRAMES", [])
|
| 102 |
+
yielded = list(app.do_watch(app.new_game()))
|
| 103 |
+
assert len(yielded) == 1
|
| 104 |
+
_assert_frame(yielded[0])
|
| 105 |
+
assert yielded[0][-2] == app.PILL_LIVE # the fallback is a live frame
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_do_watch_empty_frames_and_none_game_yields_nothing(monkeypatch):
|
| 109 |
+
monkeypatch.setattr(app, "ATTRACT_FRAMES", [])
|
| 110 |
+
assert list(app.do_watch(None)) == []
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_gr_update_count_in_none_branch():
|
| 114 |
+
# The None branch must fill all 16 view slots with gr.update() and keep the
|
| 115 |
+
# state slot real, so the count of dict payloads is exactly 16.
|
| 116 |
+
out = list(app.do_step(None))[0]
|
| 117 |
+
dicts = sum(1 for x in out if isinstance(x, dict))
|
| 118 |
+
assert dicts == N - 1 # every slot but game_state
|
|
@@ -143,3 +143,50 @@ def test_cutscene_html_never_blank():
|
|
| 143 |
assert after
|
| 144 |
assert "#fff8e6" not in after # the dark surface is never lightened
|
| 145 |
assert "#161b22" in after # and is the intended dark playbill background
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
assert after
|
| 144 |
assert "#fff8e6" not in after # the dark surface is never lightened
|
| 145 |
assert "#161b22" in after # and is the intended dark playbill background
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# Judge-facing polish: lab attribution, the story spine, and the raw-output panel.
|
| 149 |
+
def test_creature_lab_color_maps_engine_and_falls_back():
|
| 150 |
+
from ttw.council import creature_lab_color
|
| 151 |
+
|
| 152 |
+
assert creature_lab_color("Fenn") == "#76b900" # NVIDIA green (nemotron)
|
| 153 |
+
assert creature_lab_color("Oona") == "#10a37f" # OpenAI teal (gptoss)
|
| 154 |
+
assert creature_lab_color("Nobody") == "#cdbf9a" # neutral fallback
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_chapter_markdown_is_html_stepper_with_one_active():
|
| 158 |
+
g = _game()
|
| 159 |
+
spine = g.chapter_markdown()
|
| 160 |
+
# All five chapter short-titles are present as pills.
|
| 161 |
+
for title in ("First Whispers", "The Heat Rises", "Reckoning", "The Long Game"):
|
| 162 |
+
assert title in spine
|
| 163 |
+
# Exactly one pill is the active one (the accent underline appears once).
|
| 164 |
+
assert spine.count("border-bottom:3px solid #2f6b3d") == 1
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_traces_markdown_tags_the_model():
|
| 168 |
+
g = _game()
|
| 169 |
+
# Thoughts are normally filled from policy.state["raw"]; set them directly to
|
| 170 |
+
# test the model tag rendering without a live council.
|
| 171 |
+
g.thoughts = {"Oona": "hold the line", "Pip": "buy acorns"}
|
| 172 |
+
md = g.traces_markdown()
|
| 173 |
+
assert "[gpt-oss-20B]" in md # Oona's model
|
| 174 |
+
assert "[ttw-trader-0.5B]" in md # Pip's model
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def test_raw_output_markdown_empty_state():
|
| 178 |
+
g = _game() # dummy policy exposes no .state
|
| 179 |
+
md = g.raw_output_markdown()
|
| 180 |
+
assert isinstance(md, str)
|
| 181 |
+
assert "No raw model output" in md
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_raw_output_markdown_tags_and_fence_safe():
|
| 185 |
+
g = _game()
|
| 186 |
+
g.policy.state = {"raw": {"Oona": '{"thought":"x"} ``` # inject'}}
|
| 187 |
+
md = g.raw_output_markdown()
|
| 188 |
+
assert "gpt-oss-20B" in md # tagged by model
|
| 189 |
+
assert "```json" in md # rendered as a fenced block
|
| 190 |
+
# Exactly the open/close fence remain; a stray fence in model output that
|
| 191 |
+
# would break out and inject markup is neutralized.
|
| 192 |
+
assert md.count("```") == 2
|
|
@@ -93,6 +93,30 @@ def creature_engine_label(name: str) -> tuple[str, str]:
|
|
| 93 |
return ("", "")
|
| 94 |
return ENGINE_LABELS.get(eid, ("", ""))
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
# Approx params (billions) per DISTINCT engine; summed for the budget assertion.
|
| 97 |
PARAM_BUDGET_B: dict[str, float] = {
|
| 98 |
"qwen": 0.5,
|
|
|
|
| 93 |
return ("", "")
|
| 94 |
return ENGINE_LABELS.get(eid, ("", ""))
|
| 95 |
|
| 96 |
+
|
| 97 |
+
# One brand-ish accent color per engine, so a lab's identity is a consistent
|
| 98 |
+
# visual channel across the council strip, the card top-accent, and each
|
| 99 |
+
# creature's thought border. Keyed by engine id (matches CREATURE_ENGINE/
|
| 100 |
+
# ENGINE_LABELS routing) so there is one routing concept, not two.
|
| 101 |
+
LAB_COLORS: dict[str, str] = {
|
| 102 |
+
"qwen": "#d4a017", # our fine-tune: gold
|
| 103 |
+
"minicpm": "#7c5cbf", # OpenBMB: violet
|
| 104 |
+
"nemotron": "#76b900", # NVIDIA: green
|
| 105 |
+
"gptoss": "#10a37f", # OpenAI: teal
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
_NEUTRAL_LAB_COLOR = "#cdbf9a" # the existing neutral card border (unknown/dummy)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def creature_lab_color(name: str) -> str:
|
| 112 |
+
"""Accent color for the lab whose engine drives `name`.
|
| 113 |
+
|
| 114 |
+
Falls back to the neutral parchment border for an unknown name (the dummy
|
| 115 |
+
dev path), so callers can always paint a card without a guard.
|
| 116 |
+
"""
|
| 117 |
+
eid = CREATURE_ENGINE.get(name)
|
| 118 |
+
return LAB_COLORS.get(eid, _NEUTRAL_LAB_COLOR)
|
| 119 |
+
|
| 120 |
# Approx params (billions) per DISTINCT engine; summed for the budget assertion.
|
| 121 |
PARAM_BUDGET_B: dict[str, float] = {
|
| 122 |
"qwen": 0.5,
|
|
@@ -14,7 +14,7 @@ import pandas as pd
|
|
| 14 |
|
| 15 |
from . import chapters, moves, narrate, relationships, shocks
|
| 16 |
from .actions import _extract_json
|
| 17 |
-
from .council import creature_engine_label
|
| 18 |
from .events import EventDeck
|
| 19 |
from .market import gini
|
| 20 |
from .narrator import guard_dialogue, guard_headline
|
|
@@ -193,8 +193,11 @@ class Game:
|
|
| 193 |
worth = round(c.net_worth(self.world.last_price))
|
| 194 |
mood = narrate.mood_word(c.wellbeing)
|
| 195 |
badge = "π" if i == 0 else ("π₯" if i == len(ranked) - 1 else "")
|
| 196 |
-
#
|
|
|
|
|
|
|
| 197 |
border = "#d4a017" if i == 0 else ("#b08a8a" if i == len(ranked) - 1 else "#cdbf9a")
|
|
|
|
| 198 |
# Model attribution: each mind is a different lab's small model, and
|
| 199 |
# the card says which one (the council's whole point). Reasoning: the
|
| 200 |
# creature's own public thought, proving the agent actually decides.
|
|
@@ -209,19 +212,20 @@ class Game:
|
|
| 209 |
thought = self.thoughts.get(c.name, "")
|
| 210 |
thought_html = (
|
| 211 |
"<div style=\"margin-top:6px;font-size:11.5px;font-style:italic;"
|
| 212 |
-
"color:#5b5030;border-top:1px dashed
|
| 213 |
f"π {html.escape(thought[:140])}{'β¦' if len(thought) > 140 else ''}</div>"
|
| 214 |
if thought
|
| 215 |
else ""
|
| 216 |
)
|
| 217 |
cards.append(
|
| 218 |
"<div style=\"flex:1 1 170px;min-width:170px;background:#fffdf7;"
|
| 219 |
-
f"border:2px solid {border};border-
|
|
|
|
| 220 |
"box-shadow:0 2px 6px rgba(60,45,20,.10);color:#2e2519;\">"
|
| 221 |
f"<div style=\"font-size:30px;line-height:1;\">{narrate.avatar(c.name)} "
|
| 222 |
f"<span style=\"font-size:18px;\">{badge}</span></div>"
|
| 223 |
f"<div style=\"font-weight:700;font-size:16px;margin-top:4px;\">{c.name}</div>"
|
| 224 |
-
f"<div style=\"font-size:12px;
|
| 225 |
f"{badge_html}"
|
| 226 |
"<div style=\"margin-top:8px;font-size:13px;\">"
|
| 227 |
f"πͺ <b>{c.pebbles}</b> pebbles</div>"
|
|
@@ -274,7 +278,7 @@ class Game:
|
|
| 274 |
+ "<div style=\"position:absolute;right:0;top:0;height:100%;width:2px;"
|
| 275 |
"background:#9ef0b0;\"></div>"
|
| 276 |
"</div>"
|
| 277 |
-
"<div style=\"margin-top:4px;font-size:11px;color:#9ef0b0;
|
| 278 |
f"Heron's line ({threshold})</div>"
|
| 279 |
+ caption
|
| 280 |
+ "</div>"
|
|
@@ -369,9 +373,44 @@ class Game:
|
|
| 369 |
def traces_markdown(self) -> str:
|
| 370 |
if not self.thoughts:
|
| 371 |
return "_Run a turn to hear what the creatures are thinking._"
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
def legend_markdown(self) -> str:
|
| 377 |
lg = self.current_legend
|
|
@@ -388,10 +427,43 @@ class Game:
|
|
| 388 |
return "\n\n".join(f"- {line}" for line in self.ticker[-limit:][::-1])
|
| 389 |
|
| 390 |
def chapter_markdown(self) -> str:
|
| 391 |
-
"""The
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
|
| 396 |
def journal_markdown(self, limit: int = 14) -> str:
|
| 397 |
"""The Wood Street Journal feed: headline + dialogue over the ticker body.
|
|
|
|
| 14 |
|
| 15 |
from . import chapters, moves, narrate, relationships, shocks
|
| 16 |
from .actions import _extract_json
|
| 17 |
+
from .council import creature_engine_label, creature_lab_color
|
| 18 |
from .events import EventDeck
|
| 19 |
from .market import gini
|
| 20 |
from .narrator import guard_dialogue, guard_headline
|
|
|
|
| 193 |
worth = round(c.net_worth(self.world.last_price))
|
| 194 |
mood = narrate.mood_word(c.wellbeing)
|
| 195 |
badge = "π" if i == 0 else ("π₯" if i == len(ranked) - 1 else "")
|
| 196 |
+
# Two orthogonal visual channels: rank (gold/faded side border, the
|
| 197 |
+
# wealth story) and lab identity (a thicker top accent keyed to the
|
| 198 |
+
# creature's model, so the cards never look like one model in five hats).
|
| 199 |
border = "#d4a017" if i == 0 else ("#b08a8a" if i == len(ranked) - 1 else "#cdbf9a")
|
| 200 |
+
lab_color = creature_lab_color(c.name)
|
| 201 |
# Model attribution: each mind is a different lab's small model, and
|
| 202 |
# the card says which one (the council's whole point). Reasoning: the
|
| 203 |
# creature's own public thought, proving the agent actually decides.
|
|
|
|
| 212 |
thought = self.thoughts.get(c.name, "")
|
| 213 |
thought_html = (
|
| 214 |
"<div style=\"margin-top:6px;font-size:11.5px;font-style:italic;"
|
| 215 |
+
f"color:#5b5030;border-top:1px dashed {lab_color};padding-top:5px;\">"
|
| 216 |
f"π {html.escape(thought[:140])}{'β¦' if len(thought) > 140 else ''}</div>"
|
| 217 |
if thought
|
| 218 |
else ""
|
| 219 |
)
|
| 220 |
cards.append(
|
| 221 |
"<div style=\"flex:1 1 170px;min-width:170px;background:#fffdf7;"
|
| 222 |
+
f"border:2px solid {border};border-top:4px solid {lab_color};"
|
| 223 |
+
"border-radius:14px;padding:12px 14px;"
|
| 224 |
"box-shadow:0 2px 6px rgba(60,45,20,.10);color:#2e2519;\">"
|
| 225 |
f"<div style=\"font-size:30px;line-height:1;\">{narrate.avatar(c.name)} "
|
| 226 |
f"<span style=\"font-size:18px;\">{badge}</span></div>"
|
| 227 |
f"<div style=\"font-weight:700;font-size:16px;margin-top:4px;\">{c.name}</div>"
|
| 228 |
+
f"<div style=\"font-size:12px;color:#6b5d3e;\">grows {c.produces}</div>"
|
| 229 |
f"{badge_html}"
|
| 230 |
"<div style=\"margin-top:8px;font-size:13px;\">"
|
| 231 |
f"πͺ <b>{c.pebbles}</b> pebbles</div>"
|
|
|
|
| 278 |
+ "<div style=\"position:absolute;right:0;top:0;height:100%;width:2px;"
|
| 279 |
"background:#9ef0b0;\"></div>"
|
| 280 |
"</div>"
|
| 281 |
+
"<div style=\"margin-top:4px;font-size:11px;color:#9ef0b0;\">"
|
| 282 |
f"Heron's line ({threshold})</div>"
|
| 283 |
+ caption
|
| 284 |
+ "</div>"
|
|
|
|
| 373 |
def traces_markdown(self) -> str:
|
| 374 |
if not self.thoughts:
|
| 375 |
return "_Run a turn to hear what the creatures are thinking._"
|
| 376 |
+
lines = []
|
| 377 |
+
for name, thought in self.thoughts.items():
|
| 378 |
+
model, _ = creature_engine_label(name)
|
| 379 |
+
# Tag the model that produced the thought so the contrast between a
|
| 380 |
+
# 20B's reasoning and a 0.5B's terse output reads as five models, not
|
| 381 |
+
# writing variety. Omit the tag on the unknown/dummy path.
|
| 382 |
+
tag = f" `[{model}]`" if model else ""
|
| 383 |
+
lines.append(f"{narrate.avatar(name)} **{name}**{tag}: {thought}")
|
| 384 |
+
return "\n\n".join(lines)
|
| 385 |
+
|
| 386 |
+
def raw_output_markdown(self, cap: int = 1200) -> str:
|
| 387 |
+
"""The raw JSON each model emitted this turn, for a "is this scripted?" check.
|
| 388 |
+
|
| 389 |
+
Reads the policy's per-creature raw completions (the same duck-typed
|
| 390 |
+
`.state["raw"]` traces use) and shows them verbatim in fenced blocks,
|
| 391 |
+
tagged by model. This is the falsifiable artifact: a judge can see real,
|
| 392 |
+
slightly-malformed model output, proving the agents genuinely decide.
|
| 393 |
+
|
| 394 |
+
Firewall: this is the model's OWN completion. truth and legend_key are
|
| 395 |
+
never in any prompt (bound server-side), so a model cannot echo them. The
|
| 396 |
+
text is shown in a fenced block and Gradio sanitizes HTML on render; we
|
| 397 |
+
additionally neutralize any stray code fence so odd output can't break out
|
| 398 |
+
and inject markup. Do NOT screen this for the substrings "true"/"false":
|
| 399 |
+
those are ordinary JSON booleans, not the hidden tip-truth flag.
|
| 400 |
+
"""
|
| 401 |
+
state = getattr(self.policy, "state", None)
|
| 402 |
+
if not state or not state.get("raw"):
|
| 403 |
+
return "_No raw model output yet. Press **Step (live council)**._"
|
| 404 |
+
blocks = []
|
| 405 |
+
for name, raw in state["raw"].items():
|
| 406 |
+
model, _ = creature_engine_label(name)
|
| 407 |
+
tag = f" [{model}]" if model else ""
|
| 408 |
+
text = (raw or "").strip()
|
| 409 |
+
if len(text) > cap:
|
| 410 |
+
text = text[:cap] + "\n... (truncated)"
|
| 411 |
+
text = text.replace("```", "'''") # cannot break out of the fence
|
| 412 |
+
blocks.append(f"**{narrate.avatar(name)} {name}{tag}**\n```json\n{text}\n```")
|
| 413 |
+
return "\n\n".join(blocks)
|
| 414 |
|
| 415 |
def legend_markdown(self) -> str:
|
| 416 |
lg = self.current_legend
|
|
|
|
| 427 |
return "\n\n".join(f"- {line}" for line in self.ticker[-limit:][::-1])
|
| 428 |
|
| 429 |
def chapter_markdown(self) -> str:
|
| 430 |
+
"""The authored arc as a horizontal stage tracker, current chapter lit.
|
| 431 |
+
|
| 432 |
+
Returns HTML (the panel is a gr.HTML): one pill per chapter, the active
|
| 433 |
+
one filled and bold with an accent underline, past ones solid-muted,
|
| 434 |
+
future ones faint, plus the active subtitle below. Designed to read under
|
| 435 |
+
the panel's forced dark-text rule, so the active state is signaled by
|
| 436 |
+
background, weight, and underline, never by light text (which the
|
| 437 |
+
id-scoped !important rule would override anyway). Lets a muted-video judge
|
| 438 |
+
follow the crime arc (whispers -> heat -> inquiry -> reckoning) at a glance.
|
| 439 |
+
"""
|
| 440 |
+
cur = min(self._chapter_idx, len(chapters.CHAPTERS) - 1)
|
| 441 |
+
pills = []
|
| 442 |
+
for i, (name, _sub) in enumerate(chapters.CHAPTERS):
|
| 443 |
+
short = name.split(":", 1)[1].strip() if ":" in name else name
|
| 444 |
+
if i == cur:
|
| 445 |
+
style = (
|
| 446 |
+
"background:#cfe8d2;font-weight:700;"
|
| 447 |
+
"border:1px solid #4f9d52;border-bottom:3px solid #2f6b3d;"
|
| 448 |
+
)
|
| 449 |
+
elif i < cur:
|
| 450 |
+
style = "background:#e6dfcd;border:1px solid #d8ccab;"
|
| 451 |
+
else:
|
| 452 |
+
style = "background:#f3eddc;border:1px dashed #ddd0aa;"
|
| 453 |
+
pills.append(
|
| 454 |
+
"<span style=\"display:inline-block;padding:4px 10px;margin:2px;"
|
| 455 |
+
f"border-radius:10px;font-size:11.5px;color:#2e2519;{style}\">"
|
| 456 |
+
f"{html.escape(short)}</span>"
|
| 457 |
+
)
|
| 458 |
+
connector = "<span style=\"color:#9c8f6a;font-size:11px;\"> βΊ </span>"
|
| 459 |
+
_name, sub = chapters.CHAPTERS[cur]
|
| 460 |
+
return (
|
| 461 |
+
"<div style=\"display:flex;flex-wrap:wrap;align-items:center;\">"
|
| 462 |
+
+ connector.join(pills)
|
| 463 |
+
+ "</div>"
|
| 464 |
+
"<div style=\"margin-top:5px;font-size:12px;color:#6b5d3e;font-style:italic;\">"
|
| 465 |
+
f"{html.escape(sub)}</div>"
|
| 466 |
+
)
|
| 467 |
|
| 468 |
def journal_markdown(self, limit: int = 14) -> str:
|
| 469 |
"""The Wood Street Journal feed: headline + dialogue over the ticker body.
|
|
@@ -137,7 +137,12 @@ def _narrate_bankruptcy(event: dict) -> str:
|
|
| 137 |
def _narrate_suspicious_win(event: dict) -> str:
|
| 138 |
"""One ticker line for a Magistrate-flagged well-timed Patron win."""
|
| 139 |
if event.get("reason") == "short_before_legend":
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
return f"π₯ A suspicious win: the Patron's {event.get('good')} short crashed on cue."
|
| 142 |
|
| 143 |
|
|
|
|
| 137 |
def _narrate_suspicious_win(event: dict) -> str:
|
| 138 |
"""One ticker line for a Magistrate-flagged well-timed Patron win."""
|
| 139 |
if event.get("reason") == "short_before_legend":
|
| 140 |
+
pnl = event.get("pnl")
|
| 141 |
+
gain = f" The gambit pays {pnl:+d}p." if isinstance(pnl, int) else ""
|
| 142 |
+
return (
|
| 143 |
+
f"π₯ A suspicious win: the Patron shorted {event.get('good')} "
|
| 144 |
+
f"right before the legend struck.{gain}"
|
| 145 |
+
)
|
| 146 |
return f"π₯ A suspicious win: the Patron's {event.get('good')} short crashed on cue."
|
| 147 |
|
| 148 |
|
|
@@ -337,11 +337,12 @@ def _settle_suspicious_wins(world: WorldState, price_before: dict[str, float]) -
|
|
| 337 |
if not short.open or short.good != legend_good:
|
| 338 |
continue
|
| 339 |
patron.heat += moves.HEAT_SHORT_BEFORE_LEGEND
|
| 340 |
-
|
|
|
|
| 341 |
short.open = False
|
| 342 |
events.append(
|
| 343 |
{"type": "suspicious_win", "turn": world.turn, "good": legend_good,
|
| 344 |
-
"reason": "short_before_legend"}
|
| 345 |
)
|
| 346 |
world.pending_legend = None
|
| 347 |
|
|
|
|
| 337 |
if not short.open or short.good != legend_good:
|
| 338 |
continue
|
| 339 |
patron.heat += moves.HEAT_SHORT_BEFORE_LEGEND
|
| 340 |
+
pnl = short_unrealized_pnl(short, world.last_price)
|
| 341 |
+
patron.realized_pnl += pnl
|
| 342 |
short.open = False
|
| 343 |
events.append(
|
| 344 |
{"type": "suspicious_win", "turn": world.turn, "good": legend_good,
|
| 345 |
+
"reason": "short_before_legend", "pnl": pnl}
|
| 346 |
)
|
| 347 |
world.pending_legend = None
|
| 348 |
|