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    /// Center position assigned by layout. Absent when layout has not run.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub pos: Option<Point>,
122}
123
124/// The five canonical node types, plus a preserved escape hatch.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum NodeKind {
128    Question,
129    Experiment,
130    Decision,
131    DeadEnd,
132    Insight,
133    /// An unrecognized `type:`; the raw string is preserved.
134    Other(String),
135}
136
137/// Typed body fields, one variant per canonical kind.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum NodeFields {
141    Question,
142    Experiment {
143        result: Option<String>,
144    },
145    Decision {
146        choice: Option<String>,
147        alternatives: Vec<String>,
148        rationale: Option<String>,
149    },
150    DeadEnd {
151        why_failed: Option<String>,
152    },
153    Insight,
154    /// Unknown kind: body fields are captured (as warnings) at the raw layer.
155    Other,
156}
157
158/// A directed node → node edge.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct Link {
161    pub from: NodeId,
162    pub to: NodeId,
163    pub kind: LinkKind,
164}
165
166/// Kind of a node → node edge.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum LinkKind {
170    /// Nesting edge, from `children:`.
171    Child,
172    /// Cross-reference edge, from `also_depends_on:`.
173    DependsOn,
174}
175
176/// A resolved node → claim reference.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct Binding {
179    pub node: NodeId,
180    pub claim: ClaimId,
181    pub role: BindingRole,
182}
183
184/// Role of a node → claim reference.
185///
186/// `non_exhaustive`: `Verifies` (SOULFuzz-only) is intentionally out of scope
187/// and may be added later without breaking consumers.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190#[non_exhaustive]
191pub enum BindingRole {
192    /// From a node's `evidence:` list.
193    Evidence,
194}
195
196/// Claim content, parsed from `logic/claims.md`.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct Claim {
199    pub id: ClaimId,
200    pub title: String,
201    pub statement: Option<String>,
202    pub status: Option<String>,
203    /// `E##` proof refs, stored raw. Not validated — no evidence registry yet.
204    pub proof: Vec<String>,
205    /// Claim → claim dependencies.
206    pub deps: Vec<ClaimId>,
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn canonical_id_grammar() {
215        assert!(is_canonical_id("N01", 'N'));
216        assert!(is_canonical_id("N7", 'N'));
217        assert!(is_canonical_id("C123", 'C'));
218        assert!(!is_canonical_id("N", 'N')); // no digits
219        assert!(!is_canonical_id("n01", 'N')); // case-sensitive
220        assert!(!is_canonical_id("C01", 'N')); // wrong prefix
221        assert!(!is_canonical_id("N01a", 'N')); // trailing junk
222        assert!(!is_canonical_id("", 'N'));
223    }
224
225    #[test]
226    fn id_accessors_and_display() {
227        let n = NodeId::new("N01");
228        assert_eq!(n.as_str(), "N01");
229        assert_eq!(n.to_string(), "N01");
230        assert!(n.is_canonical());
231        assert!(!NodeId::new("nope").is_canonical());
232        assert!(ClaimId::new("C02").is_canonical());
233    }
234}