Skip to main content

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}