Skip to main content

candle_graph/trace/
health.rs

1//! Structural validation and observed evidence coverage for a parsed trace.
2
3use 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    /// The event stream is internally consistent enough for derived analysis.
48    pub structurally_valid: bool,
49    /// The producer emitted a successful terminal event.
50    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 = &parameter.root;
725            let key = &parameter.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}