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}