diff --git a/.gitignore b/.gitignore
index 2c62fc9..cdd216c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@ __pycache__/
media/
.superpowers/
*.egg-info/
+# Simulator sample media (look-tuning only; populate via setup_sample_media.py)
+simulator/sample_media/forest/*.mp4
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 50efbc7..5667f11 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -123,6 +123,22 @@ longer *select* a pre-tagged clip; they drive an **alteration engine** (design
Plan: [`2026-06-05-player-alteration-core.md`](./superpowers/plans/2026-06-05-player-alteration-core.md).
+**Slice 2 — simulator-first alteration preview ✅ Done.** Merged to `main`
+(session 0009). Wires the alteration engine into the web simulator so the look is
+tunable by eye before any hardware, and **reconciles** the unmerged session-0007
+design with the merged session-0008 design (the Left-HUD conflict): Left is a
+**runtime overlay** (authored annotation track + per-language string tables),
+Right is a **discrete pre-baked** flow-stabilized variant, Dark/Light a **live**
+grade — superseding 0007's baked-HUD 5×5 grid. Engine: a parameterized
+`Calibration` (tuned by eye, baked into `DEFAULT_CALIBRATION`), `Restyle.variant`
+(discrete) replacing the continuous blend, and `AnalyticalOverlay.level`. Sim:
+`/api/alteration` + `/api/clips` over `simulator/clips.py`, an alteration-preview
+UI, and one neutral clip with a real flow-stabilized Right variant from the POC.
+Design:
+[`2026-06-07-reconciled-simulator-alteration-slice-design.md`](./superpowers/specs/2026-06-07-reconciled-simulator-alteration-slice-design.md);
+plan:
+[`2026-06-07-reconciled-simulator-alteration-slice.md`](./superpowers/plans/2026-06-07-reconciled-simulator-alteration-slice.md).
+
**Remaining slices (not started):**
- **Runtime renderer** — drive the single panoramic projector via mpv/ffmpeg;
@@ -134,7 +150,12 @@ Plan: [`2026-06-05-player-alteration-core.md`](./superpowers/plans/2026-06-05-pl
`Controls` stream).
- **White-noise generation** — runtime white/pink noise for that content position.
- **Offline v2v variant pipeline** — author the pre-baked Right restyle variants
- (the only paid AI step, design §9) + the multilingual label/string tables + TTS.
+ via the local flow-stabilized SD pipeline (now ~free, not a paid API — see the
+ scales-library/right-axis design §1/§4); a real multi-strength flow-stabilized
+ re-bake per base clip + the multilingual label/string tables + TTS.
+- **Scale-ring navigation** — the endless rotary encoder + short pre-baked AI
+ zoom/warp transitions between neutral "scales of nature" clips on a closed ring
+ (scales-library design §3); a new control + offline pipeline element.
- **Catalog model changes** — audio *source* + "neutral base" vs "altered
variant" flag (sub-project 2 territory, design §13).
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 232e515..ad95237 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -287,11 +287,23 @@ Records point at files via `file_path`. Those files live on the player's drive,
`file_path` values consistent with wherever you mount that drive on the machine
that will eventually run the room.
-## Playing with the simulator (curator's X-ray)
+## Playing with the simulator (alteration preview)
The simulator is a web stand-in for the installation's control panel. It runs the
-real `hef.selection` code against a synthetic catalog so you can feel whether the
-dials surface fitting pieces before any hardware exists.
+real `player.alteration` engine and **alters** a neutral base clip toward the knob
+state in the browser, so you can tune the *look* of the filter before any hardware
+exists. (The earlier selection-era "curator's X-ray" view was retired when the
+piece moved from *selecting* clips to *altering* them.)
+
+**One-time setup — populate the sample footage** (look-tuning only; not shipped
+content):
+
+ python simulator/setup_sample_media.py
+
+This copies the session-0008 POC artifacts (`~/hef-poc/out/`) into
+`simulator/sample_media/forest/` — the neutral base clip and the real
+flow-stabilized Right restyle — and generates placeholder intermediate Right
+strengths. The `.mp4` binaries are gitignored.
**Run it (Docker):**
@@ -304,11 +316,22 @@ then open http://localhost:8000.
pip install -e ".[sim]"
make sim-local
-**What you see:** the five real dials (mode + Left/Right/Dark/Light), the model
-knobs (brain/mood weights, pool size, approved-only), and the X-ray — the picked
-piece, the ranked candidate pool with distances, and brain/mood coordinate maps
-showing where your knob point and the candidates sit.
+**What you see and can do:**
-By default it loads a generated fixture catalog. To point it at a real catalog,
-set `HEF_SIM_CATALOG=catalog/library.jsonl` (used automatically when that file is
-non-empty).
+- **Content dial** — picks audio/video channel; "off" and audio-only positions go
+ to black walls.
+- **Four experience knobs (0–4):**
+ - **Dark / Light** — a live runtime color grade (cool/dark ↔ warm/bright; equal
+ or zero = the raw footage).
+ - **Right (dreamlike)** — selects a discrete pre-baked, flow-stabilized restyle
+ variant and crossfades to it (strength 0 = raw base).
+ - **Left (analytical)** — a live overlay: labelled boxes from the clip's authored
+ annotation track, with more annotations appearing at higher levels. Text is
+ shaped live (the simulator analogue of the Pi's Pango/HarfBuzz path).
+- **Calibration sliders** — adjust the grade/overlay gain curves live; once a look
+ is liked, bake the values into `DEFAULT_CALIBRATION` in `player/alteration.py`.
+- **RenderPlan readout** — always shows the exact numbers the engine produced (the
+ project's honesty "X-ray," now over the alteration model).
+
+The base clips, Right variants, Left annotation track, and string tables come from
+`simulator/sample_media/manifest.json`.
diff --git a/docs/superpowers/plans/2026-06-07-reconciled-simulator-alteration-slice.md b/docs/superpowers/plans/2026-06-07-reconciled-simulator-alteration-slice.md
new file mode 100644
index 0000000..4166be1
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-07-reconciled-simulator-alteration-slice.md
@@ -0,0 +1,1208 @@
+# Reconciled Simulator-First Alteration Slice — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Wire the alteration engine into the simulator — live Dark/Light grade, a live Left analytical overlay (authored annotation track + runtime-shaped text), and discrete pre-baked flow-stabilized Right variants over ONE neutral clip — so the look can be tuned by eye before any hardware.
+
+**Architecture:** Python stays the single source of truth for the alteration math (`player/alteration.py`); the browser only renders. The engine model changes are surgical: a continuous `Restyle.blend` becomes a discrete `Restyle.variant` (selecting a pre-baked clip), `AnalyticalOverlay` gains a Left `level`, and a new frozen `Calibration` parameterizes the knob→strength curves (default behavior-preserving). The simulator retires its selection-era surface (`/api/select`, X-ray, `fixtures.py`) for an alteration surface (`/api/alteration`, `/api/clips`, `clips.py`) reading a base-clip + variant + annotation manifest.
+
+**Tech Stack:** Python 3.13, FastAPI + pydantic, pytest, frozen dataclasses; vanilla JS + CSS filters / SVG for the browser; ffmpeg (via `imageio-ffmpeg` or system `ffmpeg`) for placeholder media.
+
+**Spec:** `docs/superpowers/specs/2026-06-07-reconciled-simulator-alteration-slice-design.md`
+
+---
+
+## File Structure
+
+**Engine (modify):**
+- `player/alteration.py` — add `Calibration` + `DEFAULT_CALIBRATION`; `Restyle.blend: float` → `Restyle.variant: int`; `AnalyticalOverlay` gains `level: int`; `plan_alteration(coord, calibration=DEFAULT_CALIBRATION)`; a `render_plan_to_dict` serializer for the API.
+- `player/state.py` — no logic change (compares whole `Restyle` objects); docstring touch only.
+
+**Simulator (create/rewrite):**
+- `simulator/clips.py` (create) — load a base-clip + variant + annotation manifest; replaces `simulator/fixtures.py` (delete).
+- `simulator/app.py` (rewrite) — `POST /api/alteration`, `GET /api/clips`; remove `/api/select`, `/api/catalog/meta`.
+- `simulator/static/{index.html,app.js,style.css}` (rewrite) — alteration preview.
+- `simulator/sample_media/manifest.json` (create, committed) + `simulator/sample_media/README.md`; media files themselves gitignored.
+- `simulator/setup_sample_media.py` (create) — copy POC clips + generate placeholder Right strengths.
+
+**Tests (create/rewrite):**
+- `tests/test_player_alteration.py` (rewrite for new model).
+- `tests/test_player_state.py` (update `.blend`→`.variant` references).
+- `tests/test_clips.py` (create); `tests/test_fixtures.py` (delete).
+- `tests/test_simulator_api.py` (rewrite for new endpoints).
+
+**Docs:**
+- `docs/superpowers/specs/2026-06-05-machine-altered-perception-design.md` — §4.3/§10 pointer to the reconciliation.
+- `docs/ROADMAP.md` — §3 slice status.
+- `docs/USER_GUIDE.md` — "Playing with the simulator" rewrite.
+
+---
+
+## Task 1: Engine — `Calibration`, discrete `Restyle.variant`, overlay `level`
+
+**Files:**
+- Modify: `player/alteration.py`
+- Test: `tests/test_player_alteration.py` (rewrite)
+
+- [ ] **Step 1: Rewrite the failing tests for the new model**
+
+Replace the entire contents of `tests/test_player_alteration.py`:
+
+```python
+import pytest
+
+from hef.selection import Coordinate
+from player.alteration import (
+ DEFAULT_CALIBRATION,
+ AnalyticalOverlay,
+ Calibration,
+ ColorGrade,
+ RenderPlan,
+ Restyle,
+ plan_alteration,
+ render_plan_to_dict,
+)
+
+
+def _coord(left=0, right=0, dark=0, light=0):
+ return Coordinate(left=left, right=right, dark=dark, light=light)
+
+
+def test_all_zero_knobs_is_the_unaltered_base():
+ plan = plan_alteration(_coord())
+ assert plan.is_identity
+ assert plan.overlay.level == 0
+ assert plan.overlay.intensity == 0.0
+ assert plan.restyle.variant == 0
+ assert plan.grade.tone == 0.0
+ assert plan.grade.is_identity
+
+
+def test_left_drives_the_analytical_overlay_only():
+ plan = plan_alteration(_coord(left=4))
+ assert plan.overlay.level == 4
+ assert plan.overlay.intensity == 1.0
+ assert plan.restyle.variant == 0 # Left does not touch the substrate
+ assert plan.grade.tone == 0.0
+
+
+def test_right_selects_a_discrete_restyle_variant_only():
+ plan = plan_alteration(_coord(right=2))
+ assert plan.restyle.variant == 2
+ assert plan.overlay.level == 0 # Right does not add overlay
+
+
+def test_left_and_right_stack_not_cancel():
+ # design §4.2: whole-brain corner = dreamlike substrate WITH labels on top
+ plan = plan_alteration(_coord(left=4, right=4))
+ assert plan.overlay.level == 4
+ assert plan.restyle.variant == 4
+
+
+def test_light_pole_grades_warm_positive_tone():
+ plan = plan_alteration(_coord(light=4))
+ assert plan.grade.tone == 1.0
+ assert not plan.grade.is_identity
+
+
+def test_dark_pole_grades_cool_negative_tone():
+ assert plan_alteration(_coord(dark=4)).grade.tone == -1.0
+
+
+def test_equal_dark_and_light_is_identity_grade():
+ # design §5: the mood center is the raw, ungraded footage
+ assert plan_alteration(_coord(dark=3, light=3)).grade.is_identity
+ assert plan_alteration(_coord(dark=2, light=2)).grade.is_identity
+
+
+def test_dark_minus_light_sets_intermediate_tone():
+ assert plan_alteration(_coord(dark=4, light=2)).grade.tone == pytest.approx(-0.5)
+ assert plan_alteration(_coord(dark=1, light=3)).grade.tone == pytest.approx(0.5)
+
+
+def test_whole_brain_dark_corner_stacks_grade_substrate_and_overlay():
+ plan = plan_alteration(_coord(left=4, right=2, dark=4, light=0))
+ assert plan.overlay.level == 4
+ assert plan.restyle.variant == 2
+ assert plan.grade.tone == -1.0
+ assert not plan.is_identity
+
+
+def test_default_calibration_is_behavior_preserving():
+ # DEFAULT_CALIBRATION must reproduce the original three helpers exactly.
+ for left in range(5):
+ assert plan_alteration(_coord(left=left)).overlay.intensity == pytest.approx(left / 4)
+ for right in range(5):
+ assert plan_alteration(_coord(right=right)).restyle.variant == right
+ for dark in range(5):
+ for light in range(5):
+ expected = (light - dark) / 4
+ assert plan_alteration(_coord(dark=dark, light=light)).grade.tone == pytest.approx(expected)
+
+
+def test_custom_calibration_scales_mood_and_overlay():
+ cal = Calibration(mood_gain=0.5, overlay_gain=0.5, right_variant_map=(0, 0, 1, 1, 2))
+ assert plan_alteration(_coord(light=4), cal).grade.tone == pytest.approx(0.5)
+ assert plan_alteration(_coord(left=4), cal).overlay.intensity == pytest.approx(0.5)
+ assert plan_alteration(_coord(right=3), cal).restyle.variant == 1
+
+
+def test_calibration_gain_is_clamped_to_unit_range():
+ cal = Calibration(mood_gain=10.0, overlay_gain=10.0)
+ assert plan_alteration(_coord(light=4), cal).grade.tone == 1.0 # clamped, not 10
+ assert plan_alteration(_coord(left=4), cal).overlay.intensity == 1.0
+
+
+def test_render_plan_to_dict_round_trips_the_numbers():
+ d = render_plan_to_dict(plan_alteration(_coord(left=4, right=2, dark=4, light=0)))
+ assert d == {
+ "grade": {"tone": -1.0},
+ "overlay": {"level": 4, "intensity": 1.0},
+ "restyle": {"variant": 2},
+ "is_identity": False,
+ }
+
+
+def test_render_plan_is_frozen():
+ plan = plan_alteration(_coord())
+ with pytest.raises(Exception):
+ plan.grade.tone = 0.5 # type: ignore[misc]
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `python -m pytest tests/test_player_alteration.py -q`
+Expected: FAIL — `ImportError` for `Calibration` / `DEFAULT_CALIBRATION` / `render_plan_to_dict` (and `Restyle.variant` / `overlay.level` attribute errors).
+
+- [ ] **Step 3: Rewrite `player/alteration.py` for the new model**
+
+Replace the entire contents of `player/alteration.py`:
+
+```python
+"""The alteration engine: a knob vector -> a layered RenderPlan (design §4, §5).
+
+Reconciled slice (2026-06-07): the Right axis is a DISCRETE selection of a
+pre-baked, flow-stabilized restyle variant (not a continuous blend), the Left
+axis carries its knob LEVEL so a runtime annotation track can pick which labels
+show, and a frozen `Calibration` parameterizes the knob->strength curves so they
+can be tuned by eye in the simulator and baked into DEFAULT_CALIBRATION.
+
+Layers compose per §4.2:
+ - Substrate: ColorGrade (Dark/Light mood, center = identity §5) + a pre-baked
+ Right restyle variant.
+ - Overlay: AnalyticalOverlay (Left), composited on top at runtime.
+Left and Right stack (different layers); Dark/Light are the two poles of one
+mood grade. See docs/superpowers/specs/2026-06-07-reconciled-simulator-
+alteration-slice-design.md.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from hef.selection import Coordinate
+
+KNOB_MAX = 4 # knob full-scale (0..4)
+
+
+def _clamp(x: float, lo: float, hi: float) -> float:
+ return max(lo, min(hi, x))
+
+
+@dataclass(frozen=True)
+class Calibration:
+ """Tunable knob->strength curves (settled by eye in the sim, then baked).
+
+ - mood_gain: scales the signed Dark/Light tone (result clamped to [-1, 1]).
+ - overlay_gain: scales the Left overlay intensity (clamped to [0, 1]).
+ - right_variant_map: knob value (0..4) -> pre-baked Right variant index.
+ """
+
+ mood_gain: float = 1.0
+ overlay_gain: float = 1.0
+ right_variant_map: tuple = (0, 1, 2, 3, 4)
+
+
+DEFAULT_CALIBRATION = Calibration()
+
+
+@dataclass(frozen=True)
+class ColorGrade:
+ """Mood-axis grade (§5). `tone` is signed: >0 warm yellow->white (light),
+ <0 cool blue->black (dark), 0 = identity (raw, ungraded)."""
+
+ tone: float
+
+ @property
+ def is_identity(self) -> bool:
+ return self.tone == 0.0
+
+
+@dataclass(frozen=True)
+class AnalyticalOverlay:
+ """Left axis (§4.1): analytical HUD/annotation, composited on top at runtime.
+ `level` is the Left knob (0..4); a runtime annotation track uses it to pick
+ which labels appear. `intensity` 0..1 is the overlay opacity/strength."""
+
+ level: int
+ intensity: float
+
+
+@dataclass(frozen=True)
+class Restyle:
+ """Right axis (§4.1): selects a pre-baked, flow-stabilized restyle variant.
+ `variant` is a discrete index (0 = raw base, no restyle)."""
+
+ variant: int
+
+
+@dataclass(frozen=True)
+class RenderPlan:
+ """The full layered alteration for one knob vector (§4.2)."""
+
+ grade: ColorGrade
+ overlay: AnalyticalOverlay
+ restyle: Restyle
+
+ @property
+ def is_identity(self) -> bool:
+ """True when the plan leaves the neutral base un-altered."""
+ return (
+ self.grade.is_identity
+ and self.overlay.level == 0
+ and self.restyle.variant == 0
+ )
+
+
+def _overlay_intensity(left: int, cal: Calibration) -> float:
+ return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0)
+
+
+def _right_variant(right: int, cal: Calibration) -> int:
+ return cal.right_variant_map[right]
+
+
+def _mood_tone(dark: int, light: int, cal: Calibration) -> float:
+ return _clamp(cal.mood_gain * (light - dark) / KNOB_MAX, -1.0, 1.0)
+
+
+def plan_alteration(
+ coord: Coordinate, calibration: Calibration = DEFAULT_CALIBRATION
+) -> RenderPlan:
+ """Map a knob vector to its layered RenderPlan (design §4)."""
+ return RenderPlan(
+ grade=ColorGrade(tone=_mood_tone(coord.dark, coord.light, calibration)),
+ overlay=AnalyticalOverlay(
+ level=coord.left,
+ intensity=_overlay_intensity(coord.left, calibration),
+ ),
+ restyle=Restyle(variant=_right_variant(coord.right, calibration)),
+ )
+
+
+def render_plan_to_dict(plan: RenderPlan) -> dict:
+ """JSON-serializable form for the simulator API."""
+ return {
+ "grade": {"tone": plan.grade.tone},
+ "overlay": {"level": plan.overlay.level, "intensity": plan.overlay.intensity},
+ "restyle": {"variant": plan.restyle.variant},
+ "is_identity": plan.is_identity,
+ }
+```
+
+Note: `field` import is unused — drop it; only `dataclass` is needed. (Final import line: `from dataclasses import dataclass`.)
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `python -m pytest tests/test_player_alteration.py -q`
+Expected: PASS (all).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add player/alteration.py tests/test_player_alteration.py
+git commit -m "feat(player): discrete Right variant + Left level + Calibration (slice design §2)"
+```
+
+---
+
+## Task 2: `state.py` reconcile (field rename in tests; no logic change)
+
+**Files:**
+- Modify: `player/state.py` (docstring only)
+- Test: `tests/test_player_state.py`
+
+- [ ] **Step 1: Update the failing test references**
+
+In `tests/test_player_state.py`, change the two `.blend` assertions to `.variant` and the overlay assertion to `.level`:
+
+Replace `test_overlay_change_is_a_live_update` body's last assertion line:
+```python
+ assert t.playback.plan.overlay.level == 4
+```
+Replace `test_restyle_change_crossfades_the_substrate` body's last assertion line:
+```python
+ assert t.playback.plan.restyle.variant == 4
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `python -m pytest tests/test_player_state.py -q`
+Expected: FAIL — `AttributeError: 'Restyle' object has no attribute 'blend'` is already gone after Task 1, so the failure is now the *test's own* stale references resolved by Step 1; re-running confirms. If Step 1 is applied, this should already pass — run to confirm the suite is green.
+
+- [ ] **Step 3: Touch the `state.py` docstring**
+
+In `player/state.py`, update the module docstring sentence mentioning the Right variant to read:
+```
+swapping the clip or the pre-baked Right restyle variant (a discrete index)
+needs a CROSSFADE
+```
+(No code change — `_classify` already compares whole `Restyle` objects via `nxt.plan.restyle != prev.plan.restyle`.)
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `python -m pytest tests/test_player_state.py -q`
+Expected: PASS (all).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add player/state.py tests/test_player_state.py
+git commit -m "refactor(player): state.py tests track discrete Restyle.variant"
+```
+
+---
+
+## Task 3: `simulator/clips.py` — the base-clip + variant + annotation manifest
+
+**Files:**
+- Create: `simulator/clips.py`
+- Create: `tests/test_clips.py`
+- Delete: `simulator/fixtures.py`, `tests/test_fixtures.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/test_clips.py`:
+
+```python
+import json
+
+import pytest
+
+from simulator.clips import Clip, load_manifest
+
+
+def _manifest_dict():
+ return {
+ "clips": [
+ {
+ "id": "forest",
+ "title": "Yosemite Falls (neutral)",
+ "base_file": "forest/base.mp4",
+ "license": "poc-sample",
+ "source": "hef-poc",
+ "right_variants": {
+ "4": {"file": "forest/right4.mp4", "model": "sd-turbo+flow"},
+ "1": {"file": "forest/right1.mp4"},
+ },
+ "annotations": [
+ {"key": "detected.water", "box": [0.1, 0.2, 0.3, 0.4], "min_level": 1},
+ {"key": "detected.conifer", "box": [0.6, 0.1, 0.2, 0.2], "min_level": 3},
+ ],
+ "strings": {"en": {"detected.water": "flowing water", "detected.conifer": "conifer"}},
+ }
+ ]
+ }
+
+
+def test_load_manifest_parses_clips(tmp_path):
+ p = tmp_path / "manifest.json"
+ p.write_text(json.dumps(_manifest_dict()))
+ clips = load_manifest(p)
+ assert len(clips) == 1
+ c = clips[0]
+ assert isinstance(c, Clip)
+ assert c.id == "forest"
+ assert c.base_file == "forest/base.mp4"
+
+
+def test_clip_lists_variant_files_by_strength(tmp_path):
+ p = tmp_path / "manifest.json"
+ p.write_text(json.dumps(_manifest_dict()))
+ c = load_manifest(p)[0]
+ # variant 0 is always the raw base; authored strengths come from the manifest
+ assert c.variant_file(0) == "forest/base.mp4"
+ assert c.variant_file(4) == "forest/right4.mp4"
+ assert c.variant_file(1) == "forest/right1.mp4"
+ # an unauthored strength falls back to the raw base
+ assert c.variant_file(2) == "forest/base.mp4"
+
+
+def test_clip_serializes_to_dict_for_the_api(tmp_path):
+ p = tmp_path / "manifest.json"
+ p.write_text(json.dumps(_manifest_dict()))
+ d = load_manifest(p)[0].to_dict()
+ assert d["id"] == "forest"
+ assert d["base_file"] == "forest/base.mp4"
+ assert d["annotations"][0]["key"] == "detected.water"
+ assert d["strings"]["en"]["detected.water"] == "flowing water"
+ # variant map is exposed keyed by strength string, including 0 -> base
+ assert d["right_variants"]["0"]["file"] == "forest/base.mp4"
+ assert d["right_variants"]["4"]["file"] == "forest/right4.mp4"
+
+
+def test_missing_manifest_raises(tmp_path):
+ with pytest.raises(FileNotFoundError):
+ load_manifest(tmp_path / "nope.json")
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `python -m pytest tests/test_clips.py -q`
+Expected: FAIL — `ModuleNotFoundError: No module named 'simulator.clips'`.
+
+- [ ] **Step 3: Create `simulator/clips.py`**
+
+```python
+"""The base-clip + variant + annotation manifest the simulator renders.
+
+Replaces simulator/fixtures.py (the selection-era synthetic catalog). Each base
+clip carries: the raw base file, a map of pre-baked Right-strength variant files
+(strength 0 is always the raw base), an authored Left annotation track (box +
+label key + the minimum Left level at which it appears), and per-language string
+tables. See the reconciled-simulator-alteration-slice design §3.2.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+
+@dataclass(frozen=True)
+class Clip:
+ id: str
+ title: str
+ base_file: str
+ license: str
+ source: str
+ right_variants: dict # {"1": {"file": ...}, "4": {...}} (no "0")
+ annotations: list # [{"key", "box":[x,y,w,h], "min_level"}, ...]
+ strings: dict # {"en": {key: text}}
+
+ def variant_file(self, strength: int) -> str:
+ """The video file for a Right strength; 0 and any unauthored strength
+ fall back to the raw base file."""
+ entry = self.right_variants.get(str(strength))
+ return entry["file"] if entry else self.base_file
+
+ def to_dict(self) -> dict:
+ variants = {"0": {"file": self.base_file, "raw": True}}
+ for k, v in self.right_variants.items():
+ variants[k] = v
+ return {
+ "id": self.id,
+ "title": self.title,
+ "base_file": self.base_file,
+ "license": self.license,
+ "source": self.source,
+ "right_variants": variants,
+ "annotations": self.annotations,
+ "strings": self.strings,
+ }
+
+
+def _clip_from_dict(d: dict[str, Any]) -> Clip:
+ return Clip(
+ id=d["id"],
+ title=d["title"],
+ base_file=d["base_file"],
+ license=d.get("license", ""),
+ source=d.get("source", ""),
+ right_variants=d.get("right_variants", {}),
+ annotations=d.get("annotations", []),
+ strings=d.get("strings", {}),
+ )
+
+
+def load_manifest(path: str | Path) -> list[Clip]:
+ """Load the base-clip manifest. Raises FileNotFoundError if missing."""
+ path = Path(path)
+ data = json.loads(path.read_text())
+ return [_clip_from_dict(c) for c in data["clips"]]
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `python -m pytest tests/test_clips.py -q`
+Expected: PASS (all).
+
+- [ ] **Step 5: Delete the retired fixtures**
+
+```bash
+git rm simulator/fixtures.py tests/test_fixtures.py
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add simulator/clips.py tests/test_clips.py
+git commit -m "feat(simulator): clips.py manifest model; retire selection fixtures"
+```
+
+---
+
+## Task 4: `simulator/app.py` — `/api/alteration` + `/api/clips`
+
+**Files:**
+- Modify: `simulator/app.py` (rewrite)
+- Test: `tests/test_simulator_api.py` (rewrite)
+
+- [ ] **Step 1: Rewrite the failing tests**
+
+Replace the entire contents of `tests/test_simulator_api.py`:
+
+```python
+import json
+
+import pytest
+from fastapi.testclient import TestClient
+
+from simulator.app import create_app
+
+
+@pytest.fixture
+def manifest_path(tmp_path):
+ p = tmp_path / "manifest.json"
+ p.write_text(json.dumps({
+ "clips": [{
+ "id": "forest",
+ "title": "neutral forest",
+ "base_file": "forest/base.mp4",
+ "license": "poc", "source": "hef-poc",
+ "right_variants": {"4": {"file": "forest/right4.mp4"}},
+ "annotations": [{"key": "detected.water", "box": [0.1, 0.2, 0.3, 0.4], "min_level": 1}],
+ "strings": {"en": {"detected.water": "flowing water"}},
+ }]
+ }))
+ return p
+
+
+@pytest.fixture
+def client(manifest_path):
+ return TestClient(create_app(manifest_path=manifest_path))
+
+
+def _controls(content="video", left=0, right=0, dark=0, light=0, volume=2, brightness=2):
+ return dict(content=content, left=left, right=right, dark=dark,
+ light=light, volume=volume, brightness=brightness)
+
+
+def test_alteration_returns_the_engine_plan(client):
+ resp = client.post("/api/alteration", json={"controls": _controls(left=4, right=2, dark=4)})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["plan"]["overlay"]["level"] == 4
+ assert data["plan"]["restyle"]["variant"] == 2
+ assert data["plan"]["grade"]["tone"] == -1.0
+ assert data["content"]["video"] is True
+
+
+def test_alteration_honors_off_as_black(client):
+ resp = client.post("/api/alteration", json={"controls": _controls(content="off")})
+ data = resp.json()
+ assert data["content"]["video"] is False
+
+
+def test_alteration_accepts_calibration(client):
+ body = {"controls": _controls(light=4),
+ "calibration": {"mood_gain": 0.5, "overlay_gain": 1.0, "right_variant_map": [0, 1, 2, 3, 4]}}
+ resp = client.post("/api/alteration", json=body)
+ assert resp.json()["plan"]["grade"]["tone"] == 0.5
+
+
+def test_alteration_rejects_out_of_range_knob(client):
+ resp = client.post("/api/alteration", json={"controls": _controls(left=7)})
+ assert resp.status_code == 422
+
+
+def test_alteration_rejects_bad_content(client):
+ resp = client.post("/api/alteration", json={"controls": _controls(content="banana")})
+ assert resp.status_code == 422
+
+
+def test_clips_returns_the_manifest(client):
+ resp = client.get("/api/clips")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["clips"][0]["id"] == "forest"
+ assert data["clips"][0]["right_variants"]["0"]["file"] == "forest/base.mp4"
+ assert data["clips"][0]["annotations"][0]["key"] == "detected.water"
+
+
+def test_retired_selection_endpoints_are_gone(client):
+ assert client.post("/api/select", json={}).status_code == 404
+ assert client.get("/api/catalog/meta").status_code == 404
+
+
+def test_index_is_served():
+ client = TestClient(create_app())
+ resp = client.get("/")
+ assert resp.status_code == 200
+ assert "text/html" in resp.headers["content-type"]
+ assert "Alteration" in resp.text
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `python -m pytest tests/test_simulator_api.py -q`
+Expected: FAIL — `create_app` has no `manifest_path` kwarg; `/api/alteration` 404; old selection imports break.
+
+- [ ] **Step 3: Rewrite `simulator/app.py`**
+
+```python
+"""FastAPI service: controls -> the real alteration engine -> a RenderPlan.
+
+The simulator's alteration surface (reconciled slice). It calls the canonical
+player.alteration.plan_alteration; the browser only renders. The selection-era
+endpoints (/api/select, /api/catalog/meta) and the X-ray are retired.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Optional
+
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel, Field
+
+from hef.selection import Coordinate
+from player.alteration import (
+ DEFAULT_CALIBRATION,
+ Calibration,
+ plan_alteration,
+ render_plan_to_dict,
+)
+from player.content import resolve_content
+from player.controls import CONTENT_POSITIONS
+from simulator.clips import load_manifest
+
+STATIC_DIR = Path(__file__).parent / "static"
+DEFAULT_MANIFEST = Path(__file__).parent / "sample_media" / "manifest.json"
+MEDIA_DIR = Path(__file__).parent / "sample_media"
+
+
+class ControlsModel(BaseModel):
+ content: str
+ left: int = Field(ge=0, le=4)
+ right: int = Field(ge=0, le=4)
+ dark: int = Field(ge=0, le=4)
+ light: int = Field(ge=0, le=4)
+ volume: int = Field(ge=0, le=4)
+ brightness: int = Field(ge=0, le=4)
+
+
+class CalibrationModel(BaseModel):
+ mood_gain: float = 1.0
+ overlay_gain: float = 1.0
+ right_variant_map: list[int] = [0, 1, 2, 3, 4]
+
+
+class AlterationRequest(BaseModel):
+ controls: ControlsModel
+ calibration: Optional[CalibrationModel] = None
+
+
+def _load_clips(manifest_path: Optional[Path]):
+ path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
+ if path.exists():
+ return load_manifest(path)
+ return []
+
+
+def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
+ app = FastAPI(title="HEF Alteration Simulator")
+ app.state.clips = _load_clips(manifest_path)
+
+ @app.post("/api/alteration")
+ def api_alteration(req: AlterationRequest):
+ c = req.controls
+ if c.content not in CONTENT_POSITIONS:
+ # mirror Controls validation as a 422
+ from fastapi import HTTPException
+ raise HTTPException(status_code=422, detail=f"invalid content {c.content!r}")
+ coord = Coordinate(c.left, c.right, c.dark, c.light)
+ cal = (
+ Calibration(
+ mood_gain=req.calibration.mood_gain,
+ overlay_gain=req.calibration.overlay_gain,
+ right_variant_map=tuple(req.calibration.right_variant_map),
+ )
+ if req.calibration
+ else DEFAULT_CALIBRATION
+ )
+ plan = plan_alteration(coord, cal)
+ content = resolve_content(c.content)
+ return {
+ "plan": render_plan_to_dict(plan),
+ "content": {"audio_source": content.audio_source, "video": content.video},
+ }
+
+ @app.get("/api/clips")
+ def api_clips():
+ return {"clips": [c.to_dict() for c in app.state.clips]}
+
+ if MEDIA_DIR.exists():
+ app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
+ if STATIC_DIR.exists():
+ app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+
+ return app
+
+
+app = create_app()
+```
+
+Note: confirm `player/content.py`'s `ContentResolution` exposes `audio_source` and `video` (it does — `state.py` reads both). If the attribute is named differently, match it.
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `python -m pytest tests/test_simulator_api.py -q`
+Expected: PASS. The `test_index_is_served` test needs the rewritten `index.html` (Task 6) containing "Alteration" — if Task 6 is not yet done, that one test fails; run the rest with `-k "not index"` until Task 6.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add simulator/app.py tests/test_simulator_api.py
+git commit -m "feat(simulator): /api/alteration + /api/clips; retire selection endpoints"
+```
+
+---
+
+## Task 5: Sample media + placeholder Right strengths
+
+**Files:**
+- Create: `simulator/sample_media/manifest.json` (committed)
+- Create: `simulator/sample_media/README.md` (committed)
+- Create: `simulator/setup_sample_media.py` (committed)
+- Modify: `.gitignore` (ignore the media binaries)
+
+- [ ] **Step 1: Author the manifest**
+
+Create `simulator/sample_media/manifest.json`:
+
+```json
+{
+ "clips": [
+ {
+ "id": "forest",
+ "title": "Yosemite Falls (neutral base, POC sample)",
+ "base_file": "forest/base.mp4",
+ "license": "poc-sample (look-tuning only; not shipped content)",
+ "source": "hef-poc/out/neutral.mp4",
+ "right_variants": {
+ "1": {"file": "forest/right1.mp4", "model": "placeholder"},
+ "2": {"file": "forest/right2.mp4", "model": "placeholder"},
+ "3": {"file": "forest/right3.mp4", "model": "placeholder"},
+ "4": {"file": "forest/right4.mp4", "model": "sd-turbo+farneback-flow"}
+ },
+ "annotations": [
+ {"key": "detected.water", "box": [0.30, 0.10, 0.18, 0.70], "min_level": 1},
+ {"key": "detected.rock_face", "box": [0.05, 0.30, 0.20, 0.55], "min_level": 2},
+ {"key": "detected.conifer", "box": [0.70, 0.20, 0.22, 0.45], "min_level": 3},
+ {"key": "measure.flow_rate", "box": [0.34, 0.55, 0.14, 0.08], "min_level": 4}
+ ],
+ "strings": {
+ "en": {
+ "detected.water": "flowing water",
+ "detected.rock_face": "granite face",
+ "detected.conifer": "conifer stand",
+ "measure.flow_rate": "~2.1 m³/s"
+ }
+ }
+ }
+ ]
+}
+```
+
+- [ ] **Step 2: Write the setup script**
+
+Create `simulator/setup_sample_media.py`:
+
+```python
+"""Populate simulator/sample_media/forest/ from the session-0008 POC artifacts.
+
+Copies the real neutral base + the real flow-stabilized Right restyle out of
+~/hef-poc/out/, and generates placeholder intermediate Right strengths (1..3) by
+blending the base toward the real restyle with ffmpeg. The media binaries are
+gitignored; only the manifest is committed. Sample footage is for look-tuning
+only, not shipped content.
+
+Usage: python simulator/setup_sample_media.py
+Requires: ffmpeg on PATH (or `pip install imageio-ffmpeg`), and ~/hef-poc/out/.
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+from pathlib import Path
+
+POC = Path.home() / "hef-poc" / "out"
+DEST = Path(__file__).parent / "sample_media" / "forest"
+
+
+def _ffmpeg() -> str:
+ if shutil.which("ffmpeg"):
+ return "ffmpeg"
+ import imageio_ffmpeg
+ return imageio_ffmpeg.get_ffmpeg_exe()
+
+
+def main() -> None:
+ DEST.mkdir(parents=True, exist_ok=True)
+ base = DEST / "base.mp4"
+ right4 = DEST / "right4.mp4"
+ shutil.copyfile(POC / "neutral.mp4", base)
+ shutil.copyfile(POC / "right_flow.mp4", right4)
+ ff = _ffmpeg()
+ # Placeholder strengths 1..3: opacity-blend base toward the real restyle.
+ for strength, alpha in ((1, 0.25), (2, 0.5), (3, 0.75)):
+ out = DEST / f"right{strength}.mp4"
+ subprocess.run(
+ [ff, "-y", "-i", str(base), "-i", str(right4),
+ "-filter_complex",
+ f"[1:v]format=yuva444p,colorchannelmixer=aa={alpha}[top];"
+ f"[0:v][top]overlay=shortest=1[v]",
+ "-map", "[v]", "-an", str(out)],
+ check=True,
+ )
+ print(f"generated {out.name} (alpha {alpha})")
+ print(f"sample media ready in {DEST}")
+
+
+if __name__ == "__main__":
+ main()
+```
+
+- [ ] **Step 3: Gitignore the media binaries, document them**
+
+Append to `.gitignore`:
+```
+# Simulator sample media (look-tuning only; populate via setup_sample_media.py)
+simulator/sample_media/forest/*.mp4
+```
+
+Create `simulator/sample_media/README.md`:
+```markdown
+# Simulator sample media
+
+`manifest.json` is committed; the `.mp4` binaries are **not** (gitignored). They
+are look-tuning samples, not shipped installation content.
+
+Populate them from the session-0008 POC artifacts:
+
+ python simulator/setup_sample_media.py
+
+This copies `~/hef-poc/out/neutral.mp4` → `forest/base.mp4` and
+`~/hef-poc/out/right_flow.mp4` → `forest/right4.mp4` (the real flow-stabilized
+restyle), and generates placeholder strengths `forest/right1..3.mp4`.
+```
+
+- [ ] **Step 4: Run the setup script and verify media exists**
+
+Run: `python simulator/setup_sample_media.py && ls -la simulator/sample_media/forest/`
+Expected: `base.mp4`, `right1.mp4`, `right2.mp4`, `right3.mp4`, `right4.mp4` present.
+
+- [ ] **Step 5: Commit (manifest, README, script, gitignore — NOT the mp4s)**
+
+```bash
+git add simulator/sample_media/manifest.json simulator/sample_media/README.md simulator/setup_sample_media.py .gitignore
+git commit -m "feat(simulator): sample-media manifest + POC setup/placeholder generator"
+```
+
+---
+
+## Task 6: Browser preview (`simulator/static/`)
+
+**Files:**
+- Modify: `simulator/static/index.html` (rewrite)
+- Modify: `simulator/static/app.js` (rewrite)
+- Modify: `simulator/static/style.css` (rewrite)
+
+- [ ] **Step 1: Rewrite `index.html`**
+
+```html
+
+
+
+
+
+ HEF — Alteration Simulator
+
+
+
+ Human Experience Filter — Alteration Preview
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Rewrite `app.js`**
+
+```javascript
+// Thin renderer: post controls+calibration -> RenderPlan; render grade, Right
+// variant crossfade, and the live Left overlay. All math stays in Python.
+const $ = (id) => document.getElementById(id);
+const vid = $("vid"), overlay = $("overlay"), black = $("black"), readout = $("readout");
+
+let clip = null; // active clip manifest entry
+let currentVariant = -1; // last loaded Right strength
+
+async function loadClips() {
+ const data = await (await fetch("/api/clips")).json();
+ clip = data.clips[0] || null;
+}
+
+function mediaUrl(file) { return "/media/" + file; }
+
+function variantFile(strength) {
+ const v = clip.right_variants[String(strength)];
+ return v ? v.file : clip.base_file;
+}
+
+function applyGrade(tone) {
+ // Light: warm + brighten; Dark: cool + darken; 0: raw.
+ const warm = tone > 0 ? tone : 0, cool = tone < 0 ? -tone : 0;
+ const bright = 1 + 0.25 * tone, sat = 1 + 0.15 * Math.abs(tone);
+ vid.style.filter =
+ `brightness(${bright}) saturate(${sat}) ` +
+ `sepia(${(warm * 0.5).toFixed(3)}) hue-rotate(${(-cool * 200).toFixed(0)}deg)`;
+}
+
+function loadVariant(strength) {
+ if (strength === currentVariant) return;
+ currentVariant = strength;
+ vid.style.opacity = "0";
+ setTimeout(() => {
+ vid.src = mediaUrl(variantFile(strength));
+ vid.play().catch(() => {});
+ vid.style.opacity = "1";
+ }, 150);
+}
+
+function renderOverlay(level, intensity) {
+ overlay.innerHTML = "";
+ if (!clip || level <= 0) { overlay.style.opacity = "0"; return; }
+ overlay.style.opacity = String(intensity);
+ const strings = (clip.strings && clip.strings.en) || {};
+ for (const a of clip.annotations) {
+ if (a.min_level > level) continue;
+ const [x, y, w, h] = a.box.map((n) => n * 100);
+ const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ rect.setAttribute("x", x); rect.setAttribute("y", y);
+ rect.setAttribute("width", w); rect.setAttribute("height", h);
+ rect.setAttribute("class", "anno-box");
+ overlay.appendChild(rect);
+ const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ text.setAttribute("x", x + 0.5); text.setAttribute("y", Math.max(y - 0.5, 2));
+ text.setAttribute("class", "anno-label");
+ text.textContent = strings[a.key] || a.key;
+ overlay.appendChild(text);
+ }
+}
+
+function controls() {
+ return {
+ content: $("content").value,
+ left: +$("left").value, right: +$("right").value,
+ dark: +$("dark").value, light: +$("light").value,
+ volume: 2, brightness: 2,
+ };
+}
+
+function calibration() {
+ return { mood_gain: +$("mood_gain").value, overlay_gain: +$("overlay_gain").value,
+ right_variant_map: [0, 1, 2, 3, 4] };
+}
+
+let timer = null;
+async function update() {
+ const resp = await fetch("/api/alteration", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ controls: controls(), calibration: calibration() }),
+ });
+ if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
+ const data = await resp.json();
+ readout.textContent = JSON.stringify(data, null, 2);
+ if (!data.content.video) { black.classList.remove("hidden"); return; }
+ black.classList.add("hidden");
+ applyGrade(data.plan.grade.tone);
+ loadVariant(data.plan.restyle.variant);
+ renderOverlay(data.plan.overlay.level, data.plan.overlay.intensity);
+}
+
+function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
+
+async function main() {
+ await loadClips();
+ for (const id of ["content", "left", "right", "dark", "light", "mood_gain", "overlay_gain"]) {
+ $(id).addEventListener("input", debounced);
+ }
+ update();
+}
+main();
+```
+
+- [ ] **Step 3: Rewrite `style.css`**
+
+```css
+* { box-sizing: border-box; }
+body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: #111; color: #eee; }
+header { padding: 0.6rem 1rem; background: #000; }
+h1 { font-size: 1rem; margin: 0; font-weight: 600; }
+main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; }
+.stage { flex: 1 1 640px; }
+.screen { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000;
+ border-radius: 6px; overflow: hidden; }
+#vid { width: 100%; height: 100%; object-fit: cover; transition: opacity 0.15s ease; }
+#overlay { position: absolute; inset: 0; width: 100%; height: 100%;
+ pointer-events: none; transition: opacity 0.2s ease; }
+.anno-box { fill: none; stroke: #6cf; stroke-width: 0.4; vector-effect: non-scaling-stroke; }
+.anno-label { fill: #6cf; font-size: 3px; font-family: monospace; }
+.black { position: absolute; inset: 0; background: #000; }
+.hidden { display: none; }
+.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem; }
+fieldset { border: 1px solid #333; border-radius: 6px; }
+legend { color: #9af; padding: 0 0.4rem; }
+label { display: block; margin: 0.4rem 0; }
+input[type=range], select { width: 100%; }
+#readout { background: #000; padding: 0.5rem; border-radius: 4px; font-size: 12px;
+ white-space: pre-wrap; max-height: 240px; overflow: auto; }
+```
+
+- [ ] **Step 4: Run the full simulator test suite (index test now passes)**
+
+Run: `python -m pytest tests/test_simulator_api.py -q`
+Expected: PASS (all, including `test_index_is_served`).
+
+- [ ] **Step 5: Manual smoke (optional, needs media from Task 5)**
+
+Run: `python -m uvicorn simulator.app:app --port 8000` then open `http://localhost:8000`.
+Expected: the base clip loops; Right slider crossfades variants; Dark/Light grade the footage live; Left slider reveals boxes + labels; "off" → black; the readout shows the engine numbers.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add simulator/static/
+git commit -m "feat(simulator): alteration preview UI (grade + variant crossfade + Left overlay)"
+```
+
+---
+
+## Task 7: Docs — update parent design, roadmap, user guide
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-06-05-machine-altered-perception-design.md`
+- Modify: `docs/ROADMAP.md`
+- Modify: `docs/USER_GUIDE.md`
+
+- [ ] **Step 1: Point the parent design at the reconciliation**
+
+In `docs/superpowers/specs/2026-06-05-machine-altered-perception-design.md`, near the §4.3 and §10 headings, add a note (one line each):
+```
+> **Reconciled (2026-06-07):** Left HUD is a runtime overlay driven by an authored
+> annotation track + per-language string tables; Right is a discrete pre-baked
+> variant. See 2026-06-07-reconciled-simulator-alteration-slice-design.md §1.
+```
+
+- [ ] **Step 2: Update the roadmap slice status**
+
+In `docs/ROADMAP.md` §3 (sub-project 3 slices), mark the simulator-alteration slice as built: add a bullet:
+```
+- [x] Simulator-first alteration preview — live Dark/Light grade, runtime Left
+ overlay (authored annotation track + string tables), discrete pre-baked Right
+ variants over one neutral clip, parameterized Calibration. (session 0009)
+```
+Keep the deferred slices (Pi renderer, serial, endless-encoder + AI transitions,
+real multi-strength re-bake) listed as not-started.
+
+- [ ] **Step 3: Rewrite the user-guide simulator section**
+
+In `docs/USER_GUIDE.md`, replace the "Playing with the simulator" section to describe the alteration preview: run `python simulator/setup_sample_media.py` once, then `uvicorn simulator.app:app`; turn the four knobs + content dial; tune the calibration sliders; read the RenderPlan readout. Remove references to the retired X-ray / selection view.
+
+- [ ] **Step 4: Verify the whole suite is green**
+
+Run: `python -m pytest -q`
+Expected: PASS (no failures; previously-passing unrelated suites unaffected).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docs/
+git commit -m "docs: point parent design at reconciliation; roadmap + user guide for sim alteration"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- §1 decision (Left runtime overlay, Right discrete pre-baked, Dark/Light live) → Tasks 1 (engine), 6 (renderer), 5 (annotation track). ✓
+- §2 engine reconciliation (`Calibration`, `Restyle.variant`, `AnalyticalOverlay.level`, `state.py`) → Tasks 1, 2. ✓
+- §3 simulator (retire selection; `clips.py`; `/api/alteration` + `/api/clips`; browser preview) → Tasks 3, 4, 6. ✓
+- §4 sample clip + real Right variant + placeholder generator + Left annotation track → Task 5 (media/script), Task 5 manifest (annotations). ✓
+- §5 testing (player unit, sim API, removed endpoints, fixtures retired) → Tasks 1–4. ✓
+- §6 ships (incl. doc updates) → Task 7. ✓
+
+**Placeholder scan:** No TBD/TODO; every code step shows complete code. ✓
+
+**Type consistency:** `Restyle.variant` (int), `AnalyticalOverlay.level`/`.intensity`, `Calibration(mood_gain, overlay_gain, right_variant_map)`, `render_plan_to_dict` keys (`grade.tone`, `overlay.level/intensity`, `restyle.variant`, `is_identity`), `Clip.variant_file`/`to_dict`, `load_manifest`, `create_app(manifest_path=)` — used identically across Tasks 1, 3, 4, 6. ✓
+
+**Note for the executor:** verify `player/content.py`'s `ContentResolution` field names (`audio_source`, `video`) before Task 4 Step 3; match them if different. `CONTENT_POSITIONS` is importable from `player/controls.py`.
diff --git a/docs/superpowers/specs/2026-06-05-machine-altered-perception-design.md b/docs/superpowers/specs/2026-06-05-machine-altered-perception-design.md
index c855cdc..7298a4b 100644
--- a/docs/superpowers/specs/2026-06-05-machine-altered-perception-design.md
+++ b/docs/superpowers/specs/2026-06-05-machine-altered-perception-design.md
@@ -121,6 +121,13 @@ layers. This single rule covers the whole `5×5×5×5` space:
### 4.3 Where each transform runs
+> **Reconciled (2026-06-07, session 0009):** the Left HUD is a **runtime overlay**
+> driven by an authored annotation track + per-language string tables (text shaped
+> live); the Right axis selects a **discrete pre-baked** flow-stabilized variant
+> (not a continuous blend). See
+> [`2026-06-07-reconciled-simulator-alteration-slice-design.md`](./2026-06-07-reconciled-simulator-alteration-slice-design.md)
+> §1, which supersedes the session-0007 baked-HUD / 5×5-grid proposal.
+
- **Runtime, on the Pi (free, continuous, full-res):** the Dark/Light color grade
and the Left analytical overlay. These are cheap (LUT/curves + luma key, and
text/graphics compositing) and can move continuously with the knob.
@@ -235,6 +242,13 @@ one-time build.
## 10. Accessibility, i18n, and the translation-cost finding
+> **Reconciled (2026-06-07, session 0009):** the near-free-i18n path is kept — the
+> Left HUD is a runtime overlay (authored annotation track + per-language string
+> tables, shaped live via Pango/HarfBuzz on the Pi). The session-0007 baked-HUD
+> reversal is **not** adopted. See
+> [`2026-06-07-reconciled-simulator-alteration-slice-design.md`](./2026-06-07-reconciled-simulator-alteration-slice-design.md)
+> §1.
+
The piece is operable **blind, in the dark, in your language**, via four redundant
channels on the control panel: **touch** (engraved symbol shape), **low-light
color** (LEDs), **braille**, and **audio** (a read-aloud button on a small *local*
diff --git a/docs/superpowers/specs/2026-06-06-simulator-alteration-preview-design.md b/docs/superpowers/specs/2026-06-06-simulator-alteration-preview-design.md
new file mode 100644
index 0000000..94d8194
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-06-simulator-alteration-preview-design.md
@@ -0,0 +1,242 @@
+# Human Experience Filter — Simulator Alteration Preview (Design)
+
+**Date:** 2026-06-06
+**Status:** Approved design (pre-implementation)
+**Repo:** `human-experience-filter-art`
+**Builds on:** the alteration engine of
+[`2026-06-05-machine-altered-perception-design.md`](./2026-06-05-machine-altered-perception-design.md)
+(the "design" below) and the simulator scaffold of
+[`2026-06-04-experience-simulator-design.md`](./2026-06-04-experience-simulator-design.md).
+**Revises the design:** §4.3 and §10 — the Left analytical HUD is **no longer a
+runtime overlay**; it is **baked into authored variant videos** (see §8 below).
+This is a deliberate trade of near-free multilingual support for authorial
+precision over the HUD.
+**Retires:** the simulator's selection-era "curator's X-ray" view and its
+`/api/select` + `/api/catalog/meta` endpoints (the selection model they
+visualize was superseded by the alteration model).
+
+> **Why this exists.** Operator directive (session 0007): *build and design only
+> things that run in the simulator, and get the whole experience working the way
+> we like in the simulator before moving to hardware.* The slice-1 alteration
+> engine (PR #5) is pure logic with **no simulator surface** — you cannot yet turn
+> the experience knobs and *see* the result. This design brings the alteration
+> into the simulator so the look can be tuned and liked before any Pi/serial work.
+
+---
+
+## 1. Scope
+
+A browser **alteration preview**: turn the four experience knobs and the content
+dial, and see the neutral base footage altered toward the knob state, in real
+time, on a looping clip. The purpose is to **tune the look** of the filter and to
+**settle the knob→strength calibration by eye** (the open §3-vs-§4.2/§5 question
+from session 0006).
+
+**In scope**
+- Live preview of the four experience knobs (Left/Right/Dark/Light) on a looping
+ base clip.
+- **Dark/Light** rendered as a live, deterministic runtime color grade.
+- **Left/Right** rendered by selecting a **pre-baked authored variant clip** from
+ a 5×5 grid and crossfading on change.
+- A **calibration panel** that adjusts the grade curve live; the chosen values are
+ baked back into `player/alteration.py` defaults.
+- A **RenderPlan readout** (the project's "X-ray" honesty): always show the exact
+ numbers the engine produced.
+- The content dial's **video on/off** behavior (so "Off" goes to black), driven by
+ the existing `resolve_content`.
+- Placeholder variant generation so the mechanism is testable before the operator
+ authors real videos.
+
+**Out of scope (this slice)** — real generative v2v; serial input / the 3⇄4
+framing contract; audio playback (music / white-noise / audio-track); the Pi/mpv
+runtime renderer; the full transition/crossfade timing engine; multilingual label
+tables; catalog-model changes. These remain later roadmap slices.
+
+---
+
+## 2. Architecture — Python-canonical, thin browser renderer
+
+`player/alteration.py` stays the **single source of truth** for the alteration
+math (handbook §4.2 — deterministic core, thin I/O). The browser owns only
+*rendering*. Data flow:
+
+```mermaid
+flowchart LR
+ subgraph Browser
+ K[4 experience knobs +
content dial +
calibration sliders]
+ V["<video> variant + canvas grade"]
+ RO[RenderPlan readout]
+ end
+ subgraph FastAPI [simulator/app.py]
+ EP["POST /api/alteration"]
+ CL["GET /api/clips"]
+ end
+ ENG["player.alteration.plan_alteration(coord, calibration)"]
+ K -- "debounced POST {controls, calibration}" --> EP
+ EP --> ENG --> EP -- "RenderPlan {variant, grade}" --> V
+ EP --> RO
+ CL -- "base clip + variant manifest" --> V
+```
+
+- The browser sends control/calibration changes (debounced) and receives a
+ `RenderPlan`. Video filtering itself runs continuously in the browser; only a
+ *plan recompute* makes a round-trip. On localhost these JSON round-trips are
+ imperceptible.
+- "Bake the calibration winner in" = change the `Calibration` defaults in Python.
+ Nothing in the browser is canonical.
+
+---
+
+## 3. The four axes — how each is rendered
+
+| Axis | Engine output | Browser rendering |
+|---|---|---|
+| **Dark / Light** | `ColorGrade.tone` ∈ [−1, 1], center = identity | live color grade on the `