deepstrike_core/context/
token_engine.rs1use std::sync::Arc;
3
4use crate::types::message::{Content, ContentPart, CoreMessage};
5
6pub trait TokenCounter: Send + Sync {
9 fn count(&self, text: &str) -> u32;
11
12 fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str;
15}
16
17pub 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(); 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
42pub struct FallbackEstimator {
52 tokenizer: deepstrike_tokenizer::Tokenizer,
53 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 let raw_budget = ((max_tokens as f64) / self.safety_margin).floor() as u32;
83 self.tokenizer.truncate(text, raw_budget)
84 }
85}
86
87#[derive(Clone)]
91pub struct ContextTokenEngine(Arc<dyn TokenCounter>);
92
93impl ContextTokenEngine {
94 pub fn char_approx() -> Self {
98 Self(Arc::new(CharApproxCounter))
99 }
100
101 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 ContentPart::Image { .. } | ContentPart::Audio { .. } => {
135 modality_estimate_tokens(part).unwrap_or(1)
136 }
137 }
138 }
139
140 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
160fn 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 let cjk_count = e.count("你好世界"); let ascii_count = e.count("abcd"); 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 #[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 #[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 #[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 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 assert!(e.count_message(&audio) < 100);
352 }
353}