返回 CodeWhale
token_estimate_cache.rs
根目录 / crates / tui / src / core / engine / token_estimate_cache.rs
1 //! Process-local memoization for [`crate::compaction::estimate_input_tokens_conservative`].
2 //!
3 //! The token estimator walks the full [`crate::models::Message`] history and the
4 //! active system prompt, which is by far the most expensive per-turn CPU cost
5 //! in the engine hot path. The same input data is queried from at least five
6 //! sites per turn: capacity pre/post tool checkpoints, error escalation,
7 //! the seam manager, and the trimmed-message budget check, plus four more
8 //! from the TUI footer, `/status`, `/debug`, and the context inspector.
9 //!
10 //! Without memoization, a 200-message history with 5 KB of tool results costs
11 //! ~2 ms per call; that is 20 ms of pure waste on a single turn. The estimator
12 //! itself is a pure function of `(messages, system_prompt)`, so a
13 //! content-versioned cache is safe: the caller bumps `messages_revision`
14 //! on every mutation, and we also include a fast fingerprint of the system
15 //! prompt as part of the key.
16 //!
17 //! The cache is process-local only — cross-session persistence is intentionally
18 //! out of scope (see PR #2520 for the cross-session prompt-base disk cache).
19
20 use std::collections::hash_map::DefaultHasher;
21 use std::hash::{Hash, Hasher};
22
23 use crate::compaction::estimate_input_tokens_conservative;
24 use crate::models::{Message, SystemPrompt};
25
26 /// Default capacity for the rolling audit ring. Sized so a 64-entry window
27 /// covers a full capacity controller observation cycle without unbounded
28 /// growth on long-running sessions.
29 const AUDIT_RING_CAPACITY: usize = 64;
30
31 /// Process-local memoization for `estimate_input_tokens_conservative`.
32 ///
33 /// The cache is keyed on the `(messages_revision, system_fingerprint)`
34 /// pair, both of which the engine bumps on every content change. On a hit
35 /// the previously stored token estimate is returned without re-walking the
36 /// message list. On a miss, the estimator runs and the result is stored
37 /// alongside the audit ring entry.
38 #[derive(Debug, Default, Clone)]
39 pub struct TokenEstimateCache {
40 /// Monotonic counter bumped by the engine on every message mutation.
41 messages_revision: u64,
42 /// Stable 64-bit hash of the current system prompt text. Computed once
43 /// per `lookup_or_compute` call when the cache misses.
44 system_fingerprint: u64,
45 /// Cached token count, valid iff both keys match the current inputs.
46 cached_tokens: Option<usize>,
47 /// Audit ring of recent (revision, tokens) pairs. The most recent entry
48 /// is the tail; the oldest is dropped when capacity is exceeded. Used by
49 /// observability to surface cache effectiveness to `/status`.
50 audit_ring: Vec<(u64, usize)>,
51 /// Number of cache hits since the cache was last cleared. Saturates at
52 /// `u64::MAX` (effectively never in practice).
53 hits: u64,
54 /// Number of cache misses since the cache was last cleared.
55 misses: u64,
56 }
57
58 impl TokenEstimateCache {
59 /// Construct a fresh, empty cache. `messages_revision` defaults to 0; the
60 /// engine must call [`bump_messages_revision`](Self::bump_messages_revision)
61 /// whenever a mutation occurs so the next lookup correctly invalidates.
62 #[must_use]
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 /// Returns the cached token estimate, recomputing on miss.
68 ///
69 /// `messages_revision` is the engine's monotonic counter; bump it on
70 /// every add/remove/clear. `system_prompt` may be `None`. `messages` is
71 /// borrowed for the duration of the call so a miss can re-tokenize.
72 pub fn lookup_or_compute(
73 &mut self,
74 messages_revision: u64,
75 system_prompt: Option<&SystemPrompt>,
76 messages: &[Message],
77 ) -> usize {
78 let system_fingerprint = fingerprint_system_prompt(system_prompt);
79
80 if self.messages_revision == messages_revision
81 && self.system_fingerprint == system_fingerprint
82 && let Some(tokens) = self.cached_tokens
83 {
84 self.hits = self.hits.saturating_add(1);
85 return tokens;
86 }
87
88 let tokens = estimate_input_tokens_conservative(messages, system_prompt);
89 self.messages_revision = messages_revision;
90 self.system_fingerprint = system_fingerprint;
91 self.cached_tokens = Some(tokens);
92 self.misses = self.misses.saturating_add(1);
93 self.push_audit(messages_revision, tokens);
94 tokens
95 }
96
97 /// Record a messages-revision bump. The engine calls this whenever
98 /// `session.messages` is mutated. Calling it with a value smaller than
99 /// the current value is a no-op (the cache is monotonic).
100 #[allow(dead_code)] // exposed for future wiring of /clear and reset paths; tests exercise it
101 pub fn bump_messages_revision(&mut self, revision: u64) {
102 if revision > self.messages_revision {
103 self.messages_revision = revision;
104 self.cached_tokens = None;
105 }
106 }
107
108 /// Forget all cached state. Used by `/clear` and session reset paths.
109 #[allow(dead_code)] // exposed for future wiring of /clear and reset paths; tests exercise it
110 pub fn invalidate(&mut self) {
111 self.cached_tokens = None;
112 self.system_fingerprint = 0;
113 self.audit_ring.clear();
114 self.hits = 0;
115 self.misses = 0;
116 }
117
118 /// Returns `(hits, misses)` counters since the last `invalidate` call.
119 #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it
120 #[must_use]
121 pub fn stats(&self) -> (u64, u64) {
122 (self.hits, self.misses)
123 }
124
125 /// Returns the most recent `(revision, tokens)` audit entries, newest
126 /// first. Bounded by [`AUDIT_RING_CAPACITY`].
127 #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it
128 #[must_use]
129 pub fn recent_audit(&self) -> &[(u64, usize)] {
130 &self.audit_ring
131 }
132
133 fn push_audit(&mut self, revision: u64, tokens: usize) {
134 if self.audit_ring.len() >= AUDIT_RING_CAPACITY {
135 self.audit_ring.remove(0);
136 }
137 self.audit_ring.push((revision, tokens));
138 }
139 }
140
141 /// Stable 64-bit hash of the system prompt text. Walks the same shape the
142 /// estimator consumes: a `Text` variant or a list of `Blocks`. Returns 0
143 /// for `None` so the empty case is distinguishable but cheap to compare.
144 fn fingerprint_system_prompt(system: Option<&SystemPrompt>) -> u64 {
145 let Some(system) = system else {
146 return 0;
147 };
148 let mut hasher = DefaultHasher::new();
149 match system {
150 SystemPrompt::Text(text) => {
151 "text".hash(&mut hasher);
152 text.hash(&mut hasher);
153 }
154 SystemPrompt::Blocks(blocks) => {
155 "blocks".hash(&mut hasher);
156 blocks.len().hash(&mut hasher);
157 for block in blocks {
158 block.block_type.hash(&mut hasher);
159 block.text.hash(&mut hasher);
160 }
161 }
162 }
163 hasher.finish()
164 }
165
166 #[cfg(test)]
167 mod tests {
168 use super::*;
169 use crate::models::{ContentBlock, SystemBlock};
170
171 fn user_text(s: &str) -> Message {
172 Message {
173 role: "user".to_string(),
174 content: vec![ContentBlock::Text {
175 text: s.to_string(),
176 cache_control: None,
177 }],
178 }
179 }
180
181 fn sys_text(s: &str) -> SystemPrompt {
182 SystemPrompt::Text(s.to_string())
183 }
184
185 #[test]
186 fn first_call_is_a_miss() {
187 let mut cache = TokenEstimateCache::new();
188 let messages = vec![user_text("hello world")];
189 let tokens = cache.lookup_or_compute(1, None, &messages);
190 let (hits, misses) = cache.stats();
191 assert!(tokens > 0);
192 assert_eq!(hits, 0);
193 assert_eq!(misses, 1);
194 }
195
196 #[test]
197 fn repeated_call_with_same_revision_is_a_hit() {
198 let mut cache = TokenEstimateCache::new();
199 let messages = vec![user_text("hello world")];
200 let _ = cache.lookup_or_compute(1, None, &messages);
201 let _ = cache.lookup_or_compute(1, None, &messages);
202 let (hits, misses) = cache.stats();
203 assert_eq!(hits, 1);
204 assert_eq!(misses, 1);
205 }
206
207 #[test]
208 fn revision_bump_invalidates() {
209 let mut cache = TokenEstimateCache::new();
210 let messages = vec![user_text("hi")];
211 let a = cache.lookup_or_compute(1, None, &messages);
212 let b = cache.lookup_or_compute(2, None, &messages);
213 let (hits, misses) = cache.stats();
214 // Both calls were misses (different revisions), neither hit the cache.
215 assert_eq!(a, b);
216 assert_eq!(hits, 0);
217 assert_eq!(misses, 2);
218 }
219
220 #[test]
221 fn system_prompt_change_invalidates() {
222 let mut cache = TokenEstimateCache::new();
223 let messages = vec![user_text("hi")];
224 let _ = cache.lookup_or_compute(1, Some(&sys_text("alpha")), &messages);
225 let _ = cache.lookup_or_compute(1, Some(&sys_text("beta")), &messages);
226 let (hits, misses) = cache.stats();
227 assert_eq!(hits, 0);
228 assert_eq!(misses, 2);
229 }
230
231 #[test]
232 fn bump_messages_revision_clears_cache() {
233 let mut cache = TokenEstimateCache::new();
234 let messages = vec![user_text("x")];
235 let _ = cache.lookup_or_compute(1, None, &messages);
236 cache.bump_messages_revision(2);
237 let _ = cache.lookup_or_compute(2, None, &messages);
238 let (hits, misses) = cache.stats();
239 assert_eq!(hits, 0);
240 assert_eq!(misses, 2);
241 }
242
243 #[test]
244 fn bump_to_smaller_revision_is_noop() {
245 let mut cache = TokenEstimateCache::new();
246 let messages = vec![user_text("x")];
247 let _ = cache.lookup_or_compute(5, None, &messages);
248 cache.bump_messages_revision(2);
249 // revision went down, cache should still be valid for revision 5
250 let _ = cache.lookup_or_compute(5, None, &messages);
251 let (hits, _) = cache.stats();
252 assert_eq!(hits, 1, "downward revision bumps must not invalidate");
253 }
254
255 #[test]
256 fn invalidate_resets_state() {
257 let mut cache = TokenEstimateCache::new();
258 let messages = vec![user_text("x")];
259 let _ = cache.lookup_or_compute(1, None, &messages);
260 let _ = cache.lookup_or_compute(1, None, &messages);
261 cache.invalidate();
262 let (hits, misses) = cache.stats();
263 assert_eq!(hits, 0);
264 assert_eq!(misses, 0);
265 }
266
267 #[test]
268 fn blocks_system_prompt_yields_distinct_fingerprint() {
269 let blocks_a = SystemPrompt::Blocks(vec![SystemBlock {
270 block_type: "text".to_string(),
271 text: "alpha".to_string(),
272 cache_control: None,
273 }]);
274 let blocks_b = SystemPrompt::Blocks(vec![SystemBlock {
275 block_type: "text".to_string(),
276 text: "beta".to_string(),
277 cache_control: None,
278 }]);
279 let mut cache = TokenEstimateCache::new();
280 let messages = vec![user_text("hi")];
281 let _ = cache.lookup_or_compute(1, Some(&blocks_a), &messages);
282 let _ = cache.lookup_or_compute(1, Some(&blocks_b), &messages);
283 let (hits, misses) = cache.stats();
284 assert_eq!(hits, 0);
285 assert_eq!(misses, 2);
286 }
287
288 #[test]
289 fn audit_ring_records_recent_pairs() {
290 let mut cache = TokenEstimateCache::new();
291 let messages = vec![user_text("hi")];
292 for rev in 1..=5 {
293 let _ = cache.lookup_or_compute(rev, None, &messages);
294 }
295 let ring = cache.recent_audit();
296 assert_eq!(ring.len(), 5);
297 assert_eq!(ring.last().copied(), Some((5, ring.last().unwrap().1)));
298 }
299
300 #[test]
301 fn audit_ring_bounded_by_capacity() {
302 let mut cache = TokenEstimateCache::new();
303 let messages = vec![user_text("hi")];
304 for rev in 1..=(AUDIT_RING_CAPACITY + 10) as u64 {
305 let _ = cache.lookup_or_compute(rev, None, &messages);
306 }
307 let ring = cache.recent_audit();
308 assert_eq!(ring.len(), AUDIT_RING_CAPACITY);
309 // newest entry should be the most recent revision we asked for
310 assert_eq!(ring.last().unwrap().0, (AUDIT_RING_CAPACITY + 10) as u64);
311 }
312 }
313
313 lines RUST