1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct ContextGraph {
6 pub format: String,
7 pub nodes: Vec<ContextNode>,
8 pub edges: Vec<ContextEdge>,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ContextNode {
13 pub id: String,
14 #[serde(rename = "type")]
15 pub node_type: String,
16 pub content: String,
17 #[serde(default = "default_activation")]
18 pub activation: f64,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub category: Option<String>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub source: Option<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub created_at: Option<DateTime<Utc>>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub decay_half_life_days: Option<u32>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub blob_ref: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub file_path: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub line_start: Option<usize>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub line_end: Option<usize>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub confidence: Option<f32>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub supersedes: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ContextEdge {
43 pub from: String,
44 pub to: String,
45 #[serde(rename = "type")]
46 pub edge_type: String,
47 #[serde(default = "default_weight")]
48 pub weight: f64,
49 #[serde(default, skip_serializing_if = "is_zero_u32")]
50 pub coactivations: u32,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub metadata: Option<String>,
53}
54
55fn default_activation() -> f64 {
56 1.0
57}
58
59fn default_weight() -> f64 {
60 1.0
61}
62
63fn is_zero_u32(v: &u32) -> bool {
64 *v == 0
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct GraphSummary {
69 pub node_count: u32,
70 pub edge_count: u32,
71 #[serde(default)]
72 pub node_types: Vec<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub activation_mean: Option<f64>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub freshness: Option<DateTime<Utc>>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, Default)]
80pub struct MarketplaceMeta {
81 #[serde(default)]
82 pub categories: Vec<String>,
83 #[serde(default)]
84 pub badges: Vec<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub license: Option<String>,
87}
88
89pub const GRAPH_FORMAT_V2: &str = "ctxpkg-graph-v2";
90
91impl ContextGraph {
92 pub fn new() -> Self {
93 Self {
94 format: GRAPH_FORMAT_V2.into(),
95 nodes: Vec::new(),
96 edges: Vec::new(),
97 }
98 }
99
100 pub fn add_node(&mut self, node: ContextNode) {
101 self.nodes.push(node);
102 }
103
104 pub fn add_edge(&mut self, edge: ContextEdge) {
105 self.edges.push(edge);
106 }
107
108 pub fn node_by_id(&self, id: &str) -> Option<&ContextNode> {
109 self.nodes.iter().find(|n| n.id == id)
110 }
111
112 pub fn node_types(&self) -> Vec<String> {
113 let mut types: Vec<String> = self
114 .nodes
115 .iter()
116 .map(|n| n.node_type.clone())
117 .collect::<std::collections::BTreeSet<_>>()
118 .into_iter()
119 .collect();
120 types.sort();
121 types
122 }
123
124 pub fn activation_mean(&self) -> f64 {
125 if self.nodes.is_empty() {
126 return 0.0;
127 }
128 let sum: f64 = self.nodes.iter().map(|n| n.activation).sum();
129 sum / self.nodes.len() as f64
130 }
131
132 pub fn summary(&self) -> GraphSummary {
133 GraphSummary {
134 node_count: self.nodes.len() as u32,
135 edge_count: self.edges.len() as u32,
136 node_types: self.node_types(),
137 activation_mean: Some(self.activation_mean()),
138 freshness: self.nodes.iter().filter_map(|n| n.created_at).max(),
139 }
140 }
141
142 pub fn apply_temporal_decay(&mut self, now: DateTime<Utc>) {
143 for node in &mut self.nodes {
144 let Some(half_life) = node.decay_half_life_days else {
145 continue;
146 };
147 let Some(created) = node.created_at else {
148 continue;
149 };
150 if half_life == 0 {
151 continue;
152 }
153 let age_days = (now - created).num_days().max(0) as f64;
154 let decay = 0.5_f64.powf(age_days / f64::from(half_life));
155 node.activation *= decay;
156 }
157 }
158}
159
160impl Default for ContextGraph {
161 fn default() -> Self {
162 Self::new()
163 }
164}
165
166impl ContextNode {
167 pub fn fact(id: &str, content: &str, category: &str) -> Self {
168 Self {
169 id: id.into(),
170 node_type: "fact".into(),
171 content: content.into(),
172 activation: 1.0,
173 category: Some(category.into()),
174 source: None,
175 created_at: Some(Utc::now()),
176 decay_half_life_days: Some(90),
177 blob_ref: None,
178 file_path: None,
179 line_start: None,
180 line_end: None,
181 confidence: None,
182 supersedes: None,
183 }
184 }
185
186 pub fn gotcha(id: &str, trigger: &str, resolution: &str) -> Self {
187 Self {
188 id: id.into(),
189 node_type: "gotcha".into(),
190 content: format!("{trigger}\n---\n{resolution}"),
191 activation: 1.0,
192 category: None,
193 source: None,
194 created_at: Some(Utc::now()),
195 decay_half_life_days: None,
196 blob_ref: None,
197 file_path: None,
198 line_start: None,
199 line_end: None,
200 confidence: None,
201 supersedes: None,
202 }
203 }
204
205 pub fn code_symbol(id: &str, kind: &str, name: &str, file_path: &str) -> Self {
206 Self {
207 id: id.into(),
208 node_type: format!("code_{kind}"),
209 content: name.into(),
210 activation: 1.0,
211 category: Some(kind.into()),
212 source: None,
213 created_at: None,
214 decay_half_life_days: None,
215 blob_ref: None,
216 file_path: Some(file_path.into()),
217 line_start: None,
218 line_end: None,
219 confidence: None,
220 supersedes: None,
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn new_graph_has_correct_format() {
231 let g = ContextGraph::new();
232 assert_eq!(g.format, GRAPH_FORMAT_V2);
233 assert!(g.nodes.is_empty());
234 assert!(g.edges.is_empty());
235 }
236
237 #[test]
238 fn summary_counts() {
239 let mut g = ContextGraph::new();
240 g.add_node(ContextNode::fact("n1", "hello", "arch"));
241 g.add_node(ContextNode::gotcha("n2", "trig", "res"));
242 g.add_edge(ContextEdge {
243 from: "n1".into(),
244 to: "n2".into(),
245 edge_type: "has_gotcha".into(),
246 weight: 0.9,
247 coactivations: 5,
248 metadata: None,
249 });
250 let s = g.summary();
251 assert_eq!(s.node_count, 2);
252 assert_eq!(s.edge_count, 1);
253 assert_eq!(s.node_types, vec!["fact", "gotcha"]);
254 }
255
256 #[test]
257 fn activation_mean_calculation() {
258 let mut g = ContextGraph::new();
259 let mut n1 = ContextNode::fact("n1", "a", "x");
260 n1.activation = 0.8;
261 let mut n2 = ContextNode::fact("n2", "b", "x");
262 n2.activation = 0.6;
263 g.add_node(n1);
264 g.add_node(n2);
265 let mean = g.activation_mean();
266 assert!((mean - 0.7).abs() < 0.001);
267 }
268
269 #[test]
270 fn temporal_decay_halves_at_half_life() {
271 let mut g = ContextGraph::new();
272 let mut n = ContextNode::fact("n1", "test", "x");
273 n.activation = 1.0;
274 n.decay_half_life_days = Some(30);
275 n.created_at = Some(Utc::now() - chrono::Duration::days(30));
276 g.add_node(n);
277
278 g.apply_temporal_decay(Utc::now());
279 assert!((g.nodes[0].activation - 0.5).abs() < 0.01);
280 }
281
282 #[test]
283 fn node_by_id_lookup() {
284 let mut g = ContextGraph::new();
285 g.add_node(ContextNode::fact("alpha", "content a", "cat"));
286 g.add_node(ContextNode::fact("beta", "content b", "cat"));
287 assert_eq!(g.node_by_id("alpha").unwrap().content, "content a");
288 assert!(g.node_by_id("gamma").is_none());
289 }
290
291 #[test]
292 fn serde_roundtrip() {
293 let mut g = ContextGraph::new();
294 g.add_node(ContextNode::fact("n1", "test fact", "arch"));
295 g.add_edge(ContextEdge {
296 from: "n1".into(),
297 to: "n1".into(),
298 edge_type: "self_ref".into(),
299 weight: 1.0,
300 coactivations: 0,
301 metadata: None,
302 });
303 let json = serde_json::to_string(&g).unwrap();
304 let decoded: ContextGraph = serde_json::from_str(&json).unwrap();
305 assert_eq!(decoded.nodes.len(), 1);
306 assert_eq!(decoded.edges.len(), 1);
307 assert_eq!(decoded.nodes[0].node_type, "fact");
308 }
309
310 #[test]
311 fn empty_graph_activation_mean_is_zero() {
312 let g = ContextGraph::new();
313 assert_eq!(g.activation_mean(), 0.0);
314 }
315
316 #[test]
317 fn decay_without_created_at_is_noop() {
318 let mut g = ContextGraph::new();
319 let mut n = ContextNode::fact("n1", "test", "x");
320 n.activation = 1.0;
321 n.decay_half_life_days = Some(30);
322 n.created_at = None;
323 g.add_node(n);
324 g.apply_temporal_decay(Utc::now());
325 assert!((g.nodes[0].activation - 1.0).abs() < 0.001);
326 }
327
328 #[test]
329 fn decay_with_zero_half_life_is_noop() {
330 let mut g = ContextGraph::new();
331 let mut n = ContextNode::fact("n1", "test", "x");
332 n.activation = 1.0;
333 n.decay_half_life_days = Some(0);
334 n.created_at = Some(Utc::now() - chrono::Duration::days(100));
335 g.add_node(n);
336 g.apply_temporal_decay(Utc::now());
337 assert!((g.nodes[0].activation - 1.0).abs() < 0.001);
338 }
339
340 #[test]
341 fn decay_without_half_life_is_noop() {
342 let mut g = ContextGraph::new();
343 let mut n = ContextNode::fact("n1", "test", "x");
344 n.activation = 0.9;
345 n.decay_half_life_days = None;
346 n.created_at = Some(Utc::now() - chrono::Duration::days(365));
347 g.add_node(n);
348 g.apply_temporal_decay(Utc::now());
349 assert!((g.nodes[0].activation - 0.9).abs() < 0.001);
350 }
351
352 #[test]
353 fn decay_two_half_lives_quarters() {
354 let mut g = ContextGraph::new();
355 let mut n = ContextNode::fact("n1", "test", "x");
356 n.activation = 1.0;
357 n.decay_half_life_days = Some(30);
358 n.created_at = Some(Utc::now() - chrono::Duration::days(60));
359 g.add_node(n);
360 g.apply_temporal_decay(Utc::now());
361 assert!((g.nodes[0].activation - 0.25).abs() < 0.01);
362 }
363
364 #[test]
365 fn code_symbol_factory() {
366 let n = ContextNode::code_symbol("s1", "function", "main", "src/main.rs");
367 assert_eq!(n.node_type, "code_function");
368 assert_eq!(n.content, "main");
369 assert_eq!(n.file_path.as_deref(), Some("src/main.rs"));
370 assert_eq!(n.category.as_deref(), Some("function"));
371 }
372
373 #[test]
374 fn gotcha_factory_content_format() {
375 let n = ContextNode::gotcha("g1", "race condition", "use mutex");
376 assert!(n.content.contains("race condition"));
377 assert!(n.content.contains("---"));
378 assert!(n.content.contains("use mutex"));
379 assert_eq!(n.node_type, "gotcha");
380 }
381
382 #[test]
383 fn node_types_deduplicates_and_sorts() {
384 let mut g = ContextGraph::new();
385 g.add_node(ContextNode::fact("a", "x", "c"));
386 g.add_node(ContextNode::fact("b", "y", "c"));
387 g.add_node(ContextNode::gotcha("c", "t", "r"));
388 g.add_node(ContextNode::fact("d", "z", "c"));
389 let types = g.node_types();
390 assert_eq!(types, vec!["fact", "gotcha"]);
391 }
392
393 #[test]
394 fn summary_freshness_is_most_recent() {
395 let mut g = ContextGraph::new();
396 let mut n1 = ContextNode::fact("n1", "old", "c");
397 n1.created_at = Some(Utc::now() - chrono::Duration::days(10));
398 let mut n2 = ContextNode::fact("n2", "new", "c");
399 n2.created_at = Some(Utc::now());
400 g.add_node(n1);
401 g.add_node(n2);
402 let s = g.summary();
403 let freshness = s.freshness.unwrap();
404 let age_secs = (Utc::now() - freshness).num_seconds().abs();
405 assert!(age_secs < 5);
406 }
407
408 #[test]
409 fn serde_preserves_type_field_name() {
410 let n = ContextNode::fact("n1", "test", "arch");
411 let json = serde_json::to_value(&n).unwrap();
412 assert!(json.get("type").is_some());
413 assert!(json.get("node_type").is_none());
414 }
415
416 #[test]
417 fn serde_edge_preserves_type_field_name() {
418 let e = ContextEdge {
419 from: "a".into(),
420 to: "b".into(),
421 edge_type: "supports".into(),
422 weight: 1.0,
423 coactivations: 0,
424 metadata: None,
425 };
426 let json = serde_json::to_value(&e).unwrap();
427 assert!(json.get("type").is_some());
428 assert!(json.get("edge_type").is_none());
429 }
430
431 #[test]
432 fn default_values_in_deserialization() {
433 let json = r#"{"id":"n1","type":"fact","content":"hello"}"#;
434 let node: ContextNode = serde_json::from_str(json).unwrap();
435 assert_eq!(node.activation, 1.0);
436 assert!(node.category.is_none());
437 assert!(node.supersedes.is_none());
438 }
439
440 #[test]
441 fn edge_default_weight_in_deserialization() {
442 let json = r#"{"from":"a","to":"b","type":"supports"}"#;
443 let edge: ContextEdge = serde_json::from_str(json).unwrap();
444 assert_eq!(edge.weight, 1.0);
445 assert_eq!(edge.coactivations, 0);
446 }
447}