Skip to main content

ara_core/
manifest.rs

1//! Normalized wire types — the single manifest every downstream consumer reads.
2//!
3//! This is the logical graph produced by [`crate::parse`]. It is the *only*
4//! public data model: no `serde-saphyr` types leak here (that stays confined to
5//! [`crate::schema`] / [`crate::claims`]), which keeps a future YAML-backend
6//! swap cheap. Layout/geometry is **not** part of Stage 1 — it lands in Stage 2.
7//!
8//! Ordering is significant and always mirrors the source: `nodes` are in
9//! pre-order DFS of the tree, `links`/`bindings` follow per-node source order.
10//! Nothing is ever sorted by id.
11
12use serde::{Deserialize, Serialize};
13
14use crate::layout::{Point, Rect};
15
16/// A node identifier (`^N\d+$`, case-sensitive, trimmed).
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18#[serde(transparent)]
19pub struct NodeId(String);
20
21/// A claim identifier (`^C\d+$`, case-sensitive, trimmed).
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct ClaimId(String);
25
26impl NodeId {
27    /// Wraps an already-normalized id. Callers pass a trimmed string.
28    pub fn new(id: impl Into<String>) -> Self {
29        Self(id.into())
30    }
31
32    /// The underlying string.
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36
37    /// True when the id matches the canonical grammar `^N\d+$`.
38    pub fn is_canonical(&self) -> bool {
39        is_canonical_id(&self.0, 'N')
40    }
41}
42
43impl ClaimId {
44    /// Wraps an already-normalized id. Callers pass a trimmed string.
45    pub fn new(id: impl Into<String>) -> Self {
46        Self(id.into())
47    }
48
49    /// The underlying string.
50    pub fn as_str(&self) -> &str {
51        &self.0
52    }
53
54    /// True when the id matches the canonical grammar `^C\d+$`.
55    pub fn is_canonical(&self) -> bool {
56        is_canonical_id(&self.0, 'C')
57    }
58}
59
60impl std::fmt::Display for NodeId {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str(&self.0)
63    }
64}
65
66impl std::fmt::Display for ClaimId {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.write_str(&self.0)
69    }
70}
71
72/// Checks `^<prefix>\d+$`: the given prefix followed by one or more ASCII
73/// digits, nothing else. Regex-free to keep the dependency surface small.
74pub(crate) fn is_canonical_id(s: &str, prefix: char) -> bool {
75    let mut chars = s.chars();
76    match chars.next() {
77        Some(c) if c == prefix => {}
78        _ => return false,
79    }
80    let rest = chars.as_str();
81    !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
82}
83
84/// The normalized artifact: the logical exploration graph plus claim content.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct Manifest {
87    /// Pre-order DFS of the tree, source order preserved.
88    pub nodes: Vec<Node>,
89    /// Node → node edges (`children` and `also_depends_on`).
90    pub links: Vec<Link>,
91    /// Node → claim references, resolved against `claims`.
92    pub bindings: Vec<Binding>,
93    /// Claim content, for the viewer.
94    pub claims: Vec<Claim>,
95    /// Bounding rectangle enclosing all laid-out nodes. Populated by layout.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub bounds: Option<Rect>,
98    /// Paper-level metadata from `PAPER.md` frontmatter. Absent when the file is
99    /// missing or has no frontmatter fence.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub paper: Option<PaperMeta>,
102    /// Typed prior-work dependencies from `logic/related_work.md`.
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub related_work: Vec<RelatedWork>,
105    /// Glossary terms from `logic/concepts.md`.
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub concepts: Vec<Concept>,
108    /// Problem framing from `logic/problem.md`.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub problem: Option<Problem>,
111    /// Solution recipes, one per `logic/solution/*.md` file (source order).
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub recipes: Vec<Recipe>,
114    /// Figures/tables from `evidence/`. Populated by a later evidence task.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub exhibits: Vec<Exhibit>,
117    /// Node → related-work edges. Populated by a later resolution task.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub built_on: Vec<BuiltOn>,
120    /// Node → exhibit edges. Populated by a later resolution task.
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub node_exhibits: Vec<NodeExhibit>,
123}
124
125/// Paper-level metadata, parsed from `PAPER.md` YAML frontmatter.
126///
127/// Every field is optional: a `PAPER.md` with no frontmatter fence still yields
128/// a `PaperMeta` carrying only the `title` (from the first `# H1`). `year` is
129/// normalized to a `String` even when the source encodes it as an integer.
130#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
131pub struct PaperMeta {
132    /// Paper title.
133    pub title: Option<String>,
134    /// Author names, in source order.
135    pub authors: Vec<String>,
136    /// Publication year, normalized from int or string.
137    pub year: Option<String>,
138    /// Venue string.
139    pub venue: Option<String>,
140    /// DOI or arXiv id. `None` when the source is `null` or absent.
141    pub doi: Option<String>,
142    /// Abstract text (`abstract` in the source).
143    #[serde(rename = "abstract")]
144    pub abstract_: Option<String>,
145    /// Keyword list, in source order.
146    pub keywords: Vec<String>,
147}
148
149/// One typed prior-work dependency, parsed from `logic/related_work.md`.
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct RelatedWork {
152    /// Reference id (`RW01`).
153    pub id: String,
154    /// Citation text — the header content after the id.
155    pub cite: String,
156    /// DOI or arXiv id, when present.
157    pub doi: Option<String>,
158    /// Relationship kind (`Type:` value), raw — may combine (e.g.
159    /// `baseline, extends`).
160    pub kind: Option<String>,
161    /// `Delta → What changed`.
162    pub what_changed: Option<String>,
163    /// `Delta → Why`.
164    pub why: Option<String>,
165    /// `Adopted elements`.
166    pub adopted: Option<String>,
167    /// Claims this reference affects, from the inline `C##` list. A prose
168    /// `none` resolves to an empty list.
169    pub claims_affected: Vec<ClaimId>,
170}
171
172/// One glossary term, parsed from `logic/concepts.md`.
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct Concept {
175    /// The term (the `## <Term>` header text).
176    pub term: String,
177    /// Notation, LaTeX preserved verbatim.
178    pub notation: Option<String>,
179    /// Definition prose.
180    pub definition: Option<String>,
181    /// Boundary conditions (`Boundary` or `Boundary conditions`).
182    pub boundary: Option<String>,
183    /// Related term names, split from a comma-separated list.
184    pub related: Vec<String>,
185}
186
187/// Problem framing, parsed from `logic/problem.md`.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct Problem {
190    /// Intro prose before the first `##` section.
191    pub statement: Option<String>,
192    /// `O#` observation items, full text including the id, in source order.
193    pub observations: Vec<String>,
194    /// `G#` gap items, full text including the id, in source order.
195    pub gaps: Vec<String>,
196    /// Key-insight / `I#` items, in source order.
197    pub insights: Vec<String>,
198}
199
200/// One solution recipe, one per `logic/solution/*.md` file.
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct Recipe {
203    /// Filename stem (e.g. `algorithm`).
204    pub name: String,
205    /// First `# Title` in the file, when present.
206    pub title: Option<String>,
207    /// Raw markdown body, verbatim.
208    pub body: String,
209}
210
211/// The kind of an exhibit. `Other` preserves anything not a figure, proof,
212/// result, or table.
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum ExhibitKind {
216    Figure,
217    Table,
218    Result,
219    Proof,
220    Other,
221}
222
223/// One evidence exhibit — a figure, proof, result, or table body file plus
224/// its index metadata, parsed from `evidence/`.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226pub struct Exhibit {
227    /// Exhibit id.
228    pub id: String,
229    /// Source file, relative to the artifact root.
230    pub file: String,
231    /// Figure / proof / result / table / other.
232    pub kind: ExhibitKind,
233    /// Origin of the exhibit, when stated.
234    pub source: Option<String>,
235    /// Caption / description prose.
236    pub description: Option<String>,
237    /// Claims this exhibit supports.
238    pub claims: Vec<ClaimId>,
239    /// Raw markdown body, verbatim.
240    pub body: String,
241}
242
243/// A node → related-work edge. Populated by a later resolution task.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct BuiltOn {
246    pub node: NodeId,
247    pub related_work: String,
248}
249
250/// A node → exhibit edge. Populated by a later resolution task.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct NodeExhibit {
253    pub node: NodeId,
254    pub exhibit: String,
255}
256
257/// One exploration node.
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259pub struct Node {
260    /// Unique node id.
261    pub id: NodeId,
262    /// Node type.
263    pub kind: NodeKind,
264    /// Display label, from `title:` only. Consumers fall back to `id`.
265    pub label: Option<String>,
266    /// `explicit` | `inferred` when present.
267    pub support_level: Option<String>,
268    /// Free-form provenance refs (`§1`, `Fig. 1`, ...).
269    pub source_refs: Vec<String>,
270    /// Prose description.
271    pub description: Option<String>,
272    /// Free-form provenance tag (`user`, `ai-suggested`, ...). No vocabulary
273    /// validation.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub provenance: Option<String>,
276    /// ISO date string; carried verbatim, never parsed.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub timestamp: Option<String>,
279    /// Typed per-kind body.
280    pub fields: NodeFields,
281    /// Free-text evidence entries (the non-`C##` part of `evidence:`).
282    pub evidence_notes: Vec<String>,
283    /// Whether this node is the root of an *isolated* subtree — a branch the
284    /// exploration reached but that hangs off the main tree on its own. Drives
285    /// the viewer's "isolated subtree" partition. Defaults to `false`; only the
286    /// root of a subtree carries it (children inherit placement from their root).
287    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
288    pub isolated: bool,
289    /// Center position assigned by layout. Absent when layout has not run.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub pos: Option<Point>,
292}
293
294/// The canonical node types, plus a preserved escape hatch.
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
296#[serde(rename_all = "snake_case")]
297pub enum NodeKind {
298    Question,
299    Experiment,
300    Decision,
301    DeadEnd,
302    Insight,
303    Pivot,
304    /// An unrecognized `type:`; the raw string is preserved.
305    Other(String),
306}
307
308/// Typed body fields, one variant per canonical kind.
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311pub enum NodeFields {
312    Question,
313    Experiment {
314        result: Option<String>,
315        exploration: Option<String>,
316        outcome: Option<String>,
317        /// Experiment-scoped lifecycle status (`planned`, `running`, ...).
318        status: Option<String>,
319    },
320    Decision {
321        choice: Option<String>,
322        alternatives: Vec<String>,
323        rationale: Option<String>,
324    },
325    DeadEnd {
326        hypothesis: Option<String>,
327        failure_mode: Option<String>,
328        lesson: Option<String>,
329        why_failed: Option<String>,
330    },
331    Insight,
332    Pivot {
333        prior_direction: Option<String>,
334        new_direction: Option<String>,
335        reason: Option<String>,
336        lesson: Option<String>,
337    },
338    /// Unknown kind: body fields are captured (as warnings) at the raw layer.
339    Other,
340}
341
342/// A directed node → node edge.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct Link {
345    pub from: NodeId,
346    pub to: NodeId,
347    pub kind: LinkKind,
348}
349
350/// Kind of a node → node edge.
351#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
352#[serde(rename_all = "snake_case")]
353pub enum LinkKind {
354    /// Nesting edge, from `children:`.
355    Child,
356    /// Cross-reference edge, from `also_depends_on:`.
357    DependsOn,
358}
359
360/// A resolved node → claim reference.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct Binding {
363    pub node: NodeId,
364    pub claim: ClaimId,
365    pub role: BindingRole,
366}
367
368/// Role of a node → claim reference.
369///
370/// `non_exhaustive`: `Verifies` (SOULFuzz-only) is intentionally out of scope
371/// and may be added later without breaking consumers.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374#[non_exhaustive]
375pub enum BindingRole {
376    /// From a node's `evidence:` list.
377    Evidence,
378}
379
380/// Claim content, parsed from `logic/claims.md`.
381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
382pub struct Claim {
383    pub id: ClaimId,
384    pub title: String,
385    pub statement: Option<String>,
386    pub status: Option<String>,
387    /// `E##` proof refs, stored raw. Not validated — no evidence registry yet.
388    pub proof: Vec<String>,
389    /// Claim → claim dependencies.
390    pub deps: Vec<ClaimId>,
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn canonical_id_grammar() {
399        assert!(is_canonical_id("N01", 'N'));
400        assert!(is_canonical_id("N7", 'N'));
401        assert!(is_canonical_id("C123", 'C'));
402        assert!(!is_canonical_id("N", 'N')); // no digits
403        assert!(!is_canonical_id("n01", 'N')); // case-sensitive
404        assert!(!is_canonical_id("C01", 'N')); // wrong prefix
405        assert!(!is_canonical_id("N01a", 'N')); // trailing junk
406        assert!(!is_canonical_id("", 'N'));
407    }
408
409    #[test]
410    fn id_accessors_and_display() {
411        let n = NodeId::new("N01");
412        assert_eq!(n.as_str(), "N01");
413        assert_eq!(n.to_string(), "N01");
414        assert!(n.is_canonical());
415        assert!(!NodeId::new("nope").is_canonical());
416        assert!(ClaimId::new("C02").is_canonical());
417    }
418
419    #[test]
420    fn experiment_fields_round_trip() {
421        let f = NodeFields::Experiment {
422            result: Some("28.4 BLEU".into()),
423            exploration: Some("grid over k".into()),
424            outcome: Some("sparse wins".into()),
425            status: Some("completed".into()),
426        };
427        let json = serde_json::to_string(&f).unwrap();
428        assert_eq!(
429            json,
430            r#"{"experiment":{"result":"28.4 BLEU","exploration":"grid over k","outcome":"sparse wins","status":"completed"}}"#
431        );
432        let back: NodeFields = serde_json::from_str(&json).unwrap();
433        assert_eq!(back, f);
434    }
435
436    #[test]
437    fn pivot_fields_round_trip() {
438        let f = NodeFields::Pivot {
439            prior_direction: Some("dense retrieval".into()),
440            new_direction: Some("sparse retrieval".into()),
441            reason: Some("latency budget".into()),
442            lesson: Some("profile first".into()),
443        };
444        let json = serde_json::to_string(&f).unwrap();
445        assert_eq!(
446            json,
447            r#"{"pivot":{"prior_direction":"dense retrieval","new_direction":"sparse retrieval","reason":"latency budget","lesson":"profile first"}}"#
448        );
449        let back: NodeFields = serde_json::from_str(&json).unwrap();
450        assert_eq!(back, f);
451    }
452
453    #[test]
454    fn node_provenance_timestamp_round_trip_and_skip() {
455        let mut node = Node {
456            id: NodeId::new("N01"),
457            kind: NodeKind::Question,
458            label: None,
459            support_level: None,
460            source_refs: vec![],
461            description: None,
462            provenance: Some("user".into()),
463            timestamp: Some("2026-08-19".into()),
464            fields: NodeFields::Question,
465            evidence_notes: vec![],
466            isolated: false,
467            pos: None,
468        };
469        let json = serde_json::to_string(&node).unwrap();
470        let back: Node = serde_json::from_str(&json).unwrap();
471        assert_eq!(back, node);
472        assert!(json.contains(r#""provenance":"user""#));
473        assert!(json.contains(r#""timestamp":"2026-08-19""#));
474
475        node.provenance = None;
476        node.timestamp = None;
477        let json = serde_json::to_string(&node).unwrap();
478        assert!(!json.contains("provenance"));
479        assert!(!json.contains("timestamp"));
480        let back: Node = serde_json::from_str(&json).unwrap();
481        assert_eq!(back, node);
482    }
483
484    #[test]
485    fn exhibit_kind_new_variants_round_trip() {
486        for (kind, wire) in [
487            (ExhibitKind::Figure, "figure"),
488            (ExhibitKind::Table, "table"),
489            (ExhibitKind::Result, "result"),
490            (ExhibitKind::Proof, "proof"),
491            (ExhibitKind::Other, "other"),
492        ] {
493            let json = serde_json::to_string(&kind).unwrap();
494            assert_eq!(json, format!("\"{wire}\""));
495            let back: ExhibitKind = serde_json::from_str(&json).unwrap();
496            assert_eq!(back, kind);
497        }
498    }
499}