# Mouse Post-Processing Quality Rework 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:** Remove the endpoint artifacts (vertical walls, hooks, start kinks) in generated mouse trajectories by reworking the post-processing pipeline, and reduce sampling jitter by switching 10-step Euler to 10-step Heun.

**Architecture:** Three new pure-numpy functions in `src/ai_mouse/_postprocess.py` (`soften_forward`, `damp_start`, `warp_endpoints`) replace the three hard-clamping functions (`enforce_forward_monotonic`, `smooth_start`, `snap_endpoints`). `mouse.py` wires them in a new order (soft-monotonic → start damping → smoothing both axes → global endpoint correction) and replaces the Euler ODE loop with Heun predictor-corrector. Golden regression baselines are re-captured (intentional behavior change).

**Tech Stack:** Python 3.12+, numpy, onnxruntime, pytest. Package manager: `uv`.

**Spec:** `docs/superpowers/specs/2026-07-09-mouse-postprocess-quality-design.md`

## Global Constraints

- `src/ai_mouse/` is wheel content: NEVER import torch/fastapi/scipy/matplotlib there (CI `library` job installs runtime deps only).
- All new post-processing functions are pure (no I/O, no global state), matching the existing `_postprocess.py` convention.
- Public API (`generate()` signature and return shape, exact endpoint hit) must not change.
- Scroll subsystem and `golden_scroll.npz` are untouched.
- Run library tests with: `uv run pytest tests/unit` (add `-v` per test as noted).
- All commits end with `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.

---

### Task 1: `soften_forward` — soft monotonic with overshoot compression

Replaces the hard `clip(0,1)` + strict monotonicity of `enforce_forward_monotonic`. Tolerates small backtracking (real hands micro-correct), allows natural overshoot past 1.0, and soft-compresses extreme overshoot with tanh so the path never flies far past the target.

**Files:**
- Modify: `src/ai_mouse/_postprocess.py` (add function; do NOT delete old ones yet — removal happens in Task 4)
- Test: `tests/unit/test_postprocess.py`

**Interfaces:**
- Produces: `soften_forward(forward: np.ndarray, backtrack_tol: float = 0.02, overshoot_span: float = 0.08) -> np.ndarray` — returns a new array; Task 4 calls it with defaults.

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

Append to `tests/unit/test_postprocess.py`:

```python
from ai_mouse._postprocess import soften_forward


def test_soften_forward_tolerates_small_backtrack() -> None:
    # A 0.01 dip is within the 0.02 tolerance and must survive untouched.
    f = np.array([0.0, 0.30, 0.29, 0.60, 1.0])
    out = soften_forward(f)
    assert np.isclose(out[2], 0.29)


def test_soften_forward_limits_large_backtrack() -> None:
    # A 0.30 dip is noise; it gets pulled up to prev - tol.
    f = np.array([0.0, 0.50, 0.20, 0.70, 1.0])
    out = soften_forward(f)
    assert np.isclose(out[2], 0.50 - 0.02)


def test_soften_forward_allows_moderate_overshoot() -> None:
    # Overshoot past 1.0 is natural; small overshoot survives (compressed
    # but strictly > 1.0).
    f = np.array([0.0, 0.5, 0.9, 1.04, 1.0])
    out = soften_forward(f)
    assert out[3] > 1.0


def test_soften_forward_compresses_extreme_overshoot() -> None:
    # tanh compression: no output value may exceed 1 + overshoot_span.
    f = np.array([0.0, 0.5, 1.30, 1.50, 1.0])
    out = soften_forward(f)
    assert out.max() <= 1.0 + 0.08 + 1e-9
    assert out[2] > 1.0  # still an overshoot, not clipped flat


def test_soften_forward_no_lower_clip() -> None:
    # Small wind-up behind the start is allowed (warp_endpoints pins
    # the first point later; interior may be slightly negative).
    f = np.array([0.0, -0.01, 0.30, 0.70, 1.0])
    out = soften_forward(f)
    assert out[1] < 0.0
```

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

Run: `uv run pytest tests/unit/test_postprocess.py -k soften_forward -v`
Expected: 5 failures/errors with `ImportError: cannot import name 'soften_forward'`

- [ ] **Step 3: Write the implementation**

Add to `src/ai_mouse/_postprocess.py` (after `enforce_forward_monotonic`):

```python
def soften_forward(
    forward: np.ndarray,
    backtrack_tol: float = 0.02,
    overshoot_span: float = 0.08,
) -> np.ndarray:
    """Softly regularise the forward axis without destroying natural motion.

    Real trajectories contain small backward corrections and overshoot
    past the target; hard clipping turns both into visible artifacts
    (stacked points, vertical walls). Instead:

    - Backtracking is tolerated up to ``backtrack_tol``; larger dips are
      raised to ``prev - backtrack_tol``.
    - Values above 1.0 are compressed with tanh so they asymptote at
      ``1 + overshoot_span`` (moderate overshoot survives, extremes
      cannot fly far past the target).
    - No lower clip: the endpoint warp pins the first point later.

    Args:
        forward: (T,) forward coordinates.
        backtrack_tol: max allowed per-step regression.
        overshoot_span: asymptotic max excess above 1.0.

    Returns:
        New (T,) array.
    """
    out = forward.copy()
    for i in range(1, len(out)):
        floor = out[i - 1] - backtrack_tol
        if out[i] < floor:
            out[i] = floor
    over = out > 1.0
    out[over] = 1.0 + overshoot_span * np.tanh((out[over] - 1.0) / overshoot_span)
    return out
```

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

Run: `uv run pytest tests/unit/test_postprocess.py -k soften_forward -v`
Expected: 5 passed

- [ ] **Step 5: Commit**

```bash
git add src/ai_mouse/_postprocess.py tests/unit/test_postprocess.py
git commit -m "feat: add soften_forward (backtrack tolerance + tanh overshoot compression)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

### Task 2: `damp_start` — continuous start damping

Replaces `smooth_start`, whose ×1/5-then-abrupt-release lateral damping creates start kinks. New version ramps damping with smoothstep so the weight approaches 1 continuously at the release boundary, and touches only lateral (forward regularisation is `soften_forward`'s job).

**Files:**
- Modify: `src/ai_mouse/_postprocess.py`
- Test: `tests/unit/test_postprocess.py`

**Interfaces:**
- Produces: `damp_start(lateral: np.ndarray, n: int = 4) -> np.ndarray` — returns a new array; Task 4 calls it with defaults.

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

Append to `tests/unit/test_postprocess.py`:

```python
from ai_mouse._postprocess import damp_start


def test_damp_start_dampens_early_lateral() -> None:
    lat = np.full(16, 1.0)
    out = damp_start(lat, n=4)
    assert out[1] < out[2] < out[3] < out[4] < 1.0  # monotone ramp
    assert np.all(out[5:] == 1.0)  # untouched past n


def test_damp_start_no_release_jump() -> None:
    # The weight at i=n must be close to 1 (continuous release):
    # smoothstep(4/5) = 0.896, vs the old linear 4/5 = 0.8.
    lat = np.full(16, 1.0)
    out = damp_start(lat, n=4)
    assert out[4] > 0.85


def test_damp_start_short_input_safe() -> None:
    lat = np.array([0.0, 0.5, 0.3])
    out = damp_start(lat, n=4)  # n capped to len//4 = 0 → no-op
    assert np.array_equal(out, lat)
```

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

Run: `uv run pytest tests/unit/test_postprocess.py -k damp_start -v`
Expected: 3 failures with `ImportError: cannot import name 'damp_start'`

- [ ] **Step 3: Write the implementation**

Add to `src/ai_mouse/_postprocess.py`:

```python
def damp_start(lateral: np.ndarray, n: int = 4) -> np.ndarray:
    """Dampen lateral oscillation over the first ``n`` points, continuously.

    Weights follow smoothstep(i / (n+1)) so the damping releases smoothly
    into the untouched region (the old linear blend jumped from 0.8 to
    1.0 and left a visible kink).

    Args:
        lateral: (T,) lateral coordinates.
        n: number of leading points to dampen (capped at len//4).

    Returns:
        New (T,) array.
    """
    out = lateral.copy()
    n = min(n, len(out) // 4)
    for i in range(1, n + 1):
        t = i / (n + 1)
        w = t * t * (3.0 - 2.0 * t)  # smoothstep
        out[i] *= w
    return out
```

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

Run: `uv run pytest tests/unit/test_postprocess.py -k damp_start -v`
Expected: 3 passed

- [ ] **Step 5: Commit**

```bash
git add src/ai_mouse/_postprocess.py tests/unit/test_postprocess.py
git commit -m "feat: add damp_start (smoothstep lateral damping, no release kink)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

### Task 3: `warp_endpoints` — global residual correction

Replaces `snap_endpoints`. Instead of dragging the last 6 points toward (1,0) (which fights the trajectory's own direction and creates hooks), compute the first/last-point residuals and distribute the correction across the whole curve with smoothstep weights. Endpoints land exactly; local shape and approach direction are preserved.

**Files:**
- Modify: `src/ai_mouse/_postprocess.py`
- Test: `tests/unit/test_postprocess.py`

**Interfaces:**
- Produces: `warp_endpoints(forward: np.ndarray, lateral: np.ndarray) -> tuple[np.ndarray, np.ndarray]` — returns new arrays with `forward[0]==0.0, lateral[0]==0.0, forward[-1]==1.0, lateral[-1]==0.0` exactly; Task 4 calls it last in the pipeline.

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

Append to `tests/unit/test_postprocess.py`:

```python
from ai_mouse._postprocess import warp_endpoints


def test_warp_endpoints_exact_pin() -> None:
    f = np.linspace(0.05, 1.10, 32)
    l = np.linspace(0.03, -0.07, 32)
    fo, lo = warp_endpoints(f, l)
    assert fo[0] == 0.0 and lo[0] == 0.0
    assert fo[-1] == 1.0 and lo[-1] == 0.0


def test_warp_endpoints_identity_when_already_pinned() -> None:
    f = np.linspace(0.0, 1.0, 32)
    l = np.sin(np.linspace(0, np.pi, 32)) * 0.1
    l[0] = l[-1] = 0.0
    fo, lo = warp_endpoints(f.copy(), l.copy())
    assert np.allclose(fo, f, atol=1e-12)
    assert np.allclose(lo, l, atol=1e-12)


def test_warp_endpoints_preserves_smoothness() -> None:
    # Correcting a smooth curve must not introduce sharp local bends:
    # the warp adds a smoothstep-weighted offset, so the second
    # difference (discrete curvature proxy) stays small.
    f = np.linspace(0.02, 1.08, 32)
    l = np.full(32, 0.05)
    fo, lo = warp_endpoints(f, l)
    assert np.abs(np.diff(lo, 2)).max() < 0.01
    assert np.abs(np.diff(fo, 2)).max() < 0.01


def test_warp_endpoints_correction_local_to_each_end() -> None:
    # A start-only residual should barely move the last quarter.
    # (smoothstep weight at i=24/31 is ~0.13, so 0.08 residual leaves
    # ~0.010 there — threshold 0.02 gives margin without losing meaning)
    f = np.linspace(0.0, 1.0, 32) + 0.0
    l = np.zeros(32)
    f[0] = 0.08  # start residual only
    fo, _ = warp_endpoints(f, l)
    assert np.abs(fo[24:] - f[24:]).max() < 0.02
```

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

Run: `uv run pytest tests/unit/test_postprocess.py -k warp_endpoints -v`
Expected: 4 failures with `ImportError: cannot import name 'warp_endpoints'`

- [ ] **Step 3: Write the implementation**

Add to `src/ai_mouse/_postprocess.py`:

```python
def warp_endpoints(
    forward: np.ndarray,
    lateral: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    """Warp the whole curve so endpoints land exactly on (0,0) and (1,0).

    Computes the residual of the first point vs (0, 0) and the last point
    vs (1, 0), then subtracts each residual weighted by a smoothstep that
    is 1 at its own end and 0 at the opposite end. Unlike tail-dragging,
    this preserves the trajectory's local shape and approach direction.

    Args:
        forward: (T,) forward coordinates.
        lateral: (T,) lateral coordinates.

    Returns:
        ``(forward, lateral)`` new arrays, endpoints pinned exactly.
    """
    t = np.linspace(0.0, 1.0, len(forward))
    w_end = t * t * (3.0 - 2.0 * t)      # smoothstep: 0 at start → 1 at end
    w_start = 1.0 - w_end                # mirrored

    res_f0, res_l0 = forward[0] - 0.0, lateral[0] - 0.0
    res_f1, res_l1 = forward[-1] - 1.0, lateral[-1] - 0.0

    fo = forward - w_start * res_f0 - w_end * res_f1
    lo = lateral - w_start * res_l0 - w_end * res_l1

    fo[0], lo[0] = 0.0, 0.0
    fo[-1], lo[-1] = 1.0, 0.0
    return fo, lo
```

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

Run: `uv run pytest tests/unit/test_postprocess.py -k warp_endpoints -v`
Expected: 4 passed

- [ ] **Step 5: Commit**

```bash
git add src/ai_mouse/_postprocess.py tests/unit/test_postprocess.py
git commit -m "feat: add warp_endpoints (global residual correction, shape-preserving)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

### Task 4: Wire new pipeline into `mouse.py`; remove old functions

Swap the pipeline in `MouseModel.generate` to the spec order (soft-monotonic → start damping → smooth both axes → global endpoint correction), then delete the three replaced functions and their tests. Golden tests will now fail — that is expected and fixed in Task 6; all other tests must pass.

**Files:**
- Modify: `src/ai_mouse/mouse.py:14-24` (imports), `src/ai_mouse/mouse.py:106-109` (pipeline)
- Modify: `src/ai_mouse/_postprocess.py` (delete `snap_endpoints`, `smooth_start`, `enforce_forward_monotonic`; also update the stale `snap_endpoints` cross-reference in any remaining docstring)
- Modify: `tests/unit/test_postprocess.py` (delete the 6 tests of the removed functions and their two mid-file import lines)

**Interfaces:**
- Consumes: `soften_forward(forward)` (Task 1), `damp_start(lateral)` (Task 2), `warp_endpoints(forward, lateral)` (Task 3), existing `gaussian_smooth(x, sigma)`.

- [ ] **Step 1: Update `mouse.py` imports**

Replace the import block at `src/ai_mouse/mouse.py:15-24` with:

```python
from ai_mouse._postprocess import (
    build_timestamps,
    damp_start,
    gaussian_smooth,
    resample_arc,
    sample_duration,
    soften_forward,
    truncnorm_sample,
    warp_endpoints,
)
```

- [ ] **Step 2: Replace the pipeline**

Replace `src/ai_mouse/mouse.py:106-109`:

```python
        forward, lateral = snap_endpoints(forward, lateral, self._seq_len)
        forward, lateral = smooth_start(forward, lateral)
        forward = enforce_forward_monotonic(forward)
        lateral = gaussian_smooth(lateral, sigma=1.0)
```

with:

```python
        forward = soften_forward(forward)
        lateral = damp_start(lateral)
        forward = gaussian_smooth(forward, sigma=1.0)
        lateral = gaussian_smooth(lateral, sigma=1.0)
        forward, lateral = warp_endpoints(forward, lateral)
```

- [ ] **Step 3: Delete replaced functions and their tests**

- In `src/ai_mouse/_postprocess.py`: delete `snap_endpoints` (lines 34-62), `smooth_start` (65-80), `enforce_forward_monotonic` (83-92).
- In `tests/unit/test_postprocess.py`: delete `test_snap_endpoints_pins_first_and_last`, `test_snap_endpoints_preserves_middle`, `test_smooth_start_dampens_lateral`, `test_enforce_forward_monotonic_repairs_inversions`, `test_enforce_forward_monotonic_clips_to_unit_interval`, and the `from ai_mouse._postprocess import snap_endpoints` / `from ai_mouse._postprocess import enforce_forward_monotonic, smooth_start` import lines.

- [ ] **Step 4: Run the unit suite (golden mouse failures expected)**

Run: `uv run pytest tests/unit -v`
Expected: everything passes EXCEPT `tests/unit/test_golden.py::test_mouse_golden[...]` cases, which may exceed the path envelope (behavior intentionally changed; re-baselined in Task 6). If anything else fails, fix before committing. In particular `test_mouse.py` (endpoint snap, seed reproducibility, shape) must pass.

- [ ] **Step 5: Commit**

```bash
git add src/ai_mouse/mouse.py src/ai_mouse/_postprocess.py tests/unit/test_postprocess.py
git commit -m "feat: rework mouse post-processing pipeline (soft monotonic, global endpoint warp)

Golden mouse baselines temporarily failing; re-captured in follow-up commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

### Task 5: Euler → Heun sampling

Second-order predictor-corrector at the same 10 steps (NFE 10 → 20; each call is a small d_model=128 transformer, ~1-2 ms CPU). Reduces integration error and sampling jitter.

**Files:**
- Modify: `src/ai_mouse/mouse.py:27` (constant), `src/ai_mouse/mouse.py:93-97` (ODE loop)

**Interfaces:**
- Consumes: the existing ONNX session I/O contract `session.run(["v"], {"x_t", "t", "cond"})` — unchanged.

- [ ] **Step 1: Replace the ODE loop**

Rename the constant at `src/ai_mouse/mouse.py:27`:

```python
_N_ODE_STEPS = 10
```

Replace the loop at `src/ai_mouse/mouse.py:93-97`:

```python
        dt = 1.0 / _N_EULER_STEPS
        for step in range(_N_EULER_STEPS):
            t = np.full((1,), step * dt, dtype=np.float32)
            v = self._session.run(["v"], {"x_t": x, "t": t, "cond": cond})[0]
            x = x + v * dt
```

with:

```python
        # Heun (2nd-order predictor-corrector): same step count as the old
        # Euler loop but far lower integration error for 2x NFE.
        dt = 1.0 / _N_ODE_STEPS
        for step in range(_N_ODE_STEPS):
            t0 = np.full((1,), step * dt, dtype=np.float32)
            v1 = self._session.run(["v"], {"x_t": x, "t": t0, "cond": cond})[0]
            x_pred = (x + v1 * dt).astype(np.float32)
            t1 = np.full((1,), (step + 1) * dt, dtype=np.float32)
            v2 = self._session.run(["v"], {"x_t": x_pred, "t": t1, "cond": cond})[0]
            x = x + (v1 + v2) * (dt / 2.0)
```

- [ ] **Step 2: Run the unit suite (same expectation as Task 4)**

Run: `uv run pytest tests/unit -v`
Expected: all pass except `test_mouse_golden` envelope cases (still pending re-baseline). `test_mouse.py::test_mouse_model_seed_reproducibility` must pass — Heun adds no new randomness.

- [ ] **Step 3: Commit**

```bash
git add src/ai_mouse/mouse.py
git commit -m "feat: switch flow ODE sampling from Euler to Heun (10 steps, NFE 20)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

### Task 6: Quality regression test, golden re-baseline, CHANGELOG, visual verification

Add a numeric guard against the two artifact classes (sharp turns near the endpoint, vertical walls), re-capture `golden_mouse.npz` from the new implementation (documented procedure in `tests/unit/test_golden.py` docstring), and verify visually.

**Files:**
- Test: `tests/unit/test_mouse.py` (append)
- Modify: `tests/unit/data/golden_mouse.npz` (re-captured binary)
- Modify: `CHANGELOG.md`

**Interfaces:**
- Consumes: `generate(start, end, *, seed, click)` public API.

- [ ] **Step 1: Write the quality regression test (fails on OLD pipeline, passes on new)**

Append to `tests/unit/test_mouse.py` (note: this file has no module-level
`numpy`/`generate` imports — the test is self-contained):

```python
def test_no_sharp_turns_or_walls_near_endpoint() -> None:
    """Guard against the two endpoint artifact classes:

    - sharp turns (>90°) between consecutive substantial segments in the
      final approach (the old tail-drag created hooks);
    - vertical walls: many points stacked at the target's forward
      position (the old clip(0,1) stacked overshoot at forward=1).
    """
    import math

    import numpy as np

    from ai_mouse import generate

    cases = [((100, 300), (900, 350)), ((100, 100), (700, 600)),
             ((800, 200), (150, 550))]
    for (start, end) in cases:
        for seed in range(6):
            pts = generate(start, end, seed=seed, click=False)
            arr = np.array([(x, y) for x, y, _ in pts], dtype=float)
            tail = arr[-12:]
            seg = np.diff(tail, axis=0)
            lens = np.linalg.norm(seg, axis=1)
            # Only consider substantial segments: integer-pixel staircase
            # on 1-2 px steps produces spurious 90° angles.
            keep = lens >= 3.0
            headings = np.arctan2(seg[keep][:, 1], seg[keep][:, 0])
            if len(headings) >= 2:
                turns = np.abs(np.diff(np.unwrap(headings)))
                max_turn = math.degrees(turns.max())
                assert max_turn < 90.0, (
                    f"{start}->{end} seed={seed}: {max_turn:.0f}° turn "
                    f"in final approach"
                )
            # Vertical wall: >=4 consecutive tail points within 2 px of
            # the target x while spanning >10 px of y.
            near_x = np.abs(tail[:, 0] - end[0]) <= 2.0
            run = 0
            for i, flag in enumerate(near_x[:-1]):  # exclude final point
                run = run + 1 if flag else 0
                assert run < 4 or np.ptp(tail[near_x][:, 1]) <= 10.0, (
                    f"{start}->{end} seed={seed}: vertical wall at target x"
                )
```

- [ ] **Step 2: Run the new test**

Run: `uv run pytest tests/unit/test_mouse.py::test_no_sharp_turns_or_walls_near_endpoint -v`
Expected: PASS (the new pipeline removed the artifacts). If it fails, treat it as a real defect in Tasks 1-5 — do not loosen the thresholds; investigate which pipeline step reintroduces the artifact.

- [ ] **Step 3: Re-capture the mouse golden baseline**

Write a throwaway script (scratchpad or temp path, NOT committed):

```python
# recapture_golden.py — regenerate golden_mouse.npz from new implementation
import numpy as np
from ai_mouse import generate

CASES = [
    ((100, 200), (900, 400)), ((500, 500), (500, 100)),
    ((200, 600), (800, 200)), ((100, 100), (130, 110)),
    ((50, 50), (1500, 900)), ((400, 300), (500, 300)),
    ((300, 300), (700, 700)), ((600, 400), (200, 100)),
]
out = {}
for ci, (s, e) in enumerate(CASES):
    for seed in range(4):
        pts = generate(s, e, seed=seed)
        out[f"case{ci}_seed{seed}"] = np.array(pts, dtype=np.int64)
np.savez_compressed("tests/unit/data/golden_mouse.npz", **out)
print(f"wrote {len(out)} golden traces")
```

Run: `uv run python <path>/recapture_golden.py`
Expected: `wrote 32 golden traces`

- [ ] **Step 4: Full unit suite must be green**

Run: `uv run pytest tests/unit -v`
Expected: ALL pass, including all 32 `test_mouse_golden` and all 32 `test_scroll_golden` cases (scroll goldens untouched — if any scroll test fails, something leaked outside mouse scope; stop and investigate).

- [ ] **Step 5: Update CHANGELOG**

Add under the top of `CHANGELOG.md` (after the intro, before `## [0.2.0]`):

```markdown
## [Unreleased]

### Changed

- Mouse post-processing pipeline reworked to remove endpoint artifacts:
  hard forward clip → backtrack-tolerant soft monotonic with tanh
  overshoot compression (`soften_forward`); tail-drag endpoint snapping →
  whole-curve smoothstep residual correction (`warp_endpoints`); abrupt
  start damping → continuous smoothstep damping (`damp_start`);
  gaussian smoothing now applied to both axes.
- Flow ODE sampling switched from 10-step Euler to 10-step Heun
  (predictor-corrector); ~2x model calls per trajectory, still ~40 ms CPU.
- `tests/unit/data/golden_mouse.npz` re-baselined against the new
  pipeline (intentional behavior change; scroll goldens unchanged).
```

- [ ] **Step 6: Visual verification (diagnostic plot + Web UI)**

1. Re-run the diagnostic script from the investigation (same 4 cases × 6 seeds; it lives in the session scratchpad as `diag_traj.py`) and visually compare against the "before" plot: no vertical walls in end zoom, no hooks, no start kinks; `turns>45deg` counts in the numeric output should drop sharply vs the before-values (3-8 per trace).
2. Start the Web UI: `uv run python tools/serve.py`, open the verify page, and have the user visually approve. **This is the final acceptance gate** — post-processing is Python-side, so no ONNX re-export is needed, but the server must be (re)started to pick up the new library code.

- [ ] **Step 7: Commit**

```bash
git add tests/unit/test_mouse.py tests/unit/data/golden_mouse.npz CHANGELOG.md
git commit -m "test: quality guard for endpoint artifacts; re-baseline mouse goldens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

## Acceptance summary (from spec)

- [ ] No vertical wall / hooks / start kinks in diagnostic plots (Task 6 step 6)
- [ ] `turns>45°` count drops sharply vs baseline (was 1-8 per trace)
- [ ] `uv run pytest tests/unit` fully green, goldens re-baselined
- [ ] User approves Web UI verify page visually
- [ ] Out of scope confirmed untouched: scroll subsystem, training code, ONNX assets
