Skip to main content

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: &regex_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(&regex::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(&regex::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}