lean_ctx/core/context_kernel/
invalidation.rs1use 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)]
9pub enum InvalidationReason {
11 SourceChanged,
12 PolicyChanged,
13 Expired,
14 Contradicted,
15 Deleted,
16 ManualOverride,
17}
18
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub 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)]
47pub 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)]
56pub 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 pub fn new() -> Self {
67 Self::default()
68 }
69
70 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 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 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 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}