1use 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
15pub const W_RECENCY: f64 = 0.20;
17pub const W_IMPORTANCE: f64 = 0.35;
19pub const W_ACCESS: f64 = 0.15;
21pub const W_FTS: f64 = 0.20;
23pub const W_GRAPH: f64 = 0.10;
25
26pub 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 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 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 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 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 let ids: Vec<String> = scored.iter().map(|sn| sn.node.id.clone()).collect();
140 touch_nodes(conn, &ids);
141
142 Ok(scored)
143}
144
145pub 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 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 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 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 let results = smart_recall(&conn, Some("my%"), None, 10).unwrap();
296 assert!(results.is_empty());
297 }
298}