Skip to main content

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}