Skip to main content

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}