use serde::{Deserialize, Serialize};
use crate::layout::{Point, Rect};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NodeId(String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ClaimId(String);
impl NodeId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_canonical(&self) -> bool {
is_canonical_id(&self.0, 'N')
}
}
impl ClaimId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_canonical(&self) -> bool {
is_canonical_id(&self.0, 'C')
}
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::fmt::Display for ClaimId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub(crate) fn is_canonical_id(s: &str, prefix: char) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c == prefix => {}
_ => return false,
}
let rest = chars.as_str();
!rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
pub nodes: Vec<Node>,
pub links: Vec<Link>,
pub bindings: Vec<Binding>,
pub claims: Vec<Claim>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bounds: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paper: Option<PaperMeta>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub related_work: Vec<RelatedWork>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub concepts: Vec<Concept>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub problem: Option<Problem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub recipes: Vec<Recipe>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exhibits: Vec<Exhibit>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub built_on: Vec<BuiltOn>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub node_exhibits: Vec<NodeExhibit>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PaperMeta {
pub title: Option<String>,
pub authors: Vec<String>,
pub year: Option<String>,
pub venue: Option<String>,
pub doi: Option<String>,
#[serde(rename = "abstract")]
pub abstract_: Option<String>,
pub keywords: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RelatedWork {
pub id: String,
pub cite: String,
pub doi: Option<String>,
pub kind: Option<String>,
pub what_changed: Option<String>,
pub why: Option<String>,
pub adopted: Option<String>,
pub claims_affected: Vec<ClaimId>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Concept {
pub term: String,
pub notation: Option<String>,
pub definition: Option<String>,
pub boundary: Option<String>,
pub related: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Problem {
pub statement: Option<String>,
pub observations: Vec<String>,
pub gaps: Vec<String>,
pub insights: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Recipe {
pub name: String,
pub title: Option<String>,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExhibitKind {
Figure,
Table,
Result,
Proof,
Other,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Exhibit {
pub id: String,
pub file: String,
pub kind: ExhibitKind,
pub source: Option<String>,
pub description: Option<String>,
pub claims: Vec<ClaimId>,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuiltOn {
pub node: NodeId,
pub related_work: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeExhibit {
pub node: NodeId,
pub exhibit: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Node {
pub id: NodeId,
pub kind: NodeKind,
pub label: Option<String>,
pub support_level: Option<String>,
pub source_refs: Vec<String>,
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
pub fields: NodeFields,
pub evidence_notes: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub isolated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub pos: Option<Point>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeKind {
Question,
Experiment,
Decision,
DeadEnd,
Insight,
Pivot,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeFields {
Question,
Experiment {
result: Option<String>,
exploration: Option<String>,
outcome: Option<String>,
status: Option<String>,
},
Decision {
choice: Option<String>,
alternatives: Vec<String>,
rationale: Option<String>,
},
DeadEnd {
hypothesis: Option<String>,
failure_mode: Option<String>,
lesson: Option<String>,
why_failed: Option<String>,
},
Insight,
Pivot {
prior_direction: Option<String>,
new_direction: Option<String>,
reason: Option<String>,
lesson: Option<String>,
},
Other,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Link {
pub from: NodeId,
pub to: NodeId,
pub kind: LinkKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LinkKind {
Child,
DependsOn,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Binding {
pub node: NodeId,
pub claim: ClaimId,
pub role: BindingRole,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BindingRole {
Evidence,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Claim {
pub id: ClaimId,
pub title: String,
pub statement: Option<String>,
pub status: Option<String>,
pub proof: Vec<String>,
pub deps: Vec<ClaimId>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_id_grammar() {
assert!(is_canonical_id("N01", 'N'));
assert!(is_canonical_id("N7", 'N'));
assert!(is_canonical_id("C123", 'C'));
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'));
}
#[test]
fn id_accessors_and_display() {
let n = NodeId::new("N01");
assert_eq!(n.as_str(), "N01");
assert_eq!(n.to_string(), "N01");
assert!(n.is_canonical());
assert!(!NodeId::new("nope").is_canonical());
assert!(ClaimId::new("C02").is_canonical());
}
#[test]
fn experiment_fields_round_trip() {
let f = NodeFields::Experiment {
result: Some("28.4 BLEU".into()),
exploration: Some("grid over k".into()),
outcome: Some("sparse wins".into()),
status: Some("completed".into()),
};
let json = serde_json::to_string(&f).unwrap();
assert_eq!(
json,
r#"{"experiment":{"result":"28.4 BLEU","exploration":"grid over k","outcome":"sparse wins","status":"completed"}}"#
);
let back: NodeFields = serde_json::from_str(&json).unwrap();
assert_eq!(back, f);
}
#[test]
fn pivot_fields_round_trip() {
let f = NodeFields::Pivot {
prior_direction: Some("dense retrieval".into()),
new_direction: Some("sparse retrieval".into()),
reason: Some("latency budget".into()),
lesson: Some("profile first".into()),
};
let json = serde_json::to_string(&f).unwrap();
assert_eq!(
json,
r#"{"pivot":{"prior_direction":"dense retrieval","new_direction":"sparse retrieval","reason":"latency budget","lesson":"profile first"}}"#
);
let back: NodeFields = serde_json::from_str(&json).unwrap();
assert_eq!(back, f);
}
#[test]
fn node_provenance_timestamp_round_trip_and_skip() {
let mut node = Node {
id: NodeId::new("N01"),
kind: NodeKind::Question,
label: None,
support_level: None,
source_refs: vec![],
description: None,
provenance: Some("user".into()),
timestamp: Some("2026-08-19".into()),
fields: NodeFields::Question,
evidence_notes: vec![],
isolated: false,
pos: None,
};
let json = serde_json::to_string(&node).unwrap();
let back: Node = serde_json::from_str(&json).unwrap();
assert_eq!(back, node);
assert!(json.contains(r#""provenance":"user""#));
assert!(json.contains(r#""timestamp":"2026-08-19""#));
node.provenance = None;
node.timestamp = None;
let json = serde_json::to_string(&node).unwrap();
assert!(!json.contains("provenance"));
assert!(!json.contains("timestamp"));
let back: Node = serde_json::from_str(&json).unwrap();
assert_eq!(back, node);
}
#[test]
fn exhibit_kind_new_variants_round_trip() {
for (kind, wire) in [
(ExhibitKind::Figure, "figure"),
(ExhibitKind::Table, "table"),
(ExhibitKind::Result, "result"),
(ExhibitKind::Proof, "proof"),
(ExhibitKind::Other, "other"),
] {
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, format!("\"{wire}\""));
let back: ExhibitKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, kind);
}
}
}