1use std::collections::{BTreeMap, BTreeSet};
11
12use crate::claims::parse_claims;
13use crate::manifest::{
14 Binding, BindingRole, ClaimId, Link, LinkKind, Manifest, Node, NodeFields, NodeId, NodeKind,
15 is_canonical_id,
16};
17use crate::report::ParseReport;
18use crate::schema::{RawNode, parse_doc};
19
20pub 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 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 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 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#[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 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
172struct 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 also: Vec<(NodeId, Vec<String>)>,
183}
184
185impl Normalizer {
186 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 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 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
319fn 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
341fn 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
361fn detect_cycles(nodes: &[Node], links: &[Link], report: &mut ParseReport) {
364 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(); 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")); assert_eq!(m.nodes[1].id, NodeId::new("N02"));
425 assert_eq!(m.links.len(), 1); assert_eq!(m.links[0].kind, LinkKind::Child);
427 assert_eq!(m.bindings.len(), 1); 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 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 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 assert!(report.is_ok());
569 assert!(report.errors().is_empty());
570 }
571
572 #[test]
573 fn sibling_only_depends_on_cycle_is_detected() {
574 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 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 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 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 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 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}