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}