neilA / SPEC.md
TriggerFish212's picture
initial commit
b54f80c
|
Raw
History Blame Contribute Delete
24.2 kB

A newer version of the Gradio SDK is available: 6.19.0

Upgrade

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)

@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:

@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`
wait β€” no-op (the confused/contemplative fallback)
@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)

@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:

{
  "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.

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.