sparse_ngrams/extract.rs
1//! Core sparse n-gram extraction algorithm.
2
3use crate::MAX_SPARSE_GRAM_SIZE;
4use crate::ngram::NGram;
5use crate::table::{bigram_h, bigram_priority_rolling};
6
7/// Returns the maximum number of sparse n-grams that can be produced from
8/// `content_len` bytes of input. Use this to reserve capacity for the output (e.g. a `Vec` the
9/// emit closure pushes into, or a pre-sized slice it writes).
10#[inline]
11pub const fn max_sparse_grams(content_len: usize) -> usize {
12 if content_len < 2 {
13 0
14 } else {
15 (content_len - 1) * 3
16 }
17}
18
19/// Builds the `len`-byte gram ending at the current position from the rolling `window` (newest byte
20/// in the low byte). The gram is the `len` low bytes of `window`; shifting them into the
21/// most-significant bytes gives the big-endian layout [`NGram::from_window`] consumes.
22#[inline]
23fn window_to_gram(window: u64, len: usize) -> NGram {
24 debug_assert!(len <= MAX_SPARSE_GRAM_SIZE);
25 NGram::from_window(window << ((MAX_SPARSE_GRAM_SIZE - len) * 8), len)
26}
27
28/// Collect all sparse n-grams from the input byte slice into a new [`Vec`].
29///
30/// Convenience wrapper over [`collect_sparse_grams_deque`]. For custom output — streaming into a
31/// search index, deduplicating, or filtering without building an intermediate `Vec` — call
32/// [`collect_sparse_grams_deque`] or [`collect_sparse_grams_scan`] directly with your own closure.
33pub fn collect_sparse_grams(content: &[u8]) -> Vec<NGram> {
34 // Reserve the exact worst case up front and have the emit closure write straight into the
35 // uninitialized spare capacity, then fix up the length once at the end. This avoids both the
36 // per-gram `push` bookkeeping (capacity check + length bump) and the `truncate`/default-fill of
37 // a pre-sized `vec![_; max]`.
38 let mut out = Vec::with_capacity(max_sparse_grams(content.len()));
39 let spare = out.spare_capacity_mut();
40 let mut w = 0;
41 collect_sparse_grams_deque(content, |gram, _idx| {
42 spare[w].write(gram);
43 w += 1;
44 });
45 // SAFETY: `collect_sparse_grams_deque` emits at most `max_sparse_grams(content.len())` grams —
46 // exactly the capacity reserved above — so every write above landed within `spare`, and the
47 // first `w` elements of the allocation are now initialized.
48 unsafe { out.set_len(w) };
49 out
50}
51
52/// Monotone-deque extraction, with the deque held in fixed ring buffers. Calls `emit` once for
53/// every sparse n-gram, in emission order (all bigrams, plus algorithmically selected longer
54/// grams), as `(gram, idx)` where `idx` is the position of the character just after `gram` in
55/// `content`.
56/// `emit` decides what to do with each gram — push it into a `Vec`, write it into a pre-sized
57/// slice, feed it straight into an index, etc. — so no output buffer needs to be sized or
58/// allocated up front.
59///
60/// The boundary candidates form a monotone run, kept in fixed `[_; MAX_SPARSE_GRAM_SIZE]` ring
61/// buffers addressed by a single running depth `tail` — there is no head. Rather than dropping the
62/// oldest candidate up front, the walk-back simply stops at the first candidate too far back to
63/// form a gram of at most `MAX_SPARSE_GRAM_SIZE` bytes; out-of-window candidates are never read and
64/// get overwritten by later pushes (the live window spans fewer than `MAX_SPARSE_GRAM_SIZE`
65/// candidates, so the ring never loses one it still needs).
66///
67/// A rolling `u64` holds the last 8 bytes of `content` (newest byte in the low byte). A gram is at
68/// most [`MAX_SPARSE_GRAM_SIZE`] bytes and always ends at the current index, so it is exactly the
69/// `len` low bytes of the window; `window_to_gram` shifts those up to build it straight from
70/// registers, without re-slicing `content`.
71///
72/// ```
73/// use sparse_ngrams::{collect_sparse_grams_deque, NGram};
74///
75/// let mut count = 0;
76/// collect_sparse_grams_deque(b"hello world", |_gram: NGram, _idx| count += 1);
77/// assert!(count > 0);
78/// ```
79pub fn collect_sparse_grams_deque(content: &[u8], mut emit: impl FnMut(NGram, u32)) {
80 let n = content.len();
81 if n < 2 {
82 return;
83 }
84 const MASK: usize = MAX_SPARSE_GRAM_SIZE - 1;
85 // Sentinel index for empty ring slots. It is chosen so the "too-large gram" test in the
86 // walk-back (`idx - begin + 1 >= MAX_SPARSE_GRAM_SIZE`) fires for it at every `idx >= 1` — the
87 // subtraction wraps to `idx + MAX_SPARSE_GRAM_SIZE + 1` — so the walk terminates at the bottom
88 // of the stack purely from the stored values, with no separate emptiness check.
89 const EMPTY: u32 = 0u32.wrapping_sub(MAX_SPARSE_GRAM_SIZE as u32);
90 // Monotone deque of boundary candidates (position index + priority), strictly increasing in
91 // both, held in fixed ring buffers. Only `tail` (the running stack depth) is tracked; a
92 // candidate at depth `d` lives in slot `d & MASK`. The live window never spans
93 // `MAX_SPARSE_GRAM_SIZE` candidates, so overwriting slot `tail & MASK` only clobbers an
94 // out-of-window entry that the walk-back below never reaches.
95 let mut idx_buf = [EMPTY; MAX_SPARSE_GRAM_SIZE];
96 let mut val_buf = [0u32; MAX_SPARSE_GRAM_SIZE];
97 let mut tail = 0usize;
98
99 // The rolling window starts with the first byte; each loop iteration shifts in `content[idx]`.
100 let mut window = content[0] as u64;
101 // `BIGRAM_H` of the most recent byte, carried between positions so consecutive bigrams (which
102 // overlap by one byte) only load one new H value each. Seeded with the first byte's H.
103 let mut h = bigram_h(content[0]);
104
105 for idx in 1..n as u32 {
106 window = (window << 8) | content[idx as usize] as u64;
107 // The bigram for this position is the low two bytes of the rolling window. `h` is the H
108 // value of its first byte, carried over from the previous position; `h_b` feeds the next.
109 let (value, h_b) = bigram_priority_rolling((window >> 8) as u8, window as u8, h);
110 h = h_b;
111
112 // The bigram (length 2) is always emitted.
113 emit(window_to_gram(window, 2), idx + 1);
114
115 // Walk back over the candidates from the tail, emitting one gram each. Stop at the first
116 // candidate too far back to form a gram of at most `MAX_SPARSE_GRAM_SIZE` bytes (this
117 // replaces the front-drop and, via the `EMPTY` sentinel, also terminates at the stack
118 // bottom), or one whose priority is < the current bigram's (it stays as the new left
119 // boundary). Equal-or-larger priorities are dropped, and the current position overwrites
120 // the first dropped slot.
121 let mut t = tail;
122 loop {
123 let slot = t.wrapping_sub(1) & MASK;
124 let begin = idx_buf[slot];
125 if idx.wrapping_sub(begin) + 1 >= MAX_SPARSE_GRAM_SIZE as u32 {
126 break;
127 }
128 emit(
129 window_to_gram(window, (idx.wrapping_sub(begin) + 2) as usize),
130 idx + 1,
131 );
132 let bval = val_buf[slot];
133 if bval < value {
134 break;
135 }
136 t -= 1;
137 if bval == value {
138 break;
139 }
140 }
141
142 // Push the current position by overwriting the first dropped (or next free) slot.
143 idx_buf[t & MASK] = idx;
144 val_buf[t & MASK] = value;
145 tail = t + 1;
146 }
147}
148
149/// Queue-free scan-based extraction. Calls `emit` once for every sparse n-gram, in the same order
150/// as [`collect_sparse_grams_deque`], as `(gram, idx)` where `idx` is the position of the character
151/// just after `gram` in `content`.
152///
153/// Produces identical output (same order) as [`collect_sparse_grams_deque`]: the boundary
154/// candidates are exactly the positions where a backward scan hits a new suffix minimum, so a
155/// fixed-size ring of recent priorities replaces the monotone deque.
156pub fn collect_sparse_grams_scan(content: &[u8], mut emit: impl FnMut(NGram, u32)) {
157 let n = content.len();
158 if n < 2 {
159 return;
160 }
161
162 const MASK: usize = MAX_SPARSE_GRAM_SIZE - 1;
163 let mut window = content[0] as u64;
164 let mut h = bigram_h(content[0]);
165 // Ring buffer of the most recent bigram priorities, indexed by `idx & MASK`.
166 let mut priorities = [0u32; MAX_SPARSE_GRAM_SIZE];
167 for idx in 1..n as u32 {
168 window = (window << 8) | content[idx as usize] as u64;
169 let (v1, h_b) = bigram_priority_rolling((window >> 8) as u8, window as u8, h);
170 h = h_b;
171 priorities[idx as usize & MASK] = v1;
172
173 // The bigram (length 2) is always emitted.
174 emit(window_to_gram(window, 2), idx + 1);
175
176 // Scan backwards, tracking the minimum interior priority seen so far. Each new strict
177 // minimum is a boundary candidate; emit its gram while the right boundary `v1` is strictly
178 // below the interior minimum, then stop.
179 let mut running_min = u32::MAX;
180 for d in 1..=(MAX_SPARSE_GRAM_SIZE as u32 - 2) {
181 if d >= idx {
182 break;
183 }
184 let v_p = priorities[(idx - d) as usize & MASK];
185 if v_p < running_min {
186 if running_min <= v1 {
187 break;
188 }
189 let len = d as usize + 2;
190 emit(window_to_gram(window, len), idx + 1);
191 running_min = v_p;
192 }
193 }
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::table::bigram_priority;
201 use std::collections::HashSet;
202
203 fn collect_to_vec(run: impl FnOnce(&mut dyn FnMut(NGram, u32))) -> Vec<NGram> {
204 let mut out = Vec::new();
205 run(&mut |gram, _idx| out.push(gram));
206 out
207 }
208
209 /// Brute-force reference implementation.
210 ///
211 /// Enumerates all substrings of length 2..=MAX_SPARSE_GRAM_SIZE and emits those where both the
212 /// left and right boundary bigram priorities are strictly less than every interior priority.
213 /// All bigrams (len=2) are always emitted.
214 fn brute_force_sparse_grams(content: &[u8]) -> HashSet<NGram> {
215 let n = content.len();
216 let mut result = HashSet::new();
217 if n < 2 {
218 return result;
219 }
220 // All bigrams.
221 for i in 0..n - 1 {
222 result.insert(NGram::from_bytes(&content[i..i + 2]));
223 }
224 // Longer grams: length 3..=MAX_SPARSE_GRAM_SIZE.
225 for len in 3..=MAX_SPARSE_GRAM_SIZE {
226 for start in 0..=n.saturating_sub(len) {
227 if start + len > n {
228 break;
229 }
230 let left = bigram_priority(content[start], content[start + 1]);
231 let right = bigram_priority(content[start + len - 2], content[start + len - 1]);
232 // Interior bigrams: (start+1,start+2), ..., (start+len-3,start+len-2).
233 let mut min_interior = u32::MAX;
234 for k in 1..len - 2 {
235 let p = bigram_priority(content[start + k], content[start + k + 1]);
236 min_interior = min_interior.min(p);
237 }
238 if left < min_interior && right < min_interior {
239 result.insert(NGram::from_bytes(&content[start..start + len]));
240 }
241 }
242 }
243 result
244 }
245
246 #[test]
247 fn test_empty_input() {
248 assert!(collect_sparse_grams(b"").is_empty());
249 }
250
251 #[test]
252 fn test_single_byte() {
253 assert!(collect_sparse_grams(b"a").is_empty());
254 }
255
256 #[test]
257 fn test_two_bytes() {
258 let grams = collect_sparse_grams(b"ab");
259 assert_eq!(grams.len(), 1);
260 assert_eq!(grams[0], NGram::from_bytes(b"ab"));
261 }
262
263 #[test]
264 fn test_three_bytes() {
265 let grams = collect_sparse_grams(b"abc");
266 assert!(grams.len() >= 2);
267 assert_eq!(grams[0], NGram::from_bytes(b"ab"));
268 assert_eq!(grams[1], NGram::from_bytes(b"bc"));
269 }
270
271 #[test]
272 fn test_gram_lengths_bounded() {
273 let input = b"self.reset_states(the_quick_brown_fox_jumps";
274 let grams = collect_sparse_grams(input);
275 for gram in &grams {
276 assert!(gram.len() >= 2, "gram too short: {gram:?}");
277 assert!(
278 gram.len() <= MAX_SPARSE_GRAM_SIZE,
279 "gram too long: {gram:?}"
280 );
281 }
282 }
283
284 #[test]
285 fn test_produces_longer_grams() {
286 let grams = collect_sparse_grams(b"self.reset_states(");
287 assert!(grams.iter().any(|g| g.len() > 2));
288 }
289
290 #[test]
291 fn test_max_gram_size_boundary() {
292 let grams = collect_sparse_grams(b"abcdefgh");
293 for gram in &grams {
294 assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE);
295 }
296 }
297
298 #[test]
299 fn test_repeated_bytes() {
300 let grams = collect_sparse_grams(b"aaaaaaaaaa");
301 assert!(grams.iter().filter(|g| g.len() == 2).count() >= 9);
302 }
303
304 #[test]
305 fn test_gram_count_scales_linearly() {
306 let input: Vec<u8> = (0..1000).map(|i| (i % 256) as u8).collect();
307 let grams = collect_sparse_grams(&input);
308 assert!(grams.len() >= input.len() - 1);
309 assert!(grams.len() <= input.len() * 3);
310 }
311
312 // -- Equivalence: scan vs deque --
313
314 #[test]
315 fn test_scan_equivalence_small() {
316 for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] {
317 assert_eq!(
318 collect_to_vec(|emit| collect_sparse_grams_deque(input, emit)),
319 collect_to_vec(|emit| collect_sparse_grams_scan(input, emit)),
320 "mismatch on {:?}",
321 std::str::from_utf8(input).unwrap_or("?")
322 );
323 }
324 }
325
326 #[test]
327 fn test_scan_equivalence_hello_world() {
328 let input = b"hello world";
329 assert_eq!(
330 collect_to_vec(|emit| collect_sparse_grams_deque(input, emit)),
331 collect_to_vec(|emit| collect_sparse_grams_scan(input, emit)),
332 );
333 }
334
335 #[test]
336 fn test_scan_equivalence_large() {
337 let input: Vec<u8> = (0..1000).map(|i| (i % 256) as u8).collect();
338 assert_eq!(
339 collect_to_vec(|emit| collect_sparse_grams_deque(&input, emit)),
340 collect_to_vec(|emit| collect_sparse_grams_scan(&input, emit)),
341 );
342 }
343
344 #[test]
345 fn test_scan_equivalence_source_code() {
346 let input = include_bytes!("extract.rs");
347 assert_eq!(
348 collect_to_vec(|emit| collect_sparse_grams_deque(input, emit)),
349 collect_to_vec(|emit| collect_sparse_grams_scan(input, emit)),
350 );
351 }
352
353 // -- Brute-force equivalence --
354
355 fn assert_matches_brute_force(input: &[u8]) {
356 let grams = collect_sparse_grams(input);
357 let actual: HashSet<NGram> = grams.into_iter().collect();
358 let expected = brute_force_sparse_grams(input);
359 let only_actual: Vec<_> = actual.difference(&expected).collect();
360 let only_expected: Vec<_> = expected.difference(&actual).collect();
361 if !only_actual.is_empty() || !only_expected.is_empty() {
362 panic!(
363 "mismatch on input len={}\n only in algorithm: {:?}\n only in brute force: {:?}",
364 input.len(),
365 only_actual,
366 only_expected
367 );
368 }
369 }
370
371 #[test]
372 fn test_brute_force_small() {
373 for input in [
374 b"" as &[u8],
375 b"x",
376 b"ab",
377 b"abc",
378 b"abcd",
379 b"abcdefgh",
380 b"abcdefghi",
381 ] {
382 assert_matches_brute_force(input);
383 }
384 }
385
386 #[test]
387 fn test_brute_force_hello_world() {
388 assert_matches_brute_force(b"hello world");
389 }
390
391 #[test]
392 fn test_brute_force_repeated() {
393 assert_matches_brute_force(b"aaaaaaaaaa");
394 }
395
396 #[test]
397 fn test_brute_force_code_snippet() {
398 assert_matches_brute_force(b"self.reset_states(the_quick_brown_fox_jumps");
399 }
400
401 #[test]
402 fn test_brute_force_tie_break() {
403 // Repeated bigrams create exact ties between an interior priority and a boundary; this
404 // exercises the both-strict `max(L, R) < interior` rule where deque, scan and brute force
405 // must agree (a gram whose right boundary only ties the smallest interior priority is
406 // dropped as redundant).
407 assert_matches_brute_force(b"ababababab");
408 assert_matches_brute_force(b"the the the the");
409 assert_matches_brute_force(b"a.b.a.b.a.b.");
410 }
411
412 #[test]
413 fn test_brute_force_diverse() {
414 let input: Vec<u8> = (0..200).map(|i| (i % 256) as u8).collect();
415 assert_matches_brute_force(&input);
416 }
417
418 #[test]
419 fn test_brute_force_long_ascending() {
420 // Long ascending ASCII runs create monotone priority stretches that grow the deque's
421 // `tail` counter far beyond the ring size, exercising the `EMPTY` sentinel that lets the
422 // walk-back terminate at the stack bottom without a `t > 0` check. Also runs a scan/deque
423 // cross-check on the same input.
424 let input: Vec<u8> = (0..3000u32).map(|i| 33 + (i % 90) as u8).collect();
425 assert_matches_brute_force(&input);
426 assert_eq!(
427 collect_to_vec(|emit| collect_sparse_grams_deque(&input, emit)),
428 collect_to_vec(|emit| collect_sparse_grams_scan(&input, emit)),
429 );
430 }
431
432 #[test]
433 fn test_brute_force_source_code() {
434 let input = include_bytes!("extract.rs");
435 assert_matches_brute_force(input);
436 }
437}