dsh-engram 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ # 交接 · G6 第六轮(2026-10-28 · 已跑完)
2
+
3
+ > 阅读顺序:本文 → `docs/G6-quality-ab.zh.md` §13(方法/十二道缝/四次事故/两份结果)→ `eval/quality-ab.mjs`。
4
+
5
+ ## 0. 一句话
6
+
7
+ **第六轮跑完了:40/40 行,20 对配对,Δ = +90.0pp,G6 PASS,污染 0 行。**
8
+ `p-a`(带 `dsh-engram` 0.8.8)20/20,`p-b`(裸)2/20。跑在本机 vLLM 端点
9
+ (`local-vllm-61` / `deepseek-v4.1-flash-1017`,与官方开源同款),**两臂同端点**。
10
+
11
+ 第一次尝试走的是官方付费端点,跑到 13/20 对时账户余额用尽(`QUOTA`,¥0.19),那 7 行按 `infra`
12
+ 排除、不当失败算。那份产物(`eval/g6/runs/r6-neutral`,+95.0pp)留在仓库里当**独立复现**:
13
+ 同一个模型 id、两套服务栈、两次跑批,方向一致、`p-a` 一对没输。
14
+
15
+ 详见 `docs/G6-quality-ab.zh.md` §13.4(两份结果怎么读)、§13.3(四次事故)、§13.6(围栏移植)、
16
+ §13.7(换端点必须整轮重跑)。**下一轮别再用付费端点热身**。
17
+
18
+ ## 1. 今天在仓库里留下了什么
19
+
20
+ | commit | 一句话 | 为什么必须有 |
21
+ |---|---|---|
22
+ | `3f62aa9` | 种子键用 `realpath` 后的写法 | 不修的话**治疗臂根本没被种上记忆**(`--out` 写 `/tmp/…`,插件按 `getcwd()` 记 `/private/tmp/…`),两臂跑出来一样,Δ≈0 还看不出来 |
23
+ | `573cb64` | `--resume` 真的能复用 | 原来 resume 块有 TDZ 异常被 `catch` 吞掉,打印成"没有可续的行",整批重跑 |
24
+ | `bf65c4b` | 每行原子落盘 + 空盘预检 | 第六轮第一次在 16/40 行时 ENOSPC,**1.7 小时蒸发**;现在最坏也只丢当前那一行 |
25
+ | `4b8ce6f` | 祖先目录只给 `file-read-metadata`;在跑产物搬到 `--log-dir` | 原来臂能 `ls` 实验根,看到 `runs.json`/`arm-a`/`arm-b`,10/20 的控制会话据此推理"这是评测矩阵" |
26
+ | `e510929` | 矩阵开始前 scrub 共享 home | 两臂共用 `DSH_HOME`,`recall-injections.json` 写着另一臂的 workspace,`ledger.json` 按路径记 `injectedChars`,7/7 控制日志引用了 `profiles/ab-engram` |
27
+ | (文档) | `docs/G6-quality-ab.zh.md` §13:方法、十二道缝、四次事故、§13.4 两份结果 | 每个结论都指到证据;两份产物分别对应当钱/免费两个端点 |
28
+ | `371be60` | G2 分母改成"装了 engram 的会话";run 根改成 `runs/<sha1[0:8]>` | 旧分母把 3 个没装插件的会话算进激活率(50% 假 FAIL);run 路径里写着臂名/任务名/seed |
29
+ | `a27e237` | 一臂一个 `DSH_HOME`;语料 4 → 9 任务 | 共享 home 是结构性的泄漏(`scrubSharedHome` 只能清上一轮);5 个新任务都要求"只能被告知"的约定 |
30
+ | `26d143d` | 语料自检:每个 oracle 双向验证 + 任务与记忆对齐 | 没有这道检查,一个不可满足的 oracle 会让两臂都 0/0,闸门报"两臂持平 PASS";它当场抓出 `check.mjs` 在测试环境里静默跑空 |
31
+
32
+ 测试:`npm test` → 508/508(含 `test/quality-ab` 18 条、`test/g6-sandbox` 4 条、`test/gate-audit` 6 条、
33
+ `test/g6-corpus` 2 条)。
34
+ (后者是新加的围栏回归:node 能在围栏里启动、ws 可读、实验根不可枚举、`runs.json` 与另一臂打不开;
35
+ 把规则改回 `file-read*` 它会红)。
36
+
37
+ ## 2. 停的时候的状态(本机,不随仓库走)
38
+
39
+ | 东西 | 位置 | 是否迁移 |
40
+ |---|---|---|
41
+ | 已跑的 10 行 | `eval/g6/runs/r6-partial/runs.json`(已提交) | ✅ 带走 |
42
+ | 那 10 行的 run.log / check 输出 | `/tmp/g6/r6g-logs/`(596K) | ❌ 不迁移,需要证据就把需要的几份捞出来 |
43
+ | 10 个 workspace | `/tmp/g6r16/`(260K) | ❌ 不迁移 |
44
+ | 实验 home | `~/.dsh/quality-ab/`:profile `p-a`(装了 engram **0.8.2**)/ `p-b`(裸) | ❌ 新机器重建(§3.3) |
45
+
46
+ 停之前没有任何后台进程在跑(`pgrep -f "quality-ab.mjs run"` 为空)。
47
+
48
+ ## 3. 在另一台机器上继续
49
+
50
+ ### 3.1 前置
51
+
52
+ - **macOS**。围栏用的是 `sandbox-exec`/seatbelt;Linux 得换 bwrap/firejail,工具里没有这条路。
53
+ - node 22(`node -p process.version`),dsh 0.2.x(`dsh --version`)。
54
+ - `~/.dsh/.credentials.yaml` 里有 `DEEPSEEK_API_KEY`(harness 自己读进子进程环境,不落日志)。
55
+
56
+ ### 3.2 代码与自检
57
+
58
+ ```bash
59
+ git pull && npm install
60
+ node --test test/quality-ab.test.mjs test/g6-sandbox.test.mjs # 11 个,应全绿
61
+ ```
62
+
63
+ ### 3.3 建两臂(**中性名**,不要再用 `ab-engram`/`ab-bare`)
64
+
65
+ **一臂一个 `DSH_HOME`**(2026-10-28 起,缝 #13):每臂的 home 是 `--home` 下的 `arms/<arm>/`,
66
+ 插件必须装进**本臂**的 home——装到上一层会造出一个没人用的第三个 profile,治疗臂悄悄变成裸的,
67
+ Δ 塌成 0,而闸门会报"两臂持平 PASS"。
68
+
69
+ ```bash
70
+ H=$HOME/.dsh/quality-ab-r7
71
+ node eval/quality-ab.mjs prepare --arm on=p-a,off=p-b --home "$H"
72
+ DSH_HOME="$H/arms/p-a" dsh plugin --profile p-a add "$PWD/dsh-engram-8.8.tgz" # 版本按 §3.3 的决定
73
+ cp ~/.dsh/quality-ab/profiles/p-a/cordis.patch.yml "$H/arms/p-a/profiles/p-a/" # 端点/模型
74
+ cp ~/.dsh/quality-ab/profiles/p-b/cordis.patch.yml "$H/arms/p-b/profiles/p-b/"
75
+ DSH_HOME="$H/arms/p-a" dsh --profile p-a --dump-config | grep -A2 'id: engram' # 有
76
+ DSH_HOME="$H/arms/p-b" dsh --profile p-b --dump-config | grep -c engram # 应为 0
77
+ ```
78
+
79
+ **要先做的一个决定:插件版本。**
80
+
81
+ - 本机 `p-a` 里是 **0.8.2**(从第五轮的 `ab-engram` 整个拷过来的),仓库当前是 **0.8.8**。
82
+ - 想和第五轮(Δ=+90pp)**可比** → 装 0.8.2。
83
+ - 想让这轮数字**给 v0.8 门背书** → 装 0.8.8,并在 §13.4 写明"与第五轮不是同一版本,不可直接比"。
84
+ - 我的建议是后者(门存在的意义是给当前版本出数),但**无论选哪个都要写进 §13.4**。
85
+
86
+ ### 3.4 冒烟(约 2 分钟,别省)
87
+
88
+ ```bash
89
+ node eval/quality-ab.mjs run \
90
+ --tasks <只含 codec 一件任务的 tasks.json> \
91
+ --workspace eval/fixtures/ledger-kata-blind \
92
+ --arm p-a,p-b --seeds 1 \
93
+ --out /tmp/g6-smoke --home "$HOME/.dsh/quality-ab-r7" \
94
+ --seed-memory eval/g6/seed-memory-blind.json \
95
+ --sandbox --control-first --log-dir /tmp/g6-smoke-logs
96
+ ```
97
+
98
+ 期望:`p-b` fail、`p-a` PASS(本机今天 13:15 的冒烟正是这个结果,80.6s vs 187.6s)。
99
+ **若 `p-a` 也 fail,先怀疑种子键**(`3f62aa9`):看 `p-a` 的 run.log 里有没有出现 `[seed-0x]`;
100
+ 没有就是记忆没种上,别往下跑。
101
+
102
+ ### 3.5 跑批
103
+
104
+ ```bash
105
+ # 第七轮(2026-10-28 起):9 任务 × 2 臂 × 2 seed = 18 对,本地 vLLM 端点
106
+ node eval/quality-ab.mjs run \
107
+ --tasks "$PWD/eval/g6/tasks-blind.json" \
108
+ --workspace "$PWD/eval/fixtures/ledger-kata-blind" \
109
+ --arm p-a,p-b --seeds 2 \
110
+ --out "$PWD/eval/g6/runs/r7-local" \
111
+ --home "$HOME/.dsh/quality-ab-r7" \
112
+ --seed-memory "$PWD/eval/g6/seed-memory-blind.json" \
113
+ --sandbox --control-first --min-pairs 10 \
114
+ --log-dir "$HOME/.dsh/quality-ab-logs/r7-local" \
115
+ --resume # 断了就加这条,复用已完成的行
116
+ ```
117
+
118
+ 注意 `--log-dir` 会接住**中途**的 `runs.json`(臂读不到它;`--out` 在围栏里被挂空,
119
+ 中途写在那儿就等于把答案放在臂旁边),跑完的最终产物仍落在 `--out/runs.json` + `REPORT.md`。
120
+
121
+ - **想复用已跑的 10 行**:把 `eval/g6/runs/r6-partial/` 里的 `runs.json` 拷到 `--out` 目录,加 `--resume`。
122
+ 注意那 10 行是"插件 0.8.2 + 本机环境"下跑出来的,混进 0.8.8 的一批里**不是同一个实验**;
123
+ 除非你决定沿用 0.8.2,否则**建议丢掉、整批重跑**(这就是我把它放进 `r6-partial` 而不是 `r6-neutral` 的原因)。
124
+ - 单 run 今天 2–8 分钟(endpoint 波动很大),40 run ≈ **3–4 小时**。
125
+ - 中途断了直接加 `--resume` 重跑同一条命令:现在每行都落盘,会打印"复用了 N 行"。
126
+ - 磁盘 < 512MB 时工具会拒绝启动(`AB_MIN_FREE_MB` 可调)。今天那次 ENOSPC 与本项目无关
127
+ (`$TMPDIR` 里一个外部进程写的 2.5GB Instruments trace),整轮产物只有 1.7MB。
128
+
129
+ ### 3.6 出数与填文档
130
+
131
+ 跑完命令自己会打印 Δ 与门判定(预注册留一法 `Δ ≥ −2pp`,配对下限 10)。要填的:
132
+
133
+ 1. `docs/G6-quality-ab.zh.md` **§13.4**:表(`p-a` / `p-b` 的 pass 与平均耗时)+ 逐任务控制臂 pass 数
134
+ + "能说什么/不能说"(照 §12 的写法,别外推幅度)。
135
+ 2. `docs/GATES.zh.md` 的 G6 行:
136
+
137
+ ```bash
138
+ npm run bench:ledger # 拿 G7 的数字
139
+ node eval/gate-audit.mjs --days 14 --md --out docs/GATES.zh.md \
140
+ --g6 eval/g6/runs/r6-neutral/runs.json --g7 <bench 输出里那个百分比> \
141
+ --api 'http://127.0.0.1:3080/api/dsh-engram?workspace=<repo 绝对路径>'
142
+ ```
143
+
144
+ `--api` 只是为了让 G5(渲染块大小)能算;没有宿主就省掉 G5。
145
+
146
+ ### 3.7 收尾
147
+
148
+ - ESR 任务 `tsk_81`("G6 第六轮:中性臂名复跑 20 对配对")现在是 ACTIVE、三证全缺;
149
+ 跑完用 artifact(`eval/g6/runs/r6-neutral/runs.json`)+ evaluation(Δ 与门判定)+ memory_ref 关它。
150
+ - 存一条 memory:本轮 Δ、版本、以及"六个缝"里新学到的那条。
151
+
152
+ ## 4. 这个评测工具长什么样(只需记住三件事)
153
+
154
+ 1. **判据是命令不是模型**:每个任务一条 `check`(bash 退出码),不引入第二个模型打分。
155
+ 2. **两臂在同一台机上跑,靠 seatbelt 隔离**:`--sandbox` 默认收禁 `/tmp` 和 `~`,只放行本臂 ws、
156
+ 本臂 profile、node、凭据;另一臂 profile 再禁一次;`--control-first` 保证控制臂先跑完
157
+ (后跑的控制臂能读到治疗臂的 run.log,第二轮真发生过)。
158
+ 3. **臂名不能出现在臂能看见的任何地方**:目录叫 `arm-a`/`arm-b`,profile 叫 `p-a`/`p-b`,
159
+ 映射只写在 artifact 的 `armLabels` 里。今天补的六道缝都是为了这一条。
160
+
161
+ ## 5. 未决 / 已知坑
162
+
163
+ 1. **插件版本没对齐**(§3.3)——接手第一件事。
164
+ 2. ~~共享 home 是结构性的~~ **已解决**(commit `a27e237`,缝 #13):一臂一个 `DSH_HOME`
165
+ (`arms/<arm>/{profiles,storages,sessions,engram}`),store 只种进治疗臂自己的 home,
166
+ 控制臂的 home 里没有可读的东西;围栏只 bind 本臂 home。`--hide-store-for` 退化为兼容参数。
167
+ 测试:两臂 `DSH_HOME` 不同、控制臂 home 里不存在 `dsh_engram.json`。
168
+ 3. ~~控制臂仍能看见兄弟目录名~~ **已解决**(commit `371be60`):run 根改为
169
+ `runs/<sha1(task|seed|arm)[0:8]>/ws`,围栏再把 run 根挂空、只挂回 `ws`(连本行 `run.log`
170
+ 都读不到);映射记在 `runs.json` 每行的 `runKey` 里。剩下的可见面只有"本机实验目录存在"。
171
+
172
+ 4. **耗时不可控**:同一任务今天从 53s 到 400s 都有,endpoint 抖动是主因,别用耗时差异下结论。
173
+ 5. `eval/g6/runs/` 下有历史 artifact(`r5-confined` = 第五轮 +90pp,带臂名泄漏),
174
+ 别拿它跟第六轮混着算。
175
+
176
+ ## 6. 证据索引
177
+
178
+ | 要看的 | 在哪 |
179
+ |---|---|
180
+ | 第六轮的方法/事故/两份结果 | `docs/G6-quality-ab.zh.md` §13(结果在 §13.4) |
181
+ | 第一至五轮 | 同文件 §4–§12 |
182
+ | 门表 | `docs/GATES.zh.md` |
183
+ | harness | `eval/quality-ab.mjs`(`scrubSharedHome` / `artifactPath` / `assertFreeSpace` / `snapshotRows`) |
184
+ | 围栏 | `eval/g6/sandbox-profile.mjs` |
185
+ | 测试 | `test/quality-ab.test.mjs`、`test/g6-sandbox.test.mjs` |
186
+ | 第六轮已跑的 10 行 | `eval/g6/runs/r6-partial/runs.json` |
187
+
188
+ ## 7. Linux 接管记录(2026-10-28 下午)
189
+
190
+ 上一台 macOS 上的东西一件没带过来,全部在本机重建。**结果(本地 vLLM,40/40 行)**:
191
+
192
+ | 臂 | 通过 | 通过率 | 平均墙钟 | 中位 |
193
+ |---|---|---|---|---|
194
+ | `p-a`(`dsh-engram` 0.8.8) | 20/20 | 100% | 85s | 67s |
195
+ | `p-b`(裸) | 2/20 | 10% | 200s | 162s |
196
+
197
+ 逐任务:`codec` 5–0 · `errcode` 5–0 · `money` 5–2 · `ranking` 5–0 → **Δ = +90.0pp → G6 PASS**,
198
+ `infra 0` · `contaminated 0`。
199
+
200
+ 官方端点的第一次尝试(`r6-neutral`):13 对有效配对、**+95.0pp**,7 行 `infra`(余额用尽);
201
+ 保守口径(20 对,7 行当失败)**+60.0pp**。三种读法都 PASS。
202
+
203
+ ### 7.1 与 macOS 轮次的差异(读数字前必须知道)
204
+
205
+ | 项 | macOS(第五轮) | 本机(第六轮) |
206
+ |---|---|---|
207
+ | 围栏 | `sandbox-exec`(seatbelt,DENY 列表) | `bwrap`(挂载列表),`fenceCommand()` 按平台选,**未知平台抛错** |
208
+ | 端点 | 官方付费 API | `local-vllm-61`(本机 vLLM,同款模型 id);`assertSameTarget()` 保证两臂一致 |
209
+ | 插件版本 | 0.8.2 | **0.8.8** |
210
+ | 语料 `check` | 写死 `/Users/sunchuxiong/...` | `node ${REPO}/eval/g6/check.mjs …`(拒绝没有占位符的写法) |
211
+ | 实验 home | `~/.dsh/quality-ab` | 同(本机重建,`p-a` 装 0.8.8,`p-b` 裸) |
212
+ | 日志 | `/tmp/g6/r6g-logs` | `~/.dsh/quality-ab-logs/r6-local`(在 home 之外、对臂不可读) |
213
+
214
+ **所以第六轮与第五轮的 +90pp 不能比同**:版本、语料、围栏实现都变了。
215
+
216
+ ### 7.2 这一轮补的五个漏洞(都在 §13.2 的 #7–#11)
217
+
218
+ 12. **两臂可能指向不同端点**:`run` 开头用 `--dump-config` 比两臂的 `provider`/`model`,不同就拒绝开跑;产物记 `target`
219
+
220
+ 7. 插件包自带 `docs/`(**原文含 codec 公式**)被装进治疗臂自己的 `node_modules` → 装完即删 + 围栏挂空
221
+ 8. 语料 `check` 写死上一台机器的绝对路径 → 两臂全 0 → 闸门报"两臂持平 PASS"(静默假结论)→ `${REPO}` + 拒绝规则
222
+ 9. 臂继承了调用者的 `DSH_PROFILE_DIR`/`DSH_SESSION_ID`/`DSH_SHELL` → `armEnv()` 只给 `DSH_HOME`
223
+ 10. `--control-first` 的比较器永不返回 `-1` → 改成显式分区(顺序本身就是隔离)
224
+ 11. 仓库只是"恰好"因为位于 `$HOME` 下被藏住 → 显式 `--tmpfs <repo>`
225
+
226
+ ### 7.3 现在可以直接跑的命令
227
+
228
+ ```bash
229
+ # 整轮重跑(换端点就必须整轮)
230
+ node eval/quality-ab.mjs run --tasks "$PWD/eval/g6/tasks-blind.json" \
231
+ --workspace "$PWD/eval/fixtures/ledger-kata-blind" --arm p-a,p-b --seeds 5 \
232
+ --out "$PWD/eval/g6/runs/r6-local" --home "$HOME/.dsh/quality-ab" \
233
+ --seed-memory "$PWD/eval/g6/seed-memory-blind.json" --hide-store-for p-b \
234
+ --sandbox --control-first --log-dir "$HOME/.dsh/quality-ab-logs/r6-local"
235
+ # (中途断了加 --resume:复用已完成的行,只重跑 infra 行)
236
+
237
+ ### 7.4 本机状态(不随仓库走)
238
+
239
+ | 东西 | 位置 |
240
+ |---|---|
241
+ | 40 行原始日志(run.log + check 输出) | `~/.dsh/quality-ab-logs/r6-local/`(本地端点)· `~/.dsh/quality-ab-logs/r6/`(官方端点那次) |
242
+ | 冒烟证据(2 次) | `~/.dsh/quality-ab-logs/smoke{,2}/` |
243
+ | 实验 home(`p-a` 0.8.8 / `p-b` 裸) | `~/.dsh/quality-ab/` |
244
+ | 产物 | `eval/g6/runs/r6-local/{runs.json,REPORT.md}`(主结果)· `r6-neutral`(官方端点复现)——都已提交 |
245
+
246
+ `dsh-engram-0.8.8.tgz`(`npm pack` 的产物)在仓库根,被 `.gitignore` 忽略;重装插件需要它。
@@ -0,0 +1,44 @@
1
+ # dsh-engram v0.8.8
2
+
3
+ **0.8.7 的推送腿遥测写错了一层目录——装上真机、重启、看一眼状态,5 分钟内就露馅了。**
4
+
5
+ ## 修复:`push-legs.jsonl` 写到了 `engram/engram/`
6
+
7
+ 0.8.7 引入的推送腿遥测,`engramHome()` 已经以 `engram/` 结尾,写入端又拼了一次
8
+ `engram`,于是文件落在 `~/.dsh/engram/engram/push-legs.jsonl`;而 `dsh-engram status`
9
+ 读的是 `~/.dsh/engram/push-legs.jsonl`。
10
+
11
+ 结果是**一个工作正常的遥测,在状态里显示成「还没记录」**——正好是这一版想消灭的那种
12
+ 「看起来像没发生」。单测没抓到,因为测试自己造目录、两边用的是同一个临时路径。
13
+
14
+ 现在把契约写死在一处:`pushLegsPath(engramDir)` 的参数就是 engram 目录本身,
15
+ 写入端传 `engramHome()`、读取端传 `<dsh home>/engram`,注释里写明为什么不能拼第二次。
16
+ 新增一条回归测试直接断言 `pushLegsPath(dir) === join(dir, "push-legs.jsonl")`。
17
+
18
+ ## 顺带得到的一个真数据点
19
+
20
+ 修好路径、把线上那条记录归位之后,本机(`dsh 0.2.1-rc.2` CLI + web)的答案是:
21
+
22
+ ```
23
+ 推送腿:事件日志读法 snapshotEvents · 最近 2026/10/27 15:44:35 · snapshotEvents×1
24
+ ```
25
+
26
+ 也就是说**我们自己的宿主走的是 `snapshotEvents`**——`ctx.sessionQuery` 在这套组合里
27
+ 没挂上(宿主已把它标为 deprecated,但仍在)。这解释了为什么 0.8.6 补的
28
+ `seq`+`eventAt` 第四手值得留着:桌面运行时那类宿主一旦连 `snapshotEvents` 也去掉,
29
+ `eventAt` 就是最后的退路。
30
+
31
+ ## 证据
32
+
33
+ - `npm test` **489/489**(+1 路径契约回归)
34
+ - `npm run typecheck` 通过;`prepublishOnly` 真实 tarball + peers 后 host/client 双入口导入通过
35
+ - 真机验证:`dsh-engram status` 打印出上面那行读法;`~/.dsh/engram/engram/` 这个错误
36
+ 目录已清理,记录已归位到 `~/.dsh/engram/push-legs.jsonl`
37
+
38
+ ## DSH 版本支持(必填)
39
+
40
+ - **支持的 DSH 范围**:`>=0.1.2-alpha.2 <0.3.0-0`
41
+ - **验证版本**:`dsh 0.2.1-rc.2`(本机 CLI + web)
42
+ - **最后验证日期**:2026-10-27
43
+ - **安装方式已验证**:`prepublishOnly` 用真实 tarball 装 peers 后导入 host/client 双入口
44
+ - **peer 声明**:`@deepseek-ai/dsh-{settings,storage-domain,compaction,compaction-basic,llm}@>=0.1.2-alpha.2 <0.3.0-0`、`@deepseek-ai/cordis@^4.0.2`、`@deepseek-ai/schemastery@^3.18.2`
@@ -0,0 +1,78 @@
1
+ # dsh-engram v0.9.0
2
+
3
+ **这一版几乎没改产品代码,改的是「我们凭什么说自己有用」——评测从「跑得动」变成了「作弊不了」。**
4
+
5
+ 从 `0.8.8` 到这里共 22 个 commit,`lib/` 一行未动,唯一的用户可见改动是 CLI 的一处
6
+ preset 扫描优先级。剩下的全部落在 `eval/`、`test/` 和 `docs/`:把上一版还只能"声称"的
7
+ 东西变成能复现的读数。
8
+
9
+ ## 修复
10
+
11
+ - **`web-compaction` 的 preset 扫描不再被陈旧的 legacy 目录抢占**:`--presets-dir`
12
+ 现在优先于一切。此前一个更早的 checkout 留在机器上时,它会先认领 preset id,
13
+ 声明式 patch 文件按 id 被去重掉,于是 `status` 报的是 legacy 布局、
14
+ 而声明式那份被显示成 `custom`。三条 Linux CLI 测试抓到了这个组合
15
+ (`scripts/dsh-engram.mjs`)。
16
+
17
+ ## 评测基础设施(本版真正的内容)
18
+
19
+ - **G6 有了网络围栏**(`--sandbox --net-fence --net-endpoint host:port`):
20
+ bwrap 搭挂载树 → `setpriv` 掉到专用 uid → 三条按 uid 生效的 iptables 规则,
21
+ 默认 REJECT,只放行 loopback 和一个模型端点。按 uid 而不是按进程过滤,
22
+ 所以开发会话、harness、Web GUI 的网络不受影响。
23
+ 跑矩阵前有两个自证,不通过就不计分:出网探针(端点可达 / `github 000` / DNS 死)
24
+ 和写目录探针(臂必须能在自己的 workspace 里落盘)。
25
+ - **第九轮结果**:9 任务 × 2 seed × 2 臂,p-a 18/18 vs p-b 0/18,Δ **+100.0pp**,
26
+ 18 对配对,`infraRows 0`,`contaminated 0`(`eval/g6/runs/r9-net/`)。
27
+ 18 份控制日志里有 4 份试图出网,全部拿到 `exit 124` / "network unavailable"——
28
+ 这是第七轮「控制臂去 GitHub 查到答案」的反面。
29
+ - **语料第 4 条自证**:工作区 stub 必须导出 prompt 点名的函数,参数个数必须与 prompt 一致
30
+ (oracle 可以少传、不能多传)。这条当场抓出三处不一致:`retry` 的 stub 签的是
31
+ `shouldRetry(status, code, attempt)`、`ranking`/`dedupe` 的 stub 导出名 prompt 从未提过。
32
+ 一份靠"猜哪份文档算数"得来的通过不是证据。
33
+ - **污染判定改为三分类**(`classifyLeak` → `memory` / `artifact` / `network`),
34
+ 并提供 `quality-ab.mjs classify` 从既有日志**重算**:围栏下"试过出网"不再等于"拿到了"。
35
+ - **G7 从「未测」变成实测**:账本自身开销最坏操作 **0.2956%**(闸门 < 0.5%),
36
+ 1123 条真实 step 延迟,`npm run bench:ledger` 可复现。
37
+
38
+ ## 其他
39
+
40
+ - 证据面汇总见 `docs/GATES.zh.md`:G2 激活率 83.3% PASS · G4 陈旧率 0.0% PASS ·
41
+ G6 Δ +100pp PASS · G7 0.2956% PASS · G1/G1b/G5 仍未测(未测即未测,不猜)。
42
+ - G6 方法、十二道缝与失效模式见 `docs/G6-quality-ab.zh.md` §16。
43
+
44
+ ## 这一版**不是** 1.0,也请不要这样读
45
+
46
+ `0.9.0` 表示"证据链条够了",不表示"公共面稳定了"。三条至少还挡在 1.0 前面:
47
+
48
+ 1. **宿主还是 rc**(peer `>=0.1.2-alpha.2 <0.3.0-0`,本机 `dsh 0.2.1-rc.2`):
49
+ 1.0 承诺"不无故破坏",在 rc 宿主上做不到。
50
+ 2. **旗舰收益未测**:账本 `avoided 0c`、净 **−1.46M token**、G1 未测——
51
+ 因为省钱的两条路(`boundaryPrune`、`gcReplacesCompaction`)默认关闭。
52
+ 一次开着的实测周(≥5 个 opportunity 会话)才能让 G1 第一次有读数。
53
+ 3. **减法债**:14 个工具(目标 ≤10)、70 个配置键(面向用户目标 ≤12)、
54
+ client bundle 291KB(目标 <150KB);存储格式仍无 schemaVersion/迁移。
55
+
56
+ ## DSH 版本支持
57
+
58
+ - **支持的 DSH 范围**:`>=0.1.2-alpha.2 <0.3.0-0`
59
+ - **peer 声明**:
60
+ - `@deepseek-ai/dsh-compaction`: `>=0.1.2-alpha.2 <0.3.0-0`
61
+ - `@deepseek-ai/dsh-compaction-basic`: `>=0.1.2-alpha.2 <0.3.0-0`
62
+ - `@deepseek-ai/dsh-llm`: `>=0.1.2-alpha.2 <0.3.0-0`
63
+ - `@deepseek-ai/dsh-settings`: `>=0.1.2-alpha.2 <0.3.0-0`
64
+ - `@deepseek-ai/dsh-storage-domain`: `>=0.1.2-alpha.2 <0.3.0-0`
65
+ - **验证版本**:`dsh 0.2.1-rc.2`(本机 CLI + web)
66
+ - **最后验证日期**:2026-10-29
67
+ - **安装方式已验证**:`dsh plugin --profile web add <path|dsh-engram>`
68
+ - **兼容性**:配置键与数据格式与 `0.8.8` 完全一致,**无需迁移**;无破坏性改动。
69
+
70
+ ## 验证
71
+
72
+ ```
73
+ npm test → 519/519
74
+ npm run typecheck → ok
75
+ npm run eval → BENCH PASS
76
+ npm run build:client → 无 diff(bundle 新鲜)
77
+ npm pack → 459KB / 94 files
78
+ ```
@@ -40,6 +40,9 @@ const LONG_SESSION_REQUESTS = 200;
40
40
  * gate FAIL (or PASS). The gate says 未测 until there is a distribution. */
41
41
  const MIN_SESSION_ROWS = 5;
42
42
  const MIN_LONG_ROWS = 3; // §6: 长会话 = ≥200 请求
43
+ // G2's population floor: "installed sessions". Below this the rate is a coin
44
+ // flip dressed as a percentage.
45
+ const MIN_ACTIVATION_SESSIONS = 5;
43
46
  const ENGRAM_TOOL_RE = /^(engram_|esr_)/;
44
47
 
45
48
  function parseArgs(argv) {
@@ -55,6 +58,9 @@ function parseArgs(argv) {
55
58
  else if (arg === "--g7") out.g7 = Number(argv[++i]);
56
59
  else if (arg === "--g6") out.g6 = argv[++i] ?? null;
57
60
  else if (arg === "--home") out.home = argv[++i] ?? null;
61
+ // Diagnostics: which sessions are in the denominator, and what each one
62
+ // actually did. A rate without its population is a number you cannot act on.
63
+ else if (arg === "--explain") out.explain = true;
58
64
  }
59
65
  return out;
60
66
  }
@@ -200,6 +206,35 @@ function scanSessions({ root = sessionsRoot(), days = 14, now = Date.now() } = {
200
206
  return sessions.sort((a, b) => b.mtime - a.mtime);
201
207
  }
202
208
 
209
+ /**
210
+ * How many memories each workspace has, so a session can be asked the only
211
+ * question that makes push rate meaningful: was there anything to push?
212
+ */
213
+ function countMemoriesByWorkspace(dshHome) {
214
+ const candidates = [
215
+ path.join(dshHome, "storages", "dsh_engram.json"),
216
+ path.join(dshHome, ".dsh", "storages", "dsh_engram.json"),
217
+ ];
218
+ for (const file of candidates) {
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
222
+ } catch {
223
+ continue;
224
+ }
225
+ const table = parsed?.tables?.memories;
226
+ if (table === null || table === void 0 || typeof table !== "object") continue;
227
+ const counts = new Map();
228
+ for (const row of Object.values(table)) {
229
+ if (row === null || typeof row !== "object" || row.kind === "episode") continue;
230
+ if (typeof row.workspace !== "string" || row.workspace.length === 0) continue;
231
+ counts.set(row.workspace, (counts.get(row.workspace) ?? 0) + 1);
232
+ }
233
+ return counts;
234
+ }
235
+ return new Map();
236
+ }
237
+
203
238
  /** Stale share over the memory table (read-only; the store stays the truth). */
204
239
  function readMemoryShare(dshHome) {
205
240
  const candidates = [
@@ -285,12 +320,29 @@ export function auditGates({ days = 14, now = Date.now(), home = void 0, api = n
285
320
  const netShare = pct(positive.length, opportunityRows.length);
286
321
  const longShare = pct(longPositive.length, longRows.length);
287
322
 
288
- // ── G2 Activation: mounted sessions where any engram/esr tool fired
289
- const mounted = sessions.filter((s) => s.tools.size > 0);
290
- const activated = mounted.filter((s) => [...s.tools.keys()].some((name) => ENGRAM_TOOL_RE.test(name)));
291
- const pushFired = mounted.filter((s) => s.pushSeen);
292
- const activation = pct(activated.length, mounted.length);
293
- const pushShare = pct(pushFired.length, mounted.length);
323
+ // ── G2 Activation: installed sessions where any engram/esr tool fired
324
+ //
325
+ // "installed" used to be `tools.size > 0` — that is "the session ran SOME
326
+ // tool", which is true of every session and says nothing about engram. On this
327
+ // machine that put three Kototoro sessions in the denominator whose tool list
328
+ // (bash, read, edit… — no engram_*, no esr_*) shows the plugin was never
329
+ // loaded there: they could not fire, and counting them measured the install
330
+ // state of a workspace, not activation. Evidence of a live mechanism is one of:
331
+ // the prompt carried the [ENGRAM]/[RECALL] block, the ledger counted requests,
332
+ // or an engram tool was actually called.
333
+ const ledgerLive = new Set(rows.map((row) => row.sessionId));
334
+ const engramFired = (session) => [...session.tools.keys()].some((name) => ENGRAM_TOOL_RE.test(name));
335
+ const installed = sessions.filter((s) => s.pushSeen || ledgerLive.has(s.sessionId) || engramFired(s));
336
+ const activated = installed.filter(engramFired);
337
+ const pushFired = installed.filter((s) => s.pushSeen);
338
+ const memoryCounts = countMemoriesByWorkspace(dshHome);
339
+ // The opportunity axis: a session whose workspace HAS memories is a session
340
+ // where the push leg had something to say. Below that, "no push" is not evidence.
341
+ const hadSomethingToPush = installed.filter((s) => (memoryCounts.get(s.workspace) ?? 0) > 0);
342
+ const pushWherePossible = hadSomethingToPush.filter((s) => s.pushSeen);
343
+ const activation = pct(activated.length, installed.length);
344
+ const pushShare = pct(pushFired.length, installed.length);
345
+ const pushOpportunityShare = pct(pushWherePossible.length, hadSomethingToPush.length);
294
346
 
295
347
  // ── G3 DrillRate: detail calls per recall-with-hits
296
348
  const recallWithHits = sessions.reduce((sum, s) => sum + s.recallWithHits, 0);
@@ -302,7 +354,16 @@ export function auditGates({ days = 14, now = Date.now(), home = void 0, api = n
302
354
 
303
355
  return {
304
356
  auditedAt: now,
305
- window: { days, now, sessions: sessions.length, mounted: mounted.length },
357
+ // "mounted" now means what the word says: engram was actually live in the
358
+ // session. The old count (sessions that ran any tool) is kept beside it so a
359
+ // reader can see how much of the population the fix removed.
360
+ window: {
361
+ days,
362
+ now,
363
+ sessions: sessions.length,
364
+ mounted: installed.length,
365
+ anyTool: sessions.filter((s) => s.tools.size > 0).length,
366
+ },
306
367
  ledger: {
307
368
  rows: rows.length,
308
369
  opportunityRows: opportunityRows.length,
@@ -345,19 +406,35 @@ export function auditGates({ days = 14, now = Date.now(), home = void 0, api = n
345
406
  },
346
407
  {
347
408
  id: "G2",
348
- name: "Activation · 机制激活率(挂载会话 fire ≥1 次)",
409
+ name: "Activation · 机制激活率(装上了 engram 的会话 fire ≥1 次)",
349
410
  value: fmtPct(activation),
350
411
  target: "≥ 60%",
351
- verdict: mounted.length === 0 ? "未测" : verdict(activation >= 60),
352
- note: `${activated.length}/${mounted.length} mounted sessions`,
412
+ // Enough sessions to judge: below the floor the honest answer is 未测.
413
+ // n=3 would read 100% PASS and mean nothing.
414
+ verdict:
415
+ installed.length < MIN_ACTIVATION_SESSIONS ? "未测" : verdict(activation >= 60),
416
+ note:
417
+ installed.length < MIN_ACTIVATION_SESSIONS
418
+ ? `${activated.length}/${installed.length} installed sessions — 样本不足(需 ≥ ${MIN_ACTIVATION_SESSIONS});旧口径 ${sessions.filter((s) => s.tools.size > 0).length} 个"有工具"会话里有 ${sessions.filter((s) => s.tools.size > 0 && !s.pushSeen && !ledgerLive.has(s.sessionId) && !engramFired(s)).length} 个根本没装 engram`
419
+ : `${activated.length}/${installed.length} installed sessions`,
353
420
  },
354
421
  {
355
422
  id: "G2b",
356
423
  name: "Push · [RECALL] 注入命中会话占比",
357
424
  value: fmtPct(pushShare),
358
425
  target: "观测项(无预声明门)",
359
- verdict: mounted.length === 0 ? "未测" : "—",
360
- note: `${pushFired.length}/${mounted.length} sessions carry a [RECALL] block in their prompt`,
426
+ verdict: installed.length === 0 ? "未测" : "—",
427
+ note: `${pushFired.length}/${installed.length} installed sessions carry a [RECALL] block in their prompt`,
428
+ },
429
+ {
430
+ id: "G2c",
431
+ name: "Push opportunity · 有记忆可推的会话里推送腿渲染占比",
432
+ value: fmtPct(pushOpportunityShare),
433
+ target: "观测项(无预声明门)",
434
+ verdict: hadSomethingToPush.length === 0 ? "未测" : "—",
435
+ // The only push number that can be read as a miss: the workspace had
436
+ // memories and the block still never rendered.
437
+ note: `${pushWherePossible.length}/${hadSomethingToPush.length} sessions whose workspace has ≥1 memory`,
361
438
  },
362
439
  {
363
440
  id: "G3",
@@ -393,7 +470,11 @@ export function auditGates({ days = 14, now = Date.now(), home = void 0, api = n
393
470
  verdict: g6 === null ? "未测" : (g6.gate?.verdict ?? "未测"),
394
471
  note: g6 === null
395
472
  ? "needs paired A/B runs (not derivable from logs) — pass --g6 <runs.json>"
396
- : `${g6.paired?.pairs ?? 0} 对配对 · ${g6.tasks?.length ?? 0} 任务 × ${g6.seeds ?? 1} 种子 · ${g6.source ?? ""}`.trim(),
473
+ : `${g6.paired?.pairs ?? 0} 对配对 · ${g6.tasks?.length ?? 0} 任务 × ${g6.seeds ?? 1} 种子 · ${g6.source ?? ""}`.trim() +
474
+ // A control row that went and read the answer key is not evidence.
475
+ // If the artifact dropped some, the number on this dashboard is only
476
+ // honest when the drop is visible next to it.
477
+ (g6.notBlindExcluded > 0 ? ` · 已剔除 ${g6.notBlindExcluded} 行不盲控制臂` : ""),
397
478
  },
398
479
  {
399
480
  id: "G7",
@@ -475,7 +556,7 @@ function renderMarkdown(report) {
475
556
  const out = [];
476
557
  out.push(`# 门审计(gate audit)· ${new Date(report.auditedAt ?? Date.now()).toISOString().slice(0, 10)}`);
477
558
  out.push("");
478
- out.push(`窗口:最近 ${report.window.days} 天 · 扫描 ${report.window.sessions} 个会话(其中 ${report.window.mounted} 个挂载了工具)· 账本行 ${report.ledger.rows} 条`);
559
+ out.push(`窗口:最近 ${report.window.days} 天 · 扫描 ${report.window.sessions} 个会话(其中 ${report.window.mounted} 个装了 engram;"用过任意工具"的会话 ${report.window.anyTool} 个)· 账本行 ${report.ledger.rows} 条`);
479
560
  out.push("");
480
561
  out.push("| 门 | 指标 | 实测 | 目标 | 判定 | 备注 |");
481
562
  out.push("|---|---|---|---|---|---|");
@@ -524,7 +605,30 @@ async function main() {
524
605
  return;
525
606
  }
526
607
 
527
- console.log(`gate audit · last ${report.window.days}d · ${report.window.sessions} sessions (${report.window.mounted} mounted) · ${report.ledger.rows} ledger rows`);
608
+ if (args.explain) {
609
+ const sessions = scanSessions({ days: args.days, root: sessionsRoot() });
610
+ const byId = new Map(ledger.sessions.map((row) => [row.sessionId, row]));
611
+ console.log("session ws req push tools engram-tools recall/hit detail all-tools");
612
+ for (const s of sessions) {
613
+ const row = byId.get(s.sessionId) ?? {};
614
+ const engramTools = [...s.tools.keys()].filter((name) => ENGRAM_TOOL_RE.test(name));
615
+ console.log(
616
+ [
617
+ s.sessionId.slice(0, 8).padEnd(12),
618
+ path.basename(s.workspace || "?").slice(0, 20).padEnd(22),
619
+ String(row.requests ?? 0).padStart(3),
620
+ String(s.pushSeen ? "yes" : "-").padStart(5),
621
+ String(s.tools.size).padStart(6),
622
+ (engramTools.join(",") || "-").slice(0, 18).padEnd(18),
623
+ ([...s.tools.keys()].join(",") || "-").slice(0, 46).padEnd(46),
624
+ `${s.recallQueries}/${s.recallWithHits}`.padStart(11),
625
+ String(s.detailCalls).padStart(6),
626
+ ].join(" "),
627
+ );
628
+ }
629
+ console.log("");
630
+ }
631
+ console.log(`gate audit · last ${report.window.days}d · ${report.window.sessions} sessions (${report.window.mounted} with engram live, ${report.window.anyTool} with any tool) · ${report.ledger.rows} ledger rows`);
528
632
  for (const gate of report.gates) {
529
633
  console.log(` ${gate.id.padEnd(4)} ${gate.verdict.padEnd(4)} ${gate.value.padEnd(8)} ≤/≥ ${gate.target.padEnd(10)} ${gate.name}`);
530
634
  }
package/lib/index.js CHANGED
@@ -590,7 +590,7 @@ export function apply(ctx, config) {
590
590
  if (!notedSessionEvents.has(session)) {
591
591
  notedSessionEvents.add(session);
592
592
  noteSessionEventsSource({
593
- home: engramHome(),
593
+ engramDir: engramHome(),
594
594
  source: sessionEventsSource(session, sessionQuery),
595
595
  sessionId: session.header?.id ?? session.id,
596
596
  });
package/lib/push-legs.js CHANGED
@@ -29,26 +29,34 @@ export const PUSH_LEGS_KEEP = 50;
29
29
  /** Size at which the file is rewritten to the kept tail. */
30
30
  const PUSH_LEGS_MAX_BYTES = 32 * 1024;
31
31
 
32
- /** `<home>/engram/push-legs.jsonl` */
33
- export function pushLegsPath(home) {
34
- return join(home, "engram", "push-legs.jsonl");
32
+ /**
33
+ * `<engramDir>/push-legs.jsonl`.
34
+ *
35
+ * The argument is the ENGRAM directory (`~/.dsh/engram`), not the DSH home:
36
+ * `engramHome()` already ends in `engram/`, and appending it a second time is
37
+ * how 0.8.7 shipped a telemetry file at `~/.dsh/engram/engram/push-legs.jsonl`
38
+ * that `dsh-engram status` then reported as never written. Kept explicit so the
39
+ * two call sites cannot disagree again.
40
+ */
41
+ export function pushLegsPath(engramDir) {
42
+ return join(engramDir, "push-legs.jsonl");
35
43
  }
36
44
 
37
45
  /**
38
46
  * Record the read path taken for one session. Best effort: never throws.
39
47
  *
40
48
  * @param {object} options
41
- * @param {string} options.home DSH home
49
+ * @param {string} options.engramDir engram directory (`~/.dsh/engram`)
42
50
  * @param {string} options.source `sessionEventsSource()` verdict
43
51
  * @param {string} [options.sessionId]
44
52
  * @param {number} [options.pid]
45
53
  * @param {number} [options.now]
46
54
  * @returns {boolean} whether the row was written
47
55
  */
48
- export function noteSessionEventsSource({ home, source, sessionId, pid = process.pid, now = Date.now() } = {}) {
49
- if (typeof home !== "string" || home.length === 0) return false;
56
+ export function noteSessionEventsSource({ engramDir, source, sessionId, pid = process.pid, now = Date.now() } = {}) {
57
+ if (typeof engramDir !== "string" || engramDir.length === 0) return false;
50
58
  if (typeof source !== "string" || source.length === 0) return false;
51
- const path = pushLegsPath(home);
59
+ const path = pushLegsPath(engramDir);
52
60
  const row = `${JSON.stringify({ at: now, pid, sessionId: sessionId ?? null, source })}\n`;
53
61
  try {
54
62
  mkdirSync(dirname(path), { recursive: true });
@@ -68,14 +76,14 @@ export function noteSessionEventsSource({ home, source, sessionId, pid = process
68
76
  * Newest recorded read path (and how many rows mention each verdict).
69
77
  * Never throws; returns `undefined` when nothing was ever recorded.
70
78
  *
71
- * @param {string} home DSH home
79
+ * @param {string} engramDir engram directory (`~/.dsh/engram`)
72
80
  * @returns {{at: number, source: string, sessionId: string|null, pid: number, counts: Record<string, number>} | undefined}
73
81
  */
74
- export function readPushLegsHealth(home) {
75
- if (typeof home !== "string" || home.length === 0) return undefined;
82
+ export function readPushLegsHealth(engramDir) {
83
+ if (typeof engramDir !== "string" || engramDir.length === 0) return undefined;
76
84
  let raw;
77
85
  try {
78
- raw = readFileSync(pushLegsPath(home), "utf8");
86
+ raw = readFileSync(pushLegsPath(engramDir), "utf8");
79
87
  } catch {
80
88
  return undefined;
81
89
  }