use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextGraph {
pub format: String,
pub nodes: Vec<ContextNode>,
pub edges: Vec<ContextEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextNode {
pub id: String,
#[serde(rename = "type")]
pub node_type: String,
pub content: String,
#[serde(default = "default_activation")]
pub activation: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decay_half_life_days: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_start: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_end: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextEdge {
pub from: String,
pub to: String,
#[serde(rename = "type")]
pub edge_type: String,
#[serde(default = "default_weight")]
pub weight: f64,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub coactivations: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<String>,
}
fn default_activation() -> f64 {
1.0
}
fn default_weight() -> f64 {
1.0
}
fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GraphSummary {
pub node_count: u32,
pub edge_count: u32,
#[serde(default)]
pub node_types: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activation_mean: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freshness: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MarketplaceMeta {
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub badges: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
}
pub const GRAPH_FORMAT_V2: &str = "ctxpkg-graph-v2";
impl ContextGraph {
pub fn new() -> Self {
Self {
format: GRAPH_FORMAT_V2.into(),
nodes: Vec::new(),
edges: Vec::new(),
}
}
pub fn add_node(&mut self, node: ContextNode) {
self.nodes.push(node);
}
pub fn add_edge(&mut self, edge: ContextEdge) {
self.edges.push(edge);
}
pub fn node_by_id(&self, id: &str) -> Option<&ContextNode> {
self.nodes.iter().find(|n| n.id == id)
}
pub fn node_types(&self) -> Vec<String> {
let mut types: Vec<String> = self
.nodes
.iter()
.map(|n| n.node_type.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
types.sort();
types
}
pub fn activation_mean(&self) -> f64 {
if self.nodes.is_empty() {
return 0.0;
}
let sum: f64 = self.nodes.iter().map(|n| n.activation).sum();
sum / self.nodes.len() as f64
}
pub fn summary(&self) -> GraphSummary {
GraphSummary {
node_count: self.nodes.len() as u32,
edge_count: self.edges.len() as u32,
node_types: self.node_types(),
activation_mean: Some(self.activation_mean()),
freshness: self.nodes.iter().filter_map(|n| n.created_at).max(),
}
}
pub fn apply_temporal_decay(&mut self, now: DateTime<Utc>) {
for node in &mut self.nodes {
let Some(half_life) = node.decay_half_life_days else {
continue;
};
let Some(created) = node.created_at else {
continue;
};
if half_life == 0 {
continue;
}
let age_days = (now - created).num_days().max(0) as f64;
let decay = 0.5_f64.powf(age_days / f64::from(half_life));
node.activation *= decay;
}
}
}
impl Default for ContextGraph {
fn default() -> Self {
Self::new()
}
}
impl ContextNode {
pub fn fact(id: &str, content: &str, category: &str) -> Self {
Self {
id: id.into(),
node_type: "fact".into(),
content: content.into(),
activation: 1.0,
category: Some(category.into()),
source: None,
created_at: Some(Utc::now()),
decay_half_life_days: Some(90),
blob_ref: None,
file_path: None,
line_start: None,
line_end: None,
confidence: None,
supersedes: None,
}
}
pub fn gotcha(id: &str, trigger: &str, resolution: &str) -> Self {
Self {
id: id.into(),
node_type: "gotcha".into(),
content: format!("{trigger}\n---\n{resolution}"),
activation: 1.0,
category: None,
source: None,
created_at: Some(Utc::now()),
decay_half_life_days: None,
blob_ref: None,
file_path: None,
line_start: None,
line_end: None,
confidence: None,
supersedes: None,
}
}
pub fn code_symbol(id: &str, kind: &str, name: &str, file_path: &str) -> Self {
Self {
id: id.into(),
node_type: format!("code_{kind}"),
content: name.into(),
activation: 1.0,
category: Some(kind.into()),
source: None,
created_at: None,
decay_half_life_days: None,
blob_ref: None,
file_path: Some(file_path.into()),
line_start: None,
line_end: None,
confidence: None,
supersedes: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_graph_has_correct_format() {
let g = ContextGraph::new();
assert_eq!(g.format, GRAPH_FORMAT_V2);
assert!(g.nodes.is_empty());
assert!(g.edges.is_empty());
}
#[test]
fn summary_counts() {
let mut g = ContextGraph::new();
g.add_node(ContextNode::fact("n1", "hello", "arch"));
g.add_node(ContextNode::gotcha("n2", "trig", "res"));
g.add_edge(ContextEdge {
from: "n1".into(),
to: "n2".into(),
edge_type: "has_gotcha".into(),
weight: 0.9,
coactivations: 5,
metadata: None,
});
let s = g.summary();
assert_eq!(s.node_count, 2);
assert_eq!(s.edge_count, 1);
assert_eq!(s.node_types, vec!["fact", "gotcha"]);
}
#[test]
fn activation_mean_calculation() {
let mut g = ContextGraph::new();
let mut n1 = ContextNode::fact("n1", "a", "x");
n1.activation = 0.8;
let mut n2 = ContextNode::fact("n2", "b", "x");
n2.activation = 0.6;
g.add_node(n1);
g.add_node(n2);
let mean = g.activation_mean();
assert!((mean - 0.7).abs() < 0.001);
}
#[test]
fn temporal_decay_halves_at_half_life() {
let mut g = ContextGraph::new();
let mut n = ContextNode::fact("n1", "test", "x");
n.activation = 1.0;
n.decay_half_life_days = Some(30);
n.created_at = Some(Utc::now() - chrono::Duration::days(30));
g.add_node(n);
g.apply_temporal_decay(Utc::now());
assert!((g.nodes[0].activation - 0.5).abs() < 0.01);
}
#[test]
fn node_by_id_lookup() {
let mut g = ContextGraph::new();
g.add_node(ContextNode::fact("alpha", "content a", "cat"));
g.add_node(ContextNode::fact("beta", "content b", "cat"));
assert_eq!(g.node_by_id("alpha").unwrap().content, "content a");
assert!(g.node_by_id("gamma").is_none());
}
#[test]
fn serde_roundtrip() {
let mut g = ContextGraph::new();
g.add_node(ContextNode::fact("n1", "test fact", "arch"));
g.add_edge(ContextEdge {
from: "n1".into(),
to: "n1".into(),
edge_type: "self_ref".into(),
weight: 1.0,
coactivations: 0,
metadata: None,
});
let json = serde_json::to_string(&g).unwrap();
let decoded: ContextGraph = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.nodes.len(), 1);
assert_eq!(decoded.edges.len(), 1);
assert_eq!(decoded.nodes[0].node_type, "fact");
}
#[test]
fn empty_graph_activation_mean_is_zero() {
let g = ContextGraph::new();
assert_eq!(g.activation_mean(), 0.0);
}
#[test]
fn decay_without_created_at_is_noop() {
let mut g = ContextGraph::new();
let mut n = ContextNode::fact("n1", "test", "x");
n.activation = 1.0;
n.decay_half_life_days = Some(30);
n.created_at = None;
g.add_node(n);
g.apply_temporal_decay(Utc::now());
assert!((g.nodes[0].activation - 1.0).abs() < 0.001);
}
#[test]
fn decay_with_zero_half_life_is_noop() {
let mut g = ContextGraph::new();
let mut n = ContextNode::fact("n1", "test", "x");
n.activation = 1.0;
n.decay_half_life_days = Some(0);
n.created_at = Some(Utc::now() - chrono::Duration::days(100));
g.add_node(n);
g.apply_temporal_decay(Utc::now());
assert!((g.nodes[0].activation - 1.0).abs() < 0.001);
}
#[test]
fn decay_without_half_life_is_noop() {
let mut g = ContextGraph::new();
let mut n = ContextNode::fact("n1", "test", "x");
n.activation = 0.9;
n.decay_half_life_days = None;
n.created_at = Some(Utc::now() - chrono::Duration::days(365));
g.add_node(n);
g.apply_temporal_decay(Utc::now());
assert!((g.nodes[0].activation - 0.9).abs() < 0.001);
}
#[test]
fn decay_two_half_lives_quarters() {
let mut g = ContextGraph::new();
let mut n = ContextNode::fact("n1", "test", "x");
n.activation = 1.0;
n.decay_half_life_days = Some(30);
n.created_at = Some(Utc::now() - chrono::Duration::days(60));
g.add_node(n);
g.apply_temporal_decay(Utc::now());
assert!((g.nodes[0].activation - 0.25).abs() < 0.01);
}
#[test]
fn code_symbol_factory() {
let n = ContextNode::code_symbol("s1", "function", "main", "src/main.rs");
assert_eq!(n.node_type, "code_function");
assert_eq!(n.content, "main");
assert_eq!(n.file_path.as_deref(), Some("src/main.rs"));
assert_eq!(n.category.as_deref(), Some("function"));
}
#[test]
fn gotcha_factory_content_format() {
let n = ContextNode::gotcha("g1", "race condition", "use mutex");
assert!(n.content.contains("race condition"));
assert!(n.content.contains("---"));
assert!(n.content.contains("use mutex"));
assert_eq!(n.node_type, "gotcha");
}
#[test]
fn node_types_deduplicates_and_sorts() {
let mut g = ContextGraph::new();
g.add_node(ContextNode::fact("a", "x", "c"));
g.add_node(ContextNode::fact("b", "y", "c"));
g.add_node(ContextNode::gotcha("c", "t", "r"));
g.add_node(ContextNode::fact("d", "z", "c"));
let types = g.node_types();
assert_eq!(types, vec!["fact", "gotcha"]);
}
#[test]
fn summary_freshness_is_most_recent() {
let mut g = ContextGraph::new();
let mut n1 = ContextNode::fact("n1", "old", "c");
n1.created_at = Some(Utc::now() - chrono::Duration::days(10));
let mut n2 = ContextNode::fact("n2", "new", "c");
n2.created_at = Some(Utc::now());
g.add_node(n1);
g.add_node(n2);
let s = g.summary();
let freshness = s.freshness.unwrap();
let age_secs = (Utc::now() - freshness).num_seconds().abs();
assert!(age_secs < 5);
}
#[test]
fn serde_preserves_type_field_name() {
let n = ContextNode::fact("n1", "test", "arch");
let json = serde_json::to_value(&n).unwrap();
assert!(json.get("type").is_some());
assert!(json.get("node_type").is_none());
}
#[test]
fn serde_edge_preserves_type_field_name() {
let e = ContextEdge {
from: "a".into(),
to: "b".into(),
edge_type: "supports".into(),
weight: 1.0,
coactivations: 0,
metadata: None,
};
let json = serde_json::to_value(&e).unwrap();
assert!(json.get("type").is_some());
assert!(json.get("edge_type").is_none());
}
#[test]
fn default_values_in_deserialization() {
let json = r#"{"id":"n1","type":"fact","content":"hello"}"#;
let node: ContextNode = serde_json::from_str(json).unwrap();
assert_eq!(node.activation, 1.0);
assert!(node.category.is_none());
assert!(node.supersedes.is_none());
}
#[test]
fn edge_default_weight_in_deserialization() {
let json = r#"{"from":"a","to":"b","type":"supports"}"#;
let edge: ContextEdge = serde_json::from_str(json).unwrap();
assert_eq!(edge.weight, 1.0);
assert_eq!(edge.coactivations, 0);
}
}