Skip to main content

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}