- Status: Accepted
- Date: 2026-08-10
- Note on numbering: the implementation plan this work follows originally reserved ADR-0024 for this decision; Phase 3 (Reality Check expansion) used 0024 first, so this is 0025. Risk-Adaptive Verification (a later phase) shifts to 0026 for the same reason.
- Context: everything in
context/is reactive — given a context and a task,prune.py/rank.py/classify.pydecide what to drop or how to order it. An engineering map done to scope the "Adaptive Context Engine" proposal found three things genuinely missing: no function computes a trigger (is now the moment to push something), no function returns candidates not already in the context, and no budget policy beyond a flatint | Nonepassed in by the caller.
Add context/adaptive.py as a thin pre-pass — three pure functions,
should_surface, plan_budget, select — plus memory/surface.py for
candidate sourcing (forced into memory/ rather than context/ by
CLAUDE.md's dependency rule: context/ must never import memory/).
The rule that matters more than the functions themselves: this module
only ever produces a list[ContextItem] for the caller to merge with the
rest of the context and hand to the existing ContextPipeline.run,
unchanged. It never bypasses that pipeline and never injects between its
stages. Three concrete failure modes this rule prevents, each traced to a
specific mechanism in prune.py:
- Ledger corruption (invariant 2).
ContextPipeline._stageis the sole writer of the token ledger — each stage'stokens_beforeis recomputed from exactly the list the previous stage returned. Injecting items outside that chokepoint (beforemeasured, between two_stagecalls, or after_place) corruptstokens_before,tokens_after, or both, and thereforereduction_ratio— this project's headline number. - The dedup-before-classify trap.
dedupruns first and keys oncontent_hash. A pushed memory record duplicating existing context text is dropped at that stage, beforeclassifyever gets to protect it asCRITICAL— so a naive merge-then-classify assumption silently loses exactly the content a push was meant to add. - The missing-task trap.
_enforce_budgetsorts candidates byrank_score, populated only when the rank stage actually ran against a non-emptytask(ContextPipeline.runskips ranking entirely for an empty task).select()refuses to run with an empty task rather than silently populaterank_score=0for everything and collapse drop order to newest-first — covered bytest_no_task_is_refused_rather_than_silently_scored_zero.
should_surface(health) -> ContextTrigger | None— pure overcompute_health's existing output. Fires on high window usage (≥75%) or low relevant ratio (≤50%); both thresholds are stated as round numbers with no pilot behind them yet, the same honestydeterministic.py's_SUSPICIOUS_DUPLICATE_SHAREstates about itself.plan_budget(counter, health, ratio=0.15) -> BudgetPlan—basisis never omitted, mirroring invariant 3's rule forTokenCount. The default ratio (15% of the window) is deliberately conservative: Gloaguen et al. (arXiv:2602.11988) found unconditional repository-level context injection raised inference cost over 20% with no task-success gain, and a careless adaptive-push policy risks reproducing exactly that finding instead of avoiding it.select(candidates, task, plan, ranker) -> SurfaceDecision— ranks candidates againsttaskvia the existingContextRanker, then keeps what fitsplan.budgetgreedily in rank order. Returns items that are RANKED, not yet BUDGETED or PROTECTED — that happens only once the caller hands the merged list toContextPipeline.run.memory/surface.py::candidates_for(store, task, counter)— converts active decisions, hard/soft constraints, discoveries, and unresolved failures intoItemKind.MEMORYitems, whichclassify.py:230-231already protects asCRITICALunconditionally. Uses a local content hash rather than importingcontext/classify.py's, since onlymemory -> context.tokenizeris a declared dependency edge — addingmemory -> context.classifywould be an undeclared one.
-
core/models.pygainsContextTrigger,BudgetPlan,SurfaceDecision— no existing model changed. -
New tests (
test_adaptive.py,test_memory_surface.py) include two that run adaptive output through the realContextPipeline.runand assert invariant 1 (critical retention) and invariant 2 (ledger chaining) still hold post-merge — not new guarantees this module invents, but confirmation that it doesn't break the ones that already exist. -
Not done here, and stated plainly: this ADR does not wire
should_surface/selectintocli/main.pyormcp/server.py, and does not run averity evalpilot comparing adaptive-surfacing against a no-injection control. Both are natural next steps — the second is actually necessary before any claim about this mechanism's effect could be published, per invariant 7 (Phase 0) — but they are future work, not claimed as complete by adding these three functions.Update, 2026-08-10: the CLI half is now done —
verity context --adaptive [--dry-run]. Wiring it surfaced two gaps in this ADR's own design, both invariant-5 violations that only became visible once something had to explain the decision to a user:should_surfacereturningNonecarried no reason (nowno_trigger_reason), andSurfaceDecision.triggerexisted but nothing ever populated it (now aselect(trigger=...)parameter, so the record is complete where it is built rather than patched by each consumer). The CLI also has to computecritical_retentionagainst the merged list rather than the original transcript: surfaced items are the CRITICAL ones, so the original baseline would exclude exactly what is at risk and the check would pass vacuously. MCP is now wired too (risk_of_changing,should_recall_memory), the CLI having been exercised first as rule 5 requires. The pilot remains not run — no claim about this mechanism's effect is made anywhere.Wiring MCP immediately surfaced a defect this ADR's own design carried from the start:
select()dropped every candidate the BM25 ranker could not score, which is exactly the memory most worth recalling. See ADR-0029. -
The threshold constants (
_HIGH_WINDOW_USAGE,_LOW_RELEVANT_RATIO,_DEFAULT_BUDGET_RATIO) are placeholders pending exactly that pilot — they should not be read as tuned values.