Skip to main content

lean_ctx/core/context_kernel/
invalidation.rs

1//! Invalidation propagation across kernel plans, receipts, and candidates.
2
3use std::collections::HashMap;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use super::types::{ContextPlanV1, ContextReceiptV1};
7
8#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9/// Reason for invalidating cached context objects.
10pub enum InvalidationReason {
11    SourceChanged,
12    PolicyChanged,
13    Expired,
14    Contradicted,
15    Deleted,
16    ManualOverride,
17}
18
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20/// An event describing which content references became invalid and why.
21pub struct InvalidationEvent {
22    pub event_id: String,
23    pub reason: InvalidationReason,
24    pub affected_content_refs: Vec<String>,
25    pub timestamp_epoch: u64,
26    pub source: String,
27}
28
29impl InvalidationEvent {
30    pub fn new(reason: InvalidationReason, content_refs: Vec<String>, source: &str) -> Self {
31        let timestamp_epoch = SystemTime::now()
32            .duration_since(UNIX_EPOCH)
33            .map_or(0, |duration| duration.as_secs());
34        let event_id = format!("invalidation:{source}:{timestamp_epoch}");
35
36        Self {
37            event_id,
38            reason,
39            affected_content_refs: content_refs,
40            timestamp_epoch,
41            source: source.to_owned(),
42        }
43    }
44}
45
46#[derive(Debug, Clone, Default)]
47/// Outcome of propagating an invalidation through kernel state.
48pub struct InvalidationResult {
49    pub invalidated_plan_ids: Vec<String>,
50    pub invalidated_receipt_ids: Vec<String>,
51    pub invalidated_candidate_ids: Vec<String>,
52    pub total_invalidated: usize,
53}
54
55#[derive(Debug, Clone, Default)]
56/// Tracks plan and receipt content references for invalidation propagation.
57pub struct KernelInvalidationState {
58    plan_content_refs: HashMap<String, Vec<String>>,
59    receipt_content_refs: HashMap<String, Vec<String>>,
60    plan_order: Vec<String>,
61    receipt_plan_ids: HashMap<String, String>,
62}
63
64impl KernelInvalidationState {
65    /// Creates an empty invalidation state.
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Records a plan's content references for future invalidation lookups.
71    pub fn register_plan(&mut self, plan: &ContextPlanV1) {
72        let content_refs: Vec<String> = plan
73            .selected
74            .iter()
75            .map(|entry| entry.object_id.clone())
76            .collect();
77
78        if !self.plan_content_refs.contains_key(&plan.plan_id) {
79            self.plan_order.push(plan.plan_id.clone());
80        }
81        self.plan_content_refs
82            .insert(plan.plan_id.clone(), content_refs);
83    }
84
85    /// Links a receipt to its plan's content references.
86    pub fn register_receipt(&mut self, receipt: &ContextReceiptV1) {
87        let content_refs = self
88            .plan_content_refs
89            .get(&receipt.plan_id)
90            .cloned()
91            .unwrap_or_default();
92
93        self.receipt_content_refs
94            .insert(receipt.receipt_id.clone(), content_refs);
95        self.receipt_plan_ids
96            .insert(receipt.receipt_id.clone(), receipt.plan_id.clone());
97    }
98
99    /// Returns all plans, receipts, and candidates affected by the event.
100    pub fn propagate(&self, event: &InvalidationEvent) -> InvalidationResult {
101        let mut result = InvalidationResult::default();
102
103        for content_ref in &event.affected_content_refs {
104            if !result.invalidated_candidate_ids.contains(content_ref) {
105                result.invalidated_candidate_ids.push(content_ref.clone());
106            }
107
108            for (plan_id, content_refs) in &self.plan_content_refs {
109                if content_refs.contains(content_ref)
110                    && !result.invalidated_plan_ids.contains(plan_id)
111                {
112                    result.invalidated_plan_ids.push(plan_id.clone());
113                }
114            }
115
116            for (receipt_id, content_refs) in &self.receipt_content_refs {
117                if content_refs.contains(content_ref)
118                    && !result.invalidated_receipt_ids.contains(receipt_id)
119                {
120                    result.invalidated_receipt_ids.push(receipt_id.clone());
121                }
122            }
123        }
124
125        result.invalidated_plan_ids.sort();
126        result.invalidated_receipt_ids.sort();
127        result.invalidated_candidate_ids.sort();
128        result.total_invalidated = result
129            .invalidated_plan_ids
130            .len()
131            .saturating_add(result.invalidated_receipt_ids.len())
132            .saturating_add(result.invalidated_candidate_ids.len());
133        result
134    }
135
136    /// Removes the oldest entries, keeping at most `keep_recent` plans tracked.
137    pub fn purge_stale(&mut self, keep_recent: usize) {
138        let remove_count = self.plan_order.len().saturating_sub(keep_recent);
139        let removed_plan_ids: Vec<String> = self.plan_order.drain(..remove_count).collect();
140
141        for plan_id in &removed_plan_ids {
142            self.plan_content_refs.remove(plan_id);
143        }
144
145        let stale_receipt_ids: Vec<String> = self
146            .receipt_plan_ids
147            .iter()
148            .filter(|(_, plan_id)| removed_plan_ids.contains(plan_id))
149            .map(|(receipt_id, _)| receipt_id.clone())
150            .collect();
151        for receipt_id in stale_receipt_ids {
152            self.receipt_content_refs.remove(&receipt_id);
153            self.receipt_plan_ids.remove(&receipt_id);
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use std::collections::HashMap;
161
162    use super::{InvalidationEvent, InvalidationReason, KernelInvalidationState};
163    use crate::core::context_kernel::types::{
164        ContextPlanV1, ContextReceiptV1, PlanBudget, PlanEntry, ReceiptOutcome,
165    };
166
167    fn plan(plan_id: &str, object_id: &str) -> ContextPlanV1 {
168        ContextPlanV1 {
169            plan_id: plan_id.to_owned(),
170            intent: "test invalidation".to_owned(),
171            budget: PlanBudget::default(),
172            selected: vec![PlanEntry {
173                object_id: object_id.to_owned(),
174                provider: "files".to_owned(),
175                view: "full".to_owned(),
176                tokens: 10,
177                phi: 1.0,
178                reason: "relevant".to_owned(),
179            }],
180            excluded: Vec::new(),
181            deferred: Vec::new(),
182            provider_stats: HashMap::new(),
183        }
184    }
185
186    fn receipt(receipt_id: &str, plan_id: &str) -> ContextReceiptV1 {
187        ContextReceiptV1 {
188            receipt_id: receipt_id.to_owned(),
189            plan_id: plan_id.to_owned(),
190            delivered_tokens: 10,
191            cache_hits: 0,
192            cache_misses: 0,
193            outcome: ReceiptOutcome::Accepted,
194            quality_signals: Vec::new(),
195            feedback_attribution: HashMap::new(),
196        }
197    }
198
199    #[test]
200    fn source_change_invalidates_plan() {
201        let mut state = KernelInvalidationState::new();
202        state.register_plan(&plan("plan-1", "file:a"));
203        let event = InvalidationEvent::new(
204            InvalidationReason::SourceChanged,
205            vec!["file:a".to_owned()],
206            "watcher",
207        );
208
209        let result = state.propagate(&event);
210
211        assert_eq!(result.invalidated_plan_ids, vec!["plan-1"]);
212        assert_eq!(result.invalidated_candidate_ids, vec!["file:a"]);
213        assert_eq!(result.total_invalidated, 2);
214    }
215
216    #[test]
217    fn receipt_invalidated_via_plan() {
218        let mut state = KernelInvalidationState::new();
219        state.register_plan(&plan("plan-1", "file:a"));
220        state.register_receipt(&receipt("receipt-1", "plan-1"));
221        let event = InvalidationEvent::new(
222            InvalidationReason::Deleted,
223            vec!["file:a".to_owned()],
224            "watcher",
225        );
226
227        let result = state.propagate(&event);
228
229        assert_eq!(result.invalidated_receipt_ids, vec!["receipt-1"]);
230        assert_eq!(result.total_invalidated, 3);
231    }
232
233    #[test]
234    fn unrelated_event_no_impact() {
235        let mut state = KernelInvalidationState::new();
236        state.register_plan(&plan("plan-1", "file:a"));
237        let event = InvalidationEvent::new(
238            InvalidationReason::Expired,
239            vec!["file:b".to_owned()],
240            "ttl",
241        );
242
243        let result = state.propagate(&event);
244
245        assert!(result.invalidated_plan_ids.is_empty());
246        assert!(result.invalidated_receipt_ids.is_empty());
247        assert_eq!(result.invalidated_candidate_ids, vec!["file:b"]);
248        assert_eq!(result.total_invalidated, 1);
249    }
250
251    #[test]
252    fn purge_limits_state_growth() {
253        let mut state = KernelInvalidationState::new();
254        state.register_plan(&plan("plan-1", "file:a"));
255        state.register_receipt(&receipt("receipt-1", "plan-1"));
256        state.register_plan(&plan("plan-2", "file:b"));
257        state.purge_stale(1);
258
259        let old_result = state.propagate(&InvalidationEvent::new(
260            InvalidationReason::ManualOverride,
261            vec!["file:a".to_owned()],
262            "operator",
263        ));
264        let current_result = state.propagate(&InvalidationEvent::new(
265            InvalidationReason::ManualOverride,
266            vec!["file:b".to_owned()],
267            "operator",
268        ));
269
270        assert!(old_result.invalidated_plan_ids.is_empty());
271        assert!(old_result.invalidated_receipt_ids.is_empty());
272        assert_eq!(current_result.invalidated_plan_ids, vec!["plan-2"]);
273        assert_eq!(state.plan_content_refs.len(), 1);
274    }
275
276    #[test]
277    fn propagation_deduplicates_repeated_references() {
278        let mut state = KernelInvalidationState::new();
279        state.register_plan(&plan("plan-1", "file:a"));
280        let event = InvalidationEvent::new(
281            InvalidationReason::Contradicted,
282            vec!["file:a".to_owned(), "file:a".to_owned()],
283            "validator",
284        );
285
286        let result = state.propagate(&event);
287
288        assert_eq!(result.invalidated_plan_ids, vec!["plan-1"]);
289        assert_eq!(result.invalidated_candidate_ids, vec!["file:a"]);
290        assert_eq!(result.total_invalidated, 2);
291    }
292}