1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4
5use serde::{Deserialize, Serialize};
6
7use crate::capability::{CoverageLevel, GradientFamilyExpectation};
8use crate::phase::{ExecutionPhase, ExecutionStep};
9
10use super::{EdgeEvent, GradientState, MemoryAction, RunOutcome, TraceDocument};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum HealthSeverity {
15 Error,
16 Warning,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct HealthIssue {
21 pub severity: HealthSeverity,
22 pub code: String,
23 pub message: String,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub struct EvidenceCoverage {
28 pub spans: usize,
29 pub closed_spans: usize,
30 pub root_spans: usize,
31 pub measured_spans: usize,
32 pub operations: usize,
33 pub tensors: usize,
34 pub memory_events: usize,
35 pub device_memory_samples: usize,
36 pub device_intervals: usize,
37 pub gradients: usize,
38 pub call_edges: usize,
39 pub data_edges: usize,
40 pub forward_spans: usize,
41 pub backward_spans: usize,
42 pub optimizer_spans: usize,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct TraceHealth {
47 pub structurally_valid: bool,
49 pub capture_complete: bool,
51 pub issues: Vec<HealthIssue>,
52 pub coverage: EvidenceCoverage,
53}
54
55impl TraceHealth {
56 pub fn gaps(&self) -> impl Iterator<Item = &HealthIssue> {
57 self.issues
58 .iter()
59 .filter(|issue| issue.severity == HealthSeverity::Warning)
60 }
61}
62
63pub fn analyze_health(doc: &TraceDocument) -> TraceHealth {
64 let failed = doc.terminal.outcome == RunOutcome::Failed;
65 let ids: HashSet<&str> = doc.spans.iter().map(|span| span.id.as_str()).collect();
66 let by_id: HashMap<&str, _> = doc
67 .spans
68 .iter()
69 .map(|span| (span.id.as_str(), span))
70 .collect();
71 let mut issues = Vec::new();
72
73 if let Err(provenance_error) = doc.run.validate() {
74 error(
75 &mut issues,
76 "run_provenance_invalid",
77 provenance_error.to_string(),
78 );
79 }
80
81 match doc.terminal.outcome {
82 RunOutcome::Complete if doc.terminal.reason.is_some() => error(
83 &mut issues,
84 "complete_with_failure_reason",
85 "complete terminal outcome cannot contain a failure reason",
86 ),
87 RunOutcome::Failed
88 if doc
89 .terminal
90 .reason
91 .as_deref()
92 .is_none_or(|reason| reason.trim().is_empty()) =>
93 {
94 error(
95 &mut issues,
96 "failed_without_reason",
97 "failed terminal outcome requires a non-empty reason",
98 )
99 }
100 _ => {}
101 }
102 let latest_host_timestamp_ns = doc
103 .spans
104 .iter()
105 .map(|span| {
106 span.start_ns
107 .saturating_add(if span.closed { span.duration_ns } else { 0 })
108 })
109 .chain(
110 doc.ops
111 .iter()
112 .map(|op| op.timestamp_ns.saturating_add(op.duration_ns)),
113 )
114 .chain(doc.memory.iter().map(|event| event.timestamp_ns))
115 .chain(doc.device_memory.iter().map(|event| event.timestamp_ns))
116 .max()
117 .unwrap_or(0);
118 if doc.terminal.timestamp_ns < latest_host_timestamp_ns {
119 error(
120 &mut issues,
121 "terminal_precedes_evidence",
122 format!(
123 "terminal timestamp {} precedes host evidence ending at {latest_host_timestamp_ns}",
124 doc.terminal.timestamp_ns
125 ),
126 );
127 }
128
129 if failed {
130 warning(
131 &mut issues,
132 "capture_failed",
133 doc.terminal
134 .reason
135 .as_deref()
136 .unwrap_or("capture ended with a failed outcome"),
137 );
138 }
139 if ids.len() != doc.spans.len() {
140 error(&mut issues, "duplicate_span_id", "span IDs must be unique");
141 }
142 let root_spans = doc
143 .spans
144 .iter()
145 .filter(|span| span.parent_id.is_none())
146 .count();
147 if root_spans != 1 {
148 error(
149 &mut issues,
150 "root_count",
151 format!("expected exactly one root span, found {root_spans}"),
152 );
153 }
154 let measured_spans = doc.spans.iter().filter(|span| span.measured).count();
155 if measured_spans != 1 && !failed {
156 error(
157 &mut issues,
158 "measurement_count",
159 format!("expected exactly one measured region, found {measured_spans}"),
160 );
161 }
162
163 for span in &doc.spans {
164 if !span.closed {
165 if failed {
166 warning(
167 &mut issues,
168 "open_span",
169 format!("span `{}` was interrupted", span.id),
170 );
171 } else {
172 error(
173 &mut issues,
174 "open_span",
175 format!("span `{}` was not closed", span.id),
176 );
177 }
178 }
179 if let Some(parent) = span.parent_id.as_deref() {
180 match by_id.get(parent) {
181 None => error(
182 &mut issues,
183 "unknown_parent",
184 format!("span `{}` refers to missing parent `{parent}`", span.id),
185 ),
186 Some(parent_span) if span.closed && parent_span.closed => {
187 let child_end = span.start_ns.saturating_add(span.duration_ns);
188 let parent_end = parent_span.start_ns.saturating_add(parent_span.duration_ns);
189 if span.start_ns < parent_span.start_ns || child_end > parent_end {
190 error(
191 &mut issues,
192 "child_outside_parent",
193 format!("span `{}` lies outside parent `{parent}`", span.id),
194 );
195 }
196 }
197 Some(_) => {}
198 }
199 }
200 let mut current = Some(span.id.as_str());
201 let mut seen = HashSet::new();
202 while let Some(id) = current {
203 if !seen.insert(id) {
204 error(
205 &mut issues,
206 "span_cycle",
207 format!("span `{}` participates in a parent cycle", span.id),
208 );
209 break;
210 }
211 current = by_id.get(id).and_then(|item| item.parent_id.as_deref());
212 }
213 }
214
215 for (kind, span_id) in doc
216 .ops
217 .iter()
218 .map(|x| ("operation", x.span_id.as_str()))
219 .chain(doc.tensors.iter().map(|x| ("tensor", x.span_id.as_str())))
220 .chain(
221 doc.tensor_stats
222 .iter()
223 .map(|x| ("tensor stats", x.span_id.as_str())),
224 )
225 .chain(doc.memory.iter().map(|x| ("memory", x.span_id.as_str())))
226 .chain(
227 doc.device_intervals
228 .iter()
229 .map(|x| ("device interval", x.span_id.as_str())),
230 )
231 {
232 if !ids.contains(span_id) {
233 error(
234 &mut issues,
235 "unknown_span",
236 format!("{kind} evidence refers to missing span `{span_id}`"),
237 );
238 }
239 }
240 for interval in &doc.device_intervals {
241 if interval.duration_ns == 0 {
242 error(
243 &mut issues,
244 "empty_device_interval",
245 format!(
246 "device interval for `{}` has zero duration",
247 interval.span_id
248 ),
249 );
250 }
251 }
252 for op in &doc.ops {
253 if let Some(span) = by_id.get(op.span_id.as_str()).filter(|span| span.closed) {
254 let span_end = span.start_ns.saturating_add(span.duration_ns);
255 let op_end = op.timestamp_ns.saturating_add(op.duration_ns);
256 if op.timestamp_ns < span.start_ns || op_end > span_end {
257 error(
258 &mut issues,
259 "operation_outside_span",
260 format!(
261 "operation `{}` lies outside span `{}`",
262 op.op_name, op.span_id
263 ),
264 );
265 }
266 }
267 }
268 for event in &doc.memory {
269 if let Some(span) = by_id.get(event.span_id.as_str()).filter(|span| span.closed) {
270 let span_end = span.start_ns.saturating_add(span.duration_ns);
271 if event.timestamp_ns < span.start_ns || event.timestamp_ns > span_end {
272 error(
273 &mut issues,
274 "memory_outside_span",
275 format!(
276 "memory event for storage `{}` lies outside span `{}`",
277 event.storage_id, event.span_id
278 ),
279 );
280 }
281 }
282 }
283 for sample in &doc.device_memory {
284 if sample.used_bytes.is_none()
285 && sample.free_bytes.is_none()
286 && sample.reserved_bytes.is_none()
287 && sample.capacity_bytes.is_none()
288 {
289 error(
290 &mut issues,
291 "empty_device_memory_sample",
292 format!(
293 "device-memory sample for `{}` contains no measurements",
294 sample.device
295 ),
296 );
297 }
298 }
299 let known_tensors = doc
300 .tensors
301 .iter()
302 .map(|tensor| tensor.tensor_id.as_str())
303 .chain(
304 doc.ops
305 .iter()
306 .flat_map(|op| op.inputs.iter().map(String::as_str)),
307 )
308 .chain(doc.ops.iter().filter_map(|op| op.output.as_deref()))
309 .collect::<HashSet<_>>();
310 for edge in &doc.edges {
311 match edge {
312 EdgeEvent::Call {
313 from_span,
314 to_span,
315 ..
316 } if !ids.contains(from_span.as_str()) || !ids.contains(to_span.as_str()) => error(
317 &mut issues,
318 "unknown_call_edge_span",
319 format!("call edge `{from_span}` -> `{to_span}` refers to a missing span"),
320 ),
321 EdgeEvent::Call {
322 from_span,
323 to_span,
324 host_duration_ns,
325 } => {
326 let target = by_id[to_span.as_str()];
327 if target.parent_id.as_deref() != Some(from_span.as_str()) {
328 error(
329 &mut issues,
330 "call_edge_hierarchy_mismatch",
331 format!(
332 "call edge `{from_span}` -> `{to_span}` does not match the span hierarchy"
333 ),
334 );
335 }
336 if target.closed && *host_duration_ns != target.duration_ns {
337 error(
338 &mut issues,
339 "call_edge_duration_mismatch",
340 format!(
341 "call edge `{from_span}` -> `{to_span}` reports {host_duration_ns} ns but the span reports {} ns",
342 target.duration_ns
343 ),
344 );
345 }
346 }
347 EdgeEvent::Data {
348 from_tensor,
349 to_tensor,
350 } if from_tensor.is_empty() || to_tensor.is_empty() => error(
351 &mut issues,
352 "empty_data_edge_endpoint",
353 "data-edge tensor IDs cannot be empty",
354 ),
355 EdgeEvent::Data {
356 from_tensor,
357 to_tensor,
358 } if !known_tensors.contains(from_tensor.as_str())
359 || !known_tensors.contains(to_tensor.as_str()) =>
360 {
361 error(
362 &mut issues,
363 "unknown_data_edge_tensor",
364 format!(
365 "data edge `{from_tensor}` -> `{to_tensor}` refers to unknown tensor evidence"
366 ),
367 )
368 }
369 _ => {}
370 }
371 }
372
373 let mut live_memory: HashMap<(&str, &str), (u64, HashSet<&str>)> = HashMap::new();
374 let mut memory = doc.memory.iter().collect::<Vec<_>>();
375 memory.sort_by_key(|event| event.timestamp_ns);
376 for event in memory {
377 let key = (event.device.as_str(), event.storage_id.as_str());
378 match event.action {
379 MemoryAction::Alloc => match live_memory.get_mut(&key) {
380 Some((bytes, _)) if *bytes != event.bytes => error(
381 &mut issues,
382 "allocation_size_mismatch",
383 format!(
384 "storage `{}` on `{}` has conflicting allocation sizes",
385 event.storage_id, event.device
386 ),
387 ),
388 Some((_, tensor_ids)) => {
389 if !tensor_ids.insert(event.tensor_id.as_str()) {
390 error(
391 &mut issues,
392 "duplicate_allocation",
393 format!(
394 "tensor `{}` repeated an allocation for storage `{}` on `{}`",
395 event.tensor_id, event.storage_id, event.device
396 ),
397 );
398 }
399 }
400 None => {
401 live_memory.insert(
402 key,
403 (event.bytes, HashSet::from([event.tensor_id.as_str()])),
404 );
405 }
406 },
407 MemoryAction::Free => match live_memory.remove(&key) {
408 None => error(
409 &mut issues,
410 "unpaired_free",
411 format!(
412 "storage `{}` on `{}` was freed while not live",
413 event.storage_id, event.device
414 ),
415 ),
416 Some((bytes, _)) if bytes != event.bytes => error(
417 &mut issues,
418 "allocation_size_mismatch",
419 format!(
420 "storage `{}` allocated {bytes} bytes but freed {}",
421 event.storage_id, event.bytes
422 ),
423 ),
424 Some(_) => {}
425 },
426 }
427 }
428 if !live_memory.is_empty() {
429 warning(
430 &mut issues,
431 "retained_allocations",
432 format!(
433 "{} storages remained live at capture end",
434 live_memory.len()
435 ),
436 );
437 }
438
439 validate_gradient_manifest(doc, failed, &mut issues);
440
441 let coverage = EvidenceCoverage {
442 spans: doc.spans.len(),
443 closed_spans: doc.spans.iter().filter(|span| span.closed).count(),
444 root_spans,
445 measured_spans,
446 operations: doc.ops.len(),
447 tensors: doc.tensors.len(),
448 memory_events: doc.memory.len(),
449 device_memory_samples: doc.device_memory.len(),
450 device_intervals: doc.device_intervals.len(),
451 gradients: doc.gradients.len(),
452 call_edges: doc
453 .edges
454 .iter()
455 .filter(|edge| matches!(edge, EdgeEvent::Call { .. }))
456 .count(),
457 data_edges: doc
458 .edges
459 .iter()
460 .filter(|edge| matches!(edge, EdgeEvent::Data { .. }))
461 .count(),
462 forward_spans: step_count(doc, ExecutionStep::Forward),
463 backward_spans: step_count(doc, ExecutionStep::Backward),
464 optimizer_spans: step_count(doc, ExecutionStep::Optimizer),
465 };
466
467 for (empty, code, message) in [
468 (
469 coverage.operations == 0,
470 "operations_absent",
471 "no operation evidence was captured",
472 ),
473 (
474 coverage.tensors == 0,
475 "tensors_absent",
476 "no tensor checkpoints were captured",
477 ),
478 (
479 coverage.memory_events == 0,
480 "logical_memory_absent",
481 "no logical storage events were captured",
482 ),
483 (
484 coverage.device_memory_samples == 0,
485 "physical_memory_absent",
486 "no physical device-memory samples were captured",
487 ),
488 (
489 coverage.device_intervals == 0,
490 "device_timing_absent",
491 "no device timing intervals were captured",
492 ),
493 (
494 coverage.gradients == 0 && doc.run.phase == ExecutionPhase::Train,
495 "gradients_absent",
496 "no gradient facts were captured for this training run",
497 ),
498 (
499 coverage.forward_spans == 0 && doc.run.phase == ExecutionPhase::Train,
500 "forward_absent",
501 "no forward span was tagged",
502 ),
503 (
504 coverage.backward_spans == 0 && doc.run.phase == ExecutionPhase::Train,
505 "backward_absent",
506 "no backward span was tagged",
507 ),
508 (
509 coverage.optimizer_spans == 0 && doc.run.phase == ExecutionPhase::Train,
510 "optimizer_absent",
511 "no optimizer span was tagged",
512 ),
513 ] {
514 if empty {
515 warning(&mut issues, code, message);
516 }
517 }
518 let mut required_labels = HashSet::new();
519 for required in &doc.run.capture_contract.required_semantic_labels {
520 if required.trim().is_empty() {
521 required_semantic_label_issue(
522 &mut issues,
523 failed,
524 "empty_required_semantic_label",
525 "required semantic labels must not be empty",
526 );
527 }
528 if !required_labels.insert(required.as_str()) {
529 required_semantic_label_issue(
530 &mut issues,
531 failed,
532 "duplicate_required_semantic_label",
533 format!("required semantic label `{required}` is declared more than once"),
534 );
535 }
536 let count = doc
537 .spans
538 .iter()
539 .filter(|span| span.name == *required)
540 .count();
541 if count != 1 {
542 required_semantic_label_issue(
543 &mut issues,
544 failed,
545 "required_semantic_label_cardinality",
546 format!(
547 "required semantic label `{required}` must occur exactly once; observed {count}"
548 ),
549 );
550 }
551 }
552 let contract = &doc.run.capture_contract;
553 let explicitly_classified = !contract.gpu_expected_semantic_labels.is_empty()
554 || !contract.cpu_only_semantic_labels.is_empty();
555 if explicitly_classified {
556 let mut classified_labels = HashSet::new();
557 for (class, labels) in [
558 ("GPU-expected", &contract.gpu_expected_semantic_labels),
559 ("CPU-only", &contract.cpu_only_semantic_labels),
560 ] {
561 let mut class_labels = HashSet::new();
562 for label in labels {
563 if label.trim().is_empty() {
564 required_semantic_label_issue(
565 &mut issues,
566 failed,
567 "empty_semantic_label_classification",
568 format!("{class} semantic labels must not be empty"),
569 );
570 }
571 if !class_labels.insert(label.as_str()) {
572 required_semantic_label_issue(
573 &mut issues,
574 failed,
575 "duplicate_semantic_label_classification",
576 format!("{class} semantic label `{label}` is declared more than once"),
577 );
578 }
579 if !required_labels.contains(label.as_str()) {
580 required_semantic_label_issue(
581 &mut issues,
582 failed,
583 "unrequired_semantic_label_classification",
584 format!(
585 "{class} semantic label `{label}` is not a required application label"
586 ),
587 );
588 }
589 if !classified_labels.insert(label.as_str()) {
590 required_semantic_label_issue(
591 &mut issues,
592 failed,
593 "overlapping_semantic_label_classification",
594 format!(
595 "semantic label `{label}` is classified as both GPU-expected and CPU-only"
596 ),
597 );
598 }
599 }
600 }
601 if classified_labels != required_labels {
602 required_semantic_label_issue(
603 &mut issues,
604 failed,
605 "incomplete_semantic_label_partition",
606 "GPU-expected and CPU-only semantic labels must partition all required application labels",
607 );
608 }
609 }
610
611 TraceHealth {
612 structurally_valid: !issues
613 .iter()
614 .any(|issue| issue.severity == HealthSeverity::Error),
615 capture_complete: !failed,
616 issues,
617 coverage,
618 }
619}
620
621fn validate_gradient_manifest(doc: &TraceDocument, failed: bool, issues: &mut Vec<HealthIssue>) {
622 let mut event_ids = HashSet::new();
623 let mut observed = BTreeMap::<(&str, &str), usize>::new();
624 let mut events_by_key = BTreeMap::new();
625 for gradient in &doc.gradients {
626 if gradient.event_id.trim().is_empty() {
627 error(
628 issues,
629 "empty_gradient_event_id",
630 "gradient event IDs must not be empty",
631 );
632 }
633 if gradient.root.trim().is_empty() || gradient.key.trim().is_empty() {
634 error(
635 issues,
636 "empty_gradient_parameter_key",
637 "gradient roots and parameter keys must not be empty",
638 );
639 }
640 if !event_ids.insert(gradient.event_id.as_str()) {
641 error(
642 issues,
643 "duplicate_gradient_event_id",
644 format!(
645 "gradient event ID {:?} occurs more than once",
646 gradient.event_id
647 ),
648 );
649 }
650 *observed
651 .entry((gradient.root.as_str(), gradient.key.as_str()))
652 .or_default() += 1;
653 events_by_key
654 .entry((gradient.root.as_str(), gradient.key.as_str()))
655 .or_insert(gradient);
656 if !gradient.state.norm_is_valid(gradient.norm) {
657 error(
658 issues,
659 "gradient_state_norm_inconsistent",
660 format!(
661 "gradient ({:?}, {:?}) state `{}` is inconsistent with norm {:?}",
662 gradient.root, gradient.key, gradient.state, gradient.norm
663 ),
664 );
665 }
666 }
667
668 let declared = doc.run.capture_contract.gradients;
669 let contract = doc.run.capture_contract.gradient_contract.as_ref();
670 match (declared, contract) {
671 (CoverageLevel::Complete, None) => {
672 error(
673 issues,
674 "gradient_contract_missing",
675 "complete gradient coverage requires an exact gradient contract",
676 );
677 return;
678 }
679 (CoverageLevel::Complete, Some(_)) | (_, None) => {}
680 (_, Some(_)) => {
681 error(
682 issues,
683 "gradient_contract_without_complete_coverage",
684 "an exact gradient contract requires complete declared gradient coverage",
685 );
686 return;
687 }
688 }
689 let Some(contract) = contract else {
690 return;
691 };
692 if let Err(contract_error) = contract.validate() {
693 error(
694 issues,
695 "gradient_contract_invalid",
696 contract_error.to_string(),
697 );
698 return;
699 }
700
701 let expected = contract
702 .expected
703 .iter()
704 .map(|gradient| (gradient.root.as_str(), gradient.key.as_str()))
705 .collect::<BTreeSet<_>>();
706 for (&(root, key), &count) in &observed {
707 if count != 1 {
708 error(
709 issues,
710 "gradient_manifest_duplicate_key",
711 format!("gradient ({root:?}, {key:?}) occurs {count} times; expected exactly once"),
712 );
713 }
714 if !expected.contains(&(root, key)) {
715 error(
716 issues,
717 "gradient_manifest_undeclared_key",
718 format!("gradient ({root:?}, {key:?}) is absent from the manifest"),
719 );
720 }
721 }
722 for parameter in &contract.expected {
723 if !observed.contains_key(&(parameter.root.as_str(), parameter.key.as_str())) {
724 let root = ¶meter.root;
725 let key = ¶meter.key;
726 let message = format!("manifest gradient ({root:?}, {key:?}) was not captured");
727 if failed {
728 warning(issues, "gradient_manifest_missing_key", message);
729 } else {
730 error(issues, "gradient_manifest_missing_key", message);
731 }
732 }
733 }
734
735 for family in &contract.families {
736 let expected_members = contract
737 .expected
738 .iter()
739 .filter(|parameter| parameter.family == family.family)
740 .count();
741 let members = contract
742 .expected
743 .iter()
744 .filter(|parameter| parameter.family == family.family)
745 .filter_map(|parameter| {
746 events_by_key
747 .get(&(parameter.root.as_str(), parameter.key.as_str()))
748 .copied()
749 })
750 .collect::<Vec<_>>();
751 let family_capture_complete = members.len() == expected_members;
752 let present = members
753 .iter()
754 .filter(|gradient| gradient.state == GradientState::Present)
755 .count();
756 let attached = members
757 .iter()
758 .filter(|gradient| gradient.state != GradientState::Missing)
759 .count();
760 let non_finite = members
761 .iter()
762 .filter(|gradient| gradient.state == GradientState::NonFinite)
763 .count();
764 if non_finite > 0 {
765 error(
766 issues,
767 "gradient_family_non_finite",
768 format!(
769 "gradient family {:?} contains {non_finite} non-finite gradients",
770 family.family
771 ),
772 );
773 }
774 match family.expectation {
775 GradientFamilyExpectation::Active
776 if (!failed || family_capture_complete) && present < family.min_present => error(
777 issues,
778 "gradient_active_family_below_minimum",
779 format!(
780 "active gradient family {:?} has {present} present gradients; requires at least {}",
781 family.family, family.min_present
782 ),
783 ),
784 GradientFamilyExpectation::Inactive if attached > 0 => error(
785 issues,
786 "gradient_inactive_family_leakage",
787 format!(
788 "inactive gradient family {:?} has {attached} attached gradients",
789 family.family
790 ),
791 ),
792 GradientFamilyExpectation::DataConditional
793 if (!failed || family_capture_complete)
794 && present > 0
795 && present < family.min_present =>
796 {
797 error(
798 issues,
799 "gradient_conditional_family_below_minimum",
800 format!(
801 "data-conditional gradient family {:?} was attached but has {present} present gradients; requires at least {}",
802 family.family, family.min_present
803 ),
804 )
805 }
806 _ => {}
807 }
808 }
809}
810
811fn step_count(doc: &TraceDocument, step: ExecutionStep) -> usize {
812 doc.spans
813 .iter()
814 .filter(|span| span.step == Some(step))
815 .count()
816}
817
818fn error(issues: &mut Vec<HealthIssue>, code: &str, message: impl Into<String>) {
819 issues.push(HealthIssue {
820 severity: HealthSeverity::Error,
821 code: code.into(),
822 message: message.into(),
823 });
824}
825
826fn warning(issues: &mut Vec<HealthIssue>, code: &str, message: impl Into<String>) {
827 issues.push(HealthIssue {
828 severity: HealthSeverity::Warning,
829 code: code.into(),
830 message: message.into(),
831 });
832}
833
834fn required_semantic_label_issue(
835 issues: &mut Vec<HealthIssue>,
836 failed: bool,
837 code: &str,
838 message: impl Into<String>,
839) {
840 if failed {
841 warning(issues, code, message);
842 } else {
843 error(issues, code, message);
844 }
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850 use crate::capability::CaptureContract;
851 use crate::trace::{
852 MemoryCategory, MemoryEvent, RunOutcome, SpanKind, SpanRecord, TerminalEvent, TimingMode,
853 TraceRunMeta, SCHEMA,
854 };
855
856 fn failed_document() -> TraceDocument {
857 TraceDocument {
858 schema: SCHEMA.into(),
859 run: TraceRunMeta {
860 run_id: "failed".into(),
861 correlation_id: "failed/run".into(),
862 entrypoint: "demo".into(),
863 phase: ExecutionPhase::Infer,
864 timestamp: "2026-08-19T00:00:00Z".into(),
865 capture_step: 1,
866 warmup_steps: 0,
867 device: "cpu".into(),
868 measured_region_device_synchronized: false,
869 timing_mode: TimingMode::Host,
870 capture_contract: CaptureContract::default(),
871 comparison_identity: None,
872 tags: Default::default(),
873 candle_version: None,
874 },
875 spans: vec![SpanRecord {
876 id: "root".into(),
877 parent_id: None,
878 name: "demo".into(),
879 kind: SpanKind::Function,
880 measured: false,
881 start_ns: 0,
882 closed: false,
883 duration_ns: 0,
884 step: None,
885 }],
886 ops: vec![],
887 tensors: vec![],
888 tensor_stats: vec![],
889 memory: vec![],
890 device_memory: vec![],
891 device_intervals: vec![],
892 gradients: vec![],
893 edges: vec![],
894 terminal: TerminalEvent {
895 outcome: RunOutcome::Failed,
896 timestamp_ns: 10,
897 reason: Some("boom".into()),
898 },
899 }
900 }
901
902 #[test]
903 fn failed_capture_is_diagnosable_without_becoming_complete() {
904 let health = analyze_health(&failed_document());
905 assert!(health.structurally_valid);
906 assert!(!health.capture_complete);
907 assert!(health
908 .issues
909 .iter()
910 .any(|issue| issue.code == "capture_failed"));
911 assert!(health
912 .issues
913 .iter()
914 .any(|issue| issue.code == "open_span" && issue.severity == HealthSeverity::Warning));
915 }
916
917 #[test]
918 fn complete_capture_requires_each_declared_semantic_label_exactly_once() {
919 let mut document = failed_document();
920 document.terminal = TerminalEvent {
921 outcome: RunOutcome::Complete,
922 timestamp_ns: 10,
923 reason: None,
924 };
925 document.spans[0].measured = true;
926 document.spans[0].closed = true;
927 document.spans[0].duration_ns = 5;
928 document.run.capture_contract.required_semantic_labels = vec!["missing".into()];
929
930 let health = analyze_health(&document);
931 assert!(!health.structurally_valid);
932 assert!(health.issues.iter().any(|issue| {
933 issue.code == "required_semantic_label_cardinality"
934 && issue.severity == HealthSeverity::Error
935 }));
936
937 document.run.capture_contract.required_semantic_labels = vec!["demo".into(), "demo".into()];
938 let duplicate_health = analyze_health(&document);
939 assert!(!duplicate_health.structurally_valid);
940 assert!(duplicate_health.issues.iter().any(|issue| {
941 issue.code == "duplicate_required_semantic_label"
942 && issue.severity == HealthSeverity::Error
943 }));
944
945 document.run.capture_contract.required_semantic_labels = vec!["demo".into()];
946 let mut repeated = document.spans[0].clone();
947 repeated.id = "child".into();
948 repeated.parent_id = Some("root".into());
949 repeated.measured = false;
950 document.spans.push(repeated);
951 let repeated_health = analyze_health(&document);
952 assert!(!repeated_health.structurally_valid);
953 assert!(repeated_health.issues.iter().any(|issue| {
954 issue.code == "required_semantic_label_cardinality"
955 && issue.severity == HealthSeverity::Error
956 && issue.message.contains("observed 2")
957 }));
958 }
959
960 #[test]
961 fn failed_capture_keeps_missing_required_labels_diagnostic() {
962 let mut document = failed_document();
963 document.run.capture_contract.required_semantic_labels = vec!["missing".into()];
964
965 let health = analyze_health(&document);
966 assert!(health.structurally_valid);
967 assert!(health.issues.iter().any(|issue| {
968 issue.code == "required_semantic_label_cardinality"
969 && issue.severity == HealthSeverity::Warning
970 }));
971 }
972
973 #[test]
974 fn complete_capture_rejects_an_incomplete_semantic_label_partition() {
975 let mut document = failed_document();
976 document.terminal = TerminalEvent {
977 outcome: RunOutcome::Complete,
978 timestamp_ns: 10,
979 reason: None,
980 };
981 document.spans[0].measured = true;
982 document.spans[0].closed = true;
983 document.spans[0].duration_ns = 5;
984 document.run.capture_contract.required_semantic_labels =
985 vec!["demo".into(), "prepare".into()];
986 document.run.capture_contract.gpu_expected_semantic_labels = vec!["demo".into()];
987
988 let health = analyze_health(&document);
989 assert!(!health.structurally_valid);
990 assert!(health.issues.iter().any(|issue| {
991 issue.code == "incomplete_semantic_label_partition"
992 && issue.severity == HealthSeverity::Error
993 }));
994 }
995
996 #[test]
997 fn out_of_domain_run_provenance_is_a_structural_error() {
998 let mut document = failed_document();
999 document.run.capture_step = 0;
1000 let health = analyze_health(&document);
1001 assert!(!health.structurally_valid);
1002 assert!(health.issues.iter().any(|issue| {
1003 issue.code == "run_provenance_invalid" && issue.severity == HealthSeverity::Error
1004 }));
1005
1006 let mut document = failed_document();
1007 document.run.warmup_steps = document.run.capture_step;
1008 assert!(!analyze_health(&document).structurally_valid);
1009
1010 let mut document = failed_document();
1011 document.run.entrypoint = " ".into();
1012 assert!(!analyze_health(&document).structurally_valid);
1013 }
1014
1015 #[test]
1016 fn memory_events_outside_their_closed_span_are_rejected() {
1017 let mut document = failed_document();
1018 document.spans[0].closed = true;
1019 document.spans[0].start_ns = 10;
1020 document.spans[0].duration_ns = 20;
1021 document.memory = vec![MemoryEvent {
1022 timestamp_ns: 50,
1023 storage_id: "late".into(),
1024 tensor_id: "late".into(),
1025 span_id: "root".into(),
1026 op_name: None,
1027 device: "cpu".into(),
1028 bytes: 8,
1029 action: MemoryAction::Alloc,
1030 shape: vec![8],
1031 dtype: "u8".into(),
1032 category: MemoryCategory::Activation,
1033 }];
1034 let health = analyze_health(&document);
1035 assert!(!health.structurally_valid);
1036 assert!(health.issues.iter().any(|issue| {
1037 issue.code == "memory_outside_span" && issue.severity == HealthSeverity::Error
1038 }));
1039 }
1040
1041 #[test]
1042 fn distinct_tensor_aliases_share_one_live_storage() {
1043 let mut document = failed_document();
1044 let memory = |timestamp_ns, tensor_id: &str, action| MemoryEvent {
1045 timestamp_ns,
1046 storage_id: "shared".into(),
1047 tensor_id: tensor_id.into(),
1048 span_id: "root".into(),
1049 op_name: None,
1050 device: "cpu".into(),
1051 bytes: 64,
1052 action,
1053 shape: vec![16],
1054 dtype: "f32".into(),
1055 category: MemoryCategory::Activation,
1056 };
1057 document.memory = vec![
1058 memory(1, "base", MemoryAction::Alloc),
1059 memory(2, "view", MemoryAction::Alloc),
1060 memory(3, "view", MemoryAction::Free),
1061 ];
1062 let health = analyze_health(&document);
1063 assert!(health.structurally_valid);
1064 assert!(!health
1065 .issues
1066 .iter()
1067 .any(|issue| issue.code == "duplicate_allocation"));
1068 }
1069}