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 /// Packs a length and payload into the structured value, then stores it *mixed* (via [`mix27`])
135 /// so the hot sorting/bucketing paths read a well-distributed value directly from the field;
136 /// [`len`](Self::len) and [`Debug`] unmix on demand.
137 #[inline]
138 fn pack(len: usize, payload: u32) -> Self {
139 let packed = ((len as u32 - Self::LEN_BIAS) << Self::PAYLOAD_BITS) | payload;
140 Self(mix27(packed))
141 }
142
143 /// The byte length of the n-gram.
144 #[inline]
145 pub fn len(&self) -> usize {
146 // The length lives in the *packed* value; unmix the stored value first.
147 let packed = unmix27(self.0);
148 (((packed >> Self::PAYLOAD_BITS) & Self::LEN_MASK) + Self::LEN_BIAS) as usize
149 }
150
151 /// Whether this represents an empty gram. Valid n-grams are always at least 2 bytes long, so
152 /// this only holds for a default-constructed placeholder.
153 #[inline]
154 pub fn is_empty(&self) -> bool {
155 self.len() == 0
156 }
157
158 /// The raw packed `u32`. This is an opaque, well-distributed identifier suitable as a hash-map
159 /// or hash-set key.
160 #[inline]
161 pub fn as_u32(&self) -> u32 {
162 self.0
163 }
164}
165
166/// A bijective 27-bit mixing permutation. The packed gram value is highly structured — the length
167/// sits in the top 3 bits and short grams carry raw ASCII bytes — which would make the
168/// most-significant bits badly skewed. This xorshift-multiply finalizer, restricted to 27 bits,
169/// spreads entropy across all bits while remaining a bijection on `[0, 2^27)` (each step is
170/// invertible: xorshifts are triangular GF(2) maps, and multiplication by an odd constant is a
171/// unit modulo `2^27`), so distinct grams stay distinct.
172fn mix27(mut x: u32) -> u32 {
173 debug_assert!(x <= NGram::MASK, "mix27 input must be a 27-bit value");
174 x ^= x >> 15;
175 x = x.wrapping_mul(0x2c1b_3c6d) & NGram::MASK;
176 x ^= x >> 12;
177 x = x.wrapping_mul(0x297a_2d39) & NGram::MASK;
178 x ^= x >> 15;
179 x
180}
181
182/// Inverse of [`mix27`]: recovers the packed (length + payload) value from the stored value. Each
183/// step undoes the corresponding `mix27` step in reverse: the `>> 15` xorshifts are self-inverse
184/// (since `2 * 15 >= 27`), the `>> 12` xorshift is undone by the doubling `>> 12` then `>> 24`, and
185/// the multiplies by the modular inverses of their constants (mod `2^27`).
186fn unmix27(mut x: u32) -> u32 {
187 debug_assert!(x <= NGram::MASK, "unmix27 input must be a 27-bit value");
188 x ^= x >> 15;
189 x = x.wrapping_mul(0x4f0_b109) & NGram::MASK; // inverse of 0x297a_2d39 mod 2^27
190 x ^= x >> 12;
191 x ^= x >> 24;
192 x = x.wrapping_mul(0x4ea_2d65) & NGram::MASK; // inverse of 0x2c1b_3c6d mod 2^27
193 x ^= x >> 15;
194 x
195}
196
197/// The encoded `u32` representation is not human readable. This formatter improves the situation
198/// at least for short ascii grams.
199impl fmt::Debug for NGram {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 let packed = unmix27(self.0);
202 let len = (((packed >> Self::PAYLOAD_BITS) & Self::LEN_MASK) + Self::LEN_BIAS) as usize;
203 let mut s = String::new();
204 if len <= 3 {
205 // The `len` payload bytes sit right-aligned in the low bits of the packed value,
206 // most-significant byte first (see `from_bytes`).
207 let payload = packed & Self::PAYLOAD_MASK;
208 let bytes = [(payload >> 16) as u8, (payload >> 8) as u8, payload as u8];
209 for &byte in &bytes[3 - len..3] {
210 if byte.is_ascii_graphic() || byte == b' ' {
211 s.push(byte as char);
212 } else {
213 write!(s, "\\x{byte:02x}")?;
214 }
215 }
216 } else {
217 write!(s, "{:#08x}", packed & Self::PAYLOAD_MASK)?;
218 }
219 write!(f, "NGram('{s}', len={len})")
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn mix27_is_invertible() {
229 // `mix27` must be a bijection on the 27-bit space so distinct grams never collide; sample
230 // the space plus boundaries and check the round-trip.
231 for x in (0..=NGram::MASK).step_by(97) {
232 assert_eq!(unmix27(mix27(x)), x, "round-trip failed for {x:#x}");
233 }
234 for x in [0, 1, 2, NGram::MASK - 1, NGram::MASK] {
235 assert_eq!(unmix27(mix27(x)), x, "round-trip failed for {x:#x}");
236 }
237 }
238
239 #[test]
240 fn test_from_bytes_roundtrip() {
241 for len in 2..=MAX_SPARSE_GRAM_SIZE {
242 let bytes = vec![b'a'; len];
243 assert_eq!(
244 NGram::from_bytes(&bytes).len(),
245 len,
246 "len mismatch for {len}"
247 );
248 }
249 }
250
251 #[test]
252 fn test_equal_content_equal_ngram() {
253 assert_eq!(NGram::from_bytes(b"abc"), NGram::from_bytes(b"abc"));
254 assert_eq!(NGram::from_bytes(b"abcdef"), NGram::from_bytes(b"abcdef"));
255 }
256
257 #[test]
258 fn test_short_grams_are_lossless() {
259 // Distinct grams of length <= 3 are packed losslessly, so they must never collide.
260 use std::collections::HashSet;
261 let mut seen = HashSet::new();
262 for a in 0u8..64 {
263 for b in 0u8..64 {
264 assert!(seen.insert(NGram::from_bytes(&[a, b])), "bigram collision");
265 for c in 0u8..8 {
266 assert!(
267 seen.insert(NGram::from_bytes(&[a, b, c])),
268 "trigram collision"
269 );
270 }
271 }
272 }
273 }
274
275 #[test]
276 fn test_same_content_different_length() {
277 // Even if payloads were to collide, different lengths produce different NGrams.
278 let a = NGram::from_bytes(b"ab");
279 let b = NGram::from_bytes(b"abc");
280 assert_ne!(a, b);
281 assert_ne!(a.len(), b.len());
282 }
283
284 #[test]
285 fn from_window_matches_from_bytes() {
286 // The extraction loop builds grams from a big-endian rolling window via `from_window`; it
287 // must produce the identical value to `from_bytes`. Check every indexable length with
288 // distinct bytes.
289 for len in (NGram::LEN_BIAS as usize)..=MAX_SPARSE_GRAM_SIZE {
290 let bytes: Vec<u8> = (0..len as u8).map(|i| b'a' + i).collect();
291 let mut buf = [0u8; 8];
292 buf[..len].copy_from_slice(&bytes);
293 // Gram bytes left-aligned in the most-significant bytes, low bytes zero.
294 let window = u64::from_be_bytes(buf);
295 assert_eq!(
296 NGram::from_window(window, len),
297 NGram::from_bytes(&bytes),
298 "mismatch for {len}-byte gram",
299 );
300 }
301 }
302
303 #[test]
304 fn test_default_is_not_empty() {
305 // Valid n-grams are always at least 2 bytes, so nothing (not even the default placeholder)
306 // is ever "empty".
307 assert!(!NGram::default().is_empty());
308 assert_eq!(NGram::default().len(), 2);
309 }
310}