codemem_engine/scoring.rs
1//! Hybrid scoring for memory recall.
2
3use crate::bm25;
4use chrono::{DateTime, Utc};
5use codemem_core::{GraphBackend, MemoryNode, ScoreBreakdown};
6
7// ── Code-graph sub-weights for `graph_strength_for_memory` ──────────────────
8
9/// Weight for PageRank in code-graph scoring.
10const CODE_GRAPH_W_PAGERANK: f64 = 0.4;
11/// Weight for betweenness centrality in code-graph scoring.
12const CODE_GRAPH_W_BETWEENNESS: f64 = 0.3;
13/// Weight for connectivity (degree / 5.0) in code-graph scoring.
14const CODE_GRAPH_W_CONNECTIVITY: f64 = 0.2;
15/// Weight for average edge weight in code-graph scoring.
16const CODE_GRAPH_W_EDGE_WEIGHT: f64 = 0.1;
17
18// ── Temporal decay constants ────────────────────────────────────────────────
19
20/// Half-life for temporal decay (hours). Updated-at age decays over 30 days.
21const TEMPORAL_DECAY_HOURS: f64 = 30.0 * 24.0;
22/// Half-life for recency decay (hours). Last-access age decays over 7 days.
23const RECENCY_DECAY_HOURS: f64 = 7.0 * 24.0;
24
25/// Compute graph strength for a memory node by combining raw graph metrics.
26///
27/// Blends two signal sources:
28/// - **Code neighbors** (`sym:`, `file:`, etc.): PageRank, betweenness, connectivity, edge weight
29/// - **Memory neighbors** (other memories linked via SHARES_THEME, PRECEDED_BY, etc.):
30/// connectivity count and average edge weight
31///
32/// A function with many linked memories ranks higher. A conversational memory
33/// connected to many session peers ranks higher than an isolated one.
34pub fn graph_strength_for_memory(graph: &dyn GraphBackend, memory_id: &str) -> f64 {
35 let metrics = match graph.raw_graph_metrics_for_memory(memory_id) {
36 Some(m) => m,
37 None => return 0.0,
38 };
39
40 // Code-graph component (0.0-1.0): centrality of linked code nodes
41 let code_score = if metrics.code_neighbor_count > 0 {
42 let connectivity = (metrics.code_neighbor_count as f64 / 5.0).min(1.0);
43 let avg_edge_w = (metrics.total_edge_weight / metrics.code_neighbor_count as f64).min(1.0);
44 CODE_GRAPH_W_PAGERANK * metrics.max_pagerank
45 + CODE_GRAPH_W_BETWEENNESS * metrics.max_betweenness
46 + CODE_GRAPH_W_CONNECTIVITY * connectivity
47 + CODE_GRAPH_W_EDGE_WEIGHT * avg_edge_w
48 } else {
49 0.0
50 };
51
52 // Memory-graph component (0.0-1.0): connectivity to other memories
53 let memory_score = if metrics.memory_neighbor_count > 0 {
54 let connectivity = (metrics.memory_neighbor_count as f64 / 10.0).min(1.0);
55 let avg_edge_w =
56 (metrics.memory_edge_weight / metrics.memory_neighbor_count as f64).min(1.0);
57 0.6 * connectivity + 0.4 * avg_edge_w
58 } else {
59 0.0
60 };
61
62 // Blend: when both exist, code neighbors dominate (70/30).
63 // When only one exists, use it fully.
64 let score = match (
65 metrics.code_neighbor_count > 0,
66 metrics.memory_neighbor_count > 0,
67 ) {
68 (true, true) => 0.7 * code_score + 0.3 * memory_score,
69 (true, false) => code_score,
70 (false, true) => memory_score,
71 (false, false) => 0.0,
72 };
73
74 score.min(1.0)
75}
76
77/// Re-export the canonical truncate from codemem-core.
78pub use codemem_core::truncate as truncate_content;
79
80/// Compute 9-component hybrid score for a memory against a query.
81/// The `graph` parameter is used to look up edge counts for graph strength scoring.
82/// The `bm25` parameter provides BM25-based token overlap scoring; if the memory
83/// is in the index it uses the indexed score, otherwise falls back to `score_text`.
84/// The `now` parameter makes scoring deterministic and testable by avoiding internal clock reads.
85pub fn compute_score(
86 memory: &MemoryNode,
87 query_tokens: &[&str],
88 vector_similarity: f64,
89 graph: &dyn GraphBackend,
90 bm25: &bm25::Bm25Index,
91 now: DateTime<Utc>,
92) -> ScoreBreakdown {
93 // BM25 token overlap (replaces naive split+intersect)
94 // Use pre-tokenized query tokens to avoid re-tokenizing per document.
95 let token_overlap = if query_tokens.is_empty() {
96 0.0
97 } else {
98 // Try indexed score first (memory already in the BM25 index),
99 // fall back to scoring against raw text for unindexed documents.
100 let indexed_score = bm25.score_with_tokens_str(query_tokens, &memory.id);
101 if indexed_score > 0.0 {
102 indexed_score
103 } else {
104 bm25.score_text_with_tokens_str(query_tokens, &memory.content)
105 }
106 };
107
108 // Temporal: how recently updated (exponential decay over 30 days)
109 let age_hours = (now - memory.updated_at).num_hours().max(0) as f64;
110 let temporal = (-age_hours / TEMPORAL_DECAY_HOURS).exp();
111
112 // Tag matching: fraction of query tokens found in tags.
113 // Per-memory `tags.join().to_lowercase()` is O(tags) which is typically <10 strings,
114 // so allocation is negligible.
115 let tag_matching = if !query_tokens.is_empty() {
116 let tag_str: String = memory.tags.join(" ").to_lowercase();
117 let matches = query_tokens
118 .iter()
119 .filter(|qt| tag_str.contains(**qt))
120 .count();
121 matches as f64 / query_tokens.len() as f64
122 } else {
123 0.0
124 };
125
126 // Recency: based on last access time (decay over 7 days)
127 let access_hours = (now - memory.last_accessed_at).num_hours().max(0) as f64;
128 let recency = (-access_hours / RECENCY_DECAY_HOURS).exp();
129
130 // Enhanced graph scoring: bridge memory UUIDs to code-graph centrality.
131 // Memory nodes live in a separate ID space from code nodes (sym:, file:),
132 // so we collect raw metrics from code-graph neighbors and apply the
133 // scoring formula here in the engine.
134 let graph_strength = graph_strength_for_memory(graph, &memory.id);
135
136 ScoreBreakdown {
137 vector_similarity,
138 graph_strength,
139 token_overlap,
140 temporal,
141 tag_matching,
142 importance: memory.importance,
143 confidence: memory.confidence,
144 recency,
145 }
146}
147
148#[cfg(test)]
149#[path = "tests/scoring_tests.rs"]
150mod tests;