sparse_ngrams/ngram.rs
1//! Compact n-gram representation.
2//!
3//! An [`NGram`] packs a substring's byte length and a payload into the low **27 bits** of a
4//! `u32` (the top 5 bits are always zero):
5//!
6//! ```text
7//! bit 31 27 24 0
8//! +-----------------+-----------+-----------+
9//! | 00000 (unused) | len - 2 | payload |
10//! +-----------------+-----------+-----------+
11//! 5 bits 3 bits 24 bits
12//! ```
13//!
14//! * **Length** (`len - 2`, bits 24..27): substring byte-lengths range from 2 (bigrams) to
15//! [`MAX_SPARSE_GRAM_SIZE`] (8), so biasing by 2 fits the 7 possible values into 3 bits.
16//! * **Payload** (bits 0..24): for substrings of at most 3 bytes the bytes are packed
17//! losslessly (right-aligned in the low bits, most-significant byte first); longer substrings are
18//! hashed down to 24 bits with a multiplicative hash.
19//!
20//! Because the length lives in its own field, an `NGram` of one size never collides with an
21//! `NGram` of another size. The packed value is finally run through a bijective [`mix27`]
22//! permutation so the most-significant bits (which callers may use for bucketing or sorting) are
23//! well distributed even though the packed value is highly structured.
24
25use std::fmt::{self, Write as _};
26
27use crate::MAX_SPARSE_GRAM_SIZE;
28
29/// Odd multiplicative constant (the golden-ratio / Fibonacci hashing constant) used to hash grams
30/// longer than 3 bytes down to the 24-bit payload.
31const MULTIPLICATIVE_HASH: u64 = 0x9E37_79B9_7F4A_7C15;
32
33/// A compact n-gram identifier. See the module-level documentation for the bit layout.
34///
35/// Note: we could store n-grams up to length 8 verbatim in a `u64`. However, that would explode
36/// the number of distinct keys in a search dictionary. For that reason we compress n-grams into a
37/// `u32`, which puts a more reasonable upper bound on the number of dictionary keys.
38///
39/// Note: by storing the length explicitly, we ensure that only n-grams of the same length can
40/// collide. This is important because there are exponentially more long n-grams than short ones.
41/// At the same time, longer n-grams occur less frequently, so colliding long n-grams won't
42/// increase the false-positive rate too much.
43///
44/// # Construction
45///
46/// Use [`NGram::from_bytes`] for one-off hashing, or the rolling 8-byte window helper inside the
47/// extraction loop for amortised O(1) computation per n-gram.
48#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50#[repr(transparent)]
51pub struct NGram(pub(crate) u32);
52
53impl NGram {
54 /// Smallest indexed gram length (bigrams); subtracted from the stored length so the biased
55 /// value fits in [`Self::LEN_BITS`] bits.
56 const LEN_BIAS: u32 = 2;
57 /// Number of bits used to encode the biased length.
58 const LEN_BITS: u32 = 3;
59 /// Mask selecting the biased-length field (once shifted down).
60 const LEN_MASK: u32 = (1 << Self::LEN_BITS) - 1;
61 /// Number of low bits used by the payload; the length sits just above it.
62 const PAYLOAD_BITS: u32 = 24;
63 /// Mask selecting the payload field.
64 const PAYLOAD_MASK: u32 = (1 << Self::PAYLOAD_BITS) - 1;
65 /// Total number of significant bits in the packed representation (the top 5 bits of the `u32`
66 /// are always zero).
67 pub(crate) const BITS: u32 = Self::PAYLOAD_BITS + Self::LEN_BITS;
68 /// Mask selecting the significant [`Self::BITS`] bits.
69 pub(crate) const MASK: u32 = (1 << Self::BITS) - 1;
70
71 /// Build an `NGram` by hashing the given byte slice from scratch.
72 ///
73 /// # Panics
74 ///
75 /// In debug builds, panics if `src.len()` is not in `2..=`[`MAX_SPARSE_GRAM_SIZE`].
76 pub fn from_bytes(src: &[u8]) -> Self {
77 debug_assert!(
78 (Self::LEN_BIAS as usize..=MAX_SPARSE_GRAM_SIZE).contains(&src.len()),
79 "ngram length {} out of range [{}, {}]",
80 src.len(),
81 Self::LEN_BIAS,
82 MAX_SPARSE_GRAM_SIZE,
83 );
84 // 24-bit payload: short grams are packed losslessly, longer ones hashed.
85 let payload = if src.len() <= 3 {
86 // Pack the 2-3 bytes into the low 24 bits, most-significant byte first.
87 let mut p = 0u32;
88 for &byte in src {
89 p = (p << 8) | byte as u32;
90 }
91 p
92 } else {
93 // Grams here are 4..=MAX_SPARSE_GRAM_SIZE (8) bytes, so they fit in a single u64. A
94 // multiplicative hash is much cheaper than a per-byte loop, and the top bits of the
95 // product mix in every input byte. `from_le_bytes` keeps the result independent of
96 // host endianness, and the gram length lives in its own field so the payload hash
97 // needn't encode it.
98 let mut buf = [0u8; 8];
99 buf[..src.len()].copy_from_slice(src);
100 let product = u64::from_le_bytes(buf).wrapping_mul(MULTIPLICATIVE_HASH);
101 (product >> (u64::BITS - Self::PAYLOAD_BITS)) as u32
102 };
103 Self::pack(src.len(), payload)
104 }
105
106 /// Builds an `NGram` from a big-endian packing of its bytes: the `len` gram bytes occupy the
107 /// most-significant bytes of `value` (the first gram byte in the top byte) and the low
108 /// `8 - len` bytes are zero. This is the form the extraction loop's rolling 8-byte window
109 /// produces, so it can construct grams without re-reading them from a slice. It returns exactly
110 /// the same value as [`from_bytes`](Self::from_bytes) would for the same gram (see the
111 /// `from_window_matches_from_bytes` test), so the two paths stay interchangeable.
112 #[inline]
113 pub(crate) fn from_window(value: u64, len: usize) -> Self {
114 debug_assert!(
115 (Self::LEN_BIAS as usize..=MAX_SPARSE_GRAM_SIZE).contains(&len),
116 "ngram length {len} out of range [{}, {}]",
117 Self::LEN_BIAS,
118 MAX_SPARSE_GRAM_SIZE,
119 );
120 // 24-bit payload: short grams are packed losslessly, longer ones hashed.
121 let payload = if len <= 3 {
122 // The bytes sit in the top `len` bytes, most-significant byte first; shifting them down
123 // to the low bits reproduces `from_bytes`'s lossless packing.
124 (value >> (u64::BITS - len as u32 * 8)) as u32
125 } else {
126 // `swap_bytes` turns the big-endian window into the little-endian byte order that
127 // `from_bytes` feeds to the multiplicative hash, so both paths agree bit-for-bit.
128 let product = value.swap_bytes().wrapping_mul(MULTIPLICATIVE_HASH);
129 (product >> (u64::BITS - Self::PAYLOAD_BITS)) as u32
130 };
131 Self::pack(len, payload)
132 }
133
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
151 /// Packs a length and payload into the structured value, then stores it *mixed* (via [`mix27`])
152 /// so the hot sorting/bucketing paths read a well-distributed value directly from the field;
153 /// [`len`](Self::len) and [`Debug`] unmix on demand.
154 #[inline]
155 fn pack(len: usize, payload: u32) -> Self {
156 let packed = ((len as u32 - Self::LEN_BIAS) << Self::PAYLOAD_BITS) | payload;
157 Self(mix27(packed))
158 }
159
160 /// The byte length of the n-gram.
161 #[inline]
162 pub fn len(&self) -> usize {
163 // The length lives in the *packed* value; unmix the stored value first.
164 let packed = unmix27(self.0);
165 (((packed >> Self::PAYLOAD_BITS) & Self::LEN_MASK) + Self::LEN_BIAS) as usize
166 }
167
168 /// Whether this represents an empty gram. Valid n-grams are always at least 2 bytes long, so
169 /// this only holds for a default-constructed placeholder.
170 #[inline]
171 pub fn is_empty(&self) -> bool {
172 self.len() == 0
173 }
174
175 /// The raw packed `u32`. This is an opaque, well-distributed identifier suitable as a hash-map
176 /// or hash-set key.
177 #[inline]
178 pub fn as_u32(&self) -> u32 {
179 self.0
180 }
181}
182
183/// A bijective 27-bit mixing permutation. The packed gram value is highly structured — the length
184/// sits in the top 3 bits and short grams carry raw ASCII bytes — which would make the
185/// most-significant bits badly skewed. This xorshift-multiply finalizer, restricted to 27 bits,
186/// spreads entropy across all bits while remaining a bijection on `[0, 2^27)` (each step is
187/// invertible: xorshifts are triangular GF(2) maps, and multiplication by an odd constant is a
188/// unit modulo `2^27`), so distinct grams stay distinct.
189fn mix27(mut x: u32) -> u32 {
190 debug_assert!(x <= NGram::MASK, "mix27 input must be a 27-bit value");
191 x ^= x >> 15;
192 x = x.wrapping_mul(0x2c1b_3c6d) & NGram::MASK;
193 x ^= x >> 12;
194 x = x.wrapping_mul(0x297a_2d39) & NGram::MASK;
195 x ^= x >> 15;
196 x
197}
198
199/// Inverse of [`mix27`]: recovers the packed (length + payload) value from the stored value. Each
200/// step undoes the corresponding `mix27` step in reverse: the `>> 15` xorshifts are self-inverse
201/// (since `2 * 15 >= 27`), the `>> 12` xorshift is undone by the doubling `>> 12` then `>> 24`, and
202/// the multiplies by the modular inverses of their constants (mod `2^27`).
203fn unmix27(mut x: u32) -> u32 {
204 debug_assert!(x <= NGram::MASK, "unmix27 input must be a 27-bit value");
205 x ^= x >> 15;
206 x = x.wrapping_mul(0x4f0_b109) & NGram::MASK; // inverse of 0x297a_2d39 mod 2^27
207 x ^= x >> 12;
208 x ^= x >> 24;
209 x = x.wrapping_mul(0x4ea_2d65) & NGram::MASK; // inverse of 0x2c1b_3c6d mod 2^27
210 x ^= x >> 15;
211 x
212}
213
214/// The encoded `u32` representation is not human readable. This formatter improves the situation
215/// at least for short ascii grams.
216impl fmt::Debug for NGram {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 let packed = unmix27(self.0);
219 let len = (((packed >> Self::PAYLOAD_BITS) & Self::LEN_MASK) + Self::LEN_BIAS) as usize;
220 let mut s = String::new();
221 if len <= 3 {
222 // The `len` payload bytes sit right-aligned in the low bits of the packed value,
223 // most-significant byte first (see `from_bytes`).
224 let payload = packed & Self::PAYLOAD_MASK;
225 let bytes = [(payload >> 16) as u8, (payload >> 8) as u8, payload as u8];
226 for &byte in &bytes[3 - len..3] {
227 if byte.is_ascii_graphic() || byte == b' ' {
228 s.push(byte as char);
229 } else {
230 write!(s, "\\x{byte:02x}")?;
231 }
232 }
233 } else {
234 write!(s, "{:#08x}", packed & Self::PAYLOAD_MASK)?;
235 }
236 write!(f, "NGram('{s}', len={len})")
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn mix27_is_invertible() {
246 // `mix27` must be a bijection on the 27-bit space so distinct grams never collide; sample
247 // the space plus boundaries and check the round-trip.
248 for x in (0..=NGram::MASK).step_by(97) {
249 assert_eq!(unmix27(mix27(x)), x, "round-trip failed for {x:#x}");
250 }
251 for x in [0, 1, 2, NGram::MASK - 1, NGram::MASK] {
252 assert_eq!(unmix27(mix27(x)), x, "round-trip failed for {x:#x}");
253 }
254 }
255
256 #[test]
257 fn test_from_bytes_roundtrip() {
258 for len in 2..=MAX_SPARSE_GRAM_SIZE {
259 let bytes = vec![b'a'; len];
260 assert_eq!(
261 NGram::from_bytes(&bytes).len(),
262 len,
263 "len mismatch for {len}"
264 );
265 }
266 }
267
268 #[test]
269 fn test_equal_content_equal_ngram() {
270 assert_eq!(NGram::from_bytes(b"abc"), NGram::from_bytes(b"abc"));
271 assert_eq!(NGram::from_bytes(b"abcdef"), NGram::from_bytes(b"abcdef"));
272 }
273
274 #[test]
275 fn test_short_grams_are_lossless() {
276 // Distinct grams of length <= 3 are packed losslessly, so they must never collide.
277 use std::collections::HashSet;
278 let mut seen = HashSet::new();
279 for a in 0u8..64 {
280 for b in 0u8..64 {
281 assert!(seen.insert(NGram::from_bytes(&[a, b])), "bigram collision");
282 for c in 0u8..8 {
283 assert!(
284 seen.insert(NGram::from_bytes(&[a, b, c])),
285 "trigram collision"
286 );
287 }
288 }
289 }
290 }
291
292 #[test]
293 fn test_same_content_different_length() {
294 // Even if payloads were to collide, different lengths produce different NGrams.
295 let a = NGram::from_bytes(b"ab");
296 let b = NGram::from_bytes(b"abc");
297 assert_ne!(a, b);
298 assert_ne!(a.len(), b.len());
299 }
300
301 #[test]
302 fn from_window_matches_from_bytes() {
303 // The extraction loop builds grams from a big-endian rolling window via `from_window`; it
304 // must produce the identical value to `from_bytes`. Check every indexable length with
305 // distinct bytes.
306 for len in (NGram::LEN_BIAS as usize)..=MAX_SPARSE_GRAM_SIZE {
307 let bytes: Vec<u8> = (0..len as u8).map(|i| b'a' + i).collect();
308 let mut buf = [0u8; 8];
309 buf[..len].copy_from_slice(&bytes);
310 // Gram bytes left-aligned in the most-significant bytes, low bytes zero.
311 let window = u64::from_be_bytes(buf);
312 assert_eq!(
313 NGram::from_window(window, len),
314 NGram::from_bytes(&bytes),
315 "mismatch for {len}-byte gram",
316 );
317 }
318 }
319
320 #[test]
321 fn test_default_is_not_empty() {
322 // Valid n-grams are always at least 2 bytes, so nothing (not even the default placeholder)
323 // is ever "empty".
324 assert!(!NGram::default().is_empty());
325 assert_eq!(NGram::default().len(), 2);
326 }
327}