Skip to main content

braid_sim/
score.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Oracle for the simulation harness: summarize the 3D tracks in a `.braidz`,
5//! and compare a live recording against an offline retrack of the same file.
6//!
7//! The headline question is whether *live* tracking produces shorter / more
8//! fragmented trajectories than *retracking* the same data. This module computes
9//! per-recording track statistics and the live-vs-retrack differential that
10//! answers it.
11
12use std::collections::BTreeMap;
13use std::path::Path;
14
15use braidz_parser::braidz_parse_path;
16
17/// Statistics about the 3D tracks in one `.braidz` recording.
18#[derive(Debug, Clone, PartialEq)]
19pub struct TrackStats {
20    /// Number of distinct `obj_id`s (track fragments).
21    pub num_objects: usize,
22    /// Total number of Kalman-estimate rows (tracked object-frames).
23    pub total_rows: usize,
24    /// Longest single-object frame span (max_frame - min_frame + 1) over all
25    /// `obj_id`s. This is the key "how long is the longest trajectory" number.
26    pub longest_span: u64,
27    /// Sum over objects of each object's frame span.
28    pub total_span: u64,
29    /// Overall frame range actually covered (min and max frame across all rows).
30    pub frame_range: Option<(u64, u64)>,
31}
32
33/// Compute [`TrackStats`] from a `.braidz` file's `kalman_estimates` table.
34pub fn track_stats(braidz_path: &Path) -> eyre::Result<TrackStats> {
35    let archive = braidz_parse_path(braidz_path)
36        .map_err(|e| eyre::eyre!("opening braidz {}: {e}", braidz_path.display()))?;
37
38    let rows = archive
39        .kalman_estimates_table
40        .as_ref()
41        .ok_or_else(|| eyre::eyre!("braidz {} has no kalman_estimates", braidz_path.display()))?;
42
43    // Per-object min/max frame and row count.
44    let mut per_obj: BTreeMap<u32, (u64, u64, usize)> = BTreeMap::new();
45    let mut global_min = u64::MAX;
46    let mut global_max = 0u64;
47    for row in rows {
48        let f = row.frame.0;
49        global_min = global_min.min(f);
50        global_max = global_max.max(f);
51        let e = per_obj.entry(row.obj_id).or_insert((f, f, 0));
52        e.0 = e.0.min(f);
53        e.1 = e.1.max(f);
54        e.2 += 1;
55    }
56
57    let mut longest_span = 0u64;
58    let mut total_span = 0u64;
59    for (lo, hi, _n) in per_obj.values() {
60        let span = hi - lo + 1;
61        longest_span = longest_span.max(span);
62        total_span += span;
63    }
64
65    Ok(TrackStats {
66        num_objects: per_obj.len(),
67        total_rows: rows.len(),
68        longest_span,
69        total_span,
70        frame_range: if rows.is_empty() {
71            None
72        } else {
73            Some((global_min, global_max))
74        },
75    })
76}
77
78/// The result of comparing a live recording against an offline retrack.
79#[derive(Debug, Clone)]
80pub struct Differential {
81    /// Stats from the live recording.
82    pub live: TrackStats,
83    /// Stats from the offline retrack of the same recording.
84    pub retrack: TrackStats,
85}
86
87impl Differential {
88    /// Whether the live recording's tracks are meaningfully shorter or more
89    /// fragmented than the retrack's — the signature of the bug under
90    /// investigation. `span_frac` is how much shorter the live longest span may
91    /// be before we flag it (e.g. 0.9 means live < 90% of retrack flags).
92    pub fn live_is_shortened(&self, span_frac: f64) -> bool {
93        let live = self.live.longest_span as f64;
94        let retrack = self.retrack.longest_span as f64;
95        let shorter = retrack > 0.0 && live < span_frac * retrack;
96        let more_fragmented = self.live.num_objects > self.retrack.num_objects;
97        shorter || more_fragmented
98    }
99
100    /// A human-readable summary table.
101    pub fn report(&self) -> String {
102        format!(
103            "                    {:>12} {:>12}\n\
104             objects (frags)     {:>12} {:>12}\n\
105             longest span        {:>12} {:>12}\n\
106             total span          {:>12} {:>12}\n\
107             total rows          {:>12} {:>12}\n\
108             frame range         {:>12} {:>12}",
109            "live",
110            "retrack",
111            self.live.num_objects,
112            self.retrack.num_objects,
113            self.live.longest_span,
114            self.retrack.longest_span,
115            self.live.total_span,
116            self.retrack.total_span,
117            self.live.total_rows,
118            self.retrack.total_rows,
119            fmt_range(self.live.frame_range),
120            fmt_range(self.retrack.frame_range),
121        )
122    }
123}
124
125fn fmt_range(r: Option<(u64, u64)>) -> String {
126    match r {
127        Some((a, b)) => format!("{a}-{b}"),
128        None => "(none)".to_string(),
129    }
130}
131
132/// Run `braid-offline-retrack` on `live_braidz`, writing to `out_braidz`, then
133/// return the live-vs-retrack [`Differential`].
134///
135/// `retrack_exe` is the path to the `braid-offline-retrack` binary.
136pub fn differential(
137    retrack_exe: &Path,
138    live_braidz: &Path,
139    out_braidz: &Path,
140) -> eyre::Result<Differential> {
141    if out_braidz.exists() {
142        std::fs::remove_file(out_braidz)?;
143    }
144    let status = std::process::Command::new(retrack_exe)
145        .arg("--data-src")
146        .arg(live_braidz)
147        .arg("--output")
148        .arg(out_braidz)
149        .status()
150        .map_err(|e| eyre::eyre!("running {}: {e}", retrack_exe.display()))?;
151    if !status.success() {
152        eyre::bail!("braid-offline-retrack failed with status {status}");
153    }
154
155    Ok(Differential {
156        live: track_stats(live_braidz)?,
157        retrack: track_stats(out_braidz)?,
158    })
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn stats(num_objects: usize, longest_span: u64) -> TrackStats {
166        TrackStats {
167            num_objects,
168            total_rows: longest_span as usize,
169            longest_span,
170            total_span: longest_span,
171            frame_range: Some((0, longest_span.saturating_sub(1))),
172        }
173    }
174
175    #[test]
176    fn agreeing_recordings_are_not_flagged() {
177        let diff = Differential {
178            live: stats(1, 800),
179            retrack: stats(1, 798),
180        };
181        assert!(!diff.live_is_shortened(0.9));
182    }
183
184    #[test]
185    fn much_shorter_live_span_is_flagged() {
186        let diff = Differential {
187            live: stats(1, 300),
188            retrack: stats(1, 800),
189        };
190        assert!(diff.live_is_shortened(0.9));
191    }
192
193    #[test]
194    fn more_live_fragments_is_flagged() {
195        // Same total span, but live split the trajectory into more obj_ids.
196        let diff = Differential {
197            live: stats(4, 800),
198            retrack: stats(1, 800),
199        };
200        assert!(diff.live_is_shortened(0.9));
201    }
202}