greplm_core/trigram.rs
1//! Trigram extraction and query decomposition.
2//!
3//! A trigram is a 3-byte sequence. The index maps each trigram to the set of
4//! documents that contain it. A query is satisfiable only in documents that
5//! contain *every* trigram of the query literal, so we can intersect posting
6//! lists to get a small candidate set before verifying with the real matcher.
7
8/// A trigram, stored big-endian so byte order matches numeric order (required
9/// for the FST term dictionary, whose keys must be lexicographically sorted).
10pub type Trigram = [u8; 3];
11
12/// One AND-group of trigrams: a document must contain every member.
13pub type TrigramGroup = Vec<Trigram>;
14
15/// A disjunction of AND-groups (disjunctive normal form): a document passes
16/// when at least one group is fully present.
17pub type TrigramDnf = Vec<TrigramGroup>;
18
19/// Encode a 3-byte window as a key. The scan itself rolls the key forward
20/// incrementally (see [`extract_keys`]), so this is only the reference form
21/// the tests check that rolling against.
22#[cfg(test)]
23pub(crate) fn key_of(w: &[u8]) -> u32 {
24 (u32::from(w[0]) << 16) | (u32::from(w[1]) << 8) | u32::from(w[2])
25}
26
27pub(crate) fn tri_of(key: u32) -> Trigram {
28 [(key >> 16) as u8, (key >> 8) as u8, key as u8]
29}
30
31std::thread_local! {
32 /// A 2^24-bit membership set (2 MiB) used to deduplicate trigram keys
33 /// *during* the scan. Reused across calls on the same thread; after each
34 /// use the set bits are cleared by replaying the distinct-key list, so
35 /// reset costs O(distinct) instead of a 2 MiB memset per file.
36 static SEEN: std::cell::RefCell<Box<[u64]>> =
37 std::cell::RefCell::new(vec![0u64; 1 << 18].into_boxed_slice());
38}
39
40/// Extract the distinct trigram *keys* present in `data`, sorted ascending.
41///
42/// Each 3-byte window is encoded as a `u32` key and deduplicated on the fly
43/// against a thread-local bitset, so the scan is O(n) and the subsequent sort
44/// runs over *distinct* keys only (typically 10-50x fewer than windows for
45/// source code). This replaces sorting every window (O(n log n) with a
46/// transient allocation of 4 bytes per input byte). The big-endian encoding
47/// means the sorted order is exactly the lexicographic order the FST term
48/// dictionary requires.
49///
50/// The key is maintained as a rolling window rather than rebuilt per position:
51/// shifting in one byte and masking off the oldest costs three ALU ops, where
52/// recomputing from the 3-byte slice costs three loads plus four shifts/ors.
53/// This is the indexing bottleneck, so that difference is worth having.
54pub fn extract_keys(data: &[u8]) -> Vec<u32> {
55 if data.len() < 3 {
56 return Vec::new();
57 }
58 SEEN.with(|seen| {
59 let mut seen = seen.borrow_mut();
60 let mut keys: Vec<u32> = Vec::new();
61 // Prime with the first two bytes; each iteration shifts in one more, so
62 // the masked value equals `key_of(&data[i - 2..=i])`.
63 let mut k = (u32::from(data[0]) << 8) | u32::from(data[1]);
64 for &b in &data[2..] {
65 k = ((k << 8) | u32::from(b)) & 0x00FF_FFFF;
66 let word = (k >> 6) as usize;
67 let bit = 1u64 << (k & 63);
68 if seen[word] & bit == 0 {
69 seen[word] |= bit;
70 keys.push(k);
71 }
72 }
73 // Every set bit corresponds to exactly one pushed key, so zeroing each
74 // key's word clears the whole set (idempotent for keys sharing a word).
75 for &k in &keys {
76 seen[(k >> 6) as usize] = 0;
77 }
78 keys.sort_unstable();
79 keys
80 })
81}
82
83/// Extract the distinct trigrams present in `data`, sorted ascending.
84///
85/// Prefer [`extract_keys`] on the indexing path: the segment writer wants the
86/// `u32` keys, so going through 3-byte arrays costs an extra allocation and a
87/// round trip back through [`key_of`].
88pub fn extract(data: &[u8]) -> Vec<Trigram> {
89 extract_keys(data).into_iter().map(tri_of).collect()
90}
91
92/// Extract the trigrams of a literal needle, sorted and deduplicated. Returns an
93/// empty vec when the needle is shorter than 3 bytes (meaning: trigram filtering
94/// can't help and the caller must scan all candidates).
95pub fn literal_trigrams(needle: &[u8]) -> Vec<Trigram> {
96 extract(needle)
97}
98
99/// A boolean query over trigrams: a conjunction of DNFs. A document is a
100/// candidate when it satisfies *every* DNF, where a DNF is satisfied when at
101/// least one of its AND-groups is fully present in the document.
102///
103/// This one shape expresses everything the planner produces:
104///
105/// * an exact literal is one DNF with a single AND-group (all its trigrams);
106/// * a case-insensitive literal contributes one DNF per needle window, each a
107/// disjunction of single-trigram groups (the window's fold variants);
108/// * a regex contributes a DNF for its required prefix literals and another
109/// for its required suffix literals.
110///
111/// A DNF that cannot filter (it is empty, or contains an empty group, which
112/// would make it trivially true) is ignored. An empty query means "scan
113/// everything".
114#[derive(Debug, Default, Clone)]
115pub struct TrigramQuery {
116 pub dnfs: Vec<TrigramDnf>,
117}
118
119impl TrigramQuery {
120 /// True when no usable trigram constraints exist and all documents are
121 /// candidates.
122 pub fn is_unconstrained(&self) -> bool {
123 !self.dnfs.iter().any(dnf_filters)
124 }
125
126 pub fn from_literal(needle: &[u8]) -> TrigramQuery {
127 let tris = literal_trigrams(needle);
128 if tris.is_empty() {
129 TrigramQuery::default()
130 } else {
131 TrigramQuery {
132 dnfs: vec![vec![tris]],
133 }
134 }
135 }
136
137 /// Build a case-insensitive literal query. Each 3-byte window of the needle
138 /// becomes one DNF listing every byte sequence the window can begin with in
139 /// a match, so the trigram index can still prune candidates without false
140 /// negatives.
141 ///
142 /// The matcher folds case Unicode-aware, so a window's variants are not
143 /// just its ASCII case permutations: `s`/`S` also matches U+017F (LATIN
144 /// SMALL LETTER LONG S) and `k`/`K` also matches U+212A (KELVIN SIGN),
145 /// whose UTF-8 encodings are multi-byte. For each window we enumerate every
146 /// combination of per-character fold forms and take the first three bytes
147 /// of each — exactly the set of trigrams a match of that window can start
148 /// with. Windows containing non-ASCII needle bytes are skipped (their fold
149 /// forms aren't enumerable this way), which only widens the candidate set.
150 pub fn from_literal_ci(needle: &[u8]) -> TrigramQuery {
151 if needle.len() < 3 {
152 return TrigramQuery::default();
153 }
154 let mut dnfs: Vec<TrigramDnf> = Vec::new();
155 for w in needle.windows(3) {
156 if let Some(clause) = ci_window_trigrams([w[0], w[1], w[2]]) {
157 dnfs.push(clause.into_iter().map(|t| vec![t]).collect());
158 }
159 }
160 if dnfs.iter().any(dnf_filters) {
161 TrigramQuery { dnfs }
162 } else {
163 TrigramQuery::default()
164 }
165 }
166}
167
168/// True when a DNF actually constrains matching: it has at least one group and
169/// no empty group (an empty group is trivially satisfied, disabling the DNF).
170pub fn dnf_filters(dnf: &TrigramDnf) -> bool {
171 !dnf.is_empty() && dnf.iter().all(|g| !g.is_empty())
172}
173
174/// A fold form: up to 3 UTF-8 bytes plus its length. Stack-only so query
175/// planning stays allocation-free per character.
176type FoldForm = ([u8; 3], usize);
177
178/// The byte sequences a single needle byte can match under the matcher's
179/// case-insensitive (Unicode simple fold) semantics, or `None` when they are
180/// not enumerable (non-ASCII bytes, whose folded forms shift window
181/// alignment unpredictably). Returns the number of forms written to `out`.
182fn fold_forms(b: u8, out: &mut [FoldForm; 3]) -> Option<usize> {
183 if b >= 0x80 {
184 return None;
185 }
186 if !b.is_ascii_alphabetic() {
187 out[0] = ([b, 0, 0], 1);
188 return Some(1);
189 }
190 out[0] = ([b.to_ascii_lowercase(), 0, 0], 1);
191 out[1] = ([b.to_ascii_uppercase(), 0, 0], 1);
192 match b.to_ascii_lowercase() {
193 // U+017F LATIN SMALL LETTER LONG S folds to 's'.
194 b's' => {
195 out[2] = ([0xC5, 0xBF, 0], 2);
196 Some(3)
197 }
198 // U+212A KELVIN SIGN folds to 'k'.
199 b'k' => {
200 out[2] = ([0xE2, 0x84, 0xAA], 3);
201 Some(3)
202 }
203 _ => Some(2),
204 }
205}
206
207/// All trigrams a case-insensitive match of window `w` can begin with: for
208/// every combination of per-character fold forms, the first three bytes of the
209/// concatenation (each form is >= 1 byte, so three forms always cover a
210/// trigram). At most 3^3 = 27 combinations; deduplicated and sorted.
211fn ci_window_trigrams(w: Trigram) -> Option<Vec<Trigram>> {
212 let mut forms = [[([0u8; 3], 0usize); 3]; 3];
213 let mut counts = [0usize; 3];
214 for i in 0..3 {
215 counts[i] = fold_forms(w[i], &mut forms[i])?;
216 }
217 let mut out: Vec<Trigram> = Vec::with_capacity(counts[0] * counts[1] * counts[2]);
218 let mut buf = [0u8; 9];
219 for a in &forms[0][..counts[0]] {
220 for b in &forms[1][..counts[1]] {
221 for c in &forms[2][..counts[2]] {
222 buf[..a.1].copy_from_slice(&a.0[..a.1]);
223 buf[a.1..a.1 + b.1].copy_from_slice(&b.0[..b.1]);
224 buf[a.1 + b.1..a.1 + b.1 + c.1].copy_from_slice(&c.0[..c.1]);
225 out.push([buf[0], buf[1], buf[2]]);
226 }
227 }
228 }
229 out.sort_unstable();
230 out.dedup();
231 Some(out)
232}
233
234/// Build a trigram query from a regular expression by extracting required
235/// literal substrings: the prefixes any match must start with *and* the
236/// suffixes any match must end with, each contributing an independent DNF.
237/// If neither side yields usable literals we fall back to an unconstrained
238/// query (scan all candidates).
239pub fn regex_trigrams(pattern: &str, case_insensitive: bool) -> TrigramQuery {
240 use regex_syntax::hir::literal::{ExtractKind, Extractor};
241 use regex_syntax::ParserBuilder;
242
243 let hir = match ParserBuilder::new()
244 .case_insensitive(case_insensitive)
245 .build()
246 .parse(pattern)
247 {
248 Ok(h) => h,
249 Err(_) => return TrigramQuery::default(),
250 };
251
252 /// Turn an extracted literal sequence into a DNF (one AND-group per
253 /// literal). Returns `None` when any literal is too short to filter,
254 /// since requiring the remaining groups could drop real matches.
255 fn dnf_of(seq: ®ex_syntax::hir::literal::Seq) -> Option<TrigramDnf> {
256 let lits = seq.literals()?;
257 let mut dnf: TrigramDnf = Vec::with_capacity(lits.len());
258 for lit in lits {
259 let tris = literal_trigrams(lit.as_bytes());
260 if tris.is_empty() {
261 return None;
262 }
263 dnf.push(tris);
264 }
265 if dnf.is_empty() {
266 None
267 } else {
268 Some(dnf)
269 }
270 }
271
272 let prefix = dnf_of(&Extractor::new().extract(&hir));
273 let suffix = {
274 let mut ex = Extractor::new();
275 ex.kind(ExtractKind::Suffix);
276 dnf_of(&ex.extract(&hir))
277 };
278
279 let mut dnfs: Vec<TrigramDnf> = Vec::new();
280 if let Some(p) = prefix {
281 dnfs.push(p);
282 }
283 if let Some(s) = suffix {
284 // A fully literal pattern yields identical prefix and suffix sets;
285 // evaluating the same DNF twice would just double posting work.
286 if dnfs.first() != Some(&s) {
287 dnfs.push(s);
288 }
289 }
290 if dnfs.is_empty() {
291 TrigramQuery::default()
292 } else {
293 TrigramQuery { dnfs }
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn extract_basic() {
303 let set = extract(b"abcd");
304 assert!(set.contains(b"abc"));
305 assert!(set.contains(b"bcd"));
306 assert_eq!(set.len(), 2);
307 }
308
309 /// The naive per-window extraction the rolling scan replaced.
310 fn reference_keys(data: &[u8]) -> Vec<u32> {
311 if data.len() < 3 {
312 return Vec::new();
313 }
314 let mut keys: Vec<u32> = data.windows(3).map(key_of).collect();
315 keys.sort_unstable();
316 keys.dedup();
317 keys
318 }
319
320 /// The rolling window must produce exactly the keys the per-window version
321 /// did, on inputs that stress byte values, repeats, and lengths.
322 #[test]
323 fn rolling_keys_match_per_window_reference() {
324 let mut cases: Vec<Vec<u8>> = vec![
325 Vec::new(),
326 b"a".to_vec(),
327 b"ab".to_vec(),
328 b"abc".to_vec(),
329 b"abcd".to_vec(),
330 b"aaaa".to_vec(),
331 b"abcabcabc".to_vec(),
332 vec![0u8; 64],
333 vec![0xFF; 64],
334 (0..=255u8).collect(),
335 (0..=255u8).rev().collect(),
336 "fn handler(x: u32) -> Result<()> { Ok(()) }"
337 .as_bytes()
338 .to_vec(),
339 ];
340 // A deterministic pseudo-random buffer: exercises high-entropy input
341 // where nearly every window is distinct.
342 let mut state = 0x243F_6A88_85A3_08D3u64;
343 let mut noisy = Vec::with_capacity(4096);
344 for _ in 0..4096 {
345 state = state
346 .wrapping_mul(6364136223846793005)
347 .wrapping_add(1442695040888963407);
348 noisy.push((state >> 33) as u8);
349 }
350 cases.push(noisy);
351
352 for data in &cases {
353 assert_eq!(
354 extract_keys(data),
355 reference_keys(data),
356 "keys differ for input of len {}",
357 data.len()
358 );
359 }
360 }
361
362 proptest::proptest! {
363 /// Same property over arbitrary byte strings, including the thread-local
364 /// dedup bitset being left clean for the next call (the loop runs many
365 /// inputs on one thread).
366 #[test]
367 fn rolling_keys_match_reference_for_any_input(data: Vec<u8>) {
368 proptest::prop_assert_eq!(extract_keys(&data), reference_keys(&data));
369 }
370 }
371
372 #[test]
373 fn extract_is_sorted_and_deduped() {
374 let set = extract(b"abcabcabc");
375 let mut sorted = set.clone();
376 sorted.sort_unstable();
377 sorted.dedup();
378 assert_eq!(set, sorted, "extract must return sorted, distinct trigrams");
379 }
380
381 #[test]
382 fn short_input_has_no_trigrams() {
383 assert!(extract(b"ab").is_empty());
384 assert!(literal_trigrams(b"ab").is_empty());
385 }
386
387 #[test]
388 fn literal_query_is_constrained() {
389 let q = TrigramQuery::from_literal(b"function");
390 assert!(!q.is_unconstrained());
391 // Short literals cannot use the trigram filter.
392 assert!(TrigramQuery::from_literal(b"fn").is_unconstrained());
393 }
394
395 #[test]
396 fn regex_extracts_required_literal() {
397 let q = regex_trigrams("error_handler", false);
398 assert!(!q.is_unconstrained());
399 // A literal pattern must not evaluate the same DNF twice.
400 assert_eq!(q.dnfs.len(), 1);
401 }
402
403 #[test]
404 fn regex_extracts_suffix_literals() {
405 // The prefix literal ("fn ") is too short to filter, but the required
406 // suffix "_handler" is selective; the query must be constrained by it.
407 let q = regex_trigrams(r"fn \w+_handler", false);
408 assert!(
409 !q.is_unconstrained(),
410 "suffix literal should constrain the query: {q:?}"
411 );
412 }
413
414 #[test]
415 fn case_insensitive_literal_is_constrained() {
416 let q = TrigramQuery::from_literal_ci(b"Foo");
417 assert!(!q.is_unconstrained());
418 // One DNF per 3-byte window; "Foo" has one window of 3 letters =>
419 // 2^3 single-trigram groups.
420 assert_eq!(q.dnfs.len(), 1);
421 let tris: Vec<Trigram> = q.dnfs[0].iter().map(|g| g[0]).collect();
422 assert!(tris.contains(b"foo"));
423 assert!(tris.contains(b"FOO"));
424 assert!(tris.contains(b"Foo"));
425 assert_eq!(tris.len(), 8);
426 // Short needles cannot be filtered.
427 assert!(TrigramQuery::from_literal_ci(b"fo").is_unconstrained());
428 }
429
430 #[test]
431 fn ci_skips_windows_with_non_ascii_bytes() {
432 // "café" => windows "caf", "af\xC3", "f\xC3\xA9". Only the all-ASCII
433 // "caf" window is enumerable; the others span the multibyte 'é' whose
434 // uppercase form ('É') has different bytes, so requiring them would drop
435 // real matches.
436 let q = TrigramQuery::from_literal_ci("café".as_bytes());
437 assert_eq!(q.dnfs.len(), 1);
438 let tris: Vec<Trigram> = q.dnfs[0].iter().map(|g| g[0]).collect();
439 assert!(tris.contains(b"caf"));
440 assert!(tris.contains(b"CAF"));
441 }
442
443 #[test]
444 fn ci_kelvin_and_long_s_windows_stay_constrained() {
445 // Windows containing 's'/'k' used to be dropped entirely (the fold
446 // class includes a non-ASCII character), degrading common needles to
447 // full scans. They are now kept by enumerating the multi-byte fold
448 // forms, so every all-ASCII needle stays constrained.
449 for needle in [&b"class"[..], b"list", b"make", b"kayak"] {
450 let q = TrigramQuery::from_literal_ci(needle);
451 assert!(
452 !q.is_unconstrained(),
453 "{needle:?} should be constrained: {q:?}"
454 );
455 }
456 // The 's' window clause must include the long-s byte prefix so a
457 // haystack containing U+017F still passes the filter.
458 let q = TrigramQuery::from_literal_ci(b"las");
459 let tris: Vec<Trigram> = q.dnfs[0].iter().map(|g| g[0]).collect();
460 assert!(tris.contains(b"las"));
461 assert!(tris.contains(b"LAS"));
462 assert!(
463 tris.contains(&[b'l', b'a', 0xC5]),
464 "expected long-s prefix variant, got {tris:?}"
465 );
466 }
467
468 /// Documents *why* the fold forms include 's'/'k' specials: the matcher's
469 /// Unicode-aware case folding makes /k/i match U+212A and /s/i match
470 /// U+017F, while a non-special letter like /a/i does not match U+00E5
471 /// ('å'). If this ever changes upstream, `fold_forms` must be revisited.
472 #[test]
473 fn regex_ci_folds_kelvin_and_long_s() {
474 let ci = |pat: &str, hay: &str| {
475 regex::bytes::RegexBuilder::new(®ex::escape(pat))
476 .case_insensitive(true)
477 .build()
478 .unwrap()
479 .is_match(hay.as_bytes())
480 };
481 assert!(ci("k", "\u{212A}"), "/k/i should match KELVIN SIGN");
482 assert!(
483 ci("s", "\u{017F}"),
484 "/s/i should match LATIN SMALL LETTER LONG S"
485 );
486 assert!(!ci("a", "\u{00E5}"), "/a/i should not match 'å'");
487 }
488
489 /// End-to-end soundness: whenever the case-insensitive matcher accepts a
490 /// haystack, the trigram filter must also keep it (no false negatives).
491 #[test]
492 fn ci_filter_never_drops_a_match() {
493 // (needle, haystack) pairs the Unicode-aware matcher accepts.
494 let matching: &[(&str, &str)] = &[
495 ("café", "a CAFÉ here"), // non-ASCII fold
496 ("café", "tiny café shop"), // exact bytes
497 ("class", "MyClass {}"), // plain 's'
498 ("class", "cla\u{017F}s X"), // long s in the haystack
499 ("foobar", "FOOBAR()"), // plain ASCII
500 ("make", "MAKEFILE"), // plain 'k'
501 ("make", "ma\u{212A}e it"), // KELVIN SIGN in the haystack
502 ("kayak", "KAYAK"), // multiple 'k's
503 ("kayak", "kaya\u{212A} trip"), // trailing Kelvin
504 ("string", "STRING s"),
505 ];
506 for (needle, hay) in matching {
507 let re = regex::bytes::RegexBuilder::new(®ex::escape(needle))
508 .case_insensitive(true)
509 .build()
510 .unwrap();
511 assert!(
512 re.is_match(hay.as_bytes()),
513 "test setup: {needle:?} must match {hay:?}"
514 );
515
516 let q = TrigramQuery::from_literal_ci(needle.as_bytes());
517 assert!(
518 filter_keeps(&q, hay.as_bytes()),
519 "filter wrongly dropped {hay:?} for needle {needle:?}"
520 );
521 }
522 }
523
524 /// Soundness for the exact-case and regex planners too.
525 #[test]
526 fn literal_and_regex_filters_keep_their_matches() {
527 let hay = b"pub fn segment_writer_flush(x: u32) {}";
528 let lit = TrigramQuery::from_literal(b"segment_writer");
529 assert!(filter_keeps(&lit, hay));
530
531 let re = regex_trigrams(r"fn \w+_flush", false);
532 assert!(filter_keeps(&re, hay));
533 }
534
535 /// Mirror of `Segment::candidates` evaluation for an in-memory document:
536 /// the doc passes when, for every filtering DNF, at least one group's
537 /// trigrams are all present.
538 fn filter_keeps(q: &TrigramQuery, haystack: &[u8]) -> bool {
539 let doc = extract(haystack);
540 let has = |t: &Trigram| doc.binary_search(t).is_ok();
541 q.dnfs
542 .iter()
543 .filter(|d| dnf_filters(d))
544 .all(|dnf| dnf.iter().any(|group| group.iter().all(&has)))
545 }
546}