Skip to main content

lean_ctx/core/context_kernel/
activation.rs

1//! Kernel activation configuration and receipt-driven feedback wiring.
2
3use std::collections::HashMap;
4use std::fs;
5use std::io::ErrorKind;
6use std::path::Path;
7
8use super::enforce::KernelMode;
9use super::feedback::FeedbackCollector;
10use super::learning::OutcomeLearner;
11use super::types::{ContextReceiptV1, ReceiptOutcome};
12
13const MAX_SUPPLEMENT_TOKENS: usize = 150;
14
15/// Configuration for kernel activation mode and feedback.
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
17pub struct ActivationConfig {
18    /// Kernel operating mode: Shadow (log only), Enforce (apply decisions),
19    /// or Explain (log + annotate).
20    pub mode: KernelModeConfig,
21    /// Whether to track real outcomes (accept/reject) from the LLM.
22    pub outcome_tracking: bool,
23    /// Hard cap on tokens the kernel may add per request.
24    pub max_supplement_tokens: usize,
25    /// Enable feedback loop: outcomes → weight updates → better selection.
26    pub feedback_loop: bool,
27}
28
29/// Serializable kernel operating mode.
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum KernelModeConfig {
33    /// Log kernel decisions but don't enforce them. Safe default.
34    #[default]
35    Shadow,
36    /// Apply kernel decisions: suppress low-value context, enforce budget.
37    Enforce,
38    /// Like Shadow but annotate output with kernel reasoning.
39    Explain,
40}
41
42impl From<KernelModeConfig> for KernelMode {
43    fn from(mode: KernelModeConfig) -> Self {
44        match mode {
45            KernelModeConfig::Shadow => Self::Shadow,
46            KernelModeConfig::Enforce => Self::Enforce,
47            KernelModeConfig::Explain => Self::Explain,
48        }
49    }
50}
51
52#[derive(Debug, Default, serde::Deserialize)]
53struct ConfigFile {
54    kernel: Option<KernelOverrides>,
55}
56
57#[derive(Debug, Default, serde::Deserialize)]
58struct KernelOverrides {
59    mode: Option<KernelModeConfig>,
60    outcome_tracking: Option<bool>,
61    #[serde(alias = "max_supplement")]
62    max_supplement_tokens: Option<usize>,
63    feedback_loop: Option<bool>,
64}
65
66/// Loads kernel activation settings from global and project-local lean-ctx config.
67///
68/// Missing or malformed files retain safe defaults. Project-local settings in
69/// `.lean-ctx.toml` override the global `config.toml` settings.
70pub fn load_config(project_root: &str) -> ActivationConfig {
71    let mut config = safe_defaults();
72
73    if let Some(path) = crate::core::config::Config::path()
74        && let Some(overrides) = read_kernel_overrides(&path)
75    {
76        apply_overrides(&mut config, &overrides);
77    }
78
79    let local_path = crate::core::config::Config::local_path(project_root);
80    if let Some(overrides) = read_kernel_overrides(&local_path) {
81        apply_overrides(&mut config, &overrides);
82    }
83
84    config
85}
86
87/// Returns a receipt copy carrying the observed accept/reject outcome.
88pub fn record_real_outcome(receipt: &ContextReceiptV1, accepted: bool) -> ContextReceiptV1 {
89    let mut recorded = receipt.clone();
90    recorded.outcome = if accepted {
91        ReceiptOutcome::Accepted
92    } else {
93        ReceiptOutcome::Rejected
94    };
95    recorded
96}
97
98/// Feeds a known receipt outcome into persisted feedback and provider learning.
99///
100/// Unknown and partial outcomes carry no binary accept/reject signal and are
101/// ignored. Feedback persistence handles unavailable paths without panicking.
102pub fn connect_feedback(receipt: &ContextReceiptV1, project_root: &str) {
103    if !matches!(
104        receipt.outcome,
105        ReceiptOutcome::Accepted | ReceiptOutcome::Rejected
106    ) {
107        return;
108    }
109
110    let mut collector = FeedbackCollector::default_for_project(project_root);
111    collector.load_weights();
112
113    let mut weights: HashMap<String, f64> = receipt
114        .feedback_attribution
115        .keys()
116        .map(|provider| (provider.clone(), collector.provider_weight(provider)))
117        .collect();
118    let learner = OutcomeLearner::default_learner();
119    let updates = learner.learn_from_receipt(receipt, &weights);
120    OutcomeLearner::apply_updates(&mut weights, &updates);
121
122    collector.record_outcome(receipt);
123}
124
125/// Returns whether kernel supplementation should run in the configured mode.
126pub fn should_supplement(config: &ActivationConfig) -> bool {
127    matches!(
128        config.mode,
129        KernelModeConfig::Shadow | KernelModeConfig::Enforce | KernelModeConfig::Explain
130    )
131}
132
133/// Returns the bounded per-request token budget for kernel supplementation.
134pub fn supplement_budget(config: &ActivationConfig) -> usize {
135    config.max_supplement_tokens.min(MAX_SUPPLEMENT_TOKENS)
136}
137
138/// Returns whether the selected mode may suppress low-value context.
139pub fn should_suppress_in_mode(mode: KernelModeConfig) -> bool {
140    matches!(KernelMode::from(mode), KernelMode::Enforce)
141}
142
143fn safe_defaults() -> ActivationConfig {
144    ActivationConfig {
145        mode: KernelModeConfig::Shadow,
146        outcome_tracking: false,
147        max_supplement_tokens: MAX_SUPPLEMENT_TOKENS,
148        feedback_loop: false,
149    }
150}
151
152fn read_kernel_overrides(path: &Path) -> Option<KernelOverrides> {
153    match fs::read_to_string(path) {
154        Ok(raw) => match toml::from_str::<ConfigFile>(&raw) {
155            Ok(config) => config.kernel,
156            Err(error) => {
157                tracing::warn!(path = %path.display(), %error, "invalid kernel configuration");
158                None
159            }
160        },
161        Err(error) if error.kind() == ErrorKind::NotFound => None,
162        Err(error) => {
163            tracing::warn!(path = %path.display(), %error, "unable to read kernel configuration");
164            None
165        }
166    }
167}
168
169fn apply_overrides(config: &mut ActivationConfig, overrides: &KernelOverrides) {
170    if let Some(mode) = overrides.mode {
171        config.mode = mode;
172    }
173    if let Some(enabled) = overrides.outcome_tracking {
174        config.outcome_tracking = enabled;
175    }
176    if let Some(tokens) = overrides.max_supplement_tokens {
177        config.max_supplement_tokens = tokens;
178    }
179    if let Some(enabled) = overrides.feedback_loop {
180        config.feedback_loop = enabled;
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use std::collections::HashMap;
187
188    use super::{
189        ActivationConfig, KernelModeConfig, connect_feedback, load_config, record_real_outcome,
190        should_suppress_in_mode, supplement_budget,
191    };
192    use crate::core::context_kernel::types::{ContextReceiptV1, ReceiptOutcome};
193
194    fn receipt(outcome: ReceiptOutcome) -> ContextReceiptV1 {
195        ContextReceiptV1 {
196            receipt_id: "receipt-1".to_owned(),
197            plan_id: "plan-1".to_owned(),
198            delivered_tokens: 10,
199            cache_hits: 0,
200            cache_misses: 0,
201            outcome,
202            quality_signals: Vec::new(),
203            feedback_attribution: HashMap::new(),
204        }
205    }
206
207    fn config(mode: KernelModeConfig, max_supplement_tokens: usize) -> ActivationConfig {
208        ActivationConfig {
209            mode,
210            outcome_tracking: false,
211            max_supplement_tokens,
212            feedback_loop: false,
213        }
214    }
215
216    #[test]
217    fn default_config_is_shadow() {
218        let loaded = load_config("/path/that/does/not/exist");
219        assert_eq!(loaded.mode, KernelModeConfig::Shadow);
220        assert!(!loaded.outcome_tracking);
221    }
222
223    #[test]
224    fn supplement_budget_capped_at_150() {
225        assert_eq!(
226            supplement_budget(&config(KernelModeConfig::Enforce, 500)),
227            150
228        );
229    }
230
231    #[test]
232    fn real_outcome_sets_rejected() {
233        let original = receipt(ReceiptOutcome::Unknown);
234        let recorded = record_real_outcome(&original, false);
235
236        assert_eq!(recorded.outcome, ReceiptOutcome::Rejected);
237        assert_eq!(original.outcome, ReceiptOutcome::Unknown);
238    }
239
240    #[test]
241    fn shadow_mode_never_suppresses() {
242        assert!(!should_suppress_in_mode(KernelModeConfig::Shadow));
243    }
244
245    #[test]
246    fn enforce_mode_suppresses() {
247        assert!(should_suppress_in_mode(KernelModeConfig::Enforce));
248    }
249
250    #[test]
251    fn connect_feedback_graceful_on_error() {
252        connect_feedback(
253            &receipt(ReceiptOutcome::Rejected),
254            "/path/that/does/not/exist",
255        );
256    }
257}