Skip to main content

eidos_kernel/
trigram.rs

1//! Trigram inverted index — the hippocampus (fast pattern separation).
2//!
3//! Inspired by Google Code Search (Russ Cox, 2012): build a posting list of trigrams
4//! (3-character substrings) at index time. At query time, extract trigrams from query terms,
5//! intersect posting lists to narrow candidates from O(all_nodes) to O(matching_nodes).
6//!
7//! ## How it works
8//!
9//! Each node's haystack (id + title + summary + aliases + query_examples, lowercased) is split
10//! into overlapping 3-char trigrams. A `HashMap<Trigram, RoaringBitSet<NodeIndex>>` maps each
11//! trigram to the set of nodes that contain it.
12//!
13//! At query time: each stemmed query term produces a set of trigrams. The intersection of
14//! posting lists for those trigrams gives the set of nodes whose haystack contains that term
15//! (as a substring, matching the existing `haystack.contains(term)` semantics exactly).
16//!
17//! ## Eval-byte-identical guarantee
18//!
19//! The trigram index only NARROWS the candidate set — it doesn't change which nodes get scored.
20//! A node is a candidate if and only if its haystack contains at least one query term as a
21//! substring. The trigram intersection is a necessary-but-not-sufficient condition: if a node's
22//! haystack contains a term, it necessarily contains all the term's trigrams. So the index never
23//! misses a real candidate (no false negatives), and the final `contains()` check eliminates
24//! false positives (nodes that have the trigrams but not the actual term).
25//!
26//! Result: identical scoring, dramatically less work on large graphs.
27
28use std::collections::HashMap;
29
30/// A trigram: a 3-byte substring, stored as a packed u32 for cheap hashing.
31type Trigram = u32;
32
33/// The trigram inverted index: maps each trigram to the set of node indices that contain it.
34///
35/// Built once at `GroundIndex::build` time. Query-side: for each query term, extract its
36/// trigrams, intersect posting lists → candidate set. The candidate set is a superset of
37/// the actual matches (trigrams are necessary but not sufficient); the final `contains()`
38/// check in the scoring loop filters false positives.
39///
40/// As of D0.6 the indexing side is dormant: `GroundIndex::build` no longer constructs one
41/// (scoring.rs:48-50 commented out the call site — see Sprint D0.6). `Default::default()`
42/// gives an empty index for callers that need one; `build()` is preserved for the D3
43/// re-enable and unit tests that round-trip the original behavior. `candidates()` is
44/// therefore safe to call only against a populated index; today the scoring loop in
45/// `ground_scoped` (retrieval.rs:835-841) bypasses it and falls through to the full scan.
46#[derive(Default)]
47pub struct TrigramIndex {
48    /// trigram → sorted list of node indices containing it
49    postings: HashMap<Trigram, Vec<usize>>,
50    /// total node count (for "match all" fallback when a term is < 3 chars)
51    node_count: usize,
52}
53
54impl Clone for TrigramIndex {
55    fn clone(&self) -> Self {
56        Self {
57            postings: self.postings.clone(),
58            node_count: self.node_count,
59        }
60    }
61}
62
63impl TrigramIndex {
64    /// Build the index from the per-node haystacks.
65    pub fn build(haystacks: &[String]) -> Self {
66        let mut postings: HashMap<Trigram, Vec<usize>> = HashMap::new();
67
68        for (node_idx, haystack) in haystacks.iter().enumerate() {
69            let lower = haystack.to_lowercase();
70            let bytes = lower.as_bytes();
71
72            // Extract all trigrams from this haystack
73            if bytes.len() < 3 {
74                // Short haystacks (< 3 chars) get the "match all" treatment
75                continue;
76            }
77
78            for window in bytes.windows(3) {
79                let tri = pack_trigram(window);
80                postings.entry(tri).or_default().push(node_idx);
81            }
82        }
83
84        // Dedup and sort each posting list (a node can contain the same trigram multiple times)
85        for indices in postings.values_mut() {
86            indices.sort_unstable();
87            indices.dedup();
88        }
89
90        TrigramIndex {
91            postings,
92            node_count: haystacks.len(),
93        }
94    }
95
96    /// Find candidate node indices for a query term.
97    ///
98    /// Returns the set of nodes whose haystack CONTAINS all trigrams of `term`.
99    /// This is a superset of nodes whose haystack `contains(term)` — the scoring loop
100    /// does the final `contains()` check.
101    ///
102    /// For terms shorter than 3 characters (no trigrams), returns None = "scan all"
103    /// (the fallback — short terms are rare and cheap).
104    pub fn candidates_for_term(&self, term: &str) -> Option<Vec<usize>> {
105        let lower = term.to_lowercase();
106        let bytes = lower.as_bytes();
107
108        if bytes.len() < 3 {
109            return None; // Too short for trigrams — scan all
110        }
111
112        // Extract trigrams from the term
113        let trigrams: Vec<Trigram> = bytes.windows(3).map(pack_trigram).collect();
114
115        if trigrams.is_empty() {
116            return None;
117        }
118
119        // Start with the first trigram's posting list, then intersect with subsequent ones
120        let mut candidates: Option<Vec<usize>> = None;
121
122        for tri in &trigrams {
123            let posting = match self.postings.get(tri) {
124                Some(p) => p,
125                None => return Some(Vec::new()), // This trigram doesn't exist anywhere → no candidates
126            };
127
128            candidates = Some(match candidates.take() {
129                None => posting.clone(),
130                Some(existing) => intersect_sorted(&existing, posting),
131            });
132
133            // Early exit: if the intersection is empty, no node contains all trigrams
134            if candidates.as_ref().is_none_or(std::vec::Vec::is_empty) {
135                return Some(Vec::new());
136            }
137        }
138
139        candidates
140    }
141
142    /// Find candidate node indices for ALL query terms: the UNION of per-term candidates.
143    ///
144    /// A node is a candidate if ANY query term's trigrams match (because the scoring loop
145    /// checks each term independently — a node that matches one term gets a non-zero score).
146    pub fn candidates(&self, terms: &[String]) -> Option<Vec<usize>> {
147        let mut union: Option<Vec<usize>> = None;
148
149        for term in terms {
150            match self.candidates_for_term(term) {
151                None => return None, // A short term → scan all (can't narrow)
152                Some(term_candidates) => {
153                    union = Some(match union.take() {
154                        None => term_candidates,
155                        Some(existing) => union_sorted(&existing, &term_candidates),
156                    });
157                }
158            }
159        }
160
161        union
162    }
163}
164
165/// Pack 3 bytes into a u32 for cheap hashing.
166fn pack_trigram(bytes: &[u8]) -> Trigram {
167    ((bytes[0] as u32) << 16) | ((bytes[1] as u32) << 8) | (bytes[2] as u32)
168}
169
170/// Intersect two sorted vectors, preserving order.
171fn intersect_sorted(a: &[usize], b: &[usize]) -> Vec<usize> {
172    let mut result = Vec::with_capacity(a.len().min(b.len()));
173    let (mut i, mut j) = (0, 0);
174    while i < a.len() && j < b.len() {
175        match a[i].cmp(&b[j]) {
176            std::cmp::Ordering::Equal => {
177                result.push(a[i]);
178                i += 1;
179                j += 1;
180            }
181            std::cmp::Ordering::Less => i += 1,
182            std::cmp::Ordering::Greater => j += 1,
183        }
184    }
185    result
186}
187
188/// Union two sorted vectors, preserving order and deduplicating.
189fn union_sorted(a: &[usize], b: &[usize]) -> Vec<usize> {
190    let mut result = Vec::with_capacity(a.len() + b.len());
191    let (mut i, mut j) = (0, 0);
192    while i < a.len() && j < b.len() {
193        match a[i].cmp(&b[j]) {
194            std::cmp::Ordering::Equal => {
195                result.push(a[i]);
196                i += 1;
197                j += 1;
198            }
199            std::cmp::Ordering::Less => {
200                result.push(a[i]);
201                i += 1;
202            }
203            std::cmp::Ordering::Greater => {
204                result.push(b[j]);
205                j += 1;
206            }
207        }
208    }
209    while i < a.len() {
210        result.push(a[i]);
211        i += 1;
212    }
213    while j < b.len() {
214        result.push(b[j]);
215        j += 1;
216    }
217    result
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn trigram_index_narrows_candidates() {
226        let haystacks = vec![
227            "retry policy backoff".to_string(),       // 0
228            "task scheduler queue".to_string(),       // 1
229            "dead letter queue handling".to_string(), // 2
230            "jitter exponential".to_string(),         // 3
231        ];
232
233        let index = TrigramIndex::build(&haystacks);
234
235        // "retry" → trigrams: ret, etr, try
236        let cands = index.candidates_for_term("retry").unwrap();
237        assert!(cands.contains(&0)); // haystack 0 contains "retry"
238        assert!(!cands.contains(&1)); // haystack 1 does not
239
240        // "scheduler" → trigrams: sch, che, edu, dul, ula, lat, ato, tor
241        let cands = index.candidates_for_term("scheduler").unwrap();
242        assert!(cands.contains(&1));
243        assert!(!cands.contains(&0));
244
245        // Short term (< 3 chars) → None = scan all
246        assert_eq!(index.candidates_for_term("go"), None);
247    }
248
249    #[test]
250    fn trigram_index_candidates_for_multiple_terms() {
251        let haystacks = vec![
252            "retry policy backoff".to_string(),
253            "task scheduler queue".to_string(),
254            "dead letter queue".to_string(),
255        ];
256
257        let index = TrigramIndex::build(&haystacks);
258
259        // Query "retry queue" → nodes matching EITHER term
260        let cands = index.candidates(&["retry".to_string(), "queue".to_string()]);
261        if let Some(cands) = cands {
262            assert!(cands.contains(&0)); // has "retry"
263            assert!(cands.contains(&1)); // has "queue"
264            assert!(cands.contains(&2)); // has "queue"
265        } else {
266            panic!("expected candidates");
267        }
268    }
269
270    #[test]
271    fn nonexistent_term_returns_empty() {
272        let haystacks = vec!["hello world".to_string()];
273        let index = TrigramIndex::build(&haystacks);
274
275        let cands = index.candidates_for_term("zzzzz").unwrap();
276        assert!(cands.is_empty());
277    }
278}