1use serde::{Deserialize, Serialize};
13
14use crate::layout::{Point, Rect};
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18#[serde(transparent)]
19pub struct NodeId(String);
20
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct ClaimId(String);
25
26impl NodeId {
27 pub fn new(id: impl Into<String>) -> Self {
29 Self(id.into())
30 }
31
32 pub fn as_str(&self) -> &str {
34 &self.0
35 }
36
37 pub fn is_canonical(&self) -> bool {
39 is_canonical_id(&self.0, 'N')
40 }
41}
42
43impl ClaimId {
44 pub fn new(id: impl Into<String>) -> Self {
46 Self(id.into())
47 }
48
49 pub fn as_str(&self) -> &str {
51 &self.0
52 }
53
54 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
72pub(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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct Manifest {
87 pub nodes: Vec<Node>,
89 pub links: Vec<Link>,
91 pub bindings: Vec<Binding>,
93 pub claims: Vec<Claim>,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub bounds: Option<Rect>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub paper: Option<PaperMeta>,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub related_work: Vec<RelatedWork>,
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub concepts: Vec<Concept>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub problem: Option<Problem>,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub recipes: Vec<Recipe>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub exhibits: Vec<Exhibit>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub built_on: Vec<BuiltOn>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub node_exhibits: Vec<NodeExhibit>,
123}
124
125#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
131pub struct PaperMeta {
132 pub title: Option<String>,
134 pub authors: Vec<String>,
136 pub year: Option<String>,
138 pub venue: Option<String>,
140 pub doi: Option<String>,
142 #[serde(rename = "abstract")]
144 pub abstract_: Option<String>,
145 pub keywords: Vec<String>,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct RelatedWork {
152 pub id: String,
154 pub cite: String,
156 pub doi: Option<String>,
158 pub kind: Option<String>,
161 pub what_changed: Option<String>,
163 pub why: Option<String>,
165 pub adopted: Option<String>,
167 pub claims_affected: Vec<ClaimId>,
170}
171
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct Concept {
175 pub term: String,
177 pub notation: Option<String>,
179 pub definition: Option<String>,
181 pub boundary: Option<String>,
183 pub related: Vec<String>,
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct Problem {
190 pub statement: Option<String>,
192 pub observations: Vec<String>,
194 pub gaps: Vec<String>,
196 pub insights: Vec<String>,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct Recipe {
203 pub name: String,
205 pub title: Option<String>,
207 pub body: String,
209}
210
211#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226pub struct Exhibit {
227 pub id: String,
229 pub file: String,
231 pub kind: ExhibitKind,
233 pub source: Option<String>,
235 pub description: Option<String>,
237 pub claims: Vec<ClaimId>,
239 pub body: String,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct BuiltOn {
246 pub node: NodeId,
247 pub related_work: String,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct NodeExhibit {
253 pub node: NodeId,
254 pub exhibit: String,
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259pub struct Node {
260 pub id: NodeId,
262 pub kind: NodeKind,
264 pub label: Option<String>,
266 pub support_level: Option<String>,
268 pub source_refs: Vec<String>,
270 pub description: Option<String>,
272 #[serde(skip_serializing_if = "Option::is_none")]
275 pub provenance: Option<String>,
276 #[serde(skip_serializing_if = "Option::is_none")]
278 pub timestamp: Option<String>,
279 pub fields: NodeFields,
281 pub evidence_notes: Vec<String>,
283 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
288 pub isolated: bool,
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub pos: Option<Point>,
292}
293
294#[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 Other(String),
306}
307
308#[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 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 Other,
340}
341
342#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
352#[serde(rename_all = "snake_case")]
353pub enum LinkKind {
354 Child,
356 DependsOn,
358}
359
360#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374#[non_exhaustive]
375pub enum BindingRole {
376 Evidence,
378}
379
380#[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 pub proof: Vec<String>,
389 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')); assert!(!is_canonical_id("n01", 'N')); assert!(!is_canonical_id("C01", 'N')); assert!(!is_canonical_id("N01a", 'N')); 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}