1use 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
17pub const SCHEMA: &str = "candle-graph/baseline/1";
19
20const HEADER: &str = "# candle-graph/baseline/1";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ModuleEntry {
25 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#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct RepeatEntry {
38 pub var: String,
39 pub bound: String,
40}
41
42#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct Baseline {
55 pub modules: Vec<ModuleEntry>,
56 pub params: Vec<ParamEntry>,
57 pub dataflow: Vec<String>,
59}
60
61#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
72#[allow(dead_code)] pub 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#[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#[derive(Debug)]
136pub enum BaselineError {
137 Missing(PathBuf),
139 Invalid {
141 path: Option<PathBuf>,
142 message: String,
143 },
144 Io { path: PathBuf, source: io::Error },
146 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 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(¶m.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 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(¶m_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 pub fn parse(text: &str) -> Result<Self, BaselineError> {
276 let mut lines = text.lines().map(str::trim_end).peekable();
277
278 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; 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 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
388pub fn render(structure: &Structure, dataflow_lines: &[impl AsRef<str>]) -> String {
390 Baseline::from_structure(structure, dataflow_lines).render()
391}
392
393pub fn parse(text: &str) -> Result<Baseline, BaselineError> {
395 Baseline::parse(text)
396}
397
398pub 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
420pub 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
515pub 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
531pub 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
542pub 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}