|
| 1 | +# ADR-0025: An adaptive context pre-pass — proactive, but never a bypass |
| 2 | + |
| 3 | +- **Status**: Accepted |
| 4 | +- **Date**: 2026-08-10 |
| 5 | +- **Note on numbering**: the implementation plan this work follows |
| 6 | + originally reserved ADR-0024 for this decision; Phase 3 (Reality Check |
| 7 | + expansion) used 0024 first, so this is 0025. Risk-Adaptive Verification |
| 8 | + (a later phase) shifts to 0026 for the same reason. |
| 9 | +- **Context**: everything in `context/` is reactive — given a context and a |
| 10 | + task, `prune.py`/`rank.py`/`classify.py` decide what to drop or how to |
| 11 | + order it. An engineering map done to scope the "Adaptive Context Engine" |
| 12 | + proposal found three things genuinely missing: no function computes a |
| 13 | + **trigger** (is now the moment to push something), no function returns |
| 14 | + **candidates not already in the context**, and no **budget policy** |
| 15 | + beyond a flat `int | None` passed in by the caller. |
| 16 | + |
| 17 | +## Decision |
| 18 | + |
| 19 | +Add `context/adaptive.py` as a thin pre-pass — three pure functions, |
| 20 | +`should_surface`, `plan_budget`, `select` — plus `memory/surface.py` for |
| 21 | +candidate sourcing (forced into `memory/` rather than `context/` by |
| 22 | +CLAUDE.md's dependency rule: `context/` must never import `memory/`). |
| 23 | + |
| 24 | +**The rule that matters more than the functions themselves:** this module |
| 25 | +only ever produces a `list[ContextItem]` for the caller to merge with the |
| 26 | +rest of the context and hand to the existing `ContextPipeline.run`, |
| 27 | +unchanged. It never bypasses that pipeline and never injects between its |
| 28 | +stages. Three concrete failure modes this rule prevents, each traced to a |
| 29 | +specific mechanism in `prune.py`: |
| 30 | + |
| 31 | +1. **Ledger corruption (invariant 2).** `ContextPipeline._stage` is the |
| 32 | + sole writer of the token ledger — each stage's `tokens_before` is |
| 33 | + recomputed from exactly the list the previous stage returned. Injecting |
| 34 | + items outside that chokepoint (before `measured`, between two `_stage` |
| 35 | + calls, or after `_place`) corrupts `tokens_before`, `tokens_after`, or |
| 36 | + both, and therefore `reduction_ratio` — this project's headline number. |
| 37 | +2. **The dedup-before-classify trap.** `dedup` runs first and keys on |
| 38 | + `content_hash`. A pushed memory record duplicating existing context text |
| 39 | + is dropped at that stage, before `classify` ever gets to protect it as |
| 40 | + `CRITICAL` — so a naive merge-then-classify assumption silently loses |
| 41 | + exactly the content a push was meant to add. |
| 42 | +3. **The missing-task trap.** `_enforce_budget` sorts candidates by |
| 43 | + `rank_score`, populated only when the rank stage actually ran against a |
| 44 | + non-empty `task` (`ContextPipeline.run` skips ranking entirely for an |
| 45 | + empty task). `select()` refuses to run with an empty task rather than |
| 46 | + silently populate `rank_score=0` for everything and collapse drop order |
| 47 | + to newest-first — covered by |
| 48 | + `test_no_task_is_refused_rather_than_silently_scored_zero`. |
| 49 | + |
| 50 | +## What each function does |
| 51 | + |
| 52 | +- **`should_surface(health) -> ContextTrigger | None`** — pure over |
| 53 | + `compute_health`'s existing output. Fires on high window usage (≥75%) or |
| 54 | + low relevant ratio (≤50%); both thresholds are stated as round numbers |
| 55 | + with no pilot behind them yet, the same honesty `deterministic.py`'s |
| 56 | + `_SUSPICIOUS_DUPLICATE_SHARE` states about itself. |
| 57 | +- **`plan_budget(counter, health, ratio=0.15) -> BudgetPlan`** — `basis` is |
| 58 | + never omitted, mirroring invariant 3's rule for `TokenCount`. The default |
| 59 | + ratio (15% of the window) is deliberately conservative: Gloaguen et al. |
| 60 | + (arXiv:2602.11988) found unconditional repository-level context injection |
| 61 | + raised inference cost over 20% with no task-success gain, and a careless |
| 62 | + adaptive-push policy risks reproducing exactly that finding instead of |
| 63 | + avoiding it. |
| 64 | +- **`select(candidates, task, plan, ranker) -> SurfaceDecision`** — ranks |
| 65 | + candidates against `task` via the existing `ContextRanker`, then keeps |
| 66 | + what fits `plan.budget` greedily in rank order. Returns items that are |
| 67 | + RANKED, not yet BUDGETED or PROTECTED — that happens only once the caller |
| 68 | + hands the merged list to `ContextPipeline.run`. |
| 69 | +- **`memory/surface.py::candidates_for(store, task, counter)`** — converts |
| 70 | + active decisions, hard/soft constraints, discoveries, and unresolved |
| 71 | + failures into `ItemKind.MEMORY` items, which `classify.py:230-231` |
| 72 | + already protects as `CRITICAL` unconditionally. Uses a local content hash |
| 73 | + rather than importing `context/classify.py`'s, since only |
| 74 | + `memory -> context.tokenizer` is a declared dependency edge — adding |
| 75 | + `memory -> context.classify` would be an undeclared one. |
| 76 | + |
| 77 | +## Consequences |
| 78 | + |
| 79 | +- `core/models.py` gains `ContextTrigger`, `BudgetPlan`, `SurfaceDecision` |
| 80 | + — no existing model changed. |
| 81 | +- New tests (`test_adaptive.py`, `test_memory_surface.py`) include two that |
| 82 | + run adaptive output through the real `ContextPipeline.run` and assert |
| 83 | + invariant 1 (critical retention) and invariant 2 (ledger chaining) still |
| 84 | + hold post-merge — not new guarantees this module invents, but |
| 85 | + confirmation that it doesn't break the ones that already exist. |
| 86 | +- **Not done here, and stated plainly:** this ADR does not wire |
| 87 | + `should_surface`/`select` into `cli/main.py` or `mcp/server.py`, and does |
| 88 | + not run a `verity eval` pilot comparing adaptive-surfacing against a |
| 89 | + no-injection control. Both are natural next steps — the second is |
| 90 | + actually necessary before any claim about this mechanism's effect could |
| 91 | + be published, per invariant 7 (Phase 0) — but they are future work, not |
| 92 | + claimed as complete by adding these three functions. |
| 93 | +- The threshold constants (`_HIGH_WINDOW_USAGE`, `_LOW_RELEVANT_RATIO`, |
| 94 | + `_DEFAULT_BUDGET_RATIO`) are placeholders pending exactly that pilot — |
| 95 | + they should not be read as tuned values. |
0 commit comments