Skip to main content

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;