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:
@@ -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 0–4 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 0–4
|
||||
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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user