1#![forbid(unsafe_code)]
4
5use std::collections::BTreeSet;
6
7pub const CONTEXT_SIZE_MARKER_INTERVAL_TOKENS: u64 = 10_000;
9
10#[derive(Clone, Debug, Eq, PartialEq)]
12pub enum CacheExpectation {
13 ColdStart,
15 ExpectedWarm,
17 PlannedInvalidation { reason: String },
19}
20
21impl CacheExpectation {
22 pub const fn label(&self) -> &'static str {
24 match self {
25 Self::ColdStart => "cold_start",
26 Self::ExpectedWarm => "expected_warm",
27 Self::PlannedInvalidation { .. } => "planned_invalidation",
28 }
29 }
30
31 pub fn planned_reason(&self) -> Option<&str> {
33 match self {
34 Self::PlannedInvalidation { reason } => Some(reason),
35 _ => None,
36 }
37 }
38
39 pub const fn expects_cache_hit(&self) -> bool {
41 matches!(self, Self::ExpectedWarm)
42 }
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub struct PreviousProjection<'a> {
48 pub text: &'a str,
49 pub material_fingerprint: &'a str,
50}
51
52pub fn classify(
54 previous: Option<PreviousProjection<'_>>,
55 current_text: &str,
56 current_material_fingerprint: &str,
57 rewrite_reason: &str,
58) -> CacheExpectation {
59 let Some(previous) = previous else {
60 return CacheExpectation::ColdStart;
61 };
62 if previous.material_fingerprint != current_material_fingerprint {
63 return CacheExpectation::PlannedInvalidation {
64 reason: "provider_material_changed".into(),
65 };
66 }
67 if current_text.starts_with(previous.text) {
68 return CacheExpectation::ExpectedWarm;
69 }
70 CacheExpectation::PlannedInvalidation {
71 reason: nonblank_reason(rewrite_reason),
72 }
73}
74
75fn nonblank_reason(reason: &str) -> String {
76 let reason = reason.trim();
77 if reason.is_empty() {
78 "other_projection_rewrite".into()
79 } else {
80 reason.into()
81 }
82}
83
84#[derive(Clone, Debug, Default, Eq, PartialEq)]
86pub struct MarkerState {
87 pub reported_stale_boxes: BTreeSet<u64>,
88 pub last_context_size_tokens: Option<u64>,
89}
90
91#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct MarkerObservation {
94 pub stale_boxes: Vec<u64>,
95 pub current_context_tokens: u64,
96 pub context_limit_tokens: u64,
97 pub expectation: CacheExpectation,
98}
99
100#[derive(Clone, Debug, Eq, PartialEq)]
102pub enum StaleMarker {
103 New(Vec<u64>),
104 Consolidated(Vec<u64>),
105}
106
107impl StaleMarker {
108 pub fn render(&self) -> String {
110 let (label, ids) = match self {
111 Self::New(ids) => ("new stale boxes", ids),
112 Self::Consolidated(ids) => ("stale boxes", ids),
113 };
114 let ids = ids
115 .iter()
116 .map(u64::to_string)
117 .collect::<Vec<_>>()
118 .join(", ");
119 format!("[{label}: {ids}]")
120 }
121}
122
123#[derive(Clone, Debug, Eq, PartialEq)]
125pub struct MarkerDecision {
126 pub reset_epoch: bool,
127 pub stale: Option<StaleMarker>,
128 pub context_size_tokens: Option<u64>,
129 pub next_state: MarkerState,
130}
131
132pub fn decide_markers(state: &MarkerState, observation: MarkerObservation) -> MarkerDecision {
134 let current_stale = observation.stale_boxes.into_iter().collect::<BTreeSet<_>>();
135 let reset_epoch = !matches!(observation.expectation, CacheExpectation::ExpectedWarm);
136 let mut next_state = if reset_epoch {
137 MarkerState::default()
138 } else {
139 state.clone()
140 };
141 let stale = if reset_epoch {
142 (!current_stale.is_empty())
143 .then(|| StaleMarker::Consolidated(current_stale.iter().copied().collect()))
144 } else {
145 let new = current_stale
146 .difference(&next_state.reported_stale_boxes)
147 .copied()
148 .collect::<Vec<_>>();
149 (!new.is_empty()).then_some(StaleMarker::New(new))
150 };
151 next_state.reported_stale_boxes = current_stale;
152
153 let context_size_tokens = size_marker_due(
154 next_state.last_context_size_tokens,
155 observation.current_context_tokens,
156 observation.context_limit_tokens,
157 );
158 if let Some(tokens) = context_size_tokens {
159 next_state.last_context_size_tokens = Some(tokens);
160 }
161 MarkerDecision {
162 reset_epoch,
163 stale,
164 context_size_tokens,
165 next_state,
166 }
167}
168
169fn size_marker_due(last: Option<u64>, current: u64, limit: u64) -> Option<u64> {
170 if limit == 0 || current.saturating_mul(10) <= limit.saturating_mul(3) {
171 return None;
172 }
173 match last {
174 None => Some(current),
175 Some(last) if current.saturating_sub(last) >= CONTEXT_SIZE_MARKER_INTERVAL_TOKENS => {
176 Some(current)
177 }
178 Some(_) => None,
179 }
180}
181
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184pub struct InputTokens {
185 pub total: u64,
186 pub cached: u64,
187}
188
189impl InputTokens {
190 pub const fn uncached(self) -> u64 {
191 self.total.saturating_sub(self.cached)
192 }
193
194 pub fn cached_ratio(self) -> Option<f64> {
195 (self.total > 0).then(|| self.cached as f64 / self.total as f64)
196 }
197}
198
199#[derive(Clone, Copy, Debug, Eq, PartialEq)]
201pub enum CacheOutcome {
202 Hit,
203 UnplannedMiss,
204 Excluded,
205 UsageUnavailable,
206}
207
208impl CacheOutcome {
209 pub const fn label(self) -> &'static str {
210 match self {
211 Self::Hit => "hit",
212 Self::UnplannedMiss => "unplanned_miss",
213 Self::Excluded => "excluded",
214 Self::UsageUnavailable => "usage_unavailable",
215 }
216 }
217}
218
219pub fn observe_cache(expectation: &CacheExpectation, tokens: Option<InputTokens>) -> CacheOutcome {
221 let Some(tokens) = tokens.filter(|tokens| tokens.total > 0) else {
222 return CacheOutcome::UsageUnavailable;
223 };
224 if tokens.cached > 0 {
225 CacheOutcome::Hit
226 } else if expectation.expects_cache_hit() {
227 CacheOutcome::UnplannedMiss
228 } else {
229 CacheOutcome::Excluded
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn exact_prefix_and_material_determine_cache_expectation() {
239 let previous = PreviousProjection {
240 text: "stable",
241 material_fingerprint: "material-a",
242 };
243 assert_eq!(
244 classify(None, "stable", "material-a", "rewrite"),
245 CacheExpectation::ColdStart
246 );
247 assert_eq!(
248 classify(Some(previous), "stable plus", "material-a", "rewrite"),
249 CacheExpectation::ExpectedWarm
250 );
251 assert_eq!(
252 classify(Some(previous), "changed", "material-a", "dehydrated"),
253 CacheExpectation::PlannedInvalidation {
254 reason: "dehydrated".into()
255 }
256 );
257 assert_eq!(
258 classify(Some(previous), "stable", "material-b", ""),
259 CacheExpectation::PlannedInvalidation {
260 reason: "provider_material_changed".into()
261 }
262 );
263 }
264
265 #[test]
266 fn warm_epochs_emit_only_new_stale_boxes() {
267 let state = MarkerState {
268 reported_stale_boxes: BTreeSet::from([2, 4]),
269 last_context_size_tokens: None,
270 };
271 let decision = decide_markers(
272 &state,
273 MarkerObservation {
274 stale_boxes: vec![2, 4, 7],
275 current_context_tokens: 20,
276 context_limit_tokens: 100,
277 expectation: CacheExpectation::ExpectedWarm,
278 },
279 );
280 assert!(!decision.reset_epoch);
281 assert_eq!(decision.stale, Some(StaleMarker::New(vec![7])));
282 assert_eq!(
283 decision.stale.as_ref().unwrap().render(),
284 "[new stale boxes: 7]"
285 );
286 assert_eq!(decision.context_size_tokens, None);
287 }
288
289 #[test]
290 fn invalidation_consolidates_stale_state_and_resets_size_baseline() {
291 let state = MarkerState {
292 reported_stale_boxes: BTreeSet::from([1]),
293 last_context_size_tokens: Some(75_000),
294 };
295 let decision = decide_markers(
296 &state,
297 MarkerObservation {
298 stale_boxes: vec![1, 9],
299 current_context_tokens: 40_000,
300 context_limit_tokens: 100_000,
301 expectation: CacheExpectation::PlannedInvalidation {
302 reason: "summarized".into(),
303 },
304 },
305 );
306 assert!(decision.reset_epoch);
307 assert_eq!(decision.stale, Some(StaleMarker::Consolidated(vec![1, 9])));
308 assert_eq!(decision.context_size_tokens, Some(40_000));
309 assert_eq!(decision.next_state.last_context_size_tokens, Some(40_000));
310 }
311
312 #[test]
313 fn size_markers_are_strictly_over_thirty_percent_and_ten_thousand_apart() {
314 for (current, last, expected) in [
315 (30_000, None, None),
316 (30_001, None, Some(30_001)),
317 (40_000, Some(30_001), None),
318 (40_001, Some(30_001), Some(40_001)),
319 ] {
320 assert_eq!(size_marker_due(last, current, 100_000), expected);
321 }
322 assert_eq!(size_marker_due(None, 100, 0), None);
323 }
324
325 #[test]
326 fn cache_outcomes_separate_health_from_expected_misses() {
327 let warm = CacheExpectation::ExpectedWarm;
328 let cold = CacheExpectation::ColdStart;
329 assert_eq!(
330 observe_cache(
331 &warm,
332 Some(InputTokens {
333 total: 100,
334 cached: 80
335 })
336 ),
337 CacheOutcome::Hit
338 );
339 assert_eq!(
340 observe_cache(
341 &warm,
342 Some(InputTokens {
343 total: 100,
344 cached: 0
345 })
346 ),
347 CacheOutcome::UnplannedMiss
348 );
349 assert_eq!(
350 observe_cache(
351 &cold,
352 Some(InputTokens {
353 total: 100,
354 cached: 0
355 })
356 ),
357 CacheOutcome::Excluded
358 );
359 assert_eq!(observe_cache(&warm, None), CacheOutcome::UsageUnavailable);
360 }
361
362 #[test]
363 fn token_dimensions_saturate_and_report_ratios() {
364 let tokens = InputTokens {
365 total: 100,
366 cached: 125,
367 };
368 assert_eq!(tokens.uncached(), 0);
369 assert_eq!(tokens.cached_ratio(), Some(1.25));
370 assert_eq!(
371 InputTokens {
372 total: 0,
373 cached: 0
374 }
375 .cached_ratio(),
376 None
377 );
378 }
379}