docs/design/2026-08-08-native-memory-recall-reliability.md
Managed-memory recall starts asynchronously for each user query. The initial request originally performed a zero-wait consume, so a useful selector result could miss the first prompt. If the turn has no tool call, that result has no later safe delivery point and is discarded.
A fixed 100 ms initial budget was the first attempt at a fix. Measurement
showed it is not sufficient on its own. Recall awaits the model selector
whenever a Config is present — the normal case — and that selector is a
network side query whose abort ceiling is 30 s. The budget is therefore
dominated by round-trip time, not by the incidental scheduler timing it was
sized for, so it expires on the common path. Delivery then falls through to the
ToolResult point, which a tool-free turn never reaches. Tool-free turns are
exactly the ones where user-level memory matters most: short questions answered
from context rather than from the repository.
The model selector remains the normal precision gate. Its failure fallback had two independent correctness problems: it tokenized only ASCII text and gave every non-empty document a positive score even without a lexical match.
Keep a single recall lifecycle and model-primary selection. Add one deterministic delivery stage in front of it — not the two-stage shared-scan Fast/Refined architecture originally proposed in RFC #7040.
selectModelCandidateDocuments already computes lexically ranked candidates
in order to build the model manifest, so the fast result reuses them and
costs no extra scan or I/O. It is capped at two documents
(MAX_FAST_RECALL_DOCS), well below the five-document prompt limit, because
it carries no model judgement.already_delivered; when the selector returned no documents at all, record
no_relevant_results.The 100 ms budget stays internal, per RFC #7040's direction of a small fixed internal budget determined by benchmark rather than exposed as public configuration; telemetry can show whether a later change is justified.
The fast result is published once recall has enumerated, read, and parsed the
memory tree — and this design removed the 200-document cap for recall, so that
scan grows with the tree. recall-scan-latency.test.ts measures the wall-clock
time from the recall call to that publication against a real temporary tree:
| topics | median | share of the 100 ms budget |
|---|---|---|
| 200 | ~29 ms | ~29% |
| 500 | ~70 ms | ~70% |
| 1000 | ~130 ms | ~130% |
Two conclusions follow, and neither is visible in the deterministic scoring cost, which is microseconds.
First, for any tree small enough to scan in time — which is the ordinary case, where a user holds tens of topics rather than hundreds — the fast result is in hand long before the ceiling. Spending the remainder waits for a model selector that this design already assumes will miss the budget, so it is close to pure added latency on every user turn. The wait therefore ends on the fast result.
This has a consequence the code does not make obvious, so state it directly:
on the initial turn, once the deterministic scorer matches anything, the fast
result is what gets delivered — regardless of how fast the selector is.
onFastResult is published before recall issues the selector request at all,
so the recall promise is necessarily unsettled when the wait ends on it, and
the "prefer a settled recall" branch is reached only when no fast result
exists: no Config, or nothing matched lexically.
That is the intended trade, not an oversight. A model side query does not complete inside a 100 ms ceiling in production, so arbitrating between the two would spend the remainder of the budget on every turn to win a race that does not occur. Local verification against a loopback selector settling in 15 ms confirms the behaviour and its bound: the fast result is delivered and the model's picks are discarded — while the pre-change build delivered nothing at all on that same turn. The selector's judgement still reaches the model, at the ToolResult delivery point, with the fast documents excluded.
Second, on a slow enough machine the scan alone exceeds the ceiling. The table
above is the conservative measurement; an independent run on faster hardware
recorded 9 ms / 21 ms / 46 ms for the same three sizes, all inside the ceiling.
The crossover is therefore a property of the machine, not a fixed topic count —
somewhere between roughly one thousand topics and never, depending on I/O
speed. Past it, a turn spends the whole budget and still delivers nothing,
which is worse than the zero-wait behaviour this design replaced. Ending the wait early does not fix
that case; it bounds it and removes the cost everywhere else. A persistent
catalog is the actual fix and remains out of scope, per
2026-08-09-bounded-memory-recall-candidates.md.
MAX_RELEVANT_DOCS is per delivery, not per turnMAX_RELEVANT_DOCS = 5 bounds one prompt. It does not bound a turn. A turn
that fast-delivers two documents and then, at ToolResult, delivers five
documents the fast phase did not include puts seven documents in front of
the model. Deduplication removes repeats, not the sum.
This is a deliberate consequence of dropping combined fast/refined budget
accounting, which RFC #7040 originally specified as a fill-to-five limit
across both phases. Keeping the combined limit means carrying a cross-phase
document budget through the delivery path — the same bookkeeping this design
declined for the duplicate-injection risk it introduces. Both prompts stay
individually bounded, each document body is still truncated to
MAX_DOC_BODY_CHARS, and the fast phase is capped at two, so the worst case
is bounded and small; it is simply not five.
Should the aggregate ever need a hard ceiling, the cheap version is to pass
limit - fastDeliveredPaths.size as the refined limit rather than to
reintroduce a second budget.
RFC #7040 specified two results produced from one shared scan, with the refined pass excluding already-delivered fast documents and filling up to a combined five-document limit. The delivery guarantee that design existed to provide is worth having; its machinery is not. A second selection pathway needs its own scan plumbing, its own budget accounting, and cross-phase document bookkeeping — and that bookkeeping is the source of the duplicate-injection class of bug the RFC itself warned about. Reusing the candidates the selector was already going to score gets the same guarantee from one added callback and one exclusion set.
phase and strategy are orthogonalphase is the delivery stage: fast for a deterministic result injected
at budget expiry, refined for the model-selected result. strategy is the
selection method: none, heuristic, or model. They are not redundant
and neither subsumes the other. A fast delivery is always heuristic, but a
refined delivery is model normally and heuristic when the selector failed
and the fallback ran. Reading delivery-stage behaviour off strategy alone
would silently merge "the deterministic result arrived first" with "the model
selector broke".
Improve the deterministic scorer, which now serves both the fast path and the selector-failure fallback:
\p{L}-based rather than [a-z0-9], so Cyrillic, Greek, Arabic,
and accented Latin produce tokens instead of none. CJK is excluded per
character rather than by alternation order, because \p{L} also matches
Han and a Latin-initial run would otherwise swallow the CJK after it and
turn abc漢字 into a single token;feedback before project before
reference before user, and MAX_FAST_RECALL_DOCS takes only the top
two, so a type tie-break would systematically drop user-level memory from
the fast result — the exact case the fast path exists to serve. Input order
as the final key keeps the project-before-user precedence, because recall
concatenates project documents ahead of user ones.2026-08-09-bounded-memory-recall-candidates.md.Recall quality is measured in packages/core/src/memory/recall-eval.test.ts
against a 51-case, 25-document labeled corpus, scored both by the shipped
scorer and by a frozen copy of the pre-change one so "no regression" is
reproducible rather than asserted. Delivery is measured separately in
recall-delivery-eval.test.ts, because a correct selection that never reaches
the model is worth nothing, and scan latency in recall-scan-latency.test.ts,
because a correct selection that is not ready in time reaches nothing either.
The eval prints the corpus size and the Recall@5 a query-blind random scorer would achieve on it (5 of 25 documents, so 20%), and a test keeps that floor at or below 25% with the measured result well clear of it. A small corpus flatters every design; the floor is what makes the headline readable.
already_delivered wherever it is discarded, not only at the ToolResult
consume point. A tool-free turn that delivered everything must not be
counted in the no_safe_delivery_point bucket; a partial overlap still is,
because the documents outside the fast set genuinely had no delivery point.recall-eval.test.ts).semantic-no-lexical slice of the corpus measures this directly — the
shipped scorer and the frozen pre-change scorer both score 0% Recall@5 on
it, so requiring a lexical match did not create the gap, but it does keep
the fast path silent there. This is why the headline tool-free delivery
figure is 92.3% and not 100%: the residual 7.7% is exactly that slice.