lean_ctx/core/context_kernel/
learning.rs1use std::collections::HashMap;
4
5use super::types::{ContextReceiptV1, ReceiptOutcome};
6
7#[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
17pub 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
72pub 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, ¤t);
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), ¤t);
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), ¤t);
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), ¤t);
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), ¤t);
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}