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
188pub 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 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)] fn 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 let mut a = ThresholdBandit::default();
381 let mut b = ThresholdBandit::default();
382 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}