Skip to main content

spatialrust_viewer/
calibration_evidence.rs

1//! Source-bound clock and frame calibration evidence.
2//!
3//! This module validates a registration receipt without pretending to solve or
4//! apply calibration.  A clock document must carry explicit quality values and
5//! a frame document must contain a source-bound, acyclic path from one root to
6//! both required lidar frames.  Downstream mapping still needs an application
7//! receipt before it can be admitted.
8
9use std::collections::{BTreeMap, BTreeSet, VecDeque};
10
11use crate::{
12    CalibrationArtifact, ClockCalibration, FrameTransform, StudioSource, ViewerError, ViewerResult,
13};
14
15/// Current serialized calibration-evidence state schema version.
16pub const CALIBRATION_EVIDENCE_STATE_VERSION: u32 = 1;
17
18/// Explicit clock calibration evidence carried by a registration document.
19#[derive(Clone, Debug, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
22pub struct CalibrationEvidenceClock {
23    /// Clock domain observed in the source records.
24    pub source_domain: String,
25    /// Clock domain to which the model would map timestamps.
26    pub target_domain: String,
27    /// Human-readable calibration method or provenance label.
28    pub method: String,
29    /// Numeric diagnostics and source-binding status.
30    pub calibration: ClockCalibration,
31}
32
33impl CalibrationEvidenceClock {
34    /// Creates and validates explicit clock evidence.
35    pub fn try_new(
36        source_domain: impl Into<String>,
37        target_domain: impl Into<String>,
38        method: impl Into<String>,
39        calibration: ClockCalibration,
40    ) -> ViewerResult<Self> {
41        let clock = Self {
42            source_domain: source_domain.into(),
43            target_domain: target_domain.into(),
44            method: method.into(),
45            calibration,
46        };
47        clock.validate()?;
48        Ok(clock)
49    }
50
51    /// Validates clock provenance, quality values, and registration ordering.
52    pub fn validate(&self) -> ViewerResult<()> {
53        if self.source_domain.trim().is_empty()
54            || self.target_domain.trim().is_empty()
55            || self.method.trim().is_empty()
56        {
57            return Err(ViewerError::InvalidState(
58                "calibration clock evidence requires domains and a method".into(),
59            ));
60        }
61        self.calibration.validate()?;
62        if self.calibration.status == "registered" {
63            if self.source_domain == self.target_domain {
64                return Err(ViewerError::InvalidState(
65                    "registered clock evidence requires distinct source and target domains".into(),
66                ));
67            }
68            if !self.calibration.source_bound || self.calibration.sample_count == 0 {
69                return Err(ViewerError::InvalidState(
70                    "registered clock evidence requires source binding and samples".into(),
71                ));
72            }
73            for (label, value) in [
74                ("p95 absolute offset", self.calibration.p95_abs_offset_nanos),
75                ("clock uncertainty", self.calibration.uncertainty_nanos),
76            ] {
77                if value.map_or(true, |value| value < 0.0) {
78                    return Err(ViewerError::InvalidState(format!(
79                        "registered clock evidence requires non-negative {label}"
80                    )));
81                }
82            }
83        }
84        Ok(())
85    }
86
87    /// Returns whether this clock document is ready for registration.
88    #[must_use]
89    pub fn registration_ready(&self) -> bool {
90        self.calibration.status == "registered"
91            && self.calibration.source_bound
92            && self.calibration.sample_count > 0
93            && self.calibration.p95_abs_offset_nanos.is_some_and(|value| value >= 0.0)
94            && self.calibration.uncertainty_nanos.is_some_and(|value| value >= 0.0)
95            && self.source_domain != self.target_domain
96    }
97}
98
99/// Explicit root-to-sensor frame evidence carried by a registration document.
100#[derive(Clone, Debug, PartialEq)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
103pub struct CalibrationEvidenceFrame {
104    /// Human-readable calibration method or provenance label.
105    pub method: String,
106    /// Root frame from which the sensor paths are evaluated.
107    pub root_frame: String,
108    /// Required sensor role to frame mapping, including `front` and `rear`.
109    pub required_frames: BTreeMap<String, String>,
110    /// All frame IDs present in the source-bound graph.
111    pub frames: Vec<String>,
112    /// Source-bound rigid edges in parent-to-child direction.
113    pub edges: Vec<FrameTransform>,
114    /// Whether the graph satisfies the root and required-sensor path checks.
115    pub graph_ready: bool,
116}
117
118impl CalibrationEvidenceFrame {
119    /// Creates a frame evidence document and derives its graph readiness.
120    pub fn try_new(
121        method: impl Into<String>,
122        root_frame: impl Into<String>,
123        required_frames: BTreeMap<String, String>,
124        frames: Vec<String>,
125        edges: Vec<FrameTransform>,
126    ) -> ViewerResult<Self> {
127        let frame = Self {
128            method: method.into(),
129            root_frame: root_frame.into(),
130            required_frames,
131            frames,
132            edges,
133            graph_ready: false,
134        };
135        let graph_ready = frame.calculate_graph_ready()?;
136        let frame = Self { graph_ready, ..frame };
137        frame.validate()?;
138        Ok(frame)
139    }
140
141    /// Validates graph identity, topology, and the derived readiness bit.
142    pub fn validate(&self) -> ViewerResult<()> {
143        if self.method.trim().is_empty() || self.root_frame.trim().is_empty() {
144            return Err(ViewerError::InvalidState(
145                "calibration frame evidence requires a method and root frame".into(),
146            ));
147        }
148        let mut required_values = BTreeSet::new();
149        for (role, frame) in &self.required_frames {
150            if role.trim().is_empty() || frame.trim().is_empty() || !required_values.insert(frame) {
151                return Err(ViewerError::InvalidState(
152                    "required calibration sensor roles and frames must be unique and non-empty"
153                        .into(),
154                ));
155            }
156        }
157        if !self.required_frames.contains_key("front") || !self.required_frames.contains_key("rear")
158        {
159            return Err(ViewerError::InvalidState(
160                "calibration frame evidence requires front and rear sensor frames".into(),
161            ));
162        }
163        let mut frame_ids = BTreeSet::new();
164        for frame in &self.frames {
165            if frame.trim().is_empty() || !frame_ids.insert(frame) {
166                return Err(ViewerError::InvalidState(
167                    "calibration frame graph IDs must be unique and non-empty".into(),
168                ));
169            }
170        }
171        let mut edges = BTreeSet::new();
172        for edge in &self.edges {
173            edge.validate()?;
174            if !frame_ids.contains(&edge.parent_frame) || !frame_ids.contains(&edge.child_frame) {
175                return Err(ViewerError::InvalidState(
176                    "calibration frame edge refers to an unknown frame".into(),
177                ));
178            }
179            if !edge.source_bound || !edge.accepted {
180                return Err(ViewerError::InvalidState(
181                    "calibration evidence edges must be source-bound and accepted".into(),
182                ));
183            }
184            if !edges.insert((&edge.parent_frame, &edge.child_frame)) {
185                return Err(ViewerError::InvalidState(
186                    "calibration frame graph contains a duplicate edge".into(),
187                ));
188            }
189        }
190        if !graph_is_acyclic(&self.frames, &self.edges) {
191            return Err(ViewerError::InvalidState(
192                "calibration frame graph must be acyclic".into(),
193            ));
194        }
195        let calculated_ready = self.calculate_graph_ready()?;
196        if self.graph_ready != calculated_ready {
197            return Err(ViewerError::InvalidState(
198                "calibration frame graph_ready disagrees with graph topology".into(),
199            ));
200        }
201        Ok(())
202    }
203
204    fn calculate_graph_ready(&self) -> ViewerResult<bool> {
205        let mut frame_ids = BTreeSet::new();
206        for frame in &self.frames {
207            if frame.trim().is_empty() || !frame_ids.insert(frame) {
208                return Err(ViewerError::InvalidState(
209                    "calibration frame graph IDs must be unique and non-empty".into(),
210                ));
211            }
212        }
213        if !frame_ids.contains(&self.root_frame)
214            || self.edges.is_empty()
215            || !self.edges.iter().all(|edge| {
216                edge.source_bound
217                    && edge.accepted
218                    && frame_ids.contains(&edge.parent_frame)
219                    && frame_ids.contains(&edge.child_frame)
220            })
221            || !graph_is_acyclic(&self.frames, &self.edges)
222        {
223            return Ok(false);
224        }
225        Ok(self.required_frames.values().all(|target| {
226            target != &self.root_frame
227                && frame_ids.contains(target)
228                && path_exists(&self.edges, &self.root_frame, target)
229        }))
230    }
231
232    /// Returns whether a complete root-to-front/rear graph is registered.
233    #[must_use]
234    pub fn registration_ready(&self) -> bool {
235        self.graph_ready
236    }
237}
238
239/// Portable source-bound calibration registration state.
240#[derive(Clone, Debug, PartialEq)]
241#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
242#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
243pub struct CalibrationEvidenceState {
244    /// Serialized state schema version.
245    pub version: u32,
246    /// User-facing title.
247    pub title: String,
248    /// Exact source identity to which both evidence documents refer.
249    pub source: StudioSource,
250    /// Checksummed clock evidence file receipt.
251    pub clock_artifact: CalibrationArtifact,
252    /// Checksummed frame evidence file receipt.
253    pub frame_artifact: CalibrationArtifact,
254    /// Parsed clock registration evidence.
255    pub clock: CalibrationEvidenceClock,
256    /// Parsed frame graph registration evidence.
257    pub frame: CalibrationEvidenceFrame,
258    /// Whether all registration gates passed.
259    pub registration_ready: bool,
260    /// Fail-closed reasons for registration or later application.
261    pub blockers: Vec<String>,
262}
263
264impl CalibrationEvidenceState {
265    /// Creates a state and derives source-bound registration admission.
266    pub fn try_new(
267        title: impl Into<String>,
268        source: StudioSource,
269        clock_artifact: CalibrationArtifact,
270        frame_artifact: CalibrationArtifact,
271        clock: CalibrationEvidenceClock,
272        frame: CalibrationEvidenceFrame,
273        blockers: Vec<String>,
274    ) -> ViewerResult<Self> {
275        let registration_ready = source.identity_matches
276            && clock_artifact.status == "registered"
277            && clock_artifact.source_bound
278            && frame_artifact.status == "registered"
279            && frame_artifact.source_bound
280            && clock.registration_ready()
281            && frame.registration_ready()
282            && blockers.is_empty();
283        let state = Self {
284            version: CALIBRATION_EVIDENCE_STATE_VERSION,
285            title: title.into(),
286            source,
287            clock_artifact,
288            frame_artifact,
289            clock,
290            frame,
291            registration_ready,
292            blockers,
293        };
294        state.validate()?;
295        Ok(state)
296    }
297
298    /// Validates evidence documents and the derived registration decision.
299    pub fn validate(&self) -> ViewerResult<()> {
300        if self.version != CALIBRATION_EVIDENCE_STATE_VERSION {
301            return Err(ViewerError::InvalidState(format!(
302                "unsupported calibration evidence state version {}",
303                self.version
304            )));
305        }
306        if self.title.trim().is_empty() {
307            return Err(ViewerError::InvalidState(
308                "calibration evidence title must not be empty".into(),
309            ));
310        }
311        self.source.validate()?;
312        self.clock_artifact.validate()?;
313        self.frame_artifact.validate()?;
314        self.clock.validate()?;
315        self.frame.validate()?;
316        let calculated_registration = self.source.identity_matches
317            && self.clock_artifact.status == "registered"
318            && self.clock_artifact.source_bound
319            && self.frame_artifact.status == "registered"
320            && self.frame_artifact.source_bound
321            && self.clock.registration_ready()
322            && self.frame.registration_ready()
323            && self.blockers.is_empty();
324        if self.registration_ready != calculated_registration {
325            return Err(ViewerError::InvalidState(
326                "calibration registration_ready disagrees with evidence gates".into(),
327            ));
328        }
329        if self.registration_ready && !self.blockers.is_empty() {
330            return Err(ViewerError::InvalidState(
331                "admitted calibration evidence cannot contain blockers".into(),
332            ));
333        }
334        if !self.registration_ready && self.blockers.is_empty() {
335            return Err(ViewerError::InvalidState(
336                "blocked calibration evidence must expose at least one blocker".into(),
337            ));
338        }
339        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
340            return Err(ViewerError::InvalidState(
341                "calibration evidence blockers must not be empty".into(),
342            ));
343        }
344        Ok(())
345    }
346}
347
348fn graph_is_acyclic(frames: &[String], edges: &[FrameTransform]) -> bool {
349    let mut adjacency: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
350    let mut indegree: BTreeMap<&str, usize> = BTreeMap::new();
351    for frame in frames {
352        adjacency.entry(frame.as_str()).or_default();
353        indegree.insert(frame.as_str(), 0);
354    }
355    for edge in edges.iter().filter(|edge| edge.accepted) {
356        let Some(value) = indegree.get_mut(edge.child_frame.as_str()) else {
357            return false;
358        };
359        adjacency.entry(edge.parent_frame.as_str()).or_default().push(edge.child_frame.as_str());
360        *value += 1;
361    }
362    let mut queue = indegree
363        .iter()
364        .filter_map(|(frame, degree)| (*degree == 0).then_some(*frame))
365        .collect::<Vec<_>>();
366    let mut visited = 0_usize;
367    while let Some(frame) = queue.pop() {
368        visited += 1;
369        for child in adjacency.get(frame).into_iter().flatten() {
370            let Some(degree) = indegree.get_mut(child) else {
371                return false;
372            };
373            *degree -= 1;
374            if *degree == 0 {
375                queue.push(child);
376            }
377        }
378    }
379    visited == indegree.len()
380}
381
382fn path_exists(edges: &[FrameTransform], root: &str, target: &str) -> bool {
383    let mut adjacency: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
384    for edge in edges.iter().filter(|edge| edge.accepted) {
385        adjacency.entry(edge.parent_frame.as_str()).or_default().push(edge.child_frame.as_str());
386    }
387    let mut queue = VecDeque::from([root]);
388    let mut visited = BTreeSet::from([root]);
389    while let Some(frame) = queue.pop_front() {
390        if frame == target {
391            return true;
392        }
393        for child in adjacency.get(frame).into_iter().flatten() {
394            if visited.insert(child) {
395                queue.push_back(child);
396            }
397        }
398    }
399    false
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
407
408    fn source(matches: bool) -> StudioSource {
409        let observed = if matches {
410            SHA
411        } else {
412            "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
413        };
414        StudioSource::try_new("canonical bag", "/media/canonical.db3", SHA, observed, matches)
415            .unwrap()
416    }
417
418    fn artifacts(bound: bool) -> (CalibrationArtifact, CalibrationArtifact) {
419        (
420            CalibrationArtifact::try_new(
421                "clock_evidence",
422                "registered",
423                Some("/media/clock.json".into()),
424                Some(SHA.into()),
425                bound,
426            )
427            .unwrap(),
428            CalibrationArtifact::try_new(
429                "frame_evidence",
430                "registered",
431                Some("/media/frame.json".into()),
432                Some(SHA.into()),
433                bound,
434            )
435            .unwrap(),
436        )
437    }
438
439    fn clock(bound: bool) -> CalibrationEvidenceClock {
440        CalibrationEvidenceClock::try_new(
441            "ros2-external",
442            "canonical",
443            "fixture-clock-fit",
444            ClockCalibration::try_new(
445                "registered",
446                "explicit clock model; not applied",
447                12,
448                Some(-2.0),
449                Some(5.0),
450                Some(0.1),
451                Some(10.0),
452                bound,
453                false,
454            )
455            .unwrap(),
456        )
457        .unwrap()
458    }
459
460    fn frame(cycle: bool) -> ViewerResult<CalibrationEvidenceFrame> {
461        let required = BTreeMap::from([
462            ("front".into(), "lidar_front".into()),
463            ("rear".into(), "lidar_rear".into()),
464        ]);
465        let mut edges = vec![
466            FrameTransform::try_new(
467                "base_link",
468                "lidar_front",
469                [1.0, 0.0, 0.0],
470                [0.0, 0.0, 0.0, 1.0],
471                None,
472                true,
473                true,
474            )
475            .unwrap(),
476            FrameTransform::try_new(
477                "base_link",
478                "lidar_rear",
479                [-1.0, 0.0, 0.0],
480                [0.0, 0.0, 0.0, 1.0],
481                None,
482                true,
483                true,
484            )
485            .unwrap(),
486        ];
487        if cycle {
488            edges.push(
489                FrameTransform::try_new(
490                    "lidar_front",
491                    "base_link",
492                    [0.0, 0.0, 0.0],
493                    [0.0, 0.0, 0.0, 1.0],
494                    None,
495                    true,
496                    true,
497                )
498                .unwrap(),
499            );
500        }
501        CalibrationEvidenceFrame::try_new(
502            "fixture-extrinsic-fit",
503            "base_link",
504            required,
505            vec!["base_link".into(), "lidar_front".into(), "lidar_rear".into()],
506            edges,
507        )
508    }
509
510    #[test]
511    fn healthy_source_bound_evidence_is_registration_ready() {
512        let (clock_artifact, frame_artifact) = artifacts(true);
513        let state = CalibrationEvidenceState::try_new(
514            "Calibration Evidence",
515            source(true),
516            clock_artifact,
517            frame_artifact,
518            clock(true),
519            frame(false).unwrap(),
520            Vec::new(),
521        )
522        .unwrap();
523        assert!(state.registration_ready);
524        state.validate().unwrap();
525    }
526
527    #[test]
528    fn source_mismatch_withholds_registration() {
529        let (clock_artifact, frame_artifact) = artifacts(true);
530        let state = CalibrationEvidenceState::try_new(
531            "Calibration Evidence",
532            source(false),
533            clock_artifact,
534            frame_artifact,
535            clock(true),
536            frame(false).unwrap(),
537            vec!["input SHA mismatch".into()],
538        )
539        .unwrap();
540        assert!(!state.registration_ready);
541    }
542
543    #[test]
544    fn missing_evidence_is_a_valid_blocked_state() {
545        let required = BTreeMap::from([
546            ("front".into(), "lidar_front".into()),
547            ("rear".into(), "lidar_rear".into()),
548        ]);
549        let (clock_artifact, frame_artifact) = (
550            CalibrationArtifact::try_new("clock_evidence", "not_registered", None, None, false)
551                .unwrap(),
552            CalibrationArtifact::try_new("frame_evidence", "not_registered", None, None, false)
553                .unwrap(),
554        );
555        let state = CalibrationEvidenceState::try_new(
556            "Calibration Evidence",
557            source(true),
558            clock_artifact,
559            frame_artifact,
560            CalibrationEvidenceClock::try_new(
561                "unknown",
562                "uncalibrated",
563                "not_registered",
564                ClockCalibration::try_new(
565                    "not_registered",
566                    "header stamp",
567                    0,
568                    None,
569                    None,
570                    None,
571                    None,
572                    false,
573                    false,
574                )
575                .unwrap(),
576            )
577            .unwrap(),
578            CalibrationEvidenceFrame::try_new(
579                "not_registered",
580                "base_link",
581                required,
582                Vec::new(),
583                Vec::new(),
584            )
585            .unwrap(),
586            vec!["clock evidence is not registered".into()],
587        )
588        .unwrap();
589        assert!(!state.registration_ready);
590        state.validate().unwrap();
591    }
592
593    #[test]
594    fn cyclic_frame_graph_is_rejected() {
595        assert!(frame(true).is_err());
596    }
597}