# 怎么量，以及跟同类比什么

> 本文是**说明书**，[README](../README.md) 是**门面**。这里的每个数字都写明口径与复算命令，
> 欢迎照着跑一遍 —— 跑不出同样的数，是我们的问题。

覆盖：一分钟选型（四条否决线）、跟同类插件的对照、召回质量的四把可复算的尺子、集百家之长。

> 复算入口：`node bench/run.mjs`、`npm run probe`、`npm run bench:check`（三条门槛里的第三条）。

---

## 一分钟选型：先看会不会被卡住

选型的顺序是先排除，再比功能。下面四条否决线是两次真实卸载换来的（云端 Memmy 装上又卸了两遍）。

| 否决线 | 判据 | 倒在这条线上的 |
|---|---|---|
| **数据不出本机** | 正文、embedding、摘要，一个字节都不该离开这台机器 | Memmy（`api.memmy.cn`）、Hindsight（默认上云）、DSH-SEP（抽取走 `api.deepseek.com`） |
| **用不上就不计费** | 没命中就该是零 token、零检索，而不是"少花一点" | Memmy（每轮无条件 550–1550 tokens）、DSH-SEP（每步 ~1250 字符 guidance + ≤4200 字符快照）、OpenViking（强制服务端 + 每轮注入） |
| **不做不可逆的事** | 可以不写，不能替人删 | 所有"自动遗忘 / 自动合并"型方案：删掉的记忆找不回来，而记忆系统的价值恰恰在长尾 |
| **三年后还能跑** | 不需要编译原生模块、不需要常驻服务 | 带 `better-sqlite3` 编译、rust 工具链、独立 daemon 的方案，会在某次系统升级后让你回来看这篇 README |

四问都过了，才轮到比功能。功能这一层，本插件的取舍是**把不确定性变成可测量的东西**：
新机制必须在库上跑出数字，跑不出来就不上（文末十四条被证伪的路就是这么留下的）。

## 跟同类插件对照

| 维度 | **本插件** | Memmy | DSH-SEP | dsh-mneme | 灵枢 dsh-memory | hindsight |
|---|---|---|---|---|---|---|
| **存储真相源** | markdown 文件（人可读、可 git、可手改） | 云端 | 云端抽取 | SQLite（markdown 是可重建的投影） | rust 索引 | 服务端 |
| **出网** | 无 | `api.memmy.cn` | `api.deepseek.com` | 无 | 无 | 默认上云 |
| **每轮常驻开销** | 0；命中才 ≤480 字符 | 550–1550 tokens | ~1250 + ≤4200 字符 | 未核实 | 未核实 | 未核实 |
| **召回** | 门控 → 头尾取样 → BM25 + IDF 选词 + 同义桥 + 别名 | embedding 向量 | 抽取式 | — | BM25 + 时间衰减 | BM25 + 时间乘性加权 |
| **写入治理** | fail-closed 闸门 + 写入审计 + 沉淀提醒（三层） | 无 | 无 | 无 | 只认 `committed` 字段 | 无 |
| **自动归档** | 不截断、整段搬家、可再分层、可回滚 | — | — | 截断 + 存 `_full_content` | — | — |
| **可观测** | 八页签只读（提醒记录 / 用上了多少 / 笔记 / 体检 / 索引全文 / 归档 / 写入记录 / 说明）+ 首屏三问 | 未核实 | 未核实 | 未核实 | 未核实 | 未核实 |
| **依赖** | 零（全 `node:` 内置） | 云端 SDK | 云端 SDK | SQLite | rust 工具链 | 服务端 |

表里的「未核实」是故意留的：没读源码也没实测过的格子就空着。一份对比表最坏的失败方式不是漏项，
而是把猜测写成事实。

### 2026-10 的货架（补记：真正会撞上的对手）

上面那张表是选型期的老对手。现在（2026-10-16 抓取）真正会撞上的是这些 —— 数据来自 npm registry、
GitHub API 与各家官方 README/文档：

| 方案 | 存储 / 出网 | 记忆怎么进模型 | 写入治理 | 可观测 | 依赖 |
|---|---|---|---|---|---|
| **本插件** | 本地 markdown；零出网 | 检索后按预算注入（`maxPointers` / `maxChars`） | fail-closed 闸门 + 索引上限拒绝增长 + 写入审计 + 可逆归档 | **账本 + 5 句判据 + 代价曲线 + 预算用量**（可复算） | 零（全 `node:` 内置） |
| `@fooxe/dsh-memory`（周 5 131） | 本地纯 markdown；零出网 | `ctx.systemPrompt` 常驻 | 守卫数字与本插件**一字不差**（200 行 / 25 KB / 500 条 + 密钥拒写）、原子写、防路径穿越 | 面板带**实时护栏仪表**（行数 / KB / 文件数） | 零 |
| `claude-mem`（101 030★ / npm 6.8 万/月） | 本地 SQLite + Chroma 向量；压缩要 LLM provider | 钩子静默注入 + 4 个 MCP 工具渐进披露 | `<private>` 标签 + 正则脱敏；**无回滚** | telemetry 报 `tokens_injected` / `tokens_saved_vs_naive` / 成本（是**用量与估算**，不是「哪条被用上」） | Bun + Chroma + 模型 |
| `mem0`（67 238★，含官方 DSH 插件） | 托管服务端；需 API key | 插件在请求组装时召回，首轮注入 ≤5 条 | ADD-only 累积、**无回滚**、记忆失败 **fail-open** | 平台 Dashboard（Analytics） | 云端 + 模型 |
| `Letta`（25 156★） | 本地 git 仓库（MemFS）+ 模型 provider | `system/` 下的记忆文件每轮进系统提示 | **回滚最强**：每次编辑一个 commit、重组前自动备份、可选先审后写 | `/doctor` 审计 token 与重复 | docker / 模型 |
| `agent-memory`（3 743★，MIT） | 本地 markdown 真源 + SQLite 可删缓存 | 根 `MEMORY.md` 唯一常驻注入 | hook 信任审查、只读默认；无回滚 API | `mem trace` / provenance / measurement ledger（**全网唯一沾到我们这一格**） | 零（CLI 为主，无面板） |

三条结论：

1. **「索引上限做成拒绝写入」不再是独家** —— `@fooxe/dsh-memory` 用的是同一组数字，
   `dsh-engramory` 自己也有单调否决的守卫。这一条从现在起只能当「及格线」，不能当卖点。
2. **仍然少见的是「注入 → 被用上」的可复算账本**：全网只有 `agent-memory`（trace + provenance +
   内部 ledger）沾到边，其余全是**用量与节省的估算**（`claude-mem` 报 tokens、`mem0` 报 Dashboard
   分析、`Memori` 报年省金额）。本插件的账本是**逐轮逐条**的，并被面板与尺子复算成人话。
3. **写入可逆这一格，Letta 是最强的一档**（git 版本历史）。我们的可逆归档（整段搬家 + 备份 +
   两段式 + 可再分层）方向对，但它靠的是 git 的通用能力 —— 这一条我们也只能当及格线。

### 有几件事不是本插件独有的

写清楚这些，省得读者以为独家：**零模型调用**至少有四家同类做了（`thoth-mem`、
`@max-null/dsh-memory`、`@tr1v3r/dsh-ltm`、`@jipika/dsh-memory`）；
**中文词法检索**也不是独家（`@max-null/dsh-memory` 做 CJK 单字加 2-gram，
`@tr1v3r/dsh-ltm` 做 CJK 二元加 FTS5 混合重排）；**markdown 当真相源**有 basic-memory（AGPL-3.0）；
**写入安全**有 `@jipika/dsh-memory`（写前 guard hook）和 `@tr1v3r/dsh-ltm`（串行写入 + 备份 + 回滚）。

### 确实少见的三件

1. **索引上限做成「拒绝写入」而不是「注入预算」**。同类的超限处理都是软的：
   `@jipika/dsh-memory` 的 `indexBudget` 默认 12000 字符，超了自动回退成索引模式；
   `@max-null/dsh-memory` 逐条截断注入；`thoth-mem` 用 strict character budget。
   本插件的 `index-guard.js` 是在**写入那一刻**拒绝让 `MEMORY.md` 超过 200 行 / 25KB，
   理由是索引每次会话都要整篇读，超限的部分会被静默地不再召回，软处理救不了这件事。
2. **可逆归档**。追加式章节整段搬家、不截断、搬前备份、两段式确认；同类里没有等价物。
3. **MIT + 零依赖 + 零进程**。最强的同生态对手 basic-memory 是 AGPL-3.0 + Python 进程 +
   FastEmbed 权重下载，装它要接受另一套运行时。

### 短板

- **公开基准只跑了一半**：LongMemEval-S 500 题跑出 RecallAny@5 98.2%（见下），但那只测英文词面
  检索；`thoth-mem` 公布的 470 题版本（RecallAny@5 432/470、Recall@5 84.8%、NDCG@10 0.8165）
  用的是别的 harness，两边数字不可相减。真正的「跨插件排名」仍然不存在。
- **自带路径上没有语义检索**：没有内置 embedding、没有向量库，开箱就是纯词面；同义改写要靠
  手工同义表覆盖，没覆盖到的就是漏检。想接语义得自己指一个提供方（`semanticProvider`，见下），
  插件本身不替你选模型、不替你出网。
- **只有 DSH 里是「全功能」**：0.8.0 起带一个零依赖的 MCP 服务器（见下），任何支持 MCP
  的宿主都能检索与读笔记；但写入闸门、归档、体检、沉淀提醒、面板这些**只在家用版（DSH 插件）
  里有** —— 别的宿主拿到的是只读视图。`claude-mem` 覆盖 7 个宿主、`thoth-mem` 覆盖 4 个，
  我们覆盖「1 个全功能 + N 个只读」。
- **没有多端或团队同步**：basic-memory 有云同步，本插件没有。
- **召回的「用没用上」只有代理指标**：`.engramory-recall.jsonl` 能证明「注入过的笔记后来被打开了」，
  证明不了「它被用进了回答」；也没有按查询统计的注入成功率（只有零命中轮数）。要把这层做实，
  得让宿主把「模型引用过某条记忆」这件事暴露出来，当前宿主接口里没有。
  0.15.0 起可以把这层代理指标摊开成周报看（见上一节「账本与周报」），但代理终究是代理。
- **写入闸门可以被绕过**：`guard` 只拦 `write` / `edit` 工具调用，`bash` 重定向它看不见；
  `@jipika/dsh-memory` 的 guard 挂在更底层的 `tools/pre-execute`，覆盖更广。

## 账本与周报：这块记忆到底花了多少上下文（`npm run recall:report`）

打开率账本（`.engramory-recall.jsonl`）从 0.8.0 就在落盘，但「翻账本」一直得自己写脚本。
0.15.0 起有一页人话周报：**谁在干活、花了多少上下文、哪些笔记被打开过、为什么有些轮次一个字都没进**。

```bash
npm run recall:report                     # 最近 7 天
npm run recall:report -- --days 30        # 最近 30 天
npm run recall:report -- --json           # 同上的机器可读版（提示走 stderr）
```

报告里**没有第二个数字来源**：引擎参数取自 `bm25.js#scoringProfile()`，宿主设置从你 profile 的
`cordis.patch.yml` 里读出来（路径原样印在报告里），插件默认取自 `index.js#DEFAULTS`。写死一份
常量迟早会和代码分叉，分叉了报告就是在撒谎。

本机第一次跑（2026-10-16 08:56，窗口 10 小时 17 分，256 行账本）：

| 问题 | 数字 |
| --- | --- |
| 问过多少轮 | 149 轮 |
| 带记忆进的 | **109 轮（73.2%）**，注入 545 条指针 |
| 一个字都没进的 | 40 轮：与上一轮重复 21、寒暄 9、消息太短 7、早期日志没记原因 3 |
| 被打开过 | 26 条（**4.8%**）；只看主对话轮的指针是 7.7%（另有 320 条来自子代理轮，它们几乎不打开笔记） |
| 预算 | 中位用掉 93%，10/33 轮顶到 95% 以上，20% 的注入轮有指针被截断（22 条缩成裸文件名、0 条整个没列出） |
| 缩成裸文件名的那几条 | 被打开 **0.0%**（0/5）；完整列出的 7.8%（9/115） |

被打开最多的三条：`reference-engramory-recall-tuning.md`（7/12）、`reference-engramory-recall-plugin.md`
（5/9）、`project-open-todos.md`（4/72）。被带入次数最多却一次没打开的：`project-by-yffto-cicd-history.md`
（53 次）等 —— 多数是「看一眼文件名就够了」的指针，长期如此说明描述不够勾人，或者它们本就不该占窗口。

**边界（报告自己也会印一遍）**：

- 「被打开」的归属：某条笔记在**注入它的那一轮之后**被读过才算，只认最近一次注入它的轮；更早的读不算数。
- 这是**代理指标**：能证明「注入过的笔记后来被打开了」，证明不了「它被用进了回答」。要让这层做实，
  得宿主把「模型引用过某条记忆」暴露出来，当前接口里没有。
- 账本只存提问的前 160 字，所以「为什么这条没命中」得回会话里看原文。
- 老账本缺字段一律标注、不拿 0 补 —— 比如「省下多少上下文」要 0.15.0 起才落盘，
  在那之前报告会说「算不出」，不会被读成「省了 0 字」。

### 消费侧门控（0.16.0）：门槛该定在哪儿，是一道算术题

门控要判「这条笔记值不值得占上下文」，手里只有两个事件：**被带进上下文**与**后来被打开**。
把门槛拍成固定次数（比如「8 次没被读就降权」）会撞上一个概率事实：在 5% 的基础打开率下，
一条**和平均一样有用**的笔记，8 次全没被读的概率也有 `0.95^8 ≈ 66%`。所以阈值改成按本库
自己的基础率算的期望值：**降权线 = 期望被读 1 次 ÷ 基础率**，**抑制线 = 期望被读 3 次 ÷ 基础率**。

本机真库实测（2026-10-16，`npm run recall:report -- --days 0` 与 A/B 重放脚本）：

| 问题 | 数字 |
| --- | --- |
| 基础打开率 | **2.86%**（560 次携带里被真读过 16 次） |
| 两条件线 | 降权 35 次、抑制 105 次（另加「最少 8 次」「观察 ≥ 7 天」） |
| 「看着像没用」 | 20 篇（携带 ≥ 8 次、0 次被读）；其中 **1 篇过降权线**、0 篇过抑制线 |
| `assist` 与 `off` 的差别 | 重放 40 轮：**13 轮（32.5%）** 注入集合/名次有变化；13 条指针掉出前四、13 条被顶进来 |
| 假杀（够线之后又被读） | 4 条候选，剔除「刚写完 10 分钟内读回自己」的自我触碰后 **0 条** |

这张表也是「阈值不是拍脑袋」的证据：**20 篇可疑里只有 1 篇真的越线**。如果一开始就按
「8 次没被读」动手，这一库里会有 20 篇笔记被降权，其中 19 篇只是**运气差**。

## 召回质量：四把可复算的尺子

### 一、合成基准（`node bench/run.mjs`）

224 篇程序生成的笔记（中文书面 83 / 英文术语 101 / 口语体 40，其中 37 篇写了 `aliases`）、
704 条查询、固定随机种子。查询按三种风格分开记分：`verbatim` 抄笔记自己的写法（对照组）、
`colloquial` 只用口语说法（与正文没有字面重叠）、`keyword` 口语症状加域特征词
（同域候选很多，必须靠事件词分辨）。语料是程序生成的，不依赖任何私人库，任何人可复算：
`npm run bench` 整跑 4 秒，零依赖、零网络，消融表里对我们不利的那几行也照打，
CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。

| 引擎 | 总 hit@1 | 总 hit@4 | verbatim | colloquial | keyword |
|---|---|---|---|---|---|
| **本插件** | **50.4%** | 67.5% | 98.7% | 34.8% | 15.6% |
| 教科书 BM25（同分词口径） | 43.2% | 46.9% | 100% | 17.9% | 17.9% |
| 教科书 BM25（中文逐字切分） | 47.2% | 53.4% | 100% | 20.1% | 28.1% |
| 瞎猜（地板线） | 0.6% | 2.6% | 0.4% | 0.9% | 0.4% |

（分风格单元格是 hit@1。地板线是实测的随机命中率：`keyword` 的同域候选有 14 篇，瞎猜大约 7%。）

消融，同一份语料，每次只动一个机制：

| 改动 | 总 hit@1 | 相对本插件 |
|---|---|---|
| 本插件 | 50.4% | — |
| 去掉同义表 | 46.4% | -4.0pt |
| 去掉笔记别名（不再声明 `aliases`） | 56.1% | **+5.7pt** |
| 去掉字段加权（`TITLE_EXTRA` 24 → 0） | 49.1% | -1.3pt |
| 同义键碎片不降权（回到改动前） | 49.0% | -1.4pt |
| 桥权重与同义表一致（都算 0.5） | 51.4% | +1.0pt |

「去掉同义表」这一行要打个折读：合成语料的口语组 / 桥组查询本身就是照着「需要桥」造的，
同义表也为它们补过词，所以 **-4.0pt 是这张表在**这份**卷子上的上界，不是它的通用价值**。
同口径在真人查询上的数字小得多：探针那 36 条真实 query，拔掉整表 hit@1 27 → 24、hit@4 36 → 31
（`npm run bench:bridges --ablate`）。成因也量出来了：519 个同义值里 **39 个只在合成语料里有落点**，
在真库上一个词项都不存在（见「覆盖率尺子」一节）。

「去掉笔记别名」是 **+6.7pt**，在总分上反而更好看 —— 这个语料里别名是把双刃剑：
它帮自己（写了别名的 37 篇 hit@1 94.6%，没写的 187 篇 23.0%），
也把同域别的笔记的口语查询抢过来（语料里同域的别名共用同一个口语域词，而别名是程序按比例随机发的）。
真库里别名是人手写的、更克制，所以别拿这一行下结论 —— 判断别名只看探针的别名组（5/5）。

最后两行是一次明确的取舍。改动前，桥组（查询里的词在目标笔记里一个字都没有）
只有 27.3%：查询自己的字面碎片（`崩了` 的「了」在别的笔记里到处都是）按满权重计分，
把桥到的词压了下去。修法有两种，一种是把命中的同义键自身的字面碎片从词项里剔掉，
实测**打红了真实存在的测试**：`test/recall.test.mjs` 的注入预算阶梯用 `代理节点与部署` 当查询，
而 `代理`、`部署` 既是同义键、也是笔记正文里的原词，剔掉等于把真证据扔了。
所以改成**降权不剔除**（`BRIDGED_FRAGMENT_WEIGHT = 0.5`，桥到的技术词算 `BRIDGE_WEIGHT = 1`）。
降权只针对键的**碎片**（单字、相邻双字），键本身照旧算满权重 —— 这一条是量出来的：
一开始连键本身一起降权，`verbatim` 对照组从 100% 掉到 95.1%，放开键本身后回到 98.7%，
而桥组一点没退。道理很直白：查询里原样出现的那个词是实打实的证据，降权的理由只是
它拆出来的碎片在别的笔记里到处都是。

代价是与「桥权重打回 0.5」相比总分少 1.0 个点（51.4% → 50.4%）。接受它的理由：
合成语料里绝大多数查询本来就有字面落点，总分被它们主导；桥是唯一在字面完全无交集时
还能工作的机制 —— 同一批桥查询，桥权重打回 0.5 只有 25.0%，碎片不降权 43.8%。
（历史值：修「碎片压桥」之前，那一版语料上这组查询只有 27.3%。）

「去掉字段加权」那一行要单独说：它现在是 -1.3pt（拿掉略微更差），**在一次引擎改动之前它是 +4pt**
（拿掉明显更好），而**真实库的中立对照一直说拿掉更差**（见下）。当时的冲突定位到机制上：
合成语料的标题是从正文模板派生的、与正文近乎重复，抬高标题权重只是放大域词噪声；
真实库的标题是人手提炼的。现在前两把尺子都偏向「留着字段加权」，但理由仍是上面这条 ——
不是冲突消失了，是那把合成尺子的标题变真实了一点。两者不一致时以对照 C 为准。

**长度归一的强度 B 是量出来的，不是抄的。** 教科书默认 0.75 我们抄了很久，当时的三把尺子都说它偏大：

| 尺子 | B=0.75 | B=0.4 |
|---|---|---|
| 这份合成卷子 | hit@1 49.9% · hit@4 66.6% | hit@1 50.4% · hit@4 67.5% |
| LongMemEval-S 500 题 | @1 87.8% · @5 97.4% · 覆盖 91.5% | @1 88.4% · @5 98.2% · 覆盖 92.5% |

（这张表单独量 B。查询侧后来又加了一类框架词过滤，@1 从 88.4% 再涨到 **89.2%** —— 见 LongMemEval 一节的第 1 条实话。）
| 真库对照 C（中立那把） | top1 141/181 · recall@4 168/181 | top1 150/181 · recall@4 177/181 |

机制：同一个词在长会话、长笔记里出现得更多，`len/avgLen` 这个惩罚就变成「谁长谁吃亏」，
与相关性无关。代价在对照 A 上看得见（top1 45→43），但 A 拿文件名当查询、结构性偏袒短名，
按一直以来的规矩以对照 C 为准。扫过的值是 0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.75 / 0.9，
0.3~0.5 是一片平台，取中间偏下的 0.4。

### 桥组：32 条，65.6%

| 组 | 条数 | hit@1 | 说明 |
|---|---|---|---|
| 桥组（全部） | 32 | 65.6% | 查询与目标笔记的字面重叠为 0，命中只能靠同义桥 |
| 其中公开词族 | 20 | 65.0% | 调表时看得见的词族 |
| 其中留出词族 | 12 | 66.7% | 没参与调表的词族，看一点泛化 |

这组查询是从引擎自己读的那张同义表里长出来的，所以它证明的是**扩展管道通不通**，
不是「表能不能猜中没见过的说法」，后者只有留出那 12 条能说一点。
真正的瓶颈在供给面：这张表有两层来源，口径必须分开说 ——

- **前四波是从真实库里挖出来的**（专有名词、某个人的叫法），换一套词汇就大面积失效；
- **第五波是通用技术口语**（「变慢」「退不回去」「改表结构」这类任何写中文技术笔记的人都会用的说法），
  不依赖具体某个库，装到别人机器上也能命中。

新表扩容后，这套语料的 16 个域词、14 个事件词全部有桥；
但仍有一批口语键因为自带字面碎片而够不上「纯桥」资格被丢弃（本次丢弃 22 组）。
704 条查询里，口语风格那批字面重叠为 0 的只有 30 条 —— 绝大多数「口语查询」
仍能靠碎片碰到笔记，真正需要跨说法的只有那几条。

未命中的查询里，101 条的 top-1 是同一领域里搞错了事件，242 条是同一事件里搞错了领域，
「完全不沾边」9 次、「一条都没召回」0 次。失败都是「差一点」，没有灾难性的空召回。

### 二、真库探针（`npm run probe`）

跑在自己的记忆库上，分三组 query 各测一层机制。数字随库变化，每次跑都打印语料指纹，
指纹变了会直接警告「分数不可直接比较」。

最近一次：**66 篇 / 200 个 chunk**（指纹 `cbe8d93f9d67`）。这组数字的用处是「别掉分」，
不是跨库比较 —— 换一台机器、库里多写一篇笔记，绝对值都会动（下表的条数就是当时的语料规模）。

| 组 | 条数 | 测什么 | 最近一次 |
|---|---|---|---|
| 口径 | 21 | 真实场景的中文口语问法 | recall@4 21/21 |
| 桥探针 | 10 | 全局同义表（大白话问英文/术语密集的笔记） | recall@4 10/10 |
| 别名探针 | 5 | 笔记自己的 `aliases:`（关键词在全局桥里一个都没有） | recall@4 5/5 |
| 死桥自检 | 519 值 | 同义桥的每个值是否真的存在于语料（不存在等于白写） | 117 个死值（均为通用词） |
| 对照 A | 200 | 文件名当查询的底线（对标题权重有偏袒） | top1 47/200 · recall@4 100/200 |
| 对照 B | 175 | 笔记中文标题当查询的底线（同样偏袒） | top1 170/175 · recall@4 174/175 |
| 对照 C | 187 | 正文独有词当查询（该词在任何标题里都没有） | top1 155/187 · recall@4 182/187 |

对照 A 和 B 都拿标题当查询，所以它们对「标题里的词多计几遍」这类参数是**结构性偏袒**的：
抬高标题权重几乎必然让这两组变好。它们既不该当目标函数，也不能用来证明「抬高标题权重是安全的」。
唯一中立的是对照 C：查询词取自某篇正文、且任何标题里都没有它，此时抬高标题权重
只会让标题里恰好撞上这个词的别的笔记平白加分、把正确答案挤下去。`TITLE_EXTRA` 的值就是按 C 定的。

探针存在的理由值得单说：`bm25.js` 里的同义表与 `EXTRA_WEIGHT` 是语料统计量撑起来的经验值，
在小 fixture 里测不出差异。写过一版单测，权重从 0.5 改到 0.8 两边都绿，
因为小库里 df 结构整个反过来。那种测试给的是假安心，已删掉，证据改由探针在真实库上产出。

它也确实拦下过一次误判：`EXTRA_WEIGHT` 一度被提到 0.8（当时量到口语 query 19/22 → 20/22），
但那是同义表还没补全时量的。等最后几条桥补完再重量，0.5 和 0.8 的三项数字一模一样
（口径 19/21、对照 A 22/82、对照 B 136/142），说明那 1 分来自补桥、不是来自权重。0.8 因此退回 0.5。
**A/B 要在最终状态上做，中途的差值可能只是「另一半还没做完」。**

还有一个坑：探针跑的是真实记忆库，库一变分数就会动。撞过一次，某篇笔记里逐字抄了探针的 query，
自己成了第一名，把真正的目标挤下去。现在写调优笔记一律不引用探针原文。

这个坑现在有机器化的答案：每次跑探针都打印一枚语料指纹（`store.js` 的 `corpusFingerprint`，
把每个 chunk 的 `id / title / description / aliases / body` 哈希成 12 位十六进制），并和基线比对：

- **指纹相同、数字有变**：这是一次可信的 A/B（改了代码，卷子没换）。
- **指纹不同**：直接警告分数不可直接比较。

指纹的第一版只哈希了 `id + body`，于是只改 `aliases` 时它纹丝不动，而 `aliases` 是被计两遍、
实打实改变排序的。结果探针一边说「语料没变、这是可信 A/B」，一边把一次语料改动伪装成了代码改动。
**一个漏字段的指纹比没有指纹更坏**，因为它是权威口吻的错误。现在指纹覆盖所有喂进索引的字段，
并有专门的回归测试守着。

### 三、公开数据集：LongMemEval-S

前两把尺子都是自己出的卷子，这一把不是：LongMemEval 是 ICLR 2025 的记忆评测集
（`xiaowu0162/longmemeval-cleaned`）。我们跑的是 S 版，500 题，每题一个约 48 个 session 的
haystack，题目分六类。

做法：一个 session = 一篇笔记（角色前缀保留），query = 问题原文（**不读** `question_date`，
也**不看** `has_answer` 标注），命中 = gold session 进 top-k。500 题跑 18 秒，零模型调用。

| 指标 | 全部 500 题 | 教科书 BM25（同分词口径） |
| --- | --- | --- |
| RecallAny@1 | **89.2%** | 86.2% |
| RecallAny@5 | **98.2%** | 97.0% |
| Recall@5（gold 覆盖率） | **92.5%** | 90.7% |
| MRR@5 | **0.9251** | 0.9056 |
| RecallAny@10 | **99.0%** | 98.2% |

| 题型 | 题数 | RecallAny@1 | RecallAny@5 | Recall@5 | MRR@5 |
| --- | --- | --- | --- | --- | --- |
| knowledge-update | 78 | 98.7% | 100.0% | 99.4% | 0.994 |
| multi-session | 133 | 88.0% | 98.5% | 86.0% | 0.920 |
| single-session-assistant | 56 | 100.0% | 100.0% | 100.0% | 1.000 |
| single-session-preference | 30 | 60.0% | 90.0% | 90.0% | 0.708 |
| single-session-user | 70 | 92.9% | 100.0% | 100.0% | 0.950 |
| temporal-reasoning | 133 | 85.0% | 97.0% | 88.5% | 0.894 |

三句必须说在前面的实话：

1. **这 3.0 个点的来源要说清楚。** 本插件比教科书 BM25 高 3.0pt @1，差的那部分**全部**来自三处通用改动：
   查询侧丢掉英文虚词（`what / is / the / of / did / how…`）；查询侧再丢掉一类「求助/建议框架词」
   （`tips / suggest / recommend / advice / think / good / weekend…` 共 14 个，这一处单独值
   **+0.8pt @1**、MRR +0.005，最弱的偏好分片 @1 从 46.7% 抬到 60.0%）；以及把长度归一常数从教科书的
   0.75 调到 0.4。三处都与中文无关 —— 是我们自己的机制（同义表、别名、字段加权、中文分词）在这份数据上
   没被激活：LongMemEval 是英文的，session 没有标题、没有别名、没有中文口语，它们在这里没事可做。
   B 的取值不是在这份数据上单独挑的（挑法见上：当时的三把尺子一致，真库中立对照 +9/+9）。所以这张表
   证明的是「英文词面检索不输、且基础卫生做到位」，**不是**「本插件的独门设计更好」。机制值多少，
   回上面两把尺子看。
2. **别拿它跟别家数字直接相减。** thoth-mem 公布过 LongMemEval-S 470 题、RecallAny@5 91.9%。
   我们用的是 cleaned 版 500 题 + 自己的 harness，题集版本、命中口径、读不读日期都未必一样，
   相减没有意义。能担保的是：harness 与逐题结果都在仓库里（`bench/longmemeval.mjs`、
   `bench/longmemeval-results.json`），可以逐题核。
3. **最差的仍是偏好题**（60.0%@1，改动前 46.7%）：「我喜欢什么」这类问题本身没什么词面抓手，
   而且它的 16 个 miss 里有 11 个掉在第 2~4 名 —— 全是「另一场给建议的对话」，是同流派干扰而不是
   够不着。丢掉求助框架词能把 @1 抬 13.3pt，剩下的差距才是零模型路线的边界：偏好类想做得更好，
   要么开可选的 `llm.js`，要么就老老实实把偏好写进标题。

复算：

```bash
mkdir -p bench/.tmp && curl -L -o bench/.tmp/longmemeval_s_cleaned.json \
  https://hf-mirror.com/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
node bench/longmemeval.mjs --data bench/.tmp/longmemeval_s_cleaned.json                     # 本插件
node bench/longmemeval.mjs --data bench/.tmp/longmemeval_s_cleaned.json --engine unigram      # 教科书 BM25
```

数据 264MB，不入库也不进 CI；CI 里跑的是同格式的小夹具
（`bench/fixtures/longmemeval-mini.json`，四道题分别覆盖「英文命中 / 中文命中 /
多 gold 只找到一半 / 零字面重叠必失手」），判据被改动就会红。

### 四、地基句尺子（`npm run bench:foundation`）

前三把尺子量的都是「笔记找没找对」。这一把量的是最后一步：**写进上下文的那一行里，
到底有没有那句话**。

道理很直白：一条笔记被找对了、也进了注入列表，但注入行是从标题或描述里抠出来的一截
——那句话就是提问的回声，跟用户刚说的话一模一样，决定性的事实一个字都没进去。
上下文花了字数，模型却什么也没拿到。全球调研里最常被引用的一条抱怨正是这个：
*"'With Memory' is 9/10 Relevancy even though it is 100% duplication of what I had said."*

口径与我们公开对照里灵枢 `hive-memory-bench` 的「保地基」一致：**逐字包含**才算保住
（空白归一后比较）。宽松口径（罕见词还在）只当诊断，不当结论。

**题集**（`bench/foundation-corpus.mjs`，固定种子 `20261016`）：96 篇笔记 / 96 题，
四组 —— `unique`（罕见词只在唯一一篇里）、`dense`（地基句同时含罕见词与通用词）、
`long`（地基句 71–77 字）、`shared`（三篇共用罕见词）；三种提问形态 —— `rare`（只有罕见词）、
`mid`（口语域词 + 罕见词）、`verbose`（「X 的时候 Y 了，〈罕见词〉那块一直对不上，帮我看下」）。
干扰句一律用通用词铺满，罕见词只出现在地基句里。**调参与汇报分开**：`tune` 66 题用来调，
`heldOut` 30 题只用来汇报（组别 / 形态 / 留出三条独立随机流 —— 第一版用取模分配，
导致留出组与 `rare` 形态完全重合，量出「留出组 87.5% vs 调参组 10.9%」的假泛化）。

**指标**：`hit@1` / `hit@K`（找对笔记，天花板）· `保地基（逐字）` ·
`罕见词还在（宽松）` · `找对了却丢掉地基句` · `平均注入行字符` · `平均用掉预算`。

同一份卷子、同一个引擎，每次只动一个机制：

| 配置 | 保地基（全部） | tune | heldOut | 宽松 | 平均注入行 |
|---|---|---|---|---|---|
| 0.14.0：按命中词数选行 + 64 字均分（旧） | 39.6% | 45.5% | 26.7% | 49.0% | 60.1 字 |
| 修一：IDF 加权选句 + 标题/描述 ×0.6 + 均分 64 字 | 76.0% | 80.3% | 66.7% | 85.4% | 72.9 字 |
| 修一 + 均分 120 字（把预算放宽看看） | 85.4% | 87.9% | 80.0% | 85.4% | 73.9 字 |
| **修一 + 名次加权预算（0.14.1 默认）** | **85.4%** | **87.9%** | **80.0%** | **85.4%** | **73.9 字** |
| 上面那档再加邻句扩展（`--neighbor`） | 85.4% | 87.9% | 80.0% | 85.4% | 85.0 字 |
| 对照：`hit@1`（笔记找错的天花板） | 93.8% | 92.4% | 96.7% | — | — |

三句话读懂这张表：

1. **病根是「按命中词数挑行」**：提问里的口语词天然命中标题与描述，于是注入行成了提问的回声。
   换成 IDF 加权 + 对标题/描述乘 0.6 折扣（打折而不是禁用：标题本身含决定性词时仍能赢），
   在同一份 64 字预算下 39.6% → **76.0%**。
2. **逐字率对裁剪预算强耦合**：同一套选句规则，均分 64 字只有 76.0%，均分 120 字就到 85.4%。
   挑得再准，预算不够也白搭 —— 这是这一期最值得记住的一条。
3. **名次加权计划用 120 字档的质量、花更少的钱**：它按名次分配每条片段的额度
   （前面的条子最多 200 字、后面的按比例收窄，放不下就整条降级成「只有文件名」），
   合成题集上与均分 120 字打平，真实库上平均用掉 419 字而均分 120 字要 450 字。

**未能越过天花板的 14.6%** 里，最大一块是笔记就找错了（6.2%），其次是长句被 200 字上限截断。
`85.4% / 93.8%` = 可达上限的 **91%**。任何声称「保地基 > `hit@1`」的报告一定是错的。

**真实库**（`--store=$HOME/memory/engramory`，261 篇 / 250 题，数据不出本机）：
逐字 0.0% → **4.0%**、宽松 52.0% → **57.6%**。这个数字低得诚实，原因是两条结构性的：

- 真实笔记的决定性句子普遍长于任何单行预算，「逐字保住」几乎不可能；宽松口径才是这里的有效读数。
- 真库题集是用「标题 + 一个全库唯一的词」合成的，本身是弱代理，会低估真实召回。

**账本重放**（`--replay`，拿真实历史轮次问「今天我们这条还答不答得出来」）：
账本 253 行 / 106 次注入 / 106 条 `opened` 事件、84 对可判，`opened` 中位延迟 0.2 小时。
旧引擎今天还会注入 27.4%、只给得出文件名 11.9%；新引擎仍注入 **32.1%**、缩到文件名的降到 **7.1%**。
两点保留：账本只存查询前 160 字（线索更少，所以是**下界**），且库在这 10 小时里还在长。

**实测证伪、当场关掉的一件**：邻句扩展（短句借前后一句）在这两套题集上都是**净成本** ——
指标一个点不涨，平均每条多花 11 字。代码与测试留着（`--neighbor` 可开、`neighbor` 选项在
`bm25.js` 里），但默认关：不涨指标的东西不配默认吃预算。另一件被否掉的是「按分数相对比例
决定要不要注入」：候选分数太挤（合成题集 top2/top1 中位 0.69、真实库 0.87），没有可切的谷。

复算：

```bash
npm run bench:foundation                    # 默认档 + profile 档（杨总那档 600 字 / 5 条）
node bench/foundation.mjs --mode=count --no-plan     # 复现 0.14.0 的旧口径（对照列）
node bench/foundation.mjs --snippet-chars=64         # 消融：只改裁剪预算，不改选句
node bench/foundation.mjs --neighbor                 # 消融：邻句扩展开起来
node bench/foundation.mjs --store=$HOME/memory/engramory            # 真实库出题
node bench/foundation.mjs --replay --store=$HOME/memory/engramory   # 账本重放
npm run bench:foundation -- --check      # CI 对账，地板在 bench/foundation-expectations.json
```

## 集百家之长

四家同类插件的源码逐个读完（hindsight / 灵枢 dsh-memory / dsh-mnemon / dsh-mneme），
产出要求是「机制 + `文件:行号` + 能否在纯 JS、零依赖、零出网下复刻」。能搬的搬，
搬不动或搬了没用的当场证伪。

| 机制 | 抄自 | 结果 |
|---|---|---|
| 注入去重 | dsh-mnemon `src/host/lifecycle.ts:339-345` | 落地，但必须换键：它比的是整段渲染文本，而本插件有跨轮轮换会改变渲染文本，照抄就是死代码（真库探针抓出来的，单测当时还是绿的） |
| 注入预算降级阶梯 | dsh-mneme `src/inject.js:232` | 落地。装不下就塌缩成只剩文件名，不再静默消失 |
| 跨轮轮换降权 | dsh-mneme `src/inject.js:101` | 落地，配套细节不可省：候选池要同步扩容 3 倍，否则降权只是让位子空着 |
| IDF 选词 cap | hindsight `engine/search/bm25_term_selection.py` | 落地，但只能对 `df > 0` 的词排名。按 df 升序取最低的 24 个，留下的全是库里没有的词，实测注入 0 条 |
| fail-closed 闸门 | 灵枢（它只看 `committed` 字段，`ok:true` 不算数） | 落地 |
| 长查询取样 | 灵枢 issue #108（长查询只有开头段进键） | 落地。本插件犯的是反方向：原来只取尾部 400 字符，把开头的主题句整个丢了 |
| 自动归档 | dsh-mneme `src/dream/sleep.js:411` `phaseDemotion` | 落地但换了实现：它截断 + 存 `_full_content`，本插件整段搬家、不截断 |
| 时间衰减 | 灵枢 `rust/src/freshness.rs` | 实测证伪。六档半衰期扫描，recall 恒定、top-1 只有一档 +1（噪声级）。它能用是因为它的 `protected_floor` 依赖人工 `importance` 字段，Engramory 笔记没这个字段 |
| 时间乘性加权 | hindsight `engine/search/reranking.py:35-37` | 实测证伪。五档扫描净收益为零，`w ≥ 0.1` 起还要拿对照组去换 |
| 同义表扩容 | 灵枢的语义归一化 | 落地。177 条桥 / 519 个值，每条都经探针验证，另加一道死桥自检 |
| 查询侧同义词要先分词再算 | 自己踩的坑 | 落地。中文同义词原样返回时永远匹配不上「英文整词 + 汉字双字」的词项形状，写了等于没写且不报错。英文同义词 tokenize 后等于自身，所以这毛病藏了很久 |
| 笔记级别名（`aliases:`） | dsh-mneme 的 `aliases` 钩子 | 落地。机制本来就在（计两遍 = 满权重），但真实库里 0 篇在用；给 8 篇补上后别名探针 1/5 → 5/5 |

**搬机制最容易犯的错**是把机制搬过来、却没搬它依赖的不变量。dsh-mnemon 没有跨轮轮换，
所以它的「渲染文本不变」是真的不变；加上轮换之后，同一个写法就成了永远不触发的死代码。
抄之前先问它凭什么成立、那个前提这里有没有。
