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 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 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 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
320fn 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
342fn 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
362fn detect_cycles(nodes: &[Node], links: &[Link], report: &mut ParseReport) {
365 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(); 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")); assert_eq!(m.nodes[1].id, NodeId::new("N02"));
426 assert_eq!(m.links.len(), 1); assert_eq!(m.links[0].kind, LinkKind::Child);
428 assert_eq!(m.bindings.len(), 1); 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 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 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 assert!(report.is_ok());
570 assert!(report.errors().is_empty());
571 }
572
573 #[test]
574 fn sibling_only_depends_on_cycle_is_detected() {
575 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 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 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 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 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 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 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}