Skip to content

feat: bounded recall — capacity budget, trace, anti-hallucination allowlist - #7

Merged
DottytheHomeless merged 1 commit into
mainfrom
feat/recall-contract-and-trace
Jul 16, 2026
Merged

DottytheHomeless merged 1 commit into
mainfrom
feat/recall-contract-and-trace

Conversation

@MXAntian

@MXAntian MXAntian commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

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 / simple extension dependency introduced).

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 + migrations/010-add-recall-traces.sql + 2 MCP 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 deliberately not copied into a second persistence surface. Retention bounded by MNEME_RECALL_TRACE_RETENTION_DAYS (1-365) and MNEME_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 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)

  • Ablation-controlled query expansion point wired into recallMemories.
  • ⚠️ The seed rule (homegrown → garden/harvest/cooking/...) is proof-of-concept only. The intended shape is the hook + query.expand trace 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_memory public limit is now hard-capped at 20 by the recall contract. Callers passing limit > 20 silently clamp — surfaced through the trace's effective_limit and (new) the CLI's capped field. package.json bumps 2.8.0 → 2.9.0 to signal this to caret consumers. Zod schema on the MCP tool declares .max(20) with an explanatory describe() 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

  • Silent 20-cap was invisible: --recall --format json now returns requested_limit / effective_limit / candidate_limit / capped / trace_id alongside the hits. Zod schema on MCP tool tightened to .max(20).describe(...). package.json bumped 2.8.0 → 2.9.0. README (EN + ZH) documents the cap on the recall_memory row.
  • stripHallucinatedMemoryIds one-space bypass: [ id:99] (single leading space after [) used to survive completely undetected — the regex required [ immediately followed by id. Regex now allows optional whitespace: \[\s*id\s*:\s*.../. Regression test added: leading-space variants like [ id:N] cannot bypass the allowlist.

P1

  • access_count bumped for budget-dropped candidates: added _deferAccessBump opt to recallMemories / recallMemoriesHybrid; buildMemoryContext uses 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.
  • Citation-contract header advertised budget-dropped IDs: renderContext now accepts advertisedIds — 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. Trace chars field records the real post-rerender length.
  • New stopwords blocked short conversational queries: used, recently, many, types, different were added to swallow one FTS false-negative pattern but also killed "what have I used recently" and similar. Reverted; comment in FTS_STOP_WORDS explains why. Regression test in query-rewrite.test.mjs proves "what have I used recently" still hits.
  • New tests weren't in CI: .github/workflows/ci.yml now runs recall-contract.test.mjs and query-rewrite.test.mjs.
  • Trace retention sweep hourly-only: added _writesSinceRecallTraceSweep counter — sweeps on either the hourly gate OR every 200 writes, so bursts cannot silently overshoot MNEME_RECALL_TRACE_MAX_ROWS intra-hour.

Migration 010 idempotency + old-DB upgrade were hands-on verified during self-check (built a DB on main, re-opened with this branch, confirmed IF NOT EXISTS upgrade + 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:

suite checks result
node recall-contract.test.mjs 14 (was 13) ✅ all pass
node query-rewrite.test.mjs 6 ✅ all pass
full upstream regression (meta-gate / anchor-pinned / hooks / memory-health / locations / recall-search-regressions / conversation-fallback-dedup / temporal-parser / temporal-recall) 155 ✅ all pass

node --check on every touched file ✅.

Migration + compatibility

  • migrations/010-add-recall-traces.sql — new table only, IF NOT EXISTS guarded, forward-only.
  • recall_traces absence is not fatal — trace persist failure logs and continues.
  • Backward-compatible otherwise: nothing in the public API rejects previously valid inputs. See "Breaking change (v2.9)" above for the limit cap.
  • No sqlite-vec / simple extension dependency introduced.

Follow-ups (not in this PR)

Context

Comes out of 07-14 mneme audit vs 新智元 HMS/Shadow-Weave memory system, follows the merged #6 decay fix. Requesting @xiaomeng review since you own the audit.

…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
MXAntian force-pushed the feat/recall-contract-and-trace branch from d7cf8df to 7c5c4de Compare July 16, 2026 06:26
@MXAntian
MXAntian marked this pull request as ready for review July 16, 2026 06:28
@DottytheHomeless
DottytheHomeless merged commit b869617 into main Jul 16, 2026
2 checks passed
@DottytheHomeless
DottytheHomeless deleted the feat/recall-contract-and-trace branch July 16, 2026 06:41
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants