Skip to main content

lean_ctx/core/
edit_quality.rs

1//! Quality loop v1 (#494): compression-caused edit failures feed back into
2//! mode selection.
3//!
4//! `BounceTracker`/`path_mode_memory` close the loop for *re-read* bounces,
5//! but an edit that fails because the file was last read in a compressed mode
6//! (`old_string` not found — the body simply wasn't in context) taught the
7//! system nothing. This module records edit outcomes correlated with the last
8//! read mode and feeds two signals back into `auto_mode_resolver::resolve`:
9//!
10//! 1. **Per-path escalation** — after a compression-correlated edit failure
11//!    the *next* auto read of that file resolves to `full` (one-shot, 1 h TTL).
12//! 2. **Per-(extension × mode) penalty** — modes whose edit-failure rate for a
13//!    file type crosses the risky threshold resolve to `full` until the rate
14//!    recovers (hysteresis, see below).
15//!
16//! Risk formula (documented in `docs/contracts/quality-loop-v1.md`):
17//! a (ext, mode) pair becomes risky when `fails >= 2 && fails / (fails +
18//! successes) >= 0.25`, and stops being risky only when the rate drops below
19//! `0.15` — two thresholds so one lucky edit doesn't flap the decision.
20//!
21//! Storage: `~/.lean-ctx/edit_quality.json`, atomic write (tmp+rename),
22//! loaded once per process, flushed periodically like `path_mode_memory`.
23
24use std::collections::HashMap;
25use std::path::PathBuf;
26use std::sync::atomic::{AtomicUsize, Ordering};
27use std::sync::{Mutex, OnceLock};
28
29use serde::{Deserialize, Serialize};
30
31const STORE_FILE: &str = "edit_quality.json";
32/// (ext, mode) pairs without a failure for this long are dropped on load.
33const DECAY_SECS: u64 = 30 * 24 * 3600;
34/// Pending per-path escalations expire after this long.
35const ESCALATION_TTL_SECS: u64 = 3600;
36/// Hard caps; oldest entries are evicted first.
37const MAX_PAIRS: usize = 200;
38const MAX_PENDING: usize = 100;
39const FLUSH_EVERY: usize = 10;
40
41/// Risky when the failure share reaches this rate (with >= 2 fails)…
42const RISKY_ENTER_RATE: f64 = 0.25;
43/// …and recovers only once the rate drops below this (hysteresis).
44const RISKY_EXIT_RATE: f64 = 0.15;
45const RISKY_MIN_FAILS: u32 = 2;
46
47static STORE: OnceLock<Mutex<EditQualityStore>> = OnceLock::new();
48static RECORD_CALLS: AtomicUsize = AtomicUsize::new(0);
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
51pub struct PairStats {
52    pub fails: u32,
53    pub successes: u32,
54    pub risky: bool,
55    pub last_fail_unix: u64,
56}
57
58impl PairStats {
59    fn fail_rate(&self) -> f64 {
60        let total = self.fails + self.successes;
61        if total == 0 {
62            return 0.0;
63        }
64        f64::from(self.fails) / f64::from(total)
65    }
66
67    /// Applies the documented enter/exit thresholds after every outcome.
68    fn update_risky(&mut self) {
69        if self.risky {
70            if self.fail_rate() < RISKY_EXIT_RATE {
71                self.risky = false;
72            }
73        } else if self.fails >= RISKY_MIN_FAILS && self.fail_rate() >= RISKY_ENTER_RATE {
74            self.risky = true;
75        }
76    }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, Default)]
80pub struct EditQualityStore {
81    /// Key: `"{ext}|{mode}"` (e.g. `"rs|map"`).
82    pub pairs: HashMap<String, PairStats>,
83    /// Normalized path -> unix time of the compression-correlated edit fail.
84    pub pending_escalations: HashMap<String, u64>,
85    /// All-time counter of consumed escalations (observability).
86    #[serde(default)]
87    pub escalations_served: u64,
88    #[serde(skip)]
89    dirty: bool,
90}
91
92fn pair_key(ext: &str, mode: &str) -> String {
93    format!("{ext}|{mode}")
94}
95
96impl EditQualityStore {
97    fn load_from_disk() -> Self {
98        let Ok(raw) = std::fs::read_to_string(store_path()) else {
99            return Self::default();
100        };
101        let mut store: Self = serde_json::from_str(&raw).unwrap_or_default();
102        store.decay(now_unix());
103        store
104    }
105
106    fn decay(&mut self, now: u64) {
107        let before = self.pairs.len() + self.pending_escalations.len();
108        self.pairs
109            .retain(|_, s| now.saturating_sub(s.last_fail_unix) <= DECAY_SECS);
110        self.pending_escalations
111            .retain(|_, ts| now.saturating_sub(*ts) <= ESCALATION_TTL_SECS);
112        if self.pairs.len() + self.pending_escalations.len() != before {
113            self.dirty = true;
114        }
115    }
116
117    fn evict_to_caps(&mut self) {
118        if self.pairs.len() > MAX_PAIRS {
119            let mut items: Vec<(String, u64)> = self
120                .pairs
121                .iter()
122                .map(|(k, s)| (k.clone(), s.last_fail_unix))
123                .collect();
124            items.sort_by_key(|(_, ts)| *ts);
125            let drop_n = self.pairs.len() - MAX_PAIRS;
126            for (key, _) in items.into_iter().take(drop_n) {
127                self.pairs.remove(&key);
128            }
129            self.dirty = true;
130        }
131        if self.pending_escalations.len() > MAX_PENDING {
132            let mut items: Vec<(String, u64)> = self
133                .pending_escalations
134                .iter()
135                .map(|(k, ts)| (k.clone(), *ts))
136                .collect();
137            items.sort_by_key(|(_, ts)| *ts);
138            let drop_n = self.pending_escalations.len() - MAX_PENDING;
139            for (key, _) in items.into_iter().take(drop_n) {
140                self.pending_escalations.remove(&key);
141            }
142            self.dirty = true;
143        }
144    }
145
146    pub fn record_failure(&mut self, ext: &str, mode: &str, now: u64) {
147        let entry = self.pairs.entry(pair_key(ext, mode)).or_default();
148        entry.fails = entry.fails.saturating_add(1);
149        entry.last_fail_unix = now;
150        entry.update_risky();
151        self.dirty = true;
152        self.evict_to_caps();
153    }
154
155    pub fn record_success(&mut self, ext: &str, mode: &str) {
156        let entry = self.pairs.entry(pair_key(ext, mode)).or_default();
157        entry.successes = entry.successes.saturating_add(1);
158        entry.update_risky();
159        self.dirty = true;
160    }
161
162    pub fn set_pending_escalation(&mut self, norm_path: &str, now: u64) {
163        self.pending_escalations.insert(norm_path.to_string(), now);
164        self.dirty = true;
165        self.evict_to_caps();
166    }
167
168    /// Consumes the escalation for this path if present and not expired.
169    pub fn take_pending_escalation(&mut self, norm_path: &str, now: u64) -> bool {
170        match self.pending_escalations.remove(norm_path) {
171            Some(ts) if now.saturating_sub(ts) <= ESCALATION_TTL_SECS => {
172                self.escalations_served += 1;
173                self.dirty = true;
174                true
175            }
176            Some(_) => {
177                self.dirty = true;
178                false
179            }
180            None => false,
181        }
182    }
183
184    pub fn is_risky(&self, ext: &str, mode: &str) -> bool {
185        self.pairs
186            .get(&pair_key(ext, mode))
187            .is_some_and(|s| s.risky)
188    }
189
190    pub fn save(&self) -> std::io::Result<()> {
191        let path = store_path();
192        if let Some(parent) = path.parent() {
193            std::fs::create_dir_all(parent)?;
194        }
195        let json = serde_json::to_string(self)?;
196        let tmp = path.with_extension("tmp");
197        std::fs::write(&tmp, json)?;
198        std::fs::rename(&tmp, &path)
199    }
200}
201
202fn store_path() -> PathBuf {
203    crate::core::data_dir::lean_ctx_data_dir()
204        .unwrap_or_else(|_| PathBuf::from("."))
205        .join(STORE_FILE)
206}
207
208fn now_unix() -> u64 {
209    std::time::SystemTime::now()
210        .duration_since(std::time::UNIX_EPOCH)
211        .map_or(0, |d| d.as_secs())
212}
213
214fn global() -> &'static Mutex<EditQualityStore> {
215    STORE.get_or_init(|| Mutex::new(EditQualityStore::load_from_disk()))
216}
217
218fn ext_of(path: &str) -> String {
219    std::path::Path::new(path)
220        .extension()
221        .and_then(|e| e.to_str())
222        .unwrap_or("")
223        .to_string()
224}
225
226/// Process-global: record the outcome of an edit, correlated with the mode of
227/// the last read of that file. `last_mode` must be the recorded read mode
228/// (empty = file was never read through lean-ctx → no signal, skipped).
229/// Compression-correlated failures additionally arm the one-shot per-path
230/// escalation so the next auto read of `path` resolves to `full`.
231pub fn record_edit_outcome(path: &str, last_mode: &str, success: bool) {
232    if last_mode.is_empty() {
233        return;
234    }
235    let ext = ext_of(path);
236    let Ok(mut store) = global().lock() else {
237        return;
238    };
239    if success {
240        store.record_success(&ext, last_mode);
241    } else {
242        let now = now_unix();
243        store.record_failure(&ext, last_mode, now);
244        if last_mode != "full" {
245            let norm = crate::core::pathutil::normalize_tool_path(path);
246            store.set_pending_escalation(&norm, now);
247            // Quality signal (#538): edit failures after compressed reads are
248            // the strongest "compressed too much" evidence we have — they also
249            // penalize the bandit arm that produced the read (#593).
250            crate::core::adaptive_thresholds::record_quality_signal(
251                path,
252                crate::core::threshold_learning::QualitySignal::EditFail,
253            );
254            // Stigmergy (#540): edit failures mark the path as Stuck.
255            let scent_path = norm.clone();
256            std::thread::spawn(move || {
257                crate::core::scent_field::deposit(
258                    crate::core::scent_field::scent_agent_id(),
259                    crate::core::scent_field::ScentKind::Stuck,
260                    &scent_path,
261                    1.0,
262                );
263            });
264        }
265    }
266    maybe_flush(&mut store);
267}
268
269/// Process-global: one-shot check-and-consume of the per-path escalation.
270pub fn take_pending_escalation(path: &str) -> bool {
271    let norm = crate::core::pathutil::normalize_tool_path(path);
272    let Ok(mut store) = global().lock() else {
273        return false;
274    };
275    let hit = store.take_pending_escalation(&norm, now_unix());
276    if hit {
277        maybe_flush(&mut store);
278    }
279    hit
280}
281
282/// Process-global: is `mode` currently risky for files with this extension?
283pub fn is_risky_mode(path: &str, mode: &str) -> bool {
284    let ext = ext_of(path);
285    global().lock().is_ok_and(|s| s.is_risky(&ext, mode))
286}
287
288/// Snapshot for `ctx_metrics`: (risky pairs, per-pair stats, escalations served).
289pub fn metrics_snapshot() -> serde_json::Value {
290    let Ok(store) = global().lock() else {
291        return serde_json::json!({});
292    };
293    let mut pairs: Vec<serde_json::Value> = store
294        .pairs
295        .iter()
296        .map(|(key, s)| {
297            serde_json::json!({
298                "pair": key,
299                "fails": s.fails,
300                "successes": s.successes,
301                "fail_rate": (s.fail_rate() * 1000.0).round() / 1000.0,
302                "risky": s.risky,
303            })
304        })
305        .collect();
306    pairs.sort_by(|a, b| {
307        let fa = a["fail_rate"].as_f64().unwrap_or(0.0);
308        let fb = b["fail_rate"].as_f64().unwrap_or(0.0);
309        fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
310    });
311    serde_json::json!({
312        "pairs": pairs,
313        "pending_escalations": store.pending_escalations.len(),
314        "escalations_served": store.escalations_served,
315    })
316}
317
318pub fn flush() {
319    if let Ok(store) = global().lock() {
320        if store.dirty {
321            let _ = store.save();
322        }
323    }
324}
325
326fn maybe_flush(store: &mut EditQualityStore) {
327    let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
328    if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
329        store.dirty = false;
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn risky_after_two_majority_fails_with_hysteresis() {
339        let mut s = EditQualityStore::default();
340        s.record_failure("rs", "map", 1000);
341        assert!(!s.is_risky("rs", "map"), "one fail is not a pattern");
342        s.record_failure("rs", "map", 1001);
343        assert!(s.is_risky("rs", "map"), "2 fails, rate 1.0 >= 0.25");
344
345        // Rate must drop below 0.15 to recover: 2 fails need > 11 successes.
346        for _ in 0..11 {
347            s.record_success("rs", "map");
348        }
349        assert!(s.is_risky("rs", "map"), "2/13 ≈ 0.154 still risky");
350        s.record_success("rs", "map");
351        assert!(!s.is_risky("rs", "map"), "2/14 ≈ 0.143 < 0.15 recovers");
352    }
353
354    #[test]
355    fn entering_risky_needs_quarter_rate_not_just_two_fails() {
356        let mut s = EditQualityStore::default();
357        for _ in 0..7 {
358            s.record_success("ts", "signatures");
359        }
360        s.record_failure("ts", "signatures", 1000);
361        s.record_failure("ts", "signatures", 1001);
362        // 2 fails / 9 total ≈ 0.22 < 0.25 — healthy mode stays usable.
363        assert!(!s.is_risky("ts", "signatures"));
364        s.record_failure("ts", "signatures", 1002);
365        // 3/10 = 0.30 — now risky.
366        assert!(s.is_risky("ts", "signatures"));
367    }
368
369    #[test]
370    fn penalty_is_per_extension_not_global() {
371        let mut s = EditQualityStore::default();
372        s.record_failure("rs", "map", 1000);
373        s.record_failure("rs", "map", 1001);
374        assert!(s.is_risky("rs", "map"));
375        assert!(!s.is_risky("py", "map"), "py|map untouched");
376        assert!(!s.is_risky("rs", "signatures"), "rs|signatures untouched");
377    }
378
379    #[test]
380    fn escalation_is_one_shot_and_expires() {
381        let mut s = EditQualityStore::default();
382        s.set_pending_escalation("src/a.rs", 1000);
383        assert!(s.take_pending_escalation("src/a.rs", 1100));
384        assert!(
385            !s.take_pending_escalation("src/a.rs", 1101),
386            "consumed — second read is normal again"
387        );
388        assert_eq!(s.escalations_served, 1);
389
390        s.set_pending_escalation("src/b.rs", 1000);
391        assert!(
392            !s.take_pending_escalation("src/b.rs", 1000 + ESCALATION_TTL_SECS + 1),
393            "expired escalations are dropped, not served"
394        );
395        assert_eq!(s.escalations_served, 1);
396    }
397
398    #[test]
399    fn decay_drops_stale_pairs_and_pendings() {
400        let mut s = EditQualityStore::default();
401        s.record_failure("rs", "map", 1000);
402        s.record_failure("go", "map", 5000);
403        s.set_pending_escalation("old.rs", 1000);
404        s.set_pending_escalation("fresh.rs", 5000);
405        s.decay(5000 + DECAY_SECS - 10);
406        assert!(!s.pairs.contains_key("rs|map"));
407        assert!(s.pairs.contains_key("go|map"));
408        // Pendings use the much shorter escalation TTL.
409        assert!(s.pending_escalations.is_empty());
410    }
411
412    #[test]
413    fn eviction_keeps_newest() {
414        let mut s = EditQualityStore::default();
415        for i in 0..(MAX_PAIRS + 10) {
416            s.record_failure(&format!("e{i}"), "map", 1000 + i as u64);
417        }
418        assert_eq!(s.pairs.len(), MAX_PAIRS);
419        assert!(!s.pairs.contains_key("e0|map"));
420        for i in 0..(MAX_PENDING + 5) {
421            s.set_pending_escalation(&format!("f{i}.rs"), 1000 + i as u64);
422        }
423        assert_eq!(s.pending_escalations.len(), MAX_PENDING);
424        assert!(!s.pending_escalations.contains_key("f0.rs"));
425    }
426
427    #[test]
428    fn roundtrip_serialization() {
429        let mut s = EditQualityStore::default();
430        s.record_failure("rs", "map", 42);
431        s.set_pending_escalation("x.rs", 42);
432        let json = serde_json::to_string(&s).unwrap();
433        let back: EditQualityStore = serde_json::from_str(&json).unwrap();
434        assert_eq!(back.pairs.get("rs|map").unwrap().fails, 1);
435        assert!(back.pending_escalations.contains_key("x.rs"));
436    }
437}