From c71228dd985168ca8e249153c5c9e2dfecaf6190 Mon Sep 17 00:00:00 2001 From: modusensus Date: Fri, 23 Oct 2026 19:16:38 +0800 Subject: [PATCH] fix(dedupe): candidates were the type's top-100 by importance, not the same-title rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `saveWithDedupe` looked up merge targets with `store.list({ type, limit: 100 })`, whose default order is `importance DESC, updated_at DESC, id`. That window is "the 100 most important rows of this type", which has nothing to do with "find the row with the same title". Once a type held more than 100 rows with importance above the incoming row's, both the incoming row and its same-title row fell outside the window, the `find(...)` never matched, and the write silently took the create branch: no error, no warning, merge semantics degrading as the library grows. Reported on a 2200-row library where one type held 1047 rows (180 of them importance=5): writing the same title twice at importance=3 returned `created`. Add `store.activeByTypeAndTitle(type, title)` — a directed query on `type = ? AND TRIM(title) = ?` with the same active-row filter as `list()`'s defaults (`archived = 0 AND forgotten = 0`). TRIM is required: `title` is stored verbatim (see `save()`), while the comparison in `saveWithDedupe` is `m.title.trim()`, so an exact match would miss stored titles carrying surrounding whitespace and reproduce the same silent degradation. The dream and narrative summary branches key on `source` (and `tags[0]`) rather than title — their dedupe key deliberately excludes the title because it changes with `memory.language`. Pointing those at same-title candidates would pin them to a permanent miss, so they keep a type-scoped candidate set; it is now unbounded instead of capped at 100, which is what those branches needed all along (they must scan the whole type to find the same-source row). Tests in test/dedupe-candidate-window.test.js reproduce the window overflow, and fail 3/5 on the pre-fix code. Signed-off-by: modusensus --- dsh-mneme/lib/service.js | 23 ++- dsh-mneme/lib/store.js | 27 ++++ dsh-mneme/src/service.js | 23 ++- dsh-mneme/src/store.js | 27 ++++ .../test/dedupe-candidate-window.test.js | 150 ++++++++++++++++++ 5 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 dsh-mneme/test/dedupe-candidate-window.test.js diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 94b264a..3caa02f 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -1368,7 +1368,24 @@ export function createService({ store, mirror, config, onWrite, logger, document // dream 总览按 source 身份去重,而非本地化标题:memory.language 切换后 // 标题变化,若仍按 (type, title) 匹配会出现两个活跃的 summary 同时注入。 // 其余类型维持 (type, title) 匹配不变。 - const candidates = store.list({ type: memory.type, limit: 100 }); + // + // Issue #505:候选集此前是 store.list({ type, limit: 100 }) —— 那是「该类型 + // 最重要的 100 行」(order 默认 importance),与「找出同标题的那一行」无关。 + // 某类型里 importance 高于待写行的行数一旦超过 100,同标题行与待写行双双落在 + // 窗口外,下面的 find 恒不命中,于是静默走创建分支:不报错、不告警,合并语义 + // 随库增长而失效(实测 1047 行类型、importance=5 有 180 行时稳定复现)。 + // 改为按同标题定向取(store.activeByTypeAndTitle,无窗口)。 + const candidates = store.activeByTypeAndTitle(memory.type, memory.title); + // dream/narrative 两条分支按 source(及 tags[0])匹配,而不是按标题:它们的 + // 去重键本来就不含标题(语言切换后标题会变)。定向取同标题会把这两条路径 + // 打回「永远不命中」——所以这两类仍走「该类型活跃行」的候选集,只是窗口换成 + // 了无界且不带 importance 排序。类型内行数由 summary 的生产方(autoDream / + // generateNarratives)决定,量级远小于普通记忆,且这两条分支本来就要求扫全 + // 类型才能找到同 source 的那一行——原来的 100 行窗口对它们同样是错的。 + const sourceScopedCandidates = (memory.type === "summary" && (memory.source === "dream" || memory.source === "narrative")) + ? store.list({ type: memory.type, limit: null }) + : candidates; + const dreamCandidates = sourceScopedCandidates; // v0.8.0 A1(issue #17):去重键由 (type, title) 扩展为 // (type, title, agent_scope, workspace_scope, sensitivity)——不同作用域的同 // 标题记忆绝不互相物理合并(旧逻辑跨 agent/workspace 并行,事后无法拆分)。 @@ -1390,13 +1407,13 @@ export function createService({ store, mirror, config, onWrite, logger, document const existing = explicitTarget ? (scopeMatches(explicitTarget) ? explicitTarget : undefined) : (memory.type === "summary" && memory.source === "dream") - ? (candidates.find((m) => m.source === "dream" && scopeMatches(m)) + ? (dreamCandidates.find((m) => m.source === "dream" && scopeMatches(m)) ?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim())) : (memory.type === "summary" && memory.source === "narrative") // 叙述条(#164)按主题键去重而非本地化标题:标题随语言变 // (叙述:X / Narrative: X),title 匹配在语言切换后会残留同 tag 双行。 // tag 存于 tags[0](generateNarratives 落库),跨语言稳定 → 原地刷新。 - ? (candidates.find((m) => m.source === "narrative" && m.tags?.[0] === memory.tags?.[0] && scopeMatches(m)) + ? (dreamCandidates.find((m) => m.source === "narrative" && m.tags?.[0] === memory.tags?.[0] && scopeMatches(m)) ?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim())) : candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim()); if (existing) { diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 45aca17..0ca9a2f 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -1645,6 +1645,30 @@ export function createStore(path) { return rows.map(toRow); } + // Issue #505:写路径的去重候选必须「按同标题定向取」,不能「按 importance 取 + // 前 N 条」。后者是「该类型最重要的 100 行」,与「找出同标题的那一行」无关: + // 某类型里 importance 高于待写行的行数一旦超过窗口,同标题行与待写行双双落在 + // 窗口外,saveWithDedupe 的 find 恒不命中,于是静默走创建分支——不报错、不告 + // 警,合并语义随库增长而失效(报告实测 1047 行类型、importance=5 有 180 行时 + // 稳定复现)。 + // + // 口径刻意与 saveWithDedupe 的比较式对齐:那边用 m.title.trim() 比 + // String(memory.title).trim(),而 title 入库时并不 trim(见 save() 的 + // `memory.title` 直写)⇒ 这里必须 TRIM(title),否则存量里带首尾空白的标题 + // 会被精确匹配漏掉,退回同一种静默降级。 + // + // archived/forgotten 的过滤与 list() 默认口径一致(两者都为 0):合并目标必须 + // 是活跃行,否则会把内容并进一条已经归档/遗忘的记忆里。 + // 无 LIMIT:同 (type, title) 的活跃行本就极少,加窗口等于把 #505 重新引进来。 + function activeByTypeAndTitle(type, title) { + const rows = db.prepare( + `SELECT ${memoryColumnList} FROM memories + WHERE type = ? AND TRIM(title) = ? + AND forgotten = 0 AND archived = 0` + ).all(type, String(title ?? "").trim()); + return rows.map(toRow); + } + // Issue #430:写路径要的「最近若干条正文」。质量过滤的近重复判定只读 content // 这一列 + 前 20 行,此前写成 all().slice(0, 20) —— 全表逐行 toRow(27 字段、 // 三次 JSON 解析)之后只截前 20 条正文;5k 行实测 100ms 级 → 0.06ms 级。 @@ -3355,6 +3379,9 @@ export function createStore(path) { getUnrecalledSince, list, all, + // Issue #505:写路径去重的定向候选(按 type + TRIM(title) 精确取活跃行), + // 替代「importance 前 N 条」那个会随库增长失效的窗口。 + activeByTypeAndTitle, // #430:有界单列读,写路径(质量过滤)用。 recentContents, search, diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 94b264a..3caa02f 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -1368,7 +1368,24 @@ export function createService({ store, mirror, config, onWrite, logger, document // dream 总览按 source 身份去重,而非本地化标题:memory.language 切换后 // 标题变化,若仍按 (type, title) 匹配会出现两个活跃的 summary 同时注入。 // 其余类型维持 (type, title) 匹配不变。 - const candidates = store.list({ type: memory.type, limit: 100 }); + // + // Issue #505:候选集此前是 store.list({ type, limit: 100 }) —— 那是「该类型 + // 最重要的 100 行」(order 默认 importance),与「找出同标题的那一行」无关。 + // 某类型里 importance 高于待写行的行数一旦超过 100,同标题行与待写行双双落在 + // 窗口外,下面的 find 恒不命中,于是静默走创建分支:不报错、不告警,合并语义 + // 随库增长而失效(实测 1047 行类型、importance=5 有 180 行时稳定复现)。 + // 改为按同标题定向取(store.activeByTypeAndTitle,无窗口)。 + const candidates = store.activeByTypeAndTitle(memory.type, memory.title); + // dream/narrative 两条分支按 source(及 tags[0])匹配,而不是按标题:它们的 + // 去重键本来就不含标题(语言切换后标题会变)。定向取同标题会把这两条路径 + // 打回「永远不命中」——所以这两类仍走「该类型活跃行」的候选集,只是窗口换成 + // 了无界且不带 importance 排序。类型内行数由 summary 的生产方(autoDream / + // generateNarratives)决定,量级远小于普通记忆,且这两条分支本来就要求扫全 + // 类型才能找到同 source 的那一行——原来的 100 行窗口对它们同样是错的。 + const sourceScopedCandidates = (memory.type === "summary" && (memory.source === "dream" || memory.source === "narrative")) + ? store.list({ type: memory.type, limit: null }) + : candidates; + const dreamCandidates = sourceScopedCandidates; // v0.8.0 A1(issue #17):去重键由 (type, title) 扩展为 // (type, title, agent_scope, workspace_scope, sensitivity)——不同作用域的同 // 标题记忆绝不互相物理合并(旧逻辑跨 agent/workspace 并行,事后无法拆分)。 @@ -1390,13 +1407,13 @@ export function createService({ store, mirror, config, onWrite, logger, document const existing = explicitTarget ? (scopeMatches(explicitTarget) ? explicitTarget : undefined) : (memory.type === "summary" && memory.source === "dream") - ? (candidates.find((m) => m.source === "dream" && scopeMatches(m)) + ? (dreamCandidates.find((m) => m.source === "dream" && scopeMatches(m)) ?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim())) : (memory.type === "summary" && memory.source === "narrative") // 叙述条(#164)按主题键去重而非本地化标题:标题随语言变 // (叙述:X / Narrative: X),title 匹配在语言切换后会残留同 tag 双行。 // tag 存于 tags[0](generateNarratives 落库),跨语言稳定 → 原地刷新。 - ? (candidates.find((m) => m.source === "narrative" && m.tags?.[0] === memory.tags?.[0] && scopeMatches(m)) + ? (dreamCandidates.find((m) => m.source === "narrative" && m.tags?.[0] === memory.tags?.[0] && scopeMatches(m)) ?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim())) : candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim()); if (existing) { diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 45aca17..0ca9a2f 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -1645,6 +1645,30 @@ export function createStore(path) { return rows.map(toRow); } + // Issue #505:写路径的去重候选必须「按同标题定向取」,不能「按 importance 取 + // 前 N 条」。后者是「该类型最重要的 100 行」,与「找出同标题的那一行」无关: + // 某类型里 importance 高于待写行的行数一旦超过窗口,同标题行与待写行双双落在 + // 窗口外,saveWithDedupe 的 find 恒不命中,于是静默走创建分支——不报错、不告 + // 警,合并语义随库增长而失效(报告实测 1047 行类型、importance=5 有 180 行时 + // 稳定复现)。 + // + // 口径刻意与 saveWithDedupe 的比较式对齐:那边用 m.title.trim() 比 + // String(memory.title).trim(),而 title 入库时并不 trim(见 save() 的 + // `memory.title` 直写)⇒ 这里必须 TRIM(title),否则存量里带首尾空白的标题 + // 会被精确匹配漏掉,退回同一种静默降级。 + // + // archived/forgotten 的过滤与 list() 默认口径一致(两者都为 0):合并目标必须 + // 是活跃行,否则会把内容并进一条已经归档/遗忘的记忆里。 + // 无 LIMIT:同 (type, title) 的活跃行本就极少,加窗口等于把 #505 重新引进来。 + function activeByTypeAndTitle(type, title) { + const rows = db.prepare( + `SELECT ${memoryColumnList} FROM memories + WHERE type = ? AND TRIM(title) = ? + AND forgotten = 0 AND archived = 0` + ).all(type, String(title ?? "").trim()); + return rows.map(toRow); + } + // Issue #430:写路径要的「最近若干条正文」。质量过滤的近重复判定只读 content // 这一列 + 前 20 行,此前写成 all().slice(0, 20) —— 全表逐行 toRow(27 字段、 // 三次 JSON 解析)之后只截前 20 条正文;5k 行实测 100ms 级 → 0.06ms 级。 @@ -3355,6 +3379,9 @@ export function createStore(path) { getUnrecalledSince, list, all, + // Issue #505:写路径去重的定向候选(按 type + TRIM(title) 精确取活跃行), + // 替代「importance 前 N 条」那个会随库增长失效的窗口。 + activeByTypeAndTitle, // #430:有界单列读,写路径(质量过滤)用。 recentContents, search, diff --git a/dsh-mneme/test/dedupe-candidate-window.test.js b/dsh-mneme/test/dedupe-candidate-window.test.js new file mode 100644 index 0000000..2bbc8cd --- /dev/null +++ b/dsh-mneme/test/dedupe-candidate-window.test.js @@ -0,0 +1,150 @@ +// Issue #505:saveWithDedupe 的去重候选窗口固定 100 条,库一变大就静默不再合并。 +// +// 根因:候选集是 store.list({ type, limit: 100 }),order 默认 importance +// ⇒「该类型最重要的 100 行」,与「找出同标题的那一行」无关。某类型里 importance +// 高于待写行的行数一旦超过窗口,同标题行与待写行双双落在窗口外,find 恒不命中, +// 于是走创建分支:不报错、不告警,合并语义随库增长而失效。 +// +// 这组用例刻意复刻报告里的实测形状(类型 1047 行、其中 importance=5 有 180 行), +// 但用更小的数字达成同样的窗口溢出,避免测试本身变慢。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; + +function freshService() { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + return { store, service }; +} + +// 往某个类型里灌 n 条 importance=5 的行,把窗口挤满。 +function crowdType(store, type, n) { + for (let i = 0; i < n; i++) { + store.save({ + type, + title: `crowd-${type}-${i}`, + content: `padding row ${i}`, + importance: 5 + }); + } +} + +test("#505: 同类型 importance 更高的行超过窗口后,同标题第二次写入仍然合并", () => { + const { store, service } = freshService(); + // 窗口是 100。灌 120 条 importance=5,保证待写行(importance=3)与它的同标题 + // 行都排不进前 100。 + crowdType(store, "project", 120); + + const payload = { + type: "project", + title: "dedupe-target", + content: "first content", + importance: 3 + }; + const first = service.saveWithDedupe(payload); + assert.equal(first.action, "created", "第一次写入应当是新建"); + + const second = service.saveWithDedupe({ ...payload, content: "second content" }); + assert.equal( + second.action, + "merged", + "第二次同标题写入必须并入已有行;返回 created 说明候选窗口把同标题行挡在窗口外(#505 静默降级)" + ); + assert.equal(second.memory.id, first.memory.id, "合并必须落在同一行"); + + const rows = store.list({ type: "project", limit: null }) + .filter((m) => m.title.trim() === "dedupe-target"); + assert.equal(rows.length, 1, "同标题只应存在一行"); +}); + +test("#505: 定向候选不受 importance 分布影响(低 importance 的同标题行也能命中)", () => { + const { store, service } = freshService(); + // 先写一条 importance=1 的目标行,再用大量 importance=5 的行把它挤出窗口。 + const first = service.saveWithDedupe({ + type: "pattern", + title: "low-importance-target", + content: "seed", + importance: 1 + }); + assert.equal(first.action, "created"); + + crowdType(store, "pattern", 120); + + const second = service.saveWithDedupe({ + type: "pattern", + title: "low-importance-target", + content: "merged later", + importance: 1 + }); + assert.equal(second.action, "merged", "低 importance 的同标题行同样必须能被找到"); + assert.equal(second.memory.id, first.memory.id); +}); + +test("#505: 标题首尾空白不影响去重命中(入库不 trim,比较 trim)", () => { + const { store, service } = freshService(); + crowdType(store, "constraint", 120); + + const first = service.saveWithDedupe({ + type: "constraint", + title: " spaced title ", + content: "seed", + importance: 3 + }); + assert.equal(first.action, "created"); + + // 同一个标题、首尾空白写法不同——按 trim 口径应当命中同一行。 + const second = service.saveWithDedupe({ + type: "constraint", + title: "spaced title", + content: "merged", + importance: 3 + }); + assert.equal(second.action, "merged", "trim 后同标题必须合并,否则存量带空白的标题会退回静默降级"); + assert.equal(second.memory.id, first.memory.id); +}); + +test("#505: 定向候选不跨类型命中", () => { + const { store, service } = freshService(); + crowdType(store, "project", 120); + + const a = service.saveWithDedupe({ + type: "project", + title: "same-title-different-type", + content: "in project", + importance: 3 + }); + const b = service.saveWithDedupe({ + type: "preference", + title: "same-title-different-type", + content: "in preference", + importance: 3 + }); + assert.equal(a.action, "created"); + assert.equal(b.action, "created", "不同 type 的同标题行不得合并"); + assert.notEqual(a.memory.id, b.memory.id); +}); + +test("#505: 归档行不作为合并目标(口径与 list 默认一致)", () => { + const { store, service } = freshService(); + crowdType(store, "decision", 120); + + const first = service.saveWithDedupe({ + type: "decision", + title: "archived-target", + content: "seed", + importance: 3 + }); + // 归档必须走 store.setArchived——store.update 的 SET 子句里没有 archived 列, + // 用 update({archived:true}) 根本不会归档,测试会假绿。 + store.setArchived(first.memory.id, true); + + const second = service.saveWithDedupe({ + type: "decision", + title: "archived-target", + content: "after archive", + importance: 3 + }); + assert.equal(second.action, "created", "已归档行不该被当作活跃合并目标"); + assert.notEqual(second.memory.id, first.memory.id); +});