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    /// Normalized path -> unix time of an anchored-edit (`ctx_patch`) staleness
86    /// miss. The next auto read of that path resolves to `anchored` (not `full`),
87    /// so the model gets fresh line anchors to retry by reference (#1008).
88    /// `#[serde(default)]` keeps stores written before anchored editing loadable.
89    #[serde(default)]
90    pub pending_anchored_escalations: HashMap<String, u64>,
91    /// All-time counter of consumed escalations (observability).
92    #[serde(default)]
93    pub escalations_served: u64,
94    #[serde(skip)]
95    dirty: bool,
96}
97
98fn pair_key(ext: &str, mode: &str) -> String {
99    format!("{ext}|{mode}")
100}
101
102/// Evict the oldest entries of a `path -> timestamp` pending map down to
103/// [`MAX_PENDING`]; flips `dirty` when anything was dropped. Shared by the
104/// `full` and `anchored` escalation maps.
105fn evict_pending_to_cap(map: &mut HashMap<String, u64>, dirty: &mut bool) {
106    if map.len() <= MAX_PENDING {
107        return;
108    }
109    let mut items: Vec<(String, u64)> = map.iter().map(|(k, ts)| (k.clone(), *ts)).collect();
110    items.sort_by_key(|(_, ts)| *ts);
111    let drop_n = map.len() - MAX_PENDING;
112    for (key, _) in items.into_iter().take(drop_n) {
113        map.remove(&key);
114    }
115    *dirty = true;
116}
117
118impl EditQualityStore {
119    fn load_from_disk() -> Self {
120        let Ok(raw) = std::fs::read_to_string(store_path()) else {
121            return Self::default();
122        };
123        let mut store: Self = serde_json::from_str(&raw).unwrap_or_default();
124        store.decay(now_unix());
125        store
126    }
127
128    fn decay(&mut self, now: u64) {
129        let before = self.pairs.len()
130            + self.pending_escalations.len()
131            + self.pending_anchored_escalations.len();
132        self.pairs
133            .retain(|_, s| now.saturating_sub(s.last_fail_unix) <= DECAY_SECS);
134        self.pending_escalations
135            .retain(|_, ts| now.saturating_sub(*ts) <= ESCALATION_TTL_SECS);
136        self.pending_anchored_escalations
137            .retain(|_, ts| now.saturating_sub(*ts) <= ESCALATION_TTL_SECS);
138        if self.pairs.len()
139            + self.pending_escalations.len()
140            + self.pending_anchored_escalations.len()
141            != before
142        {
143            self.dirty = true;
144        }
145    }
146
147    fn evict_to_caps(&mut self) {
148        if self.pairs.len() > MAX_PAIRS {
149            let mut items: Vec<(String, u64)> = self
150                .pairs
151                .iter()
152                .map(|(k, s)| (k.clone(), s.last_fail_unix))
153                .collect();
154            items.sort_by_key(|(_, ts)| *ts);
155            let drop_n = self.pairs.len() - MAX_PAIRS;
156            for (key, _) in items.into_iter().take(drop_n) {
157                self.pairs.remove(&key);
158            }
159            self.dirty = true;
160        }
161        evict_pending_to_cap(&mut self.pending_escalations, &mut self.dirty);
162        evict_pending_to_cap(&mut self.pending_anchored_escalations, &mut self.dirty);
163    }
164
165    pub fn record_failure(&mut self, ext: &str, mode: &str, now: u64) {
166        let entry = self.pairs.entry(pair_key(ext, mode)).or_default();
167        entry.fails = entry.fails.saturating_add(1);
168        entry.last_fail_unix = now;
169        entry.update_risky();
170        self.dirty = true;
171        self.evict_to_caps();
172    }
173
174    pub fn record_success(&mut self, ext: &str, mode: &str) {
175        let entry = self.pairs.entry(pair_key(ext, mode)).or_default();
176        entry.successes = entry.successes.saturating_add(1);
177        entry.update_risky();
178        self.dirty = true;
179    }
180
181    pub fn set_pending_escalation(&mut self, norm_path: &str, now: u64) {
182        self.pending_escalations.insert(norm_path.to_string(), now);
183        self.dirty = true;
184        self.evict_to_caps();
185    }
186
187    /// Consumes the escalation for this path if present and not expired.
188    pub fn take_pending_escalation(&mut self, norm_path: &str, now: u64) -> bool {
189        Self::take_from(
190            &mut self.pending_escalations,
191            norm_path,
192            now,
193            &mut self.escalations_served,
194            &mut self.dirty,
195        )
196    }
197
198    pub fn set_pending_anchored_escalation(&mut self, norm_path: &str, now: u64) {
199        self.pending_anchored_escalations
200            .insert(norm_path.to_string(), now);
201        self.dirty = true;
202        self.evict_to_caps();
203    }
204
205    /// Consumes the anchored escalation for this path if present and not expired.
206    pub fn take_pending_anchored_escalation(&mut self, norm_path: &str, now: u64) -> bool {
207        Self::take_from(
208            &mut self.pending_anchored_escalations,
209            norm_path,
210            now,
211            &mut self.escalations_served,
212            &mut self.dirty,
213        )
214    }
215
216    /// Shared one-shot consume: remove `norm_path`, count it served when still
217    /// within [`ESCALATION_TTL_SECS`], else drop it silently.
218    fn take_from(
219        map: &mut HashMap<String, u64>,
220        norm_path: &str,
221        now: u64,
222        served: &mut u64,
223        dirty: &mut bool,
224    ) -> bool {
225        match map.remove(norm_path) {
226            Some(ts) if now.saturating_sub(ts) <= ESCALATION_TTL_SECS => {
227                *served += 1;
228                *dirty = true;
229                true
230            }
231            Some(_) => {
232                *dirty = true;
233                false
234            }
235            None => false,
236        }
237    }
238
239    pub fn is_risky(&self, ext: &str, mode: &str) -> bool {
240        self.pairs
241            .get(&pair_key(ext, mode))
242            .is_some_and(|s| s.risky)
243    }
244
245    pub fn save(&self) -> std::io::Result<()> {
246        let path = store_path();
247        if let Some(parent) = path.parent() {
248            std::fs::create_dir_all(parent)?;
249        }
250        let json = serde_json::to_string(self)?;
251        let tmp = path.with_extension("tmp");
252        std::fs::write(&tmp, json)?;
253        std::fs::rename(&tmp, &path)
254    }
255}
256
257fn store_path() -> PathBuf {
258    crate::core::data_dir::lean_ctx_data_dir()
259        .unwrap_or_else(|_| PathBuf::from("."))
260        .join(STORE_FILE)
261}
262
263fn now_unix() -> u64 {
264    std::time::SystemTime::now()
265        .duration_since(std::time::UNIX_EPOCH)
266        .map_or(0, |d| d.as_secs())
267}
268
269fn global() -> &'static Mutex<EditQualityStore> {
270    STORE.get_or_init(|| Mutex::new(EditQualityStore::load_from_disk()))
271}
272
273fn ext_of(path: &str) -> String {
274    std::path::Path::new(path)
275        .extension()
276        .and_then(|e| e.to_str())
277        .unwrap_or("")
278        .to_string()
279}
280
281/// Process-global: record the outcome of an edit, correlated with the mode of
282/// the last read of that file. `last_mode` must be the recorded read mode
283/// (empty = file was never read through lean-ctx → no signal, skipped).
284/// Compression-correlated failures additionally arm the one-shot per-path
285/// escalation so the next auto read of `path` resolves to `full`.
286pub fn record_edit_outcome(path: &str, last_mode: &str, success: bool) {
287    record_outcome_with(path, last_mode, success, Escalation::Full);
288}
289
290/// Like [`record_edit_outcome`], but a failure is a `ctx_patch` anchor-staleness
291/// miss: the recovery is a *fresh anchored read* (the model edits by reference),
292/// so the next auto read escalates to `anchored` instead of `full` (#1008).
293pub fn record_anchored_edit_outcome(path: &str, last_mode: &str, success: bool) {
294    record_outcome_with(path, last_mode, success, Escalation::Anchored);
295}
296
297/// Which read mode the *next* auto read escalates to after a correlated edit
298/// failure. Both are high-signal "the context the model edited against was
299/// wrong" events; they differ only in the recovery view handed back.
300#[derive(Clone, Copy)]
301enum Escalation {
302    /// str_replace miss → give the real body (`full`).
303    Full,
304    /// anchored miss → give fresh line anchors (`anchored`).
305    Anchored,
306}
307
308impl Escalation {
309    /// The read mode that fully neutralizes this failure class, hence the value
310    /// to *not* re-arm against (escalating `full→full` / `anchored→anchored` is a
311    /// no-op).
312    fn target_mode(self) -> &'static str {
313        match self {
314            Escalation::Full => "full",
315            Escalation::Anchored => "anchored",
316        }
317    }
318}
319
320fn record_outcome_with(path: &str, last_mode: &str, success: bool, esc: Escalation) {
321    if last_mode.is_empty() {
322        return;
323    }
324    let ext = ext_of(path);
325    let Ok(mut store) = global().lock() else {
326        return;
327    };
328    if success {
329        store.record_success(&ext, last_mode);
330    } else {
331        let now = now_unix();
332        store.record_failure(&ext, last_mode, now);
333        if last_mode != esc.target_mode() {
334            let norm = crate::core::pathutil::normalize_tool_path(path);
335            match esc {
336                Escalation::Full => store.set_pending_escalation(&norm, now),
337                Escalation::Anchored => store.set_pending_anchored_escalation(&norm, now),
338            }
339            // Quality signal (#538): edit failures after a stale read are the
340            // strongest "the model's view was wrong" evidence we have — they also
341            // penalize the bandit arm that produced the read (#593).
342            crate::core::adaptive_thresholds::record_quality_signal(
343                path,
344                crate::core::threshold_learning::QualitySignal::EditFail,
345            );
346            // Stigmergy (#540): edit failures mark the path as Stuck ("context
347            // drifted"), the explicit anchor-miss signal called for in #1008.
348            let scent_path = norm.clone();
349            std::thread::spawn(move || {
350                crate::core::scent_field::deposit(
351                    crate::core::scent_field::scent_agent_id(),
352                    crate::core::scent_field::ScentKind::Stuck,
353                    &scent_path,
354                    1.0,
355                );
356            });
357        }
358    }
359    maybe_flush(&mut store);
360}
361
362/// Process-global: one-shot check-and-consume of the per-path `full` escalation.
363pub fn take_pending_escalation(path: &str) -> bool {
364    consume_escalation(path, false)
365}
366
367/// Process-global: one-shot check-and-consume of the per-path `anchored`
368/// escalation (armed by [`record_anchored_edit_outcome`]).
369pub fn take_pending_anchored_escalation(path: &str) -> bool {
370    consume_escalation(path, true)
371}
372
373fn consume_escalation(path: &str, anchored: bool) -> bool {
374    let norm = crate::core::pathutil::normalize_tool_path(path);
375    let Ok(mut store) = global().lock() else {
376        return false;
377    };
378    let now = now_unix();
379    let hit = if anchored {
380        store.take_pending_anchored_escalation(&norm, now)
381    } else {
382        store.take_pending_escalation(&norm, now)
383    };
384    if hit {
385        maybe_flush(&mut store);
386    }
387    hit
388}
389
390/// Process-global: is `mode` currently risky for files with this extension?
391pub fn is_risky_mode(path: &str, mode: &str) -> bool {
392    let ext = ext_of(path);
393    global().lock().is_ok_and(|s| s.is_risky(&ext, mode))
394}
395
396/// Snapshot for `ctx_metrics`: (risky pairs, per-pair stats, escalations served).
397pub fn metrics_snapshot() -> serde_json::Value {
398    let Ok(store) = global().lock() else {
399        return serde_json::json!({});
400    };
401    let mut pairs: Vec<serde_json::Value> = store
402        .pairs
403        .iter()
404        .map(|(key, s)| {
405            serde_json::json!({
406                "pair": key,
407                "fails": s.fails,
408                "successes": s.successes,
409                "fail_rate": (s.fail_rate() * 1000.0).round() / 1000.0,
410                "risky": s.risky,
411            })
412        })
413        .collect();
414    pairs.sort_by(|a, b| {
415        let fa = a["fail_rate"].as_f64().unwrap_or(0.0);
416        let fb = b["fail_rate"].as_f64().unwrap_or(0.0);
417        fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
418    });
419    serde_json::json!({
420        "pairs": pairs,
421        "pending_escalations": store.pending_escalations.len(),
422        "pending_anchored_escalations": store.pending_anchored_escalations.len(),
423        "escalations_served": store.escalations_served,
424    })
425}
426
427pub fn flush() {
428    if let Ok(store) = global().lock()
429        && store.dirty
430    {
431        let _ = store.save();
432    }
433}
434
435fn maybe_flush(store: &mut EditQualityStore) {
436    let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
437    if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
438        store.dirty = false;
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn risky_after_two_majority_fails_with_hysteresis() {
448        let mut s = EditQualityStore::default();
449        s.record_failure("rs", "map", 1000);
450        assert!(!s.is_risky("rs", "map"), "one fail is not a pattern");
451        s.record_failure("rs", "map", 1001);
452        assert!(s.is_risky("rs", "map"), "2 fails, rate 1.0 >= 0.25");
453
454        // Rate must drop below 0.15 to recover: 2 fails need > 11 successes.
455        for _ in 0..11 {
456            s.record_success("rs", "map");
457        }
458        assert!(s.is_risky("rs", "map"), "2/13 ≈ 0.154 still risky");
459        s.record_success("rs", "map");
460        assert!(!s.is_risky("rs", "map"), "2/14 ≈ 0.143 < 0.15 recovers");
461    }
462
463    #[test]
464    fn entering_risky_needs_quarter_rate_not_just_two_fails() {
465        let mut s = EditQualityStore::default();
466        for _ in 0..7 {
467            s.record_success("ts", "signatures");
468        }
469        s.record_failure("ts", "signatures", 1000);
470        s.record_failure("ts", "signatures", 1001);
471        // 2 fails / 9 total ≈ 0.22 < 0.25 — healthy mode stays usable.
472        assert!(!s.is_risky("ts", "signatures"));
473        s.record_failure("ts", "signatures", 1002);
474        // 3/10 = 0.30 — now risky.
475        assert!(s.is_risky("ts", "signatures"));
476    }
477
478    #[test]
479    fn penalty_is_per_extension_not_global() {
480        let mut s = EditQualityStore::default();
481        s.record_failure("rs", "map", 1000);
482        s.record_failure("rs", "map", 1001);
483        assert!(s.is_risky("rs", "map"));
484        assert!(!s.is_risky("py", "map"), "py|map untouched");
485        assert!(!s.is_risky("rs", "signatures"), "rs|signatures untouched");
486    }
487
488    #[test]
489    fn escalation_is_one_shot_and_expires() {
490        let mut s = EditQualityStore::default();
491        s.set_pending_escalation("src/a.rs", 1000);
492        assert!(s.take_pending_escalation("src/a.rs", 1100));
493        assert!(
494            !s.take_pending_escalation("src/a.rs", 1101),
495            "consumed — second read is normal again"
496        );
497        assert_eq!(s.escalations_served, 1);
498
499        s.set_pending_escalation("src/b.rs", 1000);
500        assert!(
501            !s.take_pending_escalation("src/b.rs", 1000 + ESCALATION_TTL_SECS + 1),
502            "expired escalations are dropped, not served"
503        );
504        assert_eq!(s.escalations_served, 1);
505    }
506
507    #[test]
508    fn anchored_escalation_is_independent_and_one_shot() {
509        // #1008: the anchored map is separate from the `full` map — arming one
510        // must never consume the other, so str_replace and ctx_patch recoveries
511        // don't cross-talk.
512        let mut s = EditQualityStore::default();
513        s.set_pending_anchored_escalation("src/a.rs", 1000);
514        assert!(
515            !s.take_pending_escalation("src/a.rs", 1100),
516            "anchored arming must not satisfy a full escalation"
517        );
518        assert!(s.take_pending_anchored_escalation("src/a.rs", 1100));
519        assert!(
520            !s.take_pending_anchored_escalation("src/a.rs", 1101),
521            "anchored escalation is one-shot"
522        );
523        assert_eq!(s.escalations_served, 1);
524    }
525
526    #[test]
527    fn anchored_outcome_arms_anchored_not_full() {
528        // A miss after an anchored read arms only the anchored escalation.
529        let mut s = EditQualityStore::default();
530        s.record_failure("rs", "anchored", 1000);
531        s.set_pending_anchored_escalation("src/x.rs", 1000);
532        assert!(s.pending_escalations.is_empty());
533        assert_eq!(s.pending_anchored_escalations.len(), 1);
534    }
535
536    #[test]
537    fn store_without_anchored_field_deserializes() {
538        // Back-compat (#1008): a store written before anchored editing has no
539        // `pending_anchored_escalations` key; `#[serde(default)]` must fill it.
540        let legacy = r#"{"pairs":{},"pending_escalations":{"old.rs":42}}"#;
541        let s: EditQualityStore = serde_json::from_str(legacy).unwrap();
542        assert!(s.pending_anchored_escalations.is_empty());
543        assert!(s.pending_escalations.contains_key("old.rs"));
544    }
545
546    #[test]
547    fn decay_drops_stale_pairs_and_pendings() {
548        let mut s = EditQualityStore::default();
549        s.record_failure("rs", "map", 1000);
550        s.record_failure("go", "map", 5000);
551        s.set_pending_escalation("old.rs", 1000);
552        s.set_pending_escalation("fresh.rs", 5000);
553        s.decay(5000 + DECAY_SECS - 10);
554        assert!(!s.pairs.contains_key("rs|map"));
555        assert!(s.pairs.contains_key("go|map"));
556        // Pendings use the much shorter escalation TTL.
557        assert!(s.pending_escalations.is_empty());
558    }
559
560    #[test]
561    fn eviction_keeps_newest() {
562        let mut s = EditQualityStore::default();
563        for i in 0..(MAX_PAIRS + 10) {
564            s.record_failure(&format!("e{i}"), "map", 1000 + i as u64);
565        }
566        assert_eq!(s.pairs.len(), MAX_PAIRS);
567        assert!(!s.pairs.contains_key("e0|map"));
568        for i in 0..(MAX_PENDING + 5) {
569            s.set_pending_escalation(&format!("f{i}.rs"), 1000 + i as u64);
570        }
571        assert_eq!(s.pending_escalations.len(), MAX_PENDING);
572        assert!(!s.pending_escalations.contains_key("f0.rs"));
573    }
574
575    #[test]
576    fn roundtrip_serialization() {
577        let mut s = EditQualityStore::default();
578        s.record_failure("rs", "map", 42);
579        s.set_pending_escalation("x.rs", 42);
580        let json = serde_json::to_string(&s).unwrap();
581        let back: EditQualityStore = serde_json::from_str(&json).unwrap();
582        assert_eq!(back.pairs.get("rs|map").unwrap().fails, 1);
583        assert!(back.pending_escalations.contains_key("x.rs"));
584    }
585}