Skip to main content

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}