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
8use std::collections::BTreeSet;
9
10/// A trigram, stored big-endian so byte order matches numeric order (required
11/// for the FST term dictionary, whose keys must be lexicographically sorted).
12pub type Trigram = [u8; 3];
13
14/// Extract the set of distinct trigrams present in `data`.
15pub fn extract(data: &[u8]) -> BTreeSet<Trigram> {
16    let mut set = BTreeSet::new();
17    if data.len() < 3 {
18        return set;
19    }
20    // Each 3-byte window is one trigram; the BTreeSet dedups duplicates and keeps
21    // keys sorted, which the FST term dictionary requires.
22    for w in data.windows(3) {
23        set.insert([w[0], w[1], w[2]]);
24    }
25    set
26}
27
28/// Extract the trigrams of a literal needle, sorted and deduplicated. Returns an
29/// empty vec when the needle is shorter than 3 bytes (meaning: trigram filtering
30/// can't help and the caller must scan all candidates).
31pub fn literal_trigrams(needle: &[u8]) -> Vec<Trigram> {
32    extract(needle).into_iter().collect()
33}
34
35/// A boolean query over trigrams supporting two complementary shapes:
36///
37/// * `or_groups` is disjunctive normal form (DNF): each inner group is an AND of
38///   trigrams and the outer set is an OR of groups. Used for exact literals and
39///   regex required-literal alternations.
40/// * `and_clauses` is conjunctive normal form (CNF): each inner clause is an OR
41///   of trigrams and a document must satisfy *every* clause. Used for
42///   case-insensitive literals, where each needle position contributes the OR of
43///   its case variants.
44///
45/// A document is a candidate if it satisfies the DNF part (or the DNF part is
46/// empty) *and* every CNF clause. An empty query means "scan everything".
47#[derive(Debug, Default, Clone)]
48pub struct TrigramQuery {
49    pub or_groups: Vec<Vec<Trigram>>,
50    pub and_clauses: Vec<Vec<Trigram>>,
51}
52
53impl TrigramQuery {
54    /// True when no usable trigram constraints exist and all documents are
55    /// candidates. A group/clause that is empty means that part can't filter.
56    pub fn is_unconstrained(&self) -> bool {
57        let dnf_off = self.or_groups.is_empty() || self.or_groups.iter().any(|g| g.is_empty());
58        let cnf_off = self.and_clauses.is_empty() || self.and_clauses.iter().any(|c| c.is_empty());
59        dnf_off && cnf_off
60    }
61
62    pub fn from_literal(needle: &[u8]) -> TrigramQuery {
63        let tris = literal_trigrams(needle);
64        if tris.is_empty() {
65            TrigramQuery::default()
66        } else {
67            TrigramQuery {
68                or_groups: vec![tris],
69                and_clauses: Vec::new(),
70            }
71        }
72    }
73
74    /// Build a case-insensitive literal query. Each 3-byte window of the needle
75    /// becomes a CNF clause listing every ASCII-case variant of that window, so
76    /// the trigram index can still prune candidates without false negatives.
77    ///
78    /// A window is only usable as a clause when all three of its bytes are
79    /// *ASCII-case-safe* (see [`ci_safe`]): otherwise the case-insensitive
80    /// matcher (Unicode-aware) could match bytes we did not enumerate, and
81    /// requiring the ASCII trigrams would drop real matches. Unsafe windows are
82    /// skipped, which only widens the candidate set. If no usable window remains,
83    /// the query is unconstrained and every document is scanned.
84    pub fn from_literal_ci(needle: &[u8]) -> TrigramQuery {
85        if needle.len() < 3 {
86            return TrigramQuery::default();
87        }
88        let mut and_clauses: Vec<Vec<Trigram>> = Vec::new();
89        for w in needle.windows(3) {
90            if w.iter().all(|&b| ci_safe(b)) {
91                and_clauses.push(case_variants([w[0], w[1], w[2]]));
92            }
93        }
94        if and_clauses.is_empty() {
95            return TrigramQuery::default();
96        }
97        TrigramQuery {
98            or_groups: Vec::new(),
99            and_clauses,
100        }
101    }
102}
103
104/// True when a byte's set of case-insensitive matches (under the matcher's
105/// Unicode-aware case folding) is fully captured by enumerating its ASCII case
106/// variants, so a trigram window containing it can soundly prune candidates.
107///
108/// Two classes of bytes are *not* safe:
109///
110/// * Bytes `>= 0x80` belong to multibyte UTF-8 sequences; their folded forms
111///   differ in both bytes and length, so the ASCII variants of the raw bytes do
112///   not cover what the matcher accepts.
113/// * `s`/`S` and `k`/`K`: under Unicode simple case folding their fold class also
114///   contains a non-ASCII character (U+017F LATIN SMALL LETTER LONG S folds to
115///   `s`; U+212A KELVIN SIGN folds to `k`). A case-insensitive match could land
116///   on text containing those characters, whose UTF-8 bytes never form this
117///   ASCII trigram. (The `regex` crate, which backs the matcher, folds these by
118///   default; see the `regex_ci_folds_kelvin_and_long_s` test.)
119///
120/// All other ASCII bytes are safe: ASCII letters fold only to `{lower, upper}`
121/// and ASCII non-letters fold to themselves.
122fn ci_safe(b: u8) -> bool {
123    b < 0x80 && !matches!(b, b's' | b'S' | b'k' | b'K')
124}
125
126/// All ASCII-case permutations of a trigram (bytes that aren't ASCII letters are
127/// fixed). At most 2^3 = 8 variants.
128fn case_variants(w: Trigram) -> Vec<Trigram> {
129    let mut variants: Vec<Trigram> = vec![[0; 3]];
130    for (i, &b) in w.iter().enumerate() {
131        if b.is_ascii_alphabetic() {
132            let lo = b.to_ascii_lowercase();
133            let up = b.to_ascii_uppercase();
134            let mut next = Vec::with_capacity(variants.len() * 2);
135            for v in &variants {
136                let mut a = *v;
137                a[i] = lo;
138                let mut c = *v;
139                c[i] = up;
140                next.push(a);
141                next.push(c);
142            }
143            variants = next;
144        } else {
145            for v in &mut variants {
146                v[i] = b;
147            }
148        }
149    }
150    variants.sort_unstable();
151    variants.dedup();
152    variants
153}
154
155/// Build a trigram query from a regular expression by extracting required
156/// literal substrings. If the regex has no usable required literals we fall back
157/// to an unconstrained query (scan all candidates).
158pub fn regex_trigrams(pattern: &str, case_insensitive: bool) -> TrigramQuery {
159    use regex_syntax::hir::literal::Extractor;
160    use regex_syntax::ParserBuilder;
161
162    let hir = match ParserBuilder::new()
163        .case_insensitive(case_insensitive)
164        .build()
165        .parse(pattern)
166    {
167        Ok(h) => h,
168        Err(_) => return TrigramQuery::default(),
169    };
170
171    // Prefix literals that any match must start with. If the set is not exact or
172    // is infinite, the extractor yields inexact literals which still anchor the
173    // search usefully.
174    let seq = Extractor::new().extract(&hir);
175    let mut or_groups: Vec<Vec<Trigram>> = Vec::new();
176    if let Some(lits) = seq.literals() {
177        for lit in lits {
178            let tris = literal_trigrams(lit.as_bytes());
179            if tris.is_empty() {
180                // A short or empty required literal disables filtering entirely.
181                return TrigramQuery::default();
182            }
183            or_groups.push(tris);
184        }
185    }
186    if or_groups.is_empty() {
187        TrigramQuery::default()
188    } else {
189        TrigramQuery {
190            or_groups,
191            and_clauses: Vec::new(),
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn extract_basic() {
202        let set = extract(b"abcd");
203        assert!(set.contains(b"abc"));
204        assert!(set.contains(b"bcd"));
205        assert_eq!(set.len(), 2);
206    }
207
208    #[test]
209    fn short_input_has_no_trigrams() {
210        assert!(extract(b"ab").is_empty());
211        assert!(literal_trigrams(b"ab").is_empty());
212    }
213
214    #[test]
215    fn literal_query_is_constrained() {
216        let q = TrigramQuery::from_literal(b"function");
217        assert!(!q.is_unconstrained());
218        // Short literals cannot use the trigram filter.
219        assert!(TrigramQuery::from_literal(b"fn").is_unconstrained());
220    }
221
222    #[test]
223    fn regex_extracts_required_literal() {
224        let q = regex_trigrams("error_handler", false);
225        assert!(!q.is_unconstrained());
226    }
227
228    #[test]
229    fn case_insensitive_literal_is_constrained() {
230        let q = TrigramQuery::from_literal_ci(b"Foo");
231        assert!(!q.is_unconstrained());
232        // One CNF clause per 3-byte window.
233        assert_eq!(q.and_clauses.len(), 1);
234        let clause = &q.and_clauses[0];
235        // "Foo" has 3 letters => 2^3 case variants.
236        assert!(clause.contains(b"foo"));
237        assert!(clause.contains(b"FOO"));
238        assert!(clause.contains(b"Foo"));
239        assert_eq!(clause.len(), 8);
240        // Short needles cannot be filtered.
241        assert!(TrigramQuery::from_literal_ci(b"fo").is_unconstrained());
242    }
243
244    #[test]
245    fn ci_skips_windows_with_non_ascii_bytes() {
246        // "café" => windows "caf", "af\xC3", "f\xC3\xA9". Only the all-ASCII
247        // "caf" window is sound; the others span the multibyte 'é' whose
248        // uppercase form ('É') has different bytes, so requiring them would drop
249        // real matches.
250        let q = TrigramQuery::from_literal_ci("café".as_bytes());
251        assert_eq!(q.and_clauses.len(), 1);
252        let clause = &q.and_clauses[0];
253        assert!(clause.contains(b"caf"));
254        assert!(clause.contains(b"CAF"));
255        // No clause may require a trigram containing a non-ASCII byte.
256        for clause in &q.and_clauses {
257            for tri in clause {
258                assert!(
259                    tri.iter().all(|&b| b < 0x80),
260                    "clause kept non-ASCII {tri:?}"
261                );
262            }
263        }
264    }
265
266    #[test]
267    fn ci_skips_kelvin_and_long_s_windows() {
268        // 's'/'k' fold to non-ASCII characters under Unicode, so any window
269        // containing them is dropped.
270        // "class" => "cla" (kept), "las"/"ass" (dropped: contain 's').
271        let q = TrigramQuery::from_literal_ci(b"class");
272        assert_eq!(q.and_clauses.len(), 1);
273        assert!(q.and_clauses[0].contains(b"cla"));
274
275        // Every window contains 's' or 'k' => unconstrained (full scan), but sound.
276        assert!(TrigramQuery::from_literal_ci(b"list").is_unconstrained());
277        assert!(TrigramQuery::from_literal_ci(b"make").is_unconstrained());
278    }
279
280    /// Documents *why* `ci_safe` rejects 's'/'k': the matcher's Unicode-aware
281    /// case folding makes /k/i match U+212A and /s/i match U+017F, while a
282    /// non-special letter like /a/i does not match U+00E5 ('å'). If this ever
283    /// changes upstream, the `ci_safe` skip-set must be revisited.
284    #[test]
285    fn regex_ci_folds_kelvin_and_long_s() {
286        let ci = |pat: &str, hay: &str| {
287            regex::bytes::RegexBuilder::new(&regex::escape(pat))
288                .case_insensitive(true)
289                .build()
290                .unwrap()
291                .is_match(hay.as_bytes())
292        };
293        assert!(ci("k", "\u{212A}"), "/k/i should match KELVIN SIGN");
294        assert!(
295            ci("s", "\u{017F}"),
296            "/s/i should match LATIN SMALL LETTER LONG S"
297        );
298        assert!(!ci("a", "\u{00E5}"), "/a/i should not match 'å'");
299    }
300
301    /// End-to-end soundness: whenever the case-insensitive matcher accepts a
302    /// haystack, the trigram CNF filter must also keep it (no false negatives).
303    #[test]
304    fn ci_filter_never_drops_a_match() {
305        // (needle, haystack) pairs the Unicode-aware matcher accepts.
306        let matching: &[(&str, &str)] = &[
307            ("café", "a CAFÉ here"),    // non-ASCII fold
308            ("café", "tiny café shop"), // exact bytes
309            ("class", "MyClass {}"),    // 's' windows dropped, 'cla' kept
310            ("foobar", "FOOBAR()"),     // plain ASCII
311            ("make", "MAKEFILE"),       // all windows dropped => unconstrained
312            ("kayak", "KAYAK"),         // 'k' windows dropped
313            ("string", "STRING s"),     // 's' windows dropped, others kept
314        ];
315        for (needle, hay) in matching {
316            let re = regex::bytes::RegexBuilder::new(&regex::escape(needle))
317                .case_insensitive(true)
318                .build()
319                .unwrap();
320            assert!(
321                re.is_match(hay.as_bytes()),
322                "test setup: {needle:?} must match {hay:?}"
323            );
324
325            let q = TrigramQuery::from_literal_ci(needle.as_bytes());
326            assert!(
327                ci_filter_keeps(&q, hay.as_bytes()),
328                "filter wrongly dropped {hay:?} for needle {needle:?}"
329            );
330        }
331    }
332
333    /// Mirror of `Segment::candidates` CNF evaluation for an in-memory document:
334    /// the doc passes when every clause shares at least one trigram with it.
335    fn ci_filter_keeps(q: &TrigramQuery, haystack: &[u8]) -> bool {
336        if q.is_unconstrained() {
337            return true;
338        }
339        let doc = extract(haystack);
340        q.and_clauses
341            .iter()
342            .all(|clause| clause.iter().any(|t| doc.contains(t)))
343    }
344}