neilA / SPEC.md
TriggerFish212's picture
initial commit
b54f80c
|
Raw
History Blame Contribute Delete
24.2 kB
# SPEC.md β€” "First Contact" (working title)
A small-model game for the Hugging Face **Build Small** hackathon (Adventure track).
You teach an alien that knows *words* but has never experienced human life. It
acts in a tiny sandbox world. Over a session it accumulates *concepts* and begins
to generalize them to new situations. The "it finally understood me" moment is the
payoff and the shareable artifact.
This document is the contract. Implement against it exactly. Where it says MUST,
it is load-bearing for either correctness or the hackathon constraints. Where it
says SHOULD, use judgement.
---
## 0. Non-negotiable platform constraints (READ FIRST)
These come from the hackathon rules and the current ZeroGPU docs. Violating any of
them either breaks the deploy or disqualifies the entry.
- **The submission IS a Hugging Face Space under the hackathon org.** Modal (below)
is a *development/serving convenience only*, never the deliverable.
- **ZeroGPU requires the Gradio SDK.** Not Streamlit, not Docker. The app MUST be a
Gradio app from the first commit. Do not build a Flask/FastAPI prototype to port
later.
- **Gradio 4+** and **PyTorch β‰₯ 2.8.0** are required by ZeroGPU. Pin accordingly.
- **`torch.compile` is NOT supported** on ZeroGPU. Do not use it. (Ahead-of-time
compilation exists but is out of scope for this build.)
- **Model MUST be loaded onto `'cuda'` at module level**, NOT lazily inside the
GPU function. CUDA transfers are optimized for startup placement. A PyTorch CUDA
emulation mode makes module-level `.to('cuda')` work outside the GPU function.
- **The GPU-bound function MUST be decorated with `@spaces.GPU`.** Default GPU
runtime is **60 seconds**; if a call can exceed that, pass `duration=` (e.g.
`@spaces.GPU(duration=120)`). Shorter declared durations get better queue
priority β€” keep it tight.
- **Model MUST be ≀ 32B parameters** (hackathon rule).
- **All per-user game state MUST live in `gr.State`**, never module globals.
Module globals are shared across all concurrent visitors β€” the moment the demo
link is posted, global state means every player shares one alien. This is the
single most common Gradio-on-Spaces bug; do not fall into it.
- **No secrets in code.** HF token via Space secrets / env var. No API keys
anywhere in the repo.
- **No `localStorage` / browser storage.** State is server-side in `gr.State`.
ZeroGPU quota for an org member is **40 min/day of GPU time**. This is plenty for
dev and demo *if* we don't waste it β€” see Β§6 (the model call MUST be swappable so
that ledger/world logic can be developed against a stub with zero GPU spend).
---
## 1. The core architecture in one paragraph
The model never learns in the weights sense. The alien's growing understanding
lives in a **concept ledger** maintained in plain Python and injected into the
prompt every turn. The model is a **stateless function**: given (the alien's
current ledger + the world state + the player's utterance), it returns (an action
in the sandbox + an in-character reply + structured notes about what it did and
didn't understand). The host code applies the action deterministically, checks the
win condition mechanically, and decides whether a new concept was taught. That loop
β€” not the model β€” is the game.
```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ gr.State (per session) β”‚
β”‚ ledger: list[Concept] β”‚
β”‚ world: WorldState β”‚
β”‚ challenge: Challenge β”‚
β”‚ turn: int β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β–²
player utterance β”‚ β”‚ updated state rendered to UI
β”‚ β–Ό β”‚
β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
build_prompt(...) ──▢│ model_call(prompt) β”‚ ← SWAPPABLE (stub | local | modal)
β–² β”‚ @spaces.GPU β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚ strict JSON
ledger rendered as β–Ό
"things you now parse + validate (retry once, then safe no-op)
understand" β”‚
β–Ό
apply_action(world, action) ── deterministic
β”‚
β–Ό
check_win(world, challenge) ── mechanical, no model
β”‚
β–Ό
maybe_learn(ledger, candidate_concept, player_confirm)
```
---
## 2. Data model
Implement as dataclasses (or pydantic if you prefer validation; dataclasses are
fine). Everything is JSON-serializable so it can live in `gr.State`.
### 2.1 Concept (an entry in the ledger)
```python
@dataclass
class Concept:
id: str # stable slug, e.g. "hidden_info"
label: str # short human label, e.g. "hiding information"
player_phrase: str # how the player expressed it when teaching
understanding: str # the alien's internal gloss, 1 sentence, in alien framing
taught_on_turn: int
times_applied: int = 0
```
The ledger starts with ONLY raw physical primitives the alien already has. These
are NOT social/temporal/abstract. Seed exactly these (tune wording, keep the set
small):
- `object` β€” "a thing that exists in a place"
- `move` β€” "to change where I am"
- `give_take` β€” "a thing can pass from one holder to another"
- `more_less` β€” "quantities can differ"
- `point` β€” "I can direct attention to a thing"
Everything interesting (`hidden_info`, `secret`, `gift`, `surprise`, `trade`,
`promise`, `lie`, ...) MUST be acquired in-session by teaching. Do NOT seed them.
### 2.2 WorldState (the deterministic sandbox)
Keep it tiny. The world exists so that **success is checkable without the model
judging semantics.** A reasonable starting world:
```python
@dataclass
class Obj:
id: str
name: str # "blue stone", "red stone", "basket"
location: str # an Agent id, a Container id, or "ground"
hidden: bool = False # concealed from other agents?
@dataclass
class Agent:
id: str # "alien", "other" (the alien, and a second NPC)
name: str
holding: list[str] # obj ids
@dataclass
class WorldState:
objects: dict[str, Obj]
agents: dict[str, Agent]
containers: list[str] # e.g. ["basket"] β€” things that can conceal
log: list[str] # human-readable record of actions taken
```
### 2.3 Action (what the model is allowed to do to the world)
The action space MUST be small, closed, and enumerable. The model picks ONE per
turn. This is what makes the system robust β€” the model can say anything in its
*reply*, but it can only *do* things from this list, so application is
deterministic and unsurprising.
Verbs (starting set β€” expand only if a challenge needs it):
| verb | args | effect |
|-------------|-------------------------------|-----------------------------------------------------|
| `move_to` | `target: location` | alien moves |
| `pick_up` | `obj_id` | alien adds obj to `holding`, obj.location = "alien" |
| `put_in` | `obj_id, container_id` | obj.location = container; if container conceals, obj.hidden = True |
| `give` | `obj_id, agent_id` | transfer obj to another agent's holding |
| `point_at` | `obj_id | agent_id` | no world change; signals attention |
| `wait` | β€” | no-op (the confused/contemplative fallback) |
```python
@dataclass
class Action:
verb: str # one of the above
args: dict # validated against the verb's signature
```
### 2.4 Challenge (the current goal, with a MECHANICAL win condition)
```python
@dataclass
class Challenge:
id: str
title: str # "Teach the alien to hide the stone"
setup_blurb: str # shown to the player
teaches: str | None # concept id this challenge is designed to introduce (or None if it tests generalization)
win_predicate: Callable[[WorldState], bool] # checked after each action
# generalization challenges set `teaches=None` and rely on a previously
# learned concept being applied to a NEW situation.
```
**Win is ALWAYS a predicate over WorldState, never a semantic judgement by the
model.** Example: "hide the blue stone from `other`" β†’
`lambda w: w.objects["blue_stone"].hidden and w.objects["blue_stone"].location == "basket"`.
---
## 3. The model contract (strict JSON)
The model MUST return ONLY a JSON object, no prose around it, matching this schema:
```json
{
"action": { "verb": "put_in", "args": { "obj_id": "blue_stone", "container_id": "basket" } },
"utterance": "I place the blue-thing inside the holder. The other cannot see it now?",
"gap": "I do not understand why you want the other to not-see",
"candidate_concept": {
"id": "hidden_info",
"label": "hiding information",
"understanding": "one mind can hold a thing another mind does not have, on purpose"
}
}
```
Field rules:
- `action` β€” REQUIRED. `verb` MUST be in the allowed set; `args` MUST match the
verb. If the model emits an unknown verb or bad args β†’ treated as a parse
failure (see Β§4).
- `utterance` β€” REQUIRED. The alien's in-character reply. This is where the voice
and the comedy live.
- `gap` β€” nullable. The alien naming what it could NOT do/understand. Drives both
the humour and the player's sense of what to teach next. `null` when the alien
understood fully.
- `candidate_concept` β€” nullable. The alien proposing "I think you just taught me
a new primitive." When present and coherent, it becomes a ledger entry (gated β€”
see Β§5). `null` on most turns.
**Getting a small model to emit clean JSON reliably is the #1 engineering risk.**
Mitigations, in order of preference, implement at least the first two:
1. Use the model's **chat template** and a system prompt that ends with the exact
JSON schema and the instruction to output nothing else.
2. **Constrained / grammar-guided decoding** if the serving stack supports it
(e.g. `outlines`, `lmformatenforcer`, or transformers' JSON mode). This nearly
eliminates parse failures and is worth the setup.
3. As a floor: a tolerant parser that extracts the first balanced `{...}` block
from the output before `json.loads`.
---
## 4. Robustness (the JSON parse-fail path)
The model WILL occasionally produce malformed output. The system MUST degrade
gracefully, never crash, never leak a stack trace to the player.
```
call model
└─ parse JSON
β”œβ”€ success + valid action β†’ proceed
└─ failure (bad JSON | unknown verb | bad args)
└─ re-prompt ONCE, appending: "Your previous reply was not valid.
Error: <msg>. Respond again, JSON only, matching the schema."
β”œβ”€ success β†’ proceed
└─ failure again β†’ SAFE FALLBACK:
action = {"verb": "wait"}
utterance = "<the alien looks at you, not understanding>"
gap = "I could not grasp that"
candidate_concept = null
```
The safe fallback is in-character (the alien being confused is *consistent with the
fiction*), which is why this game tolerates model failure better than most.
Budget real time for this path. It is not optional polish; it is what keeps the
live demo from dying on stage.
---
## 5. The learning loop (gating ledger additions)
Do NOT silently append every `candidate_concept` β€” that pollutes the ledger and
removes the player's sense of agency. Gate it:
- A `candidate_concept` is added to the ledger only when **the player confirms**
(a lightweight "Yes, it learned that" affordance in the UI) OR when it is clearly
coherent and non-duplicative (configurable; start with explicit player confirm so
the "it learned!" beat is deliberate and screenshot-worthy).
- On addition: assign `taught_on_turn = current turn`, set `times_applied = 0`.
- When a subsequent turn's chosen action depends on an existing concept (heuristic:
the prompt-builder injected it and the model's `gap` is null on a situation that
would previously have produced a gap), increment `times_applied`. This is what
powers the "constellation grows / concept lights up when reused" UI moment.
**The generalization beat** (the emotional core): a Challenge with `teaches=None`
presents a NEW situation that a *previously taught* concept should cover. Success
is the alien spontaneously applying e.g. `hidden_info` to understand "secret" or
"surprise" without being re-taught. Author at least two of these (see Β§8).
---
## 6. The swappable model interface (PROTECT YOUR GPU QUOTA)
The model call MUST sit behind a single interface with at least three
implementations. This is both good design and the thing that lets the entire
ledger/world/challenge logic be built and tested with **zero GPU spend**.
```python
class Brain(Protocol):
def respond(self, prompt: str) -> str: # returns raw model text
...
# 1) StubBrain β€” deterministic, no GPU. Returns canned valid-JSON responses keyed
# to test scenarios. Develop the ENTIRE loop against this first.
# 2) LocalBrain β€” transformers model on 'cuda', loaded at module level, called
# inside @spaces.GPU. The real ZeroGPU path.
# 3) ModalBrain β€” calls a Modal endpoint (see Β§7). Optional; for when ZeroGPU
# queues are bad at peak, or for the fine-tune. NOT the submission path.
```
Select implementation via env var (`BRAIN=stub|local|modal`), default `stub`
locally and `local` on the Space. Day-one development happens almost entirely on
`StubBrain`.
`@spaces.GPU` wraps only the `LocalBrain.respond` call. State mutation
(ledger/world/win-check) happens OUTSIDE the decorated function so it never holds
the GPU.
---
## 7. Modal (optional, dev/serving only β€” NEVER the submission)
Reference: `github.com/modal-labs/modal-examples`, folder `06_gpu_and_ml`
(LLM fine-tuning + serving), `04_secrets` (HF token pattern). The repo's examples
are tested on Python 3.11; match that for the Modal side to avoid surprises.
Two legitimate uses, both behind `ModalBrain`:
1. **Serving the model** as an HTTP endpoint when ZeroGPU queues are slow during
peak hours. The Space's `ModalBrain` calls it. Keep the Space the deliverable.
2. **The optional LoRA fine-tune** (badge): use the $250 Modal credits to train a
small LoRA that fixes JSON-formatting reliability and locks the alien voice. Do
this ONLY if the prompt-only `LocalBrain` is genuinely struggling on those two
axes β€” do not manufacture the need just because credits exist. If you do it, the
resulting adapter loads in `LocalBrain` for the real submission.
Modal MUST NOT be required for the Space to run. If `BRAIN != modal`, no Modal
dependency should be imported.
---
## 8. Content: the challenge arc
Authoring is where delight is won, not engineering. Keep the arc SHORT β€”
**5–6 challenges**, not 15. Scope creep on content eats the UI/submission time.
Suggested arc (the spine β€” refine the wording later):
1. **Warm-up (teaches `object`/`move` are enough):** "Put the red stone in the
basket." Pure mechanical, teaches the player the interaction model and shows the
alien being literal-competent. No new concept.
2. **First real teach (`hidden_info`):** "Hide the blue stone from the other one."
The alien has `put_in` but no concept of *concealment-as-information-state*. The
player must teach "you can keep a thing so another mind does not have it."
3. **Build on it (`gift`/`give_take`+intent):** "Give the other a present." The
alien has `give` mechanically but no concept of *gift* (transfer + positive
intent). Teach it.
4. **GENERALIZATION (teaches=None, relies on `hidden_info`):** "Make a surprise for
the other." Success = the alien combines hiding (concealment) + gift (giving)
*without being re-taught either* β€” it generalizes `hidden_info` to "surprise =
gift they don't know about yet." THIS is the headline moment.
5. **Optional stretch (`trade`/`promise`):** something that requires composing two
learned concepts. Author only if time allows.
Each challenge: `setup_blurb` for the player, a `win_predicate` over WorldState,
and (for teach challenges) the target concept id.
---
## 9. UI (Gradio, custom-styled β€” the "Off-Brand" badge)
Build with `gr.Blocks` + custom CSS. The aesthetic SHOULD be committed and
distinctive (this is a judged "would you show a friend" entry, and there's a badge
for not looking like default Gradio). Apply these principles:
- **Commit to one strong aesthetic direction.** Something that fits "first contact
with a strange mind" β€” e.g. a quiet, alien, almost-archival feel; or a warm
field-notes/xenolinguist's-journal feel. Pick one and execute it precisely. Avoid
default Gradio greys and avoid generic purple-gradient-on-white "AI" look.
- **Distinctive type.** A characterful display font for the alien's voice paired
with a clean readable body font. Not Inter/Roboto/Arial defaults.
- **The three panels:**
1. **The world** β€” a simple visual (even CSS/SVG boxes is fine) showing
objects/agents/containers and their state. The `hidden` flag must be *visibly*
represented (e.g. an object inside the basket shown as concealed).
2. **The conversation** β€” player utterances + the alien's replies. The alien's
`gap` SHOULD be shown distinctly (a muted "…did not understand…" line) because
it's both funny and instructive.
3. **The ledger** β€” the learned concepts, rendered as a *growing* set (a
constellation, a glossary, a stack of field-notes). When a concept is newly
learned or re-applied, it SHOULD visibly light up / animate once. This is the
screenshot.
- **One well-orchestrated reveal beats scattered micro-animations.** Put the polish
budget on the "concept learned" / "generalization succeeded" moment.
- **The success state** (esp. the generalization challenge) MUST be unmistakable and
shareable β€” a clear "it understood you" beat the player will want to screenshot
for the required social post.
Keep backgrounds atmospheric, not flat. No browser-storage. Everything reactive via
`gr.State` updates.
---
## 10. Repo layout
```
.
β”œβ”€β”€ app.py # Gradio Blocks UI + turn loop wiring. Space entrypoint.
β”œβ”€β”€ README.md # Space card (YAML header: sdk: gradio, sdk_version, hardware) + how-to
β”œβ”€β”€ requirements.txt # pinned; see Β§0 for version floors
β”œβ”€β”€ game/
β”‚ β”œβ”€β”€ __init__.py
β”‚ β”œβ”€β”€ models.py # dataclasses: Concept, Obj, Agent, WorldState, Action, Challenge
β”‚ β”œβ”€β”€ ledger.py # seed primitives, add/gate, times_applied tracking
β”‚ β”œβ”€β”€ world.py # apply_action (deterministic), initial world factory
β”‚ β”œβ”€β”€ challenges.py # the 5–6 challenges + win predicates (Β§8)
β”‚ β”œβ”€β”€ prompt.py # build_prompt(ledger, world, challenge, utterance) -> str
β”‚ β”œβ”€β”€ parsing.py # tolerant JSON extract + validate + the Β§4 retry/fallback
β”‚ └── brain.py # Brain protocol + StubBrain | LocalBrain | ModalBrain (Β§6)
β”œβ”€β”€ tests/
β”‚ β”œβ”€β”€ test_world.py # apply_action correctness, win predicates
β”‚ β”œβ”€β”€ test_parsing.py # malformed-output handling, fallback path
β”‚ └── test_loop_stub.py # full turn loop against StubBrain, zero GPU
└── modal/ # OPTIONAL, only if Β§7 is used
β”œβ”€β”€ serve.py # Modal serving endpoint
└── finetune_lora.py # optional LoRA training job
```
---
## 11. Build order (maps to the two-weekend window)
This is spine-first on purpose. The classic hackathon death is polishing
disconnected pieces that never form a loop.
**Weekend 1 β€” make the loop real**
- **Day 1 (Fri eve / Sat):**
- `game/models.py`, `game/world.py` (apply_action + initial world), `game/ledger.py`
(seed primitives).
- `game/parsing.py` with the full Β§4 fallback.
- `StubBrain` returning canned valid JSON for challenge #1 and #2.
- Minimal `app.py`: one hardcoded challenge, the turn loop wired end-to-end
against `StubBrain`, world + conversation rendering. Ugly is fine.
- `tests/test_world.py`, `tests/test_loop_stub.py` green. **All on zero GPU.**
- Stand up the empty Space early and push, to shake out the deploy/secrets/env
before there's anything to lose.
- **Day 2 (Sun):**
- `LocalBrain` on the Space: model to `'cuda'` at module level, `respond` inside
`@spaces.GPU(duration=...)`. Pick the model by testing 3–4 ≀32B instruct models
on the Β§3 prompt for **JSON-formatting reliability first**, capability second.
- Ledger gating + `candidate_concept` flow + the `times_applied` increment.
- Challenge #4 (the generalization beat) authored and working against the real
model. **If the generalization doesn't feel magical here, the concept design
needs adjusting and you still have a week to pivot.**
**Midweek (evenings) β€” harden + de-risk**
- Tighten the system prompt so the alien voice stays consistently *alien* and
doesn't drift to helpful-assistant. Add constrained decoding (Β§3.2) if parse
failures are common.
- This is the fine-tune decision point (Β§7): only if prompt-only is fighting you on
JSON or voice.
- Catch the hackathon AMA if it lands midweek; ask specifically about ZeroGPU quota
behaviour and JSON-mode tricks.
**Weekend 2 β€” content, UI, ship**
- **Day 1 (Sat):** finish the challenge arc (Β§8), then the custom-styled UI pass
(Β§9) with the polish budget concentrated on the "concept learned" /
"generalization succeeded" reveal.
- **Day 2 (Sun):** the submission is itself graded β€” Space under the org + demo
video + social post. Budget the back half of Sunday for it. Script the demo
around the single best generalization moment. Submit with margin, not at the
deadline.
---
## 12. Definition of done
- Runs as a Gradio app on a ZeroGPU Space under the hackathon org, model ≀32B,
loaded at module level, inference in `@spaces.GPU`.
- A fresh visitor gets their own session state (verified: two browsers don't share
an alien).
- The full arc is playable: literal warm-up β†’ first concept taught β†’ at least one
generalization beat where a learned concept transfers to a new situation
unprompted.
- Malformed model output never crashes the app; the alien "looks confused" instead.
- The ledger visibly grows and a concept lights up on learn/re-use.
- Demo video + social post produced, both centred on the generalization moment.
- (Optional badges as time allows: LoRA fine-tune, custom UI β€” UI is in scope
regardless.)
---
## 13. Things NOT to do
- Do NOT let the model judge whether communication "succeeded" β€” success is always
a WorldState predicate.
- Do NOT seed social/abstract concepts in the ledger β€” they must be taught.
- Do NOT use module globals for game state.
- Do NOT lazy-load the model inside `@spaces.GPU`.
- Do NOT use `torch.compile` (unsupported on ZeroGPU).
- Do NOT make Modal a hard dependency of the Space.
- Do NOT burn ZeroGPU quota developing the loop β€” that's what `StubBrain` is for.
- Do NOT expand the challenge arc past ~6 β€” content scope creep kills the UI/submission time.