返回 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 [`codewhale_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 codewhale_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 codewhale_models::Role;
170 use codewhale_models::{ContentBlock, SystemBlock};
171
172 fn user_text(s: &str) -> Message {
173 Message {
174 role: Role::User,
175 content: vec![ContentBlock::Text {
176 text: s.to_string(),
177 cache_control: None,
178 }],
179 }
180 }
181
182 fn sys_text(s: &str) -> SystemPrompt {
183 SystemPrompt::Text(s.to_string())
184 }
185
186 #[test]
187 fn first_call_is_a_miss() {
188 let mut cache = TokenEstimateCache::new();
189 let messages = vec![user_text("hello world")];
190 let tokens = cache.lookup_or_compute(1, None, &messages);
191 let (hits, misses) = cache.stats();
192 assert!(tokens > 0);
193 assert_eq!(hits, 0);
194 assert_eq!(misses, 1);
195 }
196
197 #[test]
198 fn repeated_call_with_same_revision_is_a_hit() {
199 let mut cache = TokenEstimateCache::new();
200 let messages = vec![user_text("hello world")];
201 let _ = cache.lookup_or_compute(1, None, &messages);
202 let _ = cache.lookup_or_compute(1, None, &messages);
203 let (hits, misses) = cache.stats();
204 assert_eq!(hits, 1);
205 assert_eq!(misses, 1);
206 }
207
208 #[test]
209 fn revision_bump_invalidates() {
210 let mut cache = TokenEstimateCache::new();
211 let messages = vec![user_text("hi")];
212 let a = cache.lookup_or_compute(1, None, &messages);
213 let b = cache.lookup_or_compute(2, None, &messages);
214 let (hits, misses) = cache.stats();
215 // Both calls were misses (different revisions), neither hit the cache.
216 assert_eq!(a, b);
217 assert_eq!(hits, 0);
218 assert_eq!(misses, 2);
219 }
220
221 #[test]
222 fn system_prompt_change_invalidates() {
223 let mut cache = TokenEstimateCache::new();
224 let messages = vec![user_text("hi")];
225 let _ = cache.lookup_or_compute(1, Some(&sys_text("alpha")), &messages);
226 let _ = cache.lookup_or_compute(1, Some(&sys_text("beta")), &messages);
227 let (hits, misses) = cache.stats();
228 assert_eq!(hits, 0);
229 assert_eq!(misses, 2);
230 }
231
232 #[test]
233 fn bump_messages_revision_clears_cache() {
234 let mut cache = TokenEstimateCache::new();
235 let messages = vec![user_text("x")];
236 let _ = cache.lookup_or_compute(1, None, &messages);
237 cache.bump_messages_revision(2);
238 let _ = cache.lookup_or_compute(2, None, &messages);
239 let (hits, misses) = cache.stats();
240 assert_eq!(hits, 0);
241 assert_eq!(misses, 2);
242 }
243
244 #[test]
245 fn bump_to_smaller_revision_is_noop() {
246 let mut cache = TokenEstimateCache::new();
247 let messages = vec![user_text("x")];
248 let _ = cache.lookup_or_compute(5, None, &messages);
249 cache.bump_messages_revision(2);
250 // revision went down, cache should still be valid for revision 5
251 let _ = cache.lookup_or_compute(5, None, &messages);
252 let (hits, _) = cache.stats();
253 assert_eq!(hits, 1, "downward revision bumps must not invalidate");
254 }
255
256 #[test]
257 fn invalidate_resets_state() {
258 let mut cache = TokenEstimateCache::new();
259 let messages = vec![user_text("x")];
260 let _ = cache.lookup_or_compute(1, None, &messages);
261 let _ = cache.lookup_or_compute(1, None, &messages);
262 cache.invalidate();
263 let (hits, misses) = cache.stats();
264 assert_eq!(hits, 0);
265 assert_eq!(misses, 0);
266 }
267
268 #[test]
269 fn blocks_system_prompt_yields_distinct_fingerprint() {
270 let blocks_a = SystemPrompt::Blocks(vec![SystemBlock {
271 block_type: "text".to_string(),
272 text: "alpha".to_string(),
273 cache_control: None,
274 }]);
275 let blocks_b = SystemPrompt::Blocks(vec![SystemBlock {
276 block_type: "text".to_string(),
277 text: "beta".to_string(),
278 cache_control: None,
279 }]);
280 let mut cache = TokenEstimateCache::new();
281 let messages = vec![user_text("hi")];
282 let _ = cache.lookup_or_compute(1, Some(&blocks_a), &messages);
283 let _ = cache.lookup_or_compute(1, Some(&blocks_b), &messages);
284 let (hits, misses) = cache.stats();
285 assert_eq!(hits, 0);
286 assert_eq!(misses, 2);
287 }
288
289 #[test]
290 fn audit_ring_records_recent_pairs() {
291 let mut cache = TokenEstimateCache::new();
292 let messages = vec![user_text("hi")];
293 for rev in 1..=5 {
294 let _ = cache.lookup_or_compute(rev, None, &messages);
295 }
296 let ring = cache.recent_audit();
297 assert_eq!(ring.len(), 5);
298 assert_eq!(ring.last().copied(), Some((5, ring.last().unwrap().1)));
299 }
300
301 #[test]
302 fn audit_ring_bounded_by_capacity() {
303 let mut cache = TokenEstimateCache::new();
304 let messages = vec![user_text("hi")];
305 for rev in 1..=(AUDIT_RING_CAPACITY + 10) as u64 {
306 let _ = cache.lookup_or_compute(rev, None, &messages);
307 }
308 let ring = cache.recent_audit();
309 assert_eq!(ring.len(), AUDIT_RING_CAPACITY);
310 // newest entry should be the most recent revision we asked for
311 assert_eq!(ring.last().unwrap().0, (AUDIT_RING_CAPACITY + 10) as u64);
312 }
313 }
314
314 lines RUST