lean_ctx/core/context_kernel/
degradation.rs1use std::collections::HashMap;
4
5use super::types::{ContextPlanV1, ExcludedEntry, PlanBudget, PlanEntry};
6
7#[derive(
9 Debug,
10 Default,
11 Clone,
12 Copy,
13 PartialEq,
14 Eq,
15 PartialOrd,
16 Ord,
17 serde::Serialize,
18 serde::Deserialize,
19)]
20pub enum DegradationLevel {
21 #[default]
23 Full,
24 Reduced,
26 Minimal,
28 Bypass,
30}
31
32#[derive(Debug, Clone)]
34pub struct ProviderStatus {
35 pub provider_id: String,
36 pub available: bool,
37 pub last_error: Option<String>,
38}
39
40#[derive(Debug, Clone)]
42pub struct KernelHealth {
43 providers: Vec<ProviderStatus>,
44}
45
46impl KernelHealth {
47 pub fn new(providers: Vec<ProviderStatus>) -> Self {
49 Self { providers }
50 }
51
52 pub fn degradation_level(&self) -> DegradationLevel {
54 let total = self.providers.len();
55 let available = self
56 .providers
57 .iter()
58 .filter(|provider| provider.available)
59 .count();
60
61 if total == 0 {
62 return DegradationLevel::Bypass;
63 }
64 match available {
65 n if n == total => DegradationLevel::Full,
66 n if n * 2 >= total => DegradationLevel::Reduced,
67 n if n >= 1 => DegradationLevel::Minimal,
68 _ => DegradationLevel::Bypass,
69 }
70 }
71
72 pub fn available_providers(&self) -> Vec<&str> {
74 self.providers
75 .iter()
76 .filter(|provider| provider.available)
77 .map(|provider| provider.provider_id.as_str())
78 .collect()
79 }
80
81 pub fn unavailable_providers(&self) -> Vec<&str> {
83 self.providers
84 .iter()
85 .filter(|provider| !provider.available)
86 .map(|provider| provider.provider_id.as_str())
87 .collect()
88 }
89
90 pub fn summary(&self) -> String {
92 format!(
93 "{}/{} providers available ({:?})",
94 self.available_providers().len(),
95 self.providers.len(),
96 self.degradation_level()
97 )
98 }
99
100 fn provider_is_available(&self, provider_id: &str) -> bool {
101 self.providers
102 .iter()
103 .any(|status| status.provider_id == provider_id && status.available)
104 }
105}
106
107pub fn degrade_plan(plan: &ContextPlanV1, health: &KernelHealth) -> ContextPlanV1 {
109 let mut selected: Vec<PlanEntry> = Vec::new();
110 let mut excluded = plan.excluded.clone();
111
112 for entry in &plan.selected {
113 if health.provider_is_available(&entry.provider) {
114 selected.push(entry.clone());
115 } else {
116 excluded.push(ExcludedEntry {
117 object_id: entry.object_id.clone(),
118 provider: entry.provider.clone(),
119 reason: "provider unavailable".to_owned(),
120 });
121 }
122 }
123
124 let used_tokens = selected.iter().map(|entry| entry.tokens).sum();
125
126 ContextPlanV1 {
127 plan_id: format!("{}-degraded", plan.plan_id),
128 intent: plan.intent.clone(),
129 budget: PlanBudget {
130 total_tokens: plan.budget.total_tokens,
131 used_tokens,
132 remaining_tokens: plan.budget.total_tokens.saturating_sub(used_tokens),
133 },
134 selected,
135 excluded,
136 deferred: plan.deferred.clone(),
137 provider_stats: plan.provider_stats.clone(),
138 }
139}
140
141pub fn fallback_plan(intent: &str, budget_tokens: usize) -> ContextPlanV1 {
143 ContextPlanV1 {
144 plan_id: format!("plan:{intent}-fallback"),
145 intent: intent.to_owned(),
146 budget: PlanBudget {
147 total_tokens: budget_tokens,
148 used_tokens: 0,
149 remaining_tokens: budget_tokens,
150 },
151 selected: Vec::new(),
152 excluded: Vec::new(),
153 deferred: Vec::new(),
154 provider_stats: HashMap::new(),
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 fn status(provider_id: &str, available: bool) -> ProviderStatus {
163 ProviderStatus {
164 provider_id: provider_id.to_owned(),
165 available,
166 last_error: (!available).then(|| "provider failed".to_owned()),
167 }
168 }
169
170 fn entry(object_id: &str, provider: &str, tokens: usize) -> PlanEntry {
171 PlanEntry {
172 object_id: object_id.to_owned(),
173 provider: provider.to_owned(),
174 view: "full".to_owned(),
175 tokens,
176 phi: 1.0,
177 reason: "selected".to_owned(),
178 }
179 }
180
181 #[test]
182 fn full_health_means_no_degradation() {
183 let health = KernelHealth::new(vec![status("files", true), status("facts", true)]);
184
185 assert_eq!(health.degradation_level(), DegradationLevel::Full);
186 assert_eq!(health.summary(), "2/2 providers available (Full)");
187 }
188
189 #[test]
190 fn partial_failure_degrades_to_reduced() {
191 let health = KernelHealth::new(vec![
192 status("files", true),
193 status("facts", true),
194 status("episodes", true),
195 status("search", false),
196 status("session", false),
197 ]);
198
199 assert_eq!(health.degradation_level(), DegradationLevel::Reduced);
200 assert_eq!(health.unavailable_providers(), vec!["search", "session"]);
201 }
202
203 #[test]
204 fn degrade_plan_removes_unavailable() {
205 let health = KernelHealth::new(vec![status("files", true), status("facts", false)]);
206 let plan = ContextPlanV1 {
207 plan_id: "plan:test".to_owned(),
208 intent: "test degradation".to_owned(),
209 budget: PlanBudget {
210 total_tokens: 100,
211 used_tokens: 70,
212 remaining_tokens: 30,
213 },
214 selected: vec![entry("file:1", "files", 40), entry("fact:1", "facts", 30)],
215 excluded: Vec::new(),
216 deferred: Vec::new(),
217 provider_stats: HashMap::new(),
218 };
219
220 let degraded = degrade_plan(&plan, &health);
221
222 assert_eq!(degraded.plan_id, "plan:test-degraded");
223 assert_eq!(degraded.selected.len(), 1);
224 assert_eq!(degraded.selected[0].object_id, "file:1");
225 assert_eq!(degraded.budget.used_tokens, 40);
226 assert_eq!(degraded.budget.remaining_tokens, 60);
227 assert_eq!(degraded.excluded.len(), 1);
228 assert_eq!(degraded.excluded[0].object_id, "fact:1");
229 assert_eq!(degraded.excluded[0].reason, "provider unavailable");
230 }
231
232 #[test]
233 fn fallback_plan_is_empty_with_budget() {
234 let plan = fallback_plan("bypass kernel", 512);
235
236 assert_eq!(plan.intent, "bypass kernel");
237 assert_eq!(plan.budget.total_tokens, 512);
238 assert_eq!(plan.budget.used_tokens, 0);
239 assert_eq!(plan.budget.remaining_tokens, 512);
240 assert!(plan.selected.is_empty());
241 assert!(plan.excluded.is_empty());
242 assert!(plan.deferred.is_empty());
243 assert!(plan.provider_stats.is_empty());
244 }
245}