Skip to content

Commit 7775c48

Browse files
Juanpacolclaude
andcommitted
feat(context): add an adaptive surfacing pre-pass, never a pipeline bypass (Phase 4)
Everything in context/ is reactive: given a context and a task, decide what to drop or how to order it. Nothing computed a trigger (is now the moment to push something), a what-to-add (candidates not already in the context), or a budget policy beyond a flat int|None from the caller. Adds context/adaptive.py (should_surface, plan_budget, select) and memory/surface.py (candidates_for), forced into memory/ rather than context/ by CLAUDE.md's dependency rule. The rule that matters more than the functions themselves: this module only ever produces a list[ContextItem] for the caller to merge and hand to the existing ContextPipeline.run, unchanged -- it never bypasses that pipeline or injects between its stages. Three failure modes this prevents, each traced to a specific prune.py mechanism: ledger corruption (only _stage writes the token accounting), the dedup-before-classify trap (a pushed duplicate is dropped before classify can protect it as CRITICAL), and the missing-task trap (_enforce_budget's rank_score only populates when a task was actually ranked against -- select() refuses to run without one rather than silently collapse drop order). plan_budget's conservative default (15% of window) is a direct response to Gloaguen et al. (arXiv:2602.11988): unconditional context injection measured elsewhere as +20% cost with no success gain. New tests run adaptive output through the real ContextPipeline.run and assert invariants 1 and 2 still hold post-merge, rather than inventing new guarantees. Explicitly not done here: CLI/MCP wiring and a verity eval pilot comparing adaptive surfacing to a no-injection control -- stated as future work, required before any effect claim per invariant 7. New ADR-0025 (renumbered from the plan's 0024, which Phase 3 used first). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f7382d9 commit 7775c48

7 files changed

Lines changed: 583 additions & 0 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ verdict was "this pilot could not have detected an effect."
4242
| [0022](0022-verity-eval-harness.md) | Can a real trial harness replace hand-run, hand-scored pilots? | Yes — `verity eval` retains a content-hashed artifact per trial and flags degenerate noise floors instead of hiding them; reproduced pilot 8's result exactly |
4343
| [0023](0023-memory-surfacing-log.md) | Can this project measure *when* memory was surfaced, not just when it was written? | Yes, narrowly — a new `Surfacing` record, emitted from `build_handoff` and decision resurfacing; "was it used" stays honestly unresolved except one negative signal |
4444
| [0024](0024-reality-check-expansion.md) | Can Agent Reality Check widen its recall without repeating ADR-0021's mistake? | Yes — `imports`, negation, multi-target relations, and constraints-as-evidence, each narrow and each declining to guess where the graph has no adjudicating edge |
45+
| [0025](0025-adaptive-context-prepass.md) | Can Verity proactively surface context without breaking the prune pipeline's invariants? | Yes, as a pre-pass only — `context/adaptive.py` + `memory/surface.py` merge into `ContextPipeline.run` unchanged; wiring and a measured pilot are stated as future work, not done here |
4546

4647
## Pre-pivot (superseded)
4748

src/verityai/context/adaptive.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""A proactive pre-pass: decide what to surface and when, before pruning.
2+
3+
Everything in `prune.py`, `rank.py`, and `classify.py` is reactive -- given
4+
a context and a task, decide what to drop or how to order it. Nothing
5+
computes a *trigger* (is now the moment to push something), a *what-to-add*
6+
(candidates not already in the context), or a *budget policy* (how much
7+
room a push gets, and why). This module is that missing layer, and it is
8+
deliberately thin: every function here is pure, and the one rule that
9+
matters more than any of them is in `select()`'s docstring below.
10+
11+
**Hard rule, non-negotiable (ADR-0025):** this module only ever produces a
12+
`list[ContextItem]` for the caller to hand to the existing
13+
`ContextPipeline.run`, unchanged. It never bypasses that pipeline and never
14+
injects items between its stages. `prune.py`'s ledger chains (invariant 2)
15+
only because `ContextPipeline._stage` is the sole writer of token
16+
accounting; injecting outside that chokepoint corrupts `tokens_before`,
17+
`tokens_after`, and therefore `reduction_ratio` -- the project's headline
18+
number. `context/` must never import `memory/` (CLAUDE.md's dependency
19+
rule), so candidate *sourcing* lives in `memory/surface.py`; this module
20+
only ranks and budgets what it is handed.
21+
"""
22+
23+
from verityai.context.rank import ContextRanker
24+
from verityai.context.tokenizer import TokenCounter
25+
from verityai.core.models import (
26+
BudgetPlan,
27+
ContextHealth,
28+
ContextItem,
29+
ContextTrigger,
30+
SurfaceDecision,
31+
)
32+
33+
# Health thresholds that justify a proactive push. Chosen the same way
34+
# `bench/deterministic.py`'s `_SUSPICIOUS_DUPLICATE_SHARE` was: a round
35+
# number that names a real condition worth looking at, not an empirically
36+
# tuned cutoff -- there is no pilot behind these yet (see ADR-0025's stated
37+
# limits).
38+
_HIGH_WINDOW_USAGE = 0.75
39+
_LOW_RELEVANT_RATIO = 0.5
40+
41+
# Default share of the window a push may use. Conservative on purpose: the
42+
# AGENTS.md finding this project's own research review cites (arXiv:2602.11988)
43+
# is that unconditional context injection cost over 20% with no success
44+
# gain -- a budget policy that pushes too eagerly risks reproducing exactly
45+
# that result instead of avoiding it.
46+
_DEFAULT_BUDGET_RATIO = 0.15
47+
48+
49+
def should_surface(health: ContextHealth) -> ContextTrigger | None:
50+
"""Does this context's health justify pushing something into it now?
51+
52+
Pure over `compute_health`'s existing output -- no new state, no new
53+
measurement. Returns `None` when nothing in `health` crosses a
54+
threshold; a caller must not surface anything without a stated reason,
55+
the same discipline every other degraded/triggered path in this
56+
codebase follows (invariant 5).
57+
"""
58+
if health.window_usage >= _HIGH_WINDOW_USAGE:
59+
return ContextTrigger(
60+
reason=f"window usage {health.window_usage:.0%} >= {_HIGH_WINDOW_USAGE:.0%}",
61+
health_snapshot={"window_usage": health.window_usage},
62+
)
63+
if health.relevant_ratio <= _LOW_RELEVANT_RATIO:
64+
return ContextTrigger(
65+
reason=f"relevant ratio {health.relevant_ratio:.0%} <= {_LOW_RELEVANT_RATIO:.0%}",
66+
health_snapshot={"relevant_ratio": health.relevant_ratio},
67+
)
68+
return None
69+
70+
71+
def plan_budget(
72+
counter: TokenCounter,
73+
health: ContextHealth,
74+
ratio: float = _DEFAULT_BUDGET_RATIO,
75+
) -> BudgetPlan:
76+
"""How many tokens a proactive push gets, and the reasoning behind it.
77+
78+
`basis` is never omitted -- a budget number without it is exactly the
79+
bare-int mistake invariant 3 exists to prevent for `TokenCount`, applied
80+
to a different field.
81+
"""
82+
window = counter.window
83+
budget = max(0, int(window * ratio))
84+
return BudgetPlan(
85+
budget=budget,
86+
window=window,
87+
basis=(
88+
f"{ratio:.0%} of the {window:,}-token window, deliberately conservative "
89+
"(Gloaguen et al., arXiv:2602.11988, found unconditional repository-level "
90+
"context injection raised inference cost >20% with no task-success gain; "
91+
"see ADR-0025)"
92+
),
93+
)
94+
95+
96+
def select(
97+
candidates: list[ContextItem],
98+
task: str,
99+
plan: BudgetPlan,
100+
ranker: ContextRanker | None = None,
101+
) -> SurfaceDecision:
102+
"""Rank `candidates` against `task` and keep what fits `plan.budget`.
103+
104+
Requires a non-empty `task`: `prune.py`'s `_enforce_budget` sorts by
105+
`rank_score`, which is only populated when ranking actually ran against
106+
a task (`ContextPipeline.run` skips the rank stage entirely for an
107+
empty task). A caller that surfaces items with no task string gets
108+
`rank_score` defaulting to zero for all of them, which silently
109+
collapses drop order to newest-first -- this function refuses that
110+
case outright rather than let it happen invisibly.
111+
112+
The returned items are RANKED, not yet BUDGETED or PROTECTED: this is a
113+
pre-pass. The caller must still hand `SurfaceDecision.items` (merged
114+
with the rest of the context) to `ContextPipeline.run`, which is what
115+
actually classifies, dedups, and enforces the budget. Selecting here and
116+
skipping that step would bypass every invariant that pipeline provides.
117+
"""
118+
if not task:
119+
return SurfaceDecision(
120+
items=[],
121+
plan=plan,
122+
degraded_reason=(
123+
"no task provided -- ranking against nothing would score every "
124+
"candidate zero and select in an undefined order"
125+
),
126+
)
127+
if not candidates:
128+
return SurfaceDecision(items=[], plan=plan, degraded_reason=None)
129+
130+
ranker = ranker or ContextRanker()
131+
ranking = ranker.rank(task, candidates)
132+
133+
selected: list[ContextItem] = []
134+
used = 0
135+
for scored in ranking.items:
136+
if used + scored.item.token_count > plan.budget:
137+
continue
138+
selected.append(scored.item)
139+
used += scored.item.token_count
140+
141+
return SurfaceDecision(
142+
items=selected,
143+
plan=plan,
144+
degraded_reason=ranking.degraded_reason,
145+
)

src/verityai/core/models.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,3 +800,46 @@ class TrialRecord(BaseModel):
800800
@property
801801
def succeeded(self) -> bool:
802802
return self.scorer_exit_code == 0
803+
804+
805+
# --- Adaptive context (proactive surfacing pre-pass) ------------------------
806+
807+
808+
class ContextTrigger(BaseModel):
809+
"""The observation that decided *now* is the moment to surface memory.
810+
811+
Mirrors `relevance_reason` on `ContextItem`: a trigger is always
812+
auditable, never a bare boolean. `should_surface` (`context/adaptive.py`)
813+
is the only producer of these.
814+
"""
815+
816+
reason: str
817+
health_snapshot: dict[str, float] = Field(default_factory=dict)
818+
819+
820+
class BudgetPlan(BaseModel):
821+
"""How much room a proactive surfacing pass has to work with, and why.
822+
823+
`basis` exists for the same reason `TokenCount` is a pair, never a bare
824+
int (invariant 3): a budget number without the reasoning that produced
825+
it invites more confidence than it earned. Never a bare `int`.
826+
"""
827+
828+
budget: int
829+
window: int
830+
basis: str
831+
832+
833+
class SurfaceDecision(BaseModel):
834+
"""What an adaptive pre-pass chose to add, and under what plan.
835+
836+
Deliberately holds `items` for `ContextPipeline.run` to consume
837+
unchanged (ADR-0025) -- this is a pre-pass's output, never a substitute
838+
for running the pipeline. `degraded_reason` follows the same rule as
839+
every other degraded path in this codebase (invariant 5).
840+
"""
841+
842+
items: list[ContextItem] = Field(default_factory=list)
843+
trigger: ContextTrigger | None = None
844+
plan: BudgetPlan | None = None
845+
degraded_reason: str | None = None

src/verityai/memory/surface.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Candidate context items sourced from memory, for a proactive surfacing pass.
2+
3+
Lives in `memory/`, not `context/`, because CLAUDE.md's dependency rule
4+
says `context/` must never import `memory/` — ranking a context and
5+
persisting a decision are independent operations, and an adaptive
6+
"decide what to push" pre-pass (`context/adaptive.py`, ADR-0025) still
7+
needs *something* to source candidates from. This module is that source:
8+
it turns active memory records into `ContextItem`s, and `adaptive.py` hands
9+
the result to the existing `ContextPipeline.run` unchanged. Nothing here
10+
ranks, prunes, or decides a budget — that stays in `context/`.
11+
"""
12+
13+
import hashlib
14+
15+
from verityai.context.tokenizer import TokenCounter
16+
from verityai.core.models import ContextItem, ItemKind
17+
from verityai.memory.store import MemoryStore
18+
19+
20+
# Local, not `context/classify.py`'s `content_hash` -- importing it would
21+
# add a `memory -> context.classify` edge the dependency table does not
22+
# declare (only `memory -> context.tokenizer` is, for handoff's token
23+
# budget). `ContextItem.content_hash` only needs to be a stable identifier
24+
# for dedup, not byte-identical to classify.py's own hashing scheme.
25+
def _content_hash(text: str) -> str:
26+
normalized = " ".join(text.split())
27+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
28+
29+
30+
def candidates_for(
31+
store: MemoryStore,
32+
task: str,
33+
counter: TokenCounter | None = None,
34+
) -> list[ContextItem]:
35+
"""Active decisions, constraints, discoveries, and unresolved failures,
36+
as `ContextItem`s -- candidates for a proactive surfacing pass to offer
37+
`context/adaptive.py::select`.
38+
39+
Every item is `ItemKind.MEMORY`, which `classify.py:230-231` already
40+
classifies `CRITICAL` unconditionally -- these records exist specifically
41+
because they must not be silently dropped, so this reuses that existing
42+
protection rather than inventing a new one. `task` is accepted for a
43+
future ranking pass to use; this function itself does not rank or filter
44+
by relevance, only converts what memory has into the shape `context/`
45+
already knows how to measure and protect.
46+
"""
47+
counter = counter or TokenCounter()
48+
49+
records: list[tuple[str, str]] = []
50+
for decision in store.decisions():
51+
records.append((f"decision: {decision.statement}", str(decision.id)))
52+
for constraint in store.constraints():
53+
marker = "hard constraint" if constraint.hard else "soft constraint"
54+
records.append((f"{marker}: {constraint.statement}", str(constraint.id)))
55+
for discovery in store.discoveries():
56+
records.append((f"discovery: {discovery.statement}", str(discovery.id)))
57+
for failure in store.failures(include_resolved=False):
58+
records.append((f"already tried, did not work: {failure.attempted}", str(failure.id)))
59+
60+
items: list[ContextItem] = []
61+
for index, (text, record_id) in enumerate(records):
62+
count = counter.count(text)
63+
items.append(
64+
ContextItem(
65+
kind=ItemKind.MEMORY,
66+
content=text,
67+
token_count=count.tokens,
68+
token_method=count.method,
69+
original_index=index,
70+
content_hash=_content_hash(text),
71+
metadata={"task": task, "record_id": record_id},
72+
)
73+
)
74+
return items

0 commit comments

Comments
 (0)