Skip to main content

spatialrust_viewer/
dataset_health.rs

1//! Portable, source-bound health state for a spatial dataset.
2//!
3//! This module contains no rosbag2, SQLite, filesystem, or renderer types.
4//! Adapters populate it from receipts and stage snapshots so a static
5//! dashboard can expose integrity, lineage, and calibration gates together.
6
7use std::collections::BTreeSet;
8
9use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
10
11/// Current serialized Dataset Health state schema version.
12pub const DATASET_HEALTH_STATE_VERSION: u32 = 1;
13
14/// One integrity or readiness check shown by the health dashboard.
15#[derive(Clone, Debug, PartialEq, Eq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
18pub struct DatasetHealthCheck {
19    /// Stable check identifier.
20    pub id: String,
21    /// User-facing check label.
22    pub label: String,
23    /// Check outcome: pass, warning, or blocked.
24    pub status: String,
25    /// Whether a blocked result prevents dataset health readiness.
26    pub critical: bool,
27    /// Observed value or receipt fact.
28    pub observed: String,
29    /// Expected value or operation contract.
30    pub expected: String,
31    /// Human-readable explanation.
32    pub detail: String,
33}
34
35impl DatasetHealthCheck {
36    /// Creates and validates one health check.
37    pub fn try_new(
38        id: impl Into<String>,
39        label: impl Into<String>,
40        status: impl Into<String>,
41        critical: bool,
42        observed: impl Into<String>,
43        expected: impl Into<String>,
44        detail: impl Into<String>,
45    ) -> ViewerResult<Self> {
46        let check = Self {
47            id: id.into(),
48            label: label.into(),
49            status: status.into(),
50            critical,
51            observed: observed.into(),
52            expected: expected.into(),
53            detail: detail.into(),
54        };
55        check.validate()?;
56        Ok(check)
57    }
58
59    /// Validates identity, outcome vocabulary, and displayed values.
60    pub fn validate(&self) -> ViewerResult<()> {
61        if self.id.trim().is_empty()
62            || self.label.trim().is_empty()
63            || self.observed.trim().is_empty()
64            || self.expected.trim().is_empty()
65            || self.detail.trim().is_empty()
66        {
67            return Err(ViewerError::InvalidState(
68                "Dataset Health checks require non-empty identity and explanation fields".into(),
69            ));
70        }
71        validate_status(&self.status)
72    }
73}
74
75/// Health counters for one canonical sensor topic.
76#[derive(Clone, Debug, PartialEq, Eq)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
79pub struct DatasetHealthTopic {
80    /// ROS topic name.
81    pub name: String,
82    /// Logical sensor role such as front or rear.
83    pub role: String,
84    /// Total messages reported by the source inventory.
85    pub message_count: u64,
86    /// Records retained by the bounded E2E episode.
87    pub retained_record_count: u64,
88    /// Points retained by the bounded E2E episode.
89    pub retained_point_count: u64,
90    /// Frame IDs observed in the retained records.
91    pub frame_ids: Vec<String>,
92    /// Topic outcome: pass, warning, or blocked.
93    pub status: String,
94}
95
96impl DatasetHealthTopic {
97    /// Creates and validates one topic health row.
98    pub fn try_new(
99        name: impl Into<String>,
100        role: impl Into<String>,
101        message_count: u64,
102        retained_record_count: u64,
103        retained_point_count: u64,
104        frame_ids: Vec<String>,
105        status: impl Into<String>,
106    ) -> ViewerResult<Self> {
107        let topic = Self {
108            name: name.into(),
109            role: role.into(),
110            message_count,
111            retained_record_count,
112            retained_point_count,
113            frame_ids,
114            status: status.into(),
115        };
116        topic.validate()?;
117        Ok(topic)
118    }
119
120    /// Validates topic counters, frame IDs, and outcome consistency.
121    pub fn validate(&self) -> ViewerResult<()> {
122        if self.name.trim().is_empty() || self.role.trim().is_empty() {
123            return Err(ViewerError::InvalidState(
124                "Dataset Health topics require a name and logical role".into(),
125            ));
126        }
127        validate_status(&self.status)?;
128        if self.retained_record_count > 0 && self.retained_point_count == 0 {
129            return Err(ViewerError::InvalidState(
130                "retained Dataset Health records require retained points".into(),
131            ));
132        }
133        let mut frames = BTreeSet::new();
134        for frame_id in &self.frame_ids {
135            if frame_id.trim().is_empty() || !frames.insert(frame_id) {
136                return Err(ViewerError::InvalidState(
137                    "Dataset Health topic frame IDs must be non-empty and unique".into(),
138                ));
139            }
140        }
141        if self.status == "pass"
142            && (self.message_count == 0
143                || self.retained_record_count == 0
144                || self.retained_point_count == 0
145                || self.frame_ids.is_empty())
146        {
147            return Err(ViewerError::InvalidState(
148                "a passing Dataset Health topic must contain source and frame evidence".into(),
149            ));
150        }
151        Ok(())
152    }
153}
154
155/// Health and gate counters aggregated across checks and artifacts.
156#[derive(Clone, Debug, PartialEq, Eq)]
157#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
158#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
159pub struct DatasetHealthSummary {
160    /// Canonical source byte count.
161    pub source_bytes: u64,
162    /// Sum of source messages across health topics.
163    pub source_message_count: u64,
164    /// Sum of retained records across health topics.
165    pub retained_record_count: u64,
166    /// Sum of retained points across health topics.
167    pub retained_point_count: u64,
168    /// Number of checksummed artifacts represented by the dashboard.
169    pub artifact_count: u64,
170    /// Sum of checksummed artifact bytes.
171    pub artifact_bytes: u64,
172    /// Number of canonical topics represented.
173    pub topic_count: u64,
174    /// Number of stage snapshots represented.
175    pub stage_count: u64,
176    /// Total number of checks.
177    pub check_count: u64,
178    /// Number of passing checks.
179    pub pass_count: u64,
180    /// Number of warning checks.
181    pub warning_count: u64,
182    /// Number of blocked checks.
183    pub blocked_count: u64,
184    /// Number of blocked checks marked critical.
185    pub critical_block_count: u64,
186    /// Whether canonical source identity matched.
187    pub source_identity_match: bool,
188    /// Whether the requested frame matched canonical stage evidence.
189    pub frame_identity_match: bool,
190    /// Whether source-bound clock and frame calibration is registered.
191    pub calibration_ready: bool,
192}
193
194impl DatasetHealthSummary {
195    /// Creates and validates aggregate health counters.
196    #[allow(clippy::too_many_arguments)]
197    pub fn try_new(
198        source_bytes: u64,
199        source_message_count: u64,
200        retained_record_count: u64,
201        retained_point_count: u64,
202        artifact_count: u64,
203        artifact_bytes: u64,
204        topic_count: u64,
205        stage_count: u64,
206        check_count: u64,
207        pass_count: u64,
208        warning_count: u64,
209        blocked_count: u64,
210        critical_block_count: u64,
211        source_identity_match: bool,
212        frame_identity_match: bool,
213        calibration_ready: bool,
214    ) -> ViewerResult<Self> {
215        let summary = Self {
216            source_bytes,
217            source_message_count,
218            retained_record_count,
219            retained_point_count,
220            artifact_count,
221            artifact_bytes,
222            topic_count,
223            stage_count,
224            check_count,
225            pass_count,
226            warning_count,
227            blocked_count,
228            critical_block_count,
229            source_identity_match,
230            frame_identity_match,
231            calibration_ready,
232        };
233        summary.validate()?;
234        Ok(summary)
235    }
236
237    /// Validates counter ordering and source/calibration relationships.
238    pub fn validate(&self) -> ViewerResult<()> {
239        if self.source_bytes == 0 {
240            return Err(ViewerError::InvalidState(
241                "Dataset Health requires a non-empty canonical source".into(),
242            ));
243        }
244        if self.retained_record_count > 0 && self.retained_point_count == 0 {
245            return Err(ViewerError::InvalidState(
246                "Dataset Health retained records require retained points".into(),
247            ));
248        }
249        if self
250            .pass_count
251            .checked_add(self.warning_count)
252            .and_then(|count| count.checked_add(self.blocked_count))
253            != Some(self.check_count)
254            || self.critical_block_count > self.blocked_count
255        {
256            return Err(ViewerError::InvalidState(
257                "Dataset Health check counters are inconsistent".into(),
258            ));
259        }
260        if self.calibration_ready && (!self.source_identity_match || !self.frame_identity_match) {
261            return Err(ViewerError::InvalidState(
262                "calibration cannot be ready for a source or frame mismatch".into(),
263            ));
264        }
265        Ok(())
266    }
267}
268
269/// Health status for one previously generated SpatialRust stage.
270#[derive(Clone, Debug, PartialEq, Eq)]
271#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
272#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
273pub struct DatasetHealthStage {
274    /// Stable stage identifier such as 145E.
275    pub id: String,
276    /// User-facing stage label.
277    pub label: String,
278    /// Stage outcome: pass, warning, or blocked.
279    pub status: String,
280    /// Whether the stage-specific inspection gate is ready.
281    pub ready: bool,
282    /// Whether the stage admitted calibrated mapping.
283    pub mapping_admitted: bool,
284    /// Whether the stage source matched the canonical source.
285    pub source_identity_match: bool,
286    /// Whether the stage explicitly checked the requested frame.
287    pub frame_identity_match: Option<bool>,
288    /// Number of stage files represented in the health manifest.
289    pub artifact_count: u64,
290    /// Short stage-specific evidence detail.
291    pub detail: String,
292}
293
294impl DatasetHealthStage {
295    /// Creates and validates one stage health row.
296    #[allow(clippy::too_many_arguments)]
297    pub fn try_new(
298        id: impl Into<String>,
299        label: impl Into<String>,
300        status: impl Into<String>,
301        ready: bool,
302        mapping_admitted: bool,
303        source_identity_match: bool,
304        frame_identity_match: Option<bool>,
305        artifact_count: u64,
306        detail: impl Into<String>,
307    ) -> ViewerResult<Self> {
308        let stage = Self {
309            id: id.into(),
310            label: label.into(),
311            status: status.into(),
312            ready,
313            mapping_admitted,
314            source_identity_match,
315            frame_identity_match,
316            artifact_count,
317            detail: detail.into(),
318        };
319        stage.validate()?;
320        Ok(stage)
321    }
322
323    /// Validates stage gate consistency and source-bound status.
324    pub fn validate(&self) -> ViewerResult<()> {
325        if self.id.trim().is_empty()
326            || self.label.trim().is_empty()
327            || self.detail.trim().is_empty()
328        {
329            return Err(ViewerError::InvalidState(
330                "Dataset Health stages require identity and evidence detail".into(),
331            ));
332        }
333        validate_status(&self.status)?;
334        if !self.source_identity_match && self.status != "blocked" {
335            return Err(ViewerError::InvalidState(
336                "a source-mismatched Dataset Health stage must be blocked".into(),
337            ));
338        }
339        if self.frame_identity_match == Some(false) && self.status != "blocked" {
340            return Err(ViewerError::InvalidState(
341                "a frame-mismatched Dataset Health stage must be blocked".into(),
342            ));
343        }
344        if self.mapping_admitted && (!self.ready || self.status != "pass") {
345            return Err(ViewerError::InvalidState(
346                "an admitted Dataset Health stage must be ready and passing".into(),
347            ));
348        }
349        Ok(())
350    }
351}
352
353/// Portable source-bound Dataset Health dashboard state.
354#[derive(Clone, Debug, PartialEq, Eq)]
355#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
356#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
357pub struct DatasetHealthState {
358    /// Serialized Dataset Health schema version.
359    pub version: u32,
360    /// User-facing dashboard title.
361    pub title: String,
362    /// Canonical input identity.
363    pub source: StudioSource,
364    /// Canonical observed frame selected for health aggregation.
365    pub frame_id: String,
366    /// Frame required by the operation contract.
367    pub expected_frame_id: String,
368    /// Human-readable timestamp basis.
369    pub time_basis: String,
370    /// Canonical sensor topic rows.
371    pub topics: Vec<DatasetHealthTopic>,
372    /// Previously generated stage snapshots.
373    pub stages: Vec<DatasetHealthStage>,
374    /// Checksummed source and stage artifacts.
375    pub artifacts: Vec<ReplayArtifact>,
376    /// Integrity, lineage, and calibration checks.
377    pub checks: Vec<DatasetHealthCheck>,
378    /// Aggregate health metrics.
379    pub summary: DatasetHealthSummary,
380    /// Whether source/data health is ready for inspection.
381    pub dataset_ready: bool,
382    /// Whether calibrated downstream mapping is admitted.
383    pub mapping_admitted: bool,
384    /// Fail-closed reasons and calibration notices.
385    pub blockers: Vec<String>,
386}
387
388impl DatasetHealthState {
389    /// Creates a health state and derives dataset and mapping gates.
390    #[allow(clippy::too_many_arguments)]
391    pub fn try_new(
392        title: impl Into<String>,
393        source: StudioSource,
394        frame_id: impl Into<String>,
395        expected_frame_id: impl Into<String>,
396        time_basis: impl Into<String>,
397        topics: Vec<DatasetHealthTopic>,
398        stages: Vec<DatasetHealthStage>,
399        artifacts: Vec<ReplayArtifact>,
400        checks: Vec<DatasetHealthCheck>,
401        summary: DatasetHealthSummary,
402        blockers: Vec<String>,
403    ) -> ViewerResult<Self> {
404        let critical_block_count =
405            checks.iter().filter(|check| check.status == "blocked" && check.critical).count();
406        let dataset_ready = source.identity_matches
407            && summary.source_identity_match
408            && summary.frame_identity_match
409            && critical_block_count == 0
410            && !topics.is_empty()
411            && !stages.is_empty();
412        let mapping_admitted = dataset_ready && summary.calibration_ready;
413        let state = Self {
414            version: DATASET_HEALTH_STATE_VERSION,
415            title: title.into(),
416            source,
417            frame_id: frame_id.into(),
418            expected_frame_id: expected_frame_id.into(),
419            time_basis: time_basis.into(),
420            topics,
421            stages,
422            artifacts,
423            checks,
424            summary,
425            dataset_ready,
426            mapping_admitted,
427            blockers,
428        };
429        state.validate()?;
430        Ok(state)
431    }
432
433    /// Validates source identity, receipts, counters, and both gates.
434    pub fn validate(&self) -> ViewerResult<()> {
435        if self.version != DATASET_HEALTH_STATE_VERSION {
436            return Err(ViewerError::InvalidState(format!(
437                "unsupported Dataset Health state version {}",
438                self.version
439            )));
440        }
441        if self.title.trim().is_empty()
442            || self.frame_id.trim().is_empty()
443            || self.expected_frame_id.trim().is_empty()
444            || self.time_basis.trim().is_empty()
445        {
446            return Err(ViewerError::InvalidState(
447                "Dataset Health title, frames, and time basis must not be empty".into(),
448            ));
449        }
450        self.source.validate()?;
451        self.summary.validate()?;
452        if self.summary.source_identity_match != self.source.identity_matches
453            || self.summary.frame_identity_match != (self.frame_id == self.expected_frame_id)
454        {
455            return Err(ViewerError::InvalidState(
456                "Dataset Health summary identity disagrees with source/frame fields".into(),
457            ));
458        }
459
460        let mut topic_names = BTreeSet::new();
461        let mut topic_message_count = 0_u64;
462        let mut topic_record_count = 0_u64;
463        let mut topic_point_count = 0_u64;
464        for topic in &self.topics {
465            topic.validate()?;
466            if !topic_names.insert(&topic.name) {
467                return Err(ViewerError::InvalidState(
468                    "Dataset Health topics must have unique names".into(),
469                ));
470            }
471            topic_message_count = topic_message_count
472                .checked_add(topic.message_count)
473                .ok_or_else(|| ViewerError::InvalidState("topic message count overflow".into()))?;
474            topic_record_count = topic_record_count
475                .checked_add(topic.retained_record_count)
476                .ok_or_else(|| ViewerError::InvalidState("topic record count overflow".into()))?;
477            topic_point_count = topic_point_count
478                .checked_add(topic.retained_point_count)
479                .ok_or_else(|| ViewerError::InvalidState("topic point count overflow".into()))?;
480        }
481        if topic_message_count != self.summary.source_message_count
482            || topic_record_count != self.summary.retained_record_count
483            || topic_point_count != self.summary.retained_point_count
484            || self.summary.topic_count != u64::try_from(self.topics.len()).unwrap_or(u64::MAX)
485        {
486            return Err(ViewerError::InvalidState(
487                "Dataset Health topic counters disagree with the summary".into(),
488            ));
489        }
490
491        let mut stage_ids = BTreeSet::new();
492        let mut stage_artifact_count = 0_u64;
493        for stage in &self.stages {
494            stage.validate()?;
495            if !stage_ids.insert(&stage.id) {
496                return Err(ViewerError::InvalidState(
497                    "Dataset Health stages must have unique IDs".into(),
498                ));
499            }
500            stage_artifact_count = stage_artifact_count
501                .checked_add(stage.artifact_count)
502                .ok_or_else(|| ViewerError::InvalidState("stage artifact count overflow".into()))?;
503        }
504        if self.summary.stage_count != u64::try_from(self.stages.len()).unwrap_or(u64::MAX) {
505            return Err(ViewerError::InvalidState(
506                "Dataset Health stage count disagrees with the stage list".into(),
507            ));
508        }
509        if stage_artifact_count == 0 && !self.stages.is_empty() {
510            return Err(ViewerError::InvalidState(
511                "Dataset Health stages must represent at least one artifact".into(),
512            ));
513        }
514
515        let mut artifact_roles = BTreeSet::new();
516        let mut artifact_paths = BTreeSet::new();
517        let mut artifact_bytes = 0_u64;
518        for artifact in &self.artifacts {
519            artifact.validate()?;
520            if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
521                return Err(ViewerError::InvalidState(
522                    "Dataset Health artifacts require unique roles and paths".into(),
523                ));
524            }
525            artifact_bytes = artifact_bytes
526                .checked_add(artifact.size_bytes)
527                .ok_or_else(|| ViewerError::InvalidState("artifact byte count overflow".into()))?;
528        }
529        if self.summary.artifact_count != u64::try_from(self.artifacts.len()).unwrap_or(u64::MAX)
530            || self.summary.artifact_bytes != artifact_bytes
531        {
532            return Err(ViewerError::InvalidState(
533                "Dataset Health artifact counters disagree with receipts".into(),
534            ));
535        }
536
537        let mut check_ids = BTreeSet::new();
538        let mut pass_count = 0_u64;
539        let mut warning_count = 0_u64;
540        let mut blocked_count = 0_u64;
541        let mut critical_block_count = 0_u64;
542        for check in &self.checks {
543            check.validate()?;
544            if !check_ids.insert(&check.id) {
545                return Err(ViewerError::InvalidState(
546                    "Dataset Health checks must have unique IDs".into(),
547                ));
548            }
549            match check.status.as_str() {
550                "pass" => pass_count = pass_count.saturating_add(1),
551                "warning" => warning_count = warning_count.saturating_add(1),
552                "blocked" => {
553                    blocked_count = blocked_count.saturating_add(1);
554                    if check.critical {
555                        critical_block_count = critical_block_count.saturating_add(1);
556                    }
557                }
558                _ => unreachable!("check status validated above"),
559            }
560        }
561        if self.summary.check_count != u64::try_from(self.checks.len()).unwrap_or(u64::MAX)
562            || self.summary.pass_count != pass_count
563            || self.summary.warning_count != warning_count
564            || self.summary.blocked_count != blocked_count
565            || self.summary.critical_block_count != critical_block_count
566        {
567            return Err(ViewerError::InvalidState(
568                "Dataset Health check counters disagree with check rows".into(),
569            ));
570        }
571
572        let calculated_dataset_ready = self.source.identity_matches
573            && self.summary.source_identity_match
574            && self.summary.frame_identity_match
575            && critical_block_count == 0
576            && !self.topics.is_empty()
577            && !self.stages.is_empty();
578        if self.dataset_ready != calculated_dataset_ready {
579            return Err(ViewerError::InvalidState(
580                "dataset_ready disagrees with source, checks, topics, or stages".into(),
581            ));
582        }
583        let calculated_mapping = self.dataset_ready && self.summary.calibration_ready;
584        if self.mapping_admitted != calculated_mapping {
585            return Err(ViewerError::InvalidState(
586                "mapping_admitted disagrees with health and calibration gates".into(),
587            ));
588        }
589        if self.mapping_admitted && !self.blockers.is_empty() {
590            return Err(ViewerError::InvalidState(
591                "admitted Dataset Health mapping cannot contain blockers".into(),
592            ));
593        }
594        if !self.mapping_admitted && self.blockers.is_empty() {
595            return Err(ViewerError::InvalidState(
596                "blocked Dataset Health mapping must expose blockers".into(),
597            ));
598        }
599        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
600            return Err(ViewerError::InvalidState(
601                "Dataset Health blockers must not contain empty messages".into(),
602            ));
603        }
604        Ok(())
605    }
606}
607
608fn validate_status(status: &str) -> ViewerResult<()> {
609    if matches!(status, "pass" | "warning" | "blocked") {
610        Ok(())
611    } else {
612        Err(ViewerError::InvalidState(
613            "Dataset Health status must be pass, warning, or blocked".into(),
614        ))
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
623    const OTHER_SHA: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
624
625    fn source(observed: &str, matches: bool) -> StudioSource {
626        StudioSource::try_new("canonical bag", "/media/input.db3", SHA, observed, matches).unwrap()
627    }
628
629    fn topic() -> DatasetHealthTopic {
630        DatasetHealthTopic::try_new("/front", "front", 2, 2, 8, vec!["lidar_front".into()], "pass")
631            .unwrap()
632    }
633
634    fn stage() -> DatasetHealthStage {
635        DatasetHealthStage::try_new(
636            "145E",
637            "Semantic Overlay",
638            "pass",
639            true,
640            false,
641            true,
642            Some(true),
643            3,
644            "overlay state and receipts validated",
645        )
646        .unwrap()
647    }
648
649    fn check(id: &str, status: &str, critical: bool) -> DatasetHealthCheck {
650        DatasetHealthCheck::try_new(
651            id,
652            id,
653            status,
654            critical,
655            "observed",
656            "expected",
657            "health evidence",
658        )
659        .unwrap()
660    }
661
662    fn summary(source_matches: bool, frame_matches: bool) -> DatasetHealthSummary {
663        DatasetHealthSummary::try_new(
664            128,
665            if source_matches { 2 } else { 0 },
666            if source_matches { 2 } else { 0 },
667            if source_matches { 8 } else { 0 },
668            if source_matches { 1 } else { 0 },
669            if source_matches { 128 } else { 0 },
670            if source_matches { 1 } else { 0 },
671            if source_matches { 1 } else { 0 },
672            if source_matches { 2 } else { 1 },
673            if source_matches { 1 } else { 0 },
674            0,
675            1,
676            if source_matches { 0 } else { 1 },
677            source_matches,
678            frame_matches,
679            false,
680        )
681        .unwrap()
682    }
683
684    #[test]
685    fn healthy_dataset_can_be_ready_while_mapping_is_blocked() {
686        let state = DatasetHealthState::try_new(
687            "Dataset Health",
688            source(SHA, true),
689            "lidar_front",
690            "lidar_front",
691            "header stamp",
692            vec![topic()],
693            vec![stage()],
694            vec![ReplayArtifact::try_new("source", "/media/input.db3", 128, SHA).unwrap()],
695            vec![check("source", "pass", true), check("calibration", "blocked", false)],
696            summary(true, true),
697            vec!["clock and TF calibration are not registered".into()],
698        )
699        .unwrap();
700        assert!(state.dataset_ready);
701        assert!(!state.mapping_admitted);
702        #[cfg(feature = "serde")]
703        {
704            let json = serde_json::to_string(&state).unwrap();
705            assert_eq!(serde_json::from_str::<DatasetHealthState>(&json).unwrap(), state);
706        }
707    }
708
709    #[test]
710    fn source_mismatch_is_critical_and_fail_closed() {
711        let state = DatasetHealthState::try_new(
712            "Dataset Health",
713            source(OTHER_SHA, false),
714            "lidar_front",
715            "lidar_front",
716            "header stamp",
717            Vec::new(),
718            Vec::new(),
719            Vec::new(),
720            vec![check("source", "blocked", true)],
721            summary(false, true),
722            vec!["canonical source SHA-256 mismatch".into()],
723        )
724        .unwrap();
725        assert!(!state.dataset_ready);
726        assert!(!state.mapping_admitted);
727    }
728}