Skip to main content

lean_ctx/core/context_package/
graph_model.rs

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}