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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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
320/// An edit failed after a compressed read: charge the task whose read it was.
321/// When this process no longer knows that read, the current task's signals
322/// become unknown rather than silently clean.
323fn charge_compressed_read(path: &str, last_mode: &str) {
324    use crate::core::context_store::task_signals::{self, Signal};
325    if matches!(last_mode, "full" | "diff") {
326        return;
327    }
328    let last = crate::core::bounce_tracker::global()
329        .lock()
330        .ok()
331        .and_then(|tracker| tracker.last_read(path));
332    match last {
333        Some((true, Some(origin))) => task_signals::record(&origin, Signal::EditFailure),
334        // The model saw the file in full since: not the compressed read's failure.
335        Some((false, _)) => {}
336        Some((true, None)) | None => task_signals::record_unattributed(),
337    }
338}
339
340fn record_outcome_with(path: &str, last_mode: &str, success: bool, esc: Escalation) {
341    if last_mode.is_empty() {
342        return;
343    }
344    // Before the edit store's lock: the bounce tracker is never locked under it.
345    if !success && last_mode != esc.target_mode() {
346        charge_compressed_read(path, last_mode);
347    }
348    let ext = ext_of(path);
349    let Ok(mut store) = global().lock() else {
350        return;
351    };
352    if success {
353        store.record_success(&ext, last_mode);
354    } else {
355        let now = now_unix();
356        store.record_failure(&ext, last_mode, now);
357        if last_mode != esc.target_mode() {
358            let norm = crate::core::pathutil::normalize_tool_path(path);
359            match esc {
360                Escalation::Full => store.set_pending_escalation(&norm, now),
361                Escalation::Anchored => store.set_pending_anchored_escalation(&norm, now),
362            }
363            // Stigmergy (#540): edit failures mark the path as Stuck ("context
364            // drifted"), the explicit anchor-miss signal called for in #1008.
365            // The request's project is resolved before the thread starts.
366            let scent_path = norm.clone();
367            if let Some(project_root) = crate::core::scent_field::current_project() {
368                std::thread::spawn(move || {
369                    crate::core::scent_field::deposit_for(
370                        &project_root,
371                        crate::core::scent_field::scent_agent_id(),
372                        crate::core::scent_field::ScentKind::Stuck,
373                        &scent_path,
374                        1.0,
375                    );
376                });
377            }
378        }
379    }
380    maybe_flush(&mut store);
381}
382
383/// Process-global: one-shot check-and-consume of the per-path `full` escalation.
384pub(crate) fn take_pending_escalation(path: &str) -> bool {
385    consume_escalation(path, false)
386}
387
388/// Process-global: one-shot check-and-consume of the per-path `anchored`
389/// escalation (armed by [`record_anchored_edit_outcome`]).
390pub(crate) fn take_pending_anchored_escalation(path: &str) -> bool {
391    consume_escalation(path, true)
392}
393
394fn consume_escalation(path: &str, anchored: bool) -> bool {
395    let norm = crate::core::pathutil::normalize_tool_path(path);
396    let Ok(mut store) = global().lock() else {
397        return false;
398    };
399    let now = now_unix();
400    let hit = if anchored {
401        store.take_pending_anchored_escalation(&norm, now)
402    } else {
403        store.take_pending_escalation(&norm, now)
404    };
405    if hit {
406        maybe_flush(&mut store);
407    }
408    hit
409}
410
411/// Process-global: is `mode` currently risky for files with this extension?
412pub(crate) fn is_risky_mode(path: &str, mode: &str) -> bool {
413    let ext = ext_of(path);
414    global().lock().is_ok_and(|s| s.is_risky(&ext, mode))
415}
416
417/// Snapshot for `ctx_metrics`: (risky pairs, per-pair stats, escalations served).
418pub fn metrics_snapshot() -> serde_json::Value {
419    let Ok(store) = global().lock() else {
420        return serde_json::json!({});
421    };
422    let mut pairs: Vec<serde_json::Value> = store
423        .pairs
424        .iter()
425        .map(|(key, s)| {
426            serde_json::json!({
427                "pair": key,
428                "fails": s.fails,
429                "successes": s.successes,
430                "fail_rate": (s.fail_rate() * 1000.0).round() / 1000.0,
431                "risky": s.risky,
432            })
433        })
434        .collect();
435    pairs.sort_by(|a, b| {
436        let fa = a["fail_rate"].as_f64().unwrap_or(0.0);
437        let fb = b["fail_rate"].as_f64().unwrap_or(0.0);
438        fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
439    });
440    serde_json::json!({
441        "pairs": pairs,
442        "pending_escalations": store.pending_escalations.len(),
443        "pending_anchored_escalations": store.pending_anchored_escalations.len(),
444        "escalations_served": store.escalations_served,
445    })
446}
447
448pub(crate) fn flush() {
449    if let Ok(store) = global().lock()
450        && store.dirty
451    {
452        let _ = store.save();
453    }
454}
455
456fn maybe_flush(store: &mut EditQualityStore) {
457    let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
458    if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
459        store.dirty = false;
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn risky_after_two_majority_fails_with_hysteresis() {
469        let mut s = EditQualityStore::default();
470        s.record_failure("rs", "map", 1000);
471        assert!(!s.is_risky("rs", "map"), "one fail is not a pattern");
472        s.record_failure("rs", "map", 1001);
473        assert!(s.is_risky("rs", "map"), "2 fails, rate 1.0 >= 0.25");
474
475        // Rate must drop below 0.15 to recover: 2 fails need > 11 successes.
476        for _ in 0..11 {
477            s.record_success("rs", "map");
478        }
479        assert!(s.is_risky("rs", "map"), "2/13 ≈ 0.154 still risky");
480        s.record_success("rs", "map");
481        assert!(!s.is_risky("rs", "map"), "2/14 ≈ 0.143 < 0.15 recovers");
482    }
483
484    #[test]
485    fn entering_risky_needs_quarter_rate_not_just_two_fails() {
486        let mut s = EditQualityStore::default();
487        for _ in 0..7 {
488            s.record_success("ts", "signatures");
489        }
490        s.record_failure("ts", "signatures", 1000);
491        s.record_failure("ts", "signatures", 1001);
492        // 2 fails / 9 total ≈ 0.22 < 0.25 — healthy mode stays usable.
493        assert!(!s.is_risky("ts", "signatures"));
494        s.record_failure("ts", "signatures", 1002);
495        // 3/10 = 0.30 — now risky.
496        assert!(s.is_risky("ts", "signatures"));
497    }
498
499    #[test]
500    fn penalty_is_per_extension_not_global() {
501        let mut s = EditQualityStore::default();
502        s.record_failure("rs", "map", 1000);
503        s.record_failure("rs", "map", 1001);
504        assert!(s.is_risky("rs", "map"));
505        assert!(!s.is_risky("py", "map"), "py|map untouched");
506        assert!(!s.is_risky("rs", "signatures"), "rs|signatures untouched");
507    }
508
509    #[test]
510    fn escalation_is_one_shot_and_expires() {
511        let mut s = EditQualityStore::default();
512        s.set_pending_escalation("src/a.rs", 1000);
513        assert!(s.take_pending_escalation("src/a.rs", 1100));
514        assert!(
515            !s.take_pending_escalation("src/a.rs", 1101),
516            "consumed — second read is normal again"
517        );
518        assert_eq!(s.escalations_served, 1);
519
520        s.set_pending_escalation("src/b.rs", 1000);
521        assert!(
522            !s.take_pending_escalation("src/b.rs", 1000 + ESCALATION_TTL_SECS + 1),
523            "expired escalations are dropped, not served"
524        );
525        assert_eq!(s.escalations_served, 1);
526    }
527
528    #[test]
529    fn anchored_escalation_is_independent_and_one_shot() {
530        // #1008: the anchored map is separate from the `full` map — arming one
531        // must never consume the other, so str_replace and ctx_patch recoveries
532        // don't cross-talk.
533        let mut s = EditQualityStore::default();
534        s.set_pending_anchored_escalation("src/a.rs", 1000);
535        assert!(
536            !s.take_pending_escalation("src/a.rs", 1100),
537            "anchored arming must not satisfy a full escalation"
538        );
539        assert!(s.take_pending_anchored_escalation("src/a.rs", 1100));
540        assert!(
541            !s.take_pending_anchored_escalation("src/a.rs", 1101),
542            "anchored escalation is one-shot"
543        );
544        assert_eq!(s.escalations_served, 1);
545    }
546
547    #[test]
548    fn anchored_outcome_arms_anchored_not_full() {
549        // A miss after an anchored read arms only the anchored escalation.
550        let mut s = EditQualityStore::default();
551        s.record_failure("rs", "anchored", 1000);
552        s.set_pending_anchored_escalation("src/x.rs", 1000);
553        assert!(s.pending_escalations.is_empty());
554        assert_eq!(s.pending_anchored_escalations.len(), 1);
555    }
556
557    #[test]
558    fn store_without_anchored_field_deserializes() {
559        // Back-compat (#1008): a store written before anchored editing has no
560        // `pending_anchored_escalations` key; `#[serde(default)]` must fill it.
561        let legacy = r#"{"pairs":{},"pending_escalations":{"old.rs":42}}"#;
562        let s: EditQualityStore = serde_json::from_str(legacy).unwrap();
563        assert!(s.pending_anchored_escalations.is_empty());
564        assert!(s.pending_escalations.contains_key("old.rs"));
565    }
566
567    #[test]
568    fn decay_drops_stale_pairs_and_pendings() {
569        let mut s = EditQualityStore::default();
570        s.record_failure("rs", "map", 1000);
571        s.record_failure("go", "map", 5000);
572        s.set_pending_escalation("old.rs", 1000);
573        s.set_pending_escalation("fresh.rs", 5000);
574        s.decay(5000 + DECAY_SECS - 10);
575        assert!(!s.pairs.contains_key("rs|map"));
576        assert!(s.pairs.contains_key("go|map"));
577        // Pendings use the much shorter escalation TTL.
578        assert!(s.pending_escalations.is_empty());
579    }
580
581    #[test]
582    fn eviction_keeps_newest() {
583        let mut s = EditQualityStore::default();
584        for i in 0..(MAX_PAIRS + 10) {
585            s.record_failure(&format!("e{i}"), "map", 1000 + i as u64);
586        }
587        assert_eq!(s.pairs.len(), MAX_PAIRS);
588        assert!(!s.pairs.contains_key("e0|map"));
589        for i in 0..(MAX_PENDING + 5) {
590            s.set_pending_escalation(&format!("f{i}.rs"), 1000 + i as u64);
591        }
592        assert_eq!(s.pending_escalations.len(), MAX_PENDING);
593        assert!(!s.pending_escalations.contains_key("f0.rs"));
594    }
595
596    #[test]
597    fn roundtrip_serialization() {
598        let mut s = EditQualityStore::default();
599        s.record_failure("rs", "map", 42);
600        s.set_pending_escalation("x.rs", 42);
601        let json = serde_json::to_string(&s).unwrap();
602        let back: EditQualityStore = serde_json::from_str(&json).unwrap();
603        assert_eq!(back.pairs.get("rs|map").unwrap().fails, 1);
604        assert!(back.pending_escalations.contains_key("x.rs"));
605    }
606}