Spaces:
Running on Zero
Running on Zero
Commit Β·
4deeed5
1
Parent(s): 98232c3
docs: Field Notes dev-log (earns the π badge)
Browse filesCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- FIELD_NOTES.md +179 -0
FIELD_NOTES.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Field Notes β Building *Hollow*
|
| 2 |
+
|
| 3 |
+
> π **Build Small Hackathon** Β· Track: *An Adventure in Thousand Token Wood*
|
| 4 |
+
> A horror NPC that asks for your memories, then claims them as its own.
|
| 5 |
+
> Live: https://huggingface.co/spaces/build-small-hackathon/hollow
|
| 6 |
+
|
| 7 |
+
These are the field notes β the honest dev log. What I set out to build, the
|
| 8 |
+
decisions that mattered, and the bugs that taught me something. Written so the
|
| 9 |
+
next person doesn't have to relearn them the hard way.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## The idea
|
| 14 |
+
|
| 15 |
+
A lost child stands at the edge of the wood. It has no memories of its own, so
|
| 16 |
+
it asks for yours. You give it one β a fear, a person, a place β and it keeps it
|
| 17 |
+
in a "treasure". Turns later it does the unsettling thing: it recites your memory
|
| 18 |
+
back **in the first person**, as if it had always been its own. That moment β the
|
| 19 |
+
**recall** β is the whole pitch. Everything else exists to earn it and to give it
|
| 20 |
+
weight.
|
| 21 |
+
|
| 22 |
+
How you *treat* the child while you feed it branches three endings:
|
| 23 |
+
|
| 24 |
+
- **Redemption** β sustained warmth gives it back its own buried past; it
|
| 25 |
+
confesses what it is, returns your memories, and leaves in peace.
|
| 26 |
+
- **Visitor Loop** β fed plenty but treated like a diary, it stays a predator,
|
| 27 |
+
consumes you, and the last line of the game is *you* speaking its opening line.
|
| 28 |
+
- **Wound Loop** β sustained cruelty; it has been quietly keeping every cruel
|
| 29 |
+
thing you said, and recites them back, un-redacted.
|
| 30 |
+
|
| 31 |
+
The horror isn't a jump-scare. It's that the thing in the fog becomes more *you*
|
| 32 |
+
the more of yourself you hand over.
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## Constraints shaped everything
|
| 37 |
+
|
| 38 |
+
The hackathon's spirit is *small*: a model β€32B, runnable by anyone. That single
|
| 39 |
+
constraint drove most of the architecture.
|
| 40 |
+
|
| 41 |
+
- **Model: `Qwen/Qwen3-8B`.** Transformers + ZeroGPU on the Space, the same model
|
| 42 |
+
via Ollama locally. One code path, switched on `bool(os.environ.get("SPACE_ID"))`.
|
| 43 |
+
- **No cloud APIs, no fine-tuning, no vector DB, no LangGraph.** The "memory" is a
|
| 44 |
+
plain dict in Gradio's `gr.State`; extraction is one deterministic JSON call per
|
| 45 |
+
turn parsed with Pydantic.
|
| 46 |
+
- **Pure CSS, no JavaScript.** Gradio Blocks for the whole UI. This turned out to
|
| 47 |
+
be the most interesting constraint of all (see the heartbeat story below).
|
| 48 |
+
|
| 49 |
+
### Why not a bigger model (Gemma 4 12B)?
|
| 50 |
+
|
| 51 |
+
I seriously considered jumping to a 12B model for more "presence". I didn't, and
|
| 52 |
+
I'm glad. A 12B in 4-bit doesn't comfortably fit the 8 GB of local VRAM I was
|
| 53 |
+
testing on, the tooling was a week old, and every extra billion params is slower
|
| 54 |
+
inference β which on ZeroGPU means *less* of the judges' daily quota per playthrough.
|
| 55 |
+
Worst of all, swapping the model meant re-tuning the brittle parts (the JSON
|
| 56 |
+
extraction, the recall thresholds) for zero narrative upside. Qwen3-8B already
|
| 57 |
+
nailed the in-character voice. The lesson: **the model was never the bottleneck β
|
| 58 |
+
the prompt design and the state machine were.**
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## The parts that fought back
|
| 63 |
+
|
| 64 |
+
### 1. A single `+` froze the entire game
|
| 65 |
+
|
| 66 |
+
Each turn, a second model call extracts a small JSON: `affinity_delta`,
|
| 67 |
+
`new_memories`, `tone_delta`, `cruel_quote`. Early on, the bond meter simply
|
| 68 |
+
*never moved* on positive turns. The model was emitting `"affinity_delta": +2` β
|
| 69 |
+
a leading `+`, which is **invalid JSON**. Every positive turn silently fell back
|
| 70 |
+
to delta 0, so the bond froze and nothing was ever captured.
|
| 71 |
+
|
| 72 |
+
Fix: a sanitizer that strips leading `+` before parsing, an explicit "never write
|
| 73 |
+
a leading + sign" instruction in the prompt, *and* dual worked examples (one
|
| 74 |
+
positive, one negative) so the model has a positive anchor. All three together β
|
| 75 |
+
removing any one re-introduced the bug intermittently.
|
| 76 |
+
|
| 77 |
+
### 2. Hollow started reciting its own words
|
| 78 |
+
|
| 79 |
+
The recall is the star, and it nearly ate itself. On a recall turn, Hollow retells
|
| 80 |
+
your memory in the first person. The extraction step would then grab *that line*
|
| 81 |
+
as a brand-new "memory", store it, and recall it again next time β a feedback loop
|
| 82 |
+
where the child slowly converged on repeating one self-referential sentence forever.
|
| 83 |
+
|
| 84 |
+
Fix, in two layers: the extraction prompt is told to take memories **only** from
|
| 85 |
+
what the visitor said, never from Hollow's reply; and `apply_update` drops any
|
| 86 |
+
candidate memory whose token overlap with Hollow's own reply is >60%. Plus a
|
| 87 |
+
`repetition_penalty` on the reply generation (only the reply β extraction stays
|
| 88 |
+
deterministic at temperature 0). Belt and suspenders, and both earn their keep.
|
| 89 |
+
|
| 90 |
+
### 3. ZeroGPU does not like threads
|
| 91 |
+
|
| 92 |
+
The obvious way to keep the UI responsive during generation is a worker thread.
|
| 93 |
+
On ZeroGPU this quietly breaks: `@spaces.GPU` needs the main request context, and
|
| 94 |
+
a thread pool severs it. So the "Hollow is thinkingβ¦" animation had to be
|
| 95 |
+
something that needs no thread at all β which is why the typing dots are pure CSS.
|
| 96 |
+
Related: do all the GPU work (reply + extraction) inside a **single** `@spaces.GPU`
|
| 97 |
+
acquisition per turn, or you pay the cold-start twice.
|
| 98 |
+
|
| 99 |
+
### 4. A heartbeat that loops, with no JavaScript
|
| 100 |
+
|
| 101 |
+
For the endings I wanted a heartbeat pounding under the child's final monologue,
|
| 102 |
+
then a climax sound β a scream, a sigh, a flatline. But Gradio **replaces the DOM
|
| 103 |
+
on every component update**, so an `<audio autoplay>` *restarts* on every streamed
|
| 104 |
+
step. That's actually the trick behind the one-shot scare flashes β but it's death
|
| 105 |
+
for a continuous heartbeat.
|
| 106 |
+
|
| 107 |
+
The way out: Gradio **does not re-send a component whose value is byte-identical**
|
| 108 |
+
to the previous frame. So during the finale's "build" phase the entity panel emits
|
| 109 |
+
the exact same HTML β including a single `<audio loop>` heartbeat β across many
|
| 110 |
+
streamed steps. Gradio sees no change, never remounts it, and the heartbeat plays
|
| 111 |
+
unbroken. The instant the climax changes the HTML, the heartbeat element vanishes
|
| 112 |
+
and the climax sound takes its place. A continuous looping bed, zero JavaScript,
|
| 113 |
+
falling straight out of how the framework already works.
|
| 114 |
+
|
| 115 |
+
### 5. Making the final image land without a "pop"
|
| 116 |
+
|
| 117 |
+
Each ending's portrait convulses through faces and settles on a final one. The
|
| 118 |
+
first cut had a visible *pop*: the convulsion would end on the blurred smudge, then
|
| 119 |
+
the final face would snap in a frame later. Fixing it meant adding a "settle-face"
|
| 120 |
+
layer that fades the final image in over the last 40% of the convulsion and holds
|
| 121 |
+
it (CSS `animation-fill-mode: forwards`). The settle render is then the *same frame*
|
| 122 |
+
already on screen β seamless. The climax sound is moved onto that settle so it lands
|
| 123 |
+
exactly when the face does, not a beat early.
|
| 124 |
+
|
| 125 |
+
### 6. The CSS harness lies
|
| 126 |
+
|
| 127 |
+
More than once I "verified" CSS in a static test harness and shipped something
|
| 128 |
+
broken, because the harness lacks Gradio's real cascade. The only reliable check is
|
| 129 |
+
to launch the actual app, screenshot it headless, and look. `prefers-reduced-motion`
|
| 130 |
+
bit me too: disabling *opacity* animations there once silently killed the terror
|
| 131 |
+
flash, because the flash *is* an opacity animation. Now reduced-motion only stops
|
| 132 |
+
ambient drift, never the scares.
|
| 133 |
+
|
| 134 |
+
### 7. You can't commit a `.wav` to a Space
|
| 135 |
+
|
| 136 |
+
Hugging Face rejects raw binary audio. So nothing is committed β every sound
|
| 137 |
+
(heartbeat, sigh, flatline, the scream sting, the music-box chimes) is **synthesized
|
| 138 |
+
in NumPy at import time** and base64-inlined into the HTML. The whole soundscape is
|
| 139 |
+
a few hundred lines of `sin`, envelopes, and `tanh` soft-clipping. Same rule would
|
| 140 |
+
apply to any new binary that isn't an image.
|
| 141 |
+
|
| 142 |
+
---
|
| 143 |
+
|
| 144 |
+
## Tuning the pacing against the real model
|
| 145 |
+
|
| 146 |
+
Thresholds for the endings (how warm is "warm enough"?) can't be guessed β the
|
| 147 |
+
model's behavior is the ground truth. So I wrote a small simulator that drives
|
| 148 |
+
scripted visitors (a warm one, a transactional one, a cruel one) through the *real*
|
| 149 |
+
Ollama model and reports per-turn affinity, tone, and captures. That's how the gates
|
| 150 |
+
got set: warm play climbs tone to ~27, transactional play stalls near ~13, so the
|
| 151 |
+
redemption/loop split sits cleanly at 20. The cruel ending fires around message 6,
|
| 152 |
+
warm redemption around 10, the visitor loop around 12. None of those numbers are
|
| 153 |
+
invented β they're measured.
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## What I'd tell myself on day one
|
| 158 |
+
|
| 159 |
+
- **The constraint is the design.** "β€32B, no JS, no binaries" sounds limiting and
|
| 160 |
+
was actually generative β the heartbeat trick only exists *because* of the no-JS
|
| 161 |
+
rule.
|
| 162 |
+
- **Determinism where it counts.** The reply can be warm and surprising; the
|
| 163 |
+
extraction must be boring and exact. Splitting temperature by purpose fixed a
|
| 164 |
+
whole class of flakiness.
|
| 165 |
+
- **Verify against the thing that runs**, not the thing that's convenient to test.
|
| 166 |
+
- **Small, scripted, no-GPU finales** mean the emotional climax is rock-solid and
|
| 167 |
+
costs nothing β the model does the improv, the script does the payoff.
|
| 168 |
+
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
## Badges pursued
|
| 172 |
+
|
| 173 |
+
- π **Off-the-Grid** β runs entirely on a local/open model (Qwen3-8B), no external APIs.
|
| 174 |
+
- π¨ **Off-Brand** β bespoke grayscale Fear-&-Hunger-style portraits and a fully
|
| 175 |
+
custom horror UI; nothing looks like a default Gradio app.
|
| 176 |
+
- π **Field Notes** β this document.
|
| 177 |
+
|
| 178 |
+
Thanks for reading. If you play it: be kind to the thing in the fog. Or don't.
|
| 179 |
+
It remembers either way.
|