Skip to main content

ara_core/
parse.rs

1//! Parse + normalize `trace/exploration_tree.yaml` (+ optional
2//! `logic/claims.md`) into a [`Manifest`].
3//!
4//! [`parse_sources`] is pure and wasm-safe (no threads, filesystem, or
5//! `SystemTime`). [`parse_dir`] is a thin native wrapper that reads the two
6//! files and delegates. Determinism comes from **preserving input order**:
7//! nodes are pre-order DFS, links/bindings follow source order. Nothing is
8//! sorted by id.
9
10use 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/// Parses in-memory sources into a [`Manifest`]. Pure and wasm-safe.
21///
22/// `claims_md = None` means claim references cannot be resolved: each `C##`
23/// evidence reference becomes an **unresolved-binding warning** (not an error),
24/// and no bindings are produced.
25///
26/// Returns `Ok((manifest, report))` when there are no errors (the report may
27/// still carry warnings that callers must surface), or `Err(report)` otherwise.
28pub fn parse_sources(
29    tree_yaml: &str,
30    claims_md: Option<&str>,
31) -> Result<(Manifest, ParseReport), ParseReport> {
32    let mut report = ParseReport::default();
33
34    let doc = match parse_doc(tree_yaml) {
35        Ok(doc) => doc,
36        Err(msg) => {
37            report.error("document", msg);
38            return Err(report);
39        }
40    };
41
42    for key in doc.extra.keys() {
43        report.warn("document", format!("unknown field `{key}`"));
44    }
45
46    let roots: Vec<RawNode> = match (doc.tree, doc.root) {
47        (Some(_), Some(_)) => {
48            report.error(
49                "document",
50                "both `tree:` and `root:` are present; exactly one is allowed",
51            );
52            return Err(report);
53        }
54        (None, None) => {
55            report.error("document", "neither `tree:` nor `root:` is present");
56            return Err(report);
57        }
58        (Some(tree), None) => {
59            if tree.is_empty() {
60                report.warn("document", "empty manifest (`tree: []`)");
61            }
62            tree
63        }
64        (None, Some(root)) => vec![*root],
65    };
66
67    // Claims resolve node→claim and claim→claim references.
68    let claims_present = claims_md.is_some();
69    let (claims, duplicate_claim_ids) = match claims_md {
70        Some(md) => {
71            let parsed = parse_claims(md);
72            (parsed.claims, parsed.duplicate_ids)
73        }
74        None => (Vec::new(), Vec::new()),
75    };
76    let claim_ids: BTreeSet<ClaimId> = claims.iter().map(|c| c.id.clone()).collect();
77    for id in duplicate_claim_ids {
78        report.error(format!("claims[{id}]"), "duplicate claim id");
79    }
80
81    let mut norm = Normalizer {
82        report,
83        claims_present,
84        claim_ids,
85        nodes: Vec::new(),
86        node_ids: BTreeSet::new(),
87        bindings: Vec::new(),
88        child_links: Vec::new(),
89        also: Vec::new(),
90    };
91    for raw in &roots {
92        norm.dfs(raw, None);
93    }
94
95    // Resolve `also_depends_on` (needs the full node-id set), then combine and
96    // dedupe links.
97    //
98    // A dependency whose target is an *ancestor* of the source (reachable by
99    // walking `children:` nesting upward) merely restates the nesting: it is
100    // redundant, and combined with the parent→child edge it would close a cycle
101    // and fail the whole artifact. Real traces do this (a child re-declaring a
102    // dependency on its parent), so drop the redundant edge with a warning
103    // instead. Genuine cross-cycles — a dependency on a sibling or descendant
104    // that closes a loop — are not ancestors and stay fatal in `detect_cycles`.
105    let parent_of: BTreeMap<NodeId, NodeId> = norm
106        .child_links
107        .iter()
108        .map(|l| (l.to.clone(), l.from.clone()))
109        .collect();
110    let mut depends_links: Vec<Link> = Vec::new();
111    for (from, targets) in &norm.also {
112        for (i, target) in targets.iter().enumerate() {
113            let t = target.trim();
114            let to = NodeId::new(t);
115            if !norm.node_ids.contains(&to) {
116                norm.report.error(
117                    format!("nodes[{from}].also_depends_on[{i}]"),
118                    format!("`also_depends_on` references unknown node `{t}`"),
119                );
120                continue;
121            }
122            if is_ancestor(&to, from, &parent_of) {
123                norm.report.warn(
124                    format!("nodes[{from}].also_depends_on[{i}]"),
125                    format!(
126                        "redundant `also_depends_on` on ancestor `{t}` (already nested under it)"
127                    ),
128                );
129                continue;
130            }
131            depends_links.push(Link {
132                from: from.clone(),
133                to,
134                kind: LinkKind::DependsOn,
135            });
136        }
137    }
138
139    let mut links = norm.child_links;
140    links.extend(depends_links);
141    let links = dedupe_links(links, &mut norm.report);
142
143    detect_cycles(&norm.nodes, &links, &mut norm.report);
144
145    // Resolve claim→claim dependencies.
146    for claim in &claims {
147        for (i, dep) in claim.deps.iter().enumerate() {
148            if !norm.claim_ids.contains(dep) {
149                norm.report.error(
150                    format!("claims[{}].dependencies[{i}]", claim.id),
151                    format!("dependency references unknown claim `{dep}`"),
152                );
153            }
154        }
155    }
156
157    let manifest = Manifest {
158        nodes: norm.nodes,
159        links,
160        bindings: norm.bindings,
161        claims,
162        bounds: None,
163        paper: None,
164        related_work: Vec::new(),
165        concepts: Vec::new(),
166        problem: None,
167        recipes: Vec::new(),
168        exhibits: Vec::new(),
169        built_on: Vec::new(),
170        node_exhibits: Vec::new(),
171    };
172
173    if norm.report.is_ok() {
174        Ok((manifest, norm.report))
175    } else {
176        Err(norm.report)
177    }
178}
179
180/// Reads `trace/exploration_tree.yaml` (required) and `logic/claims.md`
181/// (optional) from `dir` and normalizes them, then augments the manifest with
182/// the optional logic-section files (`PAPER.md`, `logic/problem.md`,
183/// `logic/concepts.md`, `logic/related_work.md`, `logic/solution/*.md`). An
184/// absent section file is silently skipped; a present-but-malformed one adds a
185/// warning without failing the parse. Native only.
186#[cfg(feature = "native")]
187pub fn parse_dir(dir: &std::path::Path) -> Result<(Manifest, ParseReport), ParseReport> {
188    let tree_path = dir.join("trace/exploration_tree.yaml");
189    let tree_yaml = match std::fs::read_to_string(&tree_path) {
190        Ok(s) => s,
191        Err(e) => {
192            let mut report = ParseReport::default();
193            report.error(
194                "document",
195                format!("cannot read {}: {e}", tree_path.display()),
196            );
197            return Err(report);
198        }
199    };
200    // A missing claims file is not an error — it downgrades bindings to warnings.
201    let claims_path = dir.join("logic/claims.md");
202    let claims_md = std::fs::read_to_string(&claims_path).ok();
203
204    // The base parse owns the Ok/Err contract; an error short-circuits here.
205    let (mut manifest, mut report) = parse_sources(&tree_yaml, claims_md.as_deref())?;
206    read_logic_layer(dir, &mut manifest, &mut report);
207    read_evidence_layer(dir, &mut manifest, &mut report);
208    Ok((manifest, report))
209}
210
211/// Reads the optional `evidence/` layer into `manifest.exhibits`, then runs the
212/// two deterministic resolution passes (`node_exhibits`, `built_on`) over the
213/// assembled manifest. Absent evidence yields empty fields; every defect warns
214/// but never errors. Native only.
215#[cfg(feature = "native")]
216fn read_evidence_layer(dir: &std::path::Path, manifest: &mut Manifest, report: &mut ParseReport) {
217    use crate::evidence::{read_evidence, resolve_built_on, resolve_node_exhibits};
218
219    manifest.exhibits = read_evidence(dir, report);
220    manifest.node_exhibits =
221        resolve_node_exhibits(&manifest.nodes, &manifest.bindings, &manifest.exhibits);
222    manifest.built_on =
223        resolve_built_on(&manifest.nodes, &manifest.bindings, &manifest.related_work);
224}
225
226/// Reads the optional logic-section files into `manifest`, appending reader
227/// warnings to `report`. Absent files are skipped without a warning; a present
228/// file that parses degenerately warns but never errors.
229#[cfg(feature = "native")]
230fn read_logic_layer(dir: &std::path::Path, manifest: &mut Manifest, report: &mut ParseReport) {
231    use crate::paper::parse_paper;
232    use crate::sections::{parse_concepts, parse_problem, parse_related_work};
233
234    if let Some(md) = read_opt(&dir.join("PAPER.md")) {
235        let (paper, warnings) = parse_paper(&md);
236        manifest.paper = paper;
237        for w in warnings {
238            report.warn("PAPER.md", w);
239        }
240    }
241
242    if let Some(md) = read_opt(&dir.join("logic/problem.md")) {
243        manifest.problem = Some(parse_problem(&md));
244    }
245
246    if let Some(md) = read_opt(&dir.join("logic/concepts.md")) {
247        let concepts = parse_concepts(&md);
248        for c in &concepts {
249            if c.definition.is_none() {
250                report.warn(format!("concepts[{}]", c.term), "concept has no definition");
251            }
252        }
253        manifest.concepts = concepts;
254    }
255
256    if let Some(md) = read_opt(&dir.join("logic/related_work.md")) {
257        let related_work = parse_related_work(&md);
258        for r in &related_work {
259            if r.doi.is_none() {
260                report.warn(format!("related_work[{}]", r.id), "related work has no DOI");
261            }
262        }
263        manifest.related_work = related_work;
264    }
265
266    manifest.recipes = read_recipes(&dir.join("logic/solution"));
267}
268
269/// Reads a file to a string, mapping any I/O error (including absence) to
270/// `None`. Absent section files are not an error.
271#[cfg(feature = "native")]
272fn read_opt(path: &std::path::Path) -> Option<String> {
273    std::fs::read_to_string(path).ok()
274}
275
276/// Enumerates `logic/solution/*.md` sorted by path (determinism) and builds one
277/// [`crate::manifest::Recipe`] per file: filename stem, first `# Title`, and the
278/// verbatim body. A missing directory yields no recipes.
279#[cfg(feature = "native")]
280fn read_recipes(solution_dir: &std::path::Path) -> Vec<crate::manifest::Recipe> {
281    let Ok(entries) = std::fs::read_dir(solution_dir) else {
282        return Vec::new();
283    };
284    let mut paths: Vec<std::path::PathBuf> = entries
285        .flatten()
286        .map(|e| e.path())
287        .filter(|p| p.extension().is_some_and(|ext| ext == "md"))
288        .collect();
289    paths.sort();
290
291    let mut recipes = Vec::new();
292    for path in paths {
293        let Ok(body) = std::fs::read_to_string(&path) else {
294            continue;
295        };
296        let name = path
297            .file_stem()
298            .map(|s| s.to_string_lossy().into_owned())
299            .unwrap_or_default();
300        let title = crate::paper::first_h1(&body);
301        recipes.push(crate::manifest::Recipe { name, title, body });
302    }
303    recipes
304}
305
306/// Mutable accumulators for the normalization DFS.
307struct Normalizer {
308    report: ParseReport,
309    claims_present: bool,
310    claim_ids: BTreeSet<ClaimId>,
311    nodes: Vec<Node>,
312    node_ids: BTreeSet<NodeId>,
313    bindings: Vec<Binding>,
314    child_links: Vec<Link>,
315    /// Per emitted node, its raw `also_depends_on` targets (resolved later).
316    also: Vec<(NodeId, Vec<String>)>,
317}
318
319impl Normalizer {
320    /// Pre-order visit of `raw`, emitting one [`Node`] plus its child link,
321    /// bindings, and evidence notes. A missing or duplicate id drops the node
322    /// (and its subtree) with an error, rather than corrupting the graph.
323    fn dfs(&mut self, raw: &RawNode, parent: Option<&NodeId>) {
324        let id_str = raw.id.as_deref().map(str::trim).filter(|s| !s.is_empty());
325        let Some(id_str) = id_str else {
326            let label = raw.title.as_deref().unwrap_or("<no id>");
327            self.report
328                .error(format!("nodes[{label}]"), "node is missing an `id`");
329            return;
330        };
331        let id = NodeId::new(id_str);
332        if self.node_ids.contains(&id) {
333            self.report
334                .error(format!("nodes[{id}]"), "duplicate node id");
335            return;
336        }
337        self.node_ids.insert(id.clone());
338
339        if let Some(parent) = parent {
340            self.child_links.push(Link {
341                from: parent.clone(),
342                to: id.clone(),
343                kind: LinkKind::Child,
344            });
345        }
346
347        let (kind, fields) = self.project_kind(raw, &id);
348        let evidence_notes = self.split_evidence(raw, &id);
349
350        for key in raw.extra.keys() {
351            self.report
352                .warn(format!("nodes[{id}]"), format!("unknown field `{key}`"));
353        }
354
355        self.nodes.push(Node {
356            id: id.clone(),
357            kind,
358            label: raw.title.clone(),
359            support_level: raw.support_level.clone(),
360            source_refs: raw.source_refs.clone(),
361            description: raw.description.clone(),
362            fields,
363            evidence_notes,
364            isolated: raw.isolated,
365            pos: None,
366        });
367        self.also.push((id.clone(), raw.also_depends_on.clone()));
368
369        for child in &raw.children {
370            self.dfs(child, Some(&id));
371        }
372    }
373
374    /// Projects `type:` + body fields into a typed [`NodeKind`]/[`NodeFields`].
375    /// Unknown/missing types become [`NodeKind::Other`]; any canonical body
376    /// fields carried by an unknown type are warned so nothing is lost silently.
377    fn project_kind(&mut self, raw: &RawNode, id: &NodeId) -> (NodeKind, NodeFields) {
378        match raw.ty.as_deref().map(str::trim) {
379            Some("question") => (NodeKind::Question, NodeFields::Question),
380            Some("experiment") => (
381                NodeKind::Experiment,
382                NodeFields::Experiment {
383                    result: raw.result.clone(),
384                },
385            ),
386            Some("decision") => (
387                NodeKind::Decision,
388                NodeFields::Decision {
389                    choice: raw.choice.clone(),
390                    alternatives: raw.alternatives.clone(),
391                    rationale: raw.rationale.clone(),
392                },
393            ),
394            Some("dead_end") => (
395                NodeKind::DeadEnd,
396                NodeFields::DeadEnd {
397                    hypothesis: raw.hypothesis.clone(),
398                    failure_mode: raw.failure_mode.clone(),
399                    lesson: raw.lesson.clone(),
400                    why_failed: raw.why_failed.clone(),
401                },
402            ),
403            Some("insight") => (NodeKind::Insight, NodeFields::Insight),
404            Some("pivot") => (
405                NodeKind::Pivot,
406                NodeFields::Pivot {
407                    from: raw.from.clone(),
408                    to: raw.to.clone(),
409                    trigger: raw.trigger.clone(),
410                },
411            ),
412            Some("") | None => {
413                self.report
414                    .warn(format!("nodes[{id}]"), "node is missing a `type`");
415                (NodeKind::Other(String::new()), NodeFields::Other)
416            }
417            Some(other) => {
418                for field in body_field_names(raw) {
419                    self.report.warn(
420                        format!("nodes[{id}]"),
421                        format!("field `{field}` dropped for unknown type `{other}`"),
422                    );
423                }
424                (NodeKind::Other(other.to_string()), NodeFields::Other)
425            }
426        }
427    }
428
429    /// Splits `evidence:` into `C##` bindings (node→claim) and prose notes.
430    fn split_evidence(&mut self, raw: &RawNode, id: &NodeId) -> Vec<String> {
431        let mut notes = Vec::new();
432        let Some(evidence) = &raw.evidence else {
433            return notes;
434        };
435        for (i, entry) in evidence.entries().iter().enumerate() {
436            let trimmed = entry.trim();
437            if is_canonical_id(trimmed, 'C') {
438                let claim = ClaimId::new(trimmed);
439                let path = format!("nodes[{id}].evidence[{i}]");
440                if !self.claims_present {
441                    self.report.warn(
442                        path,
443                        format!("claim reference `{trimmed}` unresolved (no claims.md provided)"),
444                    );
445                } else if self.claim_ids.contains(&claim) {
446                    self.bindings.push(Binding {
447                        node: id.clone(),
448                        claim,
449                        role: BindingRole::Evidence,
450                    });
451                } else {
452                    self.report.error(
453                        path,
454                        format!("evidence references unknown claim `{trimmed}`"),
455                    );
456                }
457            } else {
458                notes.push(entry.clone());
459            }
460        }
461        notes
462    }
463}
464
465/// Names of canonical body fields present on `raw` (used only to warn when an
466/// unknown-typed node carries them).
467fn body_field_names(raw: &RawNode) -> Vec<&'static str> {
468    let mut names = Vec::new();
469    if raw.result.is_some() {
470        names.push("result");
471    }
472    if raw.why_failed.is_some() {
473        names.push("why_failed");
474    }
475    if raw.hypothesis.is_some() {
476        names.push("hypothesis");
477    }
478    if raw.failure_mode.is_some() {
479        names.push("failure_mode");
480    }
481    if raw.lesson.is_some() {
482        names.push("lesson");
483    }
484    if raw.from.is_some() {
485        names.push("from");
486    }
487    if raw.to.is_some() {
488        names.push("to");
489    }
490    if raw.trigger.is_some() {
491        names.push("trigger");
492    }
493    if raw.choice.is_some() {
494        names.push("choice");
495    }
496    if !raw.alternatives.is_empty() {
497        names.push("alternatives");
498    }
499    if raw.rationale.is_some() {
500        names.push("rationale");
501    }
502    names
503}
504
505/// True when `ancestor` lies on the `children:`-nesting chain above `node` —
506/// i.e. reachable by walking parent pointers up from `node`. Used to drop
507/// redundant `also_depends_on` edges that only restate the nesting.
508fn is_ancestor(ancestor: &NodeId, node: &NodeId, parent_of: &BTreeMap<NodeId, NodeId>) -> bool {
509    // `parent_of` is built from `children:` nesting, which is a tree (each node
510    // has at most one parent and no parent cycles), so walking up terminates.
511    let mut cur = node;
512    while let Some(parent) = parent_of.get(cur) {
513        if parent == ancestor {
514            return true;
515        }
516        cur = parent;
517    }
518    false
519}
520
521/// Removes identical `(from, to, kind)` links, keeping the first and warning on
522/// each duplicate.
523fn dedupe_links(links: Vec<Link>, report: &mut ParseReport) -> Vec<Link> {
524    let mut seen: BTreeSet<(NodeId, NodeId, LinkKind)> = BTreeSet::new();
525    let mut out = Vec::with_capacity(links.len());
526    for link in links {
527        let key = (link.from.clone(), link.to.clone(), link.kind);
528        if seen.contains(&key) {
529            report.warn(
530                format!("nodes[{}]", link.from),
531                format!("duplicate {:?} link to `{}`", link.kind, link.to),
532            );
533        } else {
534            seen.insert(key);
535            out.push(link);
536        }
537    }
538    out
539}
540
541/// Reports a cycle error for every back-edge in the combined
542/// `Child` + `DependsOn` graph (DFS three-color).
543fn detect_cycles(nodes: &[Node], links: &[Link], report: &mut ParseReport) {
544    // BTreeMap (not HashMap) keeps traversal — and thus error ordering — free of
545    // any hash-seed influence, matching the crate's determinism guarantee.
546    let mut adj: BTreeMap<&NodeId, Vec<&NodeId>> = BTreeMap::new();
547    for link in links {
548        adj.entry(&link.from).or_default().push(&link.to);
549    }
550    let mut color: BTreeMap<&NodeId, u8> = BTreeMap::new(); // 0=white, 1=gray, 2=black
551    for node in nodes {
552        if color.get(&node.id).copied().unwrap_or(0) == 0 {
553            visit(&node.id, &adj, &mut color, report);
554        }
555    }
556}
557
558fn visit<'a>(
559    u: &'a NodeId,
560    adj: &BTreeMap<&'a NodeId, Vec<&'a NodeId>>,
561    color: &mut BTreeMap<&'a NodeId, u8>,
562    report: &mut ParseReport,
563) {
564    color.insert(u, 1);
565    if let Some(neighbors) = adj.get(u) {
566        for &v in neighbors {
567            match color.get(v).copied().unwrap_or(0) {
568                0 => visit(v, adj, color, report),
569                1 => report.error(
570                    format!("nodes[{u}]"),
571                    format!("cycle detected: edge to `{v}` closes a cycle"),
572                ),
573                _ => {}
574            }
575        }
576    }
577    color.insert(u, 2);
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    const MINIMAL: &str = "\
585tree:
586  - id: N01
587    type: question
588    title: Q?
589    children:
590      - id: N02
591        type: experiment
592        result: 28.4 BLEU
593        evidence: [C01, \"Table 2\"]
594";
595    const CLAIMS: &str = "## C01: A claim\n- **Statement**: yes\n";
596
597    #[test]
598    fn resolves_bindings_and_splits_evidence() {
599        let (m, report) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
600        assert!(report.is_ok());
601        assert!(report.warnings().is_empty());
602        assert_eq!(m.nodes.len(), 2);
603        assert_eq!(m.nodes[0].id, NodeId::new("N01")); // DFS/source order
604        assert_eq!(m.nodes[1].id, NodeId::new("N02"));
605        assert_eq!(m.links.len(), 1); // N01 -> N02 child
606        assert_eq!(m.links[0].kind, LinkKind::Child);
607        assert_eq!(m.bindings.len(), 1); // N02 -> C01
608        assert_eq!(m.bindings[0].claim, ClaimId::new("C01"));
609        assert_eq!(m.nodes[1].evidence_notes, vec!["Table 2"]);
610    }
611
612    #[test]
613    fn missing_claims_downgrades_binding_to_warning() {
614        let (m, report) = parse_sources(MINIMAL, None).expect("ok");
615        assert!(report.is_ok());
616        assert!(m.bindings.is_empty());
617        assert_eq!(report.warnings().len(), 1);
618        assert!(report.warnings()[0].message.contains("unresolved"));
619    }
620
621    #[test]
622    fn broken_claim_ref_is_error() {
623        let err = parse_sources(MINIMAL, Some("## C99: other\n")).unwrap_err();
624        assert!(!err.is_ok());
625        assert!(err.errors()[0].message.contains("unknown claim"));
626    }
627
628    #[test]
629    fn malformed_yaml_is_error_not_panic() {
630        let err = parse_sources("tree: not-a-list\n", None).unwrap_err();
631        assert_eq!(err.errors()[0].path, "document");
632    }
633
634    #[test]
635    fn both_roots_is_error() {
636        let err = parse_sources("tree: []\nroot:\n  id: N01\n", None).unwrap_err();
637        assert!(err.errors()[0].message.contains("both"));
638    }
639
640    #[test]
641    fn neither_root_is_error() {
642        let err = parse_sources("meta: hi\n", None).unwrap_err();
643        assert!(err.errors()[0].message.contains("neither"));
644    }
645
646    #[test]
647    fn empty_tree_warns_and_is_ok() {
648        let (m, report) = parse_sources("tree: []\n", None).expect("ok");
649        assert!(m.nodes.is_empty());
650        assert_eq!(report.warnings().len(), 1);
651    }
652
653    #[test]
654    fn cycle_is_detected() {
655        // A genuine cross-cycle across two branches: N02 -> N04 -> N02, where
656        // neither is an ancestor of the other (so it is not the tolerated
657        // redundant-back-edge case). detect_cycles must flag it.
658        let yaml = "\
659tree:
660  - id: N01
661    type: question
662    children:
663      - id: N02
664        type: experiment
665        also_depends_on: [N04]
666      - id: N03
667        type: decision
668        children:
669          - id: N04
670            type: insight
671            also_depends_on: [N02]
672";
673        let err = parse_sources(yaml, None).unwrap_err();
674        assert!(err.errors().iter().any(|d| d.message.contains("cycle")));
675    }
676
677    #[test]
678    fn duplicate_node_id_is_error() {
679        let yaml = "\
680tree:
681  - id: N01
682    type: question
683  - id: N01
684    type: insight
685";
686        let err = parse_sources(yaml, None).unwrap_err();
687        assert!(
688            err.errors()
689                .iter()
690                .any(|d| d.message.contains("duplicate node id"))
691        );
692    }
693
694    #[test]
695    fn unknown_type_becomes_other_and_warns() {
696        let yaml = "tree:\n  - id: N01\n    type: hypothesis\n    title: h\n";
697        let (m, _r) = parse_sources(yaml, None).expect("ok");
698        assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
699    }
700
701    #[test]
702    fn root_single_matches_tree_shape() {
703        let tree = "tree:\n  - id: N01\n    type: question\n    title: q\n";
704        let root = "root:\n  id: N01\n  type: question\n  title: q\n";
705        let (mt, _) = parse_sources(tree, None).expect("ok");
706        let (mr, _) = parse_sources(root, None).expect("ok");
707        assert_eq!(mt.nodes, mr.nodes);
708    }
709
710    #[test]
711    fn determinism_parse_twice_identical() {
712        let (a, _) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
713        let (b, _) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
714        assert_eq!(a, b);
715    }
716
717    #[test]
718    fn broken_node_to_node_ref_is_error() {
719        let yaml = "\
720tree:
721  - id: N01
722    type: question
723    children:
724      - id: N02
725        type: experiment
726        also_depends_on: [N99]
727";
728        let err = parse_sources(yaml, None).unwrap_err();
729        assert!(
730            err.errors()
731                .iter()
732                .any(|d| d.message.contains("unknown node") && d.path.contains("also_depends_on")),
733            "expected broken node->node error, got: {err}"
734        );
735    }
736
737    #[test]
738    fn broken_claim_to_claim_dep_is_error() {
739        // C01 depends on C99, which does not exist.
740        let claims = "## C01: A\n- **Dependencies**: [C99]\n";
741        let err = parse_sources(MINIMAL, Some(claims)).unwrap_err();
742        assert!(
743            err.errors()
744                .iter()
745                .any(|d| d.message.contains("unknown claim") && d.path.contains("dependencies")),
746            "expected broken claim->claim error, got: {err}"
747        );
748    }
749
750    #[test]
751    fn proof_evidence_refs_emit_no_error() {
752        // `E##` proof refs are stored raw and must never produce a diagnostic.
753        let claims = "## C01: A\n- **Statement**: s\n- **Proof**: [E01, E02]\n";
754        let (m, report) = parse_sources(MINIMAL, Some(claims)).expect("ok");
755        assert_eq!(m.claims[0].proof, vec!["E01", "E02"]);
756        // Success with no errors at all: E## refs are opaque, never validated.
757        assert!(report.is_ok());
758        assert!(report.errors().is_empty());
759    }
760
761    #[test]
762    fn sibling_only_depends_on_cycle_is_detected() {
763        // Cycle formed purely by DependsOn edges between siblings (no Child edge).
764        let yaml = "\
765tree:
766  - id: N01
767    type: question
768    children:
769      - id: N02
770        type: experiment
771        also_depends_on: [N03]
772      - id: N03
773        type: insight
774        also_depends_on: [N02]
775";
776        let err = parse_sources(yaml, None).unwrap_err();
777        assert!(err.errors().iter().any(|d| d.message.contains("cycle")));
778    }
779
780    #[test]
781    fn redundant_ancestor_depends_on_is_dropped_with_warning() {
782        // A child that re-declares `also_depends_on` on its own parent restates
783        // the nesting. The edge is dropped with a WARNING (not a fatal cycle),
784        // so the artifact still parses.
785        let yaml = "\
786tree:
787  - id: N01
788    type: question
789    children:
790      - id: N02
791        type: experiment
792        also_depends_on: [N01]
793";
794        let (m, report) = parse_sources(yaml, None).expect("parses ok despite ancestor dep");
795        assert!(report.is_ok(), "must not error: {report}");
796        // No DependsOn link survives; only the N01->N02 child link remains.
797        assert_eq!(m.links.len(), 1);
798        assert_eq!(m.links[0].kind, LinkKind::Child);
799        assert!(
800            report
801                .warnings()
802                .iter()
803                .any(|d| d.message.contains("redundant") && d.message.contains("ancestor")),
804            "expected redundant-ancestor warning, got: {report}"
805        );
806    }
807
808    #[test]
809    fn redundant_grandparent_depends_on_is_dropped() {
810        // Ancestry is transitive: a dependency on a grandparent is redundant too.
811        let yaml = "\
812tree:
813  - id: N01
814    type: question
815    children:
816      - id: N02
817        type: experiment
818        children:
819          - id: N03
820            type: insight
821            also_depends_on: [N01]
822";
823        let (m, report) = parse_sources(yaml, None).expect("ok");
824        assert!(report.is_ok());
825        // Two child links, zero DependsOn links.
826        assert!(m.links.iter().all(|l| l.kind == LinkKind::Child));
827        assert_eq!(m.links.len(), 2);
828    }
829
830    #[test]
831    fn sibling_depends_on_is_kept_not_dropped() {
832        // A dependency on a sibling is a genuine DAG cross-edge (the sibling is
833        // not an ancestor) and must survive.
834        let yaml = "\
835tree:
836  - id: N01
837    type: question
838    children:
839      - id: N02
840        type: experiment
841      - id: N03
842        type: insight
843        also_depends_on: [N02]
844";
845        let (m, report) = parse_sources(yaml, None).expect("ok");
846        assert!(report.is_ok());
847        assert!(
848            m.links.iter().any(|l| l.kind == LinkKind::DependsOn
849                && l.from == NodeId::new("N03")
850                && l.to == NodeId::new("N02")),
851            "sibling cross-edge must be kept"
852        );
853    }
854
855    #[test]
856    fn missing_node_id_is_error() {
857        // A node with no `id` is dropped with an ERROR (data-dropping path).
858        let err = parse_sources("tree:\n  - type: question\n    title: q\n", None).unwrap_err();
859        assert!(
860            err.errors()
861                .iter()
862                .any(|d| d.message.contains("missing an `id`")),
863            "expected missing-id error, got: {err}"
864        );
865    }
866
867    #[test]
868    fn duplicate_claim_id_is_error() {
869        // `claims.rs` surfaces the dup as data; `parse_sources` turns it into the
870        // `claims[{id}]` ERROR diagnostic.
871        let err = parse_sources(MINIMAL, Some("## C01: A\n## C01: B\n")).unwrap_err();
872        assert!(
873            err.errors()
874                .iter()
875                .any(|d| d.path.contains("claims[C01]") && d.message.contains("duplicate claim id")),
876            "expected duplicate-claim-id error, got: {err}"
877        );
878    }
879
880    #[test]
881    fn isolated_field_defaults_false_and_sources_from_raw() {
882        // Absent `isolated:` → false; an explicit `isolated: true` on a node is
883        // carried through to the normalized node.
884        let yaml = "\
885tree:
886  - id: N01
887    type: question
888    children:
889      - id: N02
890        type: experiment
891        isolated: true
892";
893        let (m, _r) = parse_sources(yaml, None).expect("ok");
894        assert!(!m.nodes[0].isolated, "N01 has no isolated key → false");
895        assert!(m.nodes[1].isolated, "N02 carries isolated: true");
896    }
897
898    #[test]
899    fn missing_type_warns() {
900        // Distinct from the unknown-type branch: an absent `type:` warns (WARNING),
901        // and the node still parses as `Other`.
902        let (m, report) = parse_sources("tree:\n  - id: N01\n    title: q\n", None).expect("ok");
903        assert_eq!(m.nodes[0].kind, NodeKind::Other(String::new()));
904        assert!(
905            report
906                .warnings()
907                .iter()
908                .any(|d| d.message.contains("missing a `type`")),
909            "expected missing-type warning, got: {report}"
910        );
911    }
912
913    #[test]
914    fn unknown_type_dropped_body_field_warns() {
915        // An unknown-typed node carrying a canonical body field warns that the
916        // field is dropped, so nothing is lost silently.
917        let yaml = "tree:\n  - id: N01\n    type: hypothesis\n    result: 28.4 BLEU\n";
918        let (m, report) = parse_sources(yaml, None).expect("ok");
919        assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
920        assert!(
921            report.warnings().iter().any(|d| d
922                .message
923                .contains("`result` dropped for unknown type `hypothesis`")),
924            "expected dropped-field warning, got: {report}"
925        );
926    }
927
928    #[test]
929    fn pivot_projects_kind_and_fields_no_warning() {
930        // A `pivot` node projects to NodeKind::Pivot + NodeFields::Pivot with
931        // from/to/trigger populated, and carries no unknown-field warning.
932        let yaml = "\
933tree:
934  - id: N01
935    type: pivot
936    from: manual
937    to: automated
938    trigger: infeasible at scale
939";
940        let (m, report) = parse_sources(yaml, None).expect("ok");
941        assert_eq!(m.nodes[0].kind, NodeKind::Pivot);
942        assert_eq!(
943            m.nodes[0].fields,
944            NodeFields::Pivot {
945                from: Some("manual".to_string()),
946                to: Some("automated".to_string()),
947                trigger: Some("infeasible at scale".to_string()),
948            }
949        );
950        assert!(
951            report.warnings().is_empty(),
952            "pivot fields must not warn, got: {report}"
953        );
954    }
955
956    #[test]
957    fn dead_end_widened_fields_no_warning() {
958        // A `dead_end` node carrying hypothesis/failure_mode/lesson populates all
959        // fields (plus why_failed) and carries no unknown-field warning.
960        let yaml = "\
961tree:
962  - id: N01
963    type: dead_end
964    hypothesis: h
965    failure_mode: fm
966    lesson: l
967    why_failed: wf
968";
969        let (m, report) = parse_sources(yaml, None).expect("ok");
970        assert_eq!(m.nodes[0].kind, NodeKind::DeadEnd);
971        assert_eq!(
972            m.nodes[0].fields,
973            NodeFields::DeadEnd {
974                hypothesis: Some("h".to_string()),
975                failure_mode: Some("fm".to_string()),
976                lesson: Some("l".to_string()),
977                why_failed: Some("wf".to_string()),
978            }
979        );
980        assert!(
981            report.warnings().is_empty(),
982            "dead_end fields must not warn, got: {report}"
983        );
984    }
985
986    #[test]
987    fn duplicate_link_warns() {
988        // A repeated `also_depends_on` target yields two identical DependsOn links;
989        // `dedupe_links` keeps the first and warns on the duplicate. Two siblings
990        // keep the graph acyclic.
991        let yaml = "\
992tree:
993  - id: N01
994    type: question
995    children:
996      - id: N02
997        type: experiment
998        also_depends_on: [N03, N03]
999      - id: N03
1000        type: insight
1001";
1002        let (_m, report) = parse_sources(yaml, None).expect("ok");
1003        assert!(
1004            report
1005                .warnings()
1006                .iter()
1007                .any(|d| d.message.contains("duplicate") && d.message.contains("link to `N03`")),
1008            "expected duplicate-link warning, got: {report}"
1009        );
1010    }
1011}