docs: implementation plan for mouse post-processing rework
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
624
docs/superpowers/plans/2026-07-09-mouse-postprocess-quality.md
Normal file
624
docs/superpowers/plans/2026-07-09-mouse-postprocess-quality.md
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
# 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
|
||||||
Reference in New Issue
Block a user