diff --git a/.gitignore b/.gitignore index 287ef9d..4cb0a89 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .pytest_cache/ .venv/ media/ +.superpowers/ diff --git a/docs/superpowers/specs/2026-06-04-experience-simulator-design.md b/docs/superpowers/specs/2026-06-04-experience-simulator-design.md new file mode 100644 index 0000000..f09333a --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-experience-simulator-design.md @@ -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": , + "pool": [ { "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. diff --git a/features/fixture_catalog.feature b/features/fixture_catalog.feature new file mode 100644 index 0000000..4b9cc3d --- /dev/null +++ b/features/fixture_catalog.feature @@ -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 diff --git a/features/model_knobs.feature b/features/model_knobs.feature new file mode 100644 index 0000000..8fa8c3e --- /dev/null +++ b/features/model_knobs.feature @@ -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 diff --git a/features/selection_xray.feature b/features/selection_xray.feature new file mode 100644 index 0000000..35601e5 --- /dev/null +++ b/features/selection_xray.feature @@ -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 "" + Then every candidate in the pool is eligible for "" + + Examples: + | mode | + | audio | + | video | + | av | diff --git a/features/simulator_service.feature b/features/simulator_service.feature new file mode 100644 index 0000000..c7e6403 --- /dev/null +++ b/features/simulator_service.feature @@ -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