deepstrike_core/context/
token_engine.rs1use std::sync::Arc;
2
3use crate::types::message::{Content, ContentPart, CoreMessage};
4
5pub trait TokenCounter: Send + Sync {
8 fn count(&self, text: &str) -> u32;
10
11 fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str;
14}
15
16pub struct CharApproxCounter;
20
21impl TokenCounter for CharApproxCounter {
22 fn count(&self, text: &str) -> u32 {
23 (text.chars().count() as u32 / 4).max(1)
24 }
25
26 fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str {
27 let max_chars = (max_tokens as usize).saturating_mul(4);
28 let mut byte_end = text.len(); let mut seen = 0usize;
30 for (byte_idx, _) in text.char_indices() {
31 if seen >= max_chars {
32 byte_end = byte_idx;
33 break;
34 }
35 seen += 1;
36 }
37 &text[..byte_end]
38 }
39}
40
41pub struct FallbackEstimator {
51 tokenizer: deepstrike_tokenizer::Tokenizer,
52 safety_margin: f64,
55}
56
57impl FallbackEstimator {
58 pub fn new(backend: deepstrike_tokenizer::TokenizerBackend, safety_margin: f64) -> Self {
59 Self {
60 tokenizer: deepstrike_tokenizer::Tokenizer::new(backend),
61 safety_margin,
62 }
63 }
64}
65
66impl Default for FallbackEstimator {
67 fn default() -> Self {
68 Self::new(deepstrike_tokenizer::TokenizerBackend::Cl100k, 1.1)
69 }
70}
71
72impl TokenCounter for FallbackEstimator {
73 fn count(&self, text: &str) -> u32 {
74 let raw = self.tokenizer.count(text) as f64;
75 ((raw * self.safety_margin).ceil() as u32).max(1)
76 }
77
78 fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str {
79 let raw_budget = ((max_tokens as f64) / self.safety_margin).floor() as u32;
82 self.tokenizer.truncate(text, raw_budget)
83 }
84}
85
86#[derive(Clone)]
90pub struct ContextTokenEngine(Arc<dyn TokenCounter>);
91
92impl ContextTokenEngine {
93 pub fn char_approx() -> Self {
97 Self(Arc::new(CharApproxCounter))
98 }
99
100 pub fn fallback_estimator() -> Self {
104 Self(Arc::new(FallbackEstimator::default()))
105 }
106
107 pub fn count(&self, text: &str) -> u32 {
108 self.0.count(text)
109 }
110
111 pub fn truncate<'a>(&self, text: &'a str, max_tokens: u32) -> &'a str {
112 self.0.truncate(text, max_tokens)
113 }
114
115 pub fn token_budget_to_bytes(&self, tokens: u32) -> usize {
116 (tokens as usize).saturating_mul(4)
117 }
118
119 pub fn count_message(&self, msg: &CoreMessage) -> u32 {
120 match &msg.content {
121 Content::Text(t) => self.count(t),
122 Content::Parts(parts) => parts.iter().map(|p| self.count_part(p)).sum(),
123 }
124 }
125
126 fn count_part(&self, part: &ContentPart) -> u32 {
127 match part {
128 ContentPart::Text { text } => self.count(text),
129 ContentPart::ToolResult { output, .. } => self.count(output),
130 ContentPart::Image { .. } | ContentPart::Audio { .. } => {
134 modality_estimate_tokens(part).unwrap_or(1)
135 }
136 }
137 }
138
139 pub fn truncate_message(&self, msg: &CoreMessage, max_tokens: u32) -> CoreMessage {
143 match &msg.content {
144 Content::Text(t) => {
145 let kept = self.0.truncate(t, max_tokens);
146 if kept.len() < t.len() {
147 let mut m = msg.clone();
148 m.content = Content::Text(format!("{}… [truncated]", kept));
149 m
150 } else {
151 msg.clone()
152 }
153 }
154 Content::Parts(_) => msg.clone(),
155 }
156 }
157}
158
159fn modality_estimate_tokens(part: &ContentPart) -> Option<u32> {
168 match part {
169 ContentPart::Image { detail, .. } => Some(match detail.as_deref() {
170 Some("low") => 85,
171 Some("high") => 680,
172 _ => 255,
173 }),
174 ContentPart::Audio { source, .. } => {
175 let data_len = match source {
176 crate::types::durable_content::DurableSource::Base64 { data } => data.len(),
177 _ => 0,
178 };
179 let decoded_bytes = (data_len as u64).saturating_mul(3) / 4;
180 Some((decoded_bytes / 1600).max(1) as u32)
181 }
182 ContentPart::Text { .. } | ContentPart::ToolResult { .. } => None,
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use crate::types::message::{ContentPart, CoreMessage};
190
191 fn engine() -> ContextTokenEngine {
192 ContextTokenEngine::char_approx()
193 }
194
195 #[test]
196 fn count_nonzero_for_nonempty_text() {
197 assert!(engine().count("hello") > 0);
198 }
199
200 #[test]
201 fn count_is_char_based_not_byte_based() {
202 let e = engine();
203 let cjk_count = e.count("你好世界"); let ascii_count = e.count("abcd"); assert_eq!(cjk_count, ascii_count);
209 }
210
211 #[test]
212 fn truncate_stays_within_budget() {
213 let e = engine();
214 let text = "a".repeat(1000);
215 let kept = e.0.truncate(&text, 10);
216 assert!(e.count(kept) <= 10);
217 }
218
219 #[test]
220 fn truncate_cjk_valid_utf8() {
221 let e = engine();
222 let text = "你好世界".repeat(100);
223 let kept = e.0.truncate(&text, 5);
224 assert!(std::str::from_utf8(kept.as_bytes()).is_ok());
225 }
226
227 #[test]
228 fn truncate_count_le_budget() {
229 let e = engine();
230 for max in [1u32, 5, 20, 100] {
231 let kept =
232 e.0.truncate("The quick brown fox jumps over the lazy dog.", max);
233 assert!(
234 e.count(kept) <= max,
235 "max={max} kept_count={}",
236 e.count(kept)
237 );
238 }
239 }
240
241 #[test]
242 fn truncate_message_appends_suffix_on_cut() {
243 let e = engine();
244 let msg = CoreMessage::user("a".repeat(200));
245 let truncated = e.truncate_message(&msg, 5);
246 let text = truncated.content.as_text().unwrap();
247 assert!(text.ends_with("… [truncated]"), "got: {text}");
248 }
249
250 #[test]
251 fn truncate_message_unchanged_when_fits() {
252 let e = engine();
253 let msg = CoreMessage::user("hi");
254 let out = e.truncate_message(&msg, 1000);
255 assert_eq!(out.content.as_text().unwrap(), "hi");
256 }
257
258 #[test]
259 fn count_image_uses_detail_heuristic_not_one() {
260 let e = engine();
261 let image = |detail: Option<&str>| ContentPart::Image {
262 source: crate::types::durable_content::DurableSource::Base64 {
263 data: "YWJj".into(),
264 },
265 media_type: Some("image/png".into()),
266 detail: detail.map(str::to_string),
267 };
268 let low = CoreMessage::user_multimodal(vec![image(Some("low"))]);
269 let auto = CoreMessage::user_multimodal(vec![image(None)]);
270 let high = CoreMessage::user_multimodal(vec![image(Some("high"))]);
271 assert_eq!(e.count_message(&low), 85);
272 assert_eq!(e.count_message(&auto), 255);
273 assert_eq!(e.count_message(&high), 680);
274 }
275
276 #[test]
282 fn char_approx_severely_underestimates_cjk_heavy_text_vs_real_bpe() {
283 let sample = "核实 `ContextTokenEngine` 默认使用 `CharApproxCounter`(4 字符≈1 token),\
284而 `ContextManager::new()` 明确默认初始化 `ContextTokenEngine::char_approx()`。这就能解释实际观察到的 \
28520%~30% 少算问题。这个值直接进入 Context ρ → Snip → Micro → Collapse → Auto → Renewal 决策链路,\
286低估会导致压缩没有按时触发,继续 append 下去最终造成 Provider context overflow。";
287
288 let approx = CharApproxCounter.count(sample);
289 let real =
290 deepstrike_tokenizer::Tokenizer::new(deepstrike_tokenizer::TokenizerBackend::Cl100k)
291 .count(sample);
292
293 let underestimate_pct = 1.0 - (approx as f64 / real as f64);
294 assert!(
295 underestimate_pct > 0.30,
296 "expected char_approx to underestimate real BPE count by >30% on CJK-heavy text, \
297 got approx={approx} real={real} ({:.1}%)",
298 underestimate_pct * 100.0
299 );
300 }
301
302 #[test]
305 fn fallback_estimator_does_not_underestimate_cjk_heavy_text() {
306 let sample = "核实 `ContextTokenEngine` 默认使用 `CharApproxCounter`(4 字符≈1 token),\
307而 `ContextManager::new()` 明确默认初始化 `ContextTokenEngine::char_approx()`。这就能解释实际观察到的 \
30820%~30% 少算问题。";
309
310 let e = ContextTokenEngine::fallback_estimator();
311 let estimated = e.count(sample);
312 let real =
313 deepstrike_tokenizer::Tokenizer::new(deepstrike_tokenizer::TokenizerBackend::Cl100k)
314 .count(sample);
315
316 assert!(
317 estimated >= real,
318 "fallback_estimator margin must stay above its cl100k base \
319 (estimated={estimated} real={real})"
320 );
321 }
322
323 #[test]
327 fn context_manager_new_does_not_default_to_char_approx() {
328 let cjk = "这是一段包含中文的示例文本,用来验证生产路径默认引擎不再是字符近似计数器。";
329 let mgr = crate::context::manager::ContextManager::new(100_000);
330 let default_engine_count = mgr.engine.count(cjk);
331 let char_approx_count = ContextTokenEngine::char_approx().count(cjk);
332 assert_ne!(
333 default_engine_count, char_approx_count,
334 "ContextManager::new() must not use char_approx as its token engine"
335 );
336 }
337
338 #[test]
339 fn count_audio_uses_decoded_byte_heuristic_not_base64_text() {
340 let e = engine();
341 let audio = CoreMessage::user_multimodal(vec![ContentPart::Audio {
343 source: crate::types::durable_content::DurableSource::Base64 {
344 data: "A".repeat(6400),
345 },
346 media_type: "audio/wav".into(),
347 }]);
348 assert_eq!(e.count_message(&audio), 3);
349 assert!(e.count_message(&audio) < 100);
351 }
352}