Skip to main content

lean_ctx/core/
adaptive_thresholds.rs

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    // Python: English-like syntax, significant whitespace → higher entropy baseline
25    (
26        "py",
27        CompressionThresholds {
28            bpe_entropy: 1.2,
29            jaccard: 0.65,
30            auto_delta: 0.55,
31        },
32    ),
33    // Rust: Repetitive keywords (fn, pub, impl, let, mut) → lower threshold catches more
34    (
35        "rs",
36        CompressionThresholds {
37            bpe_entropy: 0.85,
38            jaccard: 0.72,
39            auto_delta: 0.6,
40        },
41    ),
42    // TypeScript/JavaScript: Type annotations are predictable
43    (
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    // Go: Verbose but highly structured → aggressive threshold
76    (
77        "go",
78        CompressionThresholds {
79            bpe_entropy: 0.9,
80            jaccard: 0.72,
81            auto_delta: 0.55,
82        },
83    ),
84    // Java/Kotlin: Very verbose, lots of boilerplate
85    (
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    // C/C++: Headers are highly repetitive
102    (
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    // Ruby: English-like, high entropy
135    (
136        "rb",
137        CompressionThresholds {
138            bpe_entropy: 1.15,
139            jaccard: 0.65,
140            auto_delta: 0.55,
141        },
142    ),
143    // Config/data files: highly repetitive
144    (
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    // Markdown/docs: natural language, high entropy
185    (
186        "md",
187        CompressionThresholds {
188            bpe_entropy: 1.3,
189            jaccard: 0.6,
190            auto_delta: 0.55,
191        },
192    ),
193    // CSS: very repetitive selectors/properties
194    (
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    // SQL: repetitive keywords
211    (
212        "sql",
213        CompressionThresholds {
214            bpe_entropy: 0.8,
215            jaccard: 0.65,
216            auto_delta: 0.5,
217        },
218    ),
219    // Shell scripts
220    (
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    // Swift/C#
237    (
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    // PHP
254    (
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    // Quality-signal loop (#538): bounce/edit-fail history shifts the threshold
301    // additively, clamped to ±0.15 inside threshold_learning. Applied before the
302    // Kolmogorov adjustment so the final clamp below still bounds everything.
303    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        // #4: deterministic argmax-of-mean by default (Thompson only under the
320        // stochastic flag). The chosen arm drives both the entropy/jaccard
321        // thresholds AND the learned FieldWeights that feed Phi.
322        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/// The bandit arm selected for a file's most recent threshold-driven read, kept
334/// so a *deferred* real outcome (bounce, edit-fail) can penalize the arm that
335/// actually produced the compression — instead of a hardcoded success (#593).
336#[derive(Clone)]
337struct SelectedArm {
338    project_root: String,
339    bandit_key: String,
340    arm_name: String,
341}
342
343/// Bounded far above the bounce (5) and edit-force (10) detection windows, so
344/// evicting the oldest entry can never drop an arm a still-pending signal needs.
345const 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    /// Insertion order of distinct paths, for O(1) oldest-first eviction.
352    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
377/// Record a real downstream quality signal (#538/#593). It always feeds the
378/// online threshold learner; `Bounce`/`EditFail` additionally penalize the
379/// bandit arm that produced this file's compression, replacing the old
380/// savings-only / hardcoded reward. `CleanCompressed`/`WastedFull` stay
381/// learner-only — the positive bandit signal comes from realized savings
382/// (`report_bandit_outcome_for_path` in entropy mode), so we avoid a bandit
383/// disk write on every compressed read.
384pub 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
395/// Reward (`success=true`) or penalize (`false`) the bandit arm recorded for
396/// `path`. No-op when no arm was registered (e.g. the file was never read in a
397/// threshold-driven mode), so attribution is never guessed.
398pub 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        // Attribution must never be guessed for a path we never selected an arm for.
530        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}