Organize Iris support files
This commit is contained in:
1 parent
40e7259d99
commit
ea2112c552
11 files changed
+94
-56
No files matched your search
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AOSP's fling spline, transcribed independently of the Rust port.
|
||||
|
||||
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
|
||||
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
|
||||
grading its own homework. Every test iris's fling had before 2026-09-07
|
||||
compared the curve with itself -- monotonic, signed, integrates to the closed
|
||||
form -- and all of them passed while `distance_fraction(t)` was returning
|
||||
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
|
||||
test have to come from somewhere else, and this is the somewhere else.
|
||||
|
||||
Transcribed by hand from, and only from:
|
||||
|
||||
* frameworks/base `core/java/android/widget/OverScroller.java`,
|
||||
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
|
||||
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
|
||||
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
|
||||
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
|
||||
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
|
||||
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
|
||||
line, which is why iris ports one curve rather than two.
|
||||
|
||||
Run it with no arguments; it prints the table entries and the (velocity,
|
||||
density, t) points the Rust tests assert on.
|
||||
"""
|
||||
|
||||
NB_SAMPLES = 100
|
||||
INFLEXION = 0.35
|
||||
START_TENSION = 0.5
|
||||
END_TENSION = 1.0
|
||||
P1 = START_TENSION * INFLEXION
|
||||
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
|
||||
|
||||
SCROLL_FRICTION = 0.015
|
||||
TUNING = 0.84
|
||||
GRAVITY_EARTH = 9.80665
|
||||
INCHES_PER_METER = 39.37
|
||||
|
||||
import math
|
||||
|
||||
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
|
||||
|
||||
|
||||
def spline_positions():
|
||||
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
|
||||
position = [0.0] * (NB_SAMPLES + 1)
|
||||
x_min = 0.0
|
||||
for i in range(NB_SAMPLES):
|
||||
alpha = i / NB_SAMPLES
|
||||
x_max = 1.0
|
||||
while True:
|
||||
x = x_min + (x_max - x_min) / 2.0
|
||||
coef = 3.0 * x * (1.0 - x)
|
||||
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
|
||||
if abs(tx - alpha) < 1e-5:
|
||||
break
|
||||
if tx > alpha:
|
||||
x_max = x
|
||||
else:
|
||||
x_min = x
|
||||
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
|
||||
position[NB_SAMPLES] = 1.0
|
||||
return position
|
||||
|
||||
|
||||
POSITION = spline_positions()
|
||||
|
||||
|
||||
def fling_sample(t):
|
||||
"""(distance fraction, velocity fraction) at time fraction `t`."""
|
||||
t = min(max(t, 0.0), 1.0)
|
||||
index = int(t * NB_SAMPLES)
|
||||
if index >= NB_SAMPLES:
|
||||
return 1.0, 0.0
|
||||
t_inf = index / NB_SAMPLES
|
||||
t_sup = (index + 1) / NB_SAMPLES
|
||||
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
|
||||
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
|
||||
|
||||
|
||||
def physical_coefficient(density):
|
||||
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
|
||||
|
||||
|
||||
def deceleration(velocity, density):
|
||||
return math.log(
|
||||
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
|
||||
)
|
||||
|
||||
|
||||
def fling_distance(velocity, density):
|
||||
l = deceleration(velocity, density)
|
||||
return (
|
||||
SCROLL_FRICTION
|
||||
* physical_coefficient(density)
|
||||
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
|
||||
)
|
||||
|
||||
|
||||
def fling_duration_s(velocity, density):
|
||||
l = deceleration(velocity, density)
|
||||
return math.exp(l / (DECELERATION_RATE - 1.0))
|
||||
|
||||
|
||||
def position_at(velocity, density, t_seconds):
|
||||
d = fling_duration_s(velocity, density)
|
||||
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
|
||||
|
||||
|
||||
def velocity_at(velocity, density, t_seconds):
|
||||
d = fling_duration_s(velocity, density)
|
||||
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("SPLINE_POSITION at a few indices (index: value)")
|
||||
for i in (0, 1, 10, 25, 50, 75, 99, 100):
|
||||
print(f" {i:3}: {POSITION[i]:.6f}")
|
||||
print()
|
||||
print("distance/velocity fraction at time fractions")
|
||||
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
|
||||
d, v = fling_sample(t)
|
||||
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
|
||||
print()
|
||||
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
|
||||
# 2.75 is this checkout's emulator.
|
||||
for density in (2.55, 2.75):
|
||||
# 15250 is `app/touch/flick-120hz.touch`'s own
|
||||
# release velocity (velocity_reference.py), so `phone_screen.rs`
|
||||
# can bound the fling it produces from *here* rather than from the
|
||||
# `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1).
|
||||
for velocity in (5000.0, 11064.0, 15250.0):
|
||||
dur = fling_duration_s(velocity, density)
|
||||
print(
|
||||
f"density={density} v={velocity}: "
|
||||
f"distance={fling_distance(velocity, density):.3f}px "
|
||||
f"duration={dur:.4f}s"
|
||||
)
|
||||
# Deliberately not round fractions. The velocity coefficient is
|
||||
# piecewise *constant* across each of the 100 samples, so it
|
||||
# steps at t = k/100 and a test asserting on 0.75 is asserting
|
||||
# on which side of a discontinuity the last float landed --
|
||||
# which is genuinely different between Python and Rust and says
|
||||
# nothing about the curve.
|
||||
for frac in (0.125, 0.335, 0.505, 0.755):
|
||||
t = frac * dur
|
||||
print(
|
||||
f" t={frac:>4} of duration ({t:.4f}s): "
|
||||
f"pos={position_at(velocity, density, t):.3f}px "
|
||||
f"vel={velocity_at(velocity, density, t):.3f}px/s"
|
||||
)
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compose's touch velocity tracker, transcribed independently of the Rust port.
|
||||
|
||||
Same reason `fling_spline_reference.py` exists: the numbers checked into
|
||||
`sense.rs`'s velocity tests must not be numbers the Rust produced. The old
|
||||
estimator -- total motion over the sample span, an average -- passed every test
|
||||
it had, because every one of those tests asserted the average's own definition
|
||||
back at it. An average cannot tell an accelerating flick from a steady drag, and
|
||||
that is exactly what Iris reported from the phone on 2026-09-07: "flinging now
|
||||
actually works but is slower than Compose's immediately after releasing the
|
||||
flick".
|
||||
|
||||
Transcribed by hand from, and only from, the `-sources.jar` of
|
||||
**androidx.compose.ui:ui-android:1.12.0** and
|
||||
**androidx.compose.foundation:foundation-android:1.12.0**
|
||||
(dl.google.com/dl/android/maven2), read 2026-09-07:
|
||||
|
||||
* `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` --
|
||||
`VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`,
|
||||
`calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants
|
||||
`HistorySize = 20`, `HorizonMilliseconds = 100`,
|
||||
`AssumePointerMoveStoppedMilliseconds = 40`.
|
||||
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` --
|
||||
`Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to.
|
||||
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt`
|
||||
-- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork.
|
||||
* `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's
|
||||
default, which is `false`.
|
||||
* `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` /
|
||||
`sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag
|
||||
feeds the tracker and where the maximum-velocity clamp is applied.
|
||||
* `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and
|
||||
`NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller.
|
||||
* `androidx/compose/foundation/gestures/Scrollable.kt` --
|
||||
`DefaultFlingBehavior.performFling`, for the minimum-velocity question.
|
||||
|
||||
**Which strategy a touch fling actually uses, since this was the surprise.**
|
||||
`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through
|
||||
`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on
|
||||
Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to
|
||||
false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2
|
||||
least-squares fit over **absolute positions**, whose velocity is the fitted
|
||||
polynomial's derivative at the newest sample. Impulse is reached only through
|
||||
`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`:
|
||||
mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and
|
||||
iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the
|
||||
printed points, because ruling it out by reading is cheaper than ruling it out
|
||||
again next time somebody remembers "Compose uses impulse".
|
||||
|
||||
**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change;
|
||||
every subsequent MOVE, historical samples included, is added by `sendDragEvent`.
|
||||
The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange`
|
||||
wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`,
|
||||
and all the UP branch does is reset the tracker when more than 40ms have passed
|
||||
since the last MOVE (b/238654963). So a finger that stops before lifting reads
|
||||
as a stop, not as a decelerating tail. Positions are the raw event positions,
|
||||
so the touch slop is inside the motion the tracker sees even though the list
|
||||
never scrolled by it.
|
||||
|
||||
Two of Compose's samples iris does *not* reproduce, both noted rather than
|
||||
copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it
|
||||
feeds none either -- these agree), and the single MOVE that *crosses* the slop,
|
||||
which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that
|
||||
one, since it is a real measured position and dropping it would be copying a
|
||||
quirk of where Compose happens to split its state machine.
|
||||
|
||||
**The clamps.** Maximum: `sendDragStopped` passes
|
||||
`LocalViewConfiguration.maximumFlingVelocity`, which on Android is
|
||||
`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum:
|
||||
there is **none** on this path. `ViewConfiguration.minimumFlingVelocity`
|
||||
exists in Compose's `ViewConfiguration` interface but its only use in either
|
||||
artifact is `NestedScrollInteropConnection`, for View interop.
|
||||
`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f`
|
||||
and says why in its own comment: "we need it since spline curve gives us
|
||||
NaNs". 1 px/s, not 50 dp/s.
|
||||
|
||||
Run it with no arguments; it prints the sample sets and the velocities the
|
||||
Rust tests assert on.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
HISTORY_SIZE = 20
|
||||
HORIZON_MILLISECONDS = 100.0
|
||||
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
|
||||
MIN_SAMPLE_SIZE_LSQ2 = 3
|
||||
|
||||
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
|
||||
# DefaultFlingBehavior.performFling's own threshold, in the units of the
|
||||
# positions fed to the tracker -- pixels per second here.
|
||||
FLING_MINIMUM_PX_S = 1.0
|
||||
|
||||
|
||||
def poly_fit_least_squares(x, y, sample_count, degree):
|
||||
"""`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first."""
|
||||
if degree < 1:
|
||||
raise ValueError("The degree must be at positive integer")
|
||||
if sample_count == 0:
|
||||
raise ValueError("At least one point must be provided")
|
||||
|
||||
truncated_degree = sample_count - 1 if degree >= sample_count else degree
|
||||
m = sample_count
|
||||
n = truncated_degree + 1
|
||||
|
||||
a = [[0.0] * m for _ in range(n)]
|
||||
for h in range(m):
|
||||
a[0][h] = 1.0
|
||||
for i in range(1, n):
|
||||
a[i][h] = a[i - 1][h] * x[h]
|
||||
|
||||
q = [[0.0] * m for _ in range(n)]
|
||||
r = [[0.0] * n for _ in range(n)]
|
||||
for j in range(n):
|
||||
w = q[j]
|
||||
w[:] = a[j][:m]
|
||||
for i in range(j):
|
||||
z = q[i]
|
||||
dot = sum(w[h] * z[h] for h in range(m))
|
||||
for h in range(m):
|
||||
w[h] -= dot * z[h]
|
||||
norm = math.sqrt(sum(v * v for v in w))
|
||||
inverse_norm = 1.0 / max(norm, 1e-6)
|
||||
for h in range(m):
|
||||
w[h] *= inverse_norm
|
||||
for i in range(n):
|
||||
r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m))
|
||||
|
||||
coefficients = [0.0] * n
|
||||
for i in range(n - 1, -1, -1):
|
||||
c = sum(q[i][h] * y[h] for h in range(m))
|
||||
for j in range(n - 1, i, -1):
|
||||
c -= r[i][j] * coefficients[j]
|
||||
coefficients[i] = c / r[i][i]
|
||||
return coefficients
|
||||
|
||||
|
||||
def kinetic_energy_to_velocity(kinetic_energy):
|
||||
sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy)
|
||||
return sign * math.sqrt(2 * abs(kinetic_energy))
|
||||
|
||||
|
||||
def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential):
|
||||
"""`calculateImpulseVelocity` -- not on the touch path; see the module doc."""
|
||||
work = 0.0
|
||||
start = sample_count - 1
|
||||
next_time = time[start]
|
||||
for i in range(start, 0, -1):
|
||||
current_time = next_time
|
||||
next_time = time[i - 1]
|
||||
if current_time == next_time:
|
||||
continue
|
||||
if is_data_differential:
|
||||
delta = -data_points[i - 1]
|
||||
else:
|
||||
delta = data_points[i] - data_points[i - 1]
|
||||
v_curr = delta / (current_time - next_time)
|
||||
v_prev = kinetic_energy_to_velocity(work)
|
||||
work += (v_curr - v_prev) * abs(v_curr)
|
||||
if i == start:
|
||||
work = work * 0.5
|
||||
return kinetic_energy_to_velocity(work)
|
||||
|
||||
|
||||
def calculate_velocity(samples):
|
||||
"""`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`.
|
||||
|
||||
`samples` is `(time_millis, position)` oldest first, at most the last
|
||||
`HISTORY_SIZE` of which the circular buffer would still be holding.
|
||||
Returns units per second.
|
||||
"""
|
||||
held = samples[-HISTORY_SIZE:]
|
||||
if not held:
|
||||
return 0.0
|
||||
|
||||
data_points = []
|
||||
time = []
|
||||
newest_time, _ = held[-1]
|
||||
previous_time = newest_time
|
||||
for sample_time, sample_position in reversed(held):
|
||||
age = float(newest_time - sample_time)
|
||||
delta = abs(float(sample_time - previous_time))
|
||||
# Lsq2 walks back sample to sample; only the non-differential
|
||||
# Impulse branch compares every sample against the newest one.
|
||||
previous_time = sample_time
|
||||
if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS:
|
||||
break
|
||||
data_points.append(sample_position)
|
||||
time.append(-age)
|
||||
if len(data_points) == HISTORY_SIZE:
|
||||
break
|
||||
|
||||
if len(data_points) < MIN_SAMPLE_SIZE_LSQ2:
|
||||
return 0.0
|
||||
try:
|
||||
coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
# The 2nd coefficient is the fitted polynomial's derivative at x = 0,
|
||||
# which is the newest sample's timestamp. units/ms -> units/s.
|
||||
return coefficients[1] * 1000.0
|
||||
|
||||
|
||||
def clamped(velocity, maximum):
|
||||
"""`VelocityTracker1D.calculateVelocity(maximumVelocity)`."""
|
||||
if velocity == 0.0 or math.isnan(velocity):
|
||||
return 0.0
|
||||
return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum)
|
||||
|
||||
|
||||
def average(samples):
|
||||
"""The estimator being replaced: total motion over the span."""
|
||||
if len(samples) < 2:
|
||||
return 0.0
|
||||
span = (samples[-1][0] - samples[0][0]) / 1000.0
|
||||
if span <= 0.0:
|
||||
return 0.0
|
||||
return (samples[-1][1] - samples[0][1]) / span
|
||||
|
||||
|
||||
|
||||
# 1. `app/touch/flick-120hz.touch`, as `DragGesture` feeds it:
|
||||
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
|
||||
# sample (see the module doc), which is why the finger sitting still for its
|
||||
# last 4ms does not drag the estimate down. y only; the flick is vertical.
|
||||
FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)]
|
||||
|
||||
# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an
|
||||
# average must agree here -- this is the case that cannot tell the two
|
||||
# estimators apart, which is why it is not the only one.
|
||||
STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)]
|
||||
|
||||
# 3. A flick that accelerates into the release: 10ms apart, deltas doubling.
|
||||
# This is the case the average gets wrong, and the negative control for
|
||||
# the port -- reverting to the average must fail this test and only this
|
||||
# kind of test.
|
||||
ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)]
|
||||
|
||||
# 4. The two edges of the sample walk, checked here so the Rust asserts
|
||||
# Compose's answer rather than iris's own reading of the rule.
|
||||
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
|
||||
# drag: the burst must not leak into the estimate.
|
||||
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
|
||||
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
|
||||
|
||||
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
|
||||
# ... and one move frame, which Compose cannot fit either.
|
||||
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
|
||||
|
||||
PHONE_DENSITY = 2.55
|
||||
|
||||
|
||||
def report(name, samples):
|
||||
v = calculate_velocity(samples)
|
||||
print(f"{name}:")
|
||||
print(f" samples (t_ms, position): {samples}")
|
||||
print(f" Lsq2 (Compose's touch path): {v:.4f} px/s")
|
||||
print(f" average (the old estimator): {average(samples):.4f} px/s")
|
||||
print(f" impulse (non-touch, for ref): ", end="")
|
||||
held = list(reversed(samples[-HISTORY_SIZE:]))
|
||||
newest = held[0][0]
|
||||
print(
|
||||
f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s"
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,")
|
||||
print("non-differential (positions), HistorySize=20, Horizon=100ms,")
|
||||
print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n")
|
||||
report("flick-120hz.touch", FLICK_120HZ)
|
||||
report("steady drag (5px/10ms)", STEADY_DRAG)
|
||||
report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK)
|
||||
|
||||
report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY)
|
||||
report("stopped 48ms before release", STOPPED_BEFORE_RELEASE)
|
||||
report("press and two move frames", TWO_MOVE_FRAMES)
|
||||
report("press and one move frame", ONE_MOVE_FRAME)
|
||||
|
||||
print("Clamps:")
|
||||
print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s")
|
||||
print(
|
||||
f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}"
|
||||
)
|
||||
print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s")
|
||||
print()
|
||||
print("Two samples only (a press and one move, the phone's 120Hz worst case):")
|
||||
print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s")
|
||||
Reference in new issue
Block a user