dsh-vibe-math 2.7.2 → 2.8.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.
Files changed (153) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +682 -681
  3. package/README.md +683 -682
  4. package/cordis.patch.yml +112 -16
  5. package/docs/AUDIT-CHECKLIST.md +755 -518
  6. package/docs/COMPAT-AUDIT-ROUND2.md +354 -328
  7. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  8. package/docs/audit-methodology.md +93 -0
  9. package/docs/final-paper.md +172 -172
  10. package/docs/formal-verification.md +613 -588
  11. package/docs/generate_framework_diagram_v2.py +114 -114
  12. package/docs/generate_framework_diagram_v3.py +127 -127
  13. package/docs/math-computation.md +195 -187
  14. package/docs/parameter-schema.md +37 -0
  15. package/docs/release-notes/README.en.md +2 -0
  16. package/docs/release-notes/README.md +2 -0
  17. package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
  18. package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
  19. package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
  20. package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
  21. package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
  22. package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
  23. package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
  24. package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
  25. package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
  26. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
  27. package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
  28. package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
  29. package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
  30. package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
  31. package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
  32. package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
  33. package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
  34. package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
  35. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +2 -2
  36. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +3 -3
  37. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  38. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  39. package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
  40. package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
  41. package/docs/release-notes/TEMPLATE.md +6 -0
  42. package/docs/release-playbook.md +103 -0
  43. package/docs/slv-playbook.md +117 -0
  44. package/docs/status-report-fields.md +167 -0
  45. package/docs/test-timing.md +158 -154
  46. package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
  47. package/installer.js +793 -783
  48. package/package.json +260 -203
  49. package/prompt-corpus-persona/persona-corpus.json +9 -9
  50. package/prompt-corpus-persona/persona-corpus.md +225 -33
  51. package/prompt-corpus-v2/formal-verify-v2.json +295 -115
  52. package/prompt-corpus-v2/formal-verify-v2.md +2290 -282
  53. package/prompt-corpus-v3/formal-verify-v3.json +112 -112
  54. package/prompt-corpus-v3/formal-verify-v3.md +227 -143
  55. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  56. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  57. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  58. package/prompt-corpus-v5/prompt-corpus-v5.md +573 -207
  59. package/scripts/clean-temp.mjs +234 -0
  60. package/scripts/update-doc-counts.mjs +71 -0
  61. package/tests/audit-artifact-docs.mjs +72 -0
  62. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  63. package/tests/audit-engine-faces.mjs +298 -0
  64. package/tests/audit-formal-sensitivity.mjs +345 -342
  65. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  66. package/tests/audit-installer-compat.test.mjs +256 -208
  67. package/tests/audit-installer-policy.test.mjs +41 -8
  68. package/tests/audit-math-computation-contract.mjs +64 -8
  69. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  70. package/tests/audit-math-computation-parity.mjs +164 -3
  71. package/tests/audit-math-computation-sensitivity.mjs +5 -3
  72. package/tests/audit-package-membership.mjs +57 -0
  73. package/tests/audit-package-membership.mutants.mjs +28 -0
  74. package/tests/audit-participant-set-parity.mjs +160 -0
  75. package/tests/audit-path-discipline.mjs +83 -0
  76. package/tests/audit-path-discipline.mutants.mjs +51 -0
  77. package/tests/audit-persona-sensitivity.mjs +323 -309
  78. package/tests/audit-persona-surface.mutants.mjs +69 -0
  79. package/tests/audit-persona-surface.test.mjs +431 -390
  80. package/tests/audit-prompt-duplication.mjs +143 -0
  81. package/tests/audit-prompt-duplication.mutants.mjs +51 -0
  82. package/tests/audit-prompt-invariants.mjs +589 -508
  83. package/tests/audit-readme-bilingual.test.mjs +152 -0
  84. package/tests/audit-readme-counts.mjs +95 -0
  85. package/tests/audit-readme-counts.mutants.mjs +52 -0
  86. package/tests/audit-registration.mjs +78 -0
  87. package/tests/audit-spec-traceability.mjs +193 -193
  88. package/tests/audit-status-report-fields.mjs +172 -0
  89. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  90. package/tests/audit-v3-registration-parity.mjs +156 -0
  91. package/tests/audit-v5-integrity.mjs +795 -508
  92. package/tests/audit-v5-lean-abstention.mjs +392 -0
  93. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  94. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  95. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  96. package/tests/audit-v5-sensitivity.mjs +433 -427
  97. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  98. package/tests/e2e-v5-round2.test.mjs +2453 -1981
  99. package/tests/formal-verify-v2.test.mjs +1778 -1724
  100. package/tests/formal-verify-v3.mutants.mjs +117 -0
  101. package/tests/formal-verify-v3.test.mjs +1724 -1671
  102. package/tests/formal-verify-v4.mutants.mjs +152 -0
  103. package/tests/formal-verify-v4.test.mjs +2114 -1574
  104. package/tests/formal-verify-v5.test.mjs +790 -724
  105. package/tests/helpers/doc-counts-format.mjs +41 -0
  106. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  107. package/tests/math-computation-a1.mutants.mjs +72 -0
  108. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  109. package/tests/math-computation-discovery.mutants.mjs +76 -0
  110. package/tests/math-computation-shared.test.mjs +392 -6
  111. package/tests/math-computation-v2.test.mjs +779 -0
  112. package/tests/math-computation-v3.test.mjs +771 -0
  113. package/tests/math-computation-v4.mutants.mjs +133 -0
  114. package/tests/math-computation-v4.test.mjs +739 -0
  115. package/tests/prompt-v5-integrity.test.mjs +1291 -1280
  116. package/tests/run-tests.mjs +393 -202
  117. package/tests/run-tests.mutants.mjs +70 -0
  118. package/tests/selfdrive-v5.mjs +560 -508
  119. package/tests/selfdrive-v5.mutants.mjs +132 -0
  120. package/tests/temp-hygiene.mutants.mjs +80 -0
  121. package/tests/v2-fix-probes.mutants.mjs +104 -0
  122. package/tests/v2-fix-probes.test.mjs +1040 -0
  123. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  124. package/tests/v2v3-interrupt.mutants.mjs +111 -0
  125. package/tests/v3-fix-probes.mutants.mjs +99 -0
  126. package/tests/v3-fix-probes.test.mjs +892 -0
  127. package/tests/v4-final-paper.mutants.mjs +108 -0
  128. package/tests/v5-institute-fixes.mutants.mjs +218 -0
  129. package/vibe-math-v2/agent.cordis.yml +378 -322
  130. package/vibe-math-v2/math-computation.js +144 -53
  131. package/vibe-math-v2/math-engines.js +12 -12
  132. package/vibe-math-v2/preset.yml +2 -2
  133. package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
  134. package/vibe-math-v2/vibe-math-v2.js +5097 -4755
  135. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +491 -487
  136. package/vibe-math-v3/agent.cordis.yml +422 -356
  137. package/vibe-math-v3/math-computation.js +144 -53
  138. package/vibe-math-v3/math-engines.js +12 -12
  139. package/vibe-math-v3/preset.yml +2 -2
  140. package/vibe-math-v3/vibe-math-v3.js +6235 -5873
  141. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
  142. package/vibe-math-v4/agent.cordis.yml +438 -402
  143. package/vibe-math-v4/math-computation.js +144 -53
  144. package/vibe-math-v4/math-engines.js +12 -12
  145. package/vibe-math-v4/preset.yml +2 -2
  146. package/vibe-math-v4/vibe-math-v4.js +4490 -4269
  147. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1013
  148. package/vibe-math-v5/agent.cordis.yml +46 -12
  149. package/vibe-math-v5/math-computation.js +144 -53
  150. package/vibe-math-v5/math-engines.js +12 -12
  151. package/vibe-math-v5/vibe-math-v5.js +1266 -229
  152. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1660
  153. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -1,1641 +1,1678 @@
1
- // ============================================================
2
- // V2 LEAN FORMAL VERIFICATION SUITE (docs/formal-verification.md)
3
- //
4
- // Asserts the whole contract of the v2 `formalVerify` knob:
5
- // · 'off' is a TRUE no-op: no Lean text in ANY prompt it builds, no formal record,
6
- // no gate — while the three tools stay registered (registration is static)
7
- // · 'encourage' injects the Lean section into BOTH the review and the debate prompt and the
8
- // "顺手形式化" line into the solver/explorer prompts, and — the actual point of
9
- // the feature — turns the review into a FIDELITY check once a Lean run passed
10
- // · 'require' withholds a true/false verdict (未定论 + Formal/TODO.md + announcement) until
11
- // the object is Lean-passed or carries an explicit, reasoned blocker; then the
12
- // same verdict DOES write the card, and the card records the formal status
13
- // · the three tools (run / archive / lib) write the right things to the right paths
14
- // · the REPLY CHANNEL is real, not dead code (contract §4 / §6.3 / §10.8): a verifier's
15
- // `formal:{decision:'blocked'|'defect', note}` reply is actually absorbed into the durable
16
- // record for BOTH id spaces, a missing note is refused, and `defect` (a fidelity defect,
17
- // i.e. the Lean code does not say what the proposition says) downgrades the proof, deletes
18
- // the archived file, writes the TODO and defers the verdict — it is NEVER recorded as
19
- // "the proposition is false" (contract §4.1)
20
- //
21
- // The Lean toolchain is mocked through the subprocess SERVICE (a fake Lean: exit 0 unless the
22
- // file still contains `sorry` or the marker `-- FAIL`), so these tests exercise the REAL code
23
- // path (resolveExecutable → spawn → collected stdout → exit code) without Lean installed.
24
- //
25
- // The plugin path honours V2_PLUGIN so a sensitivity probe can point this suite at a mutated
26
- // copy of the plugin (a suite that ignored the override would make every probe vacuous —
27
- // AUDIT-CHECKLIST §2.5).
28
- //
29
- // Run: node tests/formal-verify-v2.test.mjs
30
- // ============================================================
31
- import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, statSync, mkdirSync } from 'node:fs'
32
- import { tmpdir } from 'node:os'
33
- import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
34
- import { fileURLToPath, pathToFileURL } from 'node:url'
35
-
36
- // V2_PLUGIN may be a REPO-RELATIVE path or an ABSOLUTE one (a mutant copy outside the repo, e.g. a
37
- // temp dir). The previous `new URL('file:///' + path)` form only worked for drive-qualified Windows
38
- // paths and made out-of-repo variants impossible; this is the same isAbsolute-aware resolution the
39
- // `math-computation-v*` suites use. Default behaviour (env var unset) is unchanged.
40
- const PLUGIN = process.env.V2_PLUGIN
41
- ? pathToFileURL(isAbsolute(process.env.V2_PLUGIN) ? process.env.V2_PLUGIN : join(dirname(fileURLToPath(import.meta.url)), '..', process.env.V2_PLUGIN))
42
- : new URL('../vibe-math-v2/vibe-math-v2.js', import.meta.url)
43
- // 纯函数测试面(__testHelpers):参数归一化/搜索路径计划/哈希/指纹都从这里守卫。
44
- const MOD = await import(PLUGIN.href)
45
- const HERE = dirname(fileURLToPath(import.meta.url))
46
- // Human-reviewable corpus (contract §10.10). A sensitivity probe runs THIS suite against a
47
- // MUTATED plugin copy: writing the repository corpus from such a run would replace the
48
- // reviewed text with mutated text, so a mutated run goes to a scratch directory instead
49
- // (and V2_CORPUS_DIR overrides both, exactly like the v3 suite's V3_CORPUS_DIR).
50
- const CORPUS_DIR = process.env.V2_CORPUS_DIR
51
- ? pathResolve(process.env.V2_CORPUS_DIR)
52
- : (process.env.V2_PLUGIN ? join(tmpdir(), 'vibe-v2-prompt-corpus') : join(HERE, '..', 'prompt-corpus-v2'))
53
-
54
- let passed = 0, failed = 0
55
- const failures = []
56
- const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
57
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
58
- const section = (t) => console.log('\n[' + t + ']')
59
-
60
- // ---------------------------------------------------------------
61
- // the fake Lean toolchain (mocked at the SERVICE boundary)
62
- // ---------------------------------------------------------------
63
- let toolchainAvailable = true
64
- const leanRuns = []
65
- const terminations = []
66
- const subprocess = {
67
- async resolveExecutable(cmd) {
68
- if (!toolchainAvailable) throw new Error('spawn lean ENOENT')
69
- if (String(cmd) !== 'lean') throw new Error('unknown executable ' + cmd)
70
- return 'lean'
71
- },
72
- spawn(spec) {
73
- const last = spec.argv[spec.argv.length - 1]
74
- const script = String(last || '')
75
- // v2 creates its directory tree through this SAME subprocess service
76
- // (`powershell … New-Item -Force -ItemType Directory -Path 'a','b'`). Without honouring
77
- // it, the Formal/ and Verified/Lean/ assertions would be vacuous: they would fail for the
78
- // wrong reason (a mocked no-op) instead of a real defect.
79
- if (/New-Item/.test(script)) {
80
- const m = /-Path\s+(.+?)\s*(\||$)/.exec(script)
81
- if (m) m[1].split(',').forEach((p) => { const q = p.trim().replace(/^'|'$/g, '').replace(/''/g, "'"); if (q) mkdirSync(q, { recursive: true }) })
82
- return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
83
- }
84
- if (/mkdir -p /.test(script)) {
85
- const m = /mkdir -p (.+)$/.exec(script)
86
- if (m) m[1].split(/\s+/).forEach((p) => { const q = p.trim().replace(/^'|'$/g, ''); if (q) mkdirSync(q, { recursive: true }) })
87
- return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
88
- }
89
- // v2 ALSO deletes files through this same service (`powershell … Remove-Item -LiteralPath 'x'`,
90
- // POSIX `rm -f 'x'`). Without honouring it, the `defect` assertion "the archived proof is
91
- // gone" would pass vacuously (nothing was ever deleted) instead of testing the real code path.
92
- if (/Remove-Item/.test(script)) {
93
- const m = /-LiteralPath\s+'((?:[^']|'')*)'/.exec(script)
94
- if (m) { try { rmSync(m[1].replace(/''/g, "'"), { force: true }) } catch (e) { /* best effort */ } }
95
- return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
96
- }
97
- if (/^\s*rm -f /.test(script)) {
98
- const re = /'((?:[^']|'\\'')*)'/g
99
- let m
100
- while ((m = re.exec(script)) !== null) { try { rmSync(m[1].replace(/'\\''/g, "'"), { force: true }) } catch (e) { /* best effort */ } }
101
- return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
102
- }
103
- const text = existsSync(last) ? readFileSync(last, 'utf8') : ''
104
- const bad = /sorry|-- FAIL/.test(text)
105
- leanRuns.push({ argv: spec.argv.slice(0, -1), file: last, cwd: spec.cwd, graceMs: spec.graceMs, stdio: spec.stdio })
106
- const stdout = bad ? '' : 'ok\n'
107
- const stderr = bad ? 'error: declaration uses sorry\n' : ''
108
- if (/-- HANG/.test(text)) {
109
- // A run that never finishes by itself. It settles ONLY when the plugin actively terminates it
110
- // (contract §7: "对超时调用 handle.terminate()"), with a bounded 2.5 s fallback so a plugin
111
- // that FORGETS to terminate FAILS the terminate/speed assertions instead of hanging this suite.
112
- let settled = false
113
- let finish
114
- const done = new Promise((resolve) => { finish = resolve })
115
- const fallback = REAL_SET_TIMEOUT(() => { if (!settled) { settled = true; finish({ exitCode: null, signal: 'SIGKILL' }) } }, 2500)
116
- return {
117
- done,
118
- collected: {
119
- stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) },
120
- stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) },
121
- },
122
- terminate() {
123
- if (settled) return
124
- settled = true
125
- REAL_CLEAR_TIMEOUT(fallback)
126
- terminations.push({ file: last })
127
- finish({ exitCode: null, signal: 'SIGTERM' })
128
- },
129
- }
130
- }
131
- return {
132
- done: Promise.resolve({ exitCode: bad ? 1 : 0, signal: null }),
133
- collected: {
134
- stdout: { readFrom: () => ({ text: stdout, nextOffset: stdout.length, lossy: false }) },
135
- stderr: { readFrom: () => ({ text: stderr, nextOffset: stderr.length, lossy: false }) },
136
- },
137
- terminate() {},
138
- }
139
- },
140
- }
141
-
142
- // ---------------------------------------------------------------
143
- // mock host (v2 standing-mount shape: one plugin instance, many sessions)
144
- // ---------------------------------------------------------------
145
- function makeHost(WS, opts = {}) {
146
- const listeners = {}
147
- const toolRegs = []
148
- const cmdRegs = []
149
- const spawns = []
150
- const followups = []
151
- const interrupts = []
152
- const disposers = []
153
- const ctx = {
154
- get(name) {
155
- if (name === 'subprocess') return opts.noSubprocess ? undefined : subprocess
156
- if (name === 'sandboxPolicy') return { workspaceRoot: WS, resolve: () => ({ workspaceRoot: WS }) }
157
- return undefined
158
- },
159
- on(event, fn) { (listeners[event] = listeners[event] || []).push(fn) },
160
- effect(fn) { const d = fn(); const off = () => { if (typeof d === 'function') d() }; disposers.push(off); return off },
161
- logger: { info() {}, warn() {}, error() {} },
162
- tools: { register(spec) { toolRegs.push(spec); return () => {} } },
163
- commands: { register(spec) { cmdRegs.push(spec); return () => {} } },
164
- timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
165
- subagents: {
166
- list() { return ['spawn'] },
167
- async startContinuable({ label, request }) {
168
- const childId = 'child-' + (spawns.length + 1) + '-' + Math.random().toString(36).slice(2, 6)
169
- spawns.push({ label, childId, rootId: (request && request.parent && request.parent.id) || 'root', prompt: request && request.prompt && request.prompt[0] && request.prompt[0].text })
170
- return { childId }
171
- },
172
- async followup(parent, childId, blocks) { followups.push({ childId, prompt: (blocks && blocks[0] && blocks[0].text) || '' }) },
173
- async sendMessage(parent, childId, blocks) { followups.push({ childId, prompt: (blocks && blocks[0] && blocks[0].text) || '' }) },
174
- interrupt(childId) { interrupts.push(childId) },
175
- },
176
- agents: { roots() { return [] }, get() { return undefined } },
177
- fs: {
178
- async resolve(rel, o) {
179
- const raw = String(rel)
180
- const p = isAbsolute(raw) ? raw : join((o && o.cwd) || WS, ...raw.split('/'))
181
- return p.replace(/\\/g, '/')
182
- },
183
- async stat(t) { if (!existsSync(t)) return undefined; return { type: statSync(t).isDirectory() ? 'directory' : 'file' } },
184
- async readText(t) { return readFileSync(t, 'utf8') },
185
- async writeText(t, c) { mkdirSync(dirname(t), { recursive: true }); writeFileSync(t, c, 'utf8') },
186
- async listDir(t) { if (!existsSync(t)) return []; return readdirSync(t, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
187
- },
188
- }
189
- const fireEnd = (info) => { for (const h of (listeners['subagent/end'] || [])) h(info) }
190
- return { ctx, toolRegs, cmdRegs, spawns, followups, interrupts, disposers, unmount: () => { for (const d of disposers) { try { d() } catch (e) { /* ignore */ } } }, fireEnd, WS }
191
- }
192
-
193
- function makeRoot(id, WS) {
194
- // `pushed` records the framework's OWN messages to the main agent (`rootAgent.followup`), i.e. the
195
- // reportMode=push progress report. It used to be a no-op, which is why that channel had no corpus
196
- // entry at all (AUDIT §2.4 "框架→人 的反馈与推送类提示").
197
- const pushed = []
198
- return { id, options: { provider: 'mock', model: 'mock' }, session: { id, header: { cwd: WS, parentSession: undefined } }, pushed, followup(m) { const c = (m && m.content) || []; pushed.push(c.map((b) => (b && b.text) || '').join('')) } }
199
- }
200
-
201
- const hosts = []
202
- /**
203
- * Build an INDEPENDENT plugin instance + workspace for one test case.
204
- * AUDIT-CHECKLIST §2.3: cases must not share a session root/workspace, or a leftover
205
- * heartbeat/verification from one case pollutes the next.
206
- */
207
- async function makeCase(label, opts = {}) {
208
- const WS = mkdtempSync(join(tmpdir(), 'vibe-v2-lean-' + label + '-'))
209
- const h = makeHost(WS, opts)
210
- const mod = await import(PLUGIN.href + '?t=' + Date.now() + '-' + Math.random().toString(36).slice(2, 8))
211
- ;(mod.default || mod).apply(h.ctx)
212
- const root = makeRoot('sess-' + label, WS)
213
- const call = async (name, args, agent) => {
214
- const spec = h.toolRegs.find((s) => s.name === name)
215
- if (!spec) throw new Error('no tool ' + name)
216
- return JSON.parse(await spec.execute(args || {}, { agent: agent || root }))
217
- }
218
- await call('vibe_math_new_project', { name: 'proj' })
219
- // 200ms is the sanitizer floor; verifierCount=2 (the sanitizer floor too) keeps each
220
- // verification exactly two children, so a round settles predictably.
221
- await call('vibe_math_set_params', { tickIntervalMs: 200, verifierCount: 2 })
222
- // leanAsync defaults to TRUE (background queue). This suite's Lean sections guard the
223
- // **synchronous** path verbatim, so every case pins leanAsync=false here; the async state
224
- // machine (default档) is guarded by its own section, which flips it back on explicitly.
225
- await call('vibe_math_set_params', { leanAsync: false })
226
- const c = Object.assign(h, { WS, root, call, label })
227
- hosts.push(c)
228
- return c
229
- }
230
- // The scheduler only picks objects up while it is RUNNING (scheduleTick early-returns).
231
- async function startScheduler(h) { await h.call('vibe_math_start', {}) }
232
-
233
- // ── test speed: fast-forward the SCHEDULER POLL (test-only, no production impact) ─────────
234
- // The plugin registers its scheduler poll as `setInterval(..., 1000)` at apply() time, so every
235
- // suite-side `tick()` had to wait a full wall-clock second. With ~9 verification rounds × ~17
236
- // ticks that alone accounted for ~99% of this suite's 186 s (its v3/v4/v5 siblings take seconds).
237
- // Patching ONLY setInterval (the suite's own `sleep` uses setTimeout) makes a poll cost ~25 ms,
238
- // while the plugin's due-ness logic still uses the real 200 ms `tickIntervalMs` floor — no
239
- // scheduler behaviour depends on the poll period, and `tickTheScheduler` below stays above it.
240
- const REAL_SET_INTERVAL = globalThis.setInterval
241
- globalThis.setInterval = function (fn, ms, ...rest) {
242
- return REAL_SET_INTERVAL(fn, Math.min(Number(ms) || 0, 25), ...rest)
243
- }
244
- // ── test speed / leak check: track OUTSTANDING setTimeout handles (test-only) ──────────────
245
- // `leanRunFile` races `handle.done` against its own `cap`-ms timer; forgetting to clear that timer on
246
- // the normal path leaves one pending multi-minute timer PER RUN — a leak no output assertion can see.
247
- // The plugin resolves the global at call time, so wrapping it here covers it. The mock's own bounded
248
- // fallback uses the REAL functions, so it never pollutes the count.
249
- const REAL_SET_TIMEOUT = globalThis.setTimeout
250
- const REAL_CLEAR_TIMEOUT = globalThis.clearTimeout
251
- const liveTimers = new Set()
252
- globalThis.setTimeout = function (fn, ms, ...rest) {
253
- let h
254
- h = REAL_SET_TIMEOUT(function (...a) { liveTimers.delete(h); return fn.apply(this, a) }, ms, ...rest)
255
- liveTimers.add(h)
256
- return h
257
- }
258
- globalThis.clearTimeout = function (h) { liveTimers.delete(h); return REAL_CLEAR_TIMEOUT(h) }
259
-
260
- const projRoot = (h) => join(h.WS, 'VibeMath', 'Projects', 'proj')
261
- const vibeRoot = (h) => join(h.WS, 'VibeMath')
262
- const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '')
263
- const formalStateOf = (h) => JSON.parse(readIf(join(projRoot(h), 'VibeMath_State', 'formal.json')) || '{}')
264
- // One scheduler pass. With the poll fast-forwarded above, the poll fires every ~25 ms and a tick
265
- // runs whenever the plugin's own 200 ms `tickIntervalMs` floor has elapsed, so 260 ms is one pass.
266
- const tick = (ms = 260) => sleep(ms)
267
- async function waitFor(pred, tries = 80, ms = 60) {
268
- for (let i = 0; i < tries; i++) { const v = pred(); if (v) return v; await sleep(ms) }
269
- return undefined
270
- }
271
- const verifiersOf = (h, rKind, exclude) => h.spawns.filter((s) => s.label.startsWith('verifier:' + rKind) && !(exclude || []).some((o) => o.childId === s.childId))
272
- /** A reply exactly as an agent would emit it (a fenced JSON block) — the framework's real input. */
273
- const fence = (obj) => '```json\n' + JSON.stringify(obj) + '\n```'
274
- /** Feed ONE agent reply (a fresh turn's end) to the framework through the real dispatch path. */
275
- const replyFrom = (h, childId, obj) => h.fireEnd({
276
- id: childId, runId: 'r-' + childId, provider: 'spawn', local: true, stopReason: 'completed',
277
- lastAssistantMessage: [{ type: 'text', text: fence(obj) }],
278
- })
279
- const fireVerdicts = (h, kids, vale) => {
280
- for (let i = 0; i < kids.length; i++) {
281
- h.fireEnd({ id: kids[i].childId, runId: 'v' + i, provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n' + JSON.stringify({ Result: vale, Reason: 'review ' + i }) + '\n```' }] })
282
- }
283
- }
284
- /**
285
- * Verify one object and DRIVE IT TO A SETTLEMENT.
286
- *
287
- * Important: v2's `processVerify` starts at most ONE verification and skips every candidate
288
- * while a task is live, so leaving an object mid-debate would silently block the NEXT case's
289
- * verification. The first `firstRounds` rounds are answered with `vale` (a non-unanimous value
290
- * forces a real debate round, which is exactly what the debate-prompt assertions need), and any
291
- * later round is answered with `settle` so the object actually closes.
292
- */
293
- async function verifyWithDebate(h, rKind, vale, firstRounds = 1, settle = 1) {
294
- // v2 re-wakes the SAME verifier children for every debate round (it does not spawn new
295
- // ones), so a new round is detected by counting the followups written to those children.
296
- const kids = new Set()
297
- // Early exit: a settled object stops producing followups. Without this the loop always spent
298
- // its FULL 16 passes (~21 s at the old 1.3 s/pass), which is what made this suite take 3
299
- // minutes: the debate is over, but the helper kept ticking at nothing. Three consecutive
300
- // passes with no new followup = the framework has nothing left to ask.
301
- let lastN = -1
302
- let quiet = 0
303
- for (let i = 0; i < 16; i++) {
304
- // The scheduler may need a whole pass before it notices the object and another before it
305
- // fills the verifier quota, so each wait must span several timer ticks.
306
- const cand = await waitFor(() => {
307
- const x = verifiersOf(h, rKind)
308
- return x.length >= 2 ? x : undefined
309
- }, 40, 250)
310
- if (!cand) break
311
- cand.forEach((s) => kids.add(s.childId))
312
- const rounds = 1 + h.followups.filter((f) => kids.has(f.childId)).length
313
- // `fireEnd` is a NO-OP for a child with no in-flight turn, so answering the whole current
314
- // verifier set each pass is safe; the extra answers land before the next round's wakes.
315
- fireVerdicts(h, cand, rounds <= firstRounds ? vale : settle)
316
- await sleep(200)
317
- await tick()
318
- if (h.followups.length === lastN) { if (++quiet >= 3) break } else { quiet = 0; lastN = h.followups.length }
319
- }
320
- const rounds = 1 + h.followups.filter((f) => kids.has(f.childId)).length
321
- return { first: h.spawns.filter((s) => s.label.startsWith('verifier:' + rKind)), rounds: kids.size ? rounds : 0 }
322
- }
323
-
324
- // ===============================================================
325
- console.log('-- V2 Lean formal verification --')
326
-
327
- // ---------- 1. 'off' is a true no-op ----------
328
- section("1 'off' (default) is a true no-op")
329
- {
330
- const h = await makeCase('off')
331
- const st = await h.call('vibe_math_status', {})
332
- assert(st.params.formalVerify === 'off', "the default mode is 'off' (got " + st.params.formalVerify + ')')
333
- assert(st.formal.mode === 'off' && st.formal.required === false, 'status exposes the formal mode and gate flag')
334
- assert(st.params.leanCommand === 'lean' && Array.isArray(st.params.leanArgs), 'the Lean knobs are readable in status.params')
335
- assert(st.params.leanTimeoutMs === 120000, 'leanTimeoutMs defaults to 120000 (got ' + st.params.leanTimeoutMs + ')')
336
- 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'),
337
- 'the three Lean tools are registered in every mode (registration is static)')
338
- // ★ The mode switch must be REACHABLE THROUGH THE TOOL SCHEMA (2.3.2 defect D1) ──────────────
339
- // Every tool schema here is closed (`additionalProperties:false`), so a key the schema does not
340
- // advertise is REJECTED by any schema-validating provider. v3 shipped 2.3.0/2.3.1 with all four Lean
341
- // parameters missing from the set-params schema while every assertion in this file stayed green —
342
- // because the suite calls the handler DIRECTLY and never inspects the registered schema. The feature
343
- // could not be switched on at all through the tool interface.
344
- {
345
- const setSpec = h.toolRegs.find((t) => t.name === 'vibe_math_set_params')
346
- assert(!!setSpec, "vibe_math_set_params is registered")
347
- assert(setSpec.parameters && setSpec.parameters.type === 'object' && setSpec.parameters.additionalProperties === false,
348
- '★ vibe_math_set_params publishes a CLOSED object schema (an unlisted key is rejected, so the schema IS the contract)')
349
- for (const k of ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']) {
350
- assert(Object.prototype.hasOwnProperty.call(setSpec.parameters.properties, k),
351
- '★ the registered schema advertises ' + k + ' (every other surface documents it; a schema that omits it makes the switch unreachable)')
352
- }
353
- assert(JSON.stringify(setSpec.parameters.properties.formalVerify.enum) === JSON.stringify(['off', 'encourage', 'require']),
354
- 'the schema narrows formalVerify to the three real modes (a typo must not be a fourth)')
355
- }
356
-
357
- 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')
358
- assert(existsSync(join(projRoot(h), 'Formal')) && existsSync(join(projRoot(h), 'Verified', 'Lean')), 'the project Formal/ and Verified/Lean/ dirs are created')
359
- await h.call('vibe_math_add_problem', { id: 'q1', description: 'off 模式无操作测试' })
360
- await startScheduler(h)
361
- await tick(2000)
362
- const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:q1')), 60, 200)
363
- assert(!!ex, 'the explorer was spawned (the scheduler really is running)')
364
- assert(!!ex && !/Lean|形式化/.test(ex.prompt || ''), 'the explorer prompt contains no Lean/形式化 text in off mode')
365
- if (ex) h.fireEnd({ id: ex.childId, runId: 'r1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"directions":[{"id":"d1","title":"D","method":"m","core_assumption":"c","feasibility":0.6}]}\n```' }] })
366
- await sleep(300)
367
- const so = await waitFor(() => h.spawns.find((s) => s.label.startsWith('solver:q1')), 40, 200)
368
- assert(!!so, 'the solver was spawned after the explorer returned directions')
369
- assert(!!so && !/Lean|形式化/.test(so.prompt || ''), 'the solver prompt contains no Lean/形式化 text in off mode')
370
- if (so) h.fireEnd({ id: so.childId, runId: 'r2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"status":"success","solution":"complete solution","solution_probability":0.8,"lemmas":[],"routes":[],"lessons":[],"survival_probability":0.9,"dead_end_reason":null,"sub_questions":[]}\n```' }] })
371
- const vs = await verifyWithDebate(h, 'r-q1-s0', 1)
372
- assert(!!vs.first && vs.first.length >= 2, 'verifiers were spawned for the solution (' + (vs.first ? vs.first.length : 0) + ')')
373
- const vp = vs.first ? (h.spawns.find((s) => s.label === 'verifier:r-q1-s0:0').prompt || '') : ''
374
- assert(!/Lean|形式化/.test(vp), 'the review prompt contains no Lean/形式化 text in off mode')
375
- const debateText = h.followups.map((f) => f.prompt || '').join('\n')
376
- assert(!/Lean|形式化/.test(debateText), 'no debate/followup prompt mentions Lean in off mode')
377
- const qs = JSON.parse(readIf(join(projRoot(h), 'qs', 'qs.json')) || '[]')
378
- const q = qs.find((x) => x.id === 'q1')
379
- assert(!!q && q.已解决 === true, "'off' still finalizes: the problem is marked solved with NO Lean artifact")
380
- assert(!!q && q.解法列表 && q.解法列表[0].正确概率 === 1, 'the solution probability is 1 in off mode')
381
- assert(existsSync(join(projRoot(h), 'Verified', '问题_Verified.json')), 'the Verified card was written in off mode')
382
- assert(!/形式化/.test(readIf(join(projRoot(h), 'Verified', '问题_Verified.json'))), 'the card carries no formal line in off mode')
383
- assert(!existsSync(join(projRoot(h), 'Formal', 'TODO.md')), 'no Formal/TODO.md is produced in off mode')
384
- const fs0 = formalStateOf(h)
385
- assert(!fs0.records || Object.keys(fs0.records).length === 0, 'off mode records no formal object state')
386
- }
387
-
388
- // ---------- 2. parameter validation + runtime switching ----------
389
- section('2 parameter validation and runtime switching')
390
- {
391
- const h = await makeCase('params')
392
- const bad = await h.call('vibe_math_set_params', { formalVerify: 'banana' })
393
- assert(bad.params.formalVerify === 'off', "an unknown mode degrades to 'off', never to a stronger mode (got " + bad.params.formalVerify + ')')
394
- const enc = await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
395
- assert(enc.params.formalVerify === 'encourage', "'encourage' is accepted")
396
- const req = await h.call('vibe_math_set_params', { formalVerify: 'require', leanTimeoutMs: -5, leanCommand: ' ' })
397
- assert(req.params.formalVerify === 'require', "'require' is accepted")
398
- assert(req.params.leanTimeoutMs === 120000, 'a non-positive leanTimeoutMs falls back to the default (' + req.params.leanTimeoutMs + ')')
399
- assert(req.params.leanCommand === 'lean', 'a blank leanCommand falls back to "lean"')
400
- const lake = await h.call('vibe_math_set_params', { leanCommand: 'lake', leanArgs: ['env', 'lean'] })
401
- assert(lake.params.leanCommand === 'lake' && lake.params.leanArgs.join(' ') === 'env lean', 'leanCommand/leanArgs are settable (lake env lean)')
402
- const st = await h.call('vibe_math_status', {})
403
- assert(st.formal.leanCommand === 'lake' && st.formal.leanArgs.join(' ') === 'env lean', 'the Lean knobs round-trip through status')
404
- const setup = await h.call('vibe_math_setup', {})
405
- const names = setup.parameters.map((p) => p.name)
406
- assert(['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs'].every((n) => names.indexOf(n) !== -1), 'vibe_math_setup schema lists all four Lean knobs')
407
- const fv = setup.parameters.find((p) => p.name === 'formalVerify')
408
- assert(!!fv && fv.current === 'require' && fv.default === 'off', 'the schema reports the current and default formalVerify')
409
- const tpl = await h.call('vibe_math_template', { where: 'global' })
410
- const tplText = readIf(tpl.path)
411
- assert(/formalVerify/.test(tplText) && /leanCommand/.test(tplText) && /leanTimeoutMs/.test(tplText), 'the settings template documents the Lean knobs')
412
- assert(/leanArgs/.test(tplText), 'the settings template also documents leanArgs')
413
- assert(!/:\s*undefined/.test(tplText), 'the template has no bare undefined token (settings round-trip safety)')
414
- const saved = await h.call('vibe_math_save_settings', {})
415
- assert(saved.ok === true, 'save_settings still round-trips with the new knobs')
416
- await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
417
- await h.call('vibe_math_add_problem', { id: 'q2', description: '模式切换测试' })
418
- await startScheduler(h)
419
- await tick(2000)
420
- const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:q2')), 60, 200)
421
- assert(!!ex && /【顺手形式化(鼓励[^)]*)】/.test(ex.prompt || ''), '★ switching to encourage changes the NEXT prompt immediately')
422
- if (ex) h.fireEnd({ id: ex.childId, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"directions":[{"id":"d1","title":"D","method":"m","core_assumption":"c","feasibility":0.5}]}\n```' }] })
423
- await h.call('vibe_math_set_params', { formalVerify: 'off' })
424
- await sleep(400)
425
- const so = await waitFor(() => h.spawns.find((s) => s.label.startsWith('solver:q2')), 40, 200)
426
- assert(!!so, 'the solver was spawned after the mode was switched back to off')
427
- assert(!!so && !/形式化|Lean/.test(so.prompt || ''), '★ switching back to off removes the Lean text from the next prompt')
428
- }
429
-
430
- // ---------- 3. 'encourage' injection ----------
431
- section("3 'encourage' injects the Lean section into review AND debate prompts")
432
- {
433
- const h = await makeCase('enc')
434
- await h.call('vibe_math_set_params', { formalVerify: 'encourage', maxParallelThreshold: 8 })
435
- // Keep the scheduler alive: v2 STOPS itself when there is no unsolved problem, no agent and
436
- // no task, and a stopped scheduler never starts a verification (nor spawns an explorer).
437
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
438
- await startScheduler(h)
439
- await h.call('vibe_math_add_proposition', { id: 'pEnc', 概述: '鼓励模式下的忠实性审查', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
440
- const vs = await verifyWithDebate(h, 'r-pEnc', 0.5, 1) // 0.5 -> no consensus -> a REAL debate round
441
- assert(!!vs.first, 'verifiers spawned for the bare proposition')
442
- assert(vs.rounds >= 2, 'the object really went through a debate round (' + vs.rounds + ' rounds)')
443
- const review = h.spawns.find((s) => s.label === 'verifier:r-pEnc:0')
444
- assert(!!review, 'the first review prompt was captured')
445
- const reviewText = review ? review.prompt : ''
446
- assert(/【Lean 形式化验证(鼓励模式)】/.test(reviewText), '★ the REVIEW prompt carries the Lean section')
447
- assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(reviewText), 'the review prompt states that a passing Lean run shrinks the question to fidelity')
448
- assert(/实现难度/.test(reviewText), 'the review prompt asks for the implementation-difficulty judgement')
449
- // NOTE: this assertion is only the WORDING half. Which is exactly the trap the first version
450
- // of this feature fell into (a green suite guarding a dead channel). The behaviour — the reply
451
- // really landing in the durable record — is asserted in section 11.
452
- assert(/可以不做,但请在回执的 formal 字段写明难度判断/.test(reviewText), "'encourage' explicitly allows skipping (with a recorded judgement)")
453
- assert(/"decision":"used\|blocked\|defect"/.test(reviewText), '★ the review contract lists the real decision enum (incl. defect)')
454
- assert(/vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库\/jobs)· vibe_math_lean_read(取回归档原文)/.test(reviewText), 'the review prompt names all four v2 tools (incl. the new read-only one)')
455
- assert(/归档可复用定义\/引理前先跑通(vibe_math_lean_archive run=true 或先 vibe_math_lean_run)/.test(reviewText), '★ the review prompt says a reusable artifact must run green BEFORE it is archived')
456
- assert(/LEAN_NOT_FOUND/.test(reviewText) && /宿主无 Lean 工具链/.test(reviewText), '★ the review prompt writes out the missing-toolchain escape hatch (a host without Lean must not deadlock the agent)')
457
- const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
458
- assert(/DEBATE/.test(debate), 'the debate round actually happened (a followup with the debate prompt was issued)')
459
- assert(/【Lean 形式化验证(鼓励模式)】/.test(debate), '★ the DEBATE prompt carries the Lean section too')
460
- assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(debate), 'the debate prompt states the fidelity shift')
461
- await h.call('vibe_math_add_problem', { id: 'qW', description: '顺手形式化测试' })
462
- await tick(2000)
463
- const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:qW')), 60, 200)
464
- assert(!!ex && /【顺手形式化(鼓励[^)]*)】/.test(ex.prompt || ''), '★ the explorer work prompt carries the 顺手形式化 line')
465
- assert(!!ex && /vibe_math_lean_archive kind='def'/.test(ex.prompt || ''), 'the work line points at the archive tool for reusable definitions')
466
- assert(!!ex && /vibe_math_lean_lib 查重/.test(ex.prompt || ''), 'the work line tells members to check the reuse library first')
467
- assert(!!ex && /归档前先跑通(vibe_math_lean_run 或 run=true);跑不通的定义不要进可复用库。/.test(ex.prompt || ''), '★ the work line forbids archiving a definition that has not run green')
468
- assert(!!ex && /"formal":\{"target":"<对象id>","decision":"used\|blocked\|defect"/.test(ex.prompt || ''), '★ the WORK-round contract advertises the formal reply field too (otherwise a working agent has nowhere to write its judgement)')
469
- // Every agent-facing string must name the REGISTERED tools: an abbreviated `lean_archive`
470
- // is not a tool that exists, and agents copy these literals verbatim (contract §6 hard req. 1).
471
- const workText = (ex.prompt || '') + '\n' + reviewText + '\n' + debate
472
- assert(!/(^|[^a-z_])lean_(run|archive|lib)/.test(workText), '★ no injected prompt names an abbreviated tool (every occurrence is prefixed)')
473
- const offHost = await makeCase('enc-off')
474
- await offHost.call('vibe_math_add_problem', { id: 'qN', description: 'x' })
475
- await startScheduler(offHost)
476
- await tick(2000)
477
- const exN = await waitFor(() => offHost.spawns.find((s) => s.label.startsWith('explorer:qN')), 60, 200)
478
- assert(!!exN && !/形式化|Lean/.test(exN.prompt || ''), 'the same prompt in off mode is free of Lean text (injection is mode-computed, not frozen)')
479
- }
480
-
481
- // ---------- 4. the run tool ----------
482
- section('4 lean_run executes through the subprocess service and reports honestly')
483
- {
484
- const h = await makeCase('run')
485
- await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
486
- // 这一节把 leanAsync 固定为 false:它逐字守卫**同步路径**(旧的 await 语义)。
487
- // 异步(默认档)的接口与状态机由后面的「Lean async」小节守卫——两条路径都要有绿灯。
488
- await h.call('vibe_math_set_params', { leanAsync: false })
489
- assert((await h.call('vibe_math_status', {})).params.leanAsync === false, 'the sync section pins leanAsync=false explicitly')
490
- const proj = projRoot(h)
491
- mkdirSync(join(proj, 'Formal'), { recursive: true })
492
- writeFileSync(join(proj, 'Formal', 'good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
493
- writeFileSync(join(proj, 'Formal', 'bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
494
- const before = leanRuns.length
495
- const runGood = await h.call('vibe_math_lean_run', { file: 'Formal/good.lean', target: 'pRun' })
496
- assert(runGood.ok === true && runGood.exitCode === 0, 'a file with no sorry runs green (' + JSON.stringify({ ok: runGood.ok, exitCode: runGood.exitCode }) + ')')
497
- assert(leanRuns.length > before, 'the run really went through the mocked subprocess service')
498
- const spec = leanRuns[leanRuns.length - 1]
499
- assert(spec.cwd.replace(/\\/g, '/') === proj.replace(/\\/g, '/'), 'the toolchain runs with the PROJECT root as cwd (got ' + spec.cwd + ')')
500
- assert(!!spec.stdio && spec.stdio.stdin === 'ignore' && spec.stdio.stdout.maxBytes === 64 * 1024 && spec.stdio.stderr.maxBytes === 64 * 1024, 'stdio is stdin=ignore with a 64KB output cap')
501
- assert(typeof spec.graceMs === 'number' && spec.graceMs >= 1000, 'the configured timeout is passed as graceMs (' + spec.graceMs + ')')
502
- assert(typeof runGood.command === 'string' && runGood.command.indexOf('good.lean') !== -1, 'the result reports the exact command line')
503
- const runBad = await h.call('vibe_math_lean_run', { file: 'Formal/bad.lean' })
504
- assert(runBad.ok === false && runBad.exitCode === 1, 'a file that still uses sorry reports a red run')
505
- assert(/sorry/.test(runBad.stderr), 'the compiler output is returned verbatim (' + JSON.stringify(runBad.stderr).slice(0, 60) + ')')
506
- assert(/修复后重跑/.test(runBad.hint || ''), 'a red run tells the member to fix and rerun')
507
- const runMissing = await h.call('vibe_math_lean_run', { file: 'Formal/nope.lean' })
508
- assert(runMissing.ok === false && runMissing.code === 'V2_NOT_FOUND', 'a missing file is refused with a typed code (' + runMissing.code + ')')
509
- const runNoFile = await h.call('vibe_math_lean_run', {})
510
- assert(runNoFile.ok === false && runNoFile.code === 'V2_INVALID_ARGUMENT', 'a run without `file` is refused')
511
- // The guard's boundary is the VibeMath ROOT, not the project: the global reuse library
512
- // deliberately lives at <VibeMath>/Formal/{Lib,Proved}, so climbing out of the project but
513
- // staying inside VibeMath is legal (it just fails as a missing file).
514
- const runOutOfProject = await h.call('vibe_math_lean_run', { file: '../../Formal/Lib/x.lean' })
515
- assert(runOutOfProject.ok === false && runOutOfProject.code === 'V2_NOT_FOUND',
516
- 'climbing out of the project but staying inside the VibeMath root is ALLOWED (got ' + runOutOfProject.code + ')')
517
- const runEscape = await h.call('vibe_math_lean_run', { file: '../../../../etc/evil.lean' })
518
- assert(runEscape.ok === false && runEscape.code === 'V2_INVALID_ARGUMENT', '★ a traversal that climbs ABOVE the VibeMath root is refused')
519
- const runEscape2 = await h.call('vibe_math_lean_run', { file: 'Formal/../../../../../evil.lean' })
520
- assert(runEscape2.ok === false && runEscape2.code === 'V2_INVALID_ARGUMENT', 'a deeper traversal is refused too')
521
- const runEscape3 = await h.call('vibe_math_lean_run', { file: '/etc/evil.lean' })
522
- assert(runEscape3.ok === false && runEscape3.code === 'V2_INVALID_ARGUMENT', 'an unrelated absolute path is refused')
523
- const runEscape4 = await h.call('vibe_math_lean_run', { file: 'C:/Windows/evil.lean' })
524
- assert(runEscape4.ok === false && runEscape4.code === 'V2_INVALID_ARGUMENT', 'an unrelated Windows absolute path is refused')
525
- const runNotLean = await h.call('vibe_math_lean_run', { file: 'Formal/good.txt' })
526
- assert(runNotLean.ok === false && runNotLean.code === 'V2_INVALID_ARGUMENT', 'only .lean files can be executed')
527
- assert(/VibeMath/.test(runEscape.message || ''), 'the refusal names the boundary it enforced')
528
- mkdirSync(join(vibeRoot(h), 'Formal', 'Lib'), { recursive: true })
529
- writeFileSync(join(vibeRoot(h), 'Formal', 'Lib', 'abs.lean'), 'def absTest := 1\n', 'utf8')
530
- const runAbs = await h.call('vibe_math_lean_run', { file: join(vibeRoot(h), 'Formal', 'Lib', 'abs.lean').replace(/\\/g, '/') })
531
- assert(runAbs.ok === true, 'an absolute path inside the VibeMath root is accepted')
532
- const rec = formalStateOf(h)
533
- assert(!!rec.records && !!rec.records.pRun && rec.records.pRun.status === 'attempted', 'a green lean_run records the object as attempted (stored, not promoted)')
534
- assert(!!rec.records.pRun.run && rec.records.pRun.run.ok === true && rec.records.pRun.run.exitCode === 0, 'the run record keeps ok/exitCode')
535
- assert(rec.records.pRun.run.stdoutTail !== undefined && rec.records.pRun.run.stderrTail !== undefined, 'the run record keeps truncated output tails')
536
- toolchainAvailable = false
537
- const runNoTc = await h.call('vibe_math_lean_run', { file: 'Formal/good.lean' })
538
- assert(runNoTc.ok === false && runNoTc.code === 'LEAN_NOT_FOUND', 'a missing toolchain returns LEAN_NOT_FOUND instead of crashing')
539
- assert(/仍可把形式化代码写下来归档/.test(runNoTc.message), 'the failure explains the graceful degradation')
540
- toolchainAvailable = true
541
- const noSub = await makeCase('nosub', { noSubprocess: true })
542
- mkdirSync(join(projRoot(noSub), 'Formal'), { recursive: true })
543
- writeFileSync(join(projRoot(noSub), 'Formal', 'x.lean'), 'theorem x : 1 = 1 := rfl\n', 'utf8')
544
- const noSubRun = await noSub.call('vibe_math_lean_run', { file: 'Formal/x.lean' })
545
- assert(noSubRun.ok === false && noSubRun.code === 'NO_SUBPROCESS', 'a host without the subprocess service returns NO_SUBPROCESS and does not crash (got ' + noSubRun.code + ')')
546
- const noSubStatus = await noSub.call('vibe_math_status', {})
547
- assert(noSubStatus.ok === true, 'the scheduler still answers status after that (nothing was thrown into the loop)')
548
- }
549
-
550
- // ---------- 4b. the timeout must STOP the process, not just report it ----------
551
- // Contract §7: "必须给 cwd…,并对超时调用 handle.terminate()". Reporting LEAN_TIMEOUT while the Lean
552
- // process keeps running is a silent resource leak, and the framework's own contract says otherwise.
553
- section('4b lean_run ACTIVELY terminates on timeout (contract §7)')
554
- {
555
- const h = await makeCase('timeout')
556
- await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
557
- const proj = projRoot(h)
558
- mkdirSync(join(proj, 'Formal'), { recursive: true })
559
- writeFileSync(join(proj, 'Formal', 'hang.lean'), 'theorem t : 1 = 1 := rfl -- HANG\n', 'utf8')
560
- writeFileSync(join(proj, 'Formal', 'fast.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
561
- const before = terminations.length
562
- const t0 = Date.now()
563
- const run = await h.call('vibe_math_lean_run', { file: 'Formal/hang.lean', timeout_ms: 1000 })
564
- const elapsed = Date.now() - t0
565
- assert(run.ok === false && run.code === 'LEAN_TIMEOUT', '★ a run that outlives its timeout is reported as LEAN_TIMEOUT (got ' + run.code + ')')
566
- assert(run.timedOut === true, 'the result carries timedOut=true')
567
- assert(terminations.length === before + 1 && /hang\.lean/.test(terminations[terminations.length - 1].file),
568
- '★★ the timeout ACTIVELY called handle.terminate() (graceMs alone does not stop a lingering Lean process)')
569
- assert(elapsed < 2400, 'the call returned AT its timeout instead of waiting the process out (took ' + elapsed + 'ms)')
570
- const fast = await h.call('vibe_math_lean_run', { file: 'Formal/fast.lean' })
571
- assert(fast.ok === true && fast.timedOut === false, 'a normal run still reports success')
572
- assert(terminations.length === before + 1, 'a normal run terminates nothing')
573
- await sleep(60)
574
- assert(liveTimers.size === 0, '★ no timeout timer is left pending after either run (it is cleared as soon as `done` wins)')
575
- }
576
-
577
- // ---------- 5. archive: def / lemma / proof / blocked ----------
578
- section('5 lean_archive writes the contract paths and indexes')
579
- {
580
- const h = await makeCase('arc')
581
- await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
582
- const proj = projRoot(h)
583
- const libPath = join(vibeRoot(h), 'Formal', 'Lib')
584
- const provedPath = join(vibeRoot(h), 'Formal', 'Proved')
585
- const defRes = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'ZMod5', content: 'def ZMod5 := Fin 5\n' })
586
- assert(defRes.ok === true && defRes.file === 'Formal/Lib/ZMod5.lean', 'a reusable definition is archived to the global lib (' + defRes.file + ')')
587
- assert(existsSync(join(libPath, 'ZMod5.lean')), '★ the definition exists under <VibeMath>/Formal/Lib/ (cross-project, NOT inside the project)')
588
- assert(!existsSync(join(proj, 'Formal', 'Lib', 'ZMod5.lean')), 'it is NOT duplicated inside the project tree')
589
- assert(!!defRes.run && defRes.run.ok === true, 'the archived definition was executed (kind=def runs by default)')
590
- const lemmaRes = await h.call('vibe_math_lean_archive', { kind: 'lemma', name: 'sq_odd', content: 'theorem sq_odd (n : Nat) : Odd (n*n) → Odd n := by omega\n' })
591
- assert(lemmaRes.ok === true && lemmaRes.file === 'Formal/Proved/sq_odd.lean', 'a lemma is archived to the global Proved/ (' + lemmaRes.file + ')')
592
- assert(existsSync(join(provedPath, 'sq_odd.lean')), 'the lemma exists under <VibeMath>/Formal/Proved/')
593
- const defNoRun = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'quiet', content: 'def quiet := 2\n', run: false })
594
- assert(defNoRun.ok === true && defNoRun.run === undefined, 'run:false archives without executing (still indexed)')
595
- assert(/ZMod5/.test(readIf(join(libPath, 'Index.md'))), 'Lib/Index.md lists the new definition')
596
- assert(/quiet/.test(readIf(join(libPath, 'Index.md'))), 'Lib/Index.md lists the run:false definition too')
597
- assert(/sq_odd/.test(readIf(join(provedPath, 'Index.md'))), 'Proved/Index.md lists the new lemma')
598
- writeFileSync(join(proj, 'Formal', 'src.lean'), 'def copied := 3\n', 'utf8')
599
- const defFrom = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'copied', from: 'Formal/src.lean' })
600
- assert(defFrom.ok === true && existsSync(join(libPath, 'copied.lean')), 'kind=def can archive from an existing .lean file')
601
- // The tool RESULT is agent-facing text too: a definition whose run just failed must not be advertised
602
- // as "directly importable" (contract §6 hard req. 3 / 实现方案 §9.4 — a red file must not be presented
603
- // as usable, whatever the framework decides to do with the file itself).
604
- const defRed = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'polluted', content: 'def polluted := 1 -- FAIL\n' })
605
- assert(defRed.ok === true && !!defRed.run && defRed.run.ok === false, 'a definition that does not compile is reported as a red run')
606
- assert(!/可直接 import 复用/.test(defRed.note || ''), '★ a red definition must NOT be advertised as directly reusable')
607
- assert(/运行未通过/.test(defRed.note || ''), '…and the note says what to do instead')
608
- const fromOutside = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'escape', from: '../../../../etc/passwd' })
609
- assert(fromOutside.ok === false && fromOutside.code === 'V2_INVALID_ARGUMENT', 'from=<path outside the VibeMath root> is refused')
610
- const noName = await h.call('vibe_math_lean_archive', { kind: 'def', content: 'def x := 1\n' })
611
- assert(noName.ok === false && noName.code === 'V2_INVALID_ARGUMENT', 'archiving a definition without a name is refused')
612
- const noBody = await h.call('vibe_math_lean_archive', { kind: 'lemma', name: 'xb' })
613
- assert(noBody.ok === false && noBody.code === 'V2_INVALID_ARGUMENT', 'archiving a lemma with neither content nor from is refused')
614
- const badKind = await h.call('vibe_math_lean_archive', { kind: 'nonsense' })
615
- assert(badKind.ok === false && badKind.code === 'V2_INVALID_ARGUMENT', 'an unknown archive kind is refused')
616
- const noTarget = await h.call('vibe_math_lean_archive', { kind: 'proof', content: 'theorem x : 1 = 1 := rfl\n' })
617
- assert(noTarget.ok === false && noTarget.code === 'V2_INVALID_ARGUMENT', 'kind=proof without a target is refused')
618
- const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pProof', content: 'theorem p_proof : 3 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n' })
619
- assert(proof.ok === true && proof.passed === true, 'a passing proof is archived (' + JSON.stringify({ ok: proof.ok, passed: proof.passed }) + ')')
620
- assert(proof.file === 'Formal/pProof.lean', 'the working file is Formal/<target>.lean')
621
- assert(proof.proof === 'Verified/Lean/pProof.lean', 'the archived proof path is Verified/Lean/<target>.lean')
622
- assert(existsSync(join(proj, 'Formal', 'pProof.lean')), 'the working file exists on disk')
623
- assert(existsSync(join(proj, 'Verified', 'Lean', 'pProof.lean')), '★ the proof is archived under Verified/Lean/')
624
- assert(proof.status === 'passed', 'the object status becomes passed')
625
- const proofBad = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pRed', content: 'theorem p_red : 1 = 2 := by sorry\n' })
626
- assert(proofBad.ok === true && proofBad.passed === false && proofBad.status === 'attempted', 'a red proof is recorded as attempted (not passed)')
627
- assert(!existsSync(join(proj, 'Verified', 'Lean', 'pRed.lean')), 'a red proof is NOT archived under Verified/Lean/')
628
- assert(/sorry/.test((proofBad.run && proofBad.run.stderr) || ''), 'the red run keeps the compiler output for the member to fix')
629
- const st = await h.call('vibe_math_status', {})
630
- assert(st.formal.objects.some((o) => o.target === 'pProof' && o.status === 'passed'), 'status lists the passed object')
631
- const idx = readIf(join(proj, 'Formal', 'Index.md'))
632
- assert(/pProof/.test(idx) && /passed/.test(idx) && /Verified\/Lean\/pProof\.lean/.test(idx), 'Formal/Index.md indexes the object, its status and its archived proof')
633
- assert(/pRed/.test(idx) && /attempted/.test(idx), 'Formal/Index.md also records the failed attempt')
634
- const blkNoNote = await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'pBlk' })
635
- assert(blkNoNote.ok === false && blkNoNote.code === 'V2_INVALID_ARGUMENT', 'blocked without a note is refused')
636
- const blk = await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'pBlk', note: '需要外层解析数论框架,本轮工作量不可接受' })
637
- assert(blk.ok === true && blk.status === 'blocked', 'a reasoned blocker is recorded')
638
- assert(/需要外层解析数论框架/.test(readIf(join(proj, 'Formal', 'Index.md'))), 'the blocker reason reaches the index')
639
- const stBlk = await h.call('vibe_math_status', {})
640
- assert(stBlk.formal.objects.some((o) => o.target === 'pBlk' && o.status === 'blocked'), 'status lists the blocked object')
641
- const lib = await h.call('vibe_math_lean_lib', {})
642
- assert(lib.ok === true && lib.counts.lib >= 3 && lib.counts.proved >= 1, 'lean_lib reports the reuse library sizes (' + JSON.stringify(lib.counts) + ')')
643
- assert(lib.objects.some((o) => o.target === 'pProof' && o.status === 'passed'), 'lean_lib lists the per-object formal status')
644
- assert(/复用优先/.test(lib.hint || ''), 'lean_lib tells agents to reuse before redefining')
645
- assert(lib.mode === 'encourage', 'lean_lib reports the active mode')
646
- const noRefresh = await h.call('vibe_math_lean_lib', { refresh: false })
647
- assert(noRefresh.rebuilt === false && noRefresh.counts.lib === null, 'refresh:false lists without rebuilding')
648
- assert(existsSync(join(proj, 'Formal', 'Index.md')), 'the project Formal/Index.md exists')
649
- assert(existsSync(join(libPath, 'Index.md')) && existsSync(join(provedPath, 'Index.md')), 'the two GLOBAL indexes exist')
650
- }
651
-
652
- // ---------- 6. a passing proof flips the review to FIDELITY ----------
653
- section('6 a passing proof flips the review subject to fidelity')
654
- {
655
- const h = await makeCase('fid')
656
- await h.call('vibe_math_set_params', { formalVerify: 'encourage', maxParallelThreshold: 8 })
657
- // v2's scheduler STOPS itself when there is no unsolved problem, no agent and no task; a
658
- // case that only wants to verify a proposition therefore needs one live problem to keep
659
- // the tick loop alive (otherwise no verification would ever be started).
660
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
661
- await startScheduler(h)
662
- await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'r-pFid', content: 'theorem p_fid : 2 + 2 = 4 := by decide\n' })
663
- await h.call('vibe_math_add_proposition', { id: 'pFid', 概述: '2+2=4(已有 Lean 证明)', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
664
- // 0.5 in round 1 forces a debate round whose prompt must ALSO carry the fidelity wording
665
- const vs = await verifyWithDebate(h, 'r-pFid', 0.5, 1)
666
- assert(!!vs.first, 'a verifier was spawned for the Lean-passed object')
667
- const vp = h.spawns.find((s) => s.label === 'verifier:r-pFid:0')
668
- const vpText = vp ? vp.prompt : ''
669
- assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(vpText), 'the review prompt announces the passing proof')
670
- assert(/你不需要重新检查推导/.test(vpText), '★ it tells reviewers NOT to re-derive')
671
- assert(/忠实性审查/.test(vpText), '★ it tells reviewers the review subject is now fidelity')
672
- assert(/定义 \/ 对象 \/ 条件 \/ 假设 \/ 结论是否与命题原文\*\*完全一致\*\*/.test(vpText), 'it enumerates exactly what fidelity means')
673
- assert(/Verified\/Lean\/r-pFid\.lean/.test(vpText), 'it points at the archived proof')
674
- assert(/一致 → Result = 1/.test(vpText), '★ the fidelity guidance names the REAL field (v2\'s contract field is Result)')
675
- assert(!/verdict/.test(vpText), '★ the fidelity guidance never names a `verdict` field (that vote would be silently dropped)')
676
- assert(/发现任何偏差,不要投 0/.test(vpText), '★ a fidelity defect is explicitly NOT to be voted as 0 (it is not a refutation)')
677
- assert(/formal:\{decision:'defect'/.test(vpText), '★ the reviewers are given the defect reply channel that withdraws the proof')
678
- assert(!/偏离 → 0/.test(vpText), '★ the "any deviation → 0" instruction is gone (it would fabricate a false conclusion)')
679
- assert(!/请先判断该对象的\*\*实现难度\*\*/.test(vpText), 'the "judge the difficulty first" wording is gone when a proof already exists')
680
- // The withdrawal sentence must match the MODE's real strength (contract §4.1 pt.3 / §6.1; this case is
681
- // 'encourage'): encouraging mode has NO gate, so the framework cannot hold the verdict — promising a
682
- // hold there is a lie the voter would rely on (AUDIT-CHECKLIST §1.7, "提示词承诺的强度档位").
683
- assert(/本档没有门禁/.test(vpText), '★ encourage fidelity text says THIS MODE HAS NO GATE (the framework cannot hold the verdict)')
684
- assert(!/本次裁定\*\*不定论\*\*/.test(vpText), '★ encourage must NOT promise a hold the framework cannot enforce')
685
- const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
686
- assert(/你不需要重新检查推导/.test(debate), '★ the debate prompt for a Lean-passed object also asks for fidelity, not re-derivation')
687
- assert(/发现任何偏差,不要投 0/.test(debate), '★ and it carries the same no-zero rule in the debate round')
688
- assert(/本档没有门禁/.test(debate), '★ the debate round carries the same mode-qualified withdrawal wording')
689
- await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'r-pBlk2', note: '涉及未形式化的分析学前置' })
690
- await h.call('vibe_math_add_proposition', { id: 'pBlk2', 概述: '已记录阻塞的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
691
- const vs2 = await verifyWithDebate(h, 'r-pBlk2', 0.5, 1)
692
- assert(!!vs2.first, 'verifiers spawned for the blocked object')
693
- const vp2 = h.spawns.find((s) => s.label === 'verifier:r-pBlk2:0')
694
- const vp2Text = vp2 ? vp2.prompt : ''
695
- assert(/该对象已被记录为\*\*形式化阻塞\*\*/.test(vp2Text), 'a blocked object is announced as such')
696
- assert(/涉及未形式化的分析学前置/.test(vp2Text), 'the blocker reason is shown to the reviewers')
697
- assert(/这个阻塞判断是否成立/.test(vp2Text), 'the verdict guidance asks whether the blocker is justified')
698
- }
699
-
700
- // ---------- 7. the 'require' gate (proposition) ----------
701
- section("7 'require' withholds a verdict until the formal record exists")
702
- {
703
- const h = await makeCase('gate')
704
- await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
705
- // a live problem keeps the scheduler from stopping itself (see the comment in case 6)
706
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
707
- await startScheduler(h)
708
- const proj = projRoot(h)
709
- await h.call('vibe_math_add_proposition', { id: 'pGate', 概述: '必须形式化的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
710
- const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pGate'); return x.length >= 2 ? x : undefined }, 60, 250)
711
- assert(!!vs, 'verifiers were spawned under require mode')
712
- const vp = (h.spawns.find((s) => s.label === 'verifier:r-pGate:0') || {}).prompt || ''
713
- assert(/【Lean 形式化验证(强制模式)】/.test(vp), 'the review prompt says 强制模式')
714
- assert(/必须产出 Lean 形式化/.test(vp), "'require' states the formalization is mandatory")
715
- assert(/本次裁定不会生效/.test(vp), 'the prompt warns that the verdict will not take effect without it')
716
- assert(/formal-required/.test(vp), 'the prompt names the machine-readable reason')
717
- assert(/vibe_math_lean_archive kind='blocked' note=… 或回执 formal\.note/.test(vp), '★ the require wording names BOTH blocking routes with the FULL tool name')
718
- assert(/"formal":\{"target":"r-pGate","decision":"used\|blocked\|defect"/.test(vp), '★ the require review contract carries the formal reply field keyed by the verification id')
719
- assert(!/(^|[^a-z_])lean_(run|archive|lib)/.test(vp), '★ the require prompt contains no abbreviated tool name either')
720
- fireVerdicts(h, vs, 1)
721
- // The deferral writes Formal/TODO.md from INSIDE the settle path; waiting for that file is
722
- // the observable proof that the round settled (the task is dropped right after).
723
- const todoSettled = await waitFor(() => /pGate/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 40, 150)
724
- assert(!!todoSettled, 'the deferred round settled (Formal/TODO.md recorded the object)')
725
- await tick(600)
726
- const propFile = join(proj, 'Propos', '数论_Propos.json')
727
- const props = JSON.parse(readIf(propFile) || '[]')
728
- const pGate = props.find((x) => x.id === 'pGate') || {}
729
- assert(pGate.布尔估计 === 0.5, "★ the verdict did NOT take effect: the proposition's 布尔估计 is unchanged (got " + pGate.布尔估计 + ')')
730
- assert(!(pGate.证明列表 || []).some((x) => x.正确概率 === 1), 'no probability-1 proof entry was recorded')
731
- assert(pGate.优先级 !== 'never', 'the priority was not pinned to never')
732
- assert(!existsSync(join(proj, 'Verified', '数论_Verified.json')), '★ no Verified card was written')
733
- const todo = readIf(join(proj, 'Formal', 'TODO.md'))
734
- assert(existsSync(join(proj, 'Formal', 'TODO.md')), 'Formal/TODO.md was created')
735
- assert(/pGate/.test(todo) && /formal-required/.test(todo), '★ the object is on the formalization TODO with the machine-readable reason')
736
- const recAfterGate = formalStateOf(h)
737
- // record key = the verification object id (rId), which is what lean_archive target= also uses
738
- assert(!!recAfterGate.records && !!recAfterGate.records['r-pGate'] && !!recAfterGate.records['r-pGate'].deferredAt, 'the deferral is persisted in the durable formal state (survives resume)')
739
- assert(!!recAfterGate.records['r-pGate'] && recAfterGate.records['r-pGate'].status === 'none', 'the object stays at status none (nothing was formalized)')
740
- const vlogs = existsSync(join(proj, 'Verification_logs')) ? readdirSync(join(proj, 'Verification_logs')) : []
741
- assert(vlogs.some((f) => f.startsWith('r-pGate')), '★ the verification round DID complete (its debate log exists) — the gate withheld the conclusion, it did not stall the round')
742
- const st1 = await h.call('vibe_math_status', {})
743
- assert(/require 模式搁置/.test(JSON.stringify(st1.recentActivity)), 'the withholding is announced on v2-readable channel (activity log; v2 has no Shared/Chat/)')
744
- assert(st1.formal.todo.some((t) => t.id === 'r-pGate'), 'status exposes the formalization TODO')
745
- const rep = await h.call('vibe_math_report', {})
746
- assert(!!rep.formal && rep.formal.required === true && rep.formal.mode === 'require', 'the report exposes the formal mode and gate flag')
747
- assert(rep.recentActivity.some((a) => /formal-gate/.test(a.event)), 'the deferral also lands in the report log')
748
- // The deferral leaves this project with no agent, task or solved problem, and v2's strict
749
- // termination stops the scheduler there — so a case that wants to RE-verify the object after
750
- // formalizing it must (re)start the scheduler, exactly as a human would.
751
- await startScheduler(h)
752
- const proofNow = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pGate', content: 'theorem p_gate : 2 + 2 = 4 := by decide\n' })
753
- assert(proofNow.ok === true && proofNow.passed === true, 'the object is now Lean-passed')
754
- const spawned2 = await verifyWithDebate(h, 'r-pGate', 1, 0)
755
- assert(!!spawned2.first, 'the object was re-verified after the proof was archived')
756
- const props2 = JSON.parse(readIf(propFile) || '[]')
757
- const pGateNow = props2.find((x) => x.id === 'pGate') || {}
758
- assert(pGateNow.布尔估计 === 1, '★ with a passing Lean artifact the same unanimous verdict DOES take effect (布尔估计=1)')
759
- const cardFile = join(proj, 'Verified', '数论_Verified.json')
760
- const cards = JSON.parse(readIf(cardFile) || '[]')
761
- const card = cards.find((c) => c.id === 'pGate')
762
- assert(!!card, '★ the Verified card was written after the proof passed')
763
- assert(!!card && /Lean 通过/.test(card['形式化'] || ''), '★ the card records how strong the result is (Lean 通过)')
764
- assert(!!card && /Verified\/Lean\/pGate\.lean/.test(card['形式化'] || ''), 'the card points at the archived proof')
765
- await h.call('vibe_math_add_proposition', { id: 'pBlkOk', 概述: '记录阻塞后可定论', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
766
- const sp3 = await waitFor(() => { const x = verifiersOf(h, 'r-pBlkOk'); return x.length >= 2 ? x : undefined }, 60, 250)
767
- assert(!!sp3, 'verifiers spawned for the blocker-escape case')
768
- await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'r-pBlkOk', note: '命题涉及未形式化的分析学,本轮不做' })
769
- fireVerdicts(h, sp3 || [], 1)
770
- await waitFor(() => { const c = JSON.parse(readIf(cardFile) || '[]').find((x) => x.id === 'pBlkOk'); return c ? c : undefined }, 40, 150)
771
- await tick(400)
772
- const cards3 = JSON.parse(readIf(cardFile) || '[]')
773
- const card3 = cards3.find((c) => c.id === 'pBlkOk')
774
- assert(!!card3, '★ an explicit reasoned blocker also lets the verdict through (decide by difficulty, but decide out loud)')
775
- assert(!!card3 && /阻塞(/.test(card3['形式化'] || ''), 'the card records the blocker')
776
- assert(!!card3 && /未形式化的分析学/.test(card3['形式化'] || ''), 'the card records the blocker REASON, not just the fact')
777
- }
778
-
779
- // ---------- 8. the gate covers the qs (problem) choke point ----------
780
- section('8 the gate also covers a problem-solution verdict')
781
- {
782
- const h = await makeCase('gq')
783
- await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
784
- // a live problem keeps the scheduler from stopping itself (see the comment in case 3)
785
- await h.call('vibe_math_add_problem', { id: 'qStay', description: '保持调度器运行的占位问题', priority: 9 })
786
- await startScheduler(h)
787
- const proj = projRoot(h)
788
- await h.call('vibe_math_add_problem', { id: 'qG', description: '带解法的 require 门禁问题', priority: 1 })
789
- const qsFile = join(proj, 'qs', 'qs.json')
790
- const qs0 = JSON.parse(readIf(qsFile) || '[]')
791
- qs0.find((q) => q.id === 'qG').解法列表 = [{ 完整解法: 'closing argument', 正确概率: 0.8, 已验: false }]
792
- writeFileSync(qsFile, JSON.stringify(qs0, null, 2), 'utf8')
793
- await tick(2000)
794
- const sp = await waitFor(() => { const x = verifiersOf(h, 'r-qG-s0'); return x.length >= 2 ? x : undefined }, 60, 250)
795
- assert(!!sp, 'verifiers spawned for the problem solution')
796
- fireVerdicts(h, sp || [], 1)
797
- const todoG = await waitFor(() => /qG/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 40, 150)
798
- assert(!!todoG, 'the deferred problem round settled (Formal/TODO.md recorded it)')
799
- await tick(600)
800
- const qs1 = JSON.parse(readIf(qsFile) || '[]')
801
- const qG = qs1.find((q) => q.id === 'qG')
802
- assert(!!qG && qG.已解决 !== true, '★ the problem was NOT marked solved (the require gate blocked the promotion)')
803
- assert(!!qG && qG.解法列表[0].正确概率 !== 1, "the solution's probability was left as it was")
804
- assert(!existsSync(join(proj, 'Verified', '问题_Verified.json')), 'no problem Verified card was written')
805
- assert(/qG/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 'the problem is on the formalization TODO')
806
- await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'qG', note: '问题的形式化超出本轮工作量' })
807
- await startScheduler(h) // see case 7: the deferral may have stopped the scheduler
808
- const sp2 = await verifyWithDebate(h, 'r-qG-s0', 1, 0)
809
- assert(!!sp2.first, 'the problem solution was re-verified after the blocker was recorded')
810
- const qs2 = JSON.parse(readIf(qsFile) || '[]')
811
- const qG2 = qs2.find((q) => q.id === 'qG')
812
- assert(!!qG2 && qG2.已解决 === true, 'with a reasoned blocker the problem DOES close')
813
- assert(!!qG2 && qG2.解法列表[0].正确概率 === 1, 'the solution probability becomes 1 after the gate opened')
814
- const cards = JSON.parse(readIf(join(proj, 'Verified', '问题_Verified.json')) || '[]')
815
- const card = cards.find((c) => c.id === 'qG')
816
- assert(!!card && /阻塞(/.test(card['形式化'] || ''), 'the problem card records the formal status')
817
- assert(!existsSync(join(vibeRoot(h), 'Formal', 'Lib', 'qG.lean')), 'a blocked record does not fabricate a library file')
818
- }
819
-
820
- // ---------- 9. persistence across resume ----------
821
- section('9 the formal state survives resume (v2 has no session projection)')
822
- {
823
- const h = await makeCase('persist')
824
- await h.call('vibe_math_set_params', { formalVerify: 'require' })
825
- await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pPersist', content: 'theorem p_persist : 1 + 1 = 2 := by decide\n' })
826
- const before = await h.call('vibe_math_status', {})
827
- assert(before.formal.objects.some((o) => o.target === 'pPersist' && o.status === 'passed'), 'the object is passed before the restart')
828
- const proj = projRoot(h)
829
- writeFileSync(join(proj, 'VibeMath_State', 'process_epoch.json'), JSON.stringify('OLD-PROCESS-EPOCH'), 'utf8')
830
- const res = await h.call('vibe_math_resume', {})
831
- assert(res.ok === true, 'resume succeeded')
832
- const after = await h.call('vibe_math_status', {})
833
- assert(after.formal.objects.some((o) => o.target === 'pPersist' && o.status === 'passed'),
834
- '★ the Lean-passed object is still passed after resume (the gate does not forget)')
835
- assert(/pPersist/.test(readIf(join(proj, 'Formal', 'Index.md'))), 'Formal/Index.md still indexes it (the files are the readable mirror)')
836
- }
837
-
838
- // ---------- 10. the gate is a no-op unless require ----------
839
- section("10 'encourage' never gates (a verdict still lands with no Lean artifact)")
840
- {
841
- const h = await makeCase('nogate')
842
- await h.call('vibe_math_set_params', { formalVerify: 'encourage', maxParallelThreshold: 8 })
843
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
844
- await startScheduler(h)
845
- const proj = projRoot(h)
846
- await h.call('vibe_math_add_proposition', { id: 'pFree', 概述: '鼓励模式不设门禁', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
847
- const vs = await verifyWithDebate(h, 'r-pFree', 1, 0)
848
- assert(!!vs.first, 'verifiers spawned under encourage mode')
849
- const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
850
- const p = props.find((x) => x.id === 'pFree') || {}
851
- assert(p.布尔估计 === 1, "'encourage' finalizes normally with NO Lean artifact (布尔估计=1)")
852
- const cards = JSON.parse(readIf(join(proj, 'Verified', '数论_Verified.json')) || '[]')
853
- assert(!!cards.find((c) => c.id === 'pFree'), "'encourage' writes the Verified card")
854
- assert(!existsSync(join(proj, 'Formal', 'TODO.md')) || !/pFree/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 'no formalization TODO is created in encourage mode')
855
- }
856
-
857
- // ---------- 11. the reply channel is REAL (contract §4 / §6.3 / §10.8) ----------
858
- // The first version of this feature only WROTE "请在回执的 formal 字段写明难度判断" into the prompt
859
- // and never parsed it: 177 green assertions guarded a dead channel (AUDIT-CHECKLIST §2.2). These
860
- // cases feed a genuine agent reply through the framework's own dispatch path (subagent/end →
861
- // handleVerifier → absorb) and assert the DURABLE record, not the wording.
862
- section('11 the reply channel really lands in the record (blocked / note validation)')
863
- {
864
- const h = await makeCase('reply')
865
- await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
866
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
867
- await startScheduler(h)
868
- await h.call('vibe_math_add_proposition', { id: 'pReply', 概述: '用回执记录阻塞', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
869
- const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pReply'); return x.length >= 2 ? x : undefined }, 60, 250)
870
- assert(!!vs, 'verifiers were spawned for the reply-channel proposition')
871
- const vp = (h.spawns.find((s) => s.label === 'verifier:r-pReply:0') || {}).prompt || ''
872
- assert(/"formal":\{"target":"r-pReply","decision":"used\|blocked\|defect"/.test(vp), '★ the review contract itself advertises the formal field, keyed by the verification id')
873
- if (vs) {
874
- // ① `blocked` WITH a note, named by the VERIFICATION id → the OBJECT record must be synced too.
875
- replyFrom(h, vs[0].childId, { Result: 0.5, Reason: '我判断形式化不划算', formal: { target: 'r-pReply', decision: 'blocked', note: '需要大量未形式化的实分析前置知识' } })
876
- const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['r-pReply'] && r['r-pReply'].status === 'blocked') ? r : undefined }, 40, 150)
877
- assert(!!rec, '★ a `formal.decision=blocked` reply is really absorbed — the channel is not dead code')
878
- assert(!!rec && rec['r-pReply'].decision === 'blocked' && /实分析前置知识/.test(rec['r-pReply'].note || ''), 'the record keeps the decision AND the reason')
879
- assert(!!rec && !!rec['pReply'] && rec['pReply'].status === 'blocked' && /实分析前置知识/.test(rec['pReply'].note || ''), '★ the OBJECT-id record is synced too (the two id spaces must not drift)')
880
- assert(/实分析前置知识/.test(readIf(join(projRoot(h), 'Formal', 'Index.md'))), 'the reply-recorded blocker reaches Formal/Index.md')
881
- // ② `blocked` WITHOUT a note (a different target) → refused with V2_INVALID_ARGUMENT, no record.
882
- replyFrom(h, vs[1].childId, { Result: 0.5, Reason: '不想做', formal: { target: 'r-pNoNote', decision: 'blocked' } })
883
- let acts = ''
884
- for (let i = 0; i < 25; i++) {
885
- const st = await h.call('vibe_math_status', {})
886
- acts = (st.recentActivity || []).map((a) => a.detail).join('\n')
887
- if (/V2_INVALID_ARGUMENT/.test(acts)) break
888
- await sleep(120)
889
- }
890
- assert(/V2_INVALID_ARGUMENT/.test(acts), '★ a blocked/defect judgement without a note is REJECTED with V2_INVALID_ARGUMENT')
891
- assert(/没有写明 note/.test(acts), 'the refusal says why (an explicit decision is required, never a silent skip)')
892
- const recN = formalStateOf(h)
893
- assert(!(recN.records || {})['r-pNoNote'] && !(recN.records || {})['pNoNote'], 'no record is invented for the refused judgement')
894
- assert(!/pNoNote/.test(readIf(join(projRoot(h), 'Formal', 'Index.md'))), 'the refused judgement does not reach the index either')
895
- // ③ a reply with NO target must not invent an object (safeId('') would fall back to 'anon').
896
- // The 0.5/0.5 round has no consensus, so the debate round re-woke both children — that is the
897
- // observable proof that these children are registered and can answer again.
898
- const woke = await waitFor(() => (h.followups.filter((f) => /DEBATE/.test(f.prompt || '')).length >= 2 ? true : undefined), 40, 150)
899
- assert(!!woke, 'the debate round re-woke the verifiers (round-2 replies reach the framework again)')
900
- replyFrom(h, vs[0].childId, { Result: 0.5, Reason: '漏写 target', formal: { decision: 'blocked', note: '没有写 target' } })
901
- let acts2 = ''
902
- for (let i = 0; i < 25; i++) {
903
- const st = await h.call('vibe_math_status', {})
904
- acts2 = (st.recentActivity || []).map((a) => a.detail).join('\n')
905
- if (/没有 target/.test(acts2)) break
906
- await sleep(120)
907
- }
908
- assert(/没有 target/.test(acts2), '★ a `formal` reply without a target is refused, not guessed')
909
- const recT = formalStateOf(h)
910
- assert(!(recT.records || {}).anon, '★ a `formal` reply without a target cannot invent a record (no "anon" object)')
911
- }
912
- }
913
-
914
- // ---------- 12. `defect`: a fidelity defect is NOT "the proposition is false" (§4.1) ----------
915
- section('12 a defect reply withdraws the proof, writes the TODO and defers the verdict')
916
- {
917
- const h = await makeCase('defect')
918
- await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
919
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
920
- await startScheduler(h)
921
- const proj = projRoot(h)
922
- // A PASSING Lean proof exists for the object — archived under the OBJECT id, the realistic path.
923
- const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pDefect', content: 'theorem p_defect : 2 + 2 = 4 := by decide\n' })
924
- assert(proof.ok === true && proof.passed === true, 'precondition: the object starts Lean-passed')
925
- const proofFile = join(proj, 'Verified', 'Lean', 'pDefect.lean')
926
- assert(existsSync(proofFile), 'the archived proof exists before the defect is reported')
927
- await h.call('vibe_math_add_proposition', { id: 'pDefect', 概述: '形式化写窄了的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
928
- const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pDefect'); return x.length >= 2 ? x : undefined }, 60, 250)
929
- assert(!!vs, 'verifiers were spawned for the Lean-passed object')
930
- const vp = (h.spawns.find((s) => s.label === 'verifier:r-pDefect:0') || {}).prompt || ''
931
- assert(/忠实性审查/.test(vp) && /不要投 0/.test(vp), 'the reviewers were told to audit fidelity and NOT to vote 0 on a defect')
932
- // In REQUIRE mode the framework really does hold the verdict, so THAT promise is the correct one here.
933
- assert(/本次裁定\*\*不定论\*\*/.test(vp), '★ require fidelity text does promise the hold (the gate really enforces it)')
934
- assert(!/本档没有门禁/.test(vp), 'require must not claim it has no gate')
935
- const DEFECT = 'Lean 只证了 n>0 的情形,命题原文是 n≥0'
936
- if (vs) {
937
- // A fidelity defect: the voter ABSTAINS (0.3) and records it through the reply channel.
938
- replyFrom(h, vs[0].childId, { Result: 0.3, Reason: '形式化写窄了(弃权)', formal: { target: 'pDefect', decision: 'defect', note: DEFECT } })
939
- const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['pDefect'] && r['pDefect'].decision === 'defect') ? r : undefined }, 40, 150)
940
- assert(!!rec, '★ the defect reply is absorbed')
941
- assert(!!rec && rec['pDefect'].status === 'attempted', '★ the object is DOWNGRADED to attempted (the "passed" status is withdrawn)')
942
- assert(!!rec && rec['pDefect'].proof === '', '★ the proof pointer is cleared')
943
- assert(!!rec && rec['pDefect'].note === DEFECT, 'the concrete deviation is recorded on the object record')
944
- assert(!!rec && !!rec['r-pDefect'] && rec['r-pDefect'].status === 'attempted' && rec['r-pDefect'].decision === 'defect', '★ the verification-id record is downgraded too (both id spaces)')
945
- assert(!existsSync(proofFile), '★ the archived proof Verified/Lean/pDefect.lean is DELETED')
946
- assert(existsSync(join(proj, 'Formal', 'pDefect.lean')), 'the working file Formal/pDefect.lean is kept (the code is not lost)')
947
- const todo = readIf(join(proj, 'Formal', 'TODO.md'))
948
- assert(/pDefect/.test(todo) && /defect/.test(todo), '★ the object is listed in Formal/TODO.md with the defect reason')
949
- assert(/只证了 n>0 的情形/.test(todo), 'the TODO carries the concrete deviation, not just a flag')
950
- let acts = ''
951
- for (let i = 0; i < 25; i++) {
952
- const st = await h.call('vibe_math_status', {})
953
- acts = (st.recentActivity || []).map((a) => a.detail).join('\n')
954
- if (/忠实性缺陷/.test(acts)) break
955
- await sleep(120)
956
- }
957
- assert(/忠实性缺陷/.test(acts), 'the downgrade is announced on the v2-readable channel (activity log)')
958
- assert(/撤回「已通过」状态/.test(acts), 'the announcement says the passed status was withdrawn')
959
- assert(/本次裁定不定论/.test(acts), 'and that the verdict is undecided — a defect withdraws a proof, it does not refute the proposition')
960
- // Now drive the same round to a UNANIMOUS "true": without the defect it would conclude.
961
- const settled = await verifyWithDebate(h, 'r-pDefect', 1, 0)
962
- assert(!!settled.first, 'the round was driven to a verdict after the defect')
963
- const todo2 = await waitFor(() => { const t = readIf(join(proj, 'Formal', 'TODO.md')); return /formal-required/.test(t) ? t : undefined }, 40, 150)
964
- assert(!!todo2 && /r-pDefect/.test(todo2), '★ require mode DEFERS after a defect (the object enters the formalization TODO as formal-required)')
965
- await tick(400)
966
- const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
967
- const p = props.find((x) => x.id === 'pDefect') || {}
968
- assert(p.布尔估计 === 0.5, '★★ the proposition is NOT recorded as false — 布尔估计 unchanged (got ' + p.布尔估计 + ')')
969
- assert(!(p.证明列表 || []).some((x) => x.正确概率 === 1), 'no probability-1 proof entry was written')
970
- assert(p.优先级 !== 'never', 'the priority was not pinned to never')
971
- assert(!existsSync(join(proj, 'Verified', '数论_Verified.json')), '★ no Verified card: the verdict is UNDECIDED, not "false"')
972
- }
973
- // The reverse direction: a defect named by the VERIFICATION id must still downgrade the OBJECT
974
- // record that actually holds the proof (this is the pair that silently drifts when only one side
975
- // is written — formalGateRecord would read `passed` from the other side).
976
- const h2 = await makeCase('defect-rid')
977
- await h2.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
978
- await h2.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
979
- await startScheduler(h2)
980
- const proj2 = projRoot(h2)
981
- await h2.call('vibe_math_lean_archive', { kind: 'proof', target: 'pDefect2', content: 'theorem p_defect2 : 2 + 2 = 4 := by decide\n' })
982
- await h2.call('vibe_math_add_proposition', { id: 'pDefect2', 概述: '回执用验证 id 命名的缺陷', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
983
- const vs2 = await waitFor(() => { const x = verifiersOf(h2, 'r-pDefect2'); return x.length >= 2 ? x : undefined }, 60, 250)
984
- assert(!!vs2, 'verifiers were spawned for the reverse-direction case')
985
- if (vs2) {
986
- replyFrom(h2, vs2[0].childId, { Result: 0.3, Reason: '写宽了', formal: { target: 'r-pDefect2', decision: 'defect', note: 'Lean 版本没有假设 n≥1,比原文更宽' } })
987
- const rec2 = await waitFor(() => { const r = (formalStateOf(h2).records || {}); return (r['r-pDefect2'] && r['r-pDefect2'].decision === 'defect') ? r : undefined }, 40, 150)
988
- assert(!!rec2, '★ a defect named by the verification id is absorbed')
989
- assert(!!rec2 && !!rec2['pDefect2'] && rec2['pDefect2'].status === 'attempted', '★ and the OBJECT record that held `passed` is downgraded as well')
990
- assert(!!rec2 && rec2['pDefect2'].proof === '', 'its proof pointer is cleared too')
991
- assert(!existsSync(join(proj2, 'Verified', 'Lean', 'pDefect2.lean')), '★ the archived proof is deleted even though the reply named the other id')
992
- assert(/pDefect2/.test(readIf(join(proj2, 'Formal', 'TODO.md'))), 'the object is on the formalization TODO')
993
- }
994
- }
995
-
996
- // ---------- 13. the gate must read BOTH id spaces ----------
997
- // v2 has two id spaces that name the same object: the verification id (rId, `r-pX`, `r-pX-s0`) that the
998
- // verification prompts and the gate use, and the OBJECT id (`pX`) that the agent reads in the target
999
- // block and normally archives under. `lean_archive` can only ADD a record for the id it was given, and
1000
- // `syncVerificationTarget` merely UPDATES aliases that already exist — so "archived under the object id,
1001
- // rId record does not exist yet" is a real first-time state. A gate that reads only its own side calls
1002
- // that object un-formalized (a false negative), and because the deferral itself materialises the rId
1003
- // record as `status:'none'`, the object can then NEVER conclude: every round re-defers and re-debates
1004
- // (AUDIT-CHECKLIST §3: "成对关系只做一半").
1005
- section('13 the require gate reads BOTH id spaces (archiving under the OBJECT id must not wedge)')
1006
- {
1007
- const h = await makeCase('gate-objid')
1008
- await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1009
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1010
- await startScheduler(h)
1011
- const proj = projRoot(h)
1012
- const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pObjId', content: 'theorem p_objid : 2 + 2 = 4 := by decide\n' })
1013
- assert(proof.ok === true && proof.passed === true, 'precondition: the object is Lean-passed under its OBJECT id')
1014
- const rec0 = formalStateOf(h)
1015
- assert(!!rec0.records && !!rec0.records.pObjId && rec0.records.pObjId.status === 'passed', 'the passed record lives under the object id')
1016
- assert(!(rec0.records || {})['r-pObjId'], 'precondition: no rId record exists yet (this is the state that used to fool the gate)')
1017
- await h.call('vibe_math_add_proposition', { id: 'pObjId', 概述: '用对象 id 归档后必须能定论', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1018
- const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pObjId'); return x.length >= 2 ? x : undefined }, 60, 250)
1019
- assert(!!vs, 'verifiers were spawned for the object-id case')
1020
- fireVerdicts(h, vs || [], 1)
1021
- await tick(600)
1022
- const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
1023
- const p = props.find((x) => x.id === 'pObjId') || {}
1024
- assert(p.布尔估计 === 1, "★ a Lean-passed object DOES conclude when the proof was archived under the object id (got " + p.布尔估计 + ')')
1025
- const cards = JSON.parse(readIf(join(proj, 'Verified', '数论_Verified.json')) || '[]')
1026
- assert(!!cards.find((c) => c.id === 'pObjId'), 'the Verified card was written')
1027
- const todo = readIf(join(proj, 'Formal', 'TODO.md'))
1028
- assert(!/pObjId/.test(todo), '★ the already-formalized object is NOT put on the formalization TODO')
1029
- const todo2 = await h.call('vibe_math_status', {})
1030
- assert(!(todo2.formal.todo || []).some((t) => String(t.id).indexOf('pObjId') !== -1), 'and the status TODO is empty for it too')
1031
- }
1032
-
1033
- // ---------- 14. withdrawing a proof must reach EVERY archived copy ----------
1034
- // Contract §4.1 requires the withdrawal, not a best-effort delete: after a `defect` the archived proof
1035
- // must be gone from `Verified/Lean/` — the one place everyone looks for "the proof of this object".
1036
- // Two independent ways that fails silently:
1037
- // (1) the proof was archived under a DIFFERENT id than the one the reply names (`Verified/Lean/<rId>.lean`
1038
- // while the reply names the object id) — scanning only the two named ids misses it, and the record's
1039
- // `proof` pointer is cleared anyway, so nothing ever points at the orphan again;
1040
- // (2) the host cannot delete at all (no subprocess service) — the delete fails and the stale proof stays.
1041
- section('14 defect withdrawal covers every id alias AND a host that cannot delete')
1042
- {
1043
- // (a) archived under the VERIFICATION id, defect named by the OBJECT id.
1044
- const h = await makeCase('defect-alias')
1045
- await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1046
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1047
- await startScheduler(h)
1048
- const proj = projRoot(h)
1049
- const pr = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'r-pAlias', content: 'theorem p_alias : 2 + 2 = 4 := by decide\n' })
1050
- assert(pr.ok === true && pr.passed === true, 'precondition: the proof is archived under the VERIFICATION id')
1051
- const aliasProof = join(proj, 'Verified', 'Lean', 'r-pAlias.lean')
1052
- assert(existsSync(aliasProof), 'the archived proof sits at Verified/Lean/<rId>.lean')
1053
- await h.call('vibe_math_add_proposition', { id: 'pAlias', 概述: '归档写在验证 id 上', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1054
- const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pAlias'); return x.length >= 2 ? x : undefined }, 60, 250)
1055
- assert(!!vs, 'verifiers were spawned for the alias case')
1056
- if (vs) {
1057
- replyFrom(h, vs[0].childId, { Result: 0.3, Reason: '写宽了(弃权)', formal: { target: 'pAlias', decision: 'defect', note: '原文还有 n≥1 的假设' } })
1058
- const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['pAlias'] && r['pAlias'].decision === 'defect') ? r : undefined }, 40, 150)
1059
- assert(!!rec, '★ the defect named by the object id is absorbed')
1060
- assert(!existsSync(aliasProof), '★★ the proof archived under the VERIFICATION id is withdrawn too (every id alias, not just the two named ones)')
1061
- assert(!!rec && (!rec['r-pAlias'] || rec['r-pAlias'].proof === ''), 'the rId record no longer points at a proof')
1062
- assert(!rec['r-pAlias'] || !rec['r-pAlias'].objectId || rec['r-pAlias'].objectId === 'pAlias', '★★ the rId record owner stamp is the OBJECT id (pAlias), never an r-shaped poisoned anchor (got ' + JSON.stringify(rec['r-pAlias'] && rec['r-pAlias'].objectId) + ')')
1063
- }
1064
-
1065
- // (b) a host whose deletion cannot work: the archived path must not keep reading as the proof.
1066
- const h2 = await makeCase('defect-nosub', { noSubprocess: true })
1067
- await h2.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1068
- const proj2 = projRoot(h2)
1069
- mkdirSync(join(proj2, 'Verified', 'Lean'), { recursive: true })
1070
- mkdirSync(join(proj2, 'Formal'), { recursive: true })
1071
- const WORK = 'theorem p_stale : 2 + 2 = 4 := by decide\n'
1072
- writeFileSync(join(proj2, 'Formal', 'pStale.lean'), WORK, 'utf8')
1073
- writeFileSync(join(proj2, 'Verified', 'Lean', 'pStale.lean'), WORK, 'utf8')
1074
- // The record is written directly (this host has no subprocess, so the LEAN tools cannot run at all):
1075
- // what is under test is the WITHDRAWAL path, not the archiving path.
1076
- 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')
1077
- await h2.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1078
- await h2.call('vibe_math_add_proposition', { id: 'pStale', 概述: '宿主删不掉归档证明时的撤回', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1079
- await startScheduler(h2)
1080
- const st0 = await h2.call('vibe_math_status', {})
1081
- 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')
1082
- const vs2 = await waitFor(() => { const x = verifiersOf(h2, 'r-pStale'); return x.length >= 2 ? x : undefined }, 60, 250)
1083
- assert(!!vs2, 'verifiers were spawned on the no-subprocess host')
1084
- if (vs2) {
1085
- replyFrom(h2, vs2[0].childId, { Result: 0.3, Reason: '不忠实(弃权)', formal: { target: 'pStale', decision: 'defect', note: 'Lean 少了 n≥1' } })
1086
- const rec2 = await waitFor(() => { const r = (formalStateOf(h2).records || {}); return (r['pStale'] && r['pStale'].decision === 'defect') ? r : undefined }, 40, 150)
1087
- assert(!!rec2, '★ the defect is absorbed even though this host cannot run a delete')
1088
- assert(!!rec2 && rec2['pStale'].status === 'attempted' && rec2['pStale'].proof === '', 'the record is downgraded and its proof pointer cleared')
1089
- const stalePath = join(proj2, 'Verified', 'Lean', 'pStale.lean')
1090
- assert(existsSync(stalePath), 'the archived file could NOT be deleted here (no subprocess) — so the fallback has to handle it')
1091
- const body = readIf(stalePath)
1092
- 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)')
1093
- assert(/已撤回/.test(body), '★★ …and that path carries an explicit withdrawal notice instead')
1094
- assert(/Formal\/pStale\.lean/.test(body), 'the notice points at the working file that is kept')
1095
- const acts = (await h2.call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')
1096
- assert(/覆写/.test(acts), '★ the announcement says WHAT actually happened (overwritten, not deleted)')
1097
- assert(/撤回「已通过」状态/.test(acts), 'the announcement still says the passed status was withdrawn')
1098
- }
1099
- }
1100
-
1101
- // ---------- 15. the「判断命题」transfer is a verdict on the SOURCE proposition ----------
1102
- // When a solver's solution for a "判断下述命题是否成立:X" problem is verified, the result is TRANSFERRED
1103
- // onto the source proposition: `布尔估计 = v`, `已验证 = true`, a probability-1 proof/refutation entry,
1104
- // and `优先级 = 'never'` for a boolean v. That IS a boolean verdict on X, so require mode must gate it —
1105
- // otherwise a proposition nobody formalized is silently concluded (and permanently de-scheduled).
1106
- // (c) an OBJECT ID that itself ends in `-sN` / `-pfN` / `-rfN` — exactly the shapes the rId suffix
1107
- // parser strips. `formalObjectIdOf` used to strip such a suffix unconditionally, so the verification id
1108
- // `r-pAmb-s1` resolved to object `pAmb`: a DIFFERENT object. A defect about the former would then
1109
- // downgrade the latter and even retract ITS archived proof, while the real owner stayed `passed`.
1110
- // The verification task knows its owner (r.pId / r.qid), so that is the authoritative source.
1111
- {
1112
- const h = await makeCase('defect-ambig')
1113
- await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1114
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1115
- await startScheduler(h)
1116
- const proj = projRoot(h)
1117
- const innocent = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pAmb', content: 'theorem p_amb : 2 + 2 = 4 := by decide\n' })
1118
- assert(innocent.ok === true && innocent.passed === true, 'ambiguity: the neighbour pAmb has its own passing proof')
1119
- const innocentProof = join(proj, 'Verified', 'Lean', 'pAmb.lean')
1120
- const prone = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pAmb-s1', content: 'theorem p_amb_s1 : 3 + 3 = 6 := by decide\n' })
1121
- assert(prone.ok === true && prone.passed === true, 'ambiguity: pAmb-s1 (id ending in -s1) has its own passing proof')
1122
- const proneProof = join(proj, 'Verified', 'Lean', 'pAmb-s1.lean')
1123
- assert(existsSync(innocentProof) && existsSync(proneProof), 'ambiguity: both archived proofs exist')
1124
- await h.call('vibe_math_add_proposition', { id: 'pAmb', 概述: '同前缀的邻居对象', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1125
- await h.call('vibe_math_add_proposition', { id: 'pAmb-s1', 概述: '对象 id 本身以 -s1 结尾', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1126
- const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pAmb-s1'); return x.length >= 2 ? x : undefined }, 60, 250)
1127
- assert(!!vs, 'ambiguity: verifiers were spawned for r-pAmb-s1')
1128
- const vp = (h.spawns.find((s) => s.label === 'verifier:r-pAmb-s1:0') || {}).prompt || ''
1129
- assert(/忠实性审查/.test(vp), 'ambiguity: the review subject is the Lean-passed object')
1130
- assert(/Verified\/Lean\/pAmb-s1\.lean/.test(vp), '★★ and the fidelity prompt points at pAmb-s1 OWN proof, not at a same-prefix neighbour (got ' + (vp.match(/Verified\/Lean\/[^\s))]+/) || ['none'])[0] + ')')
1131
- if (vs) {
1132
- replyFrom(h, vs[0].childId, { Result: 0.3, Reason: '结论写窄了(弃权)', formal: { target: 'r-pAmb-s1', decision: 'defect', note: 'Lean 只证了 n>0 的情形' } })
1133
- const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['pAmb-s1'] && r['pAmb-s1'].decision === 'defect') ? r : undefined }, 40, 150)
1134
- assert(!!rec, '★★ a defect named by r-pAmb-s1 downgrades its REAL owner (pAmb-s1)')
1135
- assert(!existsSync(proneProof) || /已撤回/.test(readIf(proneProof)), '★★ and retracts pAmb-s1 own archived proof')
1136
- const recs = formalStateOf(h).records || {}
1137
- assert(!!recs['pAmb'] && recs['pAmb'].status === 'passed', '★★ the same-prefix NEIGHBOUR pAmb keeps its passed status (got ' + (recs['pAmb'] ? recs['pAmb'].status : 'no record') + ')')
1138
- assert(existsSync(innocentProof) && !/已撤回/.test(readIf(innocentProof)), '★★ and its archived proof file is untouched — a defect must never retract another object\'s proof')
1139
- assert(/pAmb-s1/.test(readIf(join(proj, 'Formal', 'TODO.md')) || ''), 'the TODO names the real owner')
1140
- // The record carries the authoritative owner, so the mapping survives a resume even when the
1141
- // in-memory verification task is gone (the fallback string parse would pick the neighbour again).
1142
- assert(!!recs['r-pAmb-s1'] && recs['r-pAmb-s1'].objectId === 'pAmb-s1',
1143
- '★★ the verification record remembers its object id (authoritative, not re-parsed from the name) — got ' + JSON.stringify(recs['r-pAmb-s1'] && recs['r-pAmb-s1'].objectId))
1144
- assert(!!recs['pAmb'] && !recs['pAmb'].objectId, 'the neighbour record carries no bogus owner stamp')
1145
- }
1146
- }
1147
-
1148
- // (d) THE RECORD-OWNER FALLBACK must not be hijacked by a hand-edited state file. The state file is
1149
- // user-editable, and a record whose `objectId` is itself r-shaped is not a usable owner: it would make
1150
- // the verification id map to ITSELF, so a defect named by that rId would never reach the object side.
1151
- // This case uses a WORK-ROUND (explorer) reply, which also carries `formal` and runs with NO verification
1152
- // task in memory — i.e. it is the path that actually reads the owner from the record (a verifier reply
1153
- // would take the task-registry path instead, which is authoritative and already covered above).
1154
- {
1155
- const h = await makeCase('anchor-poison', { noSubprocess: true })
1156
- await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1157
- const proj = projRoot(h)
1158
- mkdirSync(join(proj, 'Verified', 'Lean'), { recursive: true })
1159
- mkdirSync(join(proj, 'Formal'), { recursive: true })
1160
- const WORK = 'theorem p_x : 2 + 2 = 4 := by decide\n'
1161
- writeFileSync(join(proj, 'Formal', 'pX.lean'), WORK, 'utf8')
1162
- writeFileSync(join(proj, 'Verified', 'Lean', 'pX.lean'), WORK, 'utf8')
1163
- const base = { status: 'passed', file: 'Formal/pX.lean', proof: 'Verified/Lean/pX.lean', decision: 'used', updatedAt: 1 }
1164
- writeFileSync(join(proj, 'VibeMath_State', 'formal.json'), JSON.stringify({ records: {
1165
- pX: Object.assign({}, base),
1166
- 'r-pX': Object.assign({}, base, { objectId: 'r-pX' }), // ← the r-shaped anchor a hand edit could leave
1167
- }, todo: [] }), 'utf8')
1168
- await h.call('vibe_math_add_problem', { id: 'qA', description: '锚点污染用例', priority: 1 })
1169
- await startScheduler(h)
1170
- await tick(2000)
1171
- const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:qA')), 60, 200)
1172
- assert(!!ex, 'anchor-poison: the explorer was spawned (the scheduler is running)')
1173
- if (ex) {
1174
- const reply = { directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.6 }], formal: { target: 'r-pX', decision: 'defect', note: 'Lean 少了 n≥1' } }
1175
- h.fireEnd({ id: ex.childId, runId: 'rp', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }] })
1176
- const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['pX'] && r['pX'].decision === 'defect') ? r : undefined }, 40, 150)
1177
- assert(!!rec, '★★ a work-round defect named by an rId still reaches the OBJECT side when the record carries an r-shaped anchor (the anchor is rejected, not trusted)')
1178
- const all = formalStateOf(h).records || {}
1179
- assert(!!all['r-pX'] && all['r-pX'].status === 'attempted', '★★ and the rId record is downgraded as well (both id spaces stay in step)')
1180
- }
1181
- }
1182
-
1183
- section('15 the require gate also covers the judge-problem transfer')
1184
- {
1185
- const h = await makeCase('judge-gate')
1186
- await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1187
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1188
- const proj = projRoot(h)
1189
- await h.call('vibe_math_add_proposition', { id: 'pJudgeSrc', 概述: '被判断的源命题(未形式化)', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1190
- await h.call('vibe_math_add_problem', { id: 'qJudge', description: '判断下述命题是否成立:pJudgeSrc', priority: 1 })
1191
- const qsFile = join(proj, 'qs', 'qs.json')
1192
- const qs0 = JSON.parse(readIf(qsFile) || '[]')
1193
- const qj = qs0.find((q) => q.id === 'qJudge')
1194
- qj.判断命题 = 'pJudgeSrc'
1195
- // A solver-produced solution carries no 来源列表 — that is exactly the branch that transfers to the source.
1196
- qj.解法列表 = [{ 完整解法: '该命题不成立的论证', 正确概率: 0.8, 已验: false }]
1197
- writeFileSync(qsFile, JSON.stringify(qs0, null, 2), 'utf8')
1198
- await startScheduler(h)
1199
- const sp = await waitFor(() => { const x = verifiersOf(h, 'r-qJudge-s0'); return x.length >= 2 ? x : undefined }, 60, 250)
1200
- assert(!!sp, 'verifiers were spawned for the judge problem solution')
1201
- fireVerdicts(h, sp || [], 0)
1202
- await tick(600)
1203
- const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
1204
- const ap = props.find((x) => x.id === 'pJudgeSrc') || {}
1205
- assert(ap.布尔估计 === 0.5, "★★ the source proposition's 布尔估计 is UNCHANGED (require mode must not conclude 假 through the transfer; got " + ap.布尔估计 + ')')
1206
- assert(!(ap.证伪列表 || []).some((x) => x.正确概率 === 1), 'no probability-1 refutation was written onto the source proposition')
1207
- assert(ap.优先级 !== 'never', 'the source proposition was not pinned to never')
1208
- assert(ap.已验证 !== true, '★ it stays re-verifiable (the ungated path also marked it 已验证, removing it from every future candidate set)')
1209
- assert(/pJudgeSrc/.test(readIf(join(proj, 'Formal', 'TODO.md'))), '★ the deferred source proposition is on the formalization TODO')
1210
- assert(!existsSync(join(proj, 'Verified', '数论_Verified.json')), 'no Verified card for the source proposition')
1211
- }
1212
-
1213
- // ---------- 16. the prompt corpus (contract §10.10) ----------
1214
- // A HUMAN must be able to re-read every prompt the framework emitted, not just the assertions
1215
- // about them. Paths are normalised so the dump is deterministic, diffable and machine-free.
1216
- section("15b a \`used\` reply must NOT downgrade an already-passed object")
1217
- {
1218
- // Contract §4: a fidelity defect WITHDRAWS a proof; a plain `used` judgement merely says "this round
1219
- // touched the formalization". Letting `used` downgrade an established proof would silently remove the
1220
- // fidelity branch from every later review prompt and re-close the require gate — the exact class that
1221
- // was already fixed once for v4's formalSetRun. v3/v4/v5 preserve passed/blocked here; v2 did not.
1222
- const h = await makeCase('used-keep')
1223
- await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1224
- await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1225
- await startScheduler(h)
1226
- const proj = projRoot(h)
1227
- const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pUsedKeep', content: 'theorem p_used_keep : 2 + 2 = 4 := by decide\n' })
1228
- assert(proof.ok === true && proof.passed === true, 'used-keep: the object starts Lean-passed')
1229
- const proofFile = join(proj, 'Verified', 'Lean', 'pUsedKeep.lean')
1230
- assert(existsSync(proofFile), 'used-keep: the archived proof exists before the used reply')
1231
- await h.call('vibe_math_add_proposition', { id: 'pUsedKeep', 概述: '已有通过证明后再写一次 used 回执', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1232
- const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pUsedKeep'); return x.length >= 2 ? x : undefined }, 60, 250)
1233
- assert(!!vs, 'used-keep: verifiers were spawned for the Lean-passed object')
1234
- const vp = (h.spawns.find((s) => s.label === 'verifier:r-pUsedKeep:0') || {}).prompt || ''
1235
- assert(/忠实性审查/.test(vp), 'used-keep: the reviewers were told to audit FIDELITY (the Lean code is the subject)')
1236
- if (vs) {
1237
- replyFrom(h, vs[0].childId, { Result: 0.5, Reason: '这一轮只是又写了一遍草稿', formal: { target: 'pUsedKeep', decision: 'used', file: 'Formal/pUsedKeep.lean' } })
1238
- // The reply is what materialises the canonical verification alias r-<objectId>, so waiting for
1239
- // THAT is a signal only the reply can produce (the archive already wrote decision:'used').
1240
- const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['r-pUsedKeep'] && r['pUsedKeep']) ? r : undefined }, 40, 150)
1241
- assert(!!rec, 'used-keep: the `used` reply is absorbed (the canonical verification alias was materialised)')
1242
- assert(!!rec && rec['pUsedKeep'].status === 'passed', '★★ a `used` reply does NOT downgrade an already-passed object (got ' + (rec && rec['pUsedKeep'] ? rec['pUsedKeep'].status : 'no record') + ')')
1243
- assert(!!rec && rec['pUsedKeep'].proof === 'Verified/Lean/pUsedKeep.lean', '★★ and its proof pointer survives the reply')
1244
- assert(!!rec && !!rec['r-pUsedKeep'] && rec['r-pUsedKeep'].status === 'passed', '★★ the verification-id alias keeps passed too (both id spaces)')
1245
- assert(existsSync(proofFile), '★★ the archived proof is still on disk')
1246
- const st = await h.call('vibe_math_status', {})
1247
- const obj = (st.formal.objects || []).find((o) => o.target === 'pUsedKeep')
1248
- assert(!!obj && obj.status === 'passed', '★★ status still reports the object as Lean-passed (v2 exposes status.formal.objects, not a "passed" array)')
1249
- // The consequence that actually matters: with 'passed' preserved the require gate stays OPEN, so
1250
- // driving the round to a unanimous "true" still concludes (a wrongly downgraded object is deferred).
1251
- const settled = await verifyWithDebate(h, 'r-pUsedKeep', 1, 0)
1252
- assert(!!settled.first, 'used-keep: the round can still be driven to a verdict')
1253
- assert(!/pUsedKeep/.test(readIf(join(proj, 'Formal', 'TODO.md')) || ''), '★★ no formal-required TODO was created: the surviving passed status still opens the require gate')
1254
- // The same rule covers the RUN path (docs §4): a run attributed to the object may not downgrade it.
1255
- const rerun = await h.call('vibe_math_lean_run', { file: 'Formal/pUsedKeep.lean', target: 'pUsedKeep' })
1256
- assert(rerun.ok === true, 'used-keep: the object work file runs green (the record assertion below is the point)')
1257
- const obj2 = ((await h.call('vibe_math_status', {})).formal.objects || []).find((o) => o.target === 'pUsedKeep')
1258
- assert(!!obj2 && obj2.status === 'passed', '★★ and the record is still passed (a scratch run may not withdraw a proof)')
1259
- }
1260
- }
1261
-
1262
- // ---------- 15c. the framework's OWN feedback channels (push report + gate notice) ----------
1263
- // Every corpus entry so far was a child prompt (spawn/wake). The framework also speaks to the main
1264
- // agent/human on two other channels — the `reportMode=push` progress report and the activity log's
1265
- // formal-gate notice — and neither had any reviewable artefact (AUDIT §2.4 "框架→人 的反馈与推送类
1266
- // 提示"). Both are captured DETERMINISTICALLY (no wall-clock, no timing-dependent counts):
1267
- // · push = the forced push of the strict-termination branch on an EMPTY project (fixed counters);
1268
- // · feedback = the FIRST formal-gate activity line of one controlled require-mode deferral.
1269
- section('15c the framework push report and the gate notice are captured')
1270
- {
1271
- const h = await makeCase('push-report')
1272
- await h.call('vibe_math_set_params', { reportMode: 'push', tickIntervalMs: 200 })
1273
- await h.call('vibe_math_start', {})
1274
- const pushed = await waitFor(() => (h.root.pushed && h.root.pushed.length > 0 ? h.root.pushed : undefined), 80, 100)
1275
- assert(!!pushed, 'push-report: the scheduler pushed a progress report to the main agent')
1276
- assert(!!pushed && /\[Vibe Math V2\] 进度更新/.test(pushed[0]), '★★ the push text is the real framework→main-agent prompt (reportMode=push)')
1277
- assert(!!pushed && /vibe_math_report/.test(pushed[0]), 'and it names the FULL tool the main agent must call')
1278
- await h.call('vibe_math_pause', {})
1279
-
1280
- const g = await makeCase('gate-feedback')
1281
- await g.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1282
- await g.call('vibe_math_add_proposition', { id: 'pFeed', 概述: '门禁反馈语料', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1283
- await startScheduler(g)
1284
- const gvs = await waitFor(() => { const x = verifiersOf(g, 'r-pFeed'); return x.length >= 2 ? x : undefined }, 60, 250)
1285
- assert(!!gvs, 'gate-feedback: verifiers were spawned for the proposition')
1286
- if (gvs) for (let i = 0; i < gvs.length && i < 2; i++) replyFrom(g, gvs[i].childId, { Result: 1, Reason: '门禁反馈用例' })
1287
- let gline
1288
- for (let i = 0; i < 60 && !gline; i++) {
1289
- const acts = (await g.call('vibe_math_status', {})).recentActivity || []
1290
- const hit = acts.find((a) => /formal-gate/.test(String(a.event)))
1291
- if (hit) gline = String(hit.event) + ': ' + String(hit.detail)
1292
- else await sleep(150)
1293
- }
1294
- assert(!!gline && /require 模式搁置/.test(gline), '★★ the require-gate withholding reaches the human-readable channel (captured verbatim)')
1295
- assert(!!gline && /Formal\/TODO\.md/.test(gline), 'and the notice says where the withholding was recorded')
1296
- g.feedbackLine = gline || ''
1297
- await g.call('vibe_math_pause', {})
1298
- }
1299
-
1300
- section('15d Lean 增量 + 异步(spec §1–§4 + 修订 §1–§5):参数、argv、队列、去重、只读面、提示词')
1301
- {
1302
- const H = MOD.__testHelpers
1303
- const slash = (s) => String(s == null ? '' : s).replace(/\\/g, '/') // 插件内部用 '/',测试用 path.join ⇒ 比较前统一
1304
- const SAME = (a, b) => slash(a) === slash(b)
1305
- // ── 参数:默认值 + 归一化(字符串 'false' 绝不能滑过去) ────────────────────────────
1306
- const a = await makeCase('lean-params')
1307
- const st0 = await a.call('vibe_math_status', {})
1308
- assert(st0.params.leanAsync === false, 'the suite pins leanAsync=false by default (sync-path guard)')
1309
- await a.call('vibe_math_set_params', { leanAsync: true })
1310
- assert((await a.call('vibe_math_status', {})).params.leanAsync === true, 'leanAsync=true is accepted (the async档)')
1311
- const bad = await a.call('vibe_math_set_params', { leanAsync: 'false' })
1312
- assert(bad.ok === true, 'set_params accepts a (badly typed) leanAsync')
1313
- assert((await a.call('vibe_math_status', {})).params.leanAsync === true, '★★★ leanAsync="false" (a STRING) does not slip through the `else out[k]=v` tail: it falls back to the boolean default (true) — a truthy string must never be read as a boolean')
1314
- const defs = (await a.call('vibe_math_status', {})).params
1315
- assert(defs.leanJobsMaxParallel === 1 && defs.leanInitiative === 'normal' && Array.isArray(defs.leanSearchPaths) && defs.leanSearchPaths.length === 0, 'the amendment defaults are leanJobsMaxParallel=1 / leanInitiative=normal / leanSearchPaths=[]')
1316
- await a.call('vibe_math_set_params', { leanInitiative: 'banana', leanJobsMaxParallel: 0, leanSearchPaths: 'not-an-array' })
1317
- const p1 = (await a.call('vibe_math_status', {})).params
1318
- assert(p1.leanInitiative === 'normal', 'leanInitiative rejects an unknown mode back to normal')
1319
- assert(p1.leanJobsMaxParallel === 1, 'leanJobsMaxParallel is floored at 1 (never 0/unbounded)')
1320
- assert(Array.isArray(p1.leanSearchPaths) && p1.leanSearchPaths.length === 0, 'leanSearchPaths rejects a non-array back to []')
1321
- await a.call('vibe_math_set_params', { leanSearchPaths: [' /opt/lean-lib ', '/opt/lean-lib', ''] })
1322
- assert(JSON.stringify((await a.call('vibe_math_status', {})).params.leanSearchPaths) === JSON.stringify(['/opt/lean-lib']), 'leanSearchPaths trims, drops empties and de-duplicates')
1323
- // 纯函数:搜索路径计划(修订 §2)
1324
- const planDefault = H.leanSearchPathPlan([], [], '/root/vm')
1325
- assert(JSON.stringify(planDefault.paths) === JSON.stringify(['/root/vm']) && planDefault.inject.length === 2, '★ default plan injects exactly the automatic VibeMath root')
1326
- const planExtra = H.leanSearchPathPlan(['env', 'lean'], ['/a', '/b'], '/root/vm')
1327
- assert(JSON.stringify(planExtra.paths) === JSON.stringify(['/a', '/b', '/root/vm']), '★★ user leanSearchPaths come FIRST, then the automatic root')
1328
- assert(JSON.stringify(planExtra.inject) === JSON.stringify(['--search-path', '/a', '--search-path', '/b', '--search-path', '/root/vm']), 'and each path is injected with its own --search-path flag')
1329
- assert(H.leanSearchPathPlan(['--search-path', '/x'], ['/a'], '/root/vm').inject.length === 0, '★ an explicit --search-path in leanArgs suppresses all injection (user wins)')
1330
- assert(H.leanSearchPathPlan(['-R', '/x'], ['/a'], '/root/vm').inject.length === 0 && H.leanSearchPathPlan(['--root', '/x'], ['/a'], '/root/vm').inject.length === 0, '★ -R / --root are honoured too')
1331
-
1332
- // ── argv:注入位置在用户参数之后、文件名之前 ──────────────────────────────────────
1333
- const b = await makeCase('lean-argv')
1334
- await b.call('vibe_math_set_params', { leanAsync: false, leanArgs: ['env', 'lean'], leanCommand: 'lean' })
1335
- const projB = projRoot(b)
1336
- mkdirSync(join(projB, 'Formal'), { recursive: true })
1337
- writeFileSync(join(projB, 'Formal', 'srch.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1338
- const runsBefore = leanRuns.length
1339
- await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1340
- const argv1 = leanRuns[leanRuns.length - 1].argv
1341
- assert(leanRuns.length === runsBefore + 1, 'the sync run really reached the compiler')
1342
- assert(JSON.stringify(argv1.slice(0, 2)) === JSON.stringify(['lean', 'env']), 'the user leanArgs are preserved in order')
1343
- assert(argv1[argv1.length - 2] === '--search-path' && SAME(argv1[argv1.length - 1], vibeRoot(b)), '★★ --search-path + the VibeMath root are injected AFTER the user args and immediately BEFORE the file (argv=' + JSON.stringify(argv1) + ')')
1344
- await b.call('vibe_math_set_params', { leanArgs: ['env', 'lean', '--search-path', '/explicit'] })
1345
- await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1346
- const argv2 = leanRuns[leanRuns.length - 1].argv
1347
- assert(argv2.indexOf('--search-path') === 3 && argv2[4] === '/explicit' && argv2.filter((x) => x === '--search-path').length === 1, '★ an explicit --search-path is respected verbatim (no second injection) — got ' + JSON.stringify(argv2))
1348
- await b.call('vibe_math_set_params', { leanArgs: ['env', 'lean'], leanSearchPaths: ['/extra1', '/extra2'] })
1349
- await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1350
- const argv3 = leanRuns[leanRuns.length - 1].argv
1351
- assert(JSON.stringify(argv3.slice(-6)) === JSON.stringify(['--search-path', '/extra1', '--search-path', '/extra2', '--search-path', slash(vibeRoot(b))]), '★★ leanSearchPaths are injected first, then the automatic root (got ' + JSON.stringify(argv3.slice(-6)) + ')')
1352
-
1353
- // ── 异步状态机:入队立即返回 → 心跳排空 → 只有 settled(ok) 才是通过 ────────────────
1354
- const c = await makeCase('lean-async')
1355
- await c.call('vibe_math_set_params', { leanAsync: true, tickIntervalMs: 200 })
1356
- const projC = projRoot(c)
1357
- mkdirSync(join(projC, 'Formal'), { recursive: true })
1358
- writeFileSync(join(projC, 'Formal', 'async-good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1359
- writeFileSync(join(projC, 'Formal', 'async-bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
1360
- const nBefore = leanRuns.length
1361
- const q1 = await c.call('vibe_math_lean_run', { file: 'Formal/async-good.lean', target: 'pAsync' })
1362
- assert(q1.ok === true && !!q1.async && q1.async.state === 'queued' && typeof q1.async.jobId === 'string' && q1.async.jobId.length > 3, '★★ leanAsync=true returns IMMEDIATELY with {async:{jobId,state:queued}} (got ' + JSON.stringify(q1.async) + ')')
1363
- assert(leanRuns.length === nBefore, '★★ the tool call itself did NOT block on the compiler (it is enqueued, not run)')
1364
- assert(q1.async.jobId.indexOf('pAsync-') === 0, 'the jobId is <target>-<fingerprint[0:12]> (got ' + q1.async.jobId + ')')
1365
- const jobFile = join(projC, 'Formal', 'Jobs', q1.async.jobId + '.json')
1366
- assert(existsSync(jobFile), 'the job record is mirrored to <project>/Formal/Jobs/<jobId>.json')
1367
- const rec0 = JSON.parse(readIf(jobFile))
1368
- assert(rec0.state === 'queued' && typeof rec0.sha256 === 'string' && typeof rec0.buildSha256 === 'string' && rec0.buildContext.length > 0, '★ the job record carries state + content hash + build-context hash')
1369
- assert((formalStateOf(c).records || {}).pAsync === undefined || formalStateOf(c).records.pAsync.status !== 'passed', '★★ a queued job never marks the object passed')
1370
- const done1 = await waitFor(() => { const r = JSON.parse(readIf(jobFile) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1371
- assert(!!done1, 'the apply-level heartbeat drained the queue and settled the job (got ' + JSON.parse(readIf(jobFile) || '{}').state + ')')
1372
- assert(done1 && done1.exitCode === 0, 'the settled record keeps exitCode 0')
1373
- const recAfter = formalStateOf(c).records.pAsync
1374
- assert(!!recAfter && recAfter.status === 'attempted' && recAfter.run && recAfter.run.ok === true, 'a green RUN record lands as attempted (stored, not promoted) with run.ok=true')
1375
- assert(!!recAfter.async && recAfter.async.state === 'settled' && recAfter.async.jobId === q1.async.jobId, 'the object record carries the async job pointer/state')
1376
- // 红色作业:attempted + 编译器输出入库,绝不 passed
1377
- const q2 = await c.call('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' })
1378
- const done2 = await waitFor(() => { const r = JSON.parse(readIf(join(projC, 'Formal', 'Jobs', q2.async.jobId + '.json')) || '{}'); return r.state === 'failed' ? r : undefined }, 80, 60)
1379
- assert(!!done2 && done2.exitCode === 1, '★ a red async run settles as failed with exitCode 1')
1380
- const recBad = formalStateOf(c).records.pAsyncBad
1381
- assert(!!recBad && recBad.status === 'attempted' && recBad.run.ok === false, '★ a red async run is recorded as attempted (never passed)')
1382
- assert(/sorry/.test(String(recBad.run.stderrTail || '')), 'and the compiler output is kept in the record for the member to fix')
1383
- // ── 归档证明(异步):落地前不是 passed;落地 ok 才写 Verified/Lean/ ────────────────
1384
- await c.call('vibe_math_add_proposition', { id: 'pAsymProof', 概述: '异步归档证明', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1385
- mkdirSync(join(projC, 'Formal'), { recursive: true })
1386
- const q3 = await c.call('vibe_math_lean_archive', { kind: 'proof', target: 'pAsymProof', content: 'theorem pAsymProof : 1 = 1 := rfl\n' })
1387
- assert(q3.ok === true && q3.async && q3.async.state === 'queued' && q3.status === 'attempted', '★★ kind=proof in async档 returns queued + attempted (NOT passed)')
1388
- assert(!existsSync(join(projC, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ the archived proof file does NOT exist while the job is still queued')
1389
- assert((formalStateOf(c).records.pAsymProof || {}).status !== 'passed', 'and the record is not passed yet')
1390
- const done3 = await waitFor(() => { const r = JSON.parse(readIf(join(projC, 'Formal', 'Jobs', q3.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1391
- assert(!!done3, 'the proof job settled green')
1392
- assert(existsSync(join(projC, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ only after settled(ok) is Verified/Lean/<id>.lean written')
1393
- assert((formalStateOf(c).records.pAsymProof || {}).status === 'passed', '★★ and only then does the object become passed')
1394
- assert(/pAsymProof/.test(readIf(join(projC, 'Formal', 'Index.md'))), 'the index reflects the passed object')
1395
- // ── lean_job(修订 §3):清单 / 单查 / waitMs ─────────────────────────────────────
1396
- const listJobs = await c.call('vibe_math_lean_job', {})
1397
- assert(listJobs.ok === true && Array.isArray(listJobs.jobs) && listJobs.jobs.length >= 3, '★ lean_job without jobId returns the session job list')
1398
- assert(listJobs.jobs.every((j) => !!j.paths && typeof j.paths.receipt === 'string'), 'every listed job carries its receipt path')
1399
- const one = await c.call('vibe_math_lean_job', { jobId: q3.async.jobId })
1400
- assert(one.ok === true && one.state === 'settled' && one.passed === true && one.paths.archive === 'Verified/Lean/pAsymProof.lean', '★ lean_job {jobId} returns state/exitCode/paths and the passed verdict')
1401
- assert((await c.call('vibe_math_lean_job', { jobId: 'nope-000000000000' })).code === 'V2_NOT_FOUND', 'an unknown jobId is refused with a typed code')
1402
- const q4 = await c.call('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' })
1403
- const waited = await c.call('vibe_math_lean_job', { jobId: q4.async.jobId, waitMs: 4000 })
1404
- assert(waited.ok === true && waited.state !== 'queued' && waited.stillRunning === false && waited.waitedMs > 0, '★★ lean_job waitMs>0 waits for the job to leave queued/running (got ' + waited.state + ')')
1405
- // ── 超时:leanTimeoutMs 是每个作业的预算,主动 terminate,绝不 passed ─────────────
1406
- const d = await makeCase('lean-timeout')
1407
- await d.call('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 1000 })
1408
- const projD = projRoot(d)
1409
- mkdirSync(join(projD, 'Formal'), { recursive: true })
1410
- writeFileSync(join(projD, 'Formal', 'hang.lean'), '-- HANG\ntheorem t : 1 = 1 := rfl\n', 'utf8')
1411
- const termBefore = terminations.length
1412
- const q5 = await d.call('vibe_math_lean_run', { file: 'Formal/hang.lean', target: 'pHang' })
1413
- const done5 = await waitFor(() => { const r = JSON.parse(readIf(join(projD, 'Formal', 'Jobs', q5.async.jobId + '.json')) || '{}'); return r.state === 'timeout' ? r : undefined }, 120, 60)
1414
- assert(!!done5 && done5.timedOut === true, '★★ a hanging job times out (state=timeout, timedOut=true) with leanTimeoutMs as its budget')
1415
- assert(terminations.length > termBefore, '★★ the timeout ACTIVELY called handle.terminate()')
1416
- assert((formalStateOf(d).records.pHang || {}).status !== 'passed', '★ a timed-out job never sets passed')
1417
- // ── 并发参数(修订 §5):leanJobsMaxParallel>1 时两个作业同时进入 running ──────────
1418
- const e = await makeCase('lean-parallel')
1419
- await e.call('vibe_math_set_params', { leanAsync: true, leanJobsMaxParallel: 2 })
1420
- const projE = projRoot(e)
1421
- mkdirSync(join(projE, 'Formal'), { recursive: true })
1422
- writeFileSync(join(projE, 'Formal', 'h1.lean'), '-- HANG\ntheorem h1 : 1 = 1 := rfl\n', 'utf8')
1423
- writeFileSync(join(projE, 'Formal', 'h2.lean'), '-- HANG\ntheorem h2 : 1 = 1 := rfl\n', 'utf8')
1424
- await e.call('vibe_math_set_params', { leanTimeoutMs: 3000 })
1425
- const j1 = await e.call('vibe_math_lean_run', { file: 'Formal/h1.lean', target: 'pH1' })
1426
- const j2 = await e.call('vibe_math_lean_run', { file: 'Formal/h2.lean', target: 'pH2' })
1427
- // Both are HANG jobs: with concurrency 2 they must be in `running` AT THE SAME TIME. A serial
1428
- // queue can never reach that state, so the probe discriminates the `leanJobsMaxParallel` parameter.
1429
- const both = await waitFor(() => {
1430
- const a = JSON.parse(readIf(join(projE, 'Formal', 'Jobs', j1.async.jobId + '.json')) || '{}')
1431
- const b = JSON.parse(readIf(join(projE, 'Formal', 'Jobs', j2.async.jobId + '.json')) || '{}')
1432
- return (a.state === 'running' && b.state === 'running') ? { a: a, b: b } : undefined
1433
- }, 120, 50)
1434
- assert(!!both, '★★ with leanJobsMaxParallel=2 BOTH queued jobs are in running at the same time (concurrency is a parameter, not a constant)')
1435
- for (const k of [j1, j2]) { try { await e.call('vibe_math_lean_job', { jobId: k.async.jobId, waitMs: 3500 }) } catch (err) { /* ignore */ } }
1436
- // ── 卸载(spec §2.5 / §7-6):terminate + interrupted,绝不 passed、绝不留孤儿 ────────
1437
- const dz = await makeCase('lean-dispose')
1438
- await dz.call('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 60000 })
1439
- const projZ = projRoot(dz)
1440
- mkdirSync(join(projZ, 'Formal'), { recursive: true })
1441
- writeFileSync(join(projZ, 'Formal', 'orphan.lean'), '-- HANG\ntheorem orphan : 1 = 1 := rfl\n', 'utf8')
1442
- const termZ = terminations.length
1443
- const qz = await dz.call('vibe_math_lean_run', { file: 'Formal/orphan.lean', target: 'pOrphan' })
1444
- const startedZ = await waitFor(() => { const r = JSON.parse(readIf(join(projZ, 'Formal', 'Jobs', qz.async.jobId + '.json')) || '{}'); return r.state === 'running' ? r : undefined }, 80, 60)
1445
- assert(!!startedZ, 'the dispose case got a job into running')
1446
- dz.unmount() // ctx.effect disposer ⇒ disposeLeanJobs()
1447
- assert(terminations.length > termZ, '★★ unmounting the plugin terminated the in-flight compile (no orphan process)')
1448
- assert((formalStateOf(dz).records.pOrphan || {}).status !== 'passed', '★ dispose never marks the object passed')
1449
- const afterZ = JSON.parse(readIf(join(projZ, 'Formal', 'Jobs', qz.async.jobId + '.json')) || '{}')
1450
- assert(afterZ.interrupted === true || afterZ.state === 'interrupted' || afterZ.state === 'running', '★ the disposed job is recorded as interrupted (or left running for the next recovery to mark)')
1451
- // ── 去重(§4.3):同内容 + 同构建上下文 ⇒ 第二次不再编译 ──────────────────────────
1452
- const f = await makeCase('lean-dedupe')
1453
- await f.call('vibe_math_set_params', { leanAsync: true })
1454
- const defBody = 'def reusableOne : Nat := 1\n'
1455
- const r1 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1456
- assert(r1.ok === true && r1.async && r1.deduped === false, 'the first archive of a definition is enqueued (not deduped)')
1457
- await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r1.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1458
- const runsAfter1 = leanRuns.length
1459
- const r2 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1460
- assert(r2.ok === true && r2.deduped === true && r2.async === undefined, '★★★ re-archiving identical content is DEDUPED (no job, no recompile) — got ' + JSON.stringify({ deduped: r2.deduped, async: r2.async }))
1461
- assert(leanRuns.length === runsAfter1, '★★ the deduped archive did NOT recompile')
1462
- // 构建上下文进指纹(修订 §4):同内容、不同 leanArgs ⇒ 不同 jobId / 不命中"已验证"
1463
- await f.call('vibe_math_set_params', { leanArgs: ['--threads=2'] })
1464
- const r5 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1465
- assert(r5.ok === true && r5.deduped === false && !!r5.async && !!r1.async && r5.async.jobId !== r1.async.jobId, '★★★ the SAME content under a DIFFERENT build context (leanArgs) gets a different jobId — it is not treated as already-verified (got ' + JSON.stringify({ deduped: r5.deduped, async: r5.async }) + ')')
1466
- if (r5.async) await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r5.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1467
- await f.call('vibe_math_set_params', { leanArgs: [] })
1468
- const changed = defBody + '\n-- 内容变了\n'
1469
- const r3 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed })
1470
- assert(r3.ok === true && r3.deduped === false && !!r3.async, 'a changed body is NOT deduped (content hash differs)')
1471
- if (r3.async) await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r3.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1472
- const r6 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed })
1473
- assert(r6.ok === true && r6.deduped === true, 'and the changed body dedupes on its own (content-based, not "first wins")')
1474
- // ── 崩溃恢复(§2.6 + 修订 §4):绝不置 passed ─────────────────────────────────────
1475
- const g = await makeCase('lean-recover')
1476
- await g.call('vibe_math_set_params', { leanAsync: true })
1477
- const projG = projRoot(g)
1478
- mkdirSync(join(projG, 'Formal', 'Jobs'), { recursive: true })
1479
- const goodBody = 'theorem recGood : 1 = 1 := rfl\n'
1480
- writeFileSync(join(projG, 'Formal', 'recGood.lean'), goodBody, 'utf8')
1481
- const ctxG = H.leanBuildContext('lean', [], [slash(vibeRoot(g))])
1482
- const fpG = H.leanJobFingerprint(goodBody, ctxG)
1483
- const jobIdG = H.leanJobId('pRecGood', fpG)
1484
- writeFileSync(join(projG, 'Formal', 'Jobs', jobIdG + '.json'), JSON.stringify({
1485
- jobId: jobIdG, kind: 'archive', state: 'running', attempts: 1, target: 'pRecGood', name: 'pRecGood',
1486
- rel: 'Formal/recGood.lean', scope: 'project', sha256: H.leanContentSha(goodBody), buildSha256: fpG, buildContext: ctxG,
1487
- engine: 'lean', argv: [], searchPaths: [slash(vibeRoot(g))], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1488
- }), 'utf8')
1489
- // 哈希不匹配的遗留作业:只标记,绝不重驱、绝不 passed
1490
- const otherJob = H.leanJobId('pRecOther', H.leanJobFingerprint('theorem other : 1 = 1 := rfl\n', ctxG))
1491
- writeFileSync(join(projG, 'Formal', 'Jobs', otherJob + '.json'), JSON.stringify({
1492
- jobId: otherJob, kind: 'archive', state: 'running', attempts: 3, target: 'pRecOther', name: 'pRecOther',
1493
- rel: 'Formal/recMissing.lean', scope: 'project', sha256: H.leanContentSha('theorem other : 1 = 1 := rfl\n'), buildSha256: 'ffffffffffff', buildContext: ctxG,
1494
- engine: 'lean', argv: [], searchPaths: [slash(vibeRoot(g))], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1495
- }), 'utf8')
1496
- await g.call('vibe_math_new_project', { name: 'proj' }) // 触发 setProject ⇒ recoverLeanJobs()
1497
- const recG = formalStateOf(g).records.pRecGood || {}
1498
- assert(recG.status === 'attempted', '★★ recovery marks a crashed running job as attempted (never passed)')
1499
- const recOther = formalStateOf(g).records.pRecOther || {}
1500
- assert(recOther.status === 'attempted', 'recovery marks the hash-mismatched job object as attempted too')
1501
- assert(!existsSync(join(projG, 'Verified', 'Lean', 'pRecGood.lean')), '★ recovery never writes an archived proof for a crashed job')
1502
- const recJobG = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', jobIdG + '.json')) || '{}')
1503
- assert(Number(recJobG.attempts) === 2 && ['queued', 'running', 'settled'].indexOf(recJobG.state) !== -1, '★★ the matching-hash job was re-enqueued with attempts+1 (got ' + JSON.stringify({ state: recJobG.state, attempts: recJobG.attempts }) + ')')
1504
- const otherAfter = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', otherJob + '.json')) || '{}')
1505
- assert(otherAfter.state === 'interrupted' && Number(otherAfter.attempts) === 3, '★★ the mismatched job is only marked interrupted (NOT re-driven, attempts unchanged)')
1506
- const recDone = await waitFor(() => { const r = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', jobIdG + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1507
- assert(!!recDone, 'the re-enqueued job then settles normally on the heartbeat (the queue really resumed)')
1508
- // ── lean_read(§1.2):只读、路径守卫、64KB 截断 ─────────────────────────────────
1509
- mkdirSync(join(vibeRoot(g), 'Formal', 'Lib'), { recursive: true })
1510
- writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', 'recGood.lean'), goodBody, 'utf8')
1511
- const rd = await g.call('vibe_math_lean_read', { name: 'recGood' })
1512
- assert(rd.ok === true && rd.kind === 'lib' && rd.sha256 === H.leanContentSha(goodBody) && rd.truncated === false && rd.text === goodBody, '★ lean_read returns the archived text + sha256 + bytes for a Lib file (got ' + JSON.stringify(rd).slice(0, 200) + ')')
1513
- assert(typeof rd.bytes === 'number' && rd.bytes === Buffer.byteLength(goodBody, 'utf8'), 'lean_read reports the byte length')
1514
- assert((await g.call('vibe_math_lean_read', { name: 'recGood', kind: 'proved' })).code === 'V2_NOT_FOUND', 'kind=proved looks only in Formal/Proved')
1515
- for (const badName of ['../etc/passwd', 'a/b', 'C:\\x\\y', '..']) {
1516
- const rbad = await g.call('vibe_math_lean_read', { name: badName })
1517
- assert(rbad.ok === false && rbad.code === 'V2_INVALID_ARGUMENT', '★ lean_read rejects the path-escape attempt ' + JSON.stringify(badName))
1518
- }
1519
- const bigName = 'bigOne'
1520
- mkdirSync(join(vibeRoot(g), 'Formal', 'Lib'), { recursive: true })
1521
- writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', bigName + '.lean'), '-- ' + 'x'.repeat(70 * 1024) + '\n', 'utf8')
1522
- const rbig = await g.call('vibe_math_lean_read', { name: bigName })
1523
- assert(rbig.ok === true && rbig.truncated === true && Buffer.byteLength(rbig.text, 'utf8') <= 64 * 1024, '★ lean_read truncates at 64KB and says so (truncated=true)')
1524
- // ── 索引依赖列(§4.4)与 lean_lib 的 jobs/searchPath ─────────────────────────────
1525
- writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', 'depUser.lean'), 'import Formal.Lib.bigOne\n\ndef depUser := 1\n', 'utf8')
1526
- const lib = await g.call('vibe_math_lean_lib', {})
1527
- assert(!!lib.paths && SAME(lib.paths.searchPath, vibeRoot(g)), '★ lean_lib exposes paths.searchPath = the VibeMath root (got ' + JSON.stringify(lib.paths) + ')')
1528
- assert(Array.isArray(lib.jobs) && lib.jobs.length >= 1, '★ lean_lib exposes the job list')
1529
- assert(lib.async === true, 'lean_lib reports the current async mode')
1530
- const libIdx = readIf(join(vibeRoot(g), 'Formal', 'Lib', 'Index.md'))
1531
- assert(/依赖(import)/.test(libIdx) && /Formal\.Lib\.bigOne/.test(libIdx), '★★ the Lib index carries the dependency column scanned from `import` lines')
1532
- // ── 主动性档位(修订 §1):提示词读 leanInitiative,不是 formalVerify ──────────────
1533
- const wNormal = await makeCase('lean-init-normal')
1534
- await wNormal.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'normal' })
1535
- await wNormal.call('vibe_math_add_problem', { id: 'qInit', description: '主动性提示词 normal' })
1536
- await startScheduler(wNormal)
1537
- const exN = await waitFor(() => wNormal.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1538
- const promptNormal = (exN && exN.prompt) || ''
1539
- assert(/主动性 normal/.test(promptNormal) && /三条筛选判据/.test(promptNormal), '★ the work prompt names the initiative档位 and the three selection criteria')
1540
- await wNormal.call('vibe_math_pause', {})
1541
-
1542
- const wEager = await makeCase('lean-init-eager')
1543
- await wEager.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'eager' })
1544
- await wEager.call('vibe_math_add_problem', { id: 'qInitE', description: '主动性提示词 eager' })
1545
- await startScheduler(wEager)
1546
- const exE = await waitFor(() => wEager.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1547
- assert(!!exE && /主动档(leanInitiative=eager)/.test(exE.prompt || ''), '★★ leanInitiative=eager adds the eager line (the prompt reads the initiative parameter)')
1548
- await wEager.call('vibe_math_pause', {})
1549
-
1550
- // v5 统一口径:主动性(leanInitiative)与验证要求强度(formalVerify)是两根独立的轴。
1551
- // (a)leanInitiative:'off' ⇒ 连日线都不注入(真无操作)。
1552
- const wOff = await makeCase('lean-init-off')
1553
- await wOff.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'off' })
1554
- await wOff.call('vibe_math_add_problem', { id: 'qInitO', description: '主动性提示词 off' })
1555
- await startScheduler(wOff)
1556
- const exO = await waitFor(() => wOff.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1557
- assert(!!exO && !/顺手形式化/.test(exO.prompt || ''), '★★ leanInitiative=off drops the daily line entirely (initiative is a separate axis from formalVerify, which is still encourage here)')
1558
- await wOff.call('vibe_math_pause', {})
1559
-
1560
- // (b)formalVerify:'off' + leanInitiative:'eager' ⇒ 日线仍必须注入(eager 是独立轴,不许被 off 静默吞掉),
1561
- // 而**验证阶段**的 Lean 文本仍然关闭。
1562
- const wOffEager = await makeCase('lean-init-off-eager')
1563
- await wOffEager.call('vibe_math_set_params', { formalVerify: 'off', leanInitiative: 'eager' })
1564
- await wOffEager.call('vibe_math_add_problem', { id: 'qInitOE', description: 'off+eager 主动性' })
1565
- await startScheduler(wOffEager)
1566
- const exOE = await waitFor(() => wOffEager.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1567
- const pOE = (exOE && exOE.prompt) || ''
1568
- assert(/主动档(leanInitiative=eager)/.test(pOE) && /三条筛选判据/.test(pOE), '★★★ formalVerify:off + leanInitiative:eager STILL injects the full daily line (v5 rule: the initiative axis is independent)')
1569
- assert(!/【Lean 形式化验证(/.test(pOE) && !wOffEager.spawns.some((s) => /【Lean 形式化验证(/.test(s.prompt || '')), '★★★ …while the verification-phase Lean block stays closed (formalVerify:off) in every captured prompt of that root')
1570
- const stOE = await wOffEager.call('vibe_math_status', {})
1571
- assert(stOE.params.formalVerify === 'off' && stOE.params.leanInitiative === 'eager', 'the off+eager case really ran with formalVerify:off + leanInitiative:eager')
1572
- await wOffEager.call('vibe_math_pause', {})
1573
- }
1574
-
1575
- // ---------- 15e. round-2 lens-1 RUNTIME: the initiative axis is injected at EVERY site ----------
1576
- // Three injection sites with three different shapes (explorer / rederive / solver). The static [sites]
1577
- // check can only see that the producer call is not textually gated; THIS section proves the actual
1578
- // prompt text of each site: (1) formalVerify:'off' + leanInitiative:'eager' must carry the daily line
1579
- // at that site (and keep the verification block closed), (2) leanInitiative:'off' must drop it there
1580
- // while the verification phase still follows formalVerify. Assertion names carry the site name, so a
1581
- // single-site regression names itself instead of hiding behind a sibling site's coverage.
1582
- const SITE_DAILY = /三条筛选判据/
1583
- const SITE_VERIFY = /【Lean 形式化验证(/
1584
- const FENCE = '`'.repeat(3)
1585
- const jsonReply = (obj) => FENCE + 'json\n' + JSON.stringify(obj) + '\n' + FENCE
1586
- /** Drive one problem through explorer -> solver(dead-end) -> re-derive and collect each site's prompt. */
1587
- async function driveSites(h, opts, qid) {
1588
- const q = qid || 'qSite'
1589
- await h.call('vibe_math_set_params', Object.assign({ tickIntervalMs: 200, verifierCount: 2, leanAsync: false, maxExplorerRetries: 5, maxParallelThreshold: 4 }, opts))
1590
- await h.call('vibe_math_add_problem', { id: q, description: '站点级运行时用例' })
1591
- await startScheduler(h)
1592
- const label = (n) => h.spawns.filter((s) => s.label.startsWith(n + ':' + q))
1593
- const ex1 = await waitFor(() => label('explorer')[0], 80, 200)
1594
- const out = { explorer: (ex1 && ex1.prompt) || '' }
1595
- if (ex1) h.fireEnd({ id: ex1.childId, runId: 'rS1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.8 }] }) }] })
1596
- const so = await waitFor(() => label('solver')[0], 80, 200)
1597
- out.solver = (so && so.prompt) || ''
1598
- if (so) h.fireEnd({ id: so.childId, runId: 'rS2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ status: 'dead-end', dead_end_reason: '站点用例:本方向不可行', solution: '', solution_probability: 0.1, lemmas: [], routes: [], lessons: [], survival_probability: 0.2, sub_questions: [], new_inventions: [{ 类型: '工具', 标题: '站点工具', 内容描述: 'x', 是否已入库: false }] }) }] })
1599
- // every direction is now dead-end ⇒ the NEXT explorer for this problem is built by rederivePrompt
1600
- await waitFor(() => label('explorer').length >= 2 && label('explorer')[1], 80, 200)
1601
- const exs = label('explorer')
1602
- out.rederive = (exs[1] && exs[1].prompt) || ''
1603
- out.distinct = !!out.explorer && !!out.rederive && out.explorer !== out.rederive
1604
- await h.call('vibe_math_abort', {})
1605
- return out
1606
- }
1607
- {
1608
- // (1) eager 在 formalVerify:'off' 下也必须在**每一个**站点注入日线
1609
- const he = await makeCase('site-eager-off')
1610
- const A = await driveSites(he, { formalVerify: 'off', leanInitiative: 'eager' })
1611
- assert(SITE_DAILY.test(A.explorer), '★ [explorer site] formalVerify:off + leanInitiative:eager injects the daily line')
1612
- assert(SITE_DAILY.test(A.solver), '★ [solver site] formalVerify:off + leanInitiative:eager injects the daily line')
1613
- assert(SITE_DAILY.test(A.rederive), '★ [rederive site] formalVerify:off + leanInitiative:eager injects the daily line')
1614
- assert(A.distinct, '★ [rederive site] the re-explore prompt really is the rederive builder (differs from the first explorer wave)')
1615
- assert(A.explorer.indexOf('【Lean 形式化验证(') === -1 && A.solver.indexOf('【Lean 形式化验证(') === -1 && A.rederive.indexOf('【Lean 形式化验证(') === -1, '★ [all sites] formalVerify:off keeps the verification-phase block closed while eager still injects the daily line')
1616
-
1617
- // (2) leanInitiative:'off' ⇒ 每个站点都没有日线(同一驱动)
1618
- const ho = await makeCase('site-init-off')
1619
- const B = await driveSites(ho, { formalVerify: 'encourage', leanInitiative: 'off' }, 'qSiteOff')
1620
- assert(!SITE_DAILY.test(B.explorer) && !/【顺手形式化/.test(B.explorer), '★ [explorer site] leanInitiative:off drops the daily line')
1621
- assert(!SITE_DAILY.test(B.solver) && !/【顺手形式化/.test(B.solver), '★ [solver site] leanInitiative:off drops the daily line')
1622
- assert(!SITE_DAILY.test(B.rederive) && !/【顺手形式化/.test(B.rederive), '★ [rederive site] leanInitiative:off drops the daily line')
1623
-
1624
- // (2b) …而验证段仍按 formalVerify 出现(同一参数下的另一个 root,驱动到 verifier)
1625
- const hv = await makeCase('site-init-off-verify')
1626
- await hv.call('vibe_math_set_params', { tickIntervalMs: 200, verifierCount: 2, leanAsync: false, formalVerify: 'encourage', leanInitiative: 'off' })
1627
- await hv.call('vibe_math_add_problem', { id: 'qV', description: '验证段仍在' })
1628
- await startScheduler(hv)
1629
- const exv = await waitFor(() => hv.spawns.find((s) => s.label.startsWith('explorer:qV')), 80, 200)
1630
- assert(!!exv && !SITE_DAILY.test(exv.prompt || ''), '★ [explorer site] 第二个 root:initiative off 时 explorer 仍无日线')
1631
- if (exv) hv.fireEnd({ id: exv.childId, runId: 'rV1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.8 }] }) }] })
1632
- const sov = await waitFor(() => hv.spawns.find((s) => s.label.startsWith('solver:qV')), 80, 200)
1633
- if (sov) hv.fireEnd({ id: sov.childId, runId: 'rV2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ status: 'success', solution: 'complete solution', solution_probability: 0.9, lemmas: [], routes: [], lessons: [], survival_probability: 0.9, dead_end_reason: null, sub_questions: [] }) }] })
1634
- const vv = await waitFor(() => hv.spawns.find((s) => /^verifier:/.test(s.label)), 100, 250)
1635
- assert(!!vv && SITE_VERIFY.test(vv.prompt || ''), '★ leanInitiative:off 时验证段仍按 formalVerify 注入(verifier 提示词含【Lean 形式化验证()')
1636
- await hv.call('vibe_math_abort', {})
1637
- }
1638
-
1
+ // ============================================================
2
+ // V2 LEAN FORMAL VERIFICATION SUITE (docs/formal-verification.md)
3
+ //
4
+ // Asserts the whole contract of the v2 `formalVerify` knob:
5
+ // · 'off' is a TRUE no-op: no Lean text in ANY prompt it builds, no formal record,
6
+ // no gate — while the three tools stay registered (registration is static)
7
+ // · 'encourage' injects the Lean section into BOTH the review and the debate prompt and the
8
+ // "顺手形式化" line into the solver/explorer prompts, and — the actual point of
9
+ // the feature — turns the review into a FIDELITY check once a Lean run passed
10
+ // · 'require' withholds a true/false verdict (未定论 + Formal/TODO.md + announcement) until
11
+ // the object is Lean-passed or carries an explicit, reasoned blocker; then the
12
+ // same verdict DOES write the card, and the card records the formal status
13
+ // · the three tools (run / archive / lib) write the right things to the right paths
14
+ // · the REPLY CHANNEL is real, not dead code (contract §4 / §6.3 / §10.8): a verifier's
15
+ // `formal:{decision:'blocked'|'defect', note}` reply is actually absorbed into the durable
16
+ // record for BOTH id spaces, a missing note is refused, and `defect` (a fidelity defect,
17
+ // i.e. the Lean code does not say what the proposition says) downgrades the proof, deletes
18
+ // the archived file, writes the TODO and defers the verdict — it is NEVER recorded as
19
+ // "the proposition is false" (contract §4.1)
20
+ //
21
+ // The Lean toolchain is mocked through the subprocess SERVICE (a fake Lean: exit 0 unless the
22
+ // file still contains `sorry` or the marker `-- FAIL`), so these tests exercise the REAL code
23
+ // path (resolveExecutable → spawn → collected stdout → exit code) without Lean installed.
24
+ //
25
+ // The plugin path honours V2_PLUGIN so a sensitivity probe can point this suite at a mutated
26
+ // copy of the plugin (a suite that ignored the override would make every probe vacuous —
27
+ // AUDIT-CHECKLIST §2.5).
28
+ //
29
+ // Run: node tests/formal-verify-v2.test.mjs
30
+ // ============================================================
31
+ import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, statSync, mkdirSync, renameSync } from 'node:fs'
32
+ import { tmpdir } from 'node:os'
33
+ import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
34
+ import { fileURLToPath, pathToFileURL } from 'node:url'
35
+
36
+ // Concurrency isolation: the shipped corpus is read by other probes (audit-prompt-invariants) while
37
+ // this suite runs under the gate, so the write must be atomic — a reader must never see a truncated file.
38
+ function writeFileAtomic(target, text) {
39
+ const tmp = target + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2, 8)
40
+ writeFileSync(tmp, text, 'utf8')
41
+ renameSync(tmp, target)
42
+ }
43
+
44
+
45
+ // V2_PLUGIN may be a REPO-RELATIVE path or an ABSOLUTE one (a mutant copy outside the repo, e.g. a
46
+ // temp dir). The previous `new URL('file:///' + path)` form only worked for drive-qualified Windows
47
+ // paths and made out-of-repo variants impossible; this is the same isAbsolute-aware resolution the
48
+ // `math-computation-v*` suites use. Default behaviour (env var unset) is unchanged.
49
+ const PLUGIN = process.env.V2_PLUGIN
50
+ ? pathToFileURL(isAbsolute(process.env.V2_PLUGIN) ? process.env.V2_PLUGIN : join(dirname(fileURLToPath(import.meta.url)), '..', process.env.V2_PLUGIN))
51
+ : new URL('../vibe-math-v2/vibe-math-v2.js', import.meta.url)
52
+ // 纯函数测试面(__testHelpers):参数归一化/搜索路径计划/哈希/指纹都从这里守卫。
53
+ const MOD = await import(PLUGIN.href)
54
+ const HERE = dirname(fileURLToPath(import.meta.url))
55
+ // Human-reviewable corpus (contract §10.10). A sensitivity probe runs THIS suite against a
56
+ // MUTATED plugin copy: writing the repository corpus from such a run would replace the
57
+ // reviewed text with mutated text, so a mutated run goes to a scratch directory instead
58
+ // (and V2_CORPUS_DIR overrides both, exactly like the v3 suite's V3_CORPUS_DIR).
59
+ const CORPUS_DIR = process.env.V2_CORPUS_DIR
60
+ ? pathResolve(process.env.V2_CORPUS_DIR)
61
+ : (process.env.V2_PLUGIN ? join(tmpdir(), 'vibe-v2-prompt-corpus') : join(HERE, '..', 'prompt-corpus-v2'))
62
+
63
+ let passed = 0, failed = 0
64
+ const failures = []
65
+ const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
66
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
67
+ const section = (t) => console.log('\n[' + t + ']')
68
+
69
+ // ---------------------------------------------------------------
70
+ // the fake Lean toolchain (mocked at the SERVICE boundary)
71
+ // ---------------------------------------------------------------
72
+ let toolchainAvailable = true
73
+ const leanRuns = []
74
+ const terminations = []
75
+ const subprocess = {
76
+ async resolveExecutable(cmd) {
77
+ if (!toolchainAvailable) throw new Error('spawn lean ENOENT')
78
+ if (String(cmd) !== 'lean') throw new Error('unknown executable ' + cmd)
79
+ return 'lean'
80
+ },
81
+ spawn(spec) {
82
+ const last = spec.argv[spec.argv.length - 1]
83
+ const script = String(last || '')
84
+ // v2 creates its directory tree through this SAME subprocess service
85
+ // (`powershell … New-Item -Force -ItemType Directory -Path 'a','b'`). Without honouring
86
+ // it, the Formal/ and Verified/Lean/ assertions would be vacuous: they would fail for the
87
+ // wrong reason (a mocked no-op) instead of a real defect.
88
+ if (/New-Item/.test(script)) {
89
+ const m = /-Path\s+(.+?)\s*(\||$)/.exec(script)
90
+ if (m) m[1].split(',').forEach((p) => { const q = p.trim().replace(/^'|'$/g, '').replace(/''/g, "'"); if (q) mkdirSync(q, { recursive: true }) })
91
+ return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
92
+ }
93
+ if (/mkdir -p /.test(script)) {
94
+ const m = /mkdir -p (.+)$/.exec(script)
95
+ if (m) m[1].split(/\s+/).forEach((p) => { const q = p.trim().replace(/^'|'$/g, ''); if (q) mkdirSync(q, { recursive: true }) })
96
+ return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
97
+ }
98
+ // v2 ALSO deletes files through this same service (`powershell … Remove-Item -LiteralPath 'x'`,
99
+ // POSIX `rm -f 'x'`). Without honouring it, the `defect` assertion "the archived proof is
100
+ // gone" would pass vacuously (nothing was ever deleted) instead of testing the real code path.
101
+ if (/Remove-Item/.test(script)) {
102
+ const m = /-LiteralPath\s+'((?:[^']|'')*)'/.exec(script)
103
+ if (m) { try { rmSync(m[1].replace(/''/g, "'"), { force: true }) } catch (e) { /* best effort */ } }
104
+ return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
105
+ }
106
+ if (/^\s*rm -f /.test(script)) {
107
+ const re = /'((?:[^']|'\\'')*)'/g
108
+ let m
109
+ while ((m = re.exec(script)) !== null) { try { rmSync(m[1].replace(/'\\''/g, "'"), { force: true }) } catch (e) { /* best effort */ } }
110
+ return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
111
+ }
112
+ const text = existsSync(last) ? readFileSync(last, 'utf8') : ''
113
+ const bad = /sorry|-- FAIL/.test(text)
114
+ leanRuns.push({ argv: spec.argv.slice(0, -1), file: last, cwd: spec.cwd, graceMs: spec.graceMs, stdio: spec.stdio })
115
+ const stdout = bad ? '' : 'ok\n'
116
+ const stderr = bad ? 'error: declaration uses sorry\n' : ''
117
+ if (/-- HANG/.test(text)) {
118
+ // A run that never finishes by itself. It settles ONLY when the plugin actively terminates it
119
+ // (contract §7: "对超时调用 handle.terminate()"), with a bounded 2.5 s fallback so a plugin
120
+ // that FORGETS to terminate FAILS the terminate/speed assertions instead of hanging this suite.
121
+ let settled = false
122
+ let finish
123
+ const done = new Promise((resolve) => { finish = resolve })
124
+ const fallback = REAL_SET_TIMEOUT(() => { if (!settled) { settled = true; finish({ exitCode: null, signal: 'SIGKILL' }) } }, 2500)
125
+ return {
126
+ done,
127
+ collected: {
128
+ stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) },
129
+ stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) },
130
+ },
131
+ terminate() {
132
+ if (settled) return
133
+ settled = true
134
+ REAL_CLEAR_TIMEOUT(fallback)
135
+ terminations.push({ file: last })
136
+ finish({ exitCode: null, signal: 'SIGTERM' })
137
+ },
138
+ }
139
+ }
140
+ return {
141
+ done: Promise.resolve({ exitCode: bad ? 1 : 0, signal: null }),
142
+ collected: {
143
+ stdout: { readFrom: () => ({ text: stdout, nextOffset: stdout.length, lossy: false }) },
144
+ stderr: { readFrom: () => ({ text: stderr, nextOffset: stderr.length, lossy: false }) },
145
+ },
146
+ terminate() {},
147
+ }
148
+ },
149
+ }
150
+
151
+ // ---------------------------------------------------------------
152
+ // mock host (v2 standing-mount shape: one plugin instance, many sessions)
153
+ // ---------------------------------------------------------------
154
+ function makeHost(WS, opts = {}) {
155
+ const listeners = {}
156
+ const toolRegs = []
157
+ const cmdRegs = []
158
+ const spawns = []
159
+ const followups = []
160
+ const interrupts = []
161
+ const disposers = []
162
+ const ctx = {
163
+ get(name) {
164
+ if (name === 'subprocess') return opts.noSubprocess ? undefined : subprocess
165
+ if (name === 'sandboxPolicy') return { workspaceRoot: WS, resolve: () => ({ workspaceRoot: WS }) }
166
+ return undefined
167
+ },
168
+ on(event, fn) { (listeners[event] = listeners[event] || []).push(fn) },
169
+ effect(fn) { const d = fn(); const off = () => { if (typeof d === 'function') d() }; disposers.push(off); return off },
170
+ logger: { info() {}, warn() {}, error() {} },
171
+ tools: { register(spec) { toolRegs.push(spec); return () => {} } },
172
+ commands: { register(spec) { cmdRegs.push(spec); return () => {} } },
173
+ timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
174
+ subagents: {
175
+ list() { return ['spawn'] },
176
+ async startContinuable({ label, request }) {
177
+ const childId = 'child-' + (spawns.length + 1) + '-' + Math.random().toString(36).slice(2, 6)
178
+ spawns.push({ label, childId, rootId: (request && request.parent && request.parent.id) || 'root', prompt: request && request.prompt && request.prompt[0] && request.prompt[0].text })
179
+ return { childId }
180
+ },
181
+ async followup(parent, childId, blocks) { followups.push({ childId, prompt: (blocks && blocks[0] && blocks[0].text) || '' }) },
182
+ async sendMessage(parent, childId, blocks) { followups.push({ childId, prompt: (blocks && blocks[0] && blocks[0].text) || '' }) },
183
+ interrupt(childId) { interrupts.push(childId) },
184
+ },
185
+ agents: { roots() { return [] }, get() { return undefined } },
186
+ fs: {
187
+ async resolve(rel, o) {
188
+ const raw = String(rel)
189
+ const p = isAbsolute(raw) ? raw : join((o && o.cwd) || WS, ...raw.split('/'))
190
+ return p.replace(/\\/g, '/')
191
+ },
192
+ async stat(t) { if (!existsSync(t)) return undefined; return { type: statSync(t).isDirectory() ? 'directory' : 'file' } },
193
+ async readText(t) { return readFileSync(t, 'utf8') },
194
+ async writeText(t, c) { mkdirSync(dirname(t), { recursive: true }); writeFileSync(t, c, 'utf8') },
195
+ async listDir(t) { if (!existsSync(t)) return []; return readdirSync(t, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
196
+ },
197
+ }
198
+ const fireEnd = (info) => { for (const h of (listeners['subagent/end'] || [])) h(info) }
199
+ return { ctx, toolRegs, cmdRegs, spawns, followups, interrupts, disposers, unmount: () => { for (const d of disposers) { try { d() } catch (e) { /* ignore */ } } }, fireEnd, WS }
200
+ }
201
+
202
+ function makeRoot(id, WS) {
203
+ // `pushed` records the framework's OWN messages to the main agent (`rootAgent.followup`), i.e. the
204
+ // reportMode=push progress report. It used to be a no-op, which is why that channel had no corpus
205
+ // entry at all (AUDIT §2.4 "框架→人 的反馈与推送类提示").
206
+ const pushed = []
207
+ return { id, options: { provider: 'mock', model: 'mock' }, session: { id, header: { cwd: WS, parentSession: undefined } }, pushed, followup(m) { const c = (m && m.content) || []; pushed.push(c.map((b) => (b && b.text) || '').join('')) } }
208
+ }
209
+
210
+ const hosts = []
211
+ /**
212
+ * Build an INDEPENDENT plugin instance + workspace for one test case.
213
+ * AUDIT-CHECKLIST §2.3: cases must not share a session root/workspace, or a leftover
214
+ * heartbeat/verification from one case pollutes the next.
215
+ */
216
+ async function makeCase(label, opts = {}) {
217
+ const WS = mkdtempSync(join(tmpdir(), 'vibe-v2-lean-' + label + '-'))
218
+ const h = makeHost(WS, opts)
219
+ const mod = await import(PLUGIN.href + '?t=' + Date.now() + '-' + Math.random().toString(36).slice(2, 8))
220
+ ;(mod.default || mod).apply(h.ctx)
221
+ const root = makeRoot('sess-' + label, WS)
222
+ const call = async (name, args, agent) => {
223
+ const spec = h.toolRegs.find((s) => s.name === name)
224
+ if (!spec) throw new Error('no tool ' + name)
225
+ return JSON.parse(await spec.execute(args || {}, { agent: agent || root }))
226
+ }
227
+ await call('vibe_math_new_project', { name: 'proj' })
228
+ // 200ms is the sanitizer floor; verifierCount=2 (the sanitizer floor too) keeps each
229
+ // verification exactly two children, so a round settles predictably.
230
+ await call('vibe_math_set_params', { tickIntervalMs: 200, verifierCount: 2 })
231
+ // leanAsync defaults to TRUE (background queue). This suite's Lean sections guard the
232
+ // **synchronous** path verbatim, so every case pins leanAsync=false here; the async state
233
+ // machine (default档) is guarded by its own section, which flips it back on explicitly.
234
+ await call('vibe_math_set_params', { leanAsync: false })
235
+ const c = Object.assign(h, { WS, root, call, label })
236
+ hosts.push(c)
237
+ return c
238
+ }
239
+ // The scheduler only picks objects up while it is RUNNING (scheduleTick early-returns).
240
+ async function startScheduler(h) { await h.call('vibe_math_start', {}) }
241
+
242
+ // ── test speed: fast-forward the SCHEDULER POLL (test-only, no production impact) ─────────
243
+ // The plugin registers its scheduler poll as `setInterval(..., 1000)` at apply() time, so every
244
+ // suite-side `tick()` had to wait a full wall-clock second. With ~9 verification rounds × ~17
245
+ // ticks that alone accounted for ~99% of this suite's 186 s (its v3/v4/v5 siblings take seconds).
246
+ // Patching ONLY setInterval (the suite's own `sleep` uses setTimeout) makes a poll cost ~25 ms,
247
+ // while the plugin's due-ness logic still uses the real 200 ms `tickIntervalMs` floor — no
248
+ // scheduler behaviour depends on the poll period, and `tickTheScheduler` below stays above it.
249
+ const REAL_SET_INTERVAL = globalThis.setInterval
250
+ globalThis.setInterval = function (fn, ms, ...rest) {
251
+ return REAL_SET_INTERVAL(fn, Math.min(Number(ms) || 0, 25), ...rest)
252
+ }
253
+ // ── test speed / leak check: track OUTSTANDING setTimeout handles (test-only) ──────────────
254
+ // `leanRunFile` races `handle.done` against its own `cap`-ms timer; forgetting to clear that timer on
255
+ // the normal path leaves one pending multi-minute timer PER RUN — a leak no output assertion can see.
256
+ // The plugin resolves the global at call time, so wrapping it here covers it. The mock's own bounded
257
+ // fallback uses the REAL functions, so it never pollutes the count.
258
+ const REAL_SET_TIMEOUT = globalThis.setTimeout
259
+ const REAL_CLEAR_TIMEOUT = globalThis.clearTimeout
260
+ const liveTimers = new Set()
261
+ globalThis.setTimeout = function (fn, ms, ...rest) {
262
+ let h
263
+ h = REAL_SET_TIMEOUT(function (...a) { liveTimers.delete(h); return fn.apply(this, a) }, ms, ...rest)
264
+ liveTimers.add(h)
265
+ return h
266
+ }
267
+ globalThis.clearTimeout = function (h) { liveTimers.delete(h); return REAL_CLEAR_TIMEOUT(h) }
268
+
269
+ const projRoot = (h) => join(h.WS, 'VibeMath', 'Projects', 'proj')
270
+ const vibeRoot = (h) => join(h.WS, 'VibeMath')
271
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '')
272
+ const formalStateOf = (h) => JSON.parse(readIf(join(projRoot(h), 'VibeMath_State', 'formal.json')) || '{}')
273
+ // One scheduler pass. With the poll fast-forwarded above, the poll fires every ~25 ms and a tick
274
+ // runs whenever the plugin's own 200 ms `tickIntervalMs` floor has elapsed, so 260 ms is one pass.
275
+ const tick = (ms = 260) => sleep(ms)
276
+ async function waitFor(pred, tries = 80, ms = 60) {
277
+ for (let i = 0; i < tries; i++) { const v = pred(); if (v) return v; await sleep(ms) }
278
+ return undefined
279
+ }
280
+ const verifiersOf = (h, rKind, exclude) => h.spawns.filter((s) => s.label.startsWith('verifier:' + rKind) && !(exclude || []).some((o) => o.childId === s.childId))
281
+ /** A reply exactly as an agent would emit it (a fenced JSON block) — the framework's real input. */
282
+ const fence = (obj) => '```json\n' + JSON.stringify(obj) + '\n```'
283
+ /** Feed ONE agent reply (a fresh turn's end) to the framework through the real dispatch path. */
284
+ const replyFrom = (h, childId, obj) => h.fireEnd({
285
+ id: childId, runId: 'r-' + childId, provider: 'spawn', local: true, stopReason: 'completed',
286
+ lastAssistantMessage: [{ type: 'text', text: fence(obj) }],
287
+ })
288
+ const fireVerdicts = (h, kids, vale) => {
289
+ for (let i = 0; i < kids.length; i++) {
290
+ h.fireEnd({ id: kids[i].childId, runId: 'v' + i, provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n' + JSON.stringify({ Result: vale, Reason: 'review ' + i }) + '\n```' }] })
291
+ }
292
+ }
293
+ /**
294
+ * Verify one object and DRIVE IT TO A SETTLEMENT.
295
+ *
296
+ * Important: v2's `processVerify` starts at most ONE verification and skips every candidate
297
+ * while a task is live, so leaving an object mid-debate would silently block the NEXT case's
298
+ * verification. The first `firstRounds` rounds are answered with `vale` (a non-unanimous value
299
+ * forces a real debate round, which is exactly what the debate-prompt assertions need), and any
300
+ * later round is answered with `settle` so the object actually closes.
301
+ */
302
+ async function verifyWithDebate(h, rKind, vale, firstRounds = 1, settle = 1) {
303
+ // v2 re-wakes the SAME verifier children for every debate round (it does not spawn new
304
+ // ones), so a new round is detected by counting the followups written to those children.
305
+ const kids = new Set()
306
+ // Early exit: a settled object stops producing followups. Without this the loop always spent
307
+ // its FULL 16 passes (~21 s at the old 1.3 s/pass), which is what made this suite take 3
308
+ // minutes: the debate is over, but the helper kept ticking at nothing. Three consecutive
309
+ // passes with no new followup = the framework has nothing left to ask.
310
+ let lastN = -1
311
+ let quiet = 0
312
+ for (let i = 0; i < 16; i++) {
313
+ // The scheduler may need a whole pass before it notices the object and another before it
314
+ // fills the verifier quota, so each wait must span several timer ticks.
315
+ const cand = await waitFor(() => {
316
+ const x = verifiersOf(h, rKind)
317
+ return x.length >= 2 ? x : undefined
318
+ }, 40, 250)
319
+ if (!cand) break
320
+ cand.forEach((s) => kids.add(s.childId))
321
+ const rounds = 1 + h.followups.filter((f) => kids.has(f.childId)).length
322
+ // `fireEnd` is a NO-OP for a child with no in-flight turn, so answering the whole current
323
+ // verifier set each pass is safe; the extra answers land before the next round's wakes.
324
+ fireVerdicts(h, cand, rounds <= firstRounds ? vale : settle)
325
+ await sleep(200)
326
+ await tick()
327
+ if (h.followups.length === lastN) { if (++quiet >= 3) break } else { quiet = 0; lastN = h.followups.length }
328
+ }
329
+ const rounds = 1 + h.followups.filter((f) => kids.has(f.childId)).length
330
+ return { first: h.spawns.filter((s) => s.label.startsWith('verifier:' + rKind)), rounds: kids.size ? rounds : 0 }
331
+ }
332
+
333
+ // ===============================================================
334
+ console.log('-- V2 Lean formal verification --')
335
+
336
+ // ---------- 1. 'off' is a true no-op ----------
337
+ section("1 'off' (default) is a true no-op")
338
+ {
339
+ const h = await makeCase('off')
340
+ const st = await h.call('vibe_math_status', {})
341
+ assert(st.params.formalVerify === 'off', "the default mode is 'off' (got " + st.params.formalVerify + ')')
342
+ assert(st.formal.mode === 'off' && st.formal.required === false, 'status exposes the formal mode and gate flag')
343
+ assert(st.params.leanCommand === 'lean' && Array.isArray(st.params.leanArgs), 'the Lean knobs are readable in status.params')
344
+ assert(st.params.leanTimeoutMs === 120000, 'leanTimeoutMs defaults to 120000 (got ' + st.params.leanTimeoutMs + ')')
345
+ 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'),
346
+ 'the three Lean tools are registered in every mode (registration is static)')
347
+ // ★ The mode switch must be REACHABLE THROUGH THE TOOL SCHEMA (2.3.2 defect D1) ──────────────
348
+ // Every tool schema here is closed (`additionalProperties:false`), so a key the schema does not
349
+ // advertise is REJECTED by any schema-validating provider. v3 shipped 2.3.0/2.3.1 with all four Lean
350
+ // parameters missing from the set-params schema while every assertion in this file stayed green —
351
+ // because the suite calls the handler DIRECTLY and never inspects the registered schema. The feature
352
+ // could not be switched on at all through the tool interface.
353
+ {
354
+ const setSpec = h.toolRegs.find((t) => t.name === 'vibe_math_set_params')
355
+ assert(!!setSpec, "vibe_math_set_params is registered")
356
+ assert(setSpec.parameters && setSpec.parameters.type === 'object' && setSpec.parameters.additionalProperties === false,
357
+ '★ vibe_math_set_params publishes a CLOSED object schema (an unlisted key is rejected, so the schema IS the contract)')
358
+ for (const k of ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']) {
359
+ assert(Object.prototype.hasOwnProperty.call(setSpec.parameters.properties, k),
360
+ '★ the registered schema advertises ' + k + ' (every other surface documents it; a schema that omits it makes the switch unreachable)')
361
+ }
362
+ assert(JSON.stringify(setSpec.parameters.properties.formalVerify.enum) === JSON.stringify(['off', 'encourage', 'require']),
363
+ 'the schema narrows formalVerify to the three real modes (a typo must not be a fourth)')
364
+ }
365
+
366
+ 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')
367
+ assert(existsSync(join(projRoot(h), 'Formal')) && existsSync(join(projRoot(h), 'Verified', 'Lean')), 'the project Formal/ and Verified/Lean/ dirs are created')
368
+ await h.call('vibe_math_add_problem', { id: 'q1', description: 'off 模式无操作测试' })
369
+ await startScheduler(h)
370
+ await tick(2000)
371
+ const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:q1')), 60, 200)
372
+ assert(!!ex, 'the explorer was spawned (the scheduler really is running)')
373
+ assert(!!ex && !/Lean|形式化/.test(ex.prompt || ''), 'the explorer prompt contains no Lean/形式化 text in off mode')
374
+ if (ex) h.fireEnd({ id: ex.childId, runId: 'r1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"directions":[{"id":"d1","title":"D","method":"m","core_assumption":"c","feasibility":0.6}]}\n```' }] })
375
+ await sleep(300)
376
+ const so = await waitFor(() => h.spawns.find((s) => s.label.startsWith('solver:q1')), 40, 200)
377
+ assert(!!so, 'the solver was spawned after the explorer returned directions')
378
+ assert(!!so && !/Lean|形式化/.test(so.prompt || ''), 'the solver prompt contains no Lean/形式化 text in off mode')
379
+ if (so) h.fireEnd({ id: so.childId, runId: 'r2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"status":"success","solution":"complete solution","solution_probability":0.8,"lemmas":[],"routes":[],"lessons":[],"survival_probability":0.9,"dead_end_reason":null,"sub_questions":[]}\n```' }] })
380
+ const vs = await verifyWithDebate(h, 'r-q1-s0', 1)
381
+ assert(!!vs.first && vs.first.length >= 2, 'verifiers were spawned for the solution (' + (vs.first ? vs.first.length : 0) + ')')
382
+ const vp = vs.first ? (h.spawns.find((s) => s.label === 'verifier:r-q1-s0:0').prompt || '') : ''
383
+ assert(!/Lean|形式化/.test(vp), 'the review prompt contains no Lean/形式化 text in off mode')
384
+ const debateText = h.followups.map((f) => f.prompt || '').join('\n')
385
+ assert(!/Lean|形式化/.test(debateText), 'no debate/followup prompt mentions Lean in off mode')
386
+ const qs = JSON.parse(readIf(join(projRoot(h), 'qs', 'qs.json')) || '[]')
387
+ const q = qs.find((x) => x.id === 'q1')
388
+ assert(!!q && q.已解决 === true, "'off' still finalizes: the problem is marked solved with NO Lean artifact")
389
+ assert(!!q && q.解法列表 && q.解法列表[0].正确概率 === 1, 'the solution probability is 1 in off mode')
390
+ assert(existsSync(join(projRoot(h), 'Verified', '问题_Verified.json')), 'the Verified card was written in off mode')
391
+ assert(!/形式化/.test(readIf(join(projRoot(h), 'Verified', '问题_Verified.json'))), 'the card carries no formal line in off mode')
392
+ assert(!existsSync(join(projRoot(h), 'Formal', 'TODO.md')), 'no Formal/TODO.md is produced in off mode')
393
+ const fs0 = formalStateOf(h)
394
+ assert(!fs0.records || Object.keys(fs0.records).length === 0, 'off mode records no formal object state')
395
+ }
396
+
397
+ // ---------- 2. parameter validation + runtime switching ----------
398
+ section('2 parameter validation and runtime switching')
399
+ {
400
+ const h = await makeCase('params')
401
+ const bad = await h.call('vibe_math_set_params', { formalVerify: 'banana' })
402
+ assert(bad.params.formalVerify === 'off', "an unknown mode degrades to 'off', never to a stronger mode (got " + bad.params.formalVerify + ')')
403
+ const enc = await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
404
+ assert(enc.params.formalVerify === 'encourage', "'encourage' is accepted")
405
+ const req = await h.call('vibe_math_set_params', { formalVerify: 'require', leanTimeoutMs: -5, leanCommand: ' ' })
406
+ assert(req.params.formalVerify === 'require', "'require' is accepted")
407
+ assert(req.params.leanTimeoutMs === 120000, 'a non-positive leanTimeoutMs falls back to the default (' + req.params.leanTimeoutMs + ')')
408
+ assert(req.params.leanCommand === 'lean', 'a blank leanCommand falls back to "lean"')
409
+ const lake = await h.call('vibe_math_set_params', { leanCommand: 'lake', leanArgs: ['env', 'lean'] })
410
+ assert(lake.params.leanCommand === 'lake' && lake.params.leanArgs.join(' ') === 'env lean', 'leanCommand/leanArgs are settable (lake env lean)')
411
+ const st = await h.call('vibe_math_status', {})
412
+ assert(st.formal.leanCommand === 'lake' && st.formal.leanArgs.join(' ') === 'env lean', 'the Lean knobs round-trip through status')
413
+ const setup = await h.call('vibe_math_setup', {})
414
+ const names = setup.parameters.map((p) => p.name)
415
+ assert(['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs'].every((n) => names.indexOf(n) !== -1), 'vibe_math_setup schema lists all four Lean knobs')
416
+ const fv = setup.parameters.find((p) => p.name === 'formalVerify')
417
+ assert(!!fv && fv.current === 'require' && fv.default === 'off', 'the schema reports the current and default formalVerify')
418
+ const tpl = await h.call('vibe_math_template', { where: 'global' })
419
+ const tplText = readIf(tpl.path)
420
+ assert(/formalVerify/.test(tplText) && /leanCommand/.test(tplText) && /leanTimeoutMs/.test(tplText), 'the settings template documents the Lean knobs')
421
+ assert(/leanArgs/.test(tplText), 'the settings template also documents leanArgs')
422
+ assert(!/:\s*undefined/.test(tplText), 'the template has no bare undefined token (settings round-trip safety)')
423
+ const saved = await h.call('vibe_math_save_settings', {})
424
+ assert(saved.ok === true, 'save_settings still round-trips with the new knobs')
425
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
426
+ await h.call('vibe_math_add_problem', { id: 'q2', description: '模式切换测试' })
427
+ await startScheduler(h)
428
+ await tick(2000)
429
+ const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:q2')), 60, 200)
430
+ assert(!!ex && /【顺手形式化(鼓励[^)]*)】/.test(ex.prompt || ''), '★ switching to encourage changes the NEXT prompt immediately')
431
+ if (ex) h.fireEnd({ id: ex.childId, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"directions":[{"id":"d1","title":"D","method":"m","core_assumption":"c","feasibility":0.5}]}\n```' }] })
432
+ await h.call('vibe_math_set_params', { formalVerify: 'off' })
433
+ await sleep(400)
434
+ const so = await waitFor(() => h.spawns.find((s) => s.label.startsWith('solver:q2')), 40, 200)
435
+ assert(!!so, 'the solver was spawned after the mode was switched back to off')
436
+ assert(!!so && !/形式化|Lean/.test(so.prompt || ''), '★ switching back to off removes the Lean text from the next prompt')
437
+ }
438
+
439
+ // ---------- 3. 'encourage' injection ----------
440
+ section("3 'encourage' injects the Lean section into review AND debate prompts")
441
+ {
442
+ const h = await makeCase('enc')
443
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage', maxParallelThreshold: 8 })
444
+ // Keep the scheduler alive: v2 STOPS itself when there is no unsolved problem, no agent and
445
+ // no task, and a stopped scheduler never starts a verification (nor spawns an explorer).
446
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
447
+ await startScheduler(h)
448
+ await h.call('vibe_math_add_proposition', { id: 'pEnc', 概述: '鼓励模式下的忠实性审查', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
449
+ const vs = await verifyWithDebate(h, 'r-pEnc', 0.5, 1) // 0.5 -> no consensus -> a REAL debate round
450
+ assert(!!vs.first, 'verifiers spawned for the bare proposition')
451
+ assert(vs.rounds >= 2, 'the object really went through a debate round (' + vs.rounds + ' rounds)')
452
+ const review = h.spawns.find((s) => s.label === 'verifier:r-pEnc:0')
453
+ assert(!!review, 'the first review prompt was captured')
454
+ const reviewText = review ? review.prompt : ''
455
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(reviewText), '★ the REVIEW prompt carries the Lean section')
456
+ assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(reviewText), 'the review prompt states that a passing Lean run shrinks the question to fidelity')
457
+ assert(/实现难度/.test(reviewText), 'the review prompt asks for the implementation-difficulty judgement')
458
+ // NOTE: this assertion is only the WORDING half. Which is exactly the trap the first version
459
+ // of this feature fell into (a green suite guarding a dead channel). The behaviour — the reply
460
+ // really landing in the durable record — is asserted in section 11.
461
+ assert(/可以不做,但请在回执的 formal 字段写明难度判断/.test(reviewText), "'encourage' explicitly allows skipping (with a recorded judgement)")
462
+ assert(/"decision":"used\|blocked\|defect"/.test(reviewText), '★ the review contract lists the real decision enum (incl. defect)')
463
+ assert(/vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库\/jobs)· vibe_math_lean_read(取回归档原文)/.test(reviewText), 'the review prompt names all four v2 tools (incl. the new read-only one)')
464
+ assert(/归档可复用定义\/引理前先跑通(vibe_math_lean_archive run=true 或先 vibe_math_lean_run)/.test(reviewText), '★ the review prompt says a reusable artifact must run green BEFORE it is archived')
465
+ assert(/LEAN_NOT_FOUND/.test(reviewText) && /宿主无 Lean 工具链/.test(reviewText), '★ the review prompt writes out the missing-toolchain escape hatch (a host without Lean must not deadlock the agent)')
466
+ const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
467
+ assert(/DEBATE/.test(debate), 'the debate round actually happened (a followup with the debate prompt was issued)')
468
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(debate), '★ the DEBATE prompt carries the Lean section too')
469
+ assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(debate), 'the debate prompt states the fidelity shift')
470
+ await h.call('vibe_math_add_problem', { id: 'qW', description: '顺手形式化测试' })
471
+ await tick(2000)
472
+ const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:qW')), 60, 200)
473
+ assert(!!ex && /【顺手形式化(鼓励[^)]*)】/.test(ex.prompt || ''), '★ the explorer work prompt carries the 顺手形式化 line')
474
+ assert(!!ex && /vibe_math_lean_archive kind='def'/.test(ex.prompt || ''), 'the work line points at the archive tool for reusable definitions')
475
+ assert(!!ex && /vibe_math_lean_lib 查重/.test(ex.prompt || ''), 'the work line tells members to check the reuse library first')
476
+ assert(!!ex && /归档前先跑通(vibe_math_lean_run 或 run=true);跑不通的定义不要进可复用库。/.test(ex.prompt || ''), '★ the work line forbids archiving a definition that has not run green')
477
+ assert(!!ex && /"formal":\{"target":"<对象id>","decision":"used\|blocked\|defect"/.test(ex.prompt || ''), '★ the WORK-round contract advertises the formal reply field too (otherwise a working agent has nowhere to write its judgement)')
478
+ // Every agent-facing string must name the REGISTERED tools: an abbreviated `lean_archive`
479
+ // is not a tool that exists, and agents copy these literals verbatim (contract §6 hard req. 1).
480
+ const workText = (ex.prompt || '') + '\n' + reviewText + '\n' + debate
481
+ assert(!/(^|[^a-z_])lean_(run|archive|lib)/.test(workText), '★ no injected prompt names an abbreviated tool (every occurrence is prefixed)')
482
+ const offHost = await makeCase('enc-off')
483
+ await offHost.call('vibe_math_add_problem', { id: 'qN', description: 'x' })
484
+ await startScheduler(offHost)
485
+ await tick(2000)
486
+ const exN = await waitFor(() => offHost.spawns.find((s) => s.label.startsWith('explorer:qN')), 60, 200)
487
+ assert(!!exN && !/形式化|Lean/.test(exN.prompt || ''), 'the same prompt in off mode is free of Lean text (injection is mode-computed, not frozen)')
488
+ }
489
+
490
+ // ---------- 4. the run tool ----------
491
+ section('4 lean_run executes through the subprocess service and reports honestly')
492
+ {
493
+ const h = await makeCase('run')
494
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
495
+ // 这一节把 leanAsync 固定为 false:它逐字守卫**同步路径**(旧的 await 语义)。
496
+ // 异步(默认档)的接口与状态机由后面的「Lean async」小节守卫——两条路径都要有绿灯。
497
+ await h.call('vibe_math_set_params', { leanAsync: false })
498
+ assert((await h.call('vibe_math_status', {})).params.leanAsync === false, 'the sync section pins leanAsync=false explicitly')
499
+ const proj = projRoot(h)
500
+ mkdirSync(join(proj, 'Formal'), { recursive: true })
501
+ writeFileSync(join(proj, 'Formal', 'good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
502
+ writeFileSync(join(proj, 'Formal', 'bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
503
+ const before = leanRuns.length
504
+ const runGood = await h.call('vibe_math_lean_run', { file: 'Formal/good.lean', target: 'pRun' })
505
+ assert(runGood.ok === true && runGood.exitCode === 0, 'a file with no sorry runs green (' + JSON.stringify({ ok: runGood.ok, exitCode: runGood.exitCode }) + ')')
506
+ assert(leanRuns.length > before, 'the run really went through the mocked subprocess service')
507
+ const spec = leanRuns[leanRuns.length - 1]
508
+ assert(spec.cwd.replace(/\\/g, '/') === proj.replace(/\\/g, '/'), 'the toolchain runs with the PROJECT root as cwd (got ' + spec.cwd + ')')
509
+ assert(!!spec.stdio && spec.stdio.stdin === 'ignore' && spec.stdio.stdout.maxBytes === 64 * 1024 && spec.stdio.stderr.maxBytes === 64 * 1024, 'stdio is stdin=ignore with a 64KB output cap')
510
+ assert(typeof spec.graceMs === 'number' && spec.graceMs >= 1000, 'the configured timeout is passed as graceMs (' + spec.graceMs + ')')
511
+ assert(typeof runGood.command === 'string' && runGood.command.indexOf('good.lean') !== -1, 'the result reports the exact command line')
512
+ const runBad = await h.call('vibe_math_lean_run', { file: 'Formal/bad.lean' })
513
+ assert(runBad.ok === false && runBad.exitCode === 1, 'a file that still uses sorry reports a red run')
514
+ assert(/sorry/.test(runBad.stderr), 'the compiler output is returned verbatim (' + JSON.stringify(runBad.stderr).slice(0, 60) + ')')
515
+ assert(/修复后重跑/.test(runBad.hint || ''), 'a red run tells the member to fix and rerun')
516
+ const runMissing = await h.call('vibe_math_lean_run', { file: 'Formal/nope.lean' })
517
+ assert(runMissing.ok === false && runMissing.code === 'V2_NOT_FOUND', 'a missing file is refused with a typed code (' + runMissing.code + ')')
518
+ const runNoFile = await h.call('vibe_math_lean_run', {})
519
+ assert(runNoFile.ok === false && runNoFile.code === 'V2_INVALID_ARGUMENT', 'a run without `file` is refused')
520
+ // The guard's boundary is the VibeMath ROOT, not the project: the global reuse library
521
+ // deliberately lives at <VibeMath>/Formal/{Lib,Proved}, so climbing out of the project but
522
+ // staying inside VibeMath is legal (it just fails as a missing file).
523
+ const runOutOfProject = await h.call('vibe_math_lean_run', { file: '../../Formal/Lib/x.lean' })
524
+ assert(runOutOfProject.ok === false && runOutOfProject.code === 'V2_NOT_FOUND',
525
+ 'climbing out of the project but staying inside the VibeMath root is ALLOWED (got ' + runOutOfProject.code + ')')
526
+ const runEscape = await h.call('vibe_math_lean_run', { file: '../../../../etc/evil.lean' })
527
+ assert(runEscape.ok === false && runEscape.code === 'V2_INVALID_ARGUMENT', '★ a traversal that climbs ABOVE the VibeMath root is refused')
528
+ const runEscape2 = await h.call('vibe_math_lean_run', { file: 'Formal/../../../../../evil.lean' })
529
+ assert(runEscape2.ok === false && runEscape2.code === 'V2_INVALID_ARGUMENT', 'a deeper traversal is refused too')
530
+ const runEscape3 = await h.call('vibe_math_lean_run', { file: '/etc/evil.lean' })
531
+ assert(runEscape3.ok === false && runEscape3.code === 'V2_INVALID_ARGUMENT', 'an unrelated absolute path is refused')
532
+ const runEscape4 = await h.call('vibe_math_lean_run', { file: 'C:/Windows/evil.lean' })
533
+ assert(runEscape4.ok === false && runEscape4.code === 'V2_INVALID_ARGUMENT', 'an unrelated Windows absolute path is refused')
534
+ const runNotLean = await h.call('vibe_math_lean_run', { file: 'Formal/good.txt' })
535
+ assert(runNotLean.ok === false && runNotLean.code === 'V2_INVALID_ARGUMENT', 'only .lean files can be executed')
536
+ assert(/VibeMath/.test(runEscape.message || ''), 'the refusal names the boundary it enforced')
537
+ mkdirSync(join(vibeRoot(h), 'Formal', 'Lib'), { recursive: true })
538
+ writeFileSync(join(vibeRoot(h), 'Formal', 'Lib', 'abs.lean'), 'def absTest := 1\n', 'utf8')
539
+ const runAbs = await h.call('vibe_math_lean_run', { file: join(vibeRoot(h), 'Formal', 'Lib', 'abs.lean').replace(/\\/g, '/') })
540
+ assert(runAbs.ok === true, 'an absolute path inside the VibeMath root is accepted')
541
+ const rec = formalStateOf(h)
542
+ assert(!!rec.records && !!rec.records.pRun && rec.records.pRun.status === 'attempted', 'a green lean_run records the object as attempted (stored, not promoted)')
543
+ assert(!!rec.records.pRun.run && rec.records.pRun.run.ok === true && rec.records.pRun.run.exitCode === 0, 'the run record keeps ok/exitCode')
544
+ assert(rec.records.pRun.run.stdoutTail !== undefined && rec.records.pRun.run.stderrTail !== undefined, 'the run record keeps truncated output tails')
545
+ toolchainAvailable = false
546
+ // task-6: the missing-toolchain simulation must deny the KNOWN-LOCATION fallback too (a machine with
547
+ // Lean installed would otherwise resolve it through %ELAN_HOME%/~/.elan and the run would succeed).
548
+ const _tcSaved = {}; for (const _k of ['ELAN_HOME', 'USERPROFILE', 'HOME', 'LOCALAPPDATA']) _tcSaved[_k] = process.env[_k]
549
+ process.env.ELAN_HOME = 'Z:/no-elan'; process.env.USERPROFILE = 'Z:/no-home'; process.env.HOME = 'Z:/no-home'; process.env.LOCALAPPDATA = 'Z:/no-local'
550
+ try {
551
+ const runNoTc = await h.call('vibe_math_lean_run', { file: 'Formal/good.lean' })
552
+ assert(runNoTc.ok === false && runNoTc.code === 'LEAN_NOT_FOUND', 'a missing toolchain returns LEAN_NOT_FOUND instead of crashing')
553
+ assert(/仍可把形式化代码写下来归档/.test(runNoTc.message), 'the failure explains the graceful degradation')
554
+ } finally { for (const _k of Object.keys(_tcSaved)) { if (_tcSaved[_k] === undefined) delete process.env[_k]; else process.env[_k] = _tcSaved[_k] } }
555
+ toolchainAvailable = true
556
+ const noSub = await makeCase('nosub', { noSubprocess: true })
557
+ mkdirSync(join(projRoot(noSub), 'Formal'), { recursive: true })
558
+ writeFileSync(join(projRoot(noSub), 'Formal', 'x.lean'), 'theorem x : 1 = 1 := rfl\n', 'utf8')
559
+ const noSubRun = await noSub.call('vibe_math_lean_run', { file: 'Formal/x.lean' })
560
+ assert(noSubRun.ok === false && noSubRun.code === 'NO_SUBPROCESS', 'a host without the subprocess service returns NO_SUBPROCESS and does not crash (got ' + noSubRun.code + ')')
561
+ const noSubStatus = await noSub.call('vibe_math_status', {})
562
+ assert(noSubStatus.ok === true, 'the scheduler still answers status after that (nothing was thrown into the loop)')
563
+ }
564
+
565
+ // ---------- 4b. the timeout must STOP the process, not just report it ----------
566
+ // Contract §7: "必须给 cwd…,并对超时调用 handle.terminate()". Reporting LEAN_TIMEOUT while the Lean
567
+ // process keeps running is a silent resource leak, and the framework's own contract says otherwise.
568
+ section('4b lean_run ACTIVELY terminates on timeout (contract §7)')
569
+ {
570
+ const h = await makeCase('timeout')
571
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
572
+ const proj = projRoot(h)
573
+ mkdirSync(join(proj, 'Formal'), { recursive: true })
574
+ writeFileSync(join(proj, 'Formal', 'hang.lean'), 'theorem t : 1 = 1 := rfl -- HANG\n', 'utf8')
575
+ writeFileSync(join(proj, 'Formal', 'fast.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
576
+ const before = terminations.length
577
+ const t0 = Date.now()
578
+ const run = await h.call('vibe_math_lean_run', { file: 'Formal/hang.lean', timeout_ms: 1000 })
579
+ const elapsed = Date.now() - t0
580
+ assert(run.ok === false && run.code === 'LEAN_TIMEOUT', '★ a run that outlives its timeout is reported as LEAN_TIMEOUT (got ' + run.code + ')')
581
+ assert(run.timedOut === true, 'the result carries timedOut=true')
582
+ assert(terminations.length === before + 1 && /hang\.lean/.test(terminations[terminations.length - 1].file),
583
+ '★★ the timeout ACTIVELY called handle.terminate() (graceMs alone does not stop a lingering Lean process)')
584
+ assert(elapsed < 2400, 'the call returned AT its timeout instead of waiting the process out (took ' + elapsed + 'ms)')
585
+ const fast = await h.call('vibe_math_lean_run', { file: 'Formal/fast.lean' })
586
+ assert(fast.ok === true && fast.timedOut === false, 'a normal run still reports success')
587
+ assert(terminations.length === before + 1, 'a normal run terminates nothing')
588
+ await sleep(60)
589
+ assert(liveTimers.size === 0, '★ no timeout timer is left pending after either run (it is cleared as soon as `done` wins)')
590
+ }
591
+
592
+ // ---------- 5. archive: def / lemma / proof / blocked ----------
593
+ section('5 lean_archive writes the contract paths and indexes')
594
+ {
595
+ const h = await makeCase('arc')
596
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
597
+ const proj = projRoot(h)
598
+ const libPath = join(vibeRoot(h), 'Formal', 'Lib')
599
+ const provedPath = join(vibeRoot(h), 'Formal', 'Proved')
600
+ const defRes = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'ZMod5', content: 'def ZMod5 := Fin 5\n' })
601
+ assert(defRes.ok === true && defRes.file === 'Formal/Lib/ZMod5.lean', 'a reusable definition is archived to the global lib (' + defRes.file + ')')
602
+ assert(existsSync(join(libPath, 'ZMod5.lean')), '★ the definition exists under <VibeMath>/Formal/Lib/ (cross-project, NOT inside the project)')
603
+ assert(!existsSync(join(proj, 'Formal', 'Lib', 'ZMod5.lean')), 'it is NOT duplicated inside the project tree')
604
+ assert(!!defRes.run && defRes.run.ok === true, 'the archived definition was executed (kind=def runs by default)')
605
+ const lemmaRes = await h.call('vibe_math_lean_archive', { kind: 'lemma', name: 'sq_odd', content: 'theorem sq_odd (n : Nat) : Odd (n*n) → Odd n := by omega\n' })
606
+ assert(lemmaRes.ok === true && lemmaRes.file === 'Formal/Proved/sq_odd.lean', 'a lemma is archived to the global Proved/ (' + lemmaRes.file + ')')
607
+ assert(existsSync(join(provedPath, 'sq_odd.lean')), 'the lemma exists under <VibeMath>/Formal/Proved/')
608
+ const defNoRun = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'quiet', content: 'def quiet := 2\n', run: false })
609
+ assert(defNoRun.ok === true && defNoRun.run === undefined, 'run:false archives without executing (still indexed)')
610
+ assert(/ZMod5/.test(readIf(join(libPath, 'Index.md'))), 'Lib/Index.md lists the new definition')
611
+ assert(/quiet/.test(readIf(join(libPath, 'Index.md'))), 'Lib/Index.md lists the run:false definition too')
612
+ assert(/sq_odd/.test(readIf(join(provedPath, 'Index.md'))), 'Proved/Index.md lists the new lemma')
613
+ writeFileSync(join(proj, 'Formal', 'src.lean'), 'def copied := 3\n', 'utf8')
614
+ const defFrom = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'copied', from: 'Formal/src.lean' })
615
+ assert(defFrom.ok === true && existsSync(join(libPath, 'copied.lean')), 'kind=def can archive from an existing .lean file')
616
+ // The tool RESULT is agent-facing text too: a definition whose run just failed must not be advertised
617
+ // as "directly importable" (contract §6 hard req. 3 / 实现方案 §9.4 — a red file must not be presented
618
+ // as usable, whatever the framework decides to do with the file itself).
619
+ const defRed = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'polluted', content: 'def polluted := 1 -- FAIL\n' })
620
+ assert(defRed.ok === true && !!defRed.run && defRed.run.ok === false, 'a definition that does not compile is reported as a red run')
621
+ assert(!/可直接 import 复用/.test(defRed.note || ''), '★ a red definition must NOT be advertised as directly reusable')
622
+ assert(/运行未通过/.test(defRed.note || ''), '…and the note says what to do instead')
623
+ const fromOutside = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'escape', from: '../../../../etc/passwd' })
624
+ assert(fromOutside.ok === false && fromOutside.code === 'V2_INVALID_ARGUMENT', 'from=<path outside the VibeMath root> is refused')
625
+ const noName = await h.call('vibe_math_lean_archive', { kind: 'def', content: 'def x := 1\n' })
626
+ assert(noName.ok === false && noName.code === 'V2_INVALID_ARGUMENT', 'archiving a definition without a name is refused')
627
+ const noBody = await h.call('vibe_math_lean_archive', { kind: 'lemma', name: 'xb' })
628
+ assert(noBody.ok === false && noBody.code === 'V2_INVALID_ARGUMENT', 'archiving a lemma with neither content nor from is refused')
629
+ const badKind = await h.call('vibe_math_lean_archive', { kind: 'nonsense' })
630
+ assert(badKind.ok === false && badKind.code === 'V2_INVALID_ARGUMENT', 'an unknown archive kind is refused')
631
+ const noTarget = await h.call('vibe_math_lean_archive', { kind: 'proof', content: 'theorem x : 1 = 1 := rfl\n' })
632
+ assert(noTarget.ok === false && noTarget.code === 'V2_INVALID_ARGUMENT', 'kind=proof without a target is refused')
633
+ const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pProof', content: 'theorem p_proof : 3 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n' })
634
+ assert(proof.ok === true && proof.passed === true, 'a passing proof is archived (' + JSON.stringify({ ok: proof.ok, passed: proof.passed }) + ')')
635
+ assert(proof.file === 'Formal/pProof.lean', 'the working file is Formal/<target>.lean')
636
+ assert(proof.proof === 'Verified/Lean/pProof.lean', 'the archived proof path is Verified/Lean/<target>.lean')
637
+ assert(existsSync(join(proj, 'Formal', 'pProof.lean')), 'the working file exists on disk')
638
+ assert(existsSync(join(proj, 'Verified', 'Lean', 'pProof.lean')), '★ the proof is archived under Verified/Lean/')
639
+ assert(proof.status === 'passed', 'the object status becomes passed')
640
+ const proofBad = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pRed', content: 'theorem p_red : 1 = 2 := by sorry\n' })
641
+ assert(proofBad.ok === true && proofBad.passed === false && proofBad.status === 'attempted', 'a red proof is recorded as attempted (not passed)')
642
+ assert(!existsSync(join(proj, 'Verified', 'Lean', 'pRed.lean')), 'a red proof is NOT archived under Verified/Lean/')
643
+ assert(/sorry/.test((proofBad.run && proofBad.run.stderr) || ''), 'the red run keeps the compiler output for the member to fix')
644
+ const st = await h.call('vibe_math_status', {})
645
+ assert(st.formal.objects.some((o) => o.target === 'pProof' && o.status === 'passed'), 'status lists the passed object')
646
+ const idx = readIf(join(proj, 'Formal', 'Index.md'))
647
+ assert(/pProof/.test(idx) && /passed/.test(idx) && /Verified\/Lean\/pProof\.lean/.test(idx), 'Formal/Index.md indexes the object, its status and its archived proof')
648
+ assert(/pRed/.test(idx) && /attempted/.test(idx), 'Formal/Index.md also records the failed attempt')
649
+ const blkNoNote = await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'pBlk' })
650
+ assert(blkNoNote.ok === false && blkNoNote.code === 'V2_INVALID_ARGUMENT', 'blocked without a note is refused')
651
+ const blk = await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'pBlk', note: '需要外层解析数论框架,本轮工作量不可接受' })
652
+ assert(blk.ok === true && blk.status === 'blocked', 'a reasoned blocker is recorded')
653
+ assert(/需要外层解析数论框架/.test(readIf(join(proj, 'Formal', 'Index.md'))), 'the blocker reason reaches the index')
654
+ const stBlk = await h.call('vibe_math_status', {})
655
+ assert(stBlk.formal.objects.some((o) => o.target === 'pBlk' && o.status === 'blocked'), 'status lists the blocked object')
656
+ const lib = await h.call('vibe_math_lean_lib', {})
657
+ assert(lib.ok === true && lib.counts.lib >= 3 && lib.counts.proved >= 1, 'lean_lib reports the reuse library sizes (' + JSON.stringify(lib.counts) + ')')
658
+ assert(lib.objects.some((o) => o.target === 'pProof' && o.status === 'passed'), 'lean_lib lists the per-object formal status')
659
+ assert(/复用优先/.test(lib.hint || ''), 'lean_lib tells agents to reuse before redefining')
660
+ assert(lib.mode === 'encourage', 'lean_lib reports the active mode')
661
+ const noRefresh = await h.call('vibe_math_lean_lib', { refresh: false })
662
+ assert(noRefresh.rebuilt === false && noRefresh.counts.lib === null, 'refresh:false lists without rebuilding')
663
+ assert(existsSync(join(proj, 'Formal', 'Index.md')), 'the project Formal/Index.md exists')
664
+ assert(existsSync(join(libPath, 'Index.md')) && existsSync(join(provedPath, 'Index.md')), 'the two GLOBAL indexes exist')
665
+ }
666
+
667
+ // ---------- 6. a passing proof flips the review to FIDELITY ----------
668
+ section('6 a passing proof flips the review subject to fidelity')
669
+ {
670
+ const h = await makeCase('fid')
671
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage', maxParallelThreshold: 8 })
672
+ // v2's scheduler STOPS itself when there is no unsolved problem, no agent and no task; a
673
+ // case that only wants to verify a proposition therefore needs one live problem to keep
674
+ // the tick loop alive (otherwise no verification would ever be started).
675
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
676
+ await startScheduler(h)
677
+ await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'r-pFid', content: 'theorem p_fid : 2 + 2 = 4 := by decide\n' })
678
+ await h.call('vibe_math_add_proposition', { id: 'pFid', 概述: '2+2=4(已有 Lean 证明)', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
679
+ // 0.5 in round 1 forces a debate round whose prompt must ALSO carry the fidelity wording
680
+ const vs = await verifyWithDebate(h, 'r-pFid', 0.5, 1)
681
+ assert(!!vs.first, 'a verifier was spawned for the Lean-passed object')
682
+ const vp = h.spawns.find((s) => s.label === 'verifier:r-pFid:0')
683
+ const vpText = vp ? vp.prompt : ''
684
+ assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(vpText), 'the review prompt announces the passing proof')
685
+ assert(/你不需要重新检查推导/.test(vpText), '★ it tells reviewers NOT to re-derive')
686
+ assert(/忠实性审查/.test(vpText), '★ it tells reviewers the review subject is now fidelity')
687
+ assert(/定义 \/ 对象 \/ 条件 \/ 假设 \/ 结论是否与命题原文\*\*完全一致\*\*/.test(vpText), 'it enumerates exactly what fidelity means')
688
+ assert(/Verified\/Lean\/r-pFid\.lean/.test(vpText), 'it points at the archived proof')
689
+ assert(/一致 → Result = 1/.test(vpText), '★ the fidelity guidance names the REAL field (v2\'s contract field is Result)')
690
+ assert(!/verdict/.test(vpText), '★ the fidelity guidance never names a `verdict` field (that vote would be silently dropped)')
691
+ assert(/发现任何偏差,不要投 0/.test(vpText), '★ a fidelity defect is explicitly NOT to be voted as 0 (it is not a refutation)')
692
+ assert(/formal:\{decision:'defect'/.test(vpText), '★ the reviewers are given the defect reply channel that withdraws the proof')
693
+ assert(!/偏离 → 0/.test(vpText), '★ the "any deviation → 0" instruction is gone (it would fabricate a false conclusion)')
694
+ assert(!/请先判断该对象的\*\*实现难度\*\*/.test(vpText), 'the "judge the difficulty first" wording is gone when a proof already exists')
695
+ // The withdrawal sentence must match the MODE's real strength (contract §4.1 pt.3 / §6.1; this case is
696
+ // 'encourage'): encouraging mode has NO gate, so the framework cannot hold the verdict — promising a
697
+ // hold there is a lie the voter would rely on (AUDIT-CHECKLIST §1.7, "提示词承诺的强度档位").
698
+ assert(/本档没有门禁/.test(vpText), '★ encourage fidelity text says THIS MODE HAS NO GATE (the framework cannot hold the verdict)')
699
+ assert(!/本次裁定\*\*不定论\*\*/.test(vpText), '★ encourage must NOT promise a hold the framework cannot enforce')
700
+ const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
701
+ assert(/你不需要重新检查推导/.test(debate), '★ the debate prompt for a Lean-passed object also asks for fidelity, not re-derivation')
702
+ assert(/发现任何偏差,不要投 0/.test(debate), '★ and it carries the same no-zero rule in the debate round')
703
+ assert(/本档没有门禁/.test(debate), '★ the debate round carries the same mode-qualified withdrawal wording')
704
+ await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'r-pBlk2', note: '涉及未形式化的分析学前置' })
705
+ await h.call('vibe_math_add_proposition', { id: 'pBlk2', 概述: '已记录阻塞的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
706
+ const vs2 = await verifyWithDebate(h, 'r-pBlk2', 0.5, 1)
707
+ assert(!!vs2.first, 'verifiers spawned for the blocked object')
708
+ const vp2 = h.spawns.find((s) => s.label === 'verifier:r-pBlk2:0')
709
+ const vp2Text = vp2 ? vp2.prompt : ''
710
+ assert(/该对象已被记录为\*\*形式化阻塞\*\*/.test(vp2Text), 'a blocked object is announced as such')
711
+ assert(/涉及未形式化的分析学前置/.test(vp2Text), 'the blocker reason is shown to the reviewers')
712
+ assert(/这个阻塞判断是否成立/.test(vp2Text), 'the verdict guidance asks whether the blocker is justified')
713
+ }
714
+
715
+ // ---------- 7. the 'require' gate (proposition) ----------
716
+ section("7 'require' withholds a verdict until the formal record exists")
717
+ {
718
+ const h = await makeCase('gate')
719
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
720
+ // a live problem keeps the scheduler from stopping itself (see the comment in case 6)
721
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
722
+ await startScheduler(h)
723
+ const proj = projRoot(h)
724
+ await h.call('vibe_math_add_proposition', { id: 'pGate', 概述: '必须形式化的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
725
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pGate'); return x.length >= 2 ? x : undefined }, 60, 250)
726
+ assert(!!vs, 'verifiers were spawned under require mode')
727
+ const vp = (h.spawns.find((s) => s.label === 'verifier:r-pGate:0') || {}).prompt || ''
728
+ assert(/【Lean 形式化验证(强制模式)】/.test(vp), 'the review prompt says 强制模式')
729
+ assert(/必须产出 Lean 形式化/.test(vp), "'require' states the formalization is mandatory")
730
+ assert(/本次裁定不会生效/.test(vp), 'the prompt warns that the verdict will not take effect without it')
731
+ assert(/formal-required/.test(vp), 'the prompt names the machine-readable reason')
732
+ assert(/vibe_math_lean_archive kind='blocked' note=… 或回执 formal\.note/.test(vp), '★ the require wording names BOTH blocking routes with the FULL tool name')
733
+ assert(/"formal":\{"target":"r-pGate","decision":"used\|blocked\|defect"/.test(vp), '★ the require review contract carries the formal reply field keyed by the verification id')
734
+ assert(!/(^|[^a-z_])lean_(run|archive|lib)/.test(vp), '★ the require prompt contains no abbreviated tool name either')
735
+ fireVerdicts(h, vs, 1)
736
+ // The deferral writes Formal/TODO.md from INSIDE the settle path; waiting for that file is
737
+ // the observable proof that the round settled (the task is dropped right after).
738
+ const todoSettled = await waitFor(() => /pGate/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 40, 150)
739
+ assert(!!todoSettled, 'the deferred round settled (Formal/TODO.md recorded the object)')
740
+ await tick(600)
741
+ const propFile = join(proj, 'Propos', '数论_Propos.json')
742
+ const props = JSON.parse(readIf(propFile) || '[]')
743
+ const pGate = props.find((x) => x.id === 'pGate') || {}
744
+ assert(pGate.布尔估计 === 0.5, "★ the verdict did NOT take effect: the proposition's 布尔估计 is unchanged (got " + pGate.布尔估计 + ')')
745
+ assert(!(pGate.证明列表 || []).some((x) => x.正确概率 === 1), 'no probability-1 proof entry was recorded')
746
+ assert(pGate.优先级 !== 'never', 'the priority was not pinned to never')
747
+ assert(!existsSync(join(proj, 'Verified', '数论_Verified.json')), '★ no Verified card was written')
748
+ const todo = readIf(join(proj, 'Formal', 'TODO.md'))
749
+ assert(existsSync(join(proj, 'Formal', 'TODO.md')), 'Formal/TODO.md was created')
750
+ assert(/pGate/.test(todo) && /formal-required/.test(todo), '★ the object is on the formalization TODO with the machine-readable reason')
751
+ const recAfterGate = formalStateOf(h)
752
+ // record key = the verification object id (rId), which is what lean_archive target= also uses
753
+ assert(!!recAfterGate.records && !!recAfterGate.records['r-pGate'] && !!recAfterGate.records['r-pGate'].deferredAt, 'the deferral is persisted in the durable formal state (survives resume)')
754
+ assert(!!recAfterGate.records['r-pGate'] && recAfterGate.records['r-pGate'].status === 'none', 'the object stays at status none (nothing was formalized)')
755
+ const vlogs = existsSync(join(proj, 'Verification_logs')) ? readdirSync(join(proj, 'Verification_logs')) : []
756
+ assert(vlogs.some((f) => f.startsWith('r-pGate')), '★ the verification round DID complete (its debate log exists) — the gate withheld the conclusion, it did not stall the round')
757
+ const st1 = await h.call('vibe_math_status', {})
758
+ assert(/require 模式搁置/.test(JSON.stringify(st1.recentActivity)), 'the withholding is announced on v2-readable channel (activity log; v2 has no Shared/Chat/)')
759
+ assert(st1.formal.todo.some((t) => t.id === 'r-pGate'), 'status exposes the formalization TODO')
760
+ const rep = await h.call('vibe_math_report', {})
761
+ assert(!!rep.formal && rep.formal.required === true && rep.formal.mode === 'require', 'the report exposes the formal mode and gate flag')
762
+ assert(rep.recentActivity.some((a) => /formal-gate/.test(a.event)), 'the deferral also lands in the report log')
763
+ // The deferral leaves this project with no agent, task or solved problem, and v2's strict
764
+ // termination stops the scheduler there — so a case that wants to RE-verify the object after
765
+ // formalizing it must (re)start the scheduler, exactly as a human would.
766
+ await startScheduler(h)
767
+ const proofNow = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pGate', content: 'theorem p_gate : 2 + 2 = 4 := by decide\n' })
768
+ assert(proofNow.ok === true && proofNow.passed === true, 'the object is now Lean-passed')
769
+ const spawned2 = await verifyWithDebate(h, 'r-pGate', 1, 0)
770
+ assert(!!spawned2.first, 'the object was re-verified after the proof was archived')
771
+ const props2 = JSON.parse(readIf(propFile) || '[]')
772
+ const pGateNow = props2.find((x) => x.id === 'pGate') || {}
773
+ assert(pGateNow.布尔估计 === 1, '★ with a passing Lean artifact the same unanimous verdict DOES take effect (布尔估计=1)')
774
+ const cardFile = join(proj, 'Verified', '数论_Verified.json')
775
+ const cards = JSON.parse(readIf(cardFile) || '[]')
776
+ const card = cards.find((c) => c.id === 'pGate')
777
+ assert(!!card, '★ the Verified card was written after the proof passed')
778
+ assert(!!card && /Lean 通过/.test(card['形式化'] || ''), '★ the card records how strong the result is (Lean 通过)')
779
+ assert(!!card && /Verified\/Lean\/pGate\.lean/.test(card['形式化'] || ''), 'the card points at the archived proof')
780
+ await h.call('vibe_math_add_proposition', { id: 'pBlkOk', 概述: '记录阻塞后可定论', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
781
+ const sp3 = await waitFor(() => { const x = verifiersOf(h, 'r-pBlkOk'); return x.length >= 2 ? x : undefined }, 60, 250)
782
+ assert(!!sp3, 'verifiers spawned for the blocker-escape case')
783
+ await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'r-pBlkOk', note: '命题涉及未形式化的分析学,本轮不做' })
784
+ fireVerdicts(h, sp3 || [], 1)
785
+ await waitFor(() => { const c = JSON.parse(readIf(cardFile) || '[]').find((x) => x.id === 'pBlkOk'); return c ? c : undefined }, 40, 150)
786
+ await tick(400)
787
+ const cards3 = JSON.parse(readIf(cardFile) || '[]')
788
+ const card3 = cards3.find((c) => c.id === 'pBlkOk')
789
+ assert(!!card3, '★ an explicit reasoned blocker also lets the verdict through (decide by difficulty, but decide out loud)')
790
+ assert(!!card3 && /阻塞(/.test(card3['形式化'] || ''), 'the card records the blocker')
791
+ assert(!!card3 && /未形式化的分析学/.test(card3['形式化'] || ''), 'the card records the blocker REASON, not just the fact')
792
+ }
793
+
794
+ // ---------- 8. the gate covers the qs (problem) choke point ----------
795
+ section('8 the gate also covers a problem-solution verdict')
796
+ {
797
+ const h = await makeCase('gq')
798
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
799
+ // a live problem keeps the scheduler from stopping itself (see the comment in case 3)
800
+ await h.call('vibe_math_add_problem', { id: 'qStay', description: '保持调度器运行的占位问题', priority: 9 })
801
+ await startScheduler(h)
802
+ const proj = projRoot(h)
803
+ await h.call('vibe_math_add_problem', { id: 'qG', description: '带解法的 require 门禁问题', priority: 1 })
804
+ const qsFile = join(proj, 'qs', 'qs.json')
805
+ const qs0 = JSON.parse(readIf(qsFile) || '[]')
806
+ qs0.find((q) => q.id === 'qG').解法列表 = [{ 完整解法: 'closing argument', 正确概率: 0.8, 已验: false }]
807
+ writeFileSync(qsFile, JSON.stringify(qs0, null, 2), 'utf8')
808
+ await tick(2000)
809
+ const sp = await waitFor(() => { const x = verifiersOf(h, 'r-qG-s0'); return x.length >= 2 ? x : undefined }, 60, 250)
810
+ assert(!!sp, 'verifiers spawned for the problem solution')
811
+ fireVerdicts(h, sp || [], 1)
812
+ const todoG = await waitFor(() => /qG/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 40, 150)
813
+ assert(!!todoG, 'the deferred problem round settled (Formal/TODO.md recorded it)')
814
+ await tick(600)
815
+ const qs1 = JSON.parse(readIf(qsFile) || '[]')
816
+ const qG = qs1.find((q) => q.id === 'qG')
817
+ assert(!!qG && qG.已解决 !== true, '★ the problem was NOT marked solved (the require gate blocked the promotion)')
818
+ assert(!!qG && qG.解法列表[0].正确概率 !== 1, "the solution's probability was left as it was")
819
+ assert(!existsSync(join(proj, 'Verified', '问题_Verified.json')), 'no problem Verified card was written')
820
+ assert(/qG/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 'the problem is on the formalization TODO')
821
+ await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'qG', note: '问题的形式化超出本轮工作量' })
822
+ await startScheduler(h) // see case 7: the deferral may have stopped the scheduler
823
+ const sp2 = await verifyWithDebate(h, 'r-qG-s0', 1, 0)
824
+ assert(!!sp2.first, 'the problem solution was re-verified after the blocker was recorded')
825
+ const qs2 = JSON.parse(readIf(qsFile) || '[]')
826
+ const qG2 = qs2.find((q) => q.id === 'qG')
827
+ assert(!!qG2 && qG2.已解决 === true, 'with a reasoned blocker the problem DOES close')
828
+ assert(!!qG2 && qG2.解法列表[0].正确概率 === 1, 'the solution probability becomes 1 after the gate opened')
829
+ const cards = JSON.parse(readIf(join(proj, 'Verified', '问题_Verified.json')) || '[]')
830
+ const card = cards.find((c) => c.id === 'qG')
831
+ assert(!!card && /阻塞(/.test(card['形式化'] || ''), 'the problem card records the formal status')
832
+ assert(!existsSync(join(vibeRoot(h), 'Formal', 'Lib', 'qG.lean')), 'a blocked record does not fabricate a library file')
833
+ }
834
+
835
+ // ---------- 9. persistence across resume ----------
836
+ section('9 the formal state survives resume (v2 has no session projection)')
837
+ {
838
+ const h = await makeCase('persist')
839
+ await h.call('vibe_math_set_params', { formalVerify: 'require' })
840
+ await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pPersist', content: 'theorem p_persist : 1 + 1 = 2 := by decide\n' })
841
+ const before = await h.call('vibe_math_status', {})
842
+ assert(before.formal.objects.some((o) => o.target === 'pPersist' && o.status === 'passed'), 'the object is passed before the restart')
843
+ const proj = projRoot(h)
844
+ writeFileSync(join(proj, 'VibeMath_State', 'process_epoch.json'), JSON.stringify('OLD-PROCESS-EPOCH'), 'utf8')
845
+ const res = await h.call('vibe_math_resume', {})
846
+ assert(res.ok === true, 'resume succeeded')
847
+ const after = await h.call('vibe_math_status', {})
848
+ assert(after.formal.objects.some((o) => o.target === 'pPersist' && o.status === 'passed'),
849
+ '★ the Lean-passed object is still passed after resume (the gate does not forget)')
850
+ assert(/pPersist/.test(readIf(join(proj, 'Formal', 'Index.md'))), 'Formal/Index.md still indexes it (the files are the readable mirror)')
851
+ }
852
+
853
+ // ---------- 10. the gate is a no-op unless require ----------
854
+ section("10 'encourage' never gates (a verdict still lands with no Lean artifact)")
855
+ {
856
+ const h = await makeCase('nogate')
857
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage', maxParallelThreshold: 8 })
858
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
859
+ await startScheduler(h)
860
+ const proj = projRoot(h)
861
+ await h.call('vibe_math_add_proposition', { id: 'pFree', 概述: '鼓励模式不设门禁', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
862
+ const vs = await verifyWithDebate(h, 'r-pFree', 1, 0)
863
+ assert(!!vs.first, 'verifiers spawned under encourage mode')
864
+ const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
865
+ const p = props.find((x) => x.id === 'pFree') || {}
866
+ assert(p.布尔估计 === 1, "'encourage' finalizes normally with NO Lean artifact (布尔估计=1)")
867
+ const cards = JSON.parse(readIf(join(proj, 'Verified', '数论_Verified.json')) || '[]')
868
+ assert(!!cards.find((c) => c.id === 'pFree'), "'encourage' writes the Verified card")
869
+ assert(!existsSync(join(proj, 'Formal', 'TODO.md')) || !/pFree/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 'no formalization TODO is created in encourage mode')
870
+ }
871
+
872
+ // ---------- 11. the reply channel is REAL (contract §4 / §6.3 / §10.8) ----------
873
+ // The first version of this feature only WROTE "请在回执的 formal 字段写明难度判断" into the prompt
874
+ // and never parsed it: 177 green assertions guarded a dead channel (AUDIT-CHECKLIST §2.2). These
875
+ // cases feed a genuine agent reply through the framework's own dispatch path (subagent/end →
876
+ // handleVerifier → absorb) and assert the DURABLE record, not the wording.
877
+ section('11 the reply channel really lands in the record (blocked / note validation)')
878
+ {
879
+ const h = await makeCase('reply')
880
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
881
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
882
+ await startScheduler(h)
883
+ await h.call('vibe_math_add_proposition', { id: 'pReply', 概述: '用回执记录阻塞', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
884
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pReply'); return x.length >= 2 ? x : undefined }, 60, 250)
885
+ assert(!!vs, 'verifiers were spawned for the reply-channel proposition')
886
+ const vp = (h.spawns.find((s) => s.label === 'verifier:r-pReply:0') || {}).prompt || ''
887
+ assert(/"formal":\{"target":"r-pReply","decision":"used\|blocked\|defect"/.test(vp), '★ the review contract itself advertises the formal field, keyed by the verification id')
888
+ if (vs) {
889
+ // ① `blocked` WITH a note, named by the VERIFICATION id → the OBJECT record must be synced too.
890
+ replyFrom(h, vs[0].childId, { Result: 0.5, Reason: '我判断形式化不划算', formal: { target: 'r-pReply', decision: 'blocked', note: '需要大量未形式化的实分析前置知识' } })
891
+ const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['r-pReply'] && r['r-pReply'].status === 'blocked') ? r : undefined }, 40, 150)
892
+ assert(!!rec, '★ a `formal.decision=blocked` reply is really absorbed — the channel is not dead code')
893
+ assert(!!rec && rec['r-pReply'].decision === 'blocked' && /实分析前置知识/.test(rec['r-pReply'].note || ''), 'the record keeps the decision AND the reason')
894
+ assert(!!rec && !!rec['pReply'] && rec['pReply'].status === 'blocked' && /实分析前置知识/.test(rec['pReply'].note || ''), '★ the OBJECT-id record is synced too (the two id spaces must not drift)')
895
+ assert(/实分析前置知识/.test(readIf(join(projRoot(h), 'Formal', 'Index.md'))), 'the reply-recorded blocker reaches Formal/Index.md')
896
+ // ② `blocked` WITHOUT a note (a different target) → refused with V2_INVALID_ARGUMENT, no record.
897
+ replyFrom(h, vs[1].childId, { Result: 0.5, Reason: '不想做', formal: { target: 'r-pNoNote', decision: 'blocked' } })
898
+ let acts = ''
899
+ for (let i = 0; i < 25; i++) {
900
+ const st = await h.call('vibe_math_status', {})
901
+ acts = (st.recentActivity || []).map((a) => a.detail).join('\n')
902
+ if (/V2_INVALID_ARGUMENT/.test(acts)) break
903
+ await sleep(120)
904
+ }
905
+ assert(/V2_INVALID_ARGUMENT/.test(acts), '★ a blocked/defect judgement without a note is REJECTED with V2_INVALID_ARGUMENT')
906
+ assert(/没有写明 note/.test(acts), 'the refusal says why (an explicit decision is required, never a silent skip)')
907
+ const recN = formalStateOf(h)
908
+ assert(!(recN.records || {})['r-pNoNote'] && !(recN.records || {})['pNoNote'], 'no record is invented for the refused judgement')
909
+ assert(!/pNoNote/.test(readIf(join(projRoot(h), 'Formal', 'Index.md'))), 'the refused judgement does not reach the index either')
910
+ // ③ a reply with NO target must not invent an object (safeId('') would fall back to 'anon').
911
+ // The 0.5/0.5 round has no consensus, so the debate round re-woke both children — that is the
912
+ // observable proof that these children are registered and can answer again.
913
+ const woke = await waitFor(() => (h.followups.filter((f) => /DEBATE/.test(f.prompt || '')).length >= 2 ? true : undefined), 40, 150)
914
+ assert(!!woke, 'the debate round re-woke the verifiers (round-2 replies reach the framework again)')
915
+ replyFrom(h, vs[0].childId, { Result: 0.5, Reason: '漏写 target', formal: { decision: 'blocked', note: '没有写 target' } })
916
+ let acts2 = ''
917
+ for (let i = 0; i < 25; i++) {
918
+ const st = await h.call('vibe_math_status', {})
919
+ acts2 = (st.recentActivity || []).map((a) => a.detail).join('\n')
920
+ if (/没有 target/.test(acts2)) break
921
+ await sleep(120)
922
+ }
923
+ assert(/没有 target/.test(acts2), '★ a `formal` reply without a target is refused, not guessed')
924
+ const recT = formalStateOf(h)
925
+ assert(!(recT.records || {}).anon, '★ a `formal` reply without a target cannot invent a record (no "anon" object)')
926
+ }
927
+ }
928
+
929
+ // ---------- 12. `defect`: a fidelity defect is NOT "the proposition is false" (§4.1) ----------
930
+ section('12 a defect reply withdraws the proof, writes the TODO and defers the verdict')
931
+ {
932
+ const h = await makeCase('defect')
933
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
934
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
935
+ await startScheduler(h)
936
+ const proj = projRoot(h)
937
+ // A PASSING Lean proof exists for the object — archived under the OBJECT id, the realistic path.
938
+ const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pDefect', content: 'theorem p_defect : 2 + 2 = 4 := by decide\n' })
939
+ assert(proof.ok === true && proof.passed === true, 'precondition: the object starts Lean-passed')
940
+ const proofFile = join(proj, 'Verified', 'Lean', 'pDefect.lean')
941
+ assert(existsSync(proofFile), 'the archived proof exists before the defect is reported')
942
+ await h.call('vibe_math_add_proposition', { id: 'pDefect', 概述: '形式化写窄了的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
943
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pDefect'); return x.length >= 2 ? x : undefined }, 60, 250)
944
+ assert(!!vs, 'verifiers were spawned for the Lean-passed object')
945
+ const vp = (h.spawns.find((s) => s.label === 'verifier:r-pDefect:0') || {}).prompt || ''
946
+ assert(/忠实性审查/.test(vp) && /不要投 0/.test(vp), 'the reviewers were told to audit fidelity and NOT to vote 0 on a defect')
947
+ // In REQUIRE mode the framework really does hold the verdict, so THAT promise is the correct one here.
948
+ assert(/本次裁定\*\*不定论\*\*/.test(vp), '★ require fidelity text does promise the hold (the gate really enforces it)')
949
+ assert(!/本档没有门禁/.test(vp), 'require must not claim it has no gate')
950
+ const DEFECT = 'Lean 只证了 n>0 的情形,命题原文是 n≥0'
951
+ if (vs) {
952
+ // A fidelity defect: the voter ABSTAINS (0.3) and records it through the reply channel.
953
+ replyFrom(h, vs[0].childId, { Result: 0.3, Reason: '形式化写窄了(弃权)', formal: { target: 'pDefect', decision: 'defect', note: DEFECT } })
954
+ const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['pDefect'] && r['pDefect'].decision === 'defect') ? r : undefined }, 40, 150)
955
+ assert(!!rec, '★ the defect reply is absorbed')
956
+ assert(!!rec && rec['pDefect'].status === 'attempted', '★ the object is DOWNGRADED to attempted (the "passed" status is withdrawn)')
957
+ assert(!!rec && rec['pDefect'].proof === '', '★ the proof pointer is cleared')
958
+ assert(!!rec && rec['pDefect'].note === DEFECT, 'the concrete deviation is recorded on the object record')
959
+ assert(!!rec && !!rec['r-pDefect'] && rec['r-pDefect'].status === 'attempted' && rec['r-pDefect'].decision === 'defect', '★ the verification-id record is downgraded too (both id spaces)')
960
+ assert(!existsSync(proofFile), '★ the archived proof Verified/Lean/pDefect.lean is DELETED')
961
+ assert(existsSync(join(proj, 'Formal', 'pDefect.lean')), 'the working file Formal/pDefect.lean is kept (the code is not lost)')
962
+ const todo = readIf(join(proj, 'Formal', 'TODO.md'))
963
+ assert(/pDefect/.test(todo) && /defect/.test(todo), '★ the object is listed in Formal/TODO.md with the defect reason')
964
+ assert(/只证了 n>0 的情形/.test(todo), 'the TODO carries the concrete deviation, not just a flag')
965
+ let acts = ''
966
+ for (let i = 0; i < 25; i++) {
967
+ const st = await h.call('vibe_math_status', {})
968
+ acts = (st.recentActivity || []).map((a) => a.detail).join('\n')
969
+ if (/忠实性缺陷/.test(acts)) break
970
+ await sleep(120)
971
+ }
972
+ assert(/忠实性缺陷/.test(acts), 'the downgrade is announced on the v2-readable channel (activity log)')
973
+ assert(/撤回「已通过」状态/.test(acts), 'the announcement says the passed status was withdrawn')
974
+ assert(/本次裁定不定论/.test(acts), 'and that the verdict is undecided — a defect withdraws a proof, it does not refute the proposition')
975
+ // Now drive the same round to a UNANIMOUS "true": without the defect it would conclude.
976
+ const settled = await verifyWithDebate(h, 'r-pDefect', 1, 0)
977
+ assert(!!settled.first, 'the round was driven to a verdict after the defect')
978
+ const todo2 = await waitFor(() => { const t = readIf(join(proj, 'Formal', 'TODO.md')); return /formal-required/.test(t) ? t : undefined }, 40, 150)
979
+ assert(!!todo2 && /r-pDefect/.test(todo2), '★ require mode DEFERS after a defect (the object enters the formalization TODO as formal-required)')
980
+ await tick(400)
981
+ const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
982
+ const p = props.find((x) => x.id === 'pDefect') || {}
983
+ assert(p.布尔估计 === 0.5, '★★ the proposition is NOT recorded as false — 布尔估计 unchanged (got ' + p.布尔估计 + ')')
984
+ assert(!(p.证明列表 || []).some((x) => x.正确概率 === 1), 'no probability-1 proof entry was written')
985
+ assert(p.优先级 !== 'never', 'the priority was not pinned to never')
986
+ assert(!existsSync(join(proj, 'Verified', '数论_Verified.json')), '★ no Verified card: the verdict is UNDECIDED, not "false"')
987
+ }
988
+ // The reverse direction: a defect named by the VERIFICATION id must still downgrade the OBJECT
989
+ // record that actually holds the proof (this is the pair that silently drifts when only one side
990
+ // is written — formalGateRecord would read `passed` from the other side).
991
+ const h2 = await makeCase('defect-rid')
992
+ await h2.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
993
+ await h2.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
994
+ await startScheduler(h2)
995
+ const proj2 = projRoot(h2)
996
+ await h2.call('vibe_math_lean_archive', { kind: 'proof', target: 'pDefect2', content: 'theorem p_defect2 : 2 + 2 = 4 := by decide\n' })
997
+ await h2.call('vibe_math_add_proposition', { id: 'pDefect2', 概述: '回执用验证 id 命名的缺陷', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
998
+ const vs2 = await waitFor(() => { const x = verifiersOf(h2, 'r-pDefect2'); return x.length >= 2 ? x : undefined }, 60, 250)
999
+ assert(!!vs2, 'verifiers were spawned for the reverse-direction case')
1000
+ if (vs2) {
1001
+ replyFrom(h2, vs2[0].childId, { Result: 0.3, Reason: '写宽了', formal: { target: 'r-pDefect2', decision: 'defect', note: 'Lean 版本没有假设 n≥1,比原文更宽' } })
1002
+ const rec2 = await waitFor(() => { const r = (formalStateOf(h2).records || {}); return (r['r-pDefect2'] && r['r-pDefect2'].decision === 'defect') ? r : undefined }, 40, 150)
1003
+ assert(!!rec2, '★ a defect named by the verification id is absorbed')
1004
+ assert(!!rec2 && !!rec2['pDefect2'] && rec2['pDefect2'].status === 'attempted', '★ and the OBJECT record that held `passed` is downgraded as well')
1005
+ assert(!!rec2 && rec2['pDefect2'].proof === '', 'its proof pointer is cleared too')
1006
+ assert(!existsSync(join(proj2, 'Verified', 'Lean', 'pDefect2.lean')), '★ the archived proof is deleted even though the reply named the other id')
1007
+ assert(/pDefect2/.test(readIf(join(proj2, 'Formal', 'TODO.md'))), 'the object is on the formalization TODO')
1008
+ }
1009
+ }
1010
+
1011
+ // ---------- 13. the gate must read BOTH id spaces ----------
1012
+ // v2 has two id spaces that name the same object: the verification id (rId, `r-pX`, `r-pX-s0`) that the
1013
+ // verification prompts and the gate use, and the OBJECT id (`pX`) that the agent reads in the target
1014
+ // block and normally archives under. `lean_archive` can only ADD a record for the id it was given, and
1015
+ // `syncVerificationTarget` merely UPDATES aliases that already exist — so "archived under the object id,
1016
+ // rId record does not exist yet" is a real first-time state. A gate that reads only its own side calls
1017
+ // that object un-formalized (a false negative), and because the deferral itself materialises the rId
1018
+ // record as `status:'none'`, the object can then NEVER conclude: every round re-defers and re-debates
1019
+ // (AUDIT-CHECKLIST §3: "成对关系只做一半").
1020
+ section('13 the require gate reads BOTH id spaces (archiving under the OBJECT id must not wedge)')
1021
+ {
1022
+ const h = await makeCase('gate-objid')
1023
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1024
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1025
+ await startScheduler(h)
1026
+ const proj = projRoot(h)
1027
+ const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pObjId', content: 'theorem p_objid : 2 + 2 = 4 := by decide\n' })
1028
+ assert(proof.ok === true && proof.passed === true, 'precondition: the object is Lean-passed under its OBJECT id')
1029
+ const rec0 = formalStateOf(h)
1030
+ assert(!!rec0.records && !!rec0.records.pObjId && rec0.records.pObjId.status === 'passed', 'the passed record lives under the object id')
1031
+ assert(!(rec0.records || {})['r-pObjId'], 'precondition: no rId record exists yet (this is the state that used to fool the gate)')
1032
+ await h.call('vibe_math_add_proposition', { id: 'pObjId', 概述: '用对象 id 归档后必须能定论', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1033
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pObjId'); return x.length >= 2 ? x : undefined }, 60, 250)
1034
+ assert(!!vs, 'verifiers were spawned for the object-id case')
1035
+ fireVerdicts(h, vs || [], 1)
1036
+ await tick(600)
1037
+ const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
1038
+ const p = props.find((x) => x.id === 'pObjId') || {}
1039
+ assert(p.布尔估计 === 1, "★ a Lean-passed object DOES conclude when the proof was archived under the object id (got " + p.布尔估计 + ')')
1040
+ const cards = JSON.parse(readIf(join(proj, 'Verified', '数论_Verified.json')) || '[]')
1041
+ assert(!!cards.find((c) => c.id === 'pObjId'), 'the Verified card was written')
1042
+ const todo = readIf(join(proj, 'Formal', 'TODO.md'))
1043
+ assert(!/pObjId/.test(todo), '★ the already-formalized object is NOT put on the formalization TODO')
1044
+ const todo2 = await h.call('vibe_math_status', {})
1045
+ assert(!(todo2.formal.todo || []).some((t) => String(t.id).indexOf('pObjId') !== -1), 'and the status TODO is empty for it too')
1046
+ }
1047
+
1048
+ // ---------- 14. withdrawing a proof must reach EVERY archived copy ----------
1049
+ // Contract §4.1 requires the withdrawal, not a best-effort delete: after a `defect` the archived proof
1050
+ // must be gone from `Verified/Lean/` — the one place everyone looks for "the proof of this object".
1051
+ // Two independent ways that fails silently:
1052
+ // (1) the proof was archived under a DIFFERENT id than the one the reply names (`Verified/Lean/<rId>.lean`
1053
+ // while the reply names the object id) — scanning only the two named ids misses it, and the record's
1054
+ // `proof` pointer is cleared anyway, so nothing ever points at the orphan again;
1055
+ // (2) the host cannot delete at all (no subprocess service) — the delete fails and the stale proof stays.
1056
+ section('14 defect withdrawal covers every id alias AND a host that cannot delete')
1057
+ {
1058
+ // (a) archived under the VERIFICATION id, defect named by the OBJECT id.
1059
+ const h = await makeCase('defect-alias')
1060
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1061
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1062
+ await startScheduler(h)
1063
+ const proj = projRoot(h)
1064
+ const pr = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'r-pAlias', content: 'theorem p_alias : 2 + 2 = 4 := by decide\n' })
1065
+ assert(pr.ok === true && pr.passed === true, 'precondition: the proof is archived under the VERIFICATION id')
1066
+ const aliasProof = join(proj, 'Verified', 'Lean', 'r-pAlias.lean')
1067
+ assert(existsSync(aliasProof), 'the archived proof sits at Verified/Lean/<rId>.lean')
1068
+ await h.call('vibe_math_add_proposition', { id: 'pAlias', 概述: '归档写在验证 id 上', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1069
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pAlias'); return x.length >= 2 ? x : undefined }, 60, 250)
1070
+ assert(!!vs, 'verifiers were spawned for the alias case')
1071
+ if (vs) {
1072
+ replyFrom(h, vs[0].childId, { Result: 0.3, Reason: '写宽了(弃权)', formal: { target: 'pAlias', decision: 'defect', note: '原文还有 n≥1 的假设' } })
1073
+ const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['pAlias'] && r['pAlias'].decision === 'defect') ? r : undefined }, 40, 150)
1074
+ assert(!!rec, '★ the defect named by the object id is absorbed')
1075
+ assert(!existsSync(aliasProof), '★★ the proof archived under the VERIFICATION id is withdrawn too (every id alias, not just the two named ones)')
1076
+ assert(!!rec && (!rec['r-pAlias'] || rec['r-pAlias'].proof === ''), 'the rId record no longer points at a proof')
1077
+ assert(!rec['r-pAlias'] || !rec['r-pAlias'].objectId || rec['r-pAlias'].objectId === 'pAlias', '★★ the rId record owner stamp is the OBJECT id (pAlias), never an r-shaped poisoned anchor (got ' + JSON.stringify(rec['r-pAlias'] && rec['r-pAlias'].objectId) + ')')
1078
+ }
1079
+
1080
+ // (b) a host whose deletion cannot work: the archived path must not keep reading as the proof.
1081
+ const h2 = await makeCase('defect-nosub', { noSubprocess: true })
1082
+ await h2.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1083
+ const proj2 = projRoot(h2)
1084
+ mkdirSync(join(proj2, 'Verified', 'Lean'), { recursive: true })
1085
+ mkdirSync(join(proj2, 'Formal'), { recursive: true })
1086
+ const WORK = 'theorem p_stale : 2 + 2 = 4 := by decide\n'
1087
+ writeFileSync(join(proj2, 'Formal', 'pStale.lean'), WORK, 'utf8')
1088
+ writeFileSync(join(proj2, 'Verified', 'Lean', 'pStale.lean'), WORK, 'utf8')
1089
+ // The record is written directly (this host has no subprocess, so the LEAN tools cannot run at all):
1090
+ // what is under test is the WITHDRAWAL path, not the archiving path.
1091
+ 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')
1092
+ await h2.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1093
+ await h2.call('vibe_math_add_proposition', { id: 'pStale', 概述: '宿主删不掉归档证明时的撤回', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1094
+ await startScheduler(h2)
1095
+ const st0 = await h2.call('vibe_math_status', {})
1096
+ 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')
1097
+ const vs2 = await waitFor(() => { const x = verifiersOf(h2, 'r-pStale'); return x.length >= 2 ? x : undefined }, 60, 250)
1098
+ assert(!!vs2, 'verifiers were spawned on the no-subprocess host')
1099
+ if (vs2) {
1100
+ replyFrom(h2, vs2[0].childId, { Result: 0.3, Reason: '不忠实(弃权)', formal: { target: 'pStale', decision: 'defect', note: 'Lean 少了 n≥1' } })
1101
+ const rec2 = await waitFor(() => { const r = (formalStateOf(h2).records || {}); return (r['pStale'] && r['pStale'].decision === 'defect') ? r : undefined }, 40, 150)
1102
+ assert(!!rec2, '★ the defect is absorbed even though this host cannot run a delete')
1103
+ assert(!!rec2 && rec2['pStale'].status === 'attempted' && rec2['pStale'].proof === '', 'the record is downgraded and its proof pointer cleared')
1104
+ const stalePath = join(proj2, 'Verified', 'Lean', 'pStale.lean')
1105
+ assert(existsSync(stalePath), 'the archived file could NOT be deleted here (no subprocess) — so the fallback has to handle it')
1106
+ const body = readIf(stalePath)
1107
+ 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)')
1108
+ assert(/已撤回/.test(body), '★★ …and that path carries an explicit withdrawal notice instead')
1109
+ assert(/Formal\/pStale\.lean/.test(body), 'the notice points at the working file that is kept')
1110
+ const acts = (await h2.call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')
1111
+ assert(/覆写/.test(acts), '★ the announcement says WHAT actually happened (overwritten, not deleted)')
1112
+ assert(/撤回「已通过」状态/.test(acts), 'the announcement still says the passed status was withdrawn')
1113
+ }
1114
+ }
1115
+
1116
+ // ---------- 15. the「判断命题」transfer is a verdict on the SOURCE proposition ----------
1117
+ // When a solver's solution for a "判断下述命题是否成立:X" problem is verified, the result is TRANSFERRED
1118
+ // onto the source proposition: `布尔估计 = v`, `已验证 = true`, a probability-1 proof/refutation entry,
1119
+ // and `优先级 = 'never'` for a boolean v. That IS a boolean verdict on X, so require mode must gate it —
1120
+ // otherwise a proposition nobody formalized is silently concluded (and permanently de-scheduled).
1121
+ // (c) an OBJECT ID that itself ends in `-sN` / `-pfN` / `-rfN` — exactly the shapes the rId suffix
1122
+ // parser strips. `formalObjectIdOf` used to strip such a suffix unconditionally, so the verification id
1123
+ // `r-pAmb-s1` resolved to object `pAmb`: a DIFFERENT object. A defect about the former would then
1124
+ // downgrade the latter and even retract ITS archived proof, while the real owner stayed `passed`.
1125
+ // The verification task knows its owner (r.pId / r.qid), so that is the authoritative source.
1126
+ {
1127
+ const h = await makeCase('defect-ambig')
1128
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1129
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1130
+ await startScheduler(h)
1131
+ const proj = projRoot(h)
1132
+ const innocent = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pAmb', content: 'theorem p_amb : 2 + 2 = 4 := by decide\n' })
1133
+ assert(innocent.ok === true && innocent.passed === true, 'ambiguity: the neighbour pAmb has its own passing proof')
1134
+ const innocentProof = join(proj, 'Verified', 'Lean', 'pAmb.lean')
1135
+ const prone = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pAmb-s1', content: 'theorem p_amb_s1 : 3 + 3 = 6 := by decide\n' })
1136
+ assert(prone.ok === true && prone.passed === true, 'ambiguity: pAmb-s1 (id ending in -s1) has its own passing proof')
1137
+ const proneProof = join(proj, 'Verified', 'Lean', 'pAmb-s1.lean')
1138
+ assert(existsSync(innocentProof) && existsSync(proneProof), 'ambiguity: both archived proofs exist')
1139
+ await h.call('vibe_math_add_proposition', { id: 'pAmb', 概述: '同前缀的邻居对象', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1140
+ await h.call('vibe_math_add_proposition', { id: 'pAmb-s1', 概述: '对象 id 本身以 -s1 结尾', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1141
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pAmb-s1'); return x.length >= 2 ? x : undefined }, 60, 250)
1142
+ assert(!!vs, 'ambiguity: verifiers were spawned for r-pAmb-s1')
1143
+ const vp = (h.spawns.find((s) => s.label === 'verifier:r-pAmb-s1:0') || {}).prompt || ''
1144
+ assert(/忠实性审查/.test(vp), 'ambiguity: the review subject is the Lean-passed object')
1145
+ assert(/Verified\/Lean\/pAmb-s1\.lean/.test(vp), '★★ and the fidelity prompt points at pAmb-s1 OWN proof, not at a same-prefix neighbour (got ' + (vp.match(/Verified\/Lean\/[^\s))]+/) || ['none'])[0] + ')')
1146
+ if (vs) {
1147
+ replyFrom(h, vs[0].childId, { Result: 0.3, Reason: '结论写窄了(弃权)', formal: { target: 'r-pAmb-s1', decision: 'defect', note: 'Lean 只证了 n>0 的情形' } })
1148
+ const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['pAmb-s1'] && r['pAmb-s1'].decision === 'defect') ? r : undefined }, 40, 150)
1149
+ assert(!!rec, '★★ a defect named by r-pAmb-s1 downgrades its REAL owner (pAmb-s1)')
1150
+ assert(!existsSync(proneProof) || /已撤回/.test(readIf(proneProof)), '★★ and retracts pAmb-s1 own archived proof')
1151
+ const recs = formalStateOf(h).records || {}
1152
+ assert(!!recs['pAmb'] && recs['pAmb'].status === 'passed', '★★ the same-prefix NEIGHBOUR pAmb keeps its passed status (got ' + (recs['pAmb'] ? recs['pAmb'].status : 'no record') + ')')
1153
+ assert(existsSync(innocentProof) && !/已撤回/.test(readIf(innocentProof)), '★★ and its archived proof file is untouched — a defect must never retract another object\'s proof')
1154
+ assert(/pAmb-s1/.test(readIf(join(proj, 'Formal', 'TODO.md')) || ''), 'the TODO names the real owner')
1155
+ // The record carries the authoritative owner, so the mapping survives a resume even when the
1156
+ // in-memory verification task is gone (the fallback string parse would pick the neighbour again).
1157
+ assert(!!recs['r-pAmb-s1'] && recs['r-pAmb-s1'].objectId === 'pAmb-s1',
1158
+ '★★ the verification record remembers its object id (authoritative, not re-parsed from the name) — got ' + JSON.stringify(recs['r-pAmb-s1'] && recs['r-pAmb-s1'].objectId))
1159
+ assert(!!recs['pAmb'] && !recs['pAmb'].objectId, 'the neighbour record carries no bogus owner stamp')
1160
+ }
1161
+ }
1162
+
1163
+ // (d) THE RECORD-OWNER FALLBACK must not be hijacked by a hand-edited state file. The state file is
1164
+ // user-editable, and a record whose `objectId` is itself r-shaped is not a usable owner: it would make
1165
+ // the verification id map to ITSELF, so a defect named by that rId would never reach the object side.
1166
+ // This case uses a WORK-ROUND (explorer) reply, which also carries `formal` and runs with NO verification
1167
+ // task in memory — i.e. it is the path that actually reads the owner from the record (a verifier reply
1168
+ // would take the task-registry path instead, which is authoritative and already covered above).
1169
+ {
1170
+ const h = await makeCase('anchor-poison', { noSubprocess: true })
1171
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1172
+ const proj = projRoot(h)
1173
+ mkdirSync(join(proj, 'Verified', 'Lean'), { recursive: true })
1174
+ mkdirSync(join(proj, 'Formal'), { recursive: true })
1175
+ const WORK = 'theorem p_x : 2 + 2 = 4 := by decide\n'
1176
+ writeFileSync(join(proj, 'Formal', 'pX.lean'), WORK, 'utf8')
1177
+ writeFileSync(join(proj, 'Verified', 'Lean', 'pX.lean'), WORK, 'utf8')
1178
+ const base = { status: 'passed', file: 'Formal/pX.lean', proof: 'Verified/Lean/pX.lean', decision: 'used', updatedAt: 1 }
1179
+ writeFileSync(join(proj, 'VibeMath_State', 'formal.json'), JSON.stringify({ records: {
1180
+ pX: Object.assign({}, base),
1181
+ 'r-pX': Object.assign({}, base, { objectId: 'r-pX' }), // ← the r-shaped anchor a hand edit could leave
1182
+ }, todo: [] }), 'utf8')
1183
+ await h.call('vibe_math_add_problem', { id: 'qA', description: '锚点污染用例', priority: 1 })
1184
+ await startScheduler(h)
1185
+ await tick(2000)
1186
+ const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:qA')), 60, 200)
1187
+ assert(!!ex, 'anchor-poison: the explorer was spawned (the scheduler is running)')
1188
+ if (ex) {
1189
+ const reply = { directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.6 }], formal: { target: 'r-pX', decision: 'defect', note: 'Lean 少了 n≥1' } }
1190
+ h.fireEnd({ id: ex.childId, runId: 'rp', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }] })
1191
+ const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['pX'] && r['pX'].decision === 'defect') ? r : undefined }, 40, 150)
1192
+ assert(!!rec, '★★ a work-round defect named by an rId still reaches the OBJECT side when the record carries an r-shaped anchor (the anchor is rejected, not trusted)')
1193
+ const all = formalStateOf(h).records || {}
1194
+ assert(!!all['r-pX'] && all['r-pX'].status === 'attempted', '★★ and the rId record is downgraded as well (both id spaces stay in step)')
1195
+ }
1196
+ }
1197
+
1198
+ section('15 the require gate also covers the judge-problem transfer')
1199
+ {
1200
+ const h = await makeCase('judge-gate')
1201
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1202
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1203
+ const proj = projRoot(h)
1204
+ await h.call('vibe_math_add_proposition', { id: 'pJudgeSrc', 概述: '被判断的源命题(未形式化)', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1205
+ await h.call('vibe_math_add_problem', { id: 'qJudge', description: '判断下述命题是否成立:pJudgeSrc', priority: 1 })
1206
+ const qsFile = join(proj, 'qs', 'qs.json')
1207
+ const qs0 = JSON.parse(readIf(qsFile) || '[]')
1208
+ const qj = qs0.find((q) => q.id === 'qJudge')
1209
+ qj.判断命题 = 'pJudgeSrc'
1210
+ // A solver-produced solution carries no 来源列表 — that is exactly the branch that transfers to the source.
1211
+ qj.解法列表 = [{ 完整解法: '该命题不成立的论证', 正确概率: 0.8, 已验: false }]
1212
+ writeFileSync(qsFile, JSON.stringify(qs0, null, 2), 'utf8')
1213
+ await startScheduler(h)
1214
+ const sp = await waitFor(() => { const x = verifiersOf(h, 'r-qJudge-s0'); return x.length >= 2 ? x : undefined }, 60, 250)
1215
+ assert(!!sp, 'verifiers were spawned for the judge problem solution')
1216
+ fireVerdicts(h, sp || [], 0)
1217
+ await tick(600)
1218
+ const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
1219
+ const ap = props.find((x) => x.id === 'pJudgeSrc') || {}
1220
+ assert(ap.布尔估计 === 0.5, "★★ the source proposition's 布尔估计 is UNCHANGED (require mode must not conclude 假 through the transfer; got " + ap.布尔估计 + ')')
1221
+ assert(!(ap.证伪列表 || []).some((x) => x.正确概率 === 1), 'no probability-1 refutation was written onto the source proposition')
1222
+ assert(ap.优先级 !== 'never', 'the source proposition was not pinned to never')
1223
+ assert(ap.已验证 !== true, '★ it stays re-verifiable (the ungated path also marked it 已验证, removing it from every future candidate set)')
1224
+ assert(/pJudgeSrc/.test(readIf(join(proj, 'Formal', 'TODO.md'))), '★ the deferred source proposition is on the formalization TODO')
1225
+ assert(!existsSync(join(proj, 'Verified', '数论_Verified.json')), 'no Verified card for the source proposition')
1226
+ }
1227
+
1228
+ // ---------- 15g. P2 #7: the accuracy-sample caps are documented AND announced ----------
1229
+ // The caps (8 samples per object, 32 objects) are deliberate bounded retention, but they used to
1230
+ // evict SILENTLY — the H4 accuracy statistic then drifted with the object count and nothing said so.
1231
+ // This case drives a long debate (2 verifiers × several rounds ⇒ > 8 samples for one object) and
1232
+ // requires the eviction to show up in the activity log.
1233
+ section('15g P2 #7: accuracy-sample cap eviction is announced, not silent')
1234
+ {
1235
+ const h = await makeCase('score-cap-warn')
1236
+ // 一轮就凑够样本:10 个验证器 ⇒ 同一对象一次记 10 条(> 每对象上限 8),无需多轮辩论。
1237
+ await h.call('vibe_math_set_params', { verifierCount: 10, maxParallelThreshold: 20, debateMaxRounds: 1, verdictMode: 'flat' })
1238
+ await h.call('vibe_math_add_proposition', { id: 'p-cap', 概述: '计分上限告警', 概率: 0.6, 分类: '数论' })
1239
+ await startScheduler(h)
1240
+ const cand = await waitFor(() => { const x = verifiersOf(h, 'r-p-cap'); return x.length >= 10 ? x : undefined }, 80, 250)
1241
+ assert(!!cand, '对照:10 个验证器都被派出(实测 verifier spawns=' + h.spawns.filter((s) => s.label.indexOf('verifier') === 0).length + ')')
1242
+ if (cand) { fireVerdicts(h, cand, 0.5); await sleep(400); await tick() }
1243
+ const acts = (await h.call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')
1244
+ assert(/评审计分样本达上限/.test(acts), '★★ [P2#7] 超过每对象 8 条待计分样本时**写告警**,不再静默丢弃(verifier spawns=' + h.spawns.filter((s) => s.label.indexOf('verifier') === 0).length + ' verifiers=' + verifiersOf(h, 'r-p-cap').length + ' followups=' + h.followups.length + ' acts=' + JSON.stringify(acts.slice(-260)) + ')')
1245
+ await h.call('vibe_math_abort', {})
1246
+ }
1247
+
1248
+ // ---------- 16. the prompt corpus (contract §10.10) ----------
1249
+ // A HUMAN must be able to re-read every prompt the framework emitted, not just the assertions
1250
+ // about them. Paths are normalised so the dump is deterministic, diffable and machine-free.
1251
+ section("15b a \`used\` reply must NOT downgrade an already-passed object")
1252
+ {
1253
+ // Contract §4: a fidelity defect WITHDRAWS a proof; a plain `used` judgement merely says "this round
1254
+ // touched the formalization". Letting `used` downgrade an established proof would silently remove the
1255
+ // fidelity branch from every later review prompt and re-close the require gate — the exact class that
1256
+ // was already fixed once for v4's formalSetRun. v3/v4/v5 preserve passed/blocked here; v2 did not.
1257
+ const h = await makeCase('used-keep')
1258
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1259
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
1260
+ await startScheduler(h)
1261
+ const proj = projRoot(h)
1262
+ const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pUsedKeep', content: 'theorem p_used_keep : 2 + 2 = 4 := by decide\n' })
1263
+ assert(proof.ok === true && proof.passed === true, 'used-keep: the object starts Lean-passed')
1264
+ const proofFile = join(proj, 'Verified', 'Lean', 'pUsedKeep.lean')
1265
+ assert(existsSync(proofFile), 'used-keep: the archived proof exists before the used reply')
1266
+ await h.call('vibe_math_add_proposition', { id: 'pUsedKeep', 概述: '已有通过证明后再写一次 used 回执', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1267
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pUsedKeep'); return x.length >= 2 ? x : undefined }, 60, 250)
1268
+ assert(!!vs, 'used-keep: verifiers were spawned for the Lean-passed object')
1269
+ const vp = (h.spawns.find((s) => s.label === 'verifier:r-pUsedKeep:0') || {}).prompt || ''
1270
+ assert(/忠实性审查/.test(vp), 'used-keep: the reviewers were told to audit FIDELITY (the Lean code is the subject)')
1271
+ if (vs) {
1272
+ replyFrom(h, vs[0].childId, { Result: 0.5, Reason: '这一轮只是又写了一遍草稿', formal: { target: 'pUsedKeep', decision: 'used', file: 'Formal/pUsedKeep.lean' } })
1273
+ // The reply is what materialises the canonical verification alias r-<objectId>, so waiting for
1274
+ // THAT is a signal only the reply can produce (the archive already wrote decision:'used').
1275
+ const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['r-pUsedKeep'] && r['pUsedKeep']) ? r : undefined }, 40, 150)
1276
+ assert(!!rec, 'used-keep: the `used` reply is absorbed (the canonical verification alias was materialised)')
1277
+ assert(!!rec && rec['pUsedKeep'].status === 'passed', '★★ a `used` reply does NOT downgrade an already-passed object (got ' + (rec && rec['pUsedKeep'] ? rec['pUsedKeep'].status : 'no record') + ')')
1278
+ assert(!!rec && rec['pUsedKeep'].proof === 'Verified/Lean/pUsedKeep.lean', '★★ and its proof pointer survives the reply')
1279
+ assert(!!rec && !!rec['r-pUsedKeep'] && rec['r-pUsedKeep'].status === 'passed', '★★ the verification-id alias keeps passed too (both id spaces)')
1280
+ assert(existsSync(proofFile), '★★ the archived proof is still on disk')
1281
+ const st = await h.call('vibe_math_status', {})
1282
+ const obj = (st.formal.objects || []).find((o) => o.target === 'pUsedKeep')
1283
+ assert(!!obj && obj.status === 'passed', '★★ status still reports the object as Lean-passed (v2 exposes status.formal.objects, not a "passed" array)')
1284
+ // The consequence that actually matters: with 'passed' preserved the require gate stays OPEN, so
1285
+ // driving the round to a unanimous "true" still concludes (a wrongly downgraded object is deferred).
1286
+ const settled = await verifyWithDebate(h, 'r-pUsedKeep', 1, 0)
1287
+ assert(!!settled.first, 'used-keep: the round can still be driven to a verdict')
1288
+ assert(!/pUsedKeep/.test(readIf(join(proj, 'Formal', 'TODO.md')) || ''), '★★ no formal-required TODO was created: the surviving passed status still opens the require gate')
1289
+ // The same rule covers the RUN path (docs §4): a run attributed to the object may not downgrade it.
1290
+ const rerun = await h.call('vibe_math_lean_run', { file: 'Formal/pUsedKeep.lean', target: 'pUsedKeep' })
1291
+ assert(rerun.ok === true, 'used-keep: the object work file runs green (the record assertion below is the point)')
1292
+ const obj2 = ((await h.call('vibe_math_status', {})).formal.objects || []).find((o) => o.target === 'pUsedKeep')
1293
+ assert(!!obj2 && obj2.status === 'passed', '★★ and the record is still passed (a scratch run may not withdraw a proof)')
1294
+ }
1295
+ }
1296
+
1297
+ // ---------- 15c. the framework's OWN feedback channels (push report + gate notice) ----------
1298
+ // Every corpus entry so far was a child prompt (spawn/wake). The framework also speaks to the main
1299
+ // agent/human on two other channels — the `reportMode=push` progress report and the activity log's
1300
+ // formal-gate notice — and neither had any reviewable artefact (AUDIT §2.4 "框架→人 的反馈与推送类
1301
+ // 提示"). Both are captured DETERMINISTICALLY (no wall-clock, no timing-dependent counts):
1302
+ // · push = the forced push of the strict-termination branch on an EMPTY project (fixed counters);
1303
+ // · feedback = the FIRST formal-gate activity line of one controlled require-mode deferral.
1304
+ section('15c the framework push report and the gate notice are captured')
1305
+ {
1306
+ const h = await makeCase('push-report')
1307
+ await h.call('vibe_math_set_params', { reportMode: 'push', tickIntervalMs: 200 })
1308
+ await h.call('vibe_math_start', {})
1309
+ const pushed = await waitFor(() => (h.root.pushed && h.root.pushed.length > 0 ? h.root.pushed : undefined), 80, 100)
1310
+ assert(!!pushed, 'push-report: the scheduler pushed a progress report to the main agent')
1311
+ assert(!!pushed && /\[Vibe Math V2\] 进度更新/.test(pushed[0]), '★★ the push text is the real framework→main-agent prompt (reportMode=push)')
1312
+ assert(!!pushed && /vibe_math_report/.test(pushed[0]), 'and it names the FULL tool the main agent must call')
1313
+ await h.call('vibe_math_pause', {})
1314
+
1315
+ const g = await makeCase('gate-feedback')
1316
+ await g.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1317
+ await g.call('vibe_math_add_proposition', { id: 'pFeed', 概述: '门禁反馈语料', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1318
+ await startScheduler(g)
1319
+ const gvs = await waitFor(() => { const x = verifiersOf(g, 'r-pFeed'); return x.length >= 2 ? x : undefined }, 60, 250)
1320
+ assert(!!gvs, 'gate-feedback: verifiers were spawned for the proposition')
1321
+ if (gvs) for (let i = 0; i < gvs.length && i < 2; i++) replyFrom(g, gvs[i].childId, { Result: 1, Reason: '门禁反馈用例' })
1322
+ let gline
1323
+ for (let i = 0; i < 60 && !gline; i++) {
1324
+ const acts = (await g.call('vibe_math_status', {})).recentActivity || []
1325
+ const hit = acts.find((a) => /formal-gate/.test(String(a.event)))
1326
+ if (hit) gline = String(hit.event) + ': ' + String(hit.detail)
1327
+ else await sleep(150)
1328
+ }
1329
+ assert(!!gline && /require 模式搁置/.test(gline), '★★ the require-gate withholding reaches the human-readable channel (captured verbatim)')
1330
+ assert(!!gline && /Formal\/TODO\.md/.test(gline), 'and the notice says where the withholding was recorded')
1331
+ assert(/下一步:/.test(String(gline)) && /代码 formal-required/.test(String(gline)) && !/(当前状态 none)/.test(String(gline)),
1332
+ '★★ [A5] require-gate 反馈行给出**下一步**且状态已本地化(代码 token 保留),实测 ' + JSON.stringify(gline))
1333
+ g.feedbackLine = gline || ''
1334
+ await g.call('vibe_math_pause', {})
1335
+ }
1336
+
1337
+ section('15d Lean 增量 + 异步(spec §1–§4 + 修订 §1–§5):参数、argv、队列、去重、只读面、提示词')
1338
+ {
1339
+ const H = MOD.__testHelpers
1340
+ const slash = (s) => String(s == null ? '' : s).replace(/\\/g, '/') // 插件内部用 '/',测试用 path.join ⇒ 比较前统一
1341
+ const SAME = (a, b) => slash(a) === slash(b)
1342
+ // ── 参数:默认值 + 归一化(字符串 'false' 绝不能滑过去) ────────────────────────────
1343
+ const a = await makeCase('lean-params')
1344
+ const st0 = await a.call('vibe_math_status', {})
1345
+ assert(st0.params.leanAsync === false, 'the suite pins leanAsync=false by default (sync-path guard)')
1346
+ await a.call('vibe_math_set_params', { leanAsync: true })
1347
+ assert((await a.call('vibe_math_status', {})).params.leanAsync === true, 'leanAsync=true is accepted (the async档)')
1348
+ const bad = await a.call('vibe_math_set_params', { leanAsync: 'false' })
1349
+ assert(bad.ok === true, 'set_params accepts a (badly typed) leanAsync')
1350
+ assert((await a.call('vibe_math_status', {})).params.leanAsync === true, '★★★ leanAsync="false" (a STRING) does not slip through the `else out[k]=v` tail: it falls back to the boolean default (true) — a truthy string must never be read as a boolean')
1351
+ const defs = (await a.call('vibe_math_status', {})).params
1352
+ assert(defs.leanJobsMaxParallel === 1 && defs.leanInitiative === 'normal' && Array.isArray(defs.leanSearchPaths) && defs.leanSearchPaths.length === 0, 'the amendment defaults are leanJobsMaxParallel=1 / leanInitiative=normal / leanSearchPaths=[]')
1353
+ await a.call('vibe_math_set_params', { leanInitiative: 'banana', leanJobsMaxParallel: 0, leanSearchPaths: 'not-an-array' })
1354
+ const p1 = (await a.call('vibe_math_status', {})).params
1355
+ assert(p1.leanInitiative === 'normal', 'leanInitiative rejects an unknown mode back to normal')
1356
+ assert(p1.leanJobsMaxParallel === 1, 'leanJobsMaxParallel is floored at 1 (never 0/unbounded)')
1357
+ assert(Array.isArray(p1.leanSearchPaths) && p1.leanSearchPaths.length === 0, 'leanSearchPaths rejects a non-array back to []')
1358
+ await a.call('vibe_math_set_params', { leanSearchPaths: [' /opt/lean-lib ', '/opt/lean-lib', ''] })
1359
+ assert(JSON.stringify((await a.call('vibe_math_status', {})).params.leanSearchPaths) === JSON.stringify(['/opt/lean-lib']), 'leanSearchPaths trims, drops empties and de-duplicates')
1360
+ // 纯函数:搜索路径计划(修订 §2)
1361
+ const planDefault = H.leanSearchPathPlan([], [], '/root/vm')
1362
+ assert(JSON.stringify(planDefault.paths) === JSON.stringify(['/root/vm']) && planDefault.inject.length === 2, '★ default plan injects exactly the automatic VibeMath root')
1363
+ const planExtra = H.leanSearchPathPlan(['env', 'lean'], ['/a', '/b'], '/root/vm')
1364
+ assert(JSON.stringify(planExtra.paths) === JSON.stringify(['/a', '/b', '/root/vm']), '★★ user leanSearchPaths come FIRST, then the automatic root')
1365
+ assert(JSON.stringify(planExtra.inject) === JSON.stringify(['--search-path', '/a', '--search-path', '/b', '--search-path', '/root/vm']), 'and each path is injected with its own --search-path flag')
1366
+ assert(H.leanSearchPathPlan(['--search-path', '/x'], ['/a'], '/root/vm').inject.length === 0, '★ an explicit --search-path in leanArgs suppresses all injection (user wins)')
1367
+ assert(H.leanSearchPathPlan(['-R', '/x'], ['/a'], '/root/vm').inject.length === 0 && H.leanSearchPathPlan(['--root', '/x'], ['/a'], '/root/vm').inject.length === 0, '★ -R / --root are honoured too')
1368
+
1369
+ // ── argv:注入位置在用户参数之后、文件名之前 ──────────────────────────────────────
1370
+ const b = await makeCase('lean-argv')
1371
+ await b.call('vibe_math_set_params', { leanAsync: false, leanArgs: ['env', 'lean'], leanCommand: 'lean' })
1372
+ const projB = projRoot(b)
1373
+ mkdirSync(join(projB, 'Formal'), { recursive: true })
1374
+ writeFileSync(join(projB, 'Formal', 'srch.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1375
+ const runsBefore = leanRuns.length
1376
+ await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1377
+ const argv1 = leanRuns[leanRuns.length - 1].argv
1378
+ assert(leanRuns.length === runsBefore + 1, 'the sync run really reached the compiler')
1379
+ assert(JSON.stringify(argv1.slice(0, 2)) === JSON.stringify(['lean', 'env']), 'the user leanArgs are preserved in order')
1380
+ assert(argv1[argv1.length - 2] === '--search-path' && SAME(argv1[argv1.length - 1], vibeRoot(b)), '★★ --search-path + the VibeMath root are injected AFTER the user args and immediately BEFORE the file (argv=' + JSON.stringify(argv1) + ')')
1381
+ await b.call('vibe_math_set_params', { leanArgs: ['env', 'lean', '--search-path', '/explicit'] })
1382
+ await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1383
+ const argv2 = leanRuns[leanRuns.length - 1].argv
1384
+ assert(argv2.indexOf('--search-path') === 3 && argv2[4] === '/explicit' && argv2.filter((x) => x === '--search-path').length === 1, '★ an explicit --search-path is respected verbatim (no second injection) — got ' + JSON.stringify(argv2))
1385
+ await b.call('vibe_math_set_params', { leanArgs: ['env', 'lean'], leanSearchPaths: ['/extra1', '/extra2'] })
1386
+ await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1387
+ const argv3 = leanRuns[leanRuns.length - 1].argv
1388
+ assert(JSON.stringify(argv3.slice(-6)) === JSON.stringify(['--search-path', '/extra1', '--search-path', '/extra2', '--search-path', slash(vibeRoot(b))]), '★★ leanSearchPaths are injected first, then the automatic root (got ' + JSON.stringify(argv3.slice(-6)) + ')')
1389
+
1390
+ // ── 异步状态机:入队立即返回 → 心跳排空 → 只有 settled(ok) 才是通过 ────────────────
1391
+ const c = await makeCase('lean-async')
1392
+ await c.call('vibe_math_set_params', { leanAsync: true, tickIntervalMs: 200 })
1393
+ const projC = projRoot(c)
1394
+ mkdirSync(join(projC, 'Formal'), { recursive: true })
1395
+ writeFileSync(join(projC, 'Formal', 'async-good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1396
+ writeFileSync(join(projC, 'Formal', 'async-bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
1397
+ const nBefore = leanRuns.length
1398
+ const q1 = await c.call('vibe_math_lean_run', { file: 'Formal/async-good.lean', target: 'pAsync' })
1399
+ assert(q1.ok === true && !!q1.async && q1.async.state === 'queued' && typeof q1.async.jobId === 'string' && q1.async.jobId.length > 3, '★★ leanAsync=true returns IMMEDIATELY with {async:{jobId,state:queued}} (got ' + JSON.stringify(q1.async) + ')')
1400
+ assert(leanRuns.length === nBefore, '★★ the tool call itself did NOT block on the compiler (it is enqueued, not run)')
1401
+ assert(q1.async.jobId.indexOf('pAsync-') === 0, 'the jobId is <target>-<fingerprint[0:12]> (got ' + q1.async.jobId + ')')
1402
+ const jobFile = join(projC, 'Formal', 'Jobs', q1.async.jobId + '.json')
1403
+ assert(existsSync(jobFile), 'the job record is mirrored to <project>/Formal/Jobs/<jobId>.json')
1404
+ const rec0 = JSON.parse(readIf(jobFile))
1405
+ assert(rec0.state === 'queued' && typeof rec0.sha256 === 'string' && typeof rec0.buildSha256 === 'string' && rec0.buildContext.length > 0, '★ the job record carries state + content hash + build-context hash')
1406
+ assert((formalStateOf(c).records || {}).pAsync === undefined || formalStateOf(c).records.pAsync.status !== 'passed', '★★ a queued job never marks the object passed')
1407
+ const done1 = await waitFor(() => { const r = JSON.parse(readIf(jobFile) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1408
+ assert(!!done1, 'the apply-level heartbeat drained the queue and settled the job (got ' + JSON.parse(readIf(jobFile) || '{}').state + ')')
1409
+ assert(done1 && done1.exitCode === 0, 'the settled record keeps exitCode 0')
1410
+ const recAfter = formalStateOf(c).records.pAsync
1411
+ assert(!!recAfter && recAfter.status === 'attempted' && recAfter.run && recAfter.run.ok === true, 'a green RUN record lands as attempted (stored, not promoted) with run.ok=true')
1412
+ assert(!!recAfter.async && recAfter.async.state === 'settled' && recAfter.async.jobId === q1.async.jobId, 'the object record carries the async job pointer/state')
1413
+ // 红色作业:attempted + 编译器输出入库,绝不 passed
1414
+ const q2 = await c.call('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' })
1415
+ const done2 = await waitFor(() => { const r = JSON.parse(readIf(join(projC, 'Formal', 'Jobs', q2.async.jobId + '.json')) || '{}'); return r.state === 'failed' ? r : undefined }, 80, 60)
1416
+ assert(!!done2 && done2.exitCode === 1, '★ a red async run settles as failed with exitCode 1')
1417
+ const recBad = formalStateOf(c).records.pAsyncBad
1418
+ assert(!!recBad && recBad.status === 'attempted' && recBad.run.ok === false, '★ a red async run is recorded as attempted (never passed)')
1419
+ assert(/sorry/.test(String(recBad.run.stderrTail || '')), 'and the compiler output is kept in the record for the member to fix')
1420
+ // ── 归档证明(异步):落地前不是 passed;落地 ok 才写 Verified/Lean/ ────────────────
1421
+ await c.call('vibe_math_add_proposition', { id: 'pAsymProof', 概述: '异步归档证明', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1422
+ mkdirSync(join(projC, 'Formal'), { recursive: true })
1423
+ const q3 = await c.call('vibe_math_lean_archive', { kind: 'proof', target: 'pAsymProof', content: 'theorem pAsymProof : 1 = 1 := rfl\n' })
1424
+ assert(q3.ok === true && q3.async && q3.async.state === 'queued' && q3.status === 'attempted', '★★ kind=proof in async档 returns queued + attempted (NOT passed)')
1425
+ assert(!existsSync(join(projC, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ the archived proof file does NOT exist while the job is still queued')
1426
+ assert((formalStateOf(c).records.pAsymProof || {}).status !== 'passed', 'and the record is not passed yet')
1427
+ const done3 = await waitFor(() => { const r = JSON.parse(readIf(join(projC, 'Formal', 'Jobs', q3.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1428
+ assert(!!done3, 'the proof job settled green')
1429
+ assert(existsSync(join(projC, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ only after settled(ok) is Verified/Lean/<id>.lean written')
1430
+ assert((formalStateOf(c).records.pAsymProof || {}).status === 'passed', '★★ and only then does the object become passed')
1431
+ assert(/pAsymProof/.test(readIf(join(projC, 'Formal', 'Index.md'))), 'the index reflects the passed object')
1432
+ // ── lean_job(修订 §3):清单 / 单查 / waitMs ─────────────────────────────────────
1433
+ const listJobs = await c.call('vibe_math_lean_job', {})
1434
+ assert(listJobs.ok === true && Array.isArray(listJobs.jobs) && listJobs.jobs.length >= 3, '★ lean_job without jobId returns the session job list')
1435
+ assert(listJobs.jobs.every((j) => !!j.paths && typeof j.paths.receipt === 'string'), 'every listed job carries its receipt path')
1436
+ const one = await c.call('vibe_math_lean_job', { jobId: q3.async.jobId })
1437
+ assert(one.ok === true && one.state === 'settled' && one.passed === true && one.paths.archive === 'Verified/Lean/pAsymProof.lean', '★ lean_job {jobId} returns state/exitCode/paths and the passed verdict')
1438
+ assert((await c.call('vibe_math_lean_job', { jobId: 'nope-000000000000' })).code === 'V2_NOT_FOUND', 'an unknown jobId is refused with a typed code')
1439
+ const q4 = await c.call('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' })
1440
+ const waited = await c.call('vibe_math_lean_job', { jobId: q4.async.jobId, waitMs: 4000 })
1441
+ assert(waited.ok === true && waited.state !== 'queued' && waited.stillRunning === false && waited.waitedMs > 0, '★★ lean_job waitMs>0 waits for the job to leave queued/running (got ' + waited.state + ')')
1442
+ // ── 超时:leanTimeoutMs 是每个作业的预算,主动 terminate,绝不 passed ─────────────
1443
+ const d = await makeCase('lean-timeout')
1444
+ await d.call('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 1000 })
1445
+ const projD = projRoot(d)
1446
+ mkdirSync(join(projD, 'Formal'), { recursive: true })
1447
+ writeFileSync(join(projD, 'Formal', 'hang.lean'), '-- HANG\ntheorem t : 1 = 1 := rfl\n', 'utf8')
1448
+ const termBefore = terminations.length
1449
+ const q5 = await d.call('vibe_math_lean_run', { file: 'Formal/hang.lean', target: 'pHang' })
1450
+ const done5 = await waitFor(() => { const r = JSON.parse(readIf(join(projD, 'Formal', 'Jobs', q5.async.jobId + '.json')) || '{}'); return r.state === 'timeout' ? r : undefined }, 120, 60)
1451
+ assert(!!done5 && done5.timedOut === true, '★★ a hanging job times out (state=timeout, timedOut=true) with leanTimeoutMs as its budget')
1452
+ assert(terminations.length > termBefore, '★★ the timeout ACTIVELY called handle.terminate()')
1453
+ assert((formalStateOf(d).records.pHang || {}).status !== 'passed', '★ a timed-out job never sets passed')
1454
+ // ── 并发参数(修订 §5):leanJobsMaxParallel>1 时两个作业同时进入 running ──────────
1455
+ const e = await makeCase('lean-parallel')
1456
+ await e.call('vibe_math_set_params', { leanAsync: true, leanJobsMaxParallel: 2 })
1457
+ const projE = projRoot(e)
1458
+ mkdirSync(join(projE, 'Formal'), { recursive: true })
1459
+ writeFileSync(join(projE, 'Formal', 'h1.lean'), '-- HANG\ntheorem h1 : 1 = 1 := rfl\n', 'utf8')
1460
+ writeFileSync(join(projE, 'Formal', 'h2.lean'), '-- HANG\ntheorem h2 : 1 = 1 := rfl\n', 'utf8')
1461
+ await e.call('vibe_math_set_params', { leanTimeoutMs: 3000 })
1462
+ const j1 = await e.call('vibe_math_lean_run', { file: 'Formal/h1.lean', target: 'pH1' })
1463
+ const j2 = await e.call('vibe_math_lean_run', { file: 'Formal/h2.lean', target: 'pH2' })
1464
+ // Both are HANG jobs: with concurrency 2 they must be in `running` AT THE SAME TIME. A serial
1465
+ // queue can never reach that state, so the probe discriminates the `leanJobsMaxParallel` parameter.
1466
+ const both = await waitFor(() => {
1467
+ const a = JSON.parse(readIf(join(projE, 'Formal', 'Jobs', j1.async.jobId + '.json')) || '{}')
1468
+ const b = JSON.parse(readIf(join(projE, 'Formal', 'Jobs', j2.async.jobId + '.json')) || '{}')
1469
+ return (a.state === 'running' && b.state === 'running') ? { a: a, b: b } : undefined
1470
+ }, 120, 50)
1471
+ assert(!!both, '★★ with leanJobsMaxParallel=2 BOTH queued jobs are in running at the same time (concurrency is a parameter, not a constant)')
1472
+ for (const k of [j1, j2]) { try { await e.call('vibe_math_lean_job', { jobId: k.async.jobId, waitMs: 3500 }) } catch (err) { /* ignore */ } }
1473
+ // ── 卸载(spec §2.5 / §7-6):terminate + interrupted,绝不 passed、绝不留孤儿 ────────
1474
+ const dz = await makeCase('lean-dispose')
1475
+ await dz.call('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 60000 })
1476
+ const projZ = projRoot(dz)
1477
+ mkdirSync(join(projZ, 'Formal'), { recursive: true })
1478
+ writeFileSync(join(projZ, 'Formal', 'orphan.lean'), '-- HANG\ntheorem orphan : 1 = 1 := rfl\n', 'utf8')
1479
+ const termZ = terminations.length
1480
+ const qz = await dz.call('vibe_math_lean_run', { file: 'Formal/orphan.lean', target: 'pOrphan' })
1481
+ const startedZ = await waitFor(() => { const r = JSON.parse(readIf(join(projZ, 'Formal', 'Jobs', qz.async.jobId + '.json')) || '{}'); return r.state === 'running' ? r : undefined }, 80, 60)
1482
+ assert(!!startedZ, 'the dispose case got a job into running')
1483
+ dz.unmount() // ctx.effect disposer ⇒ disposeLeanJobs()
1484
+ assert(terminations.length > termZ, '★★ unmounting the plugin terminated the in-flight compile (no orphan process)')
1485
+ assert((formalStateOf(dz).records.pOrphan || {}).status !== 'passed', '★ dispose never marks the object passed')
1486
+ const afterZ = JSON.parse(readIf(join(projZ, 'Formal', 'Jobs', qz.async.jobId + '.json')) || '{}')
1487
+ assert(afterZ.interrupted === true || afterZ.state === 'interrupted' || afterZ.state === 'running', '★ the disposed job is recorded as interrupted (or left running for the next recovery to mark)')
1488
+ // ── 去重(§4.3):同内容 + 同构建上下文 ⇒ 第二次不再编译 ──────────────────────────
1489
+ const f = await makeCase('lean-dedupe')
1490
+ await f.call('vibe_math_set_params', { leanAsync: true })
1491
+ const defBody = 'def reusableOne : Nat := 1\n'
1492
+ const r1 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1493
+ assert(r1.ok === true && r1.async && r1.deduped === false, 'the first archive of a definition is enqueued (not deduped)')
1494
+ await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r1.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1495
+ const runsAfter1 = leanRuns.length
1496
+ const r2 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1497
+ assert(r2.ok === true && r2.deduped === true && r2.async === undefined, '★★★ re-archiving identical content is DEDUPED (no job, no recompile) — got ' + JSON.stringify({ deduped: r2.deduped, async: r2.async }))
1498
+ assert(leanRuns.length === runsAfter1, '★★ the deduped archive did NOT recompile')
1499
+ // 构建上下文进指纹(修订 §4):同内容、不同 leanArgs ⇒ 不同 jobId / 不命中"已验证"
1500
+ await f.call('vibe_math_set_params', { leanArgs: ['--threads=2'] })
1501
+ const r5 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1502
+ assert(r5.ok === true && r5.deduped === false && !!r5.async && !!r1.async && r5.async.jobId !== r1.async.jobId, '★★★ the SAME content under a DIFFERENT build context (leanArgs) gets a different jobId — it is not treated as already-verified (got ' + JSON.stringify({ deduped: r5.deduped, async: r5.async }) + ')')
1503
+ if (r5.async) await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r5.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1504
+ await f.call('vibe_math_set_params', { leanArgs: [] })
1505
+ const changed = defBody + '\n-- 内容变了\n'
1506
+ const r3 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed })
1507
+ assert(r3.ok === true && r3.deduped === false && !!r3.async, 'a changed body is NOT deduped (content hash differs)')
1508
+ if (r3.async) await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r3.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1509
+ const r6 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed })
1510
+ assert(r6.ok === true && r6.deduped === true, 'and the changed body dedupes on its own (content-based, not "first wins")')
1511
+ // ── 崩溃恢复(§2.6 + 修订 §4):绝不置 passed ─────────────────────────────────────
1512
+ const g = await makeCase('lean-recover')
1513
+ await g.call('vibe_math_set_params', { leanAsync: true })
1514
+ const projG = projRoot(g)
1515
+ mkdirSync(join(projG, 'Formal', 'Jobs'), { recursive: true })
1516
+ const goodBody = 'theorem recGood : 1 = 1 := rfl\n'
1517
+ writeFileSync(join(projG, 'Formal', 'recGood.lean'), goodBody, 'utf8')
1518
+ const ctxG = H.leanBuildContext('lean', [], [slash(vibeRoot(g))])
1519
+ const fpG = H.leanJobFingerprint(goodBody, ctxG)
1520
+ const jobIdG = H.leanJobId('pRecGood', fpG)
1521
+ writeFileSync(join(projG, 'Formal', 'Jobs', jobIdG + '.json'), JSON.stringify({
1522
+ jobId: jobIdG, kind: 'archive', state: 'running', attempts: 1, target: 'pRecGood', name: 'pRecGood',
1523
+ rel: 'Formal/recGood.lean', scope: 'project', sha256: H.leanContentSha(goodBody), buildSha256: fpG, buildContext: ctxG,
1524
+ engine: 'lean', argv: [], searchPaths: [slash(vibeRoot(g))], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1525
+ }), 'utf8')
1526
+ // 哈希不匹配的遗留作业:只标记,绝不重驱、绝不 passed
1527
+ const otherJob = H.leanJobId('pRecOther', H.leanJobFingerprint('theorem other : 1 = 1 := rfl\n', ctxG))
1528
+ writeFileSync(join(projG, 'Formal', 'Jobs', otherJob + '.json'), JSON.stringify({
1529
+ jobId: otherJob, kind: 'archive', state: 'running', attempts: 3, target: 'pRecOther', name: 'pRecOther',
1530
+ rel: 'Formal/recMissing.lean', scope: 'project', sha256: H.leanContentSha('theorem other : 1 = 1 := rfl\n'), buildSha256: 'ffffffffffff', buildContext: ctxG,
1531
+ engine: 'lean', argv: [], searchPaths: [slash(vibeRoot(g))], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1532
+ }), 'utf8')
1533
+ await g.call('vibe_math_new_project', { name: 'proj' }) // 触发 setProject ⇒ recoverLeanJobs()
1534
+ const recG = formalStateOf(g).records.pRecGood || {}
1535
+ assert(recG.status === 'attempted', '★★ recovery marks a crashed running job as attempted (never passed)')
1536
+ const recOther = formalStateOf(g).records.pRecOther || {}
1537
+ assert(recOther.status === 'attempted', 'recovery marks the hash-mismatched job object as attempted too')
1538
+ assert(!existsSync(join(projG, 'Verified', 'Lean', 'pRecGood.lean')), '★ recovery never writes an archived proof for a crashed job')
1539
+ const recJobG = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', jobIdG + '.json')) || '{}')
1540
+ assert(Number(recJobG.attempts) === 2 && ['queued', 'running', 'settled'].indexOf(recJobG.state) !== -1, '★★ the matching-hash job was re-enqueued with attempts+1 (got ' + JSON.stringify({ state: recJobG.state, attempts: recJobG.attempts }) + ')')
1541
+ const otherAfter = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', otherJob + '.json')) || '{}')
1542
+ assert(otherAfter.state === 'interrupted' && Number(otherAfter.attempts) === 3, '★★ the mismatched job is only marked interrupted (NOT re-driven, attempts unchanged)')
1543
+ const recDone = await waitFor(() => { const r = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', jobIdG + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1544
+ assert(!!recDone, 'the re-enqueued job then settles normally on the heartbeat (the queue really resumed)')
1545
+ // ── lean_read(§1.2):只读、路径守卫、64KB 截断 ─────────────────────────────────
1546
+ mkdirSync(join(vibeRoot(g), 'Formal', 'Lib'), { recursive: true })
1547
+ writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', 'recGood.lean'), goodBody, 'utf8')
1548
+ const rd = await g.call('vibe_math_lean_read', { name: 'recGood' })
1549
+ assert(rd.ok === true && rd.kind === 'lib' && rd.sha256 === H.leanContentSha(goodBody) && rd.truncated === false && rd.text === goodBody, '★ lean_read returns the archived text + sha256 + bytes for a Lib file (got ' + JSON.stringify(rd).slice(0, 200) + ')')
1550
+ assert(typeof rd.bytes === 'number' && rd.bytes === Buffer.byteLength(goodBody, 'utf8'), 'lean_read reports the byte length')
1551
+ assert((await g.call('vibe_math_lean_read', { name: 'recGood', kind: 'proved' })).code === 'V2_NOT_FOUND', 'kind=proved looks only in Formal/Proved')
1552
+ for (const badName of ['../etc/passwd', 'a/b', 'C:\\x\\y', '..']) {
1553
+ const rbad = await g.call('vibe_math_lean_read', { name: badName })
1554
+ assert(rbad.ok === false && rbad.code === 'V2_INVALID_ARGUMENT', '★ lean_read rejects the path-escape attempt ' + JSON.stringify(badName))
1555
+ }
1556
+ const bigName = 'bigOne'
1557
+ mkdirSync(join(vibeRoot(g), 'Formal', 'Lib'), { recursive: true })
1558
+ writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', bigName + '.lean'), '-- ' + 'x'.repeat(70 * 1024) + '\n', 'utf8')
1559
+ const rbig = await g.call('vibe_math_lean_read', { name: bigName })
1560
+ assert(rbig.ok === true && rbig.truncated === true && Buffer.byteLength(rbig.text, 'utf8') <= 64 * 1024, '★ lean_read truncates at 64KB and says so (truncated=true)')
1561
+ // ── 索引依赖列(§4.4)与 lean_lib 的 jobs/searchPath ─────────────────────────────
1562
+ writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', 'depUser.lean'), 'import Formal.Lib.bigOne\n\ndef depUser := 1\n', 'utf8')
1563
+ const lib = await g.call('vibe_math_lean_lib', {})
1564
+ assert(!!lib.paths && SAME(lib.paths.searchPath, vibeRoot(g)), '★ lean_lib exposes paths.searchPath = the VibeMath root (got ' + JSON.stringify(lib.paths) + ')')
1565
+ assert(Array.isArray(lib.jobs) && lib.jobs.length >= 1, '★ lean_lib exposes the job list')
1566
+ assert(lib.async === true, 'lean_lib reports the current async mode')
1567
+ const libIdx = readIf(join(vibeRoot(g), 'Formal', 'Lib', 'Index.md'))
1568
+ assert(/依赖(import)/.test(libIdx) && /Formal\.Lib\.bigOne/.test(libIdx), '★★ the Lib index carries the dependency column scanned from `import` lines')
1569
+ // ── 主动性档位(修订 §1):提示词读 leanInitiative,不是 formalVerify ──────────────
1570
+ const wNormal = await makeCase('lean-init-normal')
1571
+ await wNormal.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'normal' })
1572
+ await wNormal.call('vibe_math_add_problem', { id: 'qInit', description: '主动性提示词 normal' })
1573
+ await startScheduler(wNormal)
1574
+ const exN = await waitFor(() => wNormal.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1575
+ const promptNormal = (exN && exN.prompt) || ''
1576
+ assert(/主动性 normal/.test(promptNormal) && /三条筛选判据/.test(promptNormal), '★ the work prompt names the initiative档位 and the three selection criteria')
1577
+ await wNormal.call('vibe_math_pause', {})
1578
+
1579
+ const wEager = await makeCase('lean-init-eager')
1580
+ await wEager.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'eager' })
1581
+ await wEager.call('vibe_math_add_problem', { id: 'qInitE', description: '主动性提示词 eager' })
1582
+ await startScheduler(wEager)
1583
+ const exE = await waitFor(() => wEager.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1584
+ assert(!!exE && /主动档(leanInitiative=eager)/.test(exE.prompt || ''), '★★ leanInitiative=eager adds the eager line (the prompt reads the initiative parameter)')
1585
+ await wEager.call('vibe_math_pause', {})
1586
+
1587
+ // v5 统一口径:主动性(leanInitiative)与验证要求强度(formalVerify)是两根独立的轴。
1588
+ // (a)leanInitiative:'off' ⇒ 连日线都不注入(真无操作)。
1589
+ const wOff = await makeCase('lean-init-off')
1590
+ await wOff.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'off' })
1591
+ await wOff.call('vibe_math_add_problem', { id: 'qInitO', description: '主动性提示词 off' })
1592
+ await startScheduler(wOff)
1593
+ const exO = await waitFor(() => wOff.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1594
+ assert(!!exO && !/顺手形式化/.test(exO.prompt || ''), '★★ leanInitiative=off drops the daily line entirely (initiative is a separate axis from formalVerify, which is still encourage here)')
1595
+ await wOff.call('vibe_math_pause', {})
1596
+
1597
+ // (b)formalVerify:'off' + leanInitiative:'eager' ⇒ 日线仍必须注入(eager 是独立轴,不许被 off 静默吞掉),
1598
+ // 而**验证阶段**的 Lean 文本仍然关闭。
1599
+ const wOffEager = await makeCase('lean-init-off-eager')
1600
+ await wOffEager.call('vibe_math_set_params', { formalVerify: 'off', leanInitiative: 'eager' })
1601
+ await wOffEager.call('vibe_math_add_problem', { id: 'qInitOE', description: 'off+eager 主动性' })
1602
+ await startScheduler(wOffEager)
1603
+ const exOE = await waitFor(() => wOffEager.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1604
+ const pOE = (exOE && exOE.prompt) || ''
1605
+ assert(/主动档(leanInitiative=eager)/.test(pOE) && /三条筛选判据/.test(pOE), '★★★ formalVerify:off + leanInitiative:eager STILL injects the full daily line (v5 rule: the initiative axis is independent)')
1606
+ assert(!/【Lean 形式化验证(/.test(pOE) && !wOffEager.spawns.some((s) => /【Lean 形式化验证(/.test(s.prompt || '')), '★★★ …while the verification-phase Lean block stays closed (formalVerify:off) in every captured prompt of that root')
1607
+ const stOE = await wOffEager.call('vibe_math_status', {})
1608
+ assert(stOE.params.formalVerify === 'off' && stOE.params.leanInitiative === 'eager', 'the off+eager case really ran with formalVerify:off + leanInitiative:eager')
1609
+ await wOffEager.call('vibe_math_pause', {})
1610
+ }
1611
+
1612
+ // ---------- 15e. round-2 lens-1 RUNTIME: the initiative axis is injected at EVERY site ----------
1613
+ // Three injection sites with three different shapes (explorer / rederive / solver). The static [sites]
1614
+ // check can only see that the producer call is not textually gated; THIS section proves the actual
1615
+ // prompt text of each site: (1) formalVerify:'off' + leanInitiative:'eager' must carry the daily line
1616
+ // at that site (and keep the verification block closed), (2) leanInitiative:'off' must drop it there
1617
+ // while the verification phase still follows formalVerify. Assertion names carry the site name, so a
1618
+ // single-site regression names itself instead of hiding behind a sibling site's coverage.
1619
+ const SITE_DAILY = /三条筛选判据/
1620
+ const SITE_VERIFY = /【Lean 形式化验证(/
1621
+ const FENCE = '`'.repeat(3)
1622
+ const jsonReply = (obj) => FENCE + 'json\n' + JSON.stringify(obj) + '\n' + FENCE
1623
+ /** Drive one problem through explorer -> solver(dead-end) -> re-derive and collect each site's prompt. */
1624
+ async function driveSites(h, opts, qid) {
1625
+ const q = qid || 'qSite'
1626
+ await h.call('vibe_math_set_params', Object.assign({ tickIntervalMs: 200, verifierCount: 2, leanAsync: false, maxExplorerRetries: 5, maxParallelThreshold: 4 }, opts))
1627
+ await h.call('vibe_math_add_problem', { id: q, description: '站点级运行时用例' })
1628
+ await startScheduler(h)
1629
+ const label = (n) => h.spawns.filter((s) => s.label.startsWith(n + ':' + q))
1630
+ const ex1 = await waitFor(() => label('explorer')[0], 80, 200)
1631
+ const out = { explorer: (ex1 && ex1.prompt) || '' }
1632
+ if (ex1) h.fireEnd({ id: ex1.childId, runId: 'rS1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.8 }] }) }] })
1633
+ const so = await waitFor(() => label('solver')[0], 80, 200)
1634
+ out.solver = (so && so.prompt) || ''
1635
+ if (so) h.fireEnd({ id: so.childId, runId: 'rS2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ status: 'dead-end', dead_end_reason: '站点用例:本方向不可行', solution: '', solution_probability: 0.1, lemmas: [], routes: [], lessons: [], survival_probability: 0.2, sub_questions: [], new_inventions: [{ 类型: '工具', 标题: '站点工具', 内容描述: 'x', 是否已入库: false }] }) }] })
1636
+ // every direction is now dead-end ⇒ the NEXT explorer for this problem is built by rederivePrompt
1637
+ await waitFor(() => label('explorer').length >= 2 && label('explorer')[1], 80, 200)
1638
+ const exs = label('explorer')
1639
+ out.rederive = (exs[1] && exs[1].prompt) || ''
1640
+ out.distinct = !!out.explorer && !!out.rederive && out.explorer !== out.rederive
1641
+ await h.call('vibe_math_abort', {})
1642
+ return out
1643
+ }
1644
+ {
1645
+ // (1) eager 在 formalVerify:'off' 下也必须在**每一个**站点注入日线
1646
+ const he = await makeCase('site-eager-off')
1647
+ const A = await driveSites(he, { formalVerify: 'off', leanInitiative: 'eager' })
1648
+ assert(SITE_DAILY.test(A.explorer), '★ [explorer site] formalVerify:off + leanInitiative:eager injects the daily line')
1649
+ assert(SITE_DAILY.test(A.solver), '★ [solver site] formalVerify:off + leanInitiative:eager injects the daily line')
1650
+ assert(SITE_DAILY.test(A.rederive), '★ [rederive site] formalVerify:off + leanInitiative:eager injects the daily line')
1651
+ assert(A.distinct, '★ [rederive site] the re-explore prompt really is the rederive builder (differs from the first explorer wave)')
1652
+ assert(A.explorer.indexOf('【Lean 形式化验证(') === -1 && A.solver.indexOf('【Lean 形式化验证(') === -1 && A.rederive.indexOf('【Lean 形式化验证(') === -1, '★ [all sites] formalVerify:off keeps the verification-phase block closed while eager still injects the daily line')
1653
+
1654
+ // (2) leanInitiative:'off' ⇒ 每个站点都没有日线(同一驱动)
1655
+ const ho = await makeCase('site-init-off')
1656
+ const B = await driveSites(ho, { formalVerify: 'encourage', leanInitiative: 'off' }, 'qSiteOff')
1657
+ assert(!SITE_DAILY.test(B.explorer) && !/【顺手形式化/.test(B.explorer), '★ [explorer site] leanInitiative:off drops the daily line')
1658
+ assert(!SITE_DAILY.test(B.solver) && !/【顺手形式化/.test(B.solver), '★ [solver site] leanInitiative:off drops the daily line')
1659
+ assert(!SITE_DAILY.test(B.rederive) && !/【顺手形式化/.test(B.rederive), '★ [rederive site] leanInitiative:off drops the daily line')
1660
+
1661
+ // (2b) …而验证段仍按 formalVerify 出现(同一参数下的另一个 root,驱动到 verifier)
1662
+ const hv = await makeCase('site-init-off-verify')
1663
+ await hv.call('vibe_math_set_params', { tickIntervalMs: 200, verifierCount: 2, leanAsync: false, formalVerify: 'encourage', leanInitiative: 'off' })
1664
+ await hv.call('vibe_math_add_problem', { id: 'qV', description: '验证段仍在' })
1665
+ await startScheduler(hv)
1666
+ const exv = await waitFor(() => hv.spawns.find((s) => s.label.startsWith('explorer:qV')), 80, 200)
1667
+ assert(!!exv && !SITE_DAILY.test(exv.prompt || ''), '★ [explorer site] 第二个 root:initiative off 时 explorer 仍无日线')
1668
+ if (exv) hv.fireEnd({ id: exv.childId, runId: 'rV1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.8 }] }) }] })
1669
+ const sov = await waitFor(() => hv.spawns.find((s) => s.label.startsWith('solver:qV')), 80, 200)
1670
+ if (sov) hv.fireEnd({ id: sov.childId, runId: 'rV2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ status: 'success', solution: 'complete solution', solution_probability: 0.9, lemmas: [], routes: [], lessons: [], survival_probability: 0.9, dead_end_reason: null, sub_questions: [] }) }] })
1671
+ const vv = await waitFor(() => hv.spawns.find((s) => /^verifier:/.test(s.label)), 100, 250)
1672
+ assert(!!vv && SITE_VERIFY.test(vv.prompt || ''), '★ leanInitiative:off 时验证段仍按 formalVerify 注入(verifier 提示词含【Lean 形式化验证()')
1673
+ await hv.call('vibe_math_abort', {})
1674
+ }
1675
+
1639
1676
  // ---------- 15f. P2-v2 D4: ONE participant set / ONE count across the two views ----------
1640
1677
  // Pinned definition (must stay in sync with the plugin's voteCount() doc comment):
1641
1678
  // * participant set of a verify task = `t.children` of the CURRENT round (their `childResults`
@@ -1671,92 +1708,109 @@ section('15f P2-v2 D4: both views read ONE participant set (snapshot + round)')
1671
1708
  '★★★ [D4-v2] 中途调高 verifierCount **不改变**在飞轮次的参与集/需求票数(快照仍是 2;实测 ' + JSON.stringify(after) + ')')
1672
1709
  assert(JSON.stringify(after) === JSON.stringify(afterR),
1673
1710
  '★★ [D4-v2] 调参后两个视图仍然逐字段相同(实测 ' + JSON.stringify({ status: after, report: afterR }) + ')')
1711
+ // 6.5:两条路径(buildReport / getStatus)都必须走同一个 verifyTasksView(),整数组逐字段相同。
1712
+ const stAll = (await h.call('vibe_math_status', {})).verifyTasks || []
1713
+ const rpAll = (await h.call('vibe_math_report', {})).verifyTasks || []
1714
+ assert(stAll.length >= 1 && JSON.stringify(stAll) === JSON.stringify(rpAll),
1715
+ '★★★★ [6.5] status/report 的 verifyTasks 由同一个 verifyTasksView() 产出、逐字段相同(实测 ' + JSON.stringify({ status: stAll, report: rpAll }) + ')')
1674
1716
  await h.call('vibe_math_abort', {})
1675
1717
  }
1676
1718
 
1677
- section('16 the captured prompt corpus is written for human review')
1678
- {
1679
- // Freeze the scheduler in every case FIRST: a still-running tick loop could emit one more
1680
- // prompt between two runs and make the corpus non-deterministic.
1681
- for (const h of hosts) { try { await h.call('vibe_math_pause', {}) } catch (e) { /* ignore */ } }
1682
- const scrub = (h, s) => {
1683
- const ws = String(h.WS)
1684
- const slash = ws.replace(/\\/g, '/')
1685
- return String(s == null ? '' : s)
1686
- .split(slash + '/VibeMath').join('<VIBEMATH>')
1687
- .split(ws + '\\VibeMath').join('<VIBEMATH>')
1688
- .split(slash).join('<WS>')
1689
- .split(ws).join('<WS>')
1690
- // Epoch-ms in a verification-log FILE NAME is run metadata: the final-paper material embeds the
1691
- // evidence index verbatim, so it must be normalised or the shipped corpus changes every run.
1692
- .replace(/_[0-9]{10,16}\.json/g, '_<TIME>.json')
1693
- }
1694
- const entries = []
1695
- for (const h of hosts) {
1696
- for (const s of h.spawns) entries.push({ kind: 'spawn', case: h.label, label: s.label, prompt: scrub(h, s.prompt) })
1697
- for (const f of h.followups) {
1698
- const owner = h.spawns.find((s) => s.childId === f.childId)
1699
- entries.push({ kind: 'wake', case: h.label, label: owner ? owner.label : f.childId, prompt: scrub(h, f.prompt) })
1700
- }
1701
- // The framework's own messages to the main agent (reportMode=push). Only the FIRST push per case is
1702
- // kept: whether a second identical push follows depends on tick/reportDirty timing, and the corpus
1703
- // must stay byte-identical across runs.
1704
- if (h.root && Array.isArray(h.root.pushed) && h.root.pushed.length > 0) entries.push({ kind: 'push', case: h.label, label: 'main-agent', prompt: scrub(h, h.root.pushed[0]) })
1705
- // The framework's human-readable feedback channel: the activity-log line that carries the require
1706
- // gate's withholding notice. It is captured once, in case 15c, for determinism.
1707
- if (typeof h.feedbackLine === 'string' && h.feedbackLine) entries.push({ kind: 'feedback', case: h.label, label: 'activity-log', prompt: scrub(h, h.feedbackLine) })
1708
- }
1709
- mkdirSync(CORPUS_DIR, { recursive: true })
1710
- writeFileSync(join(CORPUS_DIR, 'formal-verify-v2.json'), JSON.stringify({ entries: entries }, null, 2), 'utf8')
1711
- const md = ['# V2 形式化验证交互语料(prompt corpus)', '',
1712
- '> 由 `formal-verify-v2.test.mjs` 落盘:框架**真正发出**的每一条提示词原文。',
1713
- '> 工作区路径归一化为 `<WS>`、VibeMath 根归一化为 `<VIBEMATH>`,因此可 diff、不泄露本机路径。',
1714
- '> 覆盖:off 档(无任何 Lean 文字)、encourage 与 require 的表决初评/辩论、passed 后的忠实性分支、',
1715
- '> 平时工作轮的「顺手形式化」段落与 formal 回执契约,以及框架自己的两条反馈通道:',
1716
- '> `reportMode=push` 的推送汇报(kind=push)与 require 门禁搁置的活动日志公告(kind=feedback)。', '']
1717
- for (let i = 0; i < entries.length; i++) {
1718
- const e = entries[i]
1719
- md.push('## [' + i + '] ' + e.kind + ' · ' + e.label + ' · case=' + e.case)
1720
- md.push('')
1721
- md.push('```text')
1722
- md.push(e.prompt)
1723
- md.push('```')
1724
- md.push('')
1725
- }
1726
- writeFileSync(join(CORPUS_DIR, 'formal-verify-v2.md'), md.join('\n'), 'utf8')
1727
- assert(existsSync(join(CORPUS_DIR, 'formal-verify-v2.json')) && existsSync(join(CORPUS_DIR, 'formal-verify-v2.md')), 'the prompt corpus was written (JSON + Markdown)')
1728
- assert(entries.length >= 15, 'the corpus covers the whole run (' + entries.length + ' prompts)')
1729
- assert(entries.some((e) => /【Lean 形式化验证(鼓励模式)】/.test(e.prompt)), 'the corpus contains the encourage verify prompt')
1730
- assert(entries.some((e) => /【Lean 形式化验证(强制模式)】/.test(e.prompt)), '★ the corpus contains the REQUIRE verify prompt')
1731
- assert(entries.some((e) => /你不需要重新检查推导/.test(e.prompt) && /一致 → Result = 1/.test(e.prompt)), 'the corpus contains the passed/fidelity prompt')
1732
- assert(entries.some((e) => /【顺手形式化(鼓励[^)]*)】/.test(e.prompt)), 'the corpus contains the work-round 顺手形式化 prompt')
1733
- assert(entries.some((e) => /【顺手形式化/.test(e.prompt) && /"formal":\{"target":"<对象id>","decision":"used\|blocked\|defect"/.test(e.prompt)), 'the corpus contains the formal reply contract line')
1734
- assert(entries.some((e) => !/Lean|形式化/.test(e.prompt)), 'the corpus contains off-mode prompts with no Lean text at all')
1735
- assert(entries.some((e) => e.kind === 'wake'), 'the corpus also keeps the continuation prompts (debate rounds)')
1736
- assert(entries.some((e) => e.kind === 'push' && /\[Vibe Math V2\] 进度更新/.test(e.prompt)), '★ the corpus carries the framework\'s PUSH report (reportMode=push)')
1737
- assert(entries.some((e) => e.kind === 'feedback' && /require 模式搁置/.test(e.prompt)), '★ and the require-gate withholding notice (the framework→human feedback channel)')
1738
- // Generic sweeps over EVERY captured prompt, not spot checks (AUDIT §2.1).
1739
- const joined = entries.map((e) => e.prompt).join('\n')
1740
- const bare = entries.filter((e) => /(^|[^a-z_])lean_(run|archive|lib)/.test(e.prompt))
1741
- assert(bare.length === 0, '★ no captured prompt names an abbreviated tool (' + bare.map((b) => b.label).join(',') + ')')
1742
- const zero = entries.filter((e) => /偏离\s*→\s*0/.test(e.prompt))
1743
- assert(zero.length === 0, '★ no captured prompt turns a fidelity defect into a 0 vote (' + zero.map((z) => z.label).join(',') + ')')
1744
- const wrongField = entries.filter((e) => /忠实性/.test(e.prompt) && /verdict/.test(e.prompt))
1745
- assert(wrongField.length === 0, '★ no fidelity prompt names a `verdict` field (' + wrongField.map((w) => w.label).join(',') + ')')
1746
- const dirty = entries.filter((e) => /\[object Object\]|\bNaN\b|:\s*undefined|["']undefined["']|undefined\s*[,}\]]/.test(e.prompt))
1747
- assert(dirty.length === 0, 'no captured prompt contains placeholder garbage (' + dirty.map((d) => d.label).join(',') + ')')
1748
- assert(joined.indexOf('<VIBEMATH>') !== -1, 'the VibeMath root is normalised to <VIBEMATH>')
1749
- for (const h of hosts) {
1750
- const ws = String(h.WS)
1751
- assert(joined.indexOf(ws) === -1 && joined.indexOf(ws.replace(/\\/g, '/')) === -1, 'no captured prompt leaks a machine path (' + h.label + ')')
1752
- }
1753
- }
1754
-
1755
- // cleanup
1756
- for (const h of hosts) { try { rmSync(h.WS, { recursive: true, force: true }) } catch (e) { /* ignore */ } }
1757
-
1758
- console.log('')
1759
- console.log('passed=' + passed + ' failed=' + failed)
1760
- if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
1761
- console.log('ALL GREEN')
1762
- process.exit(0)
1719
+ section('16 the captured prompt corpus is written for human review')
1720
+ {
1721
+ // Freeze the scheduler in every case FIRST: a still-running tick loop could emit one more
1722
+ // prompt between two runs and make the corpus non-deterministic.
1723
+ for (const h of hosts) { try { await h.call('vibe_math_pause', {}) } catch (e) { /* ignore */ } }
1724
+ const scrub = (h, s) => {
1725
+ const ws = String(h.WS)
1726
+ const slash = ws.replace(/\\/g, '/')
1727
+ return String(s == null ? '' : s)
1728
+ .split(slash + '/VibeMath').join('<VIBEMATH>')
1729
+ .split(ws + '\\VibeMath').join('<VIBEMATH>')
1730
+ .split(slash).join('<WS>')
1731
+ .split(ws).join('<WS>')
1732
+ // Epoch-ms in a verification-log FILE NAME is run metadata: the final-paper material embeds the
1733
+ // evidence index verbatim, so it must be normalised or the shipped corpus changes every run.
1734
+ // 为什么必须保留可选短 id 组(不要"简化"掉):P7 把裁决日志名改成了
1735
+ // `<rId>_<epochMs>_<shortId>.json`,旧的 `_[0-9]{10,16}\.json` 因此不再匹配,
1736
+ // 于是每次跑动都把自己那次的 时间戳+shortId 写进 shipped 语料(语料非确定性)。
1737
+ // 去掉 `(_[0-9a-f]{8})?` 这个组会静默地重新引入"每次跑都不同"的语料。
1738
+ .replace(/_[0-9]{10,16}(_[0-9a-f]{8})?\.json/g, '_<TIME>.json')
1739
+ }
1740
+ const entries = []
1741
+ for (const h of hosts) {
1742
+ for (const s of h.spawns) entries.push({ kind: 'spawn', case: h.label, label: s.label, prompt: scrub(h, s.prompt) })
1743
+ for (const f of h.followups) {
1744
+ const owner = h.spawns.find((s) => s.childId === f.childId)
1745
+ entries.push({ kind: 'wake', case: h.label, label: owner ? owner.label : f.childId, prompt: scrub(h, f.prompt) })
1746
+ }
1747
+ // The framework's own messages to the main agent (reportMode=push). Only the FIRST push per case is
1748
+ // kept: whether a second identical push follows depends on tick/reportDirty timing, and the corpus
1749
+ // must stay byte-identical across runs.
1750
+ if (h.root && Array.isArray(h.root.pushed) && h.root.pushed.length > 0) entries.push({ kind: 'push', case: h.label, label: 'main-agent', prompt: scrub(h, h.root.pushed[0]) })
1751
+ // The framework's human-readable feedback channel: the activity-log line that carries the require
1752
+ // gate's withholding notice. It is captured once, in case 15c, for determinism.
1753
+ if (typeof h.feedbackLine === 'string' && h.feedbackLine) entries.push({ kind: 'feedback', case: h.label, label: 'activity-log', prompt: scrub(h, h.feedbackLine) })
1754
+ }
1755
+ // F7:v2 各回执的对象类型不同(验证用 Result/Reason 大写;计划/求解/方法卡用小写键),历史上只在示例里
1756
+ // 体现大小写。现在每一条**含 json 契约**的提示词都必须显式写明「键名大小写精确匹配、不接受别名」。
1757
+ const jsonFenced = entries.filter((e) => e.prompt.indexOf('```json') !== -1 || /single JSON object/.test(e.prompt))
1758
+ const missingCaseRule = jsonFenced.filter((e) => e.prompt.indexOf('KEY CASE IS PART OF THE REPLY CONTRACT') === -1)
1759
+ assert(jsonFenced.length >= 4, 'F7: prompts carrying a json contract were captured (' + jsonFenced.length + ')')
1760
+ assert(missingCaseRule.length === 0, '★★ [F7] every json-contract prompt states the canonical key-case rule (offenders=' + JSON.stringify(missingCaseRule.map((e) => e.label)) + ')')
1761
+ const capitalised = entries.filter((e) => /"Result":/.test(e.prompt))
1762
+ assert(capitalised.every((e) => e.prompt.indexOf('KEY CASE IS PART OF THE REPLY CONTRACT') !== -1), '★★ [F7] …including the capitalised Result/Reason contracts (' + capitalised.length + ')')
1763
+ mkdirSync(CORPUS_DIR, { recursive: true })
1764
+ writeFileAtomic(join(CORPUS_DIR, 'formal-verify-v2.json'), JSON.stringify({ entries: entries }, null, 2))
1765
+ const md = ['# V2 形式化验证交互语料(prompt corpus)', '',
1766
+ '> 由 `formal-verify-v2.test.mjs` 落盘:框架**真正发出**的每一条提示词原文。',
1767
+ '> 工作区路径归一化为 `<WS>`、VibeMath 根归一化为 `<VIBEMATH>`,因此可 diff、不泄露本机路径。',
1768
+ '> 覆盖:off 档(无任何 Lean 文字)、encourage 与 require 的表决初评/辩论、passed 后的忠实性分支、',
1769
+ '> 平时工作轮的「顺手形式化」段落与 formal 回执契约,以及框架自己的两条反馈通道:',
1770
+ '> `reportMode=push` 的推送汇报(kind=push)与 require 门禁搁置的活动日志公告(kind=feedback)。', '']
1771
+ for (let i = 0; i < entries.length; i++) {
1772
+ const e = entries[i]
1773
+ md.push('## [' + i + '] ' + e.kind + ' · ' + e.label + ' · case=' + e.case)
1774
+ md.push('')
1775
+ md.push('```text')
1776
+ md.push(e.prompt)
1777
+ md.push('```')
1778
+ md.push('')
1779
+ }
1780
+ writeFileAtomic(join(CORPUS_DIR, 'formal-verify-v2.md'), md.join('\n'))
1781
+ assert(existsSync(join(CORPUS_DIR, 'formal-verify-v2.json')) && existsSync(join(CORPUS_DIR, 'formal-verify-v2.md')), 'the prompt corpus was written (JSON + Markdown)')
1782
+ assert(entries.length >= 15, 'the corpus covers the whole run (' + entries.length + ' prompts)')
1783
+ assert(entries.some((e) => /【Lean 形式化验证(鼓励模式)】/.test(e.prompt)), 'the corpus contains the encourage verify prompt')
1784
+ assert(entries.some((e) => /【Lean 形式化验证(强制模式)】/.test(e.prompt)), '★ the corpus contains the REQUIRE verify prompt')
1785
+ assert(entries.some((e) => /你不需要重新检查推导/.test(e.prompt) && /一致 → Result = 1/.test(e.prompt)), 'the corpus contains the passed/fidelity prompt')
1786
+ assert(entries.some((e) => /【顺手形式化(鼓励[^)]*)】/.test(e.prompt)), 'the corpus contains the work-round 顺手形式化 prompt')
1787
+ assert(entries.some((e) => /【顺手形式化/.test(e.prompt) && /"formal":\{"target":"<对象id>","decision":"used\|blocked\|defect"/.test(e.prompt)), 'the corpus contains the formal reply contract line')
1788
+ assert(entries.some((e) => !/Lean|形式化/.test(e.prompt)), 'the corpus contains off-mode prompts with no Lean text at all')
1789
+ assert(entries.some((e) => e.kind === 'wake'), 'the corpus also keeps the continuation prompts (debate rounds)')
1790
+ assert(entries.some((e) => e.kind === 'push' && /\[Vibe Math V2\] 进度更新/.test(e.prompt)), '★ the corpus carries the framework\'s PUSH report (reportMode=push)')
1791
+ assert(entries.some((e) => e.kind === 'feedback' && /require 模式搁置/.test(e.prompt)), '★ and the require-gate withholding notice (the framework→human feedback channel)')
1792
+ // Generic sweeps over EVERY captured prompt, not spot checks (AUDIT §2.1).
1793
+ const joined = entries.map((e) => e.prompt).join('\n')
1794
+ const bare = entries.filter((e) => /(^|[^a-z_])lean_(run|archive|lib)/.test(e.prompt))
1795
+ assert(bare.length === 0, '★ no captured prompt names an abbreviated tool (' + bare.map((b) => b.label).join(',') + ')')
1796
+ const zero = entries.filter((e) => /偏离\s*→\s*0/.test(e.prompt))
1797
+ assert(zero.length === 0, '★ no captured prompt turns a fidelity defect into a 0 vote (' + zero.map((z) => z.label).join(',') + ')')
1798
+ const wrongField = entries.filter((e) => /忠实性/.test(e.prompt) && /verdict/.test(e.prompt))
1799
+ assert(wrongField.length === 0, '★ no fidelity prompt names a `verdict` field (' + wrongField.map((w) => w.label).join(',') + ')')
1800
+ const dirty = entries.filter((e) => /\[object Object\]|\bNaN\b|:\s*undefined|["']undefined["']|undefined\s*[,}\]]/.test(e.prompt))
1801
+ assert(dirty.length === 0, 'no captured prompt contains placeholder garbage (' + dirty.map((d) => d.label).join(',') + ')')
1802
+ assert(joined.indexOf('<VIBEMATH>') !== -1, 'the VibeMath root is normalised to <VIBEMATH>')
1803
+ for (const h of hosts) {
1804
+ const ws = String(h.WS)
1805
+ assert(joined.indexOf(ws) === -1 && joined.indexOf(ws.replace(/\\/g, '/')) === -1, 'no captured prompt leaks a machine path (' + h.label + ')')
1806
+ }
1807
+ }
1808
+
1809
+ // cleanup
1810
+ for (const h of hosts) { try { rmSync(h.WS, { recursive: true, force: true }) } catch (e) { /* ignore */ } }
1811
+
1812
+ console.log('')
1813
+ console.log('passed=' + passed + ' failed=' + failed)
1814
+ if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
1815
+ console.log('ALL GREEN')
1816
+ process.exit(0)