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 =
317            crate::core::bandit::bandit_key("threshold", ext, Some(token_bucket_label(content)));
318        let mut store = super::bandit::BanditStore::load(&project_root);
319        let bandit = store.get_or_create(&bandit_key);
320        // #4: deterministic argmax-of-mean by default (Thompson only under the
321        // stochastic flag). The chosen arm drives both the entropy/jaccard
322        // thresholds AND the learned FieldWeights that feed Phi.
323        let arm = bandit.choose_arm();
324        base.bpe_entropy = base.bpe_entropy * 0.5 + arm.entropy_threshold * 0.5;
325        base.jaccard = base.jaccard * 0.5 + arm.jaccard_threshold * 0.5;
326        let arm_name = arm.name.clone();
327        super::context_field::set_active_weights(super::context_field::FieldWeights::from_arm(arm));
328        record_selected_arm(path, project_root, bandit_key, arm_name);
329    }
330
331    base
332}
333
334/// The bandit arm selected for a file's most recent threshold-driven read, kept
335/// so a *deferred* real outcome (bounce, edit-fail) can penalize the arm that
336/// actually produced the compression — instead of a hardcoded success (#593).
337#[derive(Clone)]
338struct SelectedArm {
339    project_root: String,
340    bandit_key: String,
341    arm_name: String,
342}
343
344/// Bounded far above the bounce (5) and edit-force (10) detection windows, so
345/// evicting the oldest entry can never drop an arm a still-pending signal needs.
346const ARM_REGISTRY_CAP: usize = 64;
347
348static SELECTED_ARMS: std::sync::Mutex<Option<SelectedArmRegistry>> = std::sync::Mutex::new(None);
349
350#[derive(Default)]
351struct SelectedArmRegistry {
352    /// Insertion order of distinct paths, for O(1) oldest-first eviction.
353    order: std::collections::VecDeque<String>,
354    by_path: std::collections::HashMap<String, SelectedArm>,
355}
356
357fn record_selected_arm(path: &str, project_root: String, bandit_key: String, arm_name: String) {
358    let norm = crate::core::pathutil::normalize_tool_path(path);
359    let mut guard = SELECTED_ARMS
360        .lock()
361        .unwrap_or_else(std::sync::PoisonError::into_inner);
362    let reg = guard.get_or_insert_with(SelectedArmRegistry::default);
363    let arm = SelectedArm {
364        project_root,
365        bandit_key,
366        arm_name,
367    };
368    if reg.by_path.insert(norm.clone(), arm).is_none() {
369        reg.order.push_back(norm);
370        while reg.order.len() > ARM_REGISTRY_CAP {
371            if let Some(old) = reg.order.pop_front() {
372                reg.by_path.remove(&old);
373            }
374        }
375    }
376}
377
378/// Record a real downstream quality signal (#538/#593). It always feeds the
379/// online threshold learner; `Bounce`/`EditFail` additionally penalize the
380/// bandit arm that produced this file's compression, replacing the old
381/// savings-only / hardcoded reward. `CleanCompressed`/`WastedFull` stay
382/// learner-only — the positive bandit signal comes from realized savings
383/// (`report_bandit_outcome_for_path` in entropy mode), so we avoid a bandit
384/// disk write on every compressed read.
385pub fn record_quality_signal(path: &str, signal: crate::core::threshold_learning::QualitySignal) {
386    use crate::core::threshold_learning::QualitySignal;
387    crate::core::threshold_learning::record_signal(path, signal);
388    match signal {
389        QualitySignal::Bounce | QualitySignal::EditFail => {
390            report_bandit_outcome_for_path(path, false);
391        }
392        QualitySignal::CleanCompressed | QualitySignal::WastedFull => {}
393    }
394}
395
396/// Reward (`success=true`) or penalize (`false`) the bandit arm recorded for
397/// `path`. No-op when no arm was registered (e.g. the file was never read in a
398/// threshold-driven mode), so attribution is never guessed.
399pub fn report_bandit_outcome_for_path(path: &str, success: bool) {
400    let norm = crate::core::pathutil::normalize_tool_path(path);
401    let selected = {
402        let guard = SELECTED_ARMS
403            .lock()
404            .unwrap_or_else(std::sync::PoisonError::into_inner);
405        guard.as_ref().and_then(|r| r.by_path.get(&norm).cloned())
406    };
407    if let Some(sel) = selected {
408        let mut store = super::bandit::BanditStore::load(&sel.project_root);
409        store
410            .get_or_create(&sel.bandit_key)
411            .update(&sel.arm_name, success);
412        let _ = store.save(&sel.project_root);
413    }
414}
415
416fn token_bucket_label(content: &str) -> &'static str {
417    let len = content.len();
418    match len {
419        0..=2000 => "sm",
420        2001..=10000 => "md",
421        10001..=50000 => "lg",
422        _ => "xl",
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn rust_has_lower_threshold_than_python() {
432        let rs = thresholds_for_path("src/main.rs");
433        let py = thresholds_for_path("src/main.py");
434        assert!(rs.bpe_entropy < py.bpe_entropy);
435    }
436
437    #[test]
438    fn json_has_lowest_threshold() {
439        let json = thresholds_for_path("config.json");
440        let rs = thresholds_for_path("main.rs");
441        assert!(json.bpe_entropy < rs.bpe_entropy);
442    }
443
444    #[test]
445    fn unknown_ext_uses_default() {
446        let t = thresholds_for_path("file.xyz");
447        assert!((t.bpe_entropy - 1.0).abs() < f64::EPSILON);
448    }
449
450    #[test]
451    fn adaptive_adjusts_for_compressibility() {
452        let repetitive = "use std::io;\n".repeat(200);
453        let diverse = (0..200).fold(String::new(), |mut s, i| {
454            use std::fmt::Write;
455            let _ = writeln!(s, "let var_{i} = compute_{i}(arg_{i});");
456            s
457        });
458
459        let base_rep = thresholds_for_path("main.rs");
460        let base_div = thresholds_for_path("main.rs");
461        assert!(
462            (base_rep.bpe_entropy - base_div.bpe_entropy).abs() < f64::EPSILON,
463            "same path should get same base thresholds"
464        );
465
466        let k_rep = kolmogorov_proxy(&repetitive);
467        let k_div = kolmogorov_proxy(&diverse);
468        assert!(
469            k_rep < k_div,
470            "repetitive content should have lower Kolmogorov proxy: {k_rep} vs {k_div}"
471        );
472    }
473
474    use crate::core::threshold_learning::QualitySignal;
475
476    fn arm_mean(project_root: &str, key: &str, arm: &str) -> f64 {
477        let mut store = crate::core::bandit::BanditStore::load(project_root);
478        store
479            .get_or_create(key)
480            .arms
481            .iter()
482            .find(|a| a.name == arm)
483            .map_or(0.5, crate::core::bandit::BanditArm::mean)
484    }
485
486    #[test]
487    fn real_failure_signal_penalizes_selected_arm() {
488        let _data = crate::core::data_dir::isolated_data_dir();
489        let root = "/fix1/penalize";
490        record_selected_arm(
491            "src/foo.rs",
492            root.into(),
493            "threshold:rs:md".into(),
494            "aggressive".into(),
495        );
496
497        let before = arm_mean(root, "threshold:rs:md", "aggressive");
498        for _ in 0..15 {
499            record_quality_signal("src/foo.rs", QualitySignal::Bounce);
500        }
501        record_quality_signal("src/foo.rs", QualitySignal::EditFail);
502        let after = arm_mean(root, "threshold:rs:md", "aggressive");
503
504        assert!(
505            after < before,
506            "bounce/edit-fail must lower the selected arm mean: {before} -> {after}"
507        );
508    }
509
510    #[test]
511    fn clean_and_wasted_signals_leave_bandit_untouched() {
512        let _data = crate::core::data_dir::isolated_data_dir();
513        let root = "/fix1/untouched";
514        record_selected_arm(
515            "a.rs",
516            root.into(),
517            "threshold:rs:sm".into(),
518            "balanced".into(),
519        );
520
521        let before = arm_mean(root, "threshold:rs:sm", "balanced");
522        record_quality_signal("a.rs", QualitySignal::CleanCompressed);
523        record_quality_signal("a.rs", QualitySignal::WastedFull);
524        let after = arm_mean(root, "threshold:rs:sm", "balanced");
525
526        assert!(
527            (before - after).abs() < f64::EPSILON,
528            "clean/wasted are learner-only: bandit mean must not move ({before} -> {after})"
529        );
530    }
531
532    #[test]
533    fn outcome_without_registered_arm_does_not_register_path() {
534        let _data = crate::core::data_dir::isolated_data_dir();
535        // Attribution must never be guessed for a path we never selected an arm for.
536        report_bandit_outcome_for_path("fix1/never-seen-xyz.rs", false);
537        let norm = crate::core::pathutil::normalize_tool_path("fix1/never-seen-xyz.rs");
538        let guard = SELECTED_ARMS
539            .lock()
540            .unwrap_or_else(std::sync::PoisonError::into_inner);
541        let registered = guard
542            .as_ref()
543            .is_some_and(|r| r.by_path.contains_key(&norm));
544        assert!(!registered, "no-op report must not create a registry entry");
545    }
546
547    #[test]
548    fn registry_evicts_oldest_beyond_cap() {
549        let _data = crate::core::data_dir::isolated_data_dir();
550        for i in 0..(ARM_REGISTRY_CAP + 5) {
551            record_selected_arm(
552                &format!("evict/f{i}.rs"),
553                "/fix1/evict".into(),
554                "threshold:rs:md".into(),
555                "balanced".into(),
556            );
557        }
558        let guard = SELECTED_ARMS
559            .lock()
560            .unwrap_or_else(std::sync::PoisonError::into_inner);
561        let reg = guard.as_ref().expect("registry initialized");
562        assert!(reg.order.len() <= ARM_REGISTRY_CAP);
563        assert_eq!(reg.order.len(), reg.by_path.len());
564    }
565}