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