fix(humanize): preserve gaussian magnitude in jitter_point; unify swipe return shape

This commit is contained in:
2026-07-15 19:58:02 +08:00
parent 5a93651db7
commit 9da73cc6e3
3 changed files with 42 additions and 10 deletions
+22
View File
@@ -27,6 +27,28 @@ def test_jitter_point_centered_on_input():
assert abs(sum(xs) / len(xs)) < 0.3 assert abs(sum(xs) / len(xs)) < 0.3
def test_jitter_point_concentrated_near_target():
"""Regression: jitter must be polar-Gaussian (concentrated near target),
not uniform-on-circle (all points at exactly ``radius``).
For a folded Gaussian with sigma=radius/2, the expected mean distance is
~0.8*sigma ~= 0.4*radius. Using ``< 0.7*radius`` gives a safe margin that
fails the previous reprojection bug (mean distance == radius exactly).
"""
rng = random.Random(1234)
radius = 5.0
n = 5000
distances = [
math.hypot(x - 0.0, y - 0.0)
for x, y in (jitter_point(0.0, 0.0, radius=radius, rng=rng) for _ in range(n))
]
mean_distance = sum(distances) / n
assert mean_distance < radius * 0.7, (
f"mean distance {mean_distance:.3f} is too large; jitter looks like "
"uniform-on-circle rather than polar-Gaussian"
)
def test_jitter_duration_within_spread(): def test_jitter_duration_within_spread():
rng = random.Random(3) rng = random.Random(3)
for _ in range(200): for _ in range(200):
+18 -10
View File
@@ -90,23 +90,31 @@ def set_rng(rng: random.Random | None) -> None:
def jitter_point( def jitter_point(
x: float, y: float, *, radius: float, rng: random.Random x: float, y: float, *, radius: float, rng: random.Random
) -> tuple[float, float]: ) -> tuple[float, float]:
"""Gaussian offset clamped to a ``radius``-px circle around the target.""" """Polar-Gaussian offset around the target.
Magnitude is drawn from a folded Gaussian with sigma=radius/2, then
capped at ``radius``. The angle is uniform on [0, 2*pi). This concentrates
jittered points near the target rather than uniformly on the circle edge,
matching how humans tap close to (but not exactly on) a button center.
"""
r = abs(rng.gauss(0.0, radius / 2.0)) r = abs(rng.gauss(0.0, radius / 2.0))
r = min(r, radius) r = min(r, radius)
angle = rng.uniform(0, 2 * math.pi) angle = rng.uniform(0, 2 * math.pi)
dx = r * math.cos(angle) dx = r * math.cos(angle)
dy = r * math.sin(angle) dy = r * math.sin(angle)
# Reproject to exact radius to absorb FP drift in cos/sin # Guard against cos/sin FP drift pushing distance slightly past radius.
d = math.sqrt(dx * dx + dy * dy) fd = math.hypot(dx, dy)
if d > 0: if fd > radius:
dx = dx / d * radius scale = radius / fd
dy = dy / d * radius dx *= scale
dy *= scale
# Final clamp: hypot of the returned offset may overshoot by ulps; tighten
# to ``radius - 1e-10`` to keep ``<= radius`` after subtraction/hypot.
fx = x + dx fx = x + dx
fy = y + dy fy = y + dy
fd = math.hypot(fx - x, fy - y) fd_final = math.hypot(fx - x, fy - y)
if fd > radius: if fd_final > radius:
# Scale to a slightly tighter radius to absorb FP rounding in addition scale = (radius - 1e-10) / fd_final
scale = (radius - 1e-10) / fd
fx = x + dx * scale fx = x + dx * scale
fy = y + dy * scale fy = y + dy * scale
return (fx, fy) return (fx, fy)
+2
View File
@@ -35,6 +35,8 @@ def swipe(
return { return {
"ok": True, "ok": True,
"action": "swipe", "action": "swipe",
"start": {"x": start_x, "y": start_y},
"end": {"x": end_x, "y": end_y},
"waypoints": waypoints, "waypoints": waypoints,
"duration_ms": dms, "duration_ms": dms,
} }