diff --git a/CHANGELOG.md b/CHANGELOG.md index b8956f6..a481857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/). +## [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. +- `tests/unit/data/golden_mouse.npz` re-baselined against the new + pipeline (intentional behavior change; scroll goldens unchanged). + ## [0.2.0] - 2026-05-12 ### Changed (breaking) diff --git a/tests/unit/data/golden_mouse.npz b/tests/unit/data/golden_mouse.npz index 079f08c..6fca28e 100644 Binary files a/tests/unit/data/golden_mouse.npz and b/tests/unit/data/golden_mouse.npz differ diff --git a/tests/unit/test_mouse.py b/tests/unit/test_mouse.py index ff8a5af..b2398bf 100644 --- a/tests/unit/test_mouse.py +++ b/tests/unit/test_mouse.py @@ -85,3 +85,52 @@ def test_mouse_model_click_events_have_matching_coords() -> None: assert up[2] > down[2] # Within click_dist bounds 20..500 assert 20 <= up[2] - down[2] <= 500 + + +def test_no_sharp_turns_or_walls_near_endpoint() -> None: + """Guard against the two endpoint artifact classes: + + - jagged hook/zigzag chains (two or more >90° turns between + consecutive substantial segments) in the final approach (the old + tail-drag created hooks); a single reversal is the natural + overshoot-and-correct gesture and is allowed; + - vertical walls: many points stacked at the target's forward + position (the old clip(0,1) stacked overshoot at forward=1). + """ + 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))) + sharp = int(np.sum(np.degrees(turns) > 90.0)) + # A single large-angle reversal is the natural + # overshoot-and-correct gesture (soften_forward allows + # overshoot; warp_endpoints pins the final point). Two or + # more mean a jagged hook/zigzag chain — the artifact class. + assert sharp <= 1, ( + f"{start}->{end} seed={seed}: {sharp} turns >90° " + 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" + )