Skip to content

Commit c785f01

Browse files
Juanpacolclaude
andcommitted
fix(context): adaptive surfacing dropped the memory most worth recalling (ADR-0029)
Found while writing an MCP test. A saved hard constraint ("must not add a Redis dependency") was not surfaced for the task "rate limiting", and the report said it had been "ranked below the budget cut". The budget was 19,200 tokens and the record was 12. It had not lost a budget contest -- it never entered one. `ContextRanker.rank` returns only what it could score: a candidate sharing no term with the task is absent from the result, not present with score zero. `select()` iterated that list and applied the budget to it, so any record with no lexical overlap was dropped before the budget was consulted. Measured on a real .verity/: task "speed up the cache" surfaced 0 of 4 records, all silently discarded. This inverts the mechanism's purpose. Persisted memory is most valuable when the decisive fact is NOT inferable from the task's wording -- that is exactly ADR-0020's finding, the first pilot in eight to produce a success-rate split. Selecting memory by lexical overlap optimizes against the one case it exists for. And `classify.py` protects MEMORY items as CRITICAL unconditionally, but nothing that never reaches the pipeline can be protected by it. Unscored candidates now rank last instead of vanishing, so every candidate is either selected or genuinely did not fit (invariant 6's principle applied to selection). `degraded_reason` says when this happened and what was done. Keyed on ContextItem.id, not content_hash -- the hash is empty on a plain item, which made every unscored candidate look like a duplicate of every other, caught by a test failing for exactly that reason. Same pass also wires the last two engines to MCP, the CLI having been exercised first as rule 5 requires: - `risk_of_changing` -- per-file tiers with reasons; refuses an empty graph - `should_recall_memory` -- the trigger, budget and records, and it distinguishes "nothing crossed a threshold" from "nothing is saved" This is the third finding in one verification pass with the same shape (0027, 0028, 0029): a collaborator returned less than the caller assumed, the caller could not tell "nothing found" from "nothing looked at", and the resulting message was confidently wrong rather than absent. ADR-0029 names it as a class and the check that catches it. 644 tests pass, lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e2073b8 commit c785f01

8 files changed

Lines changed: 415 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ src/verityai/
125125
│ └── evidence.py the retained artifact: diff + manifest (ADR-0027)
126126
├── analysis/facts.py AST fact extraction (carried over from T6)
127127
├── cli/main.py the verity command
128-
├── mcp/server.py MCP server — 19 tools over the same core
128+
├── mcp/server.py MCP server — 21 tools over the same core
129129
```
130130

131131
### `.verity/` on disk
@@ -209,7 +209,7 @@ has the detail; the short version:
209209
## Development
210210

211211
```bash
212-
pytest tests/ # 634 tests, no network, no services, no fixtures needed
212+
pytest tests/ # 644 tests, no network, no services, no fixtures needed
213213
ruff check src/ tests/
214214
ruff format src/ tests/
215215
```

docs/adr/0025-adaptive-context-prepass.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,14 @@ specific mechanism in `prune.py`:
102102
`critical_retention` against the *merged* list rather than the original
103103
transcript: surfaced items are the CRITICAL ones, so the original baseline
104104
would exclude exactly what is at risk and the check would pass vacuously.
105-
MCP remains deliberately unwired, and **the pilot remains not run** — no
106-
claim about this mechanism's effect is made anywhere.
105+
MCP is now wired too (`risk_of_changing`, `should_recall_memory`), the CLI
106+
having been exercised first as rule 5 requires. **The pilot remains not
107+
run** — no claim about this mechanism's effect is made anywhere.
108+
109+
Wiring MCP immediately surfaced a defect this ADR's own design carried
110+
from the start: `select()` dropped every candidate the BM25 ranker could
111+
not score, which is exactly the memory most worth recalling. See
112+
[ADR-0029](0029-unrankable-memory-is-not-irrelevant-memory.md).
107113
- The threshold constants (`_HIGH_WINDOW_USAGE`, `_LOW_RELEVANT_RATIO`,
108114
`_DEFAULT_BUDGET_RATIO`) are placeholders pending exactly that pilot —
109115
they should not be read as tuned values.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# ADR-0029: Unrankable memory is not irrelevant memory
2+
3+
- **Status**: Accepted
4+
- **Date**: 2026-08-10
5+
- **Context**: found while writing an MCP test for `should_recall_memory`.
6+
A saved hard constraint — "must not add a Redis dependency" — was not
7+
surfaced for the task "rate limiting", and the report said it had been
8+
"ranked below the budget cut". The budget was 19,200 tokens and the record
9+
was 12. It had not lost a budget contest; it had never entered one.
10+
11+
## The defect
12+
13+
`ContextRanker.rank` returns only the candidates it could score. A candidate
14+
sharing no term with the task is **absent from the result**, not present with
15+
score zero:
16+
17+
```
18+
ContextRanker().rank("rate limiting", [redis_constraint, token_bucket_decision])
19+
-> 1 item: score=0.0164 "decision: use a token bucket for rate limiting"
20+
(the Redis constraint is simply not there)
21+
```
22+
23+
`select()` iterated `ranking.items` and applied the budget to that. So any
24+
record with no lexical overlap with the task was dropped **before** the budget
25+
was consulted, and then reported as a budget outcome. Measured on a real
26+
`.verity/`: task "speed up the cache" surfaced **0 of 4** records, all four
27+
silently discarded.
28+
29+
Three things make this worse than a ranking imperfection:
30+
31+
1. **It removes exactly the records worth recalling.** The value of persisted
32+
memory is highest when the decisive fact is *not* inferable from the task's
33+
wording. That is [ADR-0020](0020-arbitrary-tiebreak-pilot.md)'s finding —
34+
the first pilot in eight to produce a success-rate split did so precisely
35+
because the correct answer had no linguistic convention pointing at it.
36+
Selecting memory by lexical overlap optimizes against the one case the
37+
mechanism exists for.
38+
2. **`classify.py` protects MEMORY items as CRITICAL unconditionally**, and
39+
the prune pipeline honours that. But nothing that never reaches the
40+
pipeline can be protected by it. The guarantee was real and simply
41+
unreachable.
42+
3. **The explanation was wrong, not merely missing.** "Ranked below the budget
43+
cut" tells a reader to raise the budget. Raising it would have changed
44+
nothing.
45+
46+
## Decision
47+
48+
Unscored candidates are ordered **last**, not dropped:
49+
50+
```python
51+
scored_ids = {scored.item.id for scored in ranking.items}
52+
ordered = [scored.item for scored in ranking.items]
53+
unscored = [item for item in candidates if item.id not in scored_ids]
54+
ordered.extend(unscored)
55+
```
56+
57+
Relevance still orders the budget; it can no longer make a record vanish
58+
before the budget applies. This is invariant 6's principle — *the parts must
59+
sum to the whole* — applied to selection rather than to parsing: every
60+
candidate is now either selected or genuinely did not fit, and
61+
`test_nothing_is_lost_between_candidates_and_the_budget` asserts it.
62+
63+
When any candidate was unrankable, `degraded_reason` says so and says what
64+
was done about it, distinct from the ranker's own degradation:
65+
66+
```
67+
degraded no embed_fn configured; 4 candidate(s) share no term with the task
68+
and could not be ranked; they were ordered last rather than dropped,
69+
so a constraint with no lexical overlap can still be surfaced
70+
```
71+
72+
Keyed on `ContextItem.id`, not `content_hash`: the hash is populated by
73+
whoever built the item and is empty on a plain one, which would make every
74+
unscored candidate look like a duplicate of every other. (Found by a test
75+
failing for that exact reason.)
76+
77+
## Consequences
78+
79+
- On the real `.verity/` used to check this, task "speed up the cache" goes
80+
from 0 of 4 records surfaced to 4 of 4, with the reason stated.
81+
- Budgets now bind more often, which is the correct pressure: the point of
82+
`plan_budget`'s conservative 15% default is to bound cost, and it can only
83+
do that against candidates that actually reach it.
84+
- **Not addressed here:** ordering *among* unscored candidates is their
85+
original order, which is arbitrary with respect to importance. A hard
86+
constraint and a stale discovery compete on nothing but position. Ranking
87+
memory by kind and recency rather than by term overlap is the real fix, and
88+
it needs a pilot to justify a specific ordering rather than a guess — the
89+
same reason ADR-0025's thresholds are still placeholders.
90+
- Embeddings would reduce the incidence (a semantic ranker scores everything)
91+
but not the defect: `rank()` would still be free to return a short list, and
92+
`select()` must not assume otherwise.
93+
94+
## The pattern, now three for three
95+
96+
This is the third finding in one verification pass with the same shape, and at
97+
this point it is worth naming as a class rather than three coincidences:
98+
99+
| | What was silently returned | What the caller reported |
100+
|---|---|---|
101+
| [0027](0027-retained-trial-evidence.md) | a hash of a tree, not the tree | "retained, re-derivable" |
102+
| [0028](0028-the-mocked-test-that-could-not-fail.md) | zero graph nodes, wrong path form | `low` risk — "needs no scrutiny" |
103+
| 0029 | fewer items than were passed in | "ranked below the budget cut" |
104+
105+
In each case a collaborator returned less than the caller assumed, the caller
106+
had no way to distinguish "nothing found" from "nothing looked at", and the
107+
resulting message was **confidently wrong** rather than absent. The check that
108+
would have caught all three is the same one: *when a function returns fewer
109+
things than it was given, does anything assert the difference is accounted
110+
for?* That question is now a test in all three places.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ verdict was "this pilot could not have detected an effect."
4646
| [0026](0026-risk-adaptive-verification.md) | Can verification depth scale with file risk, using only signals already in the graph? | Yes — `classify_file_risk` tiers by path convention / blast radius / fan-in / untested symbols; `rules_for_tier` gates rule depth; both builtin rules tagged with risk tiers and sql-injection caveat backfilled |
4747
| [0027](0027-retained-trial-evidence.md) | Did ADR-0022 actually close invariant 7? | **No** — six checkable failures, including a default output path inside `.gitignore` and a CLI that could not express the metric ADR-0022 claimed to reproduce. Evidence is now a diff against a hash-pinned fixture; unretained ⇒ unpublishable, mechanically |
4848
| [0028](0028-the-mocked-test-that-could-not-fail.md) | Did ADR-0026's risk tiering work outside its own tests? | **No** — any path form but the ingester's silently yielded zero signals and a clean-looking `low` for every file. The suite's only `MagicMock` could not express the difference; tests rewritten against a real graph, and `unittest.mock` is gone |
49+
| [0029](0029-unrankable-memory-is-not-irrelevant-memory.md) | Does adaptive surfacing recall the records that matter most? | **It did the opposite** — BM25 omits zero-overlap candidates entirely, so a constraint sharing no word with the task was dropped before the budget applied and reported as a budget outcome. 0 of 4 records surfaced on a real store. Unscored candidates now rank last instead of vanishing |
4950

5051
## Pre-pivot (superseded)
5152

src/verityai/context/adaptive.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,46 @@ def select(
152152
ranker = ranker or ContextRanker()
153153
ranking = ranker.rank(task, candidates)
154154

155+
# `ContextRanker.rank` returns only what it could score: a candidate whose
156+
# text shares no term with the task is absent from `ranking.items`, not
157+
# present with score zero. Selecting from that list alone would silently
158+
# lose exactly the record most worth recalling -- a hard constraint like
159+
# "must not add a Redis dependency" against the task "speed up the cache"
160+
# has no lexical overlap at all, and pilot 8's whole finding is that the
161+
# decisive fact is the one that cannot be inferred from the wording.
162+
#
163+
# So unscored candidates are ranked *last*, not dropped. Relevance still
164+
# orders the budget; it just cannot make a record vanish before the budget
165+
# is considered. Invariant 6's principle -- the parts must sum to the
166+
# whole -- applied to selection rather than to parsing.
167+
# Keyed on `ContextItem.id`, which always exists. `content_hash` is
168+
# populated by whoever built the item and is empty on a plain one, so
169+
# using it here would make every unscored candidate look like a duplicate
170+
# of every other.
171+
scored_ids = {scored.item.id for scored in ranking.items}
172+
ordered = [scored.item for scored in ranking.items]
173+
unscored = [item for item in candidates if item.id not in scored_ids]
174+
ordered.extend(unscored)
175+
155176
selected: list[ContextItem] = []
156177
used = 0
157-
for scored in ranking.items:
158-
if used + scored.item.token_count > plan.budget:
178+
for item in ordered:
179+
if used + item.token_count > plan.budget:
159180
continue
160-
selected.append(scored.item)
161-
used += scored.item.token_count
181+
selected.append(item)
182+
used += item.token_count
183+
184+
reasons = [ranking.degraded_reason] if ranking.degraded_reason else []
185+
if unscored:
186+
reasons.append(
187+
f"{len(unscored)} candidate(s) share no term with the task and could not be "
188+
"ranked; they were ordered last rather than dropped, so a constraint with no "
189+
"lexical overlap can still be surfaced"
190+
)
162191

163192
return SurfaceDecision(
164193
items=selected,
165194
plan=plan,
166195
trigger=trigger,
167-
degraded_reason=ranking.degraded_reason,
196+
degraded_reason="; ".join(reasons) or None,
168197
)

src/verityai/mcp/server.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,123 @@ def check_security() -> str:
395395
report = scan_repo(store.root.parent)
396396
return render_report(report, title="SECURITY", caveats=caveats_for(report.violations))
397397

398+
@server.tool()
399+
def risk_of_changing(paths: list[str]) -> str:
400+
"""Tier files you are about to change: how much verification each earns.
401+
402+
Call this before editing several files at once, or when deciding
403+
where to spend review effort. Returns low/medium/high per file with
404+
the reasons behind it -- blast radius, fan-in, untested public
405+
symbols, and path conventions like `auth/` or `migrations/`.
406+
407+
A tier is a *depth*, not a finding: "high" does not mean the file is
408+
broken, it means a change there deserves more scrutiny than a change
409+
to a leaf. Nothing here gates the security scan -- run `check_security`
410+
as well, always.
411+
412+
Paths must be repo-relative as `build_code_graph` stored them
413+
(`src/pkg/mod.py`). Absolute paths are relativized when possible; a
414+
path that cannot be resolved is reported as such rather than silently
415+
tiered low, since "no signals found" and "no risk found" are different
416+
answers.
417+
"""
418+
from verityai.graph.query import GraphQuery
419+
from verityai.reliability.risk import classify_paths
420+
421+
if not paths:
422+
return "No paths given. Pass the files you are about to change."
423+
424+
store = _store()
425+
with _graph() as graph:
426+
if not graph.stats().get("nodes.total"):
427+
return (
428+
"The code graph is empty, so every file would tier 'low' for lack of "
429+
"signals -- which would read as 'nothing needs scrutiny' when nothing "
430+
"was measured. Call build_code_graph first."
431+
)
432+
verdicts = classify_paths(paths, GraphQuery(graph), repo_root=store.root.parent)
433+
434+
order = {"high": 0, "medium": 1, "low": 2}
435+
lines: list[str] = []
436+
for path, (tier, reasons) in sorted(
437+
verdicts.items(), key=lambda kv: (order[kv[1][0]], kv[0])
438+
):
439+
lines.append(f"[{tier.upper()}] {path}")
440+
lines.extend(f" {reason}" for reason in reasons)
441+
return "\n".join(lines)
442+
443+
@server.tool()
444+
def should_recall_memory(task: str, context_sample: str = "") -> str:
445+
"""Ask whether now is the moment to pull saved decisions back in.
446+
447+
Call this when a task has been running for a while, when you notice
448+
you are re-deriving something, or before starting a subtask -- the
449+
cases where an agent typically *does not* think to check its own
450+
memory, which is exactly why this exists as a prompt-able tool.
451+
452+
`context_sample` is whatever slice of your working context you can
453+
pass; the answer is only about what you hand over (Verity cannot see
454+
your window). With no sample this reports what is on file without a
455+
trigger judgement.
456+
457+
Returns the trigger and its threshold, the budget and its basis, and
458+
the records worth surfacing -- or says plainly that nothing crossed a
459+
threshold, which is a different answer from "there is nothing saved".
460+
"""
461+
from verityai.context.adaptive import (
462+
no_trigger_reason,
463+
plan_budget,
464+
select,
465+
should_surface,
466+
)
467+
from verityai.memory.surface import candidates_for
468+
469+
store = _store()
470+
counter = TokenCounter()
471+
candidates = candidates_for(store, task, counter)
472+
if not candidates:
473+
return "Nothing is saved in .verity/ yet, so there is nothing to recall."
474+
475+
if not context_sample.strip():
476+
lines = [
477+
f"No context sample given, so no trigger was computed. {len(candidates)} "
478+
"record(s) are on file:",
479+
]
480+
lines.extend(f" - {' '.join(c.content.split())[:100]}" for c in candidates[:10])
481+
return "\n".join(lines)
482+
483+
items = load(context_sample)
484+
pipeline = ContextPipeline(counter=counter)
485+
measured = [pipeline.measure(item, n) for n, item in enumerate(items)]
486+
health = compute_health(classify_all(measured), counter=counter)
487+
488+
trigger = should_surface(health)
489+
if trigger is None:
490+
return (
491+
f"No trigger: {no_trigger_reason(health)}.\n"
492+
f"{len(candidates)} record(s) are on file and none are being pushed. "
493+
"This is a judgement about the context you passed, not a claim that "
494+
"the records are irrelevant -- call get_state to read them anyway."
495+
)
496+
497+
plan = plan_budget(counter, health)
498+
decision = select(candidates, task, plan, trigger=trigger)
499+
500+
lines = [
501+
f"RECALL NOW: {trigger.reason}",
502+
f" budget {plan.budget:,} of {plan.window:,} tokens",
503+
f" basis {plan.basis}",
504+
"",
505+
]
506+
if decision.degraded_reason:
507+
lines.append(f" degraded: {decision.degraded_reason}")
508+
lines.append("")
509+
lines.extend(f" - {' '.join(item.content.split())[:160]}" for item in decision.items)
510+
withheld = len(candidates) - len(decision.items)
511+
if withheld:
512+
lines.append(f"\n ({withheld} more ranked below the budget cut; get_state has all.)")
513+
return "\n".join(lines)
514+
398515
@server.tool()
399516
def check_architecture() -> str:
400517
"""Check every import against the project's declared dependency policy.

0 commit comments

Comments
 (0)