From c71228dd985168ca8e249153c5c9e2dfecaf6190 Mon Sep 17 00:00:00 2001 From: modusensus Date: Fri, 23 Oct 2026 19:16:38 +0800 Subject: [PATCH 1/2] 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 94b264a6..3caa02f2 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 45aca177..0ca9a2fd 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 94b264a6..3caa02f2 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 45aca177..0ca9a2fd 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 00000000..2bbc8cdf --- /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); +}); From 52b2d925e23301d8a8be81db01d79ad18fd117bb Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 29 Oct 2026 16:49:12 +0800 Subject: [PATCH 2/2] =?UTF-8?q?perf(dedupe):=20=E7=BB=99=E5=AE=9A=E5=90=91?= =?UTF-8?q?=E5=80=99=E9=80=89=E9=85=8D=20(type,=20TRIM(title))=20=E8=A1=A8?= =?UTF-8?q?=E8=BE=BE=E5=BC=8F=E7=B4=A2=E5=BC=95=EF=BC=8C=E5=B9=B6=E7=94=A8?= =?UTF-8?q?=20EXPLAIN=20=E9=92=89=E4=BD=8F=E5=AE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定向候选把「该类型前 100 条」换成 `type = ? AND TRIM(title) = ?`,代价随最大类型的行数 线性长。本机 node:sqlite 实测(同类型 1000 / 5000 / 20000 行): 现口径 0.13 / 0.68 / 4.63 ms,加这条索引后 ~0.00 ms。 TRIM 本身不是瓶颈——同句去掉 TRIM 只差 0.07 ms,缺的是一条能把整个条件当 seek 用的 索引;而这是每存一条都要付一次的写路径,20000 行那一档不该赌「用户到不了」。 索引表达式必须与 SQL 逐字相同,写歪一点(用 title、列序不对)优化器静默不认,而行为 用例全绿——所以补一条 EXPLAIN QUERY PLAN 用例,钉的是「这条查询真的选中这条索引」, 不是「索引存在」。存量库靠 CREATE INDEX IF NOT EXISTS 在打开时补上,与 share 那两条 索引同一写法。 --- dsh-mneme/lib/store.js | 9 ++++++ dsh-mneme/src/store.js | 9 ++++++ .../test/dedupe-candidate-window.test.js | 30 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 6c655ac3..8c452ebf 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -915,6 +915,15 @@ export function createStore(path) { // 进来('' 参与唯一性,第二条 '' 直接撞响),不是防 WHERE 子句消失。 db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_share_external ON memories(share_external_id) WHERE share_external_id IS NOT NULL"); db.exec("CREATE INDEX IF NOT EXISTS idx_memories_share_pack ON memories(share_pack_id)"); + // Issue #505:saveWithDedupe 的定向候选是 `type = ? AND TRIM(title) = ?`。只有 + // idx_memories_type 时这条语句等于「把该类型的行全读一遍、逐行 TRIM」,代价随最大 + // 类型的行数线性长(本机 node:sqlite 实测同类型 1000 / 5000 / 20000 行 = 0.13 / + // 0.68 / 4.63 ms,而它是每存一条都要付一次的写路径)。TRIM 本身不是瓶颈——同句去掉 + // TRIM 只差 0.07 ms,缺的是能把整个条件当 seek 用的索引;建好之后同一查询在同一批 + // 数据上 ~0.00 ms。索引表达式必须与 SQL 里的写法逐字相同(TRIM(title),不是 title), + // 差一点优化器就静默不认,而所有行为用例照样全绿——所以 dedupe-candidate-window + // 那条用例用 EXPLAIN QUERY PLAN 钉住它真的被选中。改 service 的 WHERE 必须同时改这里。 + db.exec("CREATE INDEX IF NOT EXISTS idx_memories_type_trimmed_title ON memories(type, TRIM(title))"); // issue #24 块2(权重演化):entity_relations 加 weight + source。存量关系 // 行缺列 → 默认 manual 1.0(最保守:既有语义不因迁移而变弱)。 addColumn("entity_relations", "weight", "ALTER TABLE entity_relations ADD COLUMN weight REAL NOT NULL DEFAULT 1.0"); diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 6c655ac3..8c452ebf 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -915,6 +915,15 @@ export function createStore(path) { // 进来('' 参与唯一性,第二条 '' 直接撞响),不是防 WHERE 子句消失。 db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_share_external ON memories(share_external_id) WHERE share_external_id IS NOT NULL"); db.exec("CREATE INDEX IF NOT EXISTS idx_memories_share_pack ON memories(share_pack_id)"); + // Issue #505:saveWithDedupe 的定向候选是 `type = ? AND TRIM(title) = ?`。只有 + // idx_memories_type 时这条语句等于「把该类型的行全读一遍、逐行 TRIM」,代价随最大 + // 类型的行数线性长(本机 node:sqlite 实测同类型 1000 / 5000 / 20000 行 = 0.13 / + // 0.68 / 4.63 ms,而它是每存一条都要付一次的写路径)。TRIM 本身不是瓶颈——同句去掉 + // TRIM 只差 0.07 ms,缺的是能把整个条件当 seek 用的索引;建好之后同一查询在同一批 + // 数据上 ~0.00 ms。索引表达式必须与 SQL 里的写法逐字相同(TRIM(title),不是 title), + // 差一点优化器就静默不认,而所有行为用例照样全绿——所以 dedupe-candidate-window + // 那条用例用 EXPLAIN QUERY PLAN 钉住它真的被选中。改 service 的 WHERE 必须同时改这里。 + db.exec("CREATE INDEX IF NOT EXISTS idx_memories_type_trimmed_title ON memories(type, TRIM(title))"); // issue #24 块2(权重演化):entity_relations 加 weight + source。存量关系 // 行缺列 → 默认 manual 1.0(最保守:既有语义不因迁移而变弱)。 addColumn("entity_relations", "weight", "ALTER TABLE entity_relations ADD COLUMN weight REAL NOT NULL DEFAULT 1.0"); diff --git a/dsh-mneme/test/dedupe-candidate-window.test.js b/dsh-mneme/test/dedupe-candidate-window.test.js index 2bbc8cdf..21cd7502 100644 --- a/dsh-mneme/test/dedupe-candidate-window.test.js +++ b/dsh-mneme/test/dedupe-candidate-window.test.js @@ -9,6 +9,10 @@ // 但用更小的数字达成同样的窗口溢出,避免测试本身变慢。 import test from "node:test"; import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { createStore } from "../src/store.js"; import { createService } from "../src/service.js"; @@ -148,3 +152,29 @@ test("#505: 归档行不作为合并目标(口径与 list 默认一致)", () assert.equal(second.action, "created", "已归档行不该被当作活跃合并目标"); assert.notEqual(second.memory.id, first.memory.id); }); + + +// #505 把「取该类型前 100 条」换成「按 type + TRIM(title) 精确取」:窗口没了、静默不 +// 合并也没了,但 SQL 形状变了——代价随最大类型的行数线性长(20000 行时 4.6ms 一次, +// 每存一条付一次),所以 store.js 里配了 (type, TRIM(title)) 表达式索引。这条用例不测 +// 快慢(机器有关节,数字不可复现),测的是**那条索引真的被这条查询选中**:表达式写歪 +// 一点(漏 TRIM、列序不对)优化器就静默退回扫整个类型,而本文件其余用例全绿。 +test("#505: 定向候选那条 SQL 命中表达式索引,而不是扫完整个类型", () => { + const dir = mkdtempSync(join(tmpdir(), "mneme-dedupe-idx-")); + const file = join(dir, "m.db"); + const store = createStore(file); + store.save({ type: "project", title: " spaced ", content: "x", importance: 3 }); + store.close(); + const db = new DatabaseSync(file); + try { + // WHERE 与 store.js 的 activeByTypeAndTitle 逐字同形,只把列表换成单列。 + const plan = db.prepare( + "EXPLAIN QUERY PLAN SELECT id FROM memories WHERE type = ? AND TRIM(title) = ? AND forgotten = 0 AND archived = 0" + ).all("project", "spaced").map((r) => r.detail).join(" "); + assert.match(plan, /idx_memories_type_trimmed_title/, + `定向候选必须走 (type, TRIM(title)) 表达式索引,实得:${plan}`); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +});