Skip to main content

spatialrust_viewer/
map_diff.rs

1//! Portable source-bound state for comparing two reconstructed maps.
2//!
3//! This module owns only validated comparison data. It does not decode glTF,
4//! apply transforms, or decide which device renders the result. Adapters can
5//! populate it from receipt-backed meshes while native and Web frontends share
6//! the same heatmap and fail-closed admission decision.
7
8use std::collections::BTreeSet;
9
10use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
11
12/// Current serialized Map Diff state schema version.
13pub const MAP_DIFF_STATE_VERSION: u32 = 1;
14
15/// Axis-aligned map bounds represented in integer micrometres.
16#[derive(Clone, Debug, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
19pub struct MapDiffBounds {
20    /// Minimum X coordinate in micrometres.
21    pub min_x_um: i64,
22    /// Minimum Y coordinate in micrometres.
23    pub min_y_um: i64,
24    /// Minimum Z coordinate in micrometres.
25    pub min_z_um: i64,
26    /// Maximum X coordinate in micrometres.
27    pub max_x_um: i64,
28    /// Maximum Y coordinate in micrometres.
29    pub max_y_um: i64,
30    /// Maximum Z coordinate in micrometres.
31    pub max_z_um: i64,
32}
33
34impl MapDiffBounds {
35    /// Creates and validates integer map bounds.
36    #[allow(clippy::too_many_arguments)]
37    pub fn try_new(
38        min_x_um: i64,
39        min_y_um: i64,
40        min_z_um: i64,
41        max_x_um: i64,
42        max_y_um: i64,
43        max_z_um: i64,
44    ) -> ViewerResult<Self> {
45        let bounds = Self { min_x_um, min_y_um, min_z_um, max_x_um, max_y_um, max_z_um };
46        bounds.validate()?;
47        Ok(bounds)
48    }
49
50    /// Validates that every maximum is at least its corresponding minimum.
51    pub fn validate(&self) -> ViewerResult<()> {
52        if self.max_x_um < self.min_x_um
53            || self.max_y_um < self.min_y_um
54            || self.max_z_um < self.min_z_um
55        {
56            return Err(ViewerError::InvalidState(
57                "Map Diff bounds have a maximum below its minimum".into(),
58            ));
59        }
60        Ok(())
61    }
62}
63
64/// Receipt-backed description of one map participating in a comparison.
65#[derive(Clone, Debug, PartialEq, Eq)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
68pub struct MapDiffMap {
69    /// User-facing map label such as `base` or `candidate`.
70    pub label: String,
71    /// Canonical input identity used to produce this map.
72    pub source: StudioSource,
73    /// Checksummed glTF or mesh artifact.
74    pub artifact: ReplayArtifact,
75    /// Frame in which the map coordinates are expressed.
76    pub frame_id: String,
77    /// Number of vertices in the map artifact.
78    pub vertex_count: u64,
79    /// Number of triangles in the map artifact.
80    pub triangle_count: u64,
81    /// Mesh coordinate bounds.
82    pub bounds: MapDiffBounds,
83}
84
85impl MapDiffMap {
86    /// Creates and validates one receipt-backed map descriptor.
87    #[allow(clippy::too_many_arguments)]
88    pub fn try_new(
89        label: impl Into<String>,
90        source: StudioSource,
91        artifact: ReplayArtifact,
92        frame_id: impl Into<String>,
93        vertex_count: u64,
94        triangle_count: u64,
95        bounds: MapDiffBounds,
96    ) -> ViewerResult<Self> {
97        let map = Self {
98            label: label.into(),
99            source,
100            artifact,
101            frame_id: frame_id.into(),
102            vertex_count,
103            triangle_count,
104            bounds,
105        };
106        map.validate()?;
107        Ok(map)
108    }
109
110    /// Validates source, artifact, frame, and geometry metadata.
111    pub fn validate(&self) -> ViewerResult<()> {
112        if self.label.trim().is_empty() || self.frame_id.trim().is_empty() {
113            return Err(ViewerError::InvalidState(
114                "Map Diff map label and frame ID must not be empty".into(),
115            ));
116        }
117        self.source.validate()?;
118        self.artifact.validate()?;
119        self.bounds.validate()?;
120        if self.vertex_count == 0 || self.triangle_count == 0 {
121            return Err(ViewerError::InvalidState(
122                "Map Diff maps require non-empty vertex and triangle counts".into(),
123            ));
124        }
125        Ok(())
126    }
127}
128
129/// One spatial heatmap cell in a map comparison.
130#[derive(Clone, Debug, PartialEq, Eq)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
133pub struct MapDiffCell {
134    /// Zero-based row-major cell index.
135    pub index: u32,
136    /// Base-map vertices assigned to this cell.
137    pub base_vertex_count: u64,
138    /// Candidate-map vertices assigned to this cell.
139    pub candidate_vertex_count: u64,
140    /// Vertices compared by stable vertex index in this cell.
141    pub compared_vertex_count: u64,
142    /// Compared vertices beyond the configured change threshold.
143    pub changed_vertex_count: u64,
144    /// Largest displacement in this cell in micrometres.
145    pub max_displacement_um: u64,
146    /// Mean displacement in this cell in micrometres.
147    pub mean_displacement_um: u64,
148}
149
150impl MapDiffCell {
151    /// Creates and validates one heatmap cell.
152    #[allow(clippy::too_many_arguments)]
153    pub fn try_new(
154        index: u32,
155        base_vertex_count: u64,
156        candidate_vertex_count: u64,
157        compared_vertex_count: u64,
158        changed_vertex_count: u64,
159        max_displacement_um: u64,
160        mean_displacement_um: u64,
161    ) -> ViewerResult<Self> {
162        let cell = Self {
163            index,
164            base_vertex_count,
165            candidate_vertex_count,
166            compared_vertex_count,
167            changed_vertex_count,
168            max_displacement_um,
169            mean_displacement_um,
170        };
171        cell.validate()?;
172        Ok(cell)
173    }
174
175    /// Validates count ordering and displacement statistics.
176    pub fn validate(&self) -> ViewerResult<()> {
177        // Comparisons are anchored to the base position. A moved candidate
178        // vertex may land in another spatial cell, so candidate cell counts
179        // are not an upper bound for this cell's stable-index comparisons.
180        let max_compared = self.base_vertex_count;
181        if self.compared_vertex_count > max_compared
182            || self.changed_vertex_count > self.compared_vertex_count
183            || self.mean_displacement_um > self.max_displacement_um
184            || (self.compared_vertex_count == 0
185                && (self.changed_vertex_count > 0
186                    || self.max_displacement_um > 0
187                    || self.mean_displacement_um > 0))
188        {
189            return Err(ViewerError::InvalidState(
190                "Map Diff cell counts or displacement statistics are inconsistent".into(),
191            ));
192        }
193        Ok(())
194    }
195}
196
197/// Aggregate vertex, topology, and admission metrics for a map comparison.
198#[derive(Clone, Debug, PartialEq, Eq)]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
201pub struct MapDiffSummary {
202    /// Number of base-map vertices.
203    pub base_vertex_count: u64,
204    /// Number of candidate-map vertices.
205    pub candidate_vertex_count: u64,
206    /// Number of vertices compared by stable index.
207    pub compared_vertex_count: u64,
208    /// Candidate-only vertices implied by a larger candidate map.
209    pub added_vertex_count: u64,
210    /// Base-only vertices implied by a larger base map.
211    pub removed_vertex_count: u64,
212    /// Compared vertices beyond the configured change threshold.
213    pub changed_vertex_count: u64,
214    /// Displacement threshold in micrometres.
215    pub change_threshold_um: u64,
216    /// Maximum compared displacement in micrometres.
217    pub max_displacement_um: u64,
218    /// Mean compared displacement in micrometres.
219    pub mean_displacement_um: u64,
220    /// P95 compared displacement in micrometres.
221    pub p95_displacement_um: u64,
222    /// Number of cells in the row-major heatmap.
223    pub cell_count: u64,
224    /// Whether the checksums of the two map artifacts are equal.
225    pub geometry_hash_equal: bool,
226    /// Whether decoded triangle index arrays are equal.
227    pub topology_equal: bool,
228    /// Whether both maps are bound to the same source checksum.
229    pub source_identity_match: bool,
230    /// Whether both maps use the same coordinate frame.
231    pub frame_identity_match: bool,
232    /// Whether a source-bound clock/frame calibration was applied.
233    pub calibration_applied: bool,
234}
235
236impl MapDiffSummary {
237    /// Creates and validates aggregate map-diff metrics.
238    #[allow(clippy::too_many_arguments)]
239    pub fn try_new(
240        base_vertex_count: u64,
241        candidate_vertex_count: u64,
242        compared_vertex_count: u64,
243        added_vertex_count: u64,
244        removed_vertex_count: u64,
245        changed_vertex_count: u64,
246        change_threshold_um: u64,
247        max_displacement_um: u64,
248        mean_displacement_um: u64,
249        p95_displacement_um: u64,
250        cell_count: u64,
251        geometry_hash_equal: bool,
252        topology_equal: bool,
253        source_identity_match: bool,
254        frame_identity_match: bool,
255        calibration_applied: bool,
256    ) -> ViewerResult<Self> {
257        let summary = Self {
258            base_vertex_count,
259            candidate_vertex_count,
260            compared_vertex_count,
261            added_vertex_count,
262            removed_vertex_count,
263            changed_vertex_count,
264            change_threshold_um,
265            max_displacement_um,
266            mean_displacement_um,
267            p95_displacement_um,
268            cell_count,
269            geometry_hash_equal,
270            topology_equal,
271            source_identity_match,
272            frame_identity_match,
273            calibration_applied,
274        };
275        summary.validate()?;
276        Ok(summary)
277    }
278
279    /// Validates count, hash, percentile, and calibration invariants.
280    pub fn validate(&self) -> ViewerResult<()> {
281        if self.change_threshold_um == 0 {
282            return Err(ViewerError::InvalidState(
283                "Map Diff change threshold must be greater than zero".into(),
284            ));
285        }
286        if self.compared_vertex_count > self.base_vertex_count.min(self.candidate_vertex_count)
287            || self.changed_vertex_count > self.compared_vertex_count
288            || self.mean_displacement_um > self.max_displacement_um
289            || self.p95_displacement_um > self.max_displacement_um
290            || (self.compared_vertex_count == 0
291                && (self.cell_count > 0
292                    || self.changed_vertex_count > 0
293                    || self.max_displacement_um > 0
294                    || self.mean_displacement_um > 0
295                    || self.p95_displacement_um > 0))
296        {
297            return Err(ViewerError::InvalidState(
298                "Map Diff summary counts or displacement statistics are inconsistent".into(),
299            ));
300        }
301        let expected_added = self.candidate_vertex_count.saturating_sub(self.base_vertex_count);
302        let expected_removed = self.base_vertex_count.saturating_sub(self.candidate_vertex_count);
303        if self.added_vertex_count != expected_added
304            || self.removed_vertex_count != expected_removed
305        {
306            return Err(ViewerError::InvalidState(
307                "Map Diff added/removed counts disagree with map vertex counts".into(),
308            ));
309        }
310        if self.geometry_hash_equal
311            && (!self.topology_equal
312                || self.base_vertex_count != self.candidate_vertex_count
313                || self.compared_vertex_count != self.base_vertex_count
314                || self.changed_vertex_count > 0
315                || self.max_displacement_um > 0
316                || self.mean_displacement_um > 0
317                || self.p95_displacement_um > 0)
318        {
319            return Err(ViewerError::InvalidState(
320                "equal Map Diff geometry hashes require identical geometry metrics".into(),
321            ));
322        }
323        if self.calibration_applied && (!self.source_identity_match || !self.frame_identity_match) {
324            return Err(ViewerError::InvalidState(
325                "Map Diff calibration cannot be applied to unbound or mixed-frame maps".into(),
326            ));
327        }
328        Ok(())
329    }
330}
331
332/// Portable state for a source-bound map comparison dashboard.
333#[derive(Clone, Debug, PartialEq, Eq)]
334#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
335#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
336pub struct MapDiffState {
337    /// Serialized Map Diff state schema version.
338    pub version: u32,
339    /// User-facing dashboard title.
340    pub title: String,
341    /// Base/reference map.
342    pub base: MapDiffMap,
343    /// Candidate/updated map.
344    pub candidate: MapDiffMap,
345    /// Aggregate comparison and admission metrics.
346    pub summary: MapDiffSummary,
347    /// Row-major spatial heatmap cells.
348    pub cells: Vec<MapDiffCell>,
349    /// Checksummed auxiliary artifacts represented by the state.
350    pub artifacts: Vec<ReplayArtifact>,
351    /// Whether the source-bound geometry comparison is valid for inspection.
352    pub compare_ready: bool,
353    /// Whether downstream calibrated mapping is admitted.
354    pub mapping_admitted: bool,
355    /// Fail-closed reasons for comparison or mapping admission.
356    pub blockers: Vec<String>,
357}
358
359impl MapDiffState {
360    /// Creates a Map Diff state and derives comparison/mapping admission.
361    pub fn try_new(
362        title: impl Into<String>,
363        base: MapDiffMap,
364        candidate: MapDiffMap,
365        summary: MapDiffSummary,
366        cells: Vec<MapDiffCell>,
367        artifacts: Vec<ReplayArtifact>,
368        blockers: Vec<String>,
369    ) -> ViewerResult<Self> {
370        let compare_ready = summary.source_identity_match
371            && summary.frame_identity_match
372            && summary.compared_vertex_count > 0
373            && summary.cell_count == u64::try_from(cells.len()).unwrap_or(u64::MAX);
374        let mapping_admitted = compare_ready && summary.calibration_applied;
375        let state = Self {
376            version: MAP_DIFF_STATE_VERSION,
377            title: title.into(),
378            base,
379            candidate,
380            summary,
381            cells,
382            artifacts,
383            compare_ready,
384            mapping_admitted,
385            blockers,
386        };
387        state.validate()?;
388        Ok(state)
389    }
390
391    /// Validates source binding, cell ordering, and derived admission flags.
392    pub fn validate(&self) -> ViewerResult<()> {
393        if self.version != MAP_DIFF_STATE_VERSION {
394            return Err(ViewerError::InvalidState(format!(
395                "unsupported Map Diff state version {}",
396                self.version
397            )));
398        }
399        if self.title.trim().is_empty() {
400            return Err(ViewerError::InvalidState("Map Diff title must not be empty".into()));
401        }
402        self.base.validate()?;
403        self.candidate.validate()?;
404        self.summary.validate()?;
405
406        let source_identity_match = self.base.source.identity_matches
407            && self.candidate.source.identity_matches
408            && self.base.source.expected_sha256 == self.candidate.source.expected_sha256
409            && self.base.source.observed_sha256 == self.candidate.source.observed_sha256;
410        if self.summary.source_identity_match != source_identity_match {
411            return Err(ViewerError::InvalidState(
412                "Map Diff source_identity_match disagrees with map source checksums".into(),
413            ));
414        }
415        let frame_identity_match = self.base.frame_id == self.candidate.frame_id;
416        if self.summary.frame_identity_match != frame_identity_match {
417            return Err(ViewerError::InvalidState(
418                "Map Diff frame_identity_match disagrees with map frame IDs".into(),
419            ));
420        }
421
422        for (expected_index, cell) in self.cells.iter().enumerate() {
423            cell.validate()?;
424            if cell.index != u32::try_from(expected_index).unwrap_or(u32::MAX) {
425                return Err(ViewerError::InvalidState(
426                    "Map Diff cells must use contiguous row-major indices".into(),
427                ));
428            }
429        }
430        if self.summary.cell_count != u64::try_from(self.cells.len()).unwrap_or(u64::MAX) {
431            return Err(ViewerError::InvalidState(
432                "Map Diff cell_count disagrees with the heatmap cell list".into(),
433            ));
434        }
435
436        let calculated_compare_ready = source_identity_match
437            && frame_identity_match
438            && self.summary.compared_vertex_count > 0
439            && self.summary.cell_count > 0;
440        if self.compare_ready != calculated_compare_ready {
441            return Err(ViewerError::InvalidState(
442                "compare_ready disagrees with source, frame, or geometry admission".into(),
443            ));
444        }
445        let calculated_mapping = self.compare_ready && self.summary.calibration_applied;
446        if self.mapping_admitted != calculated_mapping {
447            return Err(ViewerError::InvalidState(
448                "mapping_admitted disagrees with comparison and calibration admission".into(),
449            ));
450        }
451
452        let mut artifact_roles = BTreeSet::new();
453        let mut artifact_paths = BTreeSet::new();
454        for artifact in &self.artifacts {
455            artifact.validate()?;
456            if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
457                return Err(ViewerError::InvalidState(
458                    "Map Diff artifacts must have unique roles and paths".into(),
459                ));
460            }
461        }
462        if !self.mapping_admitted && self.blockers.is_empty() {
463            return Err(ViewerError::InvalidState(
464                "blocked Map Diff mapping must expose at least one blocker".into(),
465            ));
466        }
467        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
468            return Err(ViewerError::InvalidState(
469                "Map Diff blockers must not contain empty messages".into(),
470            ));
471        }
472        Ok(())
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
481    const OTHER_SHA: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
482
483    fn source(observed_sha256: &str) -> StudioSource {
484        StudioSource::try_new(
485            "canonical bag",
486            "/media/input.db3",
487            SHA,
488            observed_sha256,
489            observed_sha256 == SHA,
490        )
491        .unwrap()
492    }
493
494    fn map(label: &str, observed_sha256: &str, artifact_role: &str) -> MapDiffMap {
495        MapDiffMap::try_new(
496            label,
497            source(observed_sha256),
498            ReplayArtifact::try_new(artifact_role, format!("/media/{label}.gltf"), 12, SHA)
499                .unwrap(),
500            "lidar_front",
501            2,
502            1,
503            MapDiffBounds::try_new(0, 0, 0, 1_000_000, 1_000_000, 1_000_000).unwrap(),
504        )
505        .unwrap()
506    }
507
508    fn summary(source_identity_match: bool) -> MapDiffSummary {
509        MapDiffSummary::try_new(
510            2,
511            2,
512            if source_identity_match { 2 } else { 0 },
513            0,
514            0,
515            0,
516            1_000,
517            0,
518            0,
519            0,
520            if source_identity_match { 1 } else { 0 },
521            source_identity_match,
522            true,
523            source_identity_match,
524            true,
525            false,
526        )
527        .unwrap()
528    }
529
530    fn cell() -> MapDiffCell {
531        MapDiffCell::try_new(0, 2, 2, 2, 0, 0, 0).unwrap()
532    }
533
534    #[test]
535    fn valid_diff_state_roundtrips_with_serde() {
536        let state = MapDiffState::try_new(
537            "Map Diff",
538            map("base", SHA, "base-map"),
539            map("candidate", SHA, "candidate-map"),
540            summary(true),
541            vec![cell()],
542            Vec::new(),
543            vec!["clock calibration not applied".into()],
544        )
545        .unwrap();
546        assert!(state.compare_ready);
547        assert!(!state.mapping_admitted);
548        #[cfg(feature = "serde")]
549        {
550            let json = serde_json::to_string(&state).unwrap();
551            assert_eq!(serde_json::from_str::<MapDiffState>(&json).unwrap(), state);
552        }
553    }
554
555    #[test]
556    fn source_mismatch_cannot_be_compare_ready() {
557        let state = MapDiffState::try_new(
558            "Map Diff",
559            map("base", SHA, "base-map"),
560            map("candidate", OTHER_SHA, "candidate-map"),
561            summary(false),
562            Vec::new(),
563            Vec::new(),
564            vec!["input SHA-256 mismatch".into()],
565        )
566        .unwrap();
567        assert!(!state.compare_ready);
568        assert!(!state.mapping_admitted);
569    }
570
571    #[test]
572    fn changed_cell_rejects_inconsistent_counts() {
573        assert!(MapDiffCell::try_new(0, 1, 1, 1, 2, 4, 1).is_err());
574        assert!(MapDiffSummary::try_new(
575            2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 1, true, true, true, true, false
576        )
577        .is_err());
578    }
579}