kopitiam_tokenizer/estimate.rs
1//! Dependency-free, deterministic *token count estimation* -- the "make
2//! cost visible" primitive from the token-max plan (§0.7): let an agent
3//! choose read-vs-outline informed by an approximate token cost rather
4//! than blind.
5//!
6//! # Why an estimate, not the real BPE
7//!
8//! This crate already contains a real, from-scratch byte-level BPE
9//! ([`crate::BpeTokenizer`]) that returns the *exact* ids a GPT-2/Qwen
10//! family model was trained on -- but it is inert without a loaded vocab,
11//! and this workspace **bundles no standalone `tokenizer.json`** (only a
12//! present GGUF model carries one, via
13//! `kopitiam_runtime::tokenizer_from_gguf`). Shipping a real GPT-2/Qwen
14//! merge table purely to *count* tokens would mean embedding a multi-MB
15//! asset into every build for a number that only needs to be
16//! approximately right. The token-max card for this task is explicit that
17//! "a good BPE approximation suffices," so this module deliberately trades
18//! the last ~20-30% of accuracy for zero dependencies, zero bundled
19//! assets, and constant-time-per-char evaluation.
20//!
21//! **This is an estimate, not a billing oracle.** It exists to inform a
22//! choice (is this file cheap enough to read whole, or should I outline
23//! it?), where being within a quarter of the true count changes nothing
24//! about the decision. It is *not* suitable for anything that must match a
25//! provider's exact token accounting.
26//!
27//! # The model: per-script char weighting
28//!
29//! Real byte-level BPE compresses different scripts at very different
30//! rates, and a single "chars / 4" rule (the usual English rule of thumb)
31//! is wrong by up to ~4x on CJK text -- a Chinese ideograph is three UTF-8
32//! bytes and costs on the order of one whole token, where four Latin
33//! letters share one. So instead of one global ratio, each character
34//! contributes a *weight in tokens* chosen per Unicode script/category,
35//! and the estimate is the rounded sum. The weights are calibrated against
36//! the well-documented behavior of the GPT-2/GPT-4/Qwen tokenizers:
37//!
38//! | Class | tokens/char | ~chars/token | rationale |
39//! |---------------------------|-------------|--------------|-----------|
40//! | ASCII letter | 0.25 | 4.0 | the classic English "~4 chars/token" norm |
41//! | Whitespace | 0.25 | 4.0 | a lone inter-word space folds into the next word token, but runs/newlines cost; 0.25 keeps prose at ~chars/4 |
42//! | ASCII digit | 0.40 | 2.5 | tokenizers split numbers into 1-3 digit groups |
43//! | ASCII punctuation/symbol | 0.50 | 2.0 | often its own token, but runs merge |
44//! | CJK ideograph / kana / hangul | 0.75 | ~1.33 | card's 1.0-1.5 chars/token band for ideographs |
45//! | Other non-ASCII letter | 0.50 | 2.0 | accented Latin/Cyrillic/Greek: multi-byte, mid density |
46//! | Other non-ASCII symbol | 1.00 | 1.0 | emoji/pictographs cost 1-3 tokens each |
47//!
48//! # Expected accuracy
49//!
50//! Against a real GPT-2/Qwen-family BPE tokenizer, this lands within
51//! roughly **±25-30%** on ordinary prose (English, Chinese, or a mix) and
52//! natural-language-heavy documents -- the regime this is built for. It is
53//! looser on atypical inputs (dense source code, long digit runs, heavy
54//! emoji, base64 blobs), which is acceptable for a read-vs-outline signal.
55//! The three guarantees callers *can* rely on are exact: the estimate is
56//! **deterministic** (same input -> same count), **monotonic** (appending
57//! text never lowers the count), and **zero for the empty string**.
58//!
59//! # Relationship to Part I's measurements
60//!
61//! The token-max plan's Part I harness already reports output bytes, line
62//! count, and non-whitespace char count for converted documents (§137).
63//! This adds an orthogonal *token* axis (§268): bytes and lines measure
64//! the artifact, tokens measure what it costs an LLM to actually read it,
65//! and the two can diverge sharply (a CJK document is small in tokens
66//! relative to its byte count; a wide table is the reverse).
67
68/// The token-cost class of a single `char`, used to pick its weight.
69///
70/// Classification is by Unicode range/category and touches no per-model
71/// state, so it is stable across builds and platforms.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum CharClass {
74 Whitespace,
75 AsciiLetter,
76 AsciiDigit,
77 AsciiPunct,
78 /// CJK ideographs, kana, hangul, and CJK punctuation/fullwidth forms.
79 Cjk,
80 /// Non-ASCII alphabetic that is not CJK (accented Latin, Cyrillic,
81 /// Greek, ...).
82 OtherLetter,
83 /// Everything else non-ASCII: emoji, pictographs, box drawing, etc.
84 OtherSymbol,
85}
86
87impl CharClass {
88 /// The estimated token cost, in tokens, that a character of this class
89 /// contributes. See the module docs for the calibration rationale.
90 fn weight(self) -> f64 {
91 match self {
92 CharClass::Whitespace => 0.25,
93 CharClass::AsciiLetter => 0.25,
94 CharClass::AsciiDigit => 0.40,
95 CharClass::AsciiPunct => 0.50,
96 CharClass::Cjk => 0.75,
97 CharClass::OtherLetter => 0.50,
98 CharClass::OtherSymbol => 1.00,
99 }
100 }
101}
102
103/// True for the Unicode ranges this estimator treats as CJK-dense (one
104/// character ~ one token): Han ideographs (incl. extensions), Japanese
105/// kana, Korean hangul, and the CJK symbol/fullwidth blocks.
106fn is_cjk(c: char) -> bool {
107 matches!(c as u32,
108 0x3000..=0x303F // CJK symbols and punctuation
109 | 0x3040..=0x309F // Hiragana
110 | 0x30A0..=0x30FF // Katakana
111 | 0x3400..=0x4DBF // CJK Unified Ideographs Extension A
112 | 0x4E00..=0x9FFF // CJK Unified Ideographs
113 | 0xAC00..=0xD7AF // Hangul syllables
114 | 0xF900..=0xFAFF // CJK compatibility ideographs
115 | 0xFF00..=0xFFEF // Halfwidth and fullwidth forms
116 | 0x20000..=0x2FA1F // CJK Unified Ideographs Extensions B-F + compat supplement
117 )
118}
119
120/// Assigns a single character to its [`CharClass`].
121fn classify(c: char) -> CharClass {
122 if c.is_whitespace() {
123 CharClass::Whitespace
124 } else if c.is_ascii() {
125 if c.is_ascii_alphabetic() {
126 CharClass::AsciiLetter
127 } else if c.is_ascii_digit() {
128 CharClass::AsciiDigit
129 } else {
130 // ASCII, non-whitespace, non-alphanumeric: punctuation/symbols
131 // and the C0 control range (rare; treated as punctuation-cost).
132 CharClass::AsciiPunct
133 }
134 } else if is_cjk(c) {
135 CharClass::Cjk
136 } else if c.is_alphabetic() {
137 CharClass::OtherLetter
138 } else {
139 CharClass::OtherSymbol
140 }
141}
142
143/// The raw (unrounded) estimated token cost of `text`, in tokens.
144///
145/// Kept separate from [`estimate_tokens`] so per-line/per-section helpers
146/// can round once at the boundary they care about rather than accumulate
147/// rounding error across many small pieces.
148fn raw_cost(text: &str) -> f64 {
149 text.chars().map(|c| classify(c).weight()).sum()
150}
151
152/// Estimates the number of BPE tokens `text` would encode to, using the
153/// dependency-free per-script heuristic documented at the [module
154/// level](self).
155///
156/// Guarantees: `estimate_tokens("") == 0`; deterministic; and monotonic
157/// (for any `a` and `b`, `estimate_tokens(a) <= estimate_tokens(&(a + b))`,
158/// since every character's weight is non-negative). Expect roughly
159/// ±25-30% agreement with a real GPT-2/Qwen tokenizer on prose -- it is an
160/// estimate for informing read-vs-outline, not exact billing.
161#[must_use]
162pub fn estimate_tokens(text: &str) -> usize {
163 raw_cost(text).round() as usize
164}
165
166/// A per-line token estimate: the (1-based) line number and its estimated
167/// token count. Returned by [`estimate_tokens_by_line`] so the CLI can
168/// surface a breakdown (e.g. "which lines dominate this file's cost").
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub struct LineEstimate {
171 /// 1-based line number, matching how editors and `file:line`
172 /// coordinates (§10.1 finding 4) count lines.
173 pub line: usize,
174 /// Estimated tokens for this line's content (its trailing `\n` is not
175 /// counted -- newlines are cheap and would only add rounding noise per
176 /// line).
177 pub tokens: usize,
178}
179
180/// Estimates tokens per line, splitting on `\n` (a trailing `\r` on each
181/// line is included in that line's content, so CRLF files behave sanely).
182///
183/// Each line is rounded independently, so the sum of the per-line `tokens`
184/// may differ from [`estimate_tokens`] of the whole text by a few tokens
185/// of rounding; treat [`estimate_tokens`] as the authoritative total and
186/// this as a *relative* breakdown for spotting the expensive lines.
187///
188/// An empty input yields a single line-1 entry with `tokens == 0`, matching
189/// `"".lines()`-style expectations for "one empty line."
190#[must_use]
191pub fn estimate_tokens_by_line(text: &str) -> Vec<LineEstimate> {
192 text.split('\n')
193 .enumerate()
194 .map(|(i, line)| LineEstimate {
195 line: i + 1,
196 tokens: estimate_tokens(line),
197 })
198 .collect()
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn empty_string_is_zero() {
207 assert_eq!(estimate_tokens(""), 0);
208 }
209
210 #[test]
211 fn deterministic_same_input_same_count() {
212 let s = "The quick brown fox jumps over the lazy dog. 你好世界 123!";
213 let first = estimate_tokens(s);
214 for _ in 0..5 {
215 assert_eq!(estimate_tokens(s), first);
216 }
217 }
218
219 #[test]
220 fn monotonic_longer_text_never_fewer_tokens() {
221 let base = "Lorem ipsum dolor sit amet";
222 let mut acc = String::new();
223 let mut prev = 0usize;
224 for word in base.split(' ') {
225 acc.push_str(word);
226 acc.push(' ');
227 let now = estimate_tokens(&acc);
228 assert!(now >= prev, "estimate dropped from {prev} to {now} after adding {word:?}");
229 prev = now;
230 }
231 }
232
233 /// A real GPT-2/cl100k tokenizer encodes this 44-char pangram to ~10
234 /// tokens (`"The"," quick"," brown"," fox"," jumps"," over"," the","
235 /// lazy"," dog","."`). The heuristic must land in the documented ~4
236 /// chars/token English band -- assert 3.3-5.0 chars/token, i.e. the
237 /// estimate is within roughly a quarter of the real count.
238 #[test]
239 fn english_paragraph_within_documented_band() {
240 let text = "The quick brown fox jumps over the lazy dog.";
241 let chars = text.chars().count() as f64; // 44
242 let est = estimate_tokens(text) as f64;
243 let chars_per_token = chars / est;
244 assert!(
245 (3.3..=5.0).contains(&chars_per_token),
246 "English estimate {est} => {chars_per_token:.2} chars/token, want 3.3-5.0 \
247 (real BPE ~10 tokens for {chars} chars)"
248 );
249 }
250
251 /// A longer, more representative English paragraph, checked the same
252 /// way to make sure the band holds beyond one short sentence.
253 #[test]
254 fn longer_english_prose_within_band() {
255 let text = "Tokenization turns text into the discrete units a language model \
256 consumes. Estimating that count cheaply lets an agent decide whether \
257 to read a file whole or ask only for its outline, spending its budget \
258 where it matters most.";
259 let chars = text.chars().count() as f64;
260 let est = estimate_tokens(text) as f64;
261 let chars_per_token = chars / est;
262 assert!(
263 (3.3..=5.0).contains(&chars_per_token),
264 "prose estimate {est} => {chars_per_token:.2} chars/token, want 3.3-5.0"
265 );
266 }
267
268 /// CJK must not be counted at the Latin ~4-chars/token rate (that would
269 /// be ~4x *under* the truth). A real tokenizer spends on the order of
270 /// one token per ideograph; assert 0.5-1.0 tokens/char, well above the
271 /// naive `chars/4 == 0.25` a Latin-only heuristic would give.
272 #[test]
273 fn cjk_string_estimated_sensibly_not_4x_off() {
274 // A run of Chinese ideographs (no ASCII), so every char is CJK.
275 let text = "机器学习模型需要把文本转换成离散的标记单元";
276 let chars = text.chars().count();
277 let est = estimate_tokens(text);
278 let naive_latin = chars / 4; // the wrong (4x-under) Latin answer
279 assert!(
280 est >= 2 * naive_latin,
281 "CJK estimate {est} is too close to the Latin heuristic {naive_latin} (4x under)"
282 );
283 let tokens_per_char = est as f64 / chars as f64;
284 assert!(
285 (0.5..=1.0).contains(&tokens_per_char),
286 "CJK estimate {est} => {tokens_per_char:.2} tokens/char, want 0.5-1.0"
287 );
288 }
289
290 /// Mixed English + Chinese should sit between the two regimes, and in
291 /// particular the CJK half must pull the total above what pure-Latin
292 /// weighting of the same char count would give.
293 #[test]
294 fn mixed_script_between_regimes() {
295 let text = "The model 模型 reads text 文本 as tokens 标记.";
296 let est = estimate_tokens(text);
297 let chars = text.chars().count();
298 assert!(est > chars / 4, "mixed estimate {est} should exceed the pure-Latin chars/4");
299 assert!(est < chars, "mixed estimate {est} should stay below 1 token/char");
300 }
301
302 #[test]
303 fn whitespace_only_is_cheap_but_nonzero_for_runs() {
304 assert_eq!(estimate_tokens(""), 0);
305 // A single space rounds to zero cost; longer runs accrue.
306 assert_eq!(estimate_tokens(" "), 0);
307 assert!(estimate_tokens(&" ".repeat(40)) > 0);
308 }
309
310 #[test]
311 fn digits_denser_than_letters() {
312 // Same char count, digits should weigh more than letters.
313 let letters = estimate_tokens(&"a".repeat(20));
314 let digits = estimate_tokens(&"1".repeat(20));
315 assert!(digits > letters, "digits {digits} should exceed letters {letters}");
316 }
317
318 #[test]
319 fn by_line_numbers_lines_from_one() {
320 let text = "alpha beta\ngamma delta epsilon\n";
321 let lines = estimate_tokens_by_line(text);
322 // "a\nb\n" splits to ["a", "b", ""] -> three entries.
323 assert_eq!(lines.len(), 3);
324 assert_eq!(lines[0].line, 1);
325 assert_eq!(lines[1].line, 2);
326 assert_eq!(lines[2].line, 3);
327 assert_eq!(lines[2].tokens, 0, "trailing empty line costs nothing");
328 // The busier second line should not be cheaper than the first.
329 assert!(lines[1].tokens >= lines[0].tokens);
330 }
331
332 #[test]
333 fn by_line_empty_input_is_one_zero_line() {
334 let lines = estimate_tokens_by_line("");
335 assert_eq!(lines.len(), 1);
336 assert_eq!(lines[0], LineEstimate { line: 1, tokens: 0 });
337 }
338
339 #[test]
340 fn by_line_breakdown_roughly_sums_to_total() {
341 let text = "one two three\nfour five six seven\neight nine";
342 let total = estimate_tokens(text);
343 let sum: usize = estimate_tokens_by_line(text).iter().map(|l| l.tokens).sum();
344 // Per-line rounding drift is small; within a couple tokens.
345 let diff = total.abs_diff(sum);
346 assert!(diff <= 2, "per-line sum {sum} vs total {total} drifted by {diff}");
347 }
348}