Skip to content

fix: keep hit-test probes in the UI point metric on display-downscaled devices - #92

Merged
onevcat merged 7 commits into
mainfrom
fix/downscaled-probe-metric
Aug 6, 2026
Merged

fix: keep hit-test probes in the UI point metric on display-downscaled devices#92
onevcat merged 7 commits into
mainfrom
fix/downscaled-probe-metric

Conversation

@onevcat

@onevcat onevcat commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

Stacked on #90 — the first commit here is #90. Review the two commits on top; once #90 merges, this PR's diff reduces to just them.

Problem

#90 correctly identified that HID dispatch normalizes against pixels / scale (360x780 on the iPhone 12/13 mini) while AX frames live in the 375x812 UI point space, and introduced UIPointScale to carry AX-derived taps across. But it applies that scale to all uiToFramebuffer traffic — including points fed to the AX point hit-test, and the hit-test turns out not to share HID's metric.

Verified live on iPhone 12 mini / iOS 26.4 (Settings.app), using the v0.12.0 binary (no scale handling anywhere) as the control:

probe FB-metric hypothesis predicts UI-metric hypothesis predicts hit-test actually returned
point (187.5, 674.5) next row (y 683...735) — effective UI point would be 674.5 / 0.9606 = 702.3 the row at that point (y 630...682) the row at that point
point (317.5, 760) Screen Time row — effective UI point (330.7, 791.2) the 17x22 Dictate button at that point the Dictate button

Meanwhile the HID side behaves exactly as #90 found (re-confirmed on 26.4): the un-scaled tap opens the next row, the scaled tap opens the right one.

So the two spaces share axes (native portrait, issue #34) but not metric: the hit-test consumes UI points, HID consumes pixels / scale. On devices that render 1:1 the metrics coincide, which is why the distinction was invisible until now.

Consequences of #90 as-is on downscaled panels (all in plain portrait):

  • Calibrator discriminator probes query ~4% off target: small discriminators (the 17x22 toolbar Dictate button, precisely the off-center shapes the selector prefers) miss outright, burning the probe budget; convergence then rides on large row elements happening to tolerate the offset, otherwise the ambiguous fallback fires with an advisory.
  • Quadtree collapsed-children recovery probes (wrappedProbe now wraps portrait too) query ~4% off systematically, degrading recovery on the screens that need it most.
  • describe-ui --point's tree-calibration fallback re-issues the query through the scaled mapping and returns the element ~4% away from the one asked about.

Fix

Split the two transforms on OrientationCalibration:

wrappedProbe, the calibrator's probe loop (including its discriminator-area gate), and the --point fallback now ride the probe transform.

Second committype "" short-circuits before the framework preflight: the documented no-op previously still ran the xcode-select check + private-framework load, so it depended on the host's Xcode state for no benefit. That subprocess is what intermittently flaked the empty-input unit test on #90's first CI run ("Xcode is not available", 10.5s — while main was 10/10 green and the same suite passes locally; the rerun passed). Input is now resolved and converted first; the preflight only runs when there are events to dispatch.

Verification

  • make test: 1290 green, including two red-first suites written against the live-verified semantics — OrientationCalibrator on display-downscaled devices (mock hit-test consumes UI-metric points; both tests fail on fix: scale AX coordinates into HID space on display-downscaled devices #90 as-is, pass here) and Probe vs HID space on display-downscaled devices (pins the two transforms apart numerically, plus the wrappedProbe portrait pass-through).
  • Live, iPhone 12 mini / iOS 26.4: --point on the Dictate button and on a row-boundary point both return the element at the queried point; describe-ui --json advisory stays null (calibration converges without wasting probes); tap '#com.apple.settings.homeScreen' opens the right page (HID side intact); type "" no-ops cleanly. Control runs with v0.12.0 confirm the hit-test metric independently of this branch's code.
  • iPhone 17 / iOS 26.5 (renders 1:1): covered by the existing suite — uiMetric returns self under an identity scale, so every 1:1 path is byte-identical to fix: scale AX coordinates into HID space on display-downscaled devices #90.

subdiox and others added 3 commits August 5, 2026 09:21
iPhone 12/13 mini and the 6/6s/7/8 Plus family lay out a larger point
space than they display: the mini renders 375x812 points into a
1125x2436 buffer and downsamples it onto a 1080x2340 panel. The space
HID normalizes against is pixels / scale, so NativePortraitSize reads
360x780 there while AX frames stay in 375x812 — and because the portrait
mapping was the identity, every AX-derived coordinate reached HID 4.1%
away from the origin.

Measured on iPhone 12 mini / iOS 26.3.1: tapping a Settings row spanning
y 648...700 opened the row below it, and a 48pt-tall button could only
be hit by dividing its center by 1.041 first.

Calibration now carries a UIPointScale — the metric ratio recovered from
the AX-reported screen size, which rotation swaps but never changes — and
uiToFramebuffer / framebufferToUI apply it in both directions. The
gesture canvas, the stale-snapshot advisory and the quadtree's UI-space
probes read the UI size through the same scale, so they agree with HID
again. Devices that render at their panel resolution keep the identity
transform, and an axis-skew guard rejects a UI size that is a window
rather than the display (a resized iPad scene) instead of turning it
into a bogus scale.

Signed-off-by: yuta.ooka <yuta.ooka@lycorp.co.jp>
The AX point hit-test shares HID's native-portrait axes but consumes
points in the UI point metric, not the pixels/scale metric HID
normalizes against. Verified live on iPhone 12 mini / iOS 26.4
(Settings.app): querying the un-scaled UI point returns the element at
that point, while the pixels/scale-shrunk point lands ~4% off target.
The two metrics coincide on devices that render 1:1, which is why the
distinction was invisible until the UIPointScale work.

Applying the HID scale to probe traffic shifted the calibrator's
discriminator probes, the quadtree recovery probes, and the --point
re-issued query ~4% off on downscaled panels: small discriminators
(a 17x22 toolbar button) missed outright, wasting the probe budget and
degrading collapsed-children recovery.

Probe transforms now rotate on the UI-sized canvas via the new
OrientationCalibration.probeCGPoint; only HID dispatch carries the
metric scale. Portrait probes are the identity everywhere, keeping the
single-probe --point fast path valid on downscaled panels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: onevcat <onevcat@gmail.com>
type "" documents itself as a strict no-op, but execute() ran setup()
(xcode-select check + private-framework load) before the empty-input
guard, so the no-op still depended on a usable Xcode and paid a
subprocess spawn. On loaded CI runners that subprocess intermittently
failed, flaking the empty-input unit test with "Xcode is not available".

Resolve the input and convert it to HID events first; only reach the
preflight once there is something to dispatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: onevcat <onevcat@gmail.com>
onevcat and others added 4 commits August 6, 2026 16:17
Review follow-ups on the probe-metric split:

- The remote-content retry's sampling region (issue #64) still used the
  pixels/scale portrait bounds, leaving the right/bottom ~4% of a
  display-downscaled screen unprobed — exactly where a picker's bottom
  bar tends to live. The region now uses the hit-test canvas, with the
  UIPointScale recovered from the shell root's own display frame (bare
  shells keep the identity fallback).
- Documented the deliberate gap in --point's bounds gate: UI-only edge
  bands skip the identity probe and take the tree-calibration fallback,
  since no scale is known pre-fetch and soleOrientation clamps to the
  native canvas anyway.
- Removed a stale pre-experiment comment block left in
  PointQueryOrientationTests that contradicted the suite it introduced,
  and reworded the remaining framebuffer-metric-era comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: onevcat <onevcat@gmail.com>
Remaining comments still described the pre-experiment model (hit-test ==
HID framebuffer space): the OrientationCalibration header routed
hit-tests through hidPoint, DisplayOrientation called both consumers the
same native framebuffer space, and LegacyAccessibilityBridge asked for
"native-portrait bounds" feeding a "framebuffer-space" hit-test. All now
state the invariant explicitly — HID and the hit-test share
native-portrait AXES, but the hit-test keeps the UI point METRIC — so
the scale does not get reintroduced into probe traffic later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: onevcat <onevcat@gmail.com>
The orientation self-calibration README still stated the #34-era premise
(hit-test == HID framebuffer space) in its space table, Decision,
Implementation notes, and Open items, and showed the quadtree probe
wrapped through hidCGPoint. A prominent update note now records the
metric split (PR #90/#92) with the live evidence, the affected
statements are corrected in place, and the Open-items caveat that
anticipated exactly this divergence is marked resolved.

Also the last stale code comments: the OrientationCalibrator enum doc
("shares the HID coordinate space"), the EmptyShellTreeTests coverage-
grid note, and the OrientationRecoveryTests fixture wording (1:1
fixture, behaviour unaffected) now use the axes/metric terms.

Released CHANGELOG entries are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: onevcat <onevcat@gmail.com>
The Decision's probe step still said the discriminator center is
inverse-transformed "into a framebuffer point", contradicting the
axes/metric model the rest of the document now states; it and the
measured-transform table intro use portrait-axis / hit-test canvas
terms, with a note that the 1:1 measurement device makes the axis table
metric-agnostic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: onevcat <onevcat@gmail.com>
@onevcat
onevcat merged commit 6ebd7a5 into main Aug 6, 2026
4 checks passed
@onevcat
onevcat deleted the fix/downscaled-probe-metric branch August 6, 2026 09:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants