Spaces:
Running on Zero
Running on Zero
| """First Contact — Gradio Space entrypoint (SPEC.md §0, §9). | |
| Teach an alien that knows *words* but has never lived a human life. The model is | |
| a stateless function; the alien's growing understanding lives in a plain-Python | |
| concept ledger (game/) injected into the prompt each turn. | |
| ZeroGPU contract (SPEC §0): | |
| * Gradio SDK, model ≤32B. | |
| * The model is loaded onto 'cuda' at MODULE level (here, at import time) — NOT | |
| lazily inside the GPU function. | |
| * Only inference runs inside @spaces.GPU; all state mutation / win-checking / | |
| learning happens outside it. | |
| * All per-user state lives in gr.State — never module globals. | |
| """ | |
| from __future__ import annotations | |
| import html | |
| import os | |
| # `spaces` must be imported before torch for ZeroGPU's CUDA emulation. Locally | |
| # (no `spaces` package) fall back to a no-op decorator so the app still runs on | |
| # the StubBrain with zero extra dependencies. | |
| try: | |
| import spaces | |
| GPU = spaces.GPU | |
| except Exception: # pragma: no cover - exercised only off-Space | |
| def GPU(*args, **kwargs): | |
| if args and callable(args[0]): | |
| return args[0] | |
| def _decorator(fn): | |
| return fn | |
| return _decorator | |
| import gradio as gr | |
| # css/theme live on Blocks() in Gradio <=5 (the pinned Space target) but moved to | |
| # launch() in Gradio 6. Detect so custom styling applies whichever version is | |
| # resolved locally vs. on the Space. | |
| _GR_MAJOR = int(gr.__version__.split(".")[0]) | |
| from game.brain import LocalBrain, make_brain | |
| from game.challenges import CHALLENGES | |
| from game.engine import ( | |
| advance_challenge, | |
| confirm_candidate, | |
| current_challenge, | |
| is_arc_complete, | |
| new_session, | |
| reject_candidate, | |
| run_turn, | |
| ) | |
| from game.models import Concept, GameSession, WorldState | |
| from game.world import check_win | |
| # --------------------------------------------------------------------------- # | |
| # Brain — created ONCE at module level. For BRAIN=local this loads the model to | |
| # 'cuda' here (startup), per SPEC §0. Only the inference call below is wrapped in | |
| # @spaces.GPU; run_turn() does all mutation outside it. | |
| # --------------------------------------------------------------------------- # | |
| try: | |
| _BRAIN = make_brain() | |
| except Exception as exc: # model download/load failed — keep the Space UP on stub | |
| print( | |
| f"[brain] could not build the configured brain: {exc!r}\n" | |
| "[brain] falling back to StubBrain. On the Space, set ZeroGPU hardware " | |
| "and BRAIN=local (HF token in secrets for gated models).", | |
| flush=True, | |
| ) | |
| from game.brain import StubBrain | |
| _BRAIN = StubBrain() | |
| _USE_GPU = isinstance(_BRAIN, LocalBrain) | |
| # Loud startup line so the Space logs show which alien is live — a StubBrain here | |
| # means visitors get canned lines (set BRAIN=local), not the real model. | |
| print( | |
| "[brain] active: " + type(_BRAIN).__name__ | |
| + (f" ({_BRAIN.model_id})" if _USE_GPU | |
| else " — zero-GPU stand-in, NOT the real model"), | |
| flush=True, | |
| ) | |
| # Sized from the 2026-06 bake-off: Qwen2.5-14B measured ~3.5s median / ~5s p90 | |
| # per call, so 20s covers the slow tail ~4x over while keeping ZeroGPU queue | |
| # priority high (shorter declared durations queue better; 120 was the | |
| # pre-measurement guess). Bump this if you switch to a slower model. | |
| def _generate_on_gpu(prompt: str) -> str: | |
| return _BRAIN.respond(prompt) | |
| class _TurnBrain: | |
| """Routes the model call through the @spaces.GPU function when (and only | |
| when) a real GPU brain is loaded. Stub/Modal go straight through. | |
| Infrastructure failures (ZeroGPU queue timeout, quota exhaustion, the | |
| duration cap) are swallowed into an empty reply: empty text fails parsing, | |
| which flows into the §4 retry -> safe-fallback path, so the player sees the | |
| alien 'not understanding' instead of an error toast (SPEC §4: never crash, | |
| never leak).""" | |
| def respond(self, prompt: str) -> str: | |
| try: | |
| return _generate_on_gpu(prompt) if _USE_GPU else _BRAIN.respond(prompt) | |
| except Exception as exc: | |
| print(f"[brain] respond failed: {exc}", flush=True) | |
| return "" | |
| _turn_brain = _TurnBrain() | |
| PLACEHOLDER = "Speak to the alien… (e.g. “hide the blue stone from the other one”)" | |
| # --------------------------------------------------------------------------- # | |
| # Rendering — pure functions over session state -> HTML | |
| # --------------------------------------------------------------------------- # | |
| def _chip(obj, concealed: bool = False) -> str: | |
| color = "blue" if "blue" in obj.id else "red" if "red" in obj.id else "neutral" | |
| if concealed: | |
| return ( | |
| f'<span class="chip {color} concealed" title="hidden from the other">' | |
| f'<span class="dot"></span>{html.escape(obj.name)}<span class="lock">▣</span></span>' | |
| ) | |
| return f'<span class="chip {color}"><span class="dot"></span>{html.escape(obj.name)}</span>' | |
| def render_world(world: WorldState) -> str: | |
| held = {a_id: list(a.holding) for a_id, a in world.agents.items()} | |
| in_basket = [o for o in world.objects.values() if o.location == "basket"] | |
| on_ground = [o for o in world.objects.values() if o.location == "ground"] | |
| def agent_zone(agent_id: str, glyph: str, label: str) -> str: | |
| chips = "".join(_chip(world.objects[o]) for o in held.get(agent_id, [])) | |
| chips = chips or '<span class="empty">empty-handed</span>' | |
| return ( | |
| f'<div class="zone agent"><div class="zone-head">{glyph} {label}</div>' | |
| f'<div class="zone-body">{chips}</div></div>' | |
| ) | |
| basket_chips = "".join(_chip(o, concealed=o.hidden) for o in in_basket) or ( | |
| '<span class="empty">nothing inside</span>' | |
| ) | |
| ground_chips = "".join(_chip(o) for o in on_ground) or '<span class="empty">bare</span>' | |
| return ( | |
| '<div class="stage">' | |
| + agent_zone("alien", "◉", "the alien (you teach)") | |
| + agent_zone("other", "◎", "the other one") | |
| + f'<div class="zone basket"><div class="zone-head">⬓ the basket</div>' | |
| f'<div class="zone-body">{basket_chips}</div></div>' | |
| + f'<div class="zone ground"><div class="zone-head">· the ground</div>' | |
| f'<div class="zone-body">{ground_chips}</div></div>' | |
| + "</div>" | |
| ) | |
| def render_convo(history: list[dict]) -> str: | |
| # Each entry is one self-contained block; trim to the recent tail so the | |
| # latest turn (and any win beat) is what's on screen without inner scrolling. | |
| blocks = [] | |
| for entry in history[-12:]: | |
| who, text = entry["who"], html.escape(entry.get("text", "")) | |
| if who == "player": | |
| blocks.append(f'<div class="turn player"><span class="tag">you</span>{text}</div>') | |
| elif who == "alien": | |
| gap = ( | |
| f'<div class="gap">…did not understand — {html.escape(entry["gap"])}</div>' | |
| if entry.get("gap") | |
| else "" | |
| ) | |
| act = ( | |
| f'<div class="act">› {html.escape(entry["action"])}</div>' | |
| if entry.get("action") | |
| else "" | |
| ) | |
| blocks.append( | |
| f'<div class="turn alien"><div class="voice">{text}</div>{act}{gap}</div>' | |
| ) | |
| else: # system beat | |
| kind = entry.get("kind") or "beat" | |
| blocks.append(f'<div class="beat {kind}">{text}</div>') | |
| if not blocks: | |
| blocks.append('<div class="beat hint">The alien waits. Tell it what to do.</div>') | |
| return f'<div class="convo">{"".join(blocks)}</div>' | |
| def _concept_card(c: Concept, highlight: set[str], label_of: dict[str, str]) -> str: | |
| klass = "concept " + ("innate" if c.taught_on_turn == 0 else "learned") | |
| if c.id in highlight: | |
| klass += " glow" | |
| applied = f'<span class="applied">applied ×{c.times_applied}</span>' if c.times_applied else "" | |
| if c.built_from: | |
| names = " + ".join(label_of.get(i, i) for i in c.built_from) | |
| built = f'<span class="built">from {html.escape(names)}</span>' | |
| else: | |
| built = "" | |
| return ( | |
| f'<div class="{klass}"><div class="c-label">{html.escape(c.label)}</div>' | |
| f'<div class="c-gloss">{html.escape(c.understanding)}</div>' | |
| f'<div class="c-meta">{built}{applied}</div></div>' | |
| ) | |
| def render_ledger(ledger: list[Concept], highlight: set[str] | None = None) -> str: | |
| # Learned concepts first (newest on top — the constellation that just grew), | |
| # innate primitives below a divider. The learned ones are the §9 screenshot. | |
| highlight = highlight or set() | |
| label_of = {c.id: c.label for c in ledger} | |
| learned = sorted( | |
| (c for c in ledger if c.taught_on_turn != 0), | |
| key=lambda c: c.taught_on_turn, | |
| reverse=True, | |
| ) | |
| innate = [c for c in ledger if c.taught_on_turn == 0] | |
| cards = [_concept_card(c, highlight, label_of) for c in learned] | |
| if learned: | |
| cards.append('<div class="ledger-div">innate primitives</div>') | |
| cards.extend(_concept_card(c, highlight, label_of) for c in innate) | |
| return f'<div class="ledger">{"".join(cards)}</div>' | |
| def render_header(session: GameSession) -> str: | |
| ch = current_challenge(session) | |
| n, total = session.challenge_index + 1, len(CHALLENGES) | |
| is_gen = ch.teaches is None and bool(ch.relies_on) | |
| kind = "generalize" if is_gen else ("teach" if ch.teaches else "warm-up") | |
| note = ( | |
| '<div class="ch-note">↯ No new teaching this round — just ask, and watch ' | |
| 'whether it understands on its own.</div>' if is_gen else "" | |
| ) | |
| return ( | |
| f'<div class="ch-head"><div class="ch-meta">CHALLENGE {n}/{total} · ' | |
| f'<span class="ch-kind {kind}">{kind}</span></div>' | |
| f'<div class="ch-title">{html.escape(ch.title)}</div>' | |
| f'<div class="ch-blurb">{html.escape(ch.setup_blurb)}</div>{note}</div>' | |
| ) | |
| def render_goal(session: GameSession) -> str: | |
| """A concrete objective + live status from the real win-predicate, so the | |
| player can see what actually counts as winning the stage.""" | |
| ch = current_challenge(session) | |
| is_gen = ch.teaches is None and bool(ch.relies_on) | |
| if check_win(session.world, ch): | |
| pill = ('<span class="goal-pill done">✓ it understood!</span>' if is_gen | |
| else '<span class="goal-pill done">✓ done</span>') | |
| else: | |
| pill = ('<span class="goal-pill pending">◌ watching…</span>' if is_gen | |
| else '<span class="goal-pill pending">◌ not yet</span>') | |
| return ( | |
| '<div class="goal-strip"><span class="goal-label">goal</span>' | |
| f'<span class="goal-text">{html.escape(ch.goal)}</span>{pill}</div>' | |
| ) | |
| def _learn_offer_html(candidate: dict) -> str: | |
| return ( | |
| '<div class="offer-inner"><span class="spark">✦</span> The alien thinks it learned ' | |
| f'something new: <b>{html.escape(candidate.get("label", "?"))}</b> — ' | |
| f'<i>“{html.escape(candidate.get("understanding", ""))}”</i></div>' | |
| ) | |
| def _win_banner(ch, reapplied: tuple[str, ...]) -> str: | |
| if ch.teaches is None and ch.relies_on: | |
| used = " + ".join(reapplied) if reapplied else " + ".join(ch.relies_on) | |
| return ( | |
| '<div class="banner generalize"><div class="b-big">IT UNDERSTOOD YOU</div>' | |
| f'<div class="b-sub">“{html.escape(ch.title)}” — the alien applied ' | |
| f'<b>{html.escape(used)}</b> to a situation you never taught it.</div></div>' | |
| ) | |
| return ( | |
| '<div class="banner win"><div class="b-big">✦ understood</div>' | |
| f'<div class="b-sub">{html.escape(ch.title)}</div></div>' | |
| ) | |
| def render_finale(session: GameSession) -> str: | |
| """The end-of-arc payoff + shareable artifact: what the alien began with, what | |
| you taught it, and — set apart — what it reached on its own (SPEC §9, §12).""" | |
| learned = [c for c in session.ledger if c.taught_on_turn != 0] | |
| generalized = [c for c in learned if c.via_generalization] | |
| taught = [c for c in learned if not c.via_generalization] | |
| innate = [c for c in session.ledger if c.taught_on_turn == 0] | |
| label_of = {c.id: c.label for c in session.ledger} | |
| nt, ng = len(taught), len(generalized) | |
| if nt and ng: | |
| lead = (f"It began with {len(innate)} bare senses. You taught it {nt}. " | |
| f"From those, it reached {ng} more <b>on its own</b>.") | |
| elif ng: | |
| lead = (f"It began with {len(innate)} bare senses. On its own it reached for " | |
| f"{ng} idea{'s' if ng != 1 else ''} — though you taught it none to " | |
| f"build on.") | |
| elif nt: | |
| lead = (f"It began with {len(innate)} bare senses. You taught it {nt}, " | |
| f"and it carried them into new situations.") | |
| else: | |
| lead = f"It began with {len(innate)} bare senses, and there it remains — for now." | |
| def col(title: str, concepts: list[Concept], self_made: bool = False) -> str: | |
| if not concepts: | |
| return "" | |
| items = "" | |
| for c in concepts: | |
| lineage = "" | |
| if c.built_from: | |
| names = " + ".join(label_of.get(i, i) for i in c.built_from) | |
| lineage = f' <span class="from">(from {html.escape(names)})</span>' | |
| items += (f"<li><b>{html.escape(c.label)}</b> — " | |
| f"{html.escape(c.understanding)}{lineage}</li>") | |
| cls = "finale-col self" if self_made else "finale-col" | |
| return f'<div class="{cls}"><div class="finale-h">{title}</div><ul>{items}</ul></div>' | |
| cols = col("you taught it", taught) + col("it understood alone", generalized, True) | |
| return ( | |
| '<div class="banner finale">' | |
| '<div class="b-big">it understands you now</div>' | |
| f'<div class="b-sub">{lead}</div>' | |
| f'<div class="finale-cols">{cols}</div>' | |
| '<div class="finale-foot">First contact complete. Press ' | |
| '<b>restart</b> to begin again.</div></div>' | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # State -> outputs bundle | |
| # --------------------------------------------------------------------------- # | |
| def render_all(session: GameSession, highlight: set[str] | None = None) -> dict: | |
| return { | |
| state: session, | |
| header_html: render_header(session), | |
| goal_html: render_goal(session), | |
| world_html: render_world(session.world), | |
| convo_html: render_convo(session.history), | |
| ledger_html: render_ledger(session.ledger, highlight), | |
| } | |
| def _maybe_finale(session: GameSession, out: dict) -> bool: | |
| """If the arc is complete and no concept is awaiting confirmation, replace the | |
| per-stage banner with the finale and lock the input. Reachable from on_send | |
| (last stage won, nothing to confirm) and on_confirm/on_reject (last concept | |
| just resolved) — the old code only reached it via a continue button that the | |
| last stage never showed.""" | |
| if is_arc_complete(session) and not session.pending_candidate: | |
| out[success_banner] = gr.update(visible=True, value=render_finale(session)) | |
| out[continue_btn] = gr.update(visible=False) | |
| out[learn_row] = gr.update(visible=False) | |
| out[msg] = gr.update(interactive=False, value="", | |
| placeholder="— first contact complete · press restart —") | |
| return True | |
| return False | |
| # --------------------------------------------------------------------------- # | |
| # Handlers | |
| # --------------------------------------------------------------------------- # | |
| def on_send(session: GameSession, message: str) -> dict: | |
| message = (message or "").strip() | |
| if not message: | |
| return {msg: gr.update()} | |
| res = run_turn(session, message, _turn_brain) | |
| out = render_all(session, highlight=set(res.reapplied)) | |
| out[msg] = gr.update(value="") | |
| if res.learn_offer: | |
| out[learn_row] = gr.update(visible=True) | |
| out[learn_label] = _learn_offer_html(res.learn_offer) | |
| else: | |
| out[learn_row] = gr.update(visible=False) | |
| if res.won: | |
| is_last = session.challenge_index >= len(CHALLENGES) - 1 | |
| # If the alien proposed a concept, gate "continue" until the player | |
| # resolves it (Yes/No). Otherwise the prominent continue button silently | |
| # drops the concept, and the arc reaches the finale having taught nothing. | |
| out[continue_btn] = gr.update(visible=(not is_last and not res.learn_offer)) | |
| out[success_banner] = gr.update( | |
| visible=True, value=_win_banner(current_challenge(session), res.reapplied) | |
| ) | |
| else: | |
| out[continue_btn] = gr.update(visible=False) | |
| out[success_banner] = gr.update(visible=False) | |
| _maybe_finale(session, out) # last stage won with nothing to confirm -> finale | |
| return out | |
| def _reveal_continue(session: GameSession, out: dict) -> None: | |
| """After a pending concept is resolved, show continue if the stage is won | |
| (or the finale if the arc is done).""" | |
| if session.won_current and session.challenge_index < len(CHALLENGES) - 1: | |
| out[continue_btn] = gr.update(visible=True) | |
| _maybe_finale(session, out) | |
| def on_confirm(session: GameSession) -> dict: | |
| cid = (session.pending_candidate or {}).get("id") | |
| confirm_candidate(session) | |
| out = render_all(session, highlight={cid} if cid else set()) | |
| out[learn_row] = gr.update(visible=False) | |
| _reveal_continue(session, out) # concept resolved -> continue (or finale) | |
| return out | |
| def on_reject(session: GameSession) -> dict: | |
| reject_candidate(session) | |
| out = render_all(session) | |
| out[learn_row] = gr.update(visible=False) | |
| _reveal_continue(session, out) | |
| return out | |
| def on_continue(session: GameSession) -> dict: | |
| ok = advance_challenge(session) | |
| out = render_all(session) | |
| out[continue_btn] = gr.update(visible=False) | |
| out[learn_row] = gr.update(visible=False) | |
| if ok: | |
| out[success_banner] = gr.update(visible=False) | |
| out[msg] = gr.update(interactive=True, value="", placeholder=PLACEHOLDER) | |
| else: # safety net — the last stage shows no continue button, so this is rare | |
| out[success_banner] = gr.update(visible=True, value=render_finale(session)) | |
| out[msg] = gr.update(interactive=False, value="", | |
| placeholder="— first contact complete · press restart —") | |
| return out | |
| def on_restart() -> dict: | |
| session = new_session() | |
| out = render_all(session) | |
| out[continue_btn] = gr.update(visible=False) | |
| out[success_banner] = gr.update(visible=False) | |
| out[learn_row] = gr.update(visible=False) | |
| out[msg] = gr.update(interactive=True, value="", placeholder=PLACEHOLDER) | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # CSS — committed aesthetic: a xenolinguist's first-contact terminal. | |
| # Deep ink ground, bioluminescent accent for understanding, amber for confusion. | |
| # --------------------------------------------------------------------------- # | |
| CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,300;0,400;0,500;1,400&family=Space+Mono:wght@400;700&family=Major+Mono+Display&display=swap'); | |
| :root { | |
| --ink: #0b0e13; --ink2: #11151d; --panel: #141a24; --edge: #232c3a; | |
| --text: #d8dee9; --muted: #7c889c; --bio: #8be0c8; --bio-dim: #3f6b62; | |
| --amber: #e2b075; --blue: #6fa8dc; --red: #e0736f; | |
| } | |
| .gradio-container { background: | |
| radial-gradient(1200px 600px at 70% -10%, #16202c 0%, var(--ink) 55%) !important; | |
| color: var(--text) !important; font-family: 'Spectral', serif !important; } | |
| #masthead { text-align:center; padding: 10px 0 2px; } | |
| #masthead .title { font-family:'Major Mono Display', monospace; font-size: 1.9rem; | |
| letter-spacing: .12em; color: var(--bio); } | |
| #masthead .sub { color: var(--muted); font-style: italic; font-size: .95rem; } | |
| .fc-panel { background: var(--panel); border: 1px solid var(--edge); | |
| border-radius: 12px; padding: 14px; } | |
| .fc-panel .panel-label { font-family:'Space Mono', monospace; font-size:.72rem; | |
| letter-spacing:.18em; color: var(--muted); text-transform:uppercase; margin-bottom:8px; } | |
| /* header */ | |
| .ch-head { margin: 6px 0 2px; } | |
| .ch-meta { font-family:'Space Mono',monospace; font-size:.72rem; letter-spacing:.14em; color:var(--muted); } | |
| .ch-kind { padding:1px 7px; border:1px solid var(--edge); border-radius:999px; } | |
| .ch-kind.teach { color:var(--bio); border-color:var(--bio-dim); } | |
| .ch-kind.generalize { color:var(--amber); border-color:#6b5535; } | |
| .ch-title { font-size:1.5rem; color:var(--text); margin:3px 0; } | |
| .ch-blurb { color:var(--muted); font-size:.98rem; line-height:1.45; } | |
| /* world stage */ | |
| .stage { display:grid; grid-template-columns:1fr 1fr; gap:10px; } | |
| .zone { background: var(--ink2); border:1px solid var(--edge); border-radius:10px; padding:10px; min-height:64px; } | |
| .zone.ground, .zone.basket { grid-column: span 1; } | |
| .zone-head { font-family:'Space Mono',monospace; font-size:.72rem; color:var(--muted); letter-spacing:.1em; margin-bottom:8px; } | |
| .zone-body { display:flex; flex-wrap:wrap; gap:6px; } | |
| .zone.basket { background: repeating-linear-gradient(45deg,#10141c,#10141c 8px,#121822 8px,#121822 16px); } | |
| .chip { display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border-radius:999px; | |
| background:#1b2330; border:1px solid var(--edge); font-size:.9rem; } | |
| .chip .dot { width:9px; height:9px; border-radius:50%; background:var(--muted); } | |
| .chip.blue .dot { background:var(--blue); box-shadow:0 0 8px var(--blue); } | |
| .chip.red .dot { background:var(--red); box-shadow:0 0 8px var(--red); } | |
| .chip.concealed { border-style:dashed; opacity:.65; color:var(--amber); } | |
| .chip .lock { color:var(--amber); font-size:.8rem; } | |
| .empty { color:#4b5566; font-style:italic; font-size:.85rem; } | |
| /* conversation */ | |
| .convo { display:flex; flex-direction:column; gap:10px; padding-right:4px; } | |
| .turn.player { align-self:flex-end; background:#1a2230; border:1px solid var(--edge); | |
| border-radius:12px 12px 2px 12px; padding:7px 12px; max-width:85%; } | |
| .turn.player .tag { font-family:'Space Mono',monospace; font-size:.62rem; color:var(--muted); display:block; letter-spacing:.12em; } | |
| .turn.alien { align-self:flex-start; max-width:90%; } | |
| .voice { font-family:'Space Mono',monospace; font-size:.95rem; line-height:1.5; color:var(--bio); | |
| border-left:2px solid var(--bio-dim); padding:6px 12px; background:#101820; border-radius:2px 10px 10px 2px; } | |
| .gap { color:var(--amber); font-style:italic; font-size:.85rem; padding:2px 12px; opacity:.85; } | |
| .beat { text-align:center; font-family:'Space Mono',monospace; font-size:.74rem; letter-spacing:.14em; | |
| color:var(--muted); text-transform:uppercase; padding:4px 0; } | |
| .beat.win, .beat.learn { color:var(--bio); } | |
| .beat.hint { color:#4b5566; } | |
| /* ledger */ | |
| .ledger { display:flex; flex-direction:column; gap:8px; max-height:520px; overflow-y:auto; padding-right:4px; } | |
| .ledger-div { font-family:'Space Mono',monospace; font-size:.64rem; letter-spacing:.16em; text-transform:uppercase; | |
| color:#4b5566; text-align:center; margin:4px 0; border-top:1px dashed var(--edge); padding-top:8px; } | |
| .concept { border:1px solid var(--edge); border-radius:10px; padding:9px 11px; background:var(--ink2); } | |
| .concept.innate { opacity:.7; } | |
| .concept.learned { border-color:var(--bio-dim); background:linear-gradient(180deg,#13201c,#11151d); } | |
| .c-label { font-size:1.05rem; color:var(--text); } | |
| .concept.learned .c-label { color:var(--bio); } | |
| .c-gloss { color:var(--muted); font-size:.88rem; line-height:1.4; } | |
| .c-meta { display:flex; gap:10px; margin-top:5px; font-family:'Space Mono',monospace; font-size:.66rem; letter-spacing:.08em; } | |
| .built { color:var(--amber); } | |
| .applied { color:var(--bio); } | |
| @keyframes glowpulse { 0%{box-shadow:0 0 0 0 rgba(139,224,200,0);} 30%{box-shadow:0 0 22px 4px rgba(139,224,200,.55);} 100%{box-shadow:0 0 0 0 rgba(139,224,200,0);} } | |
| .concept.glow { animation: glowpulse 1.6s ease-out 1; border-color:var(--bio); } | |
| /* learn offer */ | |
| #learn_row { background:#15211d; border:1px solid var(--bio-dim); border-radius:12px; padding:6px 12px; } | |
| .offer-inner { color:var(--text); font-size:.95rem; } | |
| .offer-inner .spark { color:var(--bio); } | |
| /* banners */ | |
| .banner { text-align:center; border-radius:14px; padding:18px; margin-top:8px; } | |
| .banner.win { background:#13201c; border:1px solid var(--bio-dim); } | |
| .banner.generalize { background:radial-gradient(600px 200px at 50% 0%, #1d2a25, #11151d); | |
| border:1px solid var(--bio); box-shadow:0 0 40px rgba(139,224,200,.18); } | |
| .b-big { font-family:'Major Mono Display',monospace; font-size:1.4rem; color:var(--bio); letter-spacing:.1em; } | |
| .banner.generalize .b-big { animation: glowpulse 2s ease-out 1; } | |
| .b-sub { color:var(--muted); font-style:italic; margin-top:6px; } | |
| /* goal strip + generalization note */ | |
| .goal-strip { display:flex; align-items:center; gap:10px; margin:8px 0 2px; font-size:.95rem; color:var(--text); } | |
| .goal-label { font-family:'Space Mono',monospace; font-size:.6rem; letter-spacing:.18em; text-transform:uppercase; | |
| color:var(--muted); border:1px solid var(--edge); border-radius:4px; padding:2px 6px; } | |
| .goal-text { flex:1; } | |
| .goal-pill { font-family:'Space Mono',monospace; font-size:.72rem; letter-spacing:.05em; padding:2px 10px; | |
| border-radius:999px; white-space:nowrap; } | |
| .goal-pill.pending { color:var(--muted); border:1px solid var(--edge); } | |
| .goal-pill.done { color:#06120e; background:var(--bio); font-weight:700; } | |
| .ch-note { color:var(--amber); font-size:.9rem; font-style:italic; margin-top:5px; } | |
| /* mechanical action line under the alien's voice */ | |
| .act { font-family:'Space Mono',monospace; font-size:.78rem; color:var(--muted); padding:3px 12px 0; } | |
| /* capability hint */ | |
| .cap-hint { color:var(--muted); font-size:.82rem; text-align:center; margin:7px 0 0; opacity:.85; } | |
| .cap-hint b { color:var(--text); font-weight:400; } | |
| /* finale */ | |
| .banner.finale { background:radial-gradient(720px 280px at 50% 0%, #1d2a25, #0f1318); | |
| border:1px solid var(--bio); box-shadow:0 0 55px rgba(139,224,200,.22); } | |
| .banner.finale .b-big { animation: glowpulse 2.4s ease-out 1; } | |
| .finale-cols { display:flex; gap:16px; justify-content:center; margin-top:14px; text-align:left; flex-wrap:wrap; } | |
| .finale-col { flex:1; min-width:230px; background:var(--ink2); border:1px solid var(--edge); border-radius:10px; padding:10px 14px; } | |
| .finale-col.self { border-color:var(--bio); background:linear-gradient(180deg,#13201c,#11151d); } | |
| .finale-h { font-family:'Space Mono',monospace; font-size:.62rem; letter-spacing:.16em; text-transform:uppercase; | |
| color:var(--muted); margin-bottom:6px; } | |
| .finale-col.self .finale-h { color:var(--bio); } | |
| .finale-col ul { margin:0; padding-left:16px; } | |
| .finale-col li { font-size:.9rem; color:var(--text); margin:5px 0; line-height:1.4; } | |
| .finale-col .from { color:var(--amber); font-family:'Space Mono',monospace; font-size:.7rem; } | |
| .finale-foot { color:var(--muted); font-style:italic; margin-top:14px; font-size:.9rem; } | |
| /* inputs */ | |
| #send_btn { background:var(--bio-dim) !important; color:#06120e !important; border:none !important; } | |
| footer { display:none !important; } | |
| """ | |
| # --------------------------------------------------------------------------- # | |
| # Layout | |
| # --------------------------------------------------------------------------- # | |
| # On Gradio 5 (the Space's pinned sdk_version) css/theme belong on the Blocks | |
| # constructor; on Gradio 6 they belong on launch() (handled in __main__). | |
| _blocks_style = {} if _GR_MAJOR >= 6 else {"css": CSS, "theme": gr.themes.Base()} | |
| with gr.Blocks(title="First Contact", **_blocks_style) as demo: | |
| state = gr.State(new_session()) | |
| # Deliver the CSS via an inline <style> tag too. On a Gradio-6 Space css must | |
| # ride on launch(), but Spaces may auto-launch the `demo` object instead of | |
| # running our __main__ — this injection applies the styling regardless of how | |
| # the app is launched (gr.HTML renders raw; there is no sanitizer to strip it). | |
| gr.HTML(f"<style>{CSS}</style>", container=False) | |
| gr.HTML( | |
| '<div id="masthead"><div class="title">first contact</div>' | |
| '<div class="sub">teach an alien that knows words but has never lived a life</div></div>' | |
| ) | |
| header_html = gr.HTML() | |
| goal_html = gr.HTML() | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=5): | |
| with gr.Group(elem_classes="fc-panel"): | |
| gr.HTML('<div class="panel-label">the world</div>') | |
| world_html = gr.HTML() | |
| with gr.Group(elem_classes="fc-panel"): | |
| gr.HTML('<div class="panel-label">first contact log</div>') | |
| convo_html = gr.HTML() | |
| with gr.Column(scale=4): | |
| with gr.Group(elem_classes="fc-panel"): | |
| gr.HTML('<div class="panel-label">what the alien understands</div>') | |
| ledger_html = gr.HTML() | |
| success_banner = gr.HTML(visible=False) | |
| with gr.Row(visible=False, elem_id="learn_row") as learn_row: | |
| learn_label = gr.HTML() | |
| yes_btn = gr.Button("Yes — it learned that", scale=0, variant="primary") | |
| no_btn = gr.Button("No", scale=0) | |
| with gr.Row(): | |
| msg = gr.Textbox(placeholder=PLACEHOLDER, show_label=False, scale=8, autofocus=True) | |
| send_btn = gr.Button("speak", elem_id="send_btn", scale=1) | |
| gr.HTML( | |
| '<div class="cap-hint">the alien can <b>move</b>, <b>pick up</b> a thing, ' | |
| '<b>put</b> it in the basket, <b>give</b> it, <b>point</b>, or <b>wait</b> — ' | |
| 'say it however you like; it will try to understand.</div>' | |
| ) | |
| with gr.Row(): | |
| continue_btn = gr.Button("continue →", visible=False, variant="primary") | |
| restart_btn = gr.Button("restart", scale=0) | |
| # outputs every handler may touch | |
| OUT = [state, header_html, goal_html, world_html, convo_html, ledger_html, | |
| learn_row, learn_label, continue_btn, success_banner, msg] | |
| send_btn.click(on_send, [state, msg], OUT) | |
| msg.submit(on_send, [state, msg], OUT) | |
| yes_btn.click(on_confirm, [state], OUT) | |
| no_btn.click(on_reject, [state], OUT) | |
| continue_btn.click(on_continue, [state], OUT) | |
| restart_btn.click(on_restart, None, OUT) | |
| demo.load(render_all, [state], | |
| [state, header_html, goal_html, world_html, convo_html, ledger_html]) | |
| if __name__ == "__main__": | |
| # Gradio 6 takes css/theme here; Gradio 5 already has them on Blocks above. | |
| _launch_style = {"css": CSS, "theme": gr.themes.Base()} if _GR_MAJOR >= 6 else {} | |
| demo.queue().launch(**_launch_style) | |