3.7 KiB
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_memoryremain independent signals evaluated before the quality loop's penalty.