Skip to main content

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}