Skip to main content

spatialrust_viewer/
observatory.rs

1//! Portable TF and calibration observability state.
2//!
3//! The observatory records evidence and admission decisions. It does not solve
4//! calibration, apply transforms, or infer a frame root from incomplete data.
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use crate::{StudioSource, ViewerError, ViewerResult};
9
10/// Current serialized TF/calibration observatory schema version.
11pub const CALIBRATION_OBSERVATORY_STATE_VERSION: u32 = 1;
12
13/// Source-bound status for one calibration artifact.
14#[derive(Clone, Debug, PartialEq, Eq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
17pub struct CalibrationArtifact {
18    /// Artifact kind, such as clock or frame.
19    pub kind: String,
20    /// Receipt status, such as registered or not_registered.
21    pub status: String,
22    /// Registered artifact path, when supplied.
23    pub path: Option<String>,
24    /// Registered artifact SHA-256, when supplied.
25    pub sha256: Option<String>,
26    /// Whether the artifact receipt is bound to the displayed source.
27    pub source_bound: bool,
28}
29
30impl CalibrationArtifact {
31    /// Creates and validates an artifact status.
32    pub fn try_new(
33        kind: impl Into<String>,
34        status: impl Into<String>,
35        path: Option<String>,
36        sha256: Option<String>,
37        source_bound: bool,
38    ) -> ViewerResult<Self> {
39        let artifact =
40            Self { kind: kind.into(), status: status.into(), path, sha256, source_bound };
41        artifact.validate()?;
42        Ok(artifact)
43    }
44
45    /// Validates registration fields and source-binding consistency.
46    pub fn validate(&self) -> ViewerResult<()> {
47        if self.kind.trim().is_empty() || self.status.trim().is_empty() {
48            return Err(ViewerError::InvalidState(
49                "calibration artifact kind and status must not be empty".into(),
50            ));
51        }
52        if self.status == "registered" {
53            let path = self.path.as_deref().unwrap_or_default();
54            let sha256 = self.sha256.as_deref().unwrap_or_default();
55            if path.trim().is_empty() {
56                return Err(ViewerError::InvalidState(format!(
57                    "registered {} artifact has no path",
58                    self.kind
59                )));
60            }
61            validate_sha256(&format!("{} artifact", self.kind), sha256)?;
62        } else if self.source_bound {
63            return Err(ViewerError::InvalidState(format!(
64                "unregistered {} artifact cannot be source-bound",
65                self.kind
66            )));
67        }
68        Ok(())
69    }
70}
71
72/// Clock calibration observability and application status.
73#[derive(Clone, Debug, PartialEq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
76pub struct ClockCalibration {
77    /// Receipt status for the clock artifact.
78    pub status: String,
79    /// Timestamp basis exposed to downstream consumers.
80    pub time_basis: String,
81    /// Number of clock correspondence samples.
82    pub sample_count: u64,
83    /// Median signed offset in nanoseconds, when measured.
84    pub median_offset_nanos: Option<f64>,
85    /// P95 absolute offset in nanoseconds, when measured.
86    pub p95_abs_offset_nanos: Option<f64>,
87    /// Estimated clock drift in parts per million, when measured.
88    pub drift_ppm: Option<f64>,
89    /// Estimated uncertainty in nanoseconds, when measured.
90    pub uncertainty_nanos: Option<f64>,
91    /// Whether clock evidence belongs to the displayed source.
92    pub source_bound: bool,
93    /// Whether the clock model was actually applied to the timeline.
94    pub applied: bool,
95}
96
97impl ClockCalibration {
98    /// Creates and validates clock observability fields.
99    #[allow(clippy::too_many_arguments)]
100    pub fn try_new(
101        status: impl Into<String>,
102        time_basis: impl Into<String>,
103        sample_count: u64,
104        median_offset_nanos: Option<f64>,
105        p95_abs_offset_nanos: Option<f64>,
106        drift_ppm: Option<f64>,
107        uncertainty_nanos: Option<f64>,
108        source_bound: bool,
109        applied: bool,
110    ) -> ViewerResult<Self> {
111        let clock = Self {
112            status: status.into(),
113            time_basis: time_basis.into(),
114            sample_count,
115            median_offset_nanos,
116            p95_abs_offset_nanos,
117            drift_ppm,
118            uncertainty_nanos,
119            source_bound,
120            applied,
121        };
122        clock.validate()?;
123        Ok(clock)
124    }
125
126    /// Validates finite diagnostics and the applied-model invariant.
127    pub fn validate(&self) -> ViewerResult<()> {
128        if self.status.trim().is_empty() || self.time_basis.trim().is_empty() {
129            return Err(ViewerError::InvalidState(
130                "clock status and time basis must not be empty".into(),
131            ));
132        }
133        for (label, value) in [
134            ("median offset", self.median_offset_nanos),
135            ("p95 absolute offset", self.p95_abs_offset_nanos),
136            ("clock drift", self.drift_ppm),
137            ("clock uncertainty", self.uncertainty_nanos),
138        ] {
139            if value.is_some_and(|value| !value.is_finite()) {
140                return Err(ViewerError::InvalidState(format!(
141                    "clock {label} must be finite when present"
142                )));
143            }
144        }
145        if self.applied && (!self.source_bound || self.status != "registered") {
146            return Err(ViewerError::InvalidState(
147                "applied clock calibration must be registered and source-bound".into(),
148            ));
149        }
150        Ok(())
151    }
152}
153
154/// One rigid TF edge observed by the source-bound inventory.
155#[derive(Clone, Debug, PartialEq)]
156#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
157#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
158pub struct FrameTransform {
159    /// Parent frame ID.
160    pub parent_frame: String,
161    /// Child frame ID.
162    pub child_frame: String,
163    /// Translation from parent to child in metres.
164    pub translation_m: [f64; 3],
165    /// Quaternion in x, y, z, w order.
166    pub rotation_xyzw: [f64; 4],
167    /// Transform timestamp, when carried by the source message.
168    pub stamp_nanos: Option<u64>,
169    /// Whether this edge belongs to the displayed source.
170    pub source_bound: bool,
171    /// Whether this edge is admitted for composition.
172    pub accepted: bool,
173}
174
175impl FrameTransform {
176    /// Creates and validates one rigid transform edge.
177    pub fn try_new(
178        parent_frame: impl Into<String>,
179        child_frame: impl Into<String>,
180        translation_m: [f64; 3],
181        rotation_xyzw: [f64; 4],
182        stamp_nanos: Option<u64>,
183        source_bound: bool,
184        accepted: bool,
185    ) -> ViewerResult<Self> {
186        let transform = Self {
187            parent_frame: parent_frame.into(),
188            child_frame: child_frame.into(),
189            translation_m,
190            rotation_xyzw,
191            stamp_nanos,
192            source_bound,
193            accepted,
194        };
195        transform.validate()?;
196        Ok(transform)
197    }
198
199    /// Validates frame names, finite values, and the acceptance invariant.
200    pub fn validate(&self) -> ViewerResult<()> {
201        if self.parent_frame.trim().is_empty()
202            || self.child_frame.trim().is_empty()
203            || self.parent_frame == self.child_frame
204        {
205            return Err(ViewerError::InvalidState(
206                "TF edge parent and child frames must be distinct and non-empty".into(),
207            ));
208        }
209        if self.translation_m.iter().any(|value| !value.is_finite())
210            || self.rotation_xyzw.iter().any(|value| !value.is_finite())
211        {
212            return Err(ViewerError::InvalidState(
213                "TF edge translation and quaternion must be finite".into(),
214            ));
215        }
216        let norm = self.rotation_xyzw.iter().map(|value| value * value).sum::<f64>().sqrt();
217        if norm <= f64::EPSILON {
218            return Err(ViewerError::InvalidState("TF edge quaternion must be non-zero".into()));
219        }
220        if self.accepted && !self.source_bound {
221            return Err(ViewerError::InvalidState("an unbound TF edge cannot be accepted".into()));
222        }
223        Ok(())
224    }
225}
226
227/// One portable TF/calibration observatory snapshot.
228#[derive(Clone, Debug, PartialEq)]
229#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
230#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
231pub struct CalibrationObservatoryState {
232    /// Serialized observatory schema version.
233    pub version: u32,
234    /// User-facing observatory title.
235    pub title: String,
236    /// Checksummed input identity.
237    pub source: StudioSource,
238    /// Clock calibration artifact receipt.
239    pub clock_artifact: CalibrationArtifact,
240    /// Frame calibration artifact receipt.
241    pub frame_artifact: CalibrationArtifact,
242    /// Clock diagnostics and application status.
243    pub clock: ClockCalibration,
244    /// Source-bound frame IDs accepted by the inventory.
245    pub frames: Vec<String>,
246    /// Observed transform edges.
247    pub edges: Vec<FrameTransform>,
248    /// Number of edges rejected before they entered the graph.
249    pub rejected_edge_count: u64,
250    /// Whether the frame inventory belongs to the displayed source.
251    pub frame_inventory_source_bound: bool,
252    /// Root selected for a composition operation, when any.
253    pub root_frame: Option<String>,
254    /// Whether the graph has been composed for downstream use.
255    pub composed: bool,
256    /// Whether the accepted graph is acyclic.
257    pub cycle_free: bool,
258    /// Fail-closed reasons shown by the observatory.
259    pub blockers: Vec<String>,
260    /// Whether clock, frame, source, and graph gates all admit calibration use.
261    pub calibration_admitted: bool,
262}
263
264impl CalibrationObservatoryState {
265    /// Creates an observatory snapshot and derives its admission decision.
266    #[allow(clippy::too_many_arguments)]
267    pub fn try_new(
268        title: impl Into<String>,
269        source: StudioSource,
270        clock_artifact: CalibrationArtifact,
271        frame_artifact: CalibrationArtifact,
272        clock: ClockCalibration,
273        frames: Vec<String>,
274        edges: Vec<FrameTransform>,
275        rejected_edge_count: u64,
276        frame_inventory_source_bound: bool,
277        root_frame: Option<String>,
278        composed: bool,
279        blockers: Vec<String>,
280    ) -> ViewerResult<Self> {
281        let cycle_free = graph_is_acyclic(&frames, &edges);
282        let calibration_admitted = source.identity_matches
283            && clock_artifact.status == "registered"
284            && clock_artifact.source_bound
285            && frame_artifact.status == "registered"
286            && frame_artifact.source_bound
287            && clock.source_bound
288            && clock.applied
289            && frame_inventory_source_bound
290            && composed
291            && cycle_free
292            && !edges.is_empty()
293            && edges.iter().all(|edge| edge.accepted)
294            && blockers.is_empty();
295        let state = Self {
296            version: CALIBRATION_OBSERVATORY_STATE_VERSION,
297            title: title.into(),
298            source,
299            clock_artifact,
300            frame_artifact,
301            clock,
302            frames,
303            edges,
304            rejected_edge_count,
305            frame_inventory_source_bound,
306            root_frame,
307            composed,
308            cycle_free,
309            blockers,
310            calibration_admitted,
311        };
312        state.validate()?;
313        Ok(state)
314    }
315
316    /// Validates receipt identity, graph topology, and fail-closed admission.
317    pub fn validate(&self) -> ViewerResult<()> {
318        if self.version != CALIBRATION_OBSERVATORY_STATE_VERSION {
319            return Err(ViewerError::InvalidState(format!(
320                "unsupported calibration observatory state version {}",
321                self.version
322            )));
323        }
324        if self.title.trim().is_empty() {
325            return Err(ViewerError::InvalidState(
326                "calibration observatory title must not be empty".into(),
327            ));
328        }
329        self.source.validate()?;
330        self.clock_artifact.validate()?;
331        self.frame_artifact.validate()?;
332        self.clock.validate()?;
333
334        let mut frames = BTreeSet::new();
335        for frame in &self.frames {
336            if frame.trim().is_empty() || !frames.insert(frame) {
337                return Err(ViewerError::InvalidState(
338                    "observatory frame IDs must be unique and non-empty".into(),
339                ));
340            }
341        }
342        let mut edges = BTreeSet::new();
343        for edge in &self.edges {
344            edge.validate()?;
345            if !frames.contains(&edge.parent_frame) || !frames.contains(&edge.child_frame) {
346                return Err(ViewerError::InvalidState(
347                    "observatory edge refers to a frame outside the accepted inventory".into(),
348                ));
349            }
350            if !edges.insert((&edge.parent_frame, &edge.child_frame)) {
351                return Err(ViewerError::InvalidState(
352                    "observatory frame graph contains a duplicate edge".into(),
353                ));
354            }
355            if edge.accepted && (!edge.source_bound || !self.source.identity_matches) {
356                return Err(ViewerError::InvalidState(
357                    "accepted observatory edges must match the input source identity".into(),
358                ));
359            }
360        }
361        if !self.frame_inventory_source_bound && (!self.frames.is_empty() || !self.edges.is_empty())
362        {
363            return Err(ViewerError::InvalidState(
364                "unbound frame inventory cannot populate observatory graph fields".into(),
365            ));
366        }
367        if let Some(root) = &self.root_frame {
368            if root.trim().is_empty() || !frames.contains(root) {
369                return Err(ViewerError::InvalidState(
370                    "observatory root must be one of the accepted frames".into(),
371                ));
372            }
373        }
374        if self.composed && (self.root_frame.is_none() || self.edges.is_empty()) {
375            return Err(ViewerError::InvalidState(
376                "composed observatory graph requires a root and accepted edges".into(),
377            ));
378        }
379        if self.cycle_free != graph_is_acyclic(&self.frames, &self.edges) {
380            return Err(ViewerError::InvalidState(
381                "observatory cycle_free disagrees with graph topology".into(),
382            ));
383        }
384        let calculated_admission = self.source.identity_matches
385            && self.clock_artifact.status == "registered"
386            && self.clock_artifact.source_bound
387            && self.frame_artifact.status == "registered"
388            && self.frame_artifact.source_bound
389            && self.clock.source_bound
390            && self.clock.applied
391            && self.frame_inventory_source_bound
392            && self.composed
393            && self.cycle_free
394            && !self.edges.is_empty()
395            && self.edges.iter().all(|edge| edge.accepted)
396            && self.blockers.is_empty();
397        if self.calibration_admitted != calculated_admission {
398            return Err(ViewerError::InvalidState(
399                "calibration_admitted disagrees with observatory gates".into(),
400            ));
401        }
402        if self.calibration_admitted && !self.blockers.is_empty() {
403            return Err(ViewerError::InvalidState(
404                "admitted calibration cannot contain blockers".into(),
405            ));
406        }
407        if !self.calibration_admitted && self.blockers.is_empty() {
408            return Err(ViewerError::InvalidState(
409                "blocked calibration must expose at least one blocker".into(),
410            ));
411        }
412        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
413            return Err(ViewerError::InvalidState("observatory blockers must not be empty".into()));
414        }
415        Ok(())
416    }
417}
418
419fn graph_is_acyclic(frames: &[String], edges: &[FrameTransform]) -> bool {
420    let mut adjacency: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
421    let mut indegree: BTreeMap<&str, usize> = BTreeMap::new();
422    for frame in frames {
423        indegree.insert(frame.as_str(), 0);
424        adjacency.entry(frame.as_str()).or_default();
425    }
426    for edge in edges.iter().filter(|edge| edge.accepted) {
427        adjacency.entry(edge.parent_frame.as_str()).or_default().push(edge.child_frame.as_str());
428        let Some(value) = indegree.get_mut(edge.child_frame.as_str()) else {
429            return false;
430        };
431        *value += 1;
432    }
433    let mut queue = indegree
434        .iter()
435        .filter_map(|(frame, degree)| (*degree == 0).then_some(*frame))
436        .collect::<Vec<_>>();
437    let mut visited = 0_usize;
438    while let Some(frame) = queue.pop() {
439        visited += 1;
440        for child in adjacency.get(frame).into_iter().flatten() {
441            let Some(degree) = indegree.get_mut(child) else {
442                return false;
443            };
444            *degree -= 1;
445            if *degree == 0 {
446                queue.push(child);
447            }
448        }
449    }
450    visited == indegree.len()
451}
452
453fn validate_sha256(label: &str, value: &str) -> ViewerResult<()> {
454    if value.len() != 64
455        || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
456        || value.bytes().any(|byte| byte.is_ascii_uppercase())
457    {
458        return Err(ViewerError::InvalidState(format!(
459            "{label} SHA-256 must be 64 lowercase hexadecimal characters"
460        )));
461    }
462    Ok(())
463}
464
465#[cfg(test)]
466mod tests {
467    use spatialrust_math::Vec3;
468    use spatialrust_viz::{Camera, Projection};
469
470    use super::*;
471
472    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
473
474    fn source(identity_matches: bool) -> StudioSource {
475        let observed = if identity_matches {
476            SHA
477        } else {
478            "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
479        };
480        StudioSource::try_new("bag", "/tmp/bag.db3", SHA, observed, identity_matches).unwrap()
481    }
482
483    fn blocked() -> CalibrationObservatoryState {
484        CalibrationObservatoryState::try_new(
485            "TF Observatory",
486            source(true),
487            CalibrationArtifact::try_new("clock", "not_registered", None, None, false).unwrap(),
488            CalibrationArtifact::try_new("frame", "not_registered", None, None, false).unwrap(),
489            ClockCalibration::try_new(
490                "not_registered",
491                "header stamp",
492                0,
493                None,
494                None,
495                None,
496                None,
497                false,
498                false,
499            )
500            .unwrap(),
501            Vec::new(),
502            Vec::new(),
503            0,
504            false,
505            None,
506            false,
507            vec!["clock artifact is missing".into()],
508        )
509        .unwrap()
510    }
511
512    #[test]
513    fn blocked_observatory_state_is_valid() {
514        let state = blocked();
515        assert!(!state.calibration_admitted);
516        assert!(state.validate().is_ok());
517        #[cfg(feature = "serde")]
518        {
519            let json = serde_json::to_string(&state).unwrap();
520            assert_eq!(serde_json::from_str::<CalibrationObservatoryState>(&json).unwrap(), state);
521        }
522    }
523
524    #[test]
525    fn unbound_accepted_edge_is_rejected() {
526        let edge = FrameTransform::try_new(
527            "map",
528            "lidar",
529            [0.0, 0.0, 0.0],
530            [0.0, 0.0, 0.0, 1.0],
531            None,
532            false,
533            true,
534        );
535        assert!(edge.is_err());
536    }
537
538    #[test]
539    fn source_mismatch_cannot_admit_an_accepted_edge() {
540        let edge = FrameTransform::try_new(
541            "map",
542            "lidar",
543            [0.0, 0.0, 0.0],
544            [0.0, 0.0, 0.0, 1.0],
545            None,
546            true,
547            true,
548        )
549        .unwrap();
550        let result = CalibrationObservatoryState::try_new(
551            "TF Observatory",
552            source(false),
553            CalibrationArtifact::try_new("clock", "not_registered", None, None, false).unwrap(),
554            CalibrationArtifact::try_new("frame", "not_registered", None, None, false).unwrap(),
555            ClockCalibration::try_new(
556                "not_registered",
557                "header stamp",
558                0,
559                None,
560                None,
561                None,
562                None,
563                false,
564                false,
565            )
566            .unwrap(),
567            vec!["map".into(), "lidar".into()],
568            vec![edge],
569            0,
570            true,
571            None,
572            false,
573            vec!["source mismatch".into()],
574        );
575        assert!(result.is_err());
576    }
577
578    #[test]
579    fn finite_transform_and_camera_types_are_available_without_unsafe() {
580        let camera = Camera::try_new(
581            Vec3::new(0.0, 0.0, 1.0),
582            Vec3::new(0.0, 0.0, 0.0),
583            Vec3::new(0.0, 1.0, 0.0),
584            Projection::Perspective { vertical_fov_radians: 1.0, near: 0.1, far: 10.0 },
585        )
586        .unwrap();
587        assert!(camera.eye.z.is_finite());
588    }
589}