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 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 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 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)] fn 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 let mut a = ThresholdBandit::default();
329 let mut b = ThresholdBandit::default();
330 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}