Skip to content

Commit d08a649

Browse files
authored
Merge pull request #142 from github/aneubeck/query-gram-bytes
sparse-ngrams: report each query gram's index-folded bytes
2 parents a7aecfb + 5009143 commit d08a649

4 files changed

Lines changed: 74 additions & 46 deletions

File tree

crates/sparse-ngrams/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "sparse-ngrams"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2024"
55
description = "Fast sparse n-gram extraction from byte slices."
66
repository = "https://github.com/github/rust-gems"

crates/sparse-ngrams/README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,11 @@ incrementally as the user types. `QueryGrams` is a streaming state machine for e
6161
accepts one character (or already index-folded byte) at a time, emits grams as soon as they are
6262
determined, and can be `flush`ed to drain the tail.
6363

64-
The consumer receives `(gram, end, follow)`: the n-gram, the position of the character just after
65-
it, and that following byte when it has already been fed (`None` at the current stream end).
64+
The consumer receives `(gram, end, follow, bytes)`: the n-gram, the position of the character just
65+
after it, that following byte when it has already been fed (`None` at the current stream end), and
66+
the gram's index-folded bytes. `bytes` borrows a stack buffer valid only for the duration of the
67+
call — copy it if you need to keep it — so nothing is allocated per gram. A consumer that ignores
68+
the argument optimizes back to code identical to not reporting the bytes at all.
6669

6770
```rust
6871
use sparse_ngrams::{QueryGrams, NGram};
@@ -71,12 +74,13 @@ let mut q = QueryGrams::default();
7174
let mut grams = Vec::new();
7275
// Feed the query one character at a time (each is index-folded internally).
7376
for c in "hello world".chars() {
74-
q.append_char(c, |gram: NGram, _end: u32, _follow: Option<u8>| {
77+
q.append_char(c, |gram: NGram, _end: u32, _follow: Option<u8>, bytes: &[u8]| {
78+
assert_eq!(gram, NGram::from_bytes(bytes));
7579
grams.push(gram);
7680
});
7781
}
7882
// Drain the remaining tail grams.
79-
q.flush(|gram: NGram, _end, _follow| {
83+
q.flush(|gram: NGram, _end, _follow, _bytes| {
8084
grams.push(gram);
8185
});
8286

crates/sparse-ngrams/src/ngram.rs

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -131,23 +131,6 @@ impl NGram {
131131
Self::pack(len, payload)
132132
}
133133

134-
/// Builds an `NGram` from a right-shifted window where the gram's end byte is in the low
135-
/// byte and older bytes are above it. This helper keeps only the low `len` bytes, aligns them
136-
/// into the top bytes, and then delegates to [`from_window`](Self::from_window).
137-
#[inline]
138-
pub(crate) fn from_window_masked(value: u64, len: usize) -> Self {
139-
debug_assert!(
140-
(Self::LEN_BIAS as usize..=MAX_SPARSE_GRAM_SIZE).contains(&len),
141-
"ngram length {len} out of range [{}, {}]",
142-
Self::LEN_BIAS,
143-
MAX_SPARSE_GRAM_SIZE,
144-
);
145-
let bits = (len * 8) as u32;
146-
let low_mask = u64::MAX >> (u64::BITS - bits);
147-
let aligned = (value & low_mask) << ((MAX_SPARSE_GRAM_SIZE - len) * 8);
148-
Self::from_window(aligned, len)
149-
}
150-
151134
/// Packs a length and payload into the structured value, then stores it *mixed* (via [`mix27`])
152135
/// so the hot sorting/bucketing paths read a well-distributed value directly from the field;
153136
/// [`len`](Self::len) and [`Debug`] unmix on demand.

crates/sparse-ngrams/src/query.rs

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ impl Queue {
9494
/// emits the minimum number of grams that cover the input stream. Its state space is fully
9595
/// represented by the content buffer and can be shrunk on demand when only part of the stream
9696
/// needs to be retained.
97+
///
98+
/// # Consumer callback
99+
///
100+
/// Every emitting method ([`append_char`](Self::append_char), [`append_byte`](Self::append_byte),
101+
/// [`flush`](Self::flush), [`consume_first`](Self::consume_first)) takes a consumer of shape
102+
/// `FnMut(NGram, u32, Option<u8>, &[u8])`, called once per emitted gram with:
103+
///
104+
/// * `gram` — the compact [`NGram`] identifier to look up in an index.
105+
/// * `end` — the position of the character just after the gram in the fed stream.
106+
/// * `follow` — that following (index-folded) byte, when it has already been fed; `None` at the
107+
/// current stream end.
108+
/// * `bytes` — the gram's index-folded bytes, in reading order. They are borrowed from a stack
109+
/// buffer that is only valid for the duration of the call, so a consumer that needs to keep them
110+
/// must copy them. Nothing is allocated per gram, and a consumer that ignores the argument
111+
/// optimizes back to code identical to not reporting the bytes at all.
97112
#[derive(Clone)]
98113
pub struct QueryGrams {
99114
/// Queue of candidate boundaries (strictly increasing indices and nondecreasing priorities).
@@ -202,26 +217,41 @@ impl QueryGrams {
202217

203218
fn extract_gram<F>(&mut self, begin_index: u32, end_index: u32, consumer: &mut F)
204219
where
205-
F: FnMut(NGram, u32, Option<u8>),
220+
F: FnMut(NGram, u32, Option<u8>, &[u8]),
206221
{
207222
debug_assert!(end_index >= begin_index);
208223
debug_assert!(end_index <= self.content_end_idx);
209224

210225
let len = (end_index - begin_index + 2) as usize;
211226
let dist = self.content_end_idx - end_index;
212227
let shifted = self.content >> (dist * 8);
228+
// The gram is the low `len` bytes of `shifted`; shifting them up into the most-significant
229+
// bytes gives both the big-endian layout `NGram::from_window` consumes and, via
230+
// `to_be_bytes`, the gram's bytes in reading order in the first `len` slots.
231+
let aligned = shifted << ((MAX_SPARSE_GRAM_SIZE - len) * 8);
232+
let bytes = aligned.to_be_bytes();
213233
// Report the position of the character just after the emitted ngram, along with that
214234
// character itself when it has already been fed (see `follow_byte`).
215235
let follow = self.follow_byte(end_index);
216-
consumer(NGram::from_window_masked(shifted, len), end_index, follow);
236+
// `len` is always in `2..=MAX_SPARSE_GRAM_SIZE` (`from_window` debug-asserts it), so the
237+
// clamp never changes the slice. It is what makes the bound *statically* provable: without
238+
// it the slicing keeps a bounds-check panic path alive, and that observable side effect
239+
// stops the optimizer from eliminating the byte buffer for consumers that ignore it.
240+
consumer(
241+
NGram::from_window(aligned, len),
242+
end_index,
243+
follow,
244+
&bytes[..len.min(MAX_SPARSE_GRAM_SIZE)],
245+
);
217246
}
218247

219248
/// Appends a single character to the n-gram state.
220249
///
221250
/// The character is index-folded using the `casefold` crate and may trigger one or more grams.
251+
/// See the [type-level docs](Self#consumer-callback) for the consumer arguments.
222252
pub fn append_char<F>(&mut self, c: char, consumer: F)
223253
where
224-
F: FnMut(NGram, u32, Option<u8>),
254+
F: FnMut(NGram, u32, Option<u8>, &[u8]),
225255
{
226256
self.append_byte(index_fold_char(c), consumer);
227257
}
@@ -233,7 +263,7 @@ impl QueryGrams {
233263
/// applied. May trigger one or more grams.
234264
pub fn append_byte<F>(&mut self, right: u8, mut consumer: F)
235265
where
236-
F: FnMut(NGram, u32, Option<u8>),
266+
F: FnMut(NGram, u32, Option<u8>, &[u8]),
237267
{
238268
let left = (self.content & 0xFF) as u8;
239269
self.content_end_idx += 1;
@@ -276,7 +306,7 @@ impl QueryGrams {
276306
/// Flushes all buffered characters and emits remaining grams.
277307
pub fn flush<F>(mut self, mut consumer: F)
278308
where
279-
F: FnMut(NGram, u32, Option<u8>),
309+
F: FnMut(NGram, u32, Option<u8>, &[u8]),
280310
{
281311
if self.content_end_idx == 2 {
282312
self.extract_gram(2, 2, &mut consumer);
@@ -292,7 +322,7 @@ impl QueryGrams {
292322
/// Consumes and emits at most one queued gram (if available), shrinking state.
293323
pub fn consume_first<F>(&mut self, mut consumer: F)
294324
where
295-
F: FnMut(NGram, u32, Option<u8>),
325+
F: FnMut(NGram, u32, Option<u8>, &[u8]),
296326
{
297327
if self.queue.len > 1 {
298328
// Emit the gram spanning the first boundary to the next one, mirroring `flush`.
@@ -343,12 +373,12 @@ mod tests {
343373
let mut q = QueryGrams::default();
344374
let mut out = Vec::new();
345375
for c in input.chars() {
346-
q.append_char(c, |gram, end, _follow| {
376+
q.append_char(c, |gram, end, _follow, _bytes| {
347377
let begin = end + 1 - gram.len() as u32;
348378
out.push(begin..end - 1);
349379
});
350380
}
351-
q.flush(|gram, end, _follow| {
381+
q.flush(|gram, end, _follow, _bytes| {
352382
let begin = end + 1 - gram.len() as u32;
353383
out.push(begin..end - 1);
354384
});
@@ -428,10 +458,10 @@ mod tests {
428458
fn append_and_flush_emit_grams() {
429459
let mut q = QueryGrams::default();
430460
for c in "hello world".chars() {
431-
q.append_char(c, |_gram, _idx, _follow| {});
461+
q.append_char(c, |_gram, _idx, _follow, _bytes| {});
432462
}
433463
let mut out = Vec::new();
434-
q.flush(|gram, idx, _follow| out.push((gram, idx)));
464+
q.flush(|gram, idx, _follow, _bytes| out.push((gram, idx)));
435465
assert!(!out.is_empty());
436466
assert!(
437467
out.iter()
@@ -446,17 +476,18 @@ mod tests {
446476
let bytes: Vec<u8> = "ab".chars().map(crate::index_fold_char).collect();
447477
let mut q = QueryGrams::default();
448478
for &b in &bytes {
449-
q.append_byte(b, |_gram, _end, _follow| {});
479+
q.append_byte(b, |_gram, _end, _follow, _bytes| {});
450480
}
451481
let mut emitted = Vec::new();
452-
q.flush(|gram, end, follow| emitted.push((gram.len(), end, follow)));
482+
q.flush(|gram, end, follow, _bytes| emitted.push((gram.len(), end, follow)));
453483
assert_eq!(emitted, vec![(2usize, 2u32, None)]);
454484
}
455485

456486
#[test]
457-
fn emitted_follow_is_the_actual_next_char() {
487+
fn emitted_follow_and_bytes_match_the_input() {
458488
// Whenever a gram is emitted with a follow character, it must be the actual next byte of the
459489
// input at the reported boundary (and a gram ending at the newest fed byte reports `None`).
490+
// The emitted byte slice must likewise be the input slice the gram was built from.
460491
for input in [
461492
"ab",
462493
"abc",
@@ -469,7 +500,17 @@ mod tests {
469500
let bytes: Vec<u8> = input.chars().map(crate::index_fold_char).collect();
470501
let n = bytes.len() as u32;
471502
let mut q = QueryGrams::default();
472-
let mut check = |_gram: NGram, end: u32, follow: Option<u8>| {
503+
let mut check = |gram: NGram, end: u32, follow: Option<u8>, gram_bytes: &[u8]| {
504+
assert_eq!(
505+
gram_bytes,
506+
&bytes[end as usize - gram.len()..end as usize],
507+
"wrong gram bytes for {input:?} at end={end}"
508+
);
509+
assert_eq!(
510+
gram,
511+
NGram::from_bytes(gram_bytes),
512+
"gram bytes disagree with the emitted key for {input:?} at end={end}"
513+
);
473514
if let Some(b) = follow {
474515
assert!(
475516
end < n,
@@ -516,7 +557,7 @@ mod tests {
516557
"state moved without an append before byte {i}"
517558
);
518559

519-
q.append_byte(byte, |_, _, _| {});
560+
q.append_byte(byte, |_, _, _, _| {});
520561

521562
let len = expected_len[i];
522563
let window = &input[i + 1 - len as usize..=i];
@@ -537,12 +578,12 @@ mod tests {
537578
let mut b = QueryGrams::default();
538579

539580
for c in "abc".chars() {
540-
a.append_char(c, |_gram, _idx, _follow| {});
581+
a.append_char(c, |_gram, _idx, _follow, _bytes| {});
541582
}
542583
for c in "zabc".chars() {
543-
b.append_char(c, |_gram, _idx, _follow| {});
584+
b.append_char(c, |_gram, _idx, _follow, _bytes| {});
544585
}
545-
b.consume_first(|_gram, _idx, _follow| {});
586+
b.consume_first(|_gram, _idx, _follow, _bytes| {});
546587

547588
// Only assert hash consistency when Eq says they are equivalent.
548589
if a == b {
@@ -724,14 +765,14 @@ mod tests {
724765
// plus grams drained via `consume_first`.
725766
let mut first_half = Vec::new();
726767
for c in prefix.chars() {
727-
q.append_char(c, |gram, end, _follow| {
768+
q.append_char(c, |gram, end, _follow, _bytes| {
728769
let begin = end + 1 - gram.len() as u32;
729770
first_half.push(begin..end - 1);
730771
});
731772
}
732773

733774
while !q.queue.is_empty() {
734-
q.consume_first(|gram, end, _follow| {
775+
q.consume_first(|gram, end, _follow, _bytes| {
735776
let begin = end + 1 - gram.len() as u32;
736777
first_half.push(begin..end - 1);
737778
});
@@ -770,12 +811,12 @@ mod tests {
770811

771812
let mut remaining = Vec::new();
772813
for c in suffix.chars() {
773-
q.append_char(c, |gram, end, _follow| {
814+
q.append_char(c, |gram, end, _follow, _bytes| {
774815
let begin = end + 1 - gram.len() as u32;
775816
remaining.push(begin..end - 1);
776817
});
777818
}
778-
q.flush(|gram, end, _follow| {
819+
q.flush(|gram, end, _follow, _bytes| {
779820
let begin = end + 1 - gram.len() as u32;
780821
remaining.push(begin..end - 1);
781822
});
@@ -825,12 +866,12 @@ mod tests {
825866
for input in ["abc", "abcdef", "hello world", "ababababab", "mississippi"] {
826867
let mut q = QueryGrams::default();
827868
for c in input.chars() {
828-
q.append_char(c, |_gram, _idx, _follow| {});
869+
q.append_char(c, |_gram, _idx, _follow, _bytes| {});
829870
}
830871
// Far more calls than there are bytes: the state must be at the default fixpoint well
831872
// before this loop ends, and further calls must keep it there.
832873
for _ in 0..(input.len() + 8) {
833-
q.consume_first(|_gram, _idx, _follow| {});
874+
q.consume_first(|_gram, _idx, _follow, _bytes| {});
834875
}
835876
assert_eq!(
836877
q.state(),

0 commit comments

Comments
 (0)