Skip to main content

candle_graph/
extract.rs

1//! The structure extraction pass.
2//!
3//! Walks a model's constructor, threading `VarBuilder` prefixes through local bindings and into
4//! nested constructors, and records every parameter-registering call it finds.
5//!
6//! # Supported subset
7//!
8//! This is a restricted-dialect analyzer, not a Rust compiler. It understands:
9//!
10//! * `vb.pp("lit")`, `vb.pp(format!("stem.{i}"))`, `set_prefix`, `root`, and chains of those
11//! * multiple `VarBuilder` parameters per constructor, tracked as distinct namespaces
12//! * `let` bindings whose initializer resolves to a `VarBuilder`
13//! * known candle-nn constructors (see [`crate::known`]) and raw `vb.get*` calls
14//! * `Type::method(.., vb_expr, ..)` calls into inherent methods of crate-local structs
15//! * `cond.then(|| vb.pp(..))`, the idiomatic optional-builder form
16//! * struct literals of crate-local types, as grouping nodes
17//! * `for` loops and `(a..b).map(|i| ..)` closures, recorded as repeats
18//! * `if` / `match` / `Option` branches, whose contents are marked conditional
19//!
20//! Anything else becomes a [`Diagnostic`] rather than a guess. In particular the analyzer never
21//! assumes an unresolved call registers no parameters; it says it does not know.
22
23use std::collections::HashMap;
24
25use crate::ir::*;
26use crate::known::{self, ParamKind, PrefixOp};
27use crate::load::{Container, Crate};
28
29/// Guards against pathological or cyclic model definitions.
30const MAX_DEPTH: usize = 32;
31const MAX_INSTANCES: usize = 20_000;
32
33/// A resolved `VarBuilder`: which namespace it belongs to, where in it we are, and whether it
34/// exists at all.
35///
36/// Certainty rides on the builder rather than on the call site because a constructor can take
37/// several builders with different certainties — a constructor may take a
38/// unconditional `vb` and an `Option`al LoRA builder. Merging them at the call site would mark
39/// the whole attention block conditional, which is false.
40#[derive(Clone, Debug)]
41struct VbVal {
42    /// Name of the constructor parameter this builder ultimately came from, e.g. `base_vb`.
43    root: String,
44    key: Key,
45    certainty: Certainty,
46}
47
48/// Lexical scope.
49///
50/// Closures are tracked because candle model code defines local constructor helpers
51/// (`let linear = |i, o, vb| if cfg.attention_bias { nn::linear(..) } else { .. }`). Such a
52/// binding shadows the candle-nn function of the same name, so matching calls against the known
53/// constructor table by name alone would silently attribute the wrong parameter set — in that
54/// example, reporting an unconditional `bias` that is really config-gated.
55#[derive(Clone, Default)]
56struct Env {
57    vb: HashMap<String, VbVal>,
58    closures: HashMap<String, syn::ExprClosure>,
59}
60
61impl Env {
62    /// Bind a closure's parameters for an inlined call. A parameter that cannot be resolved
63    /// *removes* any outer binding of the same name rather than leaving it visible: the
64    /// closure parameter shadows it in real Rust, and inheriting the outer value would compute
65    /// a confidently wrong prefix.
66    fn bind_param(&mut self, name: String, value: Option<VbVal>) {
67        match value {
68            Some(val) => {
69                self.vb.insert(name, val);
70            }
71            None => {
72                self.vb.remove(&name);
73            }
74        }
75    }
76}
77
78pub struct Extractor<'a> {
79    krate: &'a Crate,
80    known_candle_constructors: bool,
81    out: Structure,
82    defs: HashMap<(String, String), ModuleDefId>,
83    sites: HashMap<(ModuleDefId, usize, usize, usize, String), ParamSiteId>,
84    stack: Vec<(String, String)>,
85    truncated: bool,
86}
87
88#[derive(Clone)]
89struct Ctx {
90    def: ModuleDefId,
91    instance: ModuleInstanceId,
92    /// File containing the body currently being walked. `syn` spans carry a line but not a
93    /// file, so it has to be threaded down explicitly.
94    file: usize,
95    conditional: Option<String>,
96    repeat: Option<Repeat>,
97    depth: usize,
98}
99
100impl Ctx {
101    fn certainty(&self) -> Certainty {
102        match &self.conditional {
103            Some(reason) => Certainty::Conditional(reason.clone()),
104            None => Certainty::Certain,
105        }
106    }
107
108    fn in_branch(&self, reason: impl Into<String>) -> Self {
109        let mut next = self.clone();
110        // Keep the outermost reason: it is the one a reader needs to see first.
111        if next.conditional.is_none() {
112            next.conditional = Some(reason.into());
113        }
114        next
115    }
116}
117
118impl<'a> Extractor<'a> {
119    pub fn new(krate: &'a Crate) -> Self {
120        Self {
121            krate,
122            // Direct users of the extraction API opt into the analyzer's current catalog. Crate
123            // discovery uses `for_candle_version` so metadata can disable stale constructor
124            // assumptions.
125            known_candle_constructors: true,
126            out: Structure::default(),
127            defs: HashMap::new(),
128            sites: HashMap::new(),
129            stack: Vec::new(),
130            truncated: false,
131        }
132    }
133
134    /// Build an extractor whose candle-nn constructor catalog is enabled only for the audited
135    /// version. Raw `VarBuilder::get*` sites and crate-local constructors remain analyzable when
136    /// the dependency version is absent or unsupported.
137    pub fn for_candle_version(krate: &'a Crate, version: Option<&str>) -> Self {
138        let mut extractor = Self::new(krate);
139        extractor.known_candle_constructors =
140            version.is_some_and(crate::op_semantics::is_audited_candle_version);
141        extractor
142    }
143
144    pub fn run(mut self, root_type: &str, ctor: Option<&str>) -> anyhow::Result<Structure> {
145        let ctor_name = match ctor {
146            Some(name) => name.to_string(),
147            None => self.find_entry_ctor(root_type)?,
148        };
149
150        let candidates: Vec<_> = self
151            .krate
152            .method_candidates(root_type, &ctor_name)
153            .into_iter()
154            .filter(|func| func.trait_name.is_none())
155            .collect();
156        let func = match candidates.as_slice() {
157            [func] => *func,
158            [] => {
159                return Err(anyhow::anyhow!(
160                    "`{root_type}::{ctor_name}` not found in crate"
161                ))
162            }
163            _ => {
164                return Err(anyhow::anyhow!(
165                    "`{root_type}::{ctor_name}` is ambiguous ({} definitions); use a \
166                     module-qualified root and active Cargo cfg",
167                    candidates.len()
168                ))
169            }
170        };
171
172        let def = self.def_id(root_type, &ctor_name, func.span);
173        let primary_root = func
174            .vb_params
175            .first()
176            .and_then(|i| func.params.get(*i))
177            .cloned()
178            .unwrap_or_else(|| "vb".to_string());
179
180        let root = self.out.add_instance(
181            def,
182            None,
183            None,
184            Key::default(),
185            primary_root,
186            false,
187            None,
188            func.span,
189            Certainty::Certain,
190        );
191        self.out.root = Some(root);
192
193        // Every VarBuilder parameter starts at its own empty prefix in its own namespace.
194        let mut env = Env::default();
195        for index in &func.vb_params {
196            if let Some(name) = func.params.get(*index) {
197                env.vb.insert(
198                    name.clone(),
199                    VbVal {
200                        root: name.clone(),
201                        key: Key::default(),
202                        certainty: Certainty::Certain,
203                    },
204                );
205            }
206        }
207
208        let ctx = Ctx {
209            def,
210            instance: root,
211            file: func.span.file,
212            conditional: None,
213            repeat: None,
214            depth: 0,
215        };
216        self.stack.push((root_type.to_string(), ctor_name));
217        let block = func.block.clone();
218        self.walk_block(&block, &mut env, &ctx);
219        self.stack.pop();
220
221        if self.truncated {
222            self.out.diagnose(
223                SrcSpan::UNKNOWN,
224                format!("analysis truncated at {MAX_INSTANCES} instances; output is incomplete"),
225                None,
226            );
227        }
228        self.out.dedupe_params();
229        self.out.derive_prefixes();
230        Ok(self.out)
231    }
232
233    fn find_entry_ctor(&self, root_type: &str) -> anyhow::Result<String> {
234        // Prefer `new`, then `load`, so the common case needs no flag; otherwise pick the
235        // alphabetically first candidate so the choice is at least deterministic.
236        for preferred in ["new", "load"] {
237            let candidates: Vec<_> = self
238                .krate
239                .method_candidates(root_type, preferred)
240                .into_iter()
241                .filter(|func| func.trait_name.is_none() && !func.vb_params.is_empty())
242                .collect();
243            match candidates.len() {
244                0 => {}
245                1 => return Ok(preferred.to_string()),
246                count => {
247                    return Err(anyhow::anyhow!(
248                        "`{root_type}::{preferred}` is ambiguous ({count} active-or-cfg-gated \
249                         definitions); select a module-qualified root and Cargo configuration"
250                    ))
251                }
252            }
253        }
254        let mut candidates: Vec<String> = self
255            .krate
256            .all_methods()
257            .filter(|func| {
258                let owner_matches = if root_type.contains("::") {
259                    func.qualified_type_name == root_type
260                } else {
261                    func.type_name == root_type
262                };
263                owner_matches && func.trait_name.is_none() && !func.vb_params.is_empty()
264            })
265            .map(|func| func.fn_name.clone())
266            .collect();
267        candidates.sort();
268        candidates.dedup();
269        candidates.first().cloned().ok_or_else(|| {
270            anyhow::anyhow!(
271                "no constructor taking a VarBuilder found on `{root_type}`; \
272                 pass --ctor to choose one explicitly"
273            )
274        })
275    }
276
277    fn def_id(&mut self, type_name: &str, ctor: &str, span: SrcSpan) -> ModuleDefId {
278        let key = (type_name.to_string(), ctor.to_string());
279        if let Some(id) = self.defs.get(&key) {
280            return *id;
281        }
282        let id = self
283            .out
284            .add_def(type_name.to_string(), Some(ctor.to_string()), span);
285        self.defs.insert(key, id);
286        id
287    }
288
289    // ---------------------------------------------------------------- statements
290
291    fn walk_block(&mut self, block: &syn::Block, env: &mut Env, ctx: &Ctx) {
292        for stmt in &block.stmts {
293            self.walk_stmt(stmt, env, ctx);
294        }
295    }
296
297    fn walk_stmt(&mut self, stmt: &syn::Stmt, env: &mut Env, ctx: &Ctx) {
298        match stmt {
299            syn::Stmt::Local(local) => {
300                let Some(init) = &local.init else { return };
301                // A closure binding is recorded, not walked. Its body runs at each call site
302                // with the arguments bound there; walking it here would attribute parameters to
303                // whatever `vb` happened to be in scope at the definition.
304                if let syn::Expr::Closure(closure) = unwrap_expr(&init.expr) {
305                    if let Some(name) = binding_name(&local.pat) {
306                        env.closures.insert(name, closure.clone());
307                        return;
308                    }
309                }
310                if let Some(val) = self.eval_vb(&init.expr, env, ctx) {
311                    if let Some(name) = binding_name(&local.pat) {
312                        env.vb.insert(name, val);
313                        return;
314                    }
315                }
316                self.walk_expr(&init.expr, env, ctx);
317            }
318            syn::Stmt::Expr(expr, _) => self.walk_expr(expr, env, ctx),
319            syn::Stmt::Item(_) => {}
320            syn::Stmt::Macro(m) => {
321                if macro_cannot_register_params(&m.mac.path) {
322                    return;
323                }
324                self.out.diagnose(
325                    crate::load::span_of(ctx.file, m.mac.path.segments[0].ident.span()),
326                    format!(
327                        "macro `{}!` not expanded; any parameters it registers are invisible",
328                        crate::load::type_text(&m.mac.path)
329                    ),
330                    None,
331                );
332            }
333        }
334    }
335
336    // ---------------------------------------------------------------- expressions
337
338    fn walk_expr(&mut self, expr: &syn::Expr, env: &mut Env, ctx: &Ctx) {
339        if self.out.instances.len() > MAX_INSTANCES {
340            self.truncated = true;
341            return;
342        }
343        // A call to a local closure must be inlined before the known-constructor table is
344        // consulted, because a local binding shadows the candle-nn function of the same name.
345        if self.try_local_closure(expr, env, ctx) {
346            return;
347        }
348        if self.try_param_site(expr, env, ctx) {
349            return;
350        }
351        if self.try_free_function(expr, env, ctx) {
352            return;
353        }
354        if self.try_submodule(expr, env, ctx, None) {
355            return;
356        }
357
358        match unwrap_expr(expr) {
359            syn::Expr::Struct(s) => self.walk_struct_literal(s, env, ctx),
360            syn::Expr::ForLoop(f) => {
361                let mut inner = ctx.clone();
362                inner.repeat = Some(Repeat {
363                    var: binding_name(&f.pat).unwrap_or_else(|| "_".to_string()),
364                    bound: crate::load::type_text(&f.expr),
365                });
366                let mut scoped = env.clone();
367                self.walk_block(&f.body, &mut scoped, &inner);
368            }
369            syn::Expr::While(w) => {
370                self.walk_expr(&w.cond, env, ctx);
371                let mut inner = ctx.clone();
372                inner.repeat = Some(Repeat {
373                    var: "_".to_string(),
374                    bound: format!("while {}", crate::load::type_text(&w.cond)),
375                });
376                let mut scoped = env.clone();
377                self.walk_block(&w.body, &mut scoped, &inner);
378            }
379            syn::Expr::Loop(l) => {
380                let mut inner = ctx.clone();
381                inner.repeat = Some(Repeat {
382                    var: "_".to_string(),
383                    bound: "loop".to_string(),
384                });
385                let mut scoped = env.clone();
386                self.walk_block(&l.body, &mut scoped, &inner);
387            }
388            syn::Expr::If(i) => {
389                let branch = ctx.in_branch(format!("if {}", crate::load::type_text(&i.cond)));
390                let mut scoped = env.clone();
391                self.walk_block(&i.then_branch, &mut scoped, &branch);
392                if let Some((_, alt)) = &i.else_branch {
393                    let mut scoped = env.clone();
394                    self.walk_expr(alt, &mut scoped, &branch);
395                }
396            }
397            syn::Expr::Match(m) => {
398                let branch = ctx.in_branch(format!("match {}", crate::load::type_text(&m.expr)));
399                for arm in &m.arms {
400                    let mut scoped = env.clone();
401                    self.walk_expr(&arm.body, &mut scoped, &branch);
402                }
403            }
404            syn::Expr::Closure(c) => {
405                // Reached without a receiver to bind from, so the parameters shadow into
406                // nothing rather than picking up an unrelated outer builder.
407                let mut scoped = env.clone();
408                for param in &c.inputs {
409                    if let Some(name) = binding_name(param) {
410                        scoped.bind_param(name, None);
411                    }
412                }
413                self.walk_expr(&c.body, &mut scoped, ctx);
414            }
415            syn::Expr::MethodCall(mc) => {
416                // `(0..n).map(|i| Layer::new(..))` is the iterator spelling of a layer stack;
417                // recording a repeat keeps such families visible.
418                let mut inner = ctx.clone();
419                // `cond.then(|| nn::linear(..))` gates a whole constructor, not just a
420                // builder. Without this, an untaken branch's parameters are reported as
421                // certain — tied embeddings can mean a separate `lm_head` does not exist.
422                if matches!(mc.method.to_string().as_str(), "then" | "then_some") {
423                    inner = inner.in_branch(format!(
424                        "only when {}",
425                        crate::load::type_text(&mc.receiver)
426                    ));
427                }
428                // Only a genuine iteration is a repeat. `opt_vb.as_ref().map(..)` is an
429                // `Option` combinator over a single value and must not be reported as a
430                // layer family.
431                if mc.method == "map"
432                    && inner.repeat.is_none()
433                    && looks_like_iteration(&mc.receiver)
434                {
435                    inner.repeat = Some(Repeat {
436                        var: "_".to_string(),
437                        bound: crate::load::type_text(&mc.receiver),
438                    });
439                }
440                // `opt_vb.as_ref().map(|vb| Lora::new(.., vb.pp("q")))` — the closure parameter
441                // shadows any outer `vb`, and its value is the receiver's builder. Binding it
442                // is what puts the LoRA parameters under the LoRA builder's prefix instead of
443                // the enclosing module's.
444                let receiver_vb = self.eval_vb(&mc.receiver, env, &inner);
445                self.walk_expr(&mc.receiver, env, &inner);
446                for arg in &mc.args {
447                    if let syn::Expr::Closure(closure) = unwrap_expr(arg) {
448                        let mut scoped = env.clone();
449                        for (index, param) in closure.inputs.iter().enumerate() {
450                            if let Some(name) = binding_name(param) {
451                                let value = if index == 0 {
452                                    receiver_vb.clone()
453                                } else {
454                                    None
455                                };
456                                scoped.bind_param(name, value);
457                            }
458                        }
459                        self.walk_expr(&closure.body, &mut scoped, &inner);
460                    } else {
461                        self.walk_expr(arg, env, &inner);
462                    }
463                }
464            }
465            other => self.walk_children(other, env, ctx),
466        }
467    }
468
469    /// Inline a call to a locally bound closure, binding its parameters from the call site.
470    fn try_local_closure(&mut self, expr: &syn::Expr, env: &mut Env, ctx: &Ctx) -> bool {
471        let syn::Expr::Call(call) = unwrap_expr(expr) else {
472            return false;
473        };
474        let Some(path) = call_path(&call.func) else {
475            return false;
476        };
477        if path.len() != 1 {
478            return false;
479        }
480        let Some(closure) = env.closures.get(&path[0]).cloned() else {
481            return false;
482        };
483
484        let mut scoped = env.clone();
485        // Drop the binding inside its own body so a self-referential helper cannot loop.
486        scoped.closures.remove(&path[0]);
487        for (param, arg) in closure.inputs.iter().zip(call.args.iter()) {
488            if let Some(name) = binding_name(param) {
489                let value = self.eval_vb(arg, env, ctx);
490                scoped.bind_param(name, value);
491            }
492        }
493        // Parameters the call does not supply are unbound, not inherited.
494        for param in closure.inputs.iter().skip(call.args.len()) {
495            if let Some(name) = binding_name(param) {
496                scoped.bind_param(name, None);
497            }
498        }
499
500        self.walk_expr(&closure.body, &mut scoped, ctx);
501        true
502    }
503
504    /// A struct literal of a crate-local type becomes a grouping instance. It owns no builder,
505    /// so its prefix is derived afterwards from its descendants rather than invented.
506    fn walk_struct_literal(&mut self, s: &syn::ExprStruct, env: &mut Env, ctx: &Ctx) {
507        let type_name = s.path.segments.last().map(|seg| seg.ident.to_string());
508        let own_type = self.out.def(ctx.def).name.clone();
509
510        let group = match &type_name {
511            // `Self { .. }` / `Foo { .. }` inside `Foo::new` is the constructor's own return
512            // value, not a nested module.
513            Some(name)
514                if name != "Self"
515                    && *name != own_type
516                    && self.krate.struct_candidates(name).len() == 1 =>
517            {
518                let def = self.def_id(name, "<struct literal>", span_of_expr(ctx, &s.path));
519                let id = self.out.add_instance(
520                    def,
521                    Some(ctx.instance),
522                    None,
523                    Key::default(),
524                    String::new(),
525                    true,
526                    ctx.repeat.clone(),
527                    span_of_expr(ctx, &s.path),
528                    ctx.certainty(),
529                );
530                Some((def, id))
531            }
532            _ => None,
533        };
534
535        let inner = match group {
536            Some((def, instance)) => Ctx {
537                def,
538                instance,
539                ..ctx.clone()
540            },
541            None => ctx.clone(),
542        };
543
544        for field in &s.fields {
545            let name = match &field.member {
546                syn::Member::Named(id) => Some(id.to_string()),
547                syn::Member::Unnamed(i) => Some(i.index.to_string()),
548            };
549            self.walk_field_expr(&field.expr, env, &inner, name);
550        }
551    }
552
553    fn walk_field_expr(
554        &mut self,
555        expr: &syn::Expr,
556        env: &mut Env,
557        ctx: &Ctx,
558        field: Option<String>,
559    ) {
560        // An `Option` field means the module may not exist at runtime.
561        let ctx = match &field {
562            Some(name) if self.field_is_option(ctx, name) => {
563                &ctx.in_branch(format!("Option field `{name}`"))
564            }
565            _ => ctx,
566        };
567
568        // Same ordering rule as `walk_expr`: a local closure shadows the candle-nn function of
569        // the same name, so it has to be inlined before the constructor table is consulted.
570        if self.try_local_closure(expr, env, ctx) {
571            return;
572        }
573        if self.try_param_site(expr, env, ctx) {
574            return;
575        }
576        if self.try_free_function(expr, env, ctx) {
577            return;
578        }
579        if self.try_submodule(expr, env, ctx, field) {
580            return;
581        }
582        self.walk_expr(expr, env, ctx);
583    }
584
585    fn field_is_option(&self, ctx: &Ctx, field: &str) -> bool {
586        let candidates = self.krate.struct_candidates(&self.out.def(ctx.def).name);
587        candidates
588            .first()
589            .filter(|_| candidates.len() == 1)
590            .and_then(|s| s.fields.iter().find(|f| f.name == field))
591            .map(|f| f.ty.container == Container::Option)
592            .unwrap_or(false)
593    }
594
595    fn walk_children(&mut self, expr: &syn::Expr, env: &mut Env, ctx: &Ctx) {
596        use syn::Expr as E;
597        match expr {
598            E::Call(c) => {
599                if c.args
600                    .iter()
601                    .any(|arg| self.eval_vb(arg, env, ctx).is_some())
602                {
603                    let target = call_path(&c.func)
604                        .map(|path| path.join("::"))
605                        .unwrap_or_else(|| crate::load::type_text(&c.func));
606                    self.out.diagnose(
607                        span_of_expr(ctx, c),
608                        format!(
609                            "unresolved call `{target}` receives a VarBuilder; any parameters it \
610                             registers are unknown"
611                        ),
612                        None,
613                    );
614                }
615                for a in &c.args {
616                    self.walk_expr(a, env, ctx);
617                }
618            }
619            E::Try(t) => self.walk_expr(&t.expr, env, ctx),
620            E::Reference(r) => self.walk_expr(&r.expr, env, ctx),
621            E::Paren(p) => self.walk_expr(&p.expr, env, ctx),
622            E::Group(g) => self.walk_expr(&g.expr, env, ctx),
623            E::Block(b) => {
624                let mut scoped = env.clone();
625                self.walk_block(&b.block, &mut scoped, ctx);
626            }
627            E::Unsafe(u) => {
628                let mut scoped = env.clone();
629                self.walk_block(&u.block, &mut scoped, ctx);
630            }
631            E::Tuple(t) => {
632                for e in &t.elems {
633                    self.walk_expr(e, env, ctx);
634                }
635            }
636            E::Array(a) => {
637                for e in &a.elems {
638                    self.walk_expr(e, env, ctx);
639                }
640            }
641            E::Return(r) => {
642                if let Some(e) = &r.expr {
643                    self.walk_expr(e, env, ctx);
644                }
645            }
646            E::Let(l) => self.walk_expr(&l.expr, env, ctx),
647            E::Assign(a) => self.walk_expr(&a.right, env, ctx),
648            E::Binary(b) => {
649                self.walk_expr(&b.left, env, ctx);
650                self.walk_expr(&b.right, env, ctx);
651            }
652            _ => {}
653        }
654    }
655
656    // ---------------------------------------------------------------- parameter sites
657
658    fn try_param_site(&mut self, expr: &syn::Expr, env: &mut Env, ctx: &Ctx) -> bool {
659        let expr = unwrap_expr(expr);
660
661        // `nn::linear(a, b, vb.pp("q"))`
662        if let syn::Expr::Call(call) = expr {
663            let Some(path) = call_path(&call.func) else {
664                return false;
665            };
666            let Some(func) = path.last() else {
667                return false;
668            };
669            // Defence in depth: never match a bare name against the candle-nn table while a
670            // local binding of that name is in scope.
671            if path.len() == 1 && env.closures.contains_key(func) {
672                return false;
673            }
674            let resolved_path = self.krate.resolve_unambiguous_import_path(&path);
675            let Some(ctor) = self
676                .known_candle_constructors
677                .then(|| known_constructor(resolved_path.as_slice(), func))
678                .flatten()
679            else {
680                return false;
681            };
682
683            let span = span_of_expr(ctx, expr);
684            let Some(vb) = self.resolve_vb_arg(&call.args, ctor.vb_arg, env, ctx) else {
685                self.out.diagnose(
686                    span,
687                    format!(
688                        "`{}` call whose VarBuilder argument could not be resolved to a prefix",
689                        path.join("::")
690                    ),
691                    None,
692                );
693                return true;
694            };
695
696            let rnn = rnn_config(func, &call.args);
697            for leaf in ctor.leaves {
698                // A config that visibly opts out of the biases proves those tensors are absent,
699                // so recording them at all would invent parameters.
700                if leaf.kind == ParamKind::Bias && rnn.biases == Some(false) {
701                    continue;
702                }
703                let shape = constructor_leaf_shape(func, leaf.name, &call.args);
704                // A visibly default config resolves the otherwise-conditional biases to present.
705                let unconditional = leaf.unconditional
706                    || (leaf.kind == ParamKind::Bias && rnn.biases == Some(true));
707                let leaf_certainty = combine(
708                    &vb.certainty,
709                    &ctx.certainty(),
710                    unconditional,
711                    &format!(
712                        "`{func}` registers `{}` only in some configurations",
713                        leaf.name
714                    ),
715                );
716                // `LSTM::new` formats its names from the config, so an unresolved config licenses
717                // only the layer/direction *family*, never the plain `_l0` spelling.
718                let named_by_config = leaf.config_named && !rnn.default_names;
719                let leaf_seg = if named_by_config {
720                    self.out.diagnose(
721                        span,
722                        format!(
723                            "`{func}` names `{}` from its config argument; unresolved \
724                             `layer_idx`/`direction` leaves a tensor-name family",
725                            leaf.name
726                        ),
727                        None,
728                    );
729                    KeySeg::Template {
730                        text: config_named_family(leaf.name),
731                    }
732                } else {
733                    KeySeg::Literal(leaf.name.to_string())
734                };
735                let site = self.site_for(
736                    ctx,
737                    span,
738                    leaf.name,
739                    Acquisition::Constructor {
740                        func: path.join("::"),
741                        cite: ctor.cite,
742                    },
743                    Key::default().push(leaf_seg.clone()),
744                    leaf.kind,
745                    shape,
746                    leaf_certainty.clone(),
747                );
748                let key = vb.key.push(leaf_seg);
749                self.out
750                    .add_param(site, ctx.instance, key, vb.root.clone(), leaf_certainty);
751            }
752            return true;
753        }
754
755        // `vb.get((a, b), "weight")`
756        if let syn::Expr::MethodCall(mc) = expr {
757            let method = mc.method.to_string();
758            let Some(name_arg) = known::raw_get_name_arg(&method) else {
759                return false;
760            };
761            let Some(vb) = self.eval_vb(&mc.receiver, env, ctx) else {
762                return false;
763            };
764            let span = span_of_expr(ctx, expr);
765            let Some(name_expr) = mc.args.iter().nth(name_arg) else {
766                return false;
767            };
768            let Some(name) = string_literal(name_expr) else {
769                self.out.diagnose(
770                    span,
771                    format!("`{method}` with a non-literal tensor name; key is unknown"),
772                    Some(vb.key.clone()),
773                );
774                return true;
775            };
776
777            let shape = if name_arg > 0 {
778                mc.args.first().map(crate::load::type_text)
779            } else {
780                None
781            };
782            let leaf_certainty = combine(&vb.certainty, &ctx.certainty(), true, "");
783            let site = self.site_for(
784                ctx,
785                span,
786                &name,
787                Acquisition::RawGet { method },
788                Key::default().push_literal(&name),
789                ParamKind::Raw,
790                shape,
791                leaf_certainty.clone(),
792            );
793            let key = vb.key.push_literal(&name);
794            self.out
795                .add_param(site, ctx.instance, key, vb.root, leaf_certainty);
796            return true;
797        }
798
799        false
800    }
801
802    #[allow(clippy::too_many_arguments)]
803    fn site_for(
804        &mut self,
805        ctx: &Ctx,
806        span: SrcSpan,
807        leaf: &str,
808        acquisition: Acquisition,
809        relative_key: Key,
810        kind: ParamKind,
811        shape: Option<String>,
812        certainty: Certainty,
813    ) -> ParamSiteId {
814        // One site per source position per leaf name, so a def instantiated many times reuses
815        // its sites while `weight` and `bias` from one call stay distinct.
816        let cache_key = (ctx.def, span.file, span.line, span.col, leaf.to_string());
817        if let Some(id) = self.sites.get(&cache_key) {
818            return *id;
819        }
820        let id = self.out.add_site(
821            ctx.def,
822            acquisition,
823            relative_key,
824            kind,
825            shape,
826            span,
827            certainty,
828        );
829        self.sites.insert(cache_key, id);
830        id
831    }
832
833    // ---------------------------------------------------------------- nested modules
834
835    /// Inline a crate-local free helper that accepts one or more `VarBuilder`s.
836    ///
837    /// Helpers such as `fn build_projection(vb: VarBuilder) -> Result<Linear>` are common in
838    /// model crates. They do not form a named module instance on their own, so their parameter
839    /// sites remain owned by the calling instance while their source file and builder bindings
840    /// come from the helper body.
841    fn try_free_function(&mut self, expr: &syn::Expr, env: &mut Env, ctx: &Ctx) -> bool {
842        let syn::Expr::Call(call) = unwrap_expr(expr) else {
843            return false;
844        };
845        let Some(path) = call_path(&call.func) else {
846            return false;
847        };
848        let Some(name) = path.last().cloned() else {
849            return false;
850        };
851        let lookup_name = normalize_qualified_path(&path);
852        let candidates = self.krate.function_candidates(&lookup_name);
853        let func = match candidates.as_slice() {
854            [func] => *func,
855            [] => return false,
856            _ => {
857                if candidates.iter().any(|func| !func.vb_params.is_empty()) {
858                    self.out.diagnose(
859                        span_of_expr(ctx, expr),
860                        format!(
861                            "free function `{lookup_name}` is ambiguous ({} definitions); \
862                             parameters cannot be attributed safely",
863                            candidates.len()
864                        ),
865                        None,
866                    );
867                    return true;
868                }
869                return false;
870            }
871        };
872        if func.vb_params.is_empty() {
873            return false;
874        }
875
876        let span = span_of_expr(ctx, expr);
877        let stack_key = ("<free>".to_string(), name.clone());
878        if ctx.depth >= MAX_DEPTH {
879            self.out.diagnose(
880                span,
881                format!("recursion depth {MAX_DEPTH} exceeded at free function `{name}`"),
882                None,
883            );
884            return true;
885        }
886        if self.stack.contains(&stack_key) {
887            self.out.diagnose(
888                span,
889                format!("cycle through free function `{name}`; body not expanded"),
890                None,
891            );
892            return true;
893        }
894
895        let mut helper_env = Env::default();
896        let mut resolved = 0usize;
897        for index in &func.vb_params {
898            let Some(param) = func.params.get(*index) else {
899                continue;
900            };
901            let Some(arg) = call.args.iter().nth(*index) else {
902                self.out.diagnose(
903                    span,
904                    format!("free function `{name}` is missing VarBuilder argument `{param}`"),
905                    None,
906                );
907                continue;
908            };
909            match self.eval_vb(arg, env, ctx) {
910                Some(value) => {
911                    helper_env.vb.insert(param.clone(), value);
912                    resolved += 1;
913                }
914                None => self.out.diagnose(
915                    span,
916                    format!(
917                        "free function `{name}` argument `{param}` is a VarBuilder whose prefix \
918                         could not be resolved"
919                    ),
920                    None,
921                ),
922            }
923        }
924        if resolved == 0 {
925            return true;
926        }
927
928        let helper_ctx = Ctx {
929            file: func.span.file,
930            depth: ctx.depth + 1,
931            ..ctx.clone()
932        };
933        self.stack.push(stack_key);
934        let block = func.block.clone();
935        self.walk_block(&block, &mut helper_env, &helper_ctx);
936        self.stack.pop();
937        true
938    }
939
940    /// Follow `Type::ctor(.., vb_expr, ..)` into a crate-local inherent method.
941    fn try_submodule(
942        &mut self,
943        expr: &syn::Expr,
944        env: &mut Env,
945        ctx: &Ctx,
946        field: Option<String>,
947    ) -> bool {
948        let expr = unwrap_expr(expr);
949        let syn::Expr::Call(call) = expr else {
950            return false;
951        };
952        let Some(path) = call_path(&call.func) else {
953            return false;
954        };
955        if path.len() < 2 {
956            return false;
957        }
958        let ctor_name = path[path.len() - 1].clone();
959        let raw_type = path[..path.len() - 1].join("::");
960        let type_name = if raw_type == "Self" {
961            self.out.def(ctx.def).name.clone()
962        } else {
963            normalize_qualified_name(&raw_type)
964        };
965
966        let candidates: Vec<_> = self
967            .krate
968            .method_candidates(&type_name, &ctor_name)
969            .into_iter()
970            .filter(|func| func.trait_name.is_none())
971            .collect();
972        let func = match candidates.as_slice() {
973            [func] => *func,
974            [] => {
975                if call
976                    .args
977                    .iter()
978                    .any(|arg| self.eval_vb(arg, env, ctx).is_some())
979                {
980                    self.out.diagnose(
981                        span_of_expr(ctx, expr),
982                        format!(
983                            "unresolved constructor `{type_name}::{ctor_name}` receives a \
984                             VarBuilder; its parameters are unknown"
985                        ),
986                        None,
987                    );
988                    return true;
989                }
990                return false;
991            }
992            _ => {
993                self.out.diagnose(
994                    span_of_expr(ctx, expr),
995                    format!(
996                        "constructor `{type_name}::{ctor_name}` is ambiguous ({} definitions); \
997                         subtree not expanded",
998                        candidates.len()
999                    ),
1000                    None,
1001                );
1002                return true;
1003            }
1004        };
1005        if func.vb_params.is_empty() {
1006            return false;
1007        }
1008
1009        let span = span_of_expr(ctx, expr);
1010
1011        // Bind every VarBuilder parameter of the callee from its corresponding argument. A
1012        // constructor taking a frozen and a trainable builder needs both, and they are
1013        // separate namespaces.
1014        let mut bindings: Vec<(String, VbVal)> = Vec::new();
1015        let mut primary: Option<VbVal> = None;
1016
1017        for index in &func.vb_params {
1018            let Some(arg) = call.args.iter().nth(*index) else {
1019                continue;
1020            };
1021            let Some(name) = func.params.get(*index) else {
1022                continue;
1023            };
1024            match self.eval_vb(arg, env, ctx) {
1025                Some(val) => {
1026                    // The *first* resolvable builder defines the instance. Later builders may
1027                    // be conditional (an optional LoRA adapter) without making the module
1028                    // itself conditional; their certainty stays on their own bindings.
1029                    if primary.is_none() {
1030                        primary = Some(val.clone());
1031                    }
1032                    bindings.push((name.clone(), val));
1033                }
1034                None => {
1035                    self.out.diagnose(
1036                        span,
1037                        format!(
1038                            "`{type_name}::{ctor_name}` argument `{name}` is a VarBuilder whose \
1039                             prefix could not be resolved; parameters under it are missing"
1040                        ),
1041                        None,
1042                    );
1043                }
1044            }
1045        }
1046
1047        let Some(primary) = primary else {
1048            self.out.diagnose(
1049                span,
1050                format!("`{type_name}::{ctor_name}` VarBuilder arguments not resolvable"),
1051                None,
1052            );
1053            return true;
1054        };
1055
1056        if ctx.depth >= MAX_DEPTH {
1057            self.out.diagnose(
1058                span,
1059                format!("recursion depth {MAX_DEPTH} exceeded at `{type_name}::{ctor_name}`"),
1060                Some(primary.key),
1061            );
1062            return true;
1063        }
1064        if self.stack.contains(&(type_name.clone(), ctor_name.clone())) {
1065            self.out.diagnose(
1066                span,
1067                format!("cycle through `{type_name}::{ctor_name}`; subtree not expanded"),
1068                Some(primary.key),
1069            );
1070            return true;
1071        }
1072
1073        let certainty = merge(ctx.certainty(), primary.certainty.clone());
1074        // `Self::new` commonly delegates to `Self::new_impl`. A same-type helper used as the
1075        // current constructor expression is the current instance, not a nested model module.
1076        if raw_type == "Self" && field.is_none() {
1077            let mut helper_env = Env::default();
1078            for (name, val) in bindings {
1079                helper_env.vb.insert(name, val);
1080            }
1081            let helper_ctx = Ctx {
1082                file: func.span.file,
1083                conditional: match &certainty {
1084                    Certainty::Conditional(reason) => Some(reason.clone()),
1085                    _ => ctx.conditional.clone(),
1086                },
1087                depth: ctx.depth + 1,
1088                ..ctx.clone()
1089            };
1090            self.stack.push((type_name, ctor_name));
1091            let block = func.block.clone();
1092            self.walk_block(&block, &mut helper_env, &helper_ctx);
1093            self.stack.pop();
1094            return true;
1095        }
1096
1097        let child_def = self.def_id(&type_name, &ctor_name, func.span);
1098        let child = self.out.add_instance(
1099            child_def,
1100            Some(ctx.instance),
1101            field,
1102            primary.key.clone(),
1103            primary.root.clone(),
1104            false,
1105            ctx.repeat.clone(),
1106            span,
1107            certainty.clone(),
1108        );
1109
1110        let mut child_env = Env::default();
1111        for (name, val) in bindings {
1112            child_env.vb.insert(name, val);
1113        }
1114        let child_ctx = Ctx {
1115            def: child_def,
1116            instance: child,
1117            file: func.span.file,
1118            conditional: match &certainty {
1119                Certainty::Conditional(reason) => Some(reason.clone()),
1120                _ => None,
1121            },
1122            // The child's own prefix already carries any dynamic segment, so re-marking the
1123            // repeat inside would double-count it.
1124            repeat: None,
1125            depth: ctx.depth + 1,
1126        };
1127
1128        self.stack.push((type_name, ctor_name));
1129        let block = func.block.clone();
1130        self.walk_block(&block, &mut child_env, &child_ctx);
1131        self.stack.pop();
1132        true
1133    }
1134
1135    // ---------------------------------------------------------------- VarBuilder algebra
1136
1137    fn resolve_vb_arg(
1138        &mut self,
1139        args: &syn::punctuated::Punctuated<syn::Expr, syn::Token![,]>,
1140        index: usize,
1141        env: &Env,
1142        ctx: &Ctx,
1143    ) -> Option<VbVal> {
1144        args.iter()
1145            .nth(index)
1146            .and_then(|arg| self.eval_vb(arg, env, ctx))
1147    }
1148
1149    fn eval_vb(&mut self, expr: &syn::Expr, env: &Env, ctx: &Ctx) -> Option<VbVal> {
1150        let expr = unwrap_expr(expr);
1151        match expr {
1152            syn::Expr::Path(p) => {
1153                let name = p.path.segments.last()?.ident.to_string();
1154                env.vb.get(&name).cloned()
1155            }
1156            syn::Expr::MethodCall(mc) => {
1157                let method = mc.method.to_string();
1158
1159                // `cond.then(|| vb.pp(..))` / `cond.then_some(vb)` — the idiomatic optional
1160                // builder. The prefix is real but its existence is conditional.
1161                if method == "then" || method == "then_some" {
1162                    let arg = mc.args.first()?;
1163                    let inner = match unwrap_expr(arg) {
1164                        syn::Expr::Closure(c) => c.body.as_ref(),
1165                        other => other,
1166                    };
1167                    let val = self.eval_vb(inner, env, ctx)?;
1168                    let reason = format!("only when {}", crate::load::type_text(&mc.receiver));
1169                    return Some(VbVal {
1170                        certainty: merge(val.certainty, Certainty::Conditional(reason)),
1171                        ..val
1172                    });
1173                }
1174
1175                // `opt_vb.as_ref().map(|vb| vb.pp("q"))` — an optional builder threaded through
1176                // `Option` combinators. The inner closure body is the builder expression, with
1177                // the closure parameter bound to the receiver's builder.
1178                if method == "map" || method == "and_then" {
1179                    let base = self.eval_vb(&mc.receiver, env, ctx)?;
1180                    let arg = mc.args.first()?;
1181                    let syn::Expr::Closure(closure) = unwrap_expr(arg) else {
1182                        return None;
1183                    };
1184                    let param = closure.inputs.first().and_then(binding_name)?;
1185                    let mut scoped = env.clone();
1186                    scoped.vb.insert(param, base.clone());
1187                    let val = self.eval_vb(&closure.body, &scoped, ctx)?;
1188                    return Some(VbVal {
1189                        certainty: merge(val.certainty, base.certainty),
1190                        ..val
1191                    });
1192                }
1193
1194                // These return the same logical builder with metadata or ownership adjusted.
1195                // For example, a model may use `vb.pp("norm").to_dtype(F32)` for batch norm.
1196                if matches!(
1197                    method.as_str(),
1198                    "as_ref" | "as_mut" | "clone" | "to_owned" | "to_dtype"
1199                ) {
1200                    return self.eval_vb(&mc.receiver, env, ctx);
1201                }
1202
1203                let op = known::prefix_method(&method)?;
1204                let base = self.eval_vb(&mc.receiver, env, ctx)?;
1205                match op {
1206                    PrefixOp::Root => Some(VbVal {
1207                        key: Key::default(),
1208                        ..base
1209                    }),
1210                    PrefixOp::Push | PrefixOp::Replace => {
1211                        let arg = mc.args.first()?;
1212                        let (segs, seg_certainty) = self.eval_prefix_arg(arg, ctx);
1213                        let start = if op == PrefixOp::Replace {
1214                            Key::default()
1215                        } else {
1216                            base.key
1217                        };
1218                        Some(VbVal {
1219                            root: base.root,
1220                            key: start.extend(&segs),
1221                            certainty: merge(base.certainty, seg_certainty),
1222                        })
1223                    }
1224                }
1225            }
1226            syn::Expr::Call(c) => {
1227                let path = call_path(&c.func)?;
1228                if path.last().map(String::as_str) == Some("Some") && c.args.len() == 1 {
1229                    return self.eval_vb(&c.args[0], env, ctx);
1230                }
1231                None
1232            }
1233            _ => None,
1234        }
1235    }
1236
1237    fn eval_prefix_arg(&mut self, expr: &syn::Expr, ctx: &Ctx) -> (Vec<KeySeg>, Certainty) {
1238        let expr = unwrap_expr(expr);
1239
1240        if let Some(text) = string_literal(expr) {
1241            return (literal_segs(&text), Certainty::Certain);
1242        }
1243
1244        // `format!("layers.{index}")` — the only macro interpreted, deliberately.
1245        if let syn::Expr::Macro(m) = expr {
1246            if m.mac
1247                .path
1248                .segments
1249                .last()
1250                .map(|s| s.ident.to_string())
1251                .as_deref()
1252                == Some("format")
1253            {
1254                if let Some(segs) = format_segs(&m.mac.tokens.to_string()) {
1255                    return (segs, Certainty::Certain);
1256                }
1257            }
1258        }
1259
1260        // Anything else: the prefix level exists but its text is a runtime value. Recording it
1261        // as dynamic keeps key arity correct instead of silently dropping a level.
1262        let text = crate::load::type_text(expr);
1263        self.out.diagnose(
1264            span_of_expr(ctx, expr),
1265            format!("prefix argument `{text}` is not a literal or `format!`; segment is dynamic"),
1266            None,
1267        );
1268        (vec![KeySeg::Dynamic { expr: text }], Certainty::Certain)
1269    }
1270}
1271
1272// -------------------------------------------------------------------- helpers
1273
1274fn combine(a: &Certainty, b: &Certainty, unconditional: bool, reason: &str) -> Certainty {
1275    let merged = merge(a.clone(), b.clone());
1276    if unconditional {
1277        merged
1278    } else {
1279        match merged {
1280            Certainty::Certain => Certainty::Conditional(reason.to_string()),
1281            other => other,
1282        }
1283    }
1284}
1285
1286/// Least-certain-wins join. `Unknown` dominates `Conditional` dominates `Certain`, so nothing
1287/// is ever reported as more certain than its least certain ancestor.
1288fn merge(a: Certainty, b: Certainty) -> Certainty {
1289    match (&a, &b) {
1290        (Certainty::Unknown(_), _) => a,
1291        (_, Certainty::Unknown(_)) => b,
1292        (Certainty::Conditional(_), _) => a,
1293        (_, Certainty::Conditional(_)) => b,
1294        _ => Certainty::Certain,
1295    }
1296}
1297
1298fn literal_segs(text: &str) -> Vec<KeySeg> {
1299    text.split('.')
1300        .filter(|p| !p.is_empty())
1301        .map(|p| KeySeg::Literal(p.to_string()))
1302        .collect()
1303}
1304
1305/// Parse a `format!` invocation into key segments.
1306///
1307/// Handles the shapes that appear in candle model code: `"layers.{i}"` with inline captures and
1308/// `"layers.{}"` with a positional argument. Returns `None` when the template is not a plain
1309/// string literal, so the caller falls back to a dynamic segment.
1310fn format_segs(tokens: &str) -> Option<Vec<KeySeg>> {
1311    let tokens = tokens.trim();
1312    let rest = tokens.strip_prefix('"')?;
1313    let end = find_unescaped_quote(rest)?;
1314    let template = &rest[..end];
1315    let args: Vec<String> = rest[end + 1..]
1316        .trim_start()
1317        .trim_start_matches(',')
1318        .split(',')
1319        .map(|a| a.trim().to_string())
1320        .filter(|a| !a.is_empty())
1321        .collect();
1322
1323    let mut positional = args.into_iter();
1324    let mut segs = Vec::new();
1325    for part in template.split('.').filter(|p| !p.is_empty()) {
1326        if let Some(inner) = brace_content(part) {
1327            let expr = if inner.is_empty() {
1328                positional.next().unwrap_or_else(|| "_".to_string())
1329            } else {
1330                // `{index}` and `{index:?}` both name a capture.
1331                inner.split(':').next().unwrap_or(inner).to_string()
1332            };
1333            segs.push(KeySeg::Dynamic { expr });
1334        } else if part.contains('{') {
1335            // Mixed literal and placeholder in one segment, e.g. `block{i}`. Keeping the
1336            // template text preserves the information without pretending to resolve it.
1337            segs.push(KeySeg::Template {
1338                text: part.to_string(),
1339            });
1340        } else {
1341            segs.push(KeySeg::Literal(part.to_string()));
1342        }
1343    }
1344    Some(segs)
1345}
1346
1347fn brace_content(part: &str) -> Option<&str> {
1348    let inner = part.strip_prefix('{')?.strip_suffix('}')?;
1349    (!inner.contains('{')).then_some(inner)
1350}
1351
1352fn find_unescaped_quote(s: &str) -> Option<usize> {
1353    let bytes = s.as_bytes();
1354    let mut i = 0;
1355    while i < bytes.len() {
1356        match bytes[i] {
1357            b'\\' => i += 2,
1358            b'"' => return Some(i),
1359            _ => i += 1,
1360        }
1361    }
1362    None
1363}
1364
1365/// Macros whose expansion cannot contain a parameter-registering expression.
1366///
1367/// Unknown/custom macros still produce a diagnostic because they may hide arbitrary model
1368/// construction. These built-ins and common error/logging macros only control flow, validate,
1369/// or emit text, so warning about invisible parameters is a false positive.
1370fn macro_cannot_register_params(path: &syn::Path) -> bool {
1371    let Some(name) = path
1372        .segments
1373        .last()
1374        .map(|segment| segment.ident.to_string())
1375    else {
1376        return false;
1377    };
1378    matches!(
1379        name.as_str(),
1380        "assert"
1381            | "assert_eq"
1382            | "assert_ne"
1383            | "debug_assert"
1384            | "debug_assert_eq"
1385            | "debug_assert_ne"
1386            | "bail"
1387            | "ensure"
1388            | "panic"
1389            | "todo"
1390            | "unimplemented"
1391            | "unreachable"
1392            | "print"
1393            | "println"
1394            | "eprint"
1395            | "eprintln"
1396            | "dbg"
1397    )
1398}
1399
1400fn string_literal(expr: &syn::Expr) -> Option<String> {
1401    match unwrap_expr(expr) {
1402        syn::Expr::Lit(lit) => match &lit.lit {
1403            syn::Lit::Str(s) => Some(s.value()),
1404            _ => None,
1405        },
1406        _ => None,
1407    }
1408}
1409
1410/// Strip `?`, `&`, parens and groups, which appear constantly and carry no meaning here.
1411fn unwrap_expr(expr: &syn::Expr) -> &syn::Expr {
1412    match expr {
1413        syn::Expr::Try(t) => unwrap_expr(&t.expr),
1414        syn::Expr::Reference(r) => unwrap_expr(&r.expr),
1415        syn::Expr::Paren(p) => unwrap_expr(&p.expr),
1416        syn::Expr::Group(g) => unwrap_expr(&g.expr),
1417        other => other,
1418    }
1419}
1420
1421/// Dotted segments of a call target, e.g. `nn::linear` -> `["nn", "linear"]`.
1422fn call_path(expr: &syn::Expr) -> Option<Vec<String>> {
1423    match unwrap_expr(expr) {
1424        syn::Expr::Path(p) => Some(
1425            p.path
1426                .segments
1427                .iter()
1428                .map(|s| s.ident.to_string())
1429                .collect(),
1430        ),
1431        _ => None,
1432    }
1433}
1434
1435/// Only apply Candle constructor semantics when the call is visibly in the candle-nn namespace.
1436///
1437/// A last-segment-only match (`other_crate::linear`) can confidently invent parameters. Bare
1438/// imported functions need compiler/use resolution and are therefore left unresolved by this
1439/// syntax-only pass.
1440fn known_constructor(path: &[String], func: &str) -> Option<&'static crate::known::Constructor> {
1441    let namespaced = path.len() >= 2
1442        && matches!(
1443            path.first().map(String::as_str),
1444            Some("candle_nn" | "nn" | "candle")
1445        );
1446    namespaced.then(|| known::lookup(func)).flatten()
1447}
1448
1449/// What a candle-nn 0.11.0 `LSTMConfig`/`GRUConfig` argument proves about the registered tensors.
1450///
1451/// Both RNN constructors read the bias presence out of the config (`b_ih_init`/`b_hh_init`,
1452/// rnn.rs:156-176 and :321-326), and `LSTM::new` additionally formats every tensor name from
1453/// `layer_idx`/`direction` (rnn.rs:139-147). Only a visibly default config licenses either fact,
1454/// so anything else — a local binding, a struct literal with overrides — stays unresolved.
1455#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1456struct RnnConfig {
1457    /// `Some(true)`/`Some(false)` when the bias inits are provably present/absent.
1458    biases: Option<bool>,
1459    /// True when the config visibly leaves `layer_idx: 0` and `Direction::Forward` in place.
1460    default_names: bool,
1461}
1462
1463fn rnn_config(
1464    func: &str,
1465    args: &syn::punctuated::Punctuated<syn::Expr, syn::Token![,]>,
1466) -> RnnConfig {
1467    if !matches!(func, "lstm" | "gru") {
1468        return RnnConfig::default();
1469    }
1470    // rnn.rs:189 / :345 — the config is the third positional argument of both constructors.
1471    let Some(text) = args.iter().nth(2).map(crate::load::type_text) else {
1472        return RnnConfig::default();
1473    };
1474    let text = text.replace(' ', "");
1475    // `Default::default()`, `LSTMConfig::default()` and `GRUConfig::default_no_bias()` all reduce
1476    // to their final segment; a struct literal or a binding does not.
1477    match text.rsplit("::").next().unwrap_or(&text) {
1478        "default()" => RnnConfig {
1479            biases: Some(true),
1480            default_names: true,
1481        },
1482        "default_no_bias()" => RnnConfig {
1483            biases: Some(false),
1484            default_names: true,
1485        },
1486        _ => RnnConfig::default(),
1487    }
1488}
1489
1490/// Turn a default-config leaf name into the family its config can range over.
1491///
1492/// `weight_ih_l0` becomes `weight_ih_l{layer_idx}{direction}`, which matches every layer index
1493/// and both the forward and `_reverse` spellings (rnn.rs:141-147).
1494fn config_named_family(leaf: &str) -> String {
1495    match leaf.strip_suffix("l0") {
1496        Some(stem) => format!("{stem}l{{layer_idx}}{{direction}}"),
1497        None => leaf.to_string(),
1498    }
1499}
1500
1501fn constructor_leaf_shape(
1502    func: &str,
1503    leaf: &str,
1504    args: &syn::punctuated::Punctuated<syn::Expr, syn::Token![,]>,
1505) -> Option<String> {
1506    let arg = |index: usize| args.iter().nth(index).map(crate::load::type_text);
1507    let pair =
1508        |left: Option<String>, right: Option<String>| Some(format!("({}, {})", left?, right?));
1509    match (func, leaf) {
1510        ("linear" | "linear_no_bias" | "linear_b", "weight") => pair(arg(1), arg(0)),
1511        ("linear" | "linear_b", "bias") => arg(1),
1512        ("embedding", "weight") => pair(arg(0), arg(1)),
1513        (
1514            "layer_norm" | "layer_norm_no_bias" | "rms_norm" | "batch_norm" | "group_norm",
1515            "weight" | "bias" | "running_mean" | "running_var",
1516        ) => {
1517            if func == "group_norm" {
1518                arg(1)
1519            } else {
1520                arg(0)
1521            }
1522        }
1523        ("prelu", "weight") => Some(format!("({}.unwrap_or(1),)", arg(0)?)),
1524        // rnn.rs:145-176 (LSTM, 4 gates) and :310-326 (GRU, 3 gates); arg 0 is `in_dim`, arg 1
1525        // `hidden_dim`. `_ih_` maps the input, `_hh_` the recurrent state.
1526        ("lstm" | "gru", leaf) if leaf.starts_with("weight_") || leaf.starts_with("bias_") => {
1527            let gates = if func == "lstm" { 4 } else { 3 };
1528            let rows = format!("{gates} * {}", arg(1)?);
1529            match (leaf.starts_with("weight_"), leaf.contains("_ih_")) {
1530                (true, true) => Some(format!("({rows}, {})", arg(0)?)),
1531                (true, false) => Some(format!("({rows}, {})", arg(1)?)),
1532                (false, _) => Some(format!("({rows},)")),
1533            }
1534        }
1535        ("conv1d" | "conv1d_no_bias", "weight") => Some(format!(
1536            "({}, {} / groups({}), {})",
1537            arg(1)?,
1538            arg(0)?,
1539            arg(3)?,
1540            arg(2)?
1541        )),
1542        ("conv2d" | "conv2d_no_bias", "weight") => Some(format!(
1543            "({}, {} / groups({}), {}, {})",
1544            arg(1)?,
1545            arg(0)?,
1546            arg(3)?,
1547            arg(2)?,
1548            arg(2)?
1549        )),
1550        ("conv_transpose1d" | "conv_transpose1d_no_bias", "weight") => Some(format!(
1551            "({}, {} / groups({}), {})",
1552            arg(0)?,
1553            arg(1)?,
1554            arg(3)?,
1555            arg(2)?
1556        )),
1557        ("conv_transpose2d" | "conv_transpose2d_no_bias", "weight") => Some(format!(
1558            "({}, {}, {}, {})",
1559            arg(0)?,
1560            arg(1)?,
1561            arg(2)?,
1562            arg(2)?
1563        )),
1564        (
1565            "conv1d"
1566            | "conv1d_no_bias"
1567            | "conv2d"
1568            | "conv2d_no_bias"
1569            | "conv_transpose1d"
1570            | "conv_transpose1d_no_bias"
1571            | "conv_transpose2d"
1572            | "conv_transpose2d_no_bias",
1573            "bias",
1574        ) => arg(1),
1575        _ => None,
1576    }
1577}
1578
1579fn normalize_qualified_path(path: &[String]) -> String {
1580    let mut path = path;
1581    while matches!(path.first().map(String::as_str), Some("crate" | "self")) {
1582        path = &path[1..];
1583    }
1584    path.join("::")
1585}
1586
1587fn normalize_qualified_name(name: &str) -> String {
1588    normalize_qualified_path(
1589        &name
1590            .split("::")
1591            .map(ToString::to_string)
1592            .collect::<Vec<_>>(),
1593    )
1594}
1595
1596/// Whether an expression is plausibly an iterator, as opposed to an `Option`. Used to decide
1597/// whether a `.map(..)` denotes a repeated construction.
1598fn looks_like_iteration(expr: &syn::Expr) -> bool {
1599    match unwrap_expr(expr) {
1600        syn::Expr::Range(_) => true,
1601        syn::Expr::MethodCall(mc) => matches!(
1602            mc.method.to_string().as_str(),
1603            "iter"
1604                | "into_iter"
1605                | "iter_mut"
1606                | "enumerate"
1607                | "zip"
1608                | "take"
1609                | "skip"
1610                | "rev"
1611                | "filter"
1612                | "chain"
1613                | "step_by"
1614                | "windows"
1615                | "chunks"
1616        ),
1617        _ => false,
1618    }
1619}
1620
1621fn binding_name(pat: &syn::Pat) -> Option<String> {
1622    match pat {
1623        syn::Pat::Ident(id) => Some(id.ident.to_string()),
1624        syn::Pat::Type(t) => binding_name(&t.pat),
1625        _ => None,
1626    }
1627}
1628
1629/// `syn` spans carry a line and column but no file, so the file comes from the walk context.
1630fn span_of_expr<T: syn::spanned::Spanned>(ctx: &Ctx, node: &T) -> SrcSpan {
1631    let start = node.span().start();
1632    SrcSpan {
1633        file: ctx.file,
1634        line: start.line,
1635        col: start.column,
1636    }
1637}