Skip to main content

bamboo_compression/
counter.rs

1//! Token counting for budget management.
2//!
3//! Provides both heuristic and accurate BPE-based token counting.
4//! `TiktokenTokenCounter` uses OpenAI's o200k_base encoding (bundled at compile
5//! time) for accurate counts. `HeuristicTokenCounter` remains available as a
6//! lightweight fallback and is also used automatically if the bundled BPE
7//! vocabulary ever fails to load, so a failed load degrades gracefully rather
8//! than panicking.
9
10use std::sync::OnceLock;
11
12use bamboo_domain::Message;
13use tiktoken_rs::o200k_base;
14use tiktoken_rs::CoreBPE;
15
16/// Cached BPE encoder — initialized once, reused across all count_text calls.
17///
18/// Holds `None` if the bundled o200k_base vocabulary failed to load (e.g. a
19/// corrupt or unlinkable build). When `None`, `TiktokenTokenCounter` falls back
20/// to `HeuristicTokenCounter` instead of panicking. The failure is logged once
21/// at initialization time so the degradation is observable.
22static O200K_ENCODER: OnceLock<Option<CoreBPE>> = OnceLock::new();
23
24/// Returns the cached o200k_base encoder, or `None` if it failed to load.
25///
26/// The first call loads (or attempts to load) the bundled vocabulary exactly
27/// once; a load failure is logged a single time and cached as `None`, so the
28/// hot path never panics and never re-attempts the failing load.
29fn o200k_encoder() -> Option<&'static CoreBPE> {
30    O200K_ENCODER
31        .get_or_init(|| match o200k_base() {
32            Ok(encoder) => Some(encoder),
33            Err(err) => {
34                tracing::warn!(
35                    error = %err,
36                    "failed to load bundled o200k_base tokenizer; \
37                     falling back to heuristic token counting"
38                );
39                None
40            }
41        })
42        .as_ref()
43}
44
45/// Trait for token counting implementations.
46pub trait TokenCounter: Send + Sync {
47    /// Count tokens in a single message.
48    fn count_message(&self, message: &Message) -> u32;
49
50    /// Count tokens in multiple messages.
51    fn count_messages(&self, messages: &[Message]) -> u32 {
52        messages.iter().map(|m| self.count_message(m)).sum()
53    }
54
55    /// Count tokens in a plain text string.
56    fn count_text(&self, text: &str) -> u32;
57}
58
59/// Deterministic aggregate for ordered provider-visible tool positions.
60///
61/// This is a local context-window estimate, not provider-authoritative billing
62/// usage. The provider-reported usage remains authoritative after dispatch.
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64pub struct ProviderVisibleToolEstimate {
65    /// Saturating token sum across the ordered positions.
66    pub input_tokens: u32,
67    /// Saturating UTF-8 byte sum across the ordered positions.
68    pub serialized_bytes: usize,
69    /// Saturating Unicode scalar-value sum across the ordered positions.
70    pub serialized_chars: usize,
71}
72
73/// Heuristic token counter using character-based estimation.
74///
75/// Uses the approximation: tokens ≈ characters / 4, with a 10% safety margin
76/// plus additional overhead for message metadata (role, timestamps, etc.).
77///
78/// This is intentionally conservative to avoid underestimating token usage.
79#[derive(Debug, Clone)]
80pub struct HeuristicTokenCounter {
81    /// Characters per token ratio (default: 4)
82    chars_per_token: f64,
83    /// Safety margin multiplier (default: 1.1 = 10% extra)
84    safety_margin: f64,
85    /// Metadata overhead per message in tokens
86    metadata_overhead: u32,
87}
88
89impl HeuristicTokenCounter {
90    /// Create a new heuristic counter with custom parameters.
91    pub fn new(chars_per_token: f64, safety_margin: f64, metadata_overhead: u32) -> Self {
92        Self {
93            chars_per_token,
94            safety_margin,
95            metadata_overhead,
96        }
97    }
98
99    /// Create with default parameters (chars/4 + 10% margin + 10 metadata overhead).
100    pub fn with_defaults() -> Self {
101        Self {
102            chars_per_token: 4.0,
103            safety_margin: 1.1,
104            metadata_overhead: 10,
105        }
106    }
107}
108
109impl Default for HeuristicTokenCounter {
110    fn default() -> Self {
111        Self::with_defaults()
112    }
113}
114
115impl TokenCounter for HeuristicTokenCounter {
116    fn count_message(&self, message: &Message) -> u32 {
117        let content_tokens = self.count_text(&message.content);
118
119        // Add tokens for tool calls if present
120        let tool_calls_tokens = message
121            .tool_calls
122            .as_ref()
123            .map(|tc| {
124                tc.iter()
125                    .map(|c| {
126                        // Rough estimate: id + name + arguments
127                        let args_tokens = self.count_text(&c.function.arguments);
128                        let id_tokens = self.count_text(&c.id);
129                        let name_tokens = self.count_text(&c.function.name);
130                        // Use saturating_add to prevent overflow
131                        args_tokens
132                            .saturating_add(id_tokens)
133                            .saturating_add(name_tokens)
134                            .saturating_add(5) // type overhead
135                    })
136                    .fold(0u32, |acc, x| acc.saturating_add(x))
137            })
138            .unwrap_or(0);
139
140        // Add tokens for tool_call_id if present
141        let tool_call_id_tokens = message
142            .tool_call_id
143            .as_ref()
144            .map(|id| self.count_text(id).saturating_add(3)) // +3 for field name overhead
145            .unwrap_or(0);
146
147        // Use saturating_add to prevent overflow
148        content_tokens
149            .saturating_add(tool_calls_tokens)
150            .saturating_add(tool_call_id_tokens)
151            .saturating_add(self.metadata_overhead)
152    }
153
154    fn count_text(&self, text: &str) -> u32 {
155        if text.is_empty() {
156            return 0;
157        }
158
159        let char_count = text.chars().count() as f64;
160        let base_tokens = char_count / self.chars_per_token;
161        let adjusted_tokens = base_tokens * self.safety_margin;
162
163        adjusted_tokens.ceil() as u32
164    }
165}
166
167/// Accurate BPE-based token counter using OpenAI's o200k_base encoding.
168///
169/// Uses `tiktoken-rs` with the vocabulary bundled at compile time — no runtime
170/// downloads. This is the recommended counter for production use.
171#[derive(Debug)]
172pub struct TiktokenTokenCounter {
173    /// Per-message metadata overhead in tokens (role markers, formatting, etc.)
174    metadata_overhead: u32,
175}
176
177impl TiktokenTokenCounter {
178    /// Create with a custom metadata overhead.
179    pub fn new(metadata_overhead: u32) -> Self {
180        Self { metadata_overhead }
181    }
182
183    /// Estimate already-lowered provider-visible tool segments exactly once.
184    ///
185    /// `segments` must contain compact serialized JSON/text in the order the
186    /// selected provider exposes it to the model. Each segment is one contiguous
187    /// model position and is tokenized separately with the same counter used for
188    /// prompt text; provider lowering must serialize adjacent content such as an
189    /// initial tools array into one segment. This prevents BPE merges across
190    /// positions separated by transcript content.
191    ///
192    /// Repeated fragments at different model-visible positions remain repeated
193    /// by design; callers must omit definitions that are not model-visible at
194    /// that position. An empty slice returns an all-zero estimate.
195    pub fn estimate_provider_visible_tool_segments<'a>(
196        &self,
197        segments: impl IntoIterator<Item = &'a str>,
198    ) -> ProviderVisibleToolEstimate {
199        let mut estimate = ProviderVisibleToolEstimate::default();
200        for segment in segments {
201            estimate.input_tokens = estimate
202                .input_tokens
203                .saturating_add(self.count_text(segment));
204            estimate.serialized_bytes = estimate.serialized_bytes.saturating_add(segment.len());
205            estimate.serialized_chars = estimate
206                .serialized_chars
207                .saturating_add(segment.chars().count());
208        }
209        estimate
210    }
211
212    /// Truncate `text` to at most `max_tokens` tokens, keeping the START.
213    ///
214    /// Encodes the text **once** and decodes the first `max_tokens` tokens back
215    /// to a string — O(N) (one encode + one decode), versus the O(N²)
216    /// char-by-char re-tokenization the previous `find_prefix_within_tokens`
217    /// performed (which called `count_text(&text[..i])` on every char index).
218    ///
219    /// # Semantics
220    /// - `max_tokens == 0` → empty string (exactly 0 tokens; never exceeds budget).
221    /// - Text already within `max_tokens` → returned unchanged (fast path).
222    /// - Otherwise the result is an exact prefix of `text` (its START preserved),
223    ///   is valid UTF-8, and re-counts to ≤ `max_tokens`.
224    ///
225    /// If the o200k encoder is unavailable (the issue #25 fallback path), this
226    /// degrades to a conservative char-based cut instead of panicking.
227    pub fn truncate_to_token_prefix(&self, text: &str, max_tokens: u32) -> String {
228        if max_tokens == 0 {
229            return String::new();
230        }
231        let Some(encoder) = o200k_encoder() else {
232            return heuristic_char_prefix(text, max_tokens);
233        };
234        // One encode — same encoder `count_text` uses, so the fast-path length
235        // check is consistent with `count_text(text)`.
236        let tokens = encoder.encode_with_special_tokens(text);
237        if (tokens.len() as u32) <= max_tokens {
238            return text.to_string();
239        }
240        let end = max_tokens as usize;
241        match encoder.decode_bytes(&tokens[..end]) {
242            // `decode_bytes` yields exactly the bytes of `text` spanned by the
243            // first `end` tokens — a byte-prefix of `text`. A token boundary can
244            // fall inside a multi-byte UTF-8 char, so trim any partial trailing
245            // char: the result stays a valid-UTF-8 exact prefix of `text`.
246            Ok(bytes) => valid_utf8_prefix(bytes),
247            Err(_) => heuristic_char_prefix(text, max_tokens),
248        }
249    }
250
251    /// Truncate `text` to at most `max_tokens` tokens, keeping the END.
252    ///
253    /// Symmetric to [`truncate_to_token_prefix`](Self::truncate_to_token_prefix):
254    /// encodes once and decodes the **last** `max_tokens` tokens. Same budget /
255    /// fast-path / fallback semantics; the result is a valid-UTF-8 exact suffix
256    /// of `text` (its END preserved) that re-counts to ≤ `max_tokens`.
257    pub fn truncate_to_token_suffix(&self, text: &str, max_tokens: u32) -> String {
258        if max_tokens == 0 {
259            return String::new();
260        }
261        let Some(encoder) = o200k_encoder() else {
262            return heuristic_char_suffix(text, max_tokens);
263        };
264        let tokens = encoder.encode_with_special_tokens(text);
265        if (tokens.len() as u32) <= max_tokens {
266            return text.to_string();
267        }
268        let start = tokens.len() - (max_tokens as usize);
269        match encoder.decode_bytes(&tokens[start..]) {
270            // The last `max_tokens` tokens span a byte-suffix of `text`; a
271            // boundary may split a *leading* multi-byte char, so drop any partial
272            // leading bytes to keep the result valid UTF-8 (still an exact suffix
273            // of `text`).
274            Ok(bytes) => valid_utf8_suffix(bytes),
275            Err(_) => heuristic_char_suffix(text, max_tokens),
276        }
277    }
278}
279
280// ── Encode-once truncation helpers ───────────────────────────────────────────
281//
282// These are used only by `TiktokenTokenCounter::truncate_to_token_{prefix,suffix}`.
283
284/// Conservative char-based prefix used solely when the BPE encoder is
285/// unavailable (the issue #25 fallback). Sized so the `HeuristicTokenCounter`
286/// estimate (chars/4 · 1.1) stays within budget.
287fn heuristic_char_prefix(text: &str, max_tokens: u32) -> String {
288    text.chars()
289        .take(heuristic_char_budget(max_tokens))
290        .collect()
291}
292
293/// Conservative char-based suffix — symmetric to [`heuristic_char_prefix`].
294fn heuristic_char_suffix(text: &str, max_tokens: u32) -> String {
295    let max_chars = heuristic_char_budget(max_tokens);
296    let skip = text.chars().count().saturating_sub(max_chars);
297    text.chars().skip(skip).collect()
298}
299
300/// Number of chars whose heuristic token estimate (ceil(chars/4 · 1.1)) is
301/// ≤ `max_tokens`: solves ceil(c/4 · 1.1) ≤ max_tokens ⟺ c ≤ max_tokens·4/1.1.
302fn heuristic_char_budget(max_tokens: u32) -> usize {
303    ((max_tokens as f64) * 4.0 / 1.1).floor() as usize
304}
305
306/// Turn a byte-prefix of some valid UTF-8 text into a valid UTF-8 string,
307/// trimming a partial trailing multi-byte char if the token boundary landed
308/// mid-character. The result is still an exact prefix of the original text.
309fn valid_utf8_prefix(bytes: Vec<u8>) -> String {
310    let valid_up_to = match std::str::from_utf8(&bytes) {
311        Ok(_) => bytes.len(),
312        Err(e) => e.valid_up_to(),
313    };
314    // bytes[..valid_up_to] is valid UTF-8 → lossy is a zero-copy borrow.
315    String::from_utf8_lossy(&bytes[..valid_up_to]).into_owned()
316}
317
318/// Turn a byte-suffix of some valid UTF-8 text into a valid UTF-8 string,
319/// dropping leading bytes that belong to a partial multi-byte char. Because the
320/// input is a contiguous suffix of valid UTF-8 text, the only possible
321/// invalidity is a leading partial char (≤ 3 bytes), so this advances at most a
322/// couple of times — O(N) overall.
323fn valid_utf8_suffix(bytes: Vec<u8>) -> String {
324    let mut start = 0;
325    while start < bytes.len() {
326        if std::str::from_utf8(&bytes[start..]).is_ok() {
327            return String::from_utf8_lossy(&bytes[start..]).into_owned();
328        }
329        start += 1;
330    }
331    String::new()
332}
333
334impl Default for TiktokenTokenCounter {
335    fn default() -> Self {
336        Self {
337            metadata_overhead: 10,
338        }
339    }
340}
341
342impl TokenCounter for TiktokenTokenCounter {
343    fn count_message(&self, message: &Message) -> u32 {
344        let content_tokens = self.count_text(&message.content);
345
346        let tool_calls_tokens = message
347            .tool_calls
348            .as_ref()
349            .map(|tc| {
350                tc.iter()
351                    .map(|c| {
352                        let args_tokens = self.count_text(&c.function.arguments);
353                        let id_tokens = self.count_text(&c.id);
354                        let name_tokens = self.count_text(&c.function.name);
355                        args_tokens
356                            .saturating_add(id_tokens)
357                            .saturating_add(name_tokens)
358                            .saturating_add(5)
359                    })
360                    .fold(0u32, |acc, x| acc.saturating_add(x))
361            })
362            .unwrap_or(0);
363
364        let tool_call_id_tokens = message
365            .tool_call_id
366            .as_ref()
367            .map(|id| self.count_text(id).saturating_add(3))
368            .unwrap_or(0);
369
370        content_tokens
371            .saturating_add(tool_calls_tokens)
372            .saturating_add(tool_call_id_tokens)
373            .saturating_add(self.metadata_overhead)
374    }
375
376    fn count_text(&self, text: &str) -> u32 {
377        if text.is_empty() {
378            return 0;
379        }
380        match o200k_encoder() {
381            // Accurate BPE count.
382            Some(encoder) => encoder.encode_with_special_tokens(text).len() as u32,
383            // Encoder unavailable — degrade to the char-based heuristic instead
384            // of panicking. Reuses the existing HeuristicTokenCounter.
385            None => HeuristicTokenCounter::default().count_text(text),
386        }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use bamboo_domain::{FunctionCall, ToolCall};
394
395    #[test]
396    fn heuristic_counter_counts_text() {
397        let counter = HeuristicTokenCounter::default();
398
399        // "Hello, world!" = 13 chars -> 13/4 * 1.1 ≈ 3.57 -> 4 tokens
400        let tokens = counter.count_text("Hello, world!");
401        assert!(
402            (3..=5).contains(&tokens),
403            "Expected ~4 tokens, got {}",
404            tokens
405        );
406    }
407
408    #[test]
409    fn heuristic_counter_counts_empty_text() {
410        let counter = HeuristicTokenCounter::default();
411        assert_eq!(counter.count_text(""), 0);
412    }
413
414    #[test]
415    fn heuristic_counter_counts_user_message() {
416        let counter = HeuristicTokenCounter::default();
417        let message = Message::user("Hello, world!");
418
419        let tokens = counter.count_message(&message);
420        // Should include content + metadata overhead (10)
421        assert!(
422            tokens >= 10,
423            "Expected at least 10 tokens (content + metadata), got {}",
424            tokens
425        );
426    }
427
428    #[test]
429    fn heuristic_counter_counts_tool_calls() {
430        let counter = HeuristicTokenCounter::default();
431
432        let tool_call = ToolCall {
433            id: "call_123".to_string(),
434            tool_type: "function".to_string(),
435            function: FunctionCall {
436                name: "search".to_string(),
437                arguments: r#"{"query":"test"}"#.to_string(),
438            },
439        };
440
441        let message = Message::assistant("Let me search", Some(vec![tool_call]));
442
443        let tokens = counter.count_message(&message);
444        // Should include content + tool call (id + name + args) + metadata
445        assert!(tokens >= 15, "Expected at least 15 tokens, got {}", tokens);
446    }
447
448    #[test]
449    fn heuristic_counter_counts_tool_result() {
450        let counter = HeuristicTokenCounter::default();
451        let message = Message::tool_result("call_123", "Search results here");
452
453        let tokens = counter.count_message(&message);
454        // Should include content + tool_call_id + metadata
455        assert!(tokens >= 15, "Expected at least 15 tokens, got {}", tokens);
456    }
457
458    #[test]
459    fn heuristic_counter_counts_multiple_messages() {
460        let counter = HeuristicTokenCounter::default();
461        let messages = vec![
462            Message::system("You are helpful"),
463            Message::user("Hello"),
464            Message::assistant("Hi there", None),
465        ];
466
467        let total = counter.count_messages(&messages);
468        let sum: u32 = messages.iter().map(|m| counter.count_message(m)).sum();
469
470        assert_eq!(total, sum);
471    }
472
473    #[test]
474    fn custom_chars_per_token() {
475        let counter = HeuristicTokenCounter::new(2.0, 1.0, 0);
476        // With 2 chars per token, "test" (4 chars) = 2 tokens
477        let tokens = counter.count_text("test");
478        assert_eq!(tokens, 2);
479    }
480
481    #[test]
482    fn safety_margin_applied() {
483        let counter_no_margin = HeuristicTokenCounter::new(4.0, 1.0, 0);
484        let counter_with_margin = HeuristicTokenCounter::new(4.0, 1.1, 0);
485
486        let text = "Hello world!"; // 12 chars
487        let base = counter_no_margin.count_text(text);
488        let adjusted = counter_with_margin.count_text(text);
489
490        assert!(adjusted > base, "Safety margin should increase token count");
491    }
492
493    // --- TiktokenTokenCounter tests ---
494
495    #[test]
496    fn tiktoken_counter_counts_text() {
497        let counter = TiktokenTokenCounter::default();
498        let tokens = counter.count_text("Hello, world!");
499        // "Hello, world!" is 4 tokens with o200k_base
500        assert!(
501            (3..=6).contains(&tokens),
502            "Expected ~4 tokens, got {}",
503            tokens
504        );
505    }
506
507    #[test]
508    fn tiktoken_counter_counts_empty_text() {
509        let counter = TiktokenTokenCounter::default();
510        assert_eq!(counter.count_text(""), 0);
511    }
512
513    #[test]
514    fn provider_visible_tool_estimate_is_zero_for_no_segments() {
515        let counter = TiktokenTokenCounter::default();
516        assert_eq!(
517            counter.estimate_provider_visible_tool_segments(std::iter::empty()),
518            ProviderVisibleToolEstimate::default()
519        );
520    }
521
522    #[test]
523    fn provider_visible_tool_estimate_counts_each_ordered_position_once() {
524        let counter = TiktokenTokenCounter::default();
525        let segments = [
526            r#"{"type":"function","function":{"#.to_string(),
527            r#""name":"lookup","description":"café 工具","#.to_string(),
528            r#""parameters":{"type":"object"}}}"#.to_string(),
529        ];
530        let estimate =
531            counter.estimate_provider_visible_tool_segments(segments.iter().map(String::as_str));
532        let expected_tokens = segments.iter().fold(0u32, |total, segment| {
533            total.saturating_add(counter.count_text(segment))
534        });
535        let expected_bytes = segments.iter().map(String::len).sum::<usize>();
536        let expected_chars = segments
537            .iter()
538            .map(|segment| segment.chars().count())
539            .sum::<usize>();
540
541        assert_eq!(estimate.input_tokens, expected_tokens);
542        assert_eq!(estimate.serialized_bytes, expected_bytes);
543        assert_eq!(estimate.serialized_chars, expected_chars);
544    }
545
546    #[test]
547    fn provider_visible_tool_estimate_keeps_bpe_boundaries_between_positions() {
548        let counter = TiktokenTokenCounter::default();
549        let segments = ["a".to_string(), "b".to_string()];
550
551        let estimate =
552            counter.estimate_provider_visible_tool_segments(segments.iter().map(String::as_str));
553        let separate = counter
554            .count_text(&segments[0])
555            .saturating_add(counter.count_text(&segments[1]));
556
557        assert_eq!(estimate.input_tokens, separate);
558        assert_ne!(estimate.input_tokens, counter.count_text("ab"));
559    }
560
561    #[test]
562    fn larger_provider_visible_tool_segment_increases_every_measure() {
563        let counter = TiktokenTokenCounter::default();
564        let small = [r#"{"parameters":{}}"#.to_string()];
565        let large = [format!(
566            r#"{{"parameters":{{"description":"{}"}}}}"#,
567            "provider visible parameter ".repeat(128)
568        )];
569
570        let small =
571            counter.estimate_provider_visible_tool_segments(small.iter().map(String::as_str));
572        let large =
573            counter.estimate_provider_visible_tool_segments(large.iter().map(String::as_str));
574
575        assert!(large.input_tokens > small.input_tokens);
576        assert!(large.serialized_bytes > small.serialized_bytes);
577        assert!(large.serialized_chars > small.serialized_chars);
578    }
579
580    #[test]
581    fn tiktoken_counter_counts_cjk() {
582        let counter = TiktokenTokenCounter::default();
583        // CJK text: each character is typically 1-2 tokens
584        let tokens = counter.count_text("你好世界");
585        assert!(
586            (2..=8).contains(&tokens),
587            "Expected 2-8 tokens, got {}",
588            tokens
589        );
590    }
591
592    #[test]
593    fn tiktoken_counter_counts_user_message() {
594        let counter = TiktokenTokenCounter::default();
595        let message = Message::user("Hello, world!");
596        let tokens = counter.count_message(&message);
597        // Should include content + metadata overhead (10)
598        assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
599    }
600
601    #[test]
602    fn tiktoken_counter_counts_tool_calls() {
603        let counter = TiktokenTokenCounter::default();
604        let tool_call = ToolCall {
605            id: "call_123".to_string(),
606            tool_type: "function".to_string(),
607            function: FunctionCall {
608                name: "search".to_string(),
609                arguments: r#"{"query":"test"}"#.to_string(),
610            },
611        };
612        let message = Message::assistant("Let me search", Some(vec![tool_call]));
613        let tokens = counter.count_message(&message);
614        assert!(tokens >= 15, "Expected at least 15 tokens, got {}", tokens);
615    }
616
617    #[test]
618    fn tiktoken_counter_more_accurate_than_heuristic() {
619        let heuristic = HeuristicTokenCounter::default();
620        let tiktoken = TiktokenTokenCounter::default();
621
622        let text = "The quick brown fox jumps over the lazy dog.";
623        let h_tokens = heuristic.count_text(text);
624        let t_tokens = tiktoken.count_text(text);
625
626        // Both should produce reasonable counts
627        assert!(h_tokens > 0 && t_tokens > 0);
628    }
629
630    #[test]
631    fn bundled_o200k_encoder_loads_successfully() {
632        // Regression guard: if the bundled o200k_base vocabulary ever fails to
633        // load (a build/link regression in tiktoken-rs), TiktokenTokenCounter
634        // would silently fall back to heuristic counting. Assert the bundled
635        // encoder actually loads so such a regression is caught here.
636        assert!(
637            o200k_base().is_ok(),
638            "bundled o200k_base tokenizer failed to load; \
639             suspected tiktoken-rs build/link regression"
640        );
641    }
642
643    // ── Encode-once truncation (issue #24: O(N²) → O(N)) ──
644
645    #[test]
646    fn truncate_prefix_keeps_start_and_stays_within_budget() {
647        let counter = TiktokenTokenCounter::default();
648        let text = "The quick brown fox jumps over the lazy dog. ".repeat(50);
649        // Sanity: text is well over the budget.
650        assert!(counter.count_text(&text) > 30);
651
652        let max_tokens = 30u32;
653        let prefix = counter.truncate_to_token_prefix(&text, max_tokens);
654
655        // (c) keep the START: prefix must be an exact prefix of `text`.
656        assert!(
657            text.starts_with(&prefix),
658            "prefix must be the START of text"
659        );
660        assert!(
661            !prefix.is_empty(),
662            "prefix should not be empty under budget"
663        );
664        // (a) never exceed max_tokens.
665        let count = counter.count_text(&prefix);
666        assert!(
667            count <= max_tokens,
668            "prefix token count {count} exceeds budget {max_tokens}"
669        );
670    }
671
672    #[test]
673    fn truncate_suffix_keeps_end_and_stays_within_budget() {
674        let counter = TiktokenTokenCounter::default();
675        let text = "The quick brown fox jumps over the lazy dog. ".repeat(50);
676        assert!(counter.count_text(&text) > 30);
677
678        let max_tokens = 30u32;
679        let suffix = counter.truncate_to_token_suffix(&text, max_tokens);
680
681        // (c) keep the END: suffix must be an exact suffix of `text`.
682        assert!(text.ends_with(&suffix), "suffix must be the END of text");
683        assert!(
684            !suffix.is_empty(),
685            "suffix should not be empty under budget"
686        );
687        // (a) never exceed max_tokens.
688        let count = counter.count_text(&suffix);
689        assert!(
690            count <= max_tokens,
691            "suffix token count {count} exceeds budget {max_tokens}"
692        );
693    }
694
695    #[test]
696    fn truncate_returns_text_unchanged_when_within_budget() {
697        let counter = TiktokenTokenCounter::default();
698        let text = "Hello, world!"; // a handful of tokens
699        assert!(counter.count_text(text) <= 1000);
700
701        assert_eq!(counter.truncate_to_token_prefix(text, 1000), text);
702        assert_eq!(counter.truncate_to_token_suffix(text, 1000), text);
703    }
704
705    #[test]
706    fn truncate_max_tokens_zero_returns_empty() {
707        let counter = TiktokenTokenCounter::default();
708        // (a) with budget 0 the only value that never exceeds it is empty.
709        assert_eq!(counter.truncate_to_token_prefix("Hello, world!", 0), "");
710        assert_eq!(counter.truncate_to_token_suffix("Hello, world!", 0), "");
711    }
712
713    #[test]
714    fn truncate_prefix_suffix_large_input_is_valid_and_within_budget() {
715        // Correctness + perf sanity on a ~100KB input mixing ASCII, CJK, digits
716        // and newlines — exercises multi-byte token-boundary alignment.
717        let counter = TiktokenTokenCounter::default();
718        let unit = "The quick brown fox 你好世界 jumps 1234567890 over.\n";
719        let text = unit.repeat(2_500);
720        assert!(text.len() > 100_000, "precondition: large input");
721        assert!(counter.count_text(&text) > 500);
722
723        let max_tokens = 500u32;
724
725        let prefix = counter.truncate_to_token_prefix(&text, max_tokens);
726        assert!(
727            text.starts_with(&prefix),
728            "prefix must be the START of text"
729        );
730        let pcount = counter.count_text(&prefix);
731        assert!(
732            pcount <= max_tokens,
733            "prefix token count {pcount} exceeds budget {max_tokens}"
734        );
735
736        let suffix = counter.truncate_to_token_suffix(&text, max_tokens);
737        assert!(text.ends_with(&suffix), "suffix must be the END of text");
738        let scount = counter.count_text(&suffix);
739        assert!(
740            scount <= max_tokens,
741            "suffix token count {scount} exceeds budget {max_tokens}"
742        );
743    }
744}