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