1use std::collections::{BTreeMap, BTreeSet};
11
12use crate::claims::parse_claims;
13use crate::manifest::{
14 Binding, BindingRole, ClaimId, Link, LinkKind, Manifest, Node, NodeFields, NodeId, NodeKind,
15 is_canonical_id,
16};
17use crate::report::ParseReport;
18use crate::schema::{RawNode, parse_doc};
19
20#[allow(
21 clippy::large_enum_variant,
22 reason = "boxing Manifest would add a heap allocation to every successful parse"
23)]
24pub(crate) enum ParseOutcome {
25 Normalized(Manifest, ParseReport),
26 Fatal(ParseReport),
27}
28
29pub fn parse_sources(
38 tree_yaml: &str,
39 claims_md: Option<&str>,
40) -> Result<(Manifest, ParseReport), ParseReport> {
41 match parse_sources_detailed(tree_yaml, claims_md) {
42 ParseOutcome::Normalized(manifest, report) if report.is_ok() => Ok((manifest, report)),
43 ParseOutcome::Normalized(_, report) | ParseOutcome::Fatal(report) => Err(report),
44 }
45}
46
47pub(crate) fn parse_sources_detailed(tree_yaml: &str, claims_md: Option<&str>) -> ParseOutcome {
48 let mut report = ParseReport::default();
49
50 let doc = match parse_doc(tree_yaml) {
51 Ok(doc) => doc,
52 Err(msg) => {
53 report.error("document", msg);
54 return ParseOutcome::Fatal(report);
55 }
56 };
57
58 for key in doc.extra.keys() {
59 report.warn("document", format!("unknown field `{key}`"));
60 }
61
62 let roots: Vec<RawNode> = match (doc.tree, doc.root) {
63 (Some(_), Some(_)) => {
64 report.error(
65 "document",
66 "both `tree:` and `root:` are present; exactly one is allowed",
67 );
68 return ParseOutcome::Fatal(report);
69 }
70 (None, None) => {
71 report.error("document", "neither `tree:` nor `root:` is present");
72 return ParseOutcome::Fatal(report);
73 }
74 (Some(tree), None) => {
75 if tree.is_empty() {
76 report.warn("document", "empty manifest (`tree: []`)");
77 }
78 tree
79 }
80 (None, Some(root)) => vec![*root],
81 };
82
83 let claims_present = claims_md.is_some();
85 let (claims, duplicate_claim_ids) = match claims_md {
86 Some(md) => {
87 let parsed = parse_claims(md);
88 (parsed.claims, parsed.duplicate_ids)
89 }
90 None => (Vec::new(), Vec::new()),
91 };
92 let claim_ids: BTreeSet<ClaimId> = claims.iter().map(|c| c.id.clone()).collect();
93 for id in duplicate_claim_ids {
94 report.error(format!("claims[{id}]"), "duplicate claim id");
95 }
96
97 let mut norm = Normalizer {
98 report,
99 claims_present,
100 claim_ids,
101 nodes: Vec::new(),
102 node_ids: BTreeSet::new(),
103 bindings: Vec::new(),
104 child_links: Vec::new(),
105 also: Vec::new(),
106 };
107 for raw in &roots {
108 norm.dfs(raw, None);
109 }
110
111 let parent_of: BTreeMap<NodeId, NodeId> = norm
122 .child_links
123 .iter()
124 .map(|l| (l.to.clone(), l.from.clone()))
125 .collect();
126 let mut depends_links: Vec<Link> = Vec::new();
127 for (from, targets) in &norm.also {
128 for (i, target) in targets.iter().enumerate() {
129 let t = target.trim();
130 let to = NodeId::new(t);
131 if !norm.node_ids.contains(&to) {
132 norm.report.error(
133 format!("nodes[{from}].also_depends_on[{i}]"),
134 format!("`also_depends_on` references unknown node `{t}`"),
135 );
136 continue;
137 }
138 if is_ancestor(&to, from, &parent_of) {
139 norm.report.warn(
140 format!("nodes[{from}].also_depends_on[{i}]"),
141 format!(
142 "redundant `also_depends_on` on ancestor `{t}` (already nested under it)"
143 ),
144 );
145 continue;
146 }
147 depends_links.push(Link {
148 from: from.clone(),
149 to,
150 kind: LinkKind::DependsOn,
151 });
152 }
153 }
154
155 let mut links = norm.child_links;
156 links.extend(depends_links);
157 let links = dedupe_links(links, &mut norm.report);
158
159 detect_cycles(&norm.nodes, &links, &mut norm.report);
160
161 for claim in &claims {
163 for (i, dep) in claim.deps.iter().enumerate() {
164 if !norm.claim_ids.contains(dep) {
165 norm.report.error(
166 format!("claims[{}].dependencies[{i}]", claim.id),
167 format!("dependency references unknown claim `{dep}`"),
168 );
169 }
170 }
171 }
172
173 let manifest = Manifest {
174 nodes: norm.nodes,
175 links,
176 bindings: norm.bindings,
177 claims,
178 bounds: None,
179 paper: None,
180 related_work: Vec::new(),
181 concepts: Vec::new(),
182 problem: None,
183 recipes: Vec::new(),
184 exhibits: Vec::new(),
185 built_on: Vec::new(),
186 node_exhibits: Vec::new(),
187 };
188
189 ParseOutcome::Normalized(manifest, norm.report)
190}
191
192#[cfg(feature = "native")]
199pub fn parse_dir(dir: &std::path::Path) -> Result<(Manifest, ParseReport), ParseReport> {
200 let tree_path = dir.join("trace/exploration_tree.yaml");
201 let tree_yaml = match std::fs::read_to_string(&tree_path) {
202 Ok(s) => s,
203 Err(e) => {
204 let mut report = ParseReport::default();
205 report.error(
206 "document",
207 format!("cannot read {}: {e}", tree_path.display()),
208 );
209 return Err(report);
210 }
211 };
212 let claims_path = dir.join("logic/claims.md");
214 let claims_md = std::fs::read_to_string(&claims_path).ok();
215
216 let (mut manifest, mut report) = parse_sources(&tree_yaml, claims_md.as_deref())?;
218 read_logic_layer(dir, &mut manifest, &mut report);
219 read_evidence_layer(dir, &mut manifest, &mut report);
220 Ok((manifest, report))
221}
222
223#[cfg(feature = "native")]
228fn read_evidence_layer(dir: &std::path::Path, manifest: &mut Manifest, report: &mut ParseReport) {
229 use crate::evidence::{read_evidence, resolve_built_on, resolve_node_exhibits};
230
231 manifest.exhibits = read_evidence(dir, report);
232 manifest.node_exhibits =
233 resolve_node_exhibits(&manifest.nodes, &manifest.bindings, &manifest.exhibits);
234 manifest.built_on =
235 resolve_built_on(&manifest.nodes, &manifest.bindings, &manifest.related_work);
236}
237
238#[cfg(feature = "native")]
242fn read_logic_layer(dir: &std::path::Path, manifest: &mut Manifest, report: &mut ParseReport) {
243 use crate::paper::parse_paper;
244 use crate::sections::{parse_concepts, parse_problem, parse_related_work};
245
246 if let Some(md) = read_opt(&dir.join("PAPER.md")) {
247 let (paper, warnings) = parse_paper(&md);
248 manifest.paper = paper;
249 for w in warnings {
250 report.warn("PAPER.md", w);
251 }
252 }
253
254 if let Some(md) = read_opt(&dir.join("logic/problem.md")) {
255 manifest.problem = Some(parse_problem(&md));
256 }
257
258 if let Some(md) = read_opt(&dir.join("logic/concepts.md")) {
259 let concepts = parse_concepts(&md);
260 for c in &concepts {
261 if c.definition.is_none() {
262 report.warn(format!("concepts[{}]", c.term), "concept has no definition");
263 }
264 }
265 manifest.concepts = concepts;
266 }
267
268 if let Some(md) = read_opt(&dir.join("logic/related_work.md")) {
269 let related_work = parse_related_work(&md);
270 for r in &related_work {
271 if r.doi.is_none() {
272 report.warn(format!("related_work[{}]", r.id), "related work has no DOI");
273 }
274 }
275 manifest.related_work = related_work;
276 }
277
278 manifest.recipes = read_recipes(&dir.join("logic/solution"));
279}
280
281#[cfg(feature = "native")]
284fn read_opt(path: &std::path::Path) -> Option<String> {
285 std::fs::read_to_string(path).ok()
286}
287
288#[cfg(feature = "native")]
292fn read_recipes(solution_dir: &std::path::Path) -> Vec<crate::manifest::Recipe> {
293 let Ok(entries) = std::fs::read_dir(solution_dir) else {
294 return Vec::new();
295 };
296 let mut paths: Vec<std::path::PathBuf> = entries
297 .flatten()
298 .map(|e| e.path())
299 .filter(|p| p.extension().is_some_and(|ext| ext == "md"))
300 .collect();
301 paths.sort();
302
303 let mut recipes = Vec::new();
304 for path in paths {
305 let Ok(body) = std::fs::read_to_string(&path) else {
306 continue;
307 };
308 let name = path
309 .file_stem()
310 .map(|s| s.to_string_lossy().into_owned())
311 .unwrap_or_default();
312 let title = crate::paper::first_h1(&body);
313 recipes.push(crate::manifest::Recipe { name, title, body });
314 }
315 recipes
316}
317
318struct Normalizer {
320 report: ParseReport,
321 claims_present: bool,
322 claim_ids: BTreeSet<ClaimId>,
323 nodes: Vec<Node>,
324 node_ids: BTreeSet<NodeId>,
325 bindings: Vec<Binding>,
326 child_links: Vec<Link>,
327 also: Vec<(NodeId, Vec<String>)>,
329}
330
331impl Normalizer {
332 fn dfs(&mut self, raw: &RawNode, parent: Option<&NodeId>) {
336 let id_str = raw.id.as_deref().map(str::trim).filter(|s| !s.is_empty());
337 let Some(id_str) = id_str else {
338 let label = raw.title.as_deref().unwrap_or("<no id>");
339 self.report
340 .error(format!("nodes[{label}]"), "node is missing an `id`");
341 return;
342 };
343 let id = NodeId::new(id_str);
344 if self.node_ids.contains(&id) {
345 self.report
346 .error(format!("nodes[{id}]"), "duplicate node id");
347 return;
348 }
349 self.node_ids.insert(id.clone());
350
351 if let Some(parent) = parent {
352 self.child_links.push(Link {
353 from: parent.clone(),
354 to: id.clone(),
355 kind: LinkKind::Child,
356 });
357 }
358
359 let (kind, fields) = self.project_kind(raw, &id);
360 let evidence_notes = self.split_evidence(raw, &id);
361
362 for key in raw.extra.keys() {
363 self.report
364 .warn(format!("nodes[{id}]"), format!("unknown field `{key}`"));
365 }
366
367 self.nodes.push(Node {
368 id: id.clone(),
369 kind,
370 label: raw.title.clone(),
371 support_level: raw.support_level.clone(),
372 source_refs: raw.source_refs.clone(),
373 description: raw.description.clone(),
374 provenance: raw.provenance.clone(),
375 timestamp: raw.timestamp.clone(),
376 fields,
377 evidence_notes,
378 isolated: raw.isolated,
379 pos: None,
380 });
381 self.also.push((id.clone(), raw.also_depends_on.clone()));
382
383 for child in &raw.children {
384 self.dfs(child, Some(&id));
385 }
386 }
387
388 fn project_kind(&mut self, raw: &RawNode, id: &NodeId) -> (NodeKind, NodeFields) {
393 let (kind, fields, ty, projected): (NodeKind, NodeFields, &str, &[&str]) =
396 match raw.ty.as_deref().map(str::trim) {
397 Some("question") => (NodeKind::Question, NodeFields::Question, "question", &[]),
398 Some("experiment") => (
399 NodeKind::Experiment,
400 NodeFields::Experiment {
401 result: raw.result.clone(),
402 exploration: raw.exploration.clone(),
403 outcome: raw.outcome.clone(),
404 status: raw.status.clone(),
405 },
406 "experiment",
407 &["result", "exploration", "outcome", "status"],
408 ),
409 Some("decision") => (
410 NodeKind::Decision,
411 NodeFields::Decision {
412 choice: raw.choice.clone(),
413 alternatives: raw.alternatives.clone(),
414 rationale: raw.rationale.clone(),
415 },
416 "decision",
417 &["choice", "alternatives", "rationale"],
418 ),
419 Some("dead_end") => (
420 NodeKind::DeadEnd,
421 NodeFields::DeadEnd {
422 hypothesis: raw.hypothesis.clone(),
423 failure_mode: raw.failure_mode.clone(),
424 lesson: raw.lesson.clone(),
425 why_failed: raw.why_failed.clone(),
426 },
427 "dead_end",
428 &["hypothesis", "failure_mode", "lesson", "why_failed"],
429 ),
430 Some("insight") => (NodeKind::Insight, NodeFields::Insight, "insight", &[]),
431 Some("pivot") => (
432 NodeKind::Pivot,
433 NodeFields::Pivot {
434 prior_direction: raw.prior_direction.clone(),
435 new_direction: raw.new_direction.clone(),
436 reason: raw.reason.clone(),
437 lesson: raw.lesson.clone(),
438 },
439 "pivot",
440 &["prior_direction", "new_direction", "reason", "lesson"],
441 ),
442 Some("") | None => {
443 self.report
444 .warn(format!("nodes[{id}]"), "node is missing a `type`");
445 for field in body_field_names(raw) {
446 self.report.warn(
447 format!("nodes[{id}]"),
448 format!("field `{field}` dropped for missing type"),
449 );
450 }
451 return (NodeKind::Other(String::new()), NodeFields::Other);
452 }
453 Some(other) => {
454 for field in body_field_names(raw) {
455 self.report.warn(
456 format!("nodes[{id}]"),
457 format!("field `{field}` dropped for unknown type `{other}`"),
458 );
459 }
460 return (NodeKind::Other(other.to_string()), NodeFields::Other);
461 }
462 };
463 for field in body_field_names(raw) {
464 if !projected.contains(&field) {
465 self.report.warn(
466 format!("nodes[{id}]"),
467 format!("field `{field}` dropped for type `{ty}`"),
468 );
469 }
470 }
471 (kind, fields)
472 }
473
474 fn split_evidence(&mut self, raw: &RawNode, id: &NodeId) -> Vec<String> {
476 let mut notes = Vec::new();
477 let Some(evidence) = &raw.evidence else {
478 return notes;
479 };
480 for (i, entry) in evidence.entries().iter().enumerate() {
481 let trimmed = entry.trim();
482 if is_canonical_id(trimmed, 'C') {
483 let claim = ClaimId::new(trimmed);
484 let path = format!("nodes[{id}].evidence[{i}]");
485 if !self.claims_present {
486 self.report.warn(
487 path,
488 format!("claim reference `{trimmed}` unresolved (no claims.md provided)"),
489 );
490 } else if self.claim_ids.contains(&claim) {
491 self.bindings.push(Binding {
492 node: id.clone(),
493 claim,
494 role: BindingRole::Evidence,
495 });
496 } else {
497 self.report.error(
498 path,
499 format!("evidence references unknown claim `{trimmed}`"),
500 );
501 }
502 } else {
503 notes.push(entry.clone());
504 }
505 }
506 notes
507 }
508}
509
510fn body_field_names(raw: &RawNode) -> Vec<&'static str> {
513 let mut names = Vec::new();
514 if raw.result.is_some() {
515 names.push("result");
516 }
517 if raw.status.is_some() {
518 names.push("status");
519 }
520 if raw.exploration.is_some() {
521 names.push("exploration");
522 }
523 if raw.outcome.is_some() {
524 names.push("outcome");
525 }
526 if raw.why_failed.is_some() {
527 names.push("why_failed");
528 }
529 if raw.hypothesis.is_some() {
530 names.push("hypothesis");
531 }
532 if raw.failure_mode.is_some() {
533 names.push("failure_mode");
534 }
535 if raw.lesson.is_some() {
536 names.push("lesson");
537 }
538 if raw.prior_direction.is_some() {
539 names.push("prior_direction");
540 }
541 if raw.new_direction.is_some() {
542 names.push("new_direction");
543 }
544 if raw.reason.is_some() {
545 names.push("reason");
546 }
547 if raw.choice.is_some() {
548 names.push("choice");
549 }
550 if !raw.alternatives.is_empty() {
551 names.push("alternatives");
552 }
553 if raw.rationale.is_some() {
554 names.push("rationale");
555 }
556 names
557}
558
559fn is_ancestor(ancestor: &NodeId, node: &NodeId, parent_of: &BTreeMap<NodeId, NodeId>) -> bool {
563 let mut cur = node;
566 while let Some(parent) = parent_of.get(cur) {
567 if parent == ancestor {
568 return true;
569 }
570 cur = parent;
571 }
572 false
573}
574
575fn dedupe_links(links: Vec<Link>, report: &mut ParseReport) -> Vec<Link> {
578 let mut seen: BTreeSet<(NodeId, NodeId, LinkKind)> = BTreeSet::new();
579 let mut out = Vec::with_capacity(links.len());
580 for link in links {
581 let key = (link.from.clone(), link.to.clone(), link.kind);
582 if seen.contains(&key) {
583 report.warn(
584 format!("nodes[{}]", link.from),
585 format!("duplicate {:?} link to `{}`", link.kind, link.to),
586 );
587 } else {
588 seen.insert(key);
589 out.push(link);
590 }
591 }
592 out
593}
594
595fn detect_cycles(nodes: &[Node], links: &[Link], report: &mut ParseReport) {
598 let mut adj: BTreeMap<&NodeId, Vec<&NodeId>> = BTreeMap::new();
601 for link in links {
602 adj.entry(&link.from).or_default().push(&link.to);
603 }
604 let mut color: BTreeMap<&NodeId, u8> = BTreeMap::new(); for node in nodes {
606 if color.get(&node.id).copied().unwrap_or(0) == 0 {
607 visit(&node.id, &adj, &mut color, report);
608 }
609 }
610}
611
612fn visit<'a>(
613 u: &'a NodeId,
614 adj: &BTreeMap<&'a NodeId, Vec<&'a NodeId>>,
615 color: &mut BTreeMap<&'a NodeId, u8>,
616 report: &mut ParseReport,
617) {
618 color.insert(u, 1);
619 if let Some(neighbors) = adj.get(u) {
620 for &v in neighbors {
621 match color.get(v).copied().unwrap_or(0) {
622 0 => visit(v, adj, color, report),
623 1 => report.error(
624 format!("nodes[{u}]"),
625 format!("cycle detected: edge to `{v}` closes a cycle"),
626 ),
627 _ => {}
628 }
629 }
630 }
631 color.insert(u, 2);
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 const MINIMAL: &str = "\
639tree:
640 - id: N01
641 type: question
642 title: Q?
643 children:
644 - id: N02
645 type: experiment
646 result: 28.4 BLEU
647 evidence: [C01, \"Table 2\"]
648";
649 const CLAIMS: &str = "## C01: A claim\n- **Statement**: yes\n";
650
651 #[test]
652 fn detailed_outcome_classifies_parser_trust_boundary() {
653 #[derive(Debug, PartialEq)]
654 enum ExpectedOutcome {
655 Normalized,
656 Fatal,
657 }
658
659 let cases = [
660 (
661 "clean",
662 "tree:\n - id: N01\n type: question\n",
663 ExpectedOutcome::Normalized,
664 true,
665 Some(1),
666 ),
667 (
668 "semantic duplicate id",
669 "tree:\n - id: N01\n type: question\n - id: N01\n type: insight\n",
670 ExpectedOutcome::Normalized,
671 false,
672 Some(1),
673 ),
674 (
675 "malformed yaml",
676 "tree: not-a-list\n",
677 ExpectedOutcome::Fatal,
678 false,
679 None,
680 ),
681 (
682 "both tree and root",
683 "tree: []\nroot:\n id: N01\n",
684 ExpectedOutcome::Fatal,
685 false,
686 None,
687 ),
688 (
689 "neither root",
690 "meta: hi\n",
691 ExpectedOutcome::Fatal,
692 false,
693 None,
694 ),
695 ];
696
697 for (name, yaml, expected_outcome, expected_public_ok, expected_node_count) in cases {
698 let (actual_outcome, detailed_report, node_count) =
699 match parse_sources_detailed(yaml, None) {
700 ParseOutcome::Normalized(manifest, report) => (
701 ExpectedOutcome::Normalized,
702 report,
703 Some(manifest.nodes.len()),
704 ),
705 ParseOutcome::Fatal(report) => (ExpectedOutcome::Fatal, report, None),
706 };
707 assert_eq!(actual_outcome, expected_outcome, "{name}");
708 assert_eq!(node_count, expected_node_count, "{name}");
709
710 match (parse_sources(yaml, None), expected_public_ok) {
711 (Ok((_, public_report)), true) => {
712 assert_eq!(public_report, detailed_report, "{name}");
713 }
714 (Err(public_report), false) => {
715 assert_eq!(public_report, detailed_report, "{name}");
716 }
717 (actual, expected_ok) => {
718 panic!("{name}: expected public ok={expected_ok}, got {actual:?}");
719 }
720 }
721 }
722 }
723
724 #[test]
725 fn resolves_bindings_and_splits_evidence() {
726 let (m, report) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
727 assert!(report.is_ok());
728 assert!(report.warnings().is_empty());
729 assert_eq!(m.nodes.len(), 2);
730 assert_eq!(m.nodes[0].id, NodeId::new("N01")); assert_eq!(m.nodes[1].id, NodeId::new("N02"));
732 assert_eq!(m.links.len(), 1); assert_eq!(m.links[0].kind, LinkKind::Child);
734 assert_eq!(m.bindings.len(), 1); assert_eq!(m.bindings[0].claim, ClaimId::new("C01"));
736 assert_eq!(m.nodes[1].evidence_notes, vec!["Table 2"]);
737 }
738
739 #[test]
740 fn missing_claims_downgrades_binding_to_warning() {
741 let (m, report) = parse_sources(MINIMAL, None).expect("ok");
742 assert!(report.is_ok());
743 assert!(m.bindings.is_empty());
744 assert_eq!(report.warnings().len(), 1);
745 assert!(report.warnings()[0].message.contains("unresolved"));
746 }
747
748 #[test]
749 fn broken_claim_ref_is_error() {
750 let err = parse_sources(MINIMAL, Some("## C99: other\n")).unwrap_err();
751 assert!(!err.is_ok());
752 assert!(err.errors()[0].message.contains("unknown claim"));
753 }
754
755 #[test]
756 fn malformed_yaml_is_error_not_panic() {
757 let err = parse_sources("tree: not-a-list\n", None).unwrap_err();
758 assert_eq!(err.errors()[0].path, "document");
759 }
760
761 #[test]
762 fn both_roots_is_error() {
763 let err = parse_sources("tree: []\nroot:\n id: N01\n", None).unwrap_err();
764 assert!(err.errors()[0].message.contains("both"));
765 }
766
767 #[test]
768 fn neither_root_is_error() {
769 let err = parse_sources("meta: hi\n", None).unwrap_err();
770 assert!(err.errors()[0].message.contains("neither"));
771 }
772
773 #[test]
774 fn empty_tree_warns_and_is_ok() {
775 let (m, report) = parse_sources("tree: []\n", None).expect("ok");
776 assert!(m.nodes.is_empty());
777 assert_eq!(report.warnings().len(), 1);
778 }
779
780 #[test]
781 fn cycle_is_detected() {
782 let yaml = "\
786tree:
787 - id: N01
788 type: question
789 children:
790 - id: N02
791 type: experiment
792 also_depends_on: [N04]
793 - id: N03
794 type: decision
795 children:
796 - id: N04
797 type: insight
798 also_depends_on: [N02]
799";
800 let err = parse_sources(yaml, None).unwrap_err();
801 assert!(err.errors().iter().any(|d| d.message.contains("cycle")));
802 }
803
804 #[test]
805 fn duplicate_node_id_is_error() {
806 let yaml = "\
807tree:
808 - id: N01
809 type: question
810 - id: N01
811 type: insight
812";
813 let err = parse_sources(yaml, None).unwrap_err();
814 assert!(
815 err.errors()
816 .iter()
817 .any(|d| d.message.contains("duplicate node id"))
818 );
819 }
820
821 #[test]
822 fn unknown_type_becomes_other_and_warns() {
823 let yaml = "tree:\n - id: N01\n type: hypothesis\n title: h\n";
824 let (m, _r) = parse_sources(yaml, None).expect("ok");
825 assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
826 }
827
828 #[test]
829 fn root_single_matches_tree_shape() {
830 let tree = "tree:\n - id: N01\n type: question\n title: q\n";
831 let root = "root:\n id: N01\n type: question\n title: q\n";
832 let (mt, _) = parse_sources(tree, None).expect("ok");
833 let (mr, _) = parse_sources(root, None).expect("ok");
834 assert_eq!(mt.nodes, mr.nodes);
835 }
836
837 #[test]
838 fn determinism_parse_twice_identical() {
839 let (a, _) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
840 let (b, _) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
841 assert_eq!(a, b);
842 }
843
844 #[test]
845 fn broken_node_to_node_ref_is_error() {
846 let yaml = "\
847tree:
848 - id: N01
849 type: question
850 children:
851 - id: N02
852 type: experiment
853 also_depends_on: [N99]
854";
855 let err = parse_sources(yaml, None).unwrap_err();
856 assert!(
857 err.errors()
858 .iter()
859 .any(|d| d.message.contains("unknown node") && d.path.contains("also_depends_on")),
860 "expected broken node->node error, got: {err}"
861 );
862 }
863
864 #[test]
865 fn broken_claim_to_claim_dep_is_error() {
866 let claims = "## C01: A\n- **Dependencies**: [C99]\n";
868 let err = parse_sources(MINIMAL, Some(claims)).unwrap_err();
869 assert!(
870 err.errors()
871 .iter()
872 .any(|d| d.message.contains("unknown claim") && d.path.contains("dependencies")),
873 "expected broken claim->claim error, got: {err}"
874 );
875 }
876
877 #[test]
878 fn proof_evidence_refs_emit_no_error() {
879 let claims = "## C01: A\n- **Statement**: s\n- **Proof**: [E01, E02]\n";
881 let (m, report) = parse_sources(MINIMAL, Some(claims)).expect("ok");
882 assert_eq!(m.claims[0].proof, vec!["E01", "E02"]);
883 assert!(report.is_ok());
885 assert!(report.errors().is_empty());
886 }
887
888 #[test]
889 fn sibling_only_depends_on_cycle_is_detected() {
890 let yaml = "\
892tree:
893 - id: N01
894 type: question
895 children:
896 - id: N02
897 type: experiment
898 also_depends_on: [N03]
899 - id: N03
900 type: insight
901 also_depends_on: [N02]
902";
903 let err = parse_sources(yaml, None).unwrap_err();
904 assert!(err.errors().iter().any(|d| d.message.contains("cycle")));
905 }
906
907 #[test]
908 fn redundant_ancestor_depends_on_is_dropped_with_warning() {
909 let yaml = "\
913tree:
914 - id: N01
915 type: question
916 children:
917 - id: N02
918 type: experiment
919 also_depends_on: [N01]
920";
921 let (m, report) = parse_sources(yaml, None).expect("parses ok despite ancestor dep");
922 assert!(report.is_ok(), "must not error: {report}");
923 assert_eq!(m.links.len(), 1);
925 assert_eq!(m.links[0].kind, LinkKind::Child);
926 assert!(
927 report
928 .warnings()
929 .iter()
930 .any(|d| d.message.contains("redundant") && d.message.contains("ancestor")),
931 "expected redundant-ancestor warning, got: {report}"
932 );
933 }
934
935 #[test]
936 fn redundant_grandparent_depends_on_is_dropped() {
937 let yaml = "\
939tree:
940 - id: N01
941 type: question
942 children:
943 - id: N02
944 type: experiment
945 children:
946 - id: N03
947 type: insight
948 also_depends_on: [N01]
949";
950 let (m, report) = parse_sources(yaml, None).expect("ok");
951 assert!(report.is_ok());
952 assert!(m.links.iter().all(|l| l.kind == LinkKind::Child));
954 assert_eq!(m.links.len(), 2);
955 }
956
957 #[test]
958 fn sibling_depends_on_is_kept_not_dropped() {
959 let yaml = "\
962tree:
963 - id: N01
964 type: question
965 children:
966 - id: N02
967 type: experiment
968 - id: N03
969 type: insight
970 also_depends_on: [N02]
971";
972 let (m, report) = parse_sources(yaml, None).expect("ok");
973 assert!(report.is_ok());
974 assert!(
975 m.links.iter().any(|l| l.kind == LinkKind::DependsOn
976 && l.from == NodeId::new("N03")
977 && l.to == NodeId::new("N02")),
978 "sibling cross-edge must be kept"
979 );
980 }
981
982 #[test]
983 fn missing_node_id_is_error() {
984 let err = parse_sources("tree:\n - type: question\n title: q\n", None).unwrap_err();
986 assert!(
987 err.errors()
988 .iter()
989 .any(|d| d.message.contains("missing an `id`")),
990 "expected missing-id error, got: {err}"
991 );
992 }
993
994 #[test]
995 fn duplicate_claim_id_is_error() {
996 let err = parse_sources(MINIMAL, Some("## C01: A\n## C01: B\n")).unwrap_err();
999 assert!(
1000 err.errors()
1001 .iter()
1002 .any(|d| d.path.contains("claims[C01]") && d.message.contains("duplicate claim id")),
1003 "expected duplicate-claim-id error, got: {err}"
1004 );
1005 }
1006
1007 #[test]
1008 fn isolated_field_defaults_false_and_sources_from_raw() {
1009 let yaml = "\
1012tree:
1013 - id: N01
1014 type: question
1015 children:
1016 - id: N02
1017 type: experiment
1018 isolated: true
1019";
1020 let (m, _r) = parse_sources(yaml, None).expect("ok");
1021 assert!(!m.nodes[0].isolated, "N01 has no isolated key → false");
1022 assert!(m.nodes[1].isolated, "N02 carries isolated: true");
1023 }
1024
1025 #[test]
1026 fn missing_type_warns() {
1027 let (m, report) = parse_sources("tree:\n - id: N01\n title: q\n", None).expect("ok");
1030 assert_eq!(m.nodes[0].kind, NodeKind::Other(String::new()));
1031 assert!(
1032 report
1033 .warnings()
1034 .iter()
1035 .any(|d| d.message.contains("missing a `type`")),
1036 "expected missing-type warning, got: {report}"
1037 );
1038 }
1039
1040 #[test]
1041 fn missing_type_dropped_body_fields_warn() {
1042 let yaml = "tree:\n - id: N01\n title: q\n status: running\n";
1046 let (m, report) = parse_sources(yaml, None).expect("ok");
1047 assert_eq!(m.nodes[0].kind, NodeKind::Other(String::new()));
1048 assert!(
1049 report
1050 .warnings()
1051 .iter()
1052 .any(|d| d.message.contains("missing a `type`")),
1053 "expected missing-type warning, got: {report}"
1054 );
1055 assert!(
1056 report
1057 .warnings()
1058 .iter()
1059 .any(|d| d.message.contains("`status` dropped for missing type")),
1060 "expected dropped-field warning for `status`, got: {report}"
1061 );
1062 }
1063
1064 #[test]
1065 fn unknown_type_dropped_body_field_warns() {
1066 let yaml = "tree:\n - id: N01\n type: hypothesis\n result: 28.4 BLEU\n";
1069 let (m, report) = parse_sources(yaml, None).expect("ok");
1070 assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
1071 assert!(
1072 report.warnings().iter().any(|d| d
1073 .message
1074 .contains("`result` dropped for unknown type `hypothesis`")),
1075 "expected dropped-field warning, got: {report}"
1076 );
1077 }
1078
1079 #[test]
1080 fn unknown_type_dropped_new_body_fields_warn() {
1081 let yaml = "\
1085tree:
1086 - id: N01
1087 type: hypothesis
1088 prior_direction: dense
1089 new_direction: sparse
1090 reason: latency
1091 status: running
1092 exploration: grid
1093 outcome: wins
1094";
1095 let (m, report) = parse_sources(yaml, None).expect("ok");
1096 assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
1097 for field in [
1098 "prior_direction",
1099 "new_direction",
1100 "reason",
1101 "status",
1102 "exploration",
1103 "outcome",
1104 ] {
1105 assert!(
1106 report.warnings().iter().any(|d| d
1107 .message
1108 .contains(&format!("`{field}` dropped for unknown type `hypothesis`"))),
1109 "expected dropped-field warning for `{field}`, got: {report}"
1110 );
1111 }
1112 }
1113
1114 #[test]
1115 fn pivot_projects_kind_and_fields_no_warning() {
1116 let yaml = "\
1121tree:
1122 - id: N01
1123 type: pivot
1124 prior_direction: manual
1125 new_direction: automated
1126 reason: infeasible at scale
1127 lesson: profile before committing
1128";
1129 let (m, report) = parse_sources(yaml, None).expect("ok");
1130 assert_eq!(m.nodes[0].kind, NodeKind::Pivot);
1131 assert_eq!(
1132 m.nodes[0].fields,
1133 NodeFields::Pivot {
1134 prior_direction: Some("manual".to_string()),
1135 new_direction: Some("automated".to_string()),
1136 reason: Some("infeasible at scale".to_string()),
1137 lesson: Some("profile before committing".to_string()),
1138 }
1139 );
1140 assert!(
1141 report.warnings().is_empty(),
1142 "pivot fields must not warn, got: {report}"
1143 );
1144 }
1145
1146 #[test]
1147 fn dead_end_widened_fields_no_warning() {
1148 let yaml = "\
1151tree:
1152 - id: N01
1153 type: dead_end
1154 hypothesis: h
1155 failure_mode: fm
1156 lesson: l
1157 why_failed: wf
1158";
1159 let (m, report) = parse_sources(yaml, None).expect("ok");
1160 assert_eq!(m.nodes[0].kind, NodeKind::DeadEnd);
1161 assert_eq!(
1162 m.nodes[0].fields,
1163 NodeFields::DeadEnd {
1164 hypothesis: Some("h".to_string()),
1165 failure_mode: Some("fm".to_string()),
1166 lesson: Some("l".to_string()),
1167 why_failed: Some("wf".to_string()),
1168 }
1169 );
1170 assert!(
1171 report.warnings().is_empty(),
1172 "dead_end fields must not warn, got: {report}"
1173 );
1174 }
1175
1176 #[test]
1177 fn wrong_kind_body_field_warns_per_field() {
1178 let cases: &[(&str, &str, &str)] = &[
1183 ("result", "result: r", "question"),
1185 ("status", "status: running", "question"),
1186 ("status", "status: running", "decision"),
1187 ("status", "status: running", "dead_end"),
1188 ("status", "status: running", "pivot"),
1189 ("exploration", "exploration: grid", "decision"),
1190 ("outcome", "outcome: wins", "pivot"),
1191 ("why_failed", "why_failed: wf", "experiment"),
1192 ("hypothesis", "hypothesis: h", "experiment"),
1193 ("failure_mode", "failure_mode: fm", "question"),
1194 ("lesson", "lesson: l", "question"),
1195 ("lesson", "lesson: l", "insight"),
1196 ("prior_direction", "prior_direction: dense", "dead_end"),
1197 ("new_direction", "new_direction: sparse", "experiment"),
1198 ("reason", "reason: latency", "decision"),
1199 ("choice", "choice: c", "experiment"),
1200 ("alternatives", "alternatives: [a, b]", "pivot"),
1201 ("rationale", "rationale: rat", "dead_end"),
1202 ];
1203 for (field, entry, kind) in cases {
1204 let yaml = format!("tree:\n - id: N01\n type: {kind}\n {entry}\n");
1205 let (_m, report) = parse_sources(&yaml, None).expect("ok");
1206 let drops: Vec<_> = report
1207 .warnings()
1208 .iter()
1209 .filter(|d| {
1210 d.message
1211 .contains(&format!("`{field}` dropped for type `{kind}`"))
1212 })
1213 .collect();
1214 assert_eq!(
1215 drops.len(),
1216 1,
1217 "expected exactly one drop warning for `{field}` on `{kind}`, got: {report}"
1218 );
1219 assert_eq!(
1220 report.warnings().len(),
1221 1,
1222 "no other warnings expected for `{field}` on `{kind}`, got: {report}"
1223 );
1224 }
1225 }
1226
1227 #[test]
1228 fn right_kind_body_fields_no_drop_warnings() {
1229 let yaml = "\
1235tree:
1236 - id: N01
1237 type: experiment
1238 result: r
1239 exploration: e
1240 outcome: o
1241 status: s
1242 - id: N02
1243 type: decision
1244 choice: c
1245 alternatives: [a, b]
1246 rationale: rat
1247";
1248 let (_m, report) = parse_sources(yaml, None).expect("ok");
1249 assert!(
1250 report.warnings().is_empty(),
1251 "right-kind fields must not warn, got: {report}"
1252 );
1253 }
1254
1255 #[test]
1256 fn duplicate_link_warns() {
1257 let yaml = "\
1261tree:
1262 - id: N01
1263 type: question
1264 children:
1265 - id: N02
1266 type: experiment
1267 also_depends_on: [N03, N03]
1268 - id: N03
1269 type: insight
1270";
1271 let (_m, report) = parse_sources(yaml, None).expect("ok");
1272 assert!(
1273 report
1274 .warnings()
1275 .iter()
1276 .any(|d| d.message.contains("duplicate") && d.message.contains("link to `N03`")),
1277 "expected duplicate-link warning, got: {report}"
1278 );
1279 }
1280}