fix(humanize): preserve gaussian magnitude in jitter_point; unify swipe return shape
This commit is contained in:
@@ -27,6 +27,28 @@ def test_jitter_point_centered_on_input():
|
||||
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():
|
||||
rng = random.Random(3)
|
||||
for _ in range(200):
|
||||
|
||||
+18
-10
@@ -90,23 +90,31 @@ def set_rng(rng: random.Random | None) -> None:
|
||||
def jitter_point(
|
||||
x: float, y: float, *, radius: float, rng: random.Random
|
||||
) -> 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 = min(r, radius)
|
||||
angle = rng.uniform(0, 2 * math.pi)
|
||||
dx = r * math.cos(angle)
|
||||
dy = r * math.sin(angle)
|
||||
# Reproject to exact radius to absorb FP drift in cos/sin
|
||||
d = math.sqrt(dx * dx + dy * dy)
|
||||
if d > 0:
|
||||
dx = dx / d * radius
|
||||
dy = dy / d * radius
|
||||
# Guard against cos/sin FP drift pushing distance slightly past radius.
|
||||
fd = math.hypot(dx, dy)
|
||||
if fd > radius:
|
||||
scale = radius / fd
|
||||
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
|
||||
fy = y + dy
|
||||
fd = math.hypot(fx - x, fy - y)
|
||||
if fd > radius:
|
||||
# Scale to a slightly tighter radius to absorb FP rounding in addition
|
||||
scale = (radius - 1e-10) / fd
|
||||
fd_final = math.hypot(fx - x, fy - y)
|
||||
if fd_final > radius:
|
||||
scale = (radius - 1e-10) / fd_final
|
||||
fx = x + dx * scale
|
||||
fy = y + dy * scale
|
||||
return (fx, fy)
|
||||
|
||||
@@ -35,6 +35,8 @@ def swipe(
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "swipe",
|
||||
"start": {"x": start_x, "y": start_y},
|
||||
"end": {"x": end_x, "y": end_y},
|
||||
"waypoints": waypoints,
|
||||
"duration_ms": dms,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user