Skip to main content

token_estimate/
lib.rs

1//! Estimate LLM token counts from text without loading a tokenizer.
2//!
3//! Real tokenizers (BPE, SentencePiece) need a vocabulary file of several
4//! megabytes and a non-trivial dependency tree. That is the right tool when you
5//! need an exact count. It is the wrong tool when you only need to answer
6//! "will this roughly fit in the context window", which is the common case in
7//! CLI tooling, log processing, and pre-flight budget checks.
8//!
9//! This crate gives a fast, dependency-free estimate and is explicit about how
10//! wrong it can be.
11//!
12//! # Accuracy, stated honestly
13//!
14//! For ordinary English prose the character-based estimate is typically within
15//! about 10-15% of a BPE tokenizer. It degrades in predictable ways:
16//!
17//! - **Code and markup** tokenize denser than prose (more punctuation, more
18//!   short symbols), so the estimate tends to run low. Use
19//!   [`Profile::Code`].
20//! - **Non-Latin scripts** often use one token per character or worse, so a
21//!   chars/4 heuristic can underestimate severely. [`estimate`] detects
22//!   non-ASCII density and adjusts, but treat CJK figures as a rough floor.
23//! - **Long runs of whitespace or repeated punctuation** compress well and the
24//!   estimate runs high.
25//!
26//! If you need an exact count, use a real tokenizer. This is for budgeting.
27//!
28//! # Example
29//!
30//! ```
31//! use token_estimate::{estimate, Profile};
32//!
33//! let prose = "The quick brown fox jumps over the lazy dog.";
34//! let n = estimate(prose, Profile::Prose);
35//! assert!(n > 0);
36//!
37//! // Code is denser, so the same byte count yields more tokens.
38//! let code = "fn main() { let x: Vec<u8> = vec![1,2,3]; }";
39//! assert!(estimate(code, Profile::Code) > estimate(code, Profile::Prose));
40//! ```
41
42/// Text kind, which changes the characters-per-token assumption.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Profile {
45    /// Ordinary prose. ~4 characters per token.
46    Prose,
47    /// Source code or markup. Denser: ~3 characters per token.
48    Code,
49    /// Mixed content, e.g. prose with fenced code blocks.
50    Mixed,
51}
52
53impl Profile {
54    /// Characters per token assumed for this profile.
55    pub fn chars_per_token(self) -> f64 {
56        match self {
57            Profile::Prose => 4.0,
58            Profile::Code => 3.0,
59            Profile::Mixed => 3.5,
60        }
61    }
62}
63
64/// Estimated token count for `text` under `profile`.
65///
66/// Returns 0 for empty or whitespace-only input. The result is a ceiling, so a
67/// non-empty string always estimates at least 1 token.
68pub fn estimate(text: &str, profile: Profile) -> usize {
69    if text.trim().is_empty() {
70        return 0;
71    }
72    // Collapse whitespace runs: tokenizers merge them, so counting raw bytes
73    // overestimates heavily on indented or padded text.
74    let mut effective = 0usize;
75    let mut non_ascii = 0usize;
76    let mut prev_ws = false;
77    for ch in text.chars() {
78        if ch.is_whitespace() {
79            if !prev_ws {
80                effective += 1;
81            }
82            prev_ws = true;
83            continue;
84        }
85        prev_ws = false;
86        effective += 1;
87        if !ch.is_ascii() {
88            non_ascii += 1;
89        }
90    }
91    if effective == 0 {
92        return 0;
93    }
94    let total_chars = text.chars().count();
95    let non_ascii_ratio = if total_chars == 0 { 0.0 } else { non_ascii as f64 / total_chars as f64 };
96
97    // Non-Latin scripts approach one token per character. Blend the profile
98    // ratio toward 1.0 as non-ASCII density rises.
99    let ratio = profile.chars_per_token();
100    let adjusted = ratio * (1.0 - non_ascii_ratio) + 1.0 * non_ascii_ratio;
101    let est = (effective as f64 / adjusted).ceil() as usize;
102    est.max(1)
103}
104
105/// Estimate with the profile inferred from the text itself.
106///
107/// Heuristic: a high density of code-ish punctuation (`{}()<>;=` and friends)
108/// selects [`Profile::Code`]; some but not much selects [`Profile::Mixed`].
109pub fn estimate_auto(text: &str) -> usize {
110    estimate(text, infer_profile(text))
111}
112
113/// Infer a [`Profile`] from punctuation density.
114pub fn infer_profile(text: &str) -> Profile {
115    let total = text.chars().filter(|c| !c.is_whitespace()).count();
116    if total == 0 {
117        return Profile::Prose;
118    }
119    let codeish = text
120        .chars()
121        .filter(|c| matches!(c, '{' | '}' | '(' | ')' | '<' | '>' | ';' | '=' | '[' | ']' | '|' | '_'))
122        .count();
123    let density = codeish as f64 / total as f64;
124    if density > 0.08 {
125        Profile::Code
126    } else if density > 0.03 {
127        Profile::Mixed
128    } else {
129        Profile::Prose
130    }
131}
132
133/// Whether `text` is estimated to fit in `budget` tokens.
134pub fn fits(text: &str, budget: usize, profile: Profile) -> bool {
135    estimate(text, profile) <= budget
136}
137
138/// Remaining budget after `text`, saturating at zero.
139pub fn remaining(text: &str, budget: usize, profile: Profile) -> usize {
140    budget.saturating_sub(estimate(text, profile))
141}
142
143/// A conservative upper bound, inflating the estimate by `margin_pct`.
144///
145/// Useful when the cost of overflowing a context window is higher than the cost
146/// of sending less. A margin of 20 is a reasonable default for prose.
147pub fn estimate_with_margin(text: &str, profile: Profile, margin_pct: u32) -> usize {
148    let base = estimate(text, profile) as f64;
149    (base * (1.0 + margin_pct as f64 / 100.0)).ceil() as usize
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn empty_is_zero() {
158        assert_eq!(estimate("", Profile::Prose), 0);
159        assert_eq!(estimate("   \n\t ", Profile::Prose), 0);
160    }
161
162    #[test]
163    fn non_empty_is_at_least_one() {
164        assert_eq!(estimate("a", Profile::Prose), 1);
165        assert_eq!(estimate("hi", Profile::Code), 1);
166    }
167
168    #[test]
169    fn code_estimates_higher_than_prose() {
170        let s = "fn main() { let x: Vec<u8> = vec![1,2,3]; }";
171        assert!(estimate(s, Profile::Code) > estimate(s, Profile::Prose));
172    }
173
174    #[test]
175    fn whitespace_runs_are_collapsed() {
176        let tight = "alpha beta gamma";
177        let padded = "alpha        beta        gamma";
178        assert_eq!(estimate(tight, Profile::Prose), estimate(padded, Profile::Prose));
179    }
180
181    #[test]
182    fn non_ascii_raises_the_estimate() {
183        let ascii = "aaaaaaaaaaaaaaaaaaaa";
184        let cjk = "説明説明説明説明説明説明説明説明説明説明";
185        assert!(estimate(cjk, Profile::Prose) > estimate(ascii, Profile::Prose));
186    }
187
188    #[test]
189    fn profile_inference() {
190        assert_eq!(infer_profile("A plain sentence about nothing at all."), Profile::Prose);
191        assert_eq!(infer_profile("if (a == b) { c[d] = e; }"), Profile::Code);
192    }
193
194    #[test]
195    fn auto_matches_inferred_profile() {
196        let s = "let y = f(x);";
197        assert_eq!(estimate_auto(s), estimate(s, infer_profile(s)));
198    }
199
200    #[test]
201    fn budget_helpers() {
202        let s = "a short line of prose";
203        let n = estimate(s, Profile::Prose);
204        assert!(fits(s, n, Profile::Prose));
205        assert!(!fits(s, n - 1, Profile::Prose));
206        assert_eq!(remaining(s, n + 5, Profile::Prose), 5);
207        assert_eq!(remaining(s, 0, Profile::Prose), 0);
208    }
209
210    #[test]
211    fn margin_inflates() {
212        let s = "some prose to measure against a margin";
213        let base = estimate(s, Profile::Prose);
214        assert!(estimate_with_margin(s, Profile::Prose, 20) > base);
215        assert_eq!(estimate_with_margin(s, Profile::Prose, 0), base);
216    }
217
218    #[test]
219    fn prose_estimate_is_in_a_sane_range() {
220        // ~4 chars/token: a 44-char sentence should land near 11 tokens.
221        let s = "The quick brown fox jumps over the lazy dog.";
222        let n = estimate(s, Profile::Prose);
223        assert!((8..=14).contains(&n), "got {n}");
224    }
225}