Skip to main content

spatialrust_viewer/
mapping_gate.rs

1//! Source-bound bounded full-bag mapping admission state.
2//!
3//! This module is deliberately independent of ROS, SQLite, and renderer
4//! implementations.  An adapter may populate the state after applying an
5//! explicitly registered clock model and frame graph, while consumers can
6//! inspect the same fail-closed decision without re-running the mapping.
7
8use crate::{CalibrationEvidenceState, ReplayArtifact, StudioSource, ViewerError, ViewerResult};
9
10/// Current serialized bounded full-bag mapping state schema version.
11pub const FULL_BAG_MAPPING_STATE_VERSION: u32 = 1;
12
13/// Source and bounded-ingest totals for one full-bag mapping run.
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 MappingSourceSummary {
18    /// Front sensor topic consumed by the run.
19    pub front_topic: String,
20    /// Rear sensor topic consumed by the run.
21    pub rear_topic: String,
22    /// Number of PointCloud2 messages present in the front topic.
23    pub front_bag_message_count: u64,
24    /// Number of PointCloud2 messages present in the rear topic.
25    pub rear_bag_message_count: u64,
26    /// Number of bounded source chunks emitted for the front topic.
27    pub front_chunk_count: u64,
28    /// Number of bounded source chunks emitted for the rear topic.
29    pub rear_chunk_count: u64,
30    /// Number of complete bounded records retained from the front topic.
31    pub front_record_count: u64,
32    /// Number of complete bounded records retained from the rear topic.
33    pub rear_record_count: u64,
34    /// Total retained records across both topics.
35    pub total_record_count: u64,
36    /// Total retained points across both topics.
37    pub total_point_count: u64,
38    /// Conservative allocated column bytes retained by the episode.
39    pub retained_bytes: u64,
40    /// Largest raw source message allocation declared by the selected topics.
41    pub peak_source_bytes: u64,
42    /// First corrected timestamp, when a bag was processed.
43    pub start_nanos: Option<u64>,
44    /// Last corrected timestamp, when a bag was processed.
45    pub end_nanos: Option<u64>,
46    /// Whether every selected source record was consumed within the limits.
47    pub full_bag_processed: bool,
48    /// Whether a configured hard bound stopped source consumption.
49    pub truncated: bool,
50}
51
52impl MappingSourceSummary {
53    /// Validates source identities and bounded totals.
54    pub fn validate(&self) -> ViewerResult<()> {
55        let expected_total =
56            self.front_record_count.checked_add(self.rear_record_count).ok_or_else(|| {
57                ViewerError::InvalidState("mapping source record total overflow".into())
58            })?;
59        if self.front_topic.trim().is_empty()
60            || self.rear_topic.trim().is_empty()
61            || self.front_topic == self.rear_topic
62            || self.front_bag_message_count < self.front_record_count
63            || self.rear_bag_message_count < self.rear_record_count
64            || self.front_chunk_count < self.front_record_count
65            || self.rear_chunk_count < self.rear_record_count
66            || self.total_record_count != expected_total
67            || (self.total_record_count == 0 && self.total_point_count != 0)
68            || self.truncated && self.full_bag_processed
69        {
70            return Err(ViewerError::InvalidState(
71                "mapping source summary has invalid topic or bounded totals".into(),
72            ));
73        }
74        if let (Some(start), Some(end)) = (self.start_nanos, self.end_nanos) {
75            if start > end {
76                return Err(ViewerError::InvalidState(
77                    "mapping source timestamp bounds are reversed".into(),
78                ));
79            }
80        } else if self.start_nanos.is_some() != self.end_nanos.is_some() {
81            return Err(ViewerError::InvalidState(
82                "mapping source timestamp bounds must be complete".into(),
83            ));
84        }
85        if self.full_bag_processed
86            && (self.total_record_count == 0
87                || self.total_point_count == 0
88                || self.retained_bytes == 0
89                || self.peak_source_bytes == 0)
90        {
91            return Err(ViewerError::InvalidState(
92                "a completed full-bag mapping run must retain source records and points".into(),
93            ));
94        }
95        Ok(())
96    }
97}
98
99/// Odometry and pose-graph receipt for the bounded full-bag run.
100#[derive(Clone, Debug, PartialEq, Eq)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
103pub struct MappingOdometrySummary {
104    /// Topic used as the primary trajectory stream.
105    pub topic: String,
106    /// Original sensor frame of the primary trajectory stream.
107    pub source_frame: String,
108    /// Root frame after explicit frame-graph application.
109    pub root_frame: String,
110    /// Clock identifier used by corrected trajectory stamps.
111    pub clock_id: String,
112    /// Human-readable clock-domain label.
113    pub clock_domain: String,
114    /// Matcher and bounded-run description.
115    pub matcher: String,
116    /// Number of scans in the trajectory.
117    pub scan_count: u64,
118    /// Number of sequential relative motions.
119    pub motion_count: u64,
120    /// Number of pose-graph nodes.
121    pub pose_graph_node_count: u64,
122    /// Number of pose-graph edges.
123    pub pose_graph_edge_count: u64,
124    /// Whether odometry consumed the complete selected stream.
125    pub complete: bool,
126    /// Whether the selected stream was cut by a bound.
127    pub truncated: bool,
128}
129
130impl MappingOdometrySummary {
131    /// Validates trajectory and pose-graph totals.
132    pub fn validate(&self) -> ViewerResult<()> {
133        if self.topic.trim().is_empty()
134            || self.source_frame.trim().is_empty()
135            || self.root_frame.trim().is_empty()
136            || self.clock_id.trim().is_empty()
137            || self.clock_domain.trim().is_empty()
138            || self.matcher.trim().is_empty()
139            || self.motion_count > self.scan_count
140            || self.pose_graph_node_count != self.scan_count
141            || self.pose_graph_edge_count != self.motion_count
142            || self.truncated && self.complete
143        {
144            return Err(ViewerError::InvalidState(
145                "mapping odometry summary has invalid identity or graph totals".into(),
146            ));
147        }
148        if self.complete
149            && (self.scan_count < 2 || self.motion_count.checked_add(1) != Some(self.scan_count))
150        {
151            return Err(ViewerError::InvalidState(
152                "completed mapping odometry requires a connected scan trajectory".into(),
153            ));
154        }
155        Ok(())
156    }
157}
158
159/// TSDF and mesh receipt for one mapping run.
160#[derive(Clone, Debug, PartialEq)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
163pub struct MappingTsdfSummary {
164    /// Frame in which the volume is expressed.
165    pub frame_id: String,
166    /// Volume origin in metres.
167    pub origin: [f32; 3],
168    /// Voxel edge length in metres.
169    pub voxel_size: f32,
170    /// Number of voxels on each axis.
171    pub dims: [usize; 3],
172    /// TSDF truncation distance in metres.
173    pub truncation: f32,
174    /// Number of records integrated into the volume.
175    pub integrated_record_count: u64,
176    /// Number of point samples visited by integration.
177    pub integrated_point_count: u64,
178    /// Number of extracted mesh vertices.
179    pub mesh_vertex_count: u64,
180    /// Number of extracted mesh triangles.
181    pub mesh_triangle_count: u64,
182    /// Whether TSDF integration and extraction completed.
183    pub complete: bool,
184}
185
186impl MappingTsdfSummary {
187    /// Validates finite volume configuration and integration totals.
188    pub fn validate(&self) -> ViewerResult<()> {
189        if self.frame_id.trim().is_empty()
190            || self.origin.iter().any(|value| !value.is_finite())
191            || !self.voxel_size.is_finite()
192            || self.voxel_size <= 0.0
193            || self.dims.contains(&0)
194            || !self.truncation.is_finite()
195            || self.truncation <= 0.0
196            || self.mesh_vertex_count == 0 && self.mesh_triangle_count != 0
197        {
198            return Err(ViewerError::InvalidState(
199                "mapping TSDF summary has invalid volume or mesh values".into(),
200            ));
201        }
202        if self.complete && (self.integrated_record_count == 0 || self.integrated_point_count == 0)
203        {
204            return Err(ViewerError::InvalidState(
205                "completed mapping TSDF requires integrated records and points".into(),
206            ));
207        }
208        Ok(())
209    }
210}
211
212/// Derived admission levels for bounded full-bag mapping.
213#[derive(Clone, Debug, PartialEq, Eq)]
214#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
215#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
216pub struct MappingGateSummary {
217    /// Whether source-bound calibration evidence is registered.
218    pub calibration_registered: bool,
219    /// Whether the registered clock model was applied to timestamps.
220    pub clock_applied: bool,
221    /// Whether the registered frame graph was applied to sensor geometry.
222    pub frame_graph_applied: bool,
223    /// Whether the complete selected bag was retained within bounds.
224    pub full_bag_processed: bool,
225    /// Whether bounded odometry completed over the selected stream.
226    pub odometry_complete: bool,
227    /// Whether bounded TSDF integration and extraction completed.
228    pub tsdf_complete: bool,
229    /// Whether calibrated-world mapping may be consumed downstream.
230    pub mapping_admitted: bool,
231}
232
233/// Portable source-bound bounded full-bag mapping state.
234#[derive(Clone, Debug, PartialEq)]
235#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
236#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
237pub struct FullBagMappingState {
238    /// Serialized state schema version.
239    pub version: u32,
240    /// User-facing dashboard title.
241    pub title: String,
242    /// Exact input identity used by the run.
243    pub source: StudioSource,
244    /// Optional parsed calibration state; absence is a blocking condition.
245    pub calibration: Option<CalibrationEvidenceState>,
246    /// Bounded source ingest totals.
247    pub source_summary: MappingSourceSummary,
248    /// Odometry receipt, when the source and calibration gates allowed it.
249    pub odometry: Option<MappingOdometrySummary>,
250    /// TSDF receipt, when mapping stages completed.
251    pub tsdf: Option<MappingTsdfSummary>,
252    /// Checksummed input and derived artifacts.
253    pub artifacts: Vec<ReplayArtifact>,
254    /// Derived admission levels.
255    pub summary: MappingGateSummary,
256    /// Human-readable fail-closed reasons.
257    pub blockers: Vec<String>,
258}
259
260impl FullBagMappingState {
261    /// Creates state and derives its mapping admission decision.
262    #[allow(clippy::too_many_arguments)]
263    pub fn try_new(
264        title: impl Into<String>,
265        source: StudioSource,
266        calibration: Option<CalibrationEvidenceState>,
267        source_summary: MappingSourceSummary,
268        odometry: Option<MappingOdometrySummary>,
269        tsdf: Option<MappingTsdfSummary>,
270        artifacts: Vec<ReplayArtifact>,
271        clock_applied: bool,
272        frame_graph_applied: bool,
273        mut blockers: Vec<String>,
274    ) -> ViewerResult<Self> {
275        let calibration_registered = calibration.as_ref().is_some_and(|calibration| {
276            calibration.registration_ready
277                && calibration.source.identity_matches
278                && calibration.source.path == source.path
279                && calibration.source.observed_sha256 == source.observed_sha256
280        });
281        let full_bag_processed = source_summary.full_bag_processed && !source_summary.truncated;
282        let odometry_complete =
283            odometry.as_ref().is_some_and(|odometry| odometry.complete && !odometry.truncated);
284        let tsdf_complete = tsdf.as_ref().is_some_and(|tsdf| tsdf.complete);
285
286        if !source.identity_matches {
287            push_blocker(
288                &mut blockers,
289                "mapping input source identity does not match expected SHA-256",
290            );
291        }
292        if calibration.is_none() {
293            push_blocker(&mut blockers, "source-bound calibration evidence was not supplied");
294        } else if !calibration_registered {
295            push_blocker(
296                &mut blockers,
297                "source-bound calibration evidence registration is incomplete",
298            );
299        }
300        if !full_bag_processed {
301            push_blocker(
302                &mut blockers,
303                "full-bag ingest was not completed because an admission gate blocked execution or a configured bound was reached",
304            );
305        }
306        if !clock_applied {
307            push_blocker(
308                &mut blockers,
309                "registered clock model was not applied to the mapping timeline",
310            );
311        }
312        if !frame_graph_applied {
313            push_blocker(
314                &mut blockers,
315                "registered frame graph was not applied to sensor geometry",
316            );
317        }
318        if !odometry_complete {
319            push_blocker(&mut blockers, "full-bag frame-aware odometry did not complete");
320        }
321        if !tsdf_complete {
322            push_blocker(&mut blockers, "full-bag TSDF integration did not complete");
323        }
324
325        let summary = MappingGateSummary {
326            calibration_registered,
327            clock_applied,
328            frame_graph_applied,
329            full_bag_processed,
330            odometry_complete,
331            tsdf_complete,
332            mapping_admitted: source.identity_matches
333                && calibration_registered
334                && clock_applied
335                && frame_graph_applied
336                && full_bag_processed
337                && odometry_complete
338                && tsdf_complete
339                && blockers.is_empty(),
340        };
341        let state = Self {
342            version: FULL_BAG_MAPPING_STATE_VERSION,
343            title: title.into(),
344            source,
345            calibration,
346            source_summary,
347            odometry,
348            tsdf,
349            artifacts,
350            summary,
351            blockers,
352        };
353        state.validate()?;
354        Ok(state)
355    }
356
357    /// Validates source, calibration, stage receipts, and derived gates.
358    pub fn validate(&self) -> ViewerResult<()> {
359        if self.version != FULL_BAG_MAPPING_STATE_VERSION {
360            return Err(ViewerError::InvalidState(format!(
361                "unsupported full-bag mapping state version {}",
362                self.version
363            )));
364        }
365        if self.title.trim().is_empty() {
366            return Err(ViewerError::InvalidState(
367                "full-bag mapping title must not be empty".into(),
368            ));
369        }
370        self.source.validate()?;
371        self.source_summary.validate()?;
372        if let Some(calibration) = &self.calibration {
373            calibration.validate()?;
374        }
375        if let Some(odometry) = &self.odometry {
376            odometry.validate()?;
377        }
378        if let Some(tsdf) = &self.tsdf {
379            tsdf.validate()?;
380        }
381        let mut roles = std::collections::BTreeSet::new();
382        let mut paths = std::collections::BTreeSet::new();
383        for artifact in &self.artifacts {
384            artifact.validate()?;
385            if !roles.insert(&artifact.role) || !paths.insert(&artifact.path) {
386                return Err(ViewerError::InvalidState(
387                    "full-bag mapping artifacts must have unique roles and paths".into(),
388                ));
389            }
390        }
391
392        let calculated_calibration = self.calibration.as_ref().is_some_and(|calibration| {
393            calibration.registration_ready
394                && calibration.source.identity_matches
395                && calibration.source.path == self.source.path
396                && calibration.source.observed_sha256 == self.source.observed_sha256
397        });
398        let calculated_full =
399            self.source_summary.full_bag_processed && !self.source_summary.truncated;
400        let calculated_odometry =
401            self.odometry.as_ref().is_some_and(|odometry| odometry.complete && !odometry.truncated);
402        let calculated_tsdf = self.tsdf.as_ref().is_some_and(|tsdf| tsdf.complete);
403        if self.summary.calibration_registered != calculated_calibration
404            || self.summary.full_bag_processed != calculated_full
405            || self.summary.odometry_complete != calculated_odometry
406            || self.summary.tsdf_complete != calculated_tsdf
407            || (self.summary.clock_applied && !calculated_calibration)
408            || (self.summary.frame_graph_applied && !calculated_calibration)
409        {
410            return Err(ViewerError::InvalidState(
411                "full-bag mapping summary disagrees with source or stage receipts".into(),
412            ));
413        }
414        let calculated_mapping = self.source.identity_matches
415            && calculated_calibration
416            && self.summary.clock_applied
417            && self.summary.frame_graph_applied
418            && calculated_full
419            && calculated_odometry
420            && calculated_tsdf
421            && self.blockers.is_empty();
422        if self.summary.mapping_admitted != calculated_mapping {
423            return Err(ViewerError::InvalidState(
424                "mapping_admitted disagrees with full-bag mapping gates".into(),
425            ));
426        }
427        if self.summary.mapping_admitted && !self.blockers.is_empty() {
428            return Err(ViewerError::InvalidState(
429                "admitted full-bag mapping cannot contain blockers".into(),
430            ));
431        }
432        if !self.summary.mapping_admitted && self.blockers.is_empty() {
433            return Err(ViewerError::InvalidState(
434                "blocked full-bag mapping must expose at least one blocker".into(),
435            ));
436        }
437        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
438            return Err(ViewerError::InvalidState(
439                "full-bag mapping blockers must not be empty".into(),
440            ));
441        }
442        Ok(())
443    }
444}
445
446fn push_blocker(blockers: &mut Vec<String>, blocker: impl Into<String>) {
447    let blocker = blocker.into();
448    if !blockers.iter().any(|existing| existing == &blocker) {
449        blockers.push(blocker);
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::{
457        CalibrationArtifact, CalibrationEvidenceClock, CalibrationEvidenceFrame, ClockCalibration,
458        FrameTransform,
459    };
460
461    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
462
463    fn source() -> StudioSource {
464        StudioSource::try_new("fixture", "/media/fixture.db3", SHA, SHA, true).unwrap()
465    }
466
467    fn source_summary() -> MappingSourceSummary {
468        MappingSourceSummary {
469            front_topic: "/front".into(),
470            rear_topic: "/rear".into(),
471            front_bag_message_count: 0,
472            rear_bag_message_count: 0,
473            front_chunk_count: 0,
474            rear_chunk_count: 0,
475            front_record_count: 0,
476            rear_record_count: 0,
477            total_record_count: 0,
478            total_point_count: 0,
479            retained_bytes: 0,
480            peak_source_bytes: 0,
481            start_nanos: None,
482            end_nanos: None,
483            full_bag_processed: false,
484            truncated: false,
485        }
486    }
487
488    #[test]
489    fn missing_calibration_is_a_valid_blocked_state() {
490        let state = FullBagMappingState::try_new(
491            "fixture mapping",
492            source(),
493            None,
494            source_summary(),
495            None,
496            None,
497            Vec::new(),
498            false,
499            false,
500            Vec::new(),
501        )
502        .unwrap();
503        assert!(!state.summary.mapping_admitted);
504        assert!(state.blockers.iter().any(|blocker| blocker.contains("calibration")));
505        state.validate().unwrap();
506    }
507
508    #[test]
509    fn complete_source_bound_stages_are_admitted() {
510        let calibration = CalibrationEvidenceState::try_new(
511            "fixture calibration",
512            source(),
513            CalibrationArtifact::try_new(
514                "clock_evidence",
515                "registered",
516                Some("/media/clock.json".into()),
517                Some(SHA.into()),
518                true,
519            )
520            .unwrap(),
521            CalibrationArtifact::try_new(
522                "frame_evidence",
523                "registered",
524                Some("/media/frame.json".into()),
525                Some(SHA.into()),
526                true,
527            )
528            .unwrap(),
529            CalibrationEvidenceClock::try_new(
530                "sensor",
531                "external",
532                "fixture clock fit",
533                ClockCalibration::try_new(
534                    "registered",
535                    "anchored external clock",
536                    2,
537                    Some(1.0),
538                    Some(2.0),
539                    Some(0.0),
540                    Some(3.0),
541                    true,
542                    false,
543                )
544                .unwrap(),
545            )
546            .unwrap(),
547            CalibrationEvidenceFrame::try_new(
548                "fixture extrinsic fit",
549                "base_link",
550                std::collections::BTreeMap::from([
551                    ("front".into(), "front_frame".into()),
552                    ("rear".into(), "rear_frame".into()),
553                ]),
554                vec!["base_link".into(), "front_frame".into(), "rear_frame".into()],
555                vec![
556                    FrameTransform::try_new(
557                        "base_link",
558                        "front_frame",
559                        [0.0, 0.0, 0.0],
560                        [0.0, 0.0, 0.0, 1.0],
561                        None,
562                        true,
563                        true,
564                    )
565                    .unwrap(),
566                    FrameTransform::try_new(
567                        "base_link",
568                        "rear_frame",
569                        [0.0, 0.0, 0.0],
570                        [0.0, 0.0, 0.0, 1.0],
571                        None,
572                        true,
573                        true,
574                    )
575                    .unwrap(),
576                ],
577            )
578            .unwrap(),
579            Vec::new(),
580        )
581        .unwrap();
582        let state = FullBagMappingState::try_new(
583            "fixture mapping",
584            source(),
585            Some(calibration),
586            MappingSourceSummary {
587                front_topic: "/front".into(),
588                rear_topic: "/rear".into(),
589                front_bag_message_count: 2,
590                rear_bag_message_count: 2,
591                front_chunk_count: 2,
592                rear_chunk_count: 2,
593                front_record_count: 2,
594                rear_record_count: 2,
595                total_record_count: 4,
596                total_point_count: 12,
597                retained_bytes: 48,
598                peak_source_bytes: 256,
599                start_nanos: Some(1),
600                end_nanos: Some(2),
601                full_bag_processed: true,
602                truncated: false,
603            },
604            Some(MappingOdometrySummary {
605                topic: "/front".into(),
606                source_frame: "front_frame".into(),
607                root_frame: "base_link".into(),
608                clock_id: "external".into(),
609                clock_domain: "external-calibrated".into(),
610                matcher: "fixture".into(),
611                scan_count: 2,
612                motion_count: 1,
613                pose_graph_node_count: 2,
614                pose_graph_edge_count: 1,
615                complete: true,
616                truncated: false,
617            }),
618            Some(MappingTsdfSummary {
619                frame_id: "base_link".into(),
620                origin: [0.0, 0.0, 0.0],
621                voxel_size: 0.5,
622                dims: [8, 8, 8],
623                truncation: 1.0,
624                integrated_record_count: 4,
625                integrated_point_count: 12,
626                mesh_vertex_count: 3,
627                mesh_triangle_count: 1,
628                complete: true,
629            }),
630            vec![ReplayArtifact::try_new("mesh", "/media/mesh.gltf", 1, SHA).unwrap()],
631            true,
632            true,
633            Vec::new(),
634        )
635        .unwrap();
636        assert!(state.summary.mapping_admitted);
637        state.validate().unwrap();
638    }
639
640    #[test]
641    fn summary_rejects_reversed_source_bounds() {
642        let mut summary = source_summary();
643        summary.start_nanos = Some(20);
644        summary.end_nanos = Some(10);
645        assert!(summary.validate().is_err());
646    }
647}