Skip to main content

candle_graph/
ir.rs

1//! The analysis IR.
2//!
3//! Two layers, deliberately separated: a *structure* layer, which is what this milestone
4//! builds, and a *dataflow* layer, which will hang off the same arenas later.
5//!
6//! Four splits keep the two compatible, and all four exist because collapsing them is
7//! expensive to undo:
8//!
9//! * [`ModuleDef`] (a Rust type) vs [`ModuleInstance`] (that type built at one `VarBuilder`
10//!   prefix). One `SelfAttention` def yields 28 instances in a 28-layer model.
11//! * [`ParamSite`] (a `vb.get`/constructor call in source) vs [`Param`] (the tensor that call
12//!   produces at one instance). One site under a loop yields many parameters.
13//! * Struct containment vs prefix nesting. They usually agree and are not required to: a
14//!   constructor may re-prefix with `vb.root()` or hand a sibling's builder down.
15//! * A parameter's logical identity vs its storage identity. Tied weights make these differ.
16//!
17//! The dataflow layer will reference [`ParamId`] and [`ModuleInstanceId`] directly rather than
18//! rediscovering parameters by string, which is why paths are never the primary key.
19
20use serde::Serialize;
21use std::collections::BTreeMap;
22use std::fmt;
23
24/// A source location. `file` indexes [`crate::load::Crate::files`].
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26pub struct SrcSpan {
27    pub file: usize,
28    pub line: usize,
29    pub col: usize,
30}
31
32impl SrcSpan {
33    pub const UNKNOWN: SrcSpan = SrcSpan {
34        file: usize::MAX,
35        line: 0,
36        col: 0,
37    };
38}
39
40/// The outcome of any lookup the analyzer performs.
41///
42/// Generic on purpose: every resolution step (field type, callee, prefix) returns one of these,
43/// so "we did not know" is representable everywhere and cannot decay into a default.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45#[serde(tag = "resolution", rename_all = "snake_case")]
46pub enum Resolved<T> {
47    /// Exactly one answer, statically certain.
48    Exact(T),
49    /// Several possible answers and no way to choose. Never silently pick the first.
50    Ambiguous(Vec<T>),
51    /// Nothing could be determined. Carries why, for the diagnostic stream.
52    Unresolved(String),
53}
54
55impl<T> Resolved<T> {
56    pub fn exact(&self) -> Option<&T> {
57        match self {
58            Resolved::Exact(v) => Some(v),
59            _ => None,
60        }
61    }
62
63    pub fn is_exact(&self) -> bool {
64        matches!(self, Resolved::Exact(_))
65    }
66}
67
68/// Certainty attached to a node that exists but may be conditional.
69///
70/// Distinct from [`Resolved`]: that answers "did the lookup succeed", this answers "does this
71/// thing definitely exist at runtime".
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73#[serde(tag = "kind", content = "reason", rename_all = "snake_case")]
74pub enum Certainty {
75    /// Unconditionally present.
76    Certain,
77    /// Present only under a condition the analyzer could not evaluate (a config-dependent
78    /// bias, an `Option` field, a branch).
79    Conditional(String),
80    /// The analyzer could not model the construct at all; reported so the hole is visible.
81    Unknown(String),
82}
83
84impl Certainty {
85    pub fn is_certain(&self) -> bool {
86        matches!(self, Certainty::Certain)
87    }
88}
89
90/// One element of a parameter key.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92#[serde(tag = "seg", rename_all = "snake_case")]
93pub enum KeySeg {
94    /// A literal prefix from `vb.pp("name")` or a leaf tensor name.
95    Literal(String),
96    /// A segment whose value is a runtime expression, e.g. the `{index}` in
97    /// `vb.pp(format!("layers.{index}"))`.
98    ///
99    /// The source text is kept verbatim; it is never evaluated. Concrete indices come only
100    /// from matching a real checkpoint, never from a guess.
101    Dynamic { expr: String },
102    /// A single dotted component containing both literal text and one or more runtime
103    /// placeholders, e.g. `pre_local_block_{i}`.
104    ///
105    /// This is distinct from [`KeySeg::Dynamic`] so display preserves the literal portion
106    /// instead of adding another pair of braces around the whole component.
107    Template { text: String },
108}
109
110impl fmt::Display for KeySeg {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            KeySeg::Literal(n) => write!(f, "{n}"),
114            KeySeg::Dynamic { expr } => write!(f, "{{{expr}}}"),
115            KeySeg::Template { text } => write!(f, "{text}"),
116        }
117    }
118}
119
120/// A dotted key such as `model.layers.{index}.self_attn.q_proj.weight`.
121///
122/// This is a *display and matching* type, not the primary key. Identity is [`ParamId`].
123#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
124#[serde(transparent)]
125pub struct Key {
126    pub segs: Vec<KeySeg>,
127}
128
129impl Key {
130    /// Append a prefix as written at a `pp()` call. A single call may carry dots
131    /// (`pp("lora_layers.0")`), so the text is split to keep key algebra uniform.
132    pub fn push_literal(&self, text: &str) -> Self {
133        let mut next = self.clone();
134        for part in text.split('.').filter(|p| !p.is_empty()) {
135            next.segs.push(KeySeg::Literal(part.to_string()));
136        }
137        next
138    }
139
140    pub fn push(&self, seg: KeySeg) -> Self {
141        let mut next = self.clone();
142        next.segs.push(seg);
143        next
144    }
145
146    pub fn extend(&self, segs: &[KeySeg]) -> Self {
147        let mut next = self.clone();
148        next.segs.extend_from_slice(segs);
149        next
150    }
151
152    pub fn is_empty(&self) -> bool {
153        self.segs.is_empty()
154    }
155
156    /// True when the key contains a dynamic segment and so denotes a family of tensors.
157    pub fn is_template(&self) -> bool {
158        self.segs
159            .iter()
160            .any(|s| matches!(s, KeySeg::Dynamic { .. } | KeySeg::Template { .. }))
161    }
162
163    /// Match against a concrete checkpoint tensor name. A dynamic segment matches exactly one
164    /// dotted component — candle's `VarBuilder::path` joins with `.` (var_builder.rs:186), so
165    /// one `pp` level is one component.
166    pub fn matches(&self, concrete: &str) -> bool {
167        let parts: Vec<&str> = concrete.split('.').filter(|p| !p.is_empty()).collect();
168        if parts.len() != self.segs.len() {
169            return false;
170        }
171        self.segs.iter().zip(parts).all(|(seg, part)| match seg {
172            KeySeg::Literal(n) => n == part,
173            KeySeg::Dynamic { .. } => true,
174            KeySeg::Template { text } => template_segment_matches(text, part),
175        })
176    }
177}
178
179fn template_segment_matches(template: &str, concrete: &str) -> bool {
180    let mut literals = Vec::new();
181    let mut cursor = 0usize;
182    while let Some(open_rel) = template[cursor..].find('{') {
183        let open = cursor + open_rel;
184        let Some(close_rel) = template[open + 1..].find('}') else {
185            return template == concrete;
186        };
187        let close = open + 1 + close_rel;
188        literals.push(&template[cursor..open]);
189        cursor = close + 1;
190    }
191    if literals.is_empty() {
192        return template == concrete;
193    }
194    literals.push(&template[cursor..]);
195
196    let starts_with_wildcard = template.starts_with('{');
197    let ends_with_wildcard = template.ends_with('}');
198    let mut position = 0usize;
199    for (index, literal) in literals.iter().enumerate() {
200        if literal.is_empty() {
201            continue;
202        }
203        if index == 0 && !starts_with_wildcard {
204            if !concrete.starts_with(literal) {
205                return false;
206            }
207            position = literal.len();
208            continue;
209        }
210        let Some(found) = concrete[position..].find(literal) else {
211            return false;
212        };
213        position += found + literal.len();
214    }
215    ends_with_wildcard
216        || literals
217            .last()
218            .is_some_and(|suffix| concrete.ends_with(suffix))
219}
220
221impl fmt::Display for Key {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        let joined: Vec<String> = self.segs.iter().map(|s| s.to_string()).collect();
224        write!(f, "{}", joined.join("."))
225    }
226}
227
228macro_rules! id_type {
229    ($name:ident) => {
230        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
231        pub struct $name(pub usize);
232    };
233}
234
235id_type!(ModuleDefId);
236id_type!(ModuleInstanceId);
237id_type!(ParamSiteId);
238id_type!(ParamId);
239
240/// A Rust type that constructs parameters, i.e. a module definition.
241#[derive(Debug, Clone, Serialize)]
242pub struct ModuleDef {
243    pub id: ModuleDefId,
244    /// Type name as written, e.g. `SelfAttention`.
245    pub name: String,
246    /// Constructor followed to discover this def's contents, e.g. `SelfAttention::new`.
247    pub ctor: Option<String>,
248    pub span: SrcSpan,
249    pub sites: Vec<ParamSiteId>,
250}
251
252/// A repeated construction, from a `for` loop or an iterator chain over layers.
253#[derive(Debug, Clone, Serialize)]
254pub struct Repeat {
255    /// Loop variable, e.g. `index`.
256    pub var: String,
257    /// Source text of the bound, e.g. `cfg.num_hidden_layers`. Never evaluated.
258    pub bound: String,
259}
260
261/// A module definition built at one specific `VarBuilder` prefix.
262#[derive(Debug, Clone, Serialize)]
263pub struct ModuleInstance {
264    pub id: ModuleInstanceId,
265    pub def: ModuleDefId,
266    pub parent: Option<ModuleInstanceId>,
267    /// Field name in the parent struct, when this came from a field initializer. Absent when
268    /// containment and prefix nesting diverge.
269    pub via_field: Option<String>,
270    /// Prefix at this instance. May be a template.
271    pub prefix: Key,
272    /// Which `VarBuilder` root this prefix belongs to, named after the constructor parameter
273    /// it entered through (e.g. `base_vb` vs `train_vb`). Two prefixes are only comparable
274    /// within the same root: distinct roots are distinct namespaces, and in practice one is
275    /// frozen mmapped weights while another is a trainable `VarMap`.
276    pub root: String,
277    /// True when the prefix was not written in source but computed as the longest common
278    /// prefix of this instance's descendants. Grouping structs (`Layer { .. }` literals) have
279    /// no builder of their own, so their prefix is derived rather than observed.
280    pub prefix_derived: bool,
281    pub repeat: Option<Repeat>,
282    pub origin: SrcSpan,
283    pub children: Vec<ModuleInstanceId>,
284    pub certainty: Certainty,
285}
286
287/// How a parameter is acquired.
288#[derive(Debug, Clone, Serialize)]
289#[serde(tag = "via", rename_all = "snake_case")]
290pub enum Acquisition {
291    /// Through a known candle-nn constructor, e.g. `candle_nn::linear`.
292    Constructor { func: String, cite: &'static str },
293    /// Through a raw `vb.get(..)` / `get_with_hints(..)` in user code.
294    RawGet { method: String },
295}
296
297/// A parameter-registering call site in source, relative to its owning def.
298#[derive(Debug, Clone, Serialize)]
299pub struct ParamSite {
300    pub id: ParamSiteId,
301    pub owner: ModuleDefId,
302    pub acquisition: Acquisition,
303    /// Key relative to the `VarBuilder` handed to the owning constructor.
304    pub relative_key: Key,
305    pub kind: crate::known::ParamKind,
306    /// Source text of the shape argument, when one was supplied. Symbolic on purpose: the
307    /// dimensions are config expressions and inventing numbers would be a lie.
308    pub shape: Option<String>,
309    pub span: SrcSpan,
310    pub certainty: Certainty,
311}
312
313/// Whether a parameter was found in a checkpoint. Evidence about the checkpoint, not ground
314/// truth about the model: a tensor may be absent because the checkpoint is stale, and present
315/// because something else wrote it.
316#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
317#[serde(tag = "match", rename_all = "snake_case")]
318pub enum CheckpointMatch {
319    NotChecked,
320    /// Matched exactly one tensor.
321    Found {
322        name: String,
323        shape: Vec<usize>,
324        dtype: String,
325    },
326    /// A template matched several tensors; this is the expected case for layer families.
327    FoundMany {
328        count: usize,
329        sample: String,
330    },
331    Missing,
332}
333
334/// One tensor: a site realised at one instance.
335#[derive(Debug, Clone, Serialize)]
336pub struct Param {
337    pub id: ParamId,
338    pub site: ParamSiteId,
339    pub owner: ModuleInstanceId,
340    /// Fully qualified key, prefix + relative key.
341    pub key: Key,
342    /// `VarBuilder` root this tensor lives under. See [`ModuleInstance::root`].
343    pub root: String,
344    pub certainty: Certainty,
345    pub checkpoint: CheckpointMatch,
346}
347
348/// Something the analyzer could not model, surfaced rather than swallowed.
349#[derive(Debug, Clone, Serialize)]
350pub struct Diagnostic {
351    pub span: SrcSpan,
352    pub message: String,
353    pub key: Option<Key>,
354}
355
356/// Quantitative analysis coverage. Reported in every output so a reader can judge how much of
357/// the model the tool actually saw.
358#[derive(Debug, Clone, Default, Serialize)]
359pub struct Coverage {
360    pub instances: usize,
361    pub params: usize,
362    pub params_certain: usize,
363    pub params_conditional: usize,
364    pub params_unknown: usize,
365    pub diagnostics: usize,
366}
367
368#[derive(Debug, Default, Serialize)]
369pub struct Structure {
370    pub defs: Vec<ModuleDef>,
371    pub instances: Vec<ModuleInstance>,
372    pub sites: Vec<ParamSite>,
373    pub params: Vec<Param>,
374    pub root: Option<ModuleInstanceId>,
375    pub diagnostics: Vec<Diagnostic>,
376}
377
378impl Structure {
379    pub fn def(&self, id: ModuleDefId) -> &ModuleDef {
380        &self.defs[id.0]
381    }
382
383    pub fn instance(&self, id: ModuleInstanceId) -> &ModuleInstance {
384        &self.instances[id.0]
385    }
386
387    pub fn site(&self, id: ParamSiteId) -> &ParamSite {
388        &self.sites[id.0]
389    }
390
391    pub fn add_def(&mut self, name: String, ctor: Option<String>, span: SrcSpan) -> ModuleDefId {
392        let id = ModuleDefId(self.defs.len());
393        self.defs.push(ModuleDef {
394            id,
395            name,
396            ctor,
397            span,
398            sites: Vec::new(),
399        });
400        id
401    }
402
403    #[allow(clippy::too_many_arguments)]
404    pub fn add_instance(
405        &mut self,
406        def: ModuleDefId,
407        parent: Option<ModuleInstanceId>,
408        via_field: Option<String>,
409        prefix: Key,
410        root: String,
411        prefix_derived: bool,
412        repeat: Option<Repeat>,
413        origin: SrcSpan,
414        certainty: Certainty,
415    ) -> ModuleInstanceId {
416        let id = ModuleInstanceId(self.instances.len());
417        self.instances.push(ModuleInstance {
418            id,
419            def,
420            parent,
421            via_field,
422            prefix,
423            root,
424            prefix_derived,
425            repeat,
426            origin,
427            children: Vec::new(),
428            certainty,
429        });
430        if let Some(p) = parent {
431            self.instances[p.0].children.push(id);
432        }
433        id
434    }
435
436    /// Fill in the prefixes of grouping instances as the longest common prefix of everything
437    /// beneath them. Runs bottom-up so nested grouping nodes resolve correctly.
438    ///
439    /// Keys are grouped by `VarBuilder` root first, because distinct roots are distinct
440    /// namespaces: a `Layer` holding both frozen base weights and a trainable cross-attention
441    /// adapter has no meaningful prefix spanning the two, and computing one across them would
442    /// collapse to empty and lose the grouping entirely. The most populated root wins, and the
443    /// derived prefix is reported as belonging to it.
444    pub fn derive_prefixes(&mut self) {
445        let order: Vec<ModuleInstanceId> =
446            (0..self.instances.len()).map(ModuleInstanceId).collect();
447        for id in order.into_iter().rev() {
448            if !self.instances[id.0].prefix_derived {
449                continue;
450            }
451
452            let mut by_root: BTreeMap<String, Vec<Key>> = BTreeMap::new();
453            for p in self.params.iter().filter(|p| p.owner == id) {
454                by_root
455                    .entry(p.root.clone())
456                    .or_default()
457                    .push(p.key.clone());
458            }
459            for child in self.instances[id.0].children.clone() {
460                let child = &self.instances[child.0];
461                if !child.prefix.is_empty() && !child.root.is_empty() {
462                    by_root
463                        .entry(child.root.clone())
464                        .or_default()
465                        .push(child.prefix.clone());
466                }
467            }
468
469            let Some((root, keys)) = by_root.into_iter().max_by_key(|(_, k)| k.len()) else {
470                continue;
471            };
472            if let Some(common) = longest_common_prefix(&keys) {
473                self.instances[id.0].prefix = common;
474                self.instances[id.0].root = root;
475            }
476        }
477    }
478
479    #[allow(clippy::too_many_arguments)]
480    pub fn add_site(
481        &mut self,
482        owner: ModuleDefId,
483        acquisition: Acquisition,
484        relative_key: Key,
485        kind: crate::known::ParamKind,
486        shape: Option<String>,
487        span: SrcSpan,
488        certainty: Certainty,
489    ) -> ParamSiteId {
490        let id = ParamSiteId(self.sites.len());
491        self.sites.push(ParamSite {
492            id,
493            owner,
494            acquisition,
495            relative_key,
496            kind,
497            shape,
498            span,
499            certainty,
500        });
501        self.defs[owner.0].sites.push(id);
502        id
503    }
504
505    pub fn add_param(
506        &mut self,
507        site: ParamSiteId,
508        owner: ModuleInstanceId,
509        key: Key,
510        root: String,
511        certainty: Certainty,
512    ) -> ParamId {
513        let id = ParamId(self.params.len());
514        self.params.push(Param {
515            id,
516            site,
517            owner,
518            key,
519            root,
520            certainty,
521            checkpoint: CheckpointMatch::NotChecked,
522        });
523        id
524    }
525
526    pub fn diagnose(&mut self, span: SrcSpan, message: impl Into<String>, key: Option<Key>) {
527        self.diagnostics.push(Diagnostic {
528            span,
529            message: message.into(),
530            key,
531        });
532    }
533
534    /// Collapse parameters that resolve to the same tensor.
535    ///
536    /// Branches produce duplicates: `if cfg.attention_bias { linear(..) } else {
537    /// linear_no_bias(..) }` yields `weight` from both arms. When duplicates disagree about
538    /// certainty the least certain wins, because the analyzer does not track which arms are
539    /// mutually exclusive and must not claim more than it can prove.
540    pub fn dedupe_params(&mut self) {
541        let mut seen: BTreeMap<(String, String), ParamId> = BTreeMap::new();
542        let mut keep: Vec<bool> = vec![true; self.params.len()];
543
544        for (index, should_keep) in keep.iter_mut().enumerate() {
545            let ident = (
546                self.params[index].root.clone(),
547                self.params[index].key.to_string(),
548            );
549            match seen.get(&ident) {
550                Some(first) => {
551                    let first = *first;
552                    *should_keep = false;
553                    let incoming = self.params[index].certainty.clone();
554                    let existing = self.params[first.0].certainty.clone();
555                    self.params[first.0].certainty = least_certain(existing, incoming);
556                }
557                None => {
558                    seen.insert(ident, ParamId(index));
559                }
560            }
561        }
562
563        let mut next = 0usize;
564        let mut remap: Vec<Option<ParamId>> = vec![None; self.params.len()];
565        let mut kept = Vec::new();
566        for (index, param) in self.params.drain(..).enumerate() {
567            if keep[index] {
568                remap[index] = Some(ParamId(next));
569                let mut param = param;
570                param.id = ParamId(next);
571                kept.push(param);
572                next += 1;
573            }
574        }
575        self.params = kept;
576        let _ = remap;
577    }
578
579    /// Distinct `VarBuilder` roots in declaration order.
580    pub fn roots(&self) -> Vec<String> {
581        let mut seen = Vec::new();
582        for p in &self.params {
583            if !seen.contains(&p.root) {
584                seen.push(p.root.clone());
585            }
586        }
587        seen
588    }
589
590    pub fn coverage(&self) -> Coverage {
591        let mut c = Coverage {
592            instances: self.instances.len(),
593            params: self.params.len(),
594            diagnostics: self.diagnostics.len(),
595            ..Default::default()
596        };
597        for p in &self.params {
598            match p.certainty {
599                Certainty::Certain => c.params_certain += 1,
600                Certainty::Conditional(_) => c.params_conditional += 1,
601                Certainty::Unknown(_) => c.params_unknown += 1,
602            }
603        }
604        c
605    }
606}
607
608/// Least-certain-wins join, used when merging duplicate parameters.
609fn least_certain(a: Certainty, b: Certainty) -> Certainty {
610    match (&a, &b) {
611        (Certainty::Unknown(_), _) => a,
612        (_, Certainty::Unknown(_)) => b,
613        (Certainty::Conditional(_), _) => a,
614        (_, Certainty::Conditional(_)) => b,
615        _ => Certainty::Certain,
616    }
617}
618
619/// Longest common prefix of a set of keys, comparing segments structurally.
620fn longest_common_prefix(keys: &[Key]) -> Option<Key> {
621    let first = keys.first()?;
622    let mut len = first.segs.len();
623    for key in &keys[1..] {
624        let shared = first
625            .segs
626            .iter()
627            .zip(&key.segs)
628            .take_while(|(a, b)| a == b)
629            .count();
630        len = len.min(shared);
631    }
632    (len > 0).then(|| Key {
633        segs: first.segs[..len].to_vec(),
634    })
635}