# Altitude Clip-Pair Morph + Lock 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:** When the viewer changes altitude, choose the destination clip *first*, play the morph footage that goes from the currently-shown clip to that chosen clip, then lock the clip in place so it never changes again until the altitude changes.

**Architecture:** Today the ring carries **5** per-edge morphs (each `scale_a → scale_b`, primary→primary), the server picks a random pool member only for the *landed* scale, and `advance()` plays the edge morph (which ends on the scale's *primary*) then does a *second*, jarring `fadeThroughBlack` swap to the randomly-chosen member. We replace the per-edge model with a **per-clip-pair** model: every pair of clips in adjacent altitudes gets its own morph, in both directions (**154** clips total). The server picks the destination member(s) *before* selecting the morph, threads the currently-shown clip in as the source, and chains morphs through each crossed altitude on a multi-detent jump. The renderer plays those exact morphs and locks on the final clip — no secondary swap, no re-roll while parked.

**Tech Stack:** Python 3.13 / FastAPI (`simulator/app.py`), pure ring logic (`player/ring.py`), pure manifest/schema (`simulator/clips.py`), media baking via ffmpeg `xfade=zoomin` (`simulator/build_pool_manifest.py`), vanilla JS renderer (`simulator/static/app.js`), pytest, and a new Playwright E2E tier.

## Global Constraints

- **Morph file naming:** `transitions/<src_clip_id>__<dst_clip_id>.mp4` (double-underscore separator — clip ids themselves use single `_`, e.g. `cosmos_miri`). The zoom-out companion is `<src>__<dst>.rev.mp4` and represents the **reverse** direction (`dst → src`).
- **Direction convention:** a morph is *baked* descending (higher altitude → lower altitude = zoom IN). The `.rev.mp4` companion is the ascending (zoom OUT) playback. The manifest lists **both** directions as explicit, separately-keyed entries so the renderer needs no reverse logic at play time.
- **Ring order (descending, wraps):** `["cosmos", "orbit", "coast", "reef", "abyss"]`; `abyss → cosmos` is the wrap edge.
- **Pool sizes (current):** cosmos 5, orbit 3, coast 4, reef 5, abyss 3 → **77** adjacent ordered-pair morphs descending + **77** reverse = **154** total transition clips.
- **Lock invariant:** the displayed base clip changes **only** inside `advance()` (a deliberate altitude change) and the one initial `landScale()`. Nothing else may reload base media. The Dev-Mode re-roll button stays as an explicit dev affordance.
- **Randomness stays injected:** `player/ring.py` is a pure module; the impure `random.random()` draw happens once at the API boundary (`simulator/app.py`). Pure functions take injected `float` picks in `[0,1)`.
- **Back-compat:** a manifest with no `pool` (pool of one) and the synthesized single-clip fallback ring must still work. A pool-of-one scale needs no morphs to itself.

---

### Task 1: Manifest emits clip-pair transition entries

Re-key the manifest builder from 5 per-edge entries to 154 per-clip-pair entries (both directions), each carrying `from`/`to` clip ids. Pure dict assembly — no ffmpeg.

**Files:**
- Modify: `simulator/build_pool_manifest.py:374-379` (`build_manifest()` transitions block)
- Test: `tests/test_pipeline_manifest.py`

**Interfaces:**
- Consumes: module constants `RING_ORDER: list[str]`, `POOLS: dict[str, list[str]]`.
- Produces: each transition dict shaped `{"from": <src_id>, "to": <dst_id>, "file": "transitions/<src>__<dst>.mp4", "model": "placeholder-zoom"}`. Forward entries are descending (`H→L`, `transitions/H__L.mp4`); reverse entries are ascending (`L→H`, `transitions/H__L.rev.mp4`).

- [ ] **Step 1: Write the failing test**

```python
# tests/test_pipeline_manifest.py  (add)
def test_transitions_are_per_clip_pair_both_directions():
    import simulator.build_pool_manifest as b
    m = b.build_manifest()
    trans = m["ring"]["transitions"]
    order = b.RING_ORDER
    n = len(order)
    # Every adjacent (higher H, lower L) edge, every member pair, BOTH directions.
    expected = set()
    for i in range(n):
        H, L = order[i], order[(i + 1) % n]
        for h in b.POOLS[H]:
            for l in b.POOLS[L]:
                expected.add((h, l, f"transitions/{h}__{l}.mp4"))        # zoom in
                expected.add((l, h, f"transitions/{h}__{l}.rev.mp4"))    # zoom out
    got = {(t["from"], t["to"], t["file"]) for t in trans}
    assert got == expected
    assert len(trans) == len(expected) == 154
    assert all(t["model"] == "placeholder-zoom" for t in trans)
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pytest tests/test_pipeline_manifest.py::test_transitions_are_per_clip_pair_both_directions -v`
Expected: FAIL — current builder emits 5 `scale-scale` entries with no `from`/`to`.

- [ ] **Step 3: Implement the per-clip-pair transitions block**

```python
# simulator/build_pool_manifest.py  — replace the transitions loop in build_manifest()
    transitions = []
    n = len(RING_ORDER)
    for i in range(n):
        H, L = RING_ORDER[i], RING_ORDER[(i + 1) % n]   # H higher altitude, L lower
        for h in POOLS[H]:
            for l in POOLS[L]:
                fwd = f"transitions/{h}__{l}.mp4"        # zoom IN (descend H->L)
                transitions.append({"from": h, "to": l, "file": fwd,
                                    "model": "placeholder-zoom"})
                transitions.append({"from": l, "to": h, "file": f"transitions/{h}__{l}.rev.mp4",
                                    "model": "placeholder-zoom"})        # zoom OUT (ascend L->H)
    return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pytest tests/test_pipeline_manifest.py::test_transitions_are_per_clip_pair_both_directions -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add simulator/build_pool_manifest.py tests/test_pipeline_manifest.py
git commit -m "feat(pipeline): manifest emits per-clip-pair morph transitions (both directions)"
```

---

### Task 2: Bake the member-pair morph clips

Generalize the ffmpeg morph recipe from scale-primary→scale-primary to any member→member, and have `generate_media()` bake every adjacent member pair forward + reverse. Tested with a stubbed ffmpeg runner so the test is fast and deterministic (the real bake runs as a media step at execution time).

**Files:**
- Modify: `simulator/build_pool_manifest.py:391-431` (`_make_transition`, `generate_media`)
- Test: `tests/test_pipeline_manifest.py`

**Interfaces:**
- Consumes: `RING_ORDER`, `POOLS`, `MEDIA` (Path), member base footage at `MEDIA/<clip_id>/base.mp4`.
- Produces: `_make_transition(ff, src_clip, dst_clip) -> Path` writing `transitions/<src>__<dst>.mp4` from the two members' `base.mp4`; `_make_reverse(ff, forward)` unchanged (writes `.rev.mp4`). `generate_media()` loops every adjacent ordered member pair.

- [ ] **Step 1: Write the failing test (stubbed ffmpeg)**

```python
# tests/test_pipeline_manifest.py  (add)
def test_generate_media_bakes_every_member_pair(monkeypatch, tmp_path):
    import simulator.build_pool_manifest as b
    calls = []
    def fake_run(args, **kw):
        # record the output path (last positional arg of each ffmpeg call)
        calls.append(args[-1])
        from pathlib import Path
        Path(args[-1]).parent.mkdir(parents=True, exist_ok=True)
        Path(args[-1]).write_bytes(b"x")
        class R: returncode = 0
        return R()
    monkeypatch.setattr(b, "MEDIA", tmp_path)
    monkeypatch.setattr(b.subprocess, "run", fake_run)
    monkeypatch.setattr(b, "_ffmpeg", lambda: "ffmpeg")
    b.generate_media()
    outs = {str(p).split("transitions/")[-1] for p in calls}
    # one forward + one reverse per adjacent member pair
    fwd = {f"{h}__{l}.mp4" for H, L in b._adjacent_edges()
           for h in b.POOLS[H] for l in b.POOLS[L]}
    rev = {f"{h}__{l}.rev.mp4" for H, L in b._adjacent_edges()
           for h in b.POOLS[H] for l in b.POOLS[L]}
    assert fwd <= outs and rev <= outs
    assert len(fwd) + len(rev) == 154
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pytest tests/test_pipeline_manifest.py::test_generate_media_bakes_every_member_pair -v`
Expected: FAIL — `_adjacent_edges` undefined and `generate_media` loops scales, not members.

- [ ] **Step 3: Implement member-pair baking**

```python
# simulator/build_pool_manifest.py

def _adjacent_edges() -> list[tuple[str, str]]:
    """Ordered (higher, lower) altitude edges around the ring, including the wrap."""
    n = len(RING_ORDER)
    return [(RING_ORDER[i], RING_ORDER[(i + 1) % n]) for i in range(n)]


def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path:
    """A zoom/warp morph between two CLIP MEMBERS' base footage (the well-liked
    orbit-coast recipe), keyed by clip id so every adjacent member pair gets its
    own morph. Forward = zoom IN (descend src->dst)."""
    a = MEDIA / src_clip / "base.mp4"
    b = MEDIA / dst_clip / "base.mp4"
    out = MEDIA / "transitions" / f"{src_clip}__{dst_clip}.mp4"
    out.parent.mkdir(parents=True, exist_ok=True)
    norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
    subprocess.run([
        ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
        f"[0:v]{norm}[a];[1:v]{norm}[b];"
        "[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]",
        "-map", "[v]", "-an", str(out),
    ], check=True, capture_output=True)
    return out


def generate_media() -> None:
    """Bake EVERY adjacent member-pair morph from real bases — forward (zoom in,
    descend) plus its reversed companion (zoom out, ascend)."""
    ff = _ffmpeg()
    for H, L in _adjacent_edges():
        for h in POOLS[H]:
            for l in POOLS[L]:
                fwd = _make_transition(ff, h, l)
                _make_reverse(ff, fwd)
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pytest tests/test_pipeline_manifest.py::test_generate_media_bakes_every_member_pair -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add simulator/build_pool_manifest.py tests/test_pipeline_manifest.py
git commit -m "feat(pipeline): bake member-pair zoom morphs for every adjacent video combination"
```

---

### Task 3: Clip-pair transition schema + morph lookup

Change the `Transition` dataclass and ring loader to carry `from_clip`/`to_clip`, build a `(from, to) → file` lookup on the ring, and update the structural invariant. Touches both `player/ring.py` (dataclass + ring) and `simulator/clips.py` (parse + serialize).

**Files:**
- Modify: `player/ring.py:75-113` (`Transition`, `ScaleRing.__post_init__`, add `morph_for`)
- Modify: `simulator/clips.py:88-131` (`load_ring` transition parse, `ring_to_dict` transitions block)
- Test: `tests/test_player_ring.py`, `tests/test_clips.py`

**Interfaces:**
- Produces: `Transition(from_clip: str, to_clip: str, file: str, model: str = "")`.
- Produces: `ScaleRing.morph_for(from_clip: str, to_clip: str) -> str | None` — the morph file for that directed pair, or `None` if absent.
- `ScaleRing.__post_init__`: drop the "N edges == N transitions" rule (no longer per-edge); keep "≥1 scale, single-scale ring has no transitions". Build the lookup dict in `__post_init__` (store on a private attr via `object.__setattr__`, since the dataclass is frozen).
- `ring_to_dict` transitions become `[{"from","to","file","model"}, ...]`.

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_player_ring.py  (add)
from player.ring import Scale, Transition, ScaleRing

def _two_member_ring():
    scales = (Scale(id="cosmos", clip_id="c1", pool=("c1", "c2")),
              Scale(id="abyss",  clip_id="a1", pool=("a1",)))
    # adjacency wraps: cosmos<->abyss; members c1,c2 x a1 -> 2 pairs x2 dirs = 4
    trans = (Transition("c1", "a1", "transitions/c1__a1.mp4"),
             Transition("a1", "c1", "transitions/c1__a1.rev.mp4"),
             Transition("c2", "a1", "transitions/c2__a1.mp4"),
             Transition("a1", "c2", "transitions/c2__a1.rev.mp4"))
    return ScaleRing(scales=scales, transitions=trans)

def test_morph_for_returns_directed_file():
    r = _two_member_ring()
    assert r.morph_for("c1", "a1") == "transitions/c1__a1.mp4"
    assert r.morph_for("a1", "c2") == "transitions/c2__a1.rev.mp4"
    assert r.morph_for("c1", "c2") is None  # not an adjacent-altitude pair
```

```python
# tests/test_clips.py  (add)
def test_load_ring_parses_clip_pair_transitions(tmp_path):
    import json
    from simulator.clips import load_ring
    m = {"ring": {"scales": [{"id": "x", "clip_id": "x1", "pool": ["x1"]},
                              {"id": "y", "clip_id": "y1", "pool": ["y1"]}],
                  "transitions": [{"from": "x1", "to": "y1", "file": "transitions/x1__y1.mp4"},
                                  {"from": "y1", "to": "x1", "file": "transitions/x1__y1.rev.mp4"}]}}
    p = tmp_path / "manifest.json"; p.write_text(json.dumps(m))
    ring = load_ring(p)
    assert ring.morph_for("x1", "y1") == "transitions/x1__y1.mp4"
    assert ring.morph_for("y1", "x1") == "transitions/x1__y1.rev.mp4"
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_player_ring.py::test_morph_for_returns_directed_file tests/test_clips.py::test_load_ring_parses_clip_pair_transitions -v`
Expected: FAIL — `Transition` has no `from_clip`/`to_clip`; no `morph_for`.

- [ ] **Step 3: Implement the schema + lookup**

```python
# player/ring.py — replace the Transition dataclass
@dataclass(frozen=True)
class Transition:
    """A pre-baked zoom/warp morph between TWO specific clips in adjacent
    altitudes. `file` plays as-is (the manifest holds both directions as
    separate, directed entries — forward zoom-in and `.rev` zoom-out)."""
    from_clip: str
    to_clip: str
    file: str
    model: str = ""
```

```python
# player/ring.py — ScaleRing: relax the invariant, build the lookup
@dataclass(frozen=True)
class ScaleRing:
    scales: tuple[Scale, ...]
    transitions: tuple[Transition, ...]

    def __post_init__(self) -> None:
        if len(self.scales) == 0:
            raise RingError("a ScaleRing needs at least one scale")
        lookup = {(t.from_clip, t.to_clip): t.file for t in self.transitions}
        object.__setattr__(self, "_morphs", lookup)

    def __len__(self) -> int:
        return len(self.scales)

    def morph_for(self, from_clip: str, to_clip: str) -> str | None:
        """The morph file for the directed clip pair, or None if none baked."""
        return self._morphs.get((from_clip, to_clip))
```

```python
# simulator/clips.py — load_ring transition parse (replace lines 101-104)
    transitions = tuple(
        Transition(
            from_clip=t.get("from", ""),
            to_clip=t.get("to", ""),
            file=t["file"],
            model=t.get("model", ""),
        )
        for t in ring.get("transitions", [])
    )
```

```python
# simulator/clips.py — ring_to_dict transitions block (replace line 130)
        "transitions": [
            {"from": t.from_clip, "to": t.to_clip, "file": t.file, "model": t.model}
            for t in ring.transitions
        ],
```

- [ ] **Step 4: Run the tests + the existing ring/clips suites**

Run: `pytest tests/test_player_ring.py tests/test_clips.py -v`
Expected: PASS — fix any existing test that constructed `Transition(file=..., model=...)` positionally to the new 3-arg form (`Transition(from_clip, to_clip, file)`), and any test asserting the old `N == N` invariant.

- [ ] **Step 5: Commit**

```bash
git add player/ring.py simulator/clips.py tests/test_player_ring.py tests/test_clips.py
git commit -m "feat(ring): clip-pair Transition schema + morph_for lookup"
```

---

### Task 4: Resolve chained member→member morphs (pick before transition)

Add a pure function that, given a structural `RingMove`, the currently-shown clip, and one injected pick per step, walks the steps — choosing a destination member for each crossed altitude and resolving the morph from the *previous* clip to it — and reports the final locked clip. This is the heart of "choose the clip first, then play the matching morph," chained through each altitude.

**Files:**
- Modify: `player/ring.py` (add `MorphStep`, `ResolvedMove`, `resolve_move`)
- Test: `tests/test_player_ring.py`

**Interfaces:**
- Consumes: `RingMove` (from `advance_ring`), `pick_clip_id`, `scale_at`, `ScaleRing.morph_for`.
- Produces:
  - `MorphStep(from_clip: str, to_clip: str, file: str | None, to_index: int, blended: bool)`
  - `ResolvedMove(steps: tuple[MorphStep, ...], target_clip_id: str)`
  - `resolve_move(ring: ScaleRing, move: RingMove, from_clip_id: str, picks: tuple[float, ...]) -> ResolvedMove` — `len(picks)` must equal `len(move.steps)`; each `picks[k]` chooses the member of the scale that step `k` lands on. `from_clip` of step 0 is `from_clip_id`; each subsequent step's `from_clip` is the prior step's `to_clip`. `target_clip_id` is the last step's `to_clip` (or `from_clip_id` for an empty move).

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_player_ring.py  (add)
from player.ring import advance_ring, resolve_move

def _chain_ring():
    # 3 scales, pools of 2/1/2; descending edges cosmos->coast->abyss + wrap abyss->cosmos
    scales = (Scale("cosmos", "c1", ("c1", "c2")),
              Scale("coast",  "o1", ("o1",)),
              Scale("abyss",  "a1", ("a1", "a2")))
    t = []
    def pair(h, l):
        t.append(Transition(h, l, f"transitions/{h}__{l}.mp4"))
        t.append(Transition(l, h, f"transitions/{h}__{l}.rev.mp4"))
    for h in ("c1", "c2"):
        pair(h, "o1")            # cosmos<->coast
    for h in ("o1",):
        pair(h, "a1"); pair(h, "a2")   # coast<->abyss
    for h in ("a1", "a2"):
        pair(h, "c1"); pair(h, "c2")   # abyss<->cosmos (wrap)
    return ScaleRing(scales=scales, transitions=tuple(t))

def test_resolve_single_step_zoom_in():
    r = _chain_ring()
    move = advance_ring(r, from_index=0, delta=1)        # cosmos -> coast
    res = resolve_move(r, move, from_clip_id="c2", picks=(0.0,))
    assert len(res.steps) == 1
    s = res.steps[0]
    assert (s.from_clip, s.to_clip) == ("c2", "o1")
    assert s.file == "transitions/c2__o1.mp4"
    assert res.target_clip_id == "o1"

def test_resolve_chains_through_intermediate_altitude():
    r = _chain_ring()
    move = advance_ring(r, from_index=0, delta=2)        # cosmos -> coast -> abyss
    # pick coast member (only o1) then abyss member: 0.6*2 -> index 1 -> a2
    res = resolve_move(r, move, from_clip_id="c1", picks=(0.0, 0.6))
    files = [s.file for s in res.steps]
    assert files == ["transitions/c1__o1.mp4", "transitions/o1__a2.mp4"]
    assert res.target_clip_id == "a2"

def test_resolve_zoom_out_uses_reverse_file():
    r = _chain_ring()
    move = advance_ring(r, from_index=2, delta=-1)       # abyss -> coast (ascend)
    res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,))
    assert res.steps[0].file == "transitions/o1__a1.rev.mp4"
    assert res.target_clip_id == "o1"

def test_resolve_empty_move_keeps_source():
    r = _chain_ring()
    move = advance_ring(r, from_index=0, delta=0)
    res = resolve_move(r, move, from_clip_id="c2", picks=())
    assert res.steps == () and res.target_clip_id == "c2"
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_player_ring.py -k resolve -v`
Expected: FAIL — `resolve_move` undefined.

- [ ] **Step 3: Implement `resolve_move`**

```python
# player/ring.py  (add near the bottom)

@dataclass(frozen=True)
class MorphStep:
    """One morph to play while advancing: from the clip currently shown to the
    chosen clip of the next altitude, the morph file, the index it lands on, and
    whether it plays fast (a fast-spin pass)."""
    from_clip: str
    to_clip: str
    file: str | None
    to_index: int
    blended: bool


@dataclass(frozen=True)
class ResolvedMove:
    steps: tuple[MorphStep, ...]
    target_clip_id: str


def resolve_move(
    ring: ScaleRing,
    move: RingMove,
    from_clip_id: str,
    picks: tuple[float, ...],
) -> ResolvedMove:
    """Choose the destination clip for each crossed altitude (injected `picks`,
    one per step) and resolve the morph from the previous clip to it. The morph
    is chosen AFTER the destination clip, so the footage matches what we land on.
    `file` is None if no morph was baked for a pair (renderer falls back to a
    plain cut); `target_clip_id` is the final locked clip."""
    if len(picks) != len(move.steps):
        raise ValueError(f"need one pick per step: {len(picks)} != {len(move.steps)}")
    steps: list[MorphStep] = []
    src = from_clip_id
    for st, r in zip(move.steps, picks):
        dst_scale = scale_at(ring, st.to_index)
        dst = pick_clip_id(dst_scale, r)
        steps.append(MorphStep(
            from_clip=src,
            to_clip=dst,
            file=ring.morph_for(src, dst),
            to_index=st.to_index,
            blended=st.blended,
        ))
        src = dst
    return ResolvedMove(steps=tuple(steps), target_clip_id=src)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_player_ring.py -k resolve -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add player/ring.py tests/test_player_ring.py
git commit -m "feat(ring): resolve_move picks destination clip then matching morph, chained"
```

---

### Task 5: Fast spin plays the whole chain quickly (no collapse)

The operator chose "chain through each altitude," with fast spins playing that chain *quickly* rather than collapsing to a single arrival pass. Change `advance_ring`'s fast-spin branch to keep every step but mark each `blended=True` (so the renderer plays each morph at `FAST_BLEND_RATE`) and still set `RingMove.fast=True`.

**Files:**
- Modify: `player/ring.py:219-241` (fast-spin branch in `advance_ring`)
- Test: `tests/test_player_ring.py`

**Interfaces:**
- Unchanged signature. Behavior change: a fast spin returns **all** steps (each `blended=True`), not one collapsed step.

- [ ] **Step 1: Write the failing test (and update any existing fast-spin assertions)**

```python
# tests/test_player_ring.py  (add)
def test_fast_spin_keeps_all_steps_blended():
    r = _chain_ring()                       # 3-scale ring from Task 4
    move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=3)
    assert move.fast is True
    assert len(move.steps) == 3             # NOT collapsed to 1
    assert all(s.blended for s in move.steps)
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pytest tests/test_player_ring.py::test_fast_spin_keeps_all_steps_blended -v`
Expected: FAIL — current branch collapses to a single `(blended,)` step.

- [ ] **Step 3: Implement the chain-fast branch**

```python
# player/ring.py — replace the fast-spin collapse block (lines ~219-237)
    if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
        fast_steps = tuple(
            TransitionStep(edge=s.edge, reversed=s.reversed, file=s.file,
                           to_index=s.to_index, blended=True)
            for s in steps
        )
        return RingMove(from_index=start, to_index=index, steps=fast_steps,
                        wrapped=wrapped, fast=True)
```

- [ ] **Step 4: Run the full ring suite (catch the old collapse test)**

Run: `pytest tests/test_player_ring.py -v`
Expected: PASS — update/replace any prior test that asserted fast spin yields exactly one step.

- [ ] **Step 5: Commit**

```bash
git add player/ring.py tests/test_player_ring.py
git commit -m "feat(ring): fast spin plays the full morph chain quickly instead of collapsing"
```

---

### Task 6: API picks before transitioning and serializes the resolved chain

`/api/ring/advance` accepts the currently-shown clip (`from_clip_id`), draws one random pick per step, resolves the chained morphs, and returns the resolved steps + locked `target_clip_id`. Also extend `media-versions` to cover every transition file.

**Files:**
- Modify: `simulator/app.py:119-121` (`RingAdvanceRequest`), `:237-252` (`api_ring_advance`), `:213-229` (`api_media_versions`)
- Modify: `simulator/clips.py:134-160` (`ring_move_to_dict` → resolved-move serializer)
- Test: `tests/test_simulator_api.py`

**Interfaces:**
- `RingAdvanceRequest`: add `from_clip_id: str = ""`.
- New serializer `resolved_move_to_dict(move: RingMove, resolved: ResolvedMove) -> dict`:
  ```json
  {"from_index": int, "to_index": int, "wrapped": bool, "fast": bool,
   "target_clip_id": str,
   "steps": [{"from_clip": str, "to_clip": str, "file": str|null,
              "to_index": int, "blended": bool}, ...]}
  ```
- `media-versions`: iterate `t.file` for every transition (no `.rev` derivation — both directions are explicit entries now).

- [ ] **Step 1: Write the failing test**

```python
# tests/test_simulator_api.py  (add)
def test_advance_picks_then_returns_matching_morph(client):
    # client fixture serves the sample_media manifest
    r = client.post("/api/ring/advance",
                    json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"})
    assert r.status_code == 200
    body = r.json()
    assert body["to_index"] == 1
    target = body["target_clip_id"]
    step = body["steps"][-1]
    assert step["from_clip"] == "cosmos"          # threaded current clip
    assert step["to_clip"] == target              # morph lands on the locked clip
    # file matches the directed pair (zoom-in descend, forward file)
    assert step["file"] == f"transitions/cosmos__{target}.mp4"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pytest tests/test_simulator_api.py::test_advance_picks_then_returns_matching_morph -v`
Expected: FAIL — response has no `from_clip`/`to_clip`, file is the old per-edge name.

- [ ] **Step 3: Implement request field, endpoint, serializer, media-versions**

```python
# simulator/app.py
class RingAdvanceRequest(BaseModel):
    from_index: int = 0
    delta: int
    from_clip_id: str = ""
```

```python
# simulator/app.py — api_ring_advance
    @app.post("/api/ring/advance")
    def api_ring_advance(req: RingAdvanceRequest):
        if app.state.ring is None:
            raise HTTPException(status_code=404, detail="no scale ring in manifest")
        move = advance_ring(app.state.ring, req.from_index, req.delta,
                            fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD)
        # Choose the destination member of each crossed altitude FIRST (one random
        # draw per step), then resolve the morph from the prior clip to it.
        src = req.from_clip_id or scale_at(app.state.ring, move.from_index).clip_id
        picks = tuple(random.random() for _ in move.steps)
        resolved = resolve_move(app.state.ring, move, src, picks)
        return resolved_move_to_dict(move, resolved)
```

```python
# simulator/clips.py — replace ring_move_to_dict with resolved_move_to_dict
def resolved_move_to_dict(move, resolved) -> dict:
    """JSON form of a resolved encoder move: where it lands, the locked clip, and
    the ordered chained morphs (each from the prior clip to its chosen
    destination, with the matching morph file)."""
    return {
        "from_index": move.from_index,
        "to_index": move.to_index,
        "wrapped": move.wrapped,
        "fast": move.fast,
        "target_clip_id": resolved.target_clip_id,
        "steps": [
            {"from_clip": s.from_clip, "to_clip": s.to_clip, "file": s.file,
             "to_index": s.to_index, "blended": s.blended}
            for s in resolved.steps
        ],
    }
```

```python
# simulator/app.py — api_media_versions: cover every transition file explicitly
        files = {c.base_file for c in app.state.clips}
        if app.state.ring is not None:
            for t in app.state.ring.transitions:
                files.add(t.file)
```

Update imports in `simulator/app.py` (`resolve_move`, `scale_at` already imported; add `resolve_move`) and `simulator/clips.py` (drop `ring_move_to_dict`, add `resolved_move_to_dict`; remove the now-unused `_rev_file` if nothing else uses it — grep first).

- [ ] **Step 4: Run the API suite**

Run: `pytest tests/test_simulator_api.py -v`
Expected: PASS — fix any test referencing the removed `ring_move_to_dict` / old response shape.

- [ ] **Step 5: Commit**

```bash
git add simulator/app.py simulator/clips.py tests/test_simulator_api.py
git commit -m "feat(api): /api/ring/advance picks clip then returns matching chained morphs"
```

---

### Task 7: Renderer plays the chosen morph and locks the clip (no secondary swap)

Rewire the frontend: send the currently-shown clip, play each resolved morph straight (the manifest already encodes direction, so drop the `reversed`/`reverseFile` logic), land + lock on `target_clip_id` with a plain (re)load, and **delete the secondary `fadeThroughBlack` swap**. Build a `(from,to)→file` lookup from the ring and preload only the morphs reachable from the current locked clip, refreshed on each landing (154 clips is too many to preload eagerly).

**Files:**
- Modify: `simulator/static/app.js` — `advance` (620-652), `playTransition` (568-595), `onload`/ring load (33-57), `preloadOrder`/`preloadList`/`reverseFile` (104-131, 566), remove `fadeThroughBlack` (600-618) call.
- Verified by: Task 8 (E2E) + manual run.

**Interfaces:**
- Consumes the Task 6 response: `move.steps[k] = {from_clip, to_clip, file, to_index, blended}`, `move.target_clip_id`.
- New module state: `morphByPair = {}` — `"from→to"` → file string, built from `ring.transitions`.

- [ ] **Step 1: Build the morph lookup when the ring loads**

```javascript
// simulator/static/app.js — in loadRing(), after `ring = ...` is set:
  morphByPair = {};
  if (ring && ring.transitions)
    for (const t of ring.transitions) morphByPair[`${t.from}→${t.to}`] = t.file;
```

Add `let morphByPair = {};` near the other module state (around line 38). The synthesized single-clip fallback ring has no transitions → empty lookup → advance is a no-op (pool of one), which is correct.

- [ ] **Step 2: Simplify `playTransition` (no reverse logic)**

```javascript
// simulator/static/app.js — replace playTransition
function playTransition(file, blended) {
  // Play one resolved morph start-to-finish with alteration layers hidden. The
  // manifest already encodes direction (forward zoom-in vs `.rev` zoom-out), so
  // the file is played as-is. A fast-spin step runs at FAST_BLEND_RATE. If no
  // morph exists for the pair (file null), resolve immediately (plain cut).
  return new Promise((resolve) => {
    if (!file) return resolve();
    overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; tint.style.opacity = "0";
    vid.style.filter = "none"; vid.loop = false; vid.style.opacity = "1";
    vid.src = mediaUrl(file);
    vid.playbackRate = blended ? FAST_BLEND_RATE : 1;
    let done = false;
    const finish = () => {
      if (done) return; done = true;
      vid.removeEventListener("ended", finish); vid.playbackRate = 1; resolve();
    };
    vid.addEventListener("ended", finish);
    vid.play().catch(finish);
    setTimeout(finish, blended ? 3000 : 6000);
  });
}
```

- [ ] **Step 3: Rewrite `advance` — pick first (via server), play morphs, lock, no swap**

```javascript
// simulator/static/app.js — replace advance
async function advance(delta) {
  if (busy || !ring || ring.scales.length < 2) return;
  busy = true;
  try {
    const resp = await fetch("/api/ring/advance", {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify({ from_index: ringIndex, delta, from_clip_id: activeClipId }),
    });
    if (!resp.ok) return;
    const move = await resp.json();
    for (const step of move.steps) {
      await playTransition(step.file, step.blended);
    }
    ringIndex = move.to_index;
    activeClipId = move.target_clip_id;   // the locked clip — the morph ended on it
    currentClipId = null;                 // force ensureClipMedia to (re)load + loop it
    renderScaleReadout();
  } finally {
    busy = false;
    update();                 // ensureClipMedia loads the locked clip; nothing re-rolls
    applyAudio();
    refreshReachablePreload();
  }
}
```

Then **delete** `fadeThroughBlack` (now unused) and `reverseFile` (the `.rev` companion is an explicit manifest entry, no longer derived). Grep to confirm no other callers before deleting.

- [ ] **Step 4: Preload only the reachable morphs, refreshed per landing**

```javascript
// simulator/static/app.js — replace the eager transition preload in preloadOrder()/preloadList()
// Reachable morphs from the CURRENT locked clip: to/from every member of the
// neighboring altitudes (the only clips one detent can reach). 154 total is too
// many to preload up front; we focus on what the next move can need.
function reachableMorphFiles() {
  const files = new Set();
  if (!ring || ring.scales.length < 2 || !activeClipId) return files;
  const n = ring.scales.length;
  for (const sign of [1, -1]) {
    const nb = ring.scales[((ringIndex + sign) % n + n) % n];
    const members = (nb.pool || [{ clip_id: nb.clip_id }]).map((m) => m.clip_id);
    for (const m of members) {
      const a = morphByPair[`${activeClipId}→${m}`];
      const b = morphByPair[`${m}→${activeClipId}`];
      if (a) files.add(a);
      if (b) files.add(b);
    }
  }
  return files;
}
function refreshReachablePreload() {
  // Kick a background cache fill of the reachable morphs (bounded concurrency,
  // same cacheClip helper used for bases). Idempotent — cached files are skipped.
  for (const f of reachableMorphFiles()) cacheClip(f);
}
```

In `preloadOrder()` replace the `if (ring.transitions) for (const t ...) { add(t.file); add(reverseFile(t.file)); }` block with `for (const f of reachableMorphFiles()) add(f);`. Keep base-clip preloading (current + neighbor pools) as-is. (`cacheClip` = factor out the per-file fetch→blob step already inside the preload loop so `refreshReachablePreload` can reuse it.)

- [ ] **Step 5: Manual smoke + commit**

Run: `cd simulator && python -m uvicorn app:app --port 8011` then open `http://localhost:8011/`, turn the Altitude dial one detent each way and several detents; confirm: a zoom morph plays, it lands on a clip, and that clip **does not change** while parked (watch the Dev-Mode clip readout). No black-flash second swap.

```bash
git add simulator/static/app.js
git commit -m "feat(sim): play chosen morph then lock clip per altitude; drop secondary swap"
```

---

### Task 8: Playwright E2E — lock + matching morph (new tier)

Stand up a minimal Playwright tier (none exists) and add one spec that proves the two behaviors the operator asked for: after zooming, the displayed clip is stable (locked) until the next altitude change, and the morph that plays matches `from→to`. §9 requires an E2E browser tier for this UI change.

**Files:**
- Create: `simulator/e2e/package.json`, `simulator/e2e/playwright.config.ts`, `simulator/e2e/tests/altitude-lock.spec.ts`
- Create: `simulator/e2e/README.md` (how to run; how it boots uvicorn)

**Interfaces:**
- The spec drives the real app (uvicorn on a fixed port via Playwright `webServer`), reads the Dev-Mode clip readout (enable Dev Mode via localStorage before load), and intercepts `/media/transitions/*` requests to assert the morph file played.

- [ ] **Step 1: Scaffold the tier (failing because no test yet)**

```jsonc
// simulator/e2e/package.json
{ "name": "hef-e2e", "private": true,
  "scripts": { "test": "playwright test" },
  "devDependencies": { "@playwright/test": "^1.45.0" } }
```

```ts
// simulator/e2e/playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
  testDir: "./tests",
  webServer: {
    command: "cd .. && python -m uvicorn app:app --port 8099",
    url: "http://localhost:8099/api/clips",
    reuseExistingServer: !process.env.CI,
    timeout: 60_000,
  },
  use: { baseURL: "http://localhost:8099" },
});
```

- [ ] **Step 2: Write the spec**

```ts
// simulator/e2e/tests/altitude-lock.spec.ts
import { test, expect } from "@playwright/test";

test("zoom plays a morph, lands locked, and does not change while parked", async ({ page }) => {
  const morphs: string[] = [];
  page.on("request", (r) => {
    const m = r.url().match(/\/media\/(transitions\/[^?]+)/);
    if (m) morphs.push(m[1]);
  });
  await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
  await page.goto("/");
  const readout = page.locator("#scale-name");
  await expect(readout).toContainText("cosmos");

  // Zoom inward one altitude (wheel down = +). A morph must play.
  await page.locator("#stage").dispatchEvent("wheel", { deltaY: 120 });
  await page.waitForTimeout(2500);
  expect(morphs.some((f) => f.startsWith("transitions/cosmos__"))).toBeTruthy();

  // The landed clip is now locked: capture it, wait through several alteration
  // ticks, and assert it never changes.
  const landed = (await readout.textContent())!;
  await page.waitForTimeout(2000);
  expect(await readout.textContent()).toBe(landed);
});

test("zooming back OUT plays a reverse morph and lands locked", async ({ page }) => {
  const morphs: string[] = [];
  page.on("request", (r) => {
    const m = r.url().match(/\/media\/(transitions\/[^?]+)/);
    if (m) morphs.push(m[1]);
  });
  await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
  await page.goto("/");
  const readout = page.locator("#scale-name");
  await expect(readout).toContainText("cosmos");

  // Zoom inward one altitude, then back OUT (wheel up = -). The outward move must
  // play a `.rev` morph and land back on cosmos, locked.
  await page.locator("#stage").dispatchEvent("wheel", { deltaY: 120 });
  await page.waitForTimeout(2500);
  morphs.length = 0;                                  // only watch the zoom-out move
  await page.locator("#stage").dispatchEvent("wheel", { deltaY: -120 });
  await page.waitForTimeout(2500);
  expect(morphs.some((f) => /transitions\/cosmos__.*\.rev\.mp4/.test(f))).toBeTruthy();
  await expect(readout).toContainText("cosmos");

  // Locked after the outward move too.
  const landed = (await readout.textContent())!;
  await page.waitForTimeout(2000);
  expect(await readout.textContent()).toBe(landed);
});
```

- [ ] **Step 3: Install + run**

Run: `cd simulator/e2e && npm install && npx playwright install chromium && npm test`
Expected: PASS (1 test). If the wheel handler needs a different target, adjust the selector to the element bound in `onWheel` setup.

- [ ] **Step 4: Document the tier**

`simulator/e2e/README.md`: prerequisites (`npm install`, `npx playwright install chromium`), how `webServer` boots uvicorn, and `npm test`.

- [ ] **Step 5: Commit**

```bash
git add simulator/e2e
git commit -m "test(e2e): Playwright tier asserts altitude lock + matching morph"
```

---

### Task 9: Regenerate sample media + manifest, full suite green

Bake the 154 morphs into `simulator/sample_media`, rebuild the manifest, and run the entire pytest suite + the E2E spec so the shipped sample media matches the new schema.

**Files:**
- Modify (generated): `simulator/sample_media/manifest.json`, `simulator/sample_media/transitions/*.mp4`

- [ ] **Step 1: Rebuild media + manifest**

Run: `cd simulator && python build_pool_manifest.py --media` (or the script's generate+manifest entrypoint — confirm its CLI flags) to bake morphs and rewrite `manifest.json`.
Expected: `sample_media/transitions/` contains 154 files; `manifest.json` ring has 154 transition entries.

- [ ] **Step 2: Verify every manifest transition file exists**

```python
# one-off check (or add as tests/test_catalog_integrity.py case)
import json, pathlib
m = json.load(open("simulator/sample_media/manifest.json"))
base = pathlib.Path("simulator/sample_media")
missing = [t["file"] for t in m["ring"]["transitions"] if not (base / t["file"]).exists()]
assert not missing, missing
```

- [ ] **Step 3: Run the full suite**

Run: `pytest -q`
Expected: PASS (all existing + new tests; ~270+).

- [ ] **Step 4: Run the E2E spec against the regenerated media**

Run: `cd simulator/e2e && npm test`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add simulator/sample_media
git commit -m "chore(media): regenerate 154 clip-pair morphs + manifest"
```

---

## Self-Review

**Spec coverage (operator directives):**
- "Choose which clip plays in the next altitude *before* transitioning" → Task 4 (`resolve_move` picks dst then morph) + Task 6 (server draws picks before resolving).
- "Play the appropriate morph footage" → Task 7 (renderer plays the resolved `step.file`).
- "Morph footage for all transitions between all videos in adjacent altitudes" → Tasks 1–2 (manifest + bake 154, both directions) + Task 9 (regenerate).
- "Video must not change after we've zoomed to a new altitude" / "lock per altitude until the altitude is changed" → Task 7 (delete secondary swap; lock `activeClipId`; reload only on altitude change) + Task 8 (E2E asserts stability while parked).
- "Good transition for every video combination, in and out" → both directions baked (Task 1/2), reverse via explicit `.rev` entries (Task 3/4), E2E covers in + out (extend Task 8 spec with a zoom-out case if desired).
- Multi-altitude jump chains through each altitude → Task 4 (`resolve_move` threads src→dst per step) + Task 5 (fast spin plays the chain quickly).

**Placeholder scan:** No TBD/TODO/"handle edge cases" — every code step shows real code; tests show real assertions.

**Type consistency:** `Transition(from_clip, to_clip, file, model)` (T3) used by `morph_for` (T3), `load_ring` (T3), `resolve_move` (T4), `resolved_move_to_dict` (T6). `MorphStep`/`ResolvedMove` fields (T4) match the serializer (T6) and the renderer's `step.{from_clip,to_clip,file,to_index,blended}` + `move.target_clip_id` (T7). `morphByPair` key `"from→to"` consistent in build (T7 S1) and lookup (T7 S4).

**Open items (resolved at the review gate + during execution):**
1. **Storage/repo weight:** Corrected during execution — `sample_media` was entirely
   gitignored (regenerated from the builder), NOT already committed as the plan first
   assumed. Operator decision: the media that has **graduated** into the actual
   experience (curated clip bases, per-scale audio, the 154 morphs — ~526 MB) is now
   committed via **git-LFS**, pointing at the self-hosted Gitea LFS store
   (`git.benstull.org`). Stale/unused media (parked `right_variants`, ~1.6 GB) stays
   gitignored (not deleted from disk — that's a separate explicit cleanup).
2. **`build_pool_manifest.py` CLI:** ✅ `python simulator/build_pool_manifest.py --media`
   rebuilds the manifest + bakes all morphs (confirmed at execution).
3. **Zoom-out E2E:** ✅ Task 8 covers zoom-in + lock **and** zoom-out (`.rev` morph) + lock.
   Note: one wheel notch = 60 deltaY = one detent (`Math.trunc(deltaY/60)`).
