mecha_core/cache_lens.rs
1//! The cache lens: is the cached prefix actually being reused?
2//!
3//! Prompt caching is a prefix match, and everything that protects the prefix
4//! is an invariant somewhere else: the registry's `BTreeMap` keeps the tool
5//! list stable, the system prompt is fixed for a run, and the transcript is
6//! append-only between turns. Each was verified by hand exactly once — a
7//! two-request round-trip that paid 8 uncached input tokens — and nothing
8//! has watched since. Any regression (a tool re-registered mid-run, a
9//! nondeterministic schema, a mutation the append-only rule missed) presents
10//! as nothing at all: requests succeed, answers arrive, and every turn
11//! quietly re-pays for the whole history. The bill is the only symptom.
12//!
13//! The lens is that watcher: a pure observer fed each request's surface and
14//! the usage the provider reported for it. It changes nothing — its verdicts
15//! go to tracing, never to the model or the loop — and it names the *reason*
16//! when reuse legitimately breaks (surface changed, transcript rewritten by
17//! compaction), so the one remaining case, re-payment with nothing changed,
18//! is an anomaly worth a warning rather than noise.
19//!
20//! Two honesty rules keep the warnings believable:
21//!
22//! - **Never accuse on a provider that reports nothing.** A backend with no
23//! cache (or no cache accounting) reports zero for both cache tiers, which
24//! is indistinguishable from a total miss. Until a nonzero cache figure
25//! has been seen, the verdict is [`Verdict::Unobservable`], not a drop.
26//! - **Judge only what was sent.** The verdict compares the request that
27//! actually went out (post any overflow recovery) with the one before it,
28//! both from the caller's hand — the lens holds hashes, never content.
29
30use crate::message::{CompletionRequest, Usage};
31use std::hash::{DefaultHasher, Hash, Hasher};
32
33/// Below this many re-paid tokens, a drop is not worth a warning: small
34/// prompts and the tokenizer boundary a server keeps at the end of its
35/// cached block live here (llama-server returned 2,720 of a 2,724-token
36/// prefix on a measured round-trip), and a warning that fires on them
37/// teaches the reader to ignore it.
38const DROP_FLOOR_TOKENS: u64 = 1_024;
39
40/// A drop is only called when the re-paid portion exceeds this fraction of
41/// the previous request's whole prompt — reuse degrading, not the few
42/// tokens a server trims off the end of the block it hands back.
43const DROP_FRACTION: f64 = 0.25;
44
45/// What one request's cache behaviour looked like, and why.
46#[derive(Debug, Clone, PartialEq)]
47pub enum Verdict {
48 /// The first observed request: nothing to compare against.
49 Baseline,
50 /// Same surface, appended-only transcript, and the numbers look like
51 /// reuse: the cached prefix is doing its job.
52 Stable { uncached: u64, read: u64 },
53 /// The tool list or system prompt changed since the last request. Reuse
54 /// breaking here is expected; the name says which knob moved.
55 SurfaceChanged { system: bool, tools: bool },
56 /// The messages are no longer an extension of what was last sent —
57 /// compaction, eviction or thinning rewrote history, and the moving half
58 /// of the cache is legitimately gone.
59 TranscriptRewritten,
60 /// No nonzero cache figure has ever been reported, so reuse cannot be
61 /// judged — a local server without cache accounting, or caching off.
62 Unobservable,
63 /// Nothing changed, the transcript only grew, and the previous prompt
64 /// still did not come back from the cache: the invariant this lens
65 /// exists to watch has failed somewhere. `repaid` is the part of the
66 /// previous prompt that had to be paid for again — never this turn's
67 /// new content, however large that is.
68 Drop { repaid: u64, prev_total: u64 },
69}
70
71struct Prev {
72 system: u64,
73 tools: u64,
74 /// One hash per message *as sent*, so the append-only check is a prefix
75 /// comparison rather than a diff.
76 messages: Vec<u64>,
77 total_input: u64,
78}
79
80#[derive(Default)]
81pub struct CacheLens {
82 prev: Option<Prev>,
83 /// A nonzero cache tier has been reported at least once, so zeros from
84 /// here on are evidence rather than silence.
85 reporting_seen: bool,
86}
87
88impl CacheLens {
89 pub fn new() -> Self {
90 Self::default()
91 }
92
93 /// Observe one completed request and the usage the provider reported for
94 /// it. Pure bookkeeping: the caller decides what, if anything, to do
95 /// with the verdict.
96 pub fn observe(&mut self, request: &CompletionRequest, usage: &Usage) -> Verdict {
97 let current = Prev {
98 system: hash_of(&request.system),
99 tools: hash_of(&request.tools),
100 messages: request.messages.iter().map(hash_of).collect(),
101 total_input: usage.total_input(),
102 };
103 let cached_reported =
104 usage.cache_read_input_tokens > 0 || usage.cache_creation_input_tokens > 0;
105
106 let verdict = match &self.prev {
107 None => Verdict::Baseline,
108 Some(prev) => {
109 let system = prev.system != current.system;
110 let tools = prev.tools != current.tools;
111 if system || tools {
112 Verdict::SurfaceChanged { system, tools }
113 } else if !is_prefix(&prev.messages, ¤t.messages) {
114 Verdict::TranscriptRewritten
115 } else if !self.reporting_seen && !cached_reported {
116 Verdict::Unobservable
117 } else {
118 // The question is whether the *previous* prompt came
119 // back, so the only figure that answers it is what was
120 // read. `input_tokens` cannot: it is everything not
121 // read, which on this workload is overwhelmingly the
122 // turn's new content — one mail thread or search result
123 // dwarfs the prompt it was appended to, and scoring that
124 // as re-payment made the lens shout loudest exactly when
125 // tool results were biggest. It also scored the real
126 // failure — a small prompt re-paid in full because
127 // something destabilised the prefix — as stable.
128 let repaid = prev
129 .total_input
130 .saturating_sub(usage.cache_read_input_tokens);
131 let repaid_share = repaid as f64 / prev.total_input.max(1) as f64;
132 if repaid > DROP_FLOOR_TOKENS && repaid_share > DROP_FRACTION {
133 Verdict::Drop {
134 repaid,
135 prev_total: prev.total_input,
136 }
137 } else {
138 Verdict::Stable {
139 uncached: usage.input_tokens,
140 read: usage.cache_read_input_tokens,
141 }
142 }
143 }
144 }
145 };
146
147 self.reporting_seen |= cached_reported;
148 self.prev = Some(current);
149 verdict
150 }
151}
152
153/// Hash anything serializable. `DefaultHasher` is unstable across processes,
154/// which is fine: a lens lives inside one run and its hashes never leave it.
155fn hash_of<T: serde::Serialize>(value: &T) -> u64 {
156 let mut h = DefaultHasher::new();
157 serde_json::to_string(value)
158 .unwrap_or_default()
159 .hash(&mut h);
160 h.finish()
161}
162
163fn is_prefix(prev: &[u64], current: &[u64]) -> bool {
164 current.len() >= prev.len() && current[..prev.len()] == *prev
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::message::Message;
171
172 fn request(messages: Vec<Message>) -> CompletionRequest {
173 CompletionRequest {
174 model: "m".into(),
175 system: Some("system".into()),
176 messages,
177 tools: vec![],
178 max_tokens: 512,
179 effort: None,
180 thinking: false,
181 cache_prompt: true,
182 }
183 }
184
185 fn usage(uncached: u64, creation: u64, read: u64) -> Usage {
186 Usage {
187 input_tokens: uncached,
188 output_tokens: 10,
189 cache_creation_input_tokens: creation,
190 cache_read_input_tokens: read,
191 }
192 }
193
194 fn convo(n: usize) -> Vec<Message> {
195 (0..n).map(|i| Message::user(format!("turn {i}"))).collect()
196 }
197
198 #[test]
199 fn an_appended_turn_with_reuse_is_stable() {
200 let mut lens = CacheLens::new();
201 assert_eq!(
202 lens.observe(&request(convo(1)), &usage(8, 18_000, 0)),
203 Verdict::Baseline
204 );
205 assert_eq!(
206 lens.observe(&request(convo(2)), &usage(40, 200, 18_000)),
207 Verdict::Stable {
208 uncached: 40,
209 read: 18_000
210 }
211 );
212 }
213
214 #[test]
215 fn a_changed_tool_surface_is_an_expected_break_not_a_drop() {
216 let mut lens = CacheLens::new();
217 lens.observe(&request(convo(1)), &usage(8, 18_000, 0));
218 let mut second = request(convo(2));
219 second.tools = vec![crate::message::ToolSpec {
220 name: "new_tool".into(),
221 description: "appeared mid-run".into(),
222 input_schema: serde_json::json!({}),
223 }];
224 assert_eq!(
225 lens.observe(&second, &usage(18_000, 500, 0)),
226 Verdict::SurfaceChanged {
227 system: false,
228 tools: true
229 }
230 );
231 }
232
233 #[test]
234 fn a_rewritten_transcript_is_an_expected_break_not_a_drop() {
235 let mut lens = CacheLens::new();
236 lens.observe(&request(convo(3)), &usage(8, 18_000, 0));
237 // A compaction: the head is replaced by a summary, shorter than what
238 // was sent before.
239 let compacted = vec![
240 Message::user("[summary of turns 0-1]"),
241 Message::user("turn 2"),
242 ];
243 assert_eq!(
244 lens.observe(&request(compacted), &usage(9_000, 400, 0)),
245 Verdict::TranscriptRewritten
246 );
247 }
248
249 /// The verdict the lens exists for: same surface, appended-only, and the
250 /// previous prompt did not come back from the cache.
251 #[test]
252 fn an_unexplained_repayment_is_a_drop() {
253 let mut lens = CacheLens::new();
254 lens.observe(&request(convo(1)), &usage(8, 18_000, 0));
255 assert_eq!(
256 lens.observe(&request(convo(2)), &usage(17_500, 600, 0)),
257 Verdict::Drop {
258 repaid: 18_008,
259 prev_total: 18_008
260 }
261 );
262 }
263
264 /// The regression: a turn whose tool result dwarfs the prompt it was
265 /// appended to, with the prefix reused in full. These are the figures a
266 /// live llama-server returned — 2,720 of a 2,724-token prefix read back,
267 /// 6,319 tokens of genuinely new content — and judging the new content as
268 /// re-payment called it a drop at a share of 2.32. Every warning a real
269 /// session produced was this shape, which is how a detector stops being
270 /// read.
271 #[test]
272 fn a_large_appended_tool_result_is_not_a_repayment() {
273 let mut lens = CacheLens::new();
274 lens.observe(&request(convo(1)), &usage(2_724, 0, 0));
275 assert_eq!(
276 lens.observe(&request(convo(2)), &usage(6_319, 0, 2_720)),
277 Verdict::Stable {
278 uncached: 6_319,
279 read: 2_720
280 }
281 );
282 }
283
284 /// And the inverse, which is the case the lens exists for and the one it
285 /// could not see. On a two-tier provider a lost prefix is not re-paid as
286 /// *uncached* input — it is re-**written**, so the whole history lands in
287 /// `cache_creation` at a write premium while `input_tokens` stays at the
288 /// same handful of tokens an ordinary turn pays. Judging on
289 /// `input_tokens` therefore scored a total cache rebuild, every turn, as
290 /// stable: the most expensive failure available, reported as health.
291 #[test]
292 fn a_silently_rewritten_cache_block_is_a_drop() {
293 let mut lens = CacheLens::new();
294 lens.observe(&request(convo(1)), &usage(8, 18_000, 0));
295 assert_eq!(
296 lens.observe(&request(convo(2)), &usage(8, 18_400, 0)),
297 Verdict::Drop {
298 repaid: 18_008,
299 prev_total: 18_008
300 }
301 );
302 }
303
304 /// A provider that never reports cache figures is never accused: zeros
305 /// are silence, not a miss, until a nonzero figure proves reporting.
306 #[test]
307 fn no_reporting_means_unobservable_never_a_drop() {
308 let mut lens = CacheLens::new();
309 lens.observe(&request(convo(1)), &usage(18_000, 0, 0));
310 assert_eq!(
311 lens.observe(&request(convo(2)), &usage(18_100, 0, 0)),
312 Verdict::Unobservable
313 );
314 // And once reporting appears, judgment resumes.
315 lens.observe(&request(convo(3)), &usage(50, 0, 18_100));
316 assert!(matches!(
317 lens.observe(&request(convo(4)), &usage(18_200, 0, 0)),
318 Verdict::Drop { .. }
319 ));
320 }
321
322 /// Small re-payments stay below the alarm: the uncached tail of an
323 /// ordinary turn must not read as degradation.
324 #[test]
325 fn the_ordinary_uncached_tail_stays_stable() {
326 let mut lens = CacheLens::new();
327 lens.observe(&request(convo(1)), &usage(8, 18_000, 0));
328 assert!(matches!(
329 lens.observe(&request(convo(2)), &usage(900, 100, 17_000)),
330 Verdict::Stable { .. }
331 ));
332 }
333}