diff --git a/README.md b/README.md index 29121c62..139fd408 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -297,7 +297,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ```bash cd dsh-mneme && npm install -npm test # 3442 个测试 +npm test # 3446 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -604,7 +604,7 @@ Community projects that wire mneme into more platforms — not part of this repo ```bash cd dsh-mneme && npm install -npm test # 3442 tests +npm test # 3446 tests npm run stress # three-axis stress test npm run sync # src → lib sync ``` diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 1c0c0194..f57d40a4 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +- **注入的规则池只看得到前 200 条记忆:候选池此前固定 `store.list({ limit: 200 })`,合格行 8400+ 条里 8200 条永远进不了窗(#619)**。这个窗口按 `importance DESC` 取,于是注入位实际上由「谁更重要」决定一个固定的前 200 名,而真正的排序规则(summary 优先档 → preference → importance×quality×heat×scope)只在这 200 条**内部**生效。实测本机库 9067 条活跃记忆里,按注入闸门(`injectType` / 非 narrative / importance ≥ 3)合格的有 8421 条,其中 8210 条永远进不了候选窗;窗口内 200 条的 importance 分布是**全部 5**——也就是说 summary 优先档这条规则,在最需要它的场景里根本没机会触发。 + - **窗口改成只限制回表条数,不再决定谁能参与排序**。`store.listInjectPool()` 取全部活跃行的排序键(12 列:`id / type / source / importance / quality_score / last_accessed_at / created_at / updated_at / agent_scope / workspace_scope / archived / forgotten`),过滤与排序都在 JS 侧做,最后只对入选的 `max(200, poolSize)` 条回表补正文。回表深度与改动前一致(200),所以 backfill(槽位没填满时往下补)的行为不变。 + - **不走 `store.list({ limit: null })`**:list 的投影由 PRAGMA 派生、搬全部列(含 content),9k 行实测 340-400ms;这里 12 列实测 200ms,正文只对 200 条用 `listByIdsForInject` 点查回来(6.8ms)。加起来仍低于改动前那条 `LIMIT 200` 查询的 ~76ms + 后续排序,但账要算清:**这不是变快,是把「看不见的记忆」换成了「多扫一遍排序键」**,换来召回覆盖面。 + - **排序键按 `mirror_state.generation` 缓存**,与词法索引同纪律(水位每次现读、读不到时不写缓存)。但 `heatEnabled && injectHeatEnabled` 同时打开时**绕开缓存**:heat 依赖 `last_accessed_at`,而 `touchLastAccess` 刻意不 bump generation(读不该让镜像变脏),缓存会让 heat 停在上一轮的读数上。 + - **补上末位平局键 `updated_at DESC` 再 `id`**。此前同分组的次序靠 `Array.sort` 的稳定性继承 SQL 行序,等于随数据库返回顺序漂移——实测全库只有 43 种排序键取值、37 个并列组、最大一组 2276 行,改一行 `updated_at` 就能造成上千对交换。现在同分组的次序可复现,轮换窗口不会因为一次无关写入而整批换人。 + - **测试**:`test/inject-pool-full.test.js` 4 条(250 条 importance 5 填充 + 一条 importance 1 的 summary,后者必须排在第一位并进注入——旧实现下它在第 251 位、根本进不了窗;同 generation 内多次注入只取一次排序键;heat 双开关打开时每轮都重取;入选条目带完整 `content`/`tags`,证明走了回表而不是排序键快照)。变异自证四轮各自变红:语料改回 200 行窗口(3 红)、去掉排序键缓存(1 红)、去掉 heat 绕开缓存的判断(1 红)、直接返回排序键快照不回表(2 红)。 + - **如实记一处变异假绿**:删掉 `updated_at` / `id` 两个平局键后 4 条用例全绿。原因是 `updated_at` 由 `save()` 铸造、精度到毫秒,测试里造不出「同分且同 `updated_at`」的两行;靠毫秒碰撞来造这种数据的用例本身就会随机红绿。所以平局键的收益(同分组次序可复现)今天只能靠代码审查守住,不能靠测试——与 #616 的删除补偿是同一种情况,不假装它被覆盖了。 + - **词法召回只看得到前 500 条记忆:BM25 语料此前固定 `store.list({ limit: 500 })`,语料超过 500 行时,排在 500 名之后的记忆永远进不了检索(#616)**。500 名按 `importance DESC, updated_at DESC` 排,所以越旧、越不重要的记忆越容易掉出窗口——而它们恰恰是最需要靠检索才想得起来的那批。实测 8 个查询里,500 行窗口的 top10 与全量语料只有 1-4 条重合(`记忆 审查` 2/10、`矛盾 冻结` 1/10、`注入 规则池 截断` 4/10)。这是**静默的召回缺失**:不报错、不变慢、日志里什么都没有,只是「有这条记忆但搜不到」。 - **不是把 500 改成 null 就完事**。全量重建一份索引实测 340-356ms,而现在 500 行只要 14-23ms;只放开截断,每次写入后的重建会从 ~110ms 涨到 ~680ms,等于用一个可感知的卡顿换召回。所以这次拆成两件事:语料放开到全量,同时把索引改成**增量维护**,重建成本随变化的行数走而不是随语料规模走(实测 upsert 50 行 3.4ms,单行 0.5ms)。本库 9052 行活跃记忆中,最近一小时变化的只有 49 行。 - **新开口 `store.listBm25Corpus()`,不走 `store.list({ limit: null })`**。后者的投影由 PRAGMA 派生,搬全部列,无界时 9k 行实测 340-400ms;这里只要 `id / title / content / updated_at` 四列,实测 74-108ms,且 `ORDER BY updated_at DESC` 命中 `idx_memories_updated`。**刻意不取 `content_hash`**:把这一列加进查询会让耗时涨到 310ms(覆盖索引失效),比省下的分词还贵——所以判据用 `updated_at`,它同时覆盖「改内容」「归档」「遗忘」三类变更(这三类都会刷新它),只有删除不在行上留痕。 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 5e25fee8..146789aa 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-3442%20total-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-3446%20total-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -763,7 +763,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 3442 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 3446 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单 ``` @@ -772,7 +772,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 3442 个测试 +npm test # 运行 3446 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ``` diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 97e10f02..6f0bc6b2 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -638,6 +638,36 @@ export function createService({ store, mirror, config, onWrite, logger, document return index; } + // #619(规则池截断):注入候选池的排序键缓存。与 bm25IndexCache 同一套纪律—— + // 失效键用 mirror_state.generation(读到就用,读不到就现扫一次且不写缓存), + // 只缓存「行 + 排序键」、不缓存过滤与排序结果:injectTypes、threshold、 + // codingKeywords 都是每次调用现读的配置,缓存结果会串味。 + // + // heat 是这套缓存唯一的例外:heatOf 读 last_accessed_at,而 touchLastAccess + // 刻意不 bump generation(#617 同一纪律),于是缓存会让热度停在上一轮。所以 + // 那两个开关同时开时绕开缓存,宁可每轮多扫一次 200ms,也不让热度失真。 + let injectPoolCache = null; // { generation, rows } | null + + function injectPoolRows() { + const heatSensitive = config?.heatEnabled === true && config?.injectHeatEnabled === true; + let generation = null; + if (!heatSensitive) { + try { + generation = store.getMirrorState().generation; + } catch { + generation = null; + } + if (injectPoolCache && generation !== null && injectPoolCache.generation === generation) { + return injectPoolCache.rows; + } + } + const rows = store.listInjectPool(); + // 水位读不到时不碰缓存,理由同 bm25CorpusIndex:那样的条目永远命不中,还会 + // 把有效的那份挤掉。 + if (generation !== null) injectPoolCache = { generation, rows }; + return rows; + } + /** * Graph fourth recall path (issue #219 → #24 块1 锚定层). When the query text * mentions a known entity name, memories linked to that entity (attrs + @@ -1825,7 +1855,11 @@ export function createService({ store, mirror, config, onWrite, logger, document // #230 拍板:叙述条解禁进注入受生成总闸约束——门必须对全部候选路径一致 // (规则/向量/缓存/BM25),只锁规则路的话语义路仍会漏进 narrative 行。 const allowNarrative = config?.dreamNarrativeEnabled === true; - const filtered = store.list({ limit: Math.max(200, poolSize), includeForgotten: false }) + // #619:规则池不再从 200 行窗口里筛。此前这里是 store.list({ limit: 200 }), + // 窗口按 importance DESC 取(合格行 8410 条里 8210 条永远进不了窗)。 + // listInjectPool 取全部活跃行的排序键,过滤与排序都在 JS 侧做;正文只对真正 + // 入选的那一批回表补全(见下方排序段)。 + const filtered = injectPoolRows() .filter((m) => !m.archived && injectTypes.has(m.type) && !m.forgotten && // 叙述条:#228 落地为纯按需检索;#230 合并拍板解禁为次优先档注入 // (per-topic 叙述常驻位仍只留给 dream 总览 source=dream)。 @@ -1853,32 +1887,54 @@ export function createService({ store, mirror, config, onWrite, logger, document const softScopeActive = config?.scopeEnabled === true && scope != null && Boolean(scope.agent_scope || scope.workspace_scope); const scopeMultOf = (m) => (softScopeActive ? scopeMultiplier(m, scope) : 1); - const items = filtered.sort((a, b) => { - // 编码记忆在编码任务时优先于普通 decision(与 preference 同级), - // importance 乘 codingBoostFactor 加权(封顶 5,保持 importance 语义)。 - const priority = (m) => { - // #230:叙述条与 document 摘要行同为次优先档——蒸馏摘要(0)仍最 - // 先,指针型聚合产物(1)先于普通 project/decision(2)。叙述条是 - // type=summary,判 source 必须在判 type 之前。 - if (m.source === "narrative") return 1; - if (m.type === "summary") return 0; - if (m.type === "preference") return 1; - if (m.type === "document") return 1; - if (isCoding && CODING_MEMORY_TYPES.has(m.type)) return 1; - return 2; - }; - const effImportance = (m) => - (isCoding && CODING_MEMORY_TYPES.has(m.type)) - ? Math.min(5, m.importance * (config.codingBoostFactor ?? 2)) - : m.importance; - const pa = priority(a); - const pb = priority(b); + // #619:排序键逐条算一次,而不是在 comparator 里现算(8k 行要算十几万次 + // priority/effImportance);末位补 updated_at DESC + id 两个平局键,让同分组 + // 内的次序可复现——此前靠 Array.sort 的稳定性继承 SQL 行序,改一行 updated_at + // 就能造成上千对交换,同分组的行谁进注入位实际上是随机的。 + const priority = (m) => { + // #230:叙述条与 document 摘要行同为次优先档——蒸馏摘要(0)仍最 + // 先,指针型聚合产物(1)先于普通 project/decision(2)。叙述条是 + // type=summary,判 source 必须在判 type 之前。 + if (m.source === "narrative") return 1; + if (m.type === "summary") return 0; + if (m.type === "preference") return 1; + if (m.type === "document") return 1; + if (isCoding && CODING_MEMORY_TYPES.has(m.type)) return 1; + return 2; + }; + // 编码记忆在编码任务时优先于普通 decision(与 preference 同级), + // importance 乘 codingBoostFactor 加权(封顶 5,保持 importance 语义)。 + const effImportance = (m) => + (isCoding && CODING_MEMORY_TYPES.has(m.type)) + ? Math.min(5, m.importance * (config.codingBoostFactor ?? 2)) + : m.importance; + const windowSize = Math.max(200, poolSize); + const rankedOrder = filtered + .map((m) => ({ + m, + p: priority(m), // 软加权乘在层内数值积上(priority 档位不动):foreign ×0.5 后压不过 // 同档自己行——E7 的同 importance 档设计正是这个场景。 - return pa - pb || - (effImportance(b) * qualityWeight(b) * heatOf(b) * scopeMultOf(b)) - - (effImportance(a) * qualityWeight(a) * heatOf(a) * scopeMultOf(a)); - }); + s: effImportance(m) * qualityWeight(m) * heatOf(m) * scopeMultOf(m), + })) + .sort((a, b) => + a.p - b.p || + b.s - a.s || + (a.m.updated_at === b.m.updated_at ? 0 : (a.m.updated_at ?? "") < (b.m.updated_at ?? "") ? 1 : -1) || + (a.m.id < b.m.id ? -1 : a.m.id > b.m.id ? 1 : 0)) + .slice(0, windowSize) + .map((row) => row.m.id); + // 排序键那版没有 title/content,而注入渲染要全文;只回表这一批(实测 200 条 + // 约 7ms),且保持排序后的次序。回表时行可能已被归档/遗忘/删除——丢弃, + // 不为它占位。 + const fullById = new Map(); + if (rankedOrder.length) { + for (const row of store.listByIdsForInject(rankedOrder)) fullById.set(row.id, row); + } + const items = rankedOrder + .map((id) => fullById.get(id)) + .filter((m) => m && !m.archived && !m.forgotten); + let candidates = items; // #617 第 2 条(前半):向量路与 BM25 路此前是**互斥**的——`if (!semanticItems.length)` // 包住 BM25,于是「有向量」等于「词法命中整批消失」。可两条路测的不是同一件事: diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index e607b599..0c62cb5e 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -4522,6 +4522,45 @@ export function createStore(path) { })); } + /** + * 取注入规则池的排序键:全部活跃行,只带过滤与排序真正需要的列。 + * + * 为什么单独开一个口,而不是 store.list({ limit: Math.max(200, poolSize) }): + * 那个窗口按 importance DESC 取(store.list 的默认 order),实测合格行 8410 条 + * 里有 8210 条永远进不了窗——importance 4 且质量满分、本就该排在前面的记忆, + * 一条都到不了排序。这与 #616 的 BM25 语料是同一种病:截断不是慢,是静默的 + * 候选缺失。 + * + * 为什么不用 list({ limit: null }):list 按 PRAGMA 派生列清单搬全部列(含 + * content),9k 行实测 340-400ms;这里只要 12 列,实测 200ms。content 只对真正 + * 入选的那 200 条用 listByIdsForInject 点查回来(实测 7ms)。 + * + * updated_at 一并取出,供排序末位平局键使用:同分组的行此前靠 Array.sort 的 + * 稳定性继承 SQL 行序,改一行 updated_at 就能造成上千对交换。 + */ + function listInjectPool() { + return db.prepare( + `SELECT id, type, source, importance, quality_score, last_accessed_at, + created_at, updated_at, agent_scope, workspace_scope, + archived, forgotten + FROM memories + WHERE archived = 0 AND forgotten = 0` + ).all().map((row) => ({ + id: row.id, + type: row.type, + source: row.source, + importance: row.importance, + quality_score: row.quality_score, + last_accessed_at: row.last_accessed_at, + created_at: row.created_at, + updated_at: row.updated_at, + agent_scope: row.agent_scope, + workspace_scope: row.workspace_scope, + archived: row.archived, + forgotten: row.forgotten, + })); + } + function getFailureStats({ since } = {}) { const clause = since ? "WHERE created_at >= ?" : ""; const params = since ? [since] : []; @@ -5990,6 +6029,7 @@ export function createStore(path) { countPinned, isPinned, listByIdsForInject, + listInjectPool, listBm25Corpus, createEntity, findEntityByName, diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 97e10f02..6f0bc6b2 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -638,6 +638,36 @@ export function createService({ store, mirror, config, onWrite, logger, document return index; } + // #619(规则池截断):注入候选池的排序键缓存。与 bm25IndexCache 同一套纪律—— + // 失效键用 mirror_state.generation(读到就用,读不到就现扫一次且不写缓存), + // 只缓存「行 + 排序键」、不缓存过滤与排序结果:injectTypes、threshold、 + // codingKeywords 都是每次调用现读的配置,缓存结果会串味。 + // + // heat 是这套缓存唯一的例外:heatOf 读 last_accessed_at,而 touchLastAccess + // 刻意不 bump generation(#617 同一纪律),于是缓存会让热度停在上一轮。所以 + // 那两个开关同时开时绕开缓存,宁可每轮多扫一次 200ms,也不让热度失真。 + let injectPoolCache = null; // { generation, rows } | null + + function injectPoolRows() { + const heatSensitive = config?.heatEnabled === true && config?.injectHeatEnabled === true; + let generation = null; + if (!heatSensitive) { + try { + generation = store.getMirrorState().generation; + } catch { + generation = null; + } + if (injectPoolCache && generation !== null && injectPoolCache.generation === generation) { + return injectPoolCache.rows; + } + } + const rows = store.listInjectPool(); + // 水位读不到时不碰缓存,理由同 bm25CorpusIndex:那样的条目永远命不中,还会 + // 把有效的那份挤掉。 + if (generation !== null) injectPoolCache = { generation, rows }; + return rows; + } + /** * Graph fourth recall path (issue #219 → #24 块1 锚定层). When the query text * mentions a known entity name, memories linked to that entity (attrs + @@ -1825,7 +1855,11 @@ export function createService({ store, mirror, config, onWrite, logger, document // #230 拍板:叙述条解禁进注入受生成总闸约束——门必须对全部候选路径一致 // (规则/向量/缓存/BM25),只锁规则路的话语义路仍会漏进 narrative 行。 const allowNarrative = config?.dreamNarrativeEnabled === true; - const filtered = store.list({ limit: Math.max(200, poolSize), includeForgotten: false }) + // #619:规则池不再从 200 行窗口里筛。此前这里是 store.list({ limit: 200 }), + // 窗口按 importance DESC 取(合格行 8410 条里 8210 条永远进不了窗)。 + // listInjectPool 取全部活跃行的排序键,过滤与排序都在 JS 侧做;正文只对真正 + // 入选的那一批回表补全(见下方排序段)。 + const filtered = injectPoolRows() .filter((m) => !m.archived && injectTypes.has(m.type) && !m.forgotten && // 叙述条:#228 落地为纯按需检索;#230 合并拍板解禁为次优先档注入 // (per-topic 叙述常驻位仍只留给 dream 总览 source=dream)。 @@ -1853,32 +1887,54 @@ export function createService({ store, mirror, config, onWrite, logger, document const softScopeActive = config?.scopeEnabled === true && scope != null && Boolean(scope.agent_scope || scope.workspace_scope); const scopeMultOf = (m) => (softScopeActive ? scopeMultiplier(m, scope) : 1); - const items = filtered.sort((a, b) => { - // 编码记忆在编码任务时优先于普通 decision(与 preference 同级), - // importance 乘 codingBoostFactor 加权(封顶 5,保持 importance 语义)。 - const priority = (m) => { - // #230:叙述条与 document 摘要行同为次优先档——蒸馏摘要(0)仍最 - // 先,指针型聚合产物(1)先于普通 project/decision(2)。叙述条是 - // type=summary,判 source 必须在判 type 之前。 - if (m.source === "narrative") return 1; - if (m.type === "summary") return 0; - if (m.type === "preference") return 1; - if (m.type === "document") return 1; - if (isCoding && CODING_MEMORY_TYPES.has(m.type)) return 1; - return 2; - }; - const effImportance = (m) => - (isCoding && CODING_MEMORY_TYPES.has(m.type)) - ? Math.min(5, m.importance * (config.codingBoostFactor ?? 2)) - : m.importance; - const pa = priority(a); - const pb = priority(b); + // #619:排序键逐条算一次,而不是在 comparator 里现算(8k 行要算十几万次 + // priority/effImportance);末位补 updated_at DESC + id 两个平局键,让同分组 + // 内的次序可复现——此前靠 Array.sort 的稳定性继承 SQL 行序,改一行 updated_at + // 就能造成上千对交换,同分组的行谁进注入位实际上是随机的。 + const priority = (m) => { + // #230:叙述条与 document 摘要行同为次优先档——蒸馏摘要(0)仍最 + // 先,指针型聚合产物(1)先于普通 project/decision(2)。叙述条是 + // type=summary,判 source 必须在判 type 之前。 + if (m.source === "narrative") return 1; + if (m.type === "summary") return 0; + if (m.type === "preference") return 1; + if (m.type === "document") return 1; + if (isCoding && CODING_MEMORY_TYPES.has(m.type)) return 1; + return 2; + }; + // 编码记忆在编码任务时优先于普通 decision(与 preference 同级), + // importance 乘 codingBoostFactor 加权(封顶 5,保持 importance 语义)。 + const effImportance = (m) => + (isCoding && CODING_MEMORY_TYPES.has(m.type)) + ? Math.min(5, m.importance * (config.codingBoostFactor ?? 2)) + : m.importance; + const windowSize = Math.max(200, poolSize); + const rankedOrder = filtered + .map((m) => ({ + m, + p: priority(m), // 软加权乘在层内数值积上(priority 档位不动):foreign ×0.5 后压不过 // 同档自己行——E7 的同 importance 档设计正是这个场景。 - return pa - pb || - (effImportance(b) * qualityWeight(b) * heatOf(b) * scopeMultOf(b)) - - (effImportance(a) * qualityWeight(a) * heatOf(a) * scopeMultOf(a)); - }); + s: effImportance(m) * qualityWeight(m) * heatOf(m) * scopeMultOf(m), + })) + .sort((a, b) => + a.p - b.p || + b.s - a.s || + (a.m.updated_at === b.m.updated_at ? 0 : (a.m.updated_at ?? "") < (b.m.updated_at ?? "") ? 1 : -1) || + (a.m.id < b.m.id ? -1 : a.m.id > b.m.id ? 1 : 0)) + .slice(0, windowSize) + .map((row) => row.m.id); + // 排序键那版没有 title/content,而注入渲染要全文;只回表这一批(实测 200 条 + // 约 7ms),且保持排序后的次序。回表时行可能已被归档/遗忘/删除——丢弃, + // 不为它占位。 + const fullById = new Map(); + if (rankedOrder.length) { + for (const row of store.listByIdsForInject(rankedOrder)) fullById.set(row.id, row); + } + const items = rankedOrder + .map((id) => fullById.get(id)) + .filter((m) => m && !m.archived && !m.forgotten); + let candidates = items; // #617 第 2 条(前半):向量路与 BM25 路此前是**互斥**的——`if (!semanticItems.length)` // 包住 BM25,于是「有向量」等于「词法命中整批消失」。可两条路测的不是同一件事: diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index e607b599..0c62cb5e 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -4522,6 +4522,45 @@ export function createStore(path) { })); } + /** + * 取注入规则池的排序键:全部活跃行,只带过滤与排序真正需要的列。 + * + * 为什么单独开一个口,而不是 store.list({ limit: Math.max(200, poolSize) }): + * 那个窗口按 importance DESC 取(store.list 的默认 order),实测合格行 8410 条 + * 里有 8210 条永远进不了窗——importance 4 且质量满分、本就该排在前面的记忆, + * 一条都到不了排序。这与 #616 的 BM25 语料是同一种病:截断不是慢,是静默的 + * 候选缺失。 + * + * 为什么不用 list({ limit: null }):list 按 PRAGMA 派生列清单搬全部列(含 + * content),9k 行实测 340-400ms;这里只要 12 列,实测 200ms。content 只对真正 + * 入选的那 200 条用 listByIdsForInject 点查回来(实测 7ms)。 + * + * updated_at 一并取出,供排序末位平局键使用:同分组的行此前靠 Array.sort 的 + * 稳定性继承 SQL 行序,改一行 updated_at 就能造成上千对交换。 + */ + function listInjectPool() { + return db.prepare( + `SELECT id, type, source, importance, quality_score, last_accessed_at, + created_at, updated_at, agent_scope, workspace_scope, + archived, forgotten + FROM memories + WHERE archived = 0 AND forgotten = 0` + ).all().map((row) => ({ + id: row.id, + type: row.type, + source: row.source, + importance: row.importance, + quality_score: row.quality_score, + last_accessed_at: row.last_accessed_at, + created_at: row.created_at, + updated_at: row.updated_at, + agent_scope: row.agent_scope, + workspace_scope: row.workspace_scope, + archived: row.archived, + forgotten: row.forgotten, + })); + } + function getFailureStats({ since } = {}) { const clause = since ? "WHERE created_at >= ?" : ""; const params = since ? [since] : []; @@ -5990,6 +6029,7 @@ export function createStore(path) { countPinned, isPinned, listByIdsForInject, + listInjectPool, listBm25Corpus, createEntity, findEntityByName, diff --git a/dsh-mneme/test/inject-pool-full.test.js b/dsh-mneme/test/inject-pool-full.test.js new file mode 100644 index 00000000..17676412 --- /dev/null +++ b/dsh-mneme/test/inject-pool-full.test.js @@ -0,0 +1,105 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; + +// #619 回归:注入规则池此前是 store.list({ limit: 200 }) —— 窗口按 importance +// DESC 取,合格行 8410 条里 8210 条永远进不了窗(Loom 复核报告 A/B 的 B 条)。 +// 现在规则池取全部活跃行的排序键、过滤与排序都在 JS 侧做,窗口只限制回表条数。 +// 下面四条锁住:窗口外仍能进注入、排序键同水位只取一次、heat 消费侧绕开缓存、 +// 回表真的把正文取回来了(排序键快照没有 content)。 + +function setup(config = {}) { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config }); + return { store, service }; +} + +// 按重要性序排不进前 200 的目标行(summary 优先档,但 importance 只有 1) +function seedWindowFillers(service, n = 250) { + for (let i = 0; i < n; i++) { + service.saveWithDedupe({ + type: "project", + title: `填充条目${i}`, + content: `普通项目记录第${i}条,内容与哨兵词无关`, + importance: 5 + }); + } +} + +test("#619: 200 行窗口外的高优先档记忆仍能进注入", () => { + const { store, service } = setup(); + seedWindowFillers(service, 250); + const target = service.saveWithDedupe({ + type: "summary", + title: "哨兵摘要", + content: "这条摘要排在重要性序第 251 位,但仍必须先于普通 project 进注入", + importance: 1 + }).memory; + + const picked = service.injectCandidates({ maxItems: 5 }); + assert.ok(picked.length > 0, "注入不应为空"); + assert.ok( + picked.some((m) => m.id === target.id), + "窗口外的高优先档记忆必须能进注入候选(窗口只该限制回表条数)" + ); + assert.equal(picked[0].id, target.id, "summary 优先档应排在最前"); + store.close(); +}); + +test("#619: 同一 generation 内多次注入只取一次排序键", () => { + const { store, service } = setup(); + seedWindowFillers(service, 30); + let poolLoads = 0; + const origPool = store.listInjectPool.bind(store); + store.listInjectPool = () => { + poolLoads += 1; + return origPool(); + }; + + service.injectCandidates({ maxItems: 5 }); + service.injectCandidates({ maxItems: 5 }); + assert.equal(poolLoads, 1, "无写入时排序键缓存应命中,只取一次"); + + // 写入后水位推进,缓存失效 + service.saveWithDedupe({ type: "decision", title: "新决策", content: "新内容", importance: 4 }); + service.injectCandidates({ maxItems: 5 }); + assert.equal(poolLoads, 2, "写入后应先失效再重取"); + store.close(); +}); + +test("#619: heat 双开关同时打开时每次注入都重取排序键", () => { + const { store, service } = setup({ heatEnabled: true, injectHeatEnabled: true }); + seedWindowFillers(service, 30); + let poolLoads = 0; + const origPool = store.listInjectPool.bind(store); + store.listInjectPool = () => { + poolLoads += 1; + return origPool(); + }; + + service.injectCandidates({ maxItems: 5 }); + service.injectCandidates({ maxItems: 5 }); + // touchLastAccess 只写 last_accessed_at、刻意不 bump generation,heat 依赖它, + // 所以这条路径必须绕开「按 generation 缓存」——否则 heat 停在上一轮的读数上。 + assert.equal(poolLoads, 2, "heat 消费侧必须每轮重读,不能被 generation 缓存挡住"); + store.close(); +}); + +test("#619: 注入条目带完整字段(证明走回表而不是排序键快照)", () => { + const { store, service } = setup(); + const target = service.saveWithDedupe({ + type: "decision", + title: "回表校验", + content: "正文必须回表取到,排序键快照里只有 12 列、没有 content", + importance: 5, + tags: ["回表"] + }).memory; + + const picked = service.injectCandidates({ maxItems: 5 }); + const hit = picked.find((m) => m.id === target.id); + assert.ok(hit, "目标行应入选"); + assert.equal(hit.content, "正文必须回表取到,排序键快照里只有 12 列、没有 content"); + assert.deepEqual(hit.tags, ["回表"]); + store.close(); +});