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    /// Parse a dotted checkpoint key string, treating `{…}` segments as templates.
131    pub fn from_dotted(text: &str) -> Self {
132        Key {
133            segs: text
134                .split('.')
135                .filter(|part| !part.is_empty())
136                .map(|part| {
137                    if part.contains('{') && part.contains('}') {
138                        KeySeg::Template {
139                            text: part.to_string(),
140                        }
141                    } else {
142                        KeySeg::Literal(part.to_string())
143                    }
144                })
145                .collect(),
146        }
147    }
148
149    /// Append a prefix as written at a `pp()` call. A single call may carry dots
150    /// (`pp("lora_layers.0")`), so the text is split to keep key algebra uniform.
151    pub fn push_literal(&self, text: &str) -> Self {
152        let mut next = self.clone();
153        for part in text.split('.').filter(|p| !p.is_empty()) {
154            next.segs.push(KeySeg::Literal(part.to_string()));
155        }
156        next
157    }
158
159    pub fn push(&self, seg: KeySeg) -> Self {
160        let mut next = self.clone();
161        next.segs.push(seg);
162        next
163    }
164
165    pub fn extend(&self, segs: &[KeySeg]) -> Self {
166        let mut next = self.clone();
167        next.segs.extend_from_slice(segs);
168        next
169    }
170
171    pub fn is_empty(&self) -> bool {
172        self.segs.is_empty()
173    }
174
175    /// True when the key contains a dynamic segment and so denotes a family of tensors.
176    pub fn is_template(&self) -> bool {
177        self.segs
178            .iter()
179            .any(|s| matches!(s, KeySeg::Dynamic { .. } | KeySeg::Template { .. }))
180    }
181
182    /// Match against a concrete checkpoint tensor name. A dynamic segment matches exactly one
183    /// dotted component — candle's `VarBuilder::path` joins with `.` (var_builder.rs:186), so
184    /// one `pp` level is one component.
185    pub fn matches(&self, concrete: &str) -> bool {
186        let parts: Vec<&str> = concrete.split('.').filter(|p| !p.is_empty()).collect();
187        if parts.len() != self.segs.len() {
188            return false;
189        }
190        self.segs.iter().zip(parts).all(|(seg, part)| match seg {
191            KeySeg::Literal(n) => n == part,
192            KeySeg::Dynamic { .. } => true,
193            KeySeg::Template { text } => template_segment_matches(text, part),
194        })
195    }
196}
197
198fn template_segment_matches(template: &str, concrete: &str) -> bool {
199    let mut literals = Vec::new();
200    let mut cursor = 0usize;
201    while let Some(open_rel) = template[cursor..].find('{') {
202        let open = cursor + open_rel;
203        let Some(close_rel) = template[open + 1..].find('}') else {
204            return template == concrete;
205        };
206        let close = open + 1 + close_rel;
207        literals.push(&template[cursor..open]);
208        cursor = close + 1;
209    }
210    if literals.is_empty() {
211        return template == concrete;
212    }
213    literals.push(&template[cursor..]);
214
215    let starts_with_wildcard = template.starts_with('{');
216    let ends_with_wildcard = template.ends_with('}');
217    let mut position = 0usize;
218    for (index, literal) in literals.iter().enumerate() {
219        if literal.is_empty() {
220            continue;
221        }
222        if index == 0 && !starts_with_wildcard {
223            if !concrete.starts_with(literal) {
224                return false;
225            }
226            position = literal.len();
227            continue;
228        }
229        let Some(found) = concrete[position..].find(literal) else {
230            return false;
231        };
232        position += found + literal.len();
233    }
234    ends_with_wildcard
235        || literals
236            .last()
237            .is_some_and(|suffix| concrete.ends_with(suffix))
238}
239
240impl fmt::Display for Key {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        let joined: Vec<String> = self.segs.iter().map(|s| s.to_string()).collect();
243        write!(f, "{}", joined.join("."))
244    }
245}
246
247macro_rules! id_type {
248    ($name:ident) => {
249        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
250        pub struct $name(pub usize);
251    };
252}
253
254id_type!(ModuleDefId);
255id_type!(ModuleInstanceId);
256id_type!(ParamSiteId);
257id_type!(ParamId);
258
259/// A Rust type that constructs parameters, i.e. a module definition.
260#[derive(Debug, Clone, Serialize)]
261pub struct ModuleDef {
262    pub id: ModuleDefId,
263    /// Type name as written, e.g. `SelfAttention`.
264    pub name: String,
265    /// Constructor followed to discover this def's contents, e.g. `SelfAttention::new`.
266    pub ctor: Option<String>,
267    pub span: SrcSpan,
268    pub sites: Vec<ParamSiteId>,
269}
270
271/// A repeated construction, from a `for` loop or an iterator chain over layers.
272#[derive(Debug, Clone, Serialize)]
273pub struct Repeat {
274    /// Loop variable, e.g. `index`.
275    pub var: String,
276    /// Source text of the bound, e.g. `cfg.num_hidden_layers`. Never evaluated.
277    pub bound: String,
278}
279
280/// A module definition built at one specific `VarBuilder` prefix.
281#[derive(Debug, Clone, Serialize)]
282pub struct ModuleInstance {
283    pub id: ModuleInstanceId,
284    pub def: ModuleDefId,
285    pub parent: Option<ModuleInstanceId>,
286    /// Field name in the parent struct, when this came from a field initializer. Absent when
287    /// containment and prefix nesting diverge.
288    pub via_field: Option<String>,
289    /// Prefix at this instance. May be a template.
290    pub prefix: Key,
291    /// Which `VarBuilder` root this prefix belongs to, named after the constructor parameter
292    /// it entered through (e.g. `base_vb` vs `train_vb`). Two prefixes are only comparable
293    /// within the same root: distinct roots are distinct namespaces, and in practice one is
294    /// frozen mmapped weights while another is a trainable `VarMap`.
295    pub root: String,
296    /// True when the prefix was not written in source but computed as the longest common
297    /// prefix of this instance's descendants. Grouping structs (`Layer { .. }` literals) have
298    /// no builder of their own, so their prefix is derived rather than observed.
299    pub prefix_derived: bool,
300    pub repeat: Option<Repeat>,
301    pub origin: SrcSpan,
302    pub children: Vec<ModuleInstanceId>,
303    pub certainty: Certainty,
304}
305
306/// How a parameter is acquired.
307#[derive(Debug, Clone, Serialize)]
308#[serde(tag = "via", rename_all = "snake_case")]
309pub enum Acquisition {
310    /// Through a known candle-nn constructor, e.g. `candle_nn::linear`.
311    Constructor { func: String, cite: &'static str },
312    /// Through a raw `vb.get(..)` / `get_with_hints(..)` in user code.
313    RawGet { method: String },
314}
315
316/// A parameter-registering call site in source, relative to its owning def.
317#[derive(Debug, Clone, Serialize)]
318pub struct ParamSite {
319    pub id: ParamSiteId,
320    pub owner: ModuleDefId,
321    pub acquisition: Acquisition,
322    /// Key relative to the `VarBuilder` handed to the owning constructor.
323    pub relative_key: Key,
324    pub kind: crate::known::ParamKind,
325    /// Source text of the shape argument, when one was supplied. Symbolic on purpose: the
326    /// dimensions are config expressions and inventing numbers would be a lie.
327    pub shape: Option<String>,
328    pub span: SrcSpan,
329    pub certainty: Certainty,
330}
331
332/// Whether a parameter was found in a checkpoint. Evidence about the checkpoint, not ground
333/// truth about the model: a tensor may be absent because the checkpoint is stale, and present
334/// because something else wrote it.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
336#[serde(tag = "match", rename_all = "snake_case")]
337pub enum CheckpointMatch {
338    NotChecked,
339    /// Matched exactly one tensor.
340    Found {
341        name: String,
342        shape: Vec<usize>,
343        dtype: String,
344    },
345    /// A template matched several tensors; this is the expected case for layer families.
346    FoundMany {
347        count: usize,
348        sample: String,
349    },
350    Missing,
351}
352
353/// One tensor: a site realised at one instance.
354#[derive(Debug, Clone, Serialize)]
355pub struct Param {
356    pub id: ParamId,
357    pub site: ParamSiteId,
358    pub owner: ModuleInstanceId,
359    /// Fully qualified key, prefix + relative key.
360    pub key: Key,
361    /// `VarBuilder` root this tensor lives under. See [`ModuleInstance::root`].
362    pub root: String,
363    pub certainty: Certainty,
364    pub checkpoint: CheckpointMatch,
365}
366
367/// Something the analyzer could not model, surfaced rather than swallowed.
368#[derive(Debug, Clone, Serialize)]
369pub struct Diagnostic {
370    pub span: SrcSpan,
371    pub message: String,
372    pub key: Option<Key>,
373}
374
375/// Quantitative analysis coverage. Reported in every output so a reader can judge how much of
376/// the model the tool actually saw.
377#[derive(Debug, Clone, Default, Serialize)]
378pub struct Coverage {
379    pub instances: usize,
380    pub params: usize,
381    pub params_certain: usize,
382    pub params_conditional: usize,
383    pub params_unknown: usize,
384    pub diagnostics: usize,
385}
386
387#[derive(Debug, Default, Serialize)]
388pub struct Structure {
389    pub defs: Vec<ModuleDef>,
390    pub instances: Vec<ModuleInstance>,
391    pub sites: Vec<ParamSite>,
392    pub params: Vec<Param>,
393    pub root: Option<ModuleInstanceId>,
394    pub diagnostics: Vec<Diagnostic>,
395}
396
397impl Structure {
398    pub fn def(&self, id: ModuleDefId) -> &ModuleDef {
399        &self.defs[id.0]
400    }
401
402    pub fn instance(&self, id: ModuleInstanceId) -> &ModuleInstance {
403        &self.instances[id.0]
404    }
405
406    pub fn site(&self, id: ParamSiteId) -> &ParamSite {
407        &self.sites[id.0]
408    }
409
410    pub fn add_def(&mut self, name: String, ctor: Option<String>, span: SrcSpan) -> ModuleDefId {
411        let id = ModuleDefId(self.defs.len());
412        self.defs.push(ModuleDef {
413            id,
414            name,
415            ctor,
416            span,
417            sites: Vec::new(),
418        });
419        id
420    }
421
422    #[allow(clippy::too_many_arguments)]
423    pub fn add_instance(
424        &mut self,
425        def: ModuleDefId,
426        parent: Option<ModuleInstanceId>,
427        via_field: Option<String>,
428        prefix: Key,
429        root: String,
430        prefix_derived: bool,
431        repeat: Option<Repeat>,
432        origin: SrcSpan,
433        certainty: Certainty,
434    ) -> ModuleInstanceId {
435        let id = ModuleInstanceId(self.instances.len());
436        self.instances.push(ModuleInstance {
437            id,
438            def,
439            parent,
440            via_field,
441            prefix,
442            root,
443            prefix_derived,
444            repeat,
445            origin,
446            children: Vec::new(),
447            certainty,
448        });
449        if let Some(p) = parent {
450            self.instances[p.0].children.push(id);
451        }
452        id
453    }
454
455    /// Fill in the prefixes of grouping instances as the longest common prefix of everything
456    /// beneath them. Runs bottom-up so nested grouping nodes resolve correctly.
457    ///
458    /// Keys are grouped by `VarBuilder` root first, because distinct roots are distinct
459    /// namespaces: a `Layer` holding both frozen base weights and a trainable cross-attention
460    /// adapter has no meaningful prefix spanning the two, and computing one across them would
461    /// collapse to empty and lose the grouping entirely. The most populated root wins, and the
462    /// derived prefix is reported as belonging to it.
463    pub fn derive_prefixes(&mut self) {
464        let order: Vec<ModuleInstanceId> =
465            (0..self.instances.len()).map(ModuleInstanceId).collect();
466        for id in order.into_iter().rev() {
467            if !self.instances[id.0].prefix_derived {
468                continue;
469            }
470
471            let mut by_root: BTreeMap<String, Vec<Key>> = BTreeMap::new();
472            for p in self.params.iter().filter(|p| p.owner == id) {
473                by_root
474                    .entry(p.root.clone())
475                    .or_default()
476                    .push(p.key.clone());
477            }
478            for child in self.instances[id.0].children.clone() {
479                let child = &self.instances[child.0];
480                if !child.prefix.is_empty() && !child.root.is_empty() {
481                    by_root
482                        .entry(child.root.clone())
483                        .or_default()
484                        .push(child.prefix.clone());
485                }
486            }
487
488            let Some((root, keys)) = by_root.into_iter().max_by_key(|(_, k)| k.len()) else {
489                continue;
490            };
491            if let Some(common) = longest_common_prefix(&keys) {
492                self.instances[id.0].prefix = common;
493                self.instances[id.0].root = root;
494            }
495        }
496    }
497
498    #[allow(clippy::too_many_arguments)]
499    pub fn add_site(
500        &mut self,
501        owner: ModuleDefId,
502        acquisition: Acquisition,
503        relative_key: Key,
504        kind: crate::known::ParamKind,
505        shape: Option<String>,
506        span: SrcSpan,
507        certainty: Certainty,
508    ) -> ParamSiteId {
509        let id = ParamSiteId(self.sites.len());
510        self.sites.push(ParamSite {
511            id,
512            owner,
513            acquisition,
514            relative_key,
515            kind,
516            shape,
517            span,
518            certainty,
519        });
520        self.defs[owner.0].sites.push(id);
521        id
522    }
523
524    pub fn add_param(
525        &mut self,
526        site: ParamSiteId,
527        owner: ModuleInstanceId,
528        key: Key,
529        root: String,
530        certainty: Certainty,
531    ) -> ParamId {
532        let id = ParamId(self.params.len());
533        self.params.push(Param {
534            id,
535            site,
536            owner,
537            key,
538            root,
539            certainty,
540            checkpoint: CheckpointMatch::NotChecked,
541        });
542        id
543    }
544
545    pub fn diagnose(&mut self, span: SrcSpan, message: impl Into<String>, key: Option<Key>) {
546        self.diagnostics.push(Diagnostic {
547            span,
548            message: message.into(),
549            key,
550        });
551    }
552
553    /// Collapse parameters that resolve to the same tensor.
554    ///
555    /// Branches produce duplicates: `if cfg.attention_bias { linear(..) } else {
556    /// linear_no_bias(..) }` yields `weight` from both arms. When duplicates disagree about
557    /// certainty the least certain wins, because the analyzer does not track which arms are
558    /// mutually exclusive and must not claim more than it can prove.
559    pub fn dedupe_params(&mut self) {
560        let mut seen: BTreeMap<(String, String), ParamId> = BTreeMap::new();
561        let mut keep: Vec<bool> = vec![true; self.params.len()];
562
563        for (index, should_keep) in keep.iter_mut().enumerate() {
564            let ident = (
565                self.params[index].root.clone(),
566                self.params[index].key.to_string(),
567            );
568            match seen.get(&ident) {
569                Some(first) => {
570                    let first = *first;
571                    *should_keep = false;
572                    let incoming = self.params[index].certainty.clone();
573                    let existing = self.params[first.0].certainty.clone();
574                    self.params[first.0].certainty = least_certain(existing, incoming);
575                }
576                None => {
577                    seen.insert(ident, ParamId(index));
578                }
579            }
580        }
581
582        let mut next = 0usize;
583        let mut remap: Vec<Option<ParamId>> = vec![None; self.params.len()];
584        let mut kept = Vec::new();
585        for (index, param) in self.params.drain(..).enumerate() {
586            if keep[index] {
587                remap[index] = Some(ParamId(next));
588                let mut param = param;
589                param.id = ParamId(next);
590                kept.push(param);
591                next += 1;
592            }
593        }
594        self.params = kept;
595        let _ = remap;
596    }
597
598    /// Distinct `VarBuilder` roots in declaration order.
599    pub fn roots(&self) -> Vec<String> {
600        let mut seen = Vec::new();
601        for p in &self.params {
602            if !seen.contains(&p.root) {
603                seen.push(p.root.clone());
604            }
605        }
606        seen
607    }
608
609    pub fn coverage(&self) -> Coverage {
610        let mut c = Coverage {
611            instances: self.instances.len(),
612            params: self.params.len(),
613            diagnostics: self.diagnostics.len(),
614            ..Default::default()
615        };
616        for p in &self.params {
617            match p.certainty {
618                Certainty::Certain => c.params_certain += 1,
619                Certainty::Conditional(_) => c.params_conditional += 1,
620                Certainty::Unknown(_) => c.params_unknown += 1,
621            }
622        }
623        c
624    }
625}
626
627/// Least-certain-wins join, used when merging duplicate parameters.
628fn least_certain(a: Certainty, b: Certainty) -> Certainty {
629    match (&a, &b) {
630        (Certainty::Unknown(_), _) => a,
631        (_, Certainty::Unknown(_)) => b,
632        (Certainty::Conditional(_), _) => a,
633        (_, Certainty::Conditional(_)) => b,
634        _ => Certainty::Certain,
635    }
636}
637
638/// Longest common prefix of a set of keys, comparing segments structurally.
639fn longest_common_prefix(keys: &[Key]) -> Option<Key> {
640    let first = keys.first()?;
641    let mut len = first.segs.len();
642    for key in &keys[1..] {
643        let shared = first
644            .segs
645            .iter()
646            .zip(&key.segs)
647            .take_while(|(a, b)| a == b)
648            .count();
649        len = len.min(shared);
650    }
651    (len > 0).then(|| Key {
652        segs: first.segs[..len].to_vec(),
653    })
654}