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            isolated: raw.isolated,
231            pos: None,
232        });
233        self.also.push((id.clone(), raw.also_depends_on.clone()));
234
235        for child in &raw.children {
236            self.dfs(child, Some(&id));
237        }
238    }
239
240    /// Projects `type:` + body fields into a typed [`NodeKind`]/[`NodeFields`].
241    /// Unknown/missing types become [`NodeKind::Other`]; any canonical body
242    /// fields carried by an unknown type are warned so nothing is lost silently.
243    fn project_kind(&mut self, raw: &RawNode, id: &NodeId) -> (NodeKind, NodeFields) {
244        match raw.ty.as_deref().map(str::trim) {
245            Some("question") => (NodeKind::Question, NodeFields::Question),
246            Some("experiment") => (
247                NodeKind::Experiment,
248                NodeFields::Experiment {
249                    result: raw.result.clone(),
250                },
251            ),
252            Some("decision") => (
253                NodeKind::Decision,
254                NodeFields::Decision {
255                    choice: raw.choice.clone(),
256                    alternatives: raw.alternatives.clone(),
257                    rationale: raw.rationale.clone(),
258                },
259            ),
260            Some("dead_end") => (
261                NodeKind::DeadEnd,
262                NodeFields::DeadEnd {
263                    why_failed: raw.why_failed.clone(),
264                },
265            ),
266            Some("insight") => (NodeKind::Insight, NodeFields::Insight),
267            Some("") | None => {
268                self.report
269                    .warn(format!("nodes[{id}]"), "node is missing a `type`");
270                (NodeKind::Other(String::new()), NodeFields::Other)
271            }
272            Some(other) => {
273                for field in body_field_names(raw) {
274                    self.report.warn(
275                        format!("nodes[{id}]"),
276                        format!("field `{field}` dropped for unknown type `{other}`"),
277                    );
278                }
279                (NodeKind::Other(other.to_string()), NodeFields::Other)
280            }
281        }
282    }
283
284    /// Splits `evidence:` into `C##` bindings (node→claim) and prose notes.
285    fn split_evidence(&mut self, raw: &RawNode, id: &NodeId) -> Vec<String> {
286        let mut notes = Vec::new();
287        let Some(evidence) = &raw.evidence else {
288            return notes;
289        };
290        for (i, entry) in evidence.entries().iter().enumerate() {
291            let trimmed = entry.trim();
292            if is_canonical_id(trimmed, 'C') {
293                let claim = ClaimId::new(trimmed);
294                let path = format!("nodes[{id}].evidence[{i}]");
295                if !self.claims_present {
296                    self.report.warn(
297                        path,
298                        format!("claim reference `{trimmed}` unresolved (no claims.md provided)"),
299                    );
300                } else if self.claim_ids.contains(&claim) {
301                    self.bindings.push(Binding {
302                        node: id.clone(),
303                        claim,
304                        role: BindingRole::Evidence,
305                    });
306                } else {
307                    self.report.error(
308                        path,
309                        format!("evidence references unknown claim `{trimmed}`"),
310                    );
311                }
312            } else {
313                notes.push(entry.clone());
314            }
315        }
316        notes
317    }
318}
319
320/// Names of canonical body fields present on `raw` (used only to warn when an
321/// unknown-typed node carries them).
322fn body_field_names(raw: &RawNode) -> Vec<&'static str> {
323    let mut names = Vec::new();
324    if raw.result.is_some() {
325        names.push("result");
326    }
327    if raw.why_failed.is_some() {
328        names.push("why_failed");
329    }
330    if raw.choice.is_some() {
331        names.push("choice");
332    }
333    if !raw.alternatives.is_empty() {
334        names.push("alternatives");
335    }
336    if raw.rationale.is_some() {
337        names.push("rationale");
338    }
339    names
340}
341
342/// Removes identical `(from, to, kind)` links, keeping the first and warning on
343/// each duplicate.
344fn dedupe_links(links: Vec<Link>, report: &mut ParseReport) -> Vec<Link> {
345    let mut seen: BTreeSet<(NodeId, NodeId, LinkKind)> = BTreeSet::new();
346    let mut out = Vec::with_capacity(links.len());
347    for link in links {
348        let key = (link.from.clone(), link.to.clone(), link.kind);
349        if seen.contains(&key) {
350            report.warn(
351                format!("nodes[{}]", link.from),
352                format!("duplicate {:?} link to `{}`", link.kind, link.to),
353            );
354        } else {
355            seen.insert(key);
356            out.push(link);
357        }
358    }
359    out
360}
361
362/// Reports a cycle error for every back-edge in the combined
363/// `Child` + `DependsOn` graph (DFS three-color).
364fn detect_cycles(nodes: &[Node], links: &[Link], report: &mut ParseReport) {
365    // BTreeMap (not HashMap) keeps traversal — and thus error ordering — free of
366    // any hash-seed influence, matching the crate's determinism guarantee.
367    let mut adj: BTreeMap<&NodeId, Vec<&NodeId>> = BTreeMap::new();
368    for link in links {
369        adj.entry(&link.from).or_default().push(&link.to);
370    }
371    let mut color: BTreeMap<&NodeId, u8> = BTreeMap::new(); // 0=white, 1=gray, 2=black
372    for node in nodes {
373        if color.get(&node.id).copied().unwrap_or(0) == 0 {
374            visit(&node.id, &adj, &mut color, report);
375        }
376    }
377}
378
379fn visit<'a>(
380    u: &'a NodeId,
381    adj: &BTreeMap<&'a NodeId, Vec<&'a NodeId>>,
382    color: &mut BTreeMap<&'a NodeId, u8>,
383    report: &mut ParseReport,
384) {
385    color.insert(u, 1);
386    if let Some(neighbors) = adj.get(u) {
387        for &v in neighbors {
388            match color.get(v).copied().unwrap_or(0) {
389                0 => visit(v, adj, color, report),
390                1 => report.error(
391                    format!("nodes[{u}]"),
392                    format!("cycle detected: edge to `{v}` closes a cycle"),
393                ),
394                _ => {}
395            }
396        }
397    }
398    color.insert(u, 2);
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    const MINIMAL: &str = "\
406tree:
407  - id: N01
408    type: question
409    title: Q?
410    children:
411      - id: N02
412        type: experiment
413        result: 28.4 BLEU
414        evidence: [C01, \"Table 2\"]
415";
416    const CLAIMS: &str = "## C01: A claim\n- **Statement**: yes\n";
417
418    #[test]
419    fn resolves_bindings_and_splits_evidence() {
420        let (m, report) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
421        assert!(report.is_ok());
422        assert!(report.warnings().is_empty());
423        assert_eq!(m.nodes.len(), 2);
424        assert_eq!(m.nodes[0].id, NodeId::new("N01")); // DFS/source order
425        assert_eq!(m.nodes[1].id, NodeId::new("N02"));
426        assert_eq!(m.links.len(), 1); // N01 -> N02 child
427        assert_eq!(m.links[0].kind, LinkKind::Child);
428        assert_eq!(m.bindings.len(), 1); // N02 -> C01
429        assert_eq!(m.bindings[0].claim, ClaimId::new("C01"));
430        assert_eq!(m.nodes[1].evidence_notes, vec!["Table 2"]);
431    }
432
433    #[test]
434    fn missing_claims_downgrades_binding_to_warning() {
435        let (m, report) = parse_sources(MINIMAL, None).expect("ok");
436        assert!(report.is_ok());
437        assert!(m.bindings.is_empty());
438        assert_eq!(report.warnings().len(), 1);
439        assert!(report.warnings()[0].message.contains("unresolved"));
440    }
441
442    #[test]
443    fn broken_claim_ref_is_error() {
444        let err = parse_sources(MINIMAL, Some("## C99: other\n")).unwrap_err();
445        assert!(!err.is_ok());
446        assert!(err.errors()[0].message.contains("unknown claim"));
447    }
448
449    #[test]
450    fn malformed_yaml_is_error_not_panic() {
451        let err = parse_sources("tree: not-a-list\n", None).unwrap_err();
452        assert_eq!(err.errors()[0].path, "document");
453    }
454
455    #[test]
456    fn both_roots_is_error() {
457        let err = parse_sources("tree: []\nroot:\n  id: N01\n", None).unwrap_err();
458        assert!(err.errors()[0].message.contains("both"));
459    }
460
461    #[test]
462    fn neither_root_is_error() {
463        let err = parse_sources("meta: hi\n", None).unwrap_err();
464        assert!(err.errors()[0].message.contains("neither"));
465    }
466
467    #[test]
468    fn empty_tree_warns_and_is_ok() {
469        let (m, report) = parse_sources("tree: []\n", None).expect("ok");
470        assert!(m.nodes.is_empty());
471        assert_eq!(report.warnings().len(), 1);
472    }
473
474    #[test]
475    fn cycle_is_detected() {
476        let yaml = "\
477tree:
478  - id: N01
479    type: question
480    children:
481      - id: N02
482        type: experiment
483        also_depends_on: [N01]
484";
485        let err = parse_sources(yaml, None).unwrap_err();
486        assert!(err.errors().iter().any(|d| d.message.contains("cycle")));
487    }
488
489    #[test]
490    fn duplicate_node_id_is_error() {
491        let yaml = "\
492tree:
493  - id: N01
494    type: question
495  - id: N01
496    type: insight
497";
498        let err = parse_sources(yaml, None).unwrap_err();
499        assert!(
500            err.errors()
501                .iter()
502                .any(|d| d.message.contains("duplicate node id"))
503        );
504    }
505
506    #[test]
507    fn unknown_type_becomes_other_and_warns() {
508        let yaml = "tree:\n  - id: N01\n    type: hypothesis\n    title: h\n";
509        let (m, _r) = parse_sources(yaml, None).expect("ok");
510        assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
511    }
512
513    #[test]
514    fn root_single_matches_tree_shape() {
515        let tree = "tree:\n  - id: N01\n    type: question\n    title: q\n";
516        let root = "root:\n  id: N01\n  type: question\n  title: q\n";
517        let (mt, _) = parse_sources(tree, None).expect("ok");
518        let (mr, _) = parse_sources(root, None).expect("ok");
519        assert_eq!(mt.nodes, mr.nodes);
520    }
521
522    #[test]
523    fn determinism_parse_twice_identical() {
524        let (a, _) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
525        let (b, _) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
526        assert_eq!(a, b);
527    }
528
529    #[test]
530    fn broken_node_to_node_ref_is_error() {
531        let yaml = "\
532tree:
533  - id: N01
534    type: question
535    children:
536      - id: N02
537        type: experiment
538        also_depends_on: [N99]
539";
540        let err = parse_sources(yaml, None).unwrap_err();
541        assert!(
542            err.errors()
543                .iter()
544                .any(|d| d.message.contains("unknown node") && d.path.contains("also_depends_on")),
545            "expected broken node->node error, got: {err}"
546        );
547    }
548
549    #[test]
550    fn broken_claim_to_claim_dep_is_error() {
551        // C01 depends on C99, which does not exist.
552        let claims = "## C01: A\n- **Dependencies**: [C99]\n";
553        let err = parse_sources(MINIMAL, Some(claims)).unwrap_err();
554        assert!(
555            err.errors()
556                .iter()
557                .any(|d| d.message.contains("unknown claim") && d.path.contains("dependencies")),
558            "expected broken claim->claim error, got: {err}"
559        );
560    }
561
562    #[test]
563    fn proof_evidence_refs_emit_no_error() {
564        // `E##` proof refs are stored raw and must never produce a diagnostic.
565        let claims = "## C01: A\n- **Statement**: s\n- **Proof**: [E01, E02]\n";
566        let (m, report) = parse_sources(MINIMAL, Some(claims)).expect("ok");
567        assert_eq!(m.claims[0].proof, vec!["E01", "E02"]);
568        // Success with no errors at all: E## refs are opaque, never validated.
569        assert!(report.is_ok());
570        assert!(report.errors().is_empty());
571    }
572
573    #[test]
574    fn sibling_only_depends_on_cycle_is_detected() {
575        // Cycle formed purely by DependsOn edges between siblings (no Child edge).
576        let yaml = "\
577tree:
578  - id: N01
579    type: question
580    children:
581      - id: N02
582        type: experiment
583        also_depends_on: [N03]
584      - id: N03
585        type: insight
586        also_depends_on: [N02]
587";
588        let err = parse_sources(yaml, None).unwrap_err();
589        assert!(err.errors().iter().any(|d| d.message.contains("cycle")));
590    }
591
592    #[test]
593    fn missing_node_id_is_error() {
594        // A node with no `id` is dropped with an ERROR (data-dropping path).
595        let err = parse_sources("tree:\n  - type: question\n    title: q\n", None).unwrap_err();
596        assert!(
597            err.errors()
598                .iter()
599                .any(|d| d.message.contains("missing an `id`")),
600            "expected missing-id error, got: {err}"
601        );
602    }
603
604    #[test]
605    fn duplicate_claim_id_is_error() {
606        // `claims.rs` surfaces the dup as data; `parse_sources` turns it into the
607        // `claims[{id}]` ERROR diagnostic.
608        let err = parse_sources(MINIMAL, Some("## C01: A\n## C01: B\n")).unwrap_err();
609        assert!(
610            err.errors()
611                .iter()
612                .any(|d| d.path.contains("claims[C01]") && d.message.contains("duplicate claim id")),
613            "expected duplicate-claim-id error, got: {err}"
614        );
615    }
616
617    #[test]
618    fn isolated_field_defaults_false_and_sources_from_raw() {
619        // Absent `isolated:` → false; an explicit `isolated: true` on a node is
620        // carried through to the normalized node.
621        let yaml = "\
622tree:
623  - id: N01
624    type: question
625    children:
626      - id: N02
627        type: experiment
628        isolated: true
629";
630        let (m, _r) = parse_sources(yaml, None).expect("ok");
631        assert!(!m.nodes[0].isolated, "N01 has no isolated key → false");
632        assert!(m.nodes[1].isolated, "N02 carries isolated: true");
633    }
634
635    #[test]
636    fn missing_type_warns() {
637        // Distinct from the unknown-type branch: an absent `type:` warns (WARNING),
638        // and the node still parses as `Other`.
639        let (m, report) = parse_sources("tree:\n  - id: N01\n    title: q\n", None).expect("ok");
640        assert_eq!(m.nodes[0].kind, NodeKind::Other(String::new()));
641        assert!(
642            report
643                .warnings()
644                .iter()
645                .any(|d| d.message.contains("missing a `type`")),
646            "expected missing-type warning, got: {report}"
647        );
648    }
649
650    #[test]
651    fn unknown_type_dropped_body_field_warns() {
652        // An unknown-typed node carrying a canonical body field warns that the
653        // field is dropped, so nothing is lost silently.
654        let yaml = "tree:\n  - id: N01\n    type: hypothesis\n    result: 28.4 BLEU\n";
655        let (m, report) = parse_sources(yaml, None).expect("ok");
656        assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
657        assert!(
658            report.warnings().iter().any(|d| d
659                .message
660                .contains("`result` dropped for unknown type `hypothesis`")),
661            "expected dropped-field warning, got: {report}"
662        );
663    }
664
665    #[test]
666    fn duplicate_link_warns() {
667        // A repeated `also_depends_on` target yields two identical DependsOn links;
668        // `dedupe_links` keeps the first and warns on the duplicate. Two siblings
669        // keep the graph acyclic.
670        let yaml = "\
671tree:
672  - id: N01
673    type: question
674    children:
675      - id: N02
676        type: experiment
677        also_depends_on: [N03, N03]
678      - id: N03
679        type: insight
680";
681        let (_m, report) = parse_sources(yaml, None).expect("ok");
682        assert!(
683            report
684                .warnings()
685                .iter()
686                .any(|d| d.message.contains("duplicate") && d.message.contains("link to `N03`")),
687            "expected duplicate-link warning, got: {report}"
688        );
689    }
690}