# Quality Loop v1 — Edit-Outcome Feedback into Mode Selection Status: experimental (GL #494) Owner: core engine Consumers: `auto_mode_resolver`, `ctx_edit`, `ctx_metrics` ## Problem `BounceTracker` and `path_mode_memory` close the feedback loop for *re-read* bounces (compressed read → full re-read), but an edit that fails because the file was last read in a compressed mode taught the system nothing: the agent quoted an `old_string` from a `map`/`signatures` rendering whose body was never in context, the edit missed, and the next read of a similar file was compressed exactly the same way. ## Signals Edit outcomes are recorded by `ctx_edit` (both the MCP tool and the in-process `tools::ctx_edit::handle` path) via `core::edit_quality::record_edit_outcome`, keyed by the **mode of the last lean-ctx read of that file** (session cache `last_mode`). Only two outcomes carry signal: | Outcome | Condition | Recorded as | |---|---|---| | Success | replacement applied (`CacheEffect::Invalidate`) | success for `(ext, last_mode)` | | Compression-correlated failure | `old_string` not found (auto-escalation `CacheEffect::StoreFull`, or plain miss after a `full` read as baseline) | failure for `(ext, last_mode)` | Explicitly **not** recorded (no compression signal): `create=true`, empty or identical `old_string`/`new_string`, preimage/TOCTOU mismatches, missing files, already-applied edits, and files never read through lean-ctx (`last_mode` empty). ## Feedback rules ### 1. Per-path one-shot escalation A compression-correlated failure (last mode ≠ `full`) arms a pending escalation for that path. The **next** `mode=auto` resolution of the same path returns `full` (resolver source: `edit_fail_escalation`), then the escalation is consumed. Pending escalations expire after **1 hour**. This complements the immediate in-response escalation that `ctx_edit` already appends (full content in the error message): the in-response copy serves the retry, the pending escalation serves the next independent read. ### 2. Per-(extension × mode) risky penalty Aggregated per `(file extension, read mode)` pair with hysteresis: ``` fail_rate = fails / (fails + successes) enter risky: fails >= 2 AND fail_rate >= 0.25 exit risky: fail_rate < 0.15 ``` While a pair is risky, `mode=auto` resolutions that would pick that mode for that file type return `full` instead (resolver source: `edit_quality_penalty`). The two thresholds prevent flapping: a single lucky edit cannot immediately re-enable a mode that keeps breaking edits. The penalty is **per extension**, never global: `rs|map` being risky does not affect `py|map` or `rs|signatures`. ## Persistence `~/.lean-ctx/edit_quality.json` (respects `LEAN_CTX_DATA_DIR`), atomic tmp+rename writes, flushed every 10 recordings and on server shutdown. Bounds: | Limit | Value | |---|---| | Pair decay (no failure) | 30 days | | Escalation TTL | 1 hour | | Max pairs | 200 (oldest-failure evicted) | | Max pending escalations | 100 (oldest evicted) | ## Observability `ctx_metrics` prints an `Edit quality (compression-correlated)` section: per pair fails/successes/fail-rate, the `[risky -> full]` marker, plus served and pending escalation counts. Auto-mode resolutions show up in the existing `Auto-mode sources` line as `edit_fail_escalation` and `edit_quality_penalty`. ## Invariants - Recording an outcome never blocks an edit: store access is lock-guarded and failures to lock are silently skipped. - The penalty only ever *escalates* toward `full`; it never picks a lossier mode than the resolver would have chosen. - No regression of bounce semantics: `BounceTracker` / `path_mode_memory` remain independent signals evaluated before the quality loop's penalty.