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}
99
100/// One exploration node.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct Node {
103    /// Unique node id.
104    pub id: NodeId,
105    /// Node type.
106    pub kind: NodeKind,
107    /// Display label, from `title:` only. Consumers fall back to `id`.
108    pub label: Option<String>,
109    /// `explicit` | `inferred` when present.
110    pub support_level: Option<String>,
111    /// Free-form provenance refs (`§1`, `Fig. 1`, ...).
112    pub source_refs: Vec<String>,
113    /// Prose description.
114    pub description: Option<String>,
115    /// Typed per-kind body.
116    pub fields: NodeFields,
117    /// Free-text evidence entries (the non-`C##` part of `evidence:`).
118    pub evidence_notes: Vec<String>,
119    /// Whether this node is the root of an *isolated* subtree — a branch the
120    /// exploration reached but that hangs off the main tree on its own. Drives
121    /// the viewer's "isolated subtree" partition. Defaults to `false`; only the
122    /// root of a subtree carries it (children inherit placement from their root).
123    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
124    pub isolated: bool,
125    /// Center position assigned by layout. Absent when layout has not run.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub pos: Option<Point>,
128}
129
130/// The five canonical node types, plus a preserved escape hatch.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum NodeKind {
134    Question,
135    Experiment,
136    Decision,
137    DeadEnd,
138    Insight,
139    /// An unrecognized `type:`; the raw string is preserved.
140    Other(String),
141}
142
143/// Typed body fields, one variant per canonical kind.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum NodeFields {
147    Question,
148    Experiment {
149        result: Option<String>,
150    },
151    Decision {
152        choice: Option<String>,
153        alternatives: Vec<String>,
154        rationale: Option<String>,
155    },
156    DeadEnd {
157        why_failed: Option<String>,
158    },
159    Insight,
160    /// Unknown kind: body fields are captured (as warnings) at the raw layer.
161    Other,
162}
163
164/// A directed node → node edge.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct Link {
167    pub from: NodeId,
168    pub to: NodeId,
169    pub kind: LinkKind,
170}
171
172/// Kind of a node → node edge.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175pub enum LinkKind {
176    /// Nesting edge, from `children:`.
177    Child,
178    /// Cross-reference edge, from `also_depends_on:`.
179    DependsOn,
180}
181
182/// A resolved node → claim reference.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct Binding {
185    pub node: NodeId,
186    pub claim: ClaimId,
187    pub role: BindingRole,
188}
189
190/// Role of a node → claim reference.
191///
192/// `non_exhaustive`: `Verifies` (SOULFuzz-only) is intentionally out of scope
193/// and may be added later without breaking consumers.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196#[non_exhaustive]
197pub enum BindingRole {
198    /// From a node's `evidence:` list.
199    Evidence,
200}
201
202/// Claim content, parsed from `logic/claims.md`.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct Claim {
205    pub id: ClaimId,
206    pub title: String,
207    pub statement: Option<String>,
208    pub status: Option<String>,
209    /// `E##` proof refs, stored raw. Not validated — no evidence registry yet.
210    pub proof: Vec<String>,
211    /// Claim → claim dependencies.
212    pub deps: Vec<ClaimId>,
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn canonical_id_grammar() {
221        assert!(is_canonical_id("N01", 'N'));
222        assert!(is_canonical_id("N7", 'N'));
223        assert!(is_canonical_id("C123", 'C'));
224        assert!(!is_canonical_id("N", 'N')); // no digits
225        assert!(!is_canonical_id("n01", 'N')); // case-sensitive
226        assert!(!is_canonical_id("C01", 'N')); // wrong prefix
227        assert!(!is_canonical_id("N01a", 'N')); // trailing junk
228        assert!(!is_canonical_id("", 'N'));
229    }
230
231    #[test]
232    fn id_accessors_and_display() {
233        let n = NodeId::new("N01");
234        assert_eq!(n.as_str(), "N01");
235        assert_eq!(n.to_string(), "N01");
236        assert!(n.is_canonical());
237        assert!(!NodeId::new("nope").is_canonical());
238        assert!(ClaimId::new("C02").is_canonical());
239    }
240}