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