Skip to main content

candle_graph/
model_ir.rs

1//! Unified, agent-oriented representation of a Candle model crate.
2//!
3//! The structure and expression analyzers intentionally keep their own compact arenas while
4//! running. `ModelIr` is the durable interchange layer that joins those arenas with Cargo,
5//! pipeline, optimizer, artifact, tensor-contract, and runtime evidence.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11pub use crate::phase::ExecutionPhase;
12
13/// Versioned schema emitted by scans and consumed by the query/runtime layers.
14pub 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    /// Stable semantic label when one is known (`batch`, `tokens`, `hidden`, ...).
133    pub name: Option<String>,
134    /// Literal or symbolic Rust expression.
135    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    /// Train vs inference graph this contract belongs to.
158    #[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    /// Source-level `#[cfg(...)]` predicates inherited by this definition.
237    pub cfg_predicates: Vec<String>,
238    /// Whether those predicates match the selected Cargo feature/target context.
239    /// `None` means no Cargo context was available or a predicate was unsupported.
240    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    /// Static graphs built for this entrypoint (train and/or infer).
248    #[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/// Wall-time rollup for profiled operations and data-flow edges.
259#[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    /// Aggregate one or more duration samples into avg/min/max.
269    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    /// Float-range transfer after rounding (`real`, `non_negative`, `saturating_unit`, …).
302    #[serde(default)]
303    pub domain_rule: String,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub execution_phase: Option<ExecutionPhase>,
306    /// Populated when a runtime v3 profile trace is merged.
307    #[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    /// Deterministic identity of the exact Cargo configuration used for this scan.
472    #[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}