1use serde_json::Value;
29
30use lean_ctx::core::gain::model_pricing::ModelCost;
31use lean_ctx::core::tokens::count_tokens;
32
33pub const MIN_CACHEABLE_TOKENS: u64 = 1024;
39
40fn system_tokens(system: Option<&Value>) -> u64 {
45 match system {
46 Some(v) if !v.is_null() => serde_json::to_string(v).map_or(0, |s| count_tokens(&s) as u64),
47 _ => 0,
48 }
49}
50
51#[must_use]
57pub fn prefix_tokens(system: Option<&Value>, messages: &[Value], cached: usize) -> u64 {
58 let mut total = system_tokens(system);
59 let end = cached.min(messages.len());
60 if end > 0
61 && let Ok(serialized) = serde_json::to_string(&messages[..end])
62 {
63 total += count_tokens(&serialized) as u64;
64 }
65 total
66}
67
68#[must_use]
76pub fn worth_repacking(system: Option<&Value>, messages: &[Value], cached: usize) -> bool {
77 prefix_tokens(system, messages, cached) >= MIN_CACHEABLE_TOKENS
78}
79
80#[must_use]
85pub fn repack_saving_usd(before_tokens: u64, after_tokens: u64, cost: &ModelCost) -> f64 {
86 let saved = before_tokens.saturating_sub(after_tokens);
87 saved as f64 / 1_000_000.0 * cost.cache_write_per_m
88}
89
90#[must_use]
95pub fn net_cost_decision(before_tokens: u64, after_tokens: u64, cost: &ModelCost) -> bool {
96 before_tokens >= MIN_CACHEABLE_TOKENS
97 && after_tokens < before_tokens
98 && repack_saving_usd(before_tokens, after_tokens, cost) > 0.0
99}
100
101#[derive(Debug, Clone, Copy, PartialEq)]
103pub enum MutationDecision {
104 Mutate { break_even: u32 },
106 Preserve { break_even: u32 },
108}
109
110#[must_use]
114pub fn model_cost_for(model: &str) -> ModelCost {
115 lean_ctx::core::gain::model_pricing::ModelPricing::load()
116 .quote(Some(model))
117 .cost
118}
119
120#[must_use]
131pub fn should_mutate_frozen(
132 before_tokens: u64,
133 after_tokens: u64,
134 estimated_reuse_count: u32,
135 cost: &ModelCost,
136) -> MutationDecision {
137 if before_tokens < MIN_CACHEABLE_TOKENS || after_tokens >= before_tokens {
138 return MutationDecision::Preserve {
139 break_even: u32::MAX,
140 };
141 }
142 let saved_tokens = before_tokens - after_tokens;
143 let bust_cost = (after_tokens as f64 / 1_000_000.0 * cost.cache_write_per_m
144 - before_tokens as f64 / 1_000_000.0 * cost.cache_read_per_m)
145 .max(0.0);
146 let per_call_saving = saved_tokens as f64 / 1_000_000.0 * cost.cache_read_per_m;
147 if per_call_saving <= 0.0 {
148 return MutationDecision::Preserve {
149 break_even: u32::MAX,
150 };
151 }
152 let break_even = (bust_cost / per_call_saving).ceil().max(1.0) as u32;
153 if estimated_reuse_count >= break_even {
154 MutationDecision::Mutate { break_even }
155 } else {
156 MutationDecision::Preserve { break_even }
157 }
158}
159
160#[must_use]
165pub fn observed_prefix_reuse(messages: &[Value]) -> u32 {
166 let turns = messages
167 .iter()
168 .filter(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
169 .count();
170 u32::try_from(turns).unwrap_or(u32::MAX)
171}
172
173#[must_use]
179pub fn system_mutation_decision(
180 prefix_before: u64,
181 system_before: &Value,
182 system_after: &Value,
183 reuse: u32,
184 cost: &ModelCost,
185) -> MutationDecision {
186 let saved =
187 system_tokens(Some(system_before)).saturating_sub(system_tokens(Some(system_after)));
188 should_mutate_frozen(
189 prefix_before,
190 prefix_before.saturating_sub(saved),
191 reuse,
192 cost,
193 )
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use serde_json::json;
200
201 fn opus() -> ModelCost {
202 ModelCost {
203 input_per_m: 5.00,
204 output_per_m: 25.00,
205 cache_write_per_m: 6.25,
206 cache_read_per_m: 0.50,
207 }
208 }
209
210 #[test]
211 fn prefix_tokens_zero_when_nothing_cached() {
212 let msgs = vec![json!({"role": "user", "content": "hello"})];
213 assert_eq!(prefix_tokens(None, &msgs, 0), 0);
214 }
215
216 #[test]
217 fn prefix_tokens_counts_only_the_cached_span() {
218 let big = "lorem ipsum dolor sit amet ".repeat(50);
219 let msgs = vec![
220 json!({"role": "user", "content": big}),
221 json!({"role": "assistant", "content": "tail not counted"}),
222 ];
223 let one = prefix_tokens(None, &msgs, 1);
224 let two = prefix_tokens(None, &msgs, 2);
225 assert!(one > 0);
226 assert!(two > one, "wider cached span counts more tokens");
227 }
228
229 #[test]
230 fn prefix_tokens_includes_the_system_field() {
231 let msgs = vec![json!({"role": "user", "content": "hi"})];
234 let big_system = json!("context engineering ".repeat(400));
235 let without = prefix_tokens(None, &msgs, 1);
236 let with = prefix_tokens(Some(&big_system), &msgs, 1);
237 assert!(with > without + 500, "system prose must dominate the count");
238 }
239
240 #[test]
241 fn worth_repacking_rejects_small_prefix() {
242 let msgs = vec![json!({"role": "user", "content": "tiny"})];
243 assert!(!worth_repacking(None, &msgs, 1));
244 }
245
246 #[test]
247 fn worth_repacking_accepts_large_prefix() {
248 let big = "context engineering ".repeat(1500);
250 let msgs = vec![json!({"role": "user", "content": big})];
251 assert!(worth_repacking(None, &msgs, 1));
252 }
253
254 #[test]
255 fn worth_repacking_counts_large_system_over_tiny_messages() {
256 let msgs = vec![json!({"role": "user", "content": "hi"})];
259 let big_system = json!("context engineering ".repeat(1500));
260 assert!(
261 !worth_repacking(None, &msgs, 1),
262 "tiny prefix alone is skipped"
263 );
264 assert!(
265 worth_repacking(Some(&big_system), &msgs, 1),
266 "a large system prompt makes the prefix worth re-seeding"
267 );
268 }
269
270 #[test]
271 fn net_cost_decision_rejects_subcacheable_even_if_smaller() {
272 assert!(!net_cost_decision(500, 200, &opus()));
274 }
275
276 #[test]
277 fn net_cost_decision_rejects_when_no_shrink() {
278 assert!(!net_cost_decision(4000, 4000, &opus()));
279 assert!(!net_cost_decision(4000, 5000, &opus()));
280 }
281
282 #[test]
283 fn net_cost_decision_accepts_real_saving() {
284 assert!(net_cost_decision(4000, 2500, &opus()));
285 let saved = repack_saving_usd(4000, 2500, &opus());
287 assert!((saved - (1500.0 / 1_000_000.0 * 6.25)).abs() < 1e-9);
288 }
289
290 #[test]
291 fn repack_saving_is_zero_when_inflated() {
292 assert_eq!(repack_saving_usd(1000, 2000, &opus()), 0.0);
293 }
294
295 #[test]
296 fn should_mutate_frozen_accepts_with_enough_reuse() {
297 let d = should_mutate_frozen(4000, 2000, 20, &opus());
300 assert_eq!(d, MutationDecision::Mutate { break_even: 11 });
301 assert_eq!(
302 should_mutate_frozen(4000, 2000, 10, &opus()),
303 MutationDecision::Preserve { break_even: 11 }
304 );
305 }
306
307 #[test]
308 fn should_mutate_frozen_prices_savings_as_cache_reads() {
309 let flat = ModelCost {
311 input_per_m: 2.0,
312 output_per_m: 8.0,
313 cache_write_per_m: 2.0,
314 cache_read_per_m: 2.0,
315 };
316 assert_eq!(
317 should_mutate_frozen(4000, 2000, 1, &flat),
318 MutationDecision::Mutate { break_even: 1 }
319 );
320 }
321
322 #[test]
323 fn observed_reuse_counts_prior_assistant_turns() {
324 let msgs = vec![
325 json!({"role": "user", "content": "a"}),
326 json!({"role": "assistant", "content": "b"}),
327 json!({"role": "user", "content": "c"}),
328 json!({"role": "assistant", "content": "d"}),
329 json!({"role": "user", "content": "e"}),
330 ];
331 assert_eq!(observed_prefix_reuse(&msgs), 2);
332 assert_eq!(observed_prefix_reuse(&[]), 0);
333 }
334
335 #[test]
336 fn system_gate_prices_the_whole_prefix() {
337 let before = json!("context engineering ".repeat(1500));
338 let after = json!("context engineering ".repeat(300));
339 let prefix = system_tokens(Some(&before));
340 let small = system_mutation_decision(prefix, &before, &after, 1000, &opus());
343 let large = system_mutation_decision(prefix * 10, &before, &after, 1000, &opus());
344 let be = |d: MutationDecision| match d {
345 MutationDecision::Mutate { break_even } | MutationDecision::Preserve { break_even } => {
346 break_even
347 }
348 };
349 assert!(be(large) > be(small), "{small:?} vs {large:?}");
350 assert!(matches!(
351 system_mutation_decision(prefix, &before, &before, 1000, &opus()),
352 MutationDecision::Preserve { .. }
353 ));
354 }
355
356 #[test]
357 fn should_mutate_frozen_rejects_with_low_reuse() {
358 let d = should_mutate_frozen(4000, 3900, 1, &opus());
359 assert!(matches!(d, MutationDecision::Preserve { .. }));
360 }
361
362 #[test]
363 fn should_mutate_frozen_rejects_subcacheable() {
364 let d = should_mutate_frozen(500, 200, 100, &opus());
365 assert!(matches!(d, MutationDecision::Preserve { .. }));
366 }
367
368 #[test]
369 fn should_mutate_frozen_rejects_inflation() {
370 let d = should_mutate_frozen(3000, 4000, 100, &opus());
371 assert!(matches!(d, MutationDecision::Preserve { .. }));
372 }
373
374 #[test]
375 fn model_cost_uses_current_pricing_table() {
376 let opus = model_cost_for("claude-opus-4-5");
379 assert!((opus.input_per_m - 5.0).abs() < 1e-9, "{opus:?}");
380 assert!((opus.cache_write_per_m - 6.25).abs() < 1e-9, "{opus:?}");
381 let haiku = model_cost_for("claude-haiku-4-5");
382 assert!((haiku.input_per_m - 1.0).abs() < 1e-9, "{haiku:?}");
383 }
384}