1use crate::core::gain::model_pricing::{ModelPricing, PricingMatchKind};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CompressionDepth {
11 Conservative,
12 Balanced,
13 Aggressive,
14}
15
16#[derive(Debug, Clone, Copy)]
17pub struct CompressionCandidate {
18 pub depth: CompressionDepth,
19 pub compressed_tokens: u64,
20 pub prefix_stability: f64,
22}
23
24#[derive(Debug, Clone, Copy)]
25pub struct CompressionContext<'a> {
26 pub original_tokens: u64,
27 pub prefix_tokens: u64,
29 pub current_turn: u64,
30 pub predicted_total_turns: u64,
31 pub bounce_rate: f64,
33 pub model: Option<&'a str>,
34}
35
36#[derive(Debug, Clone, Copy)]
37pub struct CandidateEconomics {
38 pub depth: CompressionDepth,
39 pub gross_benefit_usd: f64,
40 pub cache_break_risk_usd: f64,
41 pub net_benefit_usd: f64,
42 pub cache_break_probability: f64,
43}
44
45#[derive(Debug, Clone)]
46pub struct CompressionDecision {
47 pub selected: Option<CandidateEconomics>,
48 pub pricing_match: PricingMatchKind,
49}
50
51impl CompressionDecision {
52 #[must_use]
53 pub fn should_compress(&self) -> bool {
54 self.selected.is_some()
55 }
56}
57
58#[must_use]
65pub fn decide(
66 context: CompressionContext<'_>,
67 candidates: &[CompressionCandidate],
68) -> CompressionDecision {
69 let quote = ModelPricing::load().quote(context.model);
70 let remaining_turns = context
71 .predicted_total_turns
72 .saturating_sub(context.current_turn);
73 let write_rate = quote.cost.cache_write_per_m;
74 let read_rate = quote.cost.cache_read_per_m;
75 let write_premium = (write_rate - read_rate).max(0.0);
76
77 let selected = candidates
78 .iter()
79 .filter(|candidate| candidate.compressed_tokens < context.original_tokens)
80 .map(|candidate| {
81 let saved = context
82 .original_tokens
83 .saturating_sub(candidate.compressed_tokens);
84 let stability = finite_probability(candidate.prefix_stability);
85 let bounce_rate = finite_probability(context.bounce_rate);
86 let break_probability = 1.0 - stability * (1.0 - bounce_rate);
87 let gross_rate = write_rate + remaining_turns as f64 * read_rate;
88 let gross_benefit_usd = usd(saved, gross_rate);
89 let compressed_prefix = context.prefix_tokens.saturating_sub(saved);
90 let cache_break_risk_usd = usd(
91 compressed_prefix,
92 remaining_turns as f64 * break_probability * write_premium,
93 );
94 CandidateEconomics {
95 depth: candidate.depth,
96 gross_benefit_usd,
97 cache_break_risk_usd,
98 net_benefit_usd: gross_benefit_usd - cache_break_risk_usd,
99 cache_break_probability: break_probability,
100 }
101 })
102 .filter(|economics| economics.net_benefit_usd > 0.0)
103 .max_by(|a, b| {
104 a.net_benefit_usd
105 .total_cmp(&b.net_benefit_usd)
106 .then_with(|| depth_rank(a.depth).cmp(&depth_rank(b.depth)))
107 });
108
109 CompressionDecision {
110 selected,
111 pricing_match: quote.match_kind,
112 }
113}
114
115#[must_use]
118pub fn prefix_stability_score(previous: &[u8], current: &[u8]) -> f64 {
119 if previous.is_empty() {
120 return 1.0;
121 }
122 let common = previous
123 .iter()
124 .zip(current)
125 .take_while(|(a, b)| a == b)
126 .count();
127 common as f64 / previous.len() as f64
128}
129
130#[must_use]
134pub fn predict_session_length(current_turn: u64) -> u64 {
135 match current_turn {
136 0..=3 => 4,
137 4..=11 => 12,
138 _ => current_turn.saturating_add(current_turn / 2),
139 }
140}
141
142fn finite_probability(value: f64) -> f64 {
143 if value.is_finite() {
144 value.clamp(0.0, 1.0)
145 } else {
146 0.0
147 }
148}
149
150fn usd(tokens: u64, rate_per_m: f64) -> f64 {
151 tokens as f64 / 1_000_000.0 * rate_per_m
152}
153
154fn depth_rank(depth: CompressionDepth) -> u8 {
155 match depth {
156 CompressionDepth::Conservative => 0,
157 CompressionDepth::Balanced => 1,
158 CompressionDepth::Aggressive => 2,
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn context(turn: u64, predicted: u64) -> CompressionContext<'static> {
167 CompressionContext {
168 original_tokens: 1_000_000,
169 prefix_tokens: 1_000_000,
170 current_turn: turn,
171 predicted_total_turns: predicted,
172 bounce_rate: 0.0,
173 model: Some("claude-opus-4.5"),
174 }
175 }
176
177 #[test]
178 fn stable_prefix_prices_write_and_future_reads_exactly() {
179 let decision = decide(
180 context(1, 2),
181 &[CompressionCandidate {
182 depth: CompressionDepth::Balanced,
183 compressed_tokens: 500_000,
184 prefix_stability: 1.0,
185 }],
186 );
187 let economics = decision.selected.unwrap();
188 assert!((economics.gross_benefit_usd - 3.375).abs() < 1e-12);
189 assert_eq!(economics.cache_break_risk_usd, 0.0);
190 assert!((economics.net_benefit_usd - 3.375).abs() < 1e-12);
191 }
192
193 #[test]
194 fn unstable_long_prefix_costs_real_write_minus_read_premium() {
195 let decision = decide(
196 context(1, 13),
197 &[CompressionCandidate {
198 depth: CompressionDepth::Balanced,
199 compressed_tokens: 500_000,
200 prefix_stability: 0.0,
201 }],
202 );
203 assert!(!decision.should_compress());
204 let risk = 0.5 * 12.0 * (6.25 - 0.50);
205 assert!((risk - 34.5_f64).abs() < 1e-12);
206 }
207
208 #[test]
209 fn short_session_selects_largest_real_saving() {
210 let candidates = [
211 CompressionCandidate {
212 depth: CompressionDepth::Conservative,
213 compressed_tokens: 800_000,
214 prefix_stability: 0.98,
215 },
216 CompressionCandidate {
217 depth: CompressionDepth::Aggressive,
218 compressed_tokens: 300_000,
219 prefix_stability: 0.95,
220 },
221 ];
222 let selected = decide(context(3, 4), &candidates).selected.unwrap();
223 assert_eq!(selected.depth, CompressionDepth::Aggressive);
224 }
225
226 #[test]
227 fn long_session_can_prefer_conservative_stable_candidate() {
228 let candidates = [
229 CompressionCandidate {
230 depth: CompressionDepth::Conservative,
231 compressed_tokens: 800_000,
232 prefix_stability: 0.999,
233 },
234 CompressionCandidate {
235 depth: CompressionDepth::Aggressive,
236 compressed_tokens: 300_000,
237 prefix_stability: 0.75,
238 },
239 ];
240 let selected = decide(context(20, 40), &candidates).selected.unwrap();
241 assert_eq!(selected.depth, CompressionDepth::Conservative);
242 }
243
244 #[test]
245 fn non_saving_candidate_never_compresses() {
246 let candidate = CompressionCandidate {
247 depth: CompressionDepth::Aggressive,
248 compressed_tokens: 1_000_000,
249 prefix_stability: 1.0,
250 };
251 assert!(!decide(context(1, 1), &[candidate]).should_compress());
252 }
253
254 #[test]
255 fn appended_prefix_is_fully_stable() {
256 assert_eq!(prefix_stability_score(b"stable", b"stable + new"), 1.0);
257 }
258
259 #[test]
260 fn front_mutation_has_zero_stability() {
261 assert_eq!(prefix_stability_score(b"abc", b"xbc"), 0.0);
262 }
263
264 #[test]
265 fn prediction_distinguishes_short_and_long_sessions() {
266 assert_eq!(predict_session_length(2), 4);
267 assert_eq!(predict_session_length(8), 12);
268 assert_eq!(predict_session_length(40), 60);
269 }
270
271 #[test]
272 fn nan_stability_fails_closed_as_cache_break() {
273 let candidate = CompressionCandidate {
274 depth: CompressionDepth::Balanced,
275 compressed_tokens: 500_000,
276 prefix_stability: f64::NAN,
277 };
278 assert!(!decide(context(1, 13), &[candidate]).should_compress());
279 }
280
281 #[test]
282 fn measured_bounces_raise_expected_cache_risk() {
283 let candidate = CompressionCandidate {
284 depth: CompressionDepth::Balanced,
285 compressed_tokens: 500_000,
286 prefix_stability: 1.0,
287 };
288 let clean = decide(context(1, 13), &[candidate]).selected.unwrap();
289 let mut retrying = context(1, 13);
290 retrying.bounce_rate = 0.5;
291 assert!(decide(retrying, &[candidate]).selected.is_none());
292 assert_eq!(clean.cache_break_probability, 0.0);
293 }
294}