1use std::collections::HashMap;
2use std::path::Path;
3
4use super::entropy::kolmogorov_proxy;
5
6#[derive(Debug, Clone)]
7pub struct CompressionThresholds {
8 pub bpe_entropy: f64,
9 pub jaccard: f64,
10 pub auto_delta: f64,
11}
12
13impl Default for CompressionThresholds {
14 fn default() -> Self {
15 Self {
16 bpe_entropy: 1.0,
17 jaccard: 0.7,
18 auto_delta: 0.6,
19 }
20 }
21}
22
23static LANGUAGE_THRESHOLDS: &[(&str, CompressionThresholds)] = &[
24 (
26 "py",
27 CompressionThresholds {
28 bpe_entropy: 1.2,
29 jaccard: 0.65,
30 auto_delta: 0.55,
31 },
32 ),
33 (
35 "rs",
36 CompressionThresholds {
37 bpe_entropy: 0.85,
38 jaccard: 0.72,
39 auto_delta: 0.6,
40 },
41 ),
42 (
44 "ts",
45 CompressionThresholds {
46 bpe_entropy: 0.95,
47 jaccard: 0.68,
48 auto_delta: 0.58,
49 },
50 ),
51 (
52 "tsx",
53 CompressionThresholds {
54 bpe_entropy: 0.95,
55 jaccard: 0.68,
56 auto_delta: 0.58,
57 },
58 ),
59 (
60 "js",
61 CompressionThresholds {
62 bpe_entropy: 1.0,
63 jaccard: 0.68,
64 auto_delta: 0.58,
65 },
66 ),
67 (
68 "jsx",
69 CompressionThresholds {
70 bpe_entropy: 1.0,
71 jaccard: 0.68,
72 auto_delta: 0.58,
73 },
74 ),
75 (
77 "go",
78 CompressionThresholds {
79 bpe_entropy: 0.9,
80 jaccard: 0.72,
81 auto_delta: 0.55,
82 },
83 ),
84 (
86 "java",
87 CompressionThresholds {
88 bpe_entropy: 0.8,
89 jaccard: 0.65,
90 auto_delta: 0.5,
91 },
92 ),
93 (
94 "kt",
95 CompressionThresholds {
96 bpe_entropy: 0.85,
97 jaccard: 0.68,
98 auto_delta: 0.55,
99 },
100 ),
101 (
103 "c",
104 CompressionThresholds {
105 bpe_entropy: 0.9,
106 jaccard: 0.7,
107 auto_delta: 0.6,
108 },
109 ),
110 (
111 "h",
112 CompressionThresholds {
113 bpe_entropy: 0.75,
114 jaccard: 0.65,
115 auto_delta: 0.5,
116 },
117 ),
118 (
119 "cpp",
120 CompressionThresholds {
121 bpe_entropy: 0.9,
122 jaccard: 0.7,
123 auto_delta: 0.6,
124 },
125 ),
126 (
127 "hpp",
128 CompressionThresholds {
129 bpe_entropy: 0.75,
130 jaccard: 0.65,
131 auto_delta: 0.5,
132 },
133 ),
134 (
136 "rb",
137 CompressionThresholds {
138 bpe_entropy: 1.15,
139 jaccard: 0.65,
140 auto_delta: 0.55,
141 },
142 ),
143 (
145 "json",
146 CompressionThresholds {
147 bpe_entropy: 0.6,
148 jaccard: 0.6,
149 auto_delta: 0.4,
150 },
151 ),
152 (
153 "yaml",
154 CompressionThresholds {
155 bpe_entropy: 0.7,
156 jaccard: 0.62,
157 auto_delta: 0.45,
158 },
159 ),
160 (
161 "yml",
162 CompressionThresholds {
163 bpe_entropy: 0.7,
164 jaccard: 0.62,
165 auto_delta: 0.45,
166 },
167 ),
168 (
169 "toml",
170 CompressionThresholds {
171 bpe_entropy: 0.7,
172 jaccard: 0.62,
173 auto_delta: 0.45,
174 },
175 ),
176 (
177 "xml",
178 CompressionThresholds {
179 bpe_entropy: 0.6,
180 jaccard: 0.6,
181 auto_delta: 0.4,
182 },
183 ),
184 (
186 "md",
187 CompressionThresholds {
188 bpe_entropy: 1.3,
189 jaccard: 0.6,
190 auto_delta: 0.55,
191 },
192 ),
193 (
195 "css",
196 CompressionThresholds {
197 bpe_entropy: 0.7,
198 jaccard: 0.6,
199 auto_delta: 0.45,
200 },
201 ),
202 (
203 "scss",
204 CompressionThresholds {
205 bpe_entropy: 0.75,
206 jaccard: 0.62,
207 auto_delta: 0.48,
208 },
209 ),
210 (
212 "sql",
213 CompressionThresholds {
214 bpe_entropy: 0.8,
215 jaccard: 0.65,
216 auto_delta: 0.5,
217 },
218 ),
219 (
221 "sh",
222 CompressionThresholds {
223 bpe_entropy: 1.0,
224 jaccard: 0.68,
225 auto_delta: 0.55,
226 },
227 ),
228 (
229 "bash",
230 CompressionThresholds {
231 bpe_entropy: 1.0,
232 jaccard: 0.68,
233 auto_delta: 0.55,
234 },
235 ),
236 (
238 "swift",
239 CompressionThresholds {
240 bpe_entropy: 0.9,
241 jaccard: 0.68,
242 auto_delta: 0.55,
243 },
244 ),
245 (
246 "cs",
247 CompressionThresholds {
248 bpe_entropy: 0.85,
249 jaccard: 0.65,
250 auto_delta: 0.52,
251 },
252 ),
253 (
255 "php",
256 CompressionThresholds {
257 bpe_entropy: 0.95,
258 jaccard: 0.68,
259 auto_delta: 0.55,
260 },
261 ),
262];
263
264fn language_map() -> HashMap<&'static str, &'static CompressionThresholds> {
265 LANGUAGE_THRESHOLDS
266 .iter()
267 .map(|(ext, t)| (*ext, t))
268 .collect()
269}
270
271pub fn thresholds_for_path(path: &str) -> CompressionThresholds {
272 let ext = Path::new(path)
273 .extension()
274 .and_then(|e| e.to_str())
275 .unwrap_or("");
276
277 let map = language_map();
278 if let Some(t) = map.get(ext) {
279 return (*t).clone();
280 }
281
282 CompressionThresholds::default()
283}
284
285pub fn adaptive_thresholds(path: &str, content: &str) -> CompressionThresholds {
286 let mut base = thresholds_for_path(path);
287
288 let ext = std::path::Path::new(path)
289 .extension()
290 .and_then(|e| e.to_str())
291 .unwrap_or("");
292 let feedback = super::feedback::FeedbackStore::load();
293 if let Some(learned_entropy) = feedback.get_learned_entropy(ext) {
294 base.bpe_entropy = base.bpe_entropy * 0.6 + learned_entropy * 0.4;
295 }
296 if let Some(learned_jaccard) = feedback.get_learned_jaccard(ext) {
297 base.jaccard = base.jaccard * 0.6 + learned_jaccard * 0.4;
298 }
299
300 base.bpe_entropy =
304 (base.bpe_entropy + super::threshold_learning::learned_delta(ext)).clamp(0.4, 2.0);
305
306 if content.len() > 500 {
307 let k = kolmogorov_proxy(content);
308 let k_adjustment = (k - 0.45) * 0.5;
309 base.bpe_entropy = (base.bpe_entropy + k_adjustment).clamp(0.4, 2.0);
310 base.jaccard = (base.jaccard - k_adjustment * 0.3).clamp(0.5, 0.85);
311 }
312
313 if let Some(project_root) =
314 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
315 {
316 let bandit_key = format!("{ext}_{}", token_bucket_label(content));
317 let mut store = super::bandit::BanditStore::load(&project_root);
318 let bandit = store.get_or_create(&bandit_key);
319 let arm = bandit.choose_arm();
323 base.bpe_entropy = base.bpe_entropy * 0.5 + arm.entropy_threshold * 0.5;
324 base.jaccard = base.jaccard * 0.5 + arm.jaccard_threshold * 0.5;
325 let arm_name = arm.name.clone();
326 super::context_field::set_active_weights(super::context_field::FieldWeights::from_arm(arm));
327 record_selected_arm(path, project_root, bandit_key, arm_name);
328 }
329
330 base
331}
332
333#[derive(Clone)]
337struct SelectedArm {
338 project_root: String,
339 bandit_key: String,
340 arm_name: String,
341}
342
343const ARM_REGISTRY_CAP: usize = 64;
346
347static SELECTED_ARMS: std::sync::Mutex<Option<SelectedArmRegistry>> = std::sync::Mutex::new(None);
348
349#[derive(Default)]
350struct SelectedArmRegistry {
351 order: std::collections::VecDeque<String>,
353 by_path: std::collections::HashMap<String, SelectedArm>,
354}
355
356fn record_selected_arm(path: &str, project_root: String, bandit_key: String, arm_name: String) {
357 let norm = crate::core::pathutil::normalize_tool_path(path);
358 let mut guard = SELECTED_ARMS
359 .lock()
360 .unwrap_or_else(std::sync::PoisonError::into_inner);
361 let reg = guard.get_or_insert_with(SelectedArmRegistry::default);
362 let arm = SelectedArm {
363 project_root,
364 bandit_key,
365 arm_name,
366 };
367 if reg.by_path.insert(norm.clone(), arm).is_none() {
368 reg.order.push_back(norm);
369 while reg.order.len() > ARM_REGISTRY_CAP {
370 if let Some(old) = reg.order.pop_front() {
371 reg.by_path.remove(&old);
372 }
373 }
374 }
375}
376
377pub fn record_quality_signal(path: &str, signal: crate::core::threshold_learning::QualitySignal) {
385 use crate::core::threshold_learning::QualitySignal;
386 crate::core::threshold_learning::record_signal(path, signal);
387 match signal {
388 QualitySignal::Bounce | QualitySignal::EditFail => {
389 report_bandit_outcome_for_path(path, false);
390 }
391 QualitySignal::CleanCompressed | QualitySignal::WastedFull => {}
392 }
393}
394
395pub fn report_bandit_outcome_for_path(path: &str, success: bool) {
399 let norm = crate::core::pathutil::normalize_tool_path(path);
400 let selected = {
401 let guard = SELECTED_ARMS
402 .lock()
403 .unwrap_or_else(std::sync::PoisonError::into_inner);
404 guard.as_ref().and_then(|r| r.by_path.get(&norm).cloned())
405 };
406 if let Some(sel) = selected {
407 let mut store = super::bandit::BanditStore::load(&sel.project_root);
408 store
409 .get_or_create(&sel.bandit_key)
410 .update(&sel.arm_name, success);
411 let _ = store.save(&sel.project_root);
412 }
413}
414
415fn token_bucket_label(content: &str) -> &'static str {
416 let len = content.len();
417 match len {
418 0..=2000 => "sm",
419 2001..=10000 => "md",
420 10001..=50000 => "lg",
421 _ => "xl",
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn rust_has_lower_threshold_than_python() {
431 let rs = thresholds_for_path("src/main.rs");
432 let py = thresholds_for_path("src/main.py");
433 assert!(rs.bpe_entropy < py.bpe_entropy);
434 }
435
436 #[test]
437 fn json_has_lowest_threshold() {
438 let json = thresholds_for_path("config.json");
439 let rs = thresholds_for_path("main.rs");
440 assert!(json.bpe_entropy < rs.bpe_entropy);
441 }
442
443 #[test]
444 fn unknown_ext_uses_default() {
445 let t = thresholds_for_path("file.xyz");
446 assert!((t.bpe_entropy - 1.0).abs() < f64::EPSILON);
447 }
448
449 #[test]
450 fn adaptive_adjusts_for_compressibility() {
451 let repetitive = "use std::io;\n".repeat(200);
452 let diverse = (0..200).fold(String::new(), |mut s, i| {
453 use std::fmt::Write;
454 let _ = writeln!(s, "let var_{i} = compute_{i}(arg_{i});");
455 s
456 });
457
458 let base_rep = thresholds_for_path("main.rs");
459 let base_div = thresholds_for_path("main.rs");
460 assert!(
461 (base_rep.bpe_entropy - base_div.bpe_entropy).abs() < f64::EPSILON,
462 "same path should get same base thresholds"
463 );
464
465 let k_rep = kolmogorov_proxy(&repetitive);
466 let k_div = kolmogorov_proxy(&diverse);
467 assert!(
468 k_rep < k_div,
469 "repetitive content should have lower Kolmogorov proxy: {k_rep} vs {k_div}"
470 );
471 }
472
473 use crate::core::threshold_learning::QualitySignal;
474
475 fn arm_mean(project_root: &str, key: &str, arm: &str) -> f64 {
476 let mut store = crate::core::bandit::BanditStore::load(project_root);
477 store
478 .get_or_create(key)
479 .arms
480 .iter()
481 .find(|a| a.name == arm)
482 .map_or(0.5, crate::core::bandit::BanditArm::mean)
483 }
484
485 #[test]
486 fn real_failure_signal_penalizes_selected_arm() {
487 let _data = crate::core::data_dir::isolated_data_dir();
488 let root = "/fix1/penalize";
489 record_selected_arm(
490 "src/foo.rs",
491 root.into(),
492 "rs_md".into(),
493 "aggressive".into(),
494 );
495
496 let before = arm_mean(root, "rs_md", "aggressive");
497 for _ in 0..15 {
498 record_quality_signal("src/foo.rs", QualitySignal::Bounce);
499 }
500 record_quality_signal("src/foo.rs", QualitySignal::EditFail);
501 let after = arm_mean(root, "rs_md", "aggressive");
502
503 assert!(
504 after < before,
505 "bounce/edit-fail must lower the selected arm mean: {before} -> {after}"
506 );
507 }
508
509 #[test]
510 fn clean_and_wasted_signals_leave_bandit_untouched() {
511 let _data = crate::core::data_dir::isolated_data_dir();
512 let root = "/fix1/untouched";
513 record_selected_arm("a.rs", root.into(), "rs_sm".into(), "balanced".into());
514
515 let before = arm_mean(root, "rs_sm", "balanced");
516 record_quality_signal("a.rs", QualitySignal::CleanCompressed);
517 record_quality_signal("a.rs", QualitySignal::WastedFull);
518 let after = arm_mean(root, "rs_sm", "balanced");
519
520 assert!(
521 (before - after).abs() < f64::EPSILON,
522 "clean/wasted are learner-only: bandit mean must not move ({before} -> {after})"
523 );
524 }
525
526 #[test]
527 fn outcome_without_registered_arm_does_not_register_path() {
528 let _data = crate::core::data_dir::isolated_data_dir();
529 report_bandit_outcome_for_path("fix1/never-seen-xyz.rs", false);
531 let norm = crate::core::pathutil::normalize_tool_path("fix1/never-seen-xyz.rs");
532 let guard = SELECTED_ARMS
533 .lock()
534 .unwrap_or_else(std::sync::PoisonError::into_inner);
535 let registered = guard
536 .as_ref()
537 .is_some_and(|r| r.by_path.contains_key(&norm));
538 assert!(!registered, "no-op report must not create a registry entry");
539 }
540
541 #[test]
542 fn registry_evicts_oldest_beyond_cap() {
543 let _data = crate::core::data_dir::isolated_data_dir();
544 for i in 0..(ARM_REGISTRY_CAP + 5) {
545 record_selected_arm(
546 &format!("evict/f{i}.rs"),
547 "/fix1/evict".into(),
548 "rs_md".into(),
549 "balanced".into(),
550 );
551 }
552 let guard = SELECTED_ARMS
553 .lock()
554 .unwrap_or_else(std::sync::PoisonError::into_inner);
555 let reg = guard.as_ref().expect("registry initialized");
556 assert!(reg.order.len() <= ARM_REGISTRY_CAP);
557 assert_eq!(reg.order.len(), reg.by_path.len());
558 }
559}