Skip to main content

zeppelin_embed/fts/
dict.rs

1//! The sorted, front-coded term dictionary.
2//!
3//! # Why not an FST (task 13 D4)
4//!
5//! `fst` and `tantivy-fst` are outside the dependency allowlist, and adding
6//! one is an owner decision this plan recommends against. A sorted
7//! front-coded dictionary reaches the same asymptotics for the operations
8//! this engine needs: exact lookup is a binary search over block heads then
9//! a short linear walk; prefix enumeration is a range scan; and task 15's
10//! Levenshtein matching runs as automaton-guided lexicographic seek, which
11//! needs `seek_to` and forward iteration, both of which a sorted array
12//! gives directly.
13//!
14//! What an FST buys over this is a smaller dictionary and shared-suffix
15//! compression. Under the cardinal rule that is the wrong trade: the FST
16//! wins on bytes, the sorted array wins on decode simplicity and predictable
17//! cache behaviour, and bytes are no longer the scarce resource.
18//!
19//! # Layout
20//!
21//! Terms are sorted bytewise ascending and grouped into blocks of
22//! [`TERMS_PER_BLOCK`](crate::fts::dict::TERMS_PER_BLOCK). Within a block the
23//! first term is stored in full and
24//! every later term stores `(shared_prefix_len, suffix)` against its
25//! predecessor. A materialized block index holds each block's full head term
26//! and its byte offset, so a lookup binary-searches the heads — touching one
27//! small contiguous array — and then decodes at most one block.
28//!
29//! The block index is materialized rather than recomputed precisely because
30//! it is the faster shape; it is the "denser structure" the 5 MB directive
31//! asks for.
32
33use std::collections::BTreeMap;
34
35/// Terms per front-coded block.
36///
37/// Sixteen keeps the linear walk inside one block short while amortizing the
38/// full head term over enough entries to matter.
39pub const TERMS_PER_BLOCK: usize = 16;
40
41/// What the dictionary stores for each term.
42#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
43pub struct TermInfo {
44    /// Documents containing the term, within this segment.
45    pub document_frequency: u32,
46    /// Byte offset of the term's encoded posting list.
47    pub postings_offset: u64,
48    /// Byte length of the term's encoded posting list.
49    pub postings_length: u32,
50}
51
52/// A dictionary rejection.
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub enum DictError {
55    /// Terms were supplied out of order.
56    NotSorted {
57        /// The term that broke the ordering.
58        term: String,
59    },
60    /// The same term was supplied twice.
61    Duplicate {
62        /// The repeated term.
63        term: String,
64    },
65    /// A seek or prefix scan was given an empty term.
66    EmptyTerm,
67}
68
69impl std::fmt::Display for DictError {
70    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            Self::NotSorted { term } => write!(formatter, "term {term:?} is out of order"),
73            Self::Duplicate { term } => write!(formatter, "term {term:?} appears twice"),
74            Self::EmptyTerm => formatter.write_str("an empty term is not a valid query"),
75        }
76    }
77}
78
79impl std::error::Error for DictError {}
80
81/// One block's entry in the materialized index.
82#[derive(Clone, Debug, Eq, PartialEq)]
83struct BlockIndexEntry {
84    /// The block's first term, stored in full.
85    head: Vec<u8>,
86    /// Index of the block's first term in the flat term order.
87    first_term: usize,
88}
89
90/// A sorted, front-coded term dictionary.
91#[derive(Clone, Debug, Default)]
92pub struct TermDictionary {
93    /// Every term, in sorted order, decoded.
94    terms: Vec<Vec<u8>>,
95    /// Per-term information, parallel to `terms`.
96    infos: Vec<TermInfo>,
97    /// Materialized block heads for binary search.
98    index: Vec<BlockIndexEntry>,
99    /// Front-coded encoding of the terms, one run per block.
100    encoded: Vec<u8>,
101}
102
103impl TermDictionary {
104    /// Builds a dictionary from an ordered map of terms.
105    ///
106    /// A `BTreeMap` is required rather than accepted for convenience: it
107    /// makes the sorted precondition a type-level fact, so the ordering
108    /// cannot be violated by a caller iterating a hash map.
109    #[must_use]
110    pub fn from_map(entries: &BTreeMap<Vec<u8>, TermInfo>) -> Self {
111        let mut dictionary = Self::default();
112        for (term, info) in entries {
113            // BTreeMap iteration is sorted and unique, so push cannot fail.
114            let _ = dictionary.push(term, *info);
115        }
116        dictionary
117    }
118
119    /// Appends one term. Terms must arrive sorted and unique.
120    ///
121    /// # Errors
122    ///
123    /// Returns [`DictError::NotSorted`] or [`DictError::Duplicate`].
124    pub fn push(&mut self, term: &[u8], info: TermInfo) -> Result<(), DictError> {
125        if let Some(last) = self.terms.last() {
126            match term.cmp(last.as_slice()) {
127                std::cmp::Ordering::Less => {
128                    return Err(DictError::NotSorted {
129                        term: String::from_utf8_lossy(term).into_owned(),
130                    });
131                }
132                std::cmp::Ordering::Equal => {
133                    return Err(DictError::Duplicate {
134                        term: String::from_utf8_lossy(term).into_owned(),
135                    });
136                }
137                std::cmp::Ordering::Greater => {}
138            }
139        }
140
141        let position = self.terms.len();
142        if position.is_multiple_of(TERMS_PER_BLOCK) {
143            self.index.push(BlockIndexEntry {
144                head: term.to_vec(),
145                first_term: position,
146            });
147            // A block head is stored in full: shared prefix zero.
148            self.encoded.push(0);
149            push_varint(&mut self.encoded, term.len());
150            self.encoded.extend_from_slice(term);
151        } else {
152            let shared = self
153                .terms
154                .last()
155                .map(|previous| shared_prefix_len(previous, term))
156                .unwrap_or(0)
157                .min(255);
158            let suffix = term.get(shared..).unwrap_or(&[]);
159            self.encoded.push(u8::try_from(shared).unwrap_or(u8::MAX));
160            push_varint(&mut self.encoded, suffix.len());
161            self.encoded.extend_from_slice(suffix);
162        }
163
164        self.terms.push(term.to_vec());
165        self.infos.push(info);
166        Ok(())
167    }
168
169    /// Returns the number of terms.
170    #[must_use]
171    pub fn len(&self) -> usize {
172        self.terms.len()
173    }
174
175    /// Returns true when the dictionary holds no terms.
176    #[must_use]
177    pub fn is_empty(&self) -> bool {
178        self.terms.is_empty()
179    }
180
181    /// Returns the front-coded bytes.
182    #[must_use]
183    pub fn encoded_bytes(&self) -> &[u8] {
184        &self.encoded
185    }
186
187    /// Returns the term at a flat index.
188    #[must_use]
189    pub fn term_at(&self, index: usize) -> Option<&[u8]> {
190        self.terms.get(index).map(Vec::as_slice)
191    }
192
193    /// Returns the information at a flat index.
194    #[must_use]
195    pub fn info_at(&self, index: usize) -> Option<TermInfo> {
196        self.infos.get(index).copied()
197    }
198
199    /// Looks one term up exactly.
200    #[must_use]
201    pub fn get(&self, term: &[u8]) -> Option<TermInfo> {
202        let index = self.seek_exact(term)?;
203        self.infos.get(index).copied()
204    }
205
206    /// Returns the flat index of `term`, if present.
207    ///
208    /// Binary-searches the materialized block heads, then walks at most one
209    /// block. This is the operation task 15's automaton-guided seek repeats.
210    #[must_use]
211    pub fn seek_exact(&self, term: &[u8]) -> Option<usize> {
212        let block = self.block_for(term)?;
213        let start = self.index.get(block)?.first_term;
214        let end = (start + TERMS_PER_BLOCK).min(self.terms.len());
215        for index in start..end {
216            match self.terms.get(index)?.as_slice().cmp(term) {
217                std::cmp::Ordering::Less => {}
218                std::cmp::Ordering::Equal => return Some(index),
219                std::cmp::Ordering::Greater => return None,
220            }
221        }
222        None
223    }
224
225    /// Returns the flat index of the first term at or after `term`.
226    ///
227    /// This is the primitive task 15's Levenshtein walk drives: from an
228    /// automaton state it computes the smallest acceptable next term and
229    /// seeks here, rather than enumerating the dictionary.
230    #[must_use]
231    pub fn seek_ceiling(&self, term: &[u8]) -> Option<usize> {
232        // Binary search over the flat sorted terms via the block index.
233        let mut low = 0_usize;
234        let mut high = self.terms.len();
235        while low < high {
236            let middle = low + (high - low) / 2;
237            let candidate = self.terms.get(middle)?;
238            if candidate.as_slice() < term {
239                low = middle + 1;
240            } else {
241                high = middle;
242            }
243        }
244        (low < self.terms.len()).then_some(low)
245    }
246
247    /// Returns every flat index whose term starts with `prefix`.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`DictError::EmptyTerm`] for an empty prefix. An empty prefix
252    /// is a full scan wearing a query's clothes, so it is refused rather
253    /// than served.
254    pub fn prefix_range(&self, prefix: &[u8]) -> Result<std::ops::Range<usize>, DictError> {
255        if prefix.is_empty() {
256            return Err(DictError::EmptyTerm);
257        }
258        let Some(start) = self.seek_ceiling(prefix) else {
259            return Ok(self.terms.len()..self.terms.len());
260        };
261        let mut end = start;
262        while let Some(term) = self.terms.get(end) {
263            if term.starts_with(prefix) {
264                end += 1;
265            } else {
266                break;
267            }
268        }
269        Ok(start..end)
270    }
271
272    /// Returns the block whose range could contain `term`.
273    fn block_for(&self, term: &[u8]) -> Option<usize> {
274        if self.index.is_empty() {
275            return None;
276        }
277        // Find the last block whose head is <= term.
278        let mut low = 0_usize;
279        let mut high = self.index.len();
280        while low < high {
281            let middle = low + (high - low) / 2;
282            let head = &self.index.get(middle)?.head;
283            if head.as_slice() <= term {
284                low = middle + 1;
285            } else {
286                high = middle;
287            }
288        }
289        low.checked_sub(1)
290    }
291
292    /// Decodes the front-coded bytes back into the term list.
293    ///
294    /// Used by the golden test to prove the encoding is self-describing:
295    /// the in-memory `terms` vector is a decode cache, not the source of
296    /// truth for the persisted form.
297    #[must_use]
298    pub fn decode_terms(&self) -> Option<Vec<Vec<u8>>> {
299        let mut out: Vec<Vec<u8>> = Vec::with_capacity(self.terms.len());
300        let mut cursor = 0_usize;
301        let mut previous: Vec<u8> = Vec::new();
302        let mut position = 0_usize;
303        while cursor < self.encoded.len() {
304            let shared = usize::from(*self.encoded.get(cursor)?);
305            cursor += 1;
306            let (length, consumed) = read_varint(self.encoded.get(cursor..)?)?;
307            cursor += consumed;
308            let suffix = self.encoded.get(cursor..cursor + length)?;
309            cursor += length;
310            let mut term = if position.is_multiple_of(TERMS_PER_BLOCK) {
311                Vec::new()
312            } else {
313                previous.get(..shared)?.to_vec()
314            };
315            term.extend_from_slice(suffix);
316            previous = term.clone();
317            out.push(term);
318            position += 1;
319        }
320        Some(out)
321    }
322}
323
324fn shared_prefix_len(left: &[u8], right: &[u8]) -> usize {
325    left.iter()
326        .zip(right.iter())
327        .take_while(|(a, b)| a == b)
328        .count()
329}
330
331fn push_varint(output: &mut Vec<u8>, mut value: usize) {
332    loop {
333        let byte = u8::try_from(value & 0x7F).unwrap_or(0);
334        value >>= 7;
335        if value == 0 {
336            output.push(byte);
337            return;
338        }
339        output.push(byte | 0x80);
340    }
341}
342
343fn read_varint(input: &[u8]) -> Option<(usize, usize)> {
344    let mut value = 0_usize;
345    let mut shift = 0_u32;
346    for (index, byte) in input.iter().enumerate() {
347        if shift >= usize::BITS {
348            return None;
349        }
350        value |= usize::from(byte & 0x7F) << shift;
351        if byte & 0x80 == 0 {
352            return Some((value, index + 1));
353        }
354        shift += 7;
355    }
356    None
357}
358
359#[cfg(test)]
360#[allow(
361    clippy::expect_used,
362    clippy::indexing_slicing,
363    clippy::panic,
364    clippy::unwrap_used
365)]
366mod tests {
367    use super::*;
368
369    fn build(terms: &[&str]) -> TermDictionary {
370        let mut dictionary = TermDictionary::default();
371        for (index, term) in terms.iter().enumerate() {
372            dictionary
373                .push(
374                    term.as_bytes(),
375                    TermInfo {
376                        document_frequency: u32::try_from(index + 1).expect("small"),
377                        postings_offset: index as u64 * 16,
378                        postings_length: 16,
379                    },
380                )
381                .expect("sorted fixture");
382        }
383        dictionary
384    }
385
386    fn sorted_sample() -> Vec<String> {
387        let mut terms: Vec<String> = Vec::new();
388        for first in b'a'..=b'f' {
389            for second in b'a'..=b'z' {
390                terms.push(format!("{}{}", char::from(first), char::from(second)));
391            }
392        }
393        terms.sort();
394        terms
395    }
396
397    #[test]
398    fn exact_lookup_finds_every_term_and_only_those() {
399        let sample = sorted_sample();
400        let refs: Vec<&str> = sample.iter().map(String::as_str).collect();
401        let dictionary = build(&refs);
402        assert_eq!(dictionary.len(), sample.len());
403        for (index, term) in sample.iter().enumerate() {
404            let info = dictionary.get(term.as_bytes()).expect("term present");
405            assert_eq!(
406                info.document_frequency,
407                u32::try_from(index + 1).expect("small")
408            );
409        }
410        for absent in ["", "zzz", "aa0", "g", "ab_"] {
411            assert_eq!(dictionary.get(absent.as_bytes()), None, "{absent:?}");
412        }
413    }
414
415    #[test]
416    fn front_coding_round_trips_through_its_own_bytes() {
417        let sample = sorted_sample();
418        let refs: Vec<&str> = sample.iter().map(String::as_str).collect();
419        let dictionary = build(&refs);
420        let decoded = dictionary.decode_terms().expect("decodes");
421        let expected: Vec<Vec<u8>> = sample.iter().map(|t| t.as_bytes().to_vec()).collect();
422        assert_eq!(decoded, expected);
423    }
424
425    #[test]
426    fn front_coding_actually_shares_prefixes() {
427        let dictionary = build(&["engine", "engineer", "engineering", "engines"]);
428        // Four terms averaging nine bytes would be 36 bytes stored flat; the
429        // front-coded run must be materially smaller.
430        assert!(
431            dictionary.encoded_bytes().len() < 30,
432            "front coding did not compress: {} bytes",
433            dictionary.encoded_bytes().len()
434        );
435        assert_eq!(dictionary.decode_terms().expect("decodes").len(), 4);
436    }
437
438    #[test]
439    fn prefix_range_equals_brute_force() {
440        let sample = sorted_sample();
441        let refs: Vec<&str> = sample.iter().map(String::as_str).collect();
442        let dictionary = build(&refs);
443        for prefix in ["a", "b", "ab", "zz", "f"] {
444            let range = dictionary.prefix_range(prefix.as_bytes()).expect("prefix");
445            let actual: Vec<&str> = range
446                .clone()
447                .filter_map(|index| dictionary.term_at(index))
448                .filter_map(|term| std::str::from_utf8(term).ok())
449                .collect();
450            let expected: Vec<&str> = sample
451                .iter()
452                .map(String::as_str)
453                .filter(|term| term.starts_with(prefix))
454                .collect();
455            assert_eq!(actual, expected, "prefix {prefix:?}");
456        }
457    }
458
459    #[test]
460    fn an_empty_prefix_is_a_typed_error_not_a_full_scan() {
461        let dictionary = build(&["alpha", "beta"]);
462        assert_eq!(dictionary.prefix_range(b""), Err(DictError::EmptyTerm));
463    }
464
465    #[test]
466    fn seek_ceiling_lands_on_the_first_term_at_or_after_the_key() {
467        let dictionary = build(&["ant", "bee", "cat", "dog"]);
468        assert_eq!(dictionary.seek_ceiling(b"ant"), Some(0));
469        assert_eq!(dictionary.seek_ceiling(b"apple"), Some(1));
470        assert_eq!(dictionary.seek_ceiling(b"bee"), Some(1));
471        assert_eq!(dictionary.seek_ceiling(b"cz"), Some(3));
472        assert_eq!(dictionary.seek_ceiling(b"zebra"), None);
473        assert_eq!(dictionary.seek_ceiling(b""), Some(0));
474    }
475
476    #[test]
477    fn out_of_order_and_duplicate_terms_are_refused() {
478        let mut dictionary = TermDictionary::default();
479        dictionary
480            .push(b"beta", TermInfo::default())
481            .expect("first");
482        assert_eq!(
483            dictionary.push(b"alpha", TermInfo::default()),
484            Err(DictError::NotSorted {
485                term: String::from("alpha")
486            })
487        );
488        assert_eq!(
489            dictionary.push(b"beta", TermInfo::default()),
490            Err(DictError::Duplicate {
491                term: String::from("beta")
492            })
493        );
494    }
495
496    #[test]
497    fn an_empty_dictionary_answers_every_query_negatively() {
498        let dictionary = TermDictionary::default();
499        assert!(dictionary.is_empty());
500        assert_eq!(dictionary.get(b"anything"), None);
501        assert_eq!(dictionary.seek_exact(b"anything"), None);
502        assert_eq!(dictionary.seek_ceiling(b"anything"), None);
503        assert_eq!(dictionary.prefix_range(b"a").expect("prefix"), 0..0);
504    }
505
506    #[test]
507    fn building_from_a_sorted_map_matches_incremental_pushes() {
508        let mut map = BTreeMap::new();
509        for (index, term) in ["ant", "bee", "cat"].into_iter().enumerate() {
510            map.insert(
511                term.as_bytes().to_vec(),
512                TermInfo {
513                    document_frequency: u32::try_from(index + 1).expect("small"),
514                    postings_offset: index as u64 * 16,
515                    postings_length: 16,
516                },
517            );
518        }
519        let from_map = TermDictionary::from_map(&map);
520        let incremental = build(&["ant", "bee", "cat"]);
521        assert_eq!(from_map.encoded_bytes(), incremental.encoded_bytes());
522        assert_eq!(from_map.len(), 3);
523    }
524
525    #[test]
526    fn varints_round_trip_at_the_boundaries() {
527        for value in [0_usize, 1, 127, 128, 300, 16_383, 16_384, 1_000_000] {
528            let mut buffer = Vec::new();
529            push_varint(&mut buffer, value);
530            let (decoded, consumed) = read_varint(&buffer).expect("decodes");
531            assert_eq!(decoded, value);
532            assert_eq!(consumed, buffer.len());
533        }
534        assert_eq!(read_varint(&[0x80]), None, "an unterminated varint");
535    }
536
537    #[test]
538    fn lookups_work_across_many_blocks() {
539        // Enough terms to exercise the block index rather than one block.
540        let sample = sorted_sample();
541        assert!(sample.len() > TERMS_PER_BLOCK * 4);
542        let refs: Vec<&str> = sample.iter().map(String::as_str).collect();
543        let dictionary = build(&refs);
544        assert_eq!(
545            dictionary.seek_exact(sample.last().expect("non-empty").as_bytes()),
546            Some(sample.len() - 1)
547        );
548        assert_eq!(dictionary.seek_exact(sample[0].as_bytes()), Some(0));
549    }
550}