Skip to main content

lean_ctx/core/context_kernel/
degradation.rs

1//! Kernel degradation levels and fallback planning.
2
3use std::collections::HashMap;
4
5use super::types::{ContextPlanV1, ExcludedEntry, PlanBudget, PlanEntry};
6
7/// Operational capability remaining after provider failures.
8#[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    /// All configured providers are available.
22    #[default]
23    Full,
24    /// At least half of the configured providers are available.
25    Reduced,
26    /// At least one provider is available.
27    Minimal,
28    /// No providers are available, so the kernel must be bypassed.
29    Bypass,
30}
31
32/// Current availability information for one provider.
33#[derive(Debug, Clone)]
34pub struct ProviderStatus {
35    pub provider_id: String,
36    pub available: bool,
37    pub last_error: Option<String>,
38}
39
40/// Aggregate provider health used to select a degradation level.
41#[derive(Debug, Clone)]
42pub struct KernelHealth {
43    providers: Vec<ProviderStatus>,
44}
45
46impl KernelHealth {
47    /// Creates a health snapshot from the configured provider statuses.
48    pub fn new(providers: Vec<ProviderStatus>) -> Self {
49        Self { providers }
50    }
51
52    /// Returns the capability level supported by the available providers.
53    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    /// Lists provider identifiers currently available for planning.
73    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    /// Lists provider identifiers currently unavailable for planning.
82    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    /// Summarizes provider availability and the resulting capability level.
91    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
107/// Removes selections whose providers are unavailable and updates accounting.
108pub 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
141/// Creates an empty pass-through plan for bypass mode.
142pub 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}