Skip to main content

kcode_context_cache_policy/
lib.rs

1//! Deterministic cache-stability and sparse context-marker policy.
2
3#![forbid(unsafe_code)]
4
5use std::collections::BTreeSet;
6
7/// Minimum estimated context growth between size markers in one cache epoch.
8pub const CONTEXT_SIZE_MARKER_INTERVAL_TOKENS: u64 = 10_000;
9
10/// Cache behavior expected from the relationship between two submissions.
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub enum CacheExpectation {
13    /// No prior submitted cacheable projection exists.
14    ColdStart,
15    /// The prior cacheable projection is an exact prefix of the candidate.
16    ExpectedWarm,
17    /// A known rewrite invalidated the previous cacheable suffix.
18    PlannedInvalidation { reason: String },
19}
20
21impl CacheExpectation {
22    /// Stable structured-log label.
23    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    /// Planned invalidation reason, when applicable.
32    pub fn planned_reason(&self) -> Option<&str> {
33        match self {
34            Self::PlannedInvalidation { reason } => Some(reason),
35            _ => None,
36        }
37    }
38
39    /// Whether a provider cache hit is expected.
40    pub const fn expects_cache_hit(&self) -> bool {
41        matches!(self, Self::ExpectedWarm)
42    }
43}
44
45/// Exact prior cacheable input and its non-text provider-material identity.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub struct PreviousProjection<'a> {
48    pub text: &'a str,
49    pub material_fingerprint: &'a str,
50}
51
52/// Classifies one candidate projection without normalizing its bytes.
53pub 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/// Marker state reconstructed for the current cache epoch.
85#[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/// Current inputs to sparse marker policy.
92#[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/// One stale-box marker to append.
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub enum StaleMarker {
103    New(Vec<u64>),
104    Consolidated(Vec<u64>),
105}
106
107impl StaleMarker {
108    /// Exact model-visible marker text.
109    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/// Complete marker transition selected for one provider preparation.
124#[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
132/// Selects all sparse markers due for one provider preparation.
133pub 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/// Final provider input-token dimensions used by cache diagnostics.
183#[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/// Observed cache result after provider usage becomes available.
200#[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
219/// Classifies final provider cache usage without inventing unavailable data.
220pub 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}