Skip to main content

lean_ctx/core/context_kernel/
learning.rs

1//! Receipt-driven learning for Context Kernel provider weights.
2
3use std::collections::HashMap;
4
5use super::types::{ContextReceiptV1, ReceiptOutcome};
6
7/// A provider weight change inferred from a receipt outcome.
8#[derive(Debug, Clone)]
9pub struct WeightUpdate {
10    pub provider: String,
11    pub old_weight: f64,
12    pub new_weight: f64,
13    pub delta: f64,
14    pub reason: String,
15}
16
17/// Converts receipt outcomes into bounded provider weight updates.
18pub struct OutcomeLearner {
19    alpha: f64,
20}
21
22impl OutcomeLearner {
23    pub fn new(alpha: f64) -> Self {
24        Self {
25            alpha: alpha.clamp(0.01, 0.5),
26        }
27    }
28
29    pub fn default_learner() -> Self {
30        Self::new(0.1)
31    }
32
33    pub fn learn_from_receipt(
34        &self,
35        receipt: &ContextReceiptV1,
36        current_weights: &HashMap<String, f64>,
37    ) -> Vec<WeightUpdate> {
38        let (outcome_score, outcome_name) = match receipt.outcome {
39            ReceiptOutcome::Accepted => (1.0, "accepted"),
40            ReceiptOutcome::Partial => (0.5, "partial"),
41            ReceiptOutcome::Rejected => (0.0, "rejected"),
42            ReceiptOutcome::Unknown => return Vec::new(),
43        };
44
45        let mut providers: Vec<&String> = receipt.feedback_attribution.keys().collect();
46        providers.sort_by_key(|provider| provider.as_str());
47
48        providers
49            .into_iter()
50            .map(|provider| {
51                let old_weight = current_weights.get(provider).copied().unwrap_or(1.0);
52                let new_weight = old_weight * (1.0 - self.alpha) + outcome_score * self.alpha;
53
54                WeightUpdate {
55                    provider: provider.clone(),
56                    old_weight,
57                    new_weight,
58                    delta: new_weight - old_weight,
59                    reason: format!("{outcome_name} receipt outcome"),
60                }
61            })
62            .collect()
63    }
64
65    pub fn apply_updates(weights: &mut HashMap<String, f64>, updates: &[WeightUpdate]) {
66        for update in updates {
67            weights.insert(update.provider.clone(), update.new_weight);
68        }
69    }
70}
71
72/// Learns provider changes from a receipt and persists the receipt feedback.
73pub fn learn_and_update(project_root: &str, receipt: &ContextReceiptV1) -> Vec<WeightUpdate> {
74    let mut collector = super::feedback::FeedbackCollector::default_for_project(project_root);
75    collector.load_weights();
76    let learner = OutcomeLearner::default_learner();
77
78    let mut current: HashMap<String, f64> = HashMap::new();
79    for provider in receipt.feedback_attribution.keys() {
80        current.insert(provider.clone(), collector.provider_weight(provider));
81    }
82
83    let updates = learner.learn_from_receipt(receipt, &current);
84    collector.record_outcome(receipt);
85    updates
86}
87
88#[cfg(test)]
89mod tests {
90    use std::collections::HashMap;
91
92    use super::OutcomeLearner;
93    use crate::core::context_kernel::types::{ContextReceiptV1, ReceiptOutcome};
94
95    fn receipt(outcome: ReceiptOutcome) -> ContextReceiptV1 {
96        ContextReceiptV1 {
97            receipt_id: "receipt-1".to_owned(),
98            plan_id: "plan-1".to_owned(),
99            delivered_tokens: 100,
100            cache_hits: 0,
101            cache_misses: 0,
102            outcome,
103            quality_signals: Vec::new(),
104            feedback_attribution: HashMap::from([("files".to_owned(), 1.0)]),
105        }
106    }
107
108    #[test]
109    fn accepted_increases_weight() {
110        let learner = OutcomeLearner::default_learner();
111        let current: HashMap<String, f64> = HashMap::from([("files".to_owned(), 0.5)]);
112
113        let updates = learner.learn_from_receipt(&receipt(ReceiptOutcome::Accepted), &current);
114
115        assert_eq!(updates.len(), 1);
116        assert!(updates[0].new_weight > updates[0].old_weight);
117        assert!(updates[0].delta > 0.0);
118    }
119
120    #[test]
121    fn rejected_decreases_weight() {
122        let learner = OutcomeLearner::default_learner();
123        let current: HashMap<String, f64> = HashMap::from([("files".to_owned(), 1.0)]);
124
125        let updates = learner.learn_from_receipt(&receipt(ReceiptOutcome::Rejected), &current);
126
127        assert_eq!(updates.len(), 1);
128        assert!(updates[0].new_weight < updates[0].old_weight);
129        assert!(updates[0].delta < 0.0);
130    }
131
132    #[test]
133    fn unknown_skips_learning() {
134        let learner = OutcomeLearner::default_learner();
135        let current: HashMap<String, f64> = HashMap::new();
136
137        let updates = learner.learn_from_receipt(&receipt(ReceiptOutcome::Unknown), &current);
138
139        assert!(updates.is_empty());
140    }
141
142    #[test]
143    fn apply_updates_replaces_provider_weights() {
144        let learner = OutcomeLearner::default_learner();
145        let current: HashMap<String, f64> = HashMap::from([("files".to_owned(), 0.5)]);
146        let updates = learner.learn_from_receipt(&receipt(ReceiptOutcome::Accepted), &current);
147        let mut weights: HashMap<String, f64> = HashMap::new();
148
149        OutcomeLearner::apply_updates(&mut weights, &updates);
150
151        assert_eq!(weights.get("files"), Some(&updates[0].new_weight));
152    }
153}