Skip to main content

candle_graph/
discover.rs

1//! Crate-wide model discovery and unified IR assembly.
2//!
3//! Public API boundaries and `VarBuilder` constructors provide conservative component candidates.
4//! Architecture, pipeline, artifact, and optimizer relationships require compiler-resolved value
5//! flow; the former syntax/name-based implementation is quarantined until that frontend exists.
6//! Optional runtime traces refine (but never erase) static facts.
7
8use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use quote::ToTokens;
13use syn::visit::{self, Visit};
14
15use crate::cargo_context::{CargoContext, CargoOptions};
16use crate::dataflow::{self, GradState, NodeKind, NumericImpact};
17use crate::extract::Extractor;
18use crate::ir::{Acquisition, Certainty, CheckpointMatch};
19use crate::load::{self, Crate, ImplFn, StructDef};
20use crate::model_ir::{
21    ArchitectureEdge, Artifact, ArtifactKind, AssemblySite, BuilderNamespace, BuilderRole,
22    BuilderSourceKind, CargoSummary, Component, Confidence, DeviceFact, Evidence, EvidenceKind,
23    Finding, FindingSeverity, Function, FunctionParameter, LayoutFact, ModelIr, Module, Operation,
24    OptimizerMembership, Parameter, ParameterRole, PipelineStage, RuntimeSummary, ShapeFact,
25    EdgeTimingSummary, StableId, StageDispatchKind, StageKind, TensorContract, TensorRole, Visibility,
26};
27use crate::op_semantics::{self, GradFlow};
28use crate::runtime::{self, ExpectedIdentity, GradientState, RuntimeTrace};
29
30#[derive(Debug, Clone, Default)]
31pub struct ScanOptions {
32    pub cargo: CargoOptions,
33    pub runtime_trace: Option<PathBuf>,
34    /// Optional component root, including private/internal model types.
35    pub component_root: Option<String>,
36    /// Analyze expression graphs for source entrypoints. Disable only for a fast symbol scan.
37    pub dataflow: bool,
38    /// Enable name/order-derived architecture, pipeline, artifact, and optimizer heuristics.
39    /// All emitted relationships are tagged `Heuristic` and should not be treated as proven facts.
40    pub heuristic_architecture: bool,
41}
42
43/// Analyze a Cargo crate or source directory into the unified model IR.
44pub fn analyze(path: impl AsRef<Path>, options: &ScanOptions) -> Result<ModelIr> {
45    let path = path.as_ref();
46    let cargo_result = CargoContext::discover(path, &options.cargo);
47    let mut krate = match cargo_result.as_ref() {
48        Ok(context) => {
49            let roots = context.selected_source_roots(options.cargo.package_target.as_deref())?;
50            load::load_from_roots(path, &roots)?
51        }
52        Err(_) => load::load(path)?,
53    };
54    if let Ok(context) = cargo_result.as_ref() {
55        krate.set_dependency_aliases(context.dependency_aliases.clone());
56    }
57    if krate.all_structs().next().is_none() {
58        anyhow::bail!("no Rust structs found under {}", path.display());
59    }
60
61    let analysis_id = match cargo_result.as_ref() {
62        Ok(context) => StableId::new(
63            "analysis",
64            [cargo_build_id(
65                context,
66                options.cargo.package_target.as_deref(),
67            )],
68        ),
69        Err(_) => StableId::new("analysis", [canonical_label(path)]),
70    };
71    let mut model = ModelIr::empty(analysis_id);
72
73    let cargo = match cargo_result {
74        Ok(context) => {
75            model.cargo = Some(cargo_summary(
76                &context,
77                options.cargo.package_target.as_deref(),
78            ));
79            Some(context)
80        }
81        Err(error) => {
82            push_finding(
83                &mut model,
84                "cargo-context",
85                FindingSeverity::Warning,
86                Confidence::Proven,
87                format!("Cargo context unavailable: {error:#}"),
88                None,
89                Vec::new(),
90            );
91            None
92        }
93    };
94    for diagnostic in &krate.diagnostics {
95        push_finding(
96            &mut model,
97            "source-load",
98            FindingSeverity::Warning,
99            Confidence::Unknown,
100            format!("incomplete source analysis: {}", diagnostic.message),
101            Some(diagnostic.path.clone()),
102            Vec::new(),
103        );
104    }
105
106    let function_lookup = build_functions(&krate, cargo.as_ref(), &mut model);
107    link_calls(&krate, &function_lookup, &mut model);
108    discover_components(
109        &krate,
110        cargo.as_ref(),
111        options.component_root.as_deref(),
112        options.heuristic_architecture,
113        &mut model,
114    );
115    discover_composition_edges(&krate, &mut model);
116    discover_assembly_sites(&krate, &mut model);
117    add_contracts(&krate, &mut model);
118    if options.heuristic_architecture {
119        discover_architecture_edges(&krate, &mut model);
120        discover_subprocess_pipeline(&krate, &function_lookup, &mut model);
121        discover_pipeline_and_artifacts(&krate, &function_lookup, &mut model);
122        discover_optimizers(&krate, &mut model);
123        apply_optimizer_roles(&mut model);
124    } else {
125        push_finding(
126            &mut model,
127            "compiler-semantic-evidence",
128            FindingSeverity::Information,
129            Confidence::Unknown,
130            "architecture, pipeline, artifact, and optimizer relationships are unavailable until \
131             compiler-resolved value-flow evidence is implemented; use --heuristic-architecture \
132             for exploratory name- and call-order leads"
133                .to_string(),
134            None,
135            Vec::new(),
136        );
137    }
138
139    if options.dataflow {
140        add_dataflow(&krate, &mut model);
141    }
142
143    if let Some(path) = options.runtime_trace.as_deref() {
144        let text = std::fs::read_to_string(path)
145            .with_context(|| format!("reading runtime trace {}", path.display()))?;
146        let trace = runtime::parse(&text)
147            .with_context(|| format!("parsing runtime trace {}", path.display()))?;
148        merge_runtime(&mut model, &trace);
149    }
150
151    if let Some(cargo) = cargo {
152        flag_candle_semantics_version(&mut model, &cargo);
153    }
154    model.normalize();
155    Ok(model)
156}
157
158fn canonical_label(path: &Path) -> String {
159    path.canonicalize()
160        .unwrap_or_else(|_| path.to_path_buf())
161        .to_string_lossy()
162        .into_owned()
163}
164
165fn selected_target(context: &CargoContext, requested_target: Option<&str>) -> Option<String> {
166    requested_target.map(str::to_string).or_else(|| {
167        context
168            .targets
169            .iter()
170            .find(|target| target.kind.iter().any(|kind| kind == "lib"))
171            .or_else(|| {
172                context
173                    .targets
174                    .iter()
175                    .find(|target| target.kind.iter().any(|kind| kind == "bin"))
176            })
177            .map(|target| target.name.clone())
178    })
179}
180
181fn cargo_build_id(context: &CargoContext, requested_target: Option<&str>) -> String {
182    let target = selected_target(context, requested_target).unwrap_or_else(|| "unknown".into());
183    let mut identity = String::from("candle-graph/build/1\0");
184    for part in [
185        context.package_name.as_str(),
186        context.package_version.as_str(),
187        target.as_str(),
188    ] {
189        identity.push_str(part);
190        identity.push('\0');
191    }
192    for feature in &context.active_features {
193        identity.push_str("feature=");
194        identity.push_str(feature);
195        identity.push('\0');
196    }
197    for cfg in &context.cfgs {
198        identity.push_str("cfg=");
199        identity.push_str(cfg);
200        identity.push('\0');
201    }
202    for (package, version) in &context.candle_versions {
203        identity.push_str("candle=");
204        identity.push_str(package);
205        identity.push('@');
206        identity.push_str(version);
207        identity.push('\0');
208    }
209
210    // FNV-1a is deliberately fixed here rather than relying on Rust's unstable DefaultHasher.
211    let hash = identity.bytes().fold(0xcbf29ce484222325_u64, |hash, byte| {
212        (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
213    });
214    format!("candle-graph/build/1:{hash:016x}")
215}
216
217fn cargo_summary(context: &CargoContext, requested_target: Option<&str>) -> CargoSummary {
218    CargoSummary {
219        build_id: cargo_build_id(context, requested_target),
220        workspace_root: context.workspace_root.to_string_lossy().into_owned(),
221        manifest_path: context.manifest_path.to_string_lossy().into_owned(),
222        package_name: context.package_name.clone(),
223        package_version: context.package_version.clone(),
224        selected_target: selected_target(context, requested_target),
225        active_features: context.active_features.clone(),
226        active_cfg: context.cfgs.clone(),
227        candle_packages: context.candle_versions.clone(),
228    }
229}
230
231fn build_functions(
232    krate: &Crate,
233    cargo: Option<&CargoContext>,
234    model: &mut ModelIr,
235) -> HashMap<String, StableId> {
236    let mut lookup = HashMap::new();
237    for func in krate.all_functions().chain(krate.all_methods()) {
238        let cfg_active = cargo.and_then(|context| {
239            crate::cargo_context::cfg_predicates_active(&func.cfg_predicates, &context.cfgs)
240        });
241        if cfg_active == Some(false) {
242            continue;
243        }
244        let id = function_id(func);
245        lookup.insert(func.qualified_name.clone(), id.clone());
246        let tensor_signature = func.param_types.iter().any(|ty| is_tensor_type(ty))
247            || is_tensor_type(&func.return_type);
248        let is_loss = has_explicit_candle_loss_call(krate, func);
249        // A public tensor boundary is a code-derived entrypoint candidate. Its spelling carries no
250        // semantics; only its trait identity or public tensor signature matters here.
251        let is_entrypoint =
252            is_candle_module_entry(func) || is_loss || tensor_signature && func.visibility == "pub";
253        model.functions.push(Function {
254            id,
255            name: func.fn_name.clone(),
256            qualified_name: func.qualified_name.clone(),
257            owner_type: (!func.qualified_type_name.is_empty())
258                .then(|| func.qualified_type_name.clone()),
259            visibility: visibility(&func.visibility),
260            parameters: func
261                .params
262                .iter()
263                .zip(&func.param_types)
264                .map(|(name, type_name)| FunctionParameter {
265                    name: name.clone(),
266                    type_name: type_name.clone(),
267                })
268                .collect(),
269            return_type: (func.return_type != "()").then(|| func.return_type.clone()),
270            cfg_predicates: func.cfg_predicates.clone(),
271            cfg_active,
272            source: krate.file_label(func.span),
273            calls: Vec::new(),
274            tensor_inputs: Vec::new(),
275            tensor_outputs: Vec::new(),
276            is_entrypoint,
277            is_loss,
278            execution_phases: Vec::new(),
279        });
280    }
281    lookup
282}
283
284fn is_candle_module_entry(func: &ImplFn) -> bool {
285    let Some(trait_name) = func.trait_name.as_deref() else {
286        return false;
287    };
288    let trait_leaf = trait_name.rsplit("::").next().unwrap_or(trait_name);
289    matches!(
290        (trait_leaf, func.fn_name.as_str()),
291        ("Module", "forward")
292            | ("ModuleT", "forward_t")
293            | ("ModuleWithArgs", "forward")
294            | ("ModuleTWithArgs", "forward_t")
295    )
296}
297
298fn has_explicit_candle_loss_call(krate: &Crate, func: &ImplFn) -> bool {
299    let mut collector = CallCollector::default();
300    collector.visit_block(&func.block);
301    collector.calls.iter().any(|call| {
302        let segments = call.split("::").map(str::to_string).collect::<Vec<_>>();
303        let resolved = krate
304            .resolve_import_path(&func.module_path, &segments)
305            .join("::");
306        resolved
307            .strip_prefix("candle_nn::loss::")
308            .is_some_and(|leaf| {
309                matches!(
310                    leaf,
311                    "nll" | "cross_entropy" | "mse" | "binary_cross_entropy_with_logit" | "huber"
312                )
313            })
314    })
315}
316
317fn link_calls(krate: &Crate, lookup: &HashMap<String, StableId>, model: &mut ModelIr) {
318    let mut by_bare: HashMap<String, Vec<StableId>> = HashMap::new();
319    for function in &model.functions {
320        by_bare
321            .entry(function.name.clone())
322            .or_default()
323            .push(function.id.clone());
324    }
325    let index: HashMap<StableId, usize> = model
326        .functions
327        .iter()
328        .enumerate()
329        .map(|(index, function)| (function.id.clone(), index))
330        .collect();
331
332    for func in krate.all_functions().chain(krate.all_methods()) {
333        let mut collector = CallCollector::default();
334        collector.visit_block(&func.block);
335        let caller = function_id(func);
336        let Some(&caller_index) = index.get(&caller) else {
337            continue;
338        };
339        let mut calls = Vec::new();
340        for call in collector.calls {
341            if let Some(id) = resolve_call(&call, func, lookup, &by_bare) {
342                calls.push(id);
343            }
344        }
345        calls.sort();
346        calls.dedup();
347        model.functions[caller_index].calls = calls;
348    }
349}
350
351fn resolve_call(
352    call: &str,
353    caller: &ImplFn,
354    exact: &HashMap<String, StableId>,
355    bare: &HashMap<String, Vec<StableId>>,
356) -> Option<StableId> {
357    let clean = call.trim_start_matches("crate::");
358    for candidate in [
359        clean.to_string(),
360        qualify(&caller.module_path, clean),
361        clean
362            .strip_prefix("self::")
363            .map(|rest| qualify(&caller.module_path, rest))
364            .unwrap_or_default(),
365    ] {
366        if let Some(id) = exact.get(&candidate) {
367            return Some(id.clone());
368        }
369    }
370    if clean.contains("::") {
371        let suffix = format!("::{clean}");
372        let mut matches = exact
373            .iter()
374            .filter(|(name, _)| name.ends_with(&suffix))
375            .map(|(_, id)| id);
376        let first = matches.next().cloned();
377        if first.is_some() && matches.next().is_none() {
378            return first;
379        }
380    }
381    let leaf = clean.rsplit("::").next().unwrap_or(clean);
382    match bare.get(leaf).map(Vec::as_slice) {
383        Some([id]) => Some(id.clone()),
384        _ => None,
385    }
386}
387
388fn discover_architecture_edges(krate: &Crate, model: &mut ModelIr) {
389    let component_by_type: HashMap<String, StableId> = model
390        .components
391        .iter()
392        .flat_map(|component| {
393            [
394                (component.name.clone(), component.id.clone()),
395                (component.qualified_name.clone(), component.id.clone()),
396            ]
397        })
398        .collect();
399
400    let mut seen = HashSet::new();
401    for function in krate.all_functions().chain(krate.all_methods()) {
402        if !is_production_source(krate, function) {
403            continue;
404        }
405        let owner_fields = krate
406            .struct_candidates(&function.qualified_type_name)
407            .into_iter()
408            .next()
409            .map(|owner| {
410                owner
411                    .fields
412                    .iter()
413                    .filter_map(|field| {
414                        component_by_type
415                            .get(&field.ty.base)
416                            .cloned()
417                            .map(|component| (field.name.clone(), component))
418                    })
419                    .collect()
420            })
421            .unwrap_or_default();
422        let mut collector = ComponentFlowCollector {
423            component_by_type: &component_by_type,
424            owner_fields,
425            locals: HashMap::new(),
426            sequence: Vec::new(),
427        };
428        let signature_sequence: Vec<StableId> = function
429            .param_types
430            .iter()
431            .filter_map(|type_name| {
432                type_base_from_text(type_name)
433                    .and_then(|base| component_by_type.get(&base).cloned())
434            })
435            .fold(Vec::new(), |mut sequence, component| {
436                if sequence.last() != Some(&component) {
437                    sequence.push(component);
438                }
439                sequence
440            });
441        collector.visit_block(&function.block);
442        let from_signature = signature_sequence.len() > collector.sequence.len();
443        let sequence = if from_signature {
444            signature_sequence
445        } else {
446            collector.sequence
447        };
448        for pair in sequence.windows(2) {
449            if pair[0] == pair[1] {
450                continue;
451            }
452            let key = (pair[0].clone(), pair[1].clone(), function_id(function));
453            if !seen.insert(key.clone()) {
454                continue;
455            }
456            model.architecture_edges.push(ArchitectureEdge {
457                id: StableId::new(
458                    "architecture-edge",
459                    [key.0 .0.as_str(), key.1 .0.as_str(), key.2 .0.as_str()],
460                ),
461                from: key.0,
462                to: key.1,
463                via_function: key.2,
464                source: krate.file_label(function.span),
465                evidence: vec![heuristic_source_evidence(
466                    krate.file_label(function.span),
467                    if from_signature {
468                        "component-typed parameters establish this interface order"
469                    } else {
470                        "typed component receiver calls occur in this source order"
471                    },
472                )],
473            });
474        }
475    }
476}
477
478fn infer_builder_role(name: &str) -> (BuilderRole, Confidence) {
479    let lower = name.to_ascii_lowercase();
480    if lower.contains("train") || lower == "adapter_vb" {
481        return (BuilderRole::Trainable, Confidence::Heuristic);
482    }
483    if lower.contains("base")
484        || lower.contains("frozen")
485        || lower.contains("mmap")
486        || lower.contains("pretrained")
487    {
488        return (BuilderRole::Frozen, Confidence::Heuristic);
489    }
490    if lower.contains("state") || lower.contains("running") {
491        return (BuilderRole::State, Confidence::Heuristic);
492    }
493    (BuilderRole::Unknown, Confidence::Unknown)
494}
495
496fn discover_composition_edges(krate: &Crate, model: &mut ModelIr) {
497    let component_by_type: HashMap<String, StableId> = model
498        .components
499        .iter()
500        .flat_map(|component| {
501            [
502                (component.name.clone(), component.id.clone()),
503                (component.qualified_name.clone(), component.id.clone()),
504            ]
505        })
506        .collect();
507
508    let mut seen = HashSet::new();
509    for component in model.components.clone() {
510        let Some(def) = krate
511            .struct_candidates(&component.name)
512            .into_iter()
513            .find(|def| def.qualified_name == component.qualified_name)
514        else {
515            continue;
516        };
517        for field in &def.fields {
518            let child = unique_qualified_struct(krate, &field.ty.base)
519                .and_then(|qualified| component_by_type.get(&qualified).cloned())
520                .or_else(|| component_by_type.get(&field.ty.base).cloned());
521            let Some(child) = child else {
522                continue;
523            };
524            if child == component.id {
525                continue;
526            }
527            let key = (component.id.clone(), child.clone(), field.name.clone());
528            if !seen.insert(key.clone()) {
529                continue;
530            }
531            model.architecture_edges.push(ArchitectureEdge {
532                id: StableId::new(
533                    "composition-edge",
534                    [
535                        component.id.0.as_str(),
536                        child.0.as_str(),
537                        field.name.as_str(),
538                    ],
539                ),
540                from: component.id.clone(),
541                to: child,
542                via_function: component.constructor.clone(),
543                source: krate.file_label(field.span),
544                evidence: vec![Evidence {
545                    kind: EvidenceKind::Source,
546                    confidence: Confidence::Heuristic,
547                    source: Some(krate.file_label(field.span)),
548                    detail: format!(
549                        "struct field `{}` embeds component type `{}`",
550                        field.name, field.ty.base
551                    ),
552                }],
553            });
554        }
555    }
556}
557
558fn discover_assembly_sites(krate: &Crate, model: &mut ModelIr) {
559    let constructor_functions: HashMap<StableId, &Function> = model
560        .functions
561        .iter()
562        .map(|candidate| (candidate.id.clone(), candidate))
563        .collect();
564    let specs: Vec<ConstructorSpec> = model
565        .components
566        .iter()
567        .filter_map(|component| {
568            let constructor = constructor_functions.get(&component.constructor)?;
569            let builders = constructor
570                .parameters
571                .iter()
572                .enumerate()
573                .filter(|(_, parameter)| parameter.type_name.contains("VarBuilder"))
574                .zip(component.builders.iter())
575                .map(|((index, _), builder)| (index, builder.name.clone()))
576                .collect();
577            Some(ConstructorSpec {
578                component: component.id.clone(),
579                owner: component.name.clone(),
580                builders,
581            })
582        })
583        .collect();
584    if specs.is_empty() {
585        return;
586    }
587
588    for func in krate.all_functions().chain(krate.all_methods()) {
589        if !is_production_source(krate, func) {
590            continue;
591        }
592        let function_id = function_id(func);
593        let mut collector = AssemblyCollector {
594            specs: &specs,
595            function_id: function_id.clone(),
596            function_name: func.qualified_name.clone(),
597            source: krate.file_label(func.span),
598            varmap_checkpoints: HashMap::new(),
599            vb_bindings: HashMap::new(),
600            sites: Vec::new(),
601        };
602        collector.visit_block(&func.block);
603        for site in &mut collector.sites {
604            if site.checkpoint_load.is_none() {
605                site.checkpoint_load = site
606                    .varmap
607                    .as_ref()
608                    .and_then(|varmap| collector.varmap_checkpoints.get(varmap).cloned());
609            }
610        }
611        model.assembly_sites.extend(collector.sites);
612    }
613}
614
615struct AssemblyCollector<'a> {
616    specs: &'a [ConstructorSpec],
617    function_id: StableId,
618    function_name: String,
619    source: String,
620    varmap_checkpoints: HashMap<String, String>,
621    vb_bindings: HashMap<String, BuilderExpressionFacts>,
622    sites: Vec<AssemblySite>,
623}
624
625impl AssemblyCollector<'_> {
626    fn resolve_builder_facts(&self, expression: &syn::Expr) -> BuilderExpressionFacts {
627        match expression {
628            syn::Expr::Path(path) if path.path.segments.len() == 1 => self
629                .vb_bindings
630                .get(&path.path.segments[0].ident.to_string())
631                .cloned()
632                .unwrap_or_else(empty_builder_facts),
633            _ => builder_expression_facts(expression, &self.vb_bindings),
634        }
635    }
636}
637
638impl<'ast> Visit<'ast> for AssemblyCollector<'_> {
639    fn visit_local(&mut self, node: &'ast syn::Local) {
640        if let (Some(name), Some(init)) = (pat_ident(&node.pat), node.init.as_ref()) {
641            let facts = builder_expression_facts(&init.expr, &self.vb_bindings);
642            if facts.source_kind != BuilderSourceKind::Unknown
643                || !facts.prefix_chain.is_empty()
644                || facts.varmap.is_some()
645            {
646                self.vb_bindings.insert(name, facts);
647            }
648        }
649        visit::visit_local(self, node);
650    }
651
652    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
653        if let syn::Expr::Path(path) = &*node.func {
654            let leaf = path
655                .path
656                .segments
657                .last()
658                .map(|segment| segment.ident.to_string())
659                .unwrap_or_default();
660            if leaf == "load_varmap_checked" || leaf.ends_with("load_varmap_checked") {
661                if let (Some(varmap), Some(checkpoint)) = (
662                    node.args.first().and_then(load_varmap_target),
663                    node.args
664                        .get(1)
665                        .map(|arg| arg.to_token_stream().to_string()),
666                ) {
667                    self.varmap_checkpoints.insert(varmap, checkpoint);
668                }
669            }
670            let segments: Vec<_> = path
671                .path
672                .segments
673                .iter()
674                .map(|segment| segment.ident.to_string())
675                .collect();
676            if let Some(owner) = segments.get(segments.len().saturating_sub(2)) {
677                for spec in self.specs.iter().filter(|spec| &spec.owner == owner) {
678                    for (index, builder_root) in &spec.builders {
679                        let Some(argument) = node.args.iter().nth(*index) else {
680                            continue;
681                        };
682                        let facts = self.resolve_builder_facts(argument);
683                        let varmap = facts.varmap.clone();
684                        let checkpoint = varmap
685                            .as_ref()
686                            .and_then(|name| self.varmap_checkpoints.get(name).cloned());
687                        self.sites.push(AssemblySite {
688                            id: StableId::new(
689                                "assembly-site",
690                                [
691                                    self.function_id.0.as_str(),
692                                    spec.component.0.as_str(),
693                                    builder_root.as_str(),
694                                    &facts.prefix_chain.join("/"),
695                                ],
696                            ),
697                            function: self.function_id.clone(),
698                            function_name: self.function_name.clone(),
699                            component: spec.component.clone(),
700                            component_name: spec.owner.clone(),
701                            builder_root: builder_root.clone(),
702                            prefix_chain: facts.prefix_chain,
703                            varmap,
704                            source_kind: facts.source_kind,
705                            role: facts.role,
706                            checkpoint_load: checkpoint,
707                            source: self.source.clone(),
708                            evidence: vec![Evidence {
709                                kind: EvidenceKind::Source,
710                                confidence: Confidence::Heuristic,
711                                source: Some(self.source.clone()),
712                                detail: format!(
713                                    "`{owner}::new` wired through `{builder_root}` in `{}`",
714                                    self.function_name
715                                ),
716                            }],
717                        });
718                    }
719                }
720            }
721        }
722        visit::visit_expr_call(self, node);
723    }
724}
725
726#[derive(Clone)]
727struct BuilderExpressionFacts {
728    prefix_chain: Vec<String>,
729    varmap: Option<String>,
730    source_kind: BuilderSourceKind,
731    role: BuilderRole,
732}
733
734fn empty_builder_facts() -> BuilderExpressionFacts {
735    BuilderExpressionFacts {
736        prefix_chain: Vec::new(),
737        varmap: None,
738        source_kind: BuilderSourceKind::Unknown,
739        role: BuilderRole::Unknown,
740    }
741}
742
743fn builder_expression_facts(
744    expression: &syn::Expr,
745    vb_bindings: &HashMap<String, BuilderExpressionFacts>,
746) -> BuilderExpressionFacts {
747    match expression {
748        syn::Expr::Path(path) if path.path.segments.len() == 1 => vb_bindings
749            .get(&path.path.segments[0].ident.to_string())
750            .cloned()
751            .unwrap_or_else(empty_builder_facts),
752        syn::Expr::MethodCall(call) if call.method == "pp" => {
753            let mut facts = builder_expression_facts(&call.receiver, vb_bindings);
754            if let Some(syn::Expr::Lit(lit)) = call.args.first() {
755                if let syn::Lit::Str(text) = &lit.lit {
756                    facts.prefix_chain.insert(0, text.value());
757                }
758            }
759            facts
760        }
761        syn::Expr::Call(call) => {
762            let leaf = call_leaf_name(&call.func);
763            match leaf.as_deref() {
764                Some("from_varmap") => BuilderExpressionFacts {
765                    prefix_chain: Vec::new(),
766                    varmap: call.args.first().and_then(expr_identifier).or_else(|| {
767                        call.args.first().and_then(|arg| match arg {
768                            syn::Expr::Reference(reference) => expr_identifier(&reference.expr),
769                            _ => None,
770                        })
771                    }),
772                    source_kind: BuilderSourceKind::VarMap,
773                    role: BuilderRole::Trainable,
774                },
775                Some("from_mmaped_safetensors") | Some("from_buffered_safetensors") => {
776                    BuilderExpressionFacts {
777                        prefix_chain: Vec::new(),
778                        varmap: None,
779                        source_kind: if leaf.as_deref() == Some("from_mmaped_safetensors") {
780                            BuilderSourceKind::MmapSafetensors
781                        } else {
782                            BuilderSourceKind::BufferedSafetensors
783                        },
784                        role: BuilderRole::Frozen,
785                    }
786                }
787                Some("from_tensors") => BuilderExpressionFacts {
788                    prefix_chain: Vec::new(),
789                    varmap: None,
790                    source_kind: BuilderSourceKind::FromTensors,
791                    role: BuilderRole::Frozen,
792                },
793                _ => BuilderExpressionFacts {
794                    prefix_chain: Vec::new(),
795                    varmap: varmap_sources(expression, &HashMap::new())
796                        .into_iter()
797                        .next(),
798                    source_kind: BuilderSourceKind::Unknown,
799                    role: BuilderRole::Unknown,
800                },
801            }
802        }
803        syn::Expr::Reference(reference) => builder_expression_facts(&reference.expr, vb_bindings),
804        syn::Expr::Try(value) => builder_expression_facts(&value.expr, vb_bindings),
805        syn::Expr::Await(value) => builder_expression_facts(&value.base, vb_bindings),
806        syn::Expr::Unsafe(value) => value
807            .block
808            .stmts
809            .iter()
810            .find_map(|statement| match statement {
811                syn::Stmt::Expr(expression, _) => {
812                    Some(builder_expression_facts(expression, vb_bindings))
813                }
814                _ => None,
815            })
816            .unwrap_or_else(empty_builder_facts),
817        syn::Expr::Block(block) => block
818            .block
819            .stmts
820            .iter()
821            .find_map(|statement| match statement {
822                syn::Stmt::Expr(expression, _) => {
823                    Some(builder_expression_facts(expression, vb_bindings))
824                }
825                _ => None,
826            })
827            .unwrap_or_else(empty_builder_facts),
828        syn::Expr::Paren(paren) => builder_expression_facts(&paren.expr, vb_bindings),
829        syn::Expr::Group(group) => builder_expression_facts(&group.expr, vb_bindings),
830        _ => empty_builder_facts(),
831    }
832}
833
834fn call_leaf_name(expression: &syn::Expr) -> Option<String> {
835    match expression {
836        syn::Expr::Path(path) => path
837            .path
838            .segments
839            .last()
840            .map(|segment| segment.ident.to_string()),
841        _ => None,
842    }
843}
844
845fn load_varmap_target(expression: &syn::Expr) -> Option<String> {
846    match expression {
847        syn::Expr::Reference(reference) => expr_identifier(&reference.expr),
848        _ => expr_identifier(expression),
849    }
850}
851
852#[derive(Clone)]
853struct SubprocessInvocation {
854    wrapper_function: StableId,
855    subprocess_key: String,
856    cli_flags: Vec<String>,
857    launcher: String,
858    source: String,
859}
860
861fn discover_subprocess_pipeline(
862    krate: &Crate,
863    functions: &HashMap<String, StableId>,
864    model: &mut ModelIr,
865) {
866    let launchers = subprocess_launcher_functions(krate);
867    if launchers.is_empty() {
868        return;
869    }
870
871    let function_by_id: HashMap<StableId, Function> = model
872        .functions
873        .iter()
874        .map(|function| (function.id.clone(), function.clone()))
875        .collect();
876    let by_bare: HashMap<String, Vec<StableId>> =
877        model
878            .functions
879            .iter()
880            .fold(HashMap::new(), |mut grouped, function| {
881                grouped
882                    .entry(function.name.clone())
883                    .or_default()
884                    .push(function.id.clone());
885                grouped
886            });
887
888    let mut invocations = Vec::new();
889    for func in krate.all_functions().chain(krate.all_methods()) {
890        if !is_production_source(krate, func) {
891            continue;
892        }
893        let wrapper_id = function_id(func);
894        let mut collector = SubprocessInvocationCollector {
895            launchers: &launchers,
896            wrapper_id,
897            source: krate.file_label(func.span),
898            invocations: Vec::new(),
899        };
900        collector.visit_block(&func.block);
901        invocations.extend(collector.invocations);
902    }
903
904    let orchestrator = discover_orchestrator_order(krate, functions, model, &by_bare);
905    if orchestrator.is_empty() && invocations.is_empty() {
906        return;
907    }
908
909    let mut seen_functions = model
910        .stages
911        .iter()
912        .map(|stage| stage.function.clone())
913        .collect::<HashSet<_>>();
914    let order_base = model.stages.len();
915
916    for (order, (function_id, orchestrator_name)) in orchestrator.into_iter().enumerate() {
917        if seen_functions.contains(&function_id) {
918            continue;
919        }
920        let Some(function) = function_by_id.get(&function_id) else {
921            continue;
922        };
923        let invocation = invocations
924            .iter()
925            .find(|item| item.wrapper_function == function_id);
926        push_subprocess_stage(
927            model,
928            function,
929            invocation,
930            order_base + order,
931            Some(orchestrator_name),
932        );
933        seen_functions.insert(function_id);
934    }
935
936    for invocation in invocations {
937        if seen_functions.contains(&invocation.wrapper_function) {
938            if let Some(stage) = model
939                .stages
940                .iter_mut()
941                .find(|stage| stage.function == invocation.wrapper_function)
942            {
943                stage.dispatch = StageDispatchKind::Subprocess;
944                if stage.subprocess_key.is_none() {
945                    stage.subprocess_key = Some(invocation.subprocess_key.clone());
946                }
947                stage.name = stage
948                    .subprocess_key
949                    .clone()
950                    .unwrap_or_else(|| stage.name.clone());
951                stage.cli_flags.extend(invocation.cli_flags.iter().cloned());
952                stage.cli_flags.sort();
953                stage.cli_flags.dedup();
954                stage.launcher = Some(invocation.launcher.clone());
955                stage.evidence.push(Evidence {
956                    kind: EvidenceKind::Source,
957                    confidence: Confidence::Heuristic,
958                    source: Some(invocation.source.clone()),
959                    detail: format!(
960                        "subprocess relaunch via `{}` with stage key `{}`",
961                        invocation.launcher, invocation.subprocess_key
962                    ),
963                });
964            }
965            continue;
966        }
967        let Some(function) = function_by_id.get(&invocation.wrapper_function) else {
968            continue;
969        };
970        push_subprocess_stage(
971            model,
972            function,
973            Some(&invocation),
974            order_base + seen_functions.len(),
975            None,
976        );
977        seen_functions.insert(invocation.wrapper_function.clone());
978    }
979}
980
981fn push_subprocess_stage(
982    model: &mut ModelIr,
983    function: &Function,
984    invocation: Option<&SubprocessInvocation>,
985    order: usize,
986    orchestrator: Option<String>,
987) {
988    let name = invocation
989        .map(|item| item.subprocess_key.clone())
990        .unwrap_or_else(|| stage_display_name(function));
991    let id = StableId::new("subprocess-stage", [&function.qualified_name, &name]);
992    let mut evidence = vec![Evidence {
993        kind: EvidenceKind::Source,
994        confidence: Confidence::Heuristic,
995        source: Some(function.source.clone()),
996        detail: if let Some(orchestrator) = orchestrator.as_deref() {
997            format!(
998                "orchestrator `{orchestrator}` calls `{}` in source order",
999                function.name
1000            )
1001        } else {
1002            format!(
1003                "wrapper `{}` relaunches the current executable",
1004                function.name
1005            )
1006        },
1007    }];
1008    if let Some(item) = invocation {
1009        evidence.push(Evidence {
1010            kind: EvidenceKind::Source,
1011            confidence: Confidence::Heuristic,
1012            source: Some(item.source.clone()),
1013            detail: format!(
1014                "subprocess relaunch via `{}` with stage key `{}`",
1015                item.launcher, item.subprocess_key
1016            ),
1017        });
1018    }
1019    model.stages.push(PipelineStage {
1020        id,
1021        name,
1022        kind: stage_kind(&function.name),
1023        function: function.id.clone(),
1024        order: Some(order),
1025        components: reachable_components(function, model),
1026        consumes: Vec::new(),
1027        produces: Vec::new(),
1028        depends_on: Vec::new(),
1029        source: function.source.clone(),
1030        evidence,
1031        dispatch: if invocation.is_some() {
1032            StageDispatchKind::Subprocess
1033        } else {
1034            StageDispatchKind::Inline
1035        },
1036        subprocess_key: invocation.map(|item| item.subprocess_key.clone()),
1037        cli_flags: invocation
1038            .map(|item| item.cli_flags.clone())
1039            .unwrap_or_default(),
1040        launcher: invocation.map(|item| item.launcher.clone()),
1041        orchestrator,
1042    });
1043}
1044
1045fn discover_orchestrator_order(
1046    krate: &Crate,
1047    functions: &HashMap<String, StableId>,
1048    model: &ModelIr,
1049    by_bare: &HashMap<String, Vec<StableId>>,
1050) -> Vec<(StableId, String)> {
1051    let mut best = Vec::new();
1052    for pipeline in krate
1053        .all_functions()
1054        .filter(|function| function.fn_name == "run_pipeline")
1055    {
1056        let mut collector = OrderedCallCollector::default();
1057        collector.visit_block(&pipeline.block);
1058        let orchestrator_name = pipeline.qualified_name.clone();
1059        let mut ordered = Vec::new();
1060        for call in collector.calls {
1061            let Some(callee) = resolve_call(&call, pipeline, functions, by_bare) else {
1062                continue;
1063            };
1064            let Some(function) = model
1065                .functions
1066                .iter()
1067                .find(|function| function.id == callee)
1068            else {
1069                continue;
1070            };
1071            if is_orchestrator_stage_call(&function.name) {
1072                ordered.push((callee, orchestrator_name.clone()));
1073            }
1074        }
1075        if ordered.len() > best.len() {
1076            best = ordered;
1077        }
1078    }
1079    best
1080}
1081
1082fn subprocess_launcher_functions(krate: &Crate) -> HashSet<String> {
1083    let mut launchers = HashSet::new();
1084    launchers.insert("run_stage_command".to_string());
1085    for func in krate.all_functions().chain(krate.all_methods()) {
1086        let text = func.block.to_token_stream().to_string();
1087        if text.contains("current_exe") && text.contains("Command") {
1088            launchers.insert(func.fn_name.clone());
1089        }
1090    }
1091    launchers
1092}
1093
1094struct SubprocessInvocationCollector<'a> {
1095    launchers: &'a HashSet<String>,
1096    wrapper_id: StableId,
1097    source: String,
1098    invocations: Vec<SubprocessInvocation>,
1099}
1100
1101impl<'ast> Visit<'ast> for SubprocessInvocationCollector<'_> {
1102    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
1103        if let Some(leaf) = call_leaf_name(&node.func) {
1104            if self.launchers.contains(&leaf) {
1105                if let Some(subprocess_key) = node.args.first().and_then(string_literal_expr) {
1106                    let cli_flags = node
1107                        .args
1108                        .get(1)
1109                        .map(extract_cli_flags_from_expr)
1110                        .unwrap_or_default();
1111                    self.invocations.push(SubprocessInvocation {
1112                        wrapper_function: self.wrapper_id.clone(),
1113                        subprocess_key,
1114                        cli_flags,
1115                        launcher: leaf.clone(),
1116                        source: self.source.clone(),
1117                    });
1118                }
1119            } else if leaf == "run_training_stage_with_oom_recovery" {
1120                if let Some(subprocess_key) = node.args.get(2).and_then(string_literal_expr) {
1121                    let mut cli_flags = node
1122                        .args
1123                        .get(7)
1124                        .map(extract_cli_flags_from_expr)
1125                        .unwrap_or_default();
1126                    if cli_flags.is_empty() {
1127                        cli_flags = node
1128                            .args
1129                            .iter()
1130                            .flat_map(extract_cli_flags_from_expr)
1131                            .collect::<BTreeSet<_>>()
1132                            .into_iter()
1133                            .collect();
1134                    }
1135                    self.invocations.push(SubprocessInvocation {
1136                        wrapper_function: self.wrapper_id.clone(),
1137                        subprocess_key,
1138                        cli_flags,
1139                        launcher: "run_stage_command".to_string(),
1140                        source: self.source.clone(),
1141                    });
1142                }
1143            }
1144        }
1145        visit::visit_expr_call(self, node);
1146    }
1147}
1148
1149#[derive(Default)]
1150struct OrderedCallCollector {
1151    calls: Vec<String>,
1152}
1153
1154impl<'ast> Visit<'ast> for OrderedCallCollector {
1155    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
1156        if let syn::Expr::Path(path) = &*node.func {
1157            self.calls
1158                .push(path.path.to_token_stream().to_string().replace(' ', ""));
1159        }
1160        visit::visit_expr_call(self, node);
1161    }
1162
1163    fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
1164        self.calls.push(node.method.to_string());
1165        visit::visit_expr_method_call(self, node);
1166    }
1167}
1168
1169fn is_orchestrator_stage_call(name: &str) -> bool {
1170    is_pipeline_stage_call(name) || name.starts_with("preflight_")
1171}
1172
1173fn string_literal_expr(expression: &syn::Expr) -> Option<String> {
1174    match expression {
1175        syn::Expr::Lit(lit) => match &lit.lit {
1176            syn::Lit::Str(text) => Some(text.value()),
1177            _ => None,
1178        },
1179        syn::Expr::Reference(reference) => string_literal_expr(&reference.expr),
1180        syn::Expr::Group(group) => string_literal_expr(&group.expr),
1181        syn::Expr::Paren(paren) => string_literal_expr(&paren.expr),
1182        _ => None,
1183    }
1184}
1185
1186fn extract_cli_flags_from_expr(expression: &syn::Expr) -> Vec<String> {
1187    match expression {
1188        syn::Expr::Closure(closure) => extract_cli_flags_from_expr(&closure.body),
1189        syn::Expr::Block(block) => {
1190            let mut flags = block
1191                .block
1192                .stmts
1193                .iter()
1194                .flat_map(|statement| match statement {
1195                    syn::Stmt::Expr(expr, _) => extract_cli_flags_from_expr(expr),
1196                    syn::Stmt::Macro(macro_stmt) => {
1197                        flags_from_tokens(macro_stmt.mac.tokens.clone())
1198                    }
1199                    _ => Vec::new(),
1200                })
1201                .collect::<Vec<_>>();
1202            flags.sort();
1203            flags.dedup();
1204            flags
1205        }
1206        syn::Expr::Macro(expr_macro) => flags_from_tokens(expr_macro.mac.tokens.clone()),
1207        _ => extract_cli_flags(expression),
1208    }
1209}
1210
1211fn flags_from_tokens(tokens: proc_macro2::TokenStream) -> Vec<String> {
1212    if let Ok(expression) = syn::parse2::<syn::Expr>(tokens.clone()) {
1213        let flags = extract_cli_flags(&expression);
1214        if !flags.is_empty() {
1215            return flags;
1216        }
1217    }
1218    let mut flags = tokens
1219        .to_string()
1220        .split(|character: char| {
1221            !(character.is_ascii_alphanumeric() || character == '-' || character == '_')
1222        })
1223        .map(|token| token.trim_matches('"'))
1224        .filter(|token| token.starts_with("--") && token.len() > 2)
1225        .map(str::to_string)
1226        .collect::<Vec<_>>();
1227    flags.sort();
1228    flags.dedup();
1229    flags
1230}
1231
1232fn extract_cli_flags(expression: &syn::Expr) -> Vec<String> {
1233    let mut flags = string_literals(expression)
1234        .into_iter()
1235        .filter(|value| value.starts_with("--") && value.len() > 2)
1236        .collect::<Vec<_>>();
1237    flags.sort();
1238    flags.dedup();
1239    flags
1240}
1241
1242struct ComponentFlowCollector<'a> {
1243    component_by_type: &'a HashMap<String, StableId>,
1244    owner_fields: HashMap<String, StableId>,
1245    locals: HashMap<String, StableId>,
1246    sequence: Vec<StableId>,
1247}
1248
1249impl<'ast> Visit<'ast> for ComponentFlowCollector<'_> {
1250    fn visit_local(&mut self, node: &'ast syn::Local) {
1251        if let (Some(name), Some(init)) = (pat_ident(&node.pat), node.init.as_ref()) {
1252            if let Some(component) = constructor_component(&init.expr, self.component_by_type)
1253                .or_else(|| receiver_component(&init.expr, &self.locals, &self.owner_fields))
1254            {
1255                self.locals.insert(name, component);
1256            }
1257        }
1258        visit::visit_local(self, node);
1259    }
1260
1261    fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
1262        if is_model_entry_name(&node.method.to_string()) {
1263            if let Some(component) =
1264                receiver_component(&node.receiver, &self.locals, &self.owner_fields)
1265            {
1266                if self.sequence.last() != Some(&component) {
1267                    self.sequence.push(component);
1268                }
1269            }
1270        }
1271        visit::visit_expr_method_call(self, node);
1272    }
1273}
1274
1275fn pat_ident(pattern: &syn::Pat) -> Option<String> {
1276    match pattern {
1277        syn::Pat::Ident(ident) => Some(ident.ident.to_string()),
1278        syn::Pat::Type(typed) => pat_ident(&typed.pat),
1279        _ => None,
1280    }
1281}
1282
1283fn constructor_component(
1284    expression: &syn::Expr,
1285    components: &HashMap<String, StableId>,
1286) -> Option<StableId> {
1287    match expression {
1288        syn::Expr::Call(call) => {
1289            let syn::Expr::Path(path) = &*call.func else {
1290                return None;
1291            };
1292            let segments: Vec<_> = path
1293                .path
1294                .segments
1295                .iter()
1296                .map(|segment| segment.ident.to_string())
1297                .collect();
1298            let owner = segments
1299                .get(segments.len().checked_sub(2)?)
1300                .map(String::as_str)?;
1301            components.get(owner).cloned()
1302        }
1303        syn::Expr::Try(value) => constructor_component(&value.expr, components),
1304        syn::Expr::Await(value) => constructor_component(&value.base, components),
1305        syn::Expr::Paren(value) => constructor_component(&value.expr, components),
1306        syn::Expr::Group(value) => constructor_component(&value.expr, components),
1307        _ => None,
1308    }
1309}
1310
1311fn receiver_component(
1312    expression: &syn::Expr,
1313    locals: &HashMap<String, StableId>,
1314    owner_fields: &HashMap<String, StableId>,
1315) -> Option<StableId> {
1316    match expression {
1317        syn::Expr::Path(path) if path.path.segments.len() == 1 => locals
1318            .get(&path.path.segments[0].ident.to_string())
1319            .cloned(),
1320        syn::Expr::Field(field)
1321            if matches!(
1322                &*field.base,
1323                syn::Expr::Path(path)
1324                    if path.path.segments.len() == 1 && path.path.segments[0].ident == "self"
1325            ) =>
1326        {
1327            let syn::Member::Named(name) = &field.member else {
1328                return None;
1329            };
1330            owner_fields.get(&name.to_string()).cloned()
1331        }
1332        syn::Expr::Reference(reference) => {
1333            receiver_component(&reference.expr, locals, owner_fields)
1334        }
1335        syn::Expr::Try(value) => receiver_component(&value.expr, locals, owner_fields),
1336        syn::Expr::Await(value) => receiver_component(&value.base, locals, owner_fields),
1337        syn::Expr::MethodCall(call) => receiver_component(&call.receiver, locals, owner_fields),
1338        syn::Expr::Paren(paren) => receiver_component(&paren.expr, locals, owner_fields),
1339        syn::Expr::Group(group) => receiver_component(&group.expr, locals, owner_fields),
1340        _ => None,
1341    }
1342}
1343
1344fn type_base_from_text(text: &str) -> Option<String> {
1345    syn::parse_str::<syn::Type>(text)
1346        .ok()
1347        .and_then(|type_name| load::type_base_name(&type_name))
1348}
1349
1350fn discover_components(
1351    krate: &Crate,
1352    cargo: Option<&CargoContext>,
1353    selected_root: Option<&str>,
1354    heuristic_architecture: bool,
1355    model: &mut ModelIr,
1356) {
1357    let exported: HashSet<String> = krate
1358        .public_reexports
1359        .iter()
1360        .map(|item| item.name.clone())
1361        .collect();
1362    let mut candidates = Vec::new();
1363    for def in krate.all_structs() {
1364        if let Some(selected) = selected_root {
1365            if def.name != selected && def.qualified_name != selected {
1366                continue;
1367            }
1368        }
1369        if cargo.and_then(|context| {
1370            crate::cargo_context::cfg_predicates_active(&def.cfg_predicates, &context.cfgs)
1371        }) == Some(false)
1372        {
1373            continue;
1374        }
1375        let methods = krate.method_candidates(&def.qualified_name, "new");
1376        let mut constructors = if methods.is_empty() {
1377            krate.method_candidates(&def.qualified_name, "load")
1378        } else {
1379            methods
1380        };
1381        if constructors.is_empty() {
1382            constructors = krate
1383                .all_methods()
1384                .filter(|method| {
1385                    method.qualified_type_name == def.qualified_name
1386                        && method.trait_name.is_none()
1387                        && !method.vb_params.is_empty()
1388                        && return_mentions_type(&method.return_type, &def.name)
1389                })
1390                .collect();
1391            constructors.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
1392        }
1393        let Some(ctor) = constructors.into_iter().find(|method| {
1394            !method.vb_params.is_empty()
1395                && cargo.and_then(|context| {
1396                    crate::cargo_context::cfg_predicates_active(
1397                        &method.cfg_predicates,
1398                        &context.cfgs,
1399                    )
1400                }) != Some(false)
1401        }) else {
1402            continue;
1403        };
1404        let is_api_boundary =
1405            exported.contains(&def.name) || def.module_path.is_empty() && def.visibility == "pub";
1406        // Any public type with a VarBuilder constructor is a model component candidate
1407        // (e.g. `my_model::Model`), not only crate-root re-exports.
1408        let is_varbuilder_component =
1409            def.visibility == "pub" && !ctor.vb_params.is_empty();
1410        if selected_root.is_some() || is_api_boundary || is_varbuilder_component {
1411            candidates.push((def, ctor));
1412        }
1413    }
1414    candidates.sort_by(|(a, _), (b, _)| a.qualified_name.cmp(&b.qualified_name));
1415    candidates.dedup_by(|(a, _), (b, _)| a.qualified_name == b.qualified_name);
1416
1417    for (def, ctor) in candidates {
1418        if selected_root.is_some() {
1419            for function in &mut model.functions {
1420                if function.owner_type.as_deref() == Some(def.qualified_name.as_str())
1421                    && (function
1422                        .parameters
1423                        .iter()
1424                        .any(|parameter| is_tensor_type(&parameter.type_name))
1425                        || function.return_type.as_deref().is_some_and(is_tensor_type))
1426                {
1427                    function.is_entrypoint = true;
1428                }
1429            }
1430        }
1431        let candle_version = cargo.and_then(|context| {
1432            op_semantics::matched_candle_version(
1433                context
1434                    .candle_versions
1435                    .get("candle-core")
1436                    .map(String::as_str),
1437                context.candle_versions.get("candle-nn").map(String::as_str),
1438            )
1439        });
1440        add_component(
1441            krate,
1442            model,
1443            def,
1444            ctor,
1445            candle_version,
1446            heuristic_architecture,
1447        );
1448    }
1449}
1450
1451fn return_mentions_type(return_type: &str, type_name: &str) -> bool {
1452    return_type
1453        .split(|character: char| !character.is_alphanumeric() && character != '_')
1454        .any(|part| part == "Self" || part == type_name)
1455}
1456
1457fn add_component(
1458    krate: &Crate,
1459    model: &mut ModelIr,
1460    def: &StructDef,
1461    ctor: &ImplFn,
1462    candle_version: Option<&str>,
1463    heuristic_architecture: bool,
1464) {
1465    let component_id = StableId::new("component", [&def.qualified_name]);
1466    let constructor_id = function_id(ctor);
1467    let mut component = Component {
1468        id: component_id.clone(),
1469        name: def.name.clone(),
1470        qualified_name: def.qualified_name.clone(),
1471        source: krate.file_label(def.span),
1472        constructor: constructor_id,
1473        builders: ctor
1474            .vb_params
1475            .iter()
1476            .filter_map(|index| ctor.params.get(*index))
1477            .map(|name| {
1478                let (role, confidence) = if heuristic_architecture {
1479                    infer_builder_role(name)
1480                } else {
1481                    (BuilderRole::Unknown, Confidence::Unknown)
1482                };
1483                let mut evidence = vec![source_evidence(
1484                    krate.file_label(ctor.span),
1485                    format!("constructor parameter `{name}` has VarBuilder type"),
1486                )];
1487                if role != BuilderRole::Unknown {
1488                    evidence.push(Evidence {
1489                        kind: EvidenceKind::Inferred,
1490                        confidence,
1491                        source: Some(krate.file_label(ctor.span)),
1492                        detail: format!("builder name `{name}` suggests role `{role:?}`"),
1493                    });
1494                }
1495                BuilderNamespace {
1496                    name: name.clone(),
1497                    role,
1498                    evidence,
1499                }
1500            })
1501            .collect(),
1502        modules: Vec::new(),
1503        parameters: Vec::new(),
1504        entrypoints: model
1505            .functions
1506            .iter()
1507            .filter(|function| {
1508                function
1509                    .owner_type
1510                    .as_deref()
1511                    .is_some_and(|owner| owner == def.qualified_name)
1512                    && function.is_entrypoint
1513            })
1514            .map(|function| function.id.clone())
1515            .collect(),
1516        evidence: vec![source_evidence(
1517            krate.file_label(def.span),
1518            "public model API type with a VarBuilder constructor",
1519        )],
1520    };
1521
1522    match Extractor::for_candle_version(krate, candle_version)
1523        .run(&def.qualified_name, Some(&ctor.fn_name))
1524    {
1525        Ok(structure) => {
1526            let mut module_ids = HashMap::new();
1527            for instance in &structure.instances {
1528                let module_def = structure.def(instance.def);
1529                let id = StableId::new(
1530                    "module",
1531                    [
1532                        component_id.0.as_str(),
1533                        instance.root.as_str(),
1534                        instance.prefix.to_string().as_str(),
1535                        module_def.name.as_str(),
1536                        instance.id.0.to_string().as_str(),
1537                    ],
1538                );
1539                module_ids.insert(instance.id, id.clone());
1540                component.modules.push(id.clone());
1541                model.modules.push(Module {
1542                    id,
1543                    component: component_id.clone(),
1544                    parent: instance
1545                        .parent
1546                        .and_then(|parent| module_ids.get(&parent).cloned()),
1547                    type_name: module_def.name.clone(),
1548                    qualified_type: unique_qualified_struct(krate, &module_def.name),
1549                    field: instance.via_field.clone(),
1550                    builder_root: instance.root.clone(),
1551                    prefix: instance.prefix.to_string(),
1552                    repeat: instance
1553                        .repeat
1554                        .as_ref()
1555                        .map(|repeat| format!("{} in 0..{}", repeat.var, repeat.bound)),
1556                    source: krate.file_label(instance.origin),
1557                    confidence: certainty_confidence(&instance.certainty),
1558                });
1559            }
1560            for param in &structure.params {
1561                let site = structure.site(param.site);
1562                let Some(module) = module_ids.get(&param.owner).cloned() else {
1563                    continue;
1564                };
1565                let key = param.key.to_string();
1566                let id = StableId::new(
1567                    "parameter",
1568                    [component_id.0.as_str(), param.root.as_str(), key.as_str()],
1569                );
1570                component.parameters.push(id.clone());
1571                let (checkpoint_shape, checkpoint_dtype) = match &param.checkpoint {
1572                    CheckpointMatch::Found { shape, dtype, .. } => {
1573                        (Some(shape.clone()), Some(dtype.clone()))
1574                    }
1575                    _ => (None, None),
1576                };
1577                model.parameters.push(Parameter {
1578                    id,
1579                    component: component_id.clone(),
1580                    module,
1581                    key,
1582                    builder_root: param.root.clone(),
1583                    role: match site.kind {
1584                        crate::known::ParamKind::RunningMean
1585                        | crate::known::ParamKind::RunningVar => ParameterRole::RunningState,
1586                        _ => ParameterRole::Unknown,
1587                    },
1588                    kind: acquisition_label(&site.acquisition),
1589                    symbolic_shape: site.shape.clone(),
1590                    checkpoint_shape,
1591                    checkpoint_dtype,
1592                    source: krate.file_label(site.span),
1593                    uses: Vec::new(),
1594                    optimizer_memberships: Vec::new(),
1595                    evidence: vec![Evidence {
1596                        kind: EvidenceKind::Source,
1597                        confidence: certainty_confidence(&param.certainty),
1598                        source: Some(krate.file_label(site.span)),
1599                        detail: format!(
1600                            "parameter registered through {}",
1601                            acquisition_label(&site.acquisition)
1602                        ),
1603                    }],
1604                });
1605            }
1606            for diagnostic in structure.diagnostics {
1607                push_finding(
1608                    model,
1609                    "structure-unresolved",
1610                    FindingSeverity::Warning,
1611                    Confidence::Proven,
1612                    diagnostic.message,
1613                    Some(krate.file_label(diagnostic.span)),
1614                    vec![component_id.clone()],
1615                );
1616            }
1617        }
1618        Err(error) => push_finding(
1619            model,
1620            "component-expansion",
1621            FindingSeverity::Warning,
1622            Confidence::Proven,
1623            format!("could not expand {}: {error:#}", def.qualified_name),
1624            Some(krate.file_label(ctor.span)),
1625            vec![component_id.clone()],
1626        ),
1627    }
1628    model.components.push(component);
1629}
1630
1631fn add_contracts(krate: &Crate, model: &mut ModelIr) {
1632    let analysis = crate::contracts::analyze(krate);
1633    let owners: HashMap<String, StableId> = model
1634        .functions
1635        .iter()
1636        .map(|function| (function.qualified_name.clone(), function.id.clone()))
1637        .collect();
1638    let function_indices: HashMap<StableId, usize> = model
1639        .functions
1640        .iter()
1641        .enumerate()
1642        .map(|(index, function)| (function.id.clone(), index))
1643        .collect();
1644
1645    for contracts in analysis.functions {
1646        let Some(owner) = owners.get(&contracts.qualified_name).cloned() else {
1647            continue;
1648        };
1649        for mut tensor in contracts.tensors {
1650            tensor.owner_function = owner.clone();
1651            tensor.id = StableId::new("tensor", [owner.0.as_str(), tensor.name.as_str()]);
1652            if tensor.dtype.eq_ignore_ascii_case("unknown") {
1653                tensor.dtype = "Unknown".to_string();
1654            }
1655            let id = tensor.id.clone();
1656            let role = tensor.role.clone();
1657            if let Some(&index) = function_indices.get(&owner) {
1658                if matches!(role, TensorRole::Input) {
1659                    model.functions[index].tensor_inputs.push(id.clone());
1660                }
1661                if matches!(role, TensorRole::Output | TensorRole::Loss) {
1662                    model.functions[index].tensor_outputs.push(id.clone());
1663                }
1664            }
1665            model.tensors.push(tensor);
1666        }
1667    }
1668}
1669
1670fn discover_pipeline_and_artifacts(
1671    krate: &Crate,
1672    functions: &HashMap<String, StableId>,
1673    model: &mut ModelIr,
1674) {
1675    let function_by_id: HashMap<StableId, &Function> = model
1676        .functions
1677        .iter()
1678        .map(|function| (function.id.clone(), function))
1679        .collect();
1680    let by_bare: HashMap<String, Vec<StableId>> =
1681        model
1682            .functions
1683            .iter()
1684            .fold(HashMap::new(), |mut grouped, function| {
1685                grouped
1686                    .entry(function.name.clone())
1687                    .or_default()
1688                    .push(function.id.clone());
1689                grouped
1690            });
1691    let mut ordered_stage_functions: Vec<(StableId, Option<String>)> = Vec::new();
1692    for pipeline in krate
1693        .all_functions()
1694        .filter(|function| function.fn_name == "run_pipeline")
1695    {
1696        let mut collector = CallCollector::default();
1697        collector.visit_block(&pipeline.block);
1698        for call in collector.calls {
1699            let Some(callee) = resolve_call(&call, pipeline, functions, &by_bare) else {
1700                continue;
1701            };
1702            let Some(target) = function_by_id.get(&callee) else {
1703                continue;
1704            };
1705            if !is_pipeline_stage_call(&target.name) {
1706                continue;
1707            }
1708            let variants = stage_variants(krate, &callee);
1709            if variants.is_empty() {
1710                ordered_stage_functions.push((callee, None));
1711            } else {
1712                ordered_stage_functions.extend(
1713                    variants
1714                        .into_iter()
1715                        .map(|variant| (callee.clone(), Some(variant))),
1716                );
1717            }
1718        }
1719    }
1720    if ordered_stage_functions.is_empty() {
1721        ordered_stage_functions.extend(
1722            model
1723                .functions
1724                .iter()
1725                .filter(|function| is_stage_entry_name(&function.name))
1726                .map(|function| (function.id.clone(), None)),
1727        );
1728    }
1729    ordered_stage_functions.dedup();
1730
1731    let mut stage_by_function = HashMap::new();
1732    for (order, (function_id, variant)) in ordered_stage_functions.into_iter().enumerate() {
1733        let Some(function) = function_by_id.get(&function_id) else {
1734            continue;
1735        };
1736        let name = variant.unwrap_or_else(|| stage_display_name(function));
1737        let id = StableId::new("stage", [&function.qualified_name, &name]);
1738        stage_by_function
1739            .entry(function_id.clone())
1740            .or_insert_with(|| id.clone());
1741        let related_components = reachable_components(function, model);
1742        model.stages.push(PipelineStage {
1743            id,
1744            name,
1745            kind: stage_kind(&function.name),
1746            function: function_id,
1747            order: Some(order),
1748            components: related_components,
1749            consumes: Vec::new(),
1750            produces: Vec::new(),
1751            depends_on: Vec::new(),
1752            source: function.source.clone(),
1753            evidence: vec![heuristic_source_evidence(
1754                function.source.clone(),
1755                "stage entrypoint discovered from pipeline call order",
1756            )],
1757            dispatch: StageDispatchKind::Unknown,
1758            subprocess_key: None,
1759            cli_flags: Vec::new(),
1760            launcher: None,
1761            orchestrator: None,
1762        });
1763    }
1764
1765    let mut facts = Vec::new();
1766    for func in krate.all_functions().chain(krate.all_methods()) {
1767        if !is_production_source(krate, func) {
1768            continue;
1769        }
1770        let mut visitor = ArtifactVisitor::default();
1771        visitor.visit_block(&func.block);
1772        let owner = function_id(func);
1773        for observed in visitor.observed {
1774            facts.push((owner.clone(), krate.file_label(func.span), observed));
1775        }
1776    }
1777    facts.sort_by(|a, b| a.2.path_expr.cmp(&b.2.path_expr));
1778
1779    let mut artifact_index: HashMap<String, usize> = HashMap::new();
1780    for (function_id, source, observed) in facts {
1781        let identity = artifact_identity(&observed.path_expr);
1782        let index = match artifact_index.get(&identity).copied() {
1783            Some(index) => index,
1784            None => {
1785                let id = StableId::new("artifact", [&identity]);
1786                let index = model.artifacts.len();
1787                model.artifacts.push(Artifact {
1788                    id,
1789                    name: observed.label.clone(),
1790                    kind: artifact_kind(&observed.label),
1791                    path_expr: observed.path_expr.clone(),
1792                    produced_by: Vec::new(),
1793                    consumed_by: Vec::new(),
1794                    source: source.clone(),
1795                    evidence: vec![heuristic_source_evidence(
1796                        source.clone(),
1797                        format!("path passed to `{}`", observed.operation),
1798                    )],
1799                });
1800                artifact_index.insert(identity, index);
1801                index
1802            }
1803        };
1804        let stage = stage_for_function(&function_id, &stage_by_function, model);
1805        if let Some(stage_id) = stage {
1806            let artifact = &mut model.artifacts[index];
1807            if observed.produced {
1808                artifact.produced_by.push(stage_id.clone());
1809            } else {
1810                artifact.consumed_by.push(stage_id.clone());
1811            }
1812        }
1813    }
1814
1815    for artifact in &mut model.artifacts {
1816        artifact.produced_by.sort();
1817        artifact.produced_by.dedup();
1818        artifact.consumed_by.sort();
1819        artifact.consumed_by.dedup();
1820    }
1821    infer_artifact_stage_links(model);
1822    let stage_orders: HashMap<StableId, usize> = model
1823        .stages
1824        .iter()
1825        .map(|stage| (stage.id.clone(), stage.order.unwrap_or(usize::MAX)))
1826        .collect();
1827    for stage in &mut model.stages {
1828        let stage_order = stage.order.unwrap_or(usize::MAX);
1829        for artifact in &model.artifacts {
1830            if artifact.produced_by.contains(&stage.id) {
1831                stage.produces.push(artifact.id.clone());
1832            }
1833            if artifact.consumed_by.contains(&stage.id) {
1834                stage.consumes.push(artifact.id.clone());
1835                stage.depends_on.extend(
1836                    artifact
1837                        .produced_by
1838                        .iter()
1839                        .filter(|producer| {
1840                            **producer != stage.id
1841                                && stage_orders
1842                                    .get(*producer)
1843                                    .is_some_and(|producer_order| *producer_order < stage_order)
1844                        })
1845                        .cloned(),
1846                );
1847            }
1848        }
1849        stage.consumes.sort();
1850        stage.consumes.dedup();
1851        stage.produces.sort();
1852        stage.produces.dedup();
1853        stage.depends_on.sort();
1854        stage.depends_on.dedup();
1855    }
1856
1857    let _ = functions;
1858}
1859
1860fn is_production_source(krate: &Crate, function: &ImplFn) -> bool {
1861    !function
1862        .module_path
1863        .split("::")
1864        .any(|segment| segment == "tests" || segment == "test")
1865        && krate.files.get(function.span.file).is_some_and(|file| {
1866            let rel = file.rel.replace('\\', "/");
1867            rel.starts_with("src/") || !rel.contains('/')
1868        })
1869}
1870
1871#[derive(Clone)]
1872struct ConstructorSpec {
1873    component: StableId,
1874    owner: String,
1875    builders: Vec<(usize, String)>,
1876}
1877
1878fn component_varmap_bindings(
1879    _krate: &Crate,
1880    function: &ImplFn,
1881    model: &ModelIr,
1882) -> HashMap<String, Vec<(StableId, String)>> {
1883    let constructor_functions: HashMap<StableId, &Function> = model
1884        .functions
1885        .iter()
1886        .map(|candidate| (candidate.id.clone(), candidate))
1887        .collect();
1888    let specs: Vec<ConstructorSpec> = model
1889        .components
1890        .iter()
1891        .filter_map(|component| {
1892            let constructor = constructor_functions.get(&component.constructor)?;
1893            let builders = constructor
1894                .parameters
1895                .iter()
1896                .enumerate()
1897                .filter(|(_, parameter)| parameter.type_name.contains("VarBuilder"))
1898                .zip(component.builders.iter())
1899                .map(|((index, _), builder)| (index, builder.name.clone()))
1900                .collect();
1901            Some(ConstructorSpec {
1902                component: component.id.clone(),
1903                owner: component.name.clone(),
1904                builders,
1905            })
1906        })
1907        .collect();
1908
1909    let mut builder_collector = BuilderSourceCollector::default();
1910    builder_collector.visit_block(&function.block);
1911    let mut collector = ConstructorBindingCollector {
1912        specs: &specs,
1913        builder_sources: &builder_collector.sources,
1914        bindings: HashMap::new(),
1915    };
1916    collector.visit_block(&function.block);
1917    for bindings in collector.bindings.values_mut() {
1918        bindings.sort();
1919        bindings.dedup();
1920    }
1921    collector.bindings
1922}
1923
1924#[derive(Default)]
1925struct BuilderSourceCollector {
1926    sources: HashMap<String, BTreeSet<String>>,
1927}
1928
1929impl<'ast> Visit<'ast> for BuilderSourceCollector {
1930    fn visit_local(&mut self, node: &'ast syn::Local) {
1931        if let (Some(name), Some(init)) = (pat_ident(&node.pat), node.init.as_ref()) {
1932            let varmaps = varmap_sources(&init.expr, &self.sources);
1933            if !varmaps.is_empty() {
1934                self.sources.entry(name).or_default().extend(varmaps);
1935            }
1936        }
1937        visit::visit_local(self, node);
1938    }
1939}
1940
1941struct ConstructorBindingCollector<'a> {
1942    specs: &'a [ConstructorSpec],
1943    builder_sources: &'a HashMap<String, BTreeSet<String>>,
1944    bindings: HashMap<String, Vec<(StableId, String)>>,
1945}
1946
1947impl<'ast> Visit<'ast> for ConstructorBindingCollector<'_> {
1948    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
1949        if let syn::Expr::Path(path) = &*node.func {
1950            let segments: Vec<_> = path
1951                .path
1952                .segments
1953                .iter()
1954                .map(|segment| segment.ident.to_string())
1955                .collect();
1956            if let Some(owner) = segments.get(segments.len().saturating_sub(2)) {
1957                for spec in self.specs.iter().filter(|spec| &spec.owner == owner) {
1958                    for (index, root) in &spec.builders {
1959                        let Some(argument) = node.args.iter().nth(*index) else {
1960                            continue;
1961                        };
1962                        for varmap in varmap_sources(argument, self.builder_sources) {
1963                            self.bindings
1964                                .entry(varmap)
1965                                .or_default()
1966                                .push((spec.component.clone(), root.clone()));
1967                        }
1968                    }
1969                }
1970            }
1971        }
1972        visit::visit_expr_call(self, node);
1973    }
1974}
1975
1976fn varmap_sources(
1977    expression: &syn::Expr,
1978    builder_sources: &HashMap<String, BTreeSet<String>>,
1979) -> BTreeSet<String> {
1980    match expression {
1981        syn::Expr::Path(path) if path.path.segments.len() == 1 => {
1982            let name = path.path.segments[0].ident.to_string();
1983            builder_sources.get(&name).cloned().unwrap_or_default()
1984        }
1985        syn::Expr::Call(call) => {
1986            let is_from_varmap = matches!(
1987                &*call.func,
1988                syn::Expr::Path(path)
1989                    if path.path.segments.last().is_some_and(|segment| segment.ident == "from_varmap")
1990            );
1991            if is_from_varmap {
1992                call.args
1993                    .first()
1994                    .and_then(expr_identifier)
1995                    .into_iter()
1996                    .collect()
1997            } else {
1998                BTreeSet::new()
1999            }
2000        }
2001        syn::Expr::MethodCall(call) => varmap_sources(&call.receiver, builder_sources),
2002        syn::Expr::Reference(reference) => varmap_sources(&reference.expr, builder_sources),
2003        syn::Expr::Try(value) => varmap_sources(&value.expr, builder_sources),
2004        syn::Expr::Paren(paren) => varmap_sources(&paren.expr, builder_sources),
2005        syn::Expr::Group(group) => varmap_sources(&group.expr, builder_sources),
2006        _ => BTreeSet::new(),
2007    }
2008}
2009
2010fn discover_optimizers(krate: &Crate, model: &mut ModelIr) {
2011    let stage_functions: Vec<(StableId, StableId, String)> = model
2012        .stages
2013        .iter()
2014        .map(|stage| {
2015            (
2016                stage.id.clone(),
2017                stage.function.clone(),
2018                stage.source.clone(),
2019            )
2020        })
2021        .collect();
2022    for func in krate.all_functions().chain(krate.all_methods()) {
2023        if !is_production_source(krate, func) {
2024            continue;
2025        }
2026        let text = func.block.to_token_stream().to_string();
2027        if !text.contains("all_vars") && !text.contains("named_train_vars") {
2028            continue;
2029        }
2030        let mut visitor = OptimizerVisitor::default();
2031        visitor.visit_block(&func.block);
2032        visitor.excludes.sort();
2033        visitor.excludes.dedup();
2034        visitor.includes.sort();
2035        visitor.includes.dedup();
2036        if visitor.optimizer.is_none() {
2037            continue;
2038        }
2039        let component_bindings = component_varmap_bindings(krate, func, model);
2040        let function_id = function_id(func);
2041        let exact_stage = stage_functions
2042            .iter()
2043            .find(|(_, stage_function, _)| stage_function == &function_id)
2044            .map(|(stage, _, _)| stage.clone())
2045            .or_else(|| {
2046                stage_functions
2047                    .iter()
2048                    .find(|(_, _, source)| {
2049                        source.split(':').next()
2050                            == Some(
2051                                krate
2052                                    .file_label(func.span)
2053                                    .split(':')
2054                                    .next()
2055                                    .unwrap_or_default(),
2056                            )
2057                    })
2058                    .map(|(stage, _, _)| stage.clone())
2059            });
2060        let mut stages: Vec<StableId> = exact_stage.into_iter().collect();
2061        if stages.is_empty() {
2062            stages.push(StableId::new("stage", [&func.qualified_name]));
2063        }
2064
2065        for varmap in visitor.varmaps {
2066            let bindings = component_bindings.get(&varmap).cloned().unwrap_or_default();
2067            let components: Vec<StableId> = bindings
2068                .iter()
2069                .map(|(component, _)| component.clone())
2070                .collect();
2071            let components = expand_nested_components(model, components);
2072            let roots: Vec<String> = bindings.iter().map(|(_, root)| root.clone()).collect();
2073            for stage in &stages {
2074                let id = StableId::new(
2075                    "optimizer-membership",
2076                    [
2077                        stage.0.as_str(),
2078                        varmap.as_str(),
2079                        func.qualified_name.as_str(),
2080                    ],
2081                );
2082                model.optimizers.push(OptimizerMembership {
2083                    id,
2084                    stage: stage.clone(),
2085                    optimizer: visitor
2086                        .optimizer
2087                        .clone()
2088                        .unwrap_or_else(|| "optimizer".to_string()),
2089                    varmap: varmap.clone(),
2090                    components: components.clone(),
2091                    builder_roots: roots.clone(),
2092                    include_patterns: visitor.includes.clone(),
2093                    exclude_patterns: visitor.excludes.clone(),
2094                    conditional: (stages.len() > 1)
2095                        .then(|| "pipeline stage/configuration dependent".to_string()),
2096                    source: krate.file_label(func.span),
2097                    evidence: vec![heuristic_source_evidence(
2098                        krate.file_label(func.span),
2099                        "optimizer consumes variables returned by named_train_vars/all_vars",
2100                    )],
2101                });
2102            }
2103        }
2104    }
2105
2106    let components_by_stage = model.optimizers.iter().fold(
2107        HashMap::<StableId, Vec<StableId>>::new(),
2108        |mut map, optimizer| {
2109            map.entry(optimizer.stage.clone())
2110                .or_default()
2111                .extend(optimizer.components.iter().cloned());
2112            map
2113        },
2114    );
2115    for stage in &mut model.stages {
2116        if let Some(components) = components_by_stage.get(&stage.id) {
2117            stage.components.extend(components.iter().cloned());
2118            stage.components.sort();
2119            stage.components.dedup();
2120        }
2121    }
2122}
2123
2124fn expand_nested_components(model: &ModelIr, mut components: Vec<StableId>) -> Vec<StableId> {
2125    let qualified_components: HashMap<&str, &StableId> = model
2126        .components
2127        .iter()
2128        .map(|component| (component.qualified_name.as_str(), &component.id))
2129        .collect();
2130    loop {
2131        let mut added = Vec::new();
2132        for module in model
2133            .modules
2134            .iter()
2135            .filter(|module| components.contains(&module.component))
2136        {
2137            let Some(qualified_type) = module.qualified_type.as_deref() else {
2138                continue;
2139            };
2140            if let Some(component) = qualified_components.get(qualified_type) {
2141                if !components.contains(component) {
2142                    added.push((*component).clone());
2143                }
2144            }
2145        }
2146        if added.is_empty() {
2147            break;
2148        }
2149        components.extend(added);
2150        components.sort();
2151        components.dedup();
2152    }
2153    components
2154}
2155
2156fn apply_optimizer_roles(model: &mut ModelIr) {
2157    let component_names: HashMap<StableId, String> = model
2158        .components
2159        .iter()
2160        .map(|component| (component.id.clone(), component.name.to_ascii_lowercase()))
2161        .collect();
2162    for parameter in &mut model.parameters {
2163        let mut matched = Vec::new();
2164        let mut excluded = false;
2165        let mut conditionally_excluded_component = false;
2166        for optimizer in &model.optimizers {
2167            let component_matches = optimizer.components.is_empty()
2168                || optimizer.components.contains(&parameter.component);
2169            let root_matches = optimizer.builder_roots.contains(&parameter.builder_root)
2170                || optimizer.builder_roots.is_empty()
2171                    && similar_stem(&optimizer.varmap, &parameter.builder_root);
2172            if !component_matches || !root_matches {
2173                continue;
2174            }
2175            if optimizer
2176                .exclude_patterns
2177                .iter()
2178                .any(|pattern| parameter.key.contains(pattern))
2179            {
2180                excluded = true;
2181                continue;
2182            }
2183            if optimizer.exclude_patterns.iter().any(|pattern| {
2184                let prefix = pattern
2185                    .trim_matches(|character: char| !character.is_alphanumeric())
2186                    .to_ascii_lowercase();
2187                !prefix.is_empty()
2188                    && component_names
2189                        .get(&parameter.component)
2190                        .is_some_and(|component| component.contains(&prefix))
2191            }) {
2192                conditionally_excluded_component = true;
2193            }
2194            if optimizer.include_patterns.is_empty()
2195                || optimizer
2196                    .include_patterns
2197                    .iter()
2198                    .any(|pattern| parameter.key.contains(pattern))
2199            {
2200                matched.push(optimizer.id.clone());
2201            }
2202        }
2203        parameter.optimizer_memberships = matched;
2204        parameter.role = if is_running_state(&parameter.key) {
2205            ParameterRole::RunningState
2206        } else if !parameter.optimizer_memberships.is_empty() && conditionally_excluded_component {
2207            ParameterRole::Conditional
2208        } else if !parameter.optimizer_memberships.is_empty() {
2209            ParameterRole::Optimized
2210        } else if excluded {
2211            ParameterRole::Excluded
2212        } else if model.optimizers.iter().any(|optimizer| {
2213            optimizer.components.contains(&parameter.component)
2214                && !optimizer.builder_roots.contains(&parameter.builder_root)
2215        }) {
2216            ParameterRole::Frozen
2217        } else {
2218            ParameterRole::Unknown
2219        };
2220        parameter.evidence.push(Evidence {
2221            kind: EvidenceKind::Source,
2222            confidence: if parameter.optimizer_memberships.is_empty() {
2223                Confidence::Conditional
2224            } else {
2225                Confidence::Proven
2226            },
2227            source: Some(parameter.source.clone()),
2228            detail: format!(
2229                "role {:?} derived from optimizer membership, not constructor naming",
2230                parameter.role
2231            ),
2232        });
2233    }
2234
2235    for component in &mut model.components {
2236        for builder in &mut component.builders {
2237            let roles: Vec<_> = model
2238                .parameters
2239                .iter()
2240                .filter(|parameter| {
2241                    parameter.component == component.id && parameter.builder_root == builder.name
2242                })
2243                .map(|parameter| &parameter.role)
2244                .collect();
2245            builder.role = if roles
2246                .iter()
2247                .any(|role| matches!(role, ParameterRole::Optimized))
2248            {
2249                BuilderRole::Trainable
2250            } else if roles.iter().any(|role| {
2251                matches!(
2252                    role,
2253                    ParameterRole::Frozen | ParameterRole::Excluded | ParameterRole::RunningState
2254                )
2255            }) {
2256                BuilderRole::Frozen
2257            } else {
2258                BuilderRole::Unknown
2259            };
2260        }
2261    }
2262}
2263
2264fn add_dataflow(krate: &Crate, model: &mut ModelIr) {
2265    let candle_nn_version = model.cargo.as_ref().and_then(|cargo| {
2266        op_semantics::matched_candle_version(
2267            cargo.candle_packages.get("candle-core").map(String::as_str),
2268            cargo.candle_packages.get("candle-nn").map(String::as_str),
2269        )
2270        .map(str::to_string)
2271    });
2272    let selected: Vec<(StableId, String, String, bool)> = model
2273        .functions
2274        .iter()
2275        .filter(|function| function.is_entrypoint && function.cfg_active != Some(false))
2276        .map(|function| {
2277            (
2278                function.id.clone(),
2279                function.name.clone(),
2280                function.qualified_name.clone(),
2281                function.is_loss,
2282            )
2283        })
2284        .collect();
2285
2286    let function_indices: HashMap<StableId, usize> = model
2287        .functions
2288        .iter()
2289        .enumerate()
2290        .map(|(index, function)| (function.id.clone(), index))
2291        .collect();
2292    for (function_id, name, entry, is_loss) in selected {
2293        let phases = crate::phase::entrypoint_phases(&name, &entry, is_loss);
2294        if let Some(&function_index) = function_indices.get(&function_id) {
2295            model.functions[function_index].execution_phases = phases.clone();
2296        }
2297        for phase in phases {
2298        let graph = match dataflow::analyze_with_phase(
2299            krate,
2300            &entry,
2301            candle_nn_version.as_deref(),
2302            phase,
2303        ) {
2304            Ok(graph) => graph,
2305            Err(error) => {
2306                push_finding(
2307                    model,
2308                    "dataflow-entrypoint",
2309                    FindingSeverity::Information,
2310                    Confidence::Proven,
2311                    format!("could not analyze `{entry}`: {error:#}"),
2312                    None,
2313                    vec![function_id.clone()],
2314                );
2315                continue;
2316            }
2317        };
2318        let mut tensor_ids = vec![None; graph.nodes.len()];
2319        for node_id in &graph.tensor_nodes {
2320            let node = graph.node(*node_id);
2321            let id = StableId::new(
2322                "tensor",
2323                [
2324                    phase.as_str(),
2325                    function_id.0.as_str(),
2326                    node.id.0.to_string().as_str(),
2327                ],
2328            );
2329            let name = node_name(&node.kind);
2330            let role = match &node.kind {
2331                NodeKind::Param { .. } => TensorRole::Input,
2332                NodeKind::Return => TensorRole::Output,
2333                _ if graph.loss_nodes.contains(&node.id) || is_loss => TensorRole::Loss,
2334                _ => TensorRole::Activation,
2335            };
2336            model.tensors.push(TensorContract {
2337                id: id.clone(),
2338                name,
2339                role,
2340                owner_function: function_id.clone(),
2341                parameter: None,
2342                shape: ShapeFact {
2343                    rank: shape_rank(node.shape.as_deref()),
2344                    dimensions: Vec::new(),
2345                    source_expr: node.shape.clone(),
2346                },
2347                dtype: node.dtype.to_string(),
2348                device: DeviceFact::Unknown,
2349                layout: layout_for_node(&node.kind),
2350                requires_grad: match node.grad {
2351                    GradState::Trainable | GradState::Differentiable => Some(true),
2352                    GradState::Frozen | GradState::Severed => Some(false),
2353                    _ => None,
2354                },
2355                execution_phase: Some(phase),
2356                evidence: vec![source_evidence(
2357                    krate.file_label(node.span),
2358                    format!(
2359                        "expression-level static dataflow; source type {}",
2360                        node.type_name.as_deref().unwrap_or("Tensor")
2361                    ),
2362                )],
2363            });
2364            tensor_ids[node.id.0] = Some(id);
2365        }
2366        if let Some(&function_index) = function_indices.get(&function_id) {
2367            if phase == crate::phase::ExecutionPhase::Train {
2368            model.functions[function_index].tensor_inputs = graph
2369                .nodes
2370                .iter()
2371                .filter(|node| matches!(node.kind, NodeKind::Param { .. }))
2372                .filter_map(|node| tensor_ids[node.id.0].clone())
2373                .collect();
2374            model.functions[function_index].tensor_outputs = graph
2375                .entry_return
2376                .and_then(|node| tensor_ids[node.0].clone())
2377                .map(|tensor| vec![tensor])
2378                .unwrap_or_default();
2379            }
2380        }
2381        for node in &graph.nodes {
2382            let NodeKind::Call { callee } = &node.kind else {
2383                continue;
2384            };
2385            let effect = op_semantics::lookup_resolved(callee, candle_nn_version.as_deref());
2386            if matches!(effect.grad, GradFlow::Unknown)
2387                && matches!(effect.dtype, crate::op_semantics::DtypeRule::Unknown)
2388            {
2389                continue;
2390            }
2391            let Some(output) = tensor_ids[node.id.0].clone() else {
2392                continue;
2393            };
2394            let inputs = graph
2395                .edges
2396                .iter()
2397                .filter(|edge| edge.to == node.id)
2398                .filter_map(|edge| tensor_ids[edge.from.0].clone())
2399                .collect();
2400            let operation_id = StableId::new(
2401                "operation",
2402                [
2403                    phase.as_str(),
2404                    function_id.0.as_str(),
2405                    node.id.0.to_string().as_str(),
2406                ],
2407            );
2408            model.operations.push(Operation {
2409                id: operation_id.clone(),
2410                function: function_id.clone(),
2411                name: effect.name.clone(),
2412                qualified_name: callee.contains("::").then(|| callee.clone()),
2413                inputs,
2414                output,
2415                source: krate.file_label(node.span),
2416                dtype_rule: format!("{:?}", effect.dtype),
2417                gradient_rule: format!("{:?}", effect.grad),
2418                device_rule: if effect.name == "to_device" {
2419                    "explicit".to_string()
2420                } else {
2421                    "preserve".to_string()
2422                },
2423                shape_rule: shape_rule(&effect.name).to_string(),
2424                domain_rule: effect.domain_rule_label(),
2425                execution_phase: Some(phase),
2426                avg_duration_ns: None,
2427                timing_samples: None,
2428                evidence: vec![Evidence {
2429                    kind: EvidenceKind::Source,
2430                    confidence: Confidence::Proven,
2431                    source: Some(krate.file_label(node.span)),
2432                    detail: effect.note.unwrap_or("Candle operation rule").to_string(),
2433                }],
2434            });
2435            link_implicit_parameter_reads(model, &graph, node.id, &function_id, &operation_id);
2436        }
2437        let dead_params = graph.dead_params();
2438        for conflict in &graph.dtype_conflicts {
2439            push_finding(
2440                model,
2441                "dtype-conflict",
2442                FindingSeverity::Error,
2443                Confidence::Proven,
2444                conflict.message.clone(),
2445                Some(krate.file_label(conflict.span)),
2446                vec![function_id.clone()],
2447            );
2448        }
2449        for risk in &graph.dtype_risks {
2450            push_finding(
2451                model,
2452                "dtype-risk",
2453                FindingSeverity::Warning,
2454                Confidence::Conditional,
2455                risk.message.clone(),
2456                Some(krate.file_label(risk.span)),
2457                vec![function_id.clone()],
2458            );
2459        }
2460        let inference_entrypoint = phase == crate::phase::ExecutionPhase::Infer;
2461        for violation in &graph.numeric_domain_violations {
2462            let (severity, confidence) =
2463                numeric_finding_severity(violation.proven, violation.impact, inference_entrypoint);
2464            push_finding(
2465                model,
2466                "numeric-domain-violation",
2467                severity,
2468                confidence,
2469                violation.message.clone(),
2470                Some(krate.file_label(violation.span)),
2471                vec![function_id.clone()],
2472            );
2473        }
2474        for nan_shape in &graph.zero_times_infinity {
2475            let (severity, confidence) =
2476                numeric_finding_severity(true, nan_shape.impact, inference_entrypoint);
2477            push_finding(
2478                model,
2479                "zero-times-infinity",
2480                severity,
2481                confidence,
2482                nan_shape.message.clone(),
2483                Some(krate.file_label(nan_shape.span)),
2484                vec![function_id.clone()],
2485            );
2486            if nan_shape.library_cite.is_some()
2487                && matches!(
2488                    nan_shape.impact,
2489                    NumericImpact::TrainingLossNaN | NumericImpact::GradientPoison
2490                )
2491            {
2492                push_finding(
2493                    model,
2494                    "unstable-library-loss",
2495                    FindingSeverity::Error,
2496                    Confidence::Proven,
2497                    nan_shape.message.clone(),
2498                    Some(krate.file_label(nan_shape.span)),
2499                    vec![function_id.clone()],
2500                );
2501            }
2502        }
2503        for dead in dead_params {
2504            let mut related = vec![function_id.clone()];
2505            if let Some(tensor) = tensor_ids[dead.0].clone() {
2506                related.push(tensor);
2507            }
2508            push_finding(
2509                model,
2510                "dead-gradient-path",
2511                FindingSeverity::Warning,
2512                Confidence::Conditional,
2513                format!(
2514                    "trainable expression `{}` has no differentiable path to a loss",
2515                    node_name(&graph.node(dead).kind)
2516                ),
2517                Some(krate.file_label(graph.node(dead).span)),
2518                related,
2519            );
2520        }
2521        } // execution phase
2522    }
2523}
2524
2525fn link_implicit_parameter_reads(
2526    model: &mut ModelIr,
2527    graph: &dataflow::ExprGraph,
2528    operation: dataflow::NodeId,
2529    function_id: &StableId,
2530    operation_id: &StableId,
2531) {
2532    let Some(module_node) = graph
2533        .edges
2534        .iter()
2535        .find(|edge| edge.to == operation && edge.label.as_deref() == Some("module"))
2536        .map(|edge| graph.node(edge.from))
2537    else {
2538        return;
2539    };
2540    let NodeKind::Local { name } = &module_node.kind else {
2541        return;
2542    };
2543    let field = name.strip_prefix('.').unwrap_or(name);
2544    let Some(type_name) = module_node
2545        .type_name
2546        .as_deref()
2547        .and_then(|name| name.rsplit("::").next())
2548    else {
2549        return;
2550    };
2551    let modules = model
2552        .modules
2553        .iter()
2554        .filter(|module| {
2555            module.field.as_deref() == Some(field)
2556                && module.type_name.rsplit("::").next() == Some(type_name)
2557        })
2558        .map(|module| module.id.clone())
2559        .collect::<HashSet<_>>();
2560    let owner_type = model
2561        .functions
2562        .iter()
2563        .find(|function| &function.id == function_id)
2564        .and_then(|function| function.owner_type.as_deref());
2565    let mut components = model
2566        .components
2567        .iter()
2568        .filter(|component| Some(component.qualified_name.as_str()) == owner_type)
2569        .map(|component| component.id.clone())
2570        .collect::<HashSet<_>>();
2571    if let Some(owner_type) = owner_type {
2572        let owner_leaf = owner_type.rsplit("::").next().unwrap_or(owner_type);
2573        components.extend(
2574            model
2575                .modules
2576                .iter()
2577                .filter(|module| {
2578                    module.type_name.rsplit("::").next() == Some(owner_leaf)
2579                        || module
2580                            .qualified_type
2581                            .as_deref()
2582                            .and_then(|name| name.rsplit("::").next())
2583                            == Some(owner_leaf)
2584                })
2585                .map(|module| module.component.clone()),
2586        );
2587    }
2588    for parameter in &mut model.parameters {
2589        let field_prefix_match = components.contains(&parameter.component)
2590            && parameter.key.split('.').any(|segment| segment == field);
2591        if (modules.contains(&parameter.module) || field_prefix_match)
2592            && !parameter.uses.contains(operation_id)
2593        {
2594            parameter.uses.push(operation_id.clone());
2595        }
2596    }
2597}
2598
2599fn aggregate_edge_timings(trace: &RuntimeTrace) -> Vec<EdgeTimingSummary> {
2600    let mut edge_totals: BTreeMap<(String, String), (u64, u64)> = BTreeMap::new();
2601    for edge in &trace.edge_timings {
2602        let key = (edge.from_static_id.clone(), edge.to_static_id.clone());
2603        let entry = edge_totals.entry(key).or_insert((0, 0));
2604        entry.0 = entry.0.saturating_add(edge.duration_ns);
2605        entry.1 += 1;
2606    }
2607    edge_totals
2608        .into_iter()
2609        .map(|((from, to), (total, samples))| EdgeTimingSummary {
2610            from: StableId(from),
2611            to: StableId(to),
2612            avg_duration_ns: if samples > 0 { total / samples } else { 0 },
2613            samples,
2614        })
2615        .collect()
2616}
2617
2618fn merge_runtime(model: &mut ModelIr, trace: &RuntimeTrace) {
2619    let expected = ExpectedIdentity {
2620        analysis_id: Some(model.analysis_id.0.clone()),
2621        build_id: model.cargo.as_ref().map(|cargo| cargo.build_id.clone()),
2622    };
2623    let audit = trace.audit_with_identity(Some(&expected));
2624    let identity_matches = audit.identity_mismatches.is_empty();
2625
2626    if identity_matches {
2627        let static_ids = trace
2628            .tensors
2629            .iter()
2630            .filter_map(|observation| observation.static_id.as_deref())
2631            .collect::<BTreeSet<_>>();
2632        let mut unknown_static_ids = 0usize;
2633        for static_id in static_ids {
2634            let Some(observation) = trace.agreed_tensor(static_id) else {
2635                continue;
2636            };
2637            let Some(tensor) = model
2638                .tensors
2639                .iter_mut()
2640                .find(|tensor| tensor.id.0 == static_id)
2641            else {
2642                unknown_static_ids += 1;
2643                continue;
2644            };
2645            tensor.shape.rank = Some(observation.shape.len());
2646            tensor.shape.dimensions = observation
2647                .shape
2648                .iter()
2649                .map(|dimension| crate::model_ir::Dimension {
2650                    name: None,
2651                    expr: dimension.to_string(),
2652                })
2653                .collect();
2654            tensor.dtype = observation.dtype.clone();
2655            tensor.device = parse_device(&observation.device);
2656            tensor.layout = if observation.contiguous {
2657                LayoutFact::Contiguous
2658            } else {
2659                LayoutFact::NonContiguous
2660            };
2661            tensor.requires_grad = Some(observation.requires_grad);
2662            tensor.evidence.push(Evidence {
2663                kind: EvidenceKind::Runtime,
2664                confidence: Confidence::Proven,
2665                source: observation.source.clone(),
2666                detail: format!("agreed runtime observation {}", observation.event_id),
2667            });
2668        }
2669        if unknown_static_ids > 0 {
2670            push_finding(
2671                model,
2672                "runtime-unmapped",
2673                FindingSeverity::Information,
2674                Confidence::Unknown,
2675                format!(
2676                    "{unknown_static_ids} runtime tensor identities did not exist in this analysis"
2677                ),
2678                None,
2679                Vec::new(),
2680            );
2681        }
2682
2683        for parameter in &mut model.parameters {
2684            if let Some(gradient) = trace.gradient(&parameter.builder_root, &parameter.key) {
2685                parameter.evidence.push(Evidence {
2686                    kind: EvidenceKind::Runtime,
2687                    confidence: Confidence::Proven,
2688                    source: None,
2689                    detail: format!("gradient {:?}, norm {:?}", gradient.state, gradient.norm),
2690                });
2691            }
2692        }
2693        for observation in &trace.operations {
2694            let Some(static_id) = observation.static_id.as_deref() else {
2695                continue;
2696            };
2697            if let Some(operation) = model
2698                .operations
2699                .iter_mut()
2700                .find(|operation| operation.id.0 == static_id)
2701            {
2702                let mut detail = format!(
2703                    "runtime operation {} observed as {}",
2704                    observation.event_id, observation.op
2705                );
2706                if let Some(duration_ns) = observation.duration_ns {
2707                    detail.push_str(&format!(" duration_ns={duration_ns}"));
2708                }
2709                operation.evidence.push(Evidence {
2710                    kind: EvidenceKind::Runtime,
2711                    confidence: Confidence::Proven,
2712                    source: observation.source.clone(),
2713                    detail,
2714                });
2715            }
2716        }
2717        let mut op_duration_totals: BTreeMap<String, (u64, u64)> = BTreeMap::new();
2718        for observation in &trace.operations {
2719            let Some(static_id) = observation.static_id.as_deref() else {
2720                continue;
2721            };
2722            let Some(duration_ns) = observation.duration_ns else {
2723                continue;
2724            };
2725            let entry = op_duration_totals
2726                .entry(static_id.to_string())
2727                .or_insert((0, 0));
2728            entry.0 = entry.0.saturating_add(duration_ns);
2729            entry.1 += 1;
2730        }
2731        for (static_id, (total, samples)) in op_duration_totals {
2732            if samples == 0 {
2733                continue;
2734            }
2735            if let Some(operation) = model
2736                .operations
2737                .iter_mut()
2738                .find(|operation| operation.id.0 == static_id)
2739            {
2740                operation.avg_duration_ns = Some(total / samples);
2741                operation.timing_samples = Some(samples);
2742            }
2743        }
2744        for identity in audit
2745            .missing_gradients
2746            .iter()
2747            .chain(&audit.zero_gradients)
2748            .chain(&audit.non_finite_gradients)
2749        {
2750            let Some(gradient) = trace.gradient(&identity.root, &identity.key) else {
2751                continue;
2752            };
2753            let severity = if gradient.state == GradientState::NonFinite {
2754                FindingSeverity::Error
2755            } else {
2756                FindingSeverity::Warning
2757            };
2758            push_finding(
2759                model,
2760                "runtime-gradient",
2761                severity,
2762                Confidence::Proven,
2763                format!(
2764                    "{}:{} runtime gradient is {:?}",
2765                    gradient.root, gradient.key, gradient.state
2766                ),
2767                None,
2768                model
2769                    .parameters
2770                    .iter()
2771                    .filter(|parameter| {
2772                        parameter.builder_root == gradient.root && parameter.key == gradient.key
2773                    })
2774                    .map(|parameter| parameter.id.clone())
2775                    .collect(),
2776            );
2777        }
2778    }
2779    for conflict in &audit.tensor_conflicts {
2780        push_finding(
2781            model,
2782            "runtime-tensor-conflict",
2783            FindingSeverity::Error,
2784            Confidence::Unknown,
2785            format!(
2786                "{} has conflicting runtime {:?}: {}",
2787                conflict.static_id,
2788                conflict.kind,
2789                conflict.values.join(", ")
2790            ),
2791            None,
2792            vec![StableId(conflict.static_id.clone())],
2793        );
2794    }
2795    for conflict in &audit.gradient_conflicts {
2796        push_finding(
2797            model,
2798            "runtime-gradient-conflict",
2799            FindingSeverity::Error,
2800            Confidence::Unknown,
2801            format!(
2802                "{}:{} has contradictory gradient observations: {}",
2803                conflict.identity.root,
2804                conflict.identity.key,
2805                conflict.states.join(", ")
2806            ),
2807            None,
2808            model
2809                .parameters
2810                .iter()
2811                .filter(|parameter| {
2812                    parameter.builder_root == conflict.identity.root
2813                        && parameter.key == conflict.identity.key
2814                })
2815                .map(|parameter| parameter.id.clone())
2816                .collect(),
2817        );
2818    }
2819    for mismatch in &audit.identity_mismatches {
2820        push_finding(
2821            model,
2822            "runtime-identity",
2823            FindingSeverity::Error,
2824            Confidence::Unknown,
2825            format!(
2826                "runtime {} mismatch: expected {}, observed {}; trace was not merged",
2827                mismatch.field, mismatch.expected, mismatch.observed
2828            ),
2829            None,
2830            Vec::new(),
2831        );
2832    }
2833    model.coverage.runtime_observations = trace.tensors.len()
2834        + trace.operations.len()
2835        + trace.gradients.len()
2836        + trace.values.len()
2837        + trace.edge_timings.len();
2838    let edge_timing_summaries = aggregate_edge_timings(trace);
2839    let avg_operation_duration_ns = trace
2840        .operations
2841        .iter()
2842        .filter_map(|op| op.duration_ns)
2843        .reduce(|a, b| a.saturating_add(b))
2844        .and_then(|total| {
2845            let count = trace.operations.iter().filter(|op| op.duration_ns.is_some()).count();
2846            (count > 0).then_some(total / count as u64)
2847        });
2848    model.runtime = Some(RuntimeSummary {
2849        trace_schema: trace.schema.clone(),
2850        entrypoint: Some(trace.run.entrypoint.clone()),
2851        profile: Some(trace.run.profile.clone()),
2852        tensor_observations: trace.tensors.len(),
2853        operation_observations: trace.operations.len(),
2854        gradient_observations: trace.gradients.len(),
2855        missing_gradients: audit.missing_gradients.len(),
2856        zero_gradients: audit.zero_gradients.len(),
2857        non_finite_gradients: audit.non_finite_gradients.len(),
2858        tensor_conflicts: audit.tensor_conflicts.len(),
2859        gradient_conflicts: audit.gradient_conflicts.len(),
2860        identity_mismatches: audit.identity_mismatches.len(),
2861        first_non_finite_step: audit.first_non_finite_step,
2862        saturating_activations: audit.saturating_activations.len(),
2863        value_observations: trace.values.len(),
2864        execution_phase: trace
2865            .run
2866            .phase
2867            .as_deref()
2868            .and_then(crate::phase::ExecutionPhase::parse),
2869        avg_operation_duration_ns,
2870        edge_timings: edge_timing_summaries,
2871    });
2872}
2873
2874fn flag_candle_semantics_version(model: &mut ModelIr, cargo: &CargoContext) {
2875    for (package, version) in &cargo.candle_versions {
2876        if package == "candle-core" || package == "candle-nn" {
2877            let supported = op_semantics::is_audited_candle_version(version);
2878            if !supported {
2879                push_finding(
2880                    model,
2881                    "candle-semantics-version",
2882                    FindingSeverity::Warning,
2883                    Confidence::Proven,
2884                    format!(
2885                        "{package} {version} differs from the audited operation catalogs \
2886                         {}; \
2887                         unknown or changed operations remain explicit",
2888                        op_semantics::AUDITED_CANDLE_VERSION
2889                    ),
2890                    Some(cargo.manifest_path.to_string_lossy().into_owned()),
2891                    Vec::new(),
2892                );
2893            }
2894        }
2895    }
2896    let core = cargo.candle_versions.get("candle-core");
2897    let nn = cargo.candle_versions.get("candle-nn");
2898    if let (Some(core), Some(nn)) = (core, nn) {
2899        if core != nn {
2900            push_finding(
2901                model,
2902                "candle-semantics-version",
2903                FindingSeverity::Warning,
2904                Confidence::Proven,
2905                format!(
2906                    "candle-core {core} and candle-nn {nn} do not match; version-sensitive \
2907                     constructor and autograd rules remain Unknown"
2908                ),
2909                Some(cargo.manifest_path.to_string_lossy().into_owned()),
2910                Vec::new(),
2911            );
2912        }
2913    }
2914}
2915
2916#[derive(Default)]
2917struct CallCollector {
2918    calls: Vec<String>,
2919}
2920
2921impl<'ast> Visit<'ast> for CallCollector {
2922    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
2923        if let syn::Expr::Path(path) = &*node.func {
2924            self.calls
2925                .push(path.path.to_token_stream().to_string().replace(' ', ""));
2926        }
2927        visit::visit_expr_call(self, node);
2928    }
2929
2930    fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
2931        self.calls.push(node.method.to_string());
2932        visit::visit_expr_method_call(self, node);
2933    }
2934}
2935
2936#[derive(Debug)]
2937struct ArtifactObservation {
2938    operation: String,
2939    path_expr: String,
2940    label: String,
2941    produced: bool,
2942}
2943
2944#[derive(Default)]
2945struct ArtifactVisitor {
2946    observed: Vec<ArtifactObservation>,
2947}
2948
2949impl<'ast> Visit<'ast> for ArtifactVisitor {
2950    fn visit_local(&mut self, node: &'ast syn::Local) {
2951        if let Some(init) = &node.init {
2952            let path_expr = format!(
2953                "{} = {}",
2954                node.pat.to_token_stream(),
2955                init.expr.to_token_stream()
2956            );
2957            let strings = string_literals(&init.expr);
2958            if let Some(label) = strings
2959                .iter()
2960                .rev()
2961                .find(|value| is_artifact_literal(value))
2962                .cloned()
2963                .filter(|_| path_expr.len() <= 256)
2964            {
2965                self.observed.push(ArtifactObservation {
2966                    operation: "path_binding".to_string(),
2967                    path_expr,
2968                    label,
2969                    produced: false,
2970                });
2971            }
2972        }
2973        visit::visit_local(self, node);
2974    }
2975
2976    fn visit_expr_struct(&mut self, node: &'ast syn::ExprStruct) {
2977        for field in &node.fields {
2978            let strings = string_literals(&field.expr);
2979            if let Some(label) = strings
2980                .iter()
2981                .rev()
2982                .find(|value| is_artifact_literal(value))
2983                .cloned()
2984            {
2985                self.observed.push(ArtifactObservation {
2986                    operation: "path_field".to_string(),
2987                    path_expr: format!(
2988                        "{} = {}",
2989                        field.member.to_token_stream(),
2990                        field.expr.to_token_stream()
2991                    ),
2992                    label,
2993                    produced: false,
2994                });
2995            }
2996        }
2997        visit::visit_expr_struct(self, node);
2998    }
2999
3000    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
3001        let op = match &*node.func {
3002            syn::Expr::Path(path) => path.path.to_token_stream().to_string().replace(' ', ""),
3003            _ => String::new(),
3004        };
3005        if is_artifact_operation(&op) {
3006            for argument in &node.args {
3007                self.observe(&op, argument);
3008            }
3009        }
3010        visit::visit_expr_call(self, node);
3011    }
3012
3013    fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
3014        let op = node.method.to_string();
3015        if is_artifact_operation(&op) {
3016            self.observe(&op, &node.receiver);
3017            for argument in &node.args {
3018                self.observe(&op, argument);
3019            }
3020        }
3021        visit::visit_expr_method_call(self, node);
3022    }
3023}
3024
3025impl ArtifactVisitor {
3026    fn observe(&mut self, operation: &str, expr: &syn::Expr) {
3027        let text = expr.to_token_stream().to_string();
3028        let strings = string_literals(expr);
3029        let Some(label) = strings
3030            .iter()
3031            .rev()
3032            .find(|value| is_artifact_literal(value))
3033            .cloned()
3034        else {
3035            return;
3036        };
3037        self.observed.push(ArtifactObservation {
3038            operation: operation.to_string(),
3039            path_expr: text,
3040            label,
3041            produced: is_producer_operation(operation),
3042        });
3043    }
3044}
3045
3046#[derive(Default)]
3047struct StringCollector {
3048    values: Vec<String>,
3049}
3050
3051impl<'ast> Visit<'ast> for StringCollector {
3052    fn visit_lit_str(&mut self, node: &'ast syn::LitStr) {
3053        self.values.push(node.value());
3054    }
3055}
3056
3057fn string_literals(expr: &syn::Expr) -> Vec<String> {
3058    let mut collector = StringCollector::default();
3059    collector.visit_expr(expr);
3060    collector.values
3061}
3062
3063#[derive(Default)]
3064struct OptimizerVisitor {
3065    varmaps: BTreeSet<String>,
3066    includes: Vec<String>,
3067    excludes: Vec<String>,
3068    optimizer: Option<String>,
3069}
3070
3071impl<'ast> Visit<'ast> for OptimizerVisitor {
3072    fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
3073        let method = node.method.to_string();
3074        if method == "all_vars" {
3075            self.varmaps
3076                .insert(node.receiver.to_token_stream().to_string().replace(' ', ""));
3077        }
3078        if method == "retain" {
3079            let mut patterns = RetainPatternVisitor::default();
3080            for argument in &node.args {
3081                patterns.visit_expr(argument);
3082            }
3083            self.includes.extend(patterns.includes);
3084            self.excludes.extend(patterns.excludes);
3085        }
3086        visit::visit_expr_method_call(self, node);
3087    }
3088
3089    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
3090        if let syn::Expr::Path(path) = &*node.func {
3091            let name = path.path.to_token_stream().to_string().replace(' ', "");
3092            if name.ends_with("named_train_vars") {
3093                if let Some(varmap) = node.args.first().and_then(expr_identifier) {
3094                    self.varmaps.insert(varmap);
3095                }
3096            }
3097            if name.contains("Adam") || name.contains("Optimizer") {
3098                self.optimizer = Some(name);
3099            }
3100        }
3101        visit::visit_expr_call(self, node);
3102    }
3103}
3104
3105#[derive(Default)]
3106struct RetainPatternVisitor {
3107    negated: bool,
3108    includes: Vec<String>,
3109    excludes: Vec<String>,
3110}
3111
3112impl<'ast> Visit<'ast> for RetainPatternVisitor {
3113    fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
3114        if matches!(
3115            node.method.to_string().as_str(),
3116            "contains" | "starts_with" | "ends_with"
3117        ) {
3118            if let Some(syn::Expr::Lit(syn::ExprLit {
3119                lit: syn::Lit::Str(value),
3120                ..
3121            })) = node.args.first()
3122            {
3123                if self.negated {
3124                    self.excludes.push(value.value());
3125                } else {
3126                    self.includes.push(value.value());
3127                }
3128            }
3129        }
3130        visit::visit_expr_method_call(self, node);
3131    }
3132
3133    fn visit_expr_unary(&mut self, node: &'ast syn::ExprUnary) {
3134        if matches!(node.op, syn::UnOp::Not(_)) {
3135            self.negated = !self.negated;
3136            self.visit_expr(&node.expr);
3137            self.negated = !self.negated;
3138        } else {
3139            visit::visit_expr_unary(self, node);
3140        }
3141    }
3142}
3143
3144fn expr_identifier(expression: &syn::Expr) -> Option<String> {
3145    match expression {
3146        syn::Expr::Path(path) if path.path.segments.len() == 1 => {
3147            Some(path.path.segments[0].ident.to_string())
3148        }
3149        syn::Expr::Reference(reference) => expr_identifier(&reference.expr),
3150        syn::Expr::Paren(paren) => expr_identifier(&paren.expr),
3151        syn::Expr::Group(group) => expr_identifier(&group.expr),
3152        syn::Expr::Try(value) => expr_identifier(&value.expr),
3153        _ => None,
3154    }
3155}
3156
3157fn function_id(func: &ImplFn) -> StableId {
3158    StableId::new(
3159        "function",
3160        [
3161            func.qualified_name.as_str(),
3162            func.cfg_predicates.join(" && ").as_str(),
3163        ],
3164    )
3165}
3166
3167fn unique_qualified_struct(krate: &Crate, name: &str) -> Option<String> {
3168    match krate.struct_candidates(name).as_slice() {
3169        [def] => Some(def.qualified_name.clone()),
3170        _ => None,
3171    }
3172}
3173
3174fn source_evidence(source: String, detail: impl Into<String>) -> Evidence {
3175    Evidence {
3176        kind: EvidenceKind::Source,
3177        confidence: Confidence::Proven,
3178        source: Some(source),
3179        detail: detail.into(),
3180    }
3181}
3182
3183fn heuristic_source_evidence(source: String, detail: impl Into<String>) -> Evidence {
3184    Evidence {
3185        kind: EvidenceKind::Source,
3186        confidence: Confidence::Heuristic,
3187        source: Some(source),
3188        detail: detail.into(),
3189    }
3190}
3191
3192fn numeric_finding_severity(
3193    proven: bool,
3194    impact: NumericImpact,
3195    inference_entrypoint: bool,
3196) -> (FindingSeverity, Confidence) {
3197    if !proven {
3198        return (FindingSeverity::Warning, Confidence::Unknown);
3199    }
3200    match impact {
3201        NumericImpact::TrainingLossNaN | NumericImpact::GradientPoison => {
3202            (FindingSeverity::Error, Confidence::Proven)
3203        }
3204        NumericImpact::InferenceOutputRisk if inference_entrypoint => {
3205            (FindingSeverity::Error, Confidence::Proven)
3206        }
3207        NumericImpact::InferenceOutputRisk | NumericImpact::LocalOnly => {
3208            (FindingSeverity::Warning, Confidence::Proven)
3209        }
3210    }
3211}
3212
3213fn push_finding(
3214    model: &mut ModelIr,
3215    rule: &str,
3216    severity: FindingSeverity,
3217    confidence: Confidence,
3218    message: String,
3219    source: Option<String>,
3220    related: Vec<StableId>,
3221) {
3222    model.findings.push(Finding {
3223        id: StableId::new(
3224            "finding",
3225            [rule, source.as_deref().unwrap_or(""), message.as_str()],
3226        ),
3227        rule: rule.to_string(),
3228        severity,
3229        confidence,
3230        message,
3231        source,
3232        related,
3233        evidence: Vec::new(),
3234    });
3235}
3236
3237fn visibility(text: &str) -> Visibility {
3238    match text {
3239        "" => Visibility::Private,
3240        "pub" => Visibility::Public,
3241        "pub(crate)" => Visibility::Crate,
3242        value if value.starts_with("pub(") => Visibility::Restricted,
3243        _ => Visibility::Unknown,
3244    }
3245}
3246
3247fn certainty_confidence(certainty: &Certainty) -> Confidence {
3248    match certainty {
3249        Certainty::Certain => Confidence::Proven,
3250        Certainty::Conditional(_) => Confidence::Conditional,
3251        Certainty::Unknown(_) => Confidence::Unknown,
3252    }
3253}
3254
3255fn is_tensor_type(ty: &str) -> bool {
3256    ty.split(|character: char| !character.is_alphanumeric() && character != '_')
3257        .any(|segment| segment == "Tensor")
3258}
3259
3260fn is_model_entry_name(name: &str) -> bool {
3261    matches!(
3262        name,
3263        "forward"
3264            | "forward_diff"
3265            | "forward_features"
3266            | "forward_t"
3267            | "encode"
3268            | "predict"
3269            | "compress"
3270            | "conditioning"
3271            | "condition"
3272            | "generate"
3273            | "decode"
3274            | "loss"
3275    ) || name.ends_with("_forward")
3276        || name.ends_with("_loss")
3277}
3278
3279fn is_stage_entry_name(name: &str) -> bool {
3280    name == "prepare"
3281        || name.starts_with("try_run_")
3282        || name.starts_with("run_")
3283            && (name.contains("train") || name.contains("eval") || name.contains("prepare"))
3284}
3285
3286fn is_pipeline_stage_call(name: &str) -> bool {
3287    name == "prepare_data"
3288        || name == "final_eval"
3289        || name.starts_with("train_")
3290        || name.starts_with("evaluate_")
3291        || name.starts_with("export_")
3292}
3293
3294fn stage_variants(krate: &Crate, target: &StableId) -> Vec<String> {
3295    let _ = (krate, target);
3296    Vec::new()
3297}
3298
3299fn stage_kind(name: &str) -> StageKind {
3300    if name.contains("eval") {
3301        StageKind::Evaluate
3302    } else if name.contains("prepare") || name.contains("data") {
3303        StageKind::Prepare
3304    } else if name.contains("train")
3305        || name.contains("bridge")
3306        || name == "latent"
3307        || name == "world"
3308    {
3309        StageKind::Train
3310    } else if name.contains("export") {
3311        StageKind::Export
3312    } else if name.starts_with("preflight_") {
3313        StageKind::Probe
3314    } else {
3315        StageKind::Unknown
3316    }
3317}
3318
3319fn stage_display_name(function: &Function) -> String {
3320    let module = function
3321        .qualified_name
3322        .rsplit_once("::")
3323        .map(|(module, _)| module.rsplit("::").next().unwrap_or(module))
3324        .unwrap_or_default();
3325    if module.is_empty() || function.name.contains(module) {
3326        function.name.clone()
3327    } else {
3328        format!("{module}:{}", function.name)
3329    }
3330}
3331
3332fn reachable_components(function: &Function, model: &ModelIr) -> Vec<StableId> {
3333    let mut components = Vec::new();
3334    let mut queue = vec![function.id.clone()];
3335    let mut seen = HashSet::new();
3336    while let Some(id) = queue.pop() {
3337        if !seen.insert(id.clone()) {
3338            continue;
3339        }
3340        if let Some(func) = model.functions.iter().find(|func| func.id == id) {
3341            if let Some(owner) = func.owner_type.as_deref() {
3342                components.extend(
3343                    model
3344                        .components
3345                        .iter()
3346                        .filter(|component| component.qualified_name == owner)
3347                        .map(|component| component.id.clone()),
3348                );
3349            }
3350            for edge in model
3351                .architecture_edges
3352                .iter()
3353                .filter(|edge| edge.via_function == id)
3354            {
3355                components.push(edge.from.clone());
3356                components.push(edge.to.clone());
3357            }
3358            queue.extend(func.calls.iter().cloned());
3359        }
3360    }
3361    components.sort();
3362    components.dedup();
3363    components
3364}
3365
3366fn stage_for_function(
3367    function: &StableId,
3368    stages: &HashMap<StableId, StableId>,
3369    model: &ModelIr,
3370) -> Option<StableId> {
3371    if let Some(stage) = stages.get(function) {
3372        return Some(stage.clone());
3373    }
3374    model.stages.iter().find_map(|stage| {
3375        reachable_function(&stage.function, function, model, &mut HashSet::new())
3376            .then(|| stage.id.clone())
3377    })
3378}
3379
3380fn reachable_function(
3381    current: &StableId,
3382    target: &StableId,
3383    model: &ModelIr,
3384    seen: &mut HashSet<StableId>,
3385) -> bool {
3386    if current == target {
3387        return true;
3388    }
3389    if !seen.insert(current.clone()) {
3390        return false;
3391    }
3392    model
3393        .functions
3394        .iter()
3395        .find(|function| &function.id == current)
3396        .is_some_and(|function| {
3397            function
3398                .calls
3399                .iter()
3400                .any(|callee| reachable_function(callee, target, model, seen))
3401        })
3402}
3403
3404fn qualify(module: &str, path: &str) -> String {
3405    if module.is_empty() {
3406        path.to_string()
3407    } else if path.is_empty() {
3408        module.to_string()
3409    } else {
3410        format!("{module}::{path}")
3411    }
3412}
3413
3414fn acquisition_label(acquisition: &Acquisition) -> String {
3415    match acquisition {
3416        Acquisition::Constructor { func, .. } => func.clone(),
3417        Acquisition::RawGet { method } => method.clone(),
3418    }
3419}
3420
3421fn similar_stem(left: &str, right: &str) -> bool {
3422    fn stem(value: &str) -> String {
3423        value
3424            .replace("varmap", "")
3425            .replace("var_map", "")
3426            .replace("vb", "")
3427            .replace(['_', '&'], "")
3428    }
3429    let left = stem(left);
3430    let right = stem(right);
3431    !left.is_empty() && !right.is_empty() && (left.contains(&right) || right.contains(&left))
3432}
3433
3434fn is_running_state(key: &str) -> bool {
3435    key.contains("running_mean")
3436        || key.contains("running_var")
3437        || key.contains("num_batches_tracked")
3438}
3439
3440fn is_artifact_operation(name: &str) -> bool {
3441    let leaf = name.rsplit("::").next().unwrap_or(name);
3442    matches!(
3443        leaf,
3444        "load"
3445            | "save"
3446            | "read"
3447            | "write"
3448            | "open"
3449            | "mmap"
3450            | "from_mmaped_safetensors"
3451            | "from_buffered_safetensors"
3452            | "serialize_to_file"
3453            | "load_buffer"
3454    )
3455}
3456
3457fn is_producer_operation(name: &str) -> bool {
3458    let leaf = name.rsplit("::").next().unwrap_or(name);
3459    matches!(leaf, "save" | "write" | "serialize_to_file")
3460}
3461
3462fn is_artifact_literal(value: &str) -> bool {
3463    let lower = value.to_ascii_lowercase();
3464    [
3465        ".safetensors",
3466        ".safetensor",
3467        ".json",
3468        ".jsonl",
3469        ".bin",
3470        ".pt",
3471        ".pth",
3472        ".gguf",
3473        ".parquet",
3474        ".arrow",
3475    ]
3476    .iter()
3477    .any(|suffix| lower.ends_with(suffix))
3478}
3479
3480fn artifact_identity(path_expr: &str) -> String {
3481    path_expr
3482        .split_once('=')
3483        .map(|(binding, _)| binding.trim().replace(' ', ""))
3484        .filter(|binding| {
3485            !binding.is_empty()
3486                && binding
3487                    .chars()
3488                    .all(|character| character.is_alphanumeric() || character == '_')
3489        })
3490        .unwrap_or_else(|| path_expr.to_string())
3491}
3492
3493fn infer_artifact_stage_links(model: &mut ModelIr) {
3494    let _ = model;
3495}
3496
3497fn artifact_kind(label: &str) -> ArtifactKind {
3498    let lower = label.to_ascii_lowercase();
3499    if lower.ends_with(".safetensors")
3500        || lower.ends_with(".bin")
3501        || lower.ends_with(".pt")
3502        || lower.ends_with(".pth")
3503        || lower.ends_with(".gguf")
3504    {
3505        ArtifactKind::Checkpoint
3506    } else if lower.contains("eval") || lower.contains("report") || lower.contains("metric") {
3507        ArtifactKind::EvaluationReport
3508    } else if lower.ends_with(".parquet") || lower.ends_with(".arrow") {
3509        ArtifactKind::Dataset
3510    } else if lower.ends_with(".json") {
3511        ArtifactKind::Configuration
3512    } else {
3513        ArtifactKind::Unknown
3514    }
3515}
3516
3517fn node_name(kind: &NodeKind) -> String {
3518    match kind {
3519        NodeKind::Param { name } | NodeKind::Local { name } => name.clone(),
3520        NodeKind::Call { callee } => format!("result_of_{callee}"),
3521        NodeKind::Literal { text } => text.clone(),
3522        NodeKind::Phi => "branch_join".to_string(),
3523        NodeKind::Return => "return".to_string(),
3524        NodeKind::Unknown { reason } => format!("unknown:{reason}"),
3525    }
3526}
3527
3528fn shape_rank(shape: Option<&str>) -> Option<usize> {
3529    let shape = shape?;
3530    let trimmed = shape.trim();
3531    if (trimmed.starts_with('(') && trimmed.ends_with(')'))
3532        || (trimmed.starts_with('[') && trimmed.ends_with(']'))
3533    {
3534        Some(
3535            trimmed[1..trimmed.len() - 1]
3536                .split(',')
3537                .filter(|part| !part.trim().is_empty())
3538                .count(),
3539        )
3540    } else {
3541        None
3542    }
3543}
3544
3545fn layout_for_node(kind: &NodeKind) -> LayoutFact {
3546    match kind {
3547        NodeKind::Call { callee } if callee.ends_with("contiguous") => LayoutFact::Contiguous,
3548        NodeKind::Call { callee }
3549            if ["transpose", "permute", "narrow", "t"]
3550                .iter()
3551                .any(|op| callee.ends_with(op)) =>
3552        {
3553            LayoutFact::Strided
3554        }
3555        _ => LayoutFact::Unknown,
3556    }
3557}
3558
3559fn shape_rule(op: &str) -> &'static str {
3560    match op {
3561        "reshape" | "broadcast_as" | "expand" => "explicit_argument",
3562        "flatten_all" | "flatten_to" | "flatten_from" => "flatten",
3563        "squeeze" => "remove_dimension",
3564        "unsqueeze" => "insert_dimension",
3565        "transpose" | "permute" | "t" => "permute_dimensions",
3566        "narrow" => "slice_dimension",
3567        "matmul" | "broadcast_matmul" => "matrix_product",
3568        _ => "preserve_or_operation_defined",
3569    }
3570}
3571
3572fn parse_device(device: &str) -> DeviceFact {
3573    let lower = device.to_ascii_lowercase();
3574    if lower == "cpu" {
3575        DeviceFact::Cpu
3576    } else if lower.starts_with("cuda") {
3577        let ordinal = lower
3578            .split([':', '(', ')'])
3579            .find_map(|part| part.parse::<u32>().ok());
3580        DeviceFact::Cuda { ordinal }
3581    } else if lower.starts_with("metal") {
3582        DeviceFact::Metal
3583    } else {
3584        DeviceFact::Unknown
3585    }
3586}