1use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11pub use crate::phase::ExecutionPhase;
12
13pub const MODEL_IR_SCHEMA: &str = "candle-graph/model/1";
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[serde(transparent)]
18pub struct StableId(pub String);
19
20impl StableId {
21 pub fn new(kind: &str, parts: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
22 let mut value = String::from(kind);
23 for part in parts {
24 value.push(':');
25 escape_id_part(part.as_ref(), &mut value);
26 }
27 Self(value)
28 }
29}
30
31impl std::fmt::Display for StableId {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 f.write_str(&self.0)
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum EvidenceKind {
40 Source,
41 Cargo,
42 Checkpoint,
43 Runtime,
44 Inferred,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum Confidence {
50 Proven,
51 Conditional,
52 Heuristic,
53 Unknown,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Evidence {
58 pub kind: EvidenceKind,
59 pub confidence: Confidence,
60 pub source: Option<String>,
61 pub detail: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum Visibility {
67 Public,
68 Crate,
69 Restricted,
70 Private,
71 Unknown,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum BuilderRole {
77 Trainable,
78 Frozen,
79 State,
80 Conditional,
81 Unknown,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum ParameterRole {
87 Optimized,
88 Frozen,
89 RunningState,
90 Excluded,
91 Conditional,
92 Unknown,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum TensorRole {
98 Input,
99 Output,
100 Parameter,
101 Activation,
102 Target,
103 Mask,
104 Loss,
105 Cache,
106 State,
107 Unknown,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum DeviceFact {
113 Cpu,
114 Cuda { ordinal: Option<u32> },
115 Metal,
116 SameAs(String),
117 Unknown,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum LayoutFact {
123 Contiguous,
124 NonContiguous,
125 Strided,
126 SameAs(String),
127 Unknown,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Dimension {
132 pub name: Option<String>,
134 pub expr: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
139pub struct ShapeFact {
140 pub rank: Option<usize>,
141 pub dimensions: Vec<Dimension>,
142 pub source_expr: Option<String>,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct TensorContract {
147 pub id: StableId,
148 pub name: String,
149 pub role: TensorRole,
150 pub owner_function: StableId,
151 pub parameter: Option<StableId>,
152 pub shape: ShapeFact,
153 pub dtype: String,
154 pub device: DeviceFact,
155 pub layout: LayoutFact,
156 pub requires_grad: Option<bool>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub execution_phase: Option<ExecutionPhase>,
160 pub evidence: Vec<Evidence>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct BuilderNamespace {
165 pub name: String,
166 pub role: BuilderRole,
167 pub evidence: Vec<Evidence>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct Component {
172 pub id: StableId,
173 pub name: String,
174 pub qualified_name: String,
175 pub source: String,
176 pub constructor: StableId,
177 pub builders: Vec<BuilderNamespace>,
178 pub modules: Vec<StableId>,
179 pub parameters: Vec<StableId>,
180 pub entrypoints: Vec<StableId>,
181 pub evidence: Vec<Evidence>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct ArchitectureEdge {
186 pub id: StableId,
187 pub from: StableId,
188 pub to: StableId,
189 pub via_function: StableId,
190 pub source: String,
191 pub evidence: Vec<Evidence>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct Module {
196 pub id: StableId,
197 pub component: StableId,
198 pub parent: Option<StableId>,
199 pub type_name: String,
200 pub qualified_type: Option<String>,
201 pub field: Option<String>,
202 pub builder_root: String,
203 pub prefix: String,
204 pub repeat: Option<String>,
205 pub source: String,
206 pub confidence: Confidence,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct Parameter {
211 pub id: StableId,
212 pub component: StableId,
213 pub module: StableId,
214 pub key: String,
215 pub builder_root: String,
216 pub role: ParameterRole,
217 pub kind: String,
218 pub symbolic_shape: Option<String>,
219 pub checkpoint_shape: Option<Vec<usize>>,
220 pub checkpoint_dtype: Option<String>,
221 pub source: String,
222 pub uses: Vec<StableId>,
223 pub optimizer_memberships: Vec<StableId>,
224 pub evidence: Vec<Evidence>,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct Function {
229 pub id: StableId,
230 pub name: String,
231 pub qualified_name: String,
232 pub owner_type: Option<String>,
233 pub visibility: Visibility,
234 pub parameters: Vec<FunctionParameter>,
235 pub return_type: Option<String>,
236 pub cfg_predicates: Vec<String>,
238 pub cfg_active: Option<bool>,
241 pub source: String,
242 pub calls: Vec<StableId>,
243 pub tensor_inputs: Vec<StableId>,
244 pub tensor_outputs: Vec<StableId>,
245 pub is_entrypoint: bool,
246 pub is_loss: bool,
247 #[serde(default)]
249 pub execution_phases: Vec<ExecutionPhase>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct FunctionParameter {
254 pub name: String,
255 pub type_name: String,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
260pub struct TimingStats {
261 pub samples: u64,
262 pub avg_ns: u64,
263 pub min_ns: u64,
264 pub max_ns: u64,
265}
266
267impl TimingStats {
268 pub fn from_durations(durations: &[u64]) -> Option<Self> {
270 if durations.is_empty() {
271 return None;
272 }
273 let mut sorted = durations.to_vec();
274 sorted.sort_unstable();
275 let samples = sorted.len() as u64;
276 let min_ns = sorted[0];
277 let max_ns = sorted[sorted.len() - 1];
278 let total: u64 = sorted.iter().sum();
279 Some(Self {
280 samples,
281 avg_ns: total / samples,
282 min_ns,
283 max_ns,
284 })
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct Operation {
290 pub id: StableId,
291 pub function: StableId,
292 pub name: String,
293 pub qualified_name: Option<String>,
294 pub inputs: Vec<StableId>,
295 pub output: StableId,
296 pub source: String,
297 pub dtype_rule: String,
298 pub gradient_rule: String,
299 pub device_rule: String,
300 pub shape_rule: String,
301 #[serde(default)]
303 pub domain_rule: String,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub execution_phase: Option<ExecutionPhase>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub timing: Option<TimingStats>,
309 pub evidence: Vec<Evidence>,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "snake_case")]
314pub enum StageKind {
315 Prepare,
316 Train,
317 Evaluate,
318 Export,
319 Probe,
320 Unknown,
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
324#[serde(rename_all = "snake_case")]
325pub enum StageDispatchKind {
326 #[default]
327 Unknown,
328 Inline,
329 Subprocess,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct PipelineStage {
334 pub id: StableId,
335 pub name: String,
336 pub kind: StageKind,
337 pub function: StableId,
338 pub order: Option<usize>,
339 pub components: Vec<StableId>,
340 pub consumes: Vec<StableId>,
341 pub produces: Vec<StableId>,
342 pub depends_on: Vec<StableId>,
343 pub source: String,
344 pub evidence: Vec<Evidence>,
345 #[serde(default)]
346 pub dispatch: StageDispatchKind,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub subprocess_key: Option<String>,
349 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub cli_flags: Vec<String>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub launcher: Option<String>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub orchestrator: Option<String>,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "snake_case")]
359pub enum ArtifactKind {
360 Checkpoint,
361 OptimizerState,
362 Dataset,
363 Vocabulary,
364 Cache,
365 EvaluationReport,
366 Configuration,
367 Unknown,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct Artifact {
372 pub id: StableId,
373 pub name: String,
374 pub kind: ArtifactKind,
375 pub path_expr: String,
376 pub produced_by: Vec<StableId>,
377 pub consumed_by: Vec<StableId>,
378 pub source: String,
379 pub evidence: Vec<Evidence>,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "snake_case")]
384pub enum BuilderSourceKind {
385 VarMap,
386 MmapSafetensors,
387 FromTensors,
388 BufferedSafetensors,
389 Unknown,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393pub struct AssemblySite {
394 pub id: StableId,
395 pub function: StableId,
396 pub function_name: String,
397 pub component: StableId,
398 pub component_name: String,
399 pub builder_root: String,
400 pub prefix_chain: Vec<String>,
401 pub varmap: Option<String>,
402 pub source_kind: BuilderSourceKind,
403 pub role: BuilderRole,
404 pub checkpoint_load: Option<String>,
405 pub source: String,
406 pub evidence: Vec<Evidence>,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410pub struct OptimizerMembership {
411 pub id: StableId,
412 pub stage: StableId,
413 pub optimizer: String,
414 pub varmap: String,
415 pub components: Vec<StableId>,
416 pub builder_roots: Vec<String>,
417 pub include_patterns: Vec<String>,
418 pub exclude_patterns: Vec<String>,
419 pub conditional: Option<String>,
420 pub source: String,
421 pub evidence: Vec<Evidence>,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(rename_all = "snake_case")]
426pub enum FindingSeverity {
427 Error,
428 Warning,
429 Information,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433pub struct Finding {
434 pub id: StableId,
435 pub rule: String,
436 pub severity: FindingSeverity,
437 pub confidence: Confidence,
438 pub message: String,
439 pub source: Option<String>,
440 pub related: Vec<StableId>,
441 pub evidence: Vec<Evidence>,
442}
443
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
445pub struct ModelCoverage {
446 pub components: usize,
447 pub architecture_edges: usize,
448 pub modules: usize,
449 pub parameters: usize,
450 pub functions: usize,
451 pub entrypoints: usize,
452 pub component_entrypoints: usize,
453 pub composition_edges: usize,
454 pub assembly_sites: usize,
455 pub subprocess_stages: usize,
456 pub tensors: usize,
457 pub operations: usize,
458 pub pipeline_stages: usize,
459 pub artifacts: usize,
460 pub optimizer_memberships: usize,
461 pub linked_parameter_uses: usize,
462 pub tensors_with_shape: usize,
463 pub tensors_with_dtype: usize,
464 pub tensors_with_device: usize,
465 pub runtime_observations: usize,
466 pub diagnostics: usize,
467}
468
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470pub struct CargoSummary {
471 #[serde(default)]
473 pub build_id: String,
474 pub workspace_root: String,
475 pub manifest_path: String,
476 pub package_name: String,
477 pub package_version: String,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub selected_target: Option<String>,
480 pub active_features: Vec<String>,
481 pub active_cfg: Vec<String>,
482 pub candle_packages: BTreeMap<String, String>,
483}
484
485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486pub struct EdgeTimingSummary {
487 pub from: StableId,
488 pub to: StableId,
489 pub timing: TimingStats,
490}
491
492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
493pub struct RuntimeSummary {
494 pub trace_schema: String,
495 pub entrypoint: Option<String>,
496 pub profile: Option<String>,
497 pub tensor_observations: usize,
498 #[serde(default)]
499 pub operation_observations: usize,
500 pub gradient_observations: usize,
501 pub missing_gradients: usize,
502 pub zero_gradients: usize,
503 pub non_finite_gradients: usize,
504 #[serde(default)]
505 pub tensor_conflicts: usize,
506 #[serde(default)]
507 pub gradient_conflicts: usize,
508 #[serde(default)]
509 pub identity_mismatches: usize,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub first_non_finite_step: Option<u64>,
512 #[serde(default)]
513 pub saturating_activations: usize,
514 #[serde(default)]
515 pub value_observations: usize,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub execution_phase: Option<ExecutionPhase>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub avg_operation_duration_ns: Option<u64>,
520 #[serde(default)]
521 pub edge_timings: Vec<EdgeTimingSummary>,
522}
523
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
525pub struct ModelIr {
526 pub schema: String,
527 pub analysis_id: StableId,
528 pub cargo: Option<CargoSummary>,
529 pub coverage: ModelCoverage,
530 pub components: Vec<Component>,
531 pub architecture_edges: Vec<ArchitectureEdge>,
532 pub modules: Vec<Module>,
533 pub parameters: Vec<Parameter>,
534 pub functions: Vec<Function>,
535 pub tensors: Vec<TensorContract>,
536 pub operations: Vec<Operation>,
537 pub stages: Vec<PipelineStage>,
538 pub artifacts: Vec<Artifact>,
539 pub optimizers: Vec<OptimizerMembership>,
540 pub assembly_sites: Vec<AssemblySite>,
541 pub findings: Vec<Finding>,
542 pub runtime: Option<RuntimeSummary>,
543}
544
545impl ModelIr {
546 pub fn empty(analysis_id: StableId) -> Self {
547 Self {
548 schema: MODEL_IR_SCHEMA.to_string(),
549 analysis_id,
550 cargo: None,
551 coverage: ModelCoverage::default(),
552 components: Vec::new(),
553 architecture_edges: Vec::new(),
554 modules: Vec::new(),
555 parameters: Vec::new(),
556 functions: Vec::new(),
557 tensors: Vec::new(),
558 operations: Vec::new(),
559 stages: Vec::new(),
560 artifacts: Vec::new(),
561 optimizers: Vec::new(),
562 assembly_sites: Vec::new(),
563 findings: Vec::new(),
564 runtime: None,
565 }
566 }
567
568 pub fn normalize(&mut self) {
569 self.components.sort_by(|a, b| a.id.cmp(&b.id));
570 self.architecture_edges.sort_by(|a, b| a.id.cmp(&b.id));
571 self.modules.sort_by(|a, b| a.id.cmp(&b.id));
572 self.parameters.sort_by(|a, b| a.id.cmp(&b.id));
573 self.functions.sort_by(|a, b| a.id.cmp(&b.id));
574 self.tensors.sort_by(|a, b| a.id.cmp(&b.id));
575 self.operations.sort_by(|a, b| a.id.cmp(&b.id));
576 self.stages.sort_by(|a, b| {
577 (a.order.unwrap_or(usize::MAX), &a.id).cmp(&(b.order.unwrap_or(usize::MAX), &b.id))
578 });
579 self.artifacts.sort_by(|a, b| a.id.cmp(&b.id));
580 self.optimizers.sort_by(|a, b| a.id.cmp(&b.id));
581 self.findings.sort_by(|a, b| a.id.cmp(&b.id));
582 self.components.dedup_by(|a, b| a.id == b.id);
583 self.architecture_edges.dedup_by(|a, b| a.id == b.id);
584 self.modules.dedup_by(|a, b| a.id == b.id);
585 self.parameters.dedup_by(|a, b| a.id == b.id);
586 self.functions.dedup_by(|a, b| a.id == b.id);
587 self.tensors.dedup_by(|a, b| a.id == b.id);
588 self.operations.dedup_by(|a, b| a.id == b.id);
589 self.artifacts.dedup_by(|a, b| a.id == b.id);
590 self.optimizers.dedup_by(|a, b| a.id == b.id);
591 let mut merged_findings: Vec<Finding> = Vec::with_capacity(self.findings.len());
592 for finding in self.findings.drain(..) {
593 if let Some(existing) = merged_findings
594 .last_mut()
595 .filter(|existing| existing.id == finding.id)
596 {
597 existing.related.extend(finding.related);
598 existing.evidence.extend(finding.evidence);
599 } else {
600 merged_findings.push(finding);
601 }
602 }
603 self.findings = merged_findings;
604 for component in &mut self.components {
605 component.modules.sort();
606 component.modules.dedup();
607 component.parameters.sort();
608 component.parameters.dedup();
609 component.entrypoints.sort();
610 component.entrypoints.dedup();
611 component.builders.sort_by(|a, b| a.name.cmp(&b.name));
612 }
613 for function in &mut self.functions {
614 function.calls.sort();
615 function.calls.dedup();
616 function.tensor_inputs.sort();
617 function.tensor_inputs.dedup();
618 function.tensor_outputs.sort();
619 function.tensor_outputs.dedup();
620 }
621 for parameter in &mut self.parameters {
622 parameter.uses.sort();
623 parameter.uses.dedup();
624 parameter.optimizer_memberships.sort();
625 parameter.optimizer_memberships.dedup();
626 }
627 for finding in &mut self.findings {
628 finding.related.sort();
629 finding.related.dedup();
630 finding
631 .evidence
632 .sort_by(|a, b| (&a.source, &a.detail).cmp(&(&b.source, &b.detail)));
633 finding
634 .evidence
635 .dedup_by(|a, b| a.source == b.source && a.detail == b.detail);
636 }
637 self.refresh_coverage();
638 }
639
640 pub fn refresh_coverage(&mut self) {
641 self.coverage.components = self.components.len();
642 self.coverage.architecture_edges = self.architecture_edges.len();
643 self.coverage.modules = self.modules.len();
644 self.coverage.parameters = self.parameters.len();
645 self.coverage.functions = self.functions.len();
646 self.coverage.entrypoints = self.functions.iter().filter(|f| f.is_entrypoint).count();
647 let component_types: BTreeMap<String, ()> = self
648 .components
649 .iter()
650 .flat_map(|component| [component.name.clone(), component.qualified_name.clone()])
651 .map(|name| (name, ()))
652 .collect();
653 self.coverage.component_entrypoints = self
654 .functions
655 .iter()
656 .filter(|function| {
657 function.is_entrypoint
658 && function
659 .owner_type
660 .as_ref()
661 .is_some_and(|owner| component_types.contains_key(owner))
662 })
663 .count();
664 self.coverage.composition_edges = self
665 .architecture_edges
666 .iter()
667 .filter(|edge| edge.id.0.starts_with("composition-edge:"))
668 .count();
669 self.coverage.assembly_sites = self.assembly_sites.len();
670 self.coverage.subprocess_stages = self
671 .stages
672 .iter()
673 .filter(|stage| stage.dispatch == StageDispatchKind::Subprocess)
674 .count();
675 self.coverage.tensors = self.tensors.len();
676 self.coverage.operations = self.operations.len();
677 self.coverage.pipeline_stages = self.stages.len();
678 self.coverage.artifacts = self.artifacts.len();
679 self.coverage.optimizer_memberships = self.optimizers.len();
680 self.coverage.linked_parameter_uses =
681 self.parameters.iter().map(|p| p.uses.len()).sum::<usize>();
682 self.coverage.tensors_with_shape = self
683 .tensors
684 .iter()
685 .filter(|t| t.shape.rank.is_some() || !t.shape.dimensions.is_empty())
686 .count();
687 self.coverage.tensors_with_dtype = self
688 .tensors
689 .iter()
690 .filter(|t| crate::dtype_propagate::tensor_dtype_is_proven_for_display(t))
691 .count();
692 self.coverage.tensors_with_device = self
693 .tensors
694 .iter()
695 .filter(|t| !matches!(t.device, DeviceFact::Unknown))
696 .count();
697 self.coverage.diagnostics = self.findings.len();
698 }
699}
700
701fn escape_id_part(part: &str, out: &mut String) {
702 for byte in part.bytes() {
703 match byte {
704 b'%' | b':' | b'/' | b'\\' | b' ' => {
705 out.push('%');
706 out.push_str(&format!("{byte:02X}"));
707 }
708 _ => out.push(byte as char),
709 }
710 }
711}
712
713#[cfg(test)]
714mod tests {
715 use super::TimingStats;
716
717 #[test]
718 fn timing_stats_aggregate_min_max_and_avg() {
719 let stats = TimingStats::from_durations(&[100, 300, 200]).unwrap();
720 assert_eq!(stats.samples, 3);
721 assert_eq!(stats.min_ns, 100);
722 assert_eq!(stats.max_ns, 300);
723 assert_eq!(stats.avg_ns, 200);
724 }
725}