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#[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
29/// Parses in-memory sources into a [`Manifest`]. Pure and wasm-safe.
30///
31/// `claims_md = None` means claim references cannot be resolved: each `C##`
32/// evidence reference becomes an **unresolved-binding warning** (not an error),
33/// and no bindings are produced.
34///
35/// Returns `Ok((manifest, report))` when there are no errors (the report may
36/// still carry warnings that callers must surface), or `Err(report)` otherwise.
37pub 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    // Claims resolve node→claim and claim→claim references.
84    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    // Resolve `also_depends_on` (needs the full node-id set), then combine and
112    // dedupe links.
113    //
114    // A dependency whose target is an *ancestor* of the source (reachable by
115    // walking `children:` nesting upward) merely restates the nesting: it is
116    // redundant, and combined with the parent→child edge it would close a cycle
117    // and fail the whole artifact. Real traces do this (a child re-declaring a
118    // dependency on its parent), so drop the redundant edge with a warning
119    // instead. Genuine cross-cycles — a dependency on a sibling or descendant
120    // that closes a loop — are not ancestors and stay fatal in `detect_cycles`.
121    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    // Resolve claim→claim dependencies.
162    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/// Reads `trace/exploration_tree.yaml` (required) and `logic/claims.md`
193/// (optional) from `dir` and normalizes them, then augments the manifest with
194/// the optional logic-section files (`PAPER.md`, `logic/problem.md`,
195/// `logic/concepts.md`, `logic/related_work.md`, `logic/solution/*.md`). An
196/// absent section file is silently skipped; a present-but-malformed one adds a
197/// warning without failing the parse. Native only.
198#[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    // A missing claims file is not an error — it downgrades bindings to warnings.
213    let claims_path = dir.join("logic/claims.md");
214    let claims_md = std::fs::read_to_string(&claims_path).ok();
215
216    // The base parse owns the Ok/Err contract; an error short-circuits here.
217    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/// Reads the optional `evidence/` layer into `manifest.exhibits`, then runs the
224/// two deterministic resolution passes (`node_exhibits`, `built_on`) over the
225/// assembled manifest. Absent evidence yields empty fields; every defect warns
226/// but never errors. Native only.
227#[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/// Reads the optional logic-section files into `manifest`, appending reader
239/// warnings to `report`. Absent files are skipped without a warning; a present
240/// file that parses degenerately warns but never errors.
241#[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/// Reads a file to a string, mapping any I/O error (including absence) to
282/// `None`. Absent section files are not an error.
283#[cfg(feature = "native")]
284fn read_opt(path: &std::path::Path) -> Option<String> {
285    std::fs::read_to_string(path).ok()
286}
287
288/// Enumerates `logic/solution/*.md` sorted by path (determinism) and builds one
289/// [`crate::manifest::Recipe`] per file: filename stem, first `# Title`, and the
290/// verbatim body. A missing directory yields no recipes.
291#[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
318/// Mutable accumulators for the normalization DFS.
319struct 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    /// Per emitted node, its raw `also_depends_on` targets (resolved later).
328    also: Vec<(NodeId, Vec<String>)>,
329}
330
331impl Normalizer {
332    /// Pre-order visit of `raw`, emitting one [`Node`] plus its child link,
333    /// bindings, and evidence notes. A missing or duplicate id drops the node
334    /// (and its subtree) with an error, rather than corrupting the graph.
335    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    /// Projects `type:` + body fields into a typed [`NodeKind`]/[`NodeFields`].
389    /// Unknown/missing types become [`NodeKind::Other`]; any canonical body
390    /// fields carried by a type that does not project them (unknown or known)
391    /// are warned so nothing is lost silently.
392    fn project_kind(&mut self, raw: &RawNode, id: &NodeId) -> (NodeKind, NodeFields) {
393        // `projected` lists the canonical body fields the kind keeps; any other
394        // body field present on the node is dropped with a warning below.
395        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    /// Splits `evidence:` into `C##` bindings (node→claim) and prose notes.
475    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
510/// Names of canonical body fields present on `raw` (used to warn when a node
511/// carries a field its type does not project, so nothing is dropped silently).
512fn 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
559/// True when `ancestor` lies on the `children:`-nesting chain above `node` —
560/// i.e. reachable by walking parent pointers up from `node`. Used to drop
561/// redundant `also_depends_on` edges that only restate the nesting.
562fn is_ancestor(ancestor: &NodeId, node: &NodeId, parent_of: &BTreeMap<NodeId, NodeId>) -> bool {
563    // `parent_of` is built from `children:` nesting, which is a tree (each node
564    // has at most one parent and no parent cycles), so walking up terminates.
565    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
575/// Removes identical `(from, to, kind)` links, keeping the first and warning on
576/// each duplicate.
577fn 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
595/// Reports a cycle error for every back-edge in the combined
596/// `Child` + `DependsOn` graph (DFS three-color).
597fn detect_cycles(nodes: &[Node], links: &[Link], report: &mut ParseReport) {
598    // BTreeMap (not HashMap) keeps traversal — and thus error ordering — free of
599    // any hash-seed influence, matching the crate's determinism guarantee.
600    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(); // 0=white, 1=gray, 2=black
605    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")); // DFS/source order
731        assert_eq!(m.nodes[1].id, NodeId::new("N02"));
732        assert_eq!(m.links.len(), 1); // N01 -> N02 child
733        assert_eq!(m.links[0].kind, LinkKind::Child);
734        assert_eq!(m.bindings.len(), 1); // N02 -> C01
735        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        // A genuine cross-cycle across two branches: N02 -> N04 -> N02, where
783        // neither is an ancestor of the other (so it is not the tolerated
784        // redundant-back-edge case). detect_cycles must flag it.
785        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        // C01 depends on C99, which does not exist.
867        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        // `E##` proof refs are stored raw and must never produce a diagnostic.
880        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        // Success with no errors at all: E## refs are opaque, never validated.
884        assert!(report.is_ok());
885        assert!(report.errors().is_empty());
886    }
887
888    #[test]
889    fn sibling_only_depends_on_cycle_is_detected() {
890        // Cycle formed purely by DependsOn edges between siblings (no Child edge).
891        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        // A child that re-declares `also_depends_on` on its own parent restates
910        // the nesting. The edge is dropped with a WARNING (not a fatal cycle),
911        // so the artifact still parses.
912        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        // No DependsOn link survives; only the N01->N02 child link remains.
924        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        // Ancestry is transitive: a dependency on a grandparent is redundant too.
938        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        // Two child links, zero DependsOn links.
953        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        // A dependency on a sibling is a genuine DAG cross-edge (the sibling is
960        // not an ancestor) and must survive.
961        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        // A node with no `id` is dropped with an ERROR (data-dropping path).
985        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        // `claims.rs` surfaces the dup as data; `parse_sources` turns it into the
997        // `claims[{id}]` ERROR diagnostic.
998        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        // Absent `isolated:` → false; an explicit `isolated: true` on a node is
1010        // carried through to the normalized node.
1011        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        // Distinct from the unknown-type branch: an absent `type:` warns (WARNING),
1028        // and the node still parses as `Other`.
1029        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        // A missing-`type:` node carrying canonical body fields must warn per
1043        // field in addition to the missing-type warning, matching the
1044        // unknown-type arm — nothing is lost silently.
1045        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        // An unknown-typed node carrying a canonical body field warns that the
1067        // field is dropped, so nothing is lost silently.
1068        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        // The published pivot/experiment body fields are canonical too: an
1082        // unknown-typed node carrying them must warn per field, so nothing is
1083        // lost silently.
1084        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        // A `pivot` node projects to NodeKind::Pivot + NodeFields::Pivot with
1117        // prior_direction/new_direction/reason/lesson populated, and carries no
1118        // unknown-field warning. `lesson` is shared with `dead_end` at the raw
1119        // layer and must project for pivot too (regression pin).
1120        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        // A `dead_end` node carrying hypothesis/failure_mode/lesson populates all
1149        // fields (plus why_failed) and carries no unknown-field warning.
1150        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        // Every modeled body field present on a known kind that does not
1179        // project it is dropped with exactly one warning naming the field and
1180        // the kind, so nothing is lost silently. (The unknown-type path is
1181        // pinned separately by unknown_type_dropped_*.)
1182        let cases: &[(&str, &str, &str)] = &[
1183            // (field, yaml entry, a kind that does not project it)
1184            ("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        // Each scoped body field on a kind that projects it is kept silently:
1230        // experiment keeps result/exploration/outcome/status, decision keeps
1231        // choice/alternatives/rationale. (dead_end/pivot — including `lesson`,
1232        // shared by both — are pinned by dead_end_widened_fields_no_warning
1233        // and pivot_projects_kind_and_fields_no_warning.)
1234        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        // A repeated `also_depends_on` target yields two identical DependsOn links;
1258        // `dedupe_links` keeps the first and warns on the duplicate. Two siblings
1259        // keep the graph acyclic.
1260        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}