Skip to main content

lean_ctx/core/
bandit.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct BanditArm {
6    pub name: String,
7    pub alpha: f64,
8    pub beta: f64,
9    pub entropy_threshold: f64,
10    pub jaccard_threshold: f64,
11    pub budget_ratio: f64,
12}
13
14impl BanditArm {
15    fn sample(&self) -> f64 {
16        beta_sample(self.alpha, self.beta)
17    }
18
19    pub fn update_from_feedback(&mut self, outcome: &crate::core::feedback::CompressionOutcome) {
20        let efficiency = if outcome.tokens_original > 0 {
21            outcome.tokens_saved as f64 / outcome.tokens_original as f64
22        } else {
23            0.0
24        };
25        let success = efficiency > 0.3 && outcome.task_completed;
26        if success {
27            self.update_success();
28        } else {
29            self.update_failure();
30        }
31    }
32
33    pub fn update_success(&mut self) {
34        self.alpha += 1.0;
35    }
36
37    pub fn update_failure(&mut self) {
38        self.beta += 1.0;
39    }
40
41    pub fn decay(&mut self, factor: f64) {
42        self.alpha = (self.alpha * factor).max(1.0);
43        self.beta = (self.beta * factor).max(1.0);
44    }
45
46    pub fn mean(&self) -> f64 {
47        self.alpha / (self.alpha + self.beta)
48    }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ThresholdBandit {
53    pub arms: Vec<BanditArm>,
54    pub total_pulls: u64,
55}
56
57impl Default for ThresholdBandit {
58    fn default() -> Self {
59        Self {
60            arms: vec![
61                BanditArm {
62                    name: "conservative".to_string(),
63                    alpha: 2.0,
64                    beta: 1.0,
65                    entropy_threshold: 1.2,
66                    jaccard_threshold: 0.8,
67                    budget_ratio: 0.5,
68                },
69                BanditArm {
70                    name: "balanced".to_string(),
71                    alpha: 2.0,
72                    beta: 1.0,
73                    entropy_threshold: 0.9,
74                    jaccard_threshold: 0.7,
75                    budget_ratio: 0.35,
76                },
77                BanditArm {
78                    name: "aggressive".to_string(),
79                    alpha: 2.0,
80                    beta: 1.0,
81                    entropy_threshold: 0.6,
82                    jaccard_threshold: 0.55,
83                    budget_ratio: 0.2,
84                },
85            ],
86            total_pulls: 0,
87        }
88    }
89}
90
91impl ThresholdBandit {
92    /// Choose an arm for this decision. Deterministic by default — the arm with
93    /// the highest posterior mean (argmax) — so tool behavior is reproducible and
94    /// provider prompt caching stays valid (#498). Probabilistic exploration
95    /// (epsilon-greedy + Thompson sampling) is used only when
96    /// [`Config::is_stochastic_enabled`] is on (`LEAN_CTX_STOCHASTIC` or
97    /// auto-mode-learning). Always counts the pull and registers activity (#4).
98    ///
99    /// [`Config::is_stochastic_enabled`]: crate::core::config::Config::is_stochastic_enabled
100    pub fn choose_arm(&mut self) -> &BanditArm {
101        self.total_pulls += 1;
102        crate::core::introspect::tick("field_weights_bandit");
103        let idx = if crate::core::config::Config::load().is_stochastic_enabled() {
104            self.select_arm_stochastic()
105        } else {
106            self.best_arm_idx_by_mean()
107        };
108        &self.arms[idx]
109    }
110
111    /// Deterministic argmax of the posterior mean. Tie-break by lowest index so
112    /// the choice is stable and reproducible.
113    pub fn best_arm_idx_by_mean(&self) -> usize {
114        self.arms
115            .iter()
116            .enumerate()
117            .max_by(|(_, a), (_, b)| {
118                a.mean()
119                    .partial_cmp(&b.mean())
120                    .unwrap_or(std::cmp::Ordering::Equal)
121            })
122            .map_or(0, |(i, _)| i)
123    }
124
125    /// Probabilistic arm selection (epsilon-greedy + Thompson sampling). Uses
126    /// `getrandom`, so it is NON-deterministic and must only be reached behind the
127    /// stochastic gate. Returns the chosen arm index.
128    fn select_arm_stochastic(&self) -> usize {
129        let epsilon = (0.1 / (1.0 + self.total_pulls as f64 / 100.0)).max(0.02);
130        if rng_f64() < epsilon {
131            return rng_usize(self.arms.len());
132        }
133        let samples: Vec<f64> = self.arms.iter().map(BanditArm::sample).collect();
134        samples
135            .iter()
136            .enumerate()
137            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
138            .map_or(0, |(i, _)| i)
139    }
140
141    pub fn update(&mut self, arm_name: &str, success: bool) {
142        if let Some(arm) = self.arms.iter_mut().find(|a| a.name == arm_name) {
143            if success {
144                arm.update_success();
145            } else {
146                arm.update_failure();
147            }
148        }
149    }
150
151    pub fn decay_all(&mut self, factor: f64) {
152        for arm in &mut self.arms {
153            arm.decay(factor);
154        }
155    }
156
157    pub fn update_from_session(&mut self, outcomes: &[crate::core::feedback::CompressionOutcome]) {
158        for outcome in outcomes {
159            let efficiency = if outcome.tokens_original > 0 {
160                outcome.tokens_saved as f64 / outcome.tokens_original as f64
161            } else {
162                0.0
163            };
164            let success = efficiency > 0.3 && outcome.task_completed;
165
166            let arm_name = if outcome.entropy_threshold >= 1.0 {
167                "conservative"
168            } else if outcome.entropy_threshold >= 0.7 {
169                "balanced"
170            } else {
171                "aggressive"
172            };
173
174            self.update(arm_name, success);
175        }
176
177        if !outcomes.is_empty() {
178            self.decay_all(0.98);
179        }
180    }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, Default)]
184pub struct BanditStore {
185    pub bandits: HashMap<String, ThresholdBandit>,
186}
187
188impl BanditStore {
189    pub fn get_or_create(&mut self, key: &str) -> &mut ThresholdBandit {
190        self.bandits.entry(key.to_string()).or_default()
191    }
192
193    pub fn load(project_root: &str) -> Self {
194        let path = bandit_path(project_root);
195        if path.exists()
196            && let Ok(content) = std::fs::read_to_string(&path)
197            && let Ok(store) = serde_json::from_str::<BanditStore>(&content)
198        {
199            return store;
200        }
201        Self::default()
202    }
203
204    pub fn save(&self, project_root: &str) -> Result<(), String> {
205        let path = bandit_path(project_root);
206        if let Some(parent) = path.parent() {
207            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
208        }
209        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
210        std::fs::write(path, json).map_err(|e| e.to_string())
211    }
212
213    pub fn format_report(&self) -> String {
214        if self.bandits.is_empty() {
215            return "No bandit data yet.".to_string();
216        }
217        let mut lines = vec!["Threshold Bandits (Thompson Sampling):".to_string()];
218        for (key, bandit) in &self.bandits {
219            lines.push(format!("  {key} (pulls: {}):", bandit.total_pulls));
220            for arm in &bandit.arms {
221                let mean = arm.mean();
222                lines.push(format!(
223                    "    {}: α={:.1} β={:.1} mean={:.0}% entropy={:.2} jaccard={:.2} budget={:.0}%",
224                    arm.name,
225                    arm.alpha,
226                    arm.beta,
227                    mean * 100.0,
228                    arm.entropy_threshold,
229                    arm.jaccard_threshold,
230                    arm.budget_ratio * 100.0
231                ));
232            }
233        }
234        lines.join("\n")
235    }
236}
237
238fn bandit_path(project_root: &str) -> std::path::PathBuf {
239    let hash = crate::core::project_hash::hash_project_root(project_root);
240    crate::core::data_dir::lean_ctx_data_dir()
241        .unwrap_or_else(|_| std::path::PathBuf::from("."))
242        .join("projects")
243        .join(hash)
244        .join("bandits.json")
245}
246
247fn rng_f64() -> f64 {
248    let mut bytes = [0u8; 8];
249    getrandom::fill(&mut bytes).unwrap_or(());
250    let val = u64::from_le_bytes(bytes);
251    (val >> 11) as f64 / ((1u64 << 53) as f64)
252}
253
254fn rng_usize(bound: usize) -> usize {
255    if bound == 0 {
256        return 0;
257    }
258    let mut bytes = [0u8; 8];
259    getrandom::fill(&mut bytes).unwrap_or(());
260    let val = u64::from_le_bytes(bytes);
261    (val as usize) % bound
262}
263
264fn beta_sample(alpha: f64, beta: f64) -> f64 {
265    let x = gamma_sample(alpha);
266    let y = gamma_sample(beta);
267    if x + y == 0.0 {
268        return 0.5;
269    }
270    x / (x + y)
271}
272
273#[allow(clippy::many_single_char_names)] // Marsaglia's algorithm uses standard math notation
274fn gamma_sample(shape: f64) -> f64 {
275    if shape < 1.0 {
276        let u = rng_f64().max(1e-10);
277        gamma_sample(shape + 1.0) * u.powf(1.0 / shape)
278    } else {
279        let d = shape - 1.0 / 3.0;
280        let c = 1.0 / (9.0_f64 * d).sqrt();
281        loop {
282            let x = standard_normal();
283            let v = (1.0 + c * x).powi(3);
284            if v <= 0.0 {
285                continue;
286            }
287            let u = rng_f64().max(1e-10);
288            if u < 1.0 - 0.0331 * x.powi(4) || u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
289                return d * v;
290            }
291        }
292    }
293}
294
295fn standard_normal() -> f64 {
296    let u1: f64 = rng_f64().max(1e-10);
297    let u2: f64 = rng_f64();
298    (-2.0_f64 * u1.ln()).sqrt() * (2.0_f64 * std::f64::consts::PI * u2).cos()
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn bandit_default_has_three_arms() {
307        let b = ThresholdBandit::default();
308        assert_eq!(b.arms.len(), 3);
309        assert_eq!(b.arms[0].name, "conservative");
310        assert_eq!(b.arms[1].name, "balanced");
311        assert_eq!(b.arms[2].name, "aggressive");
312    }
313
314    #[test]
315    fn bandit_selection_works() {
316        let mut b = ThresholdBandit::default();
317        for _ in 0..10 {
318            let arm = b.choose_arm();
319            let _ = arm.name.clone();
320        }
321        assert_eq!(b.total_pulls, 10);
322    }
323
324    #[test]
325    fn choose_arm_is_deterministic_by_default() {
326        // #4 / #498: with stochastic exploration off (test default), choose_arm
327        // returns the argmax-of-mean arm every time — reproducible.
328        let mut a = ThresholdBandit::default();
329        let mut b = ThresholdBandit::default();
330        // Train both identically toward "aggressive".
331        for _ in 0..15 {
332            a.update("aggressive", true);
333            b.update("aggressive", true);
334        }
335        let pick_a: Vec<String> = (0..5).map(|_| a.choose_arm().name.clone()).collect();
336        let pick_b: Vec<String> = (0..5).map(|_| b.choose_arm().name.clone()).collect();
337        assert_eq!(pick_a, pick_b, "selection must be reproducible");
338        assert!(
339            pick_a.iter().all(|n| n == "aggressive"),
340            "argmax should pick the trained arm, got {pick_a:?}"
341        );
342    }
343
344    #[test]
345    fn best_arm_by_mean_picks_highest_posterior() {
346        let mut b = ThresholdBandit::default();
347        for _ in 0..10 {
348            b.update("balanced", true);
349        }
350        assert_eq!(b.arms[b.best_arm_idx_by_mean()].name, "balanced");
351    }
352
353    #[test]
354    fn bandit_update_shifts_distribution() {
355        let mut b = ThresholdBandit::default();
356        for _ in 0..20 {
357            b.update("aggressive", true);
358        }
359        for _ in 0..20 {
360            b.update("conservative", false);
361        }
362        let agg = b.arms.iter().find(|a| a.name == "aggressive").unwrap();
363        let con = b.arms.iter().find(|a| a.name == "conservative").unwrap();
364        assert!(agg.mean() > con.mean());
365    }
366
367    #[test]
368    fn beta_sample_in_range() {
369        for _ in 0..100 {
370            let s = beta_sample(2.0, 2.0);
371            assert!((0.0..=1.0).contains(&s), "got {s}");
372        }
373    }
374
375    #[test]
376    fn store_save_load_roundtrip() {
377        let _env = crate::core::data_dir::test_env_lock();
378        let data_dir = tempfile::tempdir().unwrap();
379        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
380
381        let project = tempfile::tempdir().unwrap();
382        let root = project.path().to_string_lossy().to_string();
383        let mut store = BanditStore::default();
384        store.get_or_create("rs_medium");
385        store.save(&root).unwrap();
386        let loaded = BanditStore::load(&root);
387        assert!(loaded.bandits.contains_key("rs_medium"));
388    }
389}