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
188/// Unified key format: `{domain}:{ext}:{bucket}`.
189///
190/// Separates three previously conflated BanditStore consumers:
191/// - `feedback:{ext}` — compression quality per language (was `{ext}_feedback`)
192/// - `threshold:{ext}:{sm|md|lg|xl}` — adaptive threshold tuning (was `{ext}_{bucket}`)
193/// - `mode:{ext}:{sm|md|lg|xl}` — auto mode selection (was `{ext}_{bucket}`, now separate)
194pub fn bandit_key(domain: &str, ext: &str, bucket: Option<&str>) -> String {
195    match bucket {
196        Some(b) => format!("{domain}:{ext}:{b}"),
197        None => format!("{domain}:{ext}"),
198    }
199}
200
201fn split_legacy_sized_key(key: &str) -> Option<(&str, &str)> {
202    for suffix in &["_sm", "_md", "_lg", "_xl"] {
203        if let Some(ext) = key.strip_suffix(suffix) {
204            return Some((ext, &suffix[1..]));
205        }
206    }
207    None
208}
209
210impl BanditStore {
211    pub fn get_or_create(&mut self, key: &str) -> &mut ThresholdBandit {
212        self.bandits.entry(key.to_string()).or_default()
213    }
214
215    pub fn load(project_root: &str) -> Self {
216        let path = bandit_path(project_root);
217        if path.exists()
218            && let Ok(content) = std::fs::read_to_string(&path)
219            && let Ok(mut store) = serde_json::from_str::<BanditStore>(&content)
220        {
221            store.migrate_legacy_keys();
222            return store;
223        }
224        Self::default()
225    }
226
227    pub fn save(&self, project_root: &str) -> Result<(), String> {
228        let path = bandit_path(project_root);
229        if let Some(parent) = path.parent() {
230            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
231        }
232        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
233        std::fs::write(path, json).map_err(|e| e.to_string())
234    }
235
236    /// Converts legacy key formats to the unified `domain:ext:bucket` scheme.
237    /// Reads old keys, writes new keys, removes old keys. Idempotent.
238    fn migrate_legacy_keys(&mut self) {
239        let old_keys: Vec<String> = self
240            .bandits
241            .keys()
242            .filter(|k| !k.contains(':'))
243            .cloned()
244            .collect();
245
246        for key in old_keys {
247            let Some(bandit) = self.bandits.remove(&key) else {
248                continue;
249            };
250
251            if let Some(ext) = key.strip_suffix("_feedback") {
252                let new_key = bandit_key("feedback", ext, None);
253                self.bandits.entry(new_key).or_insert(bandit);
254            } else if let Some((ext, bucket)) = split_legacy_sized_key(&key) {
255                let thresh_key = bandit_key("threshold", ext, Some(bucket));
256                let mode_key = bandit_key("mode", ext, Some(bucket));
257                self.bandits
258                    .entry(thresh_key)
259                    .or_insert_with(|| bandit.clone());
260                self.bandits.entry(mode_key).or_insert(bandit);
261            }
262        }
263    }
264
265    pub fn format_report(&self) -> String {
266        if self.bandits.is_empty() {
267            return "No bandit data yet.".to_string();
268        }
269        let mut lines = vec!["Threshold Bandits (Thompson Sampling):".to_string()];
270        for (key, bandit) in &self.bandits {
271            lines.push(format!("  {key} (pulls: {}):", bandit.total_pulls));
272            for arm in &bandit.arms {
273                let mean = arm.mean();
274                lines.push(format!(
275                    "    {}: α={:.1} β={:.1} mean={:.0}% entropy={:.2} jaccard={:.2} budget={:.0}%",
276                    arm.name,
277                    arm.alpha,
278                    arm.beta,
279                    mean * 100.0,
280                    arm.entropy_threshold,
281                    arm.jaccard_threshold,
282                    arm.budget_ratio * 100.0
283                ));
284            }
285        }
286        lines.join("\n")
287    }
288}
289
290fn bandit_path(project_root: &str) -> std::path::PathBuf {
291    let hash = crate::core::project_hash::hash_project_root(project_root);
292    crate::core::data_dir::lean_ctx_data_dir()
293        .unwrap_or_else(|_| std::path::PathBuf::from("."))
294        .join("projects")
295        .join(hash)
296        .join("bandits.json")
297}
298
299fn rng_f64() -> f64 {
300    let mut bytes = [0u8; 8];
301    getrandom::fill(&mut bytes).unwrap_or(());
302    let val = u64::from_le_bytes(bytes);
303    (val >> 11) as f64 / ((1u64 << 53) as f64)
304}
305
306fn rng_usize(bound: usize) -> usize {
307    if bound == 0 {
308        return 0;
309    }
310    let mut bytes = [0u8; 8];
311    getrandom::fill(&mut bytes).unwrap_or(());
312    let val = u64::from_le_bytes(bytes);
313    (val as usize) % bound
314}
315
316fn beta_sample(alpha: f64, beta: f64) -> f64 {
317    let x = gamma_sample(alpha);
318    let y = gamma_sample(beta);
319    if x + y == 0.0 {
320        return 0.5;
321    }
322    x / (x + y)
323}
324
325#[allow(clippy::many_single_char_names)] // Marsaglia's algorithm uses standard math notation
326fn gamma_sample(shape: f64) -> f64 {
327    if shape < 1.0 {
328        let u = rng_f64().max(1e-10);
329        gamma_sample(shape + 1.0) * u.powf(1.0 / shape)
330    } else {
331        let d = shape - 1.0 / 3.0;
332        let c = 1.0 / (9.0_f64 * d).sqrt();
333        loop {
334            let x = standard_normal();
335            let v = (1.0 + c * x).powi(3);
336            if v <= 0.0 {
337                continue;
338            }
339            let u = rng_f64().max(1e-10);
340            if u < 1.0 - 0.0331 * x.powi(4) || u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
341                return d * v;
342            }
343        }
344    }
345}
346
347fn standard_normal() -> f64 {
348    let u1: f64 = rng_f64().max(1e-10);
349    let u2: f64 = rng_f64();
350    (-2.0_f64 * u1.ln()).sqrt() * (2.0_f64 * std::f64::consts::PI * u2).cos()
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn bandit_default_has_three_arms() {
359        let b = ThresholdBandit::default();
360        assert_eq!(b.arms.len(), 3);
361        assert_eq!(b.arms[0].name, "conservative");
362        assert_eq!(b.arms[1].name, "balanced");
363        assert_eq!(b.arms[2].name, "aggressive");
364    }
365
366    #[test]
367    fn bandit_selection_works() {
368        let mut b = ThresholdBandit::default();
369        for _ in 0..10 {
370            let arm = b.choose_arm();
371            let _ = arm.name.clone();
372        }
373        assert_eq!(b.total_pulls, 10);
374    }
375
376    #[test]
377    fn choose_arm_is_deterministic_by_default() {
378        // #4 / #498: with stochastic exploration off (test default), choose_arm
379        // returns the argmax-of-mean arm every time — reproducible.
380        let mut a = ThresholdBandit::default();
381        let mut b = ThresholdBandit::default();
382        // Train both identically toward "aggressive".
383        for _ in 0..15 {
384            a.update("aggressive", true);
385            b.update("aggressive", true);
386        }
387        let pick_a: Vec<String> = (0..5).map(|_| a.choose_arm().name.clone()).collect();
388        let pick_b: Vec<String> = (0..5).map(|_| b.choose_arm().name.clone()).collect();
389        assert_eq!(pick_a, pick_b, "selection must be reproducible");
390        assert!(
391            pick_a.iter().all(|n| n == "aggressive"),
392            "argmax should pick the trained arm, got {pick_a:?}"
393        );
394    }
395
396    #[test]
397    fn best_arm_by_mean_picks_highest_posterior() {
398        let mut b = ThresholdBandit::default();
399        for _ in 0..10 {
400            b.update("balanced", true);
401        }
402        assert_eq!(b.arms[b.best_arm_idx_by_mean()].name, "balanced");
403    }
404
405    #[test]
406    fn bandit_update_shifts_distribution() {
407        let mut b = ThresholdBandit::default();
408        for _ in 0..20 {
409            b.update("aggressive", true);
410        }
411        for _ in 0..20 {
412            b.update("conservative", false);
413        }
414        let agg = b.arms.iter().find(|a| a.name == "aggressive").unwrap();
415        let con = b.arms.iter().find(|a| a.name == "conservative").unwrap();
416        assert!(agg.mean() > con.mean());
417    }
418
419    #[test]
420    fn beta_sample_in_range() {
421        for _ in 0..100 {
422            let s = beta_sample(2.0, 2.0);
423            assert!((0.0..=1.0).contains(&s), "got {s}");
424        }
425    }
426
427    #[test]
428    fn store_save_load_roundtrip() {
429        let _env = crate::core::data_dir::test_env_lock();
430        let data_dir = tempfile::tempdir().unwrap();
431        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
432
433        let project = tempfile::tempdir().unwrap();
434        let root = project.path().to_string_lossy().to_string();
435        let mut store = BanditStore::default();
436        store.get_or_create(&bandit_key("mode", "rs", Some("md")));
437        store.save(&root).unwrap();
438        let loaded = BanditStore::load(&root);
439        assert!(
440            loaded
441                .bandits
442                .contains_key(&bandit_key("mode", "rs", Some("md")))
443        );
444    }
445
446    #[test]
447    fn migrate_legacy_feedback_key() {
448        let mut store = BanditStore::default();
449        let mut b = ThresholdBandit {
450            total_pulls: 42,
451            ..ThresholdBandit::default()
452        };
453        b.update("aggressive", true);
454        store.bandits.insert("rs_feedback".to_string(), b);
455
456        store.migrate_legacy_keys();
457
458        assert!(
459            !store.bandits.contains_key("rs_feedback"),
460            "old key must be removed"
461        );
462        assert!(
463            store
464                .bandits
465                .contains_key(&bandit_key("feedback", "rs", None)),
466            "new key must exist"
467        );
468        assert_eq!(
469            store.bandits[&bandit_key("feedback", "rs", None)].total_pulls,
470            42
471        );
472    }
473
474    #[test]
475    fn migrate_legacy_sized_key_splits_into_threshold_and_mode() {
476        let mut store = BanditStore::default();
477        let b = ThresholdBandit {
478            total_pulls: 10,
479            ..ThresholdBandit::default()
480        };
481        store.bandits.insert("rs_md".to_string(), b);
482
483        store.migrate_legacy_keys();
484
485        assert!(
486            !store.bandits.contains_key("rs_md"),
487            "old key must be removed"
488        );
489        assert!(
490            store
491                .bandits
492                .contains_key(&bandit_key("threshold", "rs", Some("md"))),
493            "threshold key must exist"
494        );
495        assert!(
496            store
497                .bandits
498                .contains_key(&bandit_key("mode", "rs", Some("md"))),
499            "mode key must exist"
500        );
501    }
502
503    #[test]
504    fn migrate_is_idempotent() {
505        let mut store = BanditStore::default();
506        store.get_or_create(&bandit_key("feedback", "rs", None));
507        store.migrate_legacy_keys();
508        store.migrate_legacy_keys();
509        assert_eq!(store.bandits.len(), 1);
510    }
511}