dsh-vibe-math 2.3.1 → 2.3.2

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.
Files changed (38) hide show
  1. package/AUDIT-CHECKLIST.md +45 -0
  2. package/README.md +16 -6
  3. package/RELEASE-NOTES-2.3.2.md +145 -0
  4. package/audit-formal-sensitivity.mjs +11 -2
  5. package/audit-prompt-invariants.mjs +414 -0
  6. package/audit-spec-traceability.mjs +173 -0
  7. package/docs/formal-verification.md +33 -10
  8. package/docs/generate_framework_diagram_v5.mjs +2 -1
  9. package/docs/test-timing.md +32 -10
  10. package/formal-verify-v2.test.mjs +242 -2
  11. package/formal-verify-v3.test.mjs +176 -4
  12. package/formal-verify-v4.test.mjs +184 -5
  13. package/formal-verify-v5.test.mjs +91 -4
  14. package/installer.js +3 -1
  15. package/package.json +5 -2
  16. package/prompt-corpus-persona/persona-corpus.json +2 -2
  17. package/prompt-corpus-persona/persona-corpus.md +6 -2
  18. package/prompt-corpus-v2/formal-verify-v2.json +134 -44
  19. package/prompt-corpus-v2/formal-verify-v2.md +1033 -44
  20. package/prompt-corpus-v3/formal-verify-v3.json +200 -128
  21. package/prompt-corpus-v3/formal-verify-v3.md +948 -243
  22. package/prompt-corpus-v4/formal-verify-v4.json +8 -3
  23. package/prompt-corpus-v4/formal-verify-v4.md +38 -10
  24. package/prompt-corpus-v5/prompt-corpus-v5.json +175 -246
  25. package/prompt-corpus-v5/prompt-corpus-v5.md +341 -781
  26. package/prompt-v5-integrity.test.mjs +136 -22
  27. package/run-tests.mjs +30 -11
  28. package/vibe-math-v2/vibe-math-v2.js +149 -35
  29. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +53 -5
  30. package/vibe-math-v3/vibe-math-v3.js +88 -23
  31. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +7 -6
  32. package/vibe-math-v4/vibe-math-v4.js +103 -24
  33. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +34 -11
  34. package/vibe-math-v5/agent.cordis.yml +6 -2
  35. package/vibe-math-v5/vibe-math-v5.js +56 -10
  36. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +40 -13
  37. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +16 -2
  38. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +6 -5
@@ -46,9 +46,18 @@
46
46
 
47
47
  ### `off`(默认)—— 不额外进行任何要求
48
48
 
49
- - 提示词里**不出现**任何 Lean 相关内容;验证流程、门禁、归档全部不变。
50
- - 三个 Lean 工具**仍然注册**(注册是静态的,与既有 `ctx.effect` 纪律一致),
51
- 但框架不会告诉代理它们存在;代理/人主动调用时它们照常工作。
49
+ - **成员提示词**(按模式动态注入的那部分)里**不出现**任何 Lean 相关内容;验证流程、门禁、归档全部不变,
50
+ 也不写入任何形式化状态。这是真正的"无操作",四套都有断言与探针守着。
51
+ - **回执通道在 `off` 档必须失效**:`formal` 字段本来就不在 `off` 档的回执契约里,所以一个残留/幻觉/被
52
+ 引用的 `formal` 回执**不得**创建形式化记录(否则 `off` 就不是无操作了)。四个架构都必须在这个入口
53
+ 上加 `formalOn()` 守卫。
54
+ - 三个 Lean 工具**仍然注册且可用**(注册是静态的,与既有 `ctx.effect` 纪律一致);`off` 只是不主动
55
+ 向成员宣讲它们。代理/人主动调用时照常工作——**工具调用是刻意行为,回执字段不是**,这条区别就是
56
+ 上一条守卫的理由。
57
+ - **主代理的 persona 是静态的,不受档位影响**:它**必须始终**文档化这三个工具与四个参数
58
+ (否则用户在 `off` 档根本发现不了这个开关,也就无法打开它)。"成员提示词里没有 Lean 文字"与
59
+ "persona 里写着这个能力"**不矛盾**,两者都由测试守着(前者见各 `formal-verify-vN` 的 `off` 小节,
60
+ 后者见 `audit-persona-surface.test.mjs`)。
52
61
 
53
62
  ### `encourage` —— 鼓励但不强制
54
63
 
@@ -211,9 +220,11 @@
211
220
  > 3. **归档可复用定义/引理前必须先跑通**:`<prefix>lean_archive` 支持 `run:true`,
212
221
  > 或先 `<prefix>lean_run`。跑不通的代码不得进入 `Formal/Lib` / `Formal/Proved`——
213
222
  > 否则"可复用库"会被不编译的定义污染。
214
- > 4. **工具链缺失时的出路必须写出来**:`LEAN_NOT_FOUND` 时把代码写下来并归档,
215
- > 在 `note` 里写明"宿主无 Lean 工具链";这算显式阻塞原因,`require` 档可以据此放行,
216
- > 代理不会因为装不了 Lean 而卡死。
223
+ > 4. **工具链缺失时的出路必须写出来**:`LEAN_NOT_FOUND`(解析不到 `leanCommand`)与
224
+ > `NO_SUBPROCESS`(宿主没有 subprocess 服务)**都算"这台宿主上没有 Lean"**:把代码写下来并归档,
225
+ > 在 `note` 里写明原因;这算显式阻塞原因,`require` 档可以据此放行,代理不会因为装不了 Lean 而卡死。
226
+ > 提示词里应把**两个**错误码都点出来——只写 `LEAN_NOT_FOUND` 时,遇到 `NO_SUBPROCESS` 的代理
227
+ > 会以为自己遇到了另一种失败而反复重试。
217
228
  > 5. **忠实性缺陷不得用 0 表达**(§4.1 第 4 条):只有独立于 Lean 代码也能确定命题为假时才投 0。
218
229
 
219
230
  ### 6.1 验证提示词
@@ -330,22 +341,34 @@
330
341
 
331
342
  ### 9.2 `<VibeMath 根>/Formal/Lib/Index.md`
332
343
 
344
+ **必需列**:`名称 | 文件 | 类别 | 摘要`。
345
+ **可选列**:若该架构为库文件保留运行记录,可增加 `最近运行`(v3 就是这样做的,v2/v4/v5 没有);
346
+ **没有记录就不许加这一列、更不许写假数据**——"说得出这个文件还编不编得过"是有价值的信息,
347
+ 但编一个不说实话的运行状态比没有更糟。
348
+
333
349
  ```markdown
334
350
  # 可复用 Lean 定义库(跨项目)
335
- | 名称 | 文件 | 类别 | 摘要 | 最近运行 |
336
- |---|---|---|---|---|
351
+ | 名称 | 文件 | 类别 | 摘要 | 最近运行 | ← 最后一列可选
352
+ |---|---|---|---|---|---|
337
353
  | ZMod5 | Lib/ZMod5.lean | def | 模 5 剩余类与基本引理 | ok |
338
354
  ```
339
355
 
340
356
  ### 9.3 `<VibeMath 根>/Formal/Proved/Index.md`
341
357
 
358
+ **必需列**:`名称 | 文件 | 陈述`。
359
+ **可选列**:`依赖`(仅当该架构记录依赖关系时)、`最近运行`(同上)、`类别`(v2/v4/v5 用它标注
360
+ `lemma`;v3 不加,因为整张表都是引理)。
361
+
342
362
  ```markdown
343
363
  # 已成立的 Lean 命题 / 引理(机器已核对,可跨项目复用)
344
- | 名称 | 文件 | 陈述 | 依赖 | 最近运行 |
345
- |---|---|---|---|---|
364
+ | 名称 | 文件 | 陈述 | 依赖 | 最近运行 | ← 后两列可选
365
+ |---|---|---|---|---|---|
346
366
  | pell_sq_odd | Proved/pell_sq_odd.lean | … | ZMod5 | ok |
347
367
  ```
348
368
 
369
+ > 三份索引都是**给人/代理看的摘要**,不是权威状态(权威状态在对象记录/会话投影里)。
370
+ > 跨架构只强制"必需列",因为强行对齐可选列会逼某个架构去记录它并不维护的数据。
371
+
349
372
  ---
350
373
 
351
374
  ## 10. 测试要求(每个架构都要有)
@@ -186,10 +186,11 @@ const ccw = (IN_R - IN_L - 2 * 22) / 3
186
186
  chips.forEach((c, i) => {
187
187
  card(IN_L + (i % 3) * (ccw + 22), i < 3 ? 620 : 708, ccw, 78, c, { stroke: C.frame, fs: 12.5, sfs: 10.5 })
188
188
  })
189
- card(IN_L, 796, IN_R - IN_L, 76, [
189
+ card(IN_L, 796, IN_R - IN_L, 90, [
190
190
  'Lean 形式化验证(可调参数 formalVerify = off / encourage / require)',
191
191
  'encourage:验证时按实现难度决定是否形式化;require:真/假结论必须先有「Lean 通过」或显式阻塞记录',
192
192
  '★ 一旦 Lean 通过,审查对象就变了:不再是「推导对不对」,而是「Lean 的定义/对象/条件/假设/结论是否忠实于命题原文」',
193
+ '★ 忠实性缺陷(decision=defect)≠ 命题为假:撤回「已通过」+ 撤回归档证明 + 进「形式化待办」,绝不记成 0/假',
193
194
  '归档:证明 → Verified/Lean/<对象>.lean|可复用定义 → VibeMath/Formal/Lib/|已证引理 → VibeMath/Formal/Proved/',
194
195
  ], { stroke: C.gate, fs: 12.5, sfs: 10.5 })
195
196
  card(IN_L, 880, IN_R - IN_L, 38, [
@@ -13,19 +13,26 @@ node run-tests.mjs --only formal # 只跑名字含 formal 的套件
13
13
  node run-tests.mjs --concurrency=6 # 手动指定并发
14
14
  node audit-formal-sensitivity.mjs # 49 条不变式探针,并行(--concurrency=N / --only=<preset> / --list)
15
15
  node audit-persona-sensitivity.mjs # 11 条提示词面探针(串行,本身只要几秒)
16
+ node audit-prompt-invariants.mjs # 静态:四套的提示词/工具面不变式(< 0.1s)
17
+ node audit-prompt-invariants.mjs --self-probe # 证明上面那 145 条不变式真的会变红(5 个自探针)
18
+ node audit-spec-traceability.mjs # 静态:规格/README ↔ 代码可追溯(< 0.1s)
19
+ node audit-v5-integrity.mjs # 静态:v5 完整性/理念门禁(≈3 s)
16
20
  ```
17
21
 
18
22
  两个并行 runner 都会打印**每项耗时 + 汇总(wall / sum / speed-up / 最慢几项)**。跑完请读这几行。
19
23
 
20
- ## 2. 基线(本机:4 核 / 8 GB,Windows,2026-09 实测)
24
+ ## 2. 基线(本机:4 核 / 8 GB,Windows,实测)
21
25
 
22
26
  | 脚本 | 串行(sum) | 并行(wall) | 实测输出 |
23
27
  |---|---|---|---|
24
- | `run-tests.mjs`(23 个套件) | 219.4 s | **114.4 s**(并发 4,speed-up x1.92) | 关键路径 = `e2e-v4-fixes` 101.1 s |
28
+ | `run-tests.mjs`(23 个套件) | 221.5 s | **111.5 s**(并发 4,speed-up x1.99) | 关键路径 = `e2e-v4-fixes` 98.1 s |
25
29
  | `audit-formal-sensitivity.mjs`(49 探针) | 612.0 s | **154.6 s**(并发 4,speed-up x3.96) | 关键路径 = 12 个 v2 探针(每个 ≈32 s) |
26
30
  | `audit-persona-sensitivity.mjs`(11 探针) | ≈ 5 s | — | 本身很快,不需要并行 |
31
+ | `audit-prompt-invariants.mjs`(145 条) | 0.3 s | — | 静态 |
32
+ | `audit-prompt-invariants.mjs --self-probe`(5 探针) | 1.5 s | — | 每个探针 = 一次自我重跑(0.3 s) |
33
+ | `audit-spec-traceability.mjs`(91 条) | 0.3 s | — | 静态 |
27
34
  | `audit-v5-integrity.mjs` | ≈ 3 s | — | 静态审计 |
28
- | `prompt-v5-integrity.test.mjs` | ≈ 7 s | — | 生成 v5 语料 |
35
+ | `prompt-v5-integrity.test.mjs` | 1.6 s | — | 虚拟时钟下生成 v5 语料(语料字节稳定) |
29
36
 
30
37
  > 优化前:全量回归 ≈ 5.5 min(串行,`formal-verify-v2` 单独 186 s);
31
38
  > 探针脚本 ≈ **38 min**(49 条串行,其中 12 条 × `formal-verify-v2` 162 s)。
@@ -35,13 +42,14 @@ node audit-persona-sensitivity.mjs # 11 条提示词面探针(串行,本
35
42
 
36
43
  | 套件 | 耗时 | 备注 |
37
44
  |---|---|---|
38
- | `e2e-v4-fixes.test.mjs` | **≈ 101 s** | 9 个用例是**轮次采样**型(如 T13 采样 400 轮、T19/T25 多轮);时间 ≈ 轮数 × 框架自身的 40 ms 计时粒度 |
39
- | `formal-verify-v2.test.mjs` | **≈ 32 s** | 曾为 186 s:见 §3 |
45
+ | `e2e-v4-fixes.test.mjs` | **≈ 98 s** | 9 个用例是**轮次采样**型(如 T13 采样 400 轮、T19/T25 多轮);时间 ≈ 轮数 × 框架自身的 40 ms 计时粒度 |
46
+ | `formal-verify-v2.test.mjs` | **≈ 36 s** | 曾为 186 s:见 §3 |
40
47
  | `e2e-regression.test.mjs` | ≈ 14 s | |
41
48
  | `e2e-business.test.mjs` | ≈ 13 s | |
42
49
  | `e2e-d9-d13.test.mjs` | ≈ 13 s | |
43
- | `e2e-v3.test.mjs` | ≈ 11 s | |
44
- | 其余 17 个 | ≤ 10 s | 其中 8 个 < 1 s |
50
+ | `e2e-v3.test.mjs` | ≈ 12 s | |
51
+ | `formal-verify-v3.test.mjs` | ≈ 12 s | |
52
+ | 其余 16 个 | ≤ 6 s | 其中 8 个 < 1 s |
45
53
 
46
54
  ## 3. 已经做过的优化(别再重复踩)
47
55
 
@@ -57,14 +65,26 @@ node audit-persona-sensitivity.mjs # 11 条提示词面探针(串行,本
57
65
  **再往下压就要砍采样深度了**——那 9 个慢用例(T13/T19/T22/T23/T25/T27/T2/T9/T20)是在
58
66
  观察"多轮之后某个指令**没有**泄漏/重复",轮数是它们的不变式本体,不要再动。
59
67
  3. **两个 runner 并行**(本轮新增):探针 38 min → 2.6 min(sum 612 s,wall 154.6 s,x3.96);
60
- 全量回归 5.5 min → 1.9 min(sum 219.4 s,wall 114.4 s,x1.92)。
68
+ 全量回归 5.5 min → 1.9 min(sum 221.5 s,wall 111.5 s,x1.99)。
69
+ 4. **虚拟时钟**(`prompt-v5-integrity.test.mjs`,本轮新增):v5 研究所由 `ctx.timeout` + `Date.now()`
70
+ 驱动,真实时钟下"哪个成员被心跳/会议唤醒"取决于负载与毫秒差 → **随包语料每跑一次都变**(无法 diff,
71
+ 真实缺陷会被淹没)。套件现在把 `ctx.timeout` 接到**虚拟时钟**、`sleep(n)` 推进虚拟时间:
72
+ 套件 **7–9 s → 1.7 s**,且语料连续 6 次运行**字节一致**。语料写入端另加**全序排序**
73
+ (kind → owner → prompt),使文件成为"记录集合"的纯函数——只按 kind 排序时,同 kind 内仍会随
74
+ 异步 drain 顺序变化(真实事故:两条会议提示词顺序互换)。
75
+ > 想给别的套件套用同一手法前请注意:若套件的等待助手用 `Date.now()` 做**超时判据**、又用
76
+ > `setInterval` 轮询(例如 `e2e-v4-fixes.test.mjs` 的 `waitFor`),冻结时钟会让判据永不超时;
77
+ > 那种情况必须连**轮询定时器**一起虚拟化,不能只改 `sleep`。
61
78
 
62
79
  ## 4. 并行安全(为什么可以并发)
63
80
 
64
81
  - 每个套件/探针都自建 `mkdtempSync` 工作区,互不共享状态;
65
82
  - **会写语料的套件必须给不同的语料目录**:`V2_CORPUS_DIR` / `V3_CORPUS_DIR` / `V4_CORPUS_DIR` /
66
83
  `V5_CORPUS_DIR`。探针 runner 为**每个探针**分配独立目录,否则同一套件的并发实例会互相覆盖语料;
67
- - `audit-persona-sensitivity.mjs` 用 `PERSONA_ROOT` 指向变异副本,且在覆盖模式下**不写**语料。
84
+ - `audit-persona-sensitivity.mjs` 用 `PERSONA_ROOT` 指向变异副本,且在覆盖模式下**不写**语料;
85
+ - `audit-prompt-invariants.mjs --self-probe` 用 `PROMPT_INVARIANTS_MUTATE`(JSON `[rel, from, to]`)
86
+ 在**内存里**变异一个文件并自我重跑,**不碰磁盘**,因此可与任何东西并发;
87
+ 变异串里不要用 NUL 分隔(环境变量不允许 NUL 字节)。
68
88
 
69
89
  ## 5. 策略建议(按目的选最小代价的组合)
70
90
 
@@ -72,7 +92,9 @@ node audit-persona-sensitivity.mjs # 11 条提示词面探针(串行,本
72
92
  |---|---|---|
73
93
  | 改了某个架构的插件 | `node run-tests.mjs --only <vN>` + `node audit-formal-sensitivity.mjs --only=vN` | 30 s – 2 min |
74
94
  | 改了提示词/人设 | `node run-tests.mjs --only persona --only prompt` + `node audit-persona-sensitivity.mjs` | ≈ 15 s |
75
- | 改了共享契约 / 发版前 | `node run-tests.mjs` + `node audit-formal-sensitivity.mjs` + `node audit-persona-sensitivity.mjs` + `node audit-v5-integrity.mjs` | ≈ 4.5 min |
95
+ | **改了任何工具的参数 schema / 参数处理** | `node audit-prompt-invariants.mjs --self-probe` + `node run-tests.mjs --only formal` | ≈ 40 s(v2 套件占大头) |
96
+ | 改了共享契约 / 发版前 | `node run-tests.mjs` + `node audit-formal-sensitivity.mjs` + `node audit-persona-sensitivity.mjs` + `node audit-prompt-invariants.mjs --self-probe` + `node audit-spec-traceability.mjs` + `node audit-v5-integrity.mjs` | ≈ 4.5 min |
97
+ | 只想快速看提示词/文档有没有漂移 | `node audit-prompt-invariants.mjs && node audit-spec-traceability.mjs` | **< 0.5 s** |
76
98
  | 只想知道"快不快" | `node run-tests.mjs --json` | 读 `wallSeconds` / `slowest` |
77
99
 
78
100
  **每次跑完都要看那几行 timing**:如果某个套件突然比基线慢很多,先怀疑新增的固定等待,
@@ -56,6 +56,7 @@ const section = (t) => console.log('\n[' + t + ']')
56
56
  // ---------------------------------------------------------------
57
57
  let toolchainAvailable = true
58
58
  const leanRuns = []
59
+ const terminations = []
59
60
  const subprocess = {
60
61
  async resolveExecutable(cmd) {
61
62
  if (!toolchainAvailable) throw new Error('spawn lean ENOENT')
@@ -98,6 +99,29 @@ const subprocess = {
98
99
  leanRuns.push({ argv: spec.argv.slice(0, -1), file: last, cwd: spec.cwd, graceMs: spec.graceMs, stdio: spec.stdio })
99
100
  const stdout = bad ? '' : 'ok\n'
100
101
  const stderr = bad ? 'error: declaration uses sorry\n' : ''
102
+ if (/-- HANG/.test(text)) {
103
+ // A run that never finishes by itself. It settles ONLY when the plugin actively terminates it
104
+ // (contract §7: "对超时调用 handle.terminate()"), with a bounded 2.5 s fallback so a plugin
105
+ // that FORGETS to terminate FAILS the terminate/speed assertions instead of hanging this suite.
106
+ let settled = false
107
+ let finish
108
+ const done = new Promise((resolve) => { finish = resolve })
109
+ const fallback = REAL_SET_TIMEOUT(() => { if (!settled) { settled = true; finish({ exitCode: null, signal: 'SIGKILL' }) } }, 2500)
110
+ return {
111
+ done,
112
+ collected: {
113
+ stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) },
114
+ stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) },
115
+ },
116
+ terminate() {
117
+ if (settled) return
118
+ settled = true
119
+ REAL_CLEAR_TIMEOUT(fallback)
120
+ terminations.push({ file: last })
121
+ finish({ exitCode: null, signal: 'SIGTERM' })
122
+ },
123
+ }
124
+ }
101
125
  return {
102
126
  done: Promise.resolve({ exitCode: bad ? 1 : 0, signal: null }),
103
127
  collected: {
@@ -202,6 +226,21 @@ const REAL_SET_INTERVAL = globalThis.setInterval
202
226
  globalThis.setInterval = function (fn, ms, ...rest) {
203
227
  return REAL_SET_INTERVAL(fn, Math.min(Number(ms) || 0, 25), ...rest)
204
228
  }
229
+ // ── test speed / leak check: track OUTSTANDING setTimeout handles (test-only) ──────────────
230
+ // `leanRunFile` races `handle.done` against its own `cap`-ms timer; forgetting to clear that timer on
231
+ // the normal path leaves one pending multi-minute timer PER RUN — a leak no output assertion can see.
232
+ // The plugin resolves the global at call time, so wrapping it here covers it. The mock's own bounded
233
+ // fallback uses the REAL functions, so it never pollutes the count.
234
+ const REAL_SET_TIMEOUT = globalThis.setTimeout
235
+ const REAL_CLEAR_TIMEOUT = globalThis.clearTimeout
236
+ const liveTimers = new Set()
237
+ globalThis.setTimeout = function (fn, ms, ...rest) {
238
+ let h
239
+ h = REAL_SET_TIMEOUT(function (...a) { liveTimers.delete(h); return fn.apply(this, a) }, ms, ...rest)
240
+ liveTimers.add(h)
241
+ return h
242
+ }
243
+ globalThis.clearTimeout = function (h) { liveTimers.delete(h); return REAL_CLEAR_TIMEOUT(h) }
205
244
 
206
245
  const projRoot = (h) => join(h.WS, 'VibeMath', 'Projects', 'proj')
207
246
  const vibeRoot = (h) => join(h.WS, 'VibeMath')
@@ -281,6 +320,25 @@ section("1 'off' (default) is a true no-op")
281
320
  assert(st.params.leanTimeoutMs === 120000, 'leanTimeoutMs defaults to 120000 (got ' + st.params.leanTimeoutMs + ')')
282
321
  assert(!!h.toolRegs.find((t) => t.name === 'vibe_math_lean_run') && !!h.toolRegs.find((t) => t.name === 'vibe_math_lean_archive') && !!h.toolRegs.find((t) => t.name === 'vibe_math_lean_lib'),
283
322
  'the three Lean tools are registered in every mode (registration is static)')
323
+ // ★ The mode switch must be REACHABLE THROUGH THE TOOL SCHEMA (2.3.2 defect D1) ──────────────
324
+ // Every tool schema here is closed (`additionalProperties:false`), so a key the schema does not
325
+ // advertise is REJECTED by any schema-validating provider. v3 shipped 2.3.0/2.3.1 with all four Lean
326
+ // parameters missing from the set-params schema while every assertion in this file stayed green —
327
+ // because the suite calls the handler DIRECTLY and never inspects the registered schema. The feature
328
+ // could not be switched on at all through the tool interface.
329
+ {
330
+ const setSpec = h.toolRegs.find((t) => t.name === 'vibe_math_set_params')
331
+ assert(!!setSpec, "vibe_math_set_params is registered")
332
+ assert(setSpec.parameters && setSpec.parameters.type === 'object' && setSpec.parameters.additionalProperties === false,
333
+ '★ vibe_math_set_params publishes a CLOSED object schema (an unlisted key is rejected, so the schema IS the contract)')
334
+ for (const k of ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']) {
335
+ assert(Object.prototype.hasOwnProperty.call(setSpec.parameters.properties, k),
336
+ '★ the registered schema advertises ' + k + ' (every other surface documents it; a schema that omits it makes the switch unreachable)')
337
+ }
338
+ assert(JSON.stringify(setSpec.parameters.properties.formalVerify.enum) === JSON.stringify(['off', 'encourage', 'require']),
339
+ 'the schema narrows formalVerify to the three real modes (a typo must not be a fourth)')
340
+ }
341
+
284
342
  assert(existsSync(join(vibeRoot(h), 'Formal', 'Lib')) && existsSync(join(vibeRoot(h), 'Formal', 'Proved')), 'the GLOBAL Formal/Lib + Formal/Proved dirs are created outside the project')
285
343
  assert(existsSync(join(projRoot(h), 'Formal')) && existsSync(join(projRoot(h), 'Verified', 'Lean')), 'the project Formal/ and Verified/Lean/ dirs are created')
286
344
  await h.call('vibe_math_add_problem', { id: 'q1', description: 'off 模式无操作测试' })
@@ -470,6 +528,33 @@ section('4 lean_run executes through the subprocess service and reports honestly
470
528
  assert(noSubStatus.ok === true, 'the scheduler still answers status after that (nothing was thrown into the loop)')
471
529
  }
472
530
 
531
+ // ---------- 4b. the timeout must STOP the process, not just report it ----------
532
+ // Contract §7: "必须给 cwd…,并对超时调用 handle.terminate()". Reporting LEAN_TIMEOUT while the Lean
533
+ // process keeps running is a silent resource leak, and the framework's own contract says otherwise.
534
+ section('4b lean_run ACTIVELY terminates on timeout (contract §7)')
535
+ {
536
+ const h = await makeCase('timeout')
537
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
538
+ const proj = projRoot(h)
539
+ mkdirSync(join(proj, 'Formal'), { recursive: true })
540
+ writeFileSync(join(proj, 'Formal', 'hang.lean'), 'theorem t : 1 = 1 := rfl -- HANG\n', 'utf8')
541
+ writeFileSync(join(proj, 'Formal', 'fast.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
542
+ const before = terminations.length
543
+ const t0 = Date.now()
544
+ const run = await h.call('vibe_math_lean_run', { file: 'Formal/hang.lean', timeout_ms: 1000 })
545
+ const elapsed = Date.now() - t0
546
+ assert(run.ok === false && run.code === 'LEAN_TIMEOUT', '★ a run that outlives its timeout is reported as LEAN_TIMEOUT (got ' + run.code + ')')
547
+ assert(run.timedOut === true, 'the result carries timedOut=true')
548
+ assert(terminations.length === before + 1 && /hang\.lean/.test(terminations[terminations.length - 1].file),
549
+ '★★ the timeout ACTIVELY called handle.terminate() (graceMs alone does not stop a lingering Lean process)')
550
+ assert(elapsed < 2400, 'the call returned AT its timeout instead of waiting the process out (took ' + elapsed + 'ms)')
551
+ const fast = await h.call('vibe_math_lean_run', { file: 'Formal/fast.lean' })
552
+ assert(fast.ok === true && fast.timedOut === false, 'a normal run still reports success')
553
+ assert(terminations.length === before + 1, 'a normal run terminates nothing')
554
+ await sleep(60)
555
+ assert(liveTimers.size === 0, '★ no timeout timer is left pending after either run (it is cleared as soon as `done` wins)')
556
+ }
557
+
473
558
  // ---------- 5. archive: def / lemma / proof / blocked ----------
474
559
  section('5 lean_archive writes the contract paths and indexes')
475
560
  {
@@ -494,6 +579,13 @@ section('5 lean_archive writes the contract paths and indexes')
494
579
  writeFileSync(join(proj, 'Formal', 'src.lean'), 'def copied := 3\n', 'utf8')
495
580
  const defFrom = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'copied', from: 'Formal/src.lean' })
496
581
  assert(defFrom.ok === true && existsSync(join(libPath, 'copied.lean')), 'kind=def can archive from an existing .lean file')
582
+ // The tool RESULT is agent-facing text too: a definition whose run just failed must not be advertised
583
+ // as "directly importable" (contract §6 hard req. 3 / 实现方案 §9.4 — a red file must not be presented
584
+ // as usable, whatever the framework decides to do with the file itself).
585
+ const defRed = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'polluted', content: 'def polluted := 1 -- FAIL\n' })
586
+ assert(defRed.ok === true && !!defRed.run && defRed.run.ok === false, 'a definition that does not compile is reported as a red run')
587
+ assert(!/可直接 import 复用/.test(defRed.note || ''), '★ a red definition must NOT be advertised as directly reusable')
588
+ assert(/运行未通过/.test(defRed.note || ''), '…and the note says what to do instead')
497
589
  const fromOutside = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'escape', from: '../../../../etc/passwd' })
498
590
  assert(fromOutside.ok === false && fromOutside.code === 'V2_INVALID_ARGUMENT', 'from=<path outside the VibeMath root> is refused')
499
591
  const noName = await h.call('vibe_math_lean_archive', { kind: 'def', content: 'def x := 1\n' })
@@ -566,9 +658,15 @@ section('6 a passing proof flips the review subject to fidelity')
566
658
  assert(/formal:\{decision:'defect'/.test(vpText), '★ the reviewers are given the defect reply channel that withdraws the proof')
567
659
  assert(!/偏离 → 0/.test(vpText), '★ the "any deviation → 0" instruction is gone (it would fabricate a false conclusion)')
568
660
  assert(!/请先判断该对象的\*\*实现难度\*\*/.test(vpText), 'the "judge the difficulty first" wording is gone when a proof already exists')
661
+ // The withdrawal sentence must match the MODE's real strength (contract §4.1 pt.3 / §6.1; this case is
662
+ // 'encourage'): encouraging mode has NO gate, so the framework cannot hold the verdict — promising a
663
+ // hold there is a lie the voter would rely on (AUDIT-CHECKLIST §1.7, "提示词承诺的强度档位").
664
+ assert(/本档没有门禁/.test(vpText), '★ encourage fidelity text says THIS MODE HAS NO GATE (the framework cannot hold the verdict)')
665
+ assert(!/本次裁定\*\*不定论\*\*/.test(vpText), '★ encourage must NOT promise a hold the framework cannot enforce')
569
666
  const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
570
667
  assert(/你不需要重新检查推导/.test(debate), '★ the debate prompt for a Lean-passed object also asks for fidelity, not re-derivation')
571
668
  assert(/发现任何偏差,不要投 0/.test(debate), '★ and it carries the same no-zero rule in the debate round')
669
+ assert(/本档没有门禁/.test(debate), '★ the debate round carries the same mode-qualified withdrawal wording')
572
670
  await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'r-pBlk2', note: '涉及未形式化的分析学前置' })
573
671
  await h.call('vibe_math_add_proposition', { id: 'pBlk2', 概述: '已记录阻塞的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
574
672
  const vs2 = await verifyWithDebate(h, 'r-pBlk2', 0.5, 1)
@@ -812,6 +910,9 @@ section('12 a defect reply withdraws the proof, writes the TODO and defers the v
812
910
  assert(!!vs, 'verifiers were spawned for the Lean-passed object')
813
911
  const vp = (h.spawns.find((s) => s.label === 'verifier:r-pDefect:0') || {}).prompt || ''
814
912
  assert(/忠实性审查/.test(vp) && /不要投 0/.test(vp), 'the reviewers were told to audit fidelity and NOT to vote 0 on a defect')
913
+ // In REQUIRE mode the framework really does hold the verdict, so THAT promise is the correct one here.
914
+ assert(/本次裁定\*\*不定论\*\*/.test(vp), '★ require fidelity text does promise the hold (the gate really enforces it)')
915
+ assert(!/本档没有门禁/.test(vp), 'require must not claim it has no gate')
815
916
  const DEFECT = 'Lean 只证了 n>0 的情形,命题原文是 n≥0'
816
917
  if (vs) {
817
918
  // A fidelity defect: the voter ABSTAINS (0.3) and records it through the reply channel.
@@ -873,10 +974,149 @@ section('12 a defect reply withdraws the proof, writes the TODO and defers the v
873
974
  }
874
975
  }
875
976
 
876
- // ---------- 13. the prompt corpus (contract §10.10) ----------
977
+ // ---------- 13. the gate must read BOTH id spaces ----------
978
+ // v2 has two id spaces that name the same object: the verification id (rId, `r-pX`, `r-pX-s0`) that the
979
+ // verification prompts and the gate use, and the OBJECT id (`pX`) that the agent reads in the target
980
+ // block and normally archives under. `lean_archive` can only ADD a record for the id it was given, and
981
+ // `syncVerificationTarget` merely UPDATES aliases that already exist — so "archived under the object id,
982
+ // rId record does not exist yet" is a real first-time state. A gate that reads only its own side calls
983
+ // that object un-formalized (a false negative), and because the deferral itself materialises the rId
984
+ // record as `status:'none'`, the object can then NEVER conclude: every round re-defers and re-debates
985
+ // (AUDIT-CHECKLIST §3: "成对关系只做一半").
986
+ section('13 the require gate reads BOTH id spaces (archiving under the OBJECT id must not wedge)')
987
+ {
988
+ const h = await makeCase('gate-objid')
989
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
990
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
991
+ await startScheduler(h)
992
+ const proj = projRoot(h)
993
+ const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pObjId', content: 'theorem p_objid : 2 + 2 = 4 := by decide\n' })
994
+ assert(proof.ok === true && proof.passed === true, 'precondition: the object is Lean-passed under its OBJECT id')
995
+ const rec0 = formalStateOf(h)
996
+ assert(!!rec0.records && !!rec0.records.pObjId && rec0.records.pObjId.status === 'passed', 'the passed record lives under the object id')
997
+ assert(!(rec0.records || {})['r-pObjId'], 'precondition: no rId record exists yet (this is the state that used to fool the gate)')
998
+ await h.call('vibe_math_add_proposition', { id: 'pObjId', 概述: '用对象 id 归档后必须能定论', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
999
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pObjId'); return x.length >= 2 ? x : undefined }, 60, 250)
1000
+ assert(!!vs, 'verifiers were spawned for the object-id case')
1001
+ fireVerdicts(h, vs || [], 1)
1002
+ await tick(600)
1003
+ const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
1004
+ const p = props.find((x) => x.id === 'pObjId') || {}
1005
+ assert(p.布尔估计 === 1, "★ a Lean-passed object DOES conclude when the proof was archived under the object id (got " + p.布尔估计 + ')')
1006
+ const cards = JSON.parse(readIf(join(proj, 'Verified', '数论_Verified.json')) || '[]')
1007
+ assert(!!cards.find((c) => c.id === 'pObjId'), 'the Verified card was written')
1008
+ const todo = readIf(join(proj, 'Formal', 'TODO.md'))
1009
+ assert(!/pObjId/.test(todo), '★ the already-formalized object is NOT put on the formalization TODO')
1010
+ const todo2 = await h.call('vibe_math_status', {})
1011
+ assert(!(todo2.formal.todo || []).some((t) => String(t.id).indexOf('pObjId') !== -1), 'and the status TODO is empty for it too')
1012
+ }
1013
+
1014
+ // ---------- 14. withdrawing a proof must reach EVERY archived copy ----------
1015
+ // Contract §4.1 requires the withdrawal, not a best-effort delete: after a `defect` the archived proof
1016
+ // must be gone from `Verified/Lean/` — the one place everyone looks for "the proof of this object".
1017
+ // Two independent ways that fails silently:
1018
+ // (1) the proof was archived under a DIFFERENT id than the one the reply names (`Verified/Lean/<rId>.lean`
1019
+ // while the reply names the object id) — scanning only the two named ids misses it, and the record's
1020
+ // `proof` pointer is cleared anyway, so nothing ever points at the orphan again;
1021
+ // (2) the host cannot delete at all (no subprocess service) — the delete fails and the stale proof stays.
1022
+ section('14 defect withdrawal covers every id alias AND a host that cannot delete')
1023
+ {
1024
+ // (a) archived under the VERIFICATION id, defect named by the OBJECT id.
1025
+ const h = await makeCase('defect-alias')
1026
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1027
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1028
+ await startScheduler(h)
1029
+ const proj = projRoot(h)
1030
+ const pr = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'r-pAlias', content: 'theorem p_alias : 2 + 2 = 4 := by decide\n' })
1031
+ assert(pr.ok === true && pr.passed === true, 'precondition: the proof is archived under the VERIFICATION id')
1032
+ const aliasProof = join(proj, 'Verified', 'Lean', 'r-pAlias.lean')
1033
+ assert(existsSync(aliasProof), 'the archived proof sits at Verified/Lean/<rId>.lean')
1034
+ await h.call('vibe_math_add_proposition', { id: 'pAlias', 概述: '归档写在验证 id 上', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1035
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pAlias'); return x.length >= 2 ? x : undefined }, 60, 250)
1036
+ assert(!!vs, 'verifiers were spawned for the alias case')
1037
+ if (vs) {
1038
+ replyFrom(h, vs[0].childId, { Result: 0.3, Reason: '写宽了(弃权)', formal: { target: 'pAlias', decision: 'defect', note: '原文还有 n≥1 的假设' } })
1039
+ const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['pAlias'] && r['pAlias'].decision === 'defect') ? r : undefined }, 40, 150)
1040
+ assert(!!rec, '★ the defect named by the object id is absorbed')
1041
+ assert(!existsSync(aliasProof), '★★ the proof archived under the VERIFICATION id is withdrawn too (every id alias, not just the two named ones)')
1042
+ assert(!!rec && (!rec['r-pAlias'] || rec['r-pAlias'].proof === ''), 'the rId record no longer points at a proof')
1043
+ }
1044
+
1045
+ // (b) a host whose deletion cannot work: the archived path must not keep reading as the proof.
1046
+ const h2 = await makeCase('defect-nosub', { noSubprocess: true })
1047
+ await h2.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1048
+ const proj2 = projRoot(h2)
1049
+ mkdirSync(join(proj2, 'Verified', 'Lean'), { recursive: true })
1050
+ mkdirSync(join(proj2, 'Formal'), { recursive: true })
1051
+ const WORK = 'theorem p_stale : 2 + 2 = 4 := by decide\n'
1052
+ writeFileSync(join(proj2, 'Formal', 'pStale.lean'), WORK, 'utf8')
1053
+ writeFileSync(join(proj2, 'Verified', 'Lean', 'pStale.lean'), WORK, 'utf8')
1054
+ // The record is written directly (this host has no subprocess, so the LEAN tools cannot run at all):
1055
+ // what is under test is the WITHDRAWAL path, not the archiving path.
1056
+ writeFileSync(join(proj2, 'VibeMath_State', 'formal.json'), JSON.stringify({ records: { pStale: { status: 'passed', file: 'Formal/pStale.lean', proof: 'Verified/Lean/pStale.lean', decision: 'used', updatedAt: 1 } }, todo: [] }), 'utf8')
1057
+ await h2.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1058
+ await h2.call('vibe_math_add_proposition', { id: 'pStale', 概述: '宿主删不掉归档证明时的撤回', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1059
+ await startScheduler(h2)
1060
+ const st0 = await h2.call('vibe_math_status', {})
1061
+ assert(st0.formal.objects.some((o) => o.target === 'pStale' && o.status === 'passed'), 'precondition: the passed record with an archived proof survives resume on the no-subprocess host')
1062
+ const vs2 = await waitFor(() => { const x = verifiersOf(h2, 'r-pStale'); return x.length >= 2 ? x : undefined }, 60, 250)
1063
+ assert(!!vs2, 'verifiers were spawned on the no-subprocess host')
1064
+ if (vs2) {
1065
+ replyFrom(h2, vs2[0].childId, { Result: 0.3, Reason: '不忠实(弃权)', formal: { target: 'pStale', decision: 'defect', note: 'Lean 少了 n≥1' } })
1066
+ const rec2 = await waitFor(() => { const r = (formalStateOf(h2).records || {}); return (r['pStale'] && r['pStale'].decision === 'defect') ? r : undefined }, 40, 150)
1067
+ assert(!!rec2, '★ the defect is absorbed even though this host cannot run a delete')
1068
+ assert(!!rec2 && rec2['pStale'].status === 'attempted' && rec2['pStale'].proof === '', 'the record is downgraded and its proof pointer cleared')
1069
+ const stalePath = join(proj2, 'Verified', 'Lean', 'pStale.lean')
1070
+ assert(existsSync(stalePath), 'the archived file could NOT be deleted here (no subprocess) — so the fallback has to handle it')
1071
+ const body = readIf(stalePath)
1072
+ assert(!/theorem p_stale/.test(body), '★★ the original proof text is GONE from the archived path (it can no longer be read as the proof)')
1073
+ assert(/已撤回/.test(body), '★★ …and that path carries an explicit withdrawal notice instead')
1074
+ assert(/Formal\/pStale\.lean/.test(body), 'the notice points at the working file that is kept')
1075
+ const acts = (await h2.call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')
1076
+ assert(/覆写/.test(acts), '★ the announcement says WHAT actually happened (overwritten, not deleted)')
1077
+ assert(/撤回「已通过」状态/.test(acts), 'the announcement still says the passed status was withdrawn')
1078
+ }
1079
+ }
1080
+
1081
+ // ---------- 15. the「判断命题」transfer is a verdict on the SOURCE proposition ----------
1082
+ // When a solver's solution for a "判断下述命题是否成立:X" problem is verified, the result is TRANSFERRED
1083
+ // onto the source proposition: `布尔估计 = v`, `已验证 = true`, a probability-1 proof/refutation entry,
1084
+ // and `优先级 = 'never'` for a boolean v. That IS a boolean verdict on X, so require mode must gate it —
1085
+ // otherwise a proposition nobody formalized is silently concluded (and permanently de-scheduled).
1086
+ section('15 the require gate also covers the judge-problem transfer')
1087
+ {
1088
+ const h = await makeCase('judge-gate')
1089
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1090
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1091
+ const proj = projRoot(h)
1092
+ await h.call('vibe_math_add_proposition', { id: 'pJudgeSrc', 概述: '被判断的源命题(未形式化)', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1093
+ await h.call('vibe_math_add_problem', { id: 'qJudge', description: '判断下述命题是否成立:pJudgeSrc', priority: 1 })
1094
+ const qsFile = join(proj, 'qs', 'qs.json')
1095
+ const qs0 = JSON.parse(readIf(qsFile) || '[]')
1096
+ const qj = qs0.find((q) => q.id === 'qJudge')
1097
+ qj.判断命题 = 'pJudgeSrc'
1098
+ // A solver-produced solution carries no 来源列表 — that is exactly the branch that transfers to the source.
1099
+ qj.解法列表 = [{ 完整解法: '该命题不成立的论证', 正确概率: 0.8, 已验: false }]
1100
+ writeFileSync(qsFile, JSON.stringify(qs0, null, 2), 'utf8')
1101
+ await startScheduler(h)
1102
+ const sp = await waitFor(() => { const x = verifiersOf(h, 'r-qJudge-s0'); return x.length >= 2 ? x : undefined }, 60, 250)
1103
+ assert(!!sp, 'verifiers were spawned for the judge problem solution')
1104
+ fireVerdicts(h, sp || [], 0)
1105
+ await tick(600)
1106
+ const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
1107
+ const ap = props.find((x) => x.id === 'pJudgeSrc') || {}
1108
+ assert(ap.布尔估计 === 0.5, "★★ the source proposition's 布尔估计 is UNCHANGED (require mode must not conclude 假 through the transfer; got " + ap.布尔估计 + ')')
1109
+ assert(!(ap.证伪列表 || []).some((x) => x.正确概率 === 1), 'no probability-1 refutation was written onto the source proposition')
1110
+ assert(ap.优先级 !== 'never', 'the source proposition was not pinned to never')
1111
+ assert(ap.已验证 !== true, '★ it stays re-verifiable (the ungated path also marked it 已验证, removing it from every future candidate set)')
1112
+ assert(/pJudgeSrc/.test(readIf(join(proj, 'Formal', 'TODO.md'))), '★ the deferred source proposition is on the formalization TODO')
1113
+ assert(!existsSync(join(proj, 'Verified', '数论_Verified.json')), 'no Verified card for the source proposition')
1114
+ }
1115
+
1116
+ // ---------- 16. the prompt corpus (contract §10.10) ----------
877
1117
  // A HUMAN must be able to re-read every prompt the framework emitted, not just the assertions
878
1118
  // about them. Paths are normalised so the dump is deterministic, diffable and machine-free.
879
- section('13 the captured prompt corpus is written for human review')
1119
+ section('16 the captured prompt corpus is written for human review')
880
1120
  {
881
1121
  // Freeze the scheduler in every case FIRST: a still-running tick loop could emit one more
882
1122
  // prompt between two runs and make the corpus non-deterministic.