plugmem_arena/interner.rs
1//! String interner: `&str` -> dense [`TermId`].
2//!
3//! Tokenized terms, tags and entity names repeat constantly; the engine
4//! stores them once and passes 4-byte ids around. The interner is a
5//! [`BlobHeap`] (the string bytes; `BlobId` doubles as [`TermId`]) plus one
6//! flat open-addressing hash table — no per-string allocations, and both
7//! sections snapshot as-is (the table is stored, not rebuilt: cold-start
8//! time matters more than 4 bytes per slot).
9//!
10//! Interning is **never-forget**: terms are not removed. Vocabulary grows
11//! slowly in practice (Zipf), and dictionary compaction is explicitly out of
12//! scope for v1.
13
14use alloc::vec::Vec;
15use core::fmt;
16
17use xxhash_rust::xxh3::xxh3_64;
18
19use crate::blob::{BlobHeap, BlobHeapCfg, BlobId};
20use crate::error::Error;
21
22/// Handle to one interned string: dense, assigned in first-seen order,
23/// starting at 0. Numerically equal to the underlying heap's `BlobId`.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct TermId(pub u32);
27
28/// Initial hash-table size (slots); must be a power of two.
29const INITIAL_SLOTS: usize = 16;
30
31/// Maximum load factor as an integer fraction (`LOAD_NUM / LOAD_DEN` = 0.7):
32/// the table grows before `len / slots` would exceed it. Integer form avoids
33/// float math in `no_std` and keeps the same test in `intern` and on load.
34const LOAD_NUM: usize = 7;
35/// Denominator of the [`LOAD_NUM`] load-factor fraction.
36const LOAD_DEN: usize = 10;
37
38/// Serialized width of one table entry (little-endian `u32`: `0` = empty,
39/// else `TermId + 1`).
40const SLOT_BYTES: usize = core::mem::size_of::<u32>();
41
42/// Serialized metadata header of the dumped table: `[slots u32][len u32]`
43/// (see [`Interner::dump_table`]).
44const TABLE_HEADER: usize = 2 * core::mem::size_of::<u32>();
45
46/// A deduplicating string store over a [`BlobHeap`] and a flat hash table.
47///
48/// ```
49/// use plugmem_arena::{BlobHeapCfg, Interner};
50///
51/// let mut terms = Interner::new(BlobHeapCfg::new());
52/// let apple = terms.intern("apple").unwrap();
53/// let banana = terms.intern("banana").unwrap();
54/// assert_eq!(terms.intern("apple").unwrap(), apple); // stable id
55/// assert_ne!(apple, banana);
56/// assert_eq!(terms.resolve(apple), "apple");
57/// assert_eq!(terms.len(), 2);
58/// ```
59///
60/// `Clone` is cheap-ish (two flat memcpys) and safe: unlike the arena, every
61/// stored byte is initialized.
62#[derive(Clone)]
63pub struct Interner<'a> {
64 /// String bytes; `BlobId` values equal `TermId` values. Borrows from a
65 /// mapped snapshot on the read-only path, tied to `'a`; the
66 /// table stays owned (it is small and rebuilt-free).
67 heap: BlobHeap<'a>,
68 /// Open-addressing table: `0` = empty slot, otherwise `TermId + 1`.
69 /// Length is always a power of two; load factor is kept <= 0.7.
70 table: Vec<u32>,
71 /// Number of interned strings.
72 len: u32,
73 /// Table probes performed (feature `counters`): every slot inspection
74 /// during intern and rehash. The deterministic health metric of the
75 /// hash function + load factor combination.
76 #[cfg(feature = "counters")]
77 probes: u64,
78}
79
80impl<'a> Interner<'a> {
81 /// Creates an empty interner; `cfg` bounds the underlying string heap
82 /// ([`BlobHeapCfg::max_blob`] caps a single term's byte length). The
83 /// hash table itself is small (4 bytes per slot) and not counted against
84 /// `cfg.max_bytes`.
85 pub fn new(cfg: BlobHeapCfg) -> Self {
86 Self {
87 heap: BlobHeap::new(cfg),
88 table: alloc::vec![0; INITIAL_SLOTS],
89 len: 0,
90 #[cfg(feature = "counters")]
91 probes: 0,
92 }
93 }
94
95 /// Returns the id for `s`, storing it on first sight.
96 ///
97 /// # Errors
98 ///
99 /// - [`Error::BlobTooLarge`] if `s` is longer than the configured
100 /// [`BlobHeapCfg::max_blob`];
101 /// - [`Error::CapacityExceeded`] if storing a new term would grow the
102 /// heap past [`BlobHeapCfg::max_bytes`].
103 ///
104 /// A failed intern leaves the interner unchanged.
105 pub fn intern(&mut self, s: &str) -> Result<TermId, Error> {
106 // Grow before probing so the insert below always finds an empty slot
107 // within the load-factor bound.
108 if (self.len as usize + 1) * LOAD_DEN > self.table.len() * LOAD_NUM {
109 self.rehash();
110 }
111 let bytes = s.as_bytes();
112 let mask = self.table.len() - 1;
113 let mut idx = xxh3_64(bytes) as usize & mask;
114 loop {
115 #[cfg(feature = "counters")]
116 {
117 self.probes += 1;
118 }
119 match self.table[idx] {
120 0 => {
121 let id = self.heap.push(bytes)?;
122 self.table[idx] = id.0 + 1;
123 self.len += 1;
124 return Ok(TermId(id.0));
125 }
126 entry if self.heap.get(BlobId(entry - 1)) == bytes => {
127 return Ok(TermId(entry - 1));
128 }
129 _ => idx = (idx + 1) & mask,
130 }
131 }
132 }
133
134 /// Returns the id of an already-interned string, without creating it.
135 ///
136 /// The read-only sibling of [`Interner::intern`] — query paths must
137 /// not grow the vocabulary (a query is not a mutation).
138 pub fn lookup(&self, s: &str) -> Option<TermId> {
139 let bytes = s.as_bytes();
140 let mask = self.table.len() - 1;
141 let mut idx = xxh3_64(bytes) as usize & mask;
142 loop {
143 match self.table[idx] {
144 0 => return None,
145 entry if self.heap.get(BlobId(entry - 1)) == bytes => {
146 return Some(TermId(entry - 1));
147 }
148 _ => idx = (idx + 1) & mask,
149 }
150 }
151 }
152
153 /// Returns the string behind an id. O(1).
154 ///
155 /// # Panics
156 ///
157 /// Panics if `id` was not returned by this interner's
158 /// [`Interner::intern`] — a dangling id is a caller bug.
159 pub fn resolve(&self, id: TermId) -> &str {
160 core::str::from_utf8(self.heap.get(BlobId(id.0)))
161 .expect("interner heap holds only pushed &str bytes")
162 }
163
164 /// Number of distinct strings interned.
165 pub fn len(&self) -> usize {
166 self.len as usize
167 }
168
169 /// `true` when nothing has been interned.
170 pub fn is_empty(&self) -> bool {
171 self.len == 0
172 }
173
174 /// Total bytes of interned string content (the underlying heap's pool
175 /// size; the hash table's few bytes per slot are not counted).
176 pub fn pool_bytes(&self) -> usize {
177 self.heap.pool_bytes()
178 }
179
180 /// Doubles the table and reinserts all entries. Amortized: one table
181 /// allocation, no per-string work beyond rehashing their bytes.
182 fn rehash(&mut self) {
183 let mask = self.table.len() * 2 - 1;
184 let mut table = alloc::vec![0u32; mask + 1];
185 for &entry in self.table.iter().filter(|&&e| e != 0) {
186 let mut idx = xxh3_64(self.heap.get(BlobId(entry - 1))) as usize & mask;
187 #[cfg(feature = "counters")]
188 {
189 self.probes += 1;
190 }
191 while table[idx] != 0 {
192 idx = (idx + 1) & mask;
193 #[cfg(feature = "counters")]
194 {
195 self.probes += 1;
196 }
197 }
198 table[idx] = entry;
199 }
200 self.table = table;
201 }
202
203 /// Appends the string heap's index section to `out`;
204 /// see [`BlobHeap::dump_index`].
205 pub fn dump_index(&self, out: &mut Vec<u8>) {
206 self.heap.dump_index(out);
207 }
208
209 /// Appends the string heap's pool section to `out`;
210 /// see [`BlobHeap::dump_pool`].
211 pub fn dump_pool(&self, out: &mut Vec<u8>) {
212 self.heap.dump_pool(out);
213 }
214
215 /// Appends the hash-table section to `out`.
216 ///
217 /// Layout (little-endian): `[slots u32][len u32]` then `slots × u32`
218 /// entries (`0` = empty, else `TermId + 1`). The table is persisted
219 /// rather than rebuilt on load: rebuilding is O(terms × hash) and the
220 /// cold-start budget is tighter than the 4 bytes per slot.
221 pub fn dump_table(&self, out: &mut Vec<u8>) {
222 out.reserve(TABLE_HEADER + self.table.len() * SLOT_BYTES);
223 out.extend_from_slice(&(self.table.len() as u32).to_le_bytes());
224 out.extend_from_slice(&self.len.to_le_bytes());
225 for &entry in &self.table {
226 out.extend_from_slice(&entry.to_le_bytes());
227 }
228 }
229
230 /// Rebuilds an interner from its three dumped sections.
231 ///
232 /// The input is **untrusted** — no panic on arbitrary bytes. On top of
233 /// [`BlobHeap::load`], validation covers:
234 ///
235 /// - every stored blob is valid UTF-8 (O(pool bytes), SIMD-speed) —
236 /// this is what keeps [`Interner::resolve`] infallible;
237 /// - table shape: power-of-two slot count `>= 16`, exact section
238 /// length, the ≤ 0.7 load factor, `len` equal to the heap's blob
239 /// count;
240 /// - table entries: in bounds, no id stored twice, and exactly `len`
241 /// of them (checked with a visited bitmap).
242 ///
243 /// Entry *placement* (that each id sits on its hash's probe path) is
244 /// not re-verified — that would cost the full rebuild the stored table
245 /// exists to avoid. A well-formed but misplaced table cannot cause
246 /// memory unsafety or a panic; it degrades `intern` to assigning a
247 /// duplicate id for the affected terms. Section checksums
248 /// cover accidental corruption.
249 ///
250 /// # Errors
251 ///
252 /// [`Error::Corrupt`] for any inconsistency.
253 pub fn load(cfg: BlobHeapCfg, index: &[u8], pool: &[u8], table: &[u8]) -> Result<Self, Error> {
254 Self::from_heap_and_table(BlobHeap::load(cfg, index, pool)?, table)
255 }
256
257 /// Rebuilds an interner that **borrows** its string bytes from a
258 /// longer-lived buffer (a memory-mapped snapshot). The table
259 /// stays owned; validation is identical to [`Interner::load`]. The term
260 /// dictionary is small (Zipf vocabulary), so paging it in to check UTF-8
261 /// is cheap and the large pools (texts, vectors) still load lazily.
262 ///
263 /// # Errors
264 ///
265 /// [`Error::Corrupt`] for any inconsistency (same gates as `load`).
266 pub fn load_borrowed(
267 cfg: BlobHeapCfg,
268 index: &[u8],
269 pool: &'a [u8],
270 table: &[u8],
271 ) -> Result<Self, Error> {
272 Self::from_heap_and_table(BlobHeap::load_borrowed(cfg, index, pool)?, table)
273 }
274
275 /// Validates the hash table against an already-loaded string heap and
276 /// assembles the interner. Shared by [`Interner::load`] and
277 /// [`Interner::load_borrowed`]; input is untrusted (see `load`).
278 fn from_heap_and_table(heap: BlobHeap<'a>, table: &[u8]) -> Result<Self, Error> {
279 for (_, blob) in heap.iter() {
280 if core::str::from_utf8(blob).is_err() {
281 return Err(Error::Corrupt("interned term is not valid UTF-8"));
282 }
283 }
284 if table.len() < TABLE_HEADER {
285 return Err(Error::Corrupt("interner table shorter than its header"));
286 }
287 let slots = u32::from_le_bytes(table[0..4].try_into().unwrap()) as usize;
288 let len = u32::from_le_bytes(table[4..TABLE_HEADER].try_into().unwrap());
289 if slots < INITIAL_SLOTS || !slots.is_power_of_two() {
290 return Err(Error::Corrupt("interner table size is not a power of two"));
291 }
292 if table.len() as u64 != TABLE_HEADER as u64 + slots as u64 * SLOT_BYTES as u64 {
293 return Err(Error::Corrupt("interner table length mismatch"));
294 }
295 if len as usize != heap.len() {
296 return Err(Error::Corrupt("interner length disagrees with its heap"));
297 }
298 if len as u64 * LOAD_DEN as u64 > slots as u64 * LOAD_NUM as u64 {
299 return Err(Error::Corrupt("interner table over the load factor"));
300 }
301 let mut entries = Vec::with_capacity(slots);
302 let mut seen = alloc::vec![false; heap.len()];
303 let mut filled = 0u64;
304 for i in 0..slots {
305 let at = TABLE_HEADER + i * SLOT_BYTES;
306 let entry = u32::from_le_bytes(table[at..at + SLOT_BYTES].try_into().unwrap());
307 if entry != 0 {
308 let id = (entry - 1) as usize;
309 if id >= heap.len() {
310 return Err(Error::Corrupt("interner table entry out of bounds"));
311 }
312 if core::mem::replace(&mut seen[id], true) {
313 return Err(Error::Corrupt("interner table stores an id twice"));
314 }
315 filled += 1;
316 }
317 entries.push(entry);
318 }
319 if filled != u64::from(len) {
320 return Err(Error::Corrupt(
321 "interner table entry count disagrees with len",
322 ));
323 }
324 Ok(Self {
325 heap,
326 table: entries,
327 len,
328 #[cfg(feature = "counters")]
329 probes: 0,
330 })
331 }
332
333 /// Table probes performed so far (see the field docs). Feature
334 /// `counters` only.
335 #[cfg(feature = "counters")]
336 pub fn probes(&self) -> u64 {
337 self.probes
338 }
339
340 /// Resets the probe counter to zero. Feature `counters` only.
341 #[cfg(feature = "counters")]
342 pub fn reset_probes(&mut self) {
343 self.probes = 0;
344 }
345}
346
347impl fmt::Debug for Interner<'_> {
348 /// Summary only — the vocabulary is the owner's data, not ours to dump.
349 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350 f.debug_struct("Interner")
351 .field("terms", &self.len)
352 .field("table_slots", &self.table.len())
353 .field("heap_bytes", &self.heap.pool_bytes())
354 .finish()
355 }
356}