Skip to main content

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, &current.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}