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(®ex::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(®ex::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}