Skip to main content

llm_kernel/graph/
recall.rs

1//! Smart recall with composite scoring and graph boost.
2
3use std::collections::HashSet;
4
5use rusqlite::Connection;
6
7use crate::error::{KernelError, Result};
8
9use super::algo::{CsrGraph, pagerank_default};
10use super::lifecycle::{parse_iso_to_secs, touch_nodes};
11use super::search::search_nodes;
12use super::store::edges_among;
13use super::types::{NODE_COLUMNS, ScoredNode, escape_like};
14
15/// Weight applied to recency in the composite relevance score.
16pub const W_RECENCY: f64 = 0.20;
17/// Weight applied to node importance in the composite relevance score.
18pub const W_IMPORTANCE: f64 = 0.35;
19/// Weight applied to access frequency in the composite relevance score.
20pub const W_ACCESS: f64 = 0.15;
21/// Weight applied to FTS (full-text search) rank in the composite relevance score.
22pub const W_FTS: f64 = 0.20;
23/// Weight applied to graph-neighbor boost in the composite relevance score.
24pub const W_GRAPH: f64 = 0.10;
25
26/// Smart recall: return nodes ranked by composite relevance.
27///
28/// Scoring: `recency(20%) + importance(35%) + access_freq(15%) + FTS(20%) + graph_boost(10%)`
29///
30/// Stale nodes (tagged "stale") are excluded. Retrieved nodes have their access_count incremented.
31pub fn smart_recall(
32    conn: &Connection,
33    project: Option<&str>,
34    hint: Option<&str>,
35    limit: usize,
36) -> Result<Vec<ScoredNode>> {
37    let now_secs = std::time::SystemTime::now()
38        .duration_since(std::time::SystemTime::UNIX_EPOCH)
39        .unwrap_or_default()
40        .as_secs();
41
42    // Gather FTS matches if hint is provided
43    let fts_ids: HashSet<String> = if let Some(h) = hint {
44        if !h.is_empty() {
45            search_nodes(conn, h, limit * 4)?
46                .into_iter()
47                .map(|n| n.id.clone())
48                .collect()
49        } else {
50            Default::default()
51        }
52    } else {
53        Default::default()
54    };
55
56    // Fetch candidate nodes (broad set)
57    let candidate_limit = (limit * 4).max(40) as i64;
58    let mut conditions: Vec<&str> = vec!["',' || tags || ',' NOT LIKE '%,stale,%'"];
59    let mut param_vals: Vec<Box<dyn rusqlite::ToSql>> = vec![];
60    if let Some(p) = project {
61        conditions.push("(',' || projects || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
62        param_vals.push(Box::new(escape_like(p)));
63    }
64    let where_clause = format!("WHERE {}", conditions.join(" AND "));
65    let sql = format!(
66        "SELECT {NODE_COLUMNS} FROM nodes {where_clause}
67         ORDER BY importance DESC, updated DESC
68         LIMIT {candidate_limit}"
69    );
70
71    let mut stmt = conn
72        .prepare(&sql)
73        .map_err(|e| KernelError::Store(e.to_string()))?;
74    let refs: Vec<&dyn rusqlite::ToSql> = param_vals.iter().map(|b| b.as_ref()).collect();
75    let candidates: Vec<super::types::GraphNode> = stmt
76        .query_map(refs.as_slice(), super::types::row_to_node)
77        .map(|rows| rows.filter_map(|r| r.ok()).collect())
78        .unwrap_or_default();
79
80    // Score each candidate
81    let mut scored: Vec<ScoredNode> = candidates
82        .into_iter()
83        .map(|node| {
84            let recency = compute_recency(&node.updated, now_secs);
85            let importance = node.importance;
86            let access_freq = (node.access_count.max(0) as f64 / 20.0).min(1.0);
87            let fts_match = if fts_ids.contains(&node.id) { 1.0 } else { 0.0 };
88
89            let score = W_RECENCY * recency
90                + W_IMPORTANCE * importance
91                + W_ACCESS * access_freq
92                + W_FTS * fts_match;
93
94            ScoredNode { node, score }
95        })
96        .collect();
97
98    scored.sort_by(|a, b| {
99        b.score
100            .partial_cmp(&a.score)
101            .unwrap_or(std::cmp::Ordering::Equal)
102    });
103    scored.truncate(limit);
104
105    // Graph-boost pass: PageRank centrality over the induced subgraph of the
106    // top candidates. Replaces the former neighbor-weight-sum (an approximate
107    // degree centrality) with true PageRank — strong connectors rise, dead
108    // ends sink. The pagerank math is backend-agnostic, so the SQLite and
109    // PostgreSQL recall paths share identical scoring (zero drift).
110    if scored.len() > 1 {
111        const MAX_GRAPH_BOOST_PARTICIPANTS: usize = 100;
112        let candidate_ids: Vec<String> = scored
113            .iter()
114            .take(MAX_GRAPH_BOOST_PARTICIPANTS)
115            .map(|sn| sn.node.id.clone())
116            .collect();
117        let id_refs: Vec<&str> = candidate_ids.iter().map(String::as_str).collect();
118        let sub_edges = edges_among(conn, &id_refs).unwrap_or_default();
119        let csr = CsrGraph::from_edges(&candidate_ids, &sub_edges);
120        let pr = pagerank_default(&csr);
121        let max_pr = pr.iter().copied().fold(0.0_f64, f64::max).max(1e-12);
122        let pr_map: std::collections::HashMap<String, f64> = candidate_ids
123            .iter()
124            .zip(pr.iter())
125            .map(|(id, &s)| (id.clone(), s / max_pr))
126            .collect();
127        for sn in &mut scored {
128            let boost = pr_map.get(&sn.node.id).copied().unwrap_or(0.0);
129            sn.score += W_GRAPH * boost;
130        }
131        scored.sort_by(|a, b| {
132            b.score
133                .partial_cmp(&a.score)
134                .unwrap_or(std::cmp::Ordering::Equal)
135        });
136    }
137
138    // Touch retrieved nodes
139    let ids: Vec<String> = scored.iter().map(|sn| sn.node.id.clone()).collect();
140    touch_nodes(conn, &ids);
141
142    Ok(scored)
143}
144
145/// Compute recency score (0.0–1.0) with exponential decay, half-life = 30 days.
146///
147/// Exposed so non-SQLite backends (e.g. the `graph-pg` PostgreSQL backend at
148/// `src/graph/pg.rs`) can score candidates with identical recency math — no
149/// drift across backends.
150pub fn compute_recency(updated: &str, now_secs: u64) -> f64 {
151    let node_secs = parse_iso_to_secs(updated);
152    if node_secs == 0 || node_secs > now_secs {
153        return 0.5;
154    }
155    let age_days = (now_secs - node_secs) as f64 / 86400.0;
156    let half_life = 30.0;
157    (-age_days * (2.0_f64.ln()) / half_life).exp()
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::graph::schema::init_graph_schema;
164    use crate::graph::store::{append_edge, upsert_node};
165    use crate::graph::types::GraphEdge;
166    use rusqlite::Connection;
167
168    fn mem_db() -> Connection {
169        let conn = Connection::open_in_memory().unwrap();
170        init_graph_schema(&conn).unwrap();
171        conn
172    }
173
174    fn test_node(id: &str, importance: f64, tags: Vec<&str>) -> crate::graph::types::GraphNode {
175        crate::graph::types::GraphNode {
176            id: id.to_string(),
177            node_type: "concept".to_string(),
178            title: format!("Node {id}"),
179            body: String::new(),
180            tags: tags.into_iter().map(|s| s.to_string()).collect(),
181            projects: vec![],
182            agents: vec![],
183            created: "2026-01-01T00:00:00Z".to_string(),
184            updated: "2026-06-01T00:00:00Z".to_string(),
185            importance,
186            access_count: 0,
187            accessed_at: String::new(),
188        }
189    }
190
191    #[test]
192    fn recall_returns_nodes() {
193        let conn = mem_db();
194        upsert_node(&conn, &test_node("n1", 0.9, vec![])).unwrap();
195        upsert_node(&conn, &test_node("n2", 0.5, vec![])).unwrap();
196        let results = smart_recall(&conn, None, None, 10).unwrap();
197        assert_eq!(results.len(), 2);
198        // Higher importance first
199        assert_eq!(results[0].node.id, "n1");
200    }
201
202    #[test]
203    fn recall_filters_by_project() {
204        let conn = mem_db();
205        let mut n1 = test_node("n1", 0.7, vec![]);
206        n1.projects = vec!["myproj".to_string()];
207        upsert_node(&conn, &n1).unwrap();
208        upsert_node(&conn, &test_node("n2", 0.7, vec![])).unwrap();
209
210        let results = smart_recall(&conn, Some("myproj"), None, 10).unwrap();
211        assert_eq!(results.len(), 1);
212        assert_eq!(results[0].node.id, "n1");
213    }
214
215    #[test]
216    fn recall_with_hint_uses_fts() {
217        let conn = mem_db();
218        let mut n1 = test_node("n1", 0.5, vec![]);
219        n1.title = "Rust ownership model".to_string();
220        n1.body = "borrow checker rules".to_string();
221        upsert_node(&conn, &n1).unwrap();
222
223        let mut n2 = test_node("n2", 0.9, vec![]);
224        n2.title = "Python GIL".to_string();
225        upsert_node(&conn, &n2).unwrap();
226
227        let results = smart_recall(&conn, None, Some("Rust"), 10).unwrap();
228        // n1 should get FTS boost even though n2 has higher base importance
229        assert!(!results.is_empty());
230    }
231
232    #[test]
233    fn recall_excludes_stale() {
234        let conn = mem_db();
235        upsert_node(&conn, &test_node("n1", 0.9, vec!["stale"])).unwrap();
236        upsert_node(&conn, &test_node("n2", 0.5, vec![])).unwrap();
237        let results = smart_recall(&conn, None, None, 10).unwrap();
238        assert_eq!(results.len(), 1);
239        assert_eq!(results[0].node.id, "n2");
240    }
241
242    #[test]
243    fn recall_touches_access_count() {
244        let conn = mem_db();
245        upsert_node(&conn, &test_node("n1", 0.7, vec![])).unwrap();
246        smart_recall(&conn, None, None, 10).unwrap();
247        let node = crate::graph::store::read_node(&conn, "n1")
248            .unwrap()
249            .unwrap();
250        assert_eq!(node.access_count, 1);
251    }
252
253    #[test]
254    fn recall_graph_boost() {
255        let conn = mem_db();
256        upsert_node(&conn, &test_node("n1", 0.7, vec![])).unwrap();
257        upsert_node(&conn, &test_node("n2", 0.7, vec![])).unwrap();
258        append_edge(
259            &conn,
260            &GraphEdge {
261                id: "e1".into(),
262                source: "n1".into(),
263                target: "n2".into(),
264                relation: "related".into(),
265                weight: 1.0,
266                ts: "2026-01-01T00:00:00Z".into(),
267            },
268        )
269        .unwrap();
270
271        let results = smart_recall(&conn, None, None, 10).unwrap();
272        assert_eq!(results.len(), 2);
273        // With hint=None and identical recency/importance/access, every score
274        // component is equal across n1 and n2 EXCEPT the graph boost. n2 is a
275        // dangling sink (no out-edges) and so accrues higher PageRank than n1
276        // — its sole in-bound rank source — so the boost pass must rank n2
277        // above n1. This is the one assertion that exercises the boost's
278        // actual ranking effect (the pagerank math itself is unit-tested in
279        // algo/pagerank.rs).
280        let n1 = results.iter().find(|s| s.node.id == "n1").unwrap();
281        let n2 = results.iter().find(|s| s.node.id == "n2").unwrap();
282        assert!(
283            n2.score > n1.score,
284            "dangling sink n2 must outrank source n1 via PageRank boost"
285        );
286    }
287
288    #[test]
289    fn recall_project_wildcard_is_escaped() {
290        let conn = mem_db();
291        let mut n1 = test_node("n1", 0.7, vec![]);
292        n1.projects = vec!["myproj".to_string()];
293        upsert_node(&conn, &n1).unwrap();
294        // "my%" would match "myproj" as a LIKE wildcard, but escape_like prevents it
295        let results = smart_recall(&conn, Some("my%"), None, 10).unwrap();
296        assert!(results.is_empty());
297    }
298}