Skip to main content

llm_token/
estimation.rs

1//! Token estimation for mixed-language text
2//!
3//! Provides fast token count estimation supporting ASCII, CJK, and other Unicode.
4//! Used across the codebase for LLM request sizing, chunking, and content compression.
5
6/// Tokens per ASCII symbol (punctuation) character (default: 0.5)
7///
8/// Symbols like `->`, `(`, `{` are typically tokenized separately from
9/// identifiers. Counting them at the latin factor would underestimate
10/// code-heavy text; 0.5 per symbol approximates standalone punctuation tokens.
11pub const SYMBOL_FACTOR: f32 = 0.5;
12
13/// Tokens per message metadata overhead (role, separators, etc.)
14pub const MESSAGE_OVERHEAD_TOKENS: u32 = 4;
15
16/// Token estimator for mixed-language text
17#[derive(Debug, Clone, Copy)]
18pub struct TokenEstimator {
19    /// Tokens per CJK character (default: 1.0)
20    cjk_factor: f32,
21    /// Tokens per Latin character (default: 0.25, ~4 chars per token)
22    latin_factor: f32,
23}
24
25impl Default for TokenEstimator {
26    fn default() -> Self {
27        Self {
28            cjk_factor: 1.0,
29            latin_factor: 0.25,
30        }
31    }
32}
33
34impl TokenEstimator {
35    /// Create a new token estimator with custom factors
36    ///
37    /// # Example
38    ///
39    /// ```
40    /// use llm_token::estimation::TokenEstimator;
41    ///
42    /// let estimator = TokenEstimator::new(0.8, 0.3);
43    /// ```
44    pub const fn new(cjk_factor: f32, latin_factor: f32) -> Self {
45        Self {
46            cjk_factor,
47            latin_factor,
48        }
49    }
50
51    /// Estimate token count for text using default factors
52    ///
53    /// # Example
54    ///
55    /// ```
56    /// use llm_token::estimation::TokenEstimator;
57    ///
58    /// let tokens = TokenEstimator::estimate("Hello world");
59    /// ```
60    pub fn estimate(text: &str) -> usize {
61        DEFAULT_ESTIMATOR.estimate_with_config(text)
62    }
63
64    /// Estimate token count for text with this estimator's configuration
65    ///
66    /// Uses fast path for ASCII-only text, otherwise processes character by character.
67    pub fn estimate_with_config(&self, text: &str) -> usize {
68        let bytes = text.as_bytes();
69
70        // Fast path: ASCII-only text
71        if text.is_ascii() {
72            let ascii_ws =
73                count_byte(bytes, b' ') + count_byte(bytes, b'\t') + count_byte(bytes, b'\n');
74            let non_ws = text.len() - ascii_ws;
75            let symbols = bytes.iter().filter(|&&b| b.is_ascii_punctuation()).count();
76            let letters = non_ws - symbols;
77            let tokens = letters as f32 * self.latin_factor
78                + symbols as f32 * SYMBOL_FACTOR
79                + ascii_ws as f32 * 0.5;
80            return tokens.round() as usize;
81        }
82
83        let mut count = 0.0f32;
84
85        // Count ASCII whitespace quickly
86        let ascii_ws =
87            count_byte(bytes, b' ') + count_byte(bytes, b'\t') + count_byte(bytes, b'\n');
88        count += ascii_ws as f32 * 0.5;
89
90        // Process each character
91        for ch in text.chars() {
92            if ch.is_ascii_whitespace() || ch.is_ascii_control() {
93                continue;
94            }
95
96            if Self::is_cjk(ch) {
97                count += self.cjk_factor;
98            } else if ch.is_ascii_punctuation() {
99                // Symbols are tokenized separately from identifiers
100                count += SYMBOL_FACTOR;
101            } else if ch.is_ascii() {
102                count += self.latin_factor;
103            } else {
104                // Other Unicode (emoji, symbols, etc.) - count as 1 token
105                count += 1.0;
106            }
107        }
108
109        count.round() as usize
110    }
111
112    /// Estimate tokens for text (instance method)
113    ///
114    /// Convenience method that delegates to estimate_with_config.
115    pub fn estimate_text(&self, text: &str) -> usize {
116        self.estimate_with_config(text)
117    }
118
119    /// Check if character is CJK (Chinese, Japanese, Korean)
120    #[inline]
121    pub fn is_cjk(ch: char) -> bool {
122        let code = ch as u32;
123        // CJK Unified Ideographs
124        (0x4E00..=0x9FFF).contains(&code)
125            || (0x3400..=0x4DBF).contains(&code)
126            || (0x20000..=0x2A6DF).contains(&code)
127            // Hiragana & Katakana
128            || (0x3040..=0x309F).contains(&code)
129            || (0x30A0..=0x30FF).contains(&code)
130            // Hangul (Korean)
131            || (0xAC00..=0xD7AF).contains(&code)
132            || (0x1100..=0x11FF).contains(&code)
133            || (0x3130..=0x318F).contains(&code)
134    }
135
136    /// Find split point that fits within token limit
137    ///
138    /// Returns the byte offset where to split.
139    pub fn find_split_point(&self, text: &str, max_tokens: usize) -> usize {
140        let mut bytes = 0;
141        let mut tokens = 0.0f32;
142        let mut last_break = 0; // byte index of last safe split (newline/space)
143
144        for ch in text.chars() {
145            let ch_bytes = ch.len_utf8();
146            let ch_tokens = if Self::is_cjk(ch) {
147                self.cjk_factor
148            } else if ch.is_ascii_punctuation() {
149                SYMBOL_FACTOR
150            } else if ch.is_ascii() && !ch.is_ascii_whitespace() && !ch.is_ascii_control() {
151                self.latin_factor
152            } else if ch.is_ascii_whitespace() {
153                0.5
154            } else {
155                1.0
156            };
157
158            if tokens + ch_tokens > max_tokens as f32 {
159                // If we have a previous safe break, split there; otherwise split at current byte offset
160                return if last_break > 0 { last_break } else { bytes };
161            }
162
163            tokens += ch_tokens;
164            bytes += ch_bytes;
165
166            // Record safe split positions (after newline or space)
167            if ch == '\n' || ch == ' ' {
168                last_break = bytes;
169            }
170        }
171
172        text.len()
173    }
174}
175
176/// Count occurrences of a byte in a slice
177#[inline]
178fn count_byte(bytes: &[u8], target: u8) -> usize {
179    bytes.iter().filter(|&&b| b == target).count()
180}
181
182/// Global default estimator
183static DEFAULT_ESTIMATOR: TokenEstimator = TokenEstimator {
184    cjk_factor: 1.0,
185    latin_factor: 0.25,
186};
187
188/// Estimate tokens using default estimator
189///
190/// This is the primary function for token estimation.
191pub fn estimate_tokens(text: &str) -> usize {
192    TokenEstimator::estimate(text)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_empty_text() {
201        assert_eq!(estimate_tokens(""), 0);
202    }
203
204    #[test]
205    fn test_ascii_text() {
206        let text = "Hello World";
207        let tokens = estimate_tokens(text);
208        // 9 letters * 0.25 + 2 whitespace * 0.5 = 3.25 -> 3
209        assert_eq!(tokens, 3, "tokens: {}", tokens);
210    }
211
212    #[test]
213    fn test_cjk_text() {
214        let text = "你好,世界!";
215        let tokens = estimate_tokens(text);
216        // 4 CJK chars = 4 + full-width punctuation ",!" 2 (non-ASCII) = 6
217        assert_eq!(tokens, 6, "tokens: {}", tokens);
218    }
219
220    #[test]
221    fn test_mixed_text() {
222        let text = "Hello 世界";
223        let tokens = estimate_tokens(text);
224        // "Hello" 5 * 0.25 = 1.25 + 1 ws * 0.5 + 2 CJK = 2 -> 3.75 -> 4
225        assert_eq!(tokens, 4, "tokens: {}", tokens);
226    }
227
228    #[test]
229    fn test_whitespace() {
230        let text = "Hello   World";
231        let tokens = estimate_tokens(text);
232        // 9 letters * 0.25 = 2.25 + 3 ws * 0.5 = 1.5 -> 3.75 -> 4
233        assert_eq!(tokens, 4, "tokens: {}", tokens);
234    }
235
236    #[test]
237    fn test_long_ascii() {
238        let text = "The quick brown fox jumps over the lazy dog";
239        let tokens = estimate_tokens(text);
240        // ~35 letters + 8 spaces -> ~13
241        assert!((10..=16).contains(&tokens), "tokens: {}", tokens);
242    }
243
244    #[test]
245    fn test_japanese() {
246        let text = "こんにちは世界";
247        let tokens = estimate_tokens(text);
248        // 5 hiragana + 2 kanji = 7 tokens
249        assert_eq!(tokens, 7, "tokens: {}", tokens);
250    }
251
252    #[test]
253    fn test_korean() {
254        let text = "안녕하세요";
255        let tokens = estimate_tokens(text);
256        // 5 hangul chars = 5 tokens
257        assert_eq!(tokens, 5, "tokens: {}", tokens);
258    }
259
260    #[test]
261    fn test_custom_estimator() {
262        // Test with custom factors for different tokenization behavior
263        let estimator = TokenEstimator::new(0.8, 0.3);
264
265        let text = "Hello";
266        let tokens = estimator.estimate_with_config(text);
267        // 5 ASCII chars * 0.3 = 1.5 -> rounded to 2 tokens
268        assert_eq!(tokens, 2, "tokens: {}", tokens);
269
270        let text = "How are you?";
271        let tokens = estimator.estimate_with_config(text);
272        // 9 letters * 0.3 = 2.7 + "?" 0.5 + 2 ws * 0.5 = 4.2 -> 4
273        assert_eq!(tokens, 4, "tokens: {}", tokens);
274    }
275
276    #[test]
277    fn test_custom_estimator_ascii_fast_path() {
278        // Test that ASCII fast path respects latin_factor
279        let estimator = TokenEstimator::new(1.0, 0.2); // 5 chars per token
280
281        let text = "ABCDEFGHIJ"; // 10 chars
282        let tokens = estimator.estimate_with_config(text);
283        // 10 chars / 5 = 2 tokens
284        assert_eq!(tokens, 2, "tokens: {}", tokens);
285    }
286
287    #[test]
288    fn test_default_estimator_consistency() {
289        // Ensure default estimator matches estimate_tokens function
290        let text = "Hello World 123";
291        let estimator = TokenEstimator::default();
292        assert_eq!(estimator.estimate_text(text), estimate_tokens(text));
293    }
294
295    #[test]
296    fn test_ascii_symbols_counted_separately() {
297        let text = "fn foo() -> u32";
298        let tokens = estimate_tokens(text);
299        // letters: "fnfoou32" = 8 * 0.25 = 2.0
300        // symbols: "()->" = 4 * 0.5 = 2.0
301        // whitespace: 3 * 0.5 = 1.5
302        // total 5.5 -> 6
303        assert_eq!(tokens, 6, "tokens: {}", tokens);
304    }
305
306    #[test]
307    fn test_symbol_estimation_matches_find_split_point() {
308        // Symbol-heavy text must be estimated consistently by the splitter
309        let text = "map(|x| x + 1) -> Result<(), Error>";
310        let estimator = TokenEstimator::default();
311        let total = estimator.estimate_text(text);
312        let split = estimator.find_split_point(text, total);
313        // Split point must never exceed the requested budget (float accumulation
314        // may stop a few bytes early at the exact boundary, never late).
315        assert!(
316            estimator.estimate_text(&text[..split]) <= total,
317            "split text ({}) must fit within budget {}",
318            estimator.estimate_text(&text[..split]),
319            total
320        );
321        assert!(split > 0, "split point must be non-empty");
322    }
323
324    #[test]
325    fn test_find_split_point() {
326        let text = "This is a test string that is longer than the limit.";
327        let split = TokenEstimator::default().find_split_point(text, 5);
328        assert!(split < text.len());
329        assert!(split > 0);
330    }
331
332    #[test]
333    fn test_find_split_point_cjk() {
334        let text = "这是一个用于测试分段的较长的中文字符串内容";
335        let split = TokenEstimator::default().find_split_point(text, 8);
336        assert!(split < text.len());
337        assert!(split > 0);
338        assert!(
339            TokenEstimator::default().estimate_text(&text[..split]) <= 8,
340            "split text must fit within budget"
341        );
342    }
343}