long_screen/kernels/track/verdicts.rs
1//! Per-region, per-frame stateless tracking verdicts — split out of the former monolithic
2//! `track.rs`. Byte-for-byte
3//! ports of `src/pipeline/solve/track.ts`'s frozen oracle in `tests/support/reference/track.ts`. Every
4//! function here is pure: plain scalars/tuples in, a plain result out, no I/O, no state carried between calls.
5//!
6//! `js_hypot` (not `f64::hypot`) is used everywhere the TS original used `Math.hypot`: V8's hypot and
7//! libm's hypot round differently in the last bit for some inputs (see `crate::kernels::geometry::js_hypot`).
8
9use crate::kernels::geometry::js_hypot;
10use crate::kernels::motion::{detect_scale, MatchPoints};
11
12/// `track.ts::uncertainty`.
13pub fn uncertainty(confidence: f64, ambiguous: bool, weak_step: bool) -> bool {
14 confidence < 0.60 || ambiguous || weak_step
15}
16
17/// The historical-match half of `track.ts::relocalizeVerdict`'s input (`match` there); `None` mirrors
18/// the original's `match === undefined`.
19pub struct MatchInfo {
20 pub ambiguous: bool,
21 pub confidence: f64,
22}
23
24/// `track.ts::relocalizeVerdict`.
25pub fn relocalize_verdict(m: Option<MatchInfo>, zoom_change: bool) -> bool {
26 match m {
27 Some(m) => !m.ambiguous && m.confidence > 0.6 && !zoom_change,
28 None => false,
29 }
30}
31
32/// Whether an anchor re-acquisition contradicts the step odometry's rescue measured: the step it implies (re-acquired
33/// pose minus the current pose) is [`SAME_PLACE`] or more from the rescue's. Both measure the same frame, so they agree
34/// to a pixel or two when both are right, and the re-acquisition is then kept, as it carries no drift. When they
35/// disagreed, the rescue was the one that was right. On a Mac recording of a long web page the two met on 176 frames:
36/// 130 agreed within 3 px; of the other 46, one was an ambiguous re-acquisition at confidence 0.04 on a periodic alias,
37/// 1790 px off, 44 matched a screen-fixed panel in the moving layer at zero offset from the anchor during a fast fling,
38/// unambiguous and more confident than the rescue (0.38–0.53 against 0.25–0.44), and one was 442 px off at 0.23.
39/// Neither a confidence floor nor a comparison of confidences separates the 44.
40pub fn reacquisition_contradicts_rescue(implied_step: (f64, f64), rescue: (f64, f64)) -> bool {
41 js_hypot(implied_step.0 - rescue.0, implied_step.1 - rescue.1) >= SAME_PLACE
42}
43
44/// Distance at which two placements of the same content disagree (a loop closure's discrepancy, a thin-overlap
45/// correction, a re-acquisition against the rescue).
46const SAME_PLACE: f64 = 16.0;
47
48/// The three branches `track.ts::fragmentCause` chooses between: the trivial "gate" ("pure
49/// part"). `ZoomChange` means the caller returns `{ scale: fieldZoom, error: 0 }` directly;
50/// `ProbeScale` means the caller runs the existing `probeScale` kernel; `None` means "no cause"
51/// (`undefined`).
52#[derive(PartialEq, Eq, Debug)]
53pub enum FragmentCauseGate {
54 ZoomChange,
55 ProbeScale,
56 None,
57}
58
59/// `track.ts::fragmentCause`'s branch selection (checked in the same order: zoom change first).
60pub fn fragment_cause_gate(
61 zoom_change: bool,
62 has_previous_gray: bool,
63 blind: bool,
64) -> FragmentCauseGate {
65 if zoom_change {
66 FragmentCauseGate::ZoomChange
67 } else if has_previous_gray && !blind {
68 FragmentCauseGate::ProbeScale
69 } else {
70 FragmentCauseGate::None
71 }
72}
73
74/// `track.ts::occlusionEligible`. `decision` is the odometry/gate outcome tag shared with the ABI
75/// layer: 0 = tracked, 1 = static, 2 = blind, 3 = lost (only tracked/static are eligible).
76pub fn occlusion_eligible(has_previous: bool, kind_moving: bool, decision: u8) -> bool {
77 has_previous && kind_moving && (decision == 0 || decision == 1)
78}
79
80/// `track.ts::targetPose`.
81pub fn target_pose(keyframe: (f64, f64), offset: (f64, f64), shift: (f64, f64)) -> (f64, f64) {
82 (
83 keyframe.0 + offset.0 + shift.0,
84 keyframe.1 + offset.1 + shift.1,
85 )
86}
87
88/// The `global` revisit match `track.ts::attachVerdict` takes.
89pub struct GlobalMatch {
90 pub keyframe: (f64, f64),
91 pub offset: (f64, f64),
92 pub ambiguous: bool,
93 pub confidence: f64,
94}
95
96/// `track.ts::attachVerdict`. `resolved_target_eq_canvas` is the caller's `resolvedTarget ===
97/// canvasId` (string comparison stays in TS); the returned pose still needs pairing with
98/// `resolvedTarget` by the caller, which is why this returns only the pose, not the target string.
99pub fn attach_verdict(
100 global: Option<GlobalMatch>,
101 resolved_target_eq_canvas: bool,
102 shift: (f64, f64),
103) -> Option<(f64, f64)> {
104 let g = global?;
105 // `!(g.confidence > 0.72)`, spelled via `partial_cmp` to satisfy `clippy::neg_cmp_op_on_partial_ord` WITHOUT
106 // changing the truth table: `Some(Greater)` is the only value the negation excludes, so a NaN confidence
107 // (`partial_cmp` gives `None`) still takes this branch, exactly as `!(NaN > 0.72)` (`NaN > x` is always
108 // `false` in IEEE 754) evaluates to `true` in the TS original.
109 if resolved_target_eq_canvas
110 || g.ambiguous
111 || g.confidence.partial_cmp(&0.72) != Some(std::cmp::Ordering::Greater)
112 {
113 return None;
114 }
115 Some(target_pose(g.keyframe, g.offset, shift))
116}
117
118/// `track.ts::odometryWeight`.
119pub fn odometry_weight(weak_step: bool) -> f64 {
120 if weak_step {
121 0.05
122 } else {
123 1.0
124 }
125}
126
127/// `track.ts::thinOverlapEligible`.
128pub fn thin_overlap_eligible(
129 weak_step: bool,
130 weak: bool,
131 ambiguous: bool,
132 confidence: f64,
133 error: f64,
134) -> bool {
135 (weak_step || weak) && !ambiguous && confidence > 0.72 && error < 8.0
136}
137
138/// `track.ts::thinOverlapCorrection`. Returns `(target, discrepancy)`.
139pub fn thin_overlap_correction(
140 canonical_keyframe: (f64, f64),
141 offset: (f64, f64),
142 pose: (f64, f64),
143) -> Option<((f64, f64), f64)> {
144 let target = (
145 canonical_keyframe.0 + offset.0,
146 canonical_keyframe.1 + offset.1,
147 );
148 let discrepancy = js_hypot(target.0 - pose.0, target.1 - pose.1);
149 if discrepancy >= SAME_PLACE {
150 Some((target, discrepancy))
151 } else {
152 None
153 }
154}
155
156/// `track.ts::LoopVerdict`, in the ABI tag order (0..3) `rust/wasm/src/track.rs` returns.
157#[derive(PartialEq, Eq, Debug)]
158pub enum LoopVerdict {
159 Closure,
160 Inconsistent,
161 Ambiguous,
162 None,
163}
164
165/// `track.ts::loopClosureVerdict`. Computes the discrepancy itself, like the TS original.
166pub fn loop_closure_verdict(
167 global_keyframe: (f64, f64),
168 global_offset: (f64, f64),
169 global_ambiguous: bool,
170 global_confidence: f64,
171 shift: (f64, f64),
172 pose: (f64, f64),
173) -> (LoopVerdict, f64) {
174 let discrepancy = js_hypot(
175 global_keyframe.0 + shift.0 + global_offset.0 - pose.0,
176 global_keyframe.1 + shift.1 + global_offset.1 - pose.1,
177 );
178 if !global_ambiguous && global_confidence > 0.72 && discrepancy < SAME_PLACE {
179 (LoopVerdict::Closure, discrepancy)
180 } else if discrepancy >= SAME_PLACE {
181 (LoopVerdict::Inconsistent, discrepancy)
182 } else if global_ambiguous {
183 (LoopVerdict::Ambiguous, discrepancy)
184 } else {
185 (LoopVerdict::None, discrepancy)
186 }
187}
188
189/// `track.ts::needsKeyframe`. `last_node_frame: None` mirrors the original's `lastNodeFrame ===
190/// undefined` ("no node exists yet"); `anchor: None` mirrors `anchor` undefined (→ `Infinity`
191/// distance) exactly like the TS ternary did on `anchor ? ... : Infinity`.
192/// `f64::max`/`f64::min` (not `Math.max`/`Math.min`) are used for the rect-dimension floor: Rust's
193/// versions return the non-NaN operand where JS's propagate NaN, but `rect.width`/`rect.height` are
194/// always finite region dimensions here, so the two never observably diverge for this call.
195#[allow(clippy::too_many_arguments)]
196pub fn needs_keyframe(
197 kind_moving: bool,
198 anchor: Option<(f64, f64)>,
199 pose: (f64, f64),
200 last_node_frame: Option<i32>,
201 rect: (f64, f64),
202 frame_index: i32,
203 field_difference: f64,
204) -> bool {
205 let Some(last_node_frame) = last_node_frame else {
206 return true;
207 };
208 if !kind_moving {
209 return false;
210 }
211 let anchor_distance = match anchor {
212 Some(a) => js_hypot(pose.0 - a.0, pose.1 - a.1),
213 None => f64::INFINITY,
214 };
215 let frames_since_last_node = (frame_index - last_node_frame) as f64;
216 anchor_distance > f64::max(48.0, f64::min(rect.0, rect.1) * 0.30)
217 || (frames_since_last_node > 90.0 && field_difference > 0.2)
218}
219
220/// `track.ts::zoomChanged`.
221pub fn zoom_changed(region_zoom: Option<f64>, field_zoom: f64) -> bool {
222 match region_zoom {
223 Some(rz) => (rz - 1.0).abs() > 0.04,
224 None => (field_zoom - 1.0).abs() > 0.04,
225 }
226}
227
228/// `track.ts::regionZoom`: the eligibility gate (`kind === 'moving' && ... >= 8`) fused with the
229/// existing `detect_scale` kernel it conditionally calls, so the ABI layer makes one call instead of
230/// counting uniques in TS first. `detect_scale` is unchanged ("detectScale is already Rust").
231pub fn region_zoom(kind_moving: bool, matches: &[MatchPoints]) -> Option<f64> {
232 if !kind_moving {
233 return None;
234 }
235 let unique_count = matches.iter().filter(|m| m.unique).count();
236 if unique_count >= 8 {
237 Some(detect_scale(matches))
238 } else {
239 None
240 }
241}