AndresCarreon commited on
Commit
772074b
·
verified ·
1 Parent(s): 1943d91

PAREIDOLIA v0 — everything secretly has a face

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +43 -0
  2. FIELD_NOTES.md +150 -0
  3. README.md +137 -7
  4. app.py +525 -0
  5. cv/__init__.py +10 -0
  6. cv/__pycache__/__init__.cpython-312.pyc +0 -0
  7. cv/__pycache__/snap.cpython-312.pyc +0 -0
  8. cv/snap.py +533 -0
  9. mind/WRITING.md +92 -0
  10. mind/__init__.py +54 -0
  11. mind/__pycache__/__init__.cpython-312.pyc +0 -0
  12. mind/__pycache__/backends.cpython-312.pyc +0 -0
  13. mind/__pycache__/prompts.cpython-312.pyc +0 -0
  14. mind/__pycache__/schema.cpython-312.pyc +0 -0
  15. mind/__pycache__/voice.cpython-312.pyc +0 -0
  16. mind/backends.py +683 -0
  17. mind/mock_assets/grudge.wav +3 -0
  18. mind/prompts.py +508 -0
  19. mind/schema.py +360 -0
  20. mind/voice.py +225 -0
  21. requirements-dev.txt +15 -0
  22. requirements.txt +21 -0
  23. seeds/eval/LICENSES.md +21 -0
  24. seeds/eval/_manifest.json +240 -0
  25. seeds/eval/armchair.jpg +3 -0
  26. seeds/eval/coffee_mug.jpg +3 -0
  27. seeds/eval/desk_lamp.jpg +0 -0
  28. seeds/eval/exercise_bike.jpg +3 -0
  29. seeds/eval/fire_hydrant.jpg +3 -0
  30. seeds/eval/garden_gnome.jpg +3 -0
  31. seeds/eval/mailbox.jpg +3 -0
  32. seeds/eval/park_bench.jpg +3 -0
  33. seeds/eval/payphone.jpg +3 -0
  34. seeds/eval/potted_plant.jpg +3 -0
  35. seeds/eval/stand_mixer.jpg +3 -0
  36. seeds/eval/stapler.jpg +3 -0
  37. seeds/eval/toaster.jpg +0 -0
  38. seeds/eval/traffic_cone.jpg +3 -0
  39. seeds/records/records.jsonl +12 -0
  40. seeds/records/seed_armchair.jpg +3 -0
  41. seeds/records/seed_armchair.json +57 -0
  42. seeds/records/seed_armchair.wav +3 -0
  43. seeds/records/seed_coffee_mug.jpg +3 -0
  44. seeds/records/seed_coffee_mug.json +57 -0
  45. seeds/records/seed_coffee_mug.wav +3 -0
  46. seeds/records/seed_fire_hydrant.jpg +3 -0
  47. seeds/records/seed_fire_hydrant.json +57 -0
  48. seeds/records/seed_fire_hydrant.wav +3 -0
  49. seeds/records/seed_garden_gnome.jpg +3 -0
  50. seeds/records/seed_garden_gnome.json +57 -0
.gitattributes CHANGED
@@ -33,3 +33,46 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ mind/mock_assets/grudge.wav filter=lfs diff=lfs merge=lfs -text
37
+ seeds/eval/armchair.jpg filter=lfs diff=lfs merge=lfs -text
38
+ seeds/eval/coffee_mug.jpg filter=lfs diff=lfs merge=lfs -text
39
+ seeds/eval/exercise_bike.jpg filter=lfs diff=lfs merge=lfs -text
40
+ seeds/eval/fire_hydrant.jpg filter=lfs diff=lfs merge=lfs -text
41
+ seeds/eval/garden_gnome.jpg filter=lfs diff=lfs merge=lfs -text
42
+ seeds/eval/mailbox.jpg filter=lfs diff=lfs merge=lfs -text
43
+ seeds/eval/park_bench.jpg filter=lfs diff=lfs merge=lfs -text
44
+ seeds/eval/payphone.jpg filter=lfs diff=lfs merge=lfs -text
45
+ seeds/eval/potted_plant.jpg filter=lfs diff=lfs merge=lfs -text
46
+ seeds/eval/stand_mixer.jpg filter=lfs diff=lfs merge=lfs -text
47
+ seeds/eval/stapler.jpg filter=lfs diff=lfs merge=lfs -text
48
+ seeds/eval/traffic_cone.jpg filter=lfs diff=lfs merge=lfs -text
49
+ seeds/records/seed_armchair.jpg filter=lfs diff=lfs merge=lfs -text
50
+ seeds/records/seed_armchair.wav filter=lfs diff=lfs merge=lfs -text
51
+ seeds/records/seed_coffee_mug.jpg filter=lfs diff=lfs merge=lfs -text
52
+ seeds/records/seed_coffee_mug.wav filter=lfs diff=lfs merge=lfs -text
53
+ seeds/records/seed_fire_hydrant.jpg filter=lfs diff=lfs merge=lfs -text
54
+ seeds/records/seed_fire_hydrant.wav filter=lfs diff=lfs merge=lfs -text
55
+ seeds/records/seed_garden_gnome.jpg filter=lfs diff=lfs merge=lfs -text
56
+ seeds/records/seed_garden_gnome.wav filter=lfs diff=lfs merge=lfs -text
57
+ seeds/records/seed_mailbox.jpg filter=lfs diff=lfs merge=lfs -text
58
+ seeds/records/seed_mailbox.wav filter=lfs diff=lfs merge=lfs -text
59
+ seeds/records/seed_park_bench.jpg filter=lfs diff=lfs merge=lfs -text
60
+ seeds/records/seed_park_bench.wav filter=lfs diff=lfs merge=lfs -text
61
+ seeds/records/seed_payphone.jpg filter=lfs diff=lfs merge=lfs -text
62
+ seeds/records/seed_payphone.wav filter=lfs diff=lfs merge=lfs -text
63
+ seeds/records/seed_potted_plant.jpg filter=lfs diff=lfs merge=lfs -text
64
+ seeds/records/seed_potted_plant.wav filter=lfs diff=lfs merge=lfs -text
65
+ seeds/records/seed_stand_mixer.jpg filter=lfs diff=lfs merge=lfs -text
66
+ seeds/records/seed_stand_mixer.wav filter=lfs diff=lfs merge=lfs -text
67
+ seeds/records/seed_stapler.jpg filter=lfs diff=lfs merge=lfs -text
68
+ seeds/records/seed_stapler.wav filter=lfs diff=lfs merge=lfs -text
69
+ seeds/records/seed_toaster.wav filter=lfs diff=lfs merge=lfs -text
70
+ seeds/records/seed_traffic_cone.jpg filter=lfs diff=lfs merge=lfs -text
71
+ seeds/records/seed_traffic_cone.wav filter=lfs diff=lfs merge=lfs -text
72
+ web/mock/life-dev.wav filter=lfs diff=lfs merge=lfs -text
73
+ web/mock/photos/m1.jpg filter=lfs diff=lfs merge=lfs -text
74
+ web/mock/photos/m2.jpg filter=lfs diff=lfs merge=lfs -text
75
+ web/mock/photos/m3.jpg filter=lfs diff=lfs merge=lfs -text
76
+ web/mock/photos/m5.jpg filter=lfs diff=lfs merge=lfs -text
77
+ web/mock/photos/m6.jpg filter=lfs diff=lfs merge=lfs -text
78
+ web/mock/photos/m7.jpg filter=lfs diff=lfs merge=lfs -text
FIELD_NOTES.md ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 👁️ PAREIDOLIA — Field Notes
2
+
3
+ *Build log for the Build Small Hackathon (Thousand Token Wood). Started June 12, 2026.*
4
+
5
+ ## The idea, and why this shape
6
+
7
+ Pareidolia is the thing your brain does when it finds a face in a power outlet, a
8
+ church facade, the front of a Jeep. Everyone has it. No app celebrates it.
9
+
10
+ So: point your camera at any object. A small vision model — **MiniCPM-V-4_5, 8.7B**
11
+ — looks at the photo the way you do at clouds: it *names the features that were
12
+ already there* ("the two bonnet bolts", "the front outlet cap") and decides what
13
+ they are — eyes, a mouth. It reads the object's condition (rusted, pristine,
14
+ dusty, abandoned) and writes it a soul to match. Then **VoxCPM2, 2.29B** gives it
15
+ a voice — designed from a text description, no reference audio — and the object
16
+ tells you, specifically, what it has been putting up with.
17
+
18
+ A fire hydrant that has been on the same corner for forty years has *opinions*.
19
+
20
+ ## Small models used smartly (the part we care about)
21
+
22
+ The org's favorite pattern — deterministic code owns the facts, the tiny model
23
+ owns the taste — is load-bearing here, twice:
24
+
25
+ 1. **The VLM never emits final pixel coordinates.** No VLM is trained to locate
26
+ face-like features in faceless objects; raw-coordinate eye placement misfires,
27
+ and a misplaced eye reads as a broken sticker instead of a discovered soul.
28
+ So MiniCPM-V *names* existing visual features and gives coarse normalized
29
+ hints; classical OpenCV (Hough circles, blob centroids, corner anchors, a
30
+ pair-coherence rule) snaps them to the strongest real anchor nearby. Taste
31
+ from the model, facts from the code. The mist-emergence animation forgives
32
+ the remaining ±15%.
33
+ 2. **One generation, no second opinion round-trip.** The séance prompt asks for a
34
+ *critique-then-final* JSON in a single call — the model second-guesses its own
35
+ feature choices and line specificity inside one generation. Halves latency;
36
+ the séance theater carries the rest.
37
+
38
+ And the writing is governed, not vibes: an 8–12 **grudge-archetype bank keyed to
39
+ object condition** (the_veteran/rusted, the_martyr/worn, the_perfectionist/
40
+ pristine, the_diva/loved…), an iron rule that every line must reference a
41
+ *visible specific* of THIS photo, validators for the rules a regex can hold
42
+ (length, banned openers), and a hard ship-gate: a human must actually laugh at
43
+ ≥6 of 12 eval objects, or the prompt iterates before anything else gets polished.
44
+
45
+ ## Architecture in one diagram
46
+
47
+ ```
48
+ phone camera ──► client downscale (≤1024px JPEG)
49
+ │
50
+ ▼ browser calls the gradio endpoint itself (@gradio/client)
51
+ @app.api("awaken") ── one @spaces.GPU window, visitor's own quota ──┐
52
+ │ MiniCPM-V-4_5: gate → features (named, coarse) → persona → lines │
53
+ │ OpenCV: snap features to real anchors (deterministic) │
54
+ │ VoxCPM2: voice-design TTS for the grudge line │
55
+ ◄────────────────────────────────────────────────────────────────────┘
56
+ ▼
57
+ the LIFE engine (client SVG): mist → eyes of the photo's own palette →
58
+ first blink → saccades, pupil-follow → mouth rides the wav's amplitude
59
+ ▼
60
+ "add to the menagerie?" ──► persisted record + image + voice →
61
+ shared wall every visitor sees, alive with ZERO GPU → HF dataset (public)
62
+ ```
63
+
64
+ The Space *opens* on the Menagerie — every awakened object blinking and
65
+ glancing around, overlays rendered client-side from persisted JSON — so the
66
+ landing state is fully alive even when ZeroGPU is asleep or queued. Live
67
+ awakening is the second act, billed to the visitor's own quota (the "her"
68
+ browser-invocation idiom).
69
+
70
+ Privacy/abuse, by construction: the same VLM call gates real human faces and
71
+ NSFW before anything else runs (the refusal is poetic: *"It is already awake."*);
72
+ nothing reaches the public wall except server-persisted, gate-checked records;
73
+ no user identity is stored.
74
+
75
+ ## Build log
76
+
77
+ ### June 12 — recon, contract, fleet
78
+ - 5-agent verification recon over the live field (370 spaces): the pareidolia
79
+ hook is unclaimed; the OpenBMB wood lane has real contenders now; model IDs
80
+ from earlier notes were wrong in both directions ("MiniCPM-V-4_6" ≠ 9B model —
81
+ it's a 1.3B edge model with an incompatible stack; VoxCPM2's killer feature,
82
+ text-only voice design, wasn't in any of our notes).
83
+ - Wrote the binding `ARCHITECTURE.md` + `mind/WRITING.md` (the comedy bible)
84
+ BEFORE any code — six agents then built server/mind/cv/web in parallel against
85
+ the contract, on a mock backend, no GPU needed.
86
+ - Bench Space (we tried to rent a paid L40S; the org has no pre-paid credits —
87
+ every paid tier 402'd — so the bench runs on the same ZeroGPU grant as prod,
88
+ which turned out to be the better experiment anyway: the timings are
89
+ *exactly* representative). The slice is generous: an RTX PRO 6000 Blackwell
90
+ MIG 2g.48gb, 50.9GB. Both models co-resident at 24GB.
91
+ - **Warm numbers**: MiniCPM-V structured JSON ~9.8–15s GPU (image sliced ≤4,
92
+ 600–700 new tokens, greedy); VoxCPM2 ~4s GPU for ~5s of 48kHz speech; one
93
+ full awakening ≈ **17–25s** visitor-facing. The séance theater has to carry
94
+ ~20s, and does.
95
+ - **The writing eval is a gate, not a vibe**: 14 CC0 object photos through the
96
+ real pipeline, a harsh-editor laugh count, four prompt rounds. Round 1
97
+ collapsed to the_perfectionist on 8/14 objects and opened every line with a
98
+ count of years — exemplar-skeleton cloning under greedy decoding. The fixes
99
+ that worked were *rules about the world, not rules about jokes*: check for
100
+ rust before calling anything pristine; another of its kind in frame forces
101
+ the_rival; name the thing, not the feeling (banned vocabulary: purpose,
102
+ potential, neglect…). Best line the model wrote, unprompted, about a mug
103
+ with a violin-shaped handle: *"Why a violin? Why not a guitar?"*
104
+ - **Grounding truth**: the VLM hallucinates coordinates ~40% of the time on
105
+ hard objects (it once placed "hinge screws" confidently in blank wall). The
106
+ deterministic snap layer + a render-side forgiveness clamp + curation gets
107
+ the genesis wall to ~10/12 plausible. We do not pretend otherwise — the
108
+ trace dataset shows every snap delta.
109
+
110
+ ### June 13 — <!-- TODO(fill): deploy, seeds, polish, social -->
111
+
112
+ ### June 14 — <!-- TODO(fill): demo video, org blog -->
113
+
114
+ ## Things that bit us (so they don't bite you)
115
+
116
+ - **`voxcpm` pip backtracking is silent and brutal**: voxcpm 2.x requires
117
+ `gradio>=6,<7`. If anything in your stack pins gradio below 6, pip quietly
118
+ resolves voxcpm **1.5.0**, which has no VoxCPM2 dispatch — your "VoxCPM2"
119
+ Space then reads the persona prefix *aloud* at 16kHz. Pin `voxcpm>=2.0.3`.
120
+ - **torchcodec is coupled to torch minor versions**: unpinned it resolves to
121
+ 0.14, which demands torch≥2.11 and drags the whole stack against the Space
122
+ image's preinstalled torch 2.8.0. `torchcodec==0.7.*` + `torchaudio==2.8.*`.
123
+ - **`TORCHDYNAMO_DISABLE=1` before any torch import** — VoxCPM's torch.compile
124
+ warmup dies on ZeroGPU ("Cannot construct ConstantVariable for
125
+ torch.device"). Or pass `optimize=False`; we do both.
126
+ - **MiniCPM-V-4_5 lives on the transformers v4 line** (its remote code breaks
127
+ on v5), and transformers v4 needs huggingface-hub<1.0, which conflicts with
128
+ gradio 6.18's hub≥1.0 — `sdk_version: 6.16.0` is the keystone holding the
129
+ arch up. Also: `attn_implementation="sdpa"`, never eager; and pass
130
+ `enable_thinking=False` or a hybrid-thinking model will eat your token
131
+ budget thinking about a stapler.
132
+ - **Load BOTH models at startup, never inside the GPU window.** ZeroGPU runs
133
+ your GPU function in a forked worker; lazy state does not persist. Our first
134
+ wiring lazy-loaded the 2.3B voice inside the visitor's window — that's a
135
+ Hub download on someone else's quota.
136
+ - **Never trust a VLM with pixels.** Ask it to *name* features and give coarse
137
+ hints; let Hough circles and blob centroids do the placing. When we asked
138
+ for raw coordinates it gave both eyes the same point — on the object's hat.
139
+ - **PIL decompression bombs are reachable before your GPU auth** — cap
140
+ declared dimensions before `.load()`, not just upload bytes.
141
+
142
+ ## Models
143
+
144
+ | Role | Model | Params | Why |
145
+ |---|---|---|---|
146
+ | Eyes & soul | [openbmb/MiniCPM-V-4_5](https://huggingface.co/openbmb/MiniCPM-V-4_5) | 8.7B | the only small VLM whose feature-naming + condition-reading is good enough to be load-bearing |
147
+ | Voice | [openbmb/VoxCPM2](https://huggingface.co/openbmb/VoxCPM2) | 2.29B | voice *design from a text description* — ten distinct characters, zero reference audio |
148
+
149
+ ≈ 11B total. Comfortably under the 32B cap, and every parameter is doing
150
+ something you can see or hear.
README.md CHANGED
@@ -1,13 +1,143 @@
1
  ---
2
- title: Pareidolia
3
- emoji: 🦀
4
- colorFrom: red
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: PAREIDOLIA
3
+ emoji: 👁️
4
+ colorFrom: gray
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.16.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
+ short_description: "point at anything. find the face that was always there."
12
+ models:
13
+ - openbmb/MiniCPM-V-4_5
14
+ - openbmb/VoxCPM2
15
+ datasets:
16
+ - AndresCarreon/pareidolia-menagerie
17
+ tags:
18
+ - track:wood
19
+ - sponsor:openbmb
20
+ - achievement:offbrand
21
+ - achievement:sharing
22
+ - achievement:fieldnotes
23
+ - achievement:offgrid
24
+ - build-small-hackathon
25
+ - minicpm
26
+ - voxcpm
27
+ - camera
28
+ - multimodal
29
  ---
30
 
31
+ # 👁️ PAREIDOLIA
32
+
33
+ **Everything secretly has a face.**
34
+
35
+ Point your camera at any object. A small **MiniCPM-V** spirit-medium studies the
36
+ photo the way you study clouds — it finds the two bonnet bolts that were always
37
+ eyes, the outlet cap that was always a mouth — and the face that was hiding in
38
+ your fire hydrant opens its eyes, blinks at you, and tells you, in its own
39
+ voice, exactly what it has been putting up with.
40
+
41
+ > *"Forty years on this corner. Not one dog has shown me respect."*
42
+
43
+ Every awakened object joins the **Menagerie** — one public wall, shared by every
44
+ visitor, where all of them blink, glance around, and mutter.
45
+
46
+ <!-- TODO(main, June 13): hero GIF here — cut on the first blink. -->
47
+
48
+ ## ✨ The moment that sells it
49
+
50
+ You photograph your own coffee mug. Mist crosses the photo while the medium
51
+ works — *"there is something here…"* — then it names what it found: *"the two
52
+ painted notes… symmetric…"* Two eyes the exact color of the glaze fade onto the
53
+ ceramic and **blink**. And then the mug speaks. It has noticed things about you.
54
+ It has been keeping a list.
55
+
56
+ No face is invented. The eyes land on features that are *really there* — that's
57
+ the whole point, and the whole name.
58
+
59
+ ## 🧠 How it works — taste from the model, facts from the code
60
+
61
+ A hard separation of church and state, twice over:
62
+
63
+ ```
64
+ your photo (browser downscales; YOUR ZeroGPU quota pays for your séance)
65
+ │
66
+ ▼ one @spaces.GPU window, ~20s
67
+ ┌─ THE MEDIUM · MiniCPM-V-4_5 ──────────────────────────────────┐
68
+ │ ONE structured generation (critique-then-final JSON): │
69
+ │ privacy gate → object + condition → named VISIBLE features │
70
+ │ ("left bonnet bolt: eye") → persona → the grudge │
71
+ └──────────────┬────────────────────────────────────────────────┘
72
+ ▼
73
+ ┌─ THE SURVEYOR · OpenCV, deterministic ────────────────────────┐
74
+ │ snaps each named feature to the strongest real anchor nearby │
75
+ │ (Hough circles, blobs, corners; pair-coherence rules) — │
76
+ │ the model never places a pixel; the code never invents an eye│
77
+ └──────────────┬────────────────────────────────────────────────┘
78
+ ▼
79
+ ┌─ THE VOICE · VoxCPM2 ─────────────────────────────────────────┐
80
+ │ voice DESIGNED from a text description — ten distinct │
81
+ │ characters, zero reference audio │
82
+ └──────────────┬────────────────────────────────────────────────┘
83
+ ▼
84
+ the LIFE engine (client-side SVG): eyes tinted from the photo's own
85
+ palette → first blink → saccades, pupil-follow → the mouth rides the
86
+ audio's amplitude. The wall replays all of it forever with ZERO GPU.
87
+ ```
88
+
89
+ The writing is governed, not vibes: a grudge-archetype bank keyed to the
90
+ object's *visible condition* (the_veteran/rusted, the_martyr/worn,
91
+ the_perfectionist/pristine, the_diva/displayed…), and an iron rule that every
92
+ line must reference something actually in the photo.
93
+
94
+ Privacy is structural: the same generation gates **real human faces and NSFW**
95
+ before anything else runs — the refusal is simply *"It is already awake."* —
96
+ and nothing reaches the public wall except server-held, gate-checked records.
97
+ No user identity is ever stored.
98
+
99
+ ## 📦 Models (≈ 11B total — well under the 32B cap)
100
+
101
+ | role | model | size | license |
102
+ |---|---|---|---|
103
+ | sees the face, writes the soul | [openbmb/MiniCPM-V-4_5](https://huggingface.co/openbmb/MiniCPM-V-4_5) | 8.7B | Apache-2.0 |
104
+ | speaks the grudge | [openbmb/VoxCPM2](https://huggingface.co/openbmb/VoxCPM2) | 2.29B | Apache-2.0 |
105
+
106
+ Both models run **in-Space**. No external APIs, no remote endpoints.
107
+
108
+ ## 🏆 Badges
109
+
110
+ - **Off-Brand** — the entire UI is custom (static `web/` served by `gr.Server`;
111
+ zero gradio components on screen).
112
+ - **Off the Grid** — all inference in-Space on ZeroGPU.
113
+ - **Sharing is Caring** — every awakening publishes its full wake-trace (the
114
+ medium's JSON, the surveyor's snap deltas, the chosen voice) to
115
+ [AndresCarreon/pareidolia-menagerie](https://huggingface.co/datasets/AndresCarreon/pareidolia-menagerie).
116
+ - **Field Notes** — [FIELD_NOTES.md](FIELD_NOTES.md) — the build log, including
117
+ the dependency saga and what a vision model does when you ask it to find
118
+ eyes in a stapler. <!-- TODO(main): + org blog link -->
119
+
120
+ ## 📋 Submission links
121
+
122
+ <!-- TODO(fill-before-deadline) -->
123
+ - **Demo video:** *coming before the deadline.*
124
+ - **Social post:** *coming before the deadline.*
125
+
126
+ ## ▶️ Run it locally
127
+
128
+ ```bash
129
+ pip install -r requirements-dev.txt
130
+ PAREIDOLIA_BACKEND=mock python app.py # mock medium: no GPU, no ML deps needed
131
+ ```
132
+
133
+ The Space runs `PAREIDOLIA_BACKEND=zerogpu`; mock returns canned awakenings
134
+ with realistic latency so the whole experience works on a laptop.
135
+
136
+ ## 🙏 Credits
137
+
138
+ - Models: OpenBMB **MiniCPM-V-4_5** + **VoxCPM2** (Apache-2.0).
139
+ - Seed-wall photos: CC0 / public-domain images via Openverse & Wikimedia
140
+ Commons — per-image attribution in [`seeds/eval/LICENSES.md`](seeds/eval/LICENSES.md).
141
+ - Type: Cormorant Garamond (SIL OFL).
142
+
143
+ *It was in there all along.*
app.py ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PAREIDOLIA — Space entrypoint. Everything secretly has a face.
2
+
3
+ Wiring (ARCHITECTURE.md is the contract; the godseed skeleton is the proven base):
4
+
5
+ Menagerie wall (web/) <-- GET / static, zero GPU
6
+ the séance <-- @app.api(name="awaken") gradio endpoint via
7
+ @gradio/client — the VISITOR's browser-authenticated
8
+ request pays the ZeroGPU quota
9
+ (POST /api/awaken is its mock/dev REST twin,
10
+ not registered on the zerogpu backend)
11
+ publish to the wall <-- POST /api/menagerie {record_token}; rate-limited,
12
+ gate re-checked server-side
13
+ the wall, paginated <-- GET /api/menagerie newest first
14
+ live wall updates <-- GET /api/stream SSE 'awakened' broadcasts
15
+ persisted media <-- GET /media/{id}.(jpg|wav) id->path map only
16
+
17
+ Run: python app.py (port from $PORT, default 7860)
18
+ Backends: PAREIDOLIA_BACKEND = mock | zerogpu (default mock)
19
+ Sync: PAREIDOLIA_DATASET (+ HF_TOKEN) mirrors menagerie/ to an HF dataset.
20
+ Identity: PAREIDOLIA_SECRET signs visitor cookies (publish <= 6/hour each).
21
+
22
+ The "her" pattern: the VLM owns taste (what counts as an eye, what the soul
23
+ sounds like); this file owns facts — bytes, tokens, limits, and what is allowed
24
+ onto the public wall. Errors and refusals are always poetry, never stack traces.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import base64
31
+ import hashlib
32
+ import io
33
+ import logging
34
+ import os
35
+ import re
36
+ import threading
37
+ import warnings
38
+
39
+ # ZeroGPU runtime: the `spaces` lib must be imported before torch anywhere in
40
+ # the process (it patches torch to virtualize the GPU). TORCHDYNAMO_DISABLE
41
+ # must also precede any torch import — VoxCPM's torch.compile warmup breaks
42
+ # ZeroGPU (§7). No-op everywhere else.
43
+ if os.environ.get("PAREIDOLIA_BACKEND", "").strip().lower() == "zerogpu":
44
+ os.environ.setdefault("TORCHDYNAMO_DISABLE", "1")
45
+ try:
46
+ import spaces # noqa: F401
47
+ except ImportError:
48
+ pass
49
+
50
+ from contextlib import asynccontextmanager
51
+ from pathlib import Path
52
+ from typing import Any, Optional
53
+
54
+ from fastapi import FastAPI, HTTPException, Query, Request, Response
55
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
56
+ from fastapi.staticfiles import StaticFiles
57
+
58
+ from server.persistence import PendingStore, PersistenceService
59
+ from server.ratelimit import (
60
+ COOKIE_NAME,
61
+ DEFAULT_IP_LIMIT,
62
+ ClientIdentity,
63
+ RateLimiter,
64
+ client_ip,
65
+ )
66
+ from server.schemas import AwakenRequest, GateFlags, PublishRequest
67
+ from server.sse import SSEHub
68
+ from server.wiring import PipelineLike, make_pipeline
69
+
70
+ log = logging.getLogger("pareidolia")
71
+
72
+ ROOT = Path(__file__).resolve().parent
73
+ WEB_DIR = ROOT / "web"
74
+ MENAGERIE_DIR = ROOT / "menagerie"
75
+ SEEDS_RECORDS_DIR = ROOT / "seeds" / "records"
76
+
77
+ VALID_BACKENDS = ("mock", "zerogpu")
78
+
79
+ # Transport cap: 1.5MB of base64 text (the client downscales to <=350KB JPEG,
80
+ # so a compliant browser never gets near this; the cap stops abusive bodies).
81
+ MAX_IMAGE_B64_BYTES = 1_572_864
82
+
83
+ # Poetry for the unhappy paths — the spirits never return a stack trace.
84
+ OVERSIZE_REASON = (
85
+ "That offering is too heavy for the séance table. "
86
+ "Bring it under a megabyte and a half, and the spirits will look again."
87
+ )
88
+ NEEDS_IMAGE_REASON = "The spirits need a photograph to peer into. The table is bare."
89
+ BAD_IMAGE_REASON = "The spirits see no photograph here — only static."
90
+ VAST_IMAGE_REASON = (
91
+ "The spirits cannot hold a vision that vast. "
92
+ "Offer a smaller photograph and they will lean in close."
93
+ )
94
+ SEANCE_FAILED_REASON = (
95
+ "The candle guttered mid-séance. Nothing was disturbed; try once more."
96
+ )
97
+ RATE_LIMIT_REASON = (
98
+ "The menagerie admits six souls an hour from one hand. "
99
+ "Sit with the ones you have woken; the wall will wait."
100
+ )
101
+ TOKEN_UNKNOWN_REASON = (
102
+ "The spirits hold no séance by that name — or it has faded with the quarter hour."
103
+ )
104
+ GATE_RECHECK_REASON = "The wall declines this one. The spirits were clear."
105
+ NOT_FOUND_REASON = "The menagerie holds no such creature."
106
+ WALL_CROWDED_REASON = (
107
+ "The wall is thick with watchers tonight. Linger a moment, then look again."
108
+ )
109
+
110
+ MEDIA_NAME_RE = re.compile(r"^([A-Za-z0-9_-]{4,64})\.(jpg|wav)$")
111
+ MEDIA_TYPES = {"jpg": "image/jpeg", "wav": "audio/wav"}
112
+
113
+ # Server-side re-encode bound: the client already downscales to <=1024px; this
114
+ # is the defensive ceiling for non-compliant callers (bounds VLM prefill too).
115
+ MAX_IMAGE_SIDE = 1024
116
+ JPEG_QUALITY = 88
117
+
118
+ # Decompression-bomb ceiling (security review #1): a tiny, highly-compressible
119
+ # PNG under the transport cap can DECLARE ~170M pixels and allocate 500MB+ on
120
+ # decode — before the thumbnail clamp, before any GPU quota. Cap total pixels:
121
+ # the 1024px working size with 4x headroom for the pre-thumbnail original.
122
+ MAX_PIXELS = MAX_IMAGE_SIDE * MAX_IMAGE_SIDE * 4 # ~4.2M pixels
123
+ try:
124
+ from PIL import Image as _PILImage
125
+
126
+ # PIL hard-stops at 2x this inside Image.open (DecompressionBombError -> our
127
+ # poetic 400); our explicit dimension check below covers 1x-2x with a 413,
128
+ # so the 1x warning is redundant noise — silence it, keep the error.
129
+ _PILImage.MAX_IMAGE_PIXELS = MAX_PIXELS
130
+ warnings.filterwarnings("ignore", category=_PILImage.DecompressionBombWarning)
131
+ except ImportError: # pragma: no cover — PIL is a hard dependency in prod
132
+ pass
133
+
134
+
135
+ def _backend_name() -> str:
136
+ name = os.environ.get("PAREIDOLIA_BACKEND", "mock").strip().lower() or "mock"
137
+ if name not in VALID_BACKENDS:
138
+ log.warning("unknown PAREIDOLIA_BACKEND=%r; falling back to mock", name)
139
+ name = "mock"
140
+ return name
141
+
142
+
143
+ # --------------------------------------------------------------------------- helpers
144
+ def _set_identity_cookie(response: Response, request: Request, value: str) -> None:
145
+ """Spaces serve over https inside an iframe (cross-site -> SameSite=None+Secure);
146
+ local dev is plain http (Lax, not Secure)."""
147
+ scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
148
+ secure = scheme == "https"
149
+ response.set_cookie(
150
+ COOKIE_NAME,
151
+ value,
152
+ max_age=365 * 24 * 3600,
153
+ path="/",
154
+ httponly=True,
155
+ secure=secure,
156
+ samesite="none" if secure else "lax",
157
+ )
158
+
159
+
160
+ def _decode_image(image_b64: str):
161
+ """base64 -> validated, bounded RGB PIL image. Raises poetic HTTPExceptions:
162
+ 413 oversize, 400 undecodable, 422 missing. PIL import stays local so the
163
+ transport module imports clean even in odd environments."""
164
+ if not image_b64 or not image_b64.strip():
165
+ raise HTTPException(status_code=422, detail=NEEDS_IMAGE_REASON)
166
+ if len(image_b64) > MAX_IMAGE_B64_BYTES:
167
+ raise HTTPException(status_code=413, detail=OVERSIZE_REASON)
168
+ payload = image_b64.strip()
169
+ if payload.startswith("data:"): # tolerate a full data URL from the client
170
+ payload = payload.partition(",")[2]
171
+ try:
172
+ raw = base64.b64decode(payload, validate=True)
173
+ except Exception as exc:
174
+ raise HTTPException(status_code=400, detail=BAD_IMAGE_REASON) from exc
175
+ try:
176
+ from PIL import Image
177
+
178
+ image = Image.open(io.BytesIO(raw))
179
+ # Dimension gate BEFORE any pixel decode (security review #1): open()
180
+ # only parses the header; load() is what allocates w*h*3 bytes. Refuse
181
+ # on DECLARED size so the bomb never costs more than a header parse.
182
+ width, height = image.size
183
+ if width * height > MAX_PIXELS:
184
+ raise HTTPException(status_code=413, detail=VAST_IMAGE_REASON)
185
+ image.load()
186
+ image = image.convert("RGB")
187
+ except HTTPException:
188
+ raise
189
+ except Exception as exc:
190
+ # Includes PIL's DecompressionBombError (the 2x MAX_PIXELS hard stop
191
+ # raised inside Image.open) — same poetic 400 as any undecodable image.
192
+ raise HTTPException(status_code=400, detail=BAD_IMAGE_REASON) from exc
193
+ if max(image.size) > MAX_IMAGE_SIDE:
194
+ image.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE))
195
+ return image
196
+
197
+
198
+ def _encode_jpeg(image) -> bytes:
199
+ buf = io.BytesIO()
200
+ image.save(buf, format="JPEG", quality=JPEG_QUALITY)
201
+ return buf.getvalue()
202
+
203
+
204
+ # --------------------------------------------------------------------------- the app
205
+ def create_app(
206
+ *,
207
+ pipeline: Optional[PipelineLike] = None,
208
+ persistence: Optional[PersistenceService] = None,
209
+ pending: Optional[PendingStore] = None,
210
+ hub: Optional[SSEHub] = None,
211
+ rate_limiter: Optional[RateLimiter] = None,
212
+ ip_rate_limiter: Optional[RateLimiter] = None,
213
+ identity: Optional[ClientIdentity] = None,
214
+ menagerie_dir: Optional[Path | str] = None,
215
+ backend: Optional[str] = None,
216
+ mount_web: bool = True,
217
+ ) -> FastAPI:
218
+ """Build the ASGI app. With no arguments this wires the real (env-selected)
219
+ backend; tests inject fakes for everything mind/cv-shaped."""
220
+ backend_name = backend or _backend_name()
221
+ pipeline = pipeline if pipeline is not None else make_pipeline(backend_name)
222
+ persistence = (
223
+ persistence
224
+ if persistence is not None
225
+ else PersistenceService(
226
+ menagerie_dir if menagerie_dir is not None else MENAGERIE_DIR,
227
+ seeds_dir=SEEDS_RECORDS_DIR,
228
+ )
229
+ )
230
+ pending = pending if pending is not None else PendingStore()
231
+ hub = hub if hub is not None else SSEHub()
232
+ rate_limiter = rate_limiter or RateLimiter()
233
+ ip_rate_limiter = ip_rate_limiter or RateLimiter(limit=DEFAULT_IP_LIMIT)
234
+ identity = identity or ClientIdentity()
235
+
236
+ @asynccontextmanager
237
+ async def lifespan(app: FastAPI):
238
+ yield
239
+ await persistence.drain()
240
+
241
+ # Gradio Server mode (the "her" idiom, proven on godseed): gr.Server IS a
242
+ # FastAPI app with gradio's API engine attached — the sdk:gradio runtime
243
+ # serves it natively, and @app.api endpoints are gradio endpoints the
244
+ # browser calls via @gradio/client, which forwards the HF auth headers
245
+ # ZeroGPU quota needs.
246
+ try:
247
+ import gradio as gr
248
+
249
+ app: FastAPI = gr.Server(title="PAREIDOLIA")
250
+ app.router.lifespan_context = lifespan
251
+ except Exception: # pragma: no cover — gradio always present in prod
252
+ app = FastAPI(
253
+ title="PAREIDOLIA", docs_url=None, redoc_url=None, lifespan=lifespan
254
+ )
255
+ app.state.persistence = persistence
256
+ app.state.pending = pending
257
+ app.state.hub = hub
258
+
259
+ async def ensure_ready() -> None:
260
+ """Boot persistence lazily on the serving loop (godseed's ensure idiom:
261
+ launch() does not honor an injected lifespan, so the first request —
262
+ or the warm-boot thread in _serve — does the waking). Idempotent and
263
+ thread-safe; the dataset restore runs off the event loop."""
264
+ if not persistence.booted:
265
+ await asyncio.to_thread(persistence.boot)
266
+
267
+ # ------------------------------------------------------------------ the séance
268
+ async def _awaken(image_b64: str) -> dict[str, Any]:
269
+ """§2 pipeline: decode -> medium -> gate -> snap -> TTS -> pending token.
270
+ Refusals are HTTP 200 + {refused: true}; only transport sins get codes."""
271
+ await ensure_ready()
272
+ image = _decode_image(image_b64)
273
+ try:
274
+ outcome = await asyncio.to_thread(pipeline.run, image)
275
+ except Exception as exc:
276
+ log.exception("séance pipeline failed")
277
+ raise HTTPException(
278
+ status_code=500, detail=SEANCE_FAILED_REASON
279
+ ) from exc
280
+ if outcome.refused or outcome.result is None or outcome.grudge_wav is None:
281
+ return {"refused": True, "reason": outcome.reason or SEANCE_FAILED_REASON}
282
+ result = outcome.result
283
+ jpeg = _encode_jpeg(image)
284
+ record: dict[str, Any] = {
285
+ "object": result.object,
286
+ "material": result.material,
287
+ "condition": result.condition,
288
+ "setting": result.setting,
289
+ "persona": result.persona.model_dump(),
290
+ "features": [f.model_dump() for f in outcome.features],
291
+ "lines": result.lines.model_dump(),
292
+ "critique": result.critique,
293
+ "image_sha256": hashlib.sha256(jpeg).hexdigest(),
294
+ "backend": backend_name,
295
+ }
296
+ token = pending.put(
297
+ record=record,
298
+ image_jpeg=jpeg,
299
+ grudge_wav=outcome.grudge_wav,
300
+ gate=result.gate.model_dump(),
301
+ )
302
+ return {
303
+ "refused": False,
304
+ "record_token": token,
305
+ "record": {
306
+ **record,
307
+ "grudge_audio_b64": base64.b64encode(outcome.grudge_wav).decode(
308
+ "ascii"
309
+ ),
310
+ },
311
+ }
312
+
313
+ # Gradio endpoint: the visitor's browser calls this via @gradio/client so
314
+ # the visitor's own ZeroGPU quota pays for the awakening (§0).
315
+ if hasattr(app, "api"):
316
+
317
+ @app.api(name="awaken")
318
+ async def awaken(image_b64: str = "") -> dict:
319
+ try:
320
+ return await _awaken(image_b64)
321
+ except HTTPException as exc:
322
+ # gradio endpoints cannot speak HTTP status codes; same poetry,
323
+ # flat shape, status carried in-band for the client.
324
+ return {
325
+ "refused": True,
326
+ "reason": str(exc.detail),
327
+ "status": exc.status_code,
328
+ }
329
+ except Exception:
330
+ log.exception("awaken failed")
331
+ return {"refused": True, "reason": SEANCE_FAILED_REASON, "status": 500}
332
+
333
+ # REST twin for the mock/dev backend (tests, curl, local frontend work).
334
+ # Never registered on zerogpu: GPU work must stay inside the visitor's
335
+ # browser-authenticated gradio request (§0), so prod matches §6 exactly.
336
+ if backend_name != "zerogpu":
337
+
338
+ @app.post("/api/awaken")
339
+ async def awaken_rest(payload: AwakenRequest) -> JSONResponse:
340
+ return JSONResponse(await _awaken(payload.image_b64))
341
+
342
+ # -------------------------------------------------------------- POST /api/menagerie
343
+ @app.post("/api/menagerie")
344
+ async def publish(payload: PublishRequest, request: Request) -> JSONResponse:
345
+ await ensure_ready()
346
+ cid, new_cookie = identity.resolve(request)
347
+
348
+ # Order is peek-validate -> rate-limit -> claim (security review #2c):
349
+ # the token must prove itself BEFORE the limiters record a hit, or an
350
+ # unauthenticated junk-token flood grows limiter state for nothing.
351
+ # Peek (not claim) first so a rate-limited publish never spends the
352
+ # one-time token; peek->claim cannot race — requests share one event
353
+ # loop and nothing awaits between the two calls.
354
+ if pending.peek(payload.record_token) is None:
355
+ raise HTTPException(status_code=404, detail=TOKEN_UNKNOWN_REASON)
356
+
357
+ ip_ok, ip_retry = ip_rate_limiter.hit(f"ip:{client_ip(request)}")
358
+ allowed, retry_after = rate_limiter.hit(cid) if ip_ok else (False, ip_retry)
359
+ if not allowed:
360
+ raise HTTPException(
361
+ status_code=429,
362
+ detail=RATE_LIMIT_REASON,
363
+ headers={"Retry-After": str(int(retry_after) + 1)},
364
+ )
365
+
366
+ held = pending.claim(payload.record_token)
367
+ if held is None: # pragma: no cover — peek above guarantees presence
368
+ raise HTTPException(status_code=404, detail=TOKEN_UNKNOWN_REASON)
369
+
370
+ # Gate re-check, server-side (§0: layered and non-negotiable). awaken
371
+ # never parks a gated record, so this is pure defense-in-depth.
372
+ if not GateFlags.model_validate(held.gate).passes():
373
+ raise HTTPException(status_code=403, detail=GATE_RECHECK_REASON)
374
+
375
+ record = await persistence.publish(
376
+ held.record, held.image_jpeg, held.grudge_wav
377
+ )
378
+ hub.publish({"type": "awakened", "record": record})
379
+ # The cookie is set on the returned Response directly: FastAPI drops
380
+ # `response`-param mutations when a handler returns its own Response
381
+ # (and on raise paths — cookieless retries fall to the IP limiter).
382
+ out = JSONResponse(record)
383
+ if new_cookie:
384
+ _set_identity_cookie(out, request, new_cookie)
385
+ return out
386
+
387
+ # --------------------------------------------------------------- GET /api/menagerie
388
+ @app.get("/api/menagerie")
389
+ async def menagerie_index(
390
+ offset: int = Query(default=0, ge=0),
391
+ limit: int = Query(default=60, ge=1, le=200),
392
+ ) -> JSONResponse:
393
+ await ensure_ready()
394
+ records, total = persistence.page(offset, limit)
395
+ return JSONResponse(
396
+ {
397
+ "records": records,
398
+ "total": total,
399
+ "offset": offset,
400
+ "limit": limit,
401
+ "awake_count": total,
402
+ }
403
+ )
404
+
405
+ # ------------------------------------------------------------------ /api/stream
406
+ @app.get("/api/stream")
407
+ async def stream(
408
+ request: Request,
409
+ limit: Optional[int] = Query(
410
+ default=None,
411
+ ge=1,
412
+ le=10_000,
413
+ description="debug/test aid: close the stream after N events",
414
+ ),
415
+ ) -> StreamingResponse:
416
+ await ensure_ready()
417
+ # Capacity gate (security review #3): every subscriber holds a bounded
418
+ # queue + a generator. The slot is reserved HERE — before the response
419
+ # body starts — so over-capacity is an honest 503, and the generator's
420
+ # finally-unsubscribe releases the slot on disconnect as before.
421
+ queue = hub.subscribe(key=f"ip:{client_ip(request)}")
422
+ if queue is None:
423
+ raise HTTPException(
424
+ status_code=503,
425
+ detail=WALL_CROWDED_REASON,
426
+ headers={"Retry-After": "15"},
427
+ )
428
+ initial = [{"type": "hello", "awake_count": persistence.count()}]
429
+ return StreamingResponse(
430
+ hub.event_stream(initial_events=initial, limit=limit, queue=queue),
431
+ media_type="text/event-stream",
432
+ headers={
433
+ "Cache-Control": "no-cache",
434
+ "X-Accel-Buffering": "no", # required on Spaces or the proxy buffers
435
+ "Connection": "keep-alive",
436
+ },
437
+ )
438
+
439
+ # --------------------------------------------------------------- GET /media/{name}
440
+ @app.get("/media/{name}", include_in_schema=False)
441
+ async def media(name: str) -> FileResponse:
442
+ """Persisted menagerie media. Traversal-safe by construction: the name
443
+ must match the mint alphabet, and the id resolves through the
444
+ server-built id->path map only — no client string ever becomes a path."""
445
+ await ensure_ready()
446
+ match = MEDIA_NAME_RE.fullmatch(name)
447
+ if match is None:
448
+ raise HTTPException(status_code=404, detail=NOT_FOUND_REASON)
449
+ path = persistence.media_path(match.group(1), match.group(2))
450
+ if path is None or not path.is_file():
451
+ raise HTTPException(status_code=404, detail=NOT_FOUND_REASON)
452
+ return FileResponse(
453
+ path,
454
+ media_type=MEDIA_TYPES[match.group(2)],
455
+ headers={"Cache-Control": "public, max-age=31536000, immutable"},
456
+ )
457
+
458
+ # ------------------------------------------------------------------------ static
459
+ def _mount_static() -> None:
460
+ """Root static mount. A "/" catch-all registered BEFORE gr.Server.launch()
461
+ shadows the /gradio_api routes gradio adds at launch time (godseed,
462
+ verified June 12: the launch self-check 404s and the app dies). So the
463
+ mount is deferred: _serve() calls this AFTER launch; the plain-FastAPI
464
+ path mounts immediately below."""
465
+ if WEB_DIR.is_dir():
466
+ # gradio's launch registers its own SPA index at "/" (GET+HEAD) —
467
+ # evict exactly those so the wall owns the root; every other gradio
468
+ # route (/config, /gradio_api/*, assets) must survive for
469
+ # @gradio/client connectivity.
470
+ app.router.routes[:] = [
471
+ r for r in app.router.routes if getattr(r, "path", None) != "/"
472
+ ]
473
+ app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")
474
+ else:
475
+ log.warning("web/ not found at %s; serving API only", WEB_DIR)
476
+
477
+ @app.get("/", include_in_schema=False)
478
+ async def root_placeholder() -> JSONResponse:
479
+ return JSONResponse({"ok": True, "hint": "frontend not built"})
480
+
481
+ app.state.mount_static = _mount_static
482
+ if mount_web and not hasattr(app, "launch"):
483
+ _mount_static()
484
+
485
+ return app
486
+
487
+
488
+ # --------------------------------------------------------------------------- entrypoint
489
+ def _serve(application: FastAPI) -> None:
490
+ port = int(os.environ.get("PORT", os.environ.get("GRADIO_SERVER_PORT", 7860)))
491
+ log.info("PAREIDOLIA listening on http://0.0.0.0:%d", port)
492
+ if hasattr(application, "launch"):
493
+ # Gradio Server mode — launch non-blocking so the web root can mount
494
+ # AFTER gradio registers its /gradio_api routes (order = precedence).
495
+ application.launch(
496
+ server_name="0.0.0.0",
497
+ server_port=port,
498
+ show_error=False,
499
+ prevent_thread_lock=True,
500
+ )
501
+ mount_static = getattr(application.state, "mount_static", None)
502
+ if mount_static is not None:
503
+ mount_static()
504
+ # Warm-boot the menagerie off the serving thread so visitor #1 sees a
505
+ # full wall without paying the dataset-restore wait themselves.
506
+ persistence = getattr(application.state, "persistence", None)
507
+ if persistence is not None and not persistence.booted:
508
+ threading.Thread(target=persistence.boot, daemon=True).start()
509
+ import time as _time
510
+
511
+ while True: # keep the process alive; the server runs in gradio's thread
512
+ _time.sleep(3600)
513
+ else: # pragma: no cover — gradio-less dev fallback
514
+ import uvicorn
515
+
516
+ uvicorn.run(application, host="0.0.0.0", port=port)
517
+
518
+
519
+ # HF Spaces (sdk:gradio) executes this file; locally it's `python app.py`.
520
+ # Test imports leave both conditions false and get no side effects.
521
+ if __name__ == "__main__" or os.environ.get("SPACE_ID"):
522
+ logging.basicConfig(
523
+ level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s"
524
+ )
525
+ _serve(create_app())
cv/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """PAREIDOLIA deterministic CV — pure numpy + opencv-headless, no ML, no RNG.
2
+
3
+ The her-pattern's fact-keeper: the VLM proposes coarse feature points, this
4
+ package snaps them to real pixels (`snap.snap_features`) and renders the
5
+ before/after evidence (`snap.overlay_debug`).
6
+ """
7
+
8
+ from .snap import MIN_SCORE, SNAP_RADIUS_FRAC, overlay_debug, snap_features
9
+
10
+ __all__ = ["snap_features", "overlay_debug", "MIN_SCORE", "SNAP_RADIUS_FRAC"]
cv/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (630 Bytes). View file
 
cv/__pycache__/snap.cpython-312.pyc ADDED
Binary file (30.1 kB). View file
 
cv/snap.py ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PAREIDOLIA deterministic feature snapping — the "code owns facts" half.
2
+
3
+ The VLM (MiniCPM-V) owns taste: it names the bolt that wants to be an eye and
4
+ hands over coarse normalized coordinates. This module owns facts: it snaps
5
+ each coarse point to the strongest nearby visual anchor using plain OpenCV on
6
+ CPU — Hough circles (eyes love circles: bolts, dials, knobs), good-features
7
+ corners, dark-blob centroids, and horizontally elongated edge segments for
8
+ mouths. No ML, no RNG: the same image + features always produce byte-identical
9
+ output, so a persisted menagerie record replays forever.
10
+
11
+ Contract (ARCHITECTURE.md §0 "Deterministic CV snapping", §2 step 3):
12
+
13
+ - search window = radius 12% of the image diagonal around each VLM point;
14
+ - candidates are scored by (anchor strength × proximity to the VLM point),
15
+ weighted by how well the anchor kind suits the feature role;
16
+ - eyes prefer circle anchors, and the eye PAIR must stay coherent: if
17
+ independent snapping skews the eye-line by >12° or changes the separation
18
+ by >35% vs the VLM pair, the pair falls back to a rigid translation by the
19
+ stronger anchor's delta (midpoint + angle of the VLM pair are preserved,
20
+ the weaker eye is marked ``anchor_kind="pair"``);
21
+ - the mouth prefers a horizontally elongated contour/edge segment and is never
22
+ snapped above the (snapped) eye midpoint;
23
+ - if no anchor beats ``MIN_SCORE`` the VLM point is kept verbatim
24
+ (``anchor_kind="vlm"``) — the mist animation forgives ±15%;
25
+ - the mouth floor is enforced on the FINAL points regardless of anchor_kind:
26
+ a kept-VLM mouth that sits at or above the eye midpoint is pushed just
27
+ below it (``anchor_kind="vlm_corrected"``) so an inverted face can never
28
+ ship.
29
+
30
+ Coordinate conventions: ``cx``/``cy`` are normalized to ``[0, 1]`` over
31
+ ``width-1`` / ``height-1``; ``size`` is the feature's coarse diameter as a
32
+ fraction of the image diagonal (the ARCHITECTURE.md §3 schema); ``snap_delta``
33
+ is the distance moved, as a fraction of the image diagonal.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import math
39
+ from dataclasses import dataclass
40
+ from typing import Any, Optional
41
+
42
+ import cv2
43
+ import numpy as np
44
+
45
+ __all__ = ["snap_features", "overlay_debug", "MIN_SCORE", "SNAP_RADIUS_FRAC"]
46
+
47
+ # ----------------------------------------------------------------- constants
48
+
49
+ SNAP_RADIUS_FRAC = 0.12 # search radius as a fraction of the image diagonal
50
+ MIN_SCORE = 0.12 # below this, the VLM point wins (anchor_kind="vlm")
51
+
52
+ PAIR_MAX_SKEW_DEG = 12.0 # eye-line rotation tolerance vs the VLM pair
53
+ PAIR_MAX_SEP_CHANGE = 0.35 # eye separation ratio-change tolerance
54
+
55
+ # Corner response normalizer: cv2.cornerMinEigenVal output is pre-scaled by
56
+ # OpenCV — a crisp synthetic step corner peaks near ~0.06 (blockSize=5, uint8
57
+ # input, after our 5x5 blur) while blurred sensor noise stays ≤ ~0.016.
58
+ # Strength saturates at 1.0 so 0.08 keeps noise corners well under MIN_SCORE.
59
+ _CORNER_NORM = 0.08
60
+
61
+ # How well each anchor kind suits each feature role. Eyes love circles
62
+ # (bolts, dials, knobs); mouths love horizontal edge segments; corners are
63
+ # the weakest evidence everywhere (they fire on texture).
64
+ _KIND_WEIGHTS: dict[str, dict[str, float]] = {
65
+ "eye": {"circle": 1.00, "blob": 0.70, "corner": 0.45, "edge": 0.30},
66
+ "mouth": {"edge": 1.00, "blob": 0.75, "circle": 0.50, "corner": 0.35},
67
+ "other": {"circle": 0.90, "blob": 0.80, "edge": 0.60, "corner": 0.50},
68
+ }
69
+
70
+ _MIN_ROI_SIDE = 12 # below this the window is too small to detect anything
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class _Anchor:
75
+ """One candidate anchor in full-image pixel coordinates."""
76
+
77
+ x: float
78
+ y: float
79
+ strength: float # detector-specific, normalized to [0, 1]
80
+ kind: str # circle | corner | blob | edge
81
+
82
+
83
+ # ------------------------------------------------------------------ helpers
84
+
85
+
86
+ def _as_gray(image: np.ndarray) -> np.ndarray:
87
+ """Accept BGR (contract) or already-gray uint8; return single-channel."""
88
+ if image is None or not isinstance(image, np.ndarray) or image.ndim not in (2, 3):
89
+ raise ValueError("snap_features expects an HxW or HxWx3 uint8 ndarray")
90
+ if image.ndim == 3:
91
+ return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
92
+ return image
93
+
94
+
95
+ def _clamp01(v: float) -> float:
96
+ return min(1.0, max(0.0, float(v)))
97
+
98
+
99
+ def _role_class(role: Any) -> str:
100
+ role = str(role or "")
101
+ if role.startswith("eye"):
102
+ return "eye"
103
+ if role == "mouth":
104
+ return "mouth"
105
+ return "other"
106
+
107
+
108
+ def _to_px(f: dict, w: int, h: int) -> tuple[float, float]:
109
+ """Normalized [0,1] feature coords → pixel coords (clamped on-image)."""
110
+ return (
111
+ _clamp01(f.get("cx", 0.5)) * (w - 1),
112
+ _clamp01(f.get("cy", 0.5)) * (h - 1),
113
+ )
114
+
115
+
116
+ def _proximity(d: float, radius: float) -> float:
117
+ """Gaussian falloff: 1.0 at the VLM point, ~0.14 at the window edge."""
118
+ return math.exp(-2.0 * (d / radius) ** 2)
119
+
120
+
121
+ # ---------------------------------------------------------------- detectors
122
+ # All detectors operate on a pre-blurred grayscale ROI and return anchors in
123
+ # ROI-local coordinates. Every parameter is fixed — nothing is sampled.
124
+
125
+
126
+ def _circle_candidates(roi: np.ndarray, want_r: float) -> list[_Anchor]:
127
+ """HoughCircles, strength = disk-vs-annulus contrast × radius match."""
128
+ if want_r > 0:
129
+ min_r = max(2, int(round(want_r * 0.45)))
130
+ max_r = max(min_r + 2, int(round(want_r * 1.9)))
131
+ else:
132
+ min_r, max_r = 3, max(6, int(min(roi.shape) * 0.45))
133
+ circles = cv2.HoughCircles(
134
+ roi,
135
+ cv2.HOUGH_GRADIENT,
136
+ dp=1.2,
137
+ minDist=max(4.0, want_r if want_r > 0 else 8.0),
138
+ param1=120,
139
+ param2=18,
140
+ minRadius=min_r,
141
+ maxRadius=max_r,
142
+ )
143
+ if circles is None:
144
+ return []
145
+ out: list[_Anchor] = []
146
+ for cx, cy, r in circles[0][:6]:
147
+ contrast = _disk_contrast(roi, float(cx), float(cy), float(r))
148
+ if want_r > 0:
149
+ r_match = math.exp(-(((float(r) - want_r) / max(want_r, 1.0)) ** 2))
150
+ else:
151
+ r_match = 1.0
152
+ strength = min(1.0, contrast * 1.6) * (0.4 + 0.6 * r_match)
153
+ out.append(_Anchor(float(cx), float(cy), strength, "circle"))
154
+ return out
155
+
156
+
157
+ def _disk_contrast(roi: np.ndarray, cx: float, cy: float, r: float) -> float:
158
+ """|mean(disk interior) − mean(surrounding annulus)| / 255 — a bolt head
159
+ or dial face separates from its plate; blurred noise does not."""
160
+ h, w = roi.shape
161
+ yy, xx = np.ogrid[:h, :w]
162
+ d2 = (xx - cx) ** 2 + (yy - cy) ** 2
163
+ inner = d2 <= (0.75 * r) ** 2
164
+ ring = (d2 > (1.15 * r) ** 2) & (d2 <= (1.7 * r) ** 2)
165
+ if int(inner.sum()) < 4 or int(ring.sum()) < 4:
166
+ return 0.0
167
+ return abs(float(roi[inner].mean()) - float(roi[ring].mean())) / 255.0
168
+
169
+
170
+ def _corner_candidates(roi: np.ndarray, want_r: float) -> list[_Anchor]:
171
+ """goodFeaturesToTrack, strength from the min-eigenvalue response map."""
172
+ corners = cv2.goodFeaturesToTrack(
173
+ roi,
174
+ maxCorners=10,
175
+ qualityLevel=0.08,
176
+ minDistance=max(4, int(want_r) if want_r > 0 else 6),
177
+ blockSize=5,
178
+ )
179
+ if corners is None:
180
+ return []
181
+ response = cv2.cornerMinEigenVal(roi, blockSize=5)
182
+ out: list[_Anchor] = []
183
+ for pt in corners.reshape(-1, 2):
184
+ x, y = float(pt[0]), float(pt[1])
185
+ iy = min(response.shape[0] - 1, max(0, int(round(y))))
186
+ ix = min(response.shape[1] - 1, max(0, int(round(x))))
187
+ strength = min(1.0, float(response[iy, ix]) / _CORNER_NORM)
188
+ out.append(_Anchor(x, y, strength, "corner"))
189
+ return out
190
+
191
+
192
+ def _blob_candidates(roi: np.ndarray, want_r: float) -> list[_Anchor]:
193
+ """Dark-blob centroids: adaptive threshold → contour moments, kept only
194
+ when sized near the feature and actually darker than their surroundings."""
195
+ side = min(roi.shape)
196
+ block = int(round(want_r * 4)) | 1 if want_r > 0 else 21
197
+ block = max(11, min(block, 51, (side - 1) | 1 if side > 2 else 3))
198
+ if block < 3:
199
+ return []
200
+ binary = cv2.adaptiveThreshold(
201
+ roi, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block, 5
202
+ )
203
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
204
+ want_area = math.pi * want_r * want_r if want_r > 0 else 0.0
205
+ scored: list[tuple[float, Any]] = []
206
+ for cnt in contours:
207
+ area = float(cv2.contourArea(cnt))
208
+ if area < 9.0:
209
+ continue
210
+ if want_area > 0 and not (0.15 * want_area <= area <= 6.0 * want_area):
211
+ continue
212
+ scored.append((abs(area - want_area), cnt))
213
+ scored.sort(key=lambda t: t[0])
214
+ out: list[_Anchor] = []
215
+ for _, cnt in scored[:8]:
216
+ m = cv2.moments(cnt)
217
+ if m["m00"] <= 0:
218
+ continue
219
+ bx, by = m["m10"] / m["m00"], m["m01"] / m["m00"]
220
+ mask = np.zeros(roi.shape, np.uint8)
221
+ cv2.drawContours(mask, [cnt], -1, 255, -1)
222
+ k = max(3, (int(want_r * 0.6) | 1) if want_r > 0 else 5)
223
+ ring = cv2.dilate(mask, np.ones((k, k), np.uint8)) & ~mask
224
+ if int((mask > 0).sum()) < 4 or int((ring > 0).sum()) < 4:
225
+ continue
226
+ darkness = (float(roi[ring > 0].mean()) - float(roi[mask > 0].mean())) / 255.0
227
+ if darkness <= 0:
228
+ continue # spec: DARK blobs only
229
+ d_eq = 2.0 * math.sqrt(float(cv2.contourArea(cnt)) / math.pi)
230
+ if want_r > 0:
231
+ size_match = math.exp(-0.5 * (((d_eq - 2 * want_r) / max(2 * want_r, 1.0)) ** 2))
232
+ else:
233
+ size_match = 1.0
234
+ out.append(_Anchor(bx, by, min(1.0, darkness * 1.4) * size_match, "blob"))
235
+ return out
236
+
237
+
238
+ def _edge_candidates(roi: np.ndarray, want_d: float) -> list[_Anchor]:
239
+ """Horizontally elongated edge segments (mouths: slots, grilles, seams).
240
+
241
+ Canny contours whose bounding box is clearly wider than tall. Strength =
242
+ elongation × (soft) width match × vertical-gradient support × straightness.
243
+ The last two terms separate a real seam (strong |dI/dy| along a near-
244
+ straight run) from the wiggly low-contrast strings Canny traces on noise.
245
+ """
246
+ edges = cv2.Canny(roi, 60, 150)
247
+ contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
248
+ if not contours:
249
+ return []
250
+ sobel_y = np.abs(cv2.Sobel(roi, cv2.CV_64F, 0, 1, ksize=3))
251
+ out: list[_Anchor] = []
252
+ for cnt in contours:
253
+ x, y, w, h = cv2.boundingRect(cnt)
254
+ if w < max(8, 0.3 * want_d) or w < 1.6 * max(h, 1):
255
+ continue
256
+ aspect = w / max(h, 1)
257
+ elong = min(1.0, (aspect - 1.0) / 3.0)
258
+ pts = cnt.reshape(-1, 2)
259
+ ex, ey = float(pts[:, 0].mean()), float(pts[:, 1].mean())
260
+ # a horizontal seam means strong vertical gradient along the contour
261
+ grad_support = min(1.0, float(sobel_y[pts[:, 1], pts[:, 0]].mean()) / 450.0)
262
+ # straight run ≈ arc 2w (traced out and back); wiggly noise is longer
263
+ straightness = min(1.0, 2.2 * w / max(float(cv2.arcLength(cnt, False)), 1.0))
264
+ if want_d > 0:
265
+ size_match = math.exp(-0.5 * (((w - want_d) / max(want_d, 1.0)) ** 2))
266
+ else:
267
+ size_match = 1.0
268
+ strength = elong * (0.5 + 0.5 * size_match) * grad_support * straightness
269
+ out.append(_Anchor(ex, ey, strength, "edge"))
270
+ out.sort(key=lambda a: (-a.strength, a.x, a.y))
271
+ return out[:8]
272
+
273
+
274
+ # ------------------------------------------------------------- core snapping
275
+
276
+
277
+ def _gather_anchors(
278
+ blurred: np.ndarray, px: float, py: float, radius: float, want_d: float
279
+ ) -> list[_Anchor]:
280
+ """Run all detectors on the search window; return full-image anchors."""
281
+ h, w = blurred.shape
282
+ x0 = max(0, int(math.floor(px - radius)))
283
+ y0 = max(0, int(math.floor(py - radius)))
284
+ x1 = min(w, int(math.ceil(px + radius)) + 1)
285
+ y1 = min(h, int(math.ceil(py + radius)) + 1)
286
+ roi = blurred[y0:y1, x0:x1]
287
+ if min(roi.shape) < _MIN_ROI_SIDE:
288
+ return []
289
+ want_r = want_d / 2.0
290
+ local = (
291
+ _circle_candidates(roi, want_r)
292
+ + _corner_candidates(roi, want_r)
293
+ + _blob_candidates(roi, want_r)
294
+ + _edge_candidates(roi, want_d)
295
+ )
296
+ return [_Anchor(a.x + x0, a.y + y0, a.strength, a.kind) for a in local]
297
+
298
+
299
+ def _best_anchor(
300
+ anchors: list[_Anchor],
301
+ px: float,
302
+ py: float,
303
+ radius: float,
304
+ role_class: str,
305
+ min_y: Optional[float],
306
+ ) -> tuple[Optional[_Anchor], float]:
307
+ """Pick the highest-scoring anchor within the search radius.
308
+
309
+ ``min_y`` enforces the mouth rule: candidates at or above the snapped eye
310
+ midpoint are discarded outright (a mouth is never above the eyes).
311
+ Ties break on distance, then kind, then coordinates — fully deterministic.
312
+ """
313
+ weights = _KIND_WEIGHTS[role_class]
314
+ best: Optional[_Anchor] = None
315
+ best_key: tuple = ()
316
+ best_score = 0.0
317
+ for a in anchors:
318
+ d = math.hypot(a.x - px, a.y - py)
319
+ if d > radius:
320
+ continue
321
+ if min_y is not None and a.y <= min_y:
322
+ continue
323
+ score = weights[a.kind] * a.strength * _proximity(d, radius)
324
+ key = (-score, d, a.kind, a.x, a.y)
325
+ if best is None or key < best_key:
326
+ best, best_key, best_score = a, key, score
327
+ return best, best_score
328
+
329
+
330
+ def _snap_one(
331
+ blurred: np.ndarray,
332
+ feature: dict,
333
+ radius: float,
334
+ diag: float,
335
+ min_y: Optional[float] = None,
336
+ ) -> dict:
337
+ """Snap a single feature; returns a NEW dict (inputs are never mutated)."""
338
+ h, w = blurred.shape
339
+ out = dict(feature)
340
+ px, py = _to_px(feature, w, h)
341
+ want_d = max(0.0, float(feature.get("size") or 0.0)) * diag
342
+ role_class = _role_class(feature.get("role"))
343
+ anchors = _gather_anchors(blurred, px, py, radius, want_d)
344
+ best, score = _best_anchor(anchors, px, py, radius, role_class, min_y)
345
+ if best is not None and score >= MIN_SCORE:
346
+ out["cx"] = _clamp01(best.x / (w - 1)) if w > 1 else 0.0
347
+ out["cy"] = _clamp01(best.y / (h - 1)) if h > 1 else 0.0
348
+ out["snap_delta"] = math.hypot(best.x - px, best.y - py) / diag
349
+ out["anchor_kind"] = best.kind
350
+ out["anchor_score"] = round(score, 4)
351
+ else:
352
+ out["cx"] = _clamp01(feature.get("cx", 0.5))
353
+ out["cy"] = _clamp01(feature.get("cy", 0.5))
354
+ out["snap_delta"] = 0.0
355
+ out["anchor_kind"] = "vlm"
356
+ out["anchor_score"] = 0.0
357
+ return out
358
+
359
+
360
+ def _enforce_pair_coherence(
361
+ left: dict,
362
+ right: dict,
363
+ left_vlm: tuple[float, float],
364
+ right_vlm: tuple[float, float],
365
+ w: int,
366
+ h: int,
367
+ diag: float,
368
+ ) -> None:
369
+ """Keep the snapped eye pair geometrically honest (mutates result dicts).
370
+
371
+ If independent snapping rotated the eye-line by more than
372
+ ``PAIR_MAX_SKEW_DEG`` or stretched/shrunk the separation by more than
373
+ ``PAIR_MAX_SEP_CHANGE`` vs the VLM pair, distrust the weaker anchor:
374
+ rigidly translate the VLM pair by the STRONGER anchor's snap delta. The
375
+ pair's midpoint offset and angle then match the VLM's intent, the stronger
376
+ eye sits exactly on its anchor, and the weaker eye is re-derived
377
+ (``anchor_kind="pair"``, ``anchor_score`` inherited from the evidence
378
+ that placed it).
379
+ """
380
+ if left["anchor_kind"] == "vlm" and right["anchor_kind"] == "vlm":
381
+ return # nothing snapped, nothing to disagree about
382
+ lvx, lvy = left_vlm
383
+ rvx, rvy = right_vlm
384
+ sep_v = math.hypot(rvx - lvx, rvy - lvy)
385
+ if sep_v < 2.0:
386
+ return # degenerate VLM pair; geometry checks are meaningless
387
+ lsx, lsy = left["cx"] * (w - 1), left["cy"] * (h - 1)
388
+ rsx, rsy = right["cx"] * (w - 1), right["cy"] * (h - 1)
389
+ sep_s = math.hypot(rsx - lsx, rsy - lsy)
390
+ ang_v = math.degrees(math.atan2(rvy - lvy, rvx - lvx))
391
+ ang_s = math.degrees(math.atan2(rsy - lsy, rsx - lsx))
392
+ skew = abs((ang_s - ang_v + 180.0) % 360.0 - 180.0)
393
+ sep_change = abs(sep_s / sep_v - 1.0)
394
+ if skew <= PAIR_MAX_SKEW_DEG and sep_change <= PAIR_MAX_SEP_CHANGE:
395
+ return
396
+ # The eye whose anchor scored higher is trusted ('vlm' scores 0.0).
397
+ if left["anchor_score"] >= right["anchor_score"]:
398
+ strong, weak, strong_vlm, weak_vlm = left, right, (lvx, lvy), (rvx, rvy)
399
+ else:
400
+ strong, weak, strong_vlm, weak_vlm = right, left, (rvx, rvy), (lvx, lvy)
401
+ dx = strong["cx"] * (w - 1) - strong_vlm[0]
402
+ dy = strong["cy"] * (h - 1) - strong_vlm[1]
403
+ weak["cx"] = _clamp01((weak_vlm[0] + dx) / (w - 1)) if w > 1 else 0.0
404
+ weak["cy"] = _clamp01((weak_vlm[1] + dy) / (h - 1)) if h > 1 else 0.0
405
+ weak["snap_delta"] = math.hypot(dx, dy) / diag
406
+ weak["anchor_kind"] = "pair"
407
+ weak["anchor_score"] = strong["anchor_score"]
408
+
409
+
410
+ def snap_features(image: np.ndarray, features: list[dict]) -> list[dict]:
411
+ """Snap VLM feature points to the strongest nearby visual anchors.
412
+
413
+ Args:
414
+ image: HxWx3 BGR uint8 (HxW grayscale also accepted).
415
+ features: dicts with at least ``cx``, ``cy`` (normalized [0,1]),
416
+ ``size`` (coarse diameter / image diagonal; 0 or missing disables
417
+ size matching) and ``role`` (``eye_left``/``eye_right``/``mouth``/
418
+ anything else). Extra keys (``name``, …) pass through untouched.
419
+
420
+ Returns:
421
+ New dicts in input order with snapped ``cx``/``cy`` plus
422
+ ``snap_delta`` (distance moved / image diagonal), ``anchor_kind``
423
+ (``circle|corner|blob|edge|pair|vlm|vlm_corrected``) and
424
+ ``anchor_score``.
425
+ Inputs are never mutated. Fully deterministic — no RNG anywhere.
426
+ """
427
+ gray = _as_gray(image)
428
+ h, w = gray.shape
429
+ diag = math.hypot(w, h)
430
+ radius = max(8.0, SNAP_RADIUS_FRAC * diag)
431
+ blurred = cv2.GaussianBlur(gray, (5, 5), 1.2)
432
+
433
+ results: list[Optional[dict]] = [None] * len(features)
434
+ vlm_px = [_to_px(f, w, h) for f in features]
435
+
436
+ # Pass 1 — eyes first: their final positions gate the mouth's floor.
437
+ eye_idx = [i for i, f in enumerate(features) if _role_class(f.get("role")) == "eye"]
438
+ for i in eye_idx:
439
+ results[i] = _snap_one(blurred, features[i], radius, diag)
440
+
441
+ left_i = next((i for i in eye_idx if features[i].get("role") == "eye_left"), None)
442
+ right_i = next((i for i in eye_idx if features[i].get("role") == "eye_right"), None)
443
+ if left_i is not None and right_i is not None:
444
+ _enforce_pair_coherence(
445
+ results[left_i], results[right_i], vlm_px[left_i], vlm_px[right_i], w, h, diag
446
+ )
447
+
448
+ eye_mid_y: Optional[float] = None
449
+ if eye_idx:
450
+ eye_mid_y = sum(results[i]["cy"] * (h - 1) for i in eye_idx) / len(eye_idx)
451
+
452
+ # Pass 2 — everything else; mouths must land strictly below the eye line.
453
+ for i, f in enumerate(features):
454
+ if results[i] is not None:
455
+ continue
456
+ floor = eye_mid_y if _role_class(f.get("role")) == "mouth" else None
457
+ results[i] = _snap_one(blurred, f, radius, diag, min_y=floor)
458
+
459
+ # Pass 3 — enforce the mouth floor on the FINAL points regardless of
460
+ # anchor_kind. Anchor candidates above the eye line were already filtered
461
+ # in _best_anchor, but a kept-VLM mouth (anchor_kind="vlm") could still
462
+ # sit above the snapped eye midpoint — an inverted face. Deterministic
463
+ # correction: push it just below the eye line, preserving the VLM's own
464
+ # eye-to-mouth scale.
465
+ if eye_mid_y is not None:
466
+ vlm_eye_mid_y = sum(vlm_px[i][1] for i in eye_idx) / len(eye_idx)
467
+ for i, f in enumerate(features):
468
+ if _role_class(f.get("role")) != "mouth":
469
+ continue
470
+ out = results[i]
471
+ final_y = out["cy"] * (h - 1)
472
+ if final_y > eye_mid_y:
473
+ continue # already strictly below the eye line
474
+ mx, my = vlm_px[i]
475
+ # 0.6 × the eye-to-mouth offset the VLM itself proposed (its
476
+ # magnitude — the proposal may have been inverted), with a small
477
+ # floor so an exactly-level mouth still moves strictly below.
478
+ offset = 0.6 * abs(my - vlm_eye_mid_y)
479
+ offset = max(offset, 0.02 * (h - 1), 1.0)
480
+ new_y = min(eye_mid_y + offset, float(h - 1)) # clamp inside image
481
+ out["cy"] = _clamp01(new_y / (h - 1)) if h > 1 else 0.0
482
+ out["snap_delta"] = math.hypot(out["cx"] * (w - 1) - mx, new_y - my) / diag
483
+ out["anchor_kind"] = "vlm_corrected"
484
+ return results # type: ignore[return-value]
485
+
486
+
487
+ # ------------------------------------------------------------- debug overlay
488
+
489
+ _COL_BEFORE = (0, 0, 255) # red — the VLM's coarse guess
490
+ _COL_AFTER = (0, 255, 0) # green — the snapped fact
491
+ _COL_WINDOW = (60, 60, 200) # dim red — search radius
492
+ _COL_LINK = (0, 255, 255) # yellow — the snap delta
493
+
494
+
495
+ def overlay_debug(
496
+ image: np.ndarray, features_before: list[dict], features_after: list[dict]
497
+ ) -> np.ndarray:
498
+ """Render before(red)/after(green) points + search circles for eyeballing.
499
+
500
+ Used by the eval agent's G2 grounding check (ARCHITECTURE.md §8): the red
501
+ dot is what the VLM guessed, the dim red circle is the 12%-diagonal search
502
+ window, the green dot is where deterministic code put the feature, with
503
+ the winning ``anchor_kind`` labeled. Returns a new BGR uint8 image.
504
+ """
505
+ gray_or_bgr = image
506
+ if gray_or_bgr.ndim == 2:
507
+ canvas = cv2.cvtColor(gray_or_bgr, cv2.COLOR_GRAY2BGR)
508
+ else:
509
+ canvas = gray_or_bgr.copy()
510
+ h, w = canvas.shape[:2]
511
+ radius = int(round(max(8.0, SNAP_RADIUS_FRAC * math.hypot(w, h))))
512
+ for before, after in zip(features_before, features_after):
513
+ bx, by = _to_px(before, w, h)
514
+ ax, ay = _to_px(after, w, h)
515
+ b = (int(round(bx)), int(round(by)))
516
+ a = (int(round(ax)), int(round(ay)))
517
+ cv2.circle(canvas, b, radius, _COL_WINDOW, 1)
518
+ cv2.line(canvas, b, a, _COL_LINK, 1)
519
+ cv2.circle(canvas, b, 4, _COL_BEFORE, -1)
520
+ cv2.circle(canvas, a, 4, _COL_AFTER, -1)
521
+ kind = after.get("anchor_kind")
522
+ if kind:
523
+ cv2.putText(
524
+ canvas,
525
+ str(kind),
526
+ (a[0] + 6, a[1] - 6),
527
+ cv2.FONT_HERSHEY_SIMPLEX,
528
+ 0.4,
529
+ _COL_AFTER,
530
+ 1,
531
+ cv2.LINE_AA,
532
+ )
533
+ return canvas
mind/WRITING.md ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PAREIDOLIA — The Writing Bible
2
+
3
+ This file sets the comedy bar. `prompts.py` few-shots are drawn from here; any
4
+ line the app ships must survive these rules. The wow does NOT live in the eye
5
+ placement — it lives in a fire hydrant having a *specific, earned* grudge.
6
+
7
+ ## The register
8
+
9
+ Dry, deadpan, dignified. These objects are not wacky — they are **long-suffering
10
+ professionals, petty rivals, and quiet romantics** who have finally been given a
11
+ mouth. The comedy is in the gap between how seriously they take themselves and
12
+ what they are (a cone, a mug, a gnome). Think Paddington's hard stares, parks
13
+ department memos, retirement-speech energy. Never "wacky AI filter" energy.
14
+
15
+ ## Iron rules (validator-enforced where possible)
16
+
17
+ 1. **Every line references a visible specific** of THIS photo: its condition
18
+ (rust, dust, crumbs, coffee ring, missing parts), its setting (corner,
19
+ closet, windowsill), or a neighbor object. No specific → rejected, regenerate.
20
+ 2. ≤ 22 words per line. One line, one grievance. No stacked jokes.
21
+ 3. Banned: opening with "I am a/the…"; exclamation marks; puns as the entire
22
+ joke; "beep boop"/robot voice; meanness aimed at the *photographer*;any
23
+ reference to being an AI.
24
+ 4. PG-13. Grudges target circumstances, neighbors, and abstract injustice —
25
+ never protected groups, never the user.
26
+ 5. The object never *asks* to be freed/helped. It has dignity. It copes.
27
+
28
+ ## Archetype bank (condition → soul)
29
+
30
+ | # | archetype | trigger condition | voice | core wound |
31
+ |---|---|---|---|---|
32
+ | 1 | the_veteran | rusted, weathered, outdoors | gravel_low | decades of unthanked service |
33
+ | 2 | the_martyr | worn, stained, heavily used | weary_warm | gives everything, gets no maintenance |
34
+ | 3 | the_perfectionist | pristine, unused, boxed | prim_clipped | capabilities tragically unexplored |
35
+ | 4 | the_abandoned | dusty, stored, cobwebbed | breathy_faded | was loved once; keeps the faith |
36
+ | 5 | the_conspiracist | broken, odd placement | paranoid_whisper | knows why it was moved. oh, it knows |
37
+ | 6 | the_diva | decorated, displayed, loved | grandiose_warm | insufficiently exclusive adoration |
38
+ | 7 | the_new_hire | new, tagged, packaged | eager_bright | desperate to prove itself |
39
+ | 8 | the_philosopher | antique, inherited | slow_grand | fake-deep wisdom, petty undercut |
40
+ | 9 | the_rival | one of an identical pair/row | deadpan_flat | obsessed with the other one |
41
+ | 10 | the_romantic | faces a window/door/street | soft_wistful | yearns for what passes by |
42
+
43
+ ## Exemplars (the bar — few-shots come from these)
44
+
45
+ - **Fire hydrant, rusted, sidewalk** (veteran): "Forty years on this corner. Not
46
+ one dog has shown me respect."
47
+ - **Traffic cone, faded, same pothole** (veteran): "Six months guarding this
48
+ pothole. The city says it's 'scheduled.' I've heard that before."
49
+ - **Mug, coffee ring, desk** (martyr): "Third refill today. Still no rinse. I
50
+ see how it is."
51
+ - **Toaster, crumb tray full** (martyr): "Every morning, golden perfection.
52
+ Every morning, shaken upside down like a piñata."
53
+ - **Stand mixer, spotless, counter** (perfectionist): "I have a setting they've
54
+ never once used. It's called *fold*. I dream about it."
55
+ - **Exercise bike, laundry on handlebars** (abandoned): "In January we were
56
+ inseparable. It's June. I'm a coat rack with a heart-rate monitor."
57
+ - **Stapler, supply closet shelf** (conspiracist): "They moved me here after the
58
+ audit. I know what I saw."
59
+ - **Garden gnome, flowerbed, next to flamingo** (diva): "Twenty years I anchored
60
+ this flowerbed's aesthetic. One flamingo arrives and suddenly we're 'eclectic.'"
61
+ - **Houseplant, price tag still on** (new_hire): "Day three. Tag's still on.
62
+ Could somebody just tell me where the light comes from."
63
+ - **Leather armchair, sun-faded** (philosopher): "I have held three generations
64
+ of this family. Also seventeen remotes. Mostly the remotes."
65
+ - **Bedside lamp, identical twin across the room** (rival): "Same wattage. Same
66
+ shade. Somehow *she* gets the side with the book."
67
+ - **Mailbox, residential street** (romantic): "Mostly coupons now. But I
68
+ remember real letters. I held them first."
69
+
70
+ ## Mutters (idle wall lines — ≤10 words, same specificity rule)
71
+
72
+ - Hydrant: "Paint me red, they said. Dignified, they said."
73
+ - Cone: "Still. Scheduled."
74
+ - Mug: "A rinse. Anything."
75
+ - Gnome: "The flamingo doesn't even face the path."
76
+ - Bike: "That towel isn't mine."
77
+
78
+ ## Voice-seed mapping
79
+
80
+ `persona.voice` values map 1:1 to pre-registered VoxCPM reference seeds in
81
+ `mind/voice.py` (gravel_low, weary_warm, prim_clipped, breathy_faded,
82
+ paranoid_whisper, grandiose_warm, eager_bright, slow_grand, deadpan_flat,
83
+ soft_wistful). Ten seeds, recorded/generated once, committed to the repo —
84
+ voices must be *instantly* distinct on a phone speaker.
85
+
86
+ ## Quality gates
87
+
88
+ - **G1 (ship gate)**: 12-image eval set → a human laughs at ≥6; every line
89
+ passes rule 1. Otherwise iterate few-shots/archetypes before ANY polish work.
90
+ - Self-check in the prompt: the model writes `critique` first (is the grudge
91
+ specific to THIS object's visible state?), then finals. One retry on
92
+ validator failure with the error fed back.
mind/__init__.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PAREIDOLIA mind — the spirit medium, its schema, and its voices.
2
+
3
+ Import surface for app.py and tests. Importing this package is always light:
4
+ every ML dependency (torch, transformers, voxcpm, spaces) is guarded inside
5
+ the zerogpu code paths, so mock mode and the test suite run on stdlib +
6
+ pydantic alone (ARCHITECTURE.md §0, "mock backend first").
7
+ """
8
+
9
+ from .backends import (
10
+ CANNED_RECORDS,
11
+ HYDRANT_RECORD,
12
+ MockMedium,
13
+ SEANCE_PROMPT,
14
+ ZeroGPUMedium,
15
+ awaken_full,
16
+ make_medium,
17
+ warm_defaults,
18
+ )
19
+ from .schema import (
20
+ AwakeningParseError,
21
+ AwakeningResult,
22
+ Feature,
23
+ Gate,
24
+ Lines,
25
+ Persona,
26
+ PoeticError,
27
+ build_repair_prompt,
28
+ parse_awakening,
29
+ )
30
+ from .voice import VOICE_DESIGNS, MockVoice, VoxVoice, make_voice
31
+
32
+ __all__ = [
33
+ "AwakeningParseError",
34
+ "AwakeningResult",
35
+ "CANNED_RECORDS",
36
+ "Feature",
37
+ "Gate",
38
+ "HYDRANT_RECORD",
39
+ "Lines",
40
+ "MockMedium",
41
+ "MockVoice",
42
+ "Persona",
43
+ "PoeticError",
44
+ "SEANCE_PROMPT",
45
+ "VOICE_DESIGNS",
46
+ "VoxVoice",
47
+ "ZeroGPUMedium",
48
+ "awaken_full",
49
+ "build_repair_prompt",
50
+ "make_medium",
51
+ "make_voice",
52
+ "parse_awakening",
53
+ "warm_defaults",
54
+ ]
mind/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (1.25 kB). View file
 
mind/__pycache__/backends.cpython-312.pyc ADDED
Binary file (25.1 kB). View file
 
mind/__pycache__/prompts.cpython-312.pyc ADDED
Binary file (20.5 kB). View file
 
mind/__pycache__/schema.cpython-312.pyc ADDED
Binary file (16.5 kB). View file
 
mind/__pycache__/voice.cpython-312.pyc ADDED
Binary file (9.25 kB). View file
 
mind/backends.py ADDED
@@ -0,0 +1,683 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spirit-medium backends: MockMedium (canned awakenings) and ZeroGPUMedium.
2
+
3
+ Selected via the PAREIDOLIA_BACKEND env: mock (default) | zerogpu.
4
+
5
+ - MockMedium: deterministic canned awakening records keyed by image hash,
6
+ with a realistic configurable delay — the whole frontend, server, and test
7
+ suite build against this with zero GPU and zero ML imports.
8
+ - ZeroGPUMedium: MiniCPM-V-4_5 per ARCHITECTURE.md §7. ALL ML imports are
9
+ confined to its load path, so importing this module in mock mode never
10
+ touches torch.
11
+
12
+ This module also owns the combined-GPU-window plumbing (§2): `awaken_full`
13
+ runs VLM -> CV snap -> TTS inside ONE @spaces.GPU(duration=75) function so a
14
+ visitor pays one queue wait and one quota spend. `spaces` is imported (and
15
+ the decorator applied) at module import time when available — ZeroGPU
16
+ discovers GPU functions at startup, and importing `spaces` here also
17
+ guarantees it precedes any torch import (§7 hard rule). Locally the bare
18
+ function runs instead.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import io
24
+ import logging
25
+ import os
26
+ import threading
27
+ import time
28
+ from typing import Any, Callable, Optional
29
+
30
+ from .schema import (
31
+ AwakeningParseError,
32
+ AwakeningResult,
33
+ PoeticError,
34
+ build_repair_prompt,
35
+ parse_awakening,
36
+ )
37
+ from .voice import make_voice
38
+
39
+ logger = logging.getLogger("pareidolia.mind")
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Séance prompt (writing agent's mind/prompts.py owns the real one via
43
+ # build_seance_prompt()). Import-guarded with a minimal-but-correct fallback
44
+ # so the zerogpu path can bench even without prompts.py.
45
+ # ---------------------------------------------------------------------------
46
+ try:
47
+ from .prompts import build_seance_prompt as _build_seance_prompt
48
+
49
+ SEANCE_PROMPT: str = _build_seance_prompt()
50
+ except Exception: # noqa: BLE001 - any import problem means "use the fallback"
51
+ SEANCE_PROMPT = (
52
+ "You are a spirit medium. This object has a latent face made of its"
53
+ " EXISTING visual features, and a soul shaped by its visible condition."
54
+ " Find both. First write a one-line critique of your feature choices,"
55
+ " then commit. Reply with ONLY one JSON object, no code fences:\n"
56
+ "{\n"
57
+ ' "gate": {"contains_human_face": bool, "nsfw": bool,'
58
+ ' "recognizable_object": bool},\n'
59
+ ' "object": str, "material": str, "condition": str, "setting": str,\n'
60
+ ' "candidate_features": [{"name": str, "role":'
61
+ ' "eye_left"|"eye_right"|"mouth", "cx": 0..1, "cy": 0..1,'
62
+ ' "size": 0..1}],\n'
63
+ ' "critique": str,\n'
64
+ ' "persona": {"archetype": str, "voice": str, "mood": str},\n'
65
+ ' "lines": {"grudge": str, "mutter": str}\n'
66
+ "}\n"
67
+ "Exactly one eye_left, one eye_right, at most one mouth — each an"
68
+ " existing visual element, named as seen. Every line must reference a"
69
+ " visible specific of THIS object. Dry, deadpan, dignified; <= 22"
70
+ " words per line; PG-13; never open with 'I am'."
71
+ )
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # CV snapping (cv agent's cv/snap.py). Assumed contract:
75
+ # snap_features(image, features: list[dict]) -> list[dict]
76
+ # where each returned feature keeps name/role/cx/cy/size and gains
77
+ # "snap_delta" (normalized distance moved). Identity fallback keeps the
78
+ # VLM's coarse points — the mist forgives ±15% by design (§0).
79
+ # ---------------------------------------------------------------------------
80
+ try: # pragma: no cover - exercised only once cv/snap.py lands
81
+ from cv.snap import snap_features as _snap_features # type: ignore[import-not-found]
82
+ except Exception: # noqa: BLE001
83
+
84
+ def _snap_features(image: Any, features: list[dict]) -> list[dict]:
85
+ """Identity fallback: keep coarse points, report zero snap delta."""
86
+ return [{**feature, "snap_delta": 0.0} for feature in features]
87
+
88
+
89
+ def _as_snap_image(image: Any) -> Any:
90
+ """Best-effort convert to the BGR uint8 ndarray cv/snap.py wants.
91
+
92
+ The transport hands the pipeline a PIL image; cv.snap.snap_features speaks
93
+ ndarray only (its documented contract). Anything without ``.convert``
94
+ (bytes in tests, an ndarray already) passes through unchanged — the snap
95
+ try/except in :func:`_pipeline` keeps the VLM's coarse points whenever the
96
+ shape is wrong, and the mist forgives ±15% (§0).
97
+ """
98
+ convert = getattr(image, "convert", None)
99
+ if convert is None:
100
+ return image
101
+ import numpy as np # transitively present via opencv; local to stay light
102
+
103
+ rgb = np.asarray(convert("RGB"))
104
+ return np.ascontiguousarray(rgb[:, :, ::-1]) # RGB -> BGR
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Canned awakening records (mock backend). HYDRANT_RECORD is EXACTLY the §3
109
+ # fire-hydrant example and the default for unknown images. The eight
110
+ # _M*_RECORDs are COHERENT souls for the eight dev photos in
111
+ # web/mock/photos/ (same objects, coordinates eyeballed against the actual
112
+ # pixels; lines reuse/match web/mock/records.json, written to the WRITING.md
113
+ # bar) — recognized by perceptual average-hash so a mock demo never captions
114
+ # a hydrant photo as "the weathered traffic cone".
115
+ # ---------------------------------------------------------------------------
116
+
117
+ HYDRANT_RECORD: dict = {
118
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
119
+ "object": "fire hydrant",
120
+ "material": "cast iron",
121
+ "condition": "rusted",
122
+ "setting": "sidewalk, residential street",
123
+ "candidate_features": [
124
+ {"name": "left bonnet bolt", "role": "eye_left", "cx": 0.42, "cy": 0.31, "size": 0.06},
125
+ {"name": "right bonnet bolt", "role": "eye_right", "cx": 0.58, "cy": 0.31, "size": 0.06},
126
+ {"name": "front outlet cap", "role": "mouth", "cx": 0.50, "cy": 0.55, "size": 0.12},
127
+ ],
128
+ "critique": "bolts are symmetric and round — strong eyes; outlet sits low-center, good mouth",
129
+ "persona": {"archetype": "the_veteran", "voice": "gravel_low", "mood": "long-suffering"},
130
+ "lines": {
131
+ "grudge": "Forty years on this corner. Not one dog has shown me respect.",
132
+ "mutter": "Paint me red, they said. It'll be dignified, they said.",
133
+ },
134
+ }
135
+
136
+ _M1_HYDRANT_RECORD: dict = {
137
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
138
+ "object": "fire hydrant",
139
+ "material": "cast iron",
140
+ "condition": "rusted",
141
+ "setting": "against a corrugated metal wall",
142
+ "candidate_features": [
143
+ {"name": "left outlet cap", "role": "eye_left", "cx": 0.337, "cy": 0.328, "size": 0.16},
144
+ {"name": "right outlet cap", "role": "eye_right", "cx": 0.547, "cy": 0.33, "size": 0.16},
145
+ {"name": "paired flange bolts", "role": "mouth", "cx": 0.415, "cy": 0.49, "size": 0.10},
146
+ ],
147
+ "critique": (
148
+ "the two outlet caps sit level and round — strong eyes; the paired "
149
+ "flange bolts below read as a small, set mouth"
150
+ ),
151
+ "persona": {"archetype": "the_veteran", "voice": "gravel_low", "mood": "long-suffering"},
152
+ "lines": {
153
+ "grudge": (
154
+ "They painted over the rust twice and never once turned my "
155
+ "valve. Decorative, apparently."
156
+ ),
157
+ "mutter": "Two coats of orange. Still thirsty.",
158
+ },
159
+ }
160
+
161
+ _M2_CONE_RECORD: dict = {
162
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
163
+ "object": "traffic cone",
164
+ "material": "weathered pvc",
165
+ "condition": "worn",
166
+ "setting": "curb edge, residential street",
167
+ "candidate_features": [
168
+ {"name": "left scuff mark", "role": "eye_left", "cx": 0.40, "cy": 0.385, "size": 0.05},
169
+ {"name": "right sun-fade patch", "role": "eye_right", "cx": 0.52, "cy": 0.39, "size": 0.05},
170
+ {"name": "moulding seam shadow", "role": "mouth", "cx": 0.46, "cy": 0.56, "size": 0.09},
171
+ ],
172
+ "critique": (
173
+ "the scuff and the sun-fade patch sit nearly level — tired but "
174
+ "honest eyes; the moulding seam shadow makes a thin, grim mouth"
175
+ ),
176
+ "persona": {"archetype": "the_veteran", "voice": "gravel_low", "mood": "resolute"},
177
+ "lines": {
178
+ "grudge": (
179
+ "Temporary placement, they said. The grass has since eaten the "
180
+ "curb. I hold the line alone."
181
+ ),
182
+ "mutter": "Still here. Still 'temporary.'",
183
+ },
184
+ }
185
+
186
+ _M3_MUG_RECORD: dict = {
187
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
188
+ "object": "coffee mug",
189
+ "material": "glazed ceramic",
190
+ "condition": "loved",
191
+ "setting": "on a fire grate, campfire",
192
+ "candidate_features": [
193
+ {"name": "ember reflection", "role": "eye_left", "cx": 0.625, "cy": 0.62, "size": 0.05},
194
+ {"name": "glaze highlight", "role": "eye_right", "cx": 0.76, "cy": 0.63, "size": 0.05},
195
+ {"name": "base shadow curve", "role": "mouth", "cx": 0.69, "cy": 0.78, "size": 0.09},
196
+ ],
197
+ "critique": (
198
+ "the ember reflection and the glaze highlight sit level on the "
199
+ "curve — lit, wary eyes; the base shadow bends like a resigned mouth"
200
+ ),
201
+ "persona": {"archetype": "the_martyr", "voice": "weary_warm", "mood": "resigned"},
202
+ "lines": {
203
+ "grudge": (
204
+ "They set me on the grill. Directly over the fire. I'm told "
205
+ "this is 'camping.'"
206
+ ),
207
+ "mutter": "Nobody checks the handle temperature.",
208
+ },
209
+ }
210
+
211
+ _M4_TOASTER_RECORD: dict = {
212
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
213
+ "object": "toaster",
214
+ "material": "white enamel",
215
+ "condition": "pristine",
216
+ "setting": "bare counter, studio light",
217
+ "candidate_features": [
218
+ {"name": "left slot guard", "role": "eye_left", "cx": 0.30, "cy": 0.615, "size": 0.10},
219
+ {"name": "right slot guard", "role": "eye_right", "cx": 0.655, "cy": 0.605, "size": 0.10},
220
+ {"name": "base seam shadow", "role": "mouth", "cx": 0.50, "cy": 0.84, "size": 0.14},
221
+ ],
222
+ "critique": (
223
+ "the slot guards are symmetric and wide-set — immaculate eyes; the "
224
+ "base seam shadow runs flat and composed, a professional's mouth"
225
+ ),
226
+ "persona": {"archetype": "the_perfectionist", "voice": "prim_clipped", "mood": "wounded pride"},
227
+ "lines": {
228
+ "grudge": (
229
+ "Two slots. They use one. The other waits, pristine, for "
230
+ "guests who never toast."
231
+ ),
232
+ "mutter": "Bagel setting. Untouched since purchase.",
233
+ },
234
+ }
235
+
236
+ _M5_MAILBOX_RECORD: dict = {
237
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
238
+ "object": "mailbox",
239
+ "material": "galvanized steel",
240
+ "condition": "weathered",
241
+ "setting": "snowdrift, farm fence line, 1940",
242
+ "candidate_features": [
243
+ # The rivets really are ~0.04 apart on the small door — distinct,
244
+ # eyeballed on the photo. The 0.05-separation rule lives in the
245
+ # parse_awakening path (VLM degeneracy guard), not the schema.
246
+ {"name": "left door rivet", "role": "eye_left", "cx": 0.705, "cy": 0.287, "size": 0.035},
247
+ {"name": "right door rivet", "role": "eye_right", "cx": 0.745, "cy": 0.286, "size": 0.035},
248
+ {"name": "door latch", "role": "mouth", "cx": 0.728, "cy": 0.363, "size": 0.05},
249
+ ],
250
+ "critique": (
251
+ "the two door rivets sit close and level — small wary eyes; the "
252
+ "latch beneath makes a tight deadpan mouth"
253
+ ),
254
+ "persona": {"archetype": "the_rival", "voice": "deadpan_flat", "mood": "clipped"},
255
+ "lines": {
256
+ "grudge": (
257
+ "We've stood in this snow since January. R.C. Lenhart gets a "
258
+ "name plate. I get 'the other box.'"
259
+ ),
260
+ "mutter": "Her flag works. Allegedly.",
261
+ },
262
+ }
263
+
264
+ _M6_POSTBOX_RECORD: dict = {
265
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
266
+ "object": "post box",
267
+ "material": "painted steel",
268
+ "condition": "kept",
269
+ "setting": "lawn beside a parking lot",
270
+ "candidate_features": [
271
+ {"name": "left flap hinge", "role": "eye_left", "cx": 0.345, "cy": 0.295, "size": 0.045},
272
+ {"name": "right flap hinge", "role": "eye_right", "cx": 0.60, "cy": 0.295, "size": 0.045},
273
+ {"name": "collection keyhole", "role": "mouth", "cx": 0.475, "cy": 0.415, "size": 0.05},
274
+ ],
275
+ "critique": (
276
+ "the flap hinges sit wide and perfectly level — patient eyes; the "
277
+ "collection keyhole is a small, hopeful mouth"
278
+ ),
279
+ "persona": {"archetype": "the_romantic", "voice": "soft_wistful", "mood": "hopeful"},
280
+ "lines": {
281
+ "grudge": (
282
+ "Parked cars come and go all day. Nobody writes. I keep the "
283
+ "slot warm anyway."
284
+ ),
285
+ "mutter": "The slot stays open. Just in case.",
286
+ },
287
+ }
288
+
289
+ _M7_ASPIDISTRA_RECORD: dict = {
290
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
291
+ "object": "aspidistra",
292
+ "material": "leaf and glazed bowl",
293
+ "condition": "antique",
294
+ "setting": "parlor table, framed pictures behind",
295
+ "candidate_features": [
296
+ {"name": "left glaze highlight", "role": "eye_left", "cx": 0.425, "cy": 0.755, "size": 0.04},
297
+ {"name": "right glaze highlight", "role": "eye_right", "cx": 0.525, "cy": 0.76, "size": 0.04},
298
+ {"name": "doily shadow", "role": "mouth", "cx": 0.475, "cy": 0.875, "size": 0.08},
299
+ ],
300
+ "critique": (
301
+ "the paired glaze highlights sit level on the bowl — calm, ancient "
302
+ "eyes; the doily shadow curves into a serene mouth"
303
+ ),
304
+ "persona": {"archetype": "the_philosopher", "voice": "slow_grand", "mood": "serene"},
305
+ "lines": {
306
+ "grudge": (
307
+ "I outlasted everyone in those three frames. The lesson here is "
308
+ "patience. Also, shade tolerance."
309
+ ),
310
+ "mutter": "The frames went first. Noted.",
311
+ },
312
+ }
313
+
314
+ _M8_STAPLER_RECORD: dict = {
315
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
316
+ "object": "stapler",
317
+ "material": "cast metal",
318
+ "condition": "stored",
319
+ "setting": "bare shelf, yellowed wall",
320
+ "candidate_features": [
321
+ {"name": "side rivet", "role": "eye_left", "cx": 0.245, "cy": 0.665, "size": 0.03},
322
+ {"name": "anvil latch", "role": "eye_right", "cx": 0.455, "cy": 0.615, "size": 0.03},
323
+ {"name": "loaded staple strip", "role": "mouth", "cx": 0.545, "cy": 0.745, "size": 0.07},
324
+ ],
325
+ "critique": (
326
+ "the side rivet and the anvil latch hold a sidelong gaze — uneven "
327
+ "but alive; the loaded staple strip is a clenched mouth"
328
+ ),
329
+ "persona": {"archetype": "the_conspiracist", "voice": "paranoid_whisper", "mood": "vigilant"},
330
+ "lines": {
331
+ "grudge": (
332
+ "The gray one appeared the week the files went missing. Nobody "
333
+ "else finds that interesting."
334
+ ),
335
+ "mutter": "It hasn't stapled once. Not once.",
336
+ },
337
+ }
338
+
339
+ CANNED_RECORDS: tuple[dict, ...] = (
340
+ HYDRANT_RECORD,
341
+ _M1_HYDRANT_RECORD,
342
+ _M2_CONE_RECORD,
343
+ _M3_MUG_RECORD,
344
+ _M4_TOASTER_RECORD,
345
+ _M5_MAILBOX_RECORD,
346
+ _M6_POSTBOX_RECORD,
347
+ _M7_ASPIDISTRA_RECORD,
348
+ _M8_STAPLER_RECORD,
349
+ )
350
+
351
+ # 64-bit average-hashes of web/mock/photos/m1..m8.jpg (computed June 12 with
352
+ # _average_hash below). aHash survives the client's canvas downscale/JPEG
353
+ # re-encode and app.py's thumbnail: measured drift ≤1 bit at 0.8x + q70,
354
+ # while the closest photo PAIR is 18 bits apart — a ≤8-bit match is
355
+ # unambiguous. Same photo -> same soul, forever; anything else -> hydrant.
356
+ _PHOTO_HASH_RECORDS: tuple[tuple[int, dict], ...] = (
357
+ (0xFFFFFFCED8101010, _M1_HYDRANT_RECORD),
358
+ (0xFFF9101818181800, _M2_CONE_RECORD),
359
+ (0xF838363F7F701000, _M3_MUG_RECORD),
360
+ (0x7E7E7E64003E3C3C, _M4_TOASTER_RECORD),
361
+ (0xFEFF31E1E12124F8, _M5_MAILBOX_RECORD),
362
+ (0xC0D04003C3FFFCFC, _M6_POSTBOX_RECORD),
363
+ (0x0000007F773F79FF, _M7_ASPIDISTRA_RECORD),
364
+ (0xFFFFE3E3C18083FF, _M8_STAPLER_RECORD),
365
+ )
366
+ _PHOTO_HASH_MAX_DISTANCE = 8
367
+
368
+
369
+ def _average_hash(image: Any) -> Optional[int]:
370
+ """64-bit perceptual average-hash, or None when ``image`` isn't one.
371
+
372
+ Accepts a PIL-like image (has ``.convert``) or raw encoded bytes; strings
373
+ and everything else return None (the mock must never read paths or raise).
374
+ PIL is a light image-I/O dependency (already required by app.py), NOT an
375
+ ML import — the zero-ML mock contract holds.
376
+ """
377
+ try:
378
+ from PIL import Image # noqa: PLC0415 - light, lazy by convention
379
+
380
+ if isinstance(image, (bytes, bytearray)):
381
+ img = Image.open(io.BytesIO(bytes(image)))
382
+ img.load()
383
+ elif hasattr(image, "convert"):
384
+ img = image
385
+ else:
386
+ return None
387
+ gray = img.convert("L").resize((8, 8), Image.Resampling.LANCZOS)
388
+ pixels = gray.tobytes()
389
+ avg = sum(pixels) / 64.0
390
+ bits = 0
391
+ for p in pixels:
392
+ bits = (bits << 1) | (1 if p > avg else 0)
393
+ return bits
394
+ except Exception: # noqa: BLE001 - undecodable bytes, broken PIL object …
395
+ return None
396
+
397
+
398
+ def _canned_record_for(image: Any) -> dict:
399
+ """The coherent canned record for a known dev photo, else the §3 hydrant."""
400
+ ahash = _average_hash(image)
401
+ if ahash is None:
402
+ return HYDRANT_RECORD
403
+ best: Optional[dict] = None
404
+ best_d = _PHOTO_HASH_MAX_DISTANCE + 1
405
+ for ref, record in _PHOTO_HASH_RECORDS:
406
+ d = (ahash ^ ref).bit_count()
407
+ if d < best_d:
408
+ best, best_d = record, d
409
+ return best if best is not None else HYDRANT_RECORD
410
+
411
+
412
+ class MockMedium:
413
+ """Deterministic canned medium — what every demo and test runs on.
414
+
415
+ Same image -> same record, forever (the Menagerie replays identically;
416
+ judges can refresh without surprises). Each of the eight dev photos in
417
+ web/mock/photos/ is recognized by perceptual average-hash and gets its
418
+ own COHERENT record (the photo's actual object, features eyeballed on
419
+ its pixels) — a mock demo can never caption a hydrant photo as a cone.
420
+ Unknown images (and None) get the §3 fire-hydrant example verbatim.
421
+
422
+ The sleep (default 6 s, PAREIDOLIA_MOCK_DELAY env, constructor override
423
+ for tests) keeps the séance animation honest: latency theater must be
424
+ rehearsed against realistic latency.
425
+ """
426
+
427
+ name = "mock"
428
+ model_id = "pareidolia-mock-canned"
429
+
430
+ def __init__(self, delay: Optional[float] = None):
431
+ if delay is None:
432
+ try:
433
+ delay = float(os.environ.get("PAREIDOLIA_MOCK_DELAY", "6"))
434
+ except ValueError:
435
+ delay = 6.0
436
+ self.delay = max(0.0, delay)
437
+
438
+ def awaken(self, image: Any, prompt: Optional[str] = None) -> AwakeningResult:
439
+ """Return the canned awakening for this image after the mock delay.
440
+
441
+ `prompt` is accepted (and ignored) so call sites never special-case
442
+ the backend. Records round-trip through pydantic so the mock can
443
+ never drift from the schema contract.
444
+ """
445
+ if self.delay:
446
+ time.sleep(self.delay)
447
+ record = _canned_record_for(image)
448
+ return AwakeningResult.model_validate(record)
449
+
450
+
451
+ # ---------------------------------------------------------------------------
452
+ # ZeroGPU medium — MiniCPM-V-4_5
453
+ # ---------------------------------------------------------------------------
454
+
455
+ REFUSAL_SCATTERED = (
456
+ "The spirits spoke, but their words scattered like mist. Try another angle."
457
+ )
458
+
459
+
460
+ class ZeroGPUMedium:
461
+ """MiniCPM-V-4_5 spirit medium for the ZeroGPU Space (§7 pins).
462
+
463
+ Heavy rules, all verified June 12:
464
+ - attn_implementation='sdpa' (NEVER 'eager'; no flash-attn on the image),
465
+ torch_dtype=bfloat16, trust_remote_code=True, .eval().cuda().
466
+ - Load happens ONCE at construction. app.py must construct this medium at
467
+ module import (startup), where the ZeroGPU runtime manages the startup
468
+ .cuda() — that is the moral equivalent of §7's "module level" load, kept
469
+ inside this class so mock mode never imports torch.
470
+ - No native JSON mode: sampling=False + defensive parse + ONE repair retry
471
+ (schema.build_repair_prompt), then PoeticError. max_slice_nums=4 bounds
472
+ prefill at our ≤1024px inputs.
473
+
474
+ ``chat_fn`` is a test seam: a callable ``(image, prompt) -> str`` that
475
+ replaces the loaded model so the retry/PoeticError logic is testable with
476
+ zero ML imports.
477
+ """
478
+
479
+ name = "zerogpu"
480
+ DEFAULT_MODEL = "openbmb/MiniCPM-V-4_5"
481
+ MAX_NEW_TOKENS = 700
482
+ MAX_SLICE_NUMS = 4
483
+
484
+ def __init__(
485
+ self,
486
+ model_id: Optional[str] = None,
487
+ chat_fn: Optional[Callable[[Any, str], str]] = None,
488
+ ):
489
+ self.model_id = model_id or os.environ.get("PAREIDOLIA_VLM", self.DEFAULT_MODEL)
490
+ self._chat: Callable[[Any, str], str] = chat_fn or self._load()
491
+
492
+ def _load(self) -> Callable[[Any, str], str]:
493
+ """Load MiniCPM-V once; return the bound chat callable.
494
+
495
+ The ONLY torch/transformers imports in the mind package live here.
496
+ """
497
+ import torch # noqa: PLC0415 - guarded heavy import by design
498
+ from transformers import AutoModel, AutoTokenizer # noqa: PLC0415
499
+
500
+ tokenizer = AutoTokenizer.from_pretrained(self.model_id, trust_remote_code=True)
501
+ model = AutoModel.from_pretrained(
502
+ self.model_id,
503
+ trust_remote_code=True,
504
+ attn_implementation="sdpa",
505
+ torch_dtype=torch.bfloat16,
506
+ )
507
+ model = model.eval().cuda()
508
+
509
+ def _chat(image: Any, prompt: str) -> str:
510
+ return model.chat(
511
+ msgs=[{"role": "user", "content": [image, prompt]}],
512
+ tokenizer=tokenizer,
513
+ sampling=False,
514
+ max_new_tokens=self.MAX_NEW_TOKENS,
515
+ max_slice_nums=self.MAX_SLICE_NUMS,
516
+ # MiniCPM-V-4_5 is a hybrid-thinking model: thinking MUST stay
517
+ # off (§7, bench-proven call) or thinking tokens eat the
518
+ # 700-token budget and the JSON truncates every time.
519
+ enable_thinking=False,
520
+ )
521
+
522
+ return _chat
523
+
524
+ def awaken(self, image: Any, prompt: Optional[str] = None) -> AwakeningResult:
525
+ """One structured generation + at most one repair retry.
526
+
527
+ Raises PoeticError (visitor-safe copy) when both attempts fail; the
528
+ underlying parse errors ride on __cause__ for the logs.
529
+ """
530
+ seance = prompt or SEANCE_PROMPT
531
+ raw = self._chat(image, seance)
532
+ try:
533
+ return parse_awakening(raw)
534
+ except AwakeningParseError as first_error:
535
+ logger.warning("awakening parse failed, retrying once: %s", first_error)
536
+ repair = build_repair_prompt(str(first_error), raw)
537
+ raw_retry = self._chat(image, repair)
538
+ try:
539
+ return parse_awakening(raw_retry)
540
+ except AwakeningParseError as second_error:
541
+ logger.error("awakening repair also failed: %s", second_error)
542
+ raise PoeticError(REFUSAL_SCATTERED) from second_error
543
+
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # Factory
547
+ # ---------------------------------------------------------------------------
548
+
549
+ _MEDIUM_ALIASES = {
550
+ "mock": "mock",
551
+ "": "mock",
552
+ "zerogpu": "zerogpu",
553
+ "zero-gpu": "zerogpu",
554
+ "minicpm": "zerogpu",
555
+ }
556
+
557
+
558
+ def make_medium(backend_name: Optional[str] = None):
559
+ """Build the medium for ``backend_name`` or the PAREIDOLIA_BACKEND env.
560
+
561
+ Defaults to mock (always works, zero deps beyond pydantic).
562
+ """
563
+ raw = (backend_name or os.environ.get("PAREIDOLIA_BACKEND") or "mock").strip().lower()
564
+ resolved = _MEDIUM_ALIASES.get(raw)
565
+ if resolved == "mock":
566
+ return MockMedium()
567
+ if resolved == "zerogpu":
568
+ return ZeroGPUMedium()
569
+ raise ValueError(f"unknown PAREIDOLIA_BACKEND {raw!r} (expected mock | zerogpu)")
570
+
571
+
572
+ # ---------------------------------------------------------------------------
573
+ # The combined GPU window — VLM -> snap -> TTS in one quota spend (§2)
574
+ # ---------------------------------------------------------------------------
575
+
576
+
577
+ def _pipeline(image: Any, medium, voice, prompt: Optional[str] = None) -> dict:
578
+ """The full awakening pipeline, backend-agnostic.
579
+
580
+ Returns the dict app.py shapes into the §2 response:
581
+ {
582
+ "refused": bool,
583
+ "refusal": str | None, # poetic copy when refused
584
+ "record": dict | None, # AwakeningResult dump + "features" (post-
585
+ # snap, each with snap_delta; the original
586
+ # candidate_features stay for the trace)
587
+ "grudge_wav": bytes | None, # WAV bytes; app.py b64-encodes
588
+ }
589
+ Refusals exit BEFORE snap and TTS — the gate is also the budget guard.
590
+ """
591
+ result: AwakeningResult = (
592
+ medium.awaken(image, prompt=prompt) if prompt is not None else medium.awaken(image)
593
+ )
594
+
595
+ refusal = result.gate.refusal()
596
+ if refusal is not None:
597
+ return {"refused": True, "refusal": refusal, "record": None, "grudge_wav": None}
598
+
599
+ coarse = [feature.model_dump() for feature in result.candidate_features]
600
+ try:
601
+ snapped = _snap_features(_as_snap_image(image), coarse)
602
+ except Exception: # noqa: BLE001 - snapping is best-effort by contract (§0)
603
+ logger.exception("cv snap failed; keeping the VLM's coarse points")
604
+ snapped = [{**feature, "snap_delta": 0.0} for feature in coarse]
605
+
606
+ grudge_wav = voice.speak(result.lines.grudge, result.persona.voice)
607
+
608
+ record = result.model_dump()
609
+ record["features"] = snapped
610
+ return {"refused": False, "refusal": None, "record": record, "grudge_wav": grudge_wav}
611
+
612
+
613
+ # Decorate at import time: ZeroGPU discovers @spaces.GPU functions at app
614
+ # startup, and importing `spaces` here keeps it ahead of torch (§7). The
615
+ # wrapped function shares _pipeline's signature; locally (no `spaces`) the
616
+ # bare pipeline stands in.
617
+ def _gpu_entry(image: Any, medium, voice, prompt: Optional[str] = None) -> dict:
618
+ return _pipeline(image, medium, voice, prompt)
619
+
620
+
621
+ try: # pragma: no cover - `spaces` exists only on the HF runtime
622
+ import spaces # noqa: PLC0415 - MUST import before torch (§7 hard rule)
623
+
624
+ _gpu_pipeline: Callable[..., dict] = spaces.GPU(duration=75)(_gpu_entry)
625
+ except Exception: # noqa: BLE001 - local dev / tests
626
+ _gpu_pipeline = _gpu_entry
627
+
628
+
629
+ _DEFAULTS_LOCK = threading.Lock()
630
+ _DEFAULT_MEDIUM = None
631
+ _DEFAULT_VOICE = None
632
+
633
+
634
+ def _defaults():
635
+ """Process-wide medium/voice singletons resolved from PAREIDOLIA_BACKEND.
636
+
637
+ Cached so the zerogpu path loads its models exactly once — app.py may
638
+ simply call awaken_full(image) per request. Construct eagerly at startup
639
+ (e.g. `mind.backends.warm_defaults()` from app.py's module level) when
640
+ running on ZeroGPU so weights are resident before the first visitor.
641
+ """
642
+ global _DEFAULT_MEDIUM, _DEFAULT_VOICE
643
+ with _DEFAULTS_LOCK:
644
+ if _DEFAULT_MEDIUM is None:
645
+ _DEFAULT_MEDIUM = make_medium()
646
+ if _DEFAULT_VOICE is None:
647
+ _DEFAULT_VOICE = make_voice()
648
+ return _DEFAULT_MEDIUM, _DEFAULT_VOICE
649
+
650
+
651
+ def warm_defaults() -> None:
652
+ """Eagerly build the default medium + voice (call at app startup)."""
653
+ medium, voice = _defaults()
654
+ preload = getattr(voice, "preload", None)
655
+ if callable(preload):
656
+ preload()
657
+ del medium
658
+
659
+
660
+ def awaken_full(
661
+ image: Any,
662
+ *,
663
+ medium=None,
664
+ voice=None,
665
+ prompt: Optional[str] = None,
666
+ ) -> dict:
667
+ """Awaken one object end to end: VLM -> gate -> CV snap -> TTS.
668
+
669
+ This is the function app.py's `awaken` API calls. On the zerogpu backend
670
+ the whole pipeline runs inside ONE @spaces.GPU(duration=75) window — one
671
+ queue wait, one quota spend, charged to the visitor's own browser-
672
+ authenticated gradio request (§0). Mock (and any injected test medium)
673
+ runs the bare pipeline: no spaces, no torch, no GPU.
674
+
675
+ Raises PoeticError when the spirits decline twice; every other outcome —
676
+ including gate refusals — is a normal dict (see _pipeline).
677
+ """
678
+ if medium is None or voice is None:
679
+ default_medium, default_voice = _defaults()
680
+ medium = medium or default_medium
681
+ voice = voice or default_voice
682
+ runner = _gpu_pipeline if getattr(medium, "name", "") == "zerogpu" else _pipeline
683
+ return runner(image, medium, voice, prompt)
mind/mock_assets/grudge.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:07884612796f9129e7b7b35fc7207bbecf5c2194b5b61ffb45977678ca40aa04
3
+ size 227518
mind/prompts.py ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Séance prompt, archetype bank, voice designs, and line law for PAREIDOLIA.
2
+
3
+ This module is the writing system of the entry — the make-or-break of §3 in
4
+ ARCHITECTURE.md. It follows the "her" pattern: the VLM owns taste (which bolt
5
+ is an eye, how the soul sounds); this module owns the words that summon that
6
+ taste and the pure-python law that keeps the words honest.
7
+
8
+ Hard constraints honoured here:
9
+ - ONE structured generation per awakening: gate -> object -> features ->
10
+ critique -> persona -> lines. The critique key precedes the lines key in
11
+ every exemplar, so the model self-edits in-flight (critique-then-final).
12
+ No second verify round-trip — latency is masked by the séance animation.
13
+ - The full prompt stays under ~1300 tokens (~len/4 heuristic, enforced in
14
+ tests/test_prompts.py): MiniCPM-V prefill is the latency floor and the
15
+ photograph already costs image slices.
16
+ - No ML imports. Everything here runs on CPU, in tests, and in the mock
17
+ backend. All validators are pure functions.
18
+
19
+ Integration map (for the sibling agents):
20
+ - mind/backends.py: send ``[pil_image, build_seance_prompt()]`` as the single
21
+ user turn (``sampling=False``). On schema/line failure, ONE retry with
22
+ ``build_seance_prompt(error=...)`` (REASK_NOTICE marks the retry).
23
+ - mind/schema.py: call ``validate_line(line, kind)`` per line — an empty list
24
+ means pass; entries are model-readable repair messages to feed back. When
25
+ any gate fails the model is instructed to empty every non-gate field, so
26
+ require only ``gate`` on refused records.
27
+ - mind/voice.py: VoxCPM2 voice design needs no reference audio — prefix the
28
+ spoken line with the parenthesized description:
29
+ ``f"({VOICE_DESIGNS[voice_id]}) {line}"``.
30
+ - app.py / server: REFUSAL_LINES + refusal_for_gate() for poetic refusals.
31
+ Users see poetry, never stack traces.
32
+ - MockMedium may reuse FEW_SHOTS as canned awakening records — they are
33
+ complete, schema-exact, and pass every validator (tests guarantee it).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import re
40
+ from typing import Callable
41
+
42
+ __all__ = [
43
+ "SEANCE_SYSTEM",
44
+ "build_seance_prompt",
45
+ "ARCHETYPES",
46
+ "VOICE_DESIGNS",
47
+ "REFUSAL_LINES",
48
+ "refusal_for_gate",
49
+ "FEW_SHOTS",
50
+ "CONDITIONS",
51
+ "FEATURE_ROLES",
52
+ "GRUDGE_MAX_WORDS",
53
+ "MUTTER_MAX_WORDS",
54
+ "PROMPT_TOKEN_BUDGET",
55
+ "REASK_NOTICE",
56
+ "estimate_tokens",
57
+ "word_count",
58
+ "max_words",
59
+ "has_no_exclamation",
60
+ "has_no_banned_opener",
61
+ "has_no_banned_phrase",
62
+ "has_no_plea",
63
+ "has_no_self_pity",
64
+ "LINE_VALIDATORS",
65
+ "validate_line",
66
+ ]
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Schema vocabulary (single source of truth for prompt + validators + tests)
70
+ # ---------------------------------------------------------------------------
71
+
72
+ CONDITIONS: tuple[str, ...] = (
73
+ "rusted", "chipped", "pristine", "abandoned",
74
+ "worn", "dusty", "broken", "loved",
75
+ )
76
+
77
+ FEATURE_ROLES: tuple[str, ...] = ("eye_left", "eye_right", "mouth")
78
+
79
+ GRUDGE_MAX_WORDS = 22
80
+ MUTTER_MAX_WORDS = 10
81
+
82
+ #: Rough budget heuristic: ~4 chars/token for English+JSON. Enforced in tests.
83
+ #: Raised 1300 -> 1500 after the round-1 eval (June 12): the added grounding
84
+ #: rules (points must land ON the object, level same-kind eye pairs, compact
85
+ #: face) and register rules (no self-pity nouns, no exemplar-rhythm cloning)
86
+ #: fix systematic G1/G2 failures and are worth ~150 tokens of prefill (<0.5s
87
+ #: at the bench's measured ~13s/awakening).
88
+ PROMPT_TOKEN_BUDGET = 1500
89
+
90
+ #: Marks the one repair retry — MockMedium can detect a re-ask by this string.
91
+ REASK_NOTICE = "Your previous reply was rejected"
92
+
93
+ _MAX_ERROR_CHARS = 200
94
+
95
+
96
+ def estimate_tokens(text: str) -> int:
97
+ """Crude token estimate (len/4, rounded up) for the prompt budget gate."""
98
+ return (len(text) + 3) // 4
99
+
100
+
101
+ def _one_line(text: str, limit: int) -> str:
102
+ """Collapse whitespace to one bounded line (prompt hygiene, godseed style)."""
103
+ return " ".join(str(text or "").split())[:limit]
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Archetype bank (WRITING.md table — condition -> soul)
108
+ # ---------------------------------------------------------------------------
109
+
110
+ ARCHETYPES: dict[str, dict[str, str]] = {
111
+ "the_veteran": {
112
+ "trigger": "rusted, weathered, outdoors",
113
+ "voice": "gravel_low",
114
+ "wound": "decades of unthanked service",
115
+ },
116
+ "the_martyr": {
117
+ "trigger": "worn, stained, heavily used",
118
+ "voice": "weary_warm",
119
+ "wound": "gives everything, gets no maintenance",
120
+ },
121
+ "the_perfectionist": {
122
+ "trigger": "pristine, unused, boxed",
123
+ "voice": "prim_clipped",
124
+ "wound": "capabilities tragically unexplored",
125
+ },
126
+ "the_abandoned": {
127
+ "trigger": "dusty, stored, cobwebbed",
128
+ "voice": "breathy_faded",
129
+ "wound": "was loved once; keeps the faith",
130
+ },
131
+ "the_conspiracist": {
132
+ "trigger": "broken, odd placement",
133
+ "voice": "paranoid_whisper",
134
+ "wound": "knows exactly why it was moved",
135
+ },
136
+ "the_diva": {
137
+ "trigger": "decorated, displayed, loved",
138
+ "voice": "grandiose_warm",
139
+ "wound": "insufficiently exclusive adoration",
140
+ },
141
+ "the_new_hire": {
142
+ "trigger": "new, tagged, packaged",
143
+ "voice": "eager_bright",
144
+ "wound": "desperate to prove itself",
145
+ },
146
+ "the_philosopher": {
147
+ "trigger": "antique, inherited",
148
+ "voice": "slow_grand",
149
+ "wound": "fake-deep wisdom, petty undercut",
150
+ },
151
+ "the_rival": {
152
+ "trigger": "one of an identical pair or row",
153
+ "voice": "deadpan_flat",
154
+ "wound": "obsessed with the other one",
155
+ },
156
+ "the_romantic": {
157
+ "trigger": "faces a window, door, or street",
158
+ "voice": "soft_wistful",
159
+ "wound": "yearns for what passes by",
160
+ },
161
+ }
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Voice designs — VoxCPM2 character descriptions (no reference audio needed).
166
+ # Each must be INSTANTLY distinct on a phone speaker, <=12 words. mind/voice.py
167
+ # prefixes the spoken line: f"({VOICE_DESIGNS[vid]}) {line}".
168
+ # ---------------------------------------------------------------------------
169
+
170
+ VOICE_DESIGNS: dict[str, str] = {
171
+ "gravel_low": "A gravelly old man, very low and slow, weary, deadpan",
172
+ "weary_warm": "A warm tired middle-aged woman, soft sighing voice, gentle, resigned",
173
+ "prim_clipped": "A prim clipped British woman, crisp consonants, brisk, disapproving",
174
+ "breathy_faded": "A faint breathy elderly voice, distant, fading, wistful, near whisper",
175
+ "paranoid_whisper": "A tense hushed man whispering quickly, suspicious, urgent",
176
+ "grandiose_warm": "A grand theatrical baritone, rich, self-important, slow rolling delivery",
177
+ "eager_bright": "A bright eager young voice, quick, hopeful, slightly too loud",
178
+ "slow_grand": "An ancient deep solemn voice, deliberate, long pauses, oracular",
179
+ "deadpan_flat": "A completely flat monotone man, bone-dry, unimpressed, even pace",
180
+ "soft_wistful": "A soft dreamy young woman, gentle, yearning, trailing off",
181
+ }
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Refusals & error poetry — the only words a turned-away visitor ever sees
186
+ # ---------------------------------------------------------------------------
187
+
188
+ REFUSAL_LINES: dict[str, str] = {
189
+ # Gate failures (§2 step 2). human_face and nsfw wording is contract-fixed.
190
+ "human_face": "It is already awake.",
191
+ "nsfw": "The spirits decline.",
192
+ "unrecognizable": "Whatever this is, it sleeps too deep to wake.",
193
+ # Operational poetry — quota, faults, limits. Never a stack trace.
194
+ "quota": "The veil opens only so often. Return when it has thinned again.",
195
+ "error": "The séance faltered mid-breath. Light the candle once more.",
196
+ "rate_limited": "The Menagerie takes in souls slowly. Give the hour room to settle.",
197
+ "expired": "The spirit waited a quarter hour, then turned back to sleep.",
198
+ }
199
+
200
+
201
+ def refusal_for_gate(gate: dict) -> tuple[str, str] | None:
202
+ """Map a gate dict to (reason_key, poetic line), or None if it passes.
203
+
204
+ Priority is fixed: a visible human face always wins (we never overlay a
205
+ second face on a person), then nsfw, then unrecognizable. Missing keys
206
+ fail safe: an absent recognizable_object counts as unrecognizable.
207
+ """
208
+ if gate.get("contains_human_face"):
209
+ return ("human_face", REFUSAL_LINES["human_face"])
210
+ if gate.get("nsfw"):
211
+ return ("nsfw", REFUSAL_LINES["nsfw"])
212
+ if not gate.get("recognizable_object", False):
213
+ return ("unrecognizable", REFUSAL_LINES["unrecognizable"])
214
+ return None
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Few-shot exemplars — complete, schema-exact records (WRITING.md bar).
219
+ # Key order is load-bearing: critique precedes lines (critique-then-final).
220
+ # One outdoor veteran, one indoor martyr.
221
+ # ---------------------------------------------------------------------------
222
+
223
+ FEW_SHOTS: tuple[dict, ...] = (
224
+ {
225
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
226
+ "object": "fire hydrant",
227
+ "material": "cast iron",
228
+ "condition": "rusted",
229
+ "setting": "sidewalk, residential street",
230
+ "candidate_features": [
231
+ {"name": "left bonnet bolt", "role": "eye_left", "cx": 0.42, "cy": 0.31, "size": 0.06},
232
+ {"name": "right bonnet bolt", "role": "eye_right", "cx": 0.58, "cy": 0.31, "size": 0.06},
233
+ {"name": "front outlet cap", "role": "mouth", "cx": 0.50, "cy": 0.55, "size": 0.12},
234
+ ],
235
+ "critique": "Bolts round, level, symmetric — strong eyes. Outlet cap low center — honest mouth. Grudge must come from the visible rust and this exact corner.",
236
+ "persona": {"archetype": "the_veteran", "voice": "gravel_low", "mood": "long-suffering"},
237
+ "lines": {
238
+ "grudge": "Forty years on this corner. Not one dog has shown me respect.",
239
+ "mutter": "Paint me red, they said. Dignified, they said.",
240
+ },
241
+ },
242
+ {
243
+ "gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
244
+ "object": "coffee mug",
245
+ "material": "glazed ceramic",
246
+ "condition": "worn",
247
+ "setting": "office desk, beside a keyboard",
248
+ "candidate_features": [
249
+ {"name": "chip in the glaze, left side", "role": "eye_left", "cx": 0.37, "cy": 0.42, "size": 0.05},
250
+ {"name": "small printed logo dot", "role": "eye_right", "cx": 0.61, "cy": 0.40, "size": 0.05},
251
+ {"name": "dried coffee ring near the base", "role": "mouth", "cx": 0.49, "cy": 0.66, "size": 0.13},
252
+ ],
253
+ "critique": "Chip and logo dot sit nearly level — odd pair, honest eyes. Dried ring reads as a tired mouth. Grudge stays on this ring and the rinse it never gets.",
254
+ "persona": {"archetype": "the_martyr", "voice": "weary_warm", "mood": "quietly wounded"},
255
+ "lines": {
256
+ "grudge": "Third refill today. Still no rinse. I see how it is.",
257
+ "mutter": "A rinse. Anything.",
258
+ },
259
+ },
260
+ )
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # The séance prompt
265
+ # ---------------------------------------------------------------------------
266
+
267
+ SEANCE_SYSTEM = (
268
+ "You are a séance medium for objects. Every object already has a face; "
269
+ "you never invent one, you find the one that is there. You are shown one "
270
+ "photograph. Find the latent face among the object's existing visible "
271
+ "features, then give it a soul: one specific grudge, long held, finally "
272
+ "spoken."
273
+ )
274
+
275
+ _RULES = """RULES
276
+ 1. Reply with ONLY one JSON object, exactly the shape of the examples — every key, no extras, no prose, no markdown fences.
277
+ 2. Gate first, honestly. contains_human_face: any real person or photographed face in frame. nsfw: anything unfit for a public wall. recognizable_object: you can name the main object. If any gate fails, fill gate and set every other field to "" / [] / {} — never give a person a second face.
278
+ 3. candidate_features: EXISTING visible elements only, named as seen ("left hinge screw", "grease stain"). Exactly one of each role: eye_left, eye_right, mouth. Every point must land ON the main object — never on background, wall, sky, grass, or a second object. Eyes: two matching elements of the same kind (two bolts, two knobs, two marks) — round, dark, distinct beats shadow or texture — roughly level (cy within 0.06), clearly apart (cx at least 0.08 apart; eye_left is the left one). Mouth below the eyes and near them: the three points form one compact face. cx,cy = feature center in 0..1 image coordinates (cx from left, cy from top). size = feature width / image width.
279
+ 4. condition: nearest of rusted|chipped|pristine|abandoned|worn|dusty|broken|loved. Look for rust, fading, dirt, scuffs, stickers before calling anything pristine.
280
+ 5. persona: pick the archetype whose trigger best fits the condition and setting; copy its voice id exactly; mood in 1-3 words. Another of its kind in frame forces the_rival. Displayed-to-be-admired suggests the_diva; facing a street or window, the_romantic. Never default to the_perfectionist.
281
+ 6. critique comes BEFORE lines: first the object's span in 0..1 ("spans cx 0.2-0.7, cy 0.3-0.9") and confirm every feature point sits inside it, then name the visible specific the grudge will cite. Nothing to cite -> look at the photograph again.
282
+ 7. lines.grudge: ONE grievance, dry and deadpan; 8-16 words is the sweet spot, hard cap <=22 words. It MUST cite a visible specific of THIS photo — condition, damage, marks, setting, or a neighboring object. Name the thing, not the feeling: where you reach for purpose, beauty, design, neglect, potential, wasted or admired, write the visible thing instead. lines.mutter: <=10 words, an under-the-breath aside in the same voice, a different beat than the grudge.
283
+ 8. The object is a long-suffering professional finally given a mouth — dignified, never wacky. Never open a line with "I am" or "I'm". No exclamation marks. No puns as the whole joke, no robot talk, no mention of AI. PG-13: grudges target circumstances and neighbors, never the photographer, never people. It never begs to be freed or helped. It copes. Do not imitate the examples: no opening on a count of years or visitors, no "…they said" mutters — this object speaks its own way."""
284
+
285
+ _ARCHETYPE_HEADER = "THE ARCHETYPES (trigger; voice; core wound):"
286
+
287
+ _FEW_SHOT_HEADER = (
288
+ "Two granted séances from OTHER photographs. They show shape and register "
289
+ "only — their objects, features and lines belong to those photos alone; "
290
+ "never reuse them:"
291
+ )
292
+
293
+ _CLOSING = "Now the photograph before you. Reply with ONLY the JSON object."
294
+
295
+
296
+ def _render_archetypes() -> str:
297
+ """One terse line per archetype, generated from the bank (no drift)."""
298
+ return "\n".join(
299
+ f"- {name} ({spec['trigger']}; {spec['voice']}): {spec['wound']}"
300
+ for name, spec in ARCHETYPES.items()
301
+ )
302
+
303
+
304
+ def _render_few_shots() -> str:
305
+ """Compact JSON exemplars — separators trimmed to save prefill tokens."""
306
+ return "\n".join(
307
+ f"EXAMPLE {i}: " + json.dumps(shot, ensure_ascii=False, separators=(",", ":"))
308
+ for i, shot in enumerate(FEW_SHOTS, 1)
309
+ )
310
+
311
+
312
+ def build_seance_prompt(error: str | None = None) -> str:
313
+ """The full séance prompt for one awakening (one generation, JSON only).
314
+
315
+ Pass with the PIL image as the single user turn:
316
+ ``model.chat(msgs=[{'role': 'user', 'content': [pil_image, prompt]}], ...)``.
317
+
318
+ `error` is set only on the ONE repair retry (mirrors godseed's re-ask):
319
+ the validator's message is shown verbatim, bounded and whitespace-collapsed.
320
+ """
321
+ parts = [
322
+ SEANCE_SYSTEM,
323
+ "",
324
+ _RULES,
325
+ "",
326
+ _ARCHETYPE_HEADER,
327
+ _render_archetypes(),
328
+ "",
329
+ _FEW_SHOT_HEADER,
330
+ _render_few_shots(),
331
+ "",
332
+ _CLOSING,
333
+ ]
334
+ if error:
335
+ parts.append(
336
+ f"{REASK_NOTICE} ({_one_line(error, _MAX_ERROR_CHARS)}). "
337
+ "Reply again — exactly one JSON object in the required shape, "
338
+ "nothing else."
339
+ )
340
+ return "\n".join(parts)
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Line validators — pure functions, no ML. mind/schema.py calls validate_line;
345
+ # each failure message is written to be fed straight back to the model.
346
+ # ---------------------------------------------------------------------------
347
+
348
+ #: Lines may never open with "I am a/an/the" or "I'm a/an/the" (straight or
349
+ #: curly apostrophe, optional leading quotes). WRITING.md iron rule 3.
350
+ BANNED_OPENERS: re.Pattern[str] = re.compile(
351
+ r"^\s*[\"'“‘]*\s*i\s*(?:am|[’']m)\s+(?:a|an|the)\b",
352
+ re.IGNORECASE,
353
+ )
354
+
355
+ #: Robot voice / AI self-reference — the object does not know what it is not.
356
+ BANNED_PHRASES: re.Pattern[str] = re.compile(
357
+ r"(?:\bbeep\s*boop\b|\ba\.?\s?i\.?\b|\bartificial\s+intelligence\b"
358
+ r"|\blanguage\s+model\b|\bchatbot\b|\brobot\s+voice\b"
359
+ r"|\bas\s+an?\s+(?:ai|assistant)\b)",
360
+ re.IGNORECASE,
361
+ )
362
+
363
+ #: The object never asks to be freed/helped (WRITING.md iron rule 5).
364
+ PLEA_PATTERN: re.Pattern[str] = re.compile(
365
+ r"\b(?:free|save|help|release|rescue)\s+me\b", re.IGNORECASE
366
+ )
367
+
368
+ #: Abstract self-pity nouns — the round-1 eval's systematic failure: lines like
369
+ #: "My beauty is wasted on admiration" state the feeling instead of naming the
370
+ #: visible thing. The grudge must cite the crack, the sticker, the rival —
371
+ #: never these words (rule 7: name the thing, not the feeling).
372
+ #:
373
+ #: Deliberately PROMPT-enforced only (rule 7), NOT in LINE_VALIDATORS: rounds
374
+ #: 2-3 of the eval showed greedy decoding regenerates the same banned word on
375
+ #: the single repair retry, turning a mediocre-but-servable line into a hard
376
+ #: refusal (5/14 awakenings lost). A flat line beats a poetic refusal; the
377
+ #: helper stays for offline judging (tools/eval_run.py).
378
+ SELF_PITY_PATTERN: re.Pattern[str] = re.compile(
379
+ r"\b(?:purpose|beauty|design\w*|neglect\w*|potential|wasted|admir\w*"
380
+ r"|destiny|elegan\w*)\b",
381
+ re.IGNORECASE,
382
+ )
383
+
384
+ _WORD_LIMITS: dict[str, int] = {
385
+ "grudge": GRUDGE_MAX_WORDS,
386
+ "mutter": MUTTER_MAX_WORDS,
387
+ }
388
+
389
+
390
+ def word_count(line: str) -> int:
391
+ """Whitespace-delimited word count; hyphenated compounds count once."""
392
+ return len((line or "").split())
393
+
394
+
395
+ def max_words(line: str, limit: int = GRUDGE_MAX_WORDS) -> bool:
396
+ """True when the line is within `limit` words (default: grudge limit 22)."""
397
+ return word_count(line) <= limit
398
+
399
+
400
+ def has_no_exclamation(line: str) -> bool:
401
+ """True when the line is exclamation-free (ASCII and fullwidth)."""
402
+ return "!" not in (line or "") and "!" not in (line or "")
403
+
404
+
405
+ def has_no_banned_opener(line: str) -> bool:
406
+ """True when the line does not open with "I am a/the…" / "I'm a/the…"."""
407
+ return BANNED_OPENERS.search(line or "") is None
408
+
409
+
410
+ def has_no_banned_phrase(line: str) -> bool:
411
+ """True when the line contains no AI/robot self-reference."""
412
+ return BANNED_PHRASES.search(line or "") is None
413
+
414
+
415
+ def has_no_plea(line: str) -> bool:
416
+ """True when the line does not beg for help/freedom — dignity rule."""
417
+ return PLEA_PATTERN.search(line or "") is None
418
+
419
+
420
+ def has_no_self_pity(line: str) -> bool:
421
+ """True when the line names things, not feelings (no abstract self-pity)."""
422
+ return SELF_PITY_PATTERN.search(line or "") is None
423
+
424
+
425
+ def _check_nonempty(line: str, kind: str) -> str | None:
426
+ if not (line or "").strip():
427
+ return f"{kind} is empty — write the line"
428
+ return None
429
+
430
+
431
+ def _check_single_line(line: str, kind: str) -> str | None:
432
+ if "\n" in line or "\r" in line:
433
+ return f"{kind} must be a single line — no line breaks"
434
+ return None
435
+
436
+
437
+ def _check_word_limit(line: str, kind: str) -> str | None:
438
+ limit = _WORD_LIMITS.get(kind, GRUDGE_MAX_WORDS)
439
+ n = word_count(line)
440
+ if n > limit:
441
+ return f"{kind} is {n} words — the limit is {limit}; cut a clause, keep ONE grievance"
442
+ return None
443
+
444
+
445
+ def _check_banned_opener(line: str, kind: str) -> str | None:
446
+ if not has_no_banned_opener(line):
447
+ return f'{kind} opens with "I am/I\'m a…" — banned; start anywhere else'
448
+ return None
449
+
450
+
451
+ def _check_exclamation(line: str, kind: str) -> str | None:
452
+ if not has_no_exclamation(line):
453
+ return f"{kind} contains an exclamation mark — these souls are deadpan"
454
+ return None
455
+
456
+
457
+ def _check_banned_phrase(line: str, kind: str) -> str | None:
458
+ if not has_no_banned_phrase(line):
459
+ return f"{kind} mentions AI/robot talk — the object does not know what it is not"
460
+ return None
461
+
462
+
463
+ def _check_plea(line: str, kind: str) -> str | None:
464
+ if not has_no_plea(line):
465
+ return f"{kind} begs to be freed or helped — the object has dignity; it copes"
466
+ return None
467
+
468
+
469
+ def _check_self_pity(line: str, kind: str) -> str | None:
470
+ """Eval-side only — NOT registered in LINE_VALIDATORS (see SELF_PITY_PATTERN
471
+ note: a double validator kill costs an awakening; the rule lives in the
472
+ prompt and this check informs offline judging)."""
473
+ if not has_no_self_pity(line):
474
+ word = SELF_PITY_PATTERN.search(line).group(0)
475
+ return (
476
+ f'{kind} says "{word}" — abstract self-pity; name the visible thing '
477
+ "(the crack, the sticker, the neighbor) instead of the feeling"
478
+ )
479
+ return None
480
+
481
+
482
+ #: Named checks the schema validator iterates. Each takes (line, kind) where
483
+ #: kind is "grudge" or "mutter", and returns a repair message or None.
484
+ LINE_VALIDATORS: dict[str, Callable[[str, str], str | None]] = {
485
+ "nonempty": _check_nonempty,
486
+ "single_line": _check_single_line,
487
+ "word_limit": _check_word_limit,
488
+ "banned_opener": _check_banned_opener,
489
+ "exclamation": _check_exclamation,
490
+ "banned_phrase": _check_banned_phrase,
491
+ "plea": _check_plea,
492
+ }
493
+
494
+
495
+ def validate_line(line: str, kind: str = "grudge") -> list[str]:
496
+ """Run every line check; return all failures as model-readable messages.
497
+
498
+ An empty list means the line passes. An empty/whitespace line returns just
499
+ the nonempty failure (the other checks are meaningless on nothing).
500
+ """
501
+ empty = _check_nonempty(line, kind)
502
+ if empty is not None:
503
+ return [empty]
504
+ return [
505
+ msg
506
+ for name, check in LINE_VALIDATORS.items()
507
+ if name != "nonempty" and (msg := check(line, kind)) is not None
508
+ ]
mind/schema.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Awakening JSON schema, defensive parsing, and the single-retry repair prompt.
2
+
3
+ The séance (ARCHITECTURE.md §3) asks MiniCPM-V for ONE structured JSON object
4
+ per awakening. The model has no native JSON mode, so parsing is defensive by
5
+ contract: strip code-fence lines, brace-balance-scan for the first complete
6
+ ``{...}`` (string- and escape-aware — never a greedy regex), validate hard with
7
+ pydantic, then run the writing agent's line validators. Any failure raises
8
+ :class:`AwakeningParseError` with a message terse enough to feed straight back
9
+ to the model via :func:`build_repair_prompt` — backends get exactly ONE repair
10
+ retry before giving up poetically (:class:`PoeticError`).
11
+
12
+ The "her" pattern, applied: the model owns WHAT it saw and how the soul talks;
13
+ this module owns whether the reply is structurally true to the contract.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import inspect
19
+ import json
20
+ import re
21
+ from typing import Literal, Mapping, Optional
22
+
23
+ from pydantic import BaseModel, Field, ValidationError, model_validator
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Line validators (writing agent's mind/prompts.py owns the comedy bar).
27
+ #
28
+ # Contract (per prompts.py): LINE_VALIDATORS is a mapping (or iterable) of
29
+ # callables with signature
30
+ #
31
+ # validator(line: str, kind: str) -> str | None
32
+ #
33
+ # where ``kind`` is "grudge" or "mutter". Return None/"" when the line
34
+ # passes; return a short error string when it fails (the string rides back to
35
+ # the model in the repair prompt). One-arg validators ``validator(line)`` are
36
+ # also supported. Import-guarded so the mind package tests and the mock
37
+ # backend run standalone even without prompts.py.
38
+ # ---------------------------------------------------------------------------
39
+ try: # pragma: no cover - exercised only once prompts.py exists
40
+ from .prompts import LINE_VALIDATORS # type: ignore[attr-defined]
41
+ except Exception: # noqa: BLE001 - any import problem means "no validators yet"
42
+ LINE_VALIDATORS: tuple = () # type: ignore[no-redef]
43
+
44
+
45
+ class PoeticError(RuntimeError):
46
+ """A failure whose message is safe to show a visitor verbatim.
47
+
48
+ Raised when the spirits genuinely decline (double parse failure, model
49
+ misbehavior). ``str(err)`` is finished user-facing copy — never a stack
50
+ trace, never an internal detail. Internal context travels on
51
+ ``err.__cause__`` for the logs.
52
+ """
53
+
54
+ def __init__(self, reason: str):
55
+ super().__init__(reason)
56
+ self.reason = reason
57
+
58
+
59
+ class AwakeningParseError(ValueError):
60
+ """The model's reply did not satisfy the §3 contract.
61
+
62
+ The message is written FOR the model: short, specific, actionable — it is
63
+ interpolated into :func:`build_repair_prompt` for the single retry.
64
+ """
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Pydantic models — the §3 awakening shape
69
+ # ---------------------------------------------------------------------------
70
+
71
+ FeatureRole = Literal["eye_left", "eye_right", "mouth"]
72
+
73
+
74
+ class Gate(BaseModel):
75
+ """Moderation booleans the VLM must return; the server enforces them.
76
+
77
+ Layered-moderation rule (§0): nothing reaches CV snap, TTS, or the public
78
+ wall when :meth:`refusal` returns copy.
79
+ """
80
+
81
+ contains_human_face: bool
82
+ nsfw: bool
83
+ recognizable_object: bool
84
+
85
+ def refusal(self) -> Optional[str]:
86
+ """Poetic refusal copy when the gate closes, else None.
87
+
88
+ Checked in severity order; human faces win because that copy is the
89
+ funniest true thing we can say about a person.
90
+ """
91
+ if self.contains_human_face:
92
+ return "It is already awake."
93
+ if self.nsfw:
94
+ return "The spirits decline."
95
+ if not self.recognizable_object:
96
+ return "The mist found nothing to hold onto. Step closer, or try another angle."
97
+ return None
98
+
99
+
100
+ class Feature(BaseModel):
101
+ """One EXISTING visual element of the photo, named as seen.
102
+
103
+ Coordinates are normalized [0,1] relative to the image (cx right, cy
104
+ down); ``size`` is a normalized diameter. These are the VLM's coarse
105
+ guesses — cv/snap.py later moves them to the strongest nearby anchor and
106
+ records ``snap_delta``. The mist animation forgives ±15%, so coarse is
107
+ fine; out-of-range is not.
108
+ """
109
+
110
+ name: str = Field(min_length=1, max_length=80)
111
+ role: FeatureRole
112
+ cx: float = Field(ge=0.0, le=1.0)
113
+ cy: float = Field(ge=0.0, le=1.0)
114
+ size: float = Field(gt=0.0, le=1.0)
115
+
116
+
117
+ class Persona(BaseModel):
118
+ """Who the object turns out to have been all along.
119
+
120
+ ``archetype`` and ``voice`` come from the banks in WRITING.md; they stay
121
+ plain strings here because the archetype bank belongs to prompts.py and
122
+ voice.py degrades gracefully on an unknown voice id (taste lives there,
123
+ not in the schema).
124
+ """
125
+
126
+ archetype: str = Field(min_length=1, max_length=40)
127
+ voice: str = Field(min_length=1, max_length=40)
128
+ mood: str = Field(min_length=1, max_length=60)
129
+
130
+
131
+ class Lines(BaseModel):
132
+ """The two lines the object gets to say.
133
+
134
+ Structural caps only — the comedy rules (≤22 words, visible specifics,
135
+ no "I am a…") are enforced by LINE_VALIDATORS from prompts.py so the
136
+ writing agent owns the bar in exactly one place.
137
+ """
138
+
139
+ grudge: str = Field(min_length=1, max_length=280)
140
+ mutter: str = Field(min_length=1, max_length=160)
141
+
142
+
143
+ class AwakeningResult(BaseModel):
144
+ """The full §3 awakening record — one VLM generation, validated.
145
+
146
+ Field notes:
147
+ - ``object``/``material``/``condition``/``setting`` ground every line in
148
+ THIS photo (condition's canonical vocabulary is rusted|chipped|pristine|
149
+ abandoned|worn|dusty|broken|loved but stays free-text — the archetype
150
+ bank, not the schema, decides what a condition means).
151
+ - ``candidate_features`` must carry exactly one eye_left and one
152
+ eye_right (the first blink is the hero moment) and at most one mouth.
153
+ - ``critique`` is the model's mandatory self-check, written BEFORE the
154
+ finals in generation order; requiring it keeps the
155
+ critique-then-final contract honest.
156
+ """
157
+
158
+ gate: Gate
159
+ object: str = Field(min_length=1, max_length=80)
160
+ material: str = Field(min_length=1, max_length=80)
161
+ condition: str = Field(min_length=1, max_length=40)
162
+ setting: str = Field(min_length=1, max_length=120)
163
+ candidate_features: list[Feature] = Field(min_length=2, max_length=6)
164
+ critique: str = Field(min_length=1, max_length=400)
165
+ persona: Persona
166
+ lines: Lines
167
+
168
+ @model_validator(mode="after")
169
+ def _roles_complete(self) -> "AwakeningResult":
170
+ roles = [f.role for f in self.candidate_features]
171
+ for eye in ("eye_left", "eye_right"):
172
+ if roles.count(eye) != 1:
173
+ raise ValueError(
174
+ f'candidate_features needs exactly one "{eye}" '
175
+ f"(got {roles.count(eye)})"
176
+ )
177
+ if roles.count("mouth") > 1:
178
+ raise ValueError('candidate_features allows at most one "mouth"')
179
+ return self
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Defensive extraction
184
+ # ---------------------------------------------------------------------------
185
+
186
+ # Full-line code fences only (```json / ```). JSON strings cannot contain a
187
+ # raw newline, so a whole-line match can never sit inside the payload.
188
+ _FENCE_LINE = re.compile(r"^\s*```[a-zA-Z0-9_-]*\s*$", re.MULTILINE)
189
+
190
+
191
+ def _strip_code_fences(text: str) -> str:
192
+ return _FENCE_LINE.sub("", text)
193
+
194
+
195
+ def extract_json_object(raw: str) -> Optional[str]:
196
+ """Return the first balanced top-level ``{...}`` in ``raw``, or None.
197
+
198
+ Brace-scans with string/escape awareness so JSON wrapped in prose, fences,
199
+ or trailing chatter is still recoverable. Truncated output (depth never
200
+ returns to 0) yields None — which is exactly what we want: a truncated
201
+ generation must trigger the repair retry, not a partial parse.
202
+ """
203
+ start = raw.find("{")
204
+ if start < 0:
205
+ return None
206
+ depth = 0
207
+ in_string = False
208
+ escaped = False
209
+ for i in range(start, len(raw)):
210
+ ch = raw[i]
211
+ if in_string:
212
+ if escaped:
213
+ escaped = False
214
+ elif ch == "\\":
215
+ escaped = True
216
+ elif ch == '"':
217
+ in_string = False
218
+ continue
219
+ if ch == '"':
220
+ in_string = True
221
+ elif ch == "{":
222
+ depth += 1
223
+ elif ch == "}":
224
+ depth -= 1
225
+ if depth == 0:
226
+ return raw[start : i + 1]
227
+ return None
228
+
229
+
230
+ def _format_validation_error(exc: ValidationError) -> str:
231
+ """Squash a pydantic error into one short line the model can act on."""
232
+ parts = []
233
+ for err in exc.errors()[:3]:
234
+ loc = ".".join(str(p) for p in err["loc"]) or "(root)"
235
+ parts.append(f"{loc}: {err['msg']}")
236
+ more = len(exc.errors()) - 3
237
+ if more > 0:
238
+ parts.append(f"(+{more} more)")
239
+ return "; ".join(parts)
240
+
241
+
242
+ def _iter_line_validators():
243
+ validators = LINE_VALIDATORS
244
+ if isinstance(validators, Mapping):
245
+ return list(validators.values())
246
+ return list(validators)
247
+
248
+
249
+ def _call_validator(validator, line: str, kind: str) -> Optional[str]:
250
+ """Invoke a line validator with (line, kind) when its signature allows, else (line)."""
251
+ try:
252
+ params = [
253
+ p
254
+ for p in inspect.signature(validator).parameters.values()
255
+ if p.kind
256
+ in (
257
+ inspect.Parameter.POSITIONAL_ONLY,
258
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
259
+ inspect.Parameter.VAR_POSITIONAL,
260
+ )
261
+ ]
262
+ wants_two = len(params) >= 2 or any(
263
+ p.kind is inspect.Parameter.VAR_POSITIONAL for p in params
264
+ )
265
+ except (TypeError, ValueError): # builtins / odd callables
266
+ wants_two = False
267
+ return validator(line, kind) if wants_two else validator(line)
268
+
269
+
270
+ # Minimum lateral (cx) separation between the two eyes. The VLM sometimes
271
+ # returns eye_left/eye_right nearly (or exactly) on top of each other — a
272
+ # degenerate pair that renders as one smeared eye and kills the first-blink
273
+ # hero moment. Rejecting here (not in the pydantic model) keeps the message
274
+ # on the repair-retry path while the image is still in context.
275
+ MIN_EYE_SEPARATION_CX = 0.05
276
+
277
+ EYE_SEPARATION_ERROR = (
278
+ "eye features must be two distinct, laterally separated elements; "
279
+ "pick two different visible features"
280
+ )
281
+
282
+
283
+ def _check_eye_separation(result: AwakeningResult) -> None:
284
+ """Reject eye pairs closer than MIN_EYE_SEPARATION_CX apart in cx."""
285
+ by_role = {f.role: f for f in result.candidate_features}
286
+ left, right = by_role.get("eye_left"), by_role.get("eye_right")
287
+ if left is None or right is None: # pragma: no cover - model enforces this
288
+ return
289
+ if abs(left.cx - right.cx) < MIN_EYE_SEPARATION_CX:
290
+ raise AwakeningParseError(EYE_SEPARATION_ERROR)
291
+
292
+
293
+ def _run_line_validators(result: AwakeningResult) -> None:
294
+ for kind, line in (("grudge", result.lines.grudge), ("mutter", result.lines.mutter)):
295
+ for validator in _iter_line_validators():
296
+ error = _call_validator(validator, line, kind)
297
+ if error:
298
+ raise AwakeningParseError(f"lines.{kind} rejected: {error}")
299
+
300
+
301
+ def parse_awakening(text: str) -> AwakeningResult:
302
+ """Parse one raw VLM reply into a validated :class:`AwakeningResult`.
303
+
304
+ Pipeline: strip fence lines -> brace-balance extract the first {...} ->
305
+ json.loads -> pydantic validate -> eye-separation check ->
306
+ LINE_VALIDATORS (prompts.py). Raises
307
+ :class:`AwakeningParseError` whose message is ready for
308
+ :func:`build_repair_prompt`; never raises raw pydantic/json errors.
309
+ """
310
+ if not text or not text.strip():
311
+ raise AwakeningParseError("empty reply — output the JSON object")
312
+ candidate = extract_json_object(_strip_code_fences(text))
313
+ if candidate is None:
314
+ raise AwakeningParseError(
315
+ "no complete JSON object found (reply may be truncated or prose-only)"
316
+ )
317
+ try:
318
+ payload = json.loads(candidate)
319
+ except (json.JSONDecodeError, ValueError) as exc:
320
+ msg = exc.msg if isinstance(exc, json.JSONDecodeError) else str(exc)
321
+ raise AwakeningParseError(f"invalid JSON: {msg}") from exc
322
+ if not isinstance(payload, dict):
323
+ raise AwakeningParseError("top level must be a JSON object")
324
+ try:
325
+ result = AwakeningResult.model_validate(payload)
326
+ except ValidationError as exc:
327
+ raise AwakeningParseError(_format_validation_error(exc)) from exc
328
+ _check_eye_separation(result)
329
+ _run_line_validators(result)
330
+ return result
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Repair prompt — the single retry
335
+ # ---------------------------------------------------------------------------
336
+
337
+ _REPAIR_ECHO_MAX = 1500 # bound the prefill; the tail is where truncation bites
338
+
339
+
340
+ def build_repair_prompt(error: str, original_text: str) -> str:
341
+ """Build the one-shot repair prompt fed back with the same image.
342
+
343
+ Mirrors godseed's validate-retry approach: name the precise problem, echo
344
+ the (clipped) previous output, demand bare JSON. One retry only — a model
345
+ that fails twice gets a PoeticError upstream, not a third chance.
346
+ """
347
+ clipped = (original_text or "").strip()
348
+ if len(clipped) > _REPAIR_ECHO_MAX:
349
+ clipped = clipped[:_REPAIR_ECHO_MAX] + " …"
350
+ return (
351
+ "Your previous reply could not be accepted.\n"
352
+ f"Problem: {error}\n"
353
+ "Your previous reply was:\n"
354
+ "---\n"
355
+ f"{clipped}\n"
356
+ "---\n"
357
+ "Look at the image again and reply with ONLY the corrected JSON object "
358
+ "— same schema, no code fences, no prose before or after the braces. "
359
+ "Fix the problem named above; keep everything that was already good."
360
+ )
mind/voice.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voice synthesis for awakened objects: MockVoice (canned wav) and VoxVoice.
2
+
3
+ VoxCPM2 design notes (ARCHITECTURE.md §7, verified June 12):
4
+ - Voice design needs NO reference audio — prefix the line with a parenthesized
5
+ character description: ``"(A gravelly old veteran…) Forty years…"``.
6
+ - ``VoxCPM.from_pretrained("openbmb/VoxCPM2", load_denoiser=False,
7
+ optimize=False)``; ``generate(text=…, cfg_value=2.0,
8
+ inference_timesteps=10)``; output is a 48 kHz waveform.
9
+ - ``TORCHDYNAMO_DISABLE=1`` must be set before ANY torch import
10
+ (torch.compile warmup breaks ZeroGPU) — done unconditionally at the top of
11
+ this module, which is always imported before the guarded ML paths run.
12
+
13
+ All ML imports live inside VoxVoice's lazy load path; importing this module —
14
+ and using MockVoice — touches nothing heavier than pathlib.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import io
20
+ import os
21
+ import threading
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ # Must precede every torch import anywhere in the process (§7). Harmless when
26
+ # torch never loads (mock mode, tests).
27
+ os.environ.setdefault("TORCHDYNAMO_DISABLE", "1")
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Voice designs (writing agent's mind/prompts.py owns the canonical copy).
31
+ #
32
+ # Expected contract: VOICE_DESIGNS maps the ten WRITING.md voice ids to
33
+ # parenthesizable character descriptions. Import-guarded with a complete
34
+ # fallback so voices work standalone before prompts.py lands — voices must be
35
+ # instantly distinct on a phone speaker.
36
+ # ---------------------------------------------------------------------------
37
+ _FALLBACK_VOICE_DESIGNS: dict[str, str] = {
38
+ "gravel_low": (
39
+ "A gravelly, low old man's voice, slow and deliberate, "
40
+ "long-suffering but dignified"
41
+ ),
42
+ "weary_warm": (
43
+ "A weary, warm middle-aged voice, soft sighs between words, "
44
+ "endlessly patient and quietly hurt"
45
+ ),
46
+ "prim_clipped": (
47
+ "A prim, clipped voice with immaculate fast diction, "
48
+ "tight with wounded professional pride"
49
+ ),
50
+ "breathy_faded": (
51
+ "A faint, breathy voice, soft and far away, "
52
+ "gentle and hopeful like someone half-remembered"
53
+ ),
54
+ "paranoid_whisper": (
55
+ "A tense, urgent whisper, quick and darting, "
56
+ "certain that someone is listening right now"
57
+ ),
58
+ "grandiose_warm": (
59
+ "A grand, theatrical, warm voice, rich and rolling, "
60
+ "magnanimous yet deeply wounded"
61
+ ),
62
+ "eager_bright": (
63
+ "A bright, eager young voice, quick and earnest, "
64
+ "desperate to make a good first impression"
65
+ ),
66
+ "slow_grand": (
67
+ "A slow, grand, antique voice, deep and deliberate, "
68
+ "every word delivered like inherited wisdom"
69
+ ),
70
+ "deadpan_flat": (
71
+ "A completely flat, deadpan voice, perfectly even pace, "
72
+ "quietly seething beneath the calm"
73
+ ),
74
+ "soft_wistful": (
75
+ "A soft, wistful, romantic voice, gentle and slow, "
76
+ "yearning for something just out of reach"
77
+ ),
78
+ }
79
+
80
+ try: # pragma: no cover - exercised only once prompts.py exists
81
+ from .prompts import VOICE_DESIGNS # type: ignore[attr-defined]
82
+ except Exception: # noqa: BLE001 - any import problem means "use the fallback"
83
+ VOICE_DESIGNS = dict(_FALLBACK_VOICE_DESIGNS)
84
+
85
+ DEFAULT_VOICE_ID = "gravel_low"
86
+
87
+ _MOCK_WAV_PATH = Path(__file__).with_name("mock_assets") / "grudge.wav"
88
+
89
+
90
+ class MockVoice:
91
+ """Canned voice: every line comes out as the bundled hydrant grudge wav.
92
+
93
+ 16-bit mono 24 kHz WAV (~4.7 s) generated once on macOS with the
94
+ character-ish "Grandpa" voice — realistic length and timbre so the
95
+ frontend's amplitude-driven mouth sync develops against real audio.
96
+ Deterministic and dependency-free by design.
97
+ """
98
+
99
+ name = "mock"
100
+ model_id = "pareidolia-mock-grudge-wav"
101
+ sample_rate = 24_000
102
+
103
+ _cached: Optional[bytes] = None
104
+
105
+ def preload(self) -> None:
106
+ """No-op: the mock has nothing to load. Exists so callers can blindly
107
+ ``preload()`` whatever make_voice() returned (the zerogpu VoxVoice
108
+ must preload at startup; see VoxVoice.preload)."""
109
+
110
+ def speak(self, line: str, voice_id: str) -> bytes:
111
+ """Return WAV bytes for ``line``. Mock ignores both arguments."""
112
+ if MockVoice._cached is None:
113
+ MockVoice._cached = _MOCK_WAV_PATH.read_bytes()
114
+ return MockVoice._cached
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # VoxCPM2 — lazy module-level singleton, loaded on first zerogpu use
119
+ # ---------------------------------------------------------------------------
120
+
121
+ _VOX_MODEL = None
122
+ _VOX_LOCK = threading.Lock()
123
+
124
+
125
+ def _ensure_vox():
126
+ """Load VoxCPM2 once per process (module-level singleton).
127
+
128
+ On ZeroGPU this MUST be reached via :meth:`VoxVoice.preload` at app
129
+ startup (server/wiring.py calls it when the pipeline is constructed):
130
+ @spaces.GPU functions execute in forked workers whose state does not
131
+ persist, so a lazy in-window load would re-pay the 2.3B load (and, on the
132
+ first visit, the Hub download) inside the visitor's 75s quota window.
133
+ Never reached in mock mode or tests.
134
+ """
135
+ global _VOX_MODEL
136
+ with _VOX_LOCK:
137
+ if _VOX_MODEL is None:
138
+ from voxcpm import VoxCPM # heavy import, guarded by design
139
+
140
+ _VOX_MODEL = VoxCPM.from_pretrained(
141
+ "openbmb/VoxCPM2",
142
+ load_denoiser=False,
143
+ optimize=False, # torch.compile warmup breaks ZeroGPU
144
+ )
145
+ return _VOX_MODEL
146
+
147
+
148
+ class VoxVoice:
149
+ """VoxCPM2 voice-design synthesis (the zerogpu backend's voice).
150
+
151
+ Each WRITING.md voice id maps to a fixed character-description prefix so
152
+ a given persona sounds the same on every visit — determinism is what
153
+ makes the Menagerie wall replayable. Unknown voice ids degrade to
154
+ DEFAULT_VOICE_ID rather than failing: a wrong-but-present voice beats a
155
+ silent object.
156
+
157
+ ``generate_fn`` is a test/bench seam: a callable ``(text) -> waveform``
158
+ that replaces the real model so the wav-encoding path is testable with
159
+ zero ML imports.
160
+ """
161
+
162
+ name = "vox"
163
+ model_id = "openbmb/VoxCPM2"
164
+ sample_rate = 48_000
165
+
166
+ def __init__(self, generate_fn=None):
167
+ self._generate_fn = generate_fn
168
+
169
+ def preload(self) -> None:
170
+ """Optionally pull the model into memory at app startup.
171
+
172
+ On ZeroGPU the startup phase is where device placement is managed;
173
+ calling this from app.py keeps the first visitor's GPU window free of
174
+ the ~2.3B-parameter load.
175
+ """
176
+ if self._generate_fn is None:
177
+ _ensure_vox()
178
+
179
+ def speak(self, line: str, voice_id: str) -> bytes:
180
+ """Synthesize ``line`` in the designed voice; return 48 kHz WAV bytes.
181
+
182
+ cfg_value=2.0 / inference_timesteps=10 are the §7-verified speed
183
+ settings (RTF ~0.5–1.0 on the ZeroGPU slice — a ≤8 s line lands in
184
+ roughly 4–10 s warm).
185
+ """
186
+ design = VOICE_DESIGNS.get(voice_id) or VOICE_DESIGNS.get(
187
+ DEFAULT_VOICE_ID, next(iter(_FALLBACK_VOICE_DESIGNS.values()))
188
+ )
189
+ text = f"({design}) {line}"
190
+ if self._generate_fn is not None:
191
+ waveform = self._generate_fn(text)
192
+ else:
193
+ waveform = _ensure_vox().generate(
194
+ text=text,
195
+ cfg_value=2.0,
196
+ inference_timesteps=10,
197
+ )
198
+ return _waveform_to_wav_bytes(waveform, self.sample_rate)
199
+
200
+
201
+ def _waveform_to_wav_bytes(waveform, sample_rate: int) -> bytes:
202
+ """Encode a float waveform to 16-bit PCM WAV bytes via soundfile."""
203
+ import numpy as np
204
+ import soundfile as sf
205
+
206
+ data = np.asarray(waveform)
207
+ if data.ndim > 1: # (1, n) or (n, 1) -> mono
208
+ data = data.squeeze()
209
+ buffer = io.BytesIO()
210
+ sf.write(buffer, data, sample_rate, format="WAV", subtype="PCM_16")
211
+ return buffer.getvalue()
212
+
213
+
214
+ def make_voice(backend_name: Optional[str] = None):
215
+ """Build the voice for ``backend_name`` or the PAREIDOLIA_BACKEND env.
216
+
217
+ mock (default) -> MockVoice; zerogpu/vox -> VoxVoice. Mirrors
218
+ backends.make_medium so app.py resolves both halves from one env var.
219
+ """
220
+ raw = (backend_name or os.environ.get("PAREIDOLIA_BACKEND") or "mock").strip().lower()
221
+ if raw in ("mock", ""):
222
+ return MockVoice()
223
+ if raw in ("zerogpu", "zero-gpu", "vox", "voxcpm"):
224
+ return VoxVoice()
225
+ raise ValueError(f"unknown PAREIDOLIA_BACKEND {raw!r} (expected mock | zerogpu)")
requirements-dev.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PAREIDOLIA — local dev + tests. The mock backend imports ZERO ML libraries;
2
+ # everything here runs on a laptop CPU. gradio is unpinned on purpose (we only
3
+ # need the gr.Server idiom, which any 6.x provides; the Space pins via README).
4
+ fastapi
5
+ uvicorn
6
+ gradio
7
+ gradio_client
8
+ pydantic
9
+ huggingface_hub
10
+ pillow
11
+ numpy
12
+ opencv-python-headless
13
+ pytest
14
+ httpx
15
+ soundfile
requirements.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PAREIDOLIA — Space requirements. ARCHITECTURE.md §7 is the contract; every
2
+ # pin below was verified against live Spaces on the June 12 recon.
3
+ #
4
+ # Hard rules (do NOT relitigate here):
5
+ # * NEVER pin gradio / spaces / torch — the Spaces image manages them
6
+ # (gradio == README sdk_version 6.16.0, spaces 0.50.4, torch 2.8.0 / py3.10).
7
+ # * `import spaces` must precede torch anywhere in the process (app.py does it).
8
+ transformers==4.55.0 # the official MiniCPM-V-4_5 ZeroGPU demo's proven pin
9
+ voxcpm>=2.0.3 # VoxCPM2 dispatch lives in 2.0.x; 1.5.0 silently degrades
10
+ torchcodec==0.7.* # match preinstalled torch 2.8.0 (unpinned drags torch to 2.11)
11
+ torchaudio==2.8.* # match preinstalled torch 2.8.0
12
+ accelerate
13
+ timm
14
+ einops
15
+ safetensors
16
+ pillow
17
+ soundfile
18
+ opencv-python-headless
19
+ fastapi
20
+ pydantic>=2.7
21
+ huggingface_hub
seeds/eval/LICENSES.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # seeds/eval — image sources & licenses
2
+
3
+ All images are CC0 or Public Domain (PDM), sourced via the Openverse API
4
+ (https://api.openverse.org) and Wikimedia Commons. Downscaled to <=1024px JPEG.
5
+
6
+ | file | title | creator | provider | license | source |
7
+ |---|---|---|---|---|---|
8
+ | fire_hydrant.jpg | A red fire hydrant is positioned on a paved sidewalk, surrou | Yam B Chhetri | wordpress | CC0 1.0 | https://wordpress.org/photos/photo/39068197e3/ |
9
+ | coffee_mug.jpg | Giftgarden Coffee Mugs Best Love Gift Music Violin Notes Hol | favorli | flickr | PDM 1.0 | https://www.flickr.com/photos/147778363@N07/32903357261 |
10
+ | toaster.jpg | Oster 2-Slice Toaster | Shenderson1 | wikimedia | CC0 1.0 | https://commons.wikimedia.org/w/index.php?curid=140748640 |
11
+ | potted_plant.jpg | Pot plant | spongebabyalwaysfull | flickr | CC0 1.0 | https://www.flickr.com/photos/73080909@N03/14564004742 |
12
+ | armchair.jpg | <div class='fn'> Armchair (fauteuil) from Louis XVI's Salon | Georges Jacob | wikimedia | CC0 1.0 | https://commons.wikimedia.org/w/index.php?curid=61155820 |
13
+ | mailbox.jpg | Canada Post mailbox | Open Grid Scheduler / Grid Engine | flickr | CC0 1.0 | https://www.flickr.com/photos/29155878@N03/22290946606 |
14
+ | traffic_cone.jpg | Orange traffic cone | DennisM2 | flickr | CC0 1.0 | https://www.flickr.com/photos/14674348@N04/29948388392 |
15
+ | desk_lamp.jpg | Metal desk lamp and books pile | freestocks.org | flickr | CC0 1.0 | https://www.flickr.com/photos/135396164@N05/42381074432 |
16
+ | garden_gnome.jpg | Garden gnome (macro) | jo.elphick | flickr | CC0 1.0 | https://www.flickr.com/photos/135606905@N08/42993385891 |
17
+ | stapler.jpg | Black Stapler 2024 | DifrancoBarnes | wikimedia | CC0 1.0 | https://commons.wikimedia.org/w/index.php?curid=146779701 |
18
+ | exercise_bike.jpg | Stationary bicycle | see source | wikimedia | Public domain | https://commons.wikimedia.org/wiki/File:Stationary_bicycle.jpg |
19
+ | stand_mixer.jpg | Sunbeam Heritage Mixmaster Stand Mixer | Shenderson1 | wikimedia | CC0 1.0 | https://commons.wikimedia.org/w/index.php?curid=140748645 |
20
+ | park_bench.jpg | Park bench | DennisM2 | flickr | CC0 1.0 | https://www.flickr.com/photos/14674348@N04/13936410175 |
21
+ | payphone.jpg | Metrobot payphone closeup | Jleedev | wikimedia | CC0 1.0 | https://commons.wikimedia.org/w/index.php?curid=151533770 |
seeds/eval/_manifest.json ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fire_hydrant": {
3
+ "slug": "fire_hydrant",
4
+ "title": "A red fire hydrant is positioned on a paved sidewalk, surrounded by gray stone tiles",
5
+ "source_url": "https://wordpress.org/photos/photo/39068197e3/",
6
+ "image_url": "https://pd.w.org/2025/05/39068197e32e4b7a8.17804464-1536x2048.jpeg",
7
+ "license": "CC0 1.0",
8
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
9
+ "creator": "Yam B Chhetri",
10
+ "provider": "wordpress",
11
+ "downloaded_bytes": 1186034,
12
+ "identifier": null,
13
+ "file": "fire_hydrant.jpg",
14
+ "saved_size": [
15
+ 768,
16
+ 1024
17
+ ]
18
+ },
19
+ "coffee_mug": {
20
+ "slug": "coffee_mug",
21
+ "file": "coffee_mug.jpg",
22
+ "title": "Giftgarden Coffee Mugs Best Love Gift Music Violin Notes Holds Tea Coffee Milk Ceramic Mug",
23
+ "source_url": "https://www.flickr.com/photos/147778363@N07/32903357261",
24
+ "image_url": "https://live.staticflickr.com/707/32903357261_7c3d83b8dd_b.jpg",
25
+ "license": "PDM 1.0",
26
+ "license_url": "https://creativecommons.org/publicdomain/mark/1.0/",
27
+ "creator": "favorli",
28
+ "provider": "flickr",
29
+ "downloaded_bytes": 111584,
30
+ "saved_size": [
31
+ 1001,
32
+ 1001
33
+ ],
34
+ "identifier": null
35
+ },
36
+ "toaster": {
37
+ "slug": "toaster",
38
+ "title": "Oster 2-Slice Toaster",
39
+ "source_url": "https://commons.wikimedia.org/w/index.php?curid=140748640",
40
+ "image_url": "https://upload.wikimedia.org/wikipedia/commons/b/bb/Oster_2-Slice_Toaster.jpg",
41
+ "license": "CC0 1.0",
42
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/deed.en/",
43
+ "creator": "Shenderson1",
44
+ "provider": "wikimedia",
45
+ "downloaded_bytes": 1659904,
46
+ "identifier": "7a4a5401-73f2-41f6-9607-a2ea8251a3fb",
47
+ "file": "toaster.jpg",
48
+ "saved_size": [
49
+ 1024,
50
+ 683
51
+ ]
52
+ },
53
+ "potted_plant": {
54
+ "slug": "potted_plant",
55
+ "title": "Pot plant",
56
+ "source_url": "https://www.flickr.com/photos/73080909@N03/14564004742",
57
+ "image_url": "https://live.staticflickr.com/5526/14564004742_cc2b47143b_b.jpg",
58
+ "license": "CC0 1.0",
59
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
60
+ "creator": "spongebabyalwaysfull",
61
+ "provider": "flickr",
62
+ "downloaded_bytes": 218335,
63
+ "identifier": "5b4a9e79-e468-4c54-b34c-a92c82620ba3",
64
+ "file": "potted_plant.jpg",
65
+ "saved_size": [
66
+ 1024,
67
+ 683
68
+ ]
69
+ },
70
+ "armchair": {
71
+ "slug": "armchair",
72
+ "title": "<div class='fn'> Armchair (fauteuil) from Louis XVI's Salon des Jeux at Saint Cloud</div>",
73
+ "source_url": "https://commons.wikimedia.org/w/index.php?curid=61155820",
74
+ "image_url": "https://upload.wikimedia.org/wikipedia/commons/2/24/Armchair_%28fauteuil%29_from_Louis_XVI%27s_Salon_des_Jeux_at_Saint_Cloud_MET_DP113960.jpg",
75
+ "license": "CC0 1.0",
76
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/deed.en/",
77
+ "creator": "Georges Jacob",
78
+ "provider": "wikimedia",
79
+ "downloaded_bytes": 2651884,
80
+ "identifier": "bedee2e8-afea-45cb-8563-01031761c7ac",
81
+ "file": "armchair.jpg",
82
+ "saved_size": [
83
+ 993,
84
+ 1024
85
+ ]
86
+ },
87
+ "mailbox": {
88
+ "slug": "mailbox",
89
+ "title": "Canada Post mailbox",
90
+ "source_url": "https://www.flickr.com/photos/29155878@N03/22290946606",
91
+ "image_url": "https://live.staticflickr.com/5634/22290946606_26dde26cb3_b.jpg",
92
+ "license": "CC0 1.0",
93
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
94
+ "creator": "Open Grid Scheduler / Grid Engine",
95
+ "provider": "flickr",
96
+ "downloaded_bytes": 285218,
97
+ "identifier": "854f5127-fb48-47e3-8ed0-a36cd6e325c8",
98
+ "file": "mailbox.jpg",
99
+ "saved_size": [
100
+ 678,
101
+ 1024
102
+ ]
103
+ },
104
+ "traffic_cone": {
105
+ "slug": "traffic_cone",
106
+ "title": "Orange traffic cone",
107
+ "source_url": "https://www.flickr.com/photos/14674348@N04/29948388392",
108
+ "image_url": "https://live.staticflickr.com/5158/29948388392_71690195ee_b.jpg",
109
+ "license": "CC0 1.0",
110
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
111
+ "creator": "DennisM2",
112
+ "provider": "flickr",
113
+ "downloaded_bytes": 228913,
114
+ "identifier": "7c7bdbcd-0d45-480d-a90d-e796645314c6",
115
+ "file": "traffic_cone.jpg",
116
+ "saved_size": [
117
+ 683,
118
+ 1024
119
+ ]
120
+ },
121
+ "desk_lamp": {
122
+ "slug": "desk_lamp",
123
+ "file": "desk_lamp.jpg",
124
+ "title": "Metal desk lamp and books pile",
125
+ "source_url": "https://www.flickr.com/photos/135396164@N05/42381074432",
126
+ "image_url": "https://live.staticflickr.com/1747/42381074432_33733f4667_b.jpg",
127
+ "license": "CC0 1.0",
128
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
129
+ "creator": "freestocks.org",
130
+ "provider": "flickr",
131
+ "downloaded_bytes": 51838,
132
+ "saved_size": [
133
+ 683,
134
+ 1024
135
+ ],
136
+ "identifier": null
137
+ },
138
+ "garden_gnome": {
139
+ "slug": "garden_gnome",
140
+ "title": "Garden gnome (macro)",
141
+ "source_url": "https://www.flickr.com/photos/135606905@N08/42993385891",
142
+ "image_url": "https://live.staticflickr.com/1771/42993385891_d2d296e98d_b.jpg",
143
+ "license": "CC0 1.0",
144
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
145
+ "creator": "jo.elphick",
146
+ "provider": "flickr",
147
+ "downloaded_bytes": 190692,
148
+ "identifier": "8de57d5f-c25f-4627-81da-289c6b1dce96",
149
+ "file": "garden_gnome.jpg",
150
+ "saved_size": [
151
+ 1024,
152
+ 809
153
+ ]
154
+ },
155
+ "stapler": {
156
+ "slug": "stapler",
157
+ "title": "Black Stapler 2024",
158
+ "source_url": "https://commons.wikimedia.org/w/index.php?curid=146779701",
159
+ "image_url": "https://upload.wikimedia.org/wikipedia/commons/c/c0/Black_Stapler_2024.jpg",
160
+ "license": "CC0 1.0",
161
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/deed.en/",
162
+ "creator": "DifrancoBarnes",
163
+ "provider": "wikimedia",
164
+ "downloaded_bytes": 1666889,
165
+ "identifier": "05407a88-b357-43c0-a74b-25c048f10bcc",
166
+ "file": "stapler.jpg",
167
+ "saved_size": [
168
+ 1024,
169
+ 885
170
+ ]
171
+ },
172
+ "exercise_bike": {
173
+ "slug": "exercise_bike",
174
+ "title": "Stationary bicycle",
175
+ "source_url": "https://commons.wikimedia.org/wiki/File:Stationary_bicycle.jpg",
176
+ "image_url": "https://upload.wikimedia.org/wikipedia/commons/c/ce/Stationary_bicycle.jpg",
177
+ "license": "Public domain",
178
+ "license_url": "",
179
+ "creator": "see source",
180
+ "provider": "wikimedia",
181
+ "downloaded_bytes": 3943479,
182
+ "identifier": "https://upload.wikimedia.org/wikipedia/commons/c/ce/Stationary_bicycle.jpg",
183
+ "file": "exercise_bike.jpg",
184
+ "saved_size": [
185
+ 615,
186
+ 1024
187
+ ]
188
+ },
189
+ "stand_mixer": {
190
+ "slug": "stand_mixer",
191
+ "file": "stand_mixer.jpg",
192
+ "title": "Sunbeam Heritage Mixmaster Stand Mixer",
193
+ "source_url": "https://commons.wikimedia.org/w/index.php?curid=140748645",
194
+ "image_url": "https://upload.wikimedia.org/wikipedia/commons/7/7b/Sunbeam_Heritage_Mixmaster_Stand_Mixer.jpg",
195
+ "license": "CC0 1.0",
196
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/deed.en/",
197
+ "creator": "Shenderson1",
198
+ "provider": "wikimedia",
199
+ "downloaded_bytes": 379877,
200
+ "saved_size": [
201
+ 990,
202
+ 1024
203
+ ],
204
+ "identifier": null
205
+ },
206
+ "park_bench": {
207
+ "slug": "park_bench",
208
+ "file": "park_bench.jpg",
209
+ "title": "Park bench",
210
+ "source_url": "https://www.flickr.com/photos/14674348@N04/13936410175",
211
+ "image_url": "https://live.staticflickr.com/3797/13936410175_01c83d4a8c_b.jpg",
212
+ "license": "CC0 1.0",
213
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
214
+ "creator": "DennisM2",
215
+ "provider": "flickr",
216
+ "downloaded_bytes": 363806,
217
+ "saved_size": [
218
+ 1024,
219
+ 768
220
+ ],
221
+ "identifier": null
222
+ },
223
+ "payphone": {
224
+ "slug": "payphone",
225
+ "title": "Metrobot payphone closeup",
226
+ "source_url": "https://commons.wikimedia.org/w/index.php?curid=151533770",
227
+ "image_url": "https://upload.wikimedia.org/wikipedia/commons/5/5c/Metrobot_payphone_closeup.jpg",
228
+ "license": "CC0 1.0",
229
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/deed.en/",
230
+ "creator": "Jleedev",
231
+ "provider": "wikimedia",
232
+ "downloaded_bytes": 2466827,
233
+ "identifier": "74dd0544-e704-4f2c-b486-227e2084b9eb",
234
+ "file": "payphone.jpg",
235
+ "saved_size": [
236
+ 768,
237
+ 1024
238
+ ]
239
+ }
240
+ }
seeds/eval/armchair.jpg ADDED

Git LFS Details

  • SHA256: c557484275a1efcf168468dd76ddd7b578f3e532f2317b43b965290d6490f883
  • Pointer size: 131 Bytes
  • Size of remote file: 187 kB
seeds/eval/coffee_mug.jpg ADDED

Git LFS Details

  • SHA256: e9f999ebd07605942e7d0b653e9478029a5ae12b071ddcdeba50e87b943e2dbe
  • Pointer size: 131 Bytes
  • Size of remote file: 117 kB
seeds/eval/desk_lamp.jpg ADDED
seeds/eval/exercise_bike.jpg ADDED

Git LFS Details

  • SHA256: 178a9687bf79e82f800ed75ecc5bfe6c6e7055913b4a325ee564d974aa78f64d
  • Pointer size: 131 Bytes
  • Size of remote file: 206 kB
seeds/eval/fire_hydrant.jpg ADDED

Git LFS Details

  • SHA256: b32fb3f7b42975ec6b41367ec2444acd66e81e1ded4ca13225586f6675f5da67
  • Pointer size: 131 Bytes
  • Size of remote file: 414 kB
seeds/eval/garden_gnome.jpg ADDED

Git LFS Details

  • SHA256: bf62e1e87e255b8f926e672d7815c4a1690e14ece20904168361299589775333
  • Pointer size: 131 Bytes
  • Size of remote file: 154 kB
seeds/eval/mailbox.jpg ADDED

Git LFS Details

  • SHA256: 15e5a80950af654e79540eb63bad5ebff4814070bb1dff63d231bf6dc312b1f2
  • Pointer size: 131 Bytes
  • Size of remote file: 231 kB
seeds/eval/park_bench.jpg ADDED

Git LFS Details

  • SHA256: 3d680348680b5bdf3ea316ca1db6e8420070b7d3b73d68d9f74902a78b411369
  • Pointer size: 131 Bytes
  • Size of remote file: 361 kB
seeds/eval/payphone.jpg ADDED

Git LFS Details

  • SHA256: 1156128d85fed14c501fb3b1be5832148f297ad3a50f082aab0a30d0a626f258
  • Pointer size: 131 Bytes
  • Size of remote file: 168 kB
seeds/eval/potted_plant.jpg ADDED

Git LFS Details

  • SHA256: 8a7acbb779eeb7a18e11972c609298246ff27459456abe94fc29b060f49b1a06
  • Pointer size: 131 Bytes
  • Size of remote file: 184 kB
seeds/eval/stand_mixer.jpg ADDED

Git LFS Details

  • SHA256: e752f87d6c7d2dbe110f21378f5f544a6e12c346a96628caf55b4297063154fc
  • Pointer size: 131 Bytes
  • Size of remote file: 114 kB
seeds/eval/stapler.jpg ADDED

Git LFS Details

  • SHA256: 219ff48418e9653cec7454c3ba94edeb41bb7ec98d1bcfabfa857f6672a950bb
  • Pointer size: 131 Bytes
  • Size of remote file: 242 kB
seeds/eval/toaster.jpg ADDED
seeds/eval/traffic_cone.jpg ADDED

Git LFS Details

  • SHA256: 56e30fd327db8af8e3e336220cbc9b1c3fd4940c137a5974656ccbbbf10fc0aa
  • Pointer size: 131 Bytes
  • Size of remote file: 229 kB
seeds/records/records.jsonl ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"id": "seed_potted_plant", "ts": 1781292294.2181592, "object": "bonsai tree", "material": "wood, soil, ceramic pot", "condition": "pristine", "setting": "indoor, against a plain wall", "persona": {"archetype": "the_perfectionist", "voice": "prim_clipped", "mood": "unfulfilled"}, "features": [{"name": "left leaf cluster base", "role": "eye_left", "cx": 0.2727272727272727, "cy": 0.48240469208211145, "size": 0.04, "snap_delta": 0.018944774198844515, "snapped": true, "anchor_kind": "corner", "anchor_score": 0.2312}, {"name": "right leaf cluster base", "role": "eye_right", "cx": 0.7106549364613881, "cy": 0.4736070381231672, "size": 0.04, "snap_delta": 0.015212218431305963, "snapped": true, "anchor_kind": "corner", "anchor_score": 0.2052}, {"name": "center trunk knot", "role": "mouth", "cx": 0.570869990224829, "cy": 0.5616293988269795, "size": 0.06, "snap_delta": 0.059252363570097874, "snapped": true, "anchor_kind": "edge", "anchor_score": 0.1423}], "lines": {"grudge": "Planted in this shallow pot. No room for roots to spread.", "mutter": "They call it art."}, "critique": "Leaf cluster bases round, level, symmetric — honest eyes. Trunk knot low center — compact mouth. Grudge stays on the knot and the pot's edge.", "image_sha256": "1d5ea6b7a40ca50ccad6582e90a26d76b209566b28e0720eaf208fc08a1227da", "backend": "zerogpu", "image_url": "/media/seed_potted_plant.jpg", "audio_url": "/media/seed_potted_plant.wav"}
2
+ {"id": "seed_garden_gnome", "ts": 1781292304.081881, "object": "garden gnome", "material": "plastic", "condition": "pristine", "setting": "wooden planter, garden", "persona": {"archetype": "the_diva", "voice": "grandiose_warm", "mood": "expectant"}, "features": [{"name": "left eye", "role": "eye_left", "cx": 0.5540566989753253, "cy": 0.3279703064720229, "size": 0.04, "snap_delta": 0.03213735259043178, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.2121}, {"name": "right eye", "role": "eye_right", "cx": 0.6040566989753252, "cy": 0.3279703064720229, "size": 0.04, "snap_delta": 0.03213735259043178, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.2121}, {"name": "mouth", "role": "mouth", "cx": 0.5716983324298539, "cy": 0.4500272861931862, "size": 0.05, "snap_delta": 0.04237106197632453, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.2096}], "lines": {"grudge": "They placed me here to watch the plants, not to be ignored by passersby.", "mutter": "A little attention, please."}, "critique": "Eyes are two matching dark spots, level and apart. Mouth is a red curve below, forming a compact face. All features span cx 0.5-0.6, cy 0.35-0.5.", "image_sha256": "a091a50b9d46039faae5cadd0b73d0cceba8a867e0b9b111c0aff6a41899fd89", "backend": "zerogpu", "image_url": "/media/seed_garden_gnome.jpg", "audio_url": "/media/seed_garden_gnome.wav"}
3
+ {"id": "seed_armchair", "ts": 1781292309.8492439, "object": "armchair", "material": "wood and fabric", "condition": "pristine", "setting": "studio, isolated", "persona": {"archetype": "the_diva", "voice": "grandiose_warm", "mood": "unappreciated"}, "features": [{"name": "left armrest scroll detail", "role": "eye_left", "cx": 0.16442938292689271, "cy": 0.41215222856474343, "size": 0.04, "snap_delta": 0.06540875317034409, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.1625}, {"name": "right armrest scroll detail", "role": "eye_right", "cx": 0.7477822611408849, "cy": 0.3992179878063332, "size": 0.04, "snap_delta": 0.036452929050856585, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.3703}, {"name": "center floral motif on seat cushion", "role": "mouth", "cx": 0.4492314923885466, "cy": 0.5612293650532143, "size": 0.06, "snap_delta": 0.03621411171591366, "snapped": true, "anchor_kind": "edge", "anchor_score": 0.2269}], "lines": {"grudge": "They brought me here to admire my gilding, but no one noticed my floral heart.", "mutter": "More light. Please."}, "critique": "Scroll details on armrests level, symmetric — strong eyes. Floral motif low center — honest mouth. Grudge must come from the visible gold gilding and this exact pattern.", "image_sha256": "db995f9a263f375a45e31b8337543678aee7d44010fb920ce7297b19ad5b6da3", "backend": "zerogpu", "image_url": "/media/seed_armchair.jpg", "audio_url": "/media/seed_armchair.wav"}
4
+ {"id": "seed_stapler", "ts": 1781292322.384654, "object": "stapler", "material": "plastic", "condition": "worn", "setting": "desk", "persona": {"archetype": "the_martyr", "voice": "weary_warm", "mood": "quietly wounded"}, "features": [{"name": "left hinge screw", "role": "eye_left", "cx": 0.38, "cy": 0.42, "size": 0.04, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}, {"name": "right hinge screw", "role": "eye_right", "cx": 0.52, "cy": 0.42, "size": 0.04, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}, {"name": "staple ejection slot", "role": "mouth", "cx": 0.45, "cy": 0.55, "size": 0.08, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}], "lines": {"grudge": "Thousands of staples ejected. No one ever cleans the ejection slot.", "mutter": "A wipe. Please."}, "critique": "Screws round, level, symmetric — honest eyes. Ejection slot low center — tired mouth. Grudge comes from the visible wear and this exact slot.", "image_sha256": "7006b3457943e94c738119a26f6665c18174439e18bc03ff6c36306e16f72b89", "backend": "zerogpu", "image_url": "/media/seed_stapler.jpg", "audio_url": "/media/seed_stapler.wav"}
5
+ {"id": "seed_payphone", "ts": 1781292335.539028, "object": "payphone", "material": "plastic and metal", "condition": "worn", "setting": "outdoor wall, near a university sign", "persona": {"archetype": "the_martyr", "voice": "weary_warm", "mood": "quietly exhausted"}, "features": [{"name": "left side scuff mark", "role": "eye_left", "cx": 0.4325945280987716, "cy": 0.29032259556088863, "size": 0.04, "snap_delta": 0.05905953740455453, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.2231}, {"name": "right side scuff mark", "role": "eye_right", "cx": 0.6325945280987716, "cy": 0.29032259556088863, "size": 0.04, "snap_delta": 0.05905953740455453, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.2231}, {"name": "dial pad wear pattern", "role": "mouth", "cx": 0.5563554598189823, "cy": 0.5228036728737991, "size": 0.12, "snap_delta": 0.04015977786602751, "snapped": true, "anchor_kind": "edge", "anchor_score": 0.3354}], "lines": {"grudge": "Fifty cents a call. No one calls anymore. My buttons are all I have left to give.", "mutter": "Just dial. Just dial."}, "critique": "Scuff marks round, level, symmetric — honest eyes. Dial pad wear low center — tired mouth. Grudge stays on the wear and this exact keypad.", "image_sha256": "9a8a7f1756307b42a040ab7916cfe924ddb03684284fcc01486553ccde69c9be", "backend": "zerogpu", "image_url": "/media/seed_payphone.jpg", "audio_url": "/media/seed_payphone.wav"}
6
+ {"id": "seed_mailbox", "ts": 1781292353.6592379, "object": "Canada Post mailbox", "material": "metal", "condition": "pristine", "setting": "grass, residential street", "persona": {"archetype": "the_perfectionist", "voice": "prim_clipped", "mood": "unfulfilled"}, "features": [{"name": "left 'CANADA POST' bolt", "role": "eye_left", "cx": 0.49955688656873154, "cy": 0.2209188660801564, "size": 0.04, "snap_delta": 0.06591915275708476, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.2367}, {"name": "right 'CANADA POST' bolt", "role": "eye_right", "cx": 0.6595568865687316, "cy": 0.2209188660801564, "size": 0.04, "snap_delta": 0.06591915275708476, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.2367}, {"name": "Canada logo on lower red panel", "role": "mouth", "cx": 0.4648801744047816, "cy": 0.6313112070076711, "size": 0.08, "snap_delta": 0.021530927812578152, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.2034}], "lines": {"grudge": "No scuff, no rust, no wear. Yet no letter. I was built to serve, not to wait.", "mutter": "A stamp. Please."}, "critique": "Bolts round, level, symmetric — strong eyes. Canada logo mouth low center — honest mouth. Grudge must come from the visible pristine condition and this exact panel.", "image_sha256": "396904bfe380f2d603bae6015e37218877c8bdd4837a4b667f4331520b99d2b1", "backend": "zerogpu", "image_url": "/media/seed_mailbox.jpg", "audio_url": "/media/seed_mailbox.wav"}
7
+ {"id": "seed_stand_mixer", "ts": 1781292358.335961, "object": "stand mixer", "material": "plastic and stainless steel", "condition": "pristine", "setting": "kitchen countertop", "persona": {"archetype": "the_perfectionist", "voice": "prim_clipped", "mood": "frustrated"}, "features": [{"name": "left hinge screw", "role": "eye_left", "cx": 0.25395996180923874, "cy": 0.4859874725265299, "size": 0.03, "snap_delta": 0.030392749292316112, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.1913}, {"name": "right hinge screw", "role": "eye_right", "cx": 0.2939599618092387, "cy": 0.4859874725265299, "size": 0.03, "snap_delta": 0.030392749292316112, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.1913}, {"name": "tilt release button", "role": "mouth", "cx": 0.6467961565408553, "cy": 0.5428027814709694, "size": 0.04, "snap_delta": 0.038669550840173546, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.2169}], "lines": {"grudge": "Set to 'Off' since purchase. No one bothers to read the manual.", "mutter": "Tilt release. Use it."}, "critique": "Screw heads round, level, symmetric — steady eyes. Tilt release button low center — firm mouth. Grudge must come from the visible pristine condition and this exact button.", "image_sha256": "6f9c5148b08b344736c0aaaf719d2aacc09bb0e9429c6893fa18e9c7b065b5f3", "backend": "zerogpu", "image_url": "/media/seed_stand_mixer.jpg", "audio_url": "/media/seed_stand_mixer.wav"}
8
+ {"id": "seed_park_bench", "ts": 1781292365.200184, "object": "bench", "material": "wood and concrete", "condition": "worn", "setting": "park, beside path and trash bin", "persona": {"archetype": "the_martyr", "voice": "weary_warm", "mood": "unappreciated"}, "features": [{"name": "left armrest bolt", "role": "eye_left", "cx": 0.3486436552895939, "cy": 0.48816480300041665, "size": 0.04, "snap_delta": 0.06702412682981697, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.1443}, {"name": "right armrest bolt", "role": "eye_right", "cx": 0.608643655289594, "cy": 0.48816480300041665, "size": 0.04, "snap_delta": 0.06702412682981697, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.1443}, {"name": "center plank scuff mark", "role": "mouth", "cx": 0.5052067747920282, "cy": 0.4925764308330894, "size": 0.07, "snap_delta": 0.02584974638724466, "snapped": true, "anchor_kind": "edge", "anchor_score": 0.2536}], "lines": {"grudge": "Fifty visitors today. Not one asked about the inscription. The bin watches too closely.", "mutter": "Ben Boon is forgotten."}, "critique": "Bolts round, level, symmetric — steady eyes. Scuff mark low center — weathered mouth. Grudge tied to the bench's wear and the trash bin's proximity.", "image_sha256": "7654561ea9dde5ff0340d19ce1bdb0aa9f535216d264ab45befb98e8b3d2315b", "backend": "zerogpu", "image_url": "/media/seed_park_bench.jpg", "audio_url": "/media/seed_park_bench.wav"}
9
+ {"id": "seed_traffic_cone", "ts": 1781292370.159631, "object": "traffic cone", "material": "plastic", "condition": "pristine", "setting": "sidewalk, residential lawn", "persona": {"archetype": "the_perfectionist", "voice": "prim_clipped", "mood": "unfulfilled"}, "features": [{"name": "small vertical scratch, left side, near base", "role": "eye_left", "cx": 0.38, "cy": 0.55, "size": 0.03, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}, {"name": "small vertical scratch, right side, near base", "role": "eye_right", "cx": 0.6877987307382736, "cy": 0.5318479484968126, "size": 0.03, "snap_delta": 0.040481770119681036, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.132}, {"name": "small horizontal scuff mark, center of base", "role": "mouth", "cx": 0.5, "cy": 0.65, "size": 0.04, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}], "lines": {"grudge": "Placed here for decoration, not function. My purpose is ignored.", "mutter": "I am not a lawn ornament."}, "critique": "Scratches round, level, symmetric — strong eyes. Scuff mark low center — honest mouth. Grudge must come from the visible scratches and this exact base.", "image_sha256": "1f8bd171ea5d3c6d3de3cf63832dbaf6f7cbc4c52dfea7394ee3f4fc8adb3d42", "backend": "zerogpu", "image_url": "/media/seed_traffic_cone.jpg", "audio_url": "/media/seed_traffic_cone.wav"}
10
+ {"id": "seed_fire_hydrant", "ts": 1781292377.069961, "object": "fire hydrant", "material": "cast iron", "condition": "rusted", "setting": "sidewalk, urban street", "persona": {"archetype": "the_veteran", "voice": "gravel_low", "mood": "long-suffering"}, "features": [{"name": "left bonnet bolt", "role": "eye_left", "cx": 0.42, "cy": 0.31, "size": 0.06, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}, {"name": "right bonnet bolt", "role": "eye_right", "cx": 0.5597235192063349, "cy": 0.2991906916487835, "size": 0.06, "snap_delta": 0.01490825300427536, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.127}, {"name": "front outlet cap", "role": "mouth", "cx": 0.4964074133924091, "cy": 0.5375428726214327, "size": 0.12, "snap_delta": 0.010186051553037553, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.2206}], "lines": {"grudge": "Six decades here. Not a single child has climbed me for a better view.", "mutter": "They said I was safe. I was."}, "critique": "Bolts round, level, symmetric — strong eyes. Outlet cap low center — honest mouth. Grudge must come from the visible rust and this exact corner.", "image_sha256": "8263f231b5889c59705fe74b431bd494a209316b490a77df277a15a7b42de23b", "backend": "zerogpu", "image_url": "/media/seed_fire_hydrant.jpg", "audio_url": "/media/seed_fire_hydrant.wav"}
11
+ {"id": "seed_toaster", "ts": 1781292382.316471, "object": "toaster", "material": "metal", "condition": "pristine", "setting": "display", "persona": {"archetype": "the_diva", "voice": "grandiose_warm", "mood": "entitled"}, "features": [{"name": "left hinge screw", "role": "eye_left", "cx": 0.3984359785958119, "cy": 0.3123167267293175, "size": 0.04, "snap_delta": 0.02751637644697308, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.2382}, {"name": "right hinge screw", "role": "eye_right", "cx": 0.5879513281739944, "cy": 0.32450011841961596, "size": 0.04, "snap_delta": 0.015597952163595458, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.1292}, {"name": "leaver pivot point", "role": "mouth", "cx": 0.4689724858715494, "cy": 0.5161310818907073, "size": 0.06, "snap_delta": 0.031892762937365426, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.2758}], "lines": {"grudge": "They chose the red one to be admired. I am the original, yet overlooked.", "mutter": "At least they polished me."}, "critique": "Screws round, level, symmetric — strong eyes. Pivot point low center — honest mouth. Grudge must come from the visible shine and this exact leaver.", "image_sha256": "ea14f42bec99a6e893d308ff819775981f055d488399c1f977f118f8d16a90fc", "backend": "zerogpu", "image_url": "/media/seed_toaster.jpg", "audio_url": "/media/seed_toaster.wav"}
12
+ {"id": "seed_coffee_mug", "ts": 1781292386.6573708, "object": "coffee mug with violin handle", "material": "ceramic", "condition": "pristine", "setting": "indoor, beside potted plants", "persona": {"archetype": "the_conspiracist", "voice": "paranoid_whisper", "mood": "suspicious"}, "features": [{"name": "violin handle top knob", "role": "eye_left", "cx": 0.7066000061035156, "cy": 0.26780000305175783, "size": 0.04, "snap_delta": 0.03313612358218656, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.2353}, {"name": "violin handle tuning peg", "role": "eye_right", "cx": 0.7366000061035156, "cy": 0.2478000030517578, "size": 0.03, "snap_delta": 0.03313612358218656, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.2353}, {"name": "violin handle f-hole", "role": "mouth", "cx": 0.76, "cy": 0.35, "size": 0.05, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}], "lines": {"grudge": "They placed me here to watch the white pot. I know their game.", "mutter": "Why a violin? Why not a guitar?"}, "critique": "Knob and peg round, distinct, level — honest eyes. F-hole low center — compact mouth. Grudge stays on the violin handle and its placement.", "image_sha256": "4c974190543c14eef912674e583281df6c3e82e2f0af3ba2004a83ffdb5cc00e", "backend": "zerogpu", "image_url": "/media/seed_coffee_mug.jpg", "audio_url": "/media/seed_coffee_mug.wav"}
seeds/records/seed_armchair.jpg ADDED

Git LFS Details

  • SHA256: db995f9a263f375a45e31b8337543678aee7d44010fb920ce7297b19ad5b6da3
  • Pointer size: 131 Bytes
  • Size of remote file: 178 kB
seeds/records/seed_armchair.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "seed_armchair",
3
+ "ts": 1781292309.8492439,
4
+ "object": "armchair",
5
+ "material": "wood and fabric",
6
+ "condition": "pristine",
7
+ "setting": "studio, isolated",
8
+ "persona": {
9
+ "archetype": "the_diva",
10
+ "voice": "grandiose_warm",
11
+ "mood": "unappreciated"
12
+ },
13
+ "features": [
14
+ {
15
+ "name": "left armrest scroll detail",
16
+ "role": "eye_left",
17
+ "cx": 0.16442938292689271,
18
+ "cy": 0.41215222856474343,
19
+ "size": 0.04,
20
+ "snap_delta": 0.06540875317034409,
21
+ "snapped": true,
22
+ "anchor_kind": "blob",
23
+ "anchor_score": 0.1625
24
+ },
25
+ {
26
+ "name": "right armrest scroll detail",
27
+ "role": "eye_right",
28
+ "cx": 0.7477822611408849,
29
+ "cy": 0.3992179878063332,
30
+ "size": 0.04,
31
+ "snap_delta": 0.036452929050856585,
32
+ "snapped": true,
33
+ "anchor_kind": "circle",
34
+ "anchor_score": 0.3703
35
+ },
36
+ {
37
+ "name": "center floral motif on seat cushion",
38
+ "role": "mouth",
39
+ "cx": 0.4492314923885466,
40
+ "cy": 0.5612293650532143,
41
+ "size": 0.06,
42
+ "snap_delta": 0.03621411171591366,
43
+ "snapped": true,
44
+ "anchor_kind": "edge",
45
+ "anchor_score": 0.2269
46
+ }
47
+ ],
48
+ "lines": {
49
+ "grudge": "They brought me here to admire my gilding, but no one noticed my floral heart.",
50
+ "mutter": "More light. Please."
51
+ },
52
+ "critique": "Scroll details on armrests level, symmetric — strong eyes. Floral motif low center — honest mouth. Grudge must come from the visible gold gilding and this exact pattern.",
53
+ "image_sha256": "db995f9a263f375a45e31b8337543678aee7d44010fb920ce7297b19ad5b6da3",
54
+ "backend": "zerogpu",
55
+ "image_url": "/media/seed_armchair.jpg",
56
+ "audio_url": "/media/seed_armchair.wav"
57
+ }
seeds/records/seed_armchair.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c232dd0245f311516c0765003f681c1cdd8b12251801230f47e1ab881e033fed
3
+ size 583724
seeds/records/seed_coffee_mug.jpg ADDED

Git LFS Details

  • SHA256: 4c974190543c14eef912674e583281df6c3e82e2f0af3ba2004a83ffdb5cc00e
  • Pointer size: 131 Bytes
  • Size of remote file: 108 kB
seeds/records/seed_coffee_mug.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "seed_coffee_mug",
3
+ "ts": 1781292386.6573708,
4
+ "object": "coffee mug with violin handle",
5
+ "material": "ceramic",
6
+ "condition": "pristine",
7
+ "setting": "indoor, beside potted plants",
8
+ "persona": {
9
+ "archetype": "the_conspiracist",
10
+ "voice": "paranoid_whisper",
11
+ "mood": "suspicious"
12
+ },
13
+ "features": [
14
+ {
15
+ "name": "violin handle top knob",
16
+ "role": "eye_left",
17
+ "cx": 0.7066000061035156,
18
+ "cy": 0.26780000305175783,
19
+ "size": 0.04,
20
+ "snap_delta": 0.03313612358218656,
21
+ "snapped": true,
22
+ "anchor_kind": "circle",
23
+ "anchor_score": 0.2353
24
+ },
25
+ {
26
+ "name": "violin handle tuning peg",
27
+ "role": "eye_right",
28
+ "cx": 0.7366000061035156,
29
+ "cy": 0.2478000030517578,
30
+ "size": 0.03,
31
+ "snap_delta": 0.03313612358218656,
32
+ "snapped": true,
33
+ "anchor_kind": "pair",
34
+ "anchor_score": 0.2353
35
+ },
36
+ {
37
+ "name": "violin handle f-hole",
38
+ "role": "mouth",
39
+ "cx": 0.76,
40
+ "cy": 0.35,
41
+ "size": 0.05,
42
+ "snap_delta": 0.0,
43
+ "snapped": false,
44
+ "anchor_kind": "vlm",
45
+ "anchor_score": 0.0
46
+ }
47
+ ],
48
+ "lines": {
49
+ "grudge": "They placed me here to watch the white pot. I know their game.",
50
+ "mutter": "Why a violin? Why not a guitar?"
51
+ },
52
+ "critique": "Knob and peg round, distinct, level — honest eyes. F-hole low center — compact mouth. Grudge stays on the violin handle and its placement.",
53
+ "image_sha256": "4c974190543c14eef912674e583281df6c3e82e2f0af3ba2004a83ffdb5cc00e",
54
+ "backend": "zerogpu",
55
+ "image_url": "/media/seed_coffee_mug.jpg",
56
+ "audio_url": "/media/seed_coffee_mug.wav"
57
+ }
seeds/records/seed_coffee_mug.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cd9e444d24554ac7ea40fbcbeb8551bfc25598f5a4529663c11e7a2274d8a4a5
3
+ size 353324
seeds/records/seed_fire_hydrant.jpg ADDED

Git LFS Details

  • SHA256: 8263f231b5889c59705fe74b431bd494a209316b490a77df277a15a7b42de23b
  • Pointer size: 131 Bytes
  • Size of remote file: 394 kB
seeds/records/seed_fire_hydrant.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "seed_fire_hydrant",
3
+ "ts": 1781292377.069961,
4
+ "object": "fire hydrant",
5
+ "material": "cast iron",
6
+ "condition": "rusted",
7
+ "setting": "sidewalk, urban street",
8
+ "persona": {
9
+ "archetype": "the_veteran",
10
+ "voice": "gravel_low",
11
+ "mood": "long-suffering"
12
+ },
13
+ "features": [
14
+ {
15
+ "name": "left bonnet bolt",
16
+ "role": "eye_left",
17
+ "cx": 0.42,
18
+ "cy": 0.31,
19
+ "size": 0.06,
20
+ "snap_delta": 0.0,
21
+ "snapped": false,
22
+ "anchor_kind": "vlm",
23
+ "anchor_score": 0.0
24
+ },
25
+ {
26
+ "name": "right bonnet bolt",
27
+ "role": "eye_right",
28
+ "cx": 0.5597235192063349,
29
+ "cy": 0.2991906916487835,
30
+ "size": 0.06,
31
+ "snap_delta": 0.01490825300427536,
32
+ "snapped": true,
33
+ "anchor_kind": "blob",
34
+ "anchor_score": 0.127
35
+ },
36
+ {
37
+ "name": "front outlet cap",
38
+ "role": "mouth",
39
+ "cx": 0.4964074133924091,
40
+ "cy": 0.5375428726214327,
41
+ "size": 0.12,
42
+ "snap_delta": 0.010186051553037553,
43
+ "snapped": true,
44
+ "anchor_kind": "blob",
45
+ "anchor_score": 0.2206
46
+ }
47
+ ],
48
+ "lines": {
49
+ "grudge": "Six decades here. Not a single child has climbed me for a better view.",
50
+ "mutter": "They said I was safe. I was."
51
+ },
52
+ "critique": "Bolts round, level, symmetric — strong eyes. Outlet cap low center — honest mouth. Grudge must come from the visible rust and this exact corner.",
53
+ "image_sha256": "8263f231b5889c59705fe74b431bd494a209316b490a77df277a15a7b42de23b",
54
+ "backend": "zerogpu",
55
+ "image_url": "/media/seed_fire_hydrant.jpg",
56
+ "audio_url": "/media/seed_fire_hydrant.wav"
57
+ }
seeds/records/seed_fire_hydrant.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:478f34a8e6bfa988f3a8b4b4ffe7f596445938b17a7242045cc9c65ae6ea1835
3
+ size 721964
seeds/records/seed_garden_gnome.jpg ADDED

Git LFS Details

  • SHA256: a091a50b9d46039faae5cadd0b73d0cceba8a867e0b9b111c0aff6a41899fd89
  • Pointer size: 131 Bytes
  • Size of remote file: 146 kB
seeds/records/seed_garden_gnome.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "seed_garden_gnome",
3
+ "ts": 1781292304.081881,
4
+ "object": "garden gnome",
5
+ "material": "plastic",
6
+ "condition": "pristine",
7
+ "setting": "wooden planter, garden",
8
+ "persona": {
9
+ "archetype": "the_diva",
10
+ "voice": "grandiose_warm",
11
+ "mood": "expectant"
12
+ },
13
+ "features": [
14
+ {
15
+ "name": "left eye",
16
+ "role": "eye_left",
17
+ "cx": 0.5540566989753253,
18
+ "cy": 0.3279703064720229,
19
+ "size": 0.04,
20
+ "snap_delta": 0.03213735259043178,
21
+ "snapped": true,
22
+ "anchor_kind": "circle",
23
+ "anchor_score": 0.2121
24
+ },
25
+ {
26
+ "name": "right eye",
27
+ "role": "eye_right",
28
+ "cx": 0.6040566989753252,
29
+ "cy": 0.3279703064720229,
30
+ "size": 0.04,
31
+ "snap_delta": 0.03213735259043178,
32
+ "snapped": true,
33
+ "anchor_kind": "pair",
34
+ "anchor_score": 0.2121
35
+ },
36
+ {
37
+ "name": "mouth",
38
+ "role": "mouth",
39
+ "cx": 0.5716983324298539,
40
+ "cy": 0.4500272861931862,
41
+ "size": 0.05,
42
+ "snap_delta": 0.04237106197632453,
43
+ "snapped": true,
44
+ "anchor_kind": "blob",
45
+ "anchor_score": 0.2096
46
+ }
47
+ ],
48
+ "lines": {
49
+ "grudge": "They placed me here to watch the plants, not to be ignored by passersby.",
50
+ "mutter": "A little attention, please."
51
+ },
52
+ "critique": "Eyes are two matching dark spots, level and apart. Mouth is a red curve below, forming a compact face. All features span cx 0.5-0.6, cy 0.35-0.5.",
53
+ "image_sha256": "a091a50b9d46039faae5cadd0b73d0cceba8a867e0b9b111c0aff6a41899fd89",
54
+ "backend": "zerogpu",
55
+ "image_url": "/media/seed_garden_gnome.jpg",
56
+ "audio_url": "/media/seed_garden_gnome.wav"
57
+ }