Skip to main content

lean_ctx/core/
threshold_learning.rs

1//! Online-learned per-extension compression-threshold deltas (#538, EFF-1).
2//!
3//! Closes the quality feedback loop that the static `LANGUAGE_THRESHOLDS`
4//! table and the savings-only bandit cannot: bounces (compressed read followed
5//! by a full re-read) and edit failures after compressed reads push the
6//! entropy threshold DOWN (compress less), while clean compressed reads and
7//! wasted full reads push it UP (compress more). The learned delta is additive
8//! on top of the static base table, hard-clamped so the base stays the safety
9//! anchor, and decays toward zero daily so stale lessons fade.
10//!
11//! Neuroscience analogue: dopaminergic active forgetting — eviction policy is
12//! learned from outcome signals, not hardcoded (SleepGate 2603.14517).
13
14use std::collections::HashMap;
15use std::sync::Mutex;
16use std::time::Instant;
17
18use serde::{Deserialize, Serialize};
19
20/// Learning rate per signal; signal weights below multiply this.
21const LR: f64 = 0.02;
22/// Learned delta never exceeds ±CLAMP — static table stays the anchor.
23const CLAMP: f64 = 0.15;
24/// Deltas only apply once an extension has this many observations.
25const MIN_SAMPLES: u32 = 10;
26/// Daily multiplicative decay toward 0 (drift back to the base table).
27const DAILY_DECAY: f64 = 0.98;
28/// Flush to disk at most this often.
29const FLUSH_SECS: u64 = 60;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum QualitySignal {
33    /// Compressed read was followed by a full re-read within the bounce window.
34    Bounce,
35    /// An edit failed after the file was last read in a compressed mode.
36    EditFail,
37    /// A compressed read that (so far) was not bounced.
38    CleanCompressed,
39    /// A large full read of an extension that never bounces — compression
40    /// would almost certainly have been safe.
41    WastedFull,
42}
43
44impl QualitySignal {
45    /// Signed weight: negative lowers the entropy threshold (compress less),
46    /// positive raises it (compress more aggressively).
47    fn weight(self) -> f64 {
48        match self {
49            QualitySignal::Bounce => -3.0,
50            QualitySignal::EditFail => -6.0,
51            QualitySignal::CleanCompressed => 0.5,
52            QualitySignal::WastedFull => 2.0,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct LearnedDelta {
59    pub delta_entropy: f64,
60    pub samples: u32,
61    /// Unix epoch day of the last decay application.
62    pub last_decay_day: u64,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66pub struct ThresholdLearner {
67    /// Keyed by lowercase extension without dot (e.g. "rs").
68    pub per_ext: HashMap<String, LearnedDelta>,
69    pub schema_version: u32,
70}
71
72static BUFFER: Mutex<Option<(ThresholdLearner, Instant)>> = Mutex::new(None);
73
74fn store_path() -> std::path::PathBuf {
75    crate::core::data_dir::lean_ctx_data_dir()
76        .unwrap_or_else(|_| std::path::PathBuf::from("."))
77        .join("thresholds_learned.json")
78}
79
80fn epoch_day(now_secs: u64) -> u64 {
81    now_secs / 86_400
82}
83
84fn now_secs() -> u64 {
85    std::time::SystemTime::now()
86        .duration_since(std::time::UNIX_EPOCH)
87        .map_or(0, |d| d.as_secs())
88}
89
90impl ThresholdLearner {
91    fn load_from_disk() -> Self {
92        let path = store_path();
93        if let Ok(content) = std::fs::read_to_string(&path) {
94            if let Ok(learner) = serde_json::from_str::<ThresholdLearner>(&content) {
95                return learner;
96            }
97        }
98        ThresholdLearner {
99            schema_version: 1,
100            ..Default::default()
101        }
102    }
103
104    fn save_to_disk(&self) {
105        let path = store_path();
106        if let Some(parent) = path.parent() {
107            let _ = std::fs::create_dir_all(parent);
108        }
109        if let Ok(json) = serde_json::to_string_pretty(self) {
110            let _ = std::fs::write(path, json);
111        }
112    }
113
114    /// Team-merge (#550): sample-weighted average of deltas, never a blind
115    /// overwrite. `samples = max(...)` (not the sum) and weighted-mean deltas
116    /// make re-importing the same bundle a no-op (idempotent roundtrip) and
117    /// prevent double counting. Clamps stay authoritative.
118    pub fn merge_from(&mut self, other: &Self) {
119        for (ext, theirs) in &other.per_ext {
120            match self.per_ext.get_mut(ext) {
121                None => {
122                    let mut d = theirs.clone();
123                    d.delta_entropy = d.delta_entropy.clamp(-CLAMP, CLAMP);
124                    self.per_ext.insert(ext.clone(), d);
125                }
126                Some(ours) => {
127                    let total = u64::from(ours.samples) + u64::from(theirs.samples);
128                    if theirs.samples == 0 || total == 0 {
129                        continue;
130                    }
131                    let weighted = (ours.delta_entropy * f64::from(ours.samples)
132                        + theirs.delta_entropy * f64::from(theirs.samples))
133                        / total as f64;
134                    ours.delta_entropy = weighted.clamp(-CLAMP, CLAMP);
135                    ours.samples = ours.samples.max(theirs.samples);
136                    ours.last_decay_day = ours.last_decay_day.max(theirs.last_decay_day);
137                }
138            }
139        }
140    }
141
142    /// Apply one quality signal for `ext` at `now` (unix seconds).
143    pub fn record(&mut self, ext: &str, signal: QualitySignal, now: u64) {
144        let ext = normalize_ext(ext);
145        if ext.is_empty() {
146            return;
147        }
148        let day = epoch_day(now);
149        let entry = self.per_ext.entry(ext).or_default();
150        Self::apply_decay(entry, day);
151        entry.delta_entropy = (entry.delta_entropy + LR * signal.weight()).clamp(-CLAMP, CLAMP);
152        entry.samples = entry.samples.saturating_add(1);
153    }
154
155    /// Additive entropy-threshold delta for `ext`, or 0.0 before MIN_SAMPLES.
156    pub fn delta_for(&mut self, ext: &str, now: u64) -> f64 {
157        let ext = normalize_ext(ext);
158        let day = epoch_day(now);
159        match self.per_ext.get_mut(&ext) {
160            Some(entry) => {
161                Self::apply_decay(entry, day);
162                if entry.samples >= MIN_SAMPLES {
163                    entry.delta_entropy
164                } else {
165                    0.0
166                }
167            }
168            None => 0.0,
169        }
170    }
171
172    fn apply_decay(entry: &mut LearnedDelta, today: u64) {
173        if entry.last_decay_day == 0 {
174            entry.last_decay_day = today;
175            return;
176        }
177        let days = today.saturating_sub(entry.last_decay_day);
178        if days > 0 {
179            // Cap the exponent: after ~1 year of inactivity the delta is ~0 anyway.
180            let factor = DAILY_DECAY.powi(days.min(365) as i32);
181            entry.delta_entropy *= factor;
182            entry.last_decay_day = today;
183        }
184    }
185
186    /// One line per learned extension, for ctx_metrics.
187    pub fn report_lines(&self) -> Vec<String> {
188        let mut exts: Vec<_> = self.per_ext.iter().collect();
189        exts.sort_by(|a, b| a.0.cmp(b.0));
190        exts.iter()
191            .map(|(ext, d)| {
192                let active = if d.samples >= MIN_SAMPLES {
193                    "active"
194                } else {
195                    "warmup"
196                };
197                format!(
198                    "  .{ext}: delta={:+.3} (n={}, {active})",
199                    d.delta_entropy, d.samples
200                )
201            })
202            .collect()
203    }
204}
205
206fn normalize_ext(ext: &str) -> String {
207    ext.trim_start_matches('.').to_ascii_lowercase()
208}
209
210fn with_buffer<R>(f: impl FnOnce(&mut ThresholdLearner) -> R) -> R {
211    let mut guard = BUFFER
212        .lock()
213        .unwrap_or_else(std::sync::PoisonError::into_inner);
214    if guard.is_none() {
215        *guard = Some((ThresholdLearner::load_from_disk(), Instant::now()));
216    }
217    let (learner, last_flush) = guard.as_mut().expect("buffer initialized above");
218    let result = f(learner);
219    if last_flush.elapsed().as_secs() >= FLUSH_SECS {
220        learner.save_to_disk();
221        *last_flush = Instant::now();
222    }
223    result
224}
225
226/// Process-global: record a quality signal for the extension of `path`.
227pub fn record_signal(path: &str, signal: QualitySignal) {
228    let ext = std::path::Path::new(path)
229        .extension()
230        .and_then(|e| e.to_str())
231        .unwrap_or("")
232        .to_string();
233    if ext.is_empty() {
234        return;
235    }
236    with_buffer(|l| l.record(&ext, signal, now_secs()));
237}
238
239/// Process-global: learned additive delta for the extension (0.0 in warmup).
240pub fn learned_delta(ext: &str) -> f64 {
241    with_buffer(|l| l.delta_for(ext, now_secs()))
242}
243
244/// Process-global: flush the buffer to disk (call from shutdown paths).
245pub fn flush() {
246    let guard = BUFFER
247        .lock()
248        .unwrap_or_else(std::sync::PoisonError::into_inner);
249    if let Some((ref learner, _)) = *guard {
250        learner.save_to_disk();
251    }
252}
253
254/// Process-global: report lines for ctx_metrics.
255pub fn report() -> Vec<String> {
256    with_buffer(|l| l.report_lines())
257}
258
259/// Process-global: machine-readable snapshot for the dashboard (#548),
260/// sorted by extension.
261pub fn snapshot() -> Vec<(String, LearnedDelta)> {
262    with_buffer(|l| {
263        let mut v: Vec<_> = l
264            .per_ext
265            .iter()
266            .map(|(k, d)| (k.clone(), d.clone()))
267            .collect();
268        v.sort_by(|a, b| a.0.cmp(&b.0));
269        v
270    })
271}
272
273/// Process-global: clone of the full learner state for export (#550).
274pub fn export_state() -> ThresholdLearner {
275    with_buffer(|l| l.clone())
276}
277
278/// Process-global: merge a foreign learner state in and persist (#550).
279pub fn merge_state(other: &ThresholdLearner) {
280    with_buffer(|l| l.merge_from(other));
281    flush();
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    const NOW: u64 = 1_780_000_000;
289
290    #[test]
291    fn warmup_returns_zero_delta() {
292        let mut l = ThresholdLearner::default();
293        for _ in 0..MIN_SAMPLES - 1 {
294            l.record("rs", QualitySignal::Bounce, NOW);
295        }
296        assert_eq!(l.delta_for("rs", NOW), 0.0);
297        l.record("rs", QualitySignal::Bounce, NOW);
298        assert!(l.delta_for("rs", NOW) < 0.0);
299    }
300
301    #[test]
302    fn bounce_burst_lowers_threshold() {
303        let mut l = ThresholdLearner::default();
304        for _ in 0..20 {
305            l.record("yml", QualitySignal::Bounce, NOW);
306        }
307        let d = l.delta_for("yml", NOW);
308        assert!(d < -0.05, "bounce burst should push delta down, got {d}");
309    }
310
311    #[test]
312    fn edit_fail_is_stronger_than_bounce() {
313        let mut l = ThresholdLearner::default();
314        for _ in 0..MIN_SAMPLES {
315            l.record("a", QualitySignal::Bounce, NOW);
316            l.record("b", QualitySignal::EditFail, NOW);
317        }
318        assert!(l.delta_for("b", NOW) <= l.delta_for("a", NOW));
319    }
320
321    #[test]
322    fn waste_burst_raises_threshold() {
323        let mut l = ThresholdLearner::default();
324        for _ in 0..20 {
325            l.record("json", QualitySignal::WastedFull, NOW);
326        }
327        let d = l.delta_for("json", NOW);
328        assert!(d > 0.05, "waste burst should push delta up, got {d}");
329    }
330
331    #[test]
332    fn clamp_holds_under_extreme_signals() {
333        let mut l = ThresholdLearner::default();
334        for _ in 0..500 {
335            l.record("rs", QualitySignal::EditFail, NOW);
336        }
337        assert!(l.delta_for("rs", NOW) >= -CLAMP - f64::EPSILON);
338        for _ in 0..2000 {
339            l.record("rs", QualitySignal::WastedFull, NOW);
340        }
341        assert!(l.delta_for("rs", NOW) <= CLAMP + f64::EPSILON);
342    }
343
344    #[test]
345    fn decay_drifts_back_toward_zero() {
346        let mut l = ThresholdLearner::default();
347        for _ in 0..30 {
348            l.record("rs", QualitySignal::Bounce, NOW);
349        }
350        let before = l.delta_for("rs", NOW);
351        let after = l.delta_for("rs", NOW + 30 * 86_400);
352        assert!(
353            after.abs() < before.abs(),
354            "30 days of decay should shrink |delta|: {before} -> {after}"
355        );
356    }
357
358    #[test]
359    fn clean_reads_recover_after_bounces() {
360        let mut l = ThresholdLearner::default();
361        for _ in 0..15 {
362            l.record("ts", QualitySignal::Bounce, NOW);
363        }
364        let low = l.delta_for("ts", NOW);
365        for _ in 0..200 {
366            l.record("ts", QualitySignal::CleanCompressed, NOW);
367        }
368        assert!(l.delta_for("ts", NOW) > low);
369    }
370
371    #[test]
372    fn ext_normalization() {
373        let mut l = ThresholdLearner::default();
374        for _ in 0..MIN_SAMPLES {
375            l.record(".RS", QualitySignal::Bounce, NOW);
376        }
377        assert!(l.delta_for("rs", NOW) < 0.0);
378    }
379}