1use std::collections::BTreeSet;
8
9use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
10
11pub const DATASET_HEALTH_STATE_VERSION: u32 = 1;
13
14#[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 pub id: String,
21 pub label: String,
23 pub status: String,
25 pub critical: bool,
27 pub observed: String,
29 pub expected: String,
31 pub detail: String,
33}
34
35impl DatasetHealthCheck {
36 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 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#[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 pub name: String,
82 pub role: String,
84 pub message_count: u64,
86 pub retained_record_count: u64,
88 pub retained_point_count: u64,
90 pub frame_ids: Vec<String>,
92 pub status: String,
94}
95
96impl DatasetHealthTopic {
97 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 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#[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 pub source_bytes: u64,
162 pub source_message_count: u64,
164 pub retained_record_count: u64,
166 pub retained_point_count: u64,
168 pub artifact_count: u64,
170 pub artifact_bytes: u64,
172 pub topic_count: u64,
174 pub stage_count: u64,
176 pub check_count: u64,
178 pub pass_count: u64,
180 pub warning_count: u64,
182 pub blocked_count: u64,
184 pub critical_block_count: u64,
186 pub source_identity_match: bool,
188 pub frame_identity_match: bool,
190 pub calibration_ready: bool,
192}
193
194impl DatasetHealthSummary {
195 #[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 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#[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 pub id: String,
276 pub label: String,
278 pub status: String,
280 pub ready: bool,
282 pub mapping_admitted: bool,
284 pub source_identity_match: bool,
286 pub frame_identity_match: Option<bool>,
288 pub artifact_count: u64,
290 pub detail: String,
292}
293
294impl DatasetHealthStage {
295 #[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 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#[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 pub version: u32,
360 pub title: String,
362 pub source: StudioSource,
364 pub frame_id: String,
366 pub expected_frame_id: String,
368 pub time_basis: String,
370 pub topics: Vec<DatasetHealthTopic>,
372 pub stages: Vec<DatasetHealthStage>,
374 pub artifacts: Vec<ReplayArtifact>,
376 pub checks: Vec<DatasetHealthCheck>,
378 pub summary: DatasetHealthSummary,
380 pub dataset_ready: bool,
382 pub mapping_admitted: bool,
384 pub blockers: Vec<String>,
386}
387
388impl DatasetHealthState {
389 #[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 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}