feat(memory): kNN-nominate/LLM-decide dedup + lossless merges (memory-core-redesign slice 3) - #1585
Merged
Aaronontheweb merged 7 commits intoJul 5, 2026
Conversation
…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
force-pushed
the
redesign/slice-3-nominator-lossless-merge
branch
from
July 5, 2026 20:32
46e6d62 to
bc63f6d
Compare
Aaronontheweb
merged commit Jul 5, 2026
88cb85a
into
netclaw-dev:feature/memory-embeddings
14 of 15 checks passed
3 tasks
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.
Implements Slice 3 of the
memory-core-redesignOpenSpec 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)
CurationPromptBuilderUPDATE/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.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 rawmarkdown_body = excluded.markdown_bodyoverwrite 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 (GuardDestructiveUpdateis 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 becauseGuardDestructiveUpdatealready proves the proposal is a content superset of the target before that decision can exist.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:{title}\n{content}, the exact concatenation embed-on-write uses, so proposals and stored documents share embedding space), queryMemoryVectorIndex.TopK(NominatorK, NominatorSimilarityThreshold), hydrate nominees into full-content candidates tagged with their cosine (ExistingMemoryCandidate.CosineSimilarity; anchor/lexical candidates carry null).curation_nominator_degradedmarker).Invariants — cosine nominates ONLY:
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.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 asMemoryEmbedderHolder) registered in daemon DI and threaded into both pipelines: inline actor viaMemoryCurationActor.CreateProps/SessionMemoryServices, daemon worker viaMemoryCurationEngine's constructor. New store hydration querySQLiteMemoryStore.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)
IChatClient) even though the lexical tier finds zero candidates and would have silently said Create.IChatClientinvocations.curation_nominator_degradedfires.MemoryCurationEvaluatorParityTestsextended — actor-style vs engine-style construction with a shared embedder/index reach identical forced-LLM decisions.MemoryCurationActorwith fake embedder + scripted LLM to a committed store write;AwaitAssertAsync, no sleeps.Gates
Netclaw.Actors.Tests2614/2614,Netclaw.Daemon.Tests832/832,Netclaw.Cli.Tests1231/1231,Netclaw.Configuration.Tests461/461,Netclaw.Embeddings.Tests18/18 — all green (Release).dotnet slopwatch analyze: 0 issues.Add-FileHeaders.ps1 -Verify: clean.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.(An earlier run of the same gate scored 4/5:
memory_checkpoint_enqueue3/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-memorySKILL.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.