heddle_object_model/object/
git_note.rs1use serde::{Deserialize, Serialize};
9
10use super::{State, Status};
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct HeddleNote {
15 pub state_id: String,
16 pub change_id: String,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub source_state: Option<State>,
19 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
22 pub parents_rewritten: bool,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub agent: Option<NoteAgent>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub confidence: Option<f32>,
27 pub status: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub omitted_annotations_breakdown: Option<OmittedBreakdown>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub signal_counts: Option<SignalCounts>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub attribution: Option<NoteAttribution>,
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct NoteAgent {
42 pub provider: String,
43 pub model: String,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47pub struct OmittedBreakdown {
48 #[serde(default)]
49 pub internal: u32,
50 #[serde(default)]
51 pub team: u32,
52 #[serde(default)]
53 pub restricted: u32,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
57pub struct SignalCounts {
58 #[serde(default)]
59 pub novelty: u32,
60 #[serde(default)]
61 pub test_reachability: u32,
62 #[serde(default)]
63 pub pattern_deviation: u32,
64 #[serde(default)]
65 pub invariant_adjacency: u32,
66 #[serde(default)]
67 pub self_flagged_uncertainty: u32,
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct NoteAttribution {
72 pub principal_name: String,
73 pub principal_email: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub agent: Option<NoteAgent>,
76}
77
78impl HeddleNote {
79 pub fn from_state(state: &State) -> Self {
82 let status = match state.status {
83 Status::Draft => "draft".to_string(),
84 Status::Published => "published".to_string(),
85 };
86 let agent = state.attribution.agent.as_ref().map(|agent| NoteAgent {
87 provider: agent.provider.clone(),
88 model: agent.model.clone(),
89 });
90 Self {
91 state_id: state.id().to_string_full(),
92 change_id: state.change_id.to_string_full(),
93 source_state: Some(state.clone()),
94 parents_rewritten: false,
95 agent,
96 confidence: state.confidence,
97 status,
98 omitted_annotations_breakdown: None,
99 signal_counts: None,
100 attribution: None,
101 }
102 }
103
104 pub fn from_projected_state(state: &State) -> Self {
107 let mut note = Self::from_state(state);
108 note.parents_rewritten = true;
109 note
110 }
111
112 pub fn with_omitted_breakdown(mut self, breakdown: OmittedBreakdown) -> Self {
113 self.omitted_annotations_breakdown = Some(breakdown);
114 self
115 }
116
117 pub fn with_signal_counts(mut self, counts: SignalCounts) -> Self {
118 self.signal_counts = Some(counts);
119 self
120 }
121
122 pub fn with_attribution(mut self, attribution: NoteAttribution) -> Self {
123 self.attribution = Some(attribution);
124 self
125 }
126
127 pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
129 serde_json::to_vec_pretty(self)
130 }
131
132 pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
134 let mut note: Self = serde_json::from_slice(bytes)?;
135 if let Some(source_state) = &mut note.source_state {
136 source_state.state_id = source_state.id();
137 }
138 Ok(note)
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::object::{Attribution, Principal, State, Tree};
146
147 fn state() -> State {
148 State::new(
149 Tree::new().hash(),
150 Vec::new(),
151 Attribution::human(Principal::new("Test User", "test@example.com")),
152 )
153 }
154
155 #[test]
156 fn canonical_note_roundtrips_every_field() {
157 let note = HeddleNote::from_projected_state(&state())
158 .with_omitted_breakdown(OmittedBreakdown {
159 internal: 1,
160 team: 2,
161 restricted: 3,
162 })
163 .with_signal_counts(SignalCounts {
164 novelty: 4,
165 test_reachability: 5,
166 pattern_deviation: 6,
167 invariant_adjacency: 7,
168 self_flagged_uncertainty: 8,
169 })
170 .with_attribution(NoteAttribution {
171 principal_name: "Test User".to_string(),
172 principal_email: "test@example.com".to_string(),
173 agent: Some(NoteAgent {
174 provider: "openai".to_string(),
175 model: "codex".to_string(),
176 }),
177 });
178
179 let bytes = note.to_json_bytes().expect("encode canonical note");
180 assert_eq!(
181 HeddleNote::from_json_bytes(&bytes).expect("decode canonical note"),
182 note
183 );
184 }
185
186 #[test]
187 fn old_note_without_parent_marker_defaults_to_unmodified_graph() {
188 let source = state();
189 let bytes = serde_json::json!({
190 "state_id": source.id().to_string_full(),
191 "change_id": source.change_id.to_string_full(),
192 "status": "draft"
193 })
194 .to_string();
195
196 let note = HeddleNote::from_json_bytes(bytes.as_bytes()).expect("decode note");
197 assert!(!note.parents_rewritten);
198 }
199
200 #[test]
201 fn foreign_note_missing_required_identity_is_not_a_heddle_note() {
202 let bytes = br#"{"state_id":"hs-deadbeef","status":"published"}"#;
203 assert!(HeddleNote::from_json_bytes(bytes).is_err());
204 }
205}