Skip to main content

plugmem_core/
tokenizer.rs

1//! The core tokenizer, v2.
2//!
3//! The pipeline mirrors what the strongest lexical engines (Lucene's
4//! ICU/Standard analyzers, SQLite FTS5 `unicode61`) converge on, built
5//! from the pure-`core` unicode-rs table crates so it runs identically on
6//! native and every wasm runtime:
7//!
8//! 1. **NFKC normalization** of the input (fullwidth `A` → `A`,
9//!    ligature `fi` → `fi`, decomposed marks recomposed) — one pass into a
10//!    reused scratch buffer.
11//! 2. **UAX #29 word segmentation** (`unicode-segmentation`) — the same
12//!    boundary standard ICU implements. Consequences worth knowing:
13//!    `don't` and `o'clock` stay whole (apostrophe joins letters),
14//!    `3.14` / `v1.2.3` / `example.com` stay whole (`.`/`,` join
15//!    digits and letters), `snake_case` stays whole (`_` joins),
16//!    `gpt-4o` splits on the hyphen.
17//! 3. **Per-token folding**: full Unicode lowercase; Latin diacritics
18//!    stripped (`café` → `cafe`, the FTS5 `remove_diacritics` behavior,
19//!    applied only to Latin bases so Cyrillic `й` is untouched); the
20//!    Russian-specific `ё` → `е` fold every major Russian search engine
21//!    applies.
22//! 4. **CJK**: Han ideographs and Hiragana come out of UAX #29 as
23//!    single-character segments; adjacent ones are joined into
24//!    overlapping **bigrams** (the Lucene `CJKBigramFilter` scheme — the
25//!    standard dictionary-free CJK treatment), a lone character stays a
26//!    unigram. Katakana and Hangul already segment into word runs and are
27//!    kept as words.
28//! 5. A token longer than [`MAX_TOKEN_BYTES`] is truncated at the last
29//!    char boundary that fits (long tokens sharing a 64-byte prefix
30//!    collapse — accepted by spec).
31//!
32//! No stemming or lemmatization in v1. Emitted tokens are canonical: a
33//! fixed point of the tokenizer (fixed by a property test).
34
35use alloc::string::String;
36
37use unicode_normalization::UnicodeNormalization;
38use unicode_normalization::char::{decompose_canonical, is_combining_mark};
39use unicode_segmentation::UnicodeSegmentation;
40
41/// Upper bound on an emitted token, in bytes.
42pub const MAX_TOKEN_BYTES: usize = 64;
43
44/// `true` for characters treated as CJK unigram sources: Han ideographs
45/// (BMP blocks, the compatibility block, supplementary-plane extensions)
46/// and Hiragana. Katakana and Hangul are excluded on purpose — UAX #29
47/// already groups them into word runs.
48fn is_cjk_unigram(c: char) -> bool {
49    matches!(
50        c as u32,
51        0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FFFF
52            | 0x3041..=0x309F
53    )
54}
55
56/// Streaming tokenizer with reusable scratch buffers.
57///
58/// One instance per engine (or per thread of a wrapper): after warm-up
59/// [`Tokenizer::tokenize`] allocates nothing, which the zero-alloc recall
60/// invariant depends on.
61#[derive(Debug, Default, Clone)]
62pub struct Tokenizer {
63    /// NFKC-normalized copy of the input.
64    norm: String,
65    /// The token being assembled (folded word or CJK bigram).
66    token: String,
67}
68
69impl Tokenizer {
70    /// A tokenizer with empty scratch buffers.
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Splits `text` into normalized tokens, calling `sink` for each one.
76    ///
77    /// The emitted `&str` is only valid for the duration of one `sink`
78    /// call.
79    ///
80    /// ```
81    /// use plugmem_core::tokenizer::Tokenizer;
82    ///
83    /// let mut tk = Tokenizer::new();
84    /// let mut tokens = Vec::new();
85    /// tk.tokenize("Hello, МИР-42! 東京タワー", &mut |t| tokens.push(t.to_owned()));
86    /// assert_eq!(tokens, ["hello", "мир", "42", "東京", "タワー"]);
87    /// ```
88    pub fn tokenize(&mut self, text: &str, sink: &mut dyn FnMut(&str)) {
89        self.norm.clear();
90        if text.is_ascii() {
91            // NFKC is the identity on ASCII — skip the table walk (the
92            // common English/code case; ~3x on the ASCII benchmark).
93            self.norm.push_str(text);
94        } else {
95            self.norm.extend(text.nfkc());
96        }
97        let token = &mut self.token;
98
99        // The CJK adjacency machine: previous unigram char + run length.
100        // At a run boundary the machine may still owe a token: a lone
101        // char is a unigram; longer runs were already emitted as bigrams.
102        let mut prev_cjk: Option<char> = None;
103        let mut run_len = 0usize;
104        fn flush_cjk(
105            prev: &mut Option<char>,
106            run_len: &mut usize,
107            token: &mut String,
108            sink: &mut dyn FnMut(&str),
109        ) {
110            if let Some(p) = prev.take()
111                && *run_len == 1
112            {
113                token.clear();
114                token.push(p);
115                sink(token);
116            }
117            *run_len = 0;
118        }
119
120        for seg in self.norm.split_word_bounds() {
121            let mut chars = seg.chars();
122            let first = chars.next();
123            let single = first.is_some() && chars.next().is_none();
124            match first {
125                Some(c) if single && is_cjk_unigram(c) => {
126                    if let Some(p) = prev_cjk {
127                        token.clear();
128                        token.push(p);
129                        token.push(c);
130                        sink(token);
131                    }
132                    prev_cjk = Some(c);
133                    run_len += 1;
134                }
135                _ if seg.chars().any(char::is_alphanumeric) => {
136                    flush_cjk(&mut prev_cjk, &mut run_len, token, sink);
137                    token.clear();
138                    for c in seg.chars() {
139                        for lc in c.to_lowercase() {
140                            fold_into(lc, token);
141                        }
142                    }
143                    emit_truncated(token, sink);
144                }
145                _ => flush_cjk(&mut prev_cjk, &mut run_len, token, sink),
146            }
147        }
148        flush_cjk(&mut prev_cjk, &mut run_len, token, sink);
149    }
150}
151
152/// `true` for the default-ignorable format characters that occur inside
153/// running text (soft hyphen, zero-width space/joiners, bidi marks, word
154/// joiner block, BOM). UAX #29 glues them into word segments (they are
155/// `Format`/`Extend` for word breaking) but they carry no lexical content
156/// and must never survive into a term.
157fn is_ignorable_format(c: char) -> bool {
158    matches!(
159        c as u32,
160        0xAD | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0xFEFF
161    )
162}
163
164/// Pushes one lowercased char into the token, applying the folding rules:
165///
166/// - `ё` → `е`;
167/// - ignorable format characters are dropped ([`is_ignorable_format`]);
168/// - combining marks are dropped after an ASCII base (this also absorbs
169///   the one Unicode lowercase expansion that emits a mark, `İ` → `i` +
170///   U+0307) and kept otherwise — including a mark opening the token,
171///   which happens when UAX #29 glues a mark onto a separator base (the
172///   separator itself is dropped by the next rule);
173/// - other non-alphanumeric chars survive only *after* an alphanumeric
174///   one: that keeps the word-internal joiners UAX #29 admits (`don't`,
175///   `3.14`, `snake_case`) while separator bases glued to a segment start
176///   (a space carrying a combining mark) never enter the term;
177/// - Latin precomposed diacritics are stripped to their ASCII base;
178///   everything else — Cyrillic `й`, Greek, Kana — is kept precomposed.
179fn fold_into(c: char, out: &mut String) {
180    if c == 'ё' {
181        out.push('е');
182        return;
183    }
184    if is_ignorable_format(c) {
185        return;
186    }
187    if is_combining_mark(c) {
188        if !out.ends_with(|p: char| p.is_ascii_alphanumeric()) {
189            out.push(c);
190        }
191        return;
192    }
193    if !c.is_alphanumeric() {
194        if out.ends_with(char::is_alphanumeric) {
195            out.push(c);
196        }
197        return;
198    }
199    if c.is_ascii() {
200        out.push(c);
201        return;
202    }
203    // Canonical decomposition into a tiny fixed buffer (canonical
204    // decompositions are at most a few chars).
205    let mut parts = [char::MAX; 8];
206    let mut n = 0usize;
207    decompose_canonical(c, |d| {
208        if n < parts.len() {
209            parts[n] = d;
210        }
211        n += 1;
212    });
213    if n <= parts.len() && n > 0 && parts[0].is_ascii_alphanumeric() {
214        for &d in &parts[..n] {
215            if !is_combining_mark(d) {
216                out.push(d);
217            }
218        }
219    } else {
220        out.push(c);
221    }
222}
223
224/// Sends the assembled token, truncated to [`MAX_TOKEN_BYTES`] at a char
225/// boundary. Empty tokens (theoretically unreachable — a word segment
226/// always folds to at least one char) are guarded against rather than
227/// asserted.
228fn emit_truncated(token: &str, sink: &mut dyn FnMut(&str)) {
229    if token.is_empty() {
230        return;
231    }
232    let mut end = token.len().min(MAX_TOKEN_BYTES);
233    while !token.is_char_boundary(end) {
234        end -= 1;
235    }
236    sink(&token[..end]);
237}