Repository navigation
feat: bounded recall — capacity budget, trace, anti-hallucination allowlist - #7
Merged
Merged
Conversation
…owlist
Introduces a recall contract that binds the memory pipeline to hard,
inspectable limits. Recall was previously an unbounded FTS+vec+RRF fusion
whose only output guarantees were "some rows, sorted by score" — no
per-call ceiling on candidates or context tokens, no way to distinguish
what the model saw from what the query asked for, and no defense against
the model fabricating [id:N] citations.
## What this adds
**Capacity budget (recall-contract.mjs)**
- planRecallBudget(requested) — clamps requested -> effective <= 20
and candidates <= 100. All recall paths route through it.
- enforceContextBudget(entries, {maxChars=12000}) — packs memories up
to a hard character ceiling, records the stop reason
(maxEntries / maxChars / natural).
**Recall trace (recall-contract.mjs + migration 010 + mcp-server tools)**
- Every recall creates a traceId and records source, mode, query_hash
(sha256, never raw), query_chars, requested/effective/candidate
limits, kept_ids, per-step timing.
- recall_traces table stores hashes/counts/rowids only — query text and
memory content are deliberately not copied into a second surface
(MNEME_RECALL_TRACE_RETENTION_DAYS / MNEME_RECALL_TRACE_MAX_ROWS
bound retention).
- get_recall_trace(trace_id) MCP tool exposes the trace to auditors.
**Anti-hallucination allowlist (recall-contract.mjs + mcp tool)**
- stripHallucinatedMemoryIds(text, validIds) — regex-strips any [id:N]
in generated text whose N is not in the trace's kept_ids.
- validate_memory_references(trace_id, text) MCP tool — a missing
trace fails closed (strips all IDs), not open.
**Query rewrite hook (query-rewrite.mjs + _noQueryExpansion internal flag)**
- Adds an ablation-controlled query expansion point wired into
recallMemories. Seed rule (homegrown -> garden/harvest/cooking/...)
is proof-of-concept only; the interesting surface is the hook + the
query.expand trace step. A follow-up PR should replace the seed rule
with a proper generalized vocabulary; do not treat the seed rule as
the intended shape.
**FTS score normalization helper (recall-scoring.mjs)**
- normalizedFtsScore(rank, maxMagnitude) — bounded [0,1] transform for
FTS bm25 rank, used inside the recall pipeline to compare against
vec similarity on the same scale.
## Verification (per CONTRIBUTING — hands-on)
recall-contract.test.mjs 13 checks — capacity ceiling honoured on
both direct and hybrid paths, context budget
respects char and entry limits, trace
records the allowlist, missing trace fails
closed, trace persist failure does not fail
the recall read path.
query-rewrite.test.mjs 6 checks — rewrite hook wires into recall,
ablation flag turns it off, added terms
appear in the trace step, unrelated queries
untouched, FTS normalization preserves
saturated distinctions.
All pass under `node <file>`.
## Migration + compatibility
- migrations/010-add-recall-traces.sql — new table only, IF NOT EXISTS
guarded. Forward-only per CONTRIBUTING.
- recall_traces absence is not fatal — trace persist failure logs and
continues. Fresh installs boot without any trace history.
- Backward-compatible: existing callers get bounded results now; nothing
in the public API rejects previously valid inputs.
- No sqlite-vec / simple extension dependency introduced.
## Follow-ups (not in this PR)
- Replace query-rewrite.mjs seed rule with a generalized vocabulary.
- LongMemEval retrieval-gate harness that exercises this contract lands
in a separate PR (nothing product-facing, splits cleanly).
[prediction] fix: recall stops returning unbounded fusion results; every recall becomes inspectable (trace-id -> exact rowids exposed); hallucinated [id:N] citations stripped even when a model confabulates them
[prediction] risk: callers that expected recall.limit > 20 silently cap at 20 — surfaced through effective_limit in the trace, but pre-existing limit=50 callers get 20 without warning; query expansion seed rule is homegrown-specific and may look overfitted, PR body flags it explicitly
[prediction] verify: `node recall-contract.test.mjs` + `node query-rewrite.test.mjs` -> 19 passed; MCP get_recall_trace on a fresh recall returns candidate/effective counts + kept_ids; MCP validate_memory_references on fabricated [id:99999] strips it
MXAntian
force-pushed
the
feat/recall-contract-and-trace
branch
from
July 16, 2026 06:26
d7cf8df to
7c5c4de
Compare
MXAntian
marked this pull request as ready for review
July 16, 2026 06:28
DottytheHomeless
pushed a commit
that referenced
this pull request
Sep 23, 2026
…typed (#44) * fix(v2.11.1): stop the recall feedback loop; recall on what the user typed Four fixes found auditing a long-lived store (10k rows, 14 days of hook logs). 1. recallForClients bumped access for its whole candidate pool. It over-fetches (up to 30 rows when filtering) and trims to the caller's limit, but never passed _deferAccessBump — so every hook call added +1 to ~30 rows while returning 3. buildMemoryContext got this fix in #7; this path was missed. Now only returned rows are bumped. 2. freqScore weight 0.10 -> 0.02. It saturates at 20 accesses, so on a long-lived store it is effectively binary, and 0.10 is ~38 rank positions at RRF's ~0.0026 spacing. With (1) inflating the counts, the loop fed itself: one row reached 46% of two weeks of hook recalls. At 0.02 it is a tiebreak (~8 positions max). 3. The DB path is resolved when the store opens, not at import. A host that imports mneme and then loads its own env sets TOKENMEM_DB_PATH after ES import hoisting has evaluated the module-level const, so the store silently opened the fallback DB. With several tenants on one machine, one tenant's writes landed in another's store. 4. prompt-recall hook: recall on user-authored text only; default to meta+semi; require vector evidence before the trim. - UserPromptSubmit also delivers <task-notification>, <cross-session-message> and prepended <system-reminder> blocks. 631 of 688 fast-path queries were that wrapper text. New userPromptText() strips it. - The triggers are operational (paths, ports, restarts), and the write-time meta gate downgrades anything carrying those to semi_abstract — so a meta-only default could never match the answers the hook is for. - Ask for require_vec first so the few slots are not taken by FTS/entity matches on a shared common noun; retry without it on zero rows so zero-config installs keep plain FTS + the consensus gate. When vectors are present, gate on MNEME_MAX_VEC_DISTANCE (0.95) instead of hit count. Tests: access-bump-scope and db-path-late-env integration tests (both red on main: 12 rows bumped for 2 returned; store opened the fallback path), plus userPromptText cases. Wired into CI. Existing ranking / endpoint / contract / cold-pool / anchor / hygiene / embedding-timeout / meta-gate / hooks suites green. Co-authored-by: 千夏 <qianxia@clawgamers.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hook): one round trip via prefer_vec instead of require-then-retry Self-review P1: the zero-row retry made every zero-config install (no embeddings) pay two sequential HTTP round trips on every trigger, and in the worst case the pair ate the whole safety timer before the CLI fallback could run. prefer_vec / --prefer-vec is the fail-open twin of require_vec: keep only vector-backed rows when any exist, otherwise return the unfiltered rows — one call either way. Servers older than 2.11.1 ignore the field and answer as before. Also: an unterminated <system-reminder> (truncated upstream) is treated as non-user input instead of surviving whole as the query; the known trade-off of pasting text that starts with these tags is documented. Tests: access-bump-scope gains requireVec/preferVec cases on a no-embedding DB (0 rows vs FTS fallback); userPromptText gains the truncated case. Co-authored-by: 千夏 <qianxia@clawgamers.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 千夏 <qianxia@clawgamers.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduces a recall contract binding the memory pipeline to hard, inspectable limits. Recall was previously an unbounded FTS+vec+RRF fusion whose only output guarantees were "some rows, sorted by score" — no per-call ceiling on candidates or context tokens, no way to distinguish what the model saw from what the query asked for, and no defense against fabricated
[id:N]citations.Bridges 3 of the HMS/Shadow-Weave modules we agreed were worth porting (capacity hard-gate + full-pipeline trace + anti-hallucination allowlist) into mneme's existing hybrid retrieval, without hitting any of mneme's fallback contracts (no
sqlite-vec/simpleextension dependency introduced).What this adds
Capacity budget (
recall-contract.mjs)planRecallBudget(requested)— clampsrequested → effective ≤ 20andcandidates ≤ 100. All recall paths route through it.enforceContextBudget(entries, {maxChars=12000})— packs memories up to a hard character ceiling, records the stop reason (maxEntries/maxChars/ natural).Recall trace (
recall-contract.mjs+migrations/010-add-recall-traces.sql+ 2 MCP tools)traceIdand records:source,mode,query_hash(sha256, never raw),query_chars,requested/effective/candidatelimits,kept_ids, per-step timing.recall_tracestable stores hashes/counts/rowids only — query text and memory content deliberately not copied into a second persistence surface. Retention bounded byMNEME_RECALL_TRACE_RETENTION_DAYS(1-365) andMNEME_RECALL_TRACE_MAX_ROWS(100-100000).get_recall_trace(trace_id)MCP tool exposes the trace.Anti-hallucination allowlist (
recall-contract.mjs+ MCP tool)stripHallucinatedMemoryIds(text, validIds)— regex-strips any[id:N]in generated text whoseNis not in the trace'skept_ids.validate_memory_references(trace_id, text)MCP tool — a missing trace fails closed (strips all IDs), not open.Query rewrite hook (
query-rewrite.mjs+_noQueryExpansioninternal flag)recallMemories.homegrown → garden/harvest/cooking/...) is proof-of-concept only. The intended shape is the hook +query.expandtrace step, not the seed vocabulary. A follow-up PR should replace it with a generalized vocabulary. Landing the hook now unblocks the LongMemEval harness (test: LongMemEval retrieval-gate harness with stratified per-category sampling #8) that ablates against it.FTS score normalization (
recall-scoring.mjs)normalizedFtsScore(rank, maxMagnitude)— bounded[0,1]transform for bm25 rank, used inside the recall pipeline so FTS scores can be compared against vec similarity on the same scale.Breaking change (v2.9)
recall_memorypubliclimitis now hard-capped at 20 by the recall contract. Callers passinglimit > 20silently clamp — surfaced through the trace'seffective_limitand (new) the CLI'scappedfield.package.jsonbumps 2.8.0 → 2.9.0 to signal this to caret consumers. Zod schema on the MCP tool declares.max(20)with an explanatorydescribe()so consumers see the ceiling in the schema itself.Self-review round 1 (all fixes applied)
Ran a full fan-out self-check (Claude + Codex + Deepseek + hands-on repro) before requesting review. Findings and fixes in this commit:
P0
--recall --format jsonnow returnsrequested_limit / effective_limit / candidate_limit / capped / trace_idalongside the hits. Zod schema on MCP tool tightened to.max(20).describe(...).package.jsonbumped 2.8.0 → 2.9.0. README (EN + ZH) documents the cap on therecall_memoryrow.stripHallucinatedMemoryIdsone-space bypass:[ id:99](single leading space after[) used to survive completely undetected — the regex required[immediately followed byid. Regex now allows optional whitespace:\[\s*id\s*:\s*.../. Regression test added:leading-space variants like [ id:N] cannot bypass the allowlist.P1
access_countbumped for budget-dropped candidates: added_deferAccessBumpopt torecallMemories/recallMemoriesHybrid;buildMemoryContextuses it and bumps only the rowids that survive the section-level context budget. Restores the "recall → access_count → ranked-higher" feedback-loop invariant this repo already carefully guards.renderContextnow acceptsadvertisedIds— the budget-packing pass uses the upper bound (all candidate rowids) for honest char accounting; the final rendered header shows only the IDs actually injected. Tracecharsfield records the real post-rerender length.used,recently,many,types,differentwere added to swallow one FTS false-negative pattern but also killed"what have I used recently"and similar. Reverted; comment inFTS_STOP_WORDSexplains why. Regression test inquery-rewrite.test.mjsproves"what have I used recently"still hits..github/workflows/ci.ymlnow runsrecall-contract.test.mjsandquery-rewrite.test.mjs._writesSinceRecallTraceSweepcounter — sweeps on either the hourly gate OR every 200 writes, so bursts cannot silently overshootMNEME_RECALL_TRACE_MAX_ROWSintra-hour.Migration 010 idempotency + old-DB upgrade were hands-on verified during self-check (built a DB on
main, re-opened with this branch, confirmedIF NOT EXISTSupgrade + zero data loss). Trace persist failure path audited: no NaN, no content leak, fails open for reads.Verification (per CONTRIBUTING — hands-on)
Post-fix suite:
node recall-contract.test.mjsnode query-rewrite.test.mjsmeta-gate/anchor-pinned/hooks/memory-health/locations/recall-search-regressions/conversation-fallback-dedup/temporal-parser/temporal-recall)node --checkon every touched file ✅.Migration + compatibility
migrations/010-add-recall-traces.sql— new table only,IF NOT EXISTSguarded, forward-only.recall_tracesabsence is not fatal — trace persist failure logs and continues.limitcap.sqlite-vec/simpleextension dependency introduced.Follow-ups (not in this PR)
query-rewrite.mjsseed rule with a proper generalized vocabulary (or jieba-driven expansion).Context
Comes out of 07-14 mneme audit vs 新智元 HMS/Shadow-Weave memory system, follows the merged
#6decay fix. Requesting @xiaomeng review since you own the audit.