Skip to main content

candle_graph/
baseline.rs

1//! Deterministic structure baselines for CI.
2//!
3//! Renders [`crate::ir::Structure`] as stable, line-oriented text that omits arena
4//! integer IDs. Baselines can be loaded, compared (added / removed / changed), checked, and
5//! updated atomically. Optional pre-normalized dataflow lines may be appended without depending
6//! on a dataflow analysis module.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10use std::fs;
11use std::io::{self, Write};
12use std::path::{Path, PathBuf};
13
14use crate::ir::{Acquisition, Certainty, ModuleInstanceId, Structure};
15use crate::known::ParamKind;
16
17/// Schema line written at the top of every baseline file.
18pub const SCHEMA: &str = "candle-graph/baseline/1";
19
20const HEADER: &str = "# candle-graph/baseline/1";
21
22/// One module instance in canonical form (no arena IDs).
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ModuleEntry {
25    /// Hierarchy path from the builder root instance, e.g. `Root/blocks:Block`.
26    pub path: String,
27    pub root: String,
28    pub type_name: String,
29    pub field: Option<String>,
30    pub prefix: String,
31    pub repeat: Option<RepeatEntry>,
32    pub certainty: String,
33}
34
35/// Canonical repeat descriptor.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct RepeatEntry {
38    pub var: String,
39    pub bound: String,
40}
41
42/// One parameter in canonical form.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ParamEntry {
45    pub key: String,
46    pub root: String,
47    pub kind: String,
48    pub certainty: String,
49    pub source: String,
50}
51
52/// Parsed / rendered baseline document.
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct Baseline {
55    pub modules: Vec<ModuleEntry>,
56    pub params: Vec<ParamEntry>,
57    /// Pre-normalized dataflow finding lines (sorted, unique).
58    pub dataflow: Vec<String>,
59}
60
61/// A record that exists under the same identity in both sides but differs in attributes.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ChangedEntry {
64    pub kind: DiffKind,
65    pub identity: String,
66    pub expected: String,
67    pub actual: String,
68}
69
70/// Which section a diff line belongs to.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
72#[allow(dead_code)] // Dataflow reserved for attribute-level dataflow diffs later.
73pub enum DiffKind {
74    Module,
75    Param,
76    Dataflow,
77}
78
79impl fmt::Display for DiffKind {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            DiffKind::Module => write!(f, "module"),
83            DiffKind::Param => write!(f, "param"),
84            DiffKind::Dataflow => write!(f, "dataflow"),
85        }
86    }
87}
88
89/// Line-oriented comparison of two baselines.
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub struct BaselineDiff {
92    pub added: Vec<String>,
93    pub removed: Vec<String>,
94    pub changed: Vec<ChangedEntry>,
95}
96
97impl BaselineDiff {
98    pub fn is_empty(&self) -> bool {
99        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
100    }
101}
102
103impl fmt::Display for BaselineDiff {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        if self.is_empty() {
106            return writeln!(f, "baselines match");
107        }
108        if !self.added.is_empty() {
109            writeln!(f, "### added")?;
110            for line in &self.added {
111                writeln!(f, "+ {line}")?;
112            }
113        }
114        if !self.removed.is_empty() {
115            writeln!(f, "### removed")?;
116            for line in &self.removed {
117                writeln!(f, "- {line}")?;
118            }
119        }
120        if !self.changed.is_empty() {
121            writeln!(f, "### changed")?;
122            for change in &self.changed {
123                writeln!(
124                    f,
125                    "! {} {}\n- {}\n+ {}",
126                    change.kind, change.identity, change.expected, change.actual
127                )?;
128            }
129        }
130        Ok(())
131    }
132}
133
134/// Errors from loading, parsing, checking, or updating a baseline.
135#[derive(Debug)]
136pub enum BaselineError {
137    /// Baseline file does not exist.
138    Missing(PathBuf),
139    /// Baseline exists but is not a valid document.
140    Invalid {
141        path: Option<PathBuf>,
142        message: String,
143    },
144    /// Filesystem failure during load/update.
145    Io { path: PathBuf, source: io::Error },
146    /// Actual structure does not match the expected baseline.
147    Mismatch(BaselineDiff),
148}
149
150impl fmt::Display for BaselineError {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        match self {
153            BaselineError::Missing(path) => {
154                write!(f, "baseline not found: {}", path.display())
155            }
156            BaselineError::Invalid { path, message } => match path {
157                Some(p) => write!(f, "invalid baseline {}: {message}", p.display()),
158                None => write!(f, "invalid baseline: {message}"),
159            },
160            BaselineError::Io { path, source } => {
161                write!(f, "baseline I/O error at {}: {source}", path.display())
162            }
163            BaselineError::Mismatch(diff) => {
164                write!(f, "baseline mismatch:\n{diff}")
165            }
166        }
167    }
168}
169
170impl std::error::Error for BaselineError {
171    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
172        match self {
173            BaselineError::Io { source, .. } => Some(source),
174            _ => None,
175        }
176    }
177}
178
179impl Baseline {
180    /// Build a deterministic baseline from a structure plus optional dataflow lines.
181    ///
182    /// `dataflow_lines` are treated as already-normalized findings: trimmed, empty lines
183    /// dropped, then sorted and deduplicated. This keeps the hook independent of any future
184    /// dataflow module.
185    pub fn from_structure(structure: &Structure, dataflow_lines: &[impl AsRef<str>]) -> Self {
186        let mut modules: Vec<ModuleEntry> = structure
187            .instances
188            .iter()
189            .map(|inst| {
190                let def = structure.def(inst.def);
191                ModuleEntry {
192                    path: module_path(structure, inst.id),
193                    root: inst.root.clone(),
194                    type_name: def.name.clone(),
195                    field: inst.via_field.clone(),
196                    prefix: inst.prefix.to_string(),
197                    repeat: inst.repeat.as_ref().map(|r| RepeatEntry {
198                        var: r.var.clone(),
199                        bound: r.bound.clone(),
200                    }),
201                    certainty: certainty_str(&inst.certainty),
202                }
203            })
204            .collect();
205        modules.sort_by(|a, b| {
206            (&a.path, &a.root, &a.prefix, &a.type_name).cmp(&(
207                &b.path,
208                &b.root,
209                &b.prefix,
210                &b.type_name,
211            ))
212        });
213
214        let mut params: Vec<ParamEntry> = structure
215            .params
216            .iter()
217            .map(|param| {
218                let site = structure.site(param.site);
219                ParamEntry {
220                    key: param.key.to_string(),
221                    root: param.root.clone(),
222                    kind: kind_str(site.kind),
223                    certainty: certainty_str(&param.certainty),
224                    source: acquisition_str(&site.acquisition),
225                }
226            })
227            .collect();
228        params.sort_by(|a, b| (&a.root, &a.key, &a.kind).cmp(&(&b.root, &b.key, &b.kind)));
229
230        let mut dataflow: Vec<String> = dataflow_lines
231            .iter()
232            .map(|s| s.as_ref().trim().to_string())
233            .filter(|s| !s.is_empty())
234            .collect();
235        dataflow.sort();
236        dataflow.dedup();
237
238        Baseline {
239            modules,
240            params,
241            dataflow,
242        }
243    }
244
245    /// Render canonical stable text.
246    pub fn render(&self) -> String {
247        let mut out = String::new();
248        out.push_str(HEADER);
249        out.push('\n');
250        out.push('\n');
251
252        for module in &self.modules {
253            out.push_str(&module_line(module));
254            out.push('\n');
255        }
256        if !self.modules.is_empty() && (!self.params.is_empty() || !self.dataflow.is_empty()) {
257            out.push('\n');
258        }
259        for param in &self.params {
260            out.push_str(&param_line(param));
261            out.push('\n');
262        }
263        if !self.params.is_empty() && !self.dataflow.is_empty() {
264            out.push('\n');
265        }
266        for line in &self.dataflow {
267            out.push_str("dataflow\t");
268            out.push_str(&escape_field(line));
269            out.push('\n');
270        }
271        out
272    }
273
274    /// Parse a baseline document from text.
275    pub fn parse(text: &str) -> Result<Self, BaselineError> {
276        let mut lines = text.lines().map(str::trim_end).peekable();
277
278        // Skip leading blank lines; require schema header.
279        while matches!(lines.peek(), Some(l) if l.trim().is_empty()) {
280            lines.next();
281        }
282        let header = lines.next().ok_or_else(|| BaselineError::Invalid {
283            path: None,
284            message: "empty baseline".into(),
285        })?;
286        let header = header.trim();
287        if header != HEADER && header.trim_start_matches('#').trim() != SCHEMA {
288            return Err(BaselineError::Invalid {
289                path: None,
290                message: format!("expected header `{HEADER}`, found `{header}`"),
291            });
292        }
293
294        let mut modules = Vec::new();
295        let mut params = Vec::new();
296        let mut dataflow = Vec::new();
297
298        for (idx, raw) in lines.enumerate() {
299            let line_no = idx + 2; // header consumed as line 1 conceptually
300            let line = raw.trim();
301            if line.is_empty() || line.starts_with('#') {
302                continue;
303            }
304            let mut parts = line.split('\t');
305            let kind = parts.next().ok_or_else(|| BaselineError::Invalid {
306                path: None,
307                message: format!("line {line_no}: missing section kind"),
308            })?;
309            match kind {
310                "module" => {
311                    let fields = parse_fields(parts, line_no)?;
312                    modules.push(ModuleEntry {
313                        path: required_field(&fields, "path", line_no)?,
314                        root: required_field(&fields, "root", line_no)?,
315                        type_name: required_field(&fields, "type", line_no)?,
316                        field: optional_field(&fields, "field"),
317                        prefix: fields.get("prefix").cloned().unwrap_or_default(),
318                        repeat: parse_repeat(fields.get("repeat").map(String::as_str), line_no)?,
319                        certainty: required_field(&fields, "certainty", line_no)?,
320                    });
321                }
322                "param" => {
323                    let fields = parse_fields(parts, line_no)?;
324                    params.push(ParamEntry {
325                        key: required_field(&fields, "key", line_no)?,
326                        root: required_field(&fields, "root", line_no)?,
327                        kind: required_field(&fields, "kind", line_no)?,
328                        certainty: required_field(&fields, "certainty", line_no)?,
329                        source: required_field(&fields, "source", line_no)?,
330                    });
331                }
332                "dataflow" => {
333                    let rest: Vec<&str> = parts.collect();
334                    if rest.is_empty() {
335                        return Err(BaselineError::Invalid {
336                            path: None,
337                            message: format!("line {line_no}: dataflow line missing payload"),
338                        });
339                    }
340                    // Prefer `text=` field form; otherwise join remaining tabs as payload.
341                    let payload = if rest.len() == 1 && !rest[0].starts_with("text=") {
342                        unescape_field(rest[0])
343                    } else {
344                        let fields = parse_fields(rest.into_iter(), line_no)?;
345                        match fields.get("text") {
346                            Some(t) => t.clone(),
347                            None => {
348                                return Err(BaselineError::Invalid {
349                                    path: None,
350                                    message: format!("line {line_no}: dataflow line missing text"),
351                                });
352                            }
353                        }
354                    };
355                    if !payload.is_empty() {
356                        dataflow.push(payload);
357                    }
358                }
359                other => {
360                    return Err(BaselineError::Invalid {
361                        path: None,
362                        message: format!("line {line_no}: unknown section `{other}`"),
363                    });
364                }
365            }
366        }
367
368        modules.sort_by(|a, b| {
369            (&a.path, &a.root, &a.prefix, &a.type_name).cmp(&(
370                &b.path,
371                &b.root,
372                &b.prefix,
373                &b.type_name,
374            ))
375        });
376        params.sort_by(|a, b| (&a.root, &a.key, &a.kind).cmp(&(&b.root, &b.key, &b.kind)));
377        dataflow.sort();
378        dataflow.dedup();
379
380        Ok(Baseline {
381            modules,
382            params,
383            dataflow,
384        })
385    }
386}
387
388/// Render canonical baseline text for `structure`, optionally appending dataflow lines.
389pub fn render(structure: &Structure, dataflow_lines: &[impl AsRef<str>]) -> String {
390    Baseline::from_structure(structure, dataflow_lines).render()
391}
392
393/// Parse baseline text.
394pub fn parse(text: &str) -> Result<Baseline, BaselineError> {
395    Baseline::parse(text)
396}
397
398/// Load a baseline from disk. Missing files yield [`BaselineError::Missing`].
399pub fn load(path: impl AsRef<Path>) -> Result<Baseline, BaselineError> {
400    let path = path.as_ref();
401    let text = fs::read_to_string(path).map_err(|err| {
402        if err.kind() == io::ErrorKind::NotFound {
403            BaselineError::Missing(path.to_path_buf())
404        } else {
405            BaselineError::Io {
406                path: path.to_path_buf(),
407                source: err,
408            }
409        }
410    })?;
411    Baseline::parse(&text).map_err(|err| match err {
412        BaselineError::Invalid { message, .. } => BaselineError::Invalid {
413            path: Some(path.to_path_buf()),
414            message,
415        },
416        other => other,
417    })
418}
419
420/// Compare actual vs expected. Identities that exist on both sides with different attributes
421/// appear under `changed`; purely new/gone records under added/removed.
422pub fn compare(actual: &Baseline, expected: &Baseline) -> BaselineDiff {
423    let mut diff = BaselineDiff::default();
424
425    let exp_modules: BTreeMap<String, &ModuleEntry> = expected
426        .modules
427        .iter()
428        .map(|m| (module_identity(m), m))
429        .collect();
430    let act_modules: BTreeMap<String, &ModuleEntry> = actual
431        .modules
432        .iter()
433        .map(|m| (module_identity(m), m))
434        .collect();
435
436    let all_module_ids: BTreeSet<_> = exp_modules
437        .keys()
438        .chain(act_modules.keys())
439        .cloned()
440        .collect();
441    for id in all_module_ids {
442        match (act_modules.get(&id), exp_modules.get(&id)) {
443            (Some(a), Some(e)) => {
444                let al = module_line(a);
445                let el = module_line(e);
446                if al != el {
447                    diff.changed.push(ChangedEntry {
448                        kind: DiffKind::Module,
449                        identity: id,
450                        expected: el,
451                        actual: al,
452                    });
453                }
454            }
455            (Some(a), None) => diff.added.push(module_line(a)),
456            (None, Some(e)) => diff.removed.push(module_line(e)),
457            (None, None) => unreachable!(),
458        }
459    }
460
461    let exp_params: BTreeMap<String, &ParamEntry> = expected
462        .params
463        .iter()
464        .map(|p| (param_identity(p), p))
465        .collect();
466    let act_params: BTreeMap<String, &ParamEntry> = actual
467        .params
468        .iter()
469        .map(|p| (param_identity(p), p))
470        .collect();
471
472    let all_param_ids: BTreeSet<_> = exp_params
473        .keys()
474        .chain(act_params.keys())
475        .cloned()
476        .collect();
477    for id in all_param_ids {
478        match (act_params.get(&id), exp_params.get(&id)) {
479            (Some(a), Some(e)) => {
480                let al = param_line(a);
481                let el = param_line(e);
482                if al != el {
483                    diff.changed.push(ChangedEntry {
484                        kind: DiffKind::Param,
485                        identity: id,
486                        expected: el,
487                        actual: al,
488                    });
489                }
490            }
491            (Some(a), None) => diff.added.push(param_line(a)),
492            (None, Some(e)) => diff.removed.push(param_line(e)),
493            (None, None) => unreachable!(),
494        }
495    }
496
497    let exp_df: BTreeSet<&str> = expected.dataflow.iter().map(String::as_str).collect();
498    let act_df: BTreeSet<&str> = actual.dataflow.iter().map(String::as_str).collect();
499    for line in act_df.difference(&exp_df) {
500        diff.added.push(format!("dataflow\t{}", escape_field(line)));
501    }
502    for line in exp_df.difference(&act_df) {
503        diff.removed
504            .push(format!("dataflow\t{}", escape_field(line)));
505    }
506
507    diff.added.sort();
508    diff.removed.sort();
509    diff.changed
510        .sort_by(|a, b| (&a.kind, &a.identity).cmp(&(&b.kind, &b.identity)));
511
512    diff
513}
514
515/// Check that `structure` (+ optional dataflow lines) matches the baseline at `path`.
516pub fn check(
517    structure: &Structure,
518    path: impl AsRef<Path>,
519    dataflow_lines: &[impl AsRef<str>],
520) -> Result<(), BaselineError> {
521    let expected = load(path)?;
522    let actual = Baseline::from_structure(structure, dataflow_lines);
523    let diff = compare(&actual, &expected);
524    if diff.is_empty() {
525        Ok(())
526    } else {
527        Err(BaselineError::Mismatch(diff))
528    }
529}
530
531/// Atomically write a fresh baseline for `structure` to `path` (temp file + rename).
532pub fn update(
533    structure: &Structure,
534    path: impl AsRef<Path>,
535    dataflow_lines: &[impl AsRef<str>],
536) -> Result<(), BaselineError> {
537    let path = path.as_ref();
538    let text = render(structure, dataflow_lines);
539    atomic_write(path, text.as_bytes())
540}
541
542/// Write `bytes` to `path` via a same-directory temporary file and rename.
543pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), BaselineError> {
544    let parent = path.parent().unwrap_or_else(|| Path::new("."));
545    fs::create_dir_all(parent).map_err(|source| BaselineError::Io {
546        path: parent.to_path_buf(),
547        source,
548    })?;
549
550    let mut tmp_name = path
551        .file_name()
552        .map(|s| s.to_os_string())
553        .unwrap_or_else(|| "baseline".into());
554    tmp_name.push(".tmp");
555    let tmp_path = parent.join(tmp_name);
556
557    let write_tmp = || -> io::Result<()> {
558        let mut file = fs::File::create(&tmp_path)?;
559        file.write_all(bytes)?;
560        file.sync_all()?;
561        Ok(())
562    };
563    if let Err(source) = write_tmp() {
564        let _ = fs::remove_file(&tmp_path);
565        return Err(BaselineError::Io {
566            path: tmp_path,
567            source,
568        });
569    }
570
571    if let Err(source) = fs::rename(&tmp_path, path) {
572        let _ = fs::remove_file(&tmp_path);
573        return Err(BaselineError::Io {
574            path: path.to_path_buf(),
575            source,
576        });
577    }
578    Ok(())
579}
580
581fn module_identity(m: &ModuleEntry) -> String {
582    format!("{}\t{}", m.path, m.root)
583}
584
585fn param_identity(p: &ParamEntry) -> String {
586    format!("{}\t{}", p.root, p.key)
587}
588
589fn module_line(m: &ModuleEntry) -> String {
590    let field = m.field.as_deref().unwrap_or("");
591    let repeat = match &m.repeat {
592        Some(r) => format!("{} over {}", r.var, r.bound),
593        None => String::new(),
594    };
595    format!(
596        "module\tpath={}\troot={}\ttype={}\tfield={}\tprefix={}\trepeat={}\tcertainty={}",
597        escape_field(&m.path),
598        escape_field(&m.root),
599        escape_field(&m.type_name),
600        escape_field(field),
601        escape_field(&m.prefix),
602        escape_field(&repeat),
603        escape_field(&m.certainty),
604    )
605}
606
607fn param_line(p: &ParamEntry) -> String {
608    format!(
609        "param\tkey={}\troot={}\tkind={}\tcertainty={}\tsource={}",
610        escape_field(&p.key),
611        escape_field(&p.root),
612        escape_field(&p.kind),
613        escape_field(&p.certainty),
614        escape_field(&p.source),
615    )
616}
617
618fn module_path(structure: &Structure, id: ModuleInstanceId) -> String {
619    let mut parts = Vec::new();
620    let mut cur = Some(id);
621    while let Some(cid) = cur {
622        let inst = structure.instance(cid);
623        let type_name = structure.def(inst.def).name.as_str();
624        let seg = match &inst.via_field {
625            Some(field) => format!("{field}:{type_name}"),
626            None if inst.parent.is_some() && !inst.prefix.is_empty() => {
627                format!("{type_name}@{}", inst.prefix)
628            }
629            None => type_name.to_string(),
630        };
631        parts.push(seg);
632        cur = inst.parent;
633    }
634    parts.reverse();
635    parts.join("/")
636}
637
638fn certainty_str(c: &Certainty) -> String {
639    match c {
640        Certainty::Certain => "certain".to_string(),
641        Certainty::Conditional(reason) => format!("conditional:{reason}"),
642        Certainty::Unknown(reason) => format!("unknown:{reason}"),
643    }
644}
645
646fn kind_str(kind: ParamKind) -> String {
647    match kind {
648        ParamKind::Weight => "weight",
649        ParamKind::Bias => "bias",
650        ParamKind::RunningMean => "running_mean",
651        ParamKind::RunningVar => "running_var",
652        ParamKind::Raw => "raw",
653    }
654    .to_string()
655}
656
657fn acquisition_str(acq: &Acquisition) -> String {
658    match acq {
659        Acquisition::Constructor { func, .. } => format!("constructor:{func}"),
660        Acquisition::RawGet { method } => format!("raw_get:{method}"),
661    }
662}
663
664fn escape_field(s: &str) -> String {
665    let mut out = String::with_capacity(s.len());
666    for ch in s.chars() {
667        match ch {
668            '\\' => out.push_str("\\\\"),
669            '\n' => out.push_str("\\n"),
670            '\t' => out.push_str("\\t"),
671            c => out.push(c),
672        }
673    }
674    out
675}
676
677fn unescape_field(s: &str) -> String {
678    let mut out = String::with_capacity(s.len());
679    let mut chars = s.chars();
680    while let Some(ch) = chars.next() {
681        if ch == '\\' {
682            match chars.next() {
683                Some('\\') => out.push('\\'),
684                Some('n') => out.push('\n'),
685                Some('t') => out.push('\t'),
686                Some(other) => {
687                    out.push('\\');
688                    out.push(other);
689                }
690                None => out.push('\\'),
691            }
692        } else {
693            out.push(ch);
694        }
695    }
696    out
697}
698
699fn parse_fields<'a>(
700    parts: impl Iterator<Item = &'a str>,
701    line_no: usize,
702) -> Result<BTreeMap<String, String>, BaselineError> {
703    let mut fields = BTreeMap::new();
704    for part in parts {
705        if part.is_empty() {
706            continue;
707        }
708        let (key, value) = part.split_once('=').ok_or_else(|| BaselineError::Invalid {
709            path: None,
710            message: format!("line {line_no}: expected key=value, found `{part}`"),
711        })?;
712        fields.insert(key.to_string(), unescape_field(value));
713    }
714    Ok(fields)
715}
716
717fn required_field(
718    fields: &BTreeMap<String, String>,
719    key: &str,
720    line_no: usize,
721) -> Result<String, BaselineError> {
722    fields
723        .get(key)
724        .cloned()
725        .ok_or_else(|| BaselineError::Invalid {
726            path: None,
727            message: format!("line {line_no}: missing field `{key}`"),
728        })
729}
730
731fn optional_field(fields: &BTreeMap<String, String>, key: &str) -> Option<String> {
732    fields.get(key).cloned().filter(|v| !v.is_empty())
733}
734
735fn parse_repeat(raw: Option<&str>, line_no: usize) -> Result<Option<RepeatEntry>, BaselineError> {
736    let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
737        return Ok(None);
738    };
739    let (var, bound) = raw
740        .split_once(" over ")
741        .ok_or_else(|| BaselineError::Invalid {
742            path: None,
743            message: format!(
744                "line {line_no}: repeat must look like `var over bound`, found `{raw}`"
745            ),
746        })?;
747    Ok(Some(RepeatEntry {
748        var: var.to_string(),
749        bound: bound.to_string(),
750    }))
751}