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    let mut depends_links: Vec<Link> = Vec::new();
98    for (from, targets) in &norm.also {
99        for (i, target) in targets.iter().enumerate() {
100            let t = target.trim();
101            let to = NodeId::new(t);
102            if norm.node_ids.contains(&to) {
103                depends_links.push(Link {
104                    from: from.clone(),
105                    to,
106                    kind: LinkKind::DependsOn,
107                });
108            } else {
109                norm.report.error(
110                    format!("nodes[{from}].also_depends_on[{i}]"),
111                    format!("`also_depends_on` references unknown node `{t}`"),
112                );
113            }
114        }
115    }
116
117    let mut links = norm.child_links;
118    links.extend(depends_links);
119    let links = dedupe_links(links, &mut norm.report);
120
121    detect_cycles(&norm.nodes, &links, &mut norm.report);
122
123    // Resolve claim→claim dependencies.
124    for claim in &claims {
125        for (i, dep) in claim.deps.iter().enumerate() {
126            if !norm.claim_ids.contains(dep) {
127                norm.report.error(
128                    format!("claims[{}].dependencies[{i}]", claim.id),
129                    format!("dependency references unknown claim `{dep}`"),
130                );
131            }
132        }
133    }
134
135    let manifest = Manifest {
136        nodes: norm.nodes,
137        links,
138        bindings: norm.bindings,
139        claims,
140        bounds: None,
141    };
142
143    if norm.report.is_ok() {
144        Ok((manifest, norm.report))
145    } else {
146        Err(norm.report)
147    }
148}
149
150/// Reads `trace/exploration_tree.yaml` (required) and `logic/claims.md`
151/// (optional) from `dir` and normalizes them. Native only.
152#[cfg(feature = "native")]
153pub fn parse_dir(dir: &std::path::Path) -> Result<(Manifest, ParseReport), ParseReport> {
154    let tree_path = dir.join("trace/exploration_tree.yaml");
155    let tree_yaml = match std::fs::read_to_string(&tree_path) {
156        Ok(s) => s,
157        Err(e) => {
158            let mut report = ParseReport::default();
159            report.error(
160                "document",
161                format!("cannot read {}: {e}", tree_path.display()),
162            );
163            return Err(report);
164        }
165    };
166    // A missing claims file is not an error — it downgrades bindings to warnings.
167    let claims_path = dir.join("logic/claims.md");
168    let claims_md = std::fs::read_to_string(&claims_path).ok();
169    parse_sources(&tree_yaml, claims_md.as_deref())
170}
171
172/// Mutable accumulators for the normalization DFS.
173struct Normalizer {
174    report: ParseReport,
175    claims_present: bool,
176    claim_ids: BTreeSet<ClaimId>,
177    nodes: Vec<Node>,
178    node_ids: BTreeSet<NodeId>,
179    bindings: Vec<Binding>,
180    child_links: Vec<Link>,
181    /// Per emitted node, its raw `also_depends_on` targets (resolved later).
182    also: Vec<(NodeId, Vec<String>)>,
183}
184
185impl Normalizer {
186    /// Pre-order visit of `raw`, emitting one [`Node`] plus its child link,
187    /// bindings, and evidence notes. A missing or duplicate id drops the node
188    /// (and its subtree) with an error, rather than corrupting the graph.
189    fn dfs(&mut self, raw: &RawNode, parent: Option<&NodeId>) {
190        let id_str = raw.id.as_deref().map(str::trim).filter(|s| !s.is_empty());
191        let Some(id_str) = id_str else {
192            let label = raw.title.as_deref().unwrap_or("<no id>");
193            self.report
194                .error(format!("nodes[{label}]"), "node is missing an `id`");
195            return;
196        };
197        let id = NodeId::new(id_str);
198        if self.node_ids.contains(&id) {
199            self.report
200                .error(format!("nodes[{id}]"), "duplicate node id");
201            return;
202        }
203        self.node_ids.insert(id.clone());
204
205        if let Some(parent) = parent {
206            self.child_links.push(Link {
207                from: parent.clone(),
208                to: id.clone(),
209                kind: LinkKind::Child,
210            });
211        }
212
213        let (kind, fields) = self.project_kind(raw, &id);
214        let evidence_notes = self.split_evidence(raw, &id);
215
216        for key in raw.extra.keys() {
217            self.report
218                .warn(format!("nodes[{id}]"), format!("unknown field `{key}`"));
219        }
220
221        self.nodes.push(Node {
222            id: id.clone(),
223            kind,
224            label: raw.title.clone(),
225            support_level: raw.support_level.clone(),
226            source_refs: raw.source_refs.clone(),
227            description: raw.description.clone(),
228            fields,
229            evidence_notes,
230            pos: None,
231        });
232        self.also.push((id.clone(), raw.also_depends_on.clone()));
233
234        for child in &raw.children {
235            self.dfs(child, Some(&id));
236        }
237    }
238
239    /// Projects `type:` + body fields into a typed [`NodeKind`]/[`NodeFields`].
240    /// Unknown/missing types become [`NodeKind::Other`]; any canonical body
241    /// fields carried by an unknown type are warned so nothing is lost silently.
242    fn project_kind(&mut self, raw: &RawNode, id: &NodeId) -> (NodeKind, NodeFields) {
243        match raw.ty.as_deref().map(str::trim) {
244            Some("question") => (NodeKind::Question, NodeFields::Question),
245            Some("experiment") => (
246                NodeKind::Experiment,
247                NodeFields::Experiment {
248                    result: raw.result.clone(),
249                },
250            ),
251            Some("decision") => (
252                NodeKind::Decision,
253                NodeFields::Decision {
254                    choice: raw.choice.clone(),
255                    alternatives: raw.alternatives.clone(),
256                    rationale: raw.rationale.clone(),
257                },
258            ),
259            Some("dead_end") => (
260                NodeKind::DeadEnd,
261                NodeFields::DeadEnd {
262                    why_failed: raw.why_failed.clone(),
263                },
264            ),
265            Some("insight") => (NodeKind::Insight, NodeFields::Insight),
266            Some("") | None => {
267                self.report
268                    .warn(format!("nodes[{id}]"), "node is missing a `type`");
269                (NodeKind::Other(String::new()), NodeFields::Other)
270            }
271            Some(other) => {
272                for field in body_field_names(raw) {
273                    self.report.warn(
274                        format!("nodes[{id}]"),
275                        format!("field `{field}` dropped for unknown type `{other}`"),
276                    );
277                }
278                (NodeKind::Other(other.to_string()), NodeFields::Other)
279            }
280        }
281    }
282
283    /// Splits `evidence:` into `C##` bindings (node→claim) and prose notes.
284    fn split_evidence(&mut self, raw: &RawNode, id: &NodeId) -> Vec<String> {
285        let mut notes = Vec::new();
286        let Some(evidence) = &raw.evidence else {
287            return notes;
288        };
289        for (i, entry) in evidence.entries().iter().enumerate() {
290            let trimmed = entry.trim();
291            if is_canonical_id(trimmed, 'C') {
292                let claim = ClaimId::new(trimmed);
293                let path = format!("nodes[{id}].evidence[{i}]");
294                if !self.claims_present {
295                    self.report.warn(
296                        path,
297                        format!("claim reference `{trimmed}` unresolved (no claims.md provided)"),
298                    );
299                } else if self.claim_ids.contains(&claim) {
300                    self.bindings.push(Binding {
301                        node: id.clone(),
302                        claim,
303                        role: BindingRole::Evidence,
304                    });
305                } else {
306                    self.report.error(
307                        path,
308                        format!("evidence references unknown claim `{trimmed}`"),
309                    );
310                }
311            } else {
312                notes.push(entry.clone());
313            }
314        }
315        notes
316    }
317}
318
319/// Names of canonical body fields present on `raw` (used only to warn when an
320/// unknown-typed node carries them).
321fn body_field_names(raw: &RawNode) -> Vec<&'static str> {
322    let mut names = Vec::new();
323    if raw.result.is_some() {
324        names.push("result");
325    }
326    if raw.why_failed.is_some() {
327        names.push("why_failed");
328    }
329    if raw.choice.is_some() {
330        names.push("choice");
331    }
332    if !raw.alternatives.is_empty() {
333        names.push("alternatives");
334    }
335    if raw.rationale.is_some() {
336        names.push("rationale");
337    }
338    names
339}
340
341/// Removes identical `(from, to, kind)` links, keeping the first and warning on
342/// each duplicate.
343fn dedupe_links(links: Vec<Link>, report: &mut ParseReport) -> Vec<Link> {
344    let mut seen: BTreeSet<(NodeId, NodeId, LinkKind)> = BTreeSet::new();
345    let mut out = Vec::with_capacity(links.len());
346    for link in links {
347        let key = (link.from.clone(), link.to.clone(), link.kind);
348        if seen.contains(&key) {
349            report.warn(
350                format!("nodes[{}]", link.from),
351                format!("duplicate {:?} link to `{}`", link.kind, link.to),
352            );
353        } else {
354            seen.insert(key);
355            out.push(link);
356        }
357    }
358    out
359}
360
361/// Reports a cycle error for every back-edge in the combined
362/// `Child` + `DependsOn` graph (DFS three-color).
363fn detect_cycles(nodes: &[Node], links: &[Link], report: &mut ParseReport) {
364    // BTreeMap (not HashMap) keeps traversal — and thus error ordering — free of
365    // any hash-seed influence, matching the crate's determinism guarantee.
366    let mut adj: BTreeMap<&NodeId, Vec<&NodeId>> = BTreeMap::new();
367    for link in links {
368        adj.entry(&link.from).or_default().push(&link.to);
369    }
370    let mut color: BTreeMap<&NodeId, u8> = BTreeMap::new(); // 0=white, 1=gray, 2=black
371    for node in nodes {
372        if color.get(&node.id).copied().unwrap_or(0) == 0 {
373            visit(&node.id, &adj, &mut color, report);
374        }
375    }
376}
377
378fn visit<'a>(
379    u: &'a NodeId,
380    adj: &BTreeMap<&'a NodeId, Vec<&'a NodeId>>,
381    color: &mut BTreeMap<&'a NodeId, u8>,
382    report: &mut ParseReport,
383) {
384    color.insert(u, 1);
385    if let Some(neighbors) = adj.get(u) {
386        for &v in neighbors {
387            match color.get(v).copied().unwrap_or(0) {
388                0 => visit(v, adj, color, report),
389                1 => report.error(
390                    format!("nodes[{u}]"),
391                    format!("cycle detected: edge to `{v}` closes a cycle"),
392                ),
393                _ => {}
394            }
395        }
396    }
397    color.insert(u, 2);
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    const MINIMAL: &str = "\
405tree:
406  - id: N01
407    type: question
408    title: Q?
409    children:
410      - id: N02
411        type: experiment
412        result: 28.4 BLEU
413        evidence: [C01, \"Table 2\"]
414";
415    const CLAIMS: &str = "## C01: A claim\n- **Statement**: yes\n";
416
417    #[test]
418    fn resolves_bindings_and_splits_evidence() {
419        let (m, report) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
420        assert!(report.is_ok());
421        assert!(report.warnings().is_empty());
422        assert_eq!(m.nodes.len(), 2);
423        assert_eq!(m.nodes[0].id, NodeId::new("N01")); // DFS/source order
424        assert_eq!(m.nodes[1].id, NodeId::new("N02"));
425        assert_eq!(m.links.len(), 1); // N01 -> N02 child
426        assert_eq!(m.links[0].kind, LinkKind::Child);
427        assert_eq!(m.bindings.len(), 1); // N02 -> C01
428        assert_eq!(m.bindings[0].claim, ClaimId::new("C01"));
429        assert_eq!(m.nodes[1].evidence_notes, vec!["Table 2"]);
430    }
431
432    #[test]
433    fn missing_claims_downgrades_binding_to_warning() {
434        let (m, report) = parse_sources(MINIMAL, None).expect("ok");
435        assert!(report.is_ok());
436        assert!(m.bindings.is_empty());
437        assert_eq!(report.warnings().len(), 1);
438        assert!(report.warnings()[0].message.contains("unresolved"));
439    }
440
441    #[test]
442    fn broken_claim_ref_is_error() {
443        let err = parse_sources(MINIMAL, Some("## C99: other\n")).unwrap_err();
444        assert!(!err.is_ok());
445        assert!(err.errors()[0].message.contains("unknown claim"));
446    }
447
448    #[test]
449    fn malformed_yaml_is_error_not_panic() {
450        let err = parse_sources("tree: not-a-list\n", None).unwrap_err();
451        assert_eq!(err.errors()[0].path, "document");
452    }
453
454    #[test]
455    fn both_roots_is_error() {
456        let err = parse_sources("tree: []\nroot:\n  id: N01\n", None).unwrap_err();
457        assert!(err.errors()[0].message.contains("both"));
458    }
459
460    #[test]
461    fn neither_root_is_error() {
462        let err = parse_sources("meta: hi\n", None).unwrap_err();
463        assert!(err.errors()[0].message.contains("neither"));
464    }
465
466    #[test]
467    fn empty_tree_warns_and_is_ok() {
468        let (m, report) = parse_sources("tree: []\n", None).expect("ok");
469        assert!(m.nodes.is_empty());
470        assert_eq!(report.warnings().len(), 1);
471    }
472
473    #[test]
474    fn cycle_is_detected() {
475        let yaml = "\
476tree:
477  - id: N01
478    type: question
479    children:
480      - id: N02
481        type: experiment
482        also_depends_on: [N01]
483";
484        let err = parse_sources(yaml, None).unwrap_err();
485        assert!(err.errors().iter().any(|d| d.message.contains("cycle")));
486    }
487
488    #[test]
489    fn duplicate_node_id_is_error() {
490        let yaml = "\
491tree:
492  - id: N01
493    type: question
494  - id: N01
495    type: insight
496";
497        let err = parse_sources(yaml, None).unwrap_err();
498        assert!(
499            err.errors()
500                .iter()
501                .any(|d| d.message.contains("duplicate node id"))
502        );
503    }
504
505    #[test]
506    fn unknown_type_becomes_other_and_warns() {
507        let yaml = "tree:\n  - id: N01\n    type: hypothesis\n    title: h\n";
508        let (m, _r) = parse_sources(yaml, None).expect("ok");
509        assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
510    }
511
512    #[test]
513    fn root_single_matches_tree_shape() {
514        let tree = "tree:\n  - id: N01\n    type: question\n    title: q\n";
515        let root = "root:\n  id: N01\n  type: question\n  title: q\n";
516        let (mt, _) = parse_sources(tree, None).expect("ok");
517        let (mr, _) = parse_sources(root, None).expect("ok");
518        assert_eq!(mt.nodes, mr.nodes);
519    }
520
521    #[test]
522    fn determinism_parse_twice_identical() {
523        let (a, _) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
524        let (b, _) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
525        assert_eq!(a, b);
526    }
527
528    #[test]
529    fn broken_node_to_node_ref_is_error() {
530        let yaml = "\
531tree:
532  - id: N01
533    type: question
534    children:
535      - id: N02
536        type: experiment
537        also_depends_on: [N99]
538";
539        let err = parse_sources(yaml, None).unwrap_err();
540        assert!(
541            err.errors()
542                .iter()
543                .any(|d| d.message.contains("unknown node") && d.path.contains("also_depends_on")),
544            "expected broken node->node error, got: {err}"
545        );
546    }
547
548    #[test]
549    fn broken_claim_to_claim_dep_is_error() {
550        // C01 depends on C99, which does not exist.
551        let claims = "## C01: A\n- **Dependencies**: [C99]\n";
552        let err = parse_sources(MINIMAL, Some(claims)).unwrap_err();
553        assert!(
554            err.errors()
555                .iter()
556                .any(|d| d.message.contains("unknown claim") && d.path.contains("dependencies")),
557            "expected broken claim->claim error, got: {err}"
558        );
559    }
560
561    #[test]
562    fn proof_evidence_refs_emit_no_error() {
563        // `E##` proof refs are stored raw and must never produce a diagnostic.
564        let claims = "## C01: A\n- **Statement**: s\n- **Proof**: [E01, E02]\n";
565        let (m, report) = parse_sources(MINIMAL, Some(claims)).expect("ok");
566        assert_eq!(m.claims[0].proof, vec!["E01", "E02"]);
567        // Success with no errors at all: E## refs are opaque, never validated.
568        assert!(report.is_ok());
569        assert!(report.errors().is_empty());
570    }
571
572    #[test]
573    fn sibling_only_depends_on_cycle_is_detected() {
574        // Cycle formed purely by DependsOn edges between siblings (no Child edge).
575        let yaml = "\
576tree:
577  - id: N01
578    type: question
579    children:
580      - id: N02
581        type: experiment
582        also_depends_on: [N03]
583      - id: N03
584        type: insight
585        also_depends_on: [N02]
586";
587        let err = parse_sources(yaml, None).unwrap_err();
588        assert!(err.errors().iter().any(|d| d.message.contains("cycle")));
589    }
590
591    #[test]
592    fn missing_node_id_is_error() {
593        // A node with no `id` is dropped with an ERROR (data-dropping path).
594        let err = parse_sources("tree:\n  - type: question\n    title: q\n", None).unwrap_err();
595        assert!(
596            err.errors()
597                .iter()
598                .any(|d| d.message.contains("missing an `id`")),
599            "expected missing-id error, got: {err}"
600        );
601    }
602
603    #[test]
604    fn duplicate_claim_id_is_error() {
605        // `claims.rs` surfaces the dup as data; `parse_sources` turns it into the
606        // `claims[{id}]` ERROR diagnostic.
607        let err = parse_sources(MINIMAL, Some("## C01: A\n## C01: B\n")).unwrap_err();
608        assert!(
609            err.errors()
610                .iter()
611                .any(|d| d.path.contains("claims[C01]") && d.message.contains("duplicate claim id")),
612            "expected duplicate-claim-id error, got: {err}"
613        );
614    }
615
616    #[test]
617    fn missing_type_warns() {
618        // Distinct from the unknown-type branch: an absent `type:` warns (WARNING),
619        // and the node still parses as `Other`.
620        let (m, report) = parse_sources("tree:\n  - id: N01\n    title: q\n", None).expect("ok");
621        assert_eq!(m.nodes[0].kind, NodeKind::Other(String::new()));
622        assert!(
623            report
624                .warnings()
625                .iter()
626                .any(|d| d.message.contains("missing a `type`")),
627            "expected missing-type warning, got: {report}"
628        );
629    }
630
631    #[test]
632    fn unknown_type_dropped_body_field_warns() {
633        // An unknown-typed node carrying a canonical body field warns that the
634        // field is dropped, so nothing is lost silently.
635        let yaml = "tree:\n  - id: N01\n    type: hypothesis\n    result: 28.4 BLEU\n";
636        let (m, report) = parse_sources(yaml, None).expect("ok");
637        assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
638        assert!(
639            report.warnings().iter().any(|d| d
640                .message
641                .contains("`result` dropped for unknown type `hypothesis`")),
642            "expected dropped-field warning, got: {report}"
643        );
644    }
645
646    #[test]
647    fn duplicate_link_warns() {
648        // A repeated `also_depends_on` target yields two identical DependsOn links;
649        // `dedupe_links` keeps the first and warns on the duplicate. Two siblings
650        // keep the graph acyclic.
651        let yaml = "\
652tree:
653  - id: N01
654    type: question
655    children:
656      - id: N02
657        type: experiment
658        also_depends_on: [N03, N03]
659      - id: N03
660        type: insight
661";
662        let (_m, report) = parse_sources(yaml, None).expect("ok");
663        assert!(
664            report
665                .warnings()
666                .iter()
667                .any(|d| d.message.contains("duplicate") && d.message.contains("link to `N03`")),
668            "expected duplicate-link warning, got: {report}"
669        );
670    }
671}