From b64c7e7f072b38cc0c343f312573a6b5ed8df448 Mon Sep 17 00:00:00 2001 From: David Hagerty Date: Mon, 9 Feb 2026 10:14:53 -0500 Subject: [PATCH] feat(graph): node decay for context injection with configurable thresholds Implement DecayConfig, DecayLevel, DecayedNode, and DecayDetail types in src/graph/decay.rs. Provides decay_node and decay_nodes pure functions that reduce node detail based on age: - Full detail (< 7 days): description and metadata - Summary (7-30 days): title, status, key_outcome from metadata - Minimal (> 30 days): title and status only Verifies P1c.AC4.1-4.4 with 11 comprehensive tests covering all thresholds and custom configs. --- src/graph/decay.rs | 341 ++++++++++++++++++++++++++++++++++++++++++++ src/graph/mod.rs | 1 + tests/decay_test.rs | 276 +++++++++++++++++++++++++++++++++++ 3 files changed, 618 insertions(+) create mode 100644 src/graph/decay.rs create mode 100644 tests/decay_test.rs diff --git a/src/graph/decay.rs b/src/graph/decay.rs new file mode 100644 index 0000000..d1cf2c2 --- /dev/null +++ b/src/graph/decay.rs @@ -0,0 +1,341 @@ +//! Node decay for context injection based on age thresholds. +//! +//! This module provides functions to "decay" node details based on how old they are, +//! reducing detail for old nodes to save context tokens when injecting nodes into LLM prompts. +//! +//! # Decay Levels +//! +//! - **Full** (< 7 days): title, description, status, metadata +//! - **Summary** (7-30 days): title, status, key outcome from metadata +//! - **Minimal** (> 30 days): title and status only +//! +//! Thresholds are configurable via `DecayConfig`. + +use crate::graph::{GraphNode, NodeStatus}; +use chrono::{DateTime, Utc}; +use std::collections::HashMap; + +/// Configuration for node decay thresholds +#[derive(Debug, Clone)] +pub struct DecayConfig { + /// Days before recent threshold (default: 7). Nodes older than this but + /// younger than `older_days` show Summary detail. + pub recent_days: i64, + + /// Days before old threshold (default: 30). Nodes older than this show + /// Minimal detail. + pub older_days: i64, +} + +impl Default for DecayConfig { + fn default() -> Self { + Self { + recent_days: 7, + older_days: 30, + } + } +} + +/// Severity level of node detail +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecayLevel { + /// Full detail: description and metadata included + Full, + /// Summary: title, status, key outcome only + Summary, + /// Minimal: title and status only + Minimal, +} + +/// Detailed information for a decayed node +#[derive(Debug, Clone)] +pub enum DecayDetail { + /// Full detail with description and metadata + Full { + /// Node description + description: String, + /// Additional metadata + metadata: HashMap, + }, + /// Summary with key outcome extracted from metadata + Summary { + /// Key outcome if present in metadata (under "key_outcome" field) + key_outcome: Option, + }, + /// Minimal detail - no additional fields + Minimal, +} + +/// A graph node with decayed details based on age +#[derive(Debug, Clone)] +pub struct DecayedNode { + /// Node ID + pub id: String, + /// Node title + pub title: String, + /// Node status + pub status: NodeStatus, + /// Detail level based on age + pub detail: DecayDetail, +} + +/// Determine the decay level for a node based on its age +fn decay_level_for_age(age_days: i64, config: &DecayConfig) -> DecayLevel { + if age_days < config.recent_days { + DecayLevel::Full + } else if age_days < config.older_days { + DecayLevel::Summary + } else { + DecayLevel::Minimal + } +} + +/// Calculate the age of a node in days +/// +/// Uses `completed_at` if available, otherwise `created_at`. Returns the number +/// of complete days between the node's reference date and the provided `now`. +fn node_age_days(node: &GraphNode, now: DateTime) -> i64 { + let reference_time = node.completed_at.unwrap_or(node.created_at); + let duration = now.signed_duration_since(reference_time); + duration.num_days() +} + +/// Apply decay to a single node +/// +/// Computes the node's age and selects a decay level. Returns a `DecayedNode` +/// with details appropriate to the age threshold. +/// +/// # Arguments +/// +/// * `node` - The graph node to decay +/// * `now` - Current time for age calculation +/// * `config` - Decay configuration with thresholds +/// +/// # Example +/// +/// ```ignore +/// let config = DecayConfig::default(); +/// let decayed = decay_node(&node, Utc::now(), &config); +/// match decayed.detail { +/// DecayDetail::Full { .. } => println!("Recent node"), +/// DecayDetail::Summary { .. } => println!("Older node"), +/// DecayDetail::Minimal => println!("Very old node"), +/// } +/// ``` +pub fn decay_node(node: &GraphNode, now: DateTime, config: &DecayConfig) -> DecayedNode { + let age_days = node_age_days(node, now); + let level = decay_level_for_age(age_days, config); + + let detail = match level { + DecayLevel::Full => DecayDetail::Full { + description: node.description.clone(), + metadata: node.metadata.clone(), + }, + DecayLevel::Summary => { + let key_outcome = node.metadata.get("key_outcome").cloned(); + DecayDetail::Summary { key_outcome } + } + DecayLevel::Minimal => DecayDetail::Minimal, + }; + + DecayedNode { + id: node.id.clone(), + title: node.title.clone(), + status: node.status, + detail, + } +} + +/// Apply decay to multiple nodes +/// +/// Applies `decay_node` to each node in the slice and returns a vector +/// of decayed nodes. +/// +/// # Arguments +/// +/// * `nodes` - Slice of graph nodes to decay +/// * `now` - Current time for age calculation +/// * `config` - Decay configuration with thresholds +pub fn decay_nodes( + nodes: &[GraphNode], + now: DateTime, + config: &DecayConfig, +) -> Vec { + nodes.iter().map(|n| decay_node(n, now, config)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + fn create_test_node(id: &str, created_days_ago: i64, completed_days_ago: Option) -> GraphNode { + let now = Utc::now(); + let created_at = now - Duration::days(created_days_ago); + let completed_at = completed_days_ago.map(|d| now - Duration::days(d)); + + GraphNode { + id: id.to_string(), + project_id: "proj-test".to_string(), + node_type: crate::graph::NodeType::Task, + title: format!("Task {}", id), + description: "Test description".to_string(), + status: NodeStatus::Completed, + priority: None, + assigned_to: None, + created_by: None, + labels: vec![], + created_at, + started_at: None, + completed_at, + blocked_reason: None, + metadata: { + let mut m = HashMap::new(); + m.insert("key_outcome".to_string(), "Important result".to_string()); + m + }, + } + } + + #[test] + fn test_decay_full_detail_recent_node() { + let config = DecayConfig::default(); + let node = create_test_node("n1", 10, Some(2)); // created 10 days ago, completed 2 days ago + let decayed = decay_node(&node, Utc::now(), &config); + + assert_eq!(decayed.id, "n1"); + assert_eq!(decayed.title, "Task n1"); + assert_eq!(decayed.status, NodeStatus::Completed); + + match decayed.detail { + DecayDetail::Full { + description, + metadata, + } => { + assert_eq!(description, "Test description"); + assert!(metadata.contains_key("key_outcome")); + } + _ => panic!("Expected Full detail for recent node"), + } + } + + #[test] + fn test_decay_summary_older_node() { + let config = DecayConfig::default(); + let node = create_test_node("n2", 20, Some(15)); // completed 15 days ago + let decayed = decay_node(&node, Utc::now(), &config); + + assert_eq!(decayed.id, "n2"); + assert_eq!(decayed.status, NodeStatus::Completed); + + match decayed.detail { + DecayDetail::Summary { key_outcome } => { + assert_eq!(key_outcome, Some("Important result".to_string())); + } + _ => panic!("Expected Summary detail for older node"), + } + } + + #[test] + fn test_decay_minimal_very_old_node() { + let config = DecayConfig::default(); + let node = create_test_node("n3", 50, Some(45)); // completed 45 days ago + let decayed = decay_node(&node, Utc::now(), &config); + + assert_eq!(decayed.id, "n3"); + assert_eq!(decayed.status, NodeStatus::Completed); + + match decayed.detail { + DecayDetail::Minimal => { + // Expected + } + _ => panic!("Expected Minimal detail for very old node"), + } + } + + #[test] + fn test_decay_custom_config() { + let config = DecayConfig { + recent_days: 3, + older_days: 10, + }; + + let node = create_test_node("n4", 10, Some(5)); // completed 5 days ago + let decayed = decay_node(&node, Utc::now(), &config); + + // With custom config, 5 days ago is between 3 and 10, so should be Summary + match decayed.detail { + DecayDetail::Summary { .. } => { + // Expected + } + _ => panic!("Expected Summary detail with custom config"), + } + } + + #[test] + fn test_decay_multiple_nodes() { + let config = DecayConfig::default(); + let nodes = vec![ + create_test_node("n1", 10, Some(2)), // Full + create_test_node("n2", 20, Some(15)), // Summary + create_test_node("n3", 50, Some(45)), // Minimal + ]; + + let decayed = decay_nodes(&nodes, Utc::now(), &config); + + assert_eq!(decayed.len(), 3); + assert!(matches!(decayed[0].detail, DecayDetail::Full { .. })); + assert!(matches!(decayed[1].detail, DecayDetail::Summary { .. })); + assert!(matches!(decayed[2].detail, DecayDetail::Minimal)); + } + + #[test] + fn test_decay_uses_completed_time_if_available() { + let config = DecayConfig::default(); + let now = Utc::now(); + + // Node created 100 days ago but completed 2 days ago should be Full + let node = create_test_node("n5", 100, Some(2)); + let decayed = decay_node(&node, now, &config); + + match decayed.detail { + DecayDetail::Full { .. } => { + // Expected - uses completed_at (2 days old) + } + _ => panic!("Should use completed_at for age calculation"), + } + } + + #[test] + fn test_decay_node_without_completion() { + let config = DecayConfig::default(); + let mut node = create_test_node("n6", 2, None); // No completion time + node.status = NodeStatus::InProgress; + + let decayed = decay_node(&node, Utc::now(), &config); + + // Should use created_at - 2 days old, so Full + match decayed.detail { + DecayDetail::Full { .. } => { + // Expected + } + _ => panic!("Should use created_at when completed_at is None"), + } + } + + #[test] + fn test_summary_without_key_outcome() { + let config = DecayConfig::default(); + let mut node = create_test_node("n7", 10, Some(15)); + node.metadata.clear(); // Remove key_outcome + + let decayed = decay_node(&node, Utc::now(), &config); + + match decayed.detail { + DecayDetail::Summary { key_outcome } => { + assert_eq!(key_outcome, None); + } + _ => panic!("Expected Summary without key_outcome"), + } + } +} diff --git a/src/graph/mod.rs b/src/graph/mod.rs index 80316ce..da90bc1 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::str::FromStr; +pub mod decay; pub mod dependency; pub mod export; pub mod interchange; diff --git a/tests/decay_test.rs b/tests/decay_test.rs new file mode 100644 index 0000000..3926536 --- /dev/null +++ b/tests/decay_test.rs @@ -0,0 +1,276 @@ +//! Tests for node decay functionality (P1c.AC4.1 - P1c.AC4.4) + +use chrono::{Duration, Utc}; +use rustagent::graph::decay::{decay_node, decay_nodes, DecayConfig, DecayDetail}; +use rustagent::graph::{GraphNode, NodeStatus, NodeType}; +use std::collections::HashMap; + +fn create_test_node( + id: &str, + created_days_ago: i64, + completed_days_ago: Option, +) -> GraphNode { + let now = Utc::now(); + let created_at = now - Duration::days(created_days_ago); + let completed_at = completed_days_ago.map(|d| now - Duration::days(d)); + + GraphNode { + id: id.to_string(), + project_id: "proj-test".to_string(), + node_type: NodeType::Task, + title: format!("Task {}", id), + description: "Test description with important details".to_string(), + status: NodeStatus::Completed, + priority: None, + assigned_to: None, + created_by: None, + labels: vec![], + created_at, + started_at: None, + completed_at, + blocked_reason: None, + metadata: { + let mut m = HashMap::new(); + m.insert("key_outcome".to_string(), "Important result".to_string()); + m.insert("context".to_string(), "Additional context".to_string()); + m + }, + } +} + +/// P1c.AC4.1: Nodes < 7 days old show Full detail +#[test] +fn test_full_detail_recent_node() { + let config = DecayConfig::default(); + let node = create_test_node("recent", 10, Some(2)); // completed 2 days ago + + let decayed = decay_node(&node, Utc::now(), &config); + + assert_eq!(decayed.id, "recent"); + assert_eq!(decayed.title, "Task recent"); + assert_eq!(decayed.status, NodeStatus::Completed); + + match decayed.detail { + DecayDetail::Full { + description, + metadata, + } => { + assert_eq!(description, "Test description with important details"); + assert_eq!(metadata.get("key_outcome"), Some(&"Important result".to_string())); + assert_eq!(metadata.get("context"), Some(&"Additional context".to_string())); + } + other => panic!("Expected Full detail for recent node, got {:?}", other), + } +} + +/// P1c.AC4.2: Nodes 7-30 days old show Summary only +#[test] +fn test_summary_detail_older_node() { + let config = DecayConfig::default(); + let node = create_test_node("older", 20, Some(15)); // completed 15 days ago + + let decayed = decay_node(&node, Utc::now(), &config); + + assert_eq!(decayed.id, "older"); + assert_eq!(decayed.title, "Task older"); + assert_eq!(decayed.status, NodeStatus::Completed); + + match decayed.detail { + DecayDetail::Summary { key_outcome } => { + assert_eq!(key_outcome, Some("Important result".to_string())); + } + other => panic!("Expected Summary detail for older node, got {:?}", other), + } +} + +/// P1c.AC4.3: Nodes > 30 days old show Minimal detail +#[test] +fn test_minimal_detail_very_old_node() { + let config = DecayConfig::default(); + let node = create_test_node("very_old", 50, Some(45)); // completed 45 days ago + + let decayed = decay_node(&node, Utc::now(), &config); + + assert_eq!(decayed.id, "very_old"); + assert_eq!(decayed.title, "Task very_old"); + assert_eq!(decayed.status, NodeStatus::Completed); + + match decayed.detail { + DecayDetail::Minimal => { + // Expected - no additional fields + } + other => panic!("Expected Minimal detail for very old node, got {:?}", other), + } +} + +/// P1c.AC4.4: Configurable thresholds work correctly +#[test] +fn test_configurable_thresholds() { + // Custom config: full up to 3 days, summary up to 10 days + let config = DecayConfig { + recent_days: 3, + older_days: 10, + }; + + let now = Utc::now(); + + // Node completed 2 days ago - should be Full with custom config + let node_recent = create_test_node("n_recent", 10, Some(2)); + let decayed_recent = decay_node(&node_recent, now, &config); + assert!( + matches!(decayed_recent.detail, DecayDetail::Full { .. }), + "2 days old should be Full with recent_days=3" + ); + + // Node completed 5 days ago - should be Summary with custom config + let node_mid = create_test_node("n_mid", 10, Some(5)); + let decayed_mid = decay_node(&node_mid, now, &config); + assert!( + matches!(decayed_mid.detail, DecayDetail::Summary { .. }), + "5 days old should be Summary with recent_days=3, older_days=10" + ); + + // Node completed 12 days ago - should be Minimal with custom config + let node_old = create_test_node("n_old", 15, Some(12)); + let decayed_old = decay_node(&node_old, now, &config); + assert!( + matches!(decayed_old.detail, DecayDetail::Minimal), + "12 days old should be Minimal with older_days=10" + ); +} + +/// Test decay_nodes applies decay to multiple nodes +#[test] +fn test_decay_multiple_nodes() { + let config = DecayConfig::default(); + let nodes = vec![ + create_test_node("n1", 10, Some(2)), // Full + create_test_node("n2", 20, Some(15)), // Summary + create_test_node("n3", 50, Some(45)), // Minimal + ]; + + let decayed = decay_nodes(&nodes, Utc::now(), &config); + + assert_eq!(decayed.len(), 3); + + assert!(matches!(decayed[0].detail, DecayDetail::Full { .. })); + assert_eq!(decayed[0].id, "n1"); + + assert!(matches!(decayed[1].detail, DecayDetail::Summary { .. })); + assert_eq!(decayed[1].id, "n2"); + + assert!(matches!(decayed[2].detail, DecayDetail::Minimal)); + assert_eq!(decayed[2].id, "n3"); +} + +/// Test that completed_at is preferred over created_at for age calculation +#[test] +fn test_uses_completed_time_for_age() { + let config = DecayConfig::default(); + let now = Utc::now(); + + // Node created 100 days ago but completed 2 days ago should be Full + let node = create_test_node("old_created", 100, Some(2)); + let decayed = decay_node(&node, now, &config); + + match decayed.detail { + DecayDetail::Full { .. } => { + // Expected - uses completed_at (2 days old), not created_at (100 days old) + } + other => panic!( + "Should use completed_at for age: got {:?}", + other + ), + } +} + +/// Test decay of node without completion time +#[test] +fn test_decay_node_without_completion() { + let config = DecayConfig::default(); + let mut node = create_test_node("in_progress", 2, None); + node.status = NodeStatus::InProgress; + + let decayed = decay_node(&node, Utc::now(), &config); + + // Should use created_at - 2 days old, so Full + match decayed.detail { + DecayDetail::Full { .. } => { + // Expected - uses created_at when completed_at is None + } + other => panic!("Should use created_at when completed_at is None: got {:?}", other), + } +} + +/// Test summary without key_outcome in metadata +#[test] +fn test_summary_without_key_outcome() { + let config = DecayConfig::default(); + let mut node = create_test_node("no_outcome", 10, Some(15)); + node.metadata.remove("key_outcome"); + + let decayed = decay_node(&node, Utc::now(), &config); + + match decayed.detail { + DecayDetail::Summary { key_outcome } => { + assert_eq!(key_outcome, None); + } + other => panic!("Expected Summary without key_outcome: got {:?}", other), + } +} + +/// Test boundary condition at exact threshold (7 days = Summary) +#[test] +fn test_boundary_recent_to_summary() { + let config = DecayConfig::default(); + let now = Utc::now(); + + // Over 7 days ago should transition to Summary (not Full) + let node = create_test_node("boundary_7", 10, Some(8)); + let decayed = decay_node(&node, now, &config); + + assert!( + matches!(decayed.detail, DecayDetail::Summary { .. }), + "Node over 7 days old should be Summary" + ); +} + +/// Test boundary condition at exact threshold (30 days = Minimal) +#[test] +fn test_boundary_summary_to_minimal() { + let config = DecayConfig::default(); + let now = Utc::now(); + + // Over 30 days ago should be Minimal + let node = create_test_node("boundary_30", 40, Some(31)); + let decayed = decay_node(&node, now, &config); + + assert!( + matches!(decayed.detail, DecayDetail::Minimal), + "Node over 30 days old should be Minimal" + ); +} + +/// Test that all metadata fields are preserved in Full detail +#[test] +fn test_full_detail_preserves_all_metadata() { + let config = DecayConfig::default(); + let mut node = create_test_node("full_meta", 10, Some(1)); + + node.metadata.insert("custom_field".to_string(), "custom_value".to_string()); + node.metadata.insert("tags".to_string(), "a,b,c".to_string()); + + let decayed = decay_node(&node, Utc::now(), &config); + + match decayed.detail { + DecayDetail::Full { + description: _, + metadata, + } => { + assert_eq!(metadata.len(), 4); + assert_eq!(metadata.get("custom_field"), Some(&"custom_value".to_string())); + assert_eq!(metadata.get("tags"), Some(&"a,b,c".to_string())); + } + other => panic!("Expected Full detail with all metadata: got {:?}", other), + } +} -- 2.51.2