# 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.
