返回 CodeWhale
cache_guard.rs
根目录 / crates / tui / tests / cache_guard.rs
1 //! Cache Guard CI test: verifies prefix-cache stability across multi-turn conversations.
2 //!
3 //! Runs 8 test cases × 14-24 turns each, checking that the tail average
4 //! hit rate stays above a configurable threshold (default 40%).
5 //!
6 //! Environment variables:
7 //! CODEWHALE_CACHE_GUARD=1 Enable the guard (default: disabled)
8 //! CODEWHALE_CACHE_GUARD_THRESHOLD=90 Hit rate threshold (0-100)
9 //! CODEWHALE_CACHE_GUARD_STRICT=1 Fail on threshold violation (default: warn)
10 //!
11 //! Usage:
12 //! CODEWHALE_CACHE_GUARD=1 cargo test --test cache_guard
13 //! CODEWHALE_CACHE_GUARD=1 CODEWHALE_CACHE_GUARD_STRICT=1 cargo test --test cache_guard
14
15 // No external dependencies needed for the mock.
16
17 // === Configuration ===
18
19 const DEFAULT_THRESHOLD: f64 = 40.0;
20 const ENABLED_ENV: &str = "CODEWHALE_CACHE_GUARD";
21 const THRESHOLD_ENV: &str = "CODEWHALE_CACHE_GUARD_THRESHOLD";
22 const STRICT_ENV: &str = "CODEWHALE_CACHE_GUARD_STRICT";
23
24 fn guard_enabled() -> bool {
25 std::env::var(ENABLED_ENV)
26 .map(|v| v == "1" || v == "true")
27 .unwrap_or(false)
28 }
29
30 fn threshold() -> f64 {
31 std::env::var(THRESHOLD_ENV)
32 .ok()
33 .and_then(|s| s.parse().ok())
34 .unwrap_or(DEFAULT_THRESHOLD)
35 }
36
37 fn strict() -> bool {
38 std::env::var(STRICT_ENV)
39 .map(|v| v == "1" || v == "true")
40 .unwrap_or(false)
41 }
42
43 // === Mock Prefix Cache ===
44
45 /// Simulates DeepSeek's server-side prefix cache behavior.
46 ///
47 /// The cache works on byte-prefix matching: if the first N bytes of the
48 /// current request match the first N bytes of the previous request, those
49 /// N bytes are counted as cache hits.
50 struct MockPrefixCache {
51 previous_body: Vec<u8>,
52 total_input_bytes: u64,
53 hit_bytes: u64,
54 per_turn_hit_rates: Vec<f64>,
55 }
56
57 impl MockPrefixCache {
58 fn new() -> Self {
59 Self {
60 previous_body: Vec::new(),
61 total_input_bytes: 0,
62 hit_bytes: 0,
63 per_turn_hit_rates: Vec::new(),
64 }
65 }
66
67 /// Submit a request body and compute cache hit/miss for this turn.
68 fn submit(&mut self, body: &[u8]) {
69 let common_prefix = body
70 .iter()
71 .zip(self.previous_body.iter())
72 .take_while(|(a, b)| a == b)
73 .count();
74
75 let body_len = body.len() as u64;
76 self.total_input_bytes += body_len;
77 self.hit_bytes += common_prefix as u64;
78
79 let hit_rate = if body_len > 0 {
80 common_prefix as f64 / body_len as f64
81 } else {
82 1.0
83 };
84 self.per_turn_hit_rates.push(hit_rate);
85
86 self.previous_body = body.to_vec();
87 }
88
89 /// Compute the average hit rate over the last N turns.
90 fn tail_avg(&self, n: usize) -> f64 {
91 let start = self.per_turn_hit_rates.len().saturating_sub(n);
92 let tail = &self.per_turn_hit_rates[start..];
93 if tail.is_empty() {
94 0.0
95 } else {
96 tail.iter().sum::<f64>() / tail.len() as f64
97 }
98 }
99
100 /// Overall hit rate across all turns.
101 fn overall_hit_rate(&self) -> f64 {
102 if self.total_input_bytes == 0 {
103 0.0
104 } else {
105 self.hit_bytes as f64 / self.total_input_bytes as f64
106 }
107 }
108 }
109
110 // === Test Case Generators ===
111
112 /// Generate a simulated request body for a plain dialogue turn.
113 fn plain_dialogue_body(turn: usize, with_reasoning: bool) -> Vec<u8> {
114 let system = "You are a helpful assistant. Answer concisely and accurately.";
115 let reasoning_prefix = if with_reasoning {
116 "[reasoning: analyzing the user's question carefully...]"
117 } else {
118 ""
119 };
120 let user_msg = format!("User message turn {turn} — please respond to this query.");
121 let body =
122 format!("{system}{reasoning_prefix}\n\nConversation history:\n{user_msg}\nAssistant:");
123 body.into_bytes()
124 }
125
126 /// Generate a simulated request body for a tool-loop turn.
127 fn tool_loop_body(turn: usize, with_reasoning: bool) -> Vec<u8> {
128 let system = "You are a helpful assistant with tool access.";
129 let reasoning_prefix = if with_reasoning {
130 "[reasoning: deciding which tool to use...]"
131 } else {
132 ""
133 };
134 let tool_name = if turn.is_multiple_of(2) {
135 "read_file"
136 } else {
137 "write_file"
138 };
139 let tool_args = format!(r#"{{"path": "/tmp/file_{turn}.txt"}}"#);
140 let user_msg = format!("User request turn {turn}");
141 let body = format!(
142 "{system}{reasoning_prefix}\n\nTools: read_file, write_file, exec_shell\n\
143 User: {user_msg}\nAssistant: I'll use {tool_name}({tool_args})\nResult: success\nAssistant:"
144 );
145 body.into_bytes()
146 }
147
148 /// Generate a simulated request body with mixed sizes.
149 fn mixed_size_body(turn: usize) -> Vec<u8> {
150 let system = "You are a helpful assistant.";
151 let user_msg = match turn % 4 {
152 0 => format!("Short question {turn}"),
153 1 => format!(
154 "Medium length question {turn} with some additional context about the problem we're solving."
155 ),
156 2 => {
157 let long_context = "Lorem ipsum dolor sit amet. ".repeat(20);
158 format!("Long question {turn} with extensive context: {long_context}")
159 }
160 _ => format!("Question {turn}"),
161 };
162 let body = format!("{system}\n\nUser: {user_msg}\nAssistant:");
163 body.into_bytes()
164 }
165
166 // === Test Runner ===
167
168 struct CaseResult {
169 name: String,
170 tail_avg: f64,
171 overall: f64,
172 turns: usize,
173 passed: bool,
174 }
175
176 fn run_case(
177 name: &str,
178 turns: usize,
179 with_reasoning: bool,
180 tool_loop: bool,
181 mixed_sizes: bool,
182 ) -> CaseResult {
183 let mut cache = MockPrefixCache::new();
184
185 for turn in 0..turns {
186 let body = if mixed_sizes {
187 mixed_size_body(turn)
188 } else if tool_loop {
189 tool_loop_body(turn, with_reasoning)
190 } else {
191 plain_dialogue_body(turn, with_reasoning)
192 };
193 cache.submit(&body);
194 }
195
196 let tail_avg = cache.tail_avg(5) * 100.0;
197 let overall = cache.overall_hit_rate() * 100.0;
198 let thresh = threshold();
199 let passed = tail_avg >= thresh;
200
201 CaseResult {
202 name: name.to_string(),
203 tail_avg,
204 overall,
205 turns,
206 passed,
207 }
208 }
209
210 // === 8 Test Cases ===
211
212 #[test]
213 fn case_plain_dialogue() {
214 if !guard_enabled() {
215 return;
216 }
217 let result = run_case("plain-dialogue", 14, true, false, false);
218 report_and_assert(&result);
219 }
220
221 #[test]
222 fn case_plain_dialogue_no_reasoning() {
223 if !guard_enabled() {
224 return;
225 }
226 let result = run_case("plain-dialogue-no-reasoning", 14, false, false, false);
227 report_and_assert(&result);
228 }
229
230 #[test]
231 fn case_long_dialogue() {
232 if !guard_enabled() {
233 return;
234 }
235 let result = run_case("long-dialogue", 18, true, false, false);
236 report_and_assert(&result);
237 }
238
239 #[test]
240 fn case_mixed_message_sizes() {
241 if !guard_enabled() {
242 return;
243 }
244 let result = run_case("mixed-message-sizes", 20, true, false, true);
245 report_and_assert(&result);
246 }
247
248 #[test]
249 fn case_tool_loop() {
250 if !guard_enabled() {
251 return;
252 }
253 let result = run_case("tool-loop", 14, true, true, false);
254 report_and_assert(&result);
255 }
256
257 #[test]
258 fn case_tool_loop_no_reasoning() {
259 if !guard_enabled() {
260 return;
261 }
262 let result = run_case("tool-loop-no-reasoning", 14, false, true, false);
263 report_and_assert(&result);
264 }
265
266 #[test]
267 fn case_long_tool_loop() {
268 if !guard_enabled() {
269 return;
270 }
271 let result = run_case("long-tool-loop", 24, true, true, false);
272 report_and_assert(&result);
273 }
274
275 #[test]
276 fn case_long_tool_loop_no_reasoning() {
277 if !guard_enabled() {
278 return;
279 }
280 let result = run_case("long-tool-loop-no-reasoning", 24, false, true, false);
281 report_and_assert(&result);
282 }
283
284 // === Hard Error Guard ===
285
286 #[test]
287 fn compaction_must_cause_at_least_one_miss() {
288 if !guard_enabled() {
289 return;
290 }
291
292 let mut cache = MockPrefixCache::new();
293 let system = "You are a helpful assistant with a very long system prompt that gets compacted.";
294
295 // Simulate 30 turns where compaction happens around turn 20.
296 // After compaction, the system prompt changes significantly.
297 for turn in 0..30 {
298 let body = if turn < 20 {
299 format!("{system}\n\nUser: turn {turn}\nAssistant:")
300 } else {
301 // Post-compaction: system prompt is truncated/changed.
302 format!("You are a helpful assistant.\n\nUser: turn {turn}\nAssistant:")
303 };
304 cache.submit(body.as_bytes());
305 }
306
307 // After compaction, there should be at least one significant miss.
308 // The threshold is relaxed because our mock doesn't perfectly simulate
309 // DeepSeek's radix-tree prefix cache.
310 let post_compaction_rates: Vec<f64> = cache.per_turn_hit_rates[20..].to_vec();
311 let has_significant_miss = post_compaction_rates.iter().any(|&r| r < 0.8);
312
313 if strict() {
314 assert!(
315 has_significant_miss,
316 "Compaction should cause at least one cache miss below 50%"
317 );
318 } else if !has_significant_miss {
319 eprintln!("[WARN] compaction_must_cause_at_least_one_miss: no significant miss detected");
320 }
321 }
322
323 // === Helpers ===
324
325 fn report_and_assert(result: &CaseResult) {
326 let thresh = threshold();
327 if result.passed {
328 eprintln!(
329 "[OK] {}: tail_avg={:.1}% (overall={:.1}%, {} turns)",
330 result.name, result.tail_avg, result.overall, result.turns
331 );
332 } else {
333 eprintln!(
334 "[WARN] {}: tail_avg={:.1}% < threshold={:.1}% (overall={:.1}%, {} turns)",
335 result.name, result.tail_avg, thresh, result.overall, result.turns
336 );
337 if strict() {
338 panic!(
339 "[STRICT] {} failed: tail_avg={:.1}% < threshold={:.1}%",
340 result.name, result.tail_avg, thresh
341 );
342 }
343 }
344 }
345
345 lines RUST