Skip to main content

microscope_memory/
relevance.rs

1//! Shared lexical-spatial ranking for every natural-language recall surface.
2//!
3//! The legacy ranker subtracted a fixed keyword boost from squared spatial
4//! distance and clamped the result at zero.  On real memories this collapsed
5//! many distinct candidates to the same score, making traversal order decide
6//! the final ranking.  This module keeps the score continuous and normalizes
7//! lexical evidence by query coverage.
8
9/// Parsed query reused while scoring all candidate memories.
10#[derive(Clone, Debug)]
11pub struct RelevanceQuery {
12    normalized: String,
13    tokens: Vec<String>,
14}
15
16impl RelevanceQuery {
17    pub fn new(query: &str) -> Self {
18        let normalized = normalize(query);
19        let mut tokens = Vec::new();
20        for token in normalized
21            .split_whitespace()
22            .filter(|token| token.len() > 2)
23        {
24            if !tokens.iter().any(|existing| existing == token) {
25                tokens.push(token.to_string());
26            }
27        }
28        Self { normalized, tokens }
29    }
30
31    pub fn is_empty(&self) -> bool {
32        self.tokens.is_empty()
33    }
34
35    /// The normalized query tokens (length > 2, deduplicated). Used by the
36    /// inverted text index to prefilter blocks before lexical scoring.
37    pub fn tokens(&self) -> &[String] {
38        &self.tokens
39    }
40
41    /// Lexical relevance in `[0, 1]`.
42    ///
43    /// Exact query-token coverage is dominant.  A conservative prefix match
44    /// helps inflected English and Hungarian words without allowing short,
45    /// noisy fragments to dominate.  Contiguous phrase matches break ties.
46    pub fn lexical_score(&self, text: &str) -> f32 {
47        if self.tokens.is_empty() {
48            return 0.0;
49        }
50
51        let lowercase_text = text.to_lowercase();
52        if lowercase_text.is_empty() {
53            return 0.0;
54        }
55
56        // The word list is built once per block, not once per query token.
57        //
58        // The original was `for query_token { text.split(..).map(similarity)
59        // .fold(max) }`, which re-walked and re-split the whole block for every
60        // query token. Blocks hold a whole document (16 KiB after the
61        // BLOCK_DATA_SIZE change) and the lexical prefilter admits ~4,300 of
62        // them, so an 8-token query spent its time re-tokenising 69 MB of text
63        // eight times over: roughly 86 million `token_similarity` calls and
64        // eight full scans per block.
65        //
66        // Tokenising once and then folding the query tokens over the same words
67        // visits exactly the same (query_token, word) pairs in the same order,
68        // so the maximum per token is unchanged. `split_once_per_block` is
69        // asserted against the original formulation in the tests below rather
70        // than assumed.
71        let words: Vec<&str> = lowercase_text
72            .split(|ch: char| !ch.is_alphanumeric())
73            .filter(|token| !token.is_empty())
74            .collect();
75
76        let mut matched = 0.0f32;
77        for query_token in &self.tokens {
78            let best = words
79                .iter()
80                .map(|text_token| token_similarity(query_token, text_token))
81                .fold(0.0f32, f32::max);
82            matched += best;
83        }
84
85        let coverage = matched / self.tokens.len() as f32;
86        let phrase =
87            (!self.normalized.is_empty() && lowercase_text.contains(&self.normalized)) as u8 as f32;
88        (coverage * 0.95 + phrase * 0.05).clamp(0.0, 1.0)
89    }
90
91    /// Continuous lower-is-better rank distance.
92    ///
93    /// Lexical coverage owns most of the rank, spatial distance resolves
94    /// semantically comparable candidates, and importance is deliberately a
95    /// small prior so it cannot rescue an unrelated memory.
96    pub fn rank_distance(
97        &self,
98        text: &str,
99        spatial_dist_sq: f32,
100        keyword_boost: f32,
101        importance: u8,
102    ) -> f32 {
103        let lexical = self.lexical_score(text);
104        rank_distance_from_score(lexical, spatial_dist_sq, keyword_boost, importance)
105    }
106}
107
108/// Lower-is-better rank distance for a lexical score already computed during
109/// candidate filtering.  Keeping this separate prevents normalizing every
110/// candidate twice on the hot recall path.
111#[inline]
112pub fn rank_distance_from_score(
113    lexical_score: f32,
114    spatial_dist_sq: f32,
115    keyword_boost: f32,
116    importance: u8,
117) -> f32 {
118    let lexical_weight = 0.8 + keyword_boost.clamp(0.0, 2.0);
119    let lexical_penalty = (1.0 - lexical_score.clamp(0.0, 1.0)) * lexical_weight;
120    let spatial_component = spatial_dist_sq.max(0.0) * 0.20;
121    let importance_prior = 1.0 + (importance.min(10) as f32 * 0.015);
122    (lexical_penalty + spatial_component) / importance_prior
123}
124
125/// Apply a positive cognitive boost without collapsing distinct ranks to zero.
126#[inline]
127pub fn apply_boost(distance: f32, boost: f32) -> f32 {
128    distance / (1.0 + boost.max(0.0))
129}
130
131/// Apply an evidence-confidence boost to the rank distance.
132///
133/// `confidence` is 0..100 (0 = no evidence). The boost is subtracted from
134/// the divisor, so higher confidence means lower distance (better rank).
135/// A confidence of 50 reduces the distance by ~5%; 100 by ~10%.
136///
137/// The boost is gentle — it cannot dominate lexical or spatial distance,
138/// it only orders memories with the same content by how much evidence
139/// stands behind them. C1: recall frequency never affects this.
140#[inline]
141pub fn apply_confidence_boost(distance: f32, confidence: u8) -> f32 {
142    distance / (1.0 + (confidence as f32 * 0.001))
143}
144
145fn normalize(text: &str) -> String {
146    let mut out = String::with_capacity(text.len());
147    let mut previous_was_space = true;
148    for ch in text.chars().flat_map(char::to_lowercase) {
149        if ch.is_alphanumeric() {
150            out.push(ch);
151            previous_was_space = false;
152        } else if !previous_was_space {
153            out.push(' ');
154            previous_was_space = true;
155        }
156    }
157    if out.ends_with(' ') {
158        out.pop();
159    }
160    out
161}
162
163fn token_similarity(query: &str, text: &str) -> f32 {
164    if query == text {
165        return 1.0;
166    }
167    let common_prefix = query
168        .chars()
169        .zip(text.chars())
170        .take_while(|(left, right)| left == right)
171        .count();
172    let shorter = query.chars().count().min(text.chars().count());
173    if shorter >= 5 && common_prefix >= 5 {
174        common_prefix as f32 / query.chars().count().max(text.chars().count()) as f32 * 0.72
175    } else {
176        0.0
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    /// The original `lexical_score`, kept verbatim as a reference oracle.
185    ///
186    /// The rewrite tokenises a block once instead of once per query token. That
187    /// is only legitimate if it produces the same number, and "it obviously
188    /// does" is exactly the kind of claim that should not be believed on a hot
189    /// path that decides ranking, so the old formulation lives here and the new
190    /// one is checked against it.
191    fn lexical_score_reference(q: &RelevanceQuery, text: &str) -> f32 {
192        if q.tokens.is_empty() {
193            return 0.0;
194        }
195        let lowercase_text = text.to_lowercase();
196        if lowercase_text.is_empty() {
197            return 0.0;
198        }
199        let mut matched = 0.0f32;
200        for query_token in &q.tokens {
201            let best = lowercase_text
202                .split(|ch: char| !ch.is_alphanumeric())
203                .filter(|token| !token.is_empty())
204                .map(|text_token| token_similarity(query_token, text_token))
205                .fold(0.0f32, f32::max);
206            matched += best;
207        }
208        let coverage = matched / q.tokens.len() as f32;
209        let phrase =
210            (!q.normalized.is_empty() && lowercase_text.contains(&q.normalized)) as u8 as f32;
211        (coverage * 0.95 + phrase * 0.05).clamp(0.0, 1.0)
212    }
213
214    #[test]
215    fn split_once_per_block_matches_the_per_token_split() {
216        // Deliberately awkward inputs: empty text, text with no word characters
217        // at all, repeated punctuation, accented characters, a query token that
218        // appears twice, and a phrase that does and does not occur.
219        let texts = [
220            "",
221            "   ",
222            "!!! ??? ---",
223            "the user has a cat named Bella",
224            "Bella, bella, BELLA; the-user/has\ta\tcat",
225            "visszakeresési minőség",
226            "A visszakeresési-minőség mérhető.",
227            "aBc DeF 123 x9  456",
228            "rebuild rollback rollback rollback",
229            "Transactional rebuild uses a rollback snapshot",
230        ];
231        let queries = [
232            "",
233            "cat",
234            "the user has a cat named Bella",
235            "rebuild rollback",
236            "visszakeresési minőség",
237            "123 x9",
238            "a",
239        ];
240        for q in &queries {
241            let query = RelevanceQuery::new(q);
242            for t in &texts {
243                let got = query.lexical_score(t);
244                let want = lexical_score_reference(&query, t);
245                assert!(
246                    (got - want).abs() < 1e-6,
247                    "query {q:?} text {t:?}: got {got}, reference {want}"
248                );
249            }
250        }
251    }
252
253    #[test]
254    fn complete_query_coverage_beats_partial_match() {
255        let query = RelevanceQuery::new("transactional rebuild rollback");
256        let complete = query.lexical_score("Transactional rebuild uses a rollback snapshot");
257        let partial = query.lexical_score("Rebuild completed successfully");
258        assert!(
259            complete > partial + 0.4,
260            "{complete} should dominate {partial}"
261        );
262    }
263
264    #[test]
265    fn unicode_and_punctuation_are_normalized() {
266        let query = RelevanceQuery::new("visszakeresési minőség");
267        assert!(query.lexical_score("A visszakeresési-minőség mérhető.") >= 0.95);
268    }
269
270    #[test]
271    fn continuous_distance_does_not_collapse_keyword_matches() {
272        let query = RelevanceQuery::new("octopus snapshot lifecycle");
273        let complete = query.rank_distance("Octopus snapshot lifecycle is durable", 0.5, 0.4, 5);
274        let partial = query.rank_distance("Octopus runtime", 0.01, 0.4, 5);
275        assert!(complete < partial, "complete={complete}, partial={partial}");
276        assert_ne!(complete, 0.0);
277        assert_ne!(partial, 0.0);
278    }
279
280    #[test]
281    fn boosts_preserve_existing_order() {
282        assert!(apply_boost(0.2, 0.5) < apply_boost(0.4, 0.5));
283    }
284}
285
286/// Build the block set that recall ranks, from the two independent candidate
287/// sources.
288///
289/// The inverted text index and the embedding index each produce a partial view:
290/// tokens for one, nearest neighbours for the other. Neither is a gate on the
291/// other. An earlier CLI implementation walked the depth ranges and *skipped*
292/// any block absent from the lexical candidate list, which silently discarded
293/// every vector hit that was not also a lexical hit -- and returned nothing at
294/// all when the lexical list came back empty, i.e. for exactly the paraphrases
295/// the embedding path is meant to answer.
296///
297/// Returns a sorted, deduplicated list; out-of-range ids are dropped so a stale
298/// or corrupt index cannot cause an out-of-bounds read.
299pub fn merge_candidates(
300    lexical: impl IntoIterator<Item = u32>,
301    semantic: impl IntoIterator<Item = usize>,
302    block_count: usize,
303) -> Vec<usize> {
304    let mut out: Vec<usize> = lexical
305        .into_iter()
306        .map(|c| c as usize)
307        .chain(semantic)
308        .filter(|&i| i < block_count)
309        .collect();
310    out.sort_unstable();
311    out.dedup();
312    out
313}
314
315#[cfg(test)]
316mod candidate_tests {
317    use super::merge_candidates;
318
319    #[test]
320    fn semantic_candidates_survive_without_any_lexical_overlap() {
321        // The regression: a paraphrase sharing no token with the target block.
322        // The lexical index contributes nothing; the vector hit must still rank.
323        let merged = merge_candidates(Vec::<u32>::new(), [7usize], 16);
324        assert_eq!(merged, vec![7]);
325    }
326
327    #[test]
328    fn lexical_and_semantic_are_unioned_and_deduplicated() {
329        let merged = merge_candidates([3u32, 1, 9], [9usize, 4, 1], 16);
330        assert_eq!(merged, vec![1, 3, 4, 9]);
331    }
332
333    #[test]
334    fn out_of_range_ids_are_dropped() {
335        // A corrupt or stale index must not produce an out-of-bounds access.
336        let merged = merge_candidates([2u32, 99], [50usize, 2], 16);
337        assert_eq!(merged, vec![2]);
338    }
339
340    #[test]
341    fn empty_sources_yield_no_candidates() {
342        assert!(merge_candidates(Vec::<u32>::new(), Vec::<usize>::new(), 16).is_empty());
343    }
344}