Skip to main content

deepstrike_core/context/
token_engine.rs

1// DEL-1 migration window (0.2.67 → removed 0.2.68): this module still reads/writes the
2use std::sync::Arc;
3
4use crate::types::message::{Content, ContentPart, CoreMessage};
5
6/// Token counting and truncation interface. Implementations must be
7/// deterministic and must never panic on any valid UTF-8 input.
8pub trait TokenCounter: Send + Sync {
9    /// Count tokens in a UTF-8 string.
10    fn count(&self, text: &str) -> u32;
11
12    /// Return the longest prefix of `text` that fits within `max_tokens`.
13    /// The returned slice is always a valid UTF-8 prefix of `text`.
14    fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str;
15}
16
17/// Char-count approximation: 4 chars ≈ 1 token.
18/// Used when no real tokeniser is available. More accurate than byte-count
19/// for CJK text (3 bytes/char but ~0.5 tokens/char).
20pub struct CharApproxCounter;
21
22impl TokenCounter for CharApproxCounter {
23    fn count(&self, text: &str) -> u32 {
24        (text.chars().count() as u32 / 4).max(1)
25    }
26
27    fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str {
28        let max_chars = (max_tokens as usize).saturating_mul(4);
29        let mut byte_end = text.len(); // default: keep all
30        let mut seen = 0usize;
31        for (byte_idx, _) in text.char_indices() {
32            if seen >= max_chars {
33                byte_end = byte_idx;
34                break;
35            }
36            seen += 1;
37        }
38        &text[..byte_end]
39    }
40}
41
42/// spc_011-C-01: real-BPE-backed counter, the production default. `CharApproxCounter`'s
43/// char/4 divisor only holds for English text — on CJK-heavy text it underestimates real BPE
44/// counts by 40-70% (its own doc comment claims "~0.5 tokens/char" for CJK, but the
45/// implementation applies the same 0.25 tokens/char divisor to every script). This wraps
46/// `deepstrike-tokenizer`'s real cl100k BPE tokenizer (previously an orphaned crate — not a
47/// workspace member, zero call sites anywhere) and adds a fixed margin on top. That only
48/// guarantees a value above the selected `cl100k_base` estimate; it does not prove that the
49/// result is conservative for every provider model. Native Anthropic/Gemini token counts take
50/// precedence where callers explicitly request them.
51pub struct FallbackEstimator {
52    tokenizer: deepstrike_tokenizer::Tokenizer,
53    /// Multiplier applied on top of the raw BPE count. `1.1` is a starting margin over
54    /// `cl100k_base`, not an empirical claim about any provider tokenizer.
55    safety_margin: f64,
56}
57
58impl FallbackEstimator {
59    pub fn new(backend: deepstrike_tokenizer::TokenizerBackend, safety_margin: f64) -> Self {
60        Self {
61            tokenizer: deepstrike_tokenizer::Tokenizer::new(backend),
62            safety_margin,
63        }
64    }
65}
66
67impl Default for FallbackEstimator {
68    fn default() -> Self {
69        Self::new(deepstrike_tokenizer::TokenizerBackend::Cl100k, 1.1)
70    }
71}
72
73impl TokenCounter for FallbackEstimator {
74    fn count(&self, text: &str) -> u32 {
75        let raw = self.tokenizer.count(text) as f64;
76        ((raw * self.safety_margin).ceil() as u32).max(1)
77    }
78
79    fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str {
80        // Budget in *raw* BPE tokens so that, after the margin is applied by `count`, the
81        // truncated text's reported count still fits within `max_tokens`.
82        let raw_budget = ((max_tokens as f64) / self.safety_margin).floor() as u32;
83        self.tokenizer.truncate(text, raw_budget)
84    }
85}
86
87/// Cheaply cloneable token engine shared across the context subsystem.
88/// All token counting and truncation goes through this single object —
89/// pressure, compression, and render use the same backend.
90#[derive(Clone)]
91pub struct ContextTokenEngine(Arc<dyn TokenCounter>);
92
93impl ContextTokenEngine {
94    /// Deterministic char/4 approximation. Kept as an explicit, opt-in constructor for tests
95    /// that pin exact token counts (many do, calibrated to this specific math) — **not** used
96    /// by any production call site as of spc_011-C-01; see [`Self::fallback_estimator`].
97    pub fn char_approx() -> Self {
98        Self(Arc::new(CharApproxCounter))
99    }
100
101    /// spc_011-C-01: the production default token engine. Real-BPE-backed (see
102    /// [`FallbackEstimator`]), replacing the previous `char_approx()` default that
103    /// underestimated CJK-heavy text by 40-70%.
104    pub fn fallback_estimator() -> Self {
105        Self(Arc::new(FallbackEstimator::default()))
106    }
107
108    pub fn count(&self, text: &str) -> u32 {
109        self.0.count(text)
110    }
111
112    pub fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str {
113        self.0.truncate(text, max_tokens)
114    }
115
116    pub fn token_budget_to_bytes(&self, tokens: u32) -> usize {
117        (tokens as usize).saturating_mul(4)
118    }
119
120    pub fn count_message(&self, msg: &CoreMessage) -> u32 {
121        match &msg.content {
122            Content::Text(t) => self.count(t),
123            Content::Parts(parts) => parts.iter().map(|p| self.count_part(p)).sum(),
124        }
125    }
126
127    fn count_part(&self, part: &ContentPart) -> u32 {
128        match part {
129            ContentPart::Text { text } => self.count(text),
130            ContentPart::ToolResult { output, .. } => self.count(output),
131            // Image/Audio: modality heuristic (0.2.66 DEL-2 — the heuristic lives here,
132            // in the engine, not on ContentPart) — never treat base64/url payloads as
133            // UTF-8 text (that blind-spots compression ρ).
134            ContentPart::Image { .. } | ContentPart::Audio { .. } => {
135                modality_estimate_tokens(part).unwrap_or(1)
136            }
137        }
138    }
139
140    /// Truncate a text message to `max_tokens`. Returns the message unchanged
141    /// if it fits. Parts messages are never truncated — mangling structured
142    /// content produces worse outcomes than a minor token overrun.
143    pub fn truncate_message(&self, msg: &CoreMessage, max_tokens: u32) -> CoreMessage {
144        match &msg.content {
145            Content::Text(t) => {
146                let kept = self.0.truncate(t, max_tokens);
147                if kept.len() < t.len() {
148                    let mut m = msg.clone();
149                    m.content = Content::Text(format!("{}… [truncated]", kept));
150                    m
151                } else {
152                    msg.clone()
153                }
154            }
155            Content::Parts(_) => msg.clone(),
156        }
157    }
158}
159
160/// Modality-aware token estimate for Image/Audio parts (0.2.66 DEL-2: moved here from
161/// `ContentPart::estimate_tokens`, which is deleted — the engine holds the heuristic,
162/// `ContentPart` stays pure semantics). Returns `None` for text-bearing parts that must
163/// go through a real [`TokenCounter`].
164///
165/// Image: OpenAI-vision-style tile heuristic (`low=85`, `auto/default=255`, `high=680`).
166/// Audio: `max(1, floor(decoded_bytes / 1600))` where `decoded_bytes ≈ base64_len * 3/4`.
167/// Never treat base64 payloads as UTF-8 text for counting.
168fn modality_estimate_tokens(part: &ContentPart) -> Option<u32> {
169    match part {
170        ContentPart::Image { detail, .. } => Some(match detail.as_deref() {
171            Some("low") => 85,
172            Some("high") => 680,
173            _ => 255,
174        }),
175        ContentPart::Audio { source, .. } => {
176            let data_len = match source {
177                crate::types::durable_content::DurableSource::Base64 { data } => data.len(),
178                _ => 0,
179            };
180            let decoded_bytes = (data_len as u64).saturating_mul(3) / 4;
181            Some((decoded_bytes / 1600).max(1) as u32)
182        }
183        ContentPart::Text { .. } | ContentPart::ToolResult { .. } => None,
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::types::message::{ContentPart, CoreMessage};
191
192    fn engine() -> ContextTokenEngine {
193        ContextTokenEngine::char_approx()
194    }
195
196    #[test]
197    fn count_nonzero_for_nonempty_text() {
198        assert!(engine().count("hello") > 0);
199    }
200
201    #[test]
202    fn count_is_char_based_not_byte_based() {
203        let e = engine();
204        // "你好" = 6 bytes, 2 chars → count = max(2/4, 1) = 1
205        // "hello" = 5 bytes, 5 chars → count = max(5/4, 1) = 1
206        // The key property: count doesn't grow 3× for CJK vs ASCII
207        let cjk_count = e.count("你好世界"); // 4 chars
208        let ascii_count = e.count("abcd"); // 4 chars (same char count)
209        assert_eq!(cjk_count, ascii_count);
210    }
211
212    #[test]
213    fn truncate_stays_within_budget() {
214        let e = engine();
215        let text = "a".repeat(1000);
216        let kept = e.0.truncate(&text, 10);
217        assert!(e.count(kept) <= 10);
218    }
219
220    #[test]
221    fn truncate_cjk_valid_utf8() {
222        let e = engine();
223        let text = "你好世界".repeat(100);
224        let kept = e.0.truncate(&text, 5);
225        assert!(std::str::from_utf8(kept.as_bytes()).is_ok());
226    }
227
228    #[test]
229    fn truncate_count_le_budget() {
230        let e = engine();
231        for max in [1u32, 5, 20, 100] {
232            let kept =
233                e.0.truncate("The quick brown fox jumps over the lazy dog.", max);
234            assert!(
235                e.count(kept) <= max,
236                "max={max} kept_count={}",
237                e.count(kept)
238            );
239        }
240    }
241
242    #[test]
243    fn truncate_message_appends_suffix_on_cut() {
244        let e = engine();
245        let msg = CoreMessage::user("a".repeat(200));
246        let truncated = e.truncate_message(&msg, 5);
247        let text = truncated.content.as_text().unwrap();
248        assert!(text.ends_with("… [truncated]"), "got: {text}");
249    }
250
251    #[test]
252    fn truncate_message_unchanged_when_fits() {
253        let e = engine();
254        let msg = CoreMessage::user("hi");
255        let out = e.truncate_message(&msg, 1000);
256        assert_eq!(out.content.as_text().unwrap(), "hi");
257    }
258
259    #[test]
260    fn count_image_uses_detail_heuristic_not_one() {
261        let e = engine();
262        let image = |detail: Option<&str>| ContentPart::Image {
263            source: crate::types::durable_content::DurableSource::Base64 {
264                data: "YWJj".into(),
265            },
266            media_type: Some("image/png".into()),
267            detail: detail.map(str::to_string),
268        };
269        let low = CoreMessage::user_multimodal(vec![image(Some("low"))]);
270        let auto = CoreMessage::user_multimodal(vec![image(None)]);
271        let high = CoreMessage::user_multimodal(vec![image(Some("high"))]);
272        assert_eq!(e.count_message(&low), 85);
273        assert_eq!(e.count_message(&auto), 255);
274        assert_eq!(e.count_message(&high), 680);
275    }
276
277    /// spc_011-C-01 Red: `CharApproxCounter` (char/4) badly underestimates real BPE token
278    /// counts on CJK-heavy text — its own doc comment claims "~0.5 tokens/char" for CJK, but
279    /// the implementation divides by 4 (0.25 tokens/char) regardless of script, which is only
280    /// correct for English. This reproduces the underestimate against a sample matching this
281    /// repo's own actual workload (Chinese spec/status prose), not a synthetic worst case.
282    #[test]
283    fn char_approx_severely_underestimates_cjk_heavy_text_vs_real_bpe() {
284        let sample = "核实 `ContextTokenEngine` 默认使用 `CharApproxCounter`(4 字符≈1 token),\
285而 `ContextManager::new()` 明确默认初始化 `ContextTokenEngine::char_approx()`。这就能解释实际观察到的 \
28620%~30% 少算问题。这个值直接进入 Context ρ → Snip → Micro → Collapse → Auto → Renewal 决策链路,\
287低估会导致压缩没有按时触发,继续 append 下去最终造成 Provider context overflow。";
288
289        let approx = CharApproxCounter.count(sample);
290        let real =
291            deepstrike_tokenizer::Tokenizer::new(deepstrike_tokenizer::TokenizerBackend::Cl100k)
292                .count(sample);
293
294        let underestimate_pct = 1.0 - (approx as f64 / real as f64);
295        assert!(
296            underestimate_pct > 0.30,
297            "expected char_approx to underestimate real BPE count by >30% on CJK-heavy text, \
298             got approx={approx} real={real} ({:.1}%)",
299            underestimate_pct * 100.0
300        );
301    }
302
303    /// The production default (`fallback_estimator`) must not reproduce the CJK underestimate
304    /// above: its fixed margin keeps it at or above the selected `cl100k_base` count.
305    #[test]
306    fn fallback_estimator_does_not_underestimate_cjk_heavy_text() {
307        let sample = "核实 `ContextTokenEngine` 默认使用 `CharApproxCounter`(4 字符≈1 token),\
308而 `ContextManager::new()` 明确默认初始化 `ContextTokenEngine::char_approx()`。这就能解释实际观察到的 \
30920%~30% 少算问题。";
310
311        let e = ContextTokenEngine::fallback_estimator();
312        let estimated = e.count(sample);
313        let real =
314            deepstrike_tokenizer::Tokenizer::new(deepstrike_tokenizer::TokenizerBackend::Cl100k)
315                .count(sample);
316
317        assert!(
318            estimated >= real,
319            "fallback_estimator margin must stay above its cl100k base \
320             (estimated={estimated} real={real})"
321        );
322    }
323
324    /// The default production engine (`ContextManager::new`) must be the fallback estimator,
325    /// not `char_approx` — this is the actual bug: the constructor call site, not the counter
326    /// implementation itself (which stays correct as an explicit, deterministic test helper).
327    #[test]
328    fn context_manager_new_does_not_default_to_char_approx() {
329        let cjk = "这是一段包含中文的示例文本,用来验证生产路径默认引擎不再是字符近似计数器。";
330        let mgr = crate::context::manager::ContextManager::new(100_000);
331        let default_engine_count = mgr.engine.count(cjk);
332        let char_approx_count = ContextTokenEngine::char_approx().count(cjk);
333        assert_ne!(
334            default_engine_count, char_approx_count,
335            "ContextManager::new() must not use char_approx as its token engine"
336        );
337    }
338
339    #[test]
340    fn count_audio_uses_decoded_byte_heuristic_not_base64_text() {
341        let e = engine();
342        // 6400 base64 chars → ~4800 decoded bytes → 4800/1600 = 3 tokens
343        let audio = CoreMessage::user_multimodal(vec![ContentPart::Audio {
344            source: crate::types::durable_content::DurableSource::Base64 {
345                data: "A".repeat(6400),
346            },
347            media_type: "audio/wav".into(),
348        }]);
349        assert_eq!(e.count_message(&audio), 3);
350        // Must not explode to thousands the way counting base64 as text would.
351        assert!(e.count_message(&audio) < 100);
352    }
353}