pub const SYMBOL_FACTOR: f32 = 0.5;
pub const MESSAGE_OVERHEAD_TOKENS: u32 = 4;
#[derive(Debug, Clone, Copy)]
pub struct TokenEstimator {
cjk_factor: f32,
latin_factor: f32,
}
impl Default for TokenEstimator {
fn default() -> Self {
Self {
cjk_factor: 1.0,
latin_factor: 0.25,
}
}
}
impl TokenEstimator {
pub const fn new(cjk_factor: f32, latin_factor: f32) -> Self {
Self {
cjk_factor,
latin_factor,
}
}
pub fn estimate(text: &str) -> usize {
DEFAULT_ESTIMATOR.estimate_with_config(text)
}
pub fn estimate_with_config(&self, text: &str) -> usize {
let bytes = text.as_bytes();
if text.is_ascii() {
let ascii_ws =
count_byte(bytes, b' ') + count_byte(bytes, b'\t') + count_byte(bytes, b'\n');
let non_ws = text.len() - ascii_ws;
let symbols = bytes.iter().filter(|&&b| b.is_ascii_punctuation()).count();
let letters = non_ws - symbols;
let tokens = letters as f32 * self.latin_factor
+ symbols as f32 * SYMBOL_FACTOR
+ ascii_ws as f32 * 0.5;
return tokens.round() as usize;
}
let mut count = 0.0f32;
let ascii_ws =
count_byte(bytes, b' ') + count_byte(bytes, b'\t') + count_byte(bytes, b'\n');
count += ascii_ws as f32 * 0.5;
for ch in text.chars() {
if ch.is_ascii_whitespace() || ch.is_ascii_control() {
continue;
}
if Self::is_cjk(ch) {
count += self.cjk_factor;
} else if ch.is_ascii_punctuation() {
count += SYMBOL_FACTOR;
} else if ch.is_ascii() {
count += self.latin_factor;
} else {
count += 1.0;
}
}
count.round() as usize
}
pub fn estimate_text(&self, text: &str) -> usize {
self.estimate_with_config(text)
}
#[inline]
pub fn is_cjk(ch: char) -> bool {
let code = ch as u32;
(0x4E00..=0x9FFF).contains(&code)
|| (0x3400..=0x4DBF).contains(&code)
|| (0x20000..=0x2A6DF).contains(&code)
|| (0x3040..=0x309F).contains(&code)
|| (0x30A0..=0x30FF).contains(&code)
|| (0xAC00..=0xD7AF).contains(&code)
|| (0x1100..=0x11FF).contains(&code)
|| (0x3130..=0x318F).contains(&code)
}
pub fn find_split_point(&self, text: &str, max_tokens: usize) -> usize {
let mut bytes = 0;
let mut tokens = 0.0f32;
let mut last_break = 0;
for ch in text.chars() {
let ch_bytes = ch.len_utf8();
let ch_tokens = if Self::is_cjk(ch) {
self.cjk_factor
} else if ch.is_ascii_punctuation() {
SYMBOL_FACTOR
} else if ch.is_ascii() && !ch.is_ascii_whitespace() && !ch.is_ascii_control() {
self.latin_factor
} else if ch.is_ascii_whitespace() {
0.5
} else {
1.0
};
if tokens + ch_tokens > max_tokens as f32 {
return if last_break > 0 { last_break } else { bytes };
}
tokens += ch_tokens;
bytes += ch_bytes;
if ch == '\n' || ch == ' ' {
last_break = bytes;
}
}
text.len()
}
}
#[inline]
fn count_byte(bytes: &[u8], target: u8) -> usize {
bytes.iter().filter(|&&b| b == target).count()
}
static DEFAULT_ESTIMATOR: TokenEstimator = TokenEstimator {
cjk_factor: 1.0,
latin_factor: 0.25,
};
pub fn estimate_tokens(text: &str) -> usize {
TokenEstimator::estimate(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_text() {
assert_eq!(estimate_tokens(""), 0);
}
#[test]
fn test_ascii_text() {
let text = "Hello World";
let tokens = estimate_tokens(text);
assert_eq!(tokens, 3, "tokens: {}", tokens);
}
#[test]
fn test_cjk_text() {
let text = "你好,世界!";
let tokens = estimate_tokens(text);
assert_eq!(tokens, 6, "tokens: {}", tokens);
}
#[test]
fn test_mixed_text() {
let text = "Hello 世界";
let tokens = estimate_tokens(text);
assert_eq!(tokens, 4, "tokens: {}", tokens);
}
#[test]
fn test_whitespace() {
let text = "Hello World";
let tokens = estimate_tokens(text);
assert_eq!(tokens, 4, "tokens: {}", tokens);
}
#[test]
fn test_long_ascii() {
let text = "The quick brown fox jumps over the lazy dog";
let tokens = estimate_tokens(text);
assert!((10..=16).contains(&tokens), "tokens: {}", tokens);
}
#[test]
fn test_japanese() {
let text = "こんにちは世界";
let tokens = estimate_tokens(text);
assert_eq!(tokens, 7, "tokens: {}", tokens);
}
#[test]
fn test_korean() {
let text = "안녕하세요";
let tokens = estimate_tokens(text);
assert_eq!(tokens, 5, "tokens: {}", tokens);
}
#[test]
fn test_custom_estimator() {
let estimator = TokenEstimator::new(0.8, 0.3);
let text = "Hello";
let tokens = estimator.estimate_with_config(text);
assert_eq!(tokens, 2, "tokens: {}", tokens);
let text = "How are you?";
let tokens = estimator.estimate_with_config(text);
assert_eq!(tokens, 4, "tokens: {}", tokens);
}
#[test]
fn test_custom_estimator_ascii_fast_path() {
let estimator = TokenEstimator::new(1.0, 0.2);
let text = "ABCDEFGHIJ"; let tokens = estimator.estimate_with_config(text);
assert_eq!(tokens, 2, "tokens: {}", tokens);
}
#[test]
fn test_default_estimator_consistency() {
let text = "Hello World 123";
let estimator = TokenEstimator::default();
assert_eq!(estimator.estimate_text(text), estimate_tokens(text));
}
#[test]
fn test_ascii_symbols_counted_separately() {
let text = "fn foo() -> u32";
let tokens = estimate_tokens(text);
assert_eq!(tokens, 6, "tokens: {}", tokens);
}
#[test]
fn test_symbol_estimation_matches_find_split_point() {
let text = "map(|x| x + 1) -> Result<(), Error>";
let estimator = TokenEstimator::default();
let total = estimator.estimate_text(text);
let split = estimator.find_split_point(text, total);
assert!(
estimator.estimate_text(&text[..split]) <= total,
"split text ({}) must fit within budget {}",
estimator.estimate_text(&text[..split]),
total
);
assert!(split > 0, "split point must be non-empty");
}
#[test]
fn test_find_split_point() {
let text = "This is a test string that is longer than the limit.";
let split = TokenEstimator::default().find_split_point(text, 5);
assert!(split < text.len());
assert!(split > 0);
}
#[test]
fn test_find_split_point_cjk() {
let text = "这是一个用于测试分段的较长的中文字符串内容";
let split = TokenEstimator::default().find_split_point(text, 8);
assert!(split < text.len());
assert!(split > 0);
assert!(
TokenEstimator::default().estimate_text(&text[..split]) <= 8,
"split text must fit within budget"
);
}
}