Skip to content

feat(memory): kNN-nominate/LLM-decide dedup + lossless merges (memory-core-redesign slice 3) - #1585

Merged
Aaronontheweb merged 7 commits into
netclaw-dev:feature/memory-embeddingsfrom
Aaronontheweb:redesign/slice-3-nominator-lossless-merge
Jul 5, 2026
Merged

feat(memory): kNN-nominate/LLM-decide dedup + lossless merges (memory-core-redesign slice 3)#1585
Aaronontheweb merged 7 commits into
netclaw-dev:feature/memory-embeddingsfrom
Aaronontheweb:redesign/slice-3-nominator-lossless-merge

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Collaborator

Implements Slice 3 of the memory-core-redesign OpenSpec change (#1570): write-side dedup becomes kNN-nominate → LLM-decide, and curation merges become lossless-or-append. Closes tasks 3.1–3.7.

Stage A — lossless merge machinery (landed on this branch previously)

  • Merged-body response protocol (task 3.2): CurationPromptBuilder UPDATE/CONSOLIDATE responses now emit a complete merged document body after a --- separator (CurationDecision.MergedBody); candidates can be rendered full-content for the decider.
  • MergeGuard (task 3.3): deterministic lossless-merge validator — load-bearing tokens (URLs, numbers, versions, dates, code identifiers) from every source body must survive at ≥95% retention, and the merged body must not length-collapse.
  • Guard-validated write routing (task 3.4): every LLM-tier UPDATE/CONSOLIDATE and every deterministic CONSOLIDATE routes through ApplyGuardedMergeOrAppend — guard-pass writes the merged body (MergeDocument), guard-fail or body-absent degrades to a structural append (existing body + dated separator + proposal, AppendDocument — reachable for the first time since the enum was written). The raw markdown_body = excluded.markdown_body overwrite is unreachable from curation decisions. The overwrite-unreachable trace closed three paths: LLM UPDATE (guard or append), LLM CONSOLIDATE (guard or append), and the previously unguarded deterministic Consolidate — pre-Slice-3, a fuzzy-anchor ≥80%-overlap Consolidate reached the store with no guard at all (GuardDestructiveUpdate is a no-op for Consolidate); it now always appends, since the rules tier never synthesizes a merged body. The one deliberate exception: the deterministic exact-anchor UPDATE keeps its raw overwrite because GuardDestructiveUpdate already proves the proposal is a content superset of the target before that decision can exist.
  • Config surface (task 3.5): Memory.Curation { NominatorSimilarityThreshold=0.86, NominatorK=5, LlmMaxOutputTokens=4096, LlmTimeoutSeconds=10 } + schema sync, replacing Slice-1 hardcoded constants.

Stage B — embedding kNN nominator (this PR's new commits)

Evaluation order in the shared MemoryCurationEvaluator (design D4), identical on both write pipelines:

  1. Record bypass and exact-anchor deterministic fast paths — unchanged.
  2. Embedder available + no exact anchor match → embed the proposal ({title}\n{content}, the exact concatenation embed-on-write uses, so proposals and stored documents share embedding space), query MemoryVectorIndex.TopK(NominatorK, NominatorSimilarityThreshold), hydrate nominees into full-content candidates tagged with their cosine (ExistingMemoryCandidate.CosineSimilarity; anchor/lexical candidates carry null).
  3. Any nominee forces the LLM tier with full-content candidate previews — regardless of Jaccard bands.
  4. No nominee + no anchor match → Create with zero LLM calls (median nominee count on a random write is 0 — the common case stays cheap).
  5. Embedder unavailable → the pre-existing lexical content-term search runs unchanged as the degraded path (curation_nominator_degraded marker).

Invariants — cosine nominates ONLY:

  • The May 2026 measurement (ratified in the OpenSpec design; corroborated at corpus scale in the July audit §5) found no cosine threshold separates true duplicates from merely-related siblings — siblings live at 0.905–0.941, inside the duplicate band. Cosine similarity therefore never auto-merges and never auto-skips, anywhere.
  • Nominee present but no LLM available (the daemon checkpoint worker today) or LLM call fails → conservative Create, deliberately bypassing TryAutoResolveAmbiguous: semantic-near content is exactly the ambiguity the May data says deterministic Jaccard logic cannot adjudicate, and a duplicate document is recoverable where a wrong auto-merge is not.
  • Known limitation (documented on NominateAsync): proposals evaluated within one batch cannot nominate each other — neither is committed/indexed while the other is evaluated. Cross-batch and steady-state dedup are unaffected.

Wiring: new MemoryVectorIndexHolder (defers index construction until the warmed-up embedder's model id/dimensions exist — same rationale as MemoryEmbedderHolder) registered in daemon DI and threaded into both pipelines: inline actor via MemoryCurationActor.CreateProps/SessionMemoryServices, daemon worker via MemoryCurationEngine's constructor. New store hydration query SQLiteMemoryStore.GetCandidatesByIdsAsync. New markers: curation_nominated count/topCosine, curation_nominator_degraded, curation_nominee_no_llm_decision; all existing markers intact.

Test matrix (task 3.6 — all synthetic fixtures, hand-crafted cosine geometry)

  • Nomination forcing: paraphrase pair at cosine 0.93 with word-Jaccard <0.4 → LLM tier invoked (recording IChatClient) even though the lexical tier finds zero candidates and would have silently said Create.
  • Sibling never-auto-merge: scripted LLM answers CREATE → two separate documents persist; no-LLM variant → conservative Create — never a merge without the LLM.
  • Novel-skips-curator: no nominee + no anchor → Create with zero IChatClient invocations.
  • Degraded path: unavailable embedder (and null-holder variant) → lexical candidates still produced, curation_nominator_degraded fires.
  • Both-pipelines parity: MemoryCurationEvaluatorParityTests extended — actor-style vs engine-style construction with a shared embedder/index reach identical forced-LLM decisions.
  • Actor e2e (Akka.TestKit): proposal through the real MemoryCurationActor with fake embedder + scripted LLM to a committed store write; AwaitAssertAsync, no sleeps.
  • Stage A matrix (MergeGuard property tests, append fallback, merge routing) landed with Stage A.

Gates

  • Netclaw.Actors.Tests 2614/2614, Netclaw.Daemon.Tests 832/832, Netclaw.Cli.Tests 1231/1231, Netclaw.Configuration.Tests 461/461, Netclaw.Embeddings.Tests 18/18 — all green (Release).
  • Full solution Release build: 0 warnings / 0 errors.
  • dotnet slopwatch analyze: 0 issues. Add-FileHeaders.ps1 -Verify: clean.
  • Memory eval gate (NETCLAW_EVAL_CATEGORY=Memory, Qwen3.6-27B @ spark-acad): scoreboard below. Note: embeddings default OFF, so the nominator idles in the eval daemon — this gate protects the shared curation paths against regressions.
Category: Memory Pipeline
  [PASS] memory_recall_active           5/5 (1.00)  — recall active, not degraded
  [PASS] memory_identity_preference_routing 5/5 (1.00)  — durable user preference routed to memory, not SOUL.md
  [PASS] memory_explicit_store          4/5 (0.80)  — explicit remember request uses store_memory
  [PASS] memory_checkpoint_enqueue      5/5 (1.00)  — checkpoint enqueued for non-identity fact
  [PASS] memory_recall_filters          5/5 (1.00)  — candidate selection with score filtering
  Category: 5/5 passed (GREEN)

─────────────────────────────────────────────────
Overall: 5/5 cases passed (100.0%)

(An earlier run of the same gate scored 4/5: memory_checkpoint_enqueue 3/5 — investigated per the eval-debugging protocol, the two failing runs' stdout contained only [error] LLM provider transport error: Connection refused (spark-acad…:8000) — the remote endpoint dropped mid-suite, so those turns never executed. Endpoint recovered; the clean rerun above is the gate result.)

Skill sync

netclaw-memory SKILL.md bumped 1.8.0 → 1.9.0: documents that with embeddings enabled, near-duplicate proposals are adjudicated by the curation LLM (skip/update/consolidate/create) — similarity alone never merges — and merges are lossless-or-append.

OpenSpec change: memory-core-redesign (#1570) — tasks 3.1–3.7 checked.

…re-redesign slice 3)

MemoryConfig gains Curation { NominatorSimilarityThreshold, NominatorK,
LlmMaxOutputTokens, LlmTimeoutSeconds } with schema sync (defaults,
additionalProperties: false). Nominator threshold/K are defined now with doc
comments noting they are consumed by Slice 3 Stage B (task 3.1), not this
change. Task 3.5.
…psx: memory-core-redesign slice 3)

CurationPromptBuilder's system prompt now instructs the curator to emit a
'---'-delimited lossless merged body after UPDATE/CONSOLIDATE keyword lines;
SKIP/CREATE remain keyword-only. ParseResponse extracts the optional body into
the new CurationDecision.MergedBody (absent/malformed body treated as null,
keyword-only responses remain valid). CurationDecision also gains FromLlmTier,
distinguishing LLM-synthesized decisions from the deterministic rules tier for
write-routing purposes (wired in the next commit). BuildUserMessage gains a
useFullCandidateContent parameter (default false, current 700-char preview
behavior) for Stage B's full-content nominated candidates — not consumed yet.

Task 3.2.
…: memory-core-redesign slice 3)

New deterministic MergeGuard.Validate(sourceBodies, mergedBody) -> pure
function checking (1) retention: >=95% of the union of load-bearing tokens
(URLs, numbers/versions/quantities/dates, camelCase/snake_case/kebab-case/
dotted.path/ALL_CAPS identifiers, file paths) extracted from every source body
must survive case-insensitively in the merged body, and (2) collapse: merged
length must be >=60% of the longest single source. Converts an LLM merge error
from silent data loss into a recoverable append-fallback signal (design D5);
wired into the write path in the next commit.

Task 3.3.
…s (opsx: memory-core-redesign slice 3)

MemoryCurationEvaluator.ApplyDecisionAsync now routes every LLM-tier
UPDATE/CONSOLIDATE decision through MergeGuard-validated merge or a structural
append fallback (existing body + dated separator + proposal, AppendDocument
semantics) instead of a raw overwrite — this is what makes AppendDocument a
real, reachable write path for the first time. EvaluateAsync returns a new
CurationEvaluation (Decision + Candidates) so ApplyDecisionAsync can validate
against the same candidate bodies the decision was made against without
re-querying the store; both callers (MemoryCurationActor, MemoryCurationEngine)
and the parity tests are updated for the new signature.

GuardDestructiveUpdate is no longer applied to LLM-tier decisions in
EvaluateAsync: its raw-proposal containment check would reject a legitimate
reworded merge, and the new write-time guard supersedes it for that tier. The
deterministic tier's exact-anchor UPDATE keeps its pre-Slice-3 behavior
unchanged (GuardDestructiveUpdate's containment proof already makes that raw
overwrite non-lossy on its own terms) — this is the one decision shape
explicitly exempted per design D5. Deterministic-tier CONSOLIDATE (fuzzy match
>=80% overlap, no LLM call) previously reached the store with no guard at all;
it now flows through the same append-fallback path as an LLM decision with no
merged body, closing that gap too.

MemoryCurationConfig threads through MemoryCurationActor/MemoryCurationEngine
to TryLlmEvaluationAsync, replacing the hardcoded 10s timeout and 4096 max
output tokens. SQLiteMemoryStore exposes its TimeProvider so the append
fallback's date separator stays consistent with the store's own persisted
timestamps.

New MemoryCurationMergeRoutingTests exercise this end-to-end through the real
evaluator + store: guard-fail and body-absent LLM Update/Consolidate produce
append semantics with the target's original body intact as a prefix; a guard-
passing merge writes the merged body; the deterministic exact-anchor Update
and fuzzy-match Consolidate paths are covered as regression/closed-gap proof.

Task 3.4. Marks tasks 3.2-3.5 complete in tasks.md (NOT 3.1/3.6/3.7 — Stage B,
a later dispatch).
… (opsx: memory-core-redesign slice 3, task 3.1)

Adds the nominate→decide dedup step to the shared MemoryCurationEvaluator
(design D4): when the embedder is available and there is no exact anchor
match, the proposal is embedded (same title\ncontent concatenation as
embed-on-write) and MemoryVectorIndex.TopK shortlists up to
Memory.Curation.NominatorK existing documents at or above
Memory.Curation.NominatorSimilarityThreshold. Nominees are hydrated into
full-content candidates (SQLiteMemoryStore.GetCandidatesByIdsAsync) tagged
with their cosine (ExistingMemoryCandidate.CosineSimilarity; anchor/lexical
candidates carry null).

Invariants (May 2026 measurement: no cosine threshold separates duplicates
from siblings — siblings live at 0.905–0.941 inside the duplicate band):
- Any nominee FORCES the LLM tier with full-content candidate previews;
  cosine never auto-merges and never auto-skips.
- Nominee present + no LLM (daemon checkpoint worker today) or LLM failure
  → conservative Create, deliberately bypassing TryAutoResolveAmbiguous:
  semantic-near content is exactly the ambiguity Jaccard heuristics cannot
  adjudicate, and a duplicate is recoverable where a wrong merge is not.
- No nominee + no anchor match → Create with zero LLM calls (the cheap
  common case — median nominee count on a random write is 0).
- Embedder unavailable/absent → pre-slice lexical content-term search runs
  unchanged as the degraded path (curation_nominator_degraded marker).

Wiring: new MemoryVectorIndexHolder (defers index construction until the
warmed-up embedder's model id/dimensions are known, mirrors
MemoryEmbedderHolder's holder-not-singleton rationale) is registered in the
daemon DI and threaded into BOTH write pipelines — the inline actor via
MemoryCurationActor.CreateProps/SessionMemoryServices, and the daemon
worker via MemoryCurationEngine's constructor.

New log markers: curation_nominated count/topCosine,
curation_nominator_degraded, curation_nominee_no_llm_decision.

Known limitation (documented on NominateAsync): proposals evaluated in one
batch cannot nominate each other — neither is committed/indexed while the
other is evaluated. Cross-batch and steady-state dedup are unaffected.
…, degraded path, parity, actor e2e (opsx: memory-core-redesign slice 3, task 3.6)

All fixtures synthetic; cosine geometry is hand-crafted (unit vectors at
exactly 0.93 — inside the measured sibling band) rather than model-derived,
so every scenario is deterministic.

- MemoryCurationNominatorTests (evaluator level):
  * paraphrase pair at cosine 0.93 with word-Jaccard <0.4 forces the LLM
    tier (recording IChatClient proves the call) even though the lexical
    tier finds zero candidates and would have said Create silently
  * nominee + scripted LLM CREATE → two separate documents persist
  * nominee + NO LLM → conservative Create (reason cites no-auto-merge on
    cosine alone); still two documents — never a merge without the LLM
  * novel proposal (no nominee, no anchor) → Create with zero LLM calls
  * embedder unavailable → lexical candidates still produced +
    curation_nominator_degraded fires; null holder behaves identically
- MemoryCurationEvaluatorParityTests: nominee-present construction parity —
  actor-style (ILoggingAdapter) and engine-style (ILogger) evaluators
  sharing one store/embedder/index reach the identical forced-LLM decision
- MemoryCurationActorNominatorTests (Akka.TestKit): proposal driven through
  the real MemoryCurationActor end-to-end to a committed store write;
  AwaitAssertAsync polls for the forced LLM call (no sleeps)
…merges; check tasks 3.1/3.6/3.7 (opsx: memory-core-redesign slice 3, task 3.7)

Skill addition (How Memory Works): with Memory.Embeddings.Enabled, a
near-duplicate proposal is nominated by embedding similarity and
adjudicated by the curator LLM (skip/update/consolidate/create) —
similarity alone never merges or skips — and merges are lossless-or-append
(merged body keeps every source fact; a deterministic guard falls back to
appending instead of overwriting when that check fails).

Memory eval gate (category=Memory, Qwen3.6-27B-NVFP4 @ spark-acad,
5 runs/case): 5/5 cases passed (100.0%), GREEN. Embeddings default OFF, so
the nominator idles in the eval daemon — the gate proves the shared
curation paths did not regress.
@Aaronontheweb
Aaronontheweb force-pushed the redesign/slice-3-nominator-lossless-merge branch from 46e6d62 to bc63f6d Compare July 5, 2026 20:32
@Aaronontheweb
Aaronontheweb merged commit 88cb85a into netclaw-dev:feature/memory-embeddings Jul 5, 2026
14 of 15 checks passed
@Aaronontheweb
Aaronontheweb deleted the redesign/slice-3-nominator-lossless-merge branch July 5, 2026 20:54
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