docs(simulator): design spec + BDDs for curator's X-ray experience simulator

Discovery session 0003. Web-based, Docker-on-localhost simulator to feel the
selection model: real hef.selection wired to a synthetic fixture catalog, with
a full-transparency X-ray (ranked pool + distances + brain/mood grid maps) and
live model knobs (weights, pool size, approved-only).

- docs/superpowers/specs/2026-06-04-experience-simulator-design.md
- features/{selection_xray,model_knobs,fixture_catalog,simulator_service}.feature
- one additive hef.selection.ranked_candidates() change planned (single source of truth)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-04 15:31:30 -07:00
parent 0e5c3a1a0f
commit 9cb4fb62d8
6 changed files with 326 additions and 0 deletions
+1
View File
@@ -3,3 +3,4 @@ __pycache__/
.pytest_cache/
.venv/
media/
.superpowers/
@@ -0,0 +1,193 @@
# Experience Simulator (Curator's X-ray) — Design Spec
**Date:** 2026-06-04
**Status:** Approved design (pre-implementation)
**Repo:** `human-experience-filter-art`
**Session:** 0003 (discovery)
> A web-based stand-in for the installation's control panel, run on localhost in a
> Docker container, built so a curator can **feel the selection model** — turn the
> dials and see, in full, which catalog pieces the nearest-match algorithm
> surfaces and why. It validates the *principles* (coordinate tagging + selection)
> and the *interface* before the physical Pi-player / Arduino stack is built.
---
## 1. What this is
A single-page web tool that reproduces the installation's five controls and wires
them to the **real** `hef.selection` code, then shows a transparent "X-ray" of the
result: the picked piece, the full ranked candidate pool with distances, and a
live map of where the knob point and candidates sit in the coordinate space.
It is a **curator's instrument**, not the installation itself. Its job is to let a
human judge whether the tagging rubric (design spec §2) and the nearest-match
selection (design spec §3) actually surface fitting pieces at every knob position —
so a "wrong" result can be diagnosed as bad tagging vs. a sparse neighborhood.
Primary purpose (settled this session): **feel the selection model.** Faithful
media playback is explicitly *not* a goal.
### Design constraints
- **Real algorithm, single source of truth.** The simulator runs the shipped
`hef.selection` code. No reimplementation of selection in JavaScript — a copy
could drift from what the Pi will run, which would defeat the purpose.
- **Runs locally in Docker.** One container, one command, `localhost`. No
networking beyond the local browser, no auth, no multi-user.
- **Reads the model; never curates into it.** The simulator does not write catalog
records back. Editing/approval stays with the sub-project-2 review tools.
---
## 2. Architecture
One Python service in one container. **FastAPI** serves both a JSON selection API
and a dependency-free static frontend. The browser holds the dial state; each
change POSTs to the API and re-renders the X-ray.
```
browser (dials + X-ray) ──POST /api/select──▶ FastAPI ──▶ hef.selection (REAL)
▲ │
└────────── pool + distances + pick ◀─────┘
```
- **Backend:** `simulator/app.py` (FastAPI), `simulator/fixtures.py` (synthetic
catalog generator). Imports `hef.catalog` and `hef.selection` directly.
- **Frontend:** `simulator/static/``index.html`, `app.js`, `style.css`. Vanilla
JS, no build step.
- **Packaging:** `simulator/Dockerfile` + a one-command run (`docker compose up`
or a `make sim` target), serving on `localhost`.
This adds a `simulator/` top-level package, consistent with the repo layout in the
main design spec §10 (`hef/`, `player/`, `firmware/`, `tools/`).
---
## 3. One additive change to `hef.selection`
The X-ray needs the ranked pool with distances — which `select()` already computes
internally (`ranked`, `nearest`) but does not expose. To avoid duplicating ranking
logic in the simulator (the very drift risk §1 forbids), extract it into a public
function and refactor `select()` to call it:
```python
def ranked_candidates(
records, coord: Coordinate, mode: str, *,
pool_size: int = 4, weights: Weights = Weights(), approved_only: bool = False,
) -> list[tuple[Record, float]]:
"""Mode-eligible records sorted nearest-first, each paired with its distance.
Honors the same 'av' fallback and approved_only filtering as select().
"""
```
`select()` becomes a thin wrapper over `ranked_candidates()` (take the pool, then
return `nearest[0]` or `rng.choice(nearest)`). This is the **only** change to
shipped code: additive, behavior-preserving for `select()`, and independently
tested. The X-ray then displays exactly the pool the picker chooses from.
---
## 4. Synthetic fixture catalog (`simulator/fixtures.py`)
The real `catalog/library.jsonl` is empty. A deterministic (seeded) generator
produces a dense, representative spread of `Record`s so nearest-match behavior can
be felt everywhere immediately:
- Coverage across the `(left, right, dark, light)` space (the 5×5 brain plane ×
5×5 mood plane = 625 configs) and across all content modes (`audio`, `video`,
`av`).
- Plausible titles and `rationale`s; `source_archive`/`license` drawn from the
real pools so records validate.
- A deliberate mix of `review_status: proposed` and `approved`, so the
`approved_only` toggle is exercisable.
- No real media: `file_path` empty, `dominant_color` empty (optional field).
- Output validates against `hef.catalog.validate_catalog`.
Loaded at startup. A flag (`--catalog` / env var) lets the simulator point at
`catalog/library.jsonl` instead when it is non-empty — **same loader**, so swapping
in the real catalog later is free.
---
## 5. The API
### `POST /api/select`
Request:
```json
{ "left":0-4, "right":0-4, "dark":0-4, "light":0-4,
"mode":"none|audio|video|av",
"pool_size":int, "weights":{"brain":float,"mood":float},
"approved_only":bool }
```
Response:
```json
{ "pick": <record|null>,
"pool": [ { "record": <record>, "distance": float, "rank": int }, ... ],
"coverage": { "candidates_in_mode": int } }
```
- `none` selector mode → `pick:null`, `pool:[]` (the void/rest state).
- No candidates → `pick:null`, `pool:[]`.
- **Deterministic pick.** The displayed `pick` is the rank-1 (nearest) candidate —
`select()` called with `rng=None` — so the X-ray is legible and stable for a
given knob position. The room *shuffles* within the nearest pool at runtime;
the returned `pool` IS that shuffle-set, so the curator sees every piece the
real installation could surface there.
- Knob values are constrained 04 and `mode` to the four values by Pydantic; bad
input is a clean `422`.
- Backed by `hef.selection.ranked_candidates()` (pool) + `select()` (the pick).
### `GET /api/catalog/meta`
Counts, per-mode breakdown, `proposed`/`approved` split, and coverage stats for
the header.
---
## 6. The X-ray UI
**Controls**
- The five real dials: a 4-way mode selector (None/Audio/Video/A+V) and four 04
knobs (Left, Right, Dark, Light).
- The model knobs (so the algorithm's own parameters can be felt): brain/mood
**weight sliders**, **pool-size** control, and an **approved-only** toggle.
**Readout**
- **Picked piece:** title, mode, coordinate, distance, rationale.
- **Ranked pool:** the N nearest with distances, winner highlighted.
- **Two 5×5 grid maps** (brain L×R, mood D×L): the current knob point plus where
candidates sit, so the neighborhood that produced the pick is visible at a
glance.
---
## 7. Testing
- **Unit (`hef`):** `ranked_candidates()` ordering, the `av` pool fallback,
`approved_only` filtering, weight effects; `select()` still behaves as before.
- **Fixtures:** coverage test (every mode populated; space spanned; output passes
`validate_catalog`).
- **API:** FastAPI `TestClient` contract tests — valid select returns ranked pool;
`none` returns the void; bad input → 422; `approved_only` narrows the pool.
- **Behavior:** the felt behaviors are captured as gherkin scenarios under
`features/` (this session's BDD deliverable).
---
## 8. Explicitly out of scope (YAGNI)
- Real media playback (audio/video) — the tool reads the model, not the content.
- Writing/editing catalog records back — curation stays in the sub-project-2 tools.
- Networking, auth, multi-user, session recording.
- Any reimplementation of selection logic outside `hef`.
---
## 9. Relationship to the roadmap
This is an **upstream discovery vehicle** for the physical stack (sub-projects 3
and 4). It exercises sub-project 1's `hef.selection` against a stand-in catalog and
is the richer, experienceable form of the "keyboard/serial stand-in" the roadmap
anticipates for sub-project 3. It does not replace the Pi player or the firmware;
it de-risks their *principles and interface* first. The synthetic catalog is a
stand-in for the sub-project-2 output and is swapped for the real catalog through
the same loader once ingest has populated it.
+25
View File
@@ -0,0 +1,25 @@
Feature: Synthetic fixture catalog
As a curator with an empty real catalog
I want a dense, representative stand-in catalog generated on demand
So that I can feel nearest-match behavior everywhere before any media is ingested
Scenario: The fixture catalog spans the coordinate space and all content modes
When the fixture catalog is generated
Then it contains records covering the brain plane and the mood plane
And it contains records in each of the "audio", "video", and "av" modes
And it contains a mix of "proposed" and "approved" records
Scenario: The fixture catalog is valid
When the fixture catalog is generated
Then every record passes hef.catalog validation
And no record references real media
Scenario: Generation is deterministic
Given a fixed seed
When the fixture catalog is generated twice
Then both runs produce identical records
Scenario: The real catalog is used when it is populated
Given catalog/library.jsonl is non-empty
When the simulator starts pointed at the real catalog
Then it loads the real records through the same loader instead of the fixtures
+32
View File
@@ -0,0 +1,32 @@
Feature: Feeling the model's own parameters
As a curator tuning the algorithm, not just the curation
I want to adjust the selection model's parameters live
So that I can feel how weighting, pool size, and the approved gate reshape picks
Background:
Given the simulator is loaded with the synthetic fixture catalog
And the dials are set to a fixed knob position with mode "av"
Scenario: Reweighting the brain plane can change the winner
Given the brain and mood weights are equal
And the current pick is recorded
When I increase the brain weight relative to the mood weight
Then the ranked pool may reorder so that brain-plane distance dominates
And the picked piece can differ from the recorded pick
Scenario: Pool size controls how many candidates are shown and shuffled
When I set the pool size to 1
Then the pool shows exactly the single nearest candidate
When I set the pool size to 5
Then the pool shows up to the five nearest candidates
Scenario: Approved-only narrows the pool to human-blessed records
Given the fixture catalog contains both "proposed" and "approved" records
When I turn the approved-only toggle on
Then every candidate in the pool has review_status "approved"
When I turn the approved-only toggle off
Then "proposed" records may appear in the pool again
Scenario: Default model parameters reproduce the shipped selection behavior
Given the weights are equal, the pool size is the default, and approved-only is off
Then the picked piece matches what hef.selection.select would return for the same dials
+48
View File
@@ -0,0 +1,48 @@
Feature: Curator's X-ray of the selection model
As a curator
I want to turn the installation's dials against a representative catalog and see
the full ranked pool the nearest-match algorithm picks from
So that I can judge whether the tagging rubric and selection surface fitting pieces
Background:
Given the simulator is loaded with the synthetic fixture catalog
And the fixture catalog spans the brain plane and the mood plane across all content modes
Scenario: A pick is always accompanied by its candidate pool
When I set the mode to "av" and the dials to left=1 right=3 dark=4 light=1
Then a piece is picked
And the ranked pool is shown nearest-first with each candidate's distance
And the picked piece is the rank-1 candidate in the pool
Scenario: Turning Dark up while Light stays low pulls the pick toward somber pieces
Given the mode is "av" and the dials are left=2 right=2 dark=0 light=0
When I raise the Dark dial from 0 to 4
Then the picked piece's dark coordinate is higher than before
And the pool's nearest candidates cluster toward the high-dark / low-light region
Scenario: Turning a dial moves the knob point on the coordinate map
Given the mode is "av"
When I change any of the Left, Right, Dark, or Light dials
Then the knob point on the brain or mood grid map moves to the new coordinate
And the candidate dots reflect the new neighborhood
Scenario: None mode is the void / rest state
When I set the mode to "none"
Then no piece is picked
And the pool is empty
And the screen shows the void / rest state
Scenario: A+V mode falls back to audio and video when av records are thin
Given the fixture catalog has fewer than the pool size of native "av" records near the knob point
When I set the mode to "av"
Then the pool includes "audio" and "video" records so it is not starved
Scenario Outline: Each content mode restricts the pool to eligible records
When I set the mode to "<mode>"
Then every candidate in the pool is eligible for "<mode>"
Examples:
| mode |
| audio |
| video |
| av |
+27
View File
@@ -0,0 +1,27 @@
Feature: Simulator service and API
As an operator
I want to run the simulator locally in one container and drive it over a small API
So that the browser X-ray and its behavior are testable without the physical stack
Scenario: The service runs locally in a container
When I start the simulator container
Then the X-ray is reachable in a browser on localhost
And no external network access is required
Scenario: Selecting returns the pick and the ranked pool
When I POST a valid selection request with dials and mode "av"
Then the response contains a picked record
And the response contains the ranked pool with a distance and rank per candidate
Scenario: None mode returns the void over the API
When I POST a selection request with mode "none"
Then the response pick is null
And the response pool is empty
Scenario: Invalid dial values are rejected
When I POST a selection request with a dial value outside 0 to 4
Then the API responds with a validation error
Scenario: Catalog metadata is available for the header
When I GET the catalog metadata
Then the response reports record counts, a per-mode breakdown, and the proposed/approved split