dsh-gokifu-compact 1.11.4 → 1.11.6
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.
- package/CHANGELOG.md +88 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/engine.mjs +29 -6
- package/src/rooms/migrate.mjs +48 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,93 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 1.11.6 — 2026-10-31
|
|
4
|
+
|
|
5
|
+
### tier-2 解耦:checkpoint 的常驻成本不再随 fold 次数线性涨
|
|
6
|
+
|
|
7
|
+
**问题(只读核实)**:fold 的 range 从**上一个 checkpoint 之后**开始 ⇒ 旧 checkpoint **全部留在 surface**
|
|
8
|
+
(实测 `start=1009 > cp8=1008`),每 fold 一次就 +1 条;而旧代码**只在 pressure 分支**才收 tier-2。
|
|
9
|
+
⇒ auto 路径连做 12 次 fold,surface 上就线性涨到 **12 条旧 checkpoint**。
|
|
10
|
+
|
|
11
|
+
**改法**
|
|
12
|
+
|
|
13
|
+
- `src/engine.mjs`:删掉那个**空的 `#tier2`**(调用点 + 定义一起删);常规 fold **成功后**
|
|
14
|
+
(`tier2After > 0`)`await #tier2IfNeeded()`;**失败只 warn,不影响本次 fold**。
|
|
15
|
+
- `#tier2IfNeeded` 加 `_tier2InFlight` **防重入**(同一时刻只允许一个 tier-2 在跑,防递归 / 二次 `compactRegion`)。
|
|
16
|
+
|
|
17
|
+
**前后对照(同一会话 12 次 fold)**
|
|
18
|
+
|
|
19
|
+
| | surface 上旧 checkpoint 条数 | tier-2 次数 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| 旧 | **12**(`[1..12]`,max 12) | 0(auto 路径从不触发) |
|
|
22
|
+
| 新 | **8**(`[1..8,7,8,7,8]`,max 8) | 14 次 = 12 fold + **2 tier-2** |
|
|
23
|
+
|
|
24
|
+
**测试 6 → 7**:T2-0 改写(空方法已删、fold 后调用、防重入)、T2-5 改写(auto 也触发)、
|
|
25
|
+
T2-6 新增(不递归 / 压回上限 / `tier2After=0` 不触发)。变异:删掉 fold 后的触发 ⇒ T2-0/T2-5/T2-6 红。
|
|
26
|
+
|
|
27
|
+
### 迁移守卫补上 delta:只看 `graph.json` 会漏掉「插件其实在写」
|
|
28
|
+
|
|
29
|
+
**问题**:`migrate-dangling --apply` 的 120s 新鲜度守卫**只 stat `graph.json`**。但 1.9.2 起是**增量落盘** ——
|
|
30
|
+
`graph.json` 只在回合边界写,插件活跃时往往**只有 `graph.json.delta.jsonl` 在动**。
|
|
31
|
+
真实库实测:**8 个会话的 delta 在 120s 内被写过,而旧守卫一个都没拦住** ⇒ apply 可能在插件仍活跃时放行,
|
|
32
|
+
随后内存 flush 把迁移结果覆盖回去(这正是 P6-C 反复强调的 `netCleaned=0` 场景)。
|
|
33
|
+
|
|
34
|
+
**改法**
|
|
35
|
+
|
|
36
|
+
- 新增导出 **`DELTA_NAME = "graph.json.delta.jsonl"`**(与 `kifu-persist.sessionPaths().deltaFile` 的 basename
|
|
37
|
+
必须一致,测试有断言盯着这两个名字)。
|
|
38
|
+
- 新增 **`freshEvidence()`**:返回每个「120s 内被写过」的会话**及具体文件**;`freshSessions()` 在其上去重。
|
|
39
|
+
- 判据:`graph.json` **必看**(缺失照旧抛 ENOENT,不静默跳过);**delta 存在就看 delta**;
|
|
40
|
+
delta 缺失不判 fresh(老库没有 delta 文件也照样能迁移)。
|
|
41
|
+
- 拒绝 apply 时的报错会**点名会话与文件**,而不是只说「有会话新鲜」。
|
|
42
|
+
|
|
43
|
+
**测试 +4**:P6C-11(只有 delta 在写 ⇒ 拦住并点名文件)/ P6C-12(delta 缺失不影响)/ P6C-13(两者都旧 ⇒ 放行)/
|
|
44
|
+
P6C-14(多会话混判:按会话去重,逐个点名)。
|
|
45
|
+
|
|
46
|
+
### 验证
|
|
47
|
+
|
|
48
|
+
- 全量:**481 passed / 0 skipped / 0 failed**(36 个测试文件;476 + 1 tier-2 + 4 migrate)
|
|
49
|
+
- `tier2` **7/0** · `migrate` **14/0** · `engine-harness` 25/0 · `rooms` 15/0 · `verify-install` **16/16**
|
|
50
|
+
- `bench 2000`:**351 tokens**(与基线一致)
|
|
51
|
+
- `npm pack --dry-run --json`:**71 文件**(数量不变:只改 `src/` 与 `test/`,无新增文件)
|
|
52
|
+
|
|
53
|
+
**未验证**
|
|
54
|
+
|
|
55
|
+
- tier-2 的**运行期**收益未在真机观测 —— 8 vs 12 是引擎级前后对照(同一会话、同一 fold 序列)。
|
|
56
|
+
- delta 守卫只在测试夹具与**只读扫描**真实库上验证过;**真实 apply 仍未执行**(需主人授权 + 停插件窗口)。
|
|
57
|
+
|
|
58
|
+
## 1.11.5 — 2026-10-31
|
|
59
|
+
|
|
60
|
+
### S10d · 测试运行器终于能区分「跳过」与「通过」,「空壳用例」不再是绿的
|
|
61
|
+
|
|
62
|
+
**问题**:旧 `run.mjs` 里,平台/权限不对时用 `return` 早退,**会计入 passed** —— 单看
|
|
63
|
+
「N passed」根本排除不了跳过;而且一个**一条断言都没有**的用例也会静默算通过。
|
|
64
|
+
|
|
65
|
+
**改法**
|
|
66
|
+
|
|
67
|
+
| 能力 | 说明 |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `t.skip(reason)` | 计入 `skipped`,不计 pass/failed;**exit code 只由 failed 决定** |
|
|
70
|
+
| 汇总行 | `== N passed, M skipped, K failed ==`(新增中间那段,跳过不再藏在 passed 里) |
|
|
71
|
+
| **零断言检测** | `assertions === 0` **且**源码里没有 `throw` / `assert` 证明 ⇒ 计入 `zeroAssertions` 并**按失败处理**。本仓 300+ 用例是 throw 风格(算有证明),空壳用例从此不再静默算 pass |
|
|
72
|
+
| `--dir=` | 仅供 `run-skip` 夹具指向临时目录 |
|
|
73
|
+
|
|
74
|
+
- **`install-link` 6 处平台/权限早退改为 `t.skip(...)`**,原有断言一字未改。
|
|
75
|
+
- 新增 `test/run-skip.test.mjs` **3 条**:四态(pass/fail/skip/零断言)可区分、skip 不导致失败、零断言严格红。
|
|
76
|
+
|
|
77
|
+
**变异自检**:删掉 skip 分支 ⇒ `run-skip` 红;删掉零断言检测 ⇒ `run-skip` 红。
|
|
78
|
+
|
|
79
|
+
### 验证
|
|
80
|
+
|
|
81
|
+
- 全量:**476 passed / 0 failed / 0 skipped**(36 个测试文件;473 + 3)
|
|
82
|
+
- `install-link` **29/0** · `run-skip` **3/0** · `rooms` 15/0 · `verify-install` **16/16**
|
|
83
|
+
- `bench 2000`:**351 tokens**(与基线一致)
|
|
84
|
+
- `npm pack --dry-run --json`:**71 文件**(数量不变,只改 `test/`)
|
|
85
|
+
|
|
86
|
+
**未验证**
|
|
87
|
+
|
|
88
|
+
- 零断言检测只认 `assertions` 计数与 `throw`/`assert` 的**文本证据**:如果一个用例用别的方式实打实地检查了东西(例如把结果写进外部文件再断言),仍可能被误判 —— 目前本仓没有这种写法。
|
|
89
|
+
- `skipped` 总数现在是 0(本机 Windows)。**跨平台**(Linux/macOS 上那 6 条会真的 skip)没有实跑过。
|
|
90
|
+
|
|
3
91
|
## 1.11.4 — 2026-10-31
|
|
4
92
|
|
|
5
93
|
### 发布脚本加固:坏 remote 在预演阶段就红,git 步骤事务化
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-GOkifu-compact
|
|
2
2
|
|
|
3
|
-
> 当前版本:**`1.11.
|
|
3
|
+
> 当前版本:**`1.11.6`** · npm [`dsh-gokifu-compact`](https://www.npmjs.com/package/dsh-gokifu-compact) · [GitHub Releases](https://github.com/MYCF711/dsh-GOkifu-compact/releases) · 更新记录见 [CHANGELOG.md](CHANGELOG.md)
|
|
4
4
|
|
|
5
5
|
**DSH 的棋谱式上下文压缩后端 —— 把「历史 → 一段摘要」换成「历史 → 可复盘的棋谱 DAG + 每步只有 351 tokens 的热清单」。**
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
热清单 351 tokens (2000 边合成夹具 · L5 降级档 · 2026-10-30/10-31 两次实测同值;旧版全量 28,800)
|
|
9
9
|
失败保留 8 / 8 (历史口径 †;官方 8 段模板 4.67 / 8)
|
|
10
10
|
清单开销 未复现/待测 (原载 0.2% = 历史单会话 v1.3.1 后实测;无复跑命令、无对照组;源 evidence/真实计费核对.md)
|
|
11
|
-
验证
|
|
11
|
+
验证 481 项单测(0 skipped) · 真实包 8/8 † · 真实进程 fold 3~6/6 †
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不只有落子顺序,还有每一手的评语。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-gokifu-compact",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.6",
|
|
4
4
|
"description": "DSH context-compression backend with a go game record (kifu) DAG: node = position, edge = attempted move + result. Incremental fold (each span summarized once), failure memory that survives compaction, transposition detection (chess-engine style), and a tiny hot ledger rebuilt every step.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/src/engine.mjs
CHANGED
|
@@ -131,6 +131,9 @@ export default class ChessCompactionEngine extends BasicCompactionEngine {
|
|
|
131
131
|
/** ★ P3:每会话基线 graph.json 的内容哈希(16 位);delta 帧用它做 generation 保护。 */
|
|
132
132
|
/** ★ P3:已进入 graph.json/delta 但还没写过 nodes/<id>.md 的节点(fold 时补写)。 */
|
|
133
133
|
this.runtime = new Map();
|
|
134
|
+
/** ★ TIER2-SEMANTICS(task-142):tier-2 重入保护 —— 常规 fold 之后与 pressure 分支都会收 checkpoint,
|
|
135
|
+
* 同一时刻只允许一个 tier-2 在跑(防递归 / 二次 compactRegion)。 */
|
|
136
|
+
this._tier2InFlight = false;
|
|
134
137
|
/** ★ P7:热清单预算统计(跨会话累计;doctor 从这里读)。 */
|
|
135
138
|
this.ledgerBudget = { none: 0, "over-budget": 0, impossible: 0, 未采集: 0, maxOverBudgetBy: 0, updatedAt: 0, samples: 0 };
|
|
136
139
|
if (this.chess.hotLedger) this.#bindLedger();
|
|
@@ -608,7 +611,6 @@ ${constraintsBlock.split("\n").map((l) => `- ${l}`).join("\n")}` });
|
|
|
608
611
|
shadowedTokens: range.shadowedTokens,
|
|
609
612
|
totalAfter: after.totalTokens,
|
|
610
613
|
});
|
|
611
|
-
this.#tier2(session, g);
|
|
612
614
|
// ★ 2026-10-29 修复:这里原来传的是 `{ md: true }`,而 `#flush(session, { tree = false })`
|
|
613
615
|
// 只认 `tree` —— 参数名不匹配 ⇒ fold 后**从不**重写 `nodes/*.md`,
|
|
614
616
|
// 压缩后的节点详情停在第一次落盘的样子(Kimi 评估实测发现)。
|
|
@@ -616,6 +618,20 @@ ${constraintsBlock.split("\n").map((l) => `- ${l}`).join("\n")}` });
|
|
|
616
618
|
this.ctx.logger?.info?.(
|
|
617
619
|
`chess-context: fold #${g.foldCount} 遮蔽 ${range.end - range.start >= 0 ? `${range.start}-${range.end}` : "?"} (~${range.shadowedTokens} tokens) → 总 ${after.totalTokens} tokens(死路 ${failureEdges(g).length} 条,节点 ${g.order.length})`,
|
|
618
620
|
);
|
|
621
|
+
// ★ TIER2-SEMANTICS(task-142,方案 A):checkpoint 常驻成本与「压力」无关,常规 fold 之后也要收。
|
|
622
|
+
// 证据(ws-tier2-semantics/REPORT.md):fold 的 range 从**上一个 checkpoint 之后**开始
|
|
623
|
+
// (实测 8 个 checkpoint 之后 start=1009 > cp8=1008)⇒ 旧 checkpoint 全部留在 surface 上,
|
|
624
|
+
// 每 fold 一次 +1,数量随 fold 次数线性增长。旧版只在 trigger === "pressure" 收,
|
|
625
|
+
// auto / context-overflow 路径会无限堆积(旧的空 `#tier2` 调用不做事)。
|
|
626
|
+
// 安全性:`#tier2IfNeeded` 只调 `compactRegion`(不会回到 `compactIfNeeded`)⇒ 无递归;
|
|
627
|
+
// 另有 `_tier2InFlight` 防重入;`tier2After = 0` 直接跳过(与 compactIfNeeded 的守卫一致)。
|
|
628
|
+
if (this.chess.tier2After > 0) {
|
|
629
|
+
try {
|
|
630
|
+
await this.#tier2IfNeeded(agent, signal);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
this.ctx.logger?.warn?.(`chess-context: fold 后 tier-2 失败(不影响本次 fold):${String(error)}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
619
635
|
return result;
|
|
620
636
|
}
|
|
621
637
|
|
|
@@ -640,6 +656,18 @@ ${constraintsBlock.split("\n").map((l) => `- ${l}`).join("\n")}` });
|
|
|
640
656
|
* @returns 合并结果,或 null(不需要/无法合并)
|
|
641
657
|
*/
|
|
642
658
|
async #tier2IfNeeded(agent, signal) {
|
|
659
|
+
// ★ TIER2-SEMANTICS(task-142):防重入 —— 常规 fold 之后与 pressure 分支都会调它。
|
|
660
|
+
// 重入时直接放弃(宁可少合并一次,也不要在一个 fold 事务里套第二个 compactRegion)。
|
|
661
|
+
if (this._tier2InFlight) return null;
|
|
662
|
+
this._tier2InFlight = true;
|
|
663
|
+
try {
|
|
664
|
+
return await this.#tier2IfNeededUnsafe(agent, signal);
|
|
665
|
+
} finally {
|
|
666
|
+
this._tier2InFlight = false;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async #tier2IfNeededUnsafe(agent, signal) {
|
|
643
671
|
const session = agent.session;
|
|
644
672
|
const cps = checkpointSeqs(session);
|
|
645
673
|
if (cps.length < this.chess.tier2After) return null;
|
|
@@ -665,11 +693,6 @@ ${constraintsBlock.split("\n").map((l) => `- ${l}`).join("\n")}` });
|
|
|
665
693
|
}
|
|
666
694
|
return result;
|
|
667
695
|
}
|
|
668
|
-
|
|
669
|
-
/** 记录一次常规 fold 的元数据(tier-2 由 #tier2IfNeeded 自己记)。 */
|
|
670
|
-
#tier2(_session, _g) {
|
|
671
|
-
/* 保留方法名以免外部调用点断裂;真正的合并在 #tier2IfNeeded。 */
|
|
672
|
-
}
|
|
673
696
|
}
|
|
674
697
|
|
|
675
698
|
/* ────────────────────────────── 辅助 ────────────────────────────── */
|
package/src/rooms/migrate.mjs
CHANGED
|
@@ -239,22 +239,55 @@ export function dryRun(root, { now = Date.now(), only = null } = {}) {
|
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
/**
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
242
|
+
* 增量帧文件名。
|
|
243
|
+
* ⚠ 必须与 `kifu-persist.sessionPaths().deltaFile` 的 basename 一致(那里的值由
|
|
244
|
+
* `<graphFile>.delta.jsonl` 拼出)。测试 `test/migrate.test.mjs` 有断言盯着这两个名字。
|
|
245
|
+
*/
|
|
246
|
+
export const DELTA_NAME = "graph.json.delta.jsonl";
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 新鲜度**证据**:返回每个「最近 maxAgeMs 内被写过」的会话及**具体文件**。
|
|
250
|
+
*
|
|
251
|
+
* 为什么要看 delta:1.9.2 起是**增量落盘** —— `graph.json` 只在回合边界写,
|
|
252
|
+
* 插件活跃时往往只有 `graph.json.delta.jsonl` 在动。旧实现只 stat `graph.json`,
|
|
253
|
+
* 真实库实测有 8 个会话的 delta 在 120s 内被写却没人拦 ⇒ apply 可能在插件
|
|
254
|
+
* 仍活跃时放行,随后内存 flush 覆盖迁移结果。
|
|
255
|
+
*
|
|
256
|
+
* 判据:
|
|
257
|
+
* · `graph.json` 必看(缺失照旧抛 ENOENT,不静默跳过);
|
|
258
|
+
* · `graph.json.delta.jsonl` 存在才看;
|
|
259
|
+
* · 任一文件的 |now - mtime| < maxAgeMs ⇒ 该会话算 fresh。
|
|
260
|
+
*
|
|
261
|
+
* 用**绝对值**:NTFS 的时间戳可能比 `Date.now()` 超前几毫秒(时钟粒度/回拨),
|
|
262
|
+
* 只看 age>=0 会把「刚写过」误判成「旧文件」,守卫就失效了。
|
|
263
|
+
*
|
|
264
|
+
* @returns {{session: string, file: string, ageMs: number}[]} 命中的(会话, 文件)对
|
|
245
265
|
*/
|
|
246
|
-
export function
|
|
266
|
+
export function freshEvidence(root, { now = Date.now(), maxAgeMs = APPLY_FRESHNESS_MS, only = null } = {}) {
|
|
247
267
|
const out = [];
|
|
248
268
|
for (const dir of selectedSessions(root, only)) {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
269
|
+
const session = nodePath.basename(dir);
|
|
270
|
+
for (const name of ["graph.json", DELTA_NAME]) {
|
|
271
|
+
const file = nodePath.join(dir, name);
|
|
272
|
+
// graph.json 缺失时 statSync 照旧抛错(与旧行为一致);delta 允许不存在。
|
|
273
|
+
if (name !== "graph.json" && !nodeFs.existsSync(file)) continue;
|
|
274
|
+
const ageMs = Math.abs(now - nodeFs.statSync(file).mtimeMs);
|
|
275
|
+
if (ageMs < maxAgeMs) out.push({ session, file: name, ageMs });
|
|
276
|
+
}
|
|
254
277
|
}
|
|
255
278
|
return out;
|
|
256
279
|
}
|
|
257
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 新鲜度守卫:返回「最近 maxAgeMs 内被写过」的会话(apply 前必须为空)。
|
|
283
|
+
* 为什么需要:运行中的插件会在内存 flush 时把清理结果覆盖回去(实测 netCleaned=0)。
|
|
284
|
+
* 同时看 `graph.json` 与 `graph.json.delta.jsonl`(增量落盘,见 `freshEvidence`)。
|
|
285
|
+
* @returns {string[]} 会话目录名(去重,保留发现顺序)
|
|
286
|
+
*/
|
|
287
|
+
export function freshSessions(root, opts = {}) {
|
|
288
|
+
return [...new Set(freshEvidence(root, opts).map((hit) => hit.session))];
|
|
289
|
+
}
|
|
290
|
+
|
|
258
291
|
/* ───────────────────────────── 备份 ───────────────────────────── */
|
|
259
292
|
|
|
260
293
|
/** 整目录备份到 `<backupRoot>/backup-<ts>/`,返回备份目录与清单。 */
|
|
@@ -310,10 +343,14 @@ export function rollback(backupDir) {
|
|
|
310
343
|
* @throws 有「最近 120s 内被写过」的会话时拒绝执行(插件可能正在跑)
|
|
311
344
|
*/
|
|
312
345
|
export function apply(root, { now = Date.now(), backupRoot = backupsRoot(), stamp = new Date(), maxAgeMs = APPLY_FRESHNESS_MS, only = null } = {}) {
|
|
313
|
-
|
|
346
|
+
// 守卫必须点名**具体文件**:图在 1.9.2 起是增量落盘,只报「graph.json 新鲜」
|
|
347
|
+
// 会让人误以为主图没动就能 apply —— 真正在写的是 delta。
|
|
348
|
+
const fresh = freshEvidence(root, { now, maxAgeMs, only });
|
|
314
349
|
if (fresh.length > 0) {
|
|
350
|
+
const sessions = new Set(fresh.map((hit) => hit.session));
|
|
351
|
+
const detail = fresh.slice(0, 3).map((hit) => `${hit.session} 的 ${hit.file}`).join("、");
|
|
315
352
|
throw new Error(
|
|
316
|
-
`migrate: 拒绝 apply —— ${
|
|
353
|
+
`migrate: 拒绝 apply —— ${sessions.size} 个会话的文件在 ${maxAgeMs / 1000}s 内被写过(${detail}${fresh.length > 3 ? " …" : ""})。` +
|
|
317
354
|
" 运行中的插件会用内存图 flush 覆盖磁盘清理(实测 netCleaned=0);先停插件窗口再重试。",
|
|
318
355
|
);
|
|
319
356
|
}
|