Skip to main content

sqz_engine/
token_counter.rs

1use crate::types::ModelFamily;
2
3/// Token counter using BPE tokenizer models.
4///
5/// Supports:
6/// - OpenAI GPT-4/4o: exact via `cl100k_base`
7/// - OpenAI o1/o3: exact via `o200k_base`
8/// - Anthropic Claude: approximate via `cl100k_base` (Claude uses a proprietary tokenizer;
9///   cl100k_base is a close approximation, typically within 5% of actual count)
10/// - Google Gemini: approximate via `cl100k_base` (SentencePiece-based; cl100k_base provides
11///   a reasonable cross-model estimate)
12/// - Local/unknown models: fast heuristic fallback (`chars / 4`)
13///
14/// Uses `tiktoken-rs` singletons so the BPE data is loaded once and reused.
15pub struct TokenCounter;
16
17impl TokenCounter {
18    /// Create a new `TokenCounter`.
19    ///
20    /// The underlying tokenizers are lazily-initialized singletons from
21    /// `tiktoken-rs`, so construction is essentially free.
22    pub fn new() -> Self {
23        Self
24    }
25
26    /// Count tokens for `text` using the tokenizer appropriate for `model`.
27    ///
28    /// - `OpenAiGpt` → `o200k_base` (exact for GPT-4o / o1 / o3)
29    /// - `AnthropicClaude` → `cl100k_base` (close approximation, ~5% variance)
30    /// - `GoogleGemini` → `cl100k_base` (cross-model approximation)
31    /// - `Local(_)` → `chars / 4` heuristic fallback
32    pub fn count(&self, text: &str, model: &ModelFamily) -> u32 {
33        match model {
34            ModelFamily::OpenAiGpt => self.count_o200k(text),
35            ModelFamily::AnthropicClaude | ModelFamily::GoogleGemini => self.count_cl100k(text),
36            ModelFamily::Local(_) => Self::count_fast(text),
37        }
38    }
39
40    /// Fast character-based approximation: `ceil(chars / 4)`.
41    ///
42    /// Used as the fallback for unknown or local models.
43    pub fn count_fast(text: &str) -> u32 {
44        ((text.len() as f64) / 4.0).ceil() as u32
45    }
46
47    /// Count tokens using the `cl100k_base` encoding (GPT-4 / Claude compatible).
48    fn count_cl100k(&self, text: &str) -> u32 {
49        let bpe = tiktoken_rs::cl100k_base_singleton();
50        let lock = bpe.lock();
51        count_in_slices(text, |s| lock.encode_with_special_tokens(s).len())
52    }
53
54    /// Count tokens using the `o200k_base` encoding (GPT-4o / o1 / o3).
55    fn count_o200k(&self, text: &str) -> u32 {
56        let bpe = tiktoken_rs::o200k_base_singleton();
57        let lock = bpe.lock();
58        count_in_slices(text, |s| lock.encode_with_special_tokens(s).len())
59    }
60}
61
62/// Longest single-class run the tokenizer gets in one call: BPE is quadratic in a piece's length.
63const MAX_RUN_BYTES: usize = 2048;
64
65/// Sum of `encode` over slices with no run longer than [`MAX_RUN_BYTES`].
66fn count_in_slices(text: &str, encode: impl Fn(&str) -> usize) -> u32 {
67    let mut total = 0;
68    let mut start = 0;
69    let mut last = ("", 0);
70    for end in run_cuts(text).into_iter().chain([text.len()]) {
71        let slice = &text[start..end];
72        if slice != last.0 {
73            let n = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| encode(slice)))
74                .unwrap_or_else(|_| TokenCounter::count_fast(slice) as usize);
75            last = (slice, n);
76        }
77        total += last.1;
78        start = end;
79    }
80    total as u32
81}
82
83/// Offsets that cut every letter, symbol, space and newline run to [`MAX_RUN_BYTES`].
84fn run_cuts(text: &str) -> Vec<usize> {
85    static RUNS: std::sync::OnceLock<Vec<regex::Regex>> = std::sync::OnceLock::new();
86    if text.len() <= MAX_RUN_BYTES || !may_hold_long_run(text) {
87        return Vec::new();
88    }
89    let runs = RUNS.get_or_init(|| {
90        [r"[\p{L}\p{M}]+", r"[^\s\p{L}\p{N}]+", r"\s+", r"[\r\n/]+"]
91            .iter()
92            .filter_map(|p| regex::Regex::new(p).ok())
93            .collect()
94    });
95    let mut long: Vec<(usize, usize)> = runs
96        .iter()
97        .flat_map(|re| re.find_iter(text))
98        .filter(|m| m.len() > MAX_RUN_BYTES)
99        .map(|m| (m.start(), m.end()))
100        .collect();
101    long.sort_unstable();
102    let mut cuts = Vec::new();
103    let mut i = 0;
104    while i < long.len() {
105        let (mut at, mut end) = long[i];
106        while i + 1 < long.len() && long[i + 1].0 <= end {
107            i += 1;
108            end = end.max(long[i].1);
109        }
110        while end - at > MAX_RUN_BYTES {
111            at = crate::text_boundary::floor_char_boundary(text, at + MAX_RUN_BYTES);
112            cuts.push(at);
113        }
114        i += 1;
115    }
116    cuts
117}
118
119/// Byte scan for a run over [`MAX_RUN_BYTES`]; a non-ASCII byte counts as letter, symbol and space.
120fn may_hold_long_run(text: &str) -> bool {
121    let mut runs = [0usize; 4];
122    for &b in text.as_bytes() {
123        let space = b.is_ascii_whitespace() || b == 0x0b;
124        let letter = b.is_ascii_alphabetic();
125        let wide = !b.is_ascii();
126        let member = [
127            wide || letter,
128            wide || !(space || letter || b.is_ascii_digit()),
129            wide || space,
130            matches!(b, b'\r' | b'\n' | b'/'),
131        ];
132        for (run, member) in runs.iter_mut().zip(member) {
133            *run = if member { *run + 1 } else { 0 };
134            if *run > MAX_RUN_BYTES {
135                return true;
136            }
137        }
138    }
139    false
140}
141
142impl Default for TokenCounter {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn count_empty_string() {
154        let tc = TokenCounter::new();
155        assert_eq!(tc.count("", &ModelFamily::OpenAiGpt), 0);
156        assert_eq!(tc.count("", &ModelFamily::AnthropicClaude), 0);
157        assert_eq!(TokenCounter::count_fast(""), 0);
158    }
159
160    #[test]
161    fn count_hello_world_openai() {
162        let tc = TokenCounter::new();
163        let count = tc.count("Hello, world!", &ModelFamily::OpenAiGpt);
164        // o200k_base tokenizes "Hello, world!" into a small number of tokens
165        assert!(count > 0 && count < 10, "unexpected count: {count}");
166    }
167
168    #[test]
169    fn count_hello_world_claude() {
170        let tc = TokenCounter::new();
171        let count = tc.count("Hello, world!", &ModelFamily::AnthropicClaude);
172        assert!(count > 0 && count < 10, "unexpected count: {count}");
173    }
174
175    #[test]
176    fn count_hello_world_gemini() {
177        let tc = TokenCounter::new();
178        let count = tc.count("Hello, world!", &ModelFamily::GoogleGemini);
179        // Uses cl100k_base, same as Claude path
180        assert!(count > 0 && count < 10, "unexpected count: {count}");
181    }
182
183    #[test]
184    fn count_local_model_uses_fallback() {
185        let tc = TokenCounter::new();
186        // "abcdefgh" = 8 chars → ceil(8/4) = 2
187        let count = tc.count("abcdefgh", &ModelFamily::Local("llama".into()));
188        assert_eq!(count, 2);
189    }
190
191    #[test]
192    fn count_fast_basic() {
193        assert_eq!(TokenCounter::count_fast("abcd"), 1);
194        assert_eq!(TokenCounter::count_fast("abcde"), 2);
195        assert_eq!(TokenCounter::count_fast("a"), 1);
196    }
197
198    #[test]
199    fn exact_count_differs_from_approximation() {
200        let tc = TokenCounter::new();
201        let text = "fn main() { println!(\"Hello, world!\"); }";
202        let exact = tc.count(text, &ModelFamily::OpenAiGpt);
203        let approx = TokenCounter::count_fast(text);
204        // They should both be positive but likely differ
205        assert!(exact > 0);
206        assert!(approx > 0);
207    }
208
209    // -----------------------------------------------------------------------
210    // Property 37: Exact token count accuracy
211    // Validates: Requirements 32.1, 32.2
212    // -----------------------------------------------------------------------
213
214    use proptest::prelude::*;
215
216    /// Strategy that produces arbitrary non-empty strings.
217    fn arb_nonempty_text() -> impl Strategy<Value = String> {
218        // Mix of ASCII, unicode, code-like, and whitespace-heavy strings
219        prop_oneof![
220            // General unicode strings (1..256 chars)
221            "\\PC{1,256}",
222            // ASCII-only strings
223            "[a-zA-Z0-9 .,;:!?()\\[\\]{}<>@#$%^&*_+=~`|/\\\\-]{1,256}",
224            // Code-like strings
225            "(fn|def|class|import|const|let|var|pub|async|await|return)[ a-zA-Z0-9_(){}:;,\n]{1,200}",
226        ]
227        .prop_filter("must be non-empty", |s| !s.is_empty())
228    }
229
230    /// Strategy for known model families (not Local, which uses fallback).
231    fn arb_known_model() -> impl Strategy<Value = ModelFamily> {
232        prop_oneof![
233            Just(ModelFamily::OpenAiGpt),
234            Just(ModelFamily::AnthropicClaude),
235            Just(ModelFamily::GoogleGemini),
236        ]
237    }
238
239    proptest! {
240        /// **Validates: Requirements 32.1, 32.2**
241        ///
242        /// Property 37a: For any non-empty text and known model family,
243        /// the exact token count is always > 0.
244        #[test]
245        fn prop37a_exact_count_positive_for_nonempty(
246            text in arb_nonempty_text(),
247            model in arb_known_model(),
248        ) {
249            let tc = TokenCounter::new();
250            let count = tc.count(&text, &model);
251            prop_assert!(
252                count > 0,
253                "exact token count must be > 0 for non-empty string (len={}), got {}",
254                text.len(),
255                count
256            );
257        }
258
259        /// **Validates: Requirements 32.1, 32.2**
260        ///
261        /// Property 37b: For any non-empty text and known model family,
262        /// the exact token count is always ≤ the character count, since
263        /// BPE tokens span one or more characters.
264        #[test]
265        fn prop37b_exact_count_leq_char_count(
266            text in arb_nonempty_text(),
267            model in arb_known_model(),
268        ) {
269            let tc = TokenCounter::new();
270            let count = tc.count(&text, &model);
271            let char_count = text.len() as u32;
272            prop_assert!(
273                count <= char_count,
274                "exact token count ({}) must be <= byte length ({}) for text",
275                count,
276                char_count
277            );
278        }
279
280        /// **Validates: Requirements 32.1, 32.2**
281        ///
282        /// Property 37c: The fast fallback (chars/4) is within a
283        /// reasonable factor (0.1x–10x) of the exact count for
284        /// non-trivial text (>= 8 chars).
285        #[test]
286        fn prop37c_fast_fallback_within_reasonable_factor(
287            text in "[a-zA-Z0-9 .,;:!?]{8,256}",
288            model in arb_known_model(),
289        ) {
290            let tc = TokenCounter::new();
291            let exact = tc.count(&text, &model) as f64;
292            let fast = TokenCounter::count_fast(&text) as f64;
293            // Both should be positive
294            prop_assert!(exact > 0.0);
295            prop_assert!(fast > 0.0);
296            let ratio = exact / fast;
297            prop_assert!(
298                ratio >= 0.1 && ratio <= 10.0,
299                "exact/fast ratio ({:.2}) out of reasonable range [0.1, 10.0]; exact={}, fast={}",
300                ratio,
301                exact,
302                fast
303            );
304        }
305    }
306
307    fn tiktoken(text: &str) -> (u32, u32) {
308        let cl = tiktoken_rs::cl100k_base_singleton().lock().encode_with_special_tokens(text).len();
309        let o2 = tiktoken_rs::o200k_base_singleton().lock().encode_with_special_tokens(text).len();
310        (cl as u32, o2 as u32)
311    }
312
313    fn both(text: &str) -> (u32, u32) {
314        let tc = TokenCounter::new();
315        (tc.count(text, &ModelFamily::AnthropicClaude), tc.count(text, &ModelFamily::OpenAiGpt))
316    }
317
318    #[test]
319    fn count_is_exact_when_no_run_is_cut() {
320        let at_limit = |unit: &str| unit.repeat(MAX_RUN_BYTES / unit.len());
321        let texts = [
322            "fn main() { println!(\"hi\"); }\n".repeat(500),
323            format!("{}a{}", at_limit("="), at_limit(" ")),
324            format!("{}={}", at_limit("a"), at_limit("\n")),
325            format!("{}a{}", at_limit("─"), at_limit("/")),
326            "日本語のテキスト、句読点で区切る。".repeat(400),
327            "Привет мир строка номер 1\n".repeat(300),
328        ];
329        for text in &texts {
330            let head = crate::text_boundary::truncate_str(text, 40);
331            assert!(run_cuts(text).is_empty(), "{head:?}");
332            assert_eq!(both(text), tiktoken(text), "{head:?}");
333        }
334    }
335
336    #[test]
337    fn count_survives_a_run_of_a_million_characters() {
338        for unit in ["=", " "] {
339            let text = unit.repeat(1_100_000);
340            let (cl, o2) = both(&text);
341            assert!(cl > 0 && o2 > 0 && cl as usize <= text.len() && o2 as usize <= text.len(), "{unit:?}");
342        }
343    }
344
345    #[test]
346    fn every_class_of_run_is_cut_to_the_limit() {
347        for unit in ["=", " ", "\n", "a", "日", "\u{301}", "=\u{301}", "\n/", "Aa"] {
348            let text = unit.repeat((3 * MAX_RUN_BYTES + 7) / unit.len());
349            let cuts = run_cuts(&text);
350            assert!(cuts.len() >= 3, "{unit:?}: {cuts:?}");
351            let mut start = 0;
352            for end in cuts.into_iter().chain([text.len()]) {
353                assert!(end > start && end - start <= MAX_RUN_BYTES, "{unit:?}: {start}..{end}");
354                start = end;
355            }
356        }
357    }
358
359    #[test]
360    fn count_of_a_cut_run_stays_close() {
361        for unit in ["=", " ", "\n", "a", "ab", "─"] {
362            let text = format!("x {} y\n", unit.repeat(4200 / unit.len()));
363            let (want, got) = (tiktoken(&text), both(&text));
364            for (w, g) in [(want.0, got.0), (want.1, got.1)] {
365                assert!(w.abs_diff(g) <= w / 100 + 4, "{unit:?}: {g} vs {w}");
366            }
367        }
368    }
369
370    #[test]
371    fn count_is_linear_on_long_runs() {
372        let tc = TokenCounter::new();
373        let run = |bytes: usize| "abcdefghijklmnopqrstuvwxyz".repeat(bytes / 26);
374        let (small, large) = (run(8 << 10), run(80 << 10));
375        let ratio = crate::text_search::time_ratio(
376            || {
377                std::hint::black_box(tc.count(&small, &ModelFamily::AnthropicClaude));
378            },
379            || {
380                std::hint::black_box(tc.count(&large, &ModelFamily::AnthropicClaude));
381            },
382        );
383        assert!(ratio < 25.0, "80 KB took {ratio:.1}x as long as 8 KB");
384    }
385
386    #[test]
387    fn performance_10k_tokens_under_1ms() {
388        let tc = TokenCounter::new();
389        // ~40K chars ≈ 10K tokens for typical English text
390        let text = "Hello world. This is a test sentence for benchmarking. ".repeat(800);
391
392        // Warm up the singleton
393        let _ = tc.count(&text, &ModelFamily::OpenAiGpt);
394
395        let start = std::time::Instant::now();
396        let _count = tc.count(&text, &ModelFamily::OpenAiGpt);
397        let elapsed = start.elapsed();
398
399        // In debug builds under test load, allow generous headroom.
400        // The <1ms target (Req 32.5) applies to optimized release builds.
401        assert!(
402            elapsed.as_millis() < 500,
403            "token counting took {}ms, expected <500ms (debug build)",
404            elapsed.as_millis()
405        );
406    }
407}