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 or table.
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[serde(rename_all = "snake_case")]
214pub enum ExhibitKind {
215    Figure,
216    Table,
217    Other,
218}
219
220/// One figure or table, parsed from `evidence/`. Populated by a later task.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct Exhibit {
223    /// Exhibit id.
224    pub id: String,
225    /// Source file, relative to the artifact root.
226    pub file: String,
227    /// Figure / table / other.
228    pub kind: ExhibitKind,
229    /// Origin of the exhibit, when stated.
230    pub source: Option<String>,
231    /// Caption / description prose.
232    pub description: Option<String>,
233    /// Claims this exhibit supports.
234    pub claims: Vec<ClaimId>,
235    /// Raw markdown body, verbatim.
236    pub body: String,
237}
238
239/// A node → related-work edge. Populated by a later resolution task.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct BuiltOn {
242    pub node: NodeId,
243    pub related_work: String,
244}
245
246/// A node → exhibit edge. Populated by a later resolution task.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct NodeExhibit {
249    pub node: NodeId,
250    pub exhibit: String,
251}
252
253/// One exploration node.
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255pub struct Node {
256    /// Unique node id.
257    pub id: NodeId,
258    /// Node type.
259    pub kind: NodeKind,
260    /// Display label, from `title:` only. Consumers fall back to `id`.
261    pub label: Option<String>,
262    /// `explicit` | `inferred` when present.
263    pub support_level: Option<String>,
264    /// Free-form provenance refs (`§1`, `Fig. 1`, ...).
265    pub source_refs: Vec<String>,
266    /// Prose description.
267    pub description: Option<String>,
268    /// Typed per-kind body.
269    pub fields: NodeFields,
270    /// Free-text evidence entries (the non-`C##` part of `evidence:`).
271    pub evidence_notes: Vec<String>,
272    /// Whether this node is the root of an *isolated* subtree — a branch the
273    /// exploration reached but that hangs off the main tree on its own. Drives
274    /// the viewer's "isolated subtree" partition. Defaults to `false`; only the
275    /// root of a subtree carries it (children inherit placement from their root).
276    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
277    pub isolated: bool,
278    /// Center position assigned by layout. Absent when layout has not run.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub pos: Option<Point>,
281}
282
283/// The canonical node types, plus a preserved escape hatch.
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub enum NodeKind {
287    Question,
288    Experiment,
289    Decision,
290    DeadEnd,
291    Insight,
292    Pivot,
293    /// An unrecognized `type:`; the raw string is preserved.
294    Other(String),
295}
296
297/// Typed body fields, one variant per canonical kind.
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299#[serde(rename_all = "snake_case")]
300pub enum NodeFields {
301    Question,
302    Experiment {
303        result: Option<String>,
304    },
305    Decision {
306        choice: Option<String>,
307        alternatives: Vec<String>,
308        rationale: Option<String>,
309    },
310    DeadEnd {
311        hypothesis: Option<String>,
312        failure_mode: Option<String>,
313        lesson: Option<String>,
314        why_failed: Option<String>,
315    },
316    Insight,
317    Pivot {
318        from: Option<String>,
319        to: Option<String>,
320        trigger: Option<String>,
321    },
322    /// Unknown kind: body fields are captured (as warnings) at the raw layer.
323    Other,
324}
325
326/// A directed node → node edge.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub struct Link {
329    pub from: NodeId,
330    pub to: NodeId,
331    pub kind: LinkKind,
332}
333
334/// Kind of a node → node edge.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case")]
337pub enum LinkKind {
338    /// Nesting edge, from `children:`.
339    Child,
340    /// Cross-reference edge, from `also_depends_on:`.
341    DependsOn,
342}
343
344/// A resolved node → claim reference.
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346pub struct Binding {
347    pub node: NodeId,
348    pub claim: ClaimId,
349    pub role: BindingRole,
350}
351
352/// Role of a node → claim reference.
353///
354/// `non_exhaustive`: `Verifies` (SOULFuzz-only) is intentionally out of scope
355/// and may be added later without breaking consumers.
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "snake_case")]
358#[non_exhaustive]
359pub enum BindingRole {
360    /// From a node's `evidence:` list.
361    Evidence,
362}
363
364/// Claim content, parsed from `logic/claims.md`.
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366pub struct Claim {
367    pub id: ClaimId,
368    pub title: String,
369    pub statement: Option<String>,
370    pub status: Option<String>,
371    /// `E##` proof refs, stored raw. Not validated — no evidence registry yet.
372    pub proof: Vec<String>,
373    /// Claim → claim dependencies.
374    pub deps: Vec<ClaimId>,
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn canonical_id_grammar() {
383        assert!(is_canonical_id("N01", 'N'));
384        assert!(is_canonical_id("N7", 'N'));
385        assert!(is_canonical_id("C123", 'C'));
386        assert!(!is_canonical_id("N", 'N')); // no digits
387        assert!(!is_canonical_id("n01", 'N')); // case-sensitive
388        assert!(!is_canonical_id("C01", 'N')); // wrong prefix
389        assert!(!is_canonical_id("N01a", 'N')); // trailing junk
390        assert!(!is_canonical_id("", 'N'));
391    }
392
393    #[test]
394    fn id_accessors_and_display() {
395        let n = NodeId::new("N01");
396        assert_eq!(n.as_str(), "N01");
397        assert_eq!(n.to_string(), "N01");
398        assert!(n.is_canonical());
399        assert!(!NodeId::new("nope").is_canonical());
400        assert!(ClaimId::new("C02").is_canonical());
401    }
402}