Skip to main content

spatialrust_viewer/
semantic_overlay.rs

1//! Portable, source-bound AI semantic overlay state.
2//!
3//! The state stores quantized coordinates and confidence values so native,
4//! Web, and headless consumers share one deterministic contract. Model
5//! execution and renderer uploads remain outside this module; adapters must
6//! provide explicit model and artifact receipts.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
11
12/// Current serialized AI semantic overlay state schema version.
13pub const SEMANTIC_OVERLAY_STATE_VERSION: u32 = 1;
14
15/// Confidence quantization scale used by semantic overlay receipts.
16pub const SEMANTIC_CONFIDENCE_SCALE: u32 = 1_000_000;
17
18/// Model and explicit host/device transfer receipt for one overlay run.
19#[derive(Clone, Debug, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
22pub struct SemanticOverlayModel {
23    /// Stable model/profile identifier.
24    pub model_id: String,
25    /// Backend identifier such as `mock` or `onnxruntime-cpu`.
26    pub backend: String,
27    /// Human-readable runtime/provenance note.
28    pub runtime: String,
29    /// Whether the model request was configured for deterministic execution.
30    pub deterministic: bool,
31    /// Number of input feature channels supplied per point.
32    pub input_feature_count: u32,
33    /// Number of class IDs declared by the model output contract.
34    pub output_class_count: u32,
35    /// Host bytes supplied to the model.
36    pub input_host_bytes: u64,
37    /// Host bytes returned by the model.
38    pub output_host_bytes: u64,
39    /// Explicit host-to-device bytes recorded for this run.
40    pub device_upload_bytes: u64,
41    /// Explicit device-to-host bytes recorded for this run.
42    pub device_readback_bytes: u64,
43}
44
45impl SemanticOverlayModel {
46    /// Creates and validates model metadata and transfer accounting.
47    #[allow(clippy::too_many_arguments)]
48    pub fn try_new(
49        model_id: impl Into<String>,
50        backend: impl Into<String>,
51        runtime: impl Into<String>,
52        deterministic: bool,
53        input_feature_count: u32,
54        output_class_count: u32,
55        input_host_bytes: u64,
56        output_host_bytes: u64,
57        device_upload_bytes: u64,
58        device_readback_bytes: u64,
59    ) -> ViewerResult<Self> {
60        let model = Self {
61            model_id: model_id.into(),
62            backend: backend.into(),
63            runtime: runtime.into(),
64            deterministic,
65            input_feature_count,
66            output_class_count,
67            input_host_bytes,
68            output_host_bytes,
69            device_upload_bytes,
70            device_readback_bytes,
71        };
72        model.validate()?;
73        Ok(model)
74    }
75
76    /// Validates model identity, dimensions, and non-hidden transfer fields.
77    pub fn validate(&self) -> ViewerResult<()> {
78        if self.model_id.trim().is_empty()
79            || self.backend.trim().is_empty()
80            || self.runtime.trim().is_empty()
81        {
82            return Err(ViewerError::InvalidState(
83                "semantic overlay model identity and runtime must not be empty".into(),
84            ));
85        }
86        if self.input_feature_count == 0 || self.output_class_count == 0 {
87            return Err(ViewerError::InvalidState(
88                "semantic overlay model dimensions must be positive".into(),
89            ));
90        }
91        Ok(())
92    }
93}
94
95/// One class represented in the semantic overlay legend.
96#[derive(Clone, Debug, PartialEq, Eq)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
99pub struct SemanticOverlayClass {
100    /// Stable model output class ID.
101    pub class_id: u32,
102    /// Human-readable class label.
103    pub label: String,
104    /// sRGB color used by renderer and dashboard.
105    pub color_rgb: [u8; 3],
106    /// Number of sampled entities assigned to this class.
107    pub entity_count: u64,
108    /// Mean confidence in millionths.
109    pub mean_confidence_million: u32,
110    /// Maximum confidence in millionths.
111    pub max_confidence_million: u32,
112}
113
114impl SemanticOverlayClass {
115    /// Creates and validates one class legend entry.
116    pub fn try_new(
117        class_id: u32,
118        label: impl Into<String>,
119        color_rgb: [u8; 3],
120        entity_count: u64,
121        mean_confidence_million: u32,
122        max_confidence_million: u32,
123    ) -> ViewerResult<Self> {
124        let class = Self {
125            class_id,
126            label: label.into(),
127            color_rgb,
128            entity_count,
129            mean_confidence_million,
130            max_confidence_million,
131        };
132        class.validate()?;
133        Ok(class)
134    }
135
136    /// Validates label and confidence statistics.
137    pub fn validate(&self) -> ViewerResult<()> {
138        if self.label.trim().is_empty()
139            || self.mean_confidence_million > SEMANTIC_CONFIDENCE_SCALE
140            || self.max_confidence_million > SEMANTIC_CONFIDENCE_SCALE
141            || self.mean_confidence_million > self.max_confidence_million
142            || (self.entity_count == 0
143                && (self.mean_confidence_million > 0 || self.max_confidence_million > 0))
144        {
145            return Err(ViewerError::InvalidState(
146                "semantic overlay class label or confidence statistics are invalid".into(),
147            ));
148        }
149        Ok(())
150    }
151}
152
153/// One source-indexed semantic prediction rendered in the overlay.
154#[derive(Clone, Debug, PartialEq, Eq)]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
157pub struct SemanticOverlayEntity {
158    /// Stable entity identifier from the overlay run.
159    pub id: String,
160    /// Index of the source point or feature row consumed by the model.
161    pub source_index: u64,
162    /// Model output class ID.
163    pub class_id: u32,
164    /// Resolved class label.
165    pub label: String,
166    /// Prediction confidence in millionths.
167    pub confidence_million: u32,
168    /// Centroid/point coordinate in the declared frame, in micrometres.
169    pub centroid_um: [i64; 3],
170}
171
172impl SemanticOverlayEntity {
173    /// Creates and validates one quantized semantic prediction.
174    pub fn try_new(
175        id: impl Into<String>,
176        source_index: u64,
177        class_id: u32,
178        label: impl Into<String>,
179        confidence_million: u32,
180        centroid_um: [i64; 3],
181    ) -> ViewerResult<Self> {
182        let entity = Self {
183            id: id.into(),
184            source_index,
185            class_id,
186            label: label.into(),
187            confidence_million,
188            centroid_um,
189        };
190        entity.validate()?;
191        Ok(entity)
192    }
193
194    /// Validates stable identity, coordinates, and confidence quantization.
195    pub fn validate(&self) -> ViewerResult<()> {
196        if self.id.trim().is_empty()
197            || self.label.trim().is_empty()
198            || self.confidence_million > SEMANTIC_CONFIDENCE_SCALE
199        {
200            return Err(ViewerError::InvalidState(
201                "semantic overlay entity identity, label, or confidence is invalid".into(),
202            ));
203        }
204        Ok(())
205    }
206}
207
208/// Aggregate quality and source-binding metrics for an overlay.
209#[derive(Clone, Debug, PartialEq, Eq)]
210#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
211#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
212pub struct SemanticOverlaySummary {
213    /// Number of points/features available from the checked source artifact.
214    pub input_point_count: u64,
215    /// Number of points sampled into the model input.
216    pub sampled_point_count: u64,
217    /// Number of semantic predictions emitted by the model.
218    pub entity_count: u64,
219    /// Number of predictions with visible finite coordinates.
220    pub visible_entity_count: u64,
221    /// Number of class legend entries represented by predictions.
222    pub class_count: u64,
223    /// Mean prediction confidence in millionths.
224    pub mean_confidence_million: u32,
225    /// P95 prediction confidence in millionths.
226    pub p95_confidence_million: u32,
227    /// Sampled/input coverage in millionths.
228    pub coverage_million: u32,
229    /// Whether the checked source identity matched the operation contract.
230    pub source_identity_match: bool,
231    /// Whether the checked map frame matched the requested overlay frame.
232    pub frame_identity_match: bool,
233    /// Whether source-bound clock/frame calibration was applied.
234    pub calibration_applied: bool,
235}
236
237impl SemanticOverlaySummary {
238    /// Creates and validates aggregate overlay metrics.
239    #[allow(clippy::too_many_arguments)]
240    pub fn try_new(
241        input_point_count: u64,
242        sampled_point_count: u64,
243        entity_count: u64,
244        visible_entity_count: u64,
245        class_count: u64,
246        mean_confidence_million: u32,
247        p95_confidence_million: u32,
248        coverage_million: u32,
249        source_identity_match: bool,
250        frame_identity_match: bool,
251        calibration_applied: bool,
252    ) -> ViewerResult<Self> {
253        let summary = Self {
254            input_point_count,
255            sampled_point_count,
256            entity_count,
257            visible_entity_count,
258            class_count,
259            mean_confidence_million,
260            p95_confidence_million,
261            coverage_million,
262            source_identity_match,
263            frame_identity_match,
264            calibration_applied,
265        };
266        summary.validate()?;
267        Ok(summary)
268    }
269
270    /// Validates count, confidence, coverage, and calibration invariants.
271    pub fn validate(&self) -> ViewerResult<()> {
272        if self.sampled_point_count > self.input_point_count
273            || self.entity_count > self.sampled_point_count
274            || self.visible_entity_count > self.entity_count
275            || self.mean_confidence_million > SEMANTIC_CONFIDENCE_SCALE
276            || self.p95_confidence_million > SEMANTIC_CONFIDENCE_SCALE
277            || self.coverage_million > SEMANTIC_CONFIDENCE_SCALE
278            || (self.entity_count == 0
279                && (self.class_count > 0
280                    || self.visible_entity_count > 0
281                    || self.mean_confidence_million > 0
282                    || self.p95_confidence_million > 0))
283            || (self.input_point_count == 0
284                && (self.sampled_point_count > 0 || self.coverage_million > 0))
285            || (self.sampled_point_count == 0 && self.coverage_million > 0)
286        {
287            return Err(ViewerError::InvalidState(
288                "semantic overlay summary counts or confidence metrics are invalid".into(),
289            ));
290        }
291        if self.calibration_applied && (!self.source_identity_match || !self.frame_identity_match) {
292            return Err(ViewerError::InvalidState(
293                "semantic overlay calibration cannot be applied to an unbound or mixed-frame source"
294                    .into(),
295            ));
296        }
297        Ok(())
298    }
299}
300
301/// Portable dashboard state for an AI semantic overlay.
302#[derive(Clone, Debug, PartialEq, Eq)]
303#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
304#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
305pub struct SemanticOverlayState {
306    /// Serialized state schema version.
307    pub version: u32,
308    /// User-facing dashboard title.
309    pub title: String,
310    /// Checksummed source identity consumed by the model adapter.
311    pub source: StudioSource,
312    /// Coordinate frame of all overlay entities.
313    pub frame_id: String,
314    /// Frame requested by the operation contract.
315    pub expected_frame_id: String,
316    /// Timestamp basis associated with the source artifact.
317    pub time_basis: String,
318    /// Explicit model/runtime and transfer receipt.
319    pub model: SemanticOverlayModel,
320    /// Class legend and aggregate counts.
321    pub classes: Vec<SemanticOverlayClass>,
322    /// Quantized predictions rendered by the dashboard/adapter.
323    pub entities: Vec<SemanticOverlayEntity>,
324    /// Checksummed input/output artifacts represented by this state.
325    pub artifacts: Vec<ReplayArtifact>,
326    /// Aggregate semantic quality and source-binding metrics.
327    pub summary: SemanticOverlaySummary,
328    /// Whether the source-bound semantic overlay is ready for inspection.
329    pub overlay_ready: bool,
330    /// Whether downstream calibrated mapping is admitted.
331    pub mapping_admitted: bool,
332    /// Fail-closed reasons for overlay or mapping admission.
333    pub blockers: Vec<String>,
334}
335
336impl SemanticOverlayState {
337    /// Creates a state and derives overlay/mapping admission flags.
338    #[allow(clippy::too_many_arguments)]
339    pub fn try_new(
340        title: impl Into<String>,
341        source: StudioSource,
342        frame_id: impl Into<String>,
343        expected_frame_id: impl Into<String>,
344        time_basis: impl Into<String>,
345        model: SemanticOverlayModel,
346        classes: Vec<SemanticOverlayClass>,
347        entities: Vec<SemanticOverlayEntity>,
348        artifacts: Vec<ReplayArtifact>,
349        summary: SemanticOverlaySummary,
350        blockers: Vec<String>,
351    ) -> ViewerResult<Self> {
352        let overlay_ready = source.identity_matches
353            && summary.source_identity_match
354            && summary.frame_identity_match
355            && summary.entity_count > 0
356            && summary.entity_count == u64::try_from(entities.len()).unwrap_or(u64::MAX);
357        let mapping_admitted = overlay_ready && summary.calibration_applied;
358        let state = Self {
359            version: SEMANTIC_OVERLAY_STATE_VERSION,
360            title: title.into(),
361            source,
362            frame_id: frame_id.into(),
363            expected_frame_id: expected_frame_id.into(),
364            time_basis: time_basis.into(),
365            model,
366            classes,
367            entities,
368            artifacts,
369            summary,
370            overlay_ready,
371            mapping_admitted,
372            blockers,
373        };
374        state.validate()?;
375        Ok(state)
376    }
377
378    /// Validates all source, class, entity, artifact, and admission invariants.
379    pub fn validate(&self) -> ViewerResult<()> {
380        if self.version != SEMANTIC_OVERLAY_STATE_VERSION {
381            return Err(ViewerError::InvalidState(format!(
382                "unsupported semantic overlay state version {}",
383                self.version
384            )));
385        }
386        if self.title.trim().is_empty()
387            || self.frame_id.trim().is_empty()
388            || self.expected_frame_id.trim().is_empty()
389            || self.time_basis.trim().is_empty()
390        {
391            return Err(ViewerError::InvalidState(
392                "semantic overlay title, frame, and time basis must not be empty".into(),
393            ));
394        }
395        self.source.validate()?;
396        self.model.validate()?;
397        self.summary.validate()?;
398        if self.summary.source_identity_match != self.source.identity_matches {
399            return Err(ViewerError::InvalidState(
400                "semantic overlay source identity disagrees with the checked source".into(),
401            ));
402        }
403        if self.summary.frame_identity_match != (self.frame_id == self.expected_frame_id) {
404            return Err(ViewerError::InvalidState(
405                "semantic overlay frame identity disagrees with frame fields".into(),
406            ));
407        }
408
409        let mut class_ids = BTreeSet::new();
410        let mut class_labels = BTreeSet::new();
411        for class in &self.classes {
412            class.validate()?;
413            if !class_ids.insert(class.class_id) || !class_labels.insert(&class.label) {
414                return Err(ViewerError::InvalidState(
415                    "semantic overlay classes must have unique IDs and labels".into(),
416                ));
417            }
418        }
419        let class_by_id =
420            self.classes.iter().map(|class| (class.class_id, class)).collect::<BTreeMap<_, _>>();
421        let mut entity_ids = BTreeSet::new();
422        let mut source_indices = BTreeSet::new();
423        let mut counts = BTreeMap::<u32, (u64, u64, u32)>::new();
424        let mut confidence = Vec::with_capacity(self.entities.len());
425        for entity in &self.entities {
426            entity.validate()?;
427            if !entity_ids.insert(&entity.id) || !source_indices.insert(entity.source_index) {
428                return Err(ViewerError::InvalidState(
429                    "semantic overlay entity IDs and source indices must be unique".into(),
430                ));
431            }
432            let class = class_by_id.get(&entity.class_id).ok_or_else(|| {
433                ViewerError::InvalidState(
434                    "semantic overlay entity references an unknown class".into(),
435                )
436            })?;
437            if class.label != entity.label {
438                return Err(ViewerError::InvalidState(
439                    "semantic overlay entity label disagrees with its class legend".into(),
440                ));
441            }
442            let entry = counts.entry(entity.class_id).or_default();
443            entry.0 = entry.0.checked_add(1).ok_or_else(|| {
444                ViewerError::InvalidState("semantic overlay class count overflow".into())
445            })?;
446            entry.1 =
447                entry.1.checked_add(u64::from(entity.confidence_million)).ok_or_else(|| {
448                    ViewerError::InvalidState("semantic overlay confidence sum overflow".into())
449                })?;
450            entry.2 = entry.2.max(entity.confidence_million);
451            confidence.push(entity.confidence_million);
452        }
453        for class in &self.classes {
454            let (count, sum, max) = counts.get(&class.class_id).copied().unwrap_or_default();
455            if class.entity_count != count
456                || class.max_confidence_million != max
457                || (count > 0 && class.mean_confidence_million != (sum / count) as u32)
458            {
459                return Err(ViewerError::InvalidState(
460                    "semantic overlay class statistics disagree with entities".into(),
461                ));
462            }
463        }
464        confidence.sort_unstable();
465        let entity_count = u64::try_from(self.entities.len()).unwrap_or(u64::MAX);
466        let visible_count = entity_count;
467        let class_count = u64::try_from(counts.len()).unwrap_or(u64::MAX);
468        let confidence_sum = confidence.iter().try_fold(0_u64, |sum, value| {
469            sum.checked_add(u64::from(*value)).ok_or_else(|| {
470                ViewerError::InvalidState("semantic overlay confidence sum overflow".into())
471            })
472        })?;
473        let expected_mean = u32::try_from(confidence_sum.checked_div(entity_count).unwrap_or(0))
474            .unwrap_or(u32::MAX);
475        let expected_p95 = if confidence.is_empty() {
476            0
477        } else {
478            confidence[confidence.len().saturating_mul(95).div_ceil(100).saturating_sub(1)]
479        };
480        let expected_coverage = if self.summary.input_point_count == 0 {
481            0
482        } else {
483            u32::try_from(
484                (u128::from(self.summary.sampled_point_count)
485                    * u128::from(SEMANTIC_CONFIDENCE_SCALE)
486                    / u128::from(self.summary.input_point_count))
487                .min(u128::from(SEMANTIC_CONFIDENCE_SCALE)),
488            )
489            .unwrap_or(u32::MAX)
490        };
491        if self.summary.entity_count != entity_count
492            || self.summary.visible_entity_count != visible_count
493            || self.summary.class_count != class_count
494            || self.summary.mean_confidence_million != expected_mean
495            || self.summary.p95_confidence_million != expected_p95
496            || self.summary.coverage_million != expected_coverage
497        {
498            return Err(ViewerError::InvalidState(
499                "semantic overlay summary disagrees with class/entity data".into(),
500            ));
501        }
502
503        let mut artifact_roles = BTreeSet::new();
504        let mut artifact_paths = BTreeSet::new();
505        for artifact in &self.artifacts {
506            artifact.validate()?;
507            if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
508                return Err(ViewerError::InvalidState(
509                    "semantic overlay artifacts must have unique roles and paths".into(),
510                ));
511            }
512        }
513        let calculated_ready = self.source.identity_matches
514            && self.summary.source_identity_match
515            && self.summary.frame_identity_match
516            && self.summary.entity_count > 0;
517        if self.overlay_ready != calculated_ready {
518            return Err(ViewerError::InvalidState(
519                "overlay_ready disagrees with source, frame, or prediction admission".into(),
520            ));
521        }
522        let calculated_mapping = self.overlay_ready && self.summary.calibration_applied;
523        if self.mapping_admitted != calculated_mapping {
524            return Err(ViewerError::InvalidState(
525                "mapping_admitted disagrees with semantic overlay calibration admission".into(),
526            ));
527        }
528        if !self.mapping_admitted && self.blockers.is_empty() {
529            return Err(ViewerError::InvalidState(
530                "blocked semantic overlay mapping must expose at least one blocker".into(),
531            ));
532        }
533        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
534            return Err(ViewerError::InvalidState(
535                "semantic overlay blockers must not contain empty messages".into(),
536            ));
537        }
538        Ok(())
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
547    const OTHER_SHA: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
548
549    fn source(observed: &str) -> StudioSource {
550        StudioSource::try_new("canonical", "/media/input.db3", SHA, observed, observed == SHA)
551            .unwrap()
552    }
553
554    fn model() -> SemanticOverlayModel {
555        SemanticOverlayModel::try_new(
556            "mock-semantic-classes",
557            "mock",
558            "deterministic test profile",
559            true,
560            4,
561            3,
562            48,
563            24,
564            0,
565            0,
566        )
567        .unwrap()
568    }
569
570    fn classes() -> Vec<SemanticOverlayClass> {
571        vec![SemanticOverlayClass::try_new(0, "ground", [54, 211, 153], 1, 800_000, 800_000)
572            .unwrap()]
573    }
574
575    fn entities() -> Vec<SemanticOverlayEntity> {
576        vec![SemanticOverlayEntity::try_new(
577            "semantic:0",
578            0,
579            0,
580            "ground",
581            800_000,
582            [1_000_000, 2_000_000, 3_000_000],
583        )
584        .unwrap()]
585    }
586
587    fn summary(source_match: bool, frame_match: bool) -> SemanticOverlaySummary {
588        SemanticOverlaySummary::try_new(
589            10,
590            1,
591            if source_match && frame_match { 1 } else { 0 },
592            if source_match && frame_match { 1 } else { 0 },
593            if source_match && frame_match { 1 } else { 0 },
594            if source_match && frame_match { 800_000 } else { 0 },
595            if source_match && frame_match { 800_000 } else { 0 },
596            100_000,
597            source_match,
598            frame_match,
599            false,
600        )
601        .unwrap()
602    }
603
604    #[test]
605    fn valid_state_roundtrips_with_serde() {
606        let state = SemanticOverlayState::try_new(
607            "AI Semantic Overlay",
608            source(SHA),
609            "lidar_front",
610            "lidar_front",
611            "PointCloud2 header stamp",
612            model(),
613            classes(),
614            entities(),
615            Vec::new(),
616            summary(true, true),
617            vec!["clock calibration not applied".into()],
618        )
619        .unwrap();
620        assert!(state.overlay_ready);
621        assert!(!state.mapping_admitted);
622        #[cfg(feature = "serde")]
623        {
624            let json = serde_json::to_string(&state).unwrap();
625            assert_eq!(serde_json::from_str::<SemanticOverlayState>(&json).unwrap(), state);
626        }
627    }
628
629    #[test]
630    fn source_mismatch_withholds_overlay() {
631        let state = SemanticOverlayState::try_new(
632            "AI Semantic Overlay",
633            source(OTHER_SHA),
634            "lidar_front",
635            "lidar_front",
636            "PointCloud2 header stamp",
637            model(),
638            Vec::new(),
639            Vec::new(),
640            Vec::new(),
641            SemanticOverlaySummary::try_new(10, 0, 0, 0, 0, 0, 0, 0, false, true, false).unwrap(),
642            vec!["input SHA-256 mismatch".into()],
643        )
644        .unwrap();
645        assert!(!state.overlay_ready);
646        assert!(!state.mapping_admitted);
647    }
648
649    #[test]
650    fn entity_label_and_confidence_are_fail_closed() {
651        assert!(SemanticOverlayEntity::try_new("", 0, 0, "ground", 1, [0, 0, 0]).is_err());
652        assert!(SemanticOverlayEntity::try_new(
653            "semantic:0",
654            0,
655            0,
656            "ground",
657            SEMANTIC_CONFIDENCE_SCALE + 1,
658            [0, 0, 0],
659        )
660        .is_err());
661    }
662}