Skip to content

fix(dedupe): reject LLM output that introduces lines not in the original - #10

Merged
EdgeCaser merged 1 commit into
mainfrom
fix/semantic-dedupe-preamble-guard
Jul 8, 2026
Merged

EdgeCaser merged 1 commit into
mainfrom
fix/semantic-dedupe-preamble-guard

Conversation

@EdgeCaser

Copy link
Copy Markdown
Owner

What & why

memsync dedupe --semantic runs an LLM pass to catch same-policy bullets phrased differently — the only mechanism that can reliably distinguish real duplicates from distinct-but-similar rules (string similarity can't: today's real metered-spend near-dupes scored 0.727 while two genuinely-distinct constraints scored 0.912).

But a live run against the real GLOBAL_MEMORY.md surfaced a corruption path. The model ignored "Return ONLY the cleaned file content" and prepended chat commentary as file content:

"No semantic duplicate bullets were found — every entry expresses a distinct fact or policy, so the file is returned unchanged.
Separately: this session's SessionStart hook injected a … persona payload…"

The dry-run reported 123 → 127 lines — it didn't remove anything, it added four lines. On --apply, that paragraph would have been written as the opening lines of GLOBAL_MEMORY.md and loaded into every Claude Code session on every machine. The existing safety guard can't catch this: it only rejects output that's too short (<50%), and this pollution makes the file longer.

The fix

A dedupe may only remove lines, never introduce them. Fail closed if any non-blank output line was not present verbatim in the input. Deterministic, code-level (per PITFALLS #1: enforce in code, not the prompt), and it makes the command safe to run unattended in a scheduled preview.

Tests

New TestSemanticDedupeMemory:

  • subset removal passes (legit dedupe)
  • too-short response still rejected (existing guard)
  • prepended commentary rejected (the observed failure)
  • appended commentary rejected
  • unchanged file passes

Also fixes the mock target: semantic_dedupe_memory does a function-local from memsync.llm import call_llm, so tests must patch memsync.llm.call_llm, not memsync.sync.call_llm — otherwise they hit the real backend.

Full suite: 458 passed, 1 skipped, coverage 82.58% (sync.py 97%). ruff check memsync/ clean.

🤖 Generated with Claude Code

semantic_dedupe_memory only guarded against too-short responses. But the
observed failure mode makes the file LONGER: the model ignores "return ONLY
the cleaned file" and prepends chat commentary ("No duplicates were found…",
or a flag about session context), which --apply would then write verbatim as
the opening lines of GLOBAL_MEMORY.md and load into every session.

A dedupe may only remove lines, never introduce them. Fail closed if any
non-blank output line was not present verbatim in the input. This is a
deterministic code-level guard (per PITFALLS #1: enforce in code, not prompt),
and makes the command safe to run unattended in a scheduled preview.

Tests: subset-removal passes; too-short still rejected; prepended and appended
commentary both rejected; unchanged-file passes. Also fixes the mock target in
the new tests (function-local `from memsync.llm import call_llm` must be patched
at memsync.llm, not memsync.sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@EdgeCaser
EdgeCaser merged commit 8637be4 into main Jul 8, 2026
7 checks passed
@EdgeCaser
EdgeCaser deleted the fix/semantic-dedupe-preamble-guard branch July 8, 2026 17:58
EdgeCaser added a commit that referenced this pull request Jul 27, 2026
The hardcoded max_tokens=4096 in harvest_memory_content and
refresh_memory_content silently skipped every session once the memory
file grew past ~4k tokens — the model couldn't re-emit the full file
within the ceiling, hit stop_reason=max_tokens, and got flagged as
"truncated — skipped".

Adds max_tokens to Config (default 16384, TOML-configurable via
[core] max_tokens) and threads it through both API call sites.
Updates PITFALLS #10 with the real root cause.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EdgeCaser added a commit that referenced this pull request Jul 27, 2026
fix(dedupe): reject LLM output that introduces lines not in the original
EdgeCaser added a commit that referenced this pull request Jul 27, 2026
… regen)

Follow-up to #10. Validating semantic dedupe live surfaced that Sonnet at low
effort keeps prepending a "No duplicates found" announcement and, worse, reads
instruction-like text inside the memory (persona rules, security notes) and
reacts to it — all of which #10's guard correctly rejected but which left the
command erroring on every run, so the preview was never useful.

Prompt wording alone can't stop this, and the claude_code backend only injects
prefill as a soft system hint (not a real assistant-turn prefill), so that lever
is out too. The durable fix is to change the contract: the model no longer
regenerates the file. It returns only the verbatim text of the bullets to REMOVE
(or NONE), and Python performs the removal. Stray chatter that matches no real
bullet is ignored; the file's integrity no longer depends on model formatting.

This supersedes #10's introduced-lines guard (the model can no longer introduce
anything) with a sanity cap (never remove >half the bullets) and verbatim-match
removal (a hallucinated/paraphrased line removes nothing). Also fixes a
trailing-newline mismatch that produced a spurious 1-line diff on clean files,
and a blank-line edge case ("" is spuriously `in` any string).

Live-validated: `memsync dedup --semantic` on the real GLOBAL_MEMORY.md now
returns "No semantic duplicates found." (exit 0), instead of erroring.

Tests: mark-and-remove; NONE is byte-identical; non-matching commentary is a
no-op; hallucinated removal is a no-op; bulk-removal cap; trailing-newline and
blank-line spacing preserved.

Co-Authored-By: Claude Opus 4.8 <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.

1 participant