Skip to main content

llm_kernel/graph/
lifecycle.rs

1//! Node lifecycle: access tracking, importance decay, stale tagging, and stats.
2
3use std::collections::HashMap;
4
5use rusqlite::{Connection, params};
6
7use crate::error::{KernelError, Result};
8
9use super::types::GraphStats;
10
11// ── Access tracking ───────────────────────────────────
12
13/// Record an access event: increment access_count and update accessed_at.
14pub fn touch_node(conn: &Connection, id: &str) {
15    let now = now_iso();
16    let _ = conn.execute(
17        "UPDATE nodes SET access_count = access_count + 1, accessed_at = ?1 WHERE id = ?2",
18        params![now, id],
19    );
20}
21
22/// Batch-touch multiple nodes.
23pub fn touch_nodes(conn: &Connection, ids: &[String]) {
24    if ids.is_empty() {
25        return;
26    }
27    let _ = conn.execute_batch("SAVEPOINT touch_batch");
28    for id in ids {
29        touch_node(conn, id);
30    }
31    let _ = conn.execute_batch("RELEASE touch_batch");
32}
33
34// ── Importance decay ──────────────────────────────────
35
36/// Gradually decay importance for nodes not accessed in `days`.
37///
38/// Reduces importance by `factor` (e.g. 0.9 = 10% decay).
39/// Nodes with importance at or below `floor` are not decayed further.
40/// Nodes with the `pinned` tag are protected from decay.
41///
42/// Returns the number of nodes decayed.
43pub fn decay_importance(conn: &Connection, days: u64, factor: f64, floor: f64) -> Result<u64> {
44    let cutoff = compute_cutoff_timestamp(days);
45    let changed = conn
46        .execute(
47            "UPDATE nodes SET importance = MAX(?3, importance * ?2)
48             WHERE (accessed_at < ?1 OR accessed_at = '')
49               AND updated < ?1
50               AND importance > ?3
51               AND ',' || tags || ',' NOT LIKE '%,pinned,%'",
52            params![cutoff, factor, floor],
53        )
54        .map_err(|e| KernelError::Store(e.to_string()))?;
55    Ok(changed as u64)
56}
57
58/// Tag nodes not updated within `days` as stale by appending "stale" to tags.
59pub fn tag_stale_nodes(conn: &Connection, days: u64) -> Result<u64> {
60    let cutoff = compute_cutoff_timestamp(days);
61    let changed = conn
62        .execute(
63            "UPDATE nodes SET tags = CASE
64                WHEN tags = '' THEN 'stale'
65                WHEN ',' || tags || ',' NOT LIKE '%,stale,%' THEN tags || ',stale'
66                ELSE tags
67             END
68             WHERE updated < ?1
69               AND ',' || tags || ',' NOT LIKE '%,stale,%'",
70            params![cutoff],
71        )
72        .map_err(|e| KernelError::Store(e.to_string()))?;
73    Ok(changed as u64)
74}
75
76// ── Temporal validity ─────────────────────────────────
77
78/// Record that a node's content was verified as of `now` (ISO 8601).
79///
80/// Returns `true` when the node existed and was updated.
81pub fn mark_verified(conn: &Connection, id: &str, now: &str) -> Result<bool> {
82    let changed = conn
83        .execute(
84            "UPDATE nodes SET last_verified = ?1 WHERE id = ?2",
85            params![now, id],
86        )
87        .map_err(|e| KernelError::Store(e.to_string()))?;
88    Ok(changed > 0)
89}
90
91/// Count nodes whose `valid_until` is set and earlier than `now` (ISO 8601).
92///
93/// String comparison is correct for same-format (zero-padded, UTC) ISO 8601
94/// timestamps — the same convention the rest of the graph uses for
95/// `created`/`updated`/`accessed_at`.
96pub fn count_expired_nodes(conn: &Connection, now: &str) -> Result<u64> {
97    let n: i64 = conn
98        .query_row(
99            "SELECT COUNT(*) FROM nodes WHERE valid_until <> '' AND valid_until < ?1",
100            params![now],
101            |r| r.get(0),
102        )
103        .map_err(|e| KernelError::Store(e.to_string()))?;
104    Ok(n as u64)
105}
106
107// ── Statistics ────────────────────────────────────────
108
109/// Compute aggregate statistics about the knowledge graph.
110pub fn compute_stats(conn: &Connection) -> Result<GraphStats> {
111    let total_nodes: i64 = conn
112        .query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))
113        .unwrap_or(0);
114    let total_edges: i64 = conn
115        .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))
116        .unwrap_or(0);
117    let avg_importance: f64 = conn
118        .query_row("SELECT AVG(importance) FROM nodes", [], |r| r.get(0))
119        .unwrap_or(0.0);
120
121    let mut stmt = conn
122        .prepare("SELECT type, COUNT(*) FROM nodes GROUP BY type")
123        .map_err(|e| KernelError::Store(e.to_string()))?;
124    let by_type: HashMap<String, i64> = stmt
125        .query_map([], |row| {
126            let t: String = row.get(0)?;
127            let c: i64 = row.get(1)?;
128            Ok((t, c))
129        })
130        .map(|rows| rows.flatten().collect())
131        .unwrap_or_default();
132
133    Ok(GraphStats {
134        total_nodes,
135        total_edges,
136        avg_importance: (avg_importance * 100.0).round() / 100.0,
137        by_type,
138    })
139}
140
141// ── Timestamp helpers ─────────────────────────────────
142
143fn compute_cutoff_timestamp(days: u64) -> String {
144    let secs = std::time::SystemTime::now()
145        .duration_since(std::time::SystemTime::UNIX_EPOCH)
146        .unwrap_or_default()
147        .as_secs()
148        .saturating_sub(days * 86400);
149    let (y, m, d) = days_to_ymd(secs / 86400);
150    let hh = (secs / 3600) % 24;
151    let mm = (secs / 60) % 60;
152    let ss = secs % 60;
153    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
154}
155
156pub(crate) fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
157    let mut year = 1970u64;
158    loop {
159        let leap = is_leap(year);
160        let days_in_year = if leap { 366 } else { 365 };
161        if days < days_in_year {
162            break;
163        }
164        days -= days_in_year;
165        year += 1;
166    }
167    let leap = is_leap(year);
168    let month_days = [
169        31u64,
170        if leap { 29 } else { 28 },
171        31,
172        30,
173        31,
174        30,
175        31,
176        31,
177        30,
178        31,
179        30,
180        31,
181    ];
182    let mut month = 1u64;
183    for md in &month_days {
184        if days < *md {
185            break;
186        }
187        days -= md;
188        month += 1;
189    }
190    (year, month, days + 1)
191}
192
193fn is_leap(y: u64) -> bool {
194    (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
195}
196
197pub(crate) fn now_iso() -> String {
198    let secs = std::time::SystemTime::now()
199        .duration_since(std::time::SystemTime::UNIX_EPOCH)
200        .unwrap_or_default()
201        .as_secs();
202    let (y, m, d) = days_to_ymd(secs / 86400);
203    let hh = (secs / 3600) % 24;
204    let mm = (secs / 60) % 60;
205    let ss = secs % 60;
206    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
207}
208
209/// Parse ISO 8601 timestamp to seconds since epoch (best-effort).
210pub(crate) fn parse_iso_to_secs(ts: &str) -> u64 {
211    if ts.len() < 19 {
212        return 0;
213    }
214    let year: u64 = ts[0..4].parse().unwrap_or(0);
215    let month: u64 = ts[5..7].parse().unwrap_or(1);
216    let day: u64 = ts[8..10].parse().unwrap_or(1);
217    let hour: u64 = ts[11..13].parse().unwrap_or(0);
218    let min: u64 = ts[14..16].parse().unwrap_or(0);
219    let sec: u64 = ts[17..19].parse().unwrap_or(0);
220
221    let total_days = days_since_epoch(year, month, day);
222    total_days * 86400 + hour * 3600 + min * 60 + sec
223}
224
225fn days_since_epoch(year: u64, month: u64, day: u64) -> u64 {
226    let y = year as i64 - 1;
227    let base = 1969i64;
228    let leaps = (y / 4 - y / 100 + y / 400) - (base / 4 - base / 100 + base / 400);
229    let days_from_years = (year as i64 - 1970) * 365 + leaps;
230
231    const MONTH_DAYS: [u64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
232    let leap = (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
233    let mut days_from_months: u64 = 0;
234    let prior_months = (month.saturating_sub(1) as usize).min(12);
235    for (m, &md) in MONTH_DAYS.iter().enumerate().take(prior_months) {
236        days_from_months += md;
237        if m == 1 && leap {
238            days_from_months += 1;
239        }
240    }
241    (days_from_years as u64) + days_from_months + day.saturating_sub(1)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::graph::schema::init_graph_schema;
248    use crate::graph::store::upsert_node;
249    use crate::graph::types::GraphNode;
250    use rusqlite::Connection;
251
252    fn mem_db() -> Connection {
253        let conn = Connection::open_in_memory().unwrap();
254        init_graph_schema(&conn).unwrap();
255        conn
256    }
257
258    fn test_node(id: &str, importance: f64, tags: Vec<&str>) -> GraphNode {
259        GraphNode {
260            id: id.to_string(),
261            node_type: "concept".to_string(),
262            title: format!("Node {id}"),
263            body: String::new(),
264            tags: tags.into_iter().map(|s| s.to_string()).collect(),
265            projects: vec![],
266            agents: vec![],
267            created: "2026-01-01T00:00:00Z".to_string(),
268            updated: "2026-01-01T00:00:00Z".to_string(),
269            importance,
270            access_count: 0,
271            accessed_at: String::new(),
272            ..Default::default()
273        }
274    }
275
276    #[test]
277    fn mark_verified_sets_timestamp() {
278        let conn = mem_db();
279        upsert_node(&conn, &test_node("n1", 0.7, vec![])).unwrap();
280        assert!(mark_verified(&conn, "n1", "2026-08-18T00:00:00Z").unwrap());
281        let node = crate::graph::store::read_node(&conn, "n1")
282            .unwrap()
283            .unwrap();
284        assert_eq!(node.last_verified, "2026-08-18T00:00:00Z");
285        assert!(!mark_verified(&conn, "missing", "2026-08-18T00:00:00Z").unwrap());
286    }
287
288    #[test]
289    fn count_expired_nodes_only_counts_set_and_past() {
290        let conn = mem_db();
291        let mut expired = test_node("expired", 0.7, vec![]);
292        expired.valid_until = "2026-01-01T00:00:00Z".to_string();
293        let mut future = test_node("future", 0.7, vec![]);
294        future.valid_until = "2030-01-01T00:00:00Z".to_string();
295        upsert_node(&conn, &expired).unwrap();
296        upsert_node(&conn, &future).unwrap();
297        upsert_node(&conn, &test_node("unset", 0.7, vec![])).unwrap();
298        assert_eq!(
299            count_expired_nodes(&conn, "2026-08-18T00:00:00Z").unwrap(),
300            1
301        );
302    }
303
304    #[test]
305    fn touch_node_increments_count() {
306        let conn = mem_db();
307        upsert_node(&conn, &test_node("n1", 0.7, vec![])).unwrap();
308        touch_node(&conn, "n1");
309        touch_node(&conn, "n1");
310        let node = crate::graph::store::read_node(&conn, "n1")
311            .unwrap()
312            .unwrap();
313        assert_eq!(node.access_count, 2);
314        assert!(!node.accessed_at.is_empty());
315    }
316
317    #[test]
318    fn decay_reduces_importance() {
319        let conn = mem_db();
320        // Node updated 60 days ago → should decay
321        upsert_node(&conn, &test_node("n1", 0.8, vec![])).unwrap();
322        let changed = decay_importance(&conn, 30, 0.9, 0.05).unwrap();
323        assert!(changed > 0);
324        let node = crate::graph::store::read_node(&conn, "n1")
325            .unwrap()
326            .unwrap();
327        assert!(node.importance < 0.8);
328    }
329
330    #[test]
331    fn decay_skips_pinned() {
332        let conn = mem_db();
333        upsert_node(&conn, &test_node("n1", 0.9, vec!["pinned"])).unwrap();
334        let changed = decay_importance(&conn, 30, 0.9, 0.05).unwrap();
335        assert_eq!(changed, 0);
336    }
337
338    #[test]
339    fn tag_stale_marks_old_nodes() {
340        let conn = mem_db();
341        upsert_node(&conn, &test_node("n1", 0.5, vec![])).unwrap();
342        let changed = tag_stale_nodes(&conn, 30).unwrap();
343        assert!(changed > 0);
344        let node = crate::graph::store::read_node(&conn, "n1")
345            .unwrap()
346            .unwrap();
347        assert!(node.tags.contains(&"stale".to_string()));
348    }
349
350    #[test]
351    fn compute_stats_returns_counts() {
352        let conn = mem_db();
353        let mut n1 = test_node("n1", 0.7, vec![]);
354        n1.node_type = "decision".to_string();
355        upsert_node(&conn, &n1).unwrap();
356        upsert_node(&conn, &test_node("n2", 0.5, vec![])).unwrap();
357
358        let stats = compute_stats(&conn).unwrap();
359        assert_eq!(stats.total_nodes, 2);
360        assert_eq!(stats.total_edges, 0);
361        assert!(stats.by_type.contains_key("decision"));
362    }
363
364    #[test]
365    fn parse_iso_roundtrip() {
366        let secs = parse_iso_to_secs("2026-01-15T12:30:45Z");
367        assert!(secs > 0);
368    }
369}