dsh-vibe-math 2.7.3 → 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 (101) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +682 -682
  3. package/README.md +683 -683
  4. package/docs/AUDIT-CHECKLIST.md +755 -744
  5. package/docs/COMPAT-AUDIT-ROUND2.md +351 -351
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/audit-methodology.md +93 -0
  8. package/docs/final-paper.md +172 -172
  9. package/docs/formal-verification.md +613 -588
  10. package/docs/generate_framework_diagram_v2.py +114 -114
  11. package/docs/generate_framework_diagram_v3.py +127 -127
  12. package/docs/math-computation.md +195 -194
  13. package/docs/release-notes/README.en.md +1 -0
  14. package/docs/release-notes/README.md +1 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
  16. package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
  17. package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
  18. package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
  19. package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
  20. package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
  21. package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
  22. package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
  23. package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
  24. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
  25. package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
  26. package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
  27. package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
  28. package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
  29. package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
  30. package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
  31. package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
  32. package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
  33. package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
  34. package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
  35. package/docs/release-playbook.md +103 -0
  36. package/docs/slv-playbook.md +117 -0
  37. package/docs/test-timing.md +158 -157
  38. package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
  39. package/installer.js +792 -792
  40. package/package.json +13 -3
  41. package/prompt-corpus-v2/formal-verify-v2.json +2 -2
  42. package/prompt-corpus-v2/formal-verify-v2.md +4 -0
  43. package/prompt-corpus-v3/formal-verify-v3.json +7 -7
  44. package/prompt-corpus-v3/formal-verify-v3.md +14 -0
  45. package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
  46. package/prompt-corpus-v5/prompt-corpus-v5.md +122 -0
  47. package/scripts/clean-temp.mjs +234 -0
  48. package/tests/audit-engine-faces.mjs +298 -0
  49. package/tests/audit-formal-sensitivity.mjs +345 -342
  50. package/tests/audit-installer-compat.test.mjs +256 -256
  51. package/tests/audit-math-computation-sensitivity.mjs +1 -1
  52. package/tests/audit-persona-sensitivity.mjs +323 -323
  53. package/tests/audit-persona-surface.test.mjs +431 -431
  54. package/tests/audit-prompt-duplication.mutants.mjs +3 -2
  55. package/tests/audit-prompt-invariants.mjs +589 -546
  56. package/tests/audit-registration.mjs +78 -78
  57. package/tests/audit-spec-traceability.mjs +193 -193
  58. package/tests/audit-status-report-fields.mjs +172 -172
  59. package/tests/audit-v5-integrity.mjs +795 -795
  60. package/tests/audit-v5-sensitivity.mjs +433 -432
  61. package/tests/e2e-v5-round2.test.mjs +2453 -2426
  62. package/tests/formal-verify-v2.test.mjs +1806 -1800
  63. package/tests/formal-verify-v3.test.mjs +1757 -1750
  64. package/tests/formal-verify-v4.test.mjs +2114 -2106
  65. package/tests/formal-verify-v5.test.mjs +774 -767
  66. package/tests/math-computation-discovery.mutants.mjs +3 -3
  67. package/tests/math-computation-shared.test.mjs +122 -5
  68. package/tests/math-computation-v2.test.mjs +14 -2
  69. package/tests/math-computation-v3.test.mjs +14 -2
  70. package/tests/math-computation-v4.test.mjs +739 -739
  71. package/tests/prompt-v5-integrity.test.mjs +1291 -1291
  72. package/tests/run-tests.mjs +393 -332
  73. package/tests/selfdrive-v5.mjs +560 -560
  74. package/tests/temp-hygiene.mutants.mjs +80 -0
  75. package/tests/v2-fix-probes.mutants.mjs +4 -0
  76. package/tests/v2-fix-probes.test.mjs +1034 -1033
  77. package/tests/v2v3-interrupt.mutants.mjs +111 -0
  78. package/tests/v3-fix-probes.mutants.mjs +99 -95
  79. package/tests/v3-fix-probes.test.mjs +891 -890
  80. package/tests/v4-final-paper.mutants.mjs +108 -0
  81. package/tests/v5-institute-fixes.mutants.mjs +218 -190
  82. package/vibe-math-v2/agent.cordis.yml +376 -376
  83. package/vibe-math-v2/math-computation.js +17 -15
  84. package/vibe-math-v2/preset.yml +2 -2
  85. package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
  86. package/vibe-math-v2/vibe-math-v2.js +5078 -5011
  87. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +486 -486
  88. package/vibe-math-v3/agent.cordis.yml +420 -420
  89. package/vibe-math-v3/math-computation.js +17 -15
  90. package/vibe-math-v3/preset.yml +2 -2
  91. package/vibe-math-v3/vibe-math-v3.js +6215 -6149
  92. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
  93. package/vibe-math-v4/agent.cordis.yml +438 -438
  94. package/vibe-math-v4/math-computation.js +17 -15
  95. package/vibe-math-v4/preset.yml +2 -2
  96. package/vibe-math-v4/vibe-math-v4.js +4490 -4422
  97. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1015
  98. package/vibe-math-v5/math-computation.js +17 -15
  99. package/vibe-math-v5/vibe-math-v5.js +93 -11
  100. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1728
  101. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -1,1750 +1,1757 @@
1
- // ============================================================
2
- // V3 LEAN FORMAL VERIFICATION SUITE (docs/formal-verification.md)
3
- //
4
- // Asserts the whole contract of the `formalVerify` knob on the v3 preset:
5
- // · 'off' is a TRUE no-op (no Lean text in ANY prompt, no Formal record, no gate)
6
- // · 'encourage' injects the Lean section into the work prompts AND into both verification
7
- // prompts, and — the actual point of the feature — turns the voting prompt into
8
- // a FIDELITY review once a Lean run has passed
9
- // · 'require' withholds a true/false verdict as 未定论 until the object is Lean-passed or
10
- // carries an explicit, reasoned blocker record; then allows it, and the Verified
11
- // card records how strong the result really is
12
- // · the three tools (run / archive / lib) write the right things to the right paths
13
- //
14
- // The Lean toolchain is mocked through the subprocess SERVICE, so the tests exercise the real
15
- // code path (resolveExecutable → spawn → collected stdout → exit code) without requiring Lean.
16
- //
17
- // V3_PLUGIN overrides the plugin under test: a sensitivity probe MUST point this suite at a
18
- // mutated copy, otherwise every probe would exercise the unmutated plugin and stay green.
19
- //
20
- // Run: node tests/formal-verify-v3.test.mjs
21
- // ============================================================
22
- import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs'
23
- import { tmpdir } from 'node:os'
24
- import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
25
- import { fileURLToPath, pathToFileURL } from 'node:url'
26
-
27
- // Concurrency isolation: the shipped corpus is read by other probes (audit-prompt-invariants) while
28
- // this suite runs under the gate, so the write must be atomic — a reader must never see a truncated file.
29
- function writeFileAtomic(target, text) {
30
- const tmp = target + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2, 8)
31
- writeFileSync(tmp, text, 'utf8')
32
- renameSync(tmp, target)
33
- }
34
-
35
-
36
- // V3_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.V3_PLUGIN
41
- ? pathToFileURL(isAbsolute(process.env.V3_PLUGIN) ? process.env.V3_PLUGIN : join(dirname(fileURLToPath(import.meta.url)), '..', process.env.V3_PLUGIN))
42
- : new URL('../vibe-math-v3/vibe-math-v3.js', import.meta.url)
43
- const HERE = dirname(fileURLToPath(import.meta.url))
44
- // AUDIT-CHECKLIST §2.4: the suite must also keep the INTERACTION TEXT it drove, so a human can
45
- // re-read the prompts the framework really emitted. V3_CORPUS_DIR overrides the destination.
46
- const CORPUS_DIR = process.env.V3_CORPUS_DIR
47
- ? pathResolve(process.env.V3_CORPUS_DIR)
48
- // A MUTATED run (V3_PLUGIN set) must never rewrite the shipped corpus — same rule v2 and v4 already follow.
49
- : (process.env.V3_PLUGIN ? join(tmpdir(), 'vibe-v3-prompt-corpus') : join(HERE, '..', 'prompt-corpus-v3'))
50
- const WS = mkdtempSync(join(tmpdir(), 'vibe-v3-lean-'))
51
- const VIBE = join(WS, 'VibeMath')
52
- const projRoot = (slug) => join(VIBE, 'Projects', slug)
53
- const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '')
54
- /** A missing directory lists as [] (a mutant cascade must surface as a NAMED red, not an ENOENT crash). */
55
- const listDirIf = (p) => (existsSync(p) ? readdirSync(p) : [])
56
- const reEsc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57
- const verifyRe = (target) => new RegExp('^verifier:r-' + reEsc(target) + '(?:-\\w+)?:\\d+$')
58
-
59
- let passed = 0, failed = 0
60
- const failures = []
61
- const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
62
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
63
- const section = (t) => console.log('\n[' + t + ']')
64
-
65
- // ---------------------------------------------------------------
66
- // mock host
67
- // ---------------------------------------------------------------
68
- const listeners = {}
69
- const toolRegs = []
70
- const cmdRegs = []
71
- const spawns = [] // { label, childId, rootId, prompt }
72
- const wakes = [] // { childId, rootId, prompt }
73
- const interrupts = []
74
- const leanRuns = [] // { argv, file, cwd, graceMs }
75
- const terminated = []
76
- const disposers = []
77
- const roots = []
78
- let rootSeq = 0
79
- let subprocessAvailable = true
80
- let toolchainAvailable = true
81
- let spawnThrows = false
82
- // Interaction corpus (AUDIT-CHECKLIST §2.4): every prompt the framework actually sent, with the
83
- // workspace path normalised so the dump is deterministic and diffable.
84
- const corpus = []
85
- // Normalise BOTH slash forms. The VibeMath ROOT must be replaced BEFORE the workspace root,
86
- // otherwise `<WS>/VibeMath` would survive as a half-substituted path: the corpus would still leak
87
- // the machine layout and would not be diffable against another checkout.
88
- //
89
- // The PLANNER prompt embeds the raw state brief, whose volatile RUN METADATA would otherwise change
90
- // on every run and make the corpus undiffable (contract §10 item 10 demands byte determinism; the
91
- // same class of bug is recorded in AUDIT-CHECKLIST §2.4 as a real accident): the random plan id in
92
- // the label, epoch-millisecond `at` timestamps, child ids, and the free-slot count. They are run
93
- // metadata, not the text under review, so they are normalised to placeholders.
94
- //
95
- // The brief is not only run metadata, it is a LIVE SNAPSHOT of mutable scheduler state
96
- // (`buildBrief()` reads `agentRegistry`, `activityLog`, the verify candidates and the free-slot
97
- // count at the instant the planner is spawned). This suite drives the plugin from OUTSIDE, and the
98
- // plugin's own scheduler keeps running concurrently: `vibe_math_start` fires `scheduleTick()`
99
- // without awaiting it, and the plugin's 1s tick timer calls `scheduleTick()` on its own schedule, so
100
- // a background tick can register the explorer (or append its activity event, or close a verify)
101
- // BETWEEN the forced planner's snapshot and the next one. Two runs then legitimately disagree about
102
- // `active_agents` (the explorer is in flight or it is not), about `recent_events` (the last-eight
103
- // window and its order), and about every other instant-dependent field of the brief.
104
- //
105
- // A byte-stable corpus therefore cannot carry that snapshot's values — the disagreement is a
106
- // property of the INSTANT, not of the prompt text a human must review (this is exactly the "语料
107
- // 每次运行都变" accident §2.4 records). What the corpus carries is the brief's CONTRACT: the same
108
- // keys in the same order, with the instant-dependent values canonically placeholdered and the
109
- // stable configuration scalars kept. The prompt TEXT under review — the persona, the ACTION
110
- // VOCABULARY, and the HARD RULES that name `brief.problems[].running_solver_dirs`,
111
- // `brief.active_agents` and `brief.free_slots` — is untouched.
112
- const SNAPSHOT_KEYS = ['problems', 'verify_candidates', 'active_agents', 'methods', 'pending_inventions', 'last_plan', 'recent_events']
113
- /** Replace every instant-dependent collection of the planner's state brief with a canonical
114
- * placeholder, keeping the key set, the key order and the configuration scalars. Only called on a
115
- * valid-JSON brief (`buildBrief` is serialised with JSON.stringify(brief, null, 2)); anything that
116
- * does not match the exact planner framing is returned unchanged, so the suite can never silently
117
- * mangle an unrelated prompt. */
118
- const canonBrief = (s) => {
119
- const m = s.match(/(CURRENT STATE BRIEF \(JSON\):\n)(\{[\s\S]*?\n\})(\n\nACTION VOCABULARY)/)
120
- if (!m) return s
121
- let brief
122
- try { brief = JSON.parse(m[2]) } catch (e) { return s }
123
- for (const k of SNAPSHOT_KEYS) if (Object.prototype.hasOwnProperty.call(brief, k)) brief[k] = '<' + k.toUpperCase() + '>'
124
- return s.slice(0, m.index) + m[1] + JSON.stringify(brief, null, 2) + m[3] + s.slice(m.index + m[0].length)
125
- }
126
- const scrub = (s) => canonBrief(String(s == null ? '' : s)
127
- .split(VIBE).join('<VIBEMATH>')
128
- .split(VIBE.replace(/\\/g, '/')).join('<VIBEMATH>')
129
- .split(WS).join('<WS>')
130
- .split(WS.replace(/\\/g, '/')).join('<WS>'))
131
- .replace(/plan-[0-9a-f]{8}/g, 'plan-<ID>')
132
- .replace(/("at"\s*:\s*)\d{10,16}/g, '$1"<TIME>"')
133
- .replace(/("childId"\s*:\s*")c\d+(")/g, '$1<CHILD>$2')
134
- .replace(/("free_slots"\s*:\s*)\d+/g, '$1<SLOTS>')
135
- // Epoch-ms in a verification-log FILE NAME (`Logs/Verification/<rId>_<Date.now()>.json`) is run
136
- // metadata too: the final-paper material embeds the evidence index verbatim, so without this the
137
- // shipped corpus changed on every run (contract §10 item 10 demands byte determinism).
138
- // 为什么必须保留可选短 id 组(不要"简化"掉):P7 把裁决日志名改成了
139
- // `<rId>_<epochMs>_<shortId>.json`,旧的 `_[0-9]{10,16}\.json` 因此不再匹配,
140
- // 于是每次跑动都把自己那次的 时间戳+shortId 写进 shipped 语料(语料非确定性)。
141
- // 去掉 `(_[0-9a-f]{8})?` 这个组会静默地重新引入"每次跑都不同"的语料。
142
- .replace(/_[0-9]{10,16}(_[0-9a-f]{8})?\.json/g, '_<TIME>.json')
143
-
144
- // A fake Lean: a file PASSES unless it still contains `sorry` or the marker `-- FAIL`.
145
- // `-- HANG` simulates a toolchain that never returns (the timeout path).
146
- const subprocess = {
147
- async resolveExecutable(cmd) {
148
- if (!toolchainAvailable) throw new Error('spawn ' + cmd + ' ENOENT')
149
- if (String(cmd) !== 'lean' && String(cmd) !== 'lake') throw new Error('unknown executable ' + cmd)
150
- return String(cmd)
151
- },
152
- spawn(spec) {
153
- const argv = spec.argv || []
154
- const last = String(argv[argv.length - 1] || '')
155
- const isShell = /powershell|cmd\.exe|\/bin\/sh|(^|\/)sh$/i.test(String(argv[0] || '')) || /New-Item|Remove-Item|Move-Item|^mkdir -p/.test(last)
156
- if (isShell) {
157
- // directory shim (v3's runShell: powershell New-Item / POSIX mkdir -p)
158
- if (/New-Item/.test(last)) {
159
- const m = last.match(/-Path\s+(?:'((?:[^']|'')*)'|"((?:[^"]|"")*)")/)
160
- const raw = (m && (m[1] || m[2])) || ''
161
- for (const p of raw.split(',').map((x) => x.replace(/''/g, "'"))) if (p) mkdirSync(p, { recursive: true })
162
- }
163
- if (/^mkdir -p/.test(last)) {
164
- const re = /'((?:[^']|'\\'')*)'/g
165
- let m
166
- while ((m = re.exec(last)) !== null) mkdirSync(m[1].replace(/'\\''/g, "'"), { recursive: true })
167
- }
168
- if (/Remove-Item/.test(last)) {
169
- const m = last.match(/-LiteralPath\s+'((?:[^']|'')*)'/)
170
- if (m) rmSync(m[1].replace(/''/g, "'"), { force: true, recursive: true })
171
- }
172
- return { done: Promise.resolve({ exitCode: 0 }), collected: {} }
173
- }
174
- const file = last
175
- const text = existsSync(file) ? readFileSync(file, 'utf8') : ''
176
- leanRuns.push({ argv: argv.slice(0, -1), file, cwd: spec.cwd, graceMs: spec.graceMs })
177
- if (spawnThrows) throw new Error('spawn ' + String(argv[0]) + ' EPERM')
178
- if (/-- REJECT/.test(text)) {
179
- return {
180
- done: Promise.reject(new Error('child process died before reporting')),
181
- collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
182
- terminate() {},
183
- }
184
- }
185
- if (/-- HANG/.test(text)) {
186
- return {
187
- done: new Promise(() => {}),
188
- collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
189
- terminate() { terminated.push(file) },
190
- }
191
- }
192
- const bad = /sorry|-- FAIL/.test(text)
193
- const stdout = bad ? '' : 'ok\n'
194
- const stderr = bad ? 'error: declaration uses sorry\n' : ''
195
- return {
196
- done: Promise.resolve({ exitCode: bad ? 1 : 0, signal: null }),
197
- collected: {
198
- stdout: { readFrom: () => ({ text: stdout, nextOffset: stdout.length, lossy: false }) },
199
- stderr: { readFrom: () => ({ text: stderr, nextOffset: stderr.length, lossy: false }) },
200
- },
201
- terminate() {},
202
- }
203
- },
204
- }
205
-
206
- const ctx = {
207
- get(name) {
208
- if (name === 'subprocess') return subprocessAvailable ? subprocess : undefined
209
- if (name === 'sandboxPolicy') return { resolve() { return { workspaceRoot: WS } } }
210
- return undefined
211
- },
212
- on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
213
- effect(fn) { const d = fn(); const off = () => { if (typeof d === 'function') d() }; disposers.push(off); return off },
214
- logger: { info() {}, warn() {}, error() {} },
215
- tools: { register(spec) { toolRegs.push(spec); return () => {} } },
216
- commands: { register(spec) { cmdRegs.push(spec); return () => {} } },
217
- subagents: {
218
- list() { return ['spawn'] },
219
- async startContinuable({ label, request }) {
220
- const childId = 'c' + (spawns.length + 1)
221
- const rootId = (request && request.parent && request.parent.id) || ''
222
- const prompt = (request && request.prompt && request.prompt[0] && request.prompt[0].text) || ''
223
- spawns.push({ label, childId, rootId, prompt })
224
- corpus.push({ kind: 'spawn', label: scrub(label), root: rootId, prompt: scrub(prompt) })
225
- return { childId, messageId: 'm' + spawns.length }
226
- },
227
- async sendMessage(parent, childId, blocks) {
228
- const rootId = (parent && parent.id) || ''
229
- const prompt = (blocks && blocks[0] && blocks[0].text) || ''
230
- wakes.push({ childId, rootId, prompt })
231
- const sp = spawns.find((s) => s.childId === childId)
232
- corpus.push({ kind: 'wake', label: scrub(sp ? sp.label : childId), root: rootId, prompt: scrub(prompt) })
233
- return 'w' + wakes.length
234
- },
235
- async followup(parent, childId, blocks) { return await this.sendMessage(parent, childId, blocks) },
236
- interrupt(childId) { interrupts.push(childId) },
237
- },
238
- agents: { roots() { return roots.slice() }, get() { return undefined } },
239
- // DSH 0.1.1 fs API: resolve → { targetKey, displayPath }
240
- fs: {
241
- async resolve(rel, opts) {
242
- const base = (opts && opts.cwd) || WS
243
- const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(base, ...String(rel).split('/'))
244
- return { targetKey: p, displayPath: p }
245
- },
246
- async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
247
- async readText(t) { return readFileSync(t.targetKey, 'utf8') },
248
- async writeText(t, content) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, content, 'utf8') },
249
- async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
250
- },
251
- }
252
-
253
- const mod = await import(PLUGIN.href + '?t=' + Date.now())
254
- ;(mod.default || mod).apply(ctx)
255
- // leanAsync defaults to TRUE (background queue). The sections above guard the **synchronous** Lean
256
- // path verbatim, so pin leanAsync=false through the workspace-level settings file (every
257
- // setProject → loadSettings() picks it up). Section 9b flips it back on explicitly for the async
258
- // state machine. Nothing else in the suite depends on the async default.
259
- mkdirSync(VIBE, { recursive: true })
260
- writeFileSync(join(VIBE, 'vibe_math_setting.json'), '{ "leanAsync": false }\n', 'utf8')
261
-
262
- // ---------------------------------------------------------------
263
- // driving helpers
264
- // ---------------------------------------------------------------
265
- function makeRoot() {
266
- const id = 'sess-' + String.fromCharCode(65 + rootSeq++)
267
- const root = { id, options: { provider: 'mock', model: 'mock-model' }, session: { id, header: { cwd: WS, parentSession: undefined } }, followup() {}, ctx: undefined }
268
- roots.push(root)
269
- return root
270
- }
271
- async function callTool(name, args, agent) {
272
- const spec = toolRegs.find((s) => s.name === name)
273
- if (!spec) throw new Error('no tool ' + name)
274
- return JSON.parse(await spec.execute(args || {}, { agent }))
275
- }
276
- function fireEnd(childId, reply) {
277
- const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
278
- for (const h of (listeners['subagent/end'] || [])) h({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: blocks })
279
- }
280
- const firedChildren = new Set()
281
- // Spawns created before a restart belong to a task the restart discarded; they are never a valid
282
- // target for a later round (their onChildEnd has no agentRegistry entry any more). The floor keeps
283
- // `unfiredVerifiers` from selecting them.
284
- const spawnFloor = {}
285
- const markFloor = (root) => { spawnFloor[root.id] = spawns.length }
286
- const spawnOf = (root, prefix) => spawns.filter((s) => s.rootId === root.id && s.label.startsWith(prefix))
287
- const lastSpawn = (root, prefix) => spawnOf(root, prefix).slice(-1)[0]
288
- const unfiredVerifiers = (root, re) => spawns.filter((s, i) => s.rootId === root.id && (spawnFloor[root.id] || 0) <= i && re.test(s.label) && !firedChildren.has(s.childId))
289
- const promptText = (root) => spawns.filter((s) => s.rootId === root.id).map((s) => s.prompt).join('\n') + '\n' + wakes.filter((w) => w.rootId === root.id).map((w) => w.prompt).join('\n')
290
-
291
- /** Parameters every verification scenario uses. A high concurrency cap keeps the fallback
292
- * scheduler from starving a later object behind a pile of unfired mock verifiers. */
293
- const VPARAMS = { plannerEnabled: false, verifierCount: 2, debateMaxRounds: 1, maxParallelThreshold: 64, tickIntervalMs: 200 }
294
-
295
- /** Fire the one in-flight forced planner with an empty plan (an empty plan schedules a tick). */
296
- async function pump(root) {
297
- const pending = spawns.filter((s) => s.rootId === root.id && s.label.startsWith('planner:') && !firedChildren.has(s.childId)).slice(-1)[0]
298
- if (pending) { firedChildren.add(pending.childId); fireEnd(pending.childId, { summary: 'noop', plan: [] }); return }
299
- try { await callTool('vibe_math_plan', { force: true }, root) } catch (e) { /* not running / gated */ }
300
- }
301
- /** Poll `pred`, nudging the scheduler along with an empty forced plan (the 1s tick timer is the fallback). */
302
- async function drive(root, pred, label, timeoutMs) {
303
- const deadline = Date.now() + (timeoutMs || 15000)
304
- while (Date.now() < deadline) {
305
- if (pred()) return true
306
- await pump(root)
307
- await sleep(80)
308
- }
309
- if (pred()) return true
310
- console.error(' .. drive timed out: ' + label)
311
- return false
312
- }
313
- /** Fire the next `results.length` verifiers of one object (any of its candidate rIds). */
314
- async function runVerifyRound(root, target, results, timeoutMs) {
315
- const re = verifyRe(target)
316
- const ok = await drive(root, () => unfiredVerifiers(root, re).length >= results.length, 'verifiers for r-' + target, timeoutMs)
317
- if (!ok) return null
318
- const un = unfiredVerifiers(root, re).slice(0, results.length)
319
- for (let i = 0; i < un.length; i++) { firedChildren.add(un[i].childId); fireEnd(un[i].childId, { Result: results[i], Reason: 'mock 裁决 ' + results[i] }) }
320
- await sleep(220)
321
- return un
322
- }
323
- /** V3-G1: `unfiredVerifiers(...).length` is a COUNT - the semantic unit is that the fired reviews are
324
- * DISTINCT slots (the `:<index>` suffix), i.e. two genuinely independent reviews of the same object.
325
- * A founding that spawns the same slot twice keeps the count at 2 and used to pass. */
326
- /** The slot indices (`:<index>`) of a fired/eligible verifier round. */
327
- function slotsOf(round) { return (round || []).map((x) => String(x.label).split(':').pop()) }
328
- let gateSlots1 = null // V3-G2: round-one slots (module scope: the require round lives in a block)
329
-
330
- function assertDistinctSlots(round, tag) {
331
- assert(Array.isArray(round) && round.length > 0, tag + ': verifiers were fired')
332
- const idx = round.map((x) => String(x.label).split(':').pop())
333
- const rIds = round.map((x) => String(x.label).split(':')[1])
334
- assert(new Set(idx).size === idx.length, '* V3-G1 ' + tag + ': the reviews are DISTINCT verifier slots (indices=' + JSON.stringify(idx) + ')')
335
- assert(new Set(rIds).size === 1, '* V3-G1 ' + tag + ': every review targets the SAME object (targets=' + JSON.stringify(rIds) + ')')
336
- }
337
-
338
- /** Abort + restart: gives the next verification a FRESH task, so a prompt is guaranteed to be
339
- * constructed after whatever changed in between (mode switch, archived proof, …). */
340
- async function restart(root) {
341
- await callTool('vibe_math_abort', {}, root)
342
- await callTool('vibe_math_start', {}, root)
343
- markFloor(root)
344
- }
345
-
346
- console.log('-- V3 Lean formal verification --')
347
- console.log('plugin under test: ' + PLUGIN.href)
348
-
349
- // ===============================================================
350
- // 1. 'off' is a true no-op
351
- // ===============================================================
352
- section("1 'off' (default) is a true no-op")
353
- const RA = makeRoot()
354
- await callTool('vibe_math_new_project', { name: 'lean-off' }, RA)
355
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS), RA)
356
- await callTool('vibe_math_add_proposition', { id: 'p-off', 概述: '关模式下的普通命题', 概率: 0.6, 分类: '数论' }, RA)
357
- await callTool('vibe_math_start', {}, RA)
358
- const offRound = await runVerifyRound(RA, 'p-off', [1, 1])
359
- assert(offRound !== null && offRound.length === 2, 'off: two independent verifiers were asked (投票流程照常)')
360
- if (offRound) assertDistinctSlots(offRound, 'off round')
361
- const offProj = projRoot('lean-off')
362
- assert(await drive(RA, () => existsSync(join(offProj, 'Verified', '命题', 'p-off.md')), 'off Verified card'), "'off' still finalizes on a unanimous boolean vote with NO Lean artifact")
363
- {
364
- const st = await callTool('vibe_math_status', {}, RA)
365
- assert(st.params.formalVerify === 'off', "the default is 'off' (got " + st.params.formalVerify + ')')
366
- const all = promptText(RA)
367
- assert(!/Lean/.test(all), 'no prompt mentions Lean in off mode')
368
- assert(!/形式化/.test(all), 'no prompt mentions 形式化 in off mode')
369
- assert(!/\[形式化\]/.test(all), 'the [形式化] announcement line is absent in off mode')
370
- }
371
- assert(!/形式化/.test(readIf(join(offProj, 'Verified', '命题', 'p-off.md'))), 'the Verified card carries no formal line in off mode')
372
- assert(!existsSync(join(offProj, 'Formal', 'Index.md')), 'no Formal/Index.md is produced in off mode (no formal record)')
373
- assert(!existsSync(join(offProj, 'State', 'formal.json')), 'no State/formal.json is produced in off mode')
374
- {
375
- const st = await callTool('vibe_math_status', {}, RA)
376
- assert(st.formal.mode === 'off' && st.formal.objects.length === 0, 'status reports mode=off with zero formal objects')
377
- }
378
- // the tools still EXIST in off mode (static registration), they are just never advertised
379
- assert(!!toolRegs.find((t) => t.name === 'vibe_math_lean_run') && !!toolRegs.find((t) => t.name === 'vibe_math_lean_archive') && !!toolRegs.find((t) => t.name === 'vibe_math_lean_lib'),
380
- 'the three Lean tools are registered in every mode (registration is static)')
381
-
382
- // ★ The mode switch must be REACHABLE THROUGH THE TOOL SCHEMA (2.3.2 defect D1) ──────────────
383
- // Every tool schema here is closed (`additionalProperties:false`), so a key the schema does not
384
- // advertise is REJECTED by any schema-validating provider. v3 shipped 2.3.0/2.3.1 with all four Lean
385
- // parameters missing from the set-params schema while every assertion in this file stayed green —
386
- // because the suite calls the handler DIRECTLY and never inspects the registered schema. The feature
387
- // could not be switched on at all through the tool interface.
388
- {
389
- const setSpec = toolRegs.find((t) => t.name === 'vibe_math_set_params')
390
- assert(!!setSpec, "vibe_math_set_params is registered")
391
- assert(setSpec.parameters && setSpec.parameters.type === 'object' && setSpec.parameters.additionalProperties === false,
392
- '★ vibe_math_set_params publishes a CLOSED object schema (an unlisted key is rejected, so the schema IS the contract)')
393
- for (const k of ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']) {
394
- assert(Object.prototype.hasOwnProperty.call(setSpec.parameters.properties, k),
395
- '★ the registered schema advertises ' + k + ' (every other surface documents it; a schema that omits it makes the switch unreachable)')
396
- }
397
- assert(JSON.stringify(setSpec.parameters.properties.formalVerify.enum) === JSON.stringify(['off', 'encourage', 'require']),
398
- 'the schema narrows formalVerify to the three real modes (a typo must not be a fourth)')
399
- }
400
-
401
- // A stray `formal` reply in OFF mode must be INERT (finding #1): the absorber is mode-gated, and the
402
- // reply contract does not offer the field in off mode. The TOOLS stay usable on purpose.
403
- {
404
- const RB0 = makeRoot()
405
- await callTool('vibe_math_new_project', { name: 'lean-off-reply' }, RB0)
406
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS), RB0)
407
- await callTool('vibe_math_add_proposition', { id: 'p-offr', 概述: '关模式下的回执注入测试', 概率: 0.6, 分类: '数论' }, RB0)
408
- await callTool('vibe_math_start', {}, RB0)
409
- const re0 = verifyRe('p-offr')
410
- const ok0 = await drive(RB0, () => unfiredVerifiers(RB0, re0).length >= 1, 'off-reply verifier')
411
- assert(ok0, 'off mode: a verifier wake exists to carry the stray formal reply')
412
- const un0 = unfiredVerifiers(RB0, re0).slice(0, 1)
413
- if (un0.length) {
414
- firedChildren.add(un0[0].childId)
415
- fireEnd(un0[0].childId, { Result: 0.5, Reason: '普通评审', formal: { target: 'p-offr', decision: 'defect', note: '不应被记录' } })
416
- }
417
- await sleep(220)
418
- const pj0 = projRoot('lean-off-reply')
419
- assert(!existsSync(join(pj0, 'State', 'formal.json')), '★ a stray `formal` reply in off mode writes NO State/formal.json')
420
- const st0 = await callTool('vibe_math_status', {}, RB0)
421
- assert(st0.formal.objects.length === 0, '★ and records no formal object')
422
- }
423
-
424
- // ===============================================================
425
- // 2. parameter validation + runtime switching
426
- // ===============================================================
427
- section('2 parameter validation and runtime switching')
428
- const RB = makeRoot()
429
- await callTool('vibe_math_new_project', { name: 'lean-params' }, RB)
430
- const bad = await callTool('vibe_math_set_params', { formalVerify: 'banana' }, RB)
431
- assert(bad.params.formalVerify === 'off', "an unknown mode degrades to 'off', never to a stronger mode (got " + bad.params.formalVerify + ')')
432
- const bad2 = await callTool('vibe_math_set_params', { formalVerify: 'REQUIRE' }, RB)
433
- assert(bad2.params.formalVerify === 'off', 'the mode is matched exactly (a case typo cannot force formalization)')
434
- const enc = await callTool('vibe_math_set_params', { formalVerify: 'encourage' }, RB)
435
- assert(enc.params.formalVerify === 'encourage', "'encourage' is accepted")
436
- const req = await callTool('vibe_math_set_params', { formalVerify: 'require', leanTimeoutMs: -5, leanCommand: ' ' }, RB)
437
- assert(req.params.formalVerify === 'require', "'require' is accepted")
438
- assert(req.params.leanTimeoutMs === 120000, 'a non-positive leanTimeoutMs falls back to the default (' + req.params.leanTimeoutMs + ')')
439
- assert((await callTool('vibe_math_set_params', { leanTimeoutMs: 0 }, RB)).params.leanTimeoutMs === 120000, 'leanTimeoutMs=0 also falls back to the default')
440
- assert(req.params.leanCommand === 'lean', 'a blank leanCommand falls back to "lean"')
441
- const t5 = await callTool('vibe_math_set_params', { leanTimeoutMs: 5000 }, RB)
442
- assert(t5.params.leanTimeoutMs === 5000, 'a positive leanTimeoutMs is accepted (got ' + t5.params.leanTimeoutMs + ')')
443
- const arrBad = await callTool('vibe_math_set_params', { leanArgs: 'not-an-array' }, RB)
444
- assert(Array.isArray(arrBad.params.leanArgs) && arrBad.params.leanArgs.length === 0, 'a non-array leanArgs falls back to []')
445
- await callTool('vibe_math_set_params', { leanCommand: 'lake', leanArgs: ['env', 'lean'] }, RB)
446
- const stL = await callTool('vibe_math_status', {}, RB)
447
- assert(stL.params.leanCommand === 'lake' && stL.params.leanArgs.join(' ') === 'env lean', 'leanCommand/leanArgs are settable (lake env lean)')
448
- {
449
- const setup = await callTool('vibe_math_setup', {}, RB)
450
- const names = setup.parameters.map((p) => p.name)
451
- assert(names.indexOf('formalVerify') !== -1 && names.indexOf('leanCommand') !== -1 && names.indexOf('leanArgs') !== -1 && names.indexOf('leanTimeoutMs') !== -1,
452
- 'the settings schema surfaces all four Lean parameters')
453
- const f = setup.parameters.find((p) => p.name === 'formalVerify')
454
- assert(f.default === 'off' && f.current === 'require', 'the schema carries the default (off) and the current value (require)')
455
- const tmpl = await callTool('vibe_math_template', { where: 'project' }, RB)
456
- const tj = readIf(join(projRoot('lean-params'), 'vibe_math_setting.json'))
457
- assert(tmpl.ok === true && /"formalVerify": "off"/.test(tj) && /"leanArgs": \[\]/.test(tj), 'the generated settings template documents the Lean parameters with their defaults')
458
- assert(/formalVerify — /.test(tj), 'the template carries the human-readable parameter description as a comment')
459
- }
460
-
461
- // ===============================================================
462
- // 3. 'encourage' injection into the WORK prompts
463
- // ===============================================================
464
- section("3 'encourage' injects 顺手形式化 into solver / explorer / method-keeper prompts")
465
- const RC = makeRoot()
466
- await callTool('vibe_math_new_project', { name: 'lean-work' }, RC)
467
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage', methodKeepEvery: 1 }), RC)
468
- await callTool('vibe_math_add_problem', { id: 'qE', description: '证明 log 2 是无理数', priority: 0 }, RC)
469
- await callTool('vibe_math_start', {}, RC)
470
- assert(await drive(RC, () => !!lastSpawn(RC, 'explorer:qE'), 'explorer:qE'), 'fallback scheduler spawned explorer:qE')
471
- {
472
- const p = lastSpawn(RC, 'explorer:qE').prompt
473
- assert(/【顺手形式化(鼓励)】/.test(p), 'the explorer work prompt carries the 顺手形式化 line')
474
- assert(/vibe_math_lean_archive kind='def'/.test(p), 'the work prompt points at the archive tool for reusable definitions')
475
- assert(/vibe_math_lean_lib 查重/.test(p), 'the work prompt tells agents to check the reuse library first')
476
- assert(/形式化回执/.test(p) && /"decision":"used\|blocked\|defect"/.test(p), '★ the work-round reply contract also advertises the formal field, defect included (契约 §6.3)')
477
- }
478
- fireEnd(lastSpawn(RC, 'explorer:qE').childId, { meta: { kind: 'directions', qid: 'qE', formal: { target: 'qE', decision: 'blocked', note: '需要先形式化连分数收敛定理' }, directions: [{ id: 'd1', title: '连分数法', method: 'e 的连分数', core_assumption: '', feasibility: 0.7 }] } })
479
- // Answer EVERY explorer the fallback scheduler dispatches (it re-derives when a direction looks
480
- // unattended) until the solver for d1 appears — otherwise the retry cap can stall the project.
481
- for (let guard = 0; guard < 40 && !lastSpawn(RC, 'solver:qE:d1'); guard++) {
482
- const ex = spawns.filter((s) => s.rootId === RC.id && s.label.startsWith('explorer:qE') && !firedChildren.has(s.childId)).slice(-1)[0]
483
- if (ex) {
484
- firedChildren.add(ex.childId)
485
- fireEnd(ex.childId, { meta: { kind: 'directions', qid: 'qE', directions: [{ id: 'd1', title: '连分数法', method: 'e 的连分数', core_assumption: '', feasibility: 0.7 }] } })
486
- }
487
- await pump(RC)
488
- await sleep(80)
489
- }
490
- assert(!!lastSpawn(RC, 'solver:qE:d1'), 'solver spawned for direction d1')
491
- assert(/【顺手形式化(鼓励)】/.test(lastSpawn(RC, 'solver:qE:d1').prompt), 'the solver work prompt carries the 顺手形式化 line')
492
- {
493
- // the explorer's `meta.formal` judgement is recorded even though it never called a Lean tool
494
- const st = await callTool('vibe_math_status', {}, RC)
495
- assert(st.formal.blocked.indexOf('qE') !== -1, '★ an explorer `meta.formal` judgement is recorded (no Lean tool call needed)')
496
- assert(/- 形式化: 阻塞(/.test(readIf(join(projRoot('lean-work'), 'Problems', 'qE.md'))), 'and it lands on the problem card anchor')
497
- }
498
- fireEnd(lastSpawn(RC, 'solver:qE:d1').childId, { status: 'continue', survival_probability: 0.6, formal: { target: 'd1-lemma', decision: 'used', file: 'Formal/d1-lemma.lean' }, new_inventions: [{ 类型: '工具', 标题: '连分数估值工具', 内容描述: '控制收敛速度', 是否已入库: false }] })
499
- await sleep(250)
500
- {
501
- const st = await callTool('vibe_math_status', {}, RC)
502
- assert(st.formal.objects.some((o) => o.target === 'd1-lemma' && o.status === 'attempted'), 'a solver top-level `formal.decision=used` reply records the object as attempted')
503
- }
504
- assert(await drive(RC, () => !!lastSpawn(RC, 'method-keeper'), 'method-keeper'), 'method keeper spawned for the pending invention')
505
- {
506
- const p = lastSpawn(RC, 'method-keeper').prompt
507
- assert(/【顺手形式化(鼓励)】/.test(p), 'the method-keeper prompt carries the 顺手形式化 line')
508
- assert(/【方法沉淀 × Lean 形式化】/.test(p), '★ the method keeper is told to ALSO sediment reusable Lean definitions/lemmas')
509
- assert(/kind='lemma'/.test(p) && /Proved/.test(p), 'and where the proved lemmas go')
510
- }
511
- await callTool('vibe_math_abort', {}, RC)
512
- assert((await callTool('vibe_math_status', {}, RC)).running === false, 'work-prompt scenario aborted (keeps later scenarios deterministic)')
513
-
514
- // ===============================================================
515
- // 4. 'encourage' injection into BOTH verification prompts + the fidelity switch
516
- // ===============================================================
517
- section("4 'encourage' reaches the review prompt and the debate prompt")
518
- const RD = makeRoot()
519
- await callTool('vibe_math_new_project', { name: 'lean-verify' }, RD)
520
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage', debateMaxRounds: 2 }), RD)
521
- await callTool('vibe_math_add_proposition', { id: 'p-enc', 概述: '鼓励模式下的忠实性审查', 概率: 0.6, 分类: '数论' }, RD)
522
- await callTool('vibe_math_start', {}, RD)
523
- const encBatch = await runVerifyRound(RD, 'p-enc', [0.9, 0.95])
524
- assert(encBatch !== null, 'encourage: the first review round was asked')
525
- if (encBatch) assertDistinctSlots(encBatch, 'encourage round')
526
- if (encBatch) {
527
- const vp = encBatch.map((s) => s.prompt).join('\n')
528
- assert(/【Lean 形式化验证(鼓励模式)】/.test(vp), 'the review prompt explains the Lean mode')
529
- assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(vp), 'the review prompt states that a passing Lean run shrinks the question to fidelity')
530
- assert(/实现难度/.test(vp), 'the review prompt asks for the implementation-difficulty judgement')
531
- assert(/可以不做,但请在回执的 formal 字段写明难度判断/.test(vp), "'encourage' explicitly allows skipping (with a recorded judgement)")
532
- assert(/"formal":/.test(vp) && /"decision":"used\|blocked\|defect"/.test(vp), 'the reply contract documents the formal field (defect included)')
533
- assert(/vibe_math_lean_run/.test(vp) && /vibe_math_lean_archive/.test(vp) && /vibe_math_lean_lib/.test(vp), 'the prompt names the three v3 Lean tools')
534
- assert(/Formal\/(相对项目根)/.test(vp) && /VibeMath\/Formal\/Lib/.test(vp), 'the prompt states the path layout')
535
- }
536
- // a non-consensus round moves to the public debate, whose prompt must carry the same injection
537
- assert(await drive(RD, () => wakes.some((w) => w.rootId === RD.id && /交流群/.test(w.prompt)), 'debate prompt'), 'a non-consensus round moved to the debate')
538
- {
539
- const dp = wakes.filter((w) => w.rootId === RD.id).map((w) => w.prompt).join('\n')
540
- assert(/【Lean 形式化验证(鼓励模式)】/.test(dp), 'the debate prompt names the mode')
541
- assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性|你不需要重新检查推导/.test(dp), 'the debate prompt carries the Lean block too')
542
- assert(/"formal":/.test(dp), 'the debate reply contract carries the formal field')
543
- }
544
- // finish the debate: a unanimous round concludes
545
- if (encBatch) for (const b of encBatch) fireEnd(b.childId, { Result: 1, Reason: 'mock 第二轮一致' })
546
- assert(await drive(RD, () => existsSync(join(projRoot('lean-verify'), 'Verified', '命题', 'p-enc.md')), 'p-enc verified'), 'the debate finished and the object was verified')
547
-
548
- // ===============================================================
549
- // 5. lean_run executes through the subprocess service and reports honestly
550
- // ===============================================================
551
- section('5 lean_run executes through the subprocess service and reports honestly')
552
- const RE = makeRoot()
553
- await callTool('vibe_math_new_project', { name: 'lean-tools' }, RE)
554
- await callTool('vibe_math_set_params', { formalVerify: 'encourage' }, RE)
555
- await callTool('vibe_math_add_proposition', { id: 'p-tool', 概述: '工具实验命题', 概率: 0.6, 分类: '数论' }, RE)
556
- const toolProj = projRoot('lean-tools')
557
- mkdirSync(join(toolProj, 'Formal'), { recursive: true })
558
- writeFileSync(join(toolProj, 'Formal', 'good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
559
- writeFileSync(join(toolProj, 'Formal', 'bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
560
- writeFileSync(join(toolProj, 'Formal', 'hang.lean'), '-- HANG\ntheorem t : 1 = 1 := rfl\n', 'utf8')
561
- {
562
- const runGood = await callTool('vibe_math_lean_run', { file: 'Formal/good.lean', target: 'p-tool' }, RE)
563
- assert(runGood.ok === true && runGood.exitCode === 0, 'a file with no sorry runs green (' + JSON.stringify({ ok: runGood.ok, exitCode: runGood.exitCode }) + ')')
564
- assert(/ok/.test(runGood.stdout), 'the compiler stdout is returned')
565
- const last = leanRuns[leanRuns.length - 1]
566
- assert(last.cwd.replace(/\\/g, '/') === toolProj.replace(/\\/g, '/'), 'the toolchain runs with the PROJECT root as cwd')
567
- assert(last.argv.join(' ') === 'lean --search-path ' + VIBE.replace(/\\/g, '/'), 'by default the argv is the executable + the injected search path (got ' + last.argv.join(' ') + ')')
568
- assert(last.graceMs === 120000, 'the default leanTimeoutMs is passed down as graceMs (' + last.graceMs + ')')
569
- }
570
- {
571
- const st = await callTool('vibe_math_status', {}, RE)
572
- const o = st.formal.objects.find((x) => x.target === 'p-tool')
573
- assert(!!o && o.status === 'attempted', 'lean_run with target records the object as attempted (not passed)')
574
- assert(!!o.run && o.run.ok === true, 'the run result is recorded on the object')
575
- const card = readIf(join(toolProj, 'Propos', '数论', 'p-tool.md'))
576
- assert(/- 形式化: 已尝试未通过/.test(card), '★ the object card anchor block gains `- 形式化: <状态>` alongside 状态/概率')
577
- assert(/\n- ID: p-tool\n/.test(card) && /\n- 状态: 未定论\n/.test(card) && /\n- 概率: 0.6\n/.test(card), 'the pre-existing anchors are untouched (the scheduler parser keeps working)')
578
- }
579
- {
580
- const runBad = await callTool('vibe_math_lean_run', { file: 'Formal/bad.lean' }, RE)
581
- assert(runBad.ok === false && runBad.exitCode === 1, 'a file that still uses sorry reports a red run')
582
- assert(/sorry/.test(runBad.stderr), 'the compiler output is returned verbatim (' + JSON.stringify(runBad.stderr).slice(0, 60) + ')')
583
- assert(/修复后重跑/.test(runBad.hint || ''), 'a genuine compile failure still points at the compiler output (the hint is failure-code aware, not blanket)')
584
- }
585
- {
586
- const runMissing = await callTool('vibe_math_lean_run', { file: 'Formal/nope.lean' }, RE)
587
- assert(runMissing.ok === false && runMissing.code === 'V3_NOT_FOUND', 'a missing file is refused with a typed code')
588
- }
589
- {
590
- // The guard's boundary is the VibeMath ROOT, not the project: the global reuse library
591
- // deliberately lives at <VibeMath>/Formal/{Lib,Proved}, so climbing out of the project but
592
- // staying inside VibeMath must remain legal (it just fails as a missing file).
593
- const inside = await callTool('vibe_math_lean_run', { file: '../../Formal/Lib/x.lean' }, RE)
594
- assert(inside.code === 'V3_NOT_FOUND', 'climbing out of the project but staying inside VibeMath is allowed (the global library lives there)')
595
- const esc1 = await callTool('vibe_math_lean_run', { file: '../../../evil.lean' }, RE)
596
- assert(esc1.ok === false && esc1.code === 'V3_INVALID_ARGUMENT', '★ a traversal that climbs ABOVE the VibeMath root is refused')
597
- const esc2 = await callTool('vibe_math_lean_run', { file: 'Formal/../../../../../../evil.lean' }, RE)
598
- assert(esc2.ok === false && esc2.code === 'V3_INVALID_ARGUMENT', 'a deeper traversal is refused too')
599
- const esc3 = await callTool('vibe_math_lean_run', { file: '/etc/evil.lean' }, RE)
600
- assert(esc3.ok === false && esc3.code === 'V3_INVALID_ARGUMENT', 'an unrelated absolute path is refused')
601
- const notLean = await callTool('vibe_math_lean_run', { file: 'Formal/good.txt' }, RE)
602
- assert(notLean.ok === false && notLean.code === 'V3_INVALID_ARGUMENT', 'only .lean files can be executed')
603
- const noFile = await callTool('vibe_math_lean_run', {}, RE)
604
- assert(noFile.ok === false && noFile.code === 'V3_INVALID_ARGUMENT', 'a missing file argument is refused')
605
- }
606
- {
607
- toolchainAvailable = false
608
- const runNoTc = await callTool('vibe_math_lean_run', { file: 'Formal/good.lean' }, RE)
609
- assert(runNoTc.ok === false && runNoTc.code === 'LEAN_NOT_FOUND', 'a missing toolchain returns LEAN_NOT_FOUND instead of crashing')
610
- assert(/仍可把形式化代码写下来归档/.test(runNoTc.message), 'the failure explains the graceful degradation')
611
- assert(/本宿主无法执行 Lean(LEAN_NOT_FOUND)/.test(runNoTc.hint || '') && !/编译器输出修复后重跑/.test(runNoTc.hint || ''),
612
- '★ the failure hint never tells the agent to fix compiler output that does not exist — it points at the archive + explicit-blocker way out (§6 hard requirement 4)')
613
- toolchainAvailable = true
614
- }
615
- {
616
- subprocessAvailable = false
617
- const runNoSub = await callTool('vibe_math_lean_run', { file: 'Formal/good.lean' }, RE)
618
- assert(runNoSub.ok === false && runNoSub.code === 'NO_SUBPROCESS', 'a host without the subprocess service returns NO_SUBPROCESS')
619
- assert(/本宿主无法执行 Lean(NO_SUBPROCESS)/.test(runNoSub.hint || '') && /vibe_math_lean_archive/.test(runNoSub.hint || ''),
620
- '★ the NO_SUBPROCESS hint names the way out with the FULL tool name (no retry loop on an impossible run)')
621
- subprocessAvailable = true
622
- }
623
- {
624
- const runHang = await callTool('vibe_math_lean_run', { file: 'Formal/hang.lean', timeout_ms: 1000 }, RE)
625
- assert(runHang.ok === false && runHang.code === 'LEAN_TIMEOUT' && runHang.timedOut === true, '★ a hanging toolchain returns LEAN_TIMEOUT instead of wedging the scheduler')
626
- assert(terminated.some((f) => /hang\.lean$/.test(f)), '★ the timeout path calls handle.terminate()')
627
- }
628
- {
629
- spawnThrows = true
630
- const runSpawn = await callTool('vibe_math_lean_run', { file: 'Formal/good.lean' }, RE)
631
- assert(runSpawn.ok === false && runSpawn.code === 'LEAN_SPAWN_FAILED', 'a spawn failure is reported as LEAN_SPAWN_FAILED instead of throwing')
632
- spawnThrows = false
633
- writeFileSync(join(toolProj, 'Formal', 'reject.lean'), '-- REJECT\ntheorem t : 1 = 1 := rfl\n', 'utf8')
634
- const runReject = await callTool('vibe_math_lean_run', { file: 'Formal/reject.lean' }, RE)
635
- assert(runReject.ok === false && runReject.code === 'LEAN_RUN_FAILED', 'a rejecting handle.done is reported as LEAN_RUN_FAILED instead of throwing into the loop')
636
- }
637
- {
638
- await callTool('vibe_math_set_params', { leanCommand: 'lake', leanArgs: ['env', 'lean'] }, RE)
639
- const runLake = await callTool('vibe_math_lean_run', { file: 'Formal/good.lean' }, RE)
640
- assert(runLake.ok === true, 'lake env lean works through leanCommand/leanArgs')
641
- assert(leanRuns[leanRuns.length - 1].argv.join(' ') === 'lake env lean --search-path ' + VIBE.replace(/\\/g, '/'), 'the argv is [exe, ...leanArgs, injected --search-path] (got ' + leanRuns[leanRuns.length - 1].argv.join(' ') + ')')
642
- await callTool('vibe_math_set_params', { leanCommand: 'lean', leanArgs: [] }, RE)
643
- }
644
-
645
- // ===============================================================
646
- // 6. lean_archive: def / lemma / proof / blocked → the right paths + indexes
647
- // ===============================================================
648
- section('6 lean_archive writes the contract paths and rebuilds the indexes')
649
- const libPath = join(VIBE, 'Formal', 'Lib')
650
- const provedPath = join(VIBE, 'Formal', 'Proved')
651
- {
652
- const defRes = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'ZMod5', content: 'def ZMod5 := Fin 5\n' }, RE)
653
- assert(defRes.ok === true && defRes.file === 'Formal/Lib/ZMod5.lean', 'a reusable definition is archived to the global lib (' + defRes.file + ')')
654
- assert(existsSync(join(libPath, 'ZMod5.lean')), '★ the definition exists under VibeMath/Formal/Lib/ (cross-project, NOT inside the project)')
655
- assert(!existsSync(join(toolProj, 'Formal', 'Lib', 'ZMod5.lean')), 'it is NOT duplicated inside the project tree')
656
- assert(defRes.run && defRes.run.ok === true, 'the reusable definition is executed on archive (run defaults to true)')
657
- const libIdx = readIf(join(libPath, 'Index.md'))
658
- assert(/\| 名称 \| 文件 \| 类别 \| 依赖(import) \| 摘要 \| 最近运行 \|/.test(libIdx), 'Lib/Index.md uses the contract columns (incl. the import-dependency column, spec §4.4)')
659
- assert(/ZMod5/.test(libIdx) && /Lib\/ZMod5\.lean/.test(libIdx) && /\| ok \|/.test(libIdx), 'Lib/Index.md lists the new definition with its run result')
660
- assert(!/ZMod5/.test(readIf(join(toolProj, 'Formal', 'Index.md'))), 'the global definition does not pollute the project formal index')
661
- }
662
- {
663
- const lemRes = await callTool('vibe_math_lean_archive', { kind: 'lemma', name: 'sq_odd', content: 'import Mathlib\n\ntheorem sq_odd (n : Nat) : Odd (n*n) → Odd n := by omega\n' }, RE)
664
- assert(lemRes.ok === true && lemRes.file === 'Formal/Proved/sq_odd.lean', 'a lemma is archived to Proved/')
665
- assert(existsSync(join(provedPath, 'sq_odd.lean')), 'the lemma exists under VibeMath/Formal/Proved/')
666
- const provedIdx = readIf(join(provedPath, 'Index.md'))
667
- assert(/\| 名称 \| 文件 \| 陈述 \| 依赖 \| 最近运行 \|/.test(provedIdx), 'Proved/Index.md uses the contract columns')
668
- assert(/sq_odd/.test(provedIdx) && /Mathlib/.test(provedIdx), 'Proved/Index.md lists the lemma and its imports as 依赖')
669
- }
670
- {
671
- const noRun = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'NoRun', content: 'def NoRun := 1\n', run: false }, RE)
672
- assert(noRun.ok === true && noRun.run === undefined, 'run=false archives WITHOUT executing (no toolchain needed)')
673
- }
674
- {
675
- const noName = await callTool('vibe_math_lean_archive', { kind: 'def', content: 'def x := 1\n' }, RE)
676
- assert(noName.ok === false && noName.code === 'V3_INVALID_ARGUMENT', 'archiving a definition without a name is refused')
677
- const badKind = await callTool('vibe_math_lean_archive', { kind: 'nonsense' }, RE)
678
- assert(badKind.ok === false && badKind.code === 'V3_INVALID_ARGUMENT', 'an unknown archive kind is refused')
679
- const noTarget = await callTool('vibe_math_lean_archive', { kind: 'proof', content: 'theorem x : 1 = 1 := rfl\n' }, RE)
680
- assert(noTarget.ok === false && noTarget.code === 'V3_INVALID_ARGUMENT', 'kind=proof without a target is refused')
681
- const noBody = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-x' }, RE)
682
- assert(noBody.ok === false && noBody.code === 'V3_INVALID_ARGUMENT', 'kind=proof without content/from is refused')
683
- const badFrom = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-x', from: '../../../evil.lean' }, RE)
684
- assert(badFrom.ok === false && badFrom.code === 'V3_INVALID_ARGUMENT', 'kind=proof with an out-of-tree from is refused')
685
- }
686
- {
687
- // a green proof → passed + Verified/Lean (the archived proof lives next to the conclusion)
688
- const arc = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-proof', content: 'theorem p_proof : 3 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n' }, RE)
689
- assert(arc.ok === true && arc.passed === true, 'the proof is archived and passes (' + JSON.stringify({ ok: arc.ok, passed: arc.passed }) + ')')
690
- assert(arc.file === 'Formal/p-proof.lean', 'the working file is Formal/<target>.lean')
691
- assert(arc.proof === 'Verified/Lean/p-proof.lean', 'the archived proof path is Verified/Lean/<target>.lean')
692
- assert(existsSync(join(toolProj, 'Formal', 'p-proof.lean')), 'the working file exists on disk')
693
- assert(existsSync(join(toolProj, 'Verified', 'Lean', 'p-proof.lean')), '★ the proof is archived under Verified/Lean/ as the proof of that object')
694
- const st = await callTool('vibe_math_status', {}, RE)
695
- assert(st.formal.passed.indexOf('p-proof') !== -1, 'status reports the object as Lean-passed')
696
- const idx = readIf(join(toolProj, 'Formal', 'Index.md'))
697
- assert(/# Lean 形式化索引/.test(idx) && /p-proof/.test(idx) && /passed/.test(idx) && /Verified\/Lean\/p-proof\.lean/.test(idx), 'Formal/Index.md indexes the object, its status and its archived proof')
698
- assert(/ok(exit 0,/.test(idx), 'the index shows the run result in the contract format')
699
- // a RED proof must NOT become passed
700
- const red = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-red', content: 'theorem p_red : 1 = 2 := by sorry\n' }, RE)
701
- assert(red.ok === true && red.passed === false && red.status === 'attempted', 'a red proof is archived as attempted, not passed')
702
- assert(!existsSync(join(toolProj, 'Verified', 'Lean', 'p-red.lean')), '★ no archived proof is written for a red run')
703
- }
704
- {
705
- const blkNoNote = await callTool('vibe_math_lean_archive', { kind: 'blocked', target: 'p-blk' }, RE)
706
- assert(blkNoNote.ok === false && blkNoNote.code === 'V3_INVALID_ARGUMENT', 'blocked without a note is refused')
707
- const blk = await callTool('vibe_math_lean_archive', { kind: 'blocked', target: 'p-blk', note: '需要外层解析数论框架,本轮工作量不可接受' }, RE)
708
- assert(blk.ok === true && blk.status === 'blocked', 'a reasoned blocker is recorded')
709
- const stBlk = await callTool('vibe_math_status', {}, RE)
710
- assert(stBlk.formal.blocked.indexOf('p-blk') !== -1, 'status lists the blocked object')
711
- assert(/需要外层解析数论框架/.test(readIf(join(toolProj, 'Formal', 'Index.md'))), 'the blocker reason is written into the index')
712
- }
713
- {
714
- const libList = await callTool('vibe_math_lean_lib', {}, RE)
715
- assert(libList.ok === true && libList.counts.lib >= 2 && libList.counts.proved >= 1, 'lean_lib reports the reuse library sizes (' + JSON.stringify(libList.counts) + ')')
716
- assert(libList.objects.some((o) => o.target === 'p-proof' && o.status === 'passed'), 'lean_lib lists per-object formal status')
717
- assert(/复用优先/.test(libList.hint || ''), 'lean_lib tells agents to reuse before redefining')
718
- assert(libList.paths && /VibeMath\/Formal\/Lib/.test(libList.paths.lib), 'lean_lib reports the cross-project path layout')
719
- const ro = await callTool('vibe_math_lean_lib', { refresh: false }, RE)
720
- assert(ro.rebuilt === false && ro.counts.lib === null, 'refresh:false only reads (does not rebuild the indexes)')
721
- }
722
- {
723
- const ann = readIf(join(toolProj, 'Logs', '形式化.md'))
724
- assert(/# 形式化公告/.test(ann) && /ZMod5/.test(ann) && /p-proof/.test(ann), 'the announcement log records what was formalized')
725
- }
726
-
727
- // ===============================================================
728
- // 7. the 'require' gate
729
- // ===============================================================
730
- section("7 'require' withholds a verdict until the formal record exists")
731
- const RF = makeRoot()
732
- await callTool('vibe_math_new_project', { name: 'lean-gate' }, RF)
733
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RF)
734
- await callTool('vibe_math_add_proposition', { id: 'p-gate', 概述: '必须形式化的命题', 概率: 0.6, 分类: '数论' }, RF)
735
- const gateProj = projRoot('lean-gate')
736
- await callTool('vibe_math_start', {}, RF)
737
- {
738
- const batch = await runVerifyRound(RF, 'p-gate', [1, 1])
739
- gateSlots1 = slotsOf(batch) // V3-G2: measured ["0","1"]
740
- assert(batch !== null, 'require: the review round was asked')
741
- if (batch) {
742
- const vp = batch.map((s) => s.prompt).join('\n')
743
- assert(/【Lean 形式化验证(强制模式)】/.test(vp), 'the review prompt says 强制模式')
744
- assert(/必须产出 Lean 形式化/.test(vp), "'require' states the formalization is mandatory")
745
- assert(/本次裁定不会生效/.test(vp), 'the prompt warns that the verdict will not take effect without it')
746
- assert(/formal-required/.test(vp), 'the prompt names the machine-readable reason')
747
- assert(/归档可复用定义\/引理前先跑通(vibe_math_lean_archive run=true 或先 vibe_math_lean_run);跑不通不要入库。/.test(vp),
748
- '★ the verification prompt requires a GREEN run before archiving into the reuse library (§6 hard requirement 3)')
749
- assert(/宿主没有 Lean 工具链(LEAN_NOT_FOUND)或宿主不提供 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"/.test(vp),
750
- '★ and spells out the way out when the host has no Lean toolchain, naming BOTH failure codes (LEAN_NOT_FOUND / NO_SUBPROCESS, §6 hard requirement 4)')
751
- assert(/Result/.test(vp) && !/verdict/.test(vp) && !/(^|[^a-z_])lean_(run|archive|lib)/.test(vp),
752
- '★ the voting prompt uses FULL tool names and names Result, never verdict (§6 hard requirements 1-2)')
753
- }
754
- }
755
- assert(await drive(RF, () => /p-gate/.test(readIf(join(gateProj, 'Formal', 'TODO.md'))), 'p-gate in Formal/TODO.md'), '★ a unanimous TRUE verdict was withheld: the object is on the formalization TODO')
756
- assert(!existsSync(join(gateProj, 'Verified', '命题', 'p-gate.md')), 'no Verified card was written')
757
- {
758
- const card = readIf(join(gateProj, 'Propos', '数论', 'p-gate.md'))
759
- assert(/- 概率: 0.6/.test(card), '★ the object keeps its existing probability (no silent promotion to 1)')
760
- assert(/- 状态: 未定论/.test(card), 'and its 状态 stays 未定论')
761
- const todo = readIf(join(gateProj, 'Formal', 'TODO.md'))
762
- assert(/# 形式化待办/.test(todo) && /p-gate/.test(todo) && /formal-required/.test(todo), '★ the TODO records the object with the machine-readable reason')
763
- const idx = readIf(join(gateProj, 'Formal', 'Index.md'))
764
- assert(/## 形式化待办/.test(idx) && /p-gate/.test(idx), 'Formal/Index.md mirrors the formalization TODO')
765
- const ann = readIf(join(gateProj, 'Logs', '形式化.md'))
766
- assert(/require 模式/.test(ann) && /不定论/.test(ann), '★ the withholding is announced')
767
- const st = await callTool('vibe_math_status', {}, RF)
768
- assert(st.formal.todo.some((t) => t.id === 'p-gate'), 'status lists the deferred object')
769
- assert(/formal-required/.test(st.recentActivity.map((e) => e.detail).join('\n')), 'the activity feed shows the formal-required deferral')
770
- const voteLog = listDirIf(join(gateProj, 'Logs', 'Verification')).filter((f) => /^r-p-gate_/.test(f))
771
- assert(voteLog.length >= 1, 'the votes themselves are still recorded in Logs/Verification (nothing is lost)')
772
- }
773
- // runtime switch: the mode is read when the prompt is CONSTRUCTED, so a fresh round reflects it
774
- await callTool('vibe_math_add_proposition', { id: 'p-mode', 概述: '模式切换观察对象', 概率: 0.6, 分类: '数论' }, RF)
775
- await restart(RF)
776
- assert(await drive(RF, () => unfiredVerifiers(RF, verifyRe('p-mode')).length >= 2, 'fresh verifiers in require mode'), 'a fresh object is still verified in require mode')
777
- {
778
- const fresh = unfiredVerifiers(RF, verifyRe('p-mode'))[0]
779
- assert(!!fresh && /【Lean 形式化验证(强制模式)】/.test(fresh.prompt) && /本次裁定不会生效/.test(fresh.prompt), 'a fresh round reflects the current mode (强制模式)')
780
- }
781
- // ★ anti-idle: a deferred object must NOT be re-voted while it sits on the TODO (each round would
782
- // just be withheld again, burning verifiers on the same rId every tick). The assertion is only
783
- // meaningful because the previous block proved the scheduler IS allocating verify tasks here.
784
- assert(unfiredVerifiers(RF, verifyRe('p-gate')).length === 0, '★ a deferred object is NOT re-voted while it sits on the formalization TODO (no verification livelock)')
785
- await callTool('vibe_math_set_params', { formalVerify: 'encourage' }, RF)
786
- await restart(RF)
787
- assert(await drive(RF, () => unfiredVerifiers(RF, verifyRe('p-mode')).length >= 2, 'fresh verifiers after the mode switch'), 'the unresolved object is verified again after the mode switch')
788
- {
789
- const fresh = unfiredVerifiers(RF, verifyRe('p-mode'))[0]
790
- assert(!!fresh && /【Lean 形式化验证(鼓励模式)】/.test(fresh.prompt), '★ switching the mode at runtime immediately changes the prompt (now 鼓励模式)')
791
- assert(!!fresh && !/本次裁定不会生效/.test(fresh.prompt), 'and the mandatory wording is gone in encourage mode')
792
- }
793
- await callTool('vibe_math_set_params', { formalVerify: 'require' }, RF)
794
- // formalize it, restart (so the next prompt is built AFTER the proof exists), then re-verify
795
- {
796
- const proofNow = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-gate', content: 'theorem p_gate : 2 + 2 = 4 := by decide\n' }, RF)
797
- assert(proofNow.ok === true && proofNow.passed === true, 'the object is now Lean-passed')
798
- const st = await callTool('vibe_math_status', {}, RF)
799
- assert(st.formal.passed.indexOf('p-gate') !== -1, 'status reports p-gate as passed')
800
- assert(!st.formal.todo.some((t) => t.id === 'p-gate'), '★ satisfying the gate removes the object from the formalization TODO (no stale TODO)')
801
- assert(!/p-gate/.test(readIf(join(gateProj, 'Formal', 'TODO.md'))), 'Formal/TODO.md no longer lists it')
802
- assert(/- 形式化: Lean 通过/.test(readIf(join(gateProj, 'Propos', '数论', 'p-gate.md'))), 'the object card anchor now records Lean 通过')
803
- }
804
- await restart(RF)
805
- assert(await drive(RF, () => unfiredVerifiers(RF, verifyRe('p-gate')).length >= 2, 'p-gate re-eligible after passing'), '★ once the formal record satisfies the gate, the object becomes eligible for verification again')
806
- {
807
- // V3-G2: `.length >= 2` is a COUNT - the semantic unit is that the RE-ELIGIBLE reviews are the
808
- // SAME slots that fired in round one (a re-verification that re-armed different slots would
809
- // still satisfy the count). Measured round-one slots: ["0","1"].
810
- const un = unfiredVerifiers(RF, verifyRe('p-gate'))
811
- const slots = slotsOf(un)
812
- assert(slots.slice().sort().join(',') === gateSlots1.slice().sort().join(','), '* V3-G2 the re-eligible reviews are the SAME slots that fired in round one (before=' + JSON.stringify(gateSlots1) + ' after=' + JSON.stringify(slots) + ')')
813
- assert(new Set(slots).size === slots.length, '* V3-G2 and they are still DISTINCT slots (the V3-G1 clause restated at this site)')
814
- }
815
- {
816
- const batch = await runVerifyRound(RF, 'p-gate', [1, 1])
817
- assert(batch !== null, 'the re-verification round was asked')
818
- if (batch) {
819
- const vp = batch.map((s) => s.prompt).join('\n')
820
- assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(vp), 'the voting prompt announces the passing proof')
821
- assert(/你不需要重新检查推导/.test(vp), '★ it tells voters NOT to re-derive')
822
- assert(/忠实性审查/.test(vp), '★ it tells voters the review subject is now fidelity')
823
- assert(/定义 \/ 对象 \/ 条件 \/ 假设 \/ 结论是否与命题原文\*\*完全一致\*\*/.test(vp), 'it enumerates exactly what fidelity means')
824
- assert(!/必须产出 Lean 形式化/.test(vp), 'and the "must formalize" wording is replaced (the object already has one)')
825
- }
826
- }
827
- assert(await drive(RF, () => existsSync(join(gateProj, 'Verified', '命题', 'p-gate.md')), 'p-gate Verified card'), '★ with a passing Lean artifact the same vote DOES promote it')
828
- {
829
- const card = readIf(join(gateProj, 'Verified', '命题', 'p-gate.md'))
830
- assert(/- 形式化: Lean 通过/.test(card), '★ the Verified card records how strong the result is (Lean 通过)')
831
- assert(/Verified\/Lean\/p-gate\.lean/.test(card), 'the card points at the archived proof')
832
- assert(/- 结论: 真/.test(card), 'the card states the conclusion')
833
- }
834
- // the blocker escape hatch must also open the gate
835
- await callTool('vibe_math_add_proposition', { id: 'p-blocked-ok', 概述: '记录阻塞后可定论', 概率: 0.6, 分类: '数论' }, RF)
836
- await callTool('vibe_math_lean_archive', { kind: 'blocked', target: 'p-blocked-ok', note: '命题涉及未形式化的分析学,本轮不做' }, RF)
837
- await restart(RF)
838
- assert(await runVerifyRound(RF, 'p-blocked-ok', [1, 1]) !== null, 'the blocked object is put to a vote')
839
- assert(await drive(RF, () => existsSync(join(gateProj, 'Verified', '命题', 'p-blocked-ok.md')), 'p-blocked-ok Verified card'), '★ an explicit reasoned blocker also lets the verdict through (decide by difficulty, but decide out loud)')
840
- {
841
- const card = readIf(join(gateProj, 'Verified', '命题', 'p-blocked-ok.md'))
842
- assert(/- 形式化: 阻塞(/.test(card), 'the Verified card records the blocker')
843
- assert(/未形式化的分析学/.test(card), 'and quotes the reason')
844
- }
845
-
846
- // ===============================================================
847
- // 8. the reply-channel judgement (formal field in the verifier reply)
848
- // ===============================================================
849
- section('8 the per-round `formal` reply channel records the difficulty judgement')
850
- const RG = makeRoot()
851
- await callTool('vibe_math_new_project', { name: 'lean-reply' }, RG)
852
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RG)
853
- const replyProj = projRoot('lean-reply')
854
-
855
- // (a) blocked WITH a note → recorded, reason reaches the index + the card anchor
856
- await callTool('vibe_math_add_proposition', { id: 'p-reply', 概述: '用回执记录阻塞', 概率: 0.6, 分类: '数论' }, RG)
857
- await callTool('vibe_math_start', {}, RG)
858
- {
859
- const re = verifyRe('p-reply')
860
- assert(await drive(RG, () => unfiredVerifiers(RG, re).length >= 2, 'verifiers for r-p-reply'), 'p-reply was put to a vote')
861
- const vs = unfiredVerifiers(RG, re).slice(0, 2)
862
- if (vs.length === 2) {
863
- for (const v of vs) firedChildren.add(v.childId)
864
- // A verifier that never calls a Lean tool still has to state its difficulty judgement: the
865
- // JSON reply channel is the path that actually fires in practice.
866
- fireEnd(vs[0].childId, { Result: 0.5, Reason: '我判断形式化不划算', formal: { target: 'p-reply', decision: 'blocked', note: '需要大量未形式化的实分析前置知识' } })
867
- await sleep(150)
868
- const st1 = await callTool('vibe_math_status', {}, RG)
869
- assert(st1.formal.blocked.indexOf('p-reply') !== -1, 'a `formal.decision=blocked` reply is recorded as a blocker')
870
- assert(/实分析前置知识/.test(readIf(join(replyProj, 'Formal', 'Index.md'))), 'and its reason reaches the index')
871
- assert(/- 形式化: 阻塞(/.test(readIf(join(replyProj, 'Propos', '数论', 'p-reply.md'))), 'the reply-recorded blocker also lands on the object card anchor')
872
- fireEnd(vs[1].childId, { Result: 0.5, Reason: '同上' })
873
- await sleep(200)
874
- }
875
- }
876
- // (b) `used` records attempted — which does NOT open the require gate
877
- await restart(RG)
878
- await callTool('vibe_math_add_proposition', { id: 'p-used', 概述: '写了草稿但没跑通', 概率: 0.6, 分类: '数论' }, RG)
879
- {
880
- const re = verifyRe('p-used')
881
- assert(await drive(RG, () => unfiredVerifiers(RG, re).length >= 2, 'verifiers for r-p-used'), 'p-used was put to a vote')
882
- const vs = unfiredVerifiers(RG, re).slice(0, 2)
883
- if (vs.length === 2) {
884
- for (const v of vs) firedChildren.add(v.childId)
885
- fireEnd(vs[0].childId, { Result: 1, Reason: '看起来对', formal: { target: 'p-used', decision: 'used', file: 'Formal/p-used.lean' } })
886
- await sleep(150)
887
- const st = await callTool('vibe_math_status', {}, RG)
888
- const o = st.formal.objects.find((x) => x.target === 'p-used')
889
- assert(!!o && o.status === 'attempted' && o.file === 'Formal/p-used.lean', 'a `formal.decision=used` reply records the object as attempted with its file')
890
- fireEnd(vs[1].childId, { Result: 1, Reason: '同样看起来对' })
891
- await sleep(250)
892
- assert(!existsSync(join(replyProj, 'Verified', '命题', 'p-used.md')), '★ attempted is NOT passed: the require gate still withholds the verdict')
893
- assert(/p-used/.test(readIf(join(replyProj, 'Formal', 'TODO.md'))), 'and the object is on the formalization TODO')
894
- }
895
- }
896
- // (c) a blocker with no note is refused (an explicit decision is required, never a silent skip)
897
- await restart(RG)
898
- await callTool('vibe_math_add_proposition', { id: 'p-nonote', 概述: '没有理由的阻塞', 概率: 0.6, 分类: '数论' }, RG)
899
- {
900
- const re = verifyRe('p-nonote')
901
- assert(await drive(RG, () => unfiredVerifiers(RG, re).length >= 2, 'verifiers for r-p-nonote'), 'p-nonote was put to a vote')
902
- const vs = unfiredVerifiers(RG, re).slice(0, 2)
903
- if (vs.length === 2) {
904
- for (const v of vs) firedChildren.add(v.childId)
905
- fireEnd(vs[0].childId, { Result: 0.5, Reason: '不想做', formal: { target: 'p-nonote', decision: 'blocked' } })
906
- await sleep(180)
907
- assert(/未写明 note/.test(readIf(join(replyProj, 'Logs', '形式化.md'))), 'a blocked judgement without a note is refused with an explicit announcement')
908
- const st = await callTool('vibe_math_status', {}, RG)
909
- assert(st.formal.objects.every((o) => o.target !== 'p-nonote'), 'and no blocker record is created for the refused judgement')
910
- // a formal reply without a target must not invent an object (idSafe('') would fall back to 'id')
911
- fireEnd(vs[1].childId, { Result: 0.5, Reason: '同上', formal: { decision: 'blocked', note: '没有写 target' } })
912
- await sleep(180)
913
- const st2 = await callTool('vibe_math_status', {}, RG)
914
- assert(st2.formal.objects.every((o) => o.target !== 'id' && o.target !== ''), 'a `formal` reply with no target cannot invent an object record')
915
- }
916
- }
917
- await callTool('vibe_math_abort', {}, RG)
918
-
919
- // ===============================================================
920
- // 8b. the §4.1 `defect` channel (contract §4.1 / §6 / §10 items 8-9)
921
- //
922
- // A fidelity defect is NOT "the proposition is false". These are BEHAVIOURAL assertions: a real
923
- // agent reply carrying `formal:{decision:'defect', note}` is fed through the real reply path
924
- // (subagent/end → handleVerifier → absorbFormalReply) and the record, the archived file and the
925
- // formalization TODO are inspected on disk.
926
- // ===============================================================
927
- section('8b a formal.decision=defect reply withdraws the passed proof and withholds the verdict')
928
- const RH = makeRoot()
929
- await callTool('vibe_math_new_project', { name: 'lean-defect' }, RH)
930
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RH)
931
- const defectProj = projRoot('lean-defect')
932
- await callTool('vibe_math_add_proposition', { id: 'p-defect', 概述: '形式化写窄了的命题', 概率: 0.6, 分类: '数论' }, RH)
933
- {
934
- const pass = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-defect', content: 'theorem p_defect : 2 + 2 = 4 := by decide\n' }, RH)
935
- assert(pass.ok === true && pass.passed === true, 'defect: the object starts out Lean-passed')
936
- assert(existsSync(join(defectProj, 'Verified', 'Lean', 'p-defect.lean')), 'defect: the archived proof is on disk before the fidelity review')
937
- }
938
- await callTool('vibe_math_start', {}, RH)
939
- {
940
- const re = verifyRe('p-defect')
941
- assert(await drive(RH, () => unfiredVerifiers(RH, re).length >= 2, 'verifiers for r-p-defect'), 'defect: the Lean-passed object is put to a fidelity review')
942
- const vs = unfiredVerifiers(RH, re).slice(0, 2)
943
- if (vs.length === 2) {
944
- for (const v of vs) firedChildren.add(v.childId)
945
- assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(vs[0].prompt) && /不要投 0/.test(vs[0].prompt) && /formal:\{decision:'defect'/.test(vs[0].prompt),
946
- 'defect: the fidelity prompt asks for the defect reply and forbids recording the deviation as 0')
947
- assert(/本次裁定\*\*不定论\*\*/.test(vs[0].prompt),
948
- "★ `require` DOES keep the hold clause (the clause is mode-dependent, not deleted)")
949
- // The defect reply carries an EXTREME Result on purpose: the gate (not the vote value) has to
950
- // be what withholds the verdict — a defect must never be harvested as "the proposition is false".
951
- fireEnd(vs[0].childId, { Result: 1, Reason: '逐条核对后认定形式化不忠实', formal: { target: 'p-defect', decision: 'defect', note: 'Lean 代码多加了 h>0 假设,命题原文未要求' } })
952
- await sleep(240)
953
- const st = await callTool('vibe_math_status', {}, RH)
954
- const rec = st.formal.objects.find((o) => o.target === 'p-defect')
955
- assert(!!rec && rec.status === 'attempted', '★ defect downgrades the formal record to attempted (observed ' + (rec && rec.status) + ')')
956
- assert(st.formal.passed.indexOf('p-defect') === -1, '★ and the object is no longer reported as Lean-passed')
957
- assert(!!rec && rec.proof === '', '★ defect clears the `proof` field')
958
- assert(!existsSync(join(defectProj, 'Verified', 'Lean', 'p-defect.lean')), '★ defect deletes the archived proof Verified/Lean/p-defect.lean')
959
- assert(existsSync(join(defectProj, 'Formal', 'p-defect.lean')), 'the WORK file Formal/p-defect.lean survives (the code itself is not lost)')
960
- const persisted = JSON.parse(readIf(join(defectProj, 'State', 'formal.json')))
961
- assert(persisted.records['p-defect'].decision === 'defect' && /多加了 h>0 假设/.test(persisted.records['p-defect'].note),
962
- '★ the note (the concrete deviation) is recorded in the persisted formal record')
963
- assert(/- 形式化: 已尝试未通过/.test(readIf(join(defectProj, 'Propos', '数论', 'p-defect.md'))), 'and the object card anchor is refreshed')
964
- fireEnd(vs[1].childId, { Result: 1, Reason: '同意:形式化不忠实' })
965
- await sleep(300)
966
- }
967
- }
968
- {
969
- const todo = readIf(join(defectProj, 'Formal', 'TODO.md'))
970
- assert(/p-defect/.test(todo) && /多加了 h>0 假设/.test(todo), '★ the deviation is written into Formal/TODO.md')
971
- const idx = readIf(join(defectProj, 'Formal', 'Index.md'))
972
- assert(/p-defect/.test(idx) && /attempted/.test(idx) && !/Verified\/Lean\/p-defect\.lean/.test(idx), 'Formal/Index.md now shows attempted with no archived proof')
973
- assert(!existsSync(join(defectProj, 'Verified', '命题', 'p-defect.md')), '★ require after a defect: NO Verified card is written (the verdict is withheld, not turned into "false")')
974
- const card = readIf(join(defectProj, 'Propos', '数论', 'p-defect.md'))
975
- assert(/- 状态: 未定论/.test(card), '★ and the object stays 未定论')
976
- assert(/- 概率: 0.6/.test(card), 'the object keeps its existing probability: a defect must NOT be harvested as a refutation')
977
- const ann = readIf(join(defectProj, 'Logs', '形式化.md'))
978
- assert(/忠实性缺陷/.test(ann) && /多加了 h>0 假设/.test(ann), '★ the defect is announced with its concrete deviation')
979
- assert(ann.indexOf('不是"命题为假"') !== -1, 'the announcement spells out that a fidelity defect is NOT "the proposition is false"')
980
- const st = await callTool('vibe_math_status', {}, RH)
981
- assert(st.formal.todo.some((t) => t.id === 'p-defect'), '★ the object is on the formalization TODO (undecided until the formalization is fixed and re-run)')
982
- }
983
- await callTool('vibe_math_abort', {}, RH)
984
-
985
- section('8b-2 a defect without a note is refused (the deviation must be auditable)')
986
- const RI = makeRoot()
987
- await callTool('vibe_math_new_project', { name: 'lean-defect-nonote' }, RI)
988
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RI)
989
- const nonoteProj = projRoot('lean-defect-nonote')
990
- await callTool('vibe_math_add_proposition', { id: 'p-nonote-defect', 概述: '没有偏差说明的缺陷回执', 概率: 0.6, 分类: '数论' }, RI)
991
- await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-nonote-defect', content: 'theorem p_nn_defect : 2 + 2 = 4 := by decide\n' }, RI)
992
- await callTool('vibe_math_start', {}, RI)
993
- {
994
- const re = verifyRe('p-nonote-defect')
995
- assert(await drive(RI, () => unfiredVerifiers(RI, re).length >= 2, 'verifiers for r-p-nonote-defect'), 'defect: the passed object is put to a fidelity review')
996
- const vs = unfiredVerifiers(RI, re).slice(0, 2)
997
- if (vs.length === 2) {
998
- for (const v of vs) firedChildren.add(v.childId)
999
- fireEnd(vs[0].childId, { Result: 1, Reason: '觉得不忠实但没写清楚', formal: { target: 'p-nonote-defect', decision: 'defect' } })
1000
- await sleep(240)
1001
- const st = await callTool('vibe_math_status', {}, RI)
1002
- const rec = st.formal.objects.find((o) => o.target === 'p-nonote-defect')
1003
- assert(!!rec && rec.status === 'passed', '★ a defect WITHOUT a note is refused: the object stays Lean-passed (no silent downgrade)')
1004
- assert(existsSync(join(nonoteProj, 'Verified', 'Lean', 'p-nonote-defect.lean')), '★ and the archived proof is NOT deleted')
1005
- assert(st.formal.todo.every((t) => t.id !== 'p-nonote-defect') && !/p-nonote-defect/.test(readIf(join(nonoteProj, 'Formal', 'TODO.md'))),
1006
- 'and no bogus formalization-TODO entry is created')
1007
- assert(/未写明 note/.test(readIf(join(nonoteProj, 'Logs', '形式化.md'))), '★ the refusal is announced explicitly')
1008
- fireEnd(vs[1].childId, { Result: 1, Reason: '核对后认为一致' })
1009
- await sleep(300)
1010
- }
1011
- }
1012
- assert(await drive(RI, () => existsSync(join(nonoteProj, 'Verified', '命题', 'p-nonote-defect.md')), 'Verified card'), 'a refused defect leaves the gate open: the same vote still promotes the object')
1013
- // §4.1: the downgrade is unconditional — a `blocked` record loses to a defect too (it needs REDOING,
1014
- // not a free pass through the gate, which `blocked` would otherwise grant).
1015
- await callTool('vibe_math_add_proposition', { id: 'p-blocked-defect', 概述: '阻塞后仍被认定不忠实', 概率: 0.6, 分类: '数论' }, RI)
1016
- await callTool('vibe_math_lean_archive', { kind: 'blocked', target: 'p-blocked-defect', note: '先按难度记为阻塞' }, RI)
1017
- await restart(RI)
1018
- {
1019
- const re = verifyRe('p-blocked-defect')
1020
- assert(await drive(RI, () => unfiredVerifiers(RI, re).length >= 2, 'verifiers for r-p-blocked-defect'), 'a blocked object is put to a vote (the gate is open for blocked)')
1021
- const vs = unfiredVerifiers(RI, re).slice(0, 2)
1022
- if (vs.length === 2) {
1023
- for (const v of vs) firedChildren.add(v.childId)
1024
- fireEnd(vs[0].childId, { Result: 1, Reason: '形式化与命题不对应', formal: { target: 'p-blocked-defect', decision: 'defect', note: '阻塞所依据的形式化本身写错了对象' } })
1025
- await sleep(240)
1026
- const st = await callTool('vibe_math_status', {}, RI)
1027
- const rec = st.formal.objects.find((o) => o.target === 'p-blocked-defect')
1028
- assert(!!rec && rec.status === 'attempted', '★★ a defect ALWAYS downgrades, even from `blocked` (observed ' + (rec && rec.status) + ')')
1029
- assert(st.formal.blocked.indexOf('p-blocked-defect') === -1 && /阻塞所依据的形式化本身写错了对象/.test(rec.note || ''), 'and the blocked record is replaced by the concrete deviation')
1030
- fireEnd(vs[1].childId, { Result: 1, Reason: '同意,形式化写错了对象' })
1031
- await sleep(280)
1032
- }
1033
- }
1034
- assert(!existsSync(join(nonoteProj, 'Verified', '命题', 'p-blocked-defect.md')), 'require after a blocked→defect downgrade: still no Verified card (undecided, not "false")')
1035
- await callTool('vibe_math_abort', {}, RI)
1036
-
1037
- // ===============================================================
1038
- // 8b-3. the withdrawal is not a best-effort delete (contract §4.1)
1039
- //
1040
- // `Verified/Lean/<id>.lean` is exactly where everyone looks for "the proof of this object", so a
1041
- // downgraded record with the old code still sitting there is worse than no record at all. The fs
1042
- // service exposes no unlink and `subprocess` is optional, so on a host that cannot delete, the
1043
- // withdrawal must fall back to overwriting the file with an explicit notice. This runs in
1044
- // `encourage` mode on purpose: the same scenario also proves the injected text and the
1045
- // framework's own announcement do NOT promise a hold that only `require` can enforce (§4.1 item 3).
1046
- // ===============================================================
1047
- section('8b-3 a defect withdraws the archived proof even on a host that cannot delete files')
1048
- const RL = makeRoot()
1049
- await callTool('vibe_math_new_project', { name: 'lean-defect-nodelete' }, RL)
1050
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage' }), RL)
1051
- const nodeleteProj = projRoot('lean-defect-nodelete')
1052
- await callTool('vibe_math_add_proposition', { id: 'p-nodelete', 概述: '宿主无法删除文件时的撤回', 概率: 0.6, 分类: '数论' }, RL)
1053
- const proofFile = join(nodeleteProj, 'Verified', 'Lean', 'p-nodelete.lean')
1054
- {
1055
- const pass = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-nodelete', content: 'theorem p_nodelete : 2 + 2 = 4 := by decide\n' }, RL)
1056
- assert(pass.ok === true && pass.passed === true && /theorem p_nodelete/.test(readIf(proofFile)),
1057
- 'no-delete: the object starts out Lean-passed with its archived proof on disk')
1058
- }
1059
- await callTool('vibe_math_start', {}, RL)
1060
- {
1061
- const re = verifyRe('p-nodelete')
1062
- assert(await drive(RL, () => unfiredVerifiers(RL, re).length >= 2, 'verifiers for r-p-nodelete'), 'no-delete: the Lean-passed object is put to a fidelity review')
1063
- const vs = unfiredVerifiers(RL, re).slice(0, 2)
1064
- if (vs.length === 2) {
1065
- for (const v of vs) firedChildren.add(v.childId)
1066
- assert(/发现任何偏差,不要投 0/.test(vs[0].prompt) && /本档没有门禁/.test(vs[0].prompt) && !/不定论/.test(vs[0].prompt),
1067
- '★ encourage: the fidelity text does NOT claim the framework will withhold the verdict (only require gates; §4.1 item 3)')
1068
- // A host whose shell cannot delete anything: no subprocess service at all. The withdrawal must
1069
- // therefore be observable as an OVERWRITE, not as a missing file.
1070
- subprocessAvailable = false
1071
- fireEnd(vs[0].childId, { Result: 0.5, Reason: '逐条核对后认定形式化不忠实', formal: { target: 'p-nodelete', decision: 'defect', note: 'Lean 里把自然数写成了整数' } })
1072
- await sleep(320)
1073
- subprocessAvailable = true
1074
- const after = readIf(proofFile)
1075
- assert(existsSync(proofFile) && !/theorem p_nodelete/.test(after),
1076
- '★ without a working delete the archived proof text is gone (overwritten, not left readable as a proof)')
1077
- assert(/已撤回(/.test(after) && /Formal\/p-nodelete\.lean/.test(after),
1078
- '★ and it carries the withdrawal notice pointing at the kept working file Formal/p-nodelete.lean')
1079
- const st = await callTool('vibe_math_status', {}, RL)
1080
- const rec = st.formal.objects.find((o) => o.target === 'p-nodelete')
1081
- assert(!!rec && rec.status === 'attempted' && rec.proof === '' && /自然数写成了整数/.test(rec.note || ''),
1082
- '★ the record is still downgraded to attempted with an emptied proof pointer')
1083
- const ann = readIf(join(nodeleteProj, 'Logs', '形式化.md'))
1084
- assert(/忠实性缺陷/.test(ann) && /就地覆盖/.test(ann),
1085
- '★ the announcement says the proof was OVERWRITTEN (not deleted), so the reader knows which withdrawal happened')
1086
- assert(ann.indexOf('不定论') === -1, '★ and `encourage` never claims the framework withheld the verdict')
1087
- const todo = readIf(join(nodeleteProj, 'Formal', 'TODO.md'))
1088
- assert(/# 形式化待办/.test(todo) && /p-nodelete/.test(todo) && !/定论被搁置/.test(todo) && /没有定论门禁/.test(todo),
1089
- '★ Formal/TODO.md states what this mode really does (it does not claim 定论被搁置)')
1090
- const idx = readIf(join(nodeleteProj, 'Formal', 'Index.md'))
1091
- assert(/## 形式化待办/.test(idx) && !/定论被搁置/.test(idx), '★ and Formal/Index.md mirrors that framing')
1092
- fireEnd(vs[1].childId, { Result: 0.5, Reason: '同意,形式化不忠实' })
1093
- await sleep(280)
1094
- }
1095
- }
1096
- await callTool('vibe_math_abort', {}, RL)
1097
-
1098
- // ===============================================================
1099
- // 8b-4. a defect on an ALREADY-verified object must not leave a stale card anchor
1100
- //
1101
- // `defect` withdraws the formalization (record → attempted, proof pointer cleared, archived file
1102
- // deleted/overwritten). A Verified card written earlier still says 「形式化: Lean 通过(Verified/
1103
- // Lean/<id>.lean)」 — a pointer to a proof that no longer exists. The gate must not prevent that
1104
- // refresh: the gate decides whether a NEW conclusion may be declared, not whether the framework may
1105
- // tell the truth about one it already declared.
1106
- // ===============================================================
1107
- section('8b-4 a later defect refreshes an existing Verified card instead of leaving a stale anchor')
1108
- const RM = makeRoot()
1109
- await callTool('vibe_math_new_project', { name: 'lean-stale-card' }, RM)
1110
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RM)
1111
- const staleProj = projRoot('lean-stale-card')
1112
- await callTool('vibe_math_add_proposition', { id: 'p-stale', 概述: '定论后才被认定形式化不忠实', 概率: 0.6, 分类: '数论' }, RM)
1113
- await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-stale', content: 'theorem p_stale : 2 + 2 = 4 := by decide\n' }, RM)
1114
- await callTool('vibe_math_start', {}, RM)
1115
- assert(await runVerifyRound(RM, 'p-stale', [1, 1]) !== null, 'stale-card: the Lean-passed proposition was put to a vote')
1116
- assert(await drive(RM, () => existsSync(join(staleProj, 'Verified', '命题', 'p-stale.md')), 'p-stale Verified card'), 'stale-card: the gate was satisfied, so it reaches a Verified card')
1117
- {
1118
- const card = readIf(join(staleProj, 'Verified', '命题', 'p-stale.md'))
1119
- assert(/- 形式化: Lean 通过/.test(card) && /Verified\/Lean\/p-stale\.lean/.test(card), 'stale-card: the card records the machine-checked strength and its proof path')
1120
- }
1121
- // A LATER work-round reply (e.g. a solver that reuses the object and finds the Lean statement too
1122
- // wide) reports the fidelity defect — long after the card was written.
1123
- await callTool('vibe_math_add_problem', { id: 'q-w', description: '让 explorer 起来以便回执一条 defect', priority: 0 }, RM)
1124
- await restart(RM)
1125
- assert(await drive(RM, () => !!lastSpawn(RM, 'explorer:q-w'), 'explorer:q-w'), 'stale-card: an explorer exists to carry the work-round reply')
1126
- fireEnd(lastSpawn(RM, 'explorer:q-w').childId, {
1127
- meta: { kind: 'directions', qid: 'q-w', formal: { target: 'p-stale', decision: 'defect', note: '这份形式化把结论写宽了' },
1128
- directions: [{ id: 'd1', title: '直接法', method: '', core_assumption: '', feasibility: 0.6 }] },
1129
- })
1130
- assert(await drive(RM, () => !/Lean 通过/.test(readIf(join(staleProj, 'Verified', '命题', 'p-stale.md'))), 'p-stale card refreshed'),
1131
- '★ a defect on an already-verified object refreshes the existing Verified card (no stale 「Lean 通过」 anchor pointing at a withdrawn proof)')
1132
- {
1133
- const card = readIf(join(staleProj, 'Verified', '命题', 'p-stale.md'))
1134
- assert(/- 形式化: 已尝试未通过/.test(card), '★ the refreshed card states the honest formal status (已尝试未通过)')
1135
- assert(!/Verified\/Lean\/p-stale\.lean/.test(card), '★ and no longer points at the withdrawn proof')
1136
- assert(/- 结论: 真/.test(card), 'the conclusion itself is untouched — a fidelity defect is not a refutation')
1137
- assert(!existsSync(join(staleProj, 'Verified', 'Lean', 'p-stale.lean')), 'and the archived proof really is gone')
1138
- assert(await drive(RM, () => /p-stale/.test(readIf(join(staleProj, 'Formal', 'TODO.md'))), 'p-stale formalization TODO'), 'the object is on the formalization TODO for redoing')
1139
- }
1140
- await callTool('vibe_math_abort', {}, RM)
1141
-
1142
- // ===============================================================
1143
- // 8c. the injected text obeys the five hard requirements of contract §6
1144
- // ===============================================================
1145
- section('8c the injected text uses full tool names, Result (not verdict) and the run-before-archive rule')
1146
- const RJ = makeRoot()
1147
- await callTool('vibe_math_new_project', { name: 'lean-workline' }, RJ)
1148
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RJ)
1149
- await callTool('vibe_math_add_problem', { id: 'q-defect', description: '顺手形式化的对象', priority: 0 }, RJ)
1150
- const workProj = projRoot('lean-workline')
1151
- // a `meta.formal` defect on the WORK-round path (absorbFormalFromReply) must downgrade too
1152
- await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'q-defect', content: 'theorem q_defect : 2 + 2 = 4 := by decide\n' }, RJ)
1153
- await callTool('vibe_math_start', {}, RJ)
1154
- assert(await drive(RJ, () => !!lastSpawn(RJ, 'explorer:q-defect'), 'explorer:q-defect'), 'the explorer for q-defect was spawned')
1155
- {
1156
- const p = lastSpawn(RJ, 'explorer:q-defect').prompt
1157
- assert(/归档前先跑通(vibe_math_lean_run 或 run=true);跑不通的定义不要进可复用库。/.test(p),
1158
- '★ the work-round prompt requires a GREEN run before archiving a reusable definition (§6 hard requirement 3)')
1159
- assert(/"decision":"used\|blocked\|defect"/.test(p), 'the work-round reply contract advertises the defect decision too')
1160
- assert(!/verdict/.test(p) && !/(^|[^a-z_])lean_(run|archive|lib)/.test(p),
1161
- '★ the work-round prompt uses FULL tool names only and never the v4/v5 field name `verdict`')
1162
- fireEnd(lastSpawn(RJ, 'explorer:q-defect').childId, {
1163
- meta: { kind: 'directions', qid: 'q-defect', formal: { target: 'q-defect', decision: 'defect', note: '陈述里的自然数范围被写成了整数' },
1164
- directions: [{ id: 'd1', title: '直接形式化', method: 'Lean', core_assumption: '', feasibility: 0.6 }] },
1165
- })
1166
- await sleep(260)
1167
- const st = await callTool('vibe_math_status', {}, RJ)
1168
- const rec = st.formal.objects.find((o) => o.target === 'q-defect')
1169
- assert(!!rec && rec.status === 'attempted' && /自然数范围被写成了整数/.test(rec.note || ''),
1170
- '★ a `meta.formal` defect from a WORK reply downgrades the record too (absorbFormalFromReply, not just the verifier path)')
1171
- assert(!existsSync(join(workProj, 'Verified', 'Lean', 'q-defect.lean')), '★ and its archived proof is deleted')
1172
- }
1173
- await callTool('vibe_math_abort', {}, RJ)
1174
-
1175
- section('8c-2 the fidelity branch reaches BOTH the review and the debate prompt, and names Result')
1176
- const RK = makeRoot()
1177
- await callTool('vibe_math_new_project', { name: 'lean-fidelity' }, RK)
1178
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage', debateMaxRounds: 2 }), RK)
1179
- await callTool('vibe_math_add_proposition', { id: 'p-fid', 概述: '忠实性审查措辞观察对象', 概率: 0.6, 分类: '数论' }, RK)
1180
- await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-fid', content: 'theorem p_fid : 2 + 2 = 4 := by decide\n' }, RK)
1181
- await callTool('vibe_math_start', {}, RK)
1182
- const fidBatch = await runVerifyRound(RK, 'p-fid', [0.9, 0.95])
1183
- assert(fidBatch !== null, 'fidelity: the review round was asked')
1184
- if (fidBatch) assertDistinctSlots(fidBatch, 'fidelity round')
1185
- if (fidBatch) {
1186
- const vp = fidBatch.map((s) => s.prompt).join('\n')
1187
- assert(/一致 → Result = 1/.test(vp), "★ the review prompt states the faithful case as `Result = 1` (v3's REAL reply field, not v4/v5's verdict)")
1188
- assert(/发现任何偏差,不要投 0/.test(vp) && /形式化不合格/.test(vp), '★ and forbids expressing a fidelity defect as 0')
1189
- assert(/formal:\{decision:'defect', note:'<具体偏差>'\}/.test(vp), 'and points at the defect reply field to record it')
1190
- assert(/独立于这份 Lean 代码/.test(vp), 'only an INDEPENDENT refutation may be voted 0')
1191
- assert(/Result/.test(vp) && !/verdict/.test(vp), '★ the voting prompt names Result, never verdict (§6 hard requirement 2)')
1192
- assert(vp.indexOf('偏离 → 0') === -1, '★ no "偏离 → 0" instruction anywhere in the fidelity branch (contract §10 item 9)')
1193
- }
1194
- assert(await drive(RK, () => wakes.some((w) => w.rootId === RK.id && /交流群/.test(w.prompt)), 'debate prompt'), 'a non-consensus fidelity round moved to the debate')
1195
- {
1196
- const dp = wakes.filter((w) => w.rootId === RK.id).map((w) => w.prompt).join('\n')
1197
- assert(/一致 → Result = 1/.test(dp) && /不要投 0/.test(dp), '★ the DEBATE prompt carries the same fidelity wording')
1198
- assert(/Result/.test(dp) && !/verdict/.test(dp), 'the debate prompt names Result, never verdict')
1199
- assert(dp.indexOf('偏离 → 0') === -1, 'the debate prompt also refuses "a deviation is a 0"')
1200
- }
1201
- await callTool('vibe_math_abort', {}, RK)
1202
-
1203
- // ===============================================================
1204
- // 9. reporting + persistence
1205
- // ===============================================================
1206
- // (d) a plain `used` judgement must NOT withdraw an ESTABLISHED proof (contract §4). Only a
1207
- // fidelity defect retracts a proof; `used` just reports "this round touched the formalization".
1208
- // v2 shipped the unconditional downgrade here and silently re-closed the require gate.
1209
- section('8d a \`used\` reply must NOT downgrade an already-passed object')
1210
- await restart(RG)
1211
- await callTool('vibe_math_add_proposition', { id: 'p-usedkeep', 概述: '已有通过证明后再写一次 used 回执', 概率: 0.6, 分类: '数论' }, RG)
1212
- {
1213
- const pass = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-usedkeep', content: 'theorem p_usedkeep : 2 + 2 = 4 := by decide\n' }, RG)
1214
- assert(pass.ok === true && pass.passed === true, 'used-keep: the object starts out Lean-passed')
1215
- const re = verifyRe('p-usedkeep')
1216
- assert(await drive(RG, () => unfiredVerifiers(RG, re).length >= 2, 'verifiers for r-p-usedkeep'), 'used-keep: the Lean-passed object is put to a fidelity review')
1217
- const vs = unfiredVerifiers(RG, re).slice(0, 2)
1218
- if (vs.length === 2) {
1219
- for (const v of vs) firedChildren.add(v.childId)
1220
- fireEnd(vs[0].childId, { Result: 0.5, Reason: '这一轮只是又写了一遍草稿', formal: { target: 'p-usedkeep', decision: 'used', file: 'Formal/p-usedkeep.lean' } })
1221
- await sleep(150)
1222
- const st = await callTool('vibe_math_status', {}, RG)
1223
- const o = st.formal.objects.find((x) => x.target === 'p-usedkeep')
1224
- assert(!!o && o.status === 'passed', '★ a `used` reply does NOT downgrade an already-passed object (got ' + JSON.stringify(o) + ')')
1225
- assert(st.formal.passed.indexOf('p-usedkeep') !== -1, '★ and status still reports it as Lean-passed (the fidelity branch stays the reviewers\' subject)')
1226
- assert(existsSync(join(replyProj, 'Verified', 'Lean', 'p-usedkeep.lean')), '★ and the archived proof is still on disk by that name')
1227
- fireEnd(vs[1].childId, { Result: 0.5, Reason: '同样只是又写了一遍草稿' })
1228
- await sleep(250)
1229
- assert(!/p-usedkeep/.test(readIf(join(replyProj, 'Formal', 'TODO.md')) || ''), '★ no formal-required TODO was created: the surviving passed still opens the require gate')
1230
- // The same rule covers the RUN path (contract §4): a run attributed to the object may not downgrade it.
1231
- const rerun = await callTool('vibe_math_lean_run', { file: 'Formal/p-usedkeep.lean', target: 'p-usedkeep' }, RG)
1232
- assert(rerun.ok === true, 'used-keep: the object work file runs green (the record assertion below is the point)')
1233
- const o2 = (await callTool('vibe_math_status', {}, RG)).formal.objects.find((x) => x.target === 'p-usedkeep')
1234
- assert(!!o2 && o2.status === 'passed', '★ and the record is still passed')
1235
- }
1236
- }
1237
-
1238
- // (e) the same rule for `blocked`: a blocker is a gate-opening record, so a later `used` reply
1239
- // must not push the object back to `attempted` and silently re-close the gate (v3 used to preserve
1240
- // only `passed` here — v2/v4/v5 preserve both).
1241
- await restart(RG)
1242
- await callTool('vibe_math_add_proposition', { id: 'p-usedblocked', 概述: '已记录阻塞后再写一次 used 回执', 概率: 0.6, 分类: '数论' }, RG)
1243
- {
1244
- const blk = await callTool('vibe_math_lean_archive', { kind: 'blocked', target: 'p-usedblocked', note: '需要大量未形式化的实分析前置知识' }, RG)
1245
- assert(blk.ok === true && blk.status === 'blocked', 'used-blocked: the object starts with an explicit blocker (the gate is open)')
1246
- const re = verifyRe('p-usedblocked')
1247
- assert(await drive(RG, () => unfiredVerifiers(RG, re).length >= 2, 'verifiers for r-p-usedblocked'), 'used-blocked: the object is put to a vote')
1248
- const vs = unfiredVerifiers(RG, re).slice(0, 2)
1249
- if (vs.length === 2) {
1250
- for (const v of vs) firedChildren.add(v.childId)
1251
- fireEnd(vs[0].childId, { Result: 0.5, Reason: '这一轮只是又写了一遍草稿', formal: { target: 'p-usedblocked', decision: 'used', file: 'Formal/p-usedblocked.lean' } })
1252
- await sleep(150)
1253
- const o = (await callTool('vibe_math_status', {}, RG)).formal.objects.find((x) => x.target === 'p-usedblocked')
1254
- assert(!!o && o.status === 'blocked', '★ a `used` reply does not re-close a gate an explicit blocker opened (got ' + JSON.stringify(o) + ')')
1255
- fireEnd(vs[1].childId, { Result: 0.5, Reason: '同上' })
1256
- await sleep(200)
1257
- }
1258
- }
1259
-
1260
- section('9 the office can audit formal strength')
1261
- {
1262
- const rep = await callTool('vibe_math_report', {}, RF)
1263
- assert(rep.formal && rep.formal.mode === 'require', 'the JSON report carries the formal mode')
1264
- assert(rep.formal.passed.indexOf('p-gate') !== -1 && rep.formal.blocked.indexOf('p-blocked-ok') !== -1, 'the JSON report lists both passed and blocked objects')
1265
- assert(rep.params.formalVerify === 'require' && rep.params.leanCommand === 'lean', 'the readable parameter table carries the Lean parameters')
1266
- const md = readIf(join(gateProj, 'Logs', '报告.md'))
1267
- assert(/## Lean 形式化/.test(md), 'the human-readable report has a Lean formal-verification section')
1268
- assert(/已通过:.*p-gate/.test(md), 'it lists Lean-passed objects')
1269
- assert(/已记录阻塞:.*p-blocked-ok/.test(md), 'it lists blocked objects')
1270
- assert(/可复用库:VibeMath\/Formal\/\{Lib,Proved\}\//.test(md), 'it documents the path layout')
1271
- await callTool('vibe_math_report', {}, RA)
1272
- assert(/未启用(`formalVerify` = off/.test(readIf(join(offProj, 'Logs', '报告.md'))), 'in off mode the report says the feature is not enabled')
1273
- const offRep = await callTool('vibe_math_report', {}, RA)
1274
- assert(offRep.formal.mode === 'off' && /off/.test(offRep.formal.note || ''), 'and the JSON report says the same')
1275
- }
1276
- {
1277
- const before = JSON.parse(readIf(join(gateProj, 'State', 'formal.json')))
1278
- assert(!!before && !!before.records && before.records['p-gate'] && before.records['p-gate'].status === 'passed', 'the formal record is persisted in State/formal.json, keyed by object id')
1279
- assert(Array.isArray(before.todo), 'the formalization TODO is persisted alongside it')
1280
- assert(before.records['p-blocked-ok'].status === 'blocked' && /分析学/.test(before.records['p-blocked-ok'].note), 'a blocker record keeps its reason across sessions')
1281
- assert(existsSync(join(VIBE, 'Formal', 'Proved', 'Index.md')) && existsSync(join(libPath, 'Index.md')), 'both global indexes are framework-maintained')
1282
- }
1283
-
1284
- // ===============================================================
1285
- // 9b. Lean 增量 + 异步(spec §1–§4 + 修订 §1–§5):与 v2 同一套断言强度
1286
- // ===============================================================
1287
- section('9b Lean 增量 + 异步:参数/argv/队列/去重/只读面/主动性/恢复/卸载')
1288
- {
1289
- const H = mod.__testHelpers
1290
- const slash = (s) => String(s == null ? '' : s).replace(/\\/g, '/')
1291
- const SAME = (a, b) => slash(a) === slash(b)
1292
- const formalOf2 = (proj) => JSON.parse(readIf(join(projRoot(proj), 'State', 'formal.json')) || '{}')
1293
- const jobFile = (proj, id) => join(projRoot(proj), 'Formal', 'Jobs', id + '.json')
1294
- const readJob = (proj, id) => JSON.parse(readIf(jobFile(proj, id)) || '{}')
1295
- const vibeCmd = async (rawInput, root) => JSON.parse(await cmdRegs[0].handler({ rawInput, agent: root }))
1296
- const findJob = async (proj, id, states, tries = 100, ms = 60) => {
1297
- for (let i = 0; i < tries; i++) { const r = readJob(proj, id); if (r.state && states.indexOf(r.state) !== -1) return r; await sleep(ms) }
1298
- return undefined
1299
- }
1300
-
1301
- // ── 参数:默认值/归一化(字符串 'false' 绝不能滑过去)────────────────────────────
1302
- const rp = makeRoot()
1303
- await callTool('vibe_math_new_project', { name: 'lean-async-params' }, rp)
1304
- await callTool('vibe_math_set_params', { leanAsync: true }, rp)
1305
- const bad = await callTool('vibe_math_set_params', { leanAsync: 'false' }, rp)
1306
- assert(bad.params.leanAsync === true, '★★★ leanAsync="false" (a STRING) does not slip through the `else out[k]=v` tail: it falls back to the boolean default')
1307
- const wp = await callTool('vibe_math_set_params', { leanInitiative: 'banana', leanJobsMaxParallel: 0, leanSearchPaths: 'not-an-array' }, rp)
1308
- assert(wp.params.leanInitiative === 'normal', 'leanInitiative rejects an unknown mode back to normal')
1309
- assert(wp.params.leanJobsMaxParallel === 1, 'leanJobsMaxParallel floors at 1 (never 0)')
1310
- assert(Array.isArray(wp.params.leanSearchPaths) && wp.params.leanSearchPaths.length === 0, 'leanSearchPaths rejects a non-array back to []')
1311
- const dd = await callTool('vibe_math_set_params', { leanSearchPaths: [' /opt/lean-lib ', '/opt/lean-lib', ''] }, rp)
1312
- assert(JSON.stringify(dd.params.leanSearchPaths) === JSON.stringify(['/opt/lean-lib']), 'leanSearchPaths trims, drops empties and de-duplicates')
1313
-
1314
- // ── 纯函数:搜索路径计划(修订 §2)与指纹(修订 §4)──────────────────────────────
1315
- const planDefault = H.leanSearchPathPlan([], [], '/root/vm')
1316
- assert(JSON.stringify(planDefault.paths) === JSON.stringify(['/root/vm']) && planDefault.inject.length === 2, '★ the default plan injects exactly the automatic root')
1317
- const planExtra = H.leanSearchPathPlan(['env', 'lean'], ['/a', '/b'], '/root/vm')
1318
- assert(JSON.stringify(planExtra.paths) === JSON.stringify(['/a', '/b', '/root/vm']), '★★ user leanSearchPaths come FIRST, then the automatic root')
1319
- assert(H.leanSearchPathPlan(['--search-path', '/x'], ['/a'], '/root/vm').inject.length === 0 && H.leanSearchPathPlan(['-R', '/x'], ['/a'], '/root/vm').inject.length === 0 && H.leanSearchPathPlan(['--root', '/x'], ['/a'], '/root/vm').inject.length === 0, '★ an explicit --search-path/-R/--root in leanArgs suppresses all injection (user wins)')
1320
- const ctxA = H.leanBuildContext('lean', ['a'], ['/p'])
1321
- const ctxB = H.leanBuildContext('lean', ['b'], ['/p'])
1322
- assert(H.leanJobFingerprint('x\n', ctxA) !== H.leanJobFingerprint('x\n', ctxB), '★ the job fingerprint covers the build context (engine/args/search paths)')
1323
-
1324
- // ── argv:注入在用户参数之后、文件名之前 ─────────────────────────────────────────
1325
- const ra = makeRoot()
1326
- await callTool('vibe_math_new_project', { name: 'lean-async-argv' }, ra)
1327
- await callTool('vibe_math_set_params', { leanAsync: false, leanCommand: 'lake', leanArgs: ['env', 'lean'] }, ra)
1328
- const projA = projRoot('lean-async-argv')
1329
- mkdirSync(join(projA, 'Formal'), { recursive: true })
1330
- writeFileSync(join(projA, 'Formal', 'srch.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1331
- const runs0 = leanRuns.length
1332
- await callTool('vibe_math_lean_run', { file: 'Formal/srch.lean' }, ra)
1333
- const argv1 = leanRuns[leanRuns.length - 1].argv
1334
- assert(leanRuns.length === runs0 + 1 && JSON.stringify(argv1.slice(0, 3)) === JSON.stringify(['lake', 'env', 'lean']), 'the user leanArgs are preserved in order (lake env lean)')
1335
- assert(argv1[argv1.length - 2] === '--search-path' && SAME(argv1[argv1.length - 1], VIBE), '★★ --search-path + the VibeMath root are injected AFTER the user args and immediately BEFORE the file (' + JSON.stringify(argv1) + ')')
1336
- await callTool('vibe_math_set_params', { leanArgs: ['env', 'lean', '--search-path', '/explicit'] }, ra)
1337
- await callTool('vibe_math_lean_run', { file: 'Formal/srch.lean' }, ra)
1338
- const argv2 = leanRuns[leanRuns.length - 1].argv
1339
- assert(argv2.filter((x) => x === '--search-path').length === 1 && argv2[4] === '/explicit', '★ an explicit --search-path is respected verbatim (no second injection) — got ' + JSON.stringify(argv2))
1340
- await callTool('vibe_math_set_params', { leanArgs: ['env', 'lean'], leanSearchPaths: ['/extra1', '/extra2'] }, ra)
1341
- await callTool('vibe_math_lean_run', { file: 'Formal/srch.lean' }, ra)
1342
- const argv3 = leanRuns[leanRuns.length - 1].argv
1343
- assert(JSON.stringify(argv3.slice(-6)) === JSON.stringify(['--search-path', '/extra1', '--search-path', '/extra2', '--search-path', slash(VIBE)]), '★★ leanSearchPaths are injected first, then the automatic root (got ' + JSON.stringify(argv3.slice(-6)) + ')')
1344
-
1345
- // ── 异步状态机:入队立即返回 → 心跳排空 → 只有 settled(ok) 才是通过 ───────────────
1346
- const rq = makeRoot()
1347
- await callTool('vibe_math_new_project', { name: 'lean-async-q' }, rq)
1348
- await callTool('vibe_math_set_params', { leanAsync: true }, rq)
1349
- const projQ = projRoot('lean-async-q')
1350
- mkdirSync(join(projQ, 'Formal'), { recursive: true })
1351
- writeFileSync(join(projQ, 'Formal', 'async-good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1352
- writeFileSync(join(projQ, 'Formal', 'async-bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
1353
- const nBefore = leanRuns.length
1354
- const q1 = await callTool('vibe_math_lean_run', { file: 'Formal/async-good.lean', target: 'pAsync' }, rq)
1355
- assert(q1.ok === true && !!q1.async && q1.async.state === 'queued' && typeof q1.async.jobId === 'string', '★★ leanAsync=true returns IMMEDIATELY with {async:{jobId,state:queued}} (got ' + JSON.stringify(q1.async) + ')')
1356
- assert(leanRuns.length === nBefore, '★★ the tool call itself did NOT block on the compiler (enqueued, not run)')
1357
- assert(q1.async.jobId.indexOf('pAsync-') === 0, 'the jobId is <target>-<fingerprint[0:12]> (got ' + q1.async.jobId + ')')
1358
- assert(existsSync(jobFile('lean-async-q', q1.async.jobId)), 'the job record is mirrored to <project>/Formal/Jobs/<jobId>.json')
1359
- const rec0 = readJob('lean-async-q', q1.async.jobId)
1360
- assert(rec0.state === 'queued' && typeof rec0.sha256 === 'string' && typeof rec0.buildSha256 === 'string' && String(rec0.buildContext).length > 0, '★ the job record carries state + content hash + build-context hash')
1361
- assert((formalOf2('lean-async-q').records || {}).pAsync === undefined || formalOf2('lean-async-q').records.pAsync.status !== 'passed', '★★ a queued job never marks the object passed')
1362
- const done1 = await findJob('lean-async-q', q1.async.jobId, ['settled'])
1363
- assert(!!done1, 'the apply-level heartbeat drained the queue and settled the job (got ' + readJob('lean-async-q', q1.async.jobId).state + ')')
1364
- assert(done1 && done1.exitCode === 0, 'the settled record keeps exitCode 0')
1365
- const recAfter = (formalOf2('lean-async-q').records || {}).pAsync
1366
- assert(!!recAfter && recAfter.status === 'attempted' && recAfter.run && recAfter.run.ok === true, 'a green RUN lands as attempted with run.ok=true')
1367
- assert(!!recAfter.async && recAfter.async.state === 'settled' && recAfter.async.jobId === q1.async.jobId, 'the object record carries the async job pointer/state')
1368
- const q2 = await callTool('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' }, rq)
1369
- const done2 = await findJob('lean-async-q', q2.async.jobId, ['failed'])
1370
- assert(!!done2 && done2.exitCode === 1, '★ a red async run settles as failed with exitCode 1')
1371
- const recBad = (formalOf2('lean-async-q').records || {}).pAsyncBad
1372
- assert(!!recBad && recBad.status === 'attempted' && recBad.run.ok === false && /sorry/.test(String(recBad.run.stderrTail || '')), '★ a red async run is recorded as attempted with the compiler output kept (never passed)')
1373
- // 归档证明:落地前不是 passed,落地 ok 才写 Verified/Lean/
1374
- const q3 = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'pAsymProof', content: 'theorem pAsymProof : 1 = 1 := rfl\n' }, rq)
1375
- assert(q3.ok === true && q3.async && q3.async.state === 'queued' && q3.status === 'attempted', '★★ kind=proof in async档 returns queued + attempted (NOT passed)')
1376
- assert(!existsSync(join(projQ, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ the archived proof file does NOT exist while the job is still queued')
1377
- const done3 = await findJob('lean-async-q', q3.async.jobId, ['settled', 'failed'])
1378
- assert(!!done3 && done3.state === 'settled', 'the proof job settled green (got ' + (done3 && done3.state) + ')')
1379
- assert(existsSync(join(projQ, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ only after settled(ok) is Verified/Lean/<id>.lean written')
1380
- assert(((formalOf2('lean-async-q').records || {}).pAsymProof || {}).status === 'passed', '★★ and only then does the object become passed')
1381
- assert(/pAsymProof/.test(readIf(join(projQ, 'Formal', 'Index.md'))), 'the index reflects the passed object')
1382
- // ── lean_job(修订 §3):清单/单查/waitMs ────────────────────────────────────────
1383
- const listJobs = await callTool('vibe_math_lean_job', {}, rq)
1384
- assert(listJobs.ok === true && Array.isArray(listJobs.jobs) && listJobs.jobs.length >= 3, '★ lean_job without jobId returns the session job list')
1385
- assert(listJobs.jobs.every((j) => !!j.paths && typeof j.paths.receipt === 'string'), 'every listed job carries its receipt path')
1386
- const one = await callTool('vibe_math_lean_job', { jobId: q3.async.jobId }, rq)
1387
- 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')
1388
- assert((await callTool('vibe_math_lean_job', { jobId: 'nope-000000000000' }, rq)).code === 'V3_NOT_FOUND', 'an unknown jobId is refused with a typed code')
1389
- const q4 = await callTool('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' }, rq)
1390
- const waited = await callTool('vibe_math_lean_job', { jobId: q4.async.jobId, waitMs: 6000 }, rq)
1391
- 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 + ')')
1392
- assert((await callTool('vibe_math_lean_lib', {}, rq)).paths.searchPath === VIBE.replace(/\\/g, '/'), '★ lean_lib exposes paths.searchPath = the VibeMath root')
1393
- assert(Array.isArray((await callTool('vibe_math_lean_lib', {}, rq)).jobs), '★ lean_lib exposes the job list')
1394
-
1395
- // ── 超时:leanTimeoutMs 是每个作业的预算,主动 terminate,绝不 passed ─────────────
1396
- const rt = makeRoot()
1397
- await callTool('vibe_math_new_project', { name: 'lean-async-timeout' }, rt)
1398
- await callTool('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 1000 }, rt)
1399
- const projT = projRoot('lean-async-timeout')
1400
- mkdirSync(join(projT, 'Formal'), { recursive: true })
1401
- writeFileSync(join(projT, 'Formal', 'hang.lean'), '-- HANG\ntheorem t : 1 = 1 := rfl\n', 'utf8')
1402
- const termBefore = terminated.length
1403
- const q5 = await callTool('vibe_math_lean_run', { file: 'Formal/hang.lean', target: 'pHang' }, rt)
1404
- const done5 = await findJob('lean-async-timeout', q5.async.jobId, ['timeout'], 150)
1405
- assert(!!done5 && done5.timedOut === true, '★★ a hanging job times out (state=timeout) with leanTimeoutMs as its budget')
1406
- assert(terminated.length > termBefore, '★★ the timeout ACTIVELY called handle.terminate()')
1407
- assert(((formalOf2('lean-async-timeout').records || {}).pHang || {}).status !== 'passed', '★ a timed-out job never sets passed')
1408
-
1409
- // ── 并发参数(修订 §5):leanJobsMaxParallel>1 时两个作业同时进入 running ──────────
1410
- const rc = makeRoot()
1411
- await callTool('vibe_math_new_project', { name: 'lean-async-par' }, rc)
1412
- await callTool('vibe_math_set_params', { leanAsync: true, leanJobsMaxParallel: 2, leanTimeoutMs: 2500 }, rc)
1413
- const projC = projRoot('lean-async-par')
1414
- mkdirSync(join(projC, 'Formal'), { recursive: true })
1415
- writeFileSync(join(projC, 'Formal', 'h1.lean'), '-- HANG\ntheorem h1 : 1 = 1 := rfl\n', 'utf8')
1416
- writeFileSync(join(projC, 'Formal', 'h2.lean'), '-- HANG\ntheorem h2 : 1 = 1 := rfl\n', 'utf8')
1417
- const j1 = await callTool('vibe_math_lean_run', { file: 'Formal/h1.lean', target: 'pH1' }, rc)
1418
- const j2 = await callTool('vibe_math_lean_run', { file: 'Formal/h2.lean', target: 'pH2' }, rc)
1419
- let both = false
1420
- for (let i = 0; i < 120 && !both; i++) {
1421
- const a = readJob('lean-async-par', j1.async.jobId)
1422
- const b = readJob('lean-async-par', j2.async.jobId)
1423
- both = (a.state === 'running' && b.state === 'running')
1424
- if (!both) await sleep(50)
1425
- }
1426
- assert(both, '★★ with leanJobsMaxParallel=2 BOTH queued jobs are in running at the same time (concurrency is a parameter, not a constant)')
1427
- for (const k of [j1, j2]) { try { await callTool('vibe_math_lean_job', { jobId: k.async.jobId, waitMs: 3000 }, rc) } catch (e) { /* ignore */ } }
1428
-
1429
- // ── 去重(§4.3)+ 构建上下文进指纹(修订 §4)─────────────────────────────────────
1430
- const rd = makeRoot()
1431
- await callTool('vibe_math_new_project', { name: 'lean-async-dedupe' }, rd)
1432
- await callTool('vibe_math_set_params', { leanAsync: true, leanCommand: 'lake', leanArgs: ['env', 'lean'] }, rd)
1433
- const defBody = 'def reusableOne : Nat := 1\n'
1434
- const r1 = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody }, rd)
1435
- assert(r1.ok === true && r1.async && r1.deduped === false, 'the first archive of a definition is enqueued (not deduped)')
1436
- await findJob('lean-async-dedupe', r1.async.jobId, ['settled'])
1437
- const runsAfter1 = leanRuns.length
1438
- const r2 = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody }, rd)
1439
- assert(r2.ok === true && r2.deduped === true && r2.async === undefined, '★★★ re-archiving identical content is DEDUPED (no job, no recompile)')
1440
- assert(leanRuns.length === runsAfter1, '★★ the deduped archive did NOT recompile')
1441
- await callTool('vibe_math_set_params', { leanArgs: ['env', 'lean', '--threads=2'] }, rd)
1442
- const r5 = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody }, rd)
1443
- 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 gets a different jobId (not treated as already-verified)')
1444
- if (r5.async) await findJob('lean-async-dedupe', r5.async.jobId, ['settled', 'failed'])
1445
- await callTool('vibe_math_set_params', { leanArgs: ['env', 'lean'] }, rd)
1446
- const changed = defBody + '\n-- 内容变了\n'
1447
- const r3 = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed }, rd)
1448
- assert(r3.ok === true && r3.deduped === false && !!r3.async, 'a changed body is NOT deduped (content hash differs)')
1449
- if (r3.async) await findJob('lean-async-dedupe', r3.async.jobId, ['settled', 'failed'])
1450
- const r6 = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed }, rd)
1451
- assert(r6.ok === true && r6.deduped === true, 'and the changed body dedupes on its own (content-based, not "first wins")')
1452
-
1453
- // ── 崩溃恢复(§2.6 + 修订 §4):绝不置 passed ────────────────────────────────────
1454
- const rr = makeRoot()
1455
- await callTool('vibe_math_new_project', { name: 'lean-async-recover' }, rr)
1456
- await callTool('vibe_math_set_params', { leanAsync: true }, rr)
1457
- const projR = projRoot('lean-async-recover')
1458
- mkdirSync(join(projR, 'Formal', 'Jobs'), { recursive: true })
1459
- const goodBody = 'theorem recGood : 1 = 1 := rfl\n'
1460
- writeFileSync(join(projR, 'Formal', 'recGood.lean'), goodBody, 'utf8')
1461
- const ctxG = H.leanBuildContext('lean', [], [slash(VIBE)])
1462
- const fpG = H.leanJobFingerprint(goodBody, ctxG)
1463
- const jobIdG = H.leanJobId('pRecGood', fpG)
1464
- writeFileSync(jobFile('lean-async-recover', jobIdG), JSON.stringify({
1465
- jobId: jobIdG, kind: 'archive', state: 'running', attempts: 1, target: 'pRecGood', name: 'pRecGood',
1466
- rel: 'Formal/recGood.lean', scope: 'project', sha256: H.leanContentSha(goodBody), buildSha256: fpG, buildContext: ctxG,
1467
- engine: 'lean', argv: [], searchPaths: [slash(VIBE)], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1468
- }), 'utf8')
1469
- const otherJob = H.leanJobId('pRecOther', H.leanJobFingerprint('theorem other : 1 = 1 := rfl\n', ctxG))
1470
- writeFileSync(jobFile('lean-async-recover', otherJob), JSON.stringify({
1471
- jobId: otherJob, kind: 'archive', state: 'running', attempts: 3, target: 'pRecOther', name: 'pRecOther',
1472
- rel: 'Formal/recMissing.lean', scope: 'project', sha256: H.leanContentSha('theorem other : 1 = 1 := rfl\n'), buildSha256: 'ffffffffffff', buildContext: ctxG,
1473
- engine: 'lean', argv: [], searchPaths: [slash(VIBE)], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1474
- }), 'utf8')
1475
- await callTool('vibe_math_new_project', { name: 'lean-async-recover' }, rr) // setProject ⇒ recoverLeanJobs()
1476
- const recsR = formalOf2('lean-async-recover').records || {}
1477
- assert((recsR.pRecGood || {}).status === 'attempted', '★★ recovery marks a crashed running job as attempted (never passed)')
1478
- assert((recsR.pRecOther || {}).status === 'attempted', 'recovery marks the hash-mismatched job object as attempted too')
1479
- assert(!existsSync(join(projR, 'Verified', 'Lean', 'pRecGood.lean')), '★ recovery never writes an archived proof for a crashed job')
1480
- const afterG = readJob('lean-async-recover', jobIdG)
1481
- assert(Number(afterG.attempts) === 2 && ['queued', 'running', 'settled'].indexOf(afterG.state) !== -1, '★★ the matching-hash job was re-enqueued with attempts+1 (got ' + JSON.stringify({ state: afterG.state, attempts: afterG.attempts }) + ')')
1482
- const afterO = readJob('lean-async-recover', otherJob)
1483
- assert(afterO.state === 'interrupted' && Number(afterO.attempts) === 3, '★★ the mismatched job is only marked interrupted (NOT re-driven, attempts unchanged)')
1484
- assert(!!(await findJob('lean-async-recover', jobIdG, ['settled'])), 'the re-enqueued job then settles normally on the heartbeat')
1485
-
1486
- // ── lean_read(§1.2):只读、路径守卫、64KB 截断 ─────────────────────────────────
1487
- mkdirSync(join(VIBE, 'Formal', 'Lib'), { recursive: true })
1488
- writeFileSync(join(VIBE, 'Formal', 'Lib', 'recGood.lean'), goodBody, 'utf8')
1489
- const rd1 = await callTool('vibe_math_lean_read', { name: 'recGood' }, rr)
1490
- assert(rd1.ok === true && rd1.kind === 'lib' && rd1.sha256 === H.leanContentSha(goodBody) && rd1.truncated === false && rd1.text === goodBody, '★ lean_read returns the archived text + sha256 + bytes for a Lib file')
1491
- assert(rd1.bytes === Buffer.byteLength(goodBody, 'utf8'), 'lean_read reports the byte length (keys=' + Object.keys(rd1).join(',') + ' got ' + JSON.stringify(rd1.bytes) + ' want ' + Buffer.byteLength(goodBody, 'utf8') + ')')
1492
- assert((await callTool('vibe_math_lean_read', { name: 'recGood', kind: 'proved' }, rr)).code === 'V3_NOT_FOUND', 'kind=proved looks only in Formal/Proved')
1493
- for (const badName of ['../etc/passwd', 'a/b', 'C:\\x\\y', '..']) {
1494
- const rbad = await callTool('vibe_math_lean_read', { name: badName }, rr)
1495
- assert(rbad.ok === false && rbad.code === 'V3_INVALID_ARGUMENT', '★ lean_read rejects the path-escape attempt ' + JSON.stringify(badName))
1496
- }
1497
- writeFileSync(join(VIBE, 'Formal', 'Lib', 'bigOne.lean'), '-- ' + 'x'.repeat(70 * 1024) + '\n', 'utf8')
1498
- const rbig = await callTool('vibe_math_lean_read', { name: 'bigOne' }, rr)
1499
- assert(rbig.ok === true && rbig.truncated === true && Buffer.byteLength(rbig.text, 'utf8') <= 64 * 1024, '★ lean_read truncates at 64KB and says so (truncated=true)')
1500
- writeFileSync(join(VIBE, 'Formal', 'Lib', 'depUser.lean'), 'import Formal.Lib.bigOne\n\ndef depUser := 1\n', 'utf8')
1501
- await callTool('vibe_math_lean_lib', {}, rr)
1502
- const libIdx = readIf(join(VIBE, 'Formal', 'Lib', 'Index.md'))
1503
- assert(/Formal\.Lib\.bigOne/.test(libIdx) || /bigOne/.test(libIdx), '★★ the Lib index carries the dependency scanned from import lines (idx=' + String(libIdx).slice(0, 500).replace(/\n/g, ' | ') + ')')
1504
-
1505
- // ── 主动性档位(修订 §1 + v5 统一):提示词读 leanInitiative,不是 formalVerify ─────────
1506
- // 第 4 项为 null = "日线完全不出现";第 6 项是该行的 formalVerify 档(默认 encourage)。
1507
- for (const [mode, rootName, projName, want, notWant, fv] of [
1508
- ['normal', 'lean-init-normal', 'lean-init-normal-proj', /主动性 normal/, null, 'encourage'],
1509
- ['eager', 'lean-init-eager', 'lean-init-eager-proj', /主动档(leanInitiative=eager)/, null, 'encourage'],
1510
- ['off', 'lean-init-off', 'lean-init-off-proj', null, /顺手形式化/, 'encourage'],
1511
- // formalVerify:'off' + eager:日线仍要注入(主动性是独立轴),验证阶段文本仍关闭。
1512
- ['eager', 'lean-init-off-eager', 'lean-init-off-eager-proj', /主动档(leanInitiative=eager)/, /【Lean 形式化验证(/, 'off'],
1513
- ]) {
1514
- const rw = makeRoot()
1515
- await callTool('vibe_math_new_project', { name: projName }, rw)
1516
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: fv || 'encourage', leanInitiative: mode }), rw)
1517
- await callTool('vibe_math_add_problem', { id: 'qInit' + mode + '-' + (fv || 'e'), 陈述: '主动性提示词 ' + mode }, rw)
1518
- await callTool('vibe_math_start', {}, rw)
1519
- const got = await drive(rw, () => spawnOf(rw, 'explorer:').length >= 1 || spawnOf(rw, 'solver:').length >= 1, 'work prompt for ' + mode, 12000)
1520
- const text = promptText(rw)
1521
- const ok4 = want ? want.test(text) : !/顺手形式化/.test(text)
1522
- assert(got && ok4, '★★ leanInitiative=' + mode + (fv === 'off' ? ' (formalVerify:off)' : '') + ' is reflected in the work prompt' + (want ? '' : ':off ⇒ 连"顺手形式化"日线都不出现'))
1523
- if (notWant) assert(!notWant.test(text), 'and the ' + mode + (fv === 'off' ? '/off' : '') + ' work prompt does not carry the verification-phase Lean block (验证要求仍由 formalVerify 表达)')
1524
- if (fv === 'off') assert(text.indexOf('三条筛选判据') !== -1, '★★★ formalVerify:off + eager 仍带完整日线(三条筛选判据在),不是被 off 静默吞掉')
1525
- await callTool('vibe_math_pause', {}, rw)
1526
- }
1527
-
1528
- // ── 卸载(spec §2.5):terminate + interrupted,绝不 passed ──────────────────────
1529
- const rz = makeRoot()
1530
- await callTool('vibe_math_new_project', { name: 'lean-async-dispose' }, rz)
1531
- await callTool('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 60000 }, rz)
1532
- const projZ = projRoot('lean-async-dispose')
1533
- mkdirSync(join(projZ, 'Formal'), { recursive: true })
1534
- writeFileSync(join(projZ, 'Formal', 'orphan.lean'), '-- HANG\ntheorem orphan : 1 = 1 := rfl\n', 'utf8')
1535
- const termZ = terminated.length
1536
- const qz = await callTool('vibe_math_lean_run', { file: 'Formal/orphan.lean', target: 'pOrphan' }, rz)
1537
- const startedZ = await findJob('lean-async-dispose', qz.async.jobId, ['running'])
1538
- assert(!!startedZ, 'the dispose case got a job into running')
1539
- for (const off of disposers) { try { off() } catch (e) { /* ignore */ } }
1540
- assert(terminated.length > termZ, '★★ unmounting the plugin terminated the in-flight compile (no orphan process)')
1541
- assert(((formalOf2('lean-async-dispose').records || {}).pOrphan || {}).status !== 'passed', '★ dispose never marks the object passed')
1542
- }
1543
-
1544
- // ===============================================================
1545
- // 10. interaction corpus (AUDIT-CHECKLIST §2.4) — a HUMAN must be able to re-read
1546
- // every prompt the framework emitted, not just the assertions about them.
1547
- // ===============================================================
1548
- // ===============================================================
1549
- // 5b. round-2 lens-1 RUNTIME: the initiative axis at EVERY v3 site
1550
- // ===============================================================
1551
- // Four sites: the three formalDailySection() call sites (explorer / rederive / solver) plus the
1552
- // INLINE formalWorkLine() site inside methodKeeperPrompt. (1) formalVerify:'off' + leanInitiative:
1553
- // 'eager' must carry the daily line at that site; (2) leanInitiative:'off' must drop it there while
1554
- // the verification phase still follows formalVerify. Names carry the site so a single-site
1555
- // regression names itself.
1556
- const SITE_DAILY = /三条筛选判据/
1557
- const SITE_VERIFY = /【Lean 形式化验证(/
1558
- async function driveSiteV3(rw, opts, qid) {
1559
- const q = qid
1560
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { methodKeepEvery: 1, maxExplorerRetries: 5 }, opts), rw)
1561
- await callTool('vibe_math_add_problem', { id: q, 陈述: '站点级运行时用例' }, rw)
1562
- await callTool('vibe_math_start', {}, rw)
1563
- const exs = () => spawnOf(rw, 'explorer:' + q)
1564
- await drive(rw, () => exs().length >= 1, 'explorer site ' + q)
1565
- const out = { explorer: (exs()[0] || {}).prompt || '' }
1566
- // answer explorers with directions until the solver appears (v3's fallback scheduler re-derives)
1567
- fireEnd(exs()[0].childId, { meta: { kind: 'directions', qid: q, directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: '', feasibility: 0.8 }] } })
1568
- for (let guard = 0; guard < 30 && !lastSpawn(rw, 'solver:' + q + ':d1'); guard++) {
1569
- const pend = exs().filter((s) => !firedChildren.has(s.childId)).slice(-1)[0]
1570
- if (pend) { firedChildren.add(pend.childId); fireEnd(pend.childId, { meta: { kind: 'directions', qid: q, directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: '', feasibility: 0.8 }] } }) }
1571
- await pump(rw); await sleep(80)
1572
- }
1573
- const so = lastSpawn(rw, 'solver:' + q + ':d1')
1574
- out.solver = (so && so.prompt) || ''
1575
- // dead-end + a pending invention: the direction is exhausted (⇒ re-derive) AND the method keeper fires
1576
- if (so) fireEnd(so.childId, { status: 'dead-end', survival_probability: 0.2, dead_end_reason: '站点用例:不可行', new_inventions: [{ 类型: '工具', 标题: '站点工具', 内容描述: 'x', 是否已入库: false }] })
1577
- await drive(rw, () => exs().length >= 2, 'rederive site ' + q)
1578
- out.rederive = (exs()[1] || {}).prompt || ''
1579
- out.distinct = !!out.explorer && !!out.rederive && out.explorer !== out.rederive
1580
- await drive(rw, () => !!lastSpawn(rw, 'method-keeper'), 'method-keeper site ' + q)
1581
- out.methodKeeper = (lastSpawn(rw, 'method-keeper') || {}).prompt || ''
1582
- await callTool('vibe_math_abort', {}, rw)
1583
- return out
1584
- }
1585
- {
1586
- const re = makeRoot()
1587
- await callTool('vibe_math_new_project', { name: 'site-eager-off' }, re)
1588
- const A = await driveSiteV3(re, { formalVerify: 'off', leanInitiative: 'eager' }, 'qS')
1589
- assert(SITE_DAILY.test(A.explorer), '★ [explorer site] formalVerify:off + leanInitiative:eager injects the daily line')
1590
- assert(SITE_DAILY.test(A.solver), '★ [solver site] formalVerify:off + leanInitiative:eager injects the daily line')
1591
- assert(SITE_DAILY.test(A.rederive), '★ [rederive site] formalVerify:off + leanInitiative:eager injects the daily line')
1592
- assert(SITE_DAILY.test(A.methodKeeper), '★ [methodKeeper site] formalVerify:off + leanInitiative:eager injects the daily line (inline site)')
1593
- assert(A.explorer.indexOf('【Lean 形式化验证(') === -1 && A.solver.indexOf('【Lean 形式化验证(') === -1 && A.rederive.indexOf('【Lean 形式化验证(') === -1 && A.methodKeeper.indexOf('【Lean 形式化验证(') === -1, '★ [all sites] formalVerify:off keeps the verification-phase block closed while eager still injects the daily line')
1594
-
1595
- const ro = makeRoot()
1596
- await callTool('vibe_math_new_project', { name: 'site-init-off' }, ro)
1597
- const B = await driveSiteV3(ro, { formalVerify: 'encourage', leanInitiative: 'off' }, 'qO')
1598
- assert(!SITE_DAILY.test(B.explorer) && !/【顺手形式化/.test(B.explorer), '★ [explorer site] leanInitiative:off drops the daily line')
1599
- assert(!SITE_DAILY.test(B.solver) && !/【顺手形式化/.test(B.solver), '★ [solver site] leanInitiative:off drops the daily line')
1600
- assert(!SITE_DAILY.test(B.rederive) && !/【顺手形式化/.test(B.rederive), '★ [rederive site] leanInitiative:off drops the daily line')
1601
- assert(!SITE_DAILY.test(B.methodKeeper) && !/【顺手形式化/.test(B.methodKeeper), '★ [methodKeeper site] leanInitiative:off drops the daily line (inline site)')
1602
-
1603
- // verification phase still follows formalVerify under leanInitiative:'off'
1604
- const rvv = makeRoot()
1605
- await callTool('vibe_math_new_project', { name: 'site-init-off-verify' }, rvv)
1606
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage', leanInitiative: 'off' }), rvv)
1607
- await callTool('vibe_math_add_problem', { id: 'qV', 陈述: '验证段仍在' }, rvv)
1608
- await callTool('vibe_math_start', {}, rvv)
1609
- await drive(rvv, () => !!lastSpawn(rvv, 'explorer:qV'), 'explorer qV')
1610
- const exv = lastSpawn(rvv, 'explorer:qV')
1611
- assert(!!exv && !SITE_DAILY.test(exv.prompt || ''), '★ [explorer site] 第二个 root:initiative off 时 explorer 仍无日线')
1612
- if (exv) fireEnd(exv.childId, { meta: { kind: 'directions', qid: 'qV', directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: '', feasibility: 0.8 }] } })
1613
- for (let guard = 0; guard < 30 && !lastSpawn(rvv, 'solver:qV:d1'); guard++) {
1614
- const pend = spawnOf(rvv, 'explorer:qV').filter((s) => !firedChildren.has(s.childId)).slice(-1)[0]
1615
- if (pend) { firedChildren.add(pend.childId); fireEnd(pend.childId, { meta: { kind: 'directions', qid: 'qV', directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: '', feasibility: 0.8 }] } }) }
1616
- await pump(rvv); await sleep(80)
1617
- }
1618
- const sovv = lastSpawn(rvv, 'solver:qV:d1')
1619
- if (sovv) fireEnd(sovv.childId, { status: 'success', solution: 'complete solution', survival_probability: 0.9 })
1620
- await drive(rvv, () => spawnOf(rvv, 'verifier:').length >= 1, 'verifier qV')
1621
- const vsp = spawnOf(rvv, 'verifier:')[0]
1622
- assert(!!vsp && SITE_VERIFY.test(vsp.prompt || ''), '★ leanInitiative:off 时验证段仍按 formalVerify 注入(verifier 提示词含【Lean 形式化验证()')
1623
- await callTool('vibe_math_abort', {}, rvv)
1624
- }
1625
-
1626
- // ===============================================================
1627
- // 5c. round-2 D4: ONE vote-count source — a mid-verification verifierCount change must not re-derive
1628
- // the in-flight task's quorum from live params (the audit's "same count, different code" bug).
1629
- // ===============================================================
1630
- section('5c D4 vote count: quorum comes from the task snapshot, not from live params')
1631
- {
1632
- const RD4 = makeRoot()
1633
- await callTool('vibe_math_new_project', { name: 'd4-votecount' }, RD4)
1634
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { verifierCount: 2 }), RD4)
1635
- await callTool('vibe_math_add_proposition', { id: 'p-d4', 概述: 'D4 票数单一来源', 概率: 0.6, 分类: '数论' }, RD4)
1636
- await callTool('vibe_math_start', {}, RD4)
1637
- const reD4 = verifyRe('p-d4')
1638
- assert(await drive(RD4, () => unfiredVerifiers(RD4, reD4).length >= 2, 'two verifiers for p-d4'), 'two verifiers were dispatched for p-d4 (the creation-time snapshot)')
1639
- // Mid-verification: the office asks for MORE verifiers. Intent-wise that is a NEW requirement for
1640
- // FUTURE objects; this task's participant set was frozen when it was created.
1641
- await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { verifierCount: 4 }), RD4)
1642
- await sleep(300)
1643
- // Count PER EXACT rId: the proposition may legitimately have several candidate rIds
1644
- // (r-p-d4, r-p-d4-pf0, …); only the in-flight task for one rId must stay pinned.
1645
- const labels = spawnOf(RD4, 'verifier:').map((x) => x.label)
1646
- const exact = {}
1647
- for (const l of labels) { const m = /^verifier:(r-p-d4):(\d+)$/.exec(l); if (m) exact[m[1]] = (exact[m[1]] || 0) + 1 }
1648
- const inFlight = exact['r-p-d4'] || 0
1649
- assert(inFlight === 2, '★ [D4] raising verifierCount mid-verification does NOT re-staff the in-flight task (participant set = the snapshot;实测 ' + JSON.stringify({ inFlight: inFlight, labels: labels }) + ')')
1650
- // Both dispatched votes satisfy the quorum ⇒ the object must conclude. A view that re-derived its
1651
- // quorum from the live params would demand 4 votes and stall here forever.
1652
- await runVerifyRound(RD4, 'p-d4', [1, 1])
1653
- const cardOf = () => readIf(join(projRoot('d4-votecount'), 'Propos', '数论', 'p-d4.md'))
1654
- const concluded = await drive(RD4, () => { const c = cardOf(); return /-\s*概率:\s*1(\D|$)/.test(c) || /状态:\s*已验证/.test(c) }, 'p-d4 concludes with the two votes it has', 15000)
1655
- assert(concluded, '★★★ [D4] the two dispatched votes still satisfy the quorum and the object concludes (no view may re-derive its quorum from live params;card=' + JSON.stringify(cardOf().slice(0, 220)) + ')')
1656
- await callTool('vibe_math_abort', {}, RD4)
1657
- }
1658
-
1659
- section('10 the captured prompt corpus is written for human review')
1660
- {
1661
- mkdirSync(CORPUS_DIR, { recursive: true })
1662
- writeFileAtomic(join(CORPUS_DIR, 'formal-verify-v3.json'), JSON.stringify({ entries: corpus }, null, 2))
1663
- const md = ['# V3 形式化验证交互语料(prompt corpus)', '',
1664
- '> 由 `formal-verify-v3.test.mjs` 落盘:框架**真正发出**的每一条提示词原文。路径归一化:工作区 → `<WS>`,',
1665
- '> VibeMath 根 → `<VIBEMATH>`(两者都按正/反斜杠两种写法替换,因此语料是确定性的、可 diff 的、不泄露本机路径)。',
1666
- '> 覆盖:explorer / solver / method-keeper 的日常工作提示词(含「顺手形式化」与"归档前先跑通"),',
1667
- '> `off`(零 Lean 文本)、`encourage`、**`require`** 三档下的表决初评与辩论提示词,`passed` 之后的忠实性审查分支',
1668
- '> (含 `defect` 出口),以及规划提示词。', '']
1669
- for (let i = 0; i < corpus.length; i++) {
1670
- const c = corpus[i]
1671
- md.push('## [' + i + '] ' + c.kind + ' · ' + c.label)
1672
- md.push('')
1673
- md.push('```text')
1674
- md.push(c.prompt)
1675
- md.push('```')
1676
- md.push('')
1677
- }
1678
- writeFileAtomic(join(CORPUS_DIR, 'formal-verify-v3.md'), md.join('\n'))
1679
- assert(existsSync(join(CORPUS_DIR, 'formal-verify-v3.json')) && existsSync(join(CORPUS_DIR, 'formal-verify-v3.md')), 'the prompt corpus was written (JSON + Markdown)')
1680
- assert(corpus.length >= 25, 'the corpus covers the whole run (' + corpus.length + ' prompts)')
1681
- assert(corpus.some((c) => c.label.startsWith('explorer:')) && corpus.some((c) => c.label.startsWith('solver:')) && corpus.some((c) => c.label.startsWith('method-keeper')) && corpus.some((c) => c.label.startsWith('verifier:')), 'the corpus covers every interaction type this suite drives')
1682
- assert(corpus.some((c) => c.kind === 'wake'), 'the corpus also keeps the continuation prompts (debate rounds)')
1683
- // generic sweep over EVERY captured prompt, not spot checks (AUDIT §2.1)
1684
- const dirty = corpus.filter((c) => /\[object Object\]|\bNaN\b|:\s*undefined|["']undefined["']|undefined\s*[,}\]]/.test(c.prompt))
1685
- assert(dirty.length === 0, 'no captured prompt contains placeholder garbage (' + dirty.map((d) => d.label).join(',') + ')')
1686
- const joined = corpus.map((c) => c.prompt).join('\n')
1687
- assert(joined.indexOf(WS) === -1 && joined.indexOf(WS.replace(/\\/g, '/')) === -1, 'every captured prompt normalises the workspace path to <WS> (the corpus stays diffable)')
1688
- assert(joined.indexOf(VIBE) === -1 && joined.indexOf(VIBE.replace(/\\/g, '/')) === -1 && joined.indexOf('<VIBEMATH>') !== -1,
1689
- '★ every captured prompt normalises the VibeMath root to <VIBEMATH> (no machine path leaks into the shipped corpus, contract §10 item 10)')
1690
- assert(!corpus.some((c) => c.root === RA.id && /Lean|形式化/.test(c.prompt)),
1691
- '★ the off-mode prompts captured in the corpus contain ZERO Lean text (off stays a true no-op)')
1692
- // contract §10 item 10: the corpus must cover require AND the work round (not just encourage + fidelity)
1693
- assert(corpus.some((c) => /【Lean 形式化验证(鼓励模式)】/.test(c.prompt)), '★ the corpus covers the encourage-mode verification prompt')
1694
- assert(corpus.some((c) => /【Lean 形式化验证(强制模式)】/.test(c.prompt)), '★ the corpus covers the REQUIRE-mode verification prompt')
1695
- assert(corpus.some((c) => /【顺手形式化(鼓励)】/.test(c.prompt)) && corpus.some((c) => /【顺手形式化(强制)】/.test(c.prompt)), '★ the corpus covers the work-round 顺手形式化 prompt in both modes')
1696
- assert(corpus.some((c) => /一致 → Result = 1/.test(c.prompt)), '★ the corpus keeps the passed/fidelity branch verbatim for human review')
1697
- // contract §6 hard requirements 1-2 + §10 item 9, swept over EVERY captured prompt
1698
- const verifier = corpus.filter((c) => c.label.startsWith('verifier:'))
1699
- assert(verifier.length >= 5 && verifier.every((c) => /Result/.test(c.prompt) && !/verdict/.test(c.prompt)),
1700
- '★ every captured voting prompt names Result and never verdict (§6 hard requirement 2)')
1701
- const bareTools = corpus.filter((c) => /(^|[^a-z_])lean_(run|archive|lib)/.test(c.prompt))
1702
- assert(bareTools.length === 0, '★ no captured prompt abbreviates a Lean tool name (§6 hard requirement 1): ' + bareTools.map((b) => b.label).join(','))
1703
- const zeroDeviation = corpus.filter((c) => c.prompt.indexOf('偏离 → 0') !== -1)
1704
- assert(zeroDeviation.length === 0, '★ no captured prompt turns a fidelity defect into a 0 vote (§6 hard requirement 5 / §10 item 9): ' + zeroDeviation.map((b) => b.label).join(','))
1705
- // Contract §10 item 10 + AUDIT-CHECKLIST §2.4: the shipped corpus must be BYTE-deterministic, so
1706
- // the volatile run metadata the planner brief carries (random plan id, epoch timestamps, child
1707
- // ids, free-slot count) must be normalised out — otherwise every run diffs and the corpus loses
1708
- // its only purpose (human review of what agents actually read).
1709
- const volatile = corpus.filter((c) => /plan-[0-9a-f]{8}/.test(c.label) || /plan-[0-9a-f]{8}|"at": \d{10,}|"childId": "c\d+"|"free_slots": \d+/.test(c.prompt))
1710
- assert(volatile.length === 0, '★ no captured prompt/label keeps volatile run metadata (random plan id / epoch timestamps / child ids / slot count) — the corpus is byte-deterministic: ' + volatile.map((b) => b.label).join(','))
1711
- assert(corpus.some((c) => /"free_slots": <SLOTS>/.test(c.prompt)) && corpus.some((c) => /plan-<ID>/.test(c.label)),
1712
- '★ and the normalisation actually fired (a planner brief and its plan id were captured)')
1713
- // The brief's instant-dependent snapshot is placeholdered, but its CONTRACT (key set + order) and
1714
- // the stable configuration scalars stay, and the instruction text that references the brief is
1715
- // untouched — so the human reviewer still sees the real planner prompt, not a blank.
1716
- const briefEntries = corpus.filter((c) => /CURRENT STATE BRIEF \(JSON\):/.test(c.prompt))
1717
- assert(briefEntries.length >= 3 && briefEntries.every((c) => SNAPSHOT_KEYS.every((k) => c.prompt.indexOf('"' + k + '": "<' + k.toUpperCase() + '>"') !== -1)),
1718
- '★ every captured planner brief keeps all ' + SNAPSHOT_KEYS.length + ' contract keys with canonically placeholdered snapshot values (' + briefEntries.length + ' briefs)')
1719
- assert(briefEntries.every((c) => /"horizon": \d+/.test(c.prompt) && /"free_slots": <SLOTS>/.test(c.prompt) && /"maxParallelThreshold": \d+/.test(c.prompt)),
1720
- '★ the brief keeps its real configuration scalars (horizon / free_slots / maxParallelThreshold), not just placeholders')
1721
- assert(briefEntries.every((c) => /brief\.problems\[\]\.running_solver_dirs and brief\.active_agents/.test(c.prompt) && /ACTION VOCABULARY/.test(c.prompt)),
1722
- '★ and the planner INSTRUCTION text under review (ACTION VOCABULARY + the HARD RULES naming brief fields) is verbatim')
1723
- {
1724
- // Direct witness of the race the normaliser exists for: the two shapes a background tick can
1725
- // produce (explorer registered before vs. after the forced planner read the brief, activity log
1726
- // one entry longer, free slots one lower) must normalise to ONE byte-identical text. Without
1727
- // this the corpus could only be checked by hoping the race fires.
1728
- const briefText = (agents, events, slots, horizon) => 'PERSONA\nCURRENT STATE BRIEF (JSON):\n' + JSON.stringify({
1729
- at: 1759200000000, horizon: horizon, free_slots: slots, maxParallelThreshold: 64,
1730
- problems: [{ id: 'qE', 状态: '求解中', 方向数: agents.length, 活跃方向: agents.length ? ['d1'] : [], running_solver_dirs: [], 最高存活率: agents.length ? 0.7 : null, 解法数: 0 }],
1731
- verify_candidates: agents.length ? [{ rId: 'r-qE', kind: 'problem', target: 'qE', prob: 0.5, priority: 1 }] : [],
1732
- active_agents: agents, methods: [], pending_inventions: 0, last_plan: null, recent_events: events,
1733
- }, null, 2) + '\n\nACTION VOCABULARY:\n- x'
1734
- const withExplorer = briefText([{ childId: 'c7', role: 'explorer', target: 'qE', direction: '', round: '' }],
1735
- [{ at: 1759200000001, event: 'start', detail: 'scheduler started' }, { at: 1759200000002, event: 'explorer', detail: 'problem qE explorer spawned (plan)' }], 63, 5)
1736
- const withoutExplorer = briefText([], [{ at: 1759200000009, event: 'plan', detail: 'planner plan-abcdef12 called with 1 problem(s)' }], 64, 5)
1737
- assert(scrub(withExplorer) === scrub(withoutExplorer),
1738
- '★ the planner-brief normaliser maps the two racing snapshots (explorer present/absent, different activity order and free slots) to ONE byte-identical text')
1739
- assert(scrub(withExplorer) !== withExplorer && /"active_agents": "<ACTIVE_AGENTS>"/.test(scrub(withExplorer)),
1740
- '★ and it really rewrote the snapshot (the test above cannot pass just because nothing was normalised)')
1741
- }
1742
- }
1743
-
1744
- console.log('')
1745
- console.log('passed=' + passed + ' failed=' + failed)
1746
- if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f) }
1747
- rmSync(WS, { recursive: true, force: true })
1748
- if (failed) process.exit(1)
1749
- console.log('ALL GREEN')
1750
- process.exit(0)
1
+ // ============================================================
2
+ // V3 LEAN FORMAL VERIFICATION SUITE (docs/formal-verification.md)
3
+ //
4
+ // Asserts the whole contract of the `formalVerify` knob on the v3 preset:
5
+ // · 'off' is a TRUE no-op (no Lean text in ANY prompt, no Formal record, no gate)
6
+ // · 'encourage' injects the Lean section into the work prompts AND into both verification
7
+ // prompts, and — the actual point of the feature — turns the voting prompt into
8
+ // a FIDELITY review once a Lean run has passed
9
+ // · 'require' withholds a true/false verdict as 未定论 until the object is Lean-passed or
10
+ // carries an explicit, reasoned blocker record; then allows it, and the Verified
11
+ // card records how strong the result really is
12
+ // · the three tools (run / archive / lib) write the right things to the right paths
13
+ //
14
+ // The Lean toolchain is mocked through the subprocess SERVICE, so the tests exercise the real
15
+ // code path (resolveExecutable → spawn → collected stdout → exit code) without requiring Lean.
16
+ //
17
+ // V3_PLUGIN overrides the plugin under test: a sensitivity probe MUST point this suite at a
18
+ // mutated copy, otherwise every probe would exercise the unmutated plugin and stay green.
19
+ //
20
+ // Run: node tests/formal-verify-v3.test.mjs
21
+ // ============================================================
22
+ import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs'
23
+ import { tmpdir } from 'node:os'
24
+ import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
25
+ import { fileURLToPath, pathToFileURL } from 'node:url'
26
+
27
+ // Concurrency isolation: the shipped corpus is read by other probes (audit-prompt-invariants) while
28
+ // this suite runs under the gate, so the write must be atomic — a reader must never see a truncated file.
29
+ function writeFileAtomic(target, text) {
30
+ const tmp = target + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2, 8)
31
+ writeFileSync(tmp, text, 'utf8')
32
+ renameSync(tmp, target)
33
+ }
34
+
35
+
36
+ // V3_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.V3_PLUGIN
41
+ ? pathToFileURL(isAbsolute(process.env.V3_PLUGIN) ? process.env.V3_PLUGIN : join(dirname(fileURLToPath(import.meta.url)), '..', process.env.V3_PLUGIN))
42
+ : new URL('../vibe-math-v3/vibe-math-v3.js', import.meta.url)
43
+ const HERE = dirname(fileURLToPath(import.meta.url))
44
+ // AUDIT-CHECKLIST §2.4: the suite must also keep the INTERACTION TEXT it drove, so a human can
45
+ // re-read the prompts the framework really emitted. V3_CORPUS_DIR overrides the destination.
46
+ const CORPUS_DIR = process.env.V3_CORPUS_DIR
47
+ ? pathResolve(process.env.V3_CORPUS_DIR)
48
+ // A MUTATED run (V3_PLUGIN set) must never rewrite the shipped corpus — same rule v2 and v4 already follow.
49
+ : (process.env.V3_PLUGIN ? join(tmpdir(), 'vibe-v3-prompt-corpus') : join(HERE, '..', 'prompt-corpus-v3'))
50
+ const WS = mkdtempSync(join(tmpdir(), 'vibe-v3-lean-'))
51
+ const VIBE = join(WS, 'VibeMath')
52
+ const projRoot = (slug) => join(VIBE, 'Projects', slug)
53
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '')
54
+ /** A missing directory lists as [] (a mutant cascade must surface as a NAMED red, not an ENOENT crash). */
55
+ const listDirIf = (p) => (existsSync(p) ? readdirSync(p) : [])
56
+ const reEsc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57
+ const verifyRe = (target) => new RegExp('^verifier:r-' + reEsc(target) + '(?:-\\w+)?:\\d+$')
58
+
59
+ let passed = 0, failed = 0
60
+ const failures = []
61
+ const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
62
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
63
+ const section = (t) => console.log('\n[' + t + ']')
64
+
65
+ // ---------------------------------------------------------------
66
+ // mock host
67
+ // ---------------------------------------------------------------
68
+ const listeners = {}
69
+ const toolRegs = []
70
+ const cmdRegs = []
71
+ const spawns = [] // { label, childId, rootId, prompt }
72
+ const wakes = [] // { childId, rootId, prompt }
73
+ const interrupts = []
74
+ const leanRuns = [] // { argv, file, cwd, graceMs }
75
+ const terminated = []
76
+ const disposers = []
77
+ const roots = []
78
+ let rootSeq = 0
79
+ let subprocessAvailable = true
80
+ let toolchainAvailable = true
81
+ let spawnThrows = false
82
+ // Interaction corpus (AUDIT-CHECKLIST §2.4): every prompt the framework actually sent, with the
83
+ // workspace path normalised so the dump is deterministic and diffable.
84
+ const corpus = []
85
+ // Normalise BOTH slash forms. The VibeMath ROOT must be replaced BEFORE the workspace root,
86
+ // otherwise `<WS>/VibeMath` would survive as a half-substituted path: the corpus would still leak
87
+ // the machine layout and would not be diffable against another checkout.
88
+ //
89
+ // The PLANNER prompt embeds the raw state brief, whose volatile RUN METADATA would otherwise change
90
+ // on every run and make the corpus undiffable (contract §10 item 10 demands byte determinism; the
91
+ // same class of bug is recorded in AUDIT-CHECKLIST §2.4 as a real accident): the random plan id in
92
+ // the label, epoch-millisecond `at` timestamps, child ids, and the free-slot count. They are run
93
+ // metadata, not the text under review, so they are normalised to placeholders.
94
+ //
95
+ // The brief is not only run metadata, it is a LIVE SNAPSHOT of mutable scheduler state
96
+ // (`buildBrief()` reads `agentRegistry`, `activityLog`, the verify candidates and the free-slot
97
+ // count at the instant the planner is spawned). This suite drives the plugin from OUTSIDE, and the
98
+ // plugin's own scheduler keeps running concurrently: `vibe_math_start` fires `scheduleTick()`
99
+ // without awaiting it, and the plugin's 1s tick timer calls `scheduleTick()` on its own schedule, so
100
+ // a background tick can register the explorer (or append its activity event, or close a verify)
101
+ // BETWEEN the forced planner's snapshot and the next one. Two runs then legitimately disagree about
102
+ // `active_agents` (the explorer is in flight or it is not), about `recent_events` (the last-eight
103
+ // window and its order), and about every other instant-dependent field of the brief.
104
+ //
105
+ // A byte-stable corpus therefore cannot carry that snapshot's values — the disagreement is a
106
+ // property of the INSTANT, not of the prompt text a human must review (this is exactly the "语料
107
+ // 每次运行都变" accident §2.4 records). What the corpus carries is the brief's CONTRACT: the same
108
+ // keys in the same order, with the instant-dependent values canonically placeholdered and the
109
+ // stable configuration scalars kept. The prompt TEXT under review — the persona, the ACTION
110
+ // VOCABULARY, and the HARD RULES that name `brief.problems[].running_solver_dirs`,
111
+ // `brief.active_agents` and `brief.free_slots` — is untouched.
112
+ const SNAPSHOT_KEYS = ['problems', 'verify_candidates', 'active_agents', 'methods', 'pending_inventions', 'last_plan', 'recent_events']
113
+ /** Replace every instant-dependent collection of the planner's state brief with a canonical
114
+ * placeholder, keeping the key set, the key order and the configuration scalars. Only called on a
115
+ * valid-JSON brief (`buildBrief` is serialised with JSON.stringify(brief, null, 2)); anything that
116
+ * does not match the exact planner framing is returned unchanged, so the suite can never silently
117
+ * mangle an unrelated prompt. */
118
+ const canonBrief = (s) => {
119
+ const m = s.match(/(CURRENT STATE BRIEF \(JSON\):\n)(\{[\s\S]*?\n\})(\n\nACTION VOCABULARY)/)
120
+ if (!m) return s
121
+ let brief
122
+ try { brief = JSON.parse(m[2]) } catch (e) { return s }
123
+ for (const k of SNAPSHOT_KEYS) if (Object.prototype.hasOwnProperty.call(brief, k)) brief[k] = '<' + k.toUpperCase() + '>'
124
+ return s.slice(0, m.index) + m[1] + JSON.stringify(brief, null, 2) + m[3] + s.slice(m.index + m[0].length)
125
+ }
126
+ const scrub = (s) => canonBrief(String(s == null ? '' : s)
127
+ .split(VIBE).join('<VIBEMATH>')
128
+ .split(VIBE.replace(/\\/g, '/')).join('<VIBEMATH>')
129
+ .split(WS).join('<WS>')
130
+ .split(WS.replace(/\\/g, '/')).join('<WS>'))
131
+ .replace(/plan-[0-9a-f]{8}/g, 'plan-<ID>')
132
+ .replace(/("at"\s*:\s*)\d{10,16}/g, '$1"<TIME>"')
133
+ .replace(/("childId"\s*:\s*")c\d+(")/g, '$1<CHILD>$2')
134
+ .replace(/("free_slots"\s*:\s*)\d+/g, '$1<SLOTS>')
135
+ // Epoch-ms in a verification-log FILE NAME (`Logs/Verification/<rId>_<Date.now()>.json`) is run
136
+ // metadata too: the final-paper material embeds the evidence index verbatim, so without this the
137
+ // shipped corpus changed on every run (contract §10 item 10 demands byte determinism).
138
+ // 为什么必须保留可选短 id 组(不要"简化"掉):P7 把裁决日志名改成了
139
+ // `<rId>_<epochMs>_<shortId>.json`,旧的 `_[0-9]{10,16}\.json` 因此不再匹配,
140
+ // 于是每次跑动都把自己那次的 时间戳+shortId 写进 shipped 语料(语料非确定性)。
141
+ // 去掉 `(_[0-9a-f]{8})?` 这个组会静默地重新引入"每次跑都不同"的语料。
142
+ .replace(/_[0-9]{10,16}(_[0-9a-f]{8})?\.json/g, '_<TIME>.json')
143
+
144
+ // A fake Lean: a file PASSES unless it still contains `sorry` or the marker `-- FAIL`.
145
+ // `-- HANG` simulates a toolchain that never returns (the timeout path).
146
+ const subprocess = {
147
+ async resolveExecutable(cmd) {
148
+ if (!toolchainAvailable) throw new Error('spawn ' + cmd + ' ENOENT')
149
+ if (String(cmd) !== 'lean' && String(cmd) !== 'lake') throw new Error('unknown executable ' + cmd)
150
+ return String(cmd)
151
+ },
152
+ spawn(spec) {
153
+ const argv = spec.argv || []
154
+ const last = String(argv[argv.length - 1] || '')
155
+ const isShell = /powershell|cmd\.exe|\/bin\/sh|(^|\/)sh$/i.test(String(argv[0] || '')) || /New-Item|Remove-Item|Move-Item|^mkdir -p/.test(last)
156
+ if (isShell) {
157
+ // directory shim (v3's runShell: powershell New-Item / POSIX mkdir -p)
158
+ if (/New-Item/.test(last)) {
159
+ const m = last.match(/-Path\s+(?:'((?:[^']|'')*)'|"((?:[^"]|"")*)")/)
160
+ const raw = (m && (m[1] || m[2])) || ''
161
+ for (const p of raw.split(',').map((x) => x.replace(/''/g, "'"))) if (p) mkdirSync(p, { recursive: true })
162
+ }
163
+ if (/^mkdir -p/.test(last)) {
164
+ const re = /'((?:[^']|'\\'')*)'/g
165
+ let m
166
+ while ((m = re.exec(last)) !== null) mkdirSync(m[1].replace(/'\\''/g, "'"), { recursive: true })
167
+ }
168
+ if (/Remove-Item/.test(last)) {
169
+ const m = last.match(/-LiteralPath\s+'((?:[^']|'')*)'/)
170
+ if (m) rmSync(m[1].replace(/''/g, "'"), { force: true, recursive: true })
171
+ }
172
+ return { done: Promise.resolve({ exitCode: 0 }), collected: {} }
173
+ }
174
+ const file = last
175
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : ''
176
+ leanRuns.push({ argv: argv.slice(0, -1), file, cwd: spec.cwd, graceMs: spec.graceMs })
177
+ if (spawnThrows) throw new Error('spawn ' + String(argv[0]) + ' EPERM')
178
+ if (/-- REJECT/.test(text)) {
179
+ return {
180
+ done: Promise.reject(new Error('child process died before reporting')),
181
+ collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
182
+ terminate() {},
183
+ }
184
+ }
185
+ if (/-- HANG/.test(text)) {
186
+ return {
187
+ done: new Promise(() => {}),
188
+ collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
189
+ terminate() { terminated.push(file) },
190
+ }
191
+ }
192
+ const bad = /sorry|-- FAIL/.test(text)
193
+ const stdout = bad ? '' : 'ok\n'
194
+ const stderr = bad ? 'error: declaration uses sorry\n' : ''
195
+ return {
196
+ done: Promise.resolve({ exitCode: bad ? 1 : 0, signal: null }),
197
+ collected: {
198
+ stdout: { readFrom: () => ({ text: stdout, nextOffset: stdout.length, lossy: false }) },
199
+ stderr: { readFrom: () => ({ text: stderr, nextOffset: stderr.length, lossy: false }) },
200
+ },
201
+ terminate() {},
202
+ }
203
+ },
204
+ }
205
+
206
+ const ctx = {
207
+ get(name) {
208
+ if (name === 'subprocess') return subprocessAvailable ? subprocess : undefined
209
+ if (name === 'sandboxPolicy') return { resolve() { return { workspaceRoot: WS } } }
210
+ return undefined
211
+ },
212
+ on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
213
+ effect(fn) { const d = fn(); const off = () => { if (typeof d === 'function') d() }; disposers.push(off); return off },
214
+ logger: { info() {}, warn() {}, error() {} },
215
+ tools: { register(spec) { toolRegs.push(spec); return () => {} } },
216
+ commands: { register(spec) { cmdRegs.push(spec); return () => {} } },
217
+ subagents: {
218
+ list() { return ['spawn'] },
219
+ async startContinuable({ label, request }) {
220
+ const childId = 'c' + (spawns.length + 1)
221
+ const rootId = (request && request.parent && request.parent.id) || ''
222
+ const prompt = (request && request.prompt && request.prompt[0] && request.prompt[0].text) || ''
223
+ spawns.push({ label, childId, rootId, prompt })
224
+ corpus.push({ kind: 'spawn', label: scrub(label), root: rootId, prompt: scrub(prompt) })
225
+ return { childId, messageId: 'm' + spawns.length }
226
+ },
227
+ async sendMessage(parent, childId, blocks) {
228
+ const rootId = (parent && parent.id) || ''
229
+ const prompt = (blocks && blocks[0] && blocks[0].text) || ''
230
+ wakes.push({ childId, rootId, prompt })
231
+ const sp = spawns.find((s) => s.childId === childId)
232
+ corpus.push({ kind: 'wake', label: scrub(sp ? sp.label : childId), root: rootId, prompt: scrub(prompt) })
233
+ return 'w' + wakes.length
234
+ },
235
+ async followup(parent, childId, blocks) { return await this.sendMessage(parent, childId, blocks) },
236
+ interrupt(childId) { interrupts.push(childId) },
237
+ },
238
+ agents: { roots() { return roots.slice() }, get() { return undefined } },
239
+ // DSH 0.1.1 fs API: resolve → { targetKey, displayPath }
240
+ fs: {
241
+ async resolve(rel, opts) {
242
+ const base = (opts && opts.cwd) || WS
243
+ const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(base, ...String(rel).split('/'))
244
+ return { targetKey: p, displayPath: p }
245
+ },
246
+ async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
247
+ async readText(t) { return readFileSync(t.targetKey, 'utf8') },
248
+ async writeText(t, content) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, content, 'utf8') },
249
+ async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
250
+ },
251
+ }
252
+
253
+ const mod = await import(PLUGIN.href + '?t=' + Date.now())
254
+ ;(mod.default || mod).apply(ctx)
255
+ // leanAsync defaults to TRUE (background queue). The sections above guard the **synchronous** Lean
256
+ // path verbatim, so pin leanAsync=false through the workspace-level settings file (every
257
+ // setProject → loadSettings() picks it up). Section 9b flips it back on explicitly for the async
258
+ // state machine. Nothing else in the suite depends on the async default.
259
+ mkdirSync(VIBE, { recursive: true })
260
+ writeFileSync(join(VIBE, 'vibe_math_setting.json'), '{ "leanAsync": false }\n', 'utf8')
261
+
262
+ // ---------------------------------------------------------------
263
+ // driving helpers
264
+ // ---------------------------------------------------------------
265
+ function makeRoot() {
266
+ const id = 'sess-' + String.fromCharCode(65 + rootSeq++)
267
+ const root = { id, options: { provider: 'mock', model: 'mock-model' }, session: { id, header: { cwd: WS, parentSession: undefined } }, followup() {}, ctx: undefined }
268
+ roots.push(root)
269
+ return root
270
+ }
271
+ async function callTool(name, args, agent) {
272
+ const spec = toolRegs.find((s) => s.name === name)
273
+ if (!spec) throw new Error('no tool ' + name)
274
+ return JSON.parse(await spec.execute(args || {}, { agent }))
275
+ }
276
+ function fireEnd(childId, reply) {
277
+ const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
278
+ for (const h of (listeners['subagent/end'] || [])) h({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: blocks })
279
+ }
280
+ const firedChildren = new Set()
281
+ // Spawns created before a restart belong to a task the restart discarded; they are never a valid
282
+ // target for a later round (their onChildEnd has no agentRegistry entry any more). The floor keeps
283
+ // `unfiredVerifiers` from selecting them.
284
+ const spawnFloor = {}
285
+ const markFloor = (root) => { spawnFloor[root.id] = spawns.length }
286
+ const spawnOf = (root, prefix) => spawns.filter((s) => s.rootId === root.id && s.label.startsWith(prefix))
287
+ const lastSpawn = (root, prefix) => spawnOf(root, prefix).slice(-1)[0]
288
+ const unfiredVerifiers = (root, re) => spawns.filter((s, i) => s.rootId === root.id && (spawnFloor[root.id] || 0) <= i && re.test(s.label) && !firedChildren.has(s.childId))
289
+ const promptText = (root) => spawns.filter((s) => s.rootId === root.id).map((s) => s.prompt).join('\n') + '\n' + wakes.filter((w) => w.rootId === root.id).map((w) => w.prompt).join('\n')
290
+
291
+ /** Parameters every verification scenario uses. A high concurrency cap keeps the fallback
292
+ * scheduler from starving a later object behind a pile of unfired mock verifiers. */
293
+ const VPARAMS = { plannerEnabled: false, verifierCount: 2, debateMaxRounds: 1, maxParallelThreshold: 64, tickIntervalMs: 200 }
294
+
295
+ /** Fire the one in-flight forced planner with an empty plan (an empty plan schedules a tick). */
296
+ async function pump(root) {
297
+ const pending = spawns.filter((s) => s.rootId === root.id && s.label.startsWith('planner:') && !firedChildren.has(s.childId)).slice(-1)[0]
298
+ if (pending) { firedChildren.add(pending.childId); fireEnd(pending.childId, { summary: 'noop', plan: [] }); return }
299
+ try { await callTool('vibe_math_plan', { force: true }, root) } catch (e) { /* not running / gated */ }
300
+ }
301
+ /** Poll `pred`, nudging the scheduler along with an empty forced plan (the 1s tick timer is the fallback). */
302
+ async function drive(root, pred, label, timeoutMs) {
303
+ const deadline = Date.now() + (timeoutMs || 15000)
304
+ while (Date.now() < deadline) {
305
+ if (pred()) return true
306
+ await pump(root)
307
+ await sleep(80)
308
+ }
309
+ if (pred()) return true
310
+ console.error(' .. drive timed out: ' + label)
311
+ return false
312
+ }
313
+ /** Fire the next `results.length` verifiers of one object (any of its candidate rIds). */
314
+ async function runVerifyRound(root, target, results, timeoutMs) {
315
+ const re = verifyRe(target)
316
+ const ok = await drive(root, () => unfiredVerifiers(root, re).length >= results.length, 'verifiers for r-' + target, timeoutMs)
317
+ if (!ok) return null
318
+ const un = unfiredVerifiers(root, re).slice(0, results.length)
319
+ for (let i = 0; i < un.length; i++) { firedChildren.add(un[i].childId); fireEnd(un[i].childId, { Result: results[i], Reason: 'mock 裁决 ' + results[i] }) }
320
+ await sleep(220)
321
+ return un
322
+ }
323
+ /** V3-G1: `unfiredVerifiers(...).length` is a COUNT - the semantic unit is that the fired reviews are
324
+ * DISTINCT slots (the `:<index>` suffix), i.e. two genuinely independent reviews of the same object.
325
+ * A founding that spawns the same slot twice keeps the count at 2 and used to pass. */
326
+ /** The slot indices (`:<index>`) of a fired/eligible verifier round. */
327
+ function slotsOf(round) { return (round || []).map((x) => String(x.label).split(':').pop()) }
328
+ let gateSlots1 = null // V3-G2: round-one slots (module scope: the require round lives in a block)
329
+
330
+ function assertDistinctSlots(round, tag) {
331
+ assert(Array.isArray(round) && round.length > 0, tag + ': verifiers were fired')
332
+ const idx = round.map((x) => String(x.label).split(':').pop())
333
+ const rIds = round.map((x) => String(x.label).split(':')[1])
334
+ assert(new Set(idx).size === idx.length, '* V3-G1 ' + tag + ': the reviews are DISTINCT verifier slots (indices=' + JSON.stringify(idx) + ')')
335
+ assert(new Set(rIds).size === 1, '* V3-G1 ' + tag + ': every review targets the SAME object (targets=' + JSON.stringify(rIds) + ')')
336
+ }
337
+
338
+ /** Abort + restart: gives the next verification a FRESH task, so a prompt is guaranteed to be
339
+ * constructed after whatever changed in between (mode switch, archived proof, …). */
340
+ async function restart(root) {
341
+ await callTool('vibe_math_abort', {}, root)
342
+ await callTool('vibe_math_start', {}, root)
343
+ markFloor(root)
344
+ }
345
+
346
+ console.log('-- V3 Lean formal verification --')
347
+ console.log('plugin under test: ' + PLUGIN.href)
348
+
349
+ // ===============================================================
350
+ // 1. 'off' is a true no-op
351
+ // ===============================================================
352
+ section("1 'off' (default) is a true no-op")
353
+ const RA = makeRoot()
354
+ await callTool('vibe_math_new_project', { name: 'lean-off' }, RA)
355
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS), RA)
356
+ await callTool('vibe_math_add_proposition', { id: 'p-off', 概述: '关模式下的普通命题', 概率: 0.6, 分类: '数论' }, RA)
357
+ await callTool('vibe_math_start', {}, RA)
358
+ const offRound = await runVerifyRound(RA, 'p-off', [1, 1])
359
+ assert(offRound !== null && offRound.length === 2, 'off: two independent verifiers were asked (投票流程照常)')
360
+ if (offRound) assertDistinctSlots(offRound, 'off round')
361
+ const offProj = projRoot('lean-off')
362
+ assert(await drive(RA, () => existsSync(join(offProj, 'Verified', '命题', 'p-off.md')), 'off Verified card'), "'off' still finalizes on a unanimous boolean vote with NO Lean artifact")
363
+ {
364
+ const st = await callTool('vibe_math_status', {}, RA)
365
+ assert(st.params.formalVerify === 'off', "the default is 'off' (got " + st.params.formalVerify + ')')
366
+ const all = promptText(RA)
367
+ assert(!/Lean/.test(all), 'no prompt mentions Lean in off mode')
368
+ assert(!/形式化/.test(all), 'no prompt mentions 形式化 in off mode')
369
+ assert(!/\[形式化\]/.test(all), 'the [形式化] announcement line is absent in off mode')
370
+ }
371
+ assert(!/形式化/.test(readIf(join(offProj, 'Verified', '命题', 'p-off.md'))), 'the Verified card carries no formal line in off mode')
372
+ assert(!existsSync(join(offProj, 'Formal', 'Index.md')), 'no Formal/Index.md is produced in off mode (no formal record)')
373
+ assert(!existsSync(join(offProj, 'State', 'formal.json')), 'no State/formal.json is produced in off mode')
374
+ {
375
+ const st = await callTool('vibe_math_status', {}, RA)
376
+ assert(st.formal.mode === 'off' && st.formal.objects.length === 0, 'status reports mode=off with zero formal objects')
377
+ }
378
+ // the tools still EXIST in off mode (static registration), they are just never advertised
379
+ assert(!!toolRegs.find((t) => t.name === 'vibe_math_lean_run') && !!toolRegs.find((t) => t.name === 'vibe_math_lean_archive') && !!toolRegs.find((t) => t.name === 'vibe_math_lean_lib'),
380
+ 'the three Lean tools are registered in every mode (registration is static)')
381
+
382
+ // ★ The mode switch must be REACHABLE THROUGH THE TOOL SCHEMA (2.3.2 defect D1) ──────────────
383
+ // Every tool schema here is closed (`additionalProperties:false`), so a key the schema does not
384
+ // advertise is REJECTED by any schema-validating provider. v3 shipped 2.3.0/2.3.1 with all four Lean
385
+ // parameters missing from the set-params schema while every assertion in this file stayed green —
386
+ // because the suite calls the handler DIRECTLY and never inspects the registered schema. The feature
387
+ // could not be switched on at all through the tool interface.
388
+ {
389
+ const setSpec = toolRegs.find((t) => t.name === 'vibe_math_set_params')
390
+ assert(!!setSpec, "vibe_math_set_params is registered")
391
+ assert(setSpec.parameters && setSpec.parameters.type === 'object' && setSpec.parameters.additionalProperties === false,
392
+ '★ vibe_math_set_params publishes a CLOSED object schema (an unlisted key is rejected, so the schema IS the contract)')
393
+ for (const k of ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']) {
394
+ assert(Object.prototype.hasOwnProperty.call(setSpec.parameters.properties, k),
395
+ '★ the registered schema advertises ' + k + ' (every other surface documents it; a schema that omits it makes the switch unreachable)')
396
+ }
397
+ assert(JSON.stringify(setSpec.parameters.properties.formalVerify.enum) === JSON.stringify(['off', 'encourage', 'require']),
398
+ 'the schema narrows formalVerify to the three real modes (a typo must not be a fourth)')
399
+ }
400
+
401
+ // A stray `formal` reply in OFF mode must be INERT (finding #1): the absorber is mode-gated, and the
402
+ // reply contract does not offer the field in off mode. The TOOLS stay usable on purpose.
403
+ {
404
+ const RB0 = makeRoot()
405
+ await callTool('vibe_math_new_project', { name: 'lean-off-reply' }, RB0)
406
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS), RB0)
407
+ await callTool('vibe_math_add_proposition', { id: 'p-offr', 概述: '关模式下的回执注入测试', 概率: 0.6, 分类: '数论' }, RB0)
408
+ await callTool('vibe_math_start', {}, RB0)
409
+ const re0 = verifyRe('p-offr')
410
+ const ok0 = await drive(RB0, () => unfiredVerifiers(RB0, re0).length >= 1, 'off-reply verifier')
411
+ assert(ok0, 'off mode: a verifier wake exists to carry the stray formal reply')
412
+ const un0 = unfiredVerifiers(RB0, re0).slice(0, 1)
413
+ if (un0.length) {
414
+ firedChildren.add(un0[0].childId)
415
+ fireEnd(un0[0].childId, { Result: 0.5, Reason: '普通评审', formal: { target: 'p-offr', decision: 'defect', note: '不应被记录' } })
416
+ }
417
+ await sleep(220)
418
+ const pj0 = projRoot('lean-off-reply')
419
+ assert(!existsSync(join(pj0, 'State', 'formal.json')), '★ a stray `formal` reply in off mode writes NO State/formal.json')
420
+ const st0 = await callTool('vibe_math_status', {}, RB0)
421
+ assert(st0.formal.objects.length === 0, '★ and records no formal object')
422
+ }
423
+
424
+ // ===============================================================
425
+ // 2. parameter validation + runtime switching
426
+ // ===============================================================
427
+ section('2 parameter validation and runtime switching')
428
+ const RB = makeRoot()
429
+ await callTool('vibe_math_new_project', { name: 'lean-params' }, RB)
430
+ const bad = await callTool('vibe_math_set_params', { formalVerify: 'banana' }, RB)
431
+ assert(bad.params.formalVerify === 'off', "an unknown mode degrades to 'off', never to a stronger mode (got " + bad.params.formalVerify + ')')
432
+ const bad2 = await callTool('vibe_math_set_params', { formalVerify: 'REQUIRE' }, RB)
433
+ assert(bad2.params.formalVerify === 'off', 'the mode is matched exactly (a case typo cannot force formalization)')
434
+ const enc = await callTool('vibe_math_set_params', { formalVerify: 'encourage' }, RB)
435
+ assert(enc.params.formalVerify === 'encourage', "'encourage' is accepted")
436
+ const req = await callTool('vibe_math_set_params', { formalVerify: 'require', leanTimeoutMs: -5, leanCommand: ' ' }, RB)
437
+ assert(req.params.formalVerify === 'require', "'require' is accepted")
438
+ assert(req.params.leanTimeoutMs === 120000, 'a non-positive leanTimeoutMs falls back to the default (' + req.params.leanTimeoutMs + ')')
439
+ assert((await callTool('vibe_math_set_params', { leanTimeoutMs: 0 }, RB)).params.leanTimeoutMs === 120000, 'leanTimeoutMs=0 also falls back to the default')
440
+ assert(req.params.leanCommand === 'lean', 'a blank leanCommand falls back to "lean"')
441
+ const t5 = await callTool('vibe_math_set_params', { leanTimeoutMs: 5000 }, RB)
442
+ assert(t5.params.leanTimeoutMs === 5000, 'a positive leanTimeoutMs is accepted (got ' + t5.params.leanTimeoutMs + ')')
443
+ const arrBad = await callTool('vibe_math_set_params', { leanArgs: 'not-an-array' }, RB)
444
+ assert(Array.isArray(arrBad.params.leanArgs) && arrBad.params.leanArgs.length === 0, 'a non-array leanArgs falls back to []')
445
+ await callTool('vibe_math_set_params', { leanCommand: 'lake', leanArgs: ['env', 'lean'] }, RB)
446
+ const stL = await callTool('vibe_math_status', {}, RB)
447
+ assert(stL.params.leanCommand === 'lake' && stL.params.leanArgs.join(' ') === 'env lean', 'leanCommand/leanArgs are settable (lake env lean)')
448
+ {
449
+ const setup = await callTool('vibe_math_setup', {}, RB)
450
+ const names = setup.parameters.map((p) => p.name)
451
+ assert(names.indexOf('formalVerify') !== -1 && names.indexOf('leanCommand') !== -1 && names.indexOf('leanArgs') !== -1 && names.indexOf('leanTimeoutMs') !== -1,
452
+ 'the settings schema surfaces all four Lean parameters')
453
+ const f = setup.parameters.find((p) => p.name === 'formalVerify')
454
+ assert(f.default === 'off' && f.current === 'require', 'the schema carries the default (off) and the current value (require)')
455
+ const tmpl = await callTool('vibe_math_template', { where: 'project' }, RB)
456
+ const tj = readIf(join(projRoot('lean-params'), 'vibe_math_setting.json'))
457
+ assert(tmpl.ok === true && /"formalVerify": "off"/.test(tj) && /"leanArgs": \[\]/.test(tj), 'the generated settings template documents the Lean parameters with their defaults')
458
+ assert(/formalVerify — /.test(tj), 'the template carries the human-readable parameter description as a comment')
459
+ }
460
+
461
+ // ===============================================================
462
+ // 3. 'encourage' injection into the WORK prompts
463
+ // ===============================================================
464
+ section("3 'encourage' injects 顺手形式化 into solver / explorer / method-keeper prompts")
465
+ const RC = makeRoot()
466
+ await callTool('vibe_math_new_project', { name: 'lean-work' }, RC)
467
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage', methodKeepEvery: 1 }), RC)
468
+ await callTool('vibe_math_add_problem', { id: 'qE', description: '证明 log 2 是无理数', priority: 0 }, RC)
469
+ await callTool('vibe_math_start', {}, RC)
470
+ assert(await drive(RC, () => !!lastSpawn(RC, 'explorer:qE'), 'explorer:qE'), 'fallback scheduler spawned explorer:qE')
471
+ {
472
+ const p = lastSpawn(RC, 'explorer:qE').prompt
473
+ assert(/【顺手形式化(鼓励)】/.test(p), 'the explorer work prompt carries the 顺手形式化 line')
474
+ assert(/vibe_math_lean_archive kind='def'/.test(p), 'the work prompt points at the archive tool for reusable definitions')
475
+ assert(/vibe_math_lean_lib 查重/.test(p), 'the work prompt tells agents to check the reuse library first')
476
+ assert(/形式化回执/.test(p) && /"decision":"used\|blocked\|defect"/.test(p), '★ the work-round reply contract also advertises the formal field, defect included (契约 §6.3)')
477
+ }
478
+ fireEnd(lastSpawn(RC, 'explorer:qE').childId, { meta: { kind: 'directions', qid: 'qE', formal: { target: 'qE', decision: 'blocked', note: '需要先形式化连分数收敛定理' }, directions: [{ id: 'd1', title: '连分数法', method: 'e 的连分数', core_assumption: '', feasibility: 0.7 }] } })
479
+ // Answer EVERY explorer the fallback scheduler dispatches (it re-derives when a direction looks
480
+ // unattended) until the solver for d1 appears — otherwise the retry cap can stall the project.
481
+ for (let guard = 0; guard < 40 && !lastSpawn(RC, 'solver:qE:d1'); guard++) {
482
+ const ex = spawns.filter((s) => s.rootId === RC.id && s.label.startsWith('explorer:qE') && !firedChildren.has(s.childId)).slice(-1)[0]
483
+ if (ex) {
484
+ firedChildren.add(ex.childId)
485
+ fireEnd(ex.childId, { meta: { kind: 'directions', qid: 'qE', directions: [{ id: 'd1', title: '连分数法', method: 'e 的连分数', core_assumption: '', feasibility: 0.7 }] } })
486
+ }
487
+ await pump(RC)
488
+ await sleep(80)
489
+ }
490
+ assert(!!lastSpawn(RC, 'solver:qE:d1'), 'solver spawned for direction d1')
491
+ assert(/【顺手形式化(鼓励)】/.test(lastSpawn(RC, 'solver:qE:d1').prompt), 'the solver work prompt carries the 顺手形式化 line')
492
+ {
493
+ // the explorer's `meta.formal` judgement is recorded even though it never called a Lean tool
494
+ const st = await callTool('vibe_math_status', {}, RC)
495
+ assert(st.formal.blocked.indexOf('qE') !== -1, '★ an explorer `meta.formal` judgement is recorded (no Lean tool call needed)')
496
+ assert(/- 形式化: 阻塞(/.test(readIf(join(projRoot('lean-work'), 'Problems', 'qE.md'))), 'and it lands on the problem card anchor')
497
+ }
498
+ fireEnd(lastSpawn(RC, 'solver:qE:d1').childId, { status: 'continue', survival_probability: 0.6, formal: { target: 'd1-lemma', decision: 'used', file: 'Formal/d1-lemma.lean' }, new_inventions: [{ 类型: '工具', 标题: '连分数估值工具', 内容描述: '控制收敛速度', 是否已入库: false }] })
499
+ await sleep(250)
500
+ {
501
+ const st = await callTool('vibe_math_status', {}, RC)
502
+ assert(st.formal.objects.some((o) => o.target === 'd1-lemma' && o.status === 'attempted'), 'a solver top-level `formal.decision=used` reply records the object as attempted')
503
+ }
504
+ assert(await drive(RC, () => !!lastSpawn(RC, 'method-keeper'), 'method-keeper'), 'method keeper spawned for the pending invention')
505
+ {
506
+ const p = lastSpawn(RC, 'method-keeper').prompt
507
+ assert(/【顺手形式化(鼓励)】/.test(p), 'the method-keeper prompt carries the 顺手形式化 line')
508
+ assert(/【方法沉淀 × Lean 形式化】/.test(p), '★ the method keeper is told to ALSO sediment reusable Lean definitions/lemmas')
509
+ assert(/kind='lemma'/.test(p) && /Proved/.test(p), 'and where the proved lemmas go')
510
+ }
511
+ await callTool('vibe_math_abort', {}, RC)
512
+ assert((await callTool('vibe_math_status', {}, RC)).running === false, 'work-prompt scenario aborted (keeps later scenarios deterministic)')
513
+
514
+ // ===============================================================
515
+ // 4. 'encourage' injection into BOTH verification prompts + the fidelity switch
516
+ // ===============================================================
517
+ section("4 'encourage' reaches the review prompt and the debate prompt")
518
+ const RD = makeRoot()
519
+ await callTool('vibe_math_new_project', { name: 'lean-verify' }, RD)
520
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage', debateMaxRounds: 2 }), RD)
521
+ await callTool('vibe_math_add_proposition', { id: 'p-enc', 概述: '鼓励模式下的忠实性审查', 概率: 0.6, 分类: '数论' }, RD)
522
+ await callTool('vibe_math_start', {}, RD)
523
+ const encBatch = await runVerifyRound(RD, 'p-enc', [0.9, 0.95])
524
+ assert(encBatch !== null, 'encourage: the first review round was asked')
525
+ if (encBatch) assertDistinctSlots(encBatch, 'encourage round')
526
+ if (encBatch) {
527
+ const vp = encBatch.map((s) => s.prompt).join('\n')
528
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(vp), 'the review prompt explains the Lean mode')
529
+ assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(vp), 'the review prompt states that a passing Lean run shrinks the question to fidelity')
530
+ assert(/实现难度/.test(vp), 'the review prompt asks for the implementation-difficulty judgement')
531
+ assert(/可以不做,但请在回执的 formal 字段写明难度判断/.test(vp), "'encourage' explicitly allows skipping (with a recorded judgement)")
532
+ assert(/"formal":/.test(vp) && /"decision":"used\|blocked\|defect"/.test(vp), 'the reply contract documents the formal field (defect included)')
533
+ assert(/vibe_math_lean_run/.test(vp) && /vibe_math_lean_archive/.test(vp) && /vibe_math_lean_lib/.test(vp), 'the prompt names the three v3 Lean tools')
534
+ assert(/Formal\/(相对项目根)/.test(vp) && /VibeMath\/Formal\/Lib/.test(vp), 'the prompt states the path layout')
535
+ }
536
+ // a non-consensus round moves to the public debate, whose prompt must carry the same injection
537
+ assert(await drive(RD, () => wakes.some((w) => w.rootId === RD.id && /交流群/.test(w.prompt)), 'debate prompt'), 'a non-consensus round moved to the debate')
538
+ {
539
+ const dp = wakes.filter((w) => w.rootId === RD.id).map((w) => w.prompt).join('\n')
540
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(dp), 'the debate prompt names the mode')
541
+ assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性|你不需要重新检查推导/.test(dp), 'the debate prompt carries the Lean block too')
542
+ assert(/"formal":/.test(dp), 'the debate reply contract carries the formal field')
543
+ }
544
+ // finish the debate: a unanimous round concludes
545
+ if (encBatch) for (const b of encBatch) fireEnd(b.childId, { Result: 1, Reason: 'mock 第二轮一致' })
546
+ assert(await drive(RD, () => existsSync(join(projRoot('lean-verify'), 'Verified', '命题', 'p-enc.md')), 'p-enc verified'), 'the debate finished and the object was verified')
547
+
548
+ // ===============================================================
549
+ // 5. lean_run executes through the subprocess service and reports honestly
550
+ // ===============================================================
551
+ section('5 lean_run executes through the subprocess service and reports honestly')
552
+ const RE = makeRoot()
553
+ await callTool('vibe_math_new_project', { name: 'lean-tools' }, RE)
554
+ await callTool('vibe_math_set_params', { formalVerify: 'encourage' }, RE)
555
+ await callTool('vibe_math_add_proposition', { id: 'p-tool', 概述: '工具实验命题', 概率: 0.6, 分类: '数论' }, RE)
556
+ const toolProj = projRoot('lean-tools')
557
+ mkdirSync(join(toolProj, 'Formal'), { recursive: true })
558
+ writeFileSync(join(toolProj, 'Formal', 'good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
559
+ writeFileSync(join(toolProj, 'Formal', 'bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
560
+ writeFileSync(join(toolProj, 'Formal', 'hang.lean'), '-- HANG\ntheorem t : 1 = 1 := rfl\n', 'utf8')
561
+ {
562
+ const runGood = await callTool('vibe_math_lean_run', { file: 'Formal/good.lean', target: 'p-tool' }, RE)
563
+ assert(runGood.ok === true && runGood.exitCode === 0, 'a file with no sorry runs green (' + JSON.stringify({ ok: runGood.ok, exitCode: runGood.exitCode }) + ')')
564
+ assert(/ok/.test(runGood.stdout), 'the compiler stdout is returned')
565
+ const last = leanRuns[leanRuns.length - 1]
566
+ assert(last.cwd.replace(/\\/g, '/') === toolProj.replace(/\\/g, '/'), 'the toolchain runs with the PROJECT root as cwd')
567
+ assert(last.argv.join(' ') === 'lean --search-path ' + VIBE.replace(/\\/g, '/'), 'by default the argv is the executable + the injected search path (got ' + last.argv.join(' ') + ')')
568
+ assert(last.graceMs === 120000, 'the default leanTimeoutMs is passed down as graceMs (' + last.graceMs + ')')
569
+ }
570
+ {
571
+ const st = await callTool('vibe_math_status', {}, RE)
572
+ const o = st.formal.objects.find((x) => x.target === 'p-tool')
573
+ assert(!!o && o.status === 'attempted', 'lean_run with target records the object as attempted (not passed)')
574
+ assert(!!o.run && o.run.ok === true, 'the run result is recorded on the object')
575
+ const card = readIf(join(toolProj, 'Propos', '数论', 'p-tool.md'))
576
+ assert(/- 形式化: 已尝试未通过/.test(card), '★ the object card anchor block gains `- 形式化: <状态>` alongside 状态/概率')
577
+ assert(/\n- ID: p-tool\n/.test(card) && /\n- 状态: 未定论\n/.test(card) && /\n- 概率: 0.6\n/.test(card), 'the pre-existing anchors are untouched (the scheduler parser keeps working)')
578
+ }
579
+ {
580
+ const runBad = await callTool('vibe_math_lean_run', { file: 'Formal/bad.lean' }, RE)
581
+ assert(runBad.ok === false && runBad.exitCode === 1, 'a file that still uses sorry reports a red run')
582
+ assert(/sorry/.test(runBad.stderr), 'the compiler output is returned verbatim (' + JSON.stringify(runBad.stderr).slice(0, 60) + ')')
583
+ assert(/修复后重跑/.test(runBad.hint || ''), 'a genuine compile failure still points at the compiler output (the hint is failure-code aware, not blanket)')
584
+ }
585
+ {
586
+ const runMissing = await callTool('vibe_math_lean_run', { file: 'Formal/nope.lean' }, RE)
587
+ assert(runMissing.ok === false && runMissing.code === 'V3_NOT_FOUND', 'a missing file is refused with a typed code')
588
+ }
589
+ {
590
+ // The guard's boundary is the VibeMath ROOT, not the project: the global reuse library
591
+ // deliberately lives at <VibeMath>/Formal/{Lib,Proved}, so climbing out of the project but
592
+ // staying inside VibeMath must remain legal (it just fails as a missing file).
593
+ const inside = await callTool('vibe_math_lean_run', { file: '../../Formal/Lib/x.lean' }, RE)
594
+ assert(inside.code === 'V3_NOT_FOUND', 'climbing out of the project but staying inside VibeMath is allowed (the global library lives there)')
595
+ const esc1 = await callTool('vibe_math_lean_run', { file: '../../../evil.lean' }, RE)
596
+ assert(esc1.ok === false && esc1.code === 'V3_INVALID_ARGUMENT', '★ a traversal that climbs ABOVE the VibeMath root is refused')
597
+ const esc2 = await callTool('vibe_math_lean_run', { file: 'Formal/../../../../../../evil.lean' }, RE)
598
+ assert(esc2.ok === false && esc2.code === 'V3_INVALID_ARGUMENT', 'a deeper traversal is refused too')
599
+ const esc3 = await callTool('vibe_math_lean_run', { file: '/etc/evil.lean' }, RE)
600
+ assert(esc3.ok === false && esc3.code === 'V3_INVALID_ARGUMENT', 'an unrelated absolute path is refused')
601
+ const notLean = await callTool('vibe_math_lean_run', { file: 'Formal/good.txt' }, RE)
602
+ assert(notLean.ok === false && notLean.code === 'V3_INVALID_ARGUMENT', 'only .lean files can be executed')
603
+ const noFile = await callTool('vibe_math_lean_run', {}, RE)
604
+ assert(noFile.ok === false && noFile.code === 'V3_INVALID_ARGUMENT', 'a missing file argument is refused')
605
+ }
606
+ {
607
+ toolchainAvailable = false
608
+ // task-6: the missing-toolchain simulation must deny the KNOWN-LOCATION fallback too (a machine with
609
+ // Lean installed would otherwise resolve it through %ELAN_HOME%/~/.elan and the run would succeed).
610
+ const _tcSaved = {}; for (const _k of ['ELAN_HOME', 'USERPROFILE', 'HOME', 'LOCALAPPDATA']) _tcSaved[_k] = process.env[_k]
611
+ 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'
612
+ let runNoTc
613
+ try {
614
+ runNoTc = await callTool('vibe_math_lean_run', { file: 'Formal/good.lean' }, RE)
615
+ assert(runNoTc.ok === false && runNoTc.code === 'LEAN_NOT_FOUND', 'a missing toolchain returns LEAN_NOT_FOUND instead of crashing')
616
+ assert(/仍可把形式化代码写下来归档/.test(runNoTc.message), 'the failure explains the graceful degradation')
617
+ } finally { for (const _k of Object.keys(_tcSaved)) { if (_tcSaved[_k] === undefined) delete process.env[_k]; else process.env[_k] = _tcSaved[_k] } }
618
+ assert(/本宿主无法执行 Lean(LEAN_NOT_FOUND)/.test(runNoTc.hint || '') && !/编译器输出修复后重跑/.test(runNoTc.hint || ''),
619
+ '★ the failure hint never tells the agent to fix compiler output that does not exist — it points at the archive + explicit-blocker way out (§6 hard requirement 4)')
620
+ toolchainAvailable = true
621
+ }
622
+ {
623
+ subprocessAvailable = false
624
+ const runNoSub = await callTool('vibe_math_lean_run', { file: 'Formal/good.lean' }, RE)
625
+ assert(runNoSub.ok === false && runNoSub.code === 'NO_SUBPROCESS', 'a host without the subprocess service returns NO_SUBPROCESS')
626
+ assert(/本宿主无法执行 Lean(NO_SUBPROCESS)/.test(runNoSub.hint || '') && /vibe_math_lean_archive/.test(runNoSub.hint || ''),
627
+ '★ the NO_SUBPROCESS hint names the way out with the FULL tool name (no retry loop on an impossible run)')
628
+ subprocessAvailable = true
629
+ }
630
+ {
631
+ const runHang = await callTool('vibe_math_lean_run', { file: 'Formal/hang.lean', timeout_ms: 1000 }, RE)
632
+ assert(runHang.ok === false && runHang.code === 'LEAN_TIMEOUT' && runHang.timedOut === true, '★ a hanging toolchain returns LEAN_TIMEOUT instead of wedging the scheduler')
633
+ assert(terminated.some((f) => /hang\.lean$/.test(f)), '★ the timeout path calls handle.terminate()')
634
+ }
635
+ {
636
+ spawnThrows = true
637
+ const runSpawn = await callTool('vibe_math_lean_run', { file: 'Formal/good.lean' }, RE)
638
+ assert(runSpawn.ok === false && runSpawn.code === 'LEAN_SPAWN_FAILED', 'a spawn failure is reported as LEAN_SPAWN_FAILED instead of throwing')
639
+ spawnThrows = false
640
+ writeFileSync(join(toolProj, 'Formal', 'reject.lean'), '-- REJECT\ntheorem t : 1 = 1 := rfl\n', 'utf8')
641
+ const runReject = await callTool('vibe_math_lean_run', { file: 'Formal/reject.lean' }, RE)
642
+ assert(runReject.ok === false && runReject.code === 'LEAN_RUN_FAILED', 'a rejecting handle.done is reported as LEAN_RUN_FAILED instead of throwing into the loop')
643
+ }
644
+ {
645
+ await callTool('vibe_math_set_params', { leanCommand: 'lake', leanArgs: ['env', 'lean'] }, RE)
646
+ const runLake = await callTool('vibe_math_lean_run', { file: 'Formal/good.lean' }, RE)
647
+ assert(runLake.ok === true, 'lake env lean works through leanCommand/leanArgs')
648
+ assert(leanRuns[leanRuns.length - 1].argv.join(' ') === 'lake env lean --search-path ' + VIBE.replace(/\\/g, '/'), 'the argv is [exe, ...leanArgs, injected --search-path] (got ' + leanRuns[leanRuns.length - 1].argv.join(' ') + ')')
649
+ await callTool('vibe_math_set_params', { leanCommand: 'lean', leanArgs: [] }, RE)
650
+ }
651
+
652
+ // ===============================================================
653
+ // 6. lean_archive: def / lemma / proof / blocked → the right paths + indexes
654
+ // ===============================================================
655
+ section('6 lean_archive writes the contract paths and rebuilds the indexes')
656
+ const libPath = join(VIBE, 'Formal', 'Lib')
657
+ const provedPath = join(VIBE, 'Formal', 'Proved')
658
+ {
659
+ const defRes = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'ZMod5', content: 'def ZMod5 := Fin 5\n' }, RE)
660
+ assert(defRes.ok === true && defRes.file === 'Formal/Lib/ZMod5.lean', 'a reusable definition is archived to the global lib (' + defRes.file + ')')
661
+ assert(existsSync(join(libPath, 'ZMod5.lean')), '★ the definition exists under VibeMath/Formal/Lib/ (cross-project, NOT inside the project)')
662
+ assert(!existsSync(join(toolProj, 'Formal', 'Lib', 'ZMod5.lean')), 'it is NOT duplicated inside the project tree')
663
+ assert(defRes.run && defRes.run.ok === true, 'the reusable definition is executed on archive (run defaults to true)')
664
+ const libIdx = readIf(join(libPath, 'Index.md'))
665
+ assert(/\| 名称 \| 文件 \| 类别 \| 依赖(import) \| 摘要 \| 最近运行 \|/.test(libIdx), 'Lib/Index.md uses the contract columns (incl. the import-dependency column, spec §4.4)')
666
+ assert(/ZMod5/.test(libIdx) && /Lib\/ZMod5\.lean/.test(libIdx) && /\| ok \|/.test(libIdx), 'Lib/Index.md lists the new definition with its run result')
667
+ assert(!/ZMod5/.test(readIf(join(toolProj, 'Formal', 'Index.md'))), 'the global definition does not pollute the project formal index')
668
+ }
669
+ {
670
+ const lemRes = await callTool('vibe_math_lean_archive', { kind: 'lemma', name: 'sq_odd', content: 'import Mathlib\n\ntheorem sq_odd (n : Nat) : Odd (n*n) → Odd n := by omega\n' }, RE)
671
+ assert(lemRes.ok === true && lemRes.file === 'Formal/Proved/sq_odd.lean', 'a lemma is archived to Proved/')
672
+ assert(existsSync(join(provedPath, 'sq_odd.lean')), 'the lemma exists under VibeMath/Formal/Proved/')
673
+ const provedIdx = readIf(join(provedPath, 'Index.md'))
674
+ assert(/\| 名称 \| 文件 \| 陈述 \| 依赖 \| 最近运行 \|/.test(provedIdx), 'Proved/Index.md uses the contract columns')
675
+ assert(/sq_odd/.test(provedIdx) && /Mathlib/.test(provedIdx), 'Proved/Index.md lists the lemma and its imports as 依赖')
676
+ }
677
+ {
678
+ const noRun = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'NoRun', content: 'def NoRun := 1\n', run: false }, RE)
679
+ assert(noRun.ok === true && noRun.run === undefined, 'run=false archives WITHOUT executing (no toolchain needed)')
680
+ }
681
+ {
682
+ const noName = await callTool('vibe_math_lean_archive', { kind: 'def', content: 'def x := 1\n' }, RE)
683
+ assert(noName.ok === false && noName.code === 'V3_INVALID_ARGUMENT', 'archiving a definition without a name is refused')
684
+ const badKind = await callTool('vibe_math_lean_archive', { kind: 'nonsense' }, RE)
685
+ assert(badKind.ok === false && badKind.code === 'V3_INVALID_ARGUMENT', 'an unknown archive kind is refused')
686
+ const noTarget = await callTool('vibe_math_lean_archive', { kind: 'proof', content: 'theorem x : 1 = 1 := rfl\n' }, RE)
687
+ assert(noTarget.ok === false && noTarget.code === 'V3_INVALID_ARGUMENT', 'kind=proof without a target is refused')
688
+ const noBody = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-x' }, RE)
689
+ assert(noBody.ok === false && noBody.code === 'V3_INVALID_ARGUMENT', 'kind=proof without content/from is refused')
690
+ const badFrom = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-x', from: '../../../evil.lean' }, RE)
691
+ assert(badFrom.ok === false && badFrom.code === 'V3_INVALID_ARGUMENT', 'kind=proof with an out-of-tree from is refused')
692
+ }
693
+ {
694
+ // a green proof → passed + Verified/Lean (the archived proof lives next to the conclusion)
695
+ const arc = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-proof', content: 'theorem p_proof : 3 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n' }, RE)
696
+ assert(arc.ok === true && arc.passed === true, 'the proof is archived and passes (' + JSON.stringify({ ok: arc.ok, passed: arc.passed }) + ')')
697
+ assert(arc.file === 'Formal/p-proof.lean', 'the working file is Formal/<target>.lean')
698
+ assert(arc.proof === 'Verified/Lean/p-proof.lean', 'the archived proof path is Verified/Lean/<target>.lean')
699
+ assert(existsSync(join(toolProj, 'Formal', 'p-proof.lean')), 'the working file exists on disk')
700
+ assert(existsSync(join(toolProj, 'Verified', 'Lean', 'p-proof.lean')), '★ the proof is archived under Verified/Lean/ as the proof of that object')
701
+ const st = await callTool('vibe_math_status', {}, RE)
702
+ assert(st.formal.passed.indexOf('p-proof') !== -1, 'status reports the object as Lean-passed')
703
+ const idx = readIf(join(toolProj, 'Formal', 'Index.md'))
704
+ assert(/# Lean 形式化索引/.test(idx) && /p-proof/.test(idx) && /passed/.test(idx) && /Verified\/Lean\/p-proof\.lean/.test(idx), 'Formal/Index.md indexes the object, its status and its archived proof')
705
+ assert(/ok(exit 0,/.test(idx), 'the index shows the run result in the contract format')
706
+ // a RED proof must NOT become passed
707
+ const red = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-red', content: 'theorem p_red : 1 = 2 := by sorry\n' }, RE)
708
+ assert(red.ok === true && red.passed === false && red.status === 'attempted', 'a red proof is archived as attempted, not passed')
709
+ assert(!existsSync(join(toolProj, 'Verified', 'Lean', 'p-red.lean')), '★ no archived proof is written for a red run')
710
+ }
711
+ {
712
+ const blkNoNote = await callTool('vibe_math_lean_archive', { kind: 'blocked', target: 'p-blk' }, RE)
713
+ assert(blkNoNote.ok === false && blkNoNote.code === 'V3_INVALID_ARGUMENT', 'blocked without a note is refused')
714
+ const blk = await callTool('vibe_math_lean_archive', { kind: 'blocked', target: 'p-blk', note: '需要外层解析数论框架,本轮工作量不可接受' }, RE)
715
+ assert(blk.ok === true && blk.status === 'blocked', 'a reasoned blocker is recorded')
716
+ const stBlk = await callTool('vibe_math_status', {}, RE)
717
+ assert(stBlk.formal.blocked.indexOf('p-blk') !== -1, 'status lists the blocked object')
718
+ assert(/需要外层解析数论框架/.test(readIf(join(toolProj, 'Formal', 'Index.md'))), 'the blocker reason is written into the index')
719
+ }
720
+ {
721
+ const libList = await callTool('vibe_math_lean_lib', {}, RE)
722
+ assert(libList.ok === true && libList.counts.lib >= 2 && libList.counts.proved >= 1, 'lean_lib reports the reuse library sizes (' + JSON.stringify(libList.counts) + ')')
723
+ assert(libList.objects.some((o) => o.target === 'p-proof' && o.status === 'passed'), 'lean_lib lists per-object formal status')
724
+ assert(/复用优先/.test(libList.hint || ''), 'lean_lib tells agents to reuse before redefining')
725
+ assert(libList.paths && /VibeMath\/Formal\/Lib/.test(libList.paths.lib), 'lean_lib reports the cross-project path layout')
726
+ const ro = await callTool('vibe_math_lean_lib', { refresh: false }, RE)
727
+ assert(ro.rebuilt === false && ro.counts.lib === null, 'refresh:false only reads (does not rebuild the indexes)')
728
+ }
729
+ {
730
+ const ann = readIf(join(toolProj, 'Logs', '形式化.md'))
731
+ assert(/# 形式化公告/.test(ann) && /ZMod5/.test(ann) && /p-proof/.test(ann), 'the announcement log records what was formalized')
732
+ }
733
+
734
+ // ===============================================================
735
+ // 7. the 'require' gate
736
+ // ===============================================================
737
+ section("7 'require' withholds a verdict until the formal record exists")
738
+ const RF = makeRoot()
739
+ await callTool('vibe_math_new_project', { name: 'lean-gate' }, RF)
740
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RF)
741
+ await callTool('vibe_math_add_proposition', { id: 'p-gate', 概述: '必须形式化的命题', 概率: 0.6, 分类: '数论' }, RF)
742
+ const gateProj = projRoot('lean-gate')
743
+ await callTool('vibe_math_start', {}, RF)
744
+ {
745
+ const batch = await runVerifyRound(RF, 'p-gate', [1, 1])
746
+ gateSlots1 = slotsOf(batch) // V3-G2: measured ["0","1"]
747
+ assert(batch !== null, 'require: the review round was asked')
748
+ if (batch) {
749
+ const vp = batch.map((s) => s.prompt).join('\n')
750
+ assert(/【Lean 形式化验证(强制模式)】/.test(vp), 'the review prompt says 强制模式')
751
+ assert(/必须产出 Lean 形式化/.test(vp), "'require' states the formalization is mandatory")
752
+ assert(/本次裁定不会生效/.test(vp), 'the prompt warns that the verdict will not take effect without it')
753
+ assert(/formal-required/.test(vp), 'the prompt names the machine-readable reason')
754
+ assert(/归档可复用定义\/引理前先跑通(vibe_math_lean_archive run=true 或先 vibe_math_lean_run);跑不通不要入库。/.test(vp),
755
+ '★ the verification prompt requires a GREEN run before archiving into the reuse library (§6 hard requirement 3)')
756
+ assert(/宿主没有 Lean 工具链(LEAN_NOT_FOUND)或宿主不提供 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"/.test(vp),
757
+ '★ and spells out the way out when the host has no Lean toolchain, naming BOTH failure codes (LEAN_NOT_FOUND / NO_SUBPROCESS, §6 hard requirement 4)')
758
+ assert(/Result/.test(vp) && !/verdict/.test(vp) && !/(^|[^a-z_])lean_(run|archive|lib)/.test(vp),
759
+ '★ the voting prompt uses FULL tool names and names Result, never verdict (§6 hard requirements 1-2)')
760
+ }
761
+ }
762
+ assert(await drive(RF, () => /p-gate/.test(readIf(join(gateProj, 'Formal', 'TODO.md'))), 'p-gate in Formal/TODO.md'), '★ a unanimous TRUE verdict was withheld: the object is on the formalization TODO')
763
+ assert(!existsSync(join(gateProj, 'Verified', '命题', 'p-gate.md')), 'no Verified card was written')
764
+ {
765
+ const card = readIf(join(gateProj, 'Propos', '数论', 'p-gate.md'))
766
+ assert(/- 概率: 0.6/.test(card), '★ the object keeps its existing probability (no silent promotion to 1)')
767
+ assert(/- 状态: 未定论/.test(card), 'and its 状态 stays 未定论')
768
+ const todo = readIf(join(gateProj, 'Formal', 'TODO.md'))
769
+ assert(/# 形式化待办/.test(todo) && /p-gate/.test(todo) && /formal-required/.test(todo), '★ the TODO records the object with the machine-readable reason')
770
+ const idx = readIf(join(gateProj, 'Formal', 'Index.md'))
771
+ assert(/## 形式化待办/.test(idx) && /p-gate/.test(idx), 'Formal/Index.md mirrors the formalization TODO')
772
+ const ann = readIf(join(gateProj, 'Logs', '形式化.md'))
773
+ assert(/require 模式/.test(ann) && /不定论/.test(ann), '★ the withholding is announced')
774
+ const st = await callTool('vibe_math_status', {}, RF)
775
+ assert(st.formal.todo.some((t) => t.id === 'p-gate'), 'status lists the deferred object')
776
+ assert(/formal-required/.test(st.recentActivity.map((e) => e.detail).join('\n')), 'the activity feed shows the formal-required deferral')
777
+ const voteLog = listDirIf(join(gateProj, 'Logs', 'Verification')).filter((f) => /^r-p-gate_/.test(f))
778
+ assert(voteLog.length >= 1, 'the votes themselves are still recorded in Logs/Verification (nothing is lost)')
779
+ }
780
+ // runtime switch: the mode is read when the prompt is CONSTRUCTED, so a fresh round reflects it
781
+ await callTool('vibe_math_add_proposition', { id: 'p-mode', 概述: '模式切换观察对象', 概率: 0.6, 分类: '数论' }, RF)
782
+ await restart(RF)
783
+ assert(await drive(RF, () => unfiredVerifiers(RF, verifyRe('p-mode')).length >= 2, 'fresh verifiers in require mode'), 'a fresh object is still verified in require mode')
784
+ {
785
+ const fresh = unfiredVerifiers(RF, verifyRe('p-mode'))[0]
786
+ assert(!!fresh && /【Lean 形式化验证(强制模式)】/.test(fresh.prompt) && /本次裁定不会生效/.test(fresh.prompt), 'a fresh round reflects the current mode (强制模式)')
787
+ }
788
+ // ★ anti-idle: a deferred object must NOT be re-voted while it sits on the TODO (each round would
789
+ // just be withheld again, burning verifiers on the same rId every tick). The assertion is only
790
+ // meaningful because the previous block proved the scheduler IS allocating verify tasks here.
791
+ assert(unfiredVerifiers(RF, verifyRe('p-gate')).length === 0, '★ a deferred object is NOT re-voted while it sits on the formalization TODO (no verification livelock)')
792
+ await callTool('vibe_math_set_params', { formalVerify: 'encourage' }, RF)
793
+ await restart(RF)
794
+ assert(await drive(RF, () => unfiredVerifiers(RF, verifyRe('p-mode')).length >= 2, 'fresh verifiers after the mode switch'), 'the unresolved object is verified again after the mode switch')
795
+ {
796
+ const fresh = unfiredVerifiers(RF, verifyRe('p-mode'))[0]
797
+ assert(!!fresh && /【Lean 形式化验证(鼓励模式)】/.test(fresh.prompt), '★ switching the mode at runtime immediately changes the prompt (now 鼓励模式)')
798
+ assert(!!fresh && !/本次裁定不会生效/.test(fresh.prompt), 'and the mandatory wording is gone in encourage mode')
799
+ }
800
+ await callTool('vibe_math_set_params', { formalVerify: 'require' }, RF)
801
+ // formalize it, restart (so the next prompt is built AFTER the proof exists), then re-verify
802
+ {
803
+ const proofNow = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-gate', content: 'theorem p_gate : 2 + 2 = 4 := by decide\n' }, RF)
804
+ assert(proofNow.ok === true && proofNow.passed === true, 'the object is now Lean-passed')
805
+ const st = await callTool('vibe_math_status', {}, RF)
806
+ assert(st.formal.passed.indexOf('p-gate') !== -1, 'status reports p-gate as passed')
807
+ assert(!st.formal.todo.some((t) => t.id === 'p-gate'), '★ satisfying the gate removes the object from the formalization TODO (no stale TODO)')
808
+ assert(!/p-gate/.test(readIf(join(gateProj, 'Formal', 'TODO.md'))), 'Formal/TODO.md no longer lists it')
809
+ assert(/- 形式化: Lean 通过/.test(readIf(join(gateProj, 'Propos', '数论', 'p-gate.md'))), 'the object card anchor now records Lean 通过')
810
+ }
811
+ await restart(RF)
812
+ assert(await drive(RF, () => unfiredVerifiers(RF, verifyRe('p-gate')).length >= 2, 'p-gate re-eligible after passing'), '★ once the formal record satisfies the gate, the object becomes eligible for verification again')
813
+ {
814
+ // V3-G2: `.length >= 2` is a COUNT - the semantic unit is that the RE-ELIGIBLE reviews are the
815
+ // SAME slots that fired in round one (a re-verification that re-armed different slots would
816
+ // still satisfy the count). Measured round-one slots: ["0","1"].
817
+ const un = unfiredVerifiers(RF, verifyRe('p-gate'))
818
+ const slots = slotsOf(un)
819
+ assert(slots.slice().sort().join(',') === gateSlots1.slice().sort().join(','), '* V3-G2 the re-eligible reviews are the SAME slots that fired in round one (before=' + JSON.stringify(gateSlots1) + ' after=' + JSON.stringify(slots) + ')')
820
+ assert(new Set(slots).size === slots.length, '* V3-G2 and they are still DISTINCT slots (the V3-G1 clause restated at this site)')
821
+ }
822
+ {
823
+ const batch = await runVerifyRound(RF, 'p-gate', [1, 1])
824
+ assert(batch !== null, 'the re-verification round was asked')
825
+ if (batch) {
826
+ const vp = batch.map((s) => s.prompt).join('\n')
827
+ assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(vp), 'the voting prompt announces the passing proof')
828
+ assert(/你不需要重新检查推导/.test(vp), '★ it tells voters NOT to re-derive')
829
+ assert(/忠实性审查/.test(vp), '★ it tells voters the review subject is now fidelity')
830
+ assert(/定义 \/ 对象 \/ 条件 \/ 假设 \/ 结论是否与命题原文\*\*完全一致\*\*/.test(vp), 'it enumerates exactly what fidelity means')
831
+ assert(!/必须产出 Lean 形式化/.test(vp), 'and the "must formalize" wording is replaced (the object already has one)')
832
+ }
833
+ }
834
+ assert(await drive(RF, () => existsSync(join(gateProj, 'Verified', '命题', 'p-gate.md')), 'p-gate Verified card'), '★ with a passing Lean artifact the same vote DOES promote it')
835
+ {
836
+ const card = readIf(join(gateProj, 'Verified', '命题', 'p-gate.md'))
837
+ assert(/- 形式化: Lean 通过/.test(card), '★ the Verified card records how strong the result is (Lean 通过)')
838
+ assert(/Verified\/Lean\/p-gate\.lean/.test(card), 'the card points at the archived proof')
839
+ assert(/- 结论: 真/.test(card), 'the card states the conclusion')
840
+ }
841
+ // the blocker escape hatch must also open the gate
842
+ await callTool('vibe_math_add_proposition', { id: 'p-blocked-ok', 概述: '记录阻塞后可定论', 概率: 0.6, 分类: '数论' }, RF)
843
+ await callTool('vibe_math_lean_archive', { kind: 'blocked', target: 'p-blocked-ok', note: '命题涉及未形式化的分析学,本轮不做' }, RF)
844
+ await restart(RF)
845
+ assert(await runVerifyRound(RF, 'p-blocked-ok', [1, 1]) !== null, 'the blocked object is put to a vote')
846
+ assert(await drive(RF, () => existsSync(join(gateProj, 'Verified', '命题', 'p-blocked-ok.md')), 'p-blocked-ok Verified card'), '★ an explicit reasoned blocker also lets the verdict through (decide by difficulty, but decide out loud)')
847
+ {
848
+ const card = readIf(join(gateProj, 'Verified', '命题', 'p-blocked-ok.md'))
849
+ assert(/- 形式化: 阻塞(/.test(card), 'the Verified card records the blocker')
850
+ assert(/未形式化的分析学/.test(card), 'and quotes the reason')
851
+ }
852
+
853
+ // ===============================================================
854
+ // 8. the reply-channel judgement (formal field in the verifier reply)
855
+ // ===============================================================
856
+ section('8 the per-round `formal` reply channel records the difficulty judgement')
857
+ const RG = makeRoot()
858
+ await callTool('vibe_math_new_project', { name: 'lean-reply' }, RG)
859
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RG)
860
+ const replyProj = projRoot('lean-reply')
861
+
862
+ // (a) blocked WITH a note → recorded, reason reaches the index + the card anchor
863
+ await callTool('vibe_math_add_proposition', { id: 'p-reply', 概述: '用回执记录阻塞', 概率: 0.6, 分类: '数论' }, RG)
864
+ await callTool('vibe_math_start', {}, RG)
865
+ {
866
+ const re = verifyRe('p-reply')
867
+ assert(await drive(RG, () => unfiredVerifiers(RG, re).length >= 2, 'verifiers for r-p-reply'), 'p-reply was put to a vote')
868
+ const vs = unfiredVerifiers(RG, re).slice(0, 2)
869
+ if (vs.length === 2) {
870
+ for (const v of vs) firedChildren.add(v.childId)
871
+ // A verifier that never calls a Lean tool still has to state its difficulty judgement: the
872
+ // JSON reply channel is the path that actually fires in practice.
873
+ fireEnd(vs[0].childId, { Result: 0.5, Reason: '我判断形式化不划算', formal: { target: 'p-reply', decision: 'blocked', note: '需要大量未形式化的实分析前置知识' } })
874
+ await sleep(150)
875
+ const st1 = await callTool('vibe_math_status', {}, RG)
876
+ assert(st1.formal.blocked.indexOf('p-reply') !== -1, 'a `formal.decision=blocked` reply is recorded as a blocker')
877
+ assert(/实分析前置知识/.test(readIf(join(replyProj, 'Formal', 'Index.md'))), 'and its reason reaches the index')
878
+ assert(/- 形式化: 阻塞(/.test(readIf(join(replyProj, 'Propos', '数论', 'p-reply.md'))), 'the reply-recorded blocker also lands on the object card anchor')
879
+ fireEnd(vs[1].childId, { Result: 0.5, Reason: '同上' })
880
+ await sleep(200)
881
+ }
882
+ }
883
+ // (b) `used` records attempted — which does NOT open the require gate
884
+ await restart(RG)
885
+ await callTool('vibe_math_add_proposition', { id: 'p-used', 概述: '写了草稿但没跑通', 概率: 0.6, 分类: '数论' }, RG)
886
+ {
887
+ const re = verifyRe('p-used')
888
+ assert(await drive(RG, () => unfiredVerifiers(RG, re).length >= 2, 'verifiers for r-p-used'), 'p-used was put to a vote')
889
+ const vs = unfiredVerifiers(RG, re).slice(0, 2)
890
+ if (vs.length === 2) {
891
+ for (const v of vs) firedChildren.add(v.childId)
892
+ fireEnd(vs[0].childId, { Result: 1, Reason: '看起来对', formal: { target: 'p-used', decision: 'used', file: 'Formal/p-used.lean' } })
893
+ await sleep(150)
894
+ const st = await callTool('vibe_math_status', {}, RG)
895
+ const o = st.formal.objects.find((x) => x.target === 'p-used')
896
+ assert(!!o && o.status === 'attempted' && o.file === 'Formal/p-used.lean', 'a `formal.decision=used` reply records the object as attempted with its file')
897
+ fireEnd(vs[1].childId, { Result: 1, Reason: '同样看起来对' })
898
+ await sleep(250)
899
+ assert(!existsSync(join(replyProj, 'Verified', '命题', 'p-used.md')), '★ attempted is NOT passed: the require gate still withholds the verdict')
900
+ assert(/p-used/.test(readIf(join(replyProj, 'Formal', 'TODO.md'))), 'and the object is on the formalization TODO')
901
+ }
902
+ }
903
+ // (c) a blocker with no note is refused (an explicit decision is required, never a silent skip)
904
+ await restart(RG)
905
+ await callTool('vibe_math_add_proposition', { id: 'p-nonote', 概述: '没有理由的阻塞', 概率: 0.6, 分类: '数论' }, RG)
906
+ {
907
+ const re = verifyRe('p-nonote')
908
+ assert(await drive(RG, () => unfiredVerifiers(RG, re).length >= 2, 'verifiers for r-p-nonote'), 'p-nonote was put to a vote')
909
+ const vs = unfiredVerifiers(RG, re).slice(0, 2)
910
+ if (vs.length === 2) {
911
+ for (const v of vs) firedChildren.add(v.childId)
912
+ fireEnd(vs[0].childId, { Result: 0.5, Reason: '不想做', formal: { target: 'p-nonote', decision: 'blocked' } })
913
+ await sleep(180)
914
+ assert(/未写明 note/.test(readIf(join(replyProj, 'Logs', '形式化.md'))), 'a blocked judgement without a note is refused with an explicit announcement')
915
+ const st = await callTool('vibe_math_status', {}, RG)
916
+ assert(st.formal.objects.every((o) => o.target !== 'p-nonote'), 'and no blocker record is created for the refused judgement')
917
+ // a formal reply without a target must not invent an object (idSafe('') would fall back to 'id')
918
+ fireEnd(vs[1].childId, { Result: 0.5, Reason: '同上', formal: { decision: 'blocked', note: '没有写 target' } })
919
+ await sleep(180)
920
+ const st2 = await callTool('vibe_math_status', {}, RG)
921
+ assert(st2.formal.objects.every((o) => o.target !== 'id' && o.target !== ''), 'a `formal` reply with no target cannot invent an object record')
922
+ }
923
+ }
924
+ await callTool('vibe_math_abort', {}, RG)
925
+
926
+ // ===============================================================
927
+ // 8b. the §4.1 `defect` channel (contract §4.1 / §6 / §10 items 8-9)
928
+ //
929
+ // A fidelity defect is NOT "the proposition is false". These are BEHAVIOURAL assertions: a real
930
+ // agent reply carrying `formal:{decision:'defect', note}` is fed through the real reply path
931
+ // (subagent/end → handleVerifier → absorbFormalReply) and the record, the archived file and the
932
+ // formalization TODO are inspected on disk.
933
+ // ===============================================================
934
+ section('8b a formal.decision=defect reply withdraws the passed proof and withholds the verdict')
935
+ const RH = makeRoot()
936
+ await callTool('vibe_math_new_project', { name: 'lean-defect' }, RH)
937
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RH)
938
+ const defectProj = projRoot('lean-defect')
939
+ await callTool('vibe_math_add_proposition', { id: 'p-defect', 概述: '形式化写窄了的命题', 概率: 0.6, 分类: '数论' }, RH)
940
+ {
941
+ const pass = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-defect', content: 'theorem p_defect : 2 + 2 = 4 := by decide\n' }, RH)
942
+ assert(pass.ok === true && pass.passed === true, 'defect: the object starts out Lean-passed')
943
+ assert(existsSync(join(defectProj, 'Verified', 'Lean', 'p-defect.lean')), 'defect: the archived proof is on disk before the fidelity review')
944
+ }
945
+ await callTool('vibe_math_start', {}, RH)
946
+ {
947
+ const re = verifyRe('p-defect')
948
+ assert(await drive(RH, () => unfiredVerifiers(RH, re).length >= 2, 'verifiers for r-p-defect'), 'defect: the Lean-passed object is put to a fidelity review')
949
+ const vs = unfiredVerifiers(RH, re).slice(0, 2)
950
+ if (vs.length === 2) {
951
+ for (const v of vs) firedChildren.add(v.childId)
952
+ assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(vs[0].prompt) && /不要投 0/.test(vs[0].prompt) && /formal:\{decision:'defect'/.test(vs[0].prompt),
953
+ 'defect: the fidelity prompt asks for the defect reply and forbids recording the deviation as 0')
954
+ assert(/本次裁定\*\*不定论\*\*/.test(vs[0].prompt),
955
+ "★ `require` DOES keep the hold clause (the clause is mode-dependent, not deleted)")
956
+ // The defect reply carries an EXTREME Result on purpose: the gate (not the vote value) has to
957
+ // be what withholds the verdict — a defect must never be harvested as "the proposition is false".
958
+ fireEnd(vs[0].childId, { Result: 1, Reason: '逐条核对后认定形式化不忠实', formal: { target: 'p-defect', decision: 'defect', note: 'Lean 代码多加了 h>0 假设,命题原文未要求' } })
959
+ await sleep(240)
960
+ const st = await callTool('vibe_math_status', {}, RH)
961
+ const rec = st.formal.objects.find((o) => o.target === 'p-defect')
962
+ assert(!!rec && rec.status === 'attempted', '★ defect downgrades the formal record to attempted (observed ' + (rec && rec.status) + ')')
963
+ assert(st.formal.passed.indexOf('p-defect') === -1, '★ and the object is no longer reported as Lean-passed')
964
+ assert(!!rec && rec.proof === '', '★ defect clears the `proof` field')
965
+ assert(!existsSync(join(defectProj, 'Verified', 'Lean', 'p-defect.lean')), '★ defect deletes the archived proof Verified/Lean/p-defect.lean')
966
+ assert(existsSync(join(defectProj, 'Formal', 'p-defect.lean')), 'the WORK file Formal/p-defect.lean survives (the code itself is not lost)')
967
+ const persisted = JSON.parse(readIf(join(defectProj, 'State', 'formal.json')))
968
+ assert(persisted.records['p-defect'].decision === 'defect' && /多加了 h>0 假设/.test(persisted.records['p-defect'].note),
969
+ '★ the note (the concrete deviation) is recorded in the persisted formal record')
970
+ assert(/- 形式化: 已尝试未通过/.test(readIf(join(defectProj, 'Propos', '数论', 'p-defect.md'))), 'and the object card anchor is refreshed')
971
+ fireEnd(vs[1].childId, { Result: 1, Reason: '同意:形式化不忠实' })
972
+ await sleep(300)
973
+ }
974
+ }
975
+ {
976
+ const todo = readIf(join(defectProj, 'Formal', 'TODO.md'))
977
+ assert(/p-defect/.test(todo) && /多加了 h>0 假设/.test(todo), '★ the deviation is written into Formal/TODO.md')
978
+ const idx = readIf(join(defectProj, 'Formal', 'Index.md'))
979
+ assert(/p-defect/.test(idx) && /attempted/.test(idx) && !/Verified\/Lean\/p-defect\.lean/.test(idx), 'Formal/Index.md now shows attempted with no archived proof')
980
+ assert(!existsSync(join(defectProj, 'Verified', '命题', 'p-defect.md')), '★ require after a defect: NO Verified card is written (the verdict is withheld, not turned into "false")')
981
+ const card = readIf(join(defectProj, 'Propos', '数论', 'p-defect.md'))
982
+ assert(/- 状态: 未定论/.test(card), '★ and the object stays 未定论')
983
+ assert(/- 概率: 0.6/.test(card), 'the object keeps its existing probability: a defect must NOT be harvested as a refutation')
984
+ const ann = readIf(join(defectProj, 'Logs', '形式化.md'))
985
+ assert(/忠实性缺陷/.test(ann) && /多加了 h>0 假设/.test(ann), '★ the defect is announced with its concrete deviation')
986
+ assert(ann.indexOf('不是"命题为假"') !== -1, 'the announcement spells out that a fidelity defect is NOT "the proposition is false"')
987
+ const st = await callTool('vibe_math_status', {}, RH)
988
+ assert(st.formal.todo.some((t) => t.id === 'p-defect'), '★ the object is on the formalization TODO (undecided until the formalization is fixed and re-run)')
989
+ }
990
+ await callTool('vibe_math_abort', {}, RH)
991
+
992
+ section('8b-2 a defect without a note is refused (the deviation must be auditable)')
993
+ const RI = makeRoot()
994
+ await callTool('vibe_math_new_project', { name: 'lean-defect-nonote' }, RI)
995
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RI)
996
+ const nonoteProj = projRoot('lean-defect-nonote')
997
+ await callTool('vibe_math_add_proposition', { id: 'p-nonote-defect', 概述: '没有偏差说明的缺陷回执', 概率: 0.6, 分类: '数论' }, RI)
998
+ await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-nonote-defect', content: 'theorem p_nn_defect : 2 + 2 = 4 := by decide\n' }, RI)
999
+ await callTool('vibe_math_start', {}, RI)
1000
+ {
1001
+ const re = verifyRe('p-nonote-defect')
1002
+ assert(await drive(RI, () => unfiredVerifiers(RI, re).length >= 2, 'verifiers for r-p-nonote-defect'), 'defect: the passed object is put to a fidelity review')
1003
+ const vs = unfiredVerifiers(RI, re).slice(0, 2)
1004
+ if (vs.length === 2) {
1005
+ for (const v of vs) firedChildren.add(v.childId)
1006
+ fireEnd(vs[0].childId, { Result: 1, Reason: '觉得不忠实但没写清楚', formal: { target: 'p-nonote-defect', decision: 'defect' } })
1007
+ await sleep(240)
1008
+ const st = await callTool('vibe_math_status', {}, RI)
1009
+ const rec = st.formal.objects.find((o) => o.target === 'p-nonote-defect')
1010
+ assert(!!rec && rec.status === 'passed', '★ a defect WITHOUT a note is refused: the object stays Lean-passed (no silent downgrade)')
1011
+ assert(existsSync(join(nonoteProj, 'Verified', 'Lean', 'p-nonote-defect.lean')), '★ and the archived proof is NOT deleted')
1012
+ assert(st.formal.todo.every((t) => t.id !== 'p-nonote-defect') && !/p-nonote-defect/.test(readIf(join(nonoteProj, 'Formal', 'TODO.md'))),
1013
+ 'and no bogus formalization-TODO entry is created')
1014
+ assert(/未写明 note/.test(readIf(join(nonoteProj, 'Logs', '形式化.md'))), '★ the refusal is announced explicitly')
1015
+ fireEnd(vs[1].childId, { Result: 1, Reason: '核对后认为一致' })
1016
+ await sleep(300)
1017
+ }
1018
+ }
1019
+ assert(await drive(RI, () => existsSync(join(nonoteProj, 'Verified', '命题', 'p-nonote-defect.md')), 'Verified card'), 'a refused defect leaves the gate open: the same vote still promotes the object')
1020
+ // §4.1: the downgrade is unconditional — a `blocked` record loses to a defect too (it needs REDOING,
1021
+ // not a free pass through the gate, which `blocked` would otherwise grant).
1022
+ await callTool('vibe_math_add_proposition', { id: 'p-blocked-defect', 概述: '阻塞后仍被认定不忠实', 概率: 0.6, 分类: '数论' }, RI)
1023
+ await callTool('vibe_math_lean_archive', { kind: 'blocked', target: 'p-blocked-defect', note: '先按难度记为阻塞' }, RI)
1024
+ await restart(RI)
1025
+ {
1026
+ const re = verifyRe('p-blocked-defect')
1027
+ assert(await drive(RI, () => unfiredVerifiers(RI, re).length >= 2, 'verifiers for r-p-blocked-defect'), 'a blocked object is put to a vote (the gate is open for blocked)')
1028
+ const vs = unfiredVerifiers(RI, re).slice(0, 2)
1029
+ if (vs.length === 2) {
1030
+ for (const v of vs) firedChildren.add(v.childId)
1031
+ fireEnd(vs[0].childId, { Result: 1, Reason: '形式化与命题不对应', formal: { target: 'p-blocked-defect', decision: 'defect', note: '阻塞所依据的形式化本身写错了对象' } })
1032
+ await sleep(240)
1033
+ const st = await callTool('vibe_math_status', {}, RI)
1034
+ const rec = st.formal.objects.find((o) => o.target === 'p-blocked-defect')
1035
+ assert(!!rec && rec.status === 'attempted', '★★ a defect ALWAYS downgrades, even from `blocked` (observed ' + (rec && rec.status) + ')')
1036
+ assert(st.formal.blocked.indexOf('p-blocked-defect') === -1 && /阻塞所依据的形式化本身写错了对象/.test(rec.note || ''), 'and the blocked record is replaced by the concrete deviation')
1037
+ fireEnd(vs[1].childId, { Result: 1, Reason: '同意,形式化写错了对象' })
1038
+ await sleep(280)
1039
+ }
1040
+ }
1041
+ assert(!existsSync(join(nonoteProj, 'Verified', '命题', 'p-blocked-defect.md')), 'require after a blocked→defect downgrade: still no Verified card (undecided, not "false")')
1042
+ await callTool('vibe_math_abort', {}, RI)
1043
+
1044
+ // ===============================================================
1045
+ // 8b-3. the withdrawal is not a best-effort delete (contract §4.1)
1046
+ //
1047
+ // `Verified/Lean/<id>.lean` is exactly where everyone looks for "the proof of this object", so a
1048
+ // downgraded record with the old code still sitting there is worse than no record at all. The fs
1049
+ // service exposes no unlink and `subprocess` is optional, so on a host that cannot delete, the
1050
+ // withdrawal must fall back to overwriting the file with an explicit notice. This runs in
1051
+ // `encourage` mode on purpose: the same scenario also proves the injected text and the
1052
+ // framework's own announcement do NOT promise a hold that only `require` can enforce (§4.1 item 3).
1053
+ // ===============================================================
1054
+ section('8b-3 a defect withdraws the archived proof even on a host that cannot delete files')
1055
+ const RL = makeRoot()
1056
+ await callTool('vibe_math_new_project', { name: 'lean-defect-nodelete' }, RL)
1057
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage' }), RL)
1058
+ const nodeleteProj = projRoot('lean-defect-nodelete')
1059
+ await callTool('vibe_math_add_proposition', { id: 'p-nodelete', 概述: '宿主无法删除文件时的撤回', 概率: 0.6, 分类: '数论' }, RL)
1060
+ const proofFile = join(nodeleteProj, 'Verified', 'Lean', 'p-nodelete.lean')
1061
+ {
1062
+ const pass = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-nodelete', content: 'theorem p_nodelete : 2 + 2 = 4 := by decide\n' }, RL)
1063
+ assert(pass.ok === true && pass.passed === true && /theorem p_nodelete/.test(readIf(proofFile)),
1064
+ 'no-delete: the object starts out Lean-passed with its archived proof on disk')
1065
+ }
1066
+ await callTool('vibe_math_start', {}, RL)
1067
+ {
1068
+ const re = verifyRe('p-nodelete')
1069
+ assert(await drive(RL, () => unfiredVerifiers(RL, re).length >= 2, 'verifiers for r-p-nodelete'), 'no-delete: the Lean-passed object is put to a fidelity review')
1070
+ const vs = unfiredVerifiers(RL, re).slice(0, 2)
1071
+ if (vs.length === 2) {
1072
+ for (const v of vs) firedChildren.add(v.childId)
1073
+ assert(/发现任何偏差,不要投 0/.test(vs[0].prompt) && /本档没有门禁/.test(vs[0].prompt) && !/不定论/.test(vs[0].prompt),
1074
+ '★ encourage: the fidelity text does NOT claim the framework will withhold the verdict (only require gates; §4.1 item 3)')
1075
+ // A host whose shell cannot delete anything: no subprocess service at all. The withdrawal must
1076
+ // therefore be observable as an OVERWRITE, not as a missing file.
1077
+ subprocessAvailable = false
1078
+ fireEnd(vs[0].childId, { Result: 0.5, Reason: '逐条核对后认定形式化不忠实', formal: { target: 'p-nodelete', decision: 'defect', note: 'Lean 里把自然数写成了整数' } })
1079
+ await sleep(320)
1080
+ subprocessAvailable = true
1081
+ const after = readIf(proofFile)
1082
+ assert(existsSync(proofFile) && !/theorem p_nodelete/.test(after),
1083
+ '★ without a working delete the archived proof text is gone (overwritten, not left readable as a proof)')
1084
+ assert(/已撤回(/.test(after) && /Formal\/p-nodelete\.lean/.test(after),
1085
+ '★ and it carries the withdrawal notice pointing at the kept working file Formal/p-nodelete.lean')
1086
+ const st = await callTool('vibe_math_status', {}, RL)
1087
+ const rec = st.formal.objects.find((o) => o.target === 'p-nodelete')
1088
+ assert(!!rec && rec.status === 'attempted' && rec.proof === '' && /自然数写成了整数/.test(rec.note || ''),
1089
+ '★ the record is still downgraded to attempted with an emptied proof pointer')
1090
+ const ann = readIf(join(nodeleteProj, 'Logs', '形式化.md'))
1091
+ assert(/忠实性缺陷/.test(ann) && /就地覆盖/.test(ann),
1092
+ '★ the announcement says the proof was OVERWRITTEN (not deleted), so the reader knows which withdrawal happened')
1093
+ assert(ann.indexOf('不定论') === -1, '★ and `encourage` never claims the framework withheld the verdict')
1094
+ const todo = readIf(join(nodeleteProj, 'Formal', 'TODO.md'))
1095
+ assert(/# 形式化待办/.test(todo) && /p-nodelete/.test(todo) && !/定论被搁置/.test(todo) && /没有定论门禁/.test(todo),
1096
+ '★ Formal/TODO.md states what this mode really does (it does not claim 定论被搁置)')
1097
+ const idx = readIf(join(nodeleteProj, 'Formal', 'Index.md'))
1098
+ assert(/## 形式化待办/.test(idx) && !/定论被搁置/.test(idx), '★ and Formal/Index.md mirrors that framing')
1099
+ fireEnd(vs[1].childId, { Result: 0.5, Reason: '同意,形式化不忠实' })
1100
+ await sleep(280)
1101
+ }
1102
+ }
1103
+ await callTool('vibe_math_abort', {}, RL)
1104
+
1105
+ // ===============================================================
1106
+ // 8b-4. a defect on an ALREADY-verified object must not leave a stale card anchor
1107
+ //
1108
+ // `defect` withdraws the formalization (record → attempted, proof pointer cleared, archived file
1109
+ // deleted/overwritten). A Verified card written earlier still says 「形式化: Lean 通过(Verified/
1110
+ // Lean/<id>.lean)」 — a pointer to a proof that no longer exists. The gate must not prevent that
1111
+ // refresh: the gate decides whether a NEW conclusion may be declared, not whether the framework may
1112
+ // tell the truth about one it already declared.
1113
+ // ===============================================================
1114
+ section('8b-4 a later defect refreshes an existing Verified card instead of leaving a stale anchor')
1115
+ const RM = makeRoot()
1116
+ await callTool('vibe_math_new_project', { name: 'lean-stale-card' }, RM)
1117
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RM)
1118
+ const staleProj = projRoot('lean-stale-card')
1119
+ await callTool('vibe_math_add_proposition', { id: 'p-stale', 概述: '定论后才被认定形式化不忠实', 概率: 0.6, 分类: '数论' }, RM)
1120
+ await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-stale', content: 'theorem p_stale : 2 + 2 = 4 := by decide\n' }, RM)
1121
+ await callTool('vibe_math_start', {}, RM)
1122
+ assert(await runVerifyRound(RM, 'p-stale', [1, 1]) !== null, 'stale-card: the Lean-passed proposition was put to a vote')
1123
+ assert(await drive(RM, () => existsSync(join(staleProj, 'Verified', '命题', 'p-stale.md')), 'p-stale Verified card'), 'stale-card: the gate was satisfied, so it reaches a Verified card')
1124
+ {
1125
+ const card = readIf(join(staleProj, 'Verified', '命题', 'p-stale.md'))
1126
+ assert(/- 形式化: Lean 通过/.test(card) && /Verified\/Lean\/p-stale\.lean/.test(card), 'stale-card: the card records the machine-checked strength and its proof path')
1127
+ }
1128
+ // A LATER work-round reply (e.g. a solver that reuses the object and finds the Lean statement too
1129
+ // wide) reports the fidelity defect — long after the card was written.
1130
+ await callTool('vibe_math_add_problem', { id: 'q-w', description: '让 explorer 起来以便回执一条 defect', priority: 0 }, RM)
1131
+ await restart(RM)
1132
+ assert(await drive(RM, () => !!lastSpawn(RM, 'explorer:q-w'), 'explorer:q-w'), 'stale-card: an explorer exists to carry the work-round reply')
1133
+ fireEnd(lastSpawn(RM, 'explorer:q-w').childId, {
1134
+ meta: { kind: 'directions', qid: 'q-w', formal: { target: 'p-stale', decision: 'defect', note: '这份形式化把结论写宽了' },
1135
+ directions: [{ id: 'd1', title: '直接法', method: '', core_assumption: '', feasibility: 0.6 }] },
1136
+ })
1137
+ assert(await drive(RM, () => !/Lean 通过/.test(readIf(join(staleProj, 'Verified', '命题', 'p-stale.md'))), 'p-stale card refreshed'),
1138
+ '★ a defect on an already-verified object refreshes the existing Verified card (no stale 「Lean 通过」 anchor pointing at a withdrawn proof)')
1139
+ {
1140
+ const card = readIf(join(staleProj, 'Verified', '命题', 'p-stale.md'))
1141
+ assert(/- 形式化: 已尝试未通过/.test(card), '★ the refreshed card states the honest formal status (已尝试未通过)')
1142
+ assert(!/Verified\/Lean\/p-stale\.lean/.test(card), '★ and no longer points at the withdrawn proof')
1143
+ assert(/- 结论: 真/.test(card), 'the conclusion itself is untouched — a fidelity defect is not a refutation')
1144
+ assert(!existsSync(join(staleProj, 'Verified', 'Lean', 'p-stale.lean')), 'and the archived proof really is gone')
1145
+ assert(await drive(RM, () => /p-stale/.test(readIf(join(staleProj, 'Formal', 'TODO.md'))), 'p-stale formalization TODO'), 'the object is on the formalization TODO for redoing')
1146
+ }
1147
+ await callTool('vibe_math_abort', {}, RM)
1148
+
1149
+ // ===============================================================
1150
+ // 8c. the injected text obeys the five hard requirements of contract §6
1151
+ // ===============================================================
1152
+ section('8c the injected text uses full tool names, Result (not verdict) and the run-before-archive rule')
1153
+ const RJ = makeRoot()
1154
+ await callTool('vibe_math_new_project', { name: 'lean-workline' }, RJ)
1155
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'require' }), RJ)
1156
+ await callTool('vibe_math_add_problem', { id: 'q-defect', description: '顺手形式化的对象', priority: 0 }, RJ)
1157
+ const workProj = projRoot('lean-workline')
1158
+ // a `meta.formal` defect on the WORK-round path (absorbFormalFromReply) must downgrade too
1159
+ await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'q-defect', content: 'theorem q_defect : 2 + 2 = 4 := by decide\n' }, RJ)
1160
+ await callTool('vibe_math_start', {}, RJ)
1161
+ assert(await drive(RJ, () => !!lastSpawn(RJ, 'explorer:q-defect'), 'explorer:q-defect'), 'the explorer for q-defect was spawned')
1162
+ {
1163
+ const p = lastSpawn(RJ, 'explorer:q-defect').prompt
1164
+ assert(/归档前先跑通(vibe_math_lean_run 或 run=true);跑不通的定义不要进可复用库。/.test(p),
1165
+ '★ the work-round prompt requires a GREEN run before archiving a reusable definition (§6 hard requirement 3)')
1166
+ assert(/"decision":"used\|blocked\|defect"/.test(p), 'the work-round reply contract advertises the defect decision too')
1167
+ assert(!/verdict/.test(p) && !/(^|[^a-z_])lean_(run|archive|lib)/.test(p),
1168
+ '★ the work-round prompt uses FULL tool names only and never the v4/v5 field name `verdict`')
1169
+ fireEnd(lastSpawn(RJ, 'explorer:q-defect').childId, {
1170
+ meta: { kind: 'directions', qid: 'q-defect', formal: { target: 'q-defect', decision: 'defect', note: '陈述里的自然数范围被写成了整数' },
1171
+ directions: [{ id: 'd1', title: '直接形式化', method: 'Lean', core_assumption: '', feasibility: 0.6 }] },
1172
+ })
1173
+ await sleep(260)
1174
+ const st = await callTool('vibe_math_status', {}, RJ)
1175
+ const rec = st.formal.objects.find((o) => o.target === 'q-defect')
1176
+ assert(!!rec && rec.status === 'attempted' && /自然数范围被写成了整数/.test(rec.note || ''),
1177
+ '★ a `meta.formal` defect from a WORK reply downgrades the record too (absorbFormalFromReply, not just the verifier path)')
1178
+ assert(!existsSync(join(workProj, 'Verified', 'Lean', 'q-defect.lean')), '★ and its archived proof is deleted')
1179
+ }
1180
+ await callTool('vibe_math_abort', {}, RJ)
1181
+
1182
+ section('8c-2 the fidelity branch reaches BOTH the review and the debate prompt, and names Result')
1183
+ const RK = makeRoot()
1184
+ await callTool('vibe_math_new_project', { name: 'lean-fidelity' }, RK)
1185
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage', debateMaxRounds: 2 }), RK)
1186
+ await callTool('vibe_math_add_proposition', { id: 'p-fid', 概述: '忠实性审查措辞观察对象', 概率: 0.6, 分类: '数论' }, RK)
1187
+ await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-fid', content: 'theorem p_fid : 2 + 2 = 4 := by decide\n' }, RK)
1188
+ await callTool('vibe_math_start', {}, RK)
1189
+ const fidBatch = await runVerifyRound(RK, 'p-fid', [0.9, 0.95])
1190
+ assert(fidBatch !== null, 'fidelity: the review round was asked')
1191
+ if (fidBatch) assertDistinctSlots(fidBatch, 'fidelity round')
1192
+ if (fidBatch) {
1193
+ const vp = fidBatch.map((s) => s.prompt).join('\n')
1194
+ assert(/一致 → Result = 1/.test(vp), "★ the review prompt states the faithful case as `Result = 1` (v3's REAL reply field, not v4/v5's verdict)")
1195
+ assert(/发现任何偏差,不要投 0/.test(vp) && /形式化不合格/.test(vp), '★ and forbids expressing a fidelity defect as 0')
1196
+ assert(/formal:\{decision:'defect', note:'<具体偏差>'\}/.test(vp), 'and points at the defect reply field to record it')
1197
+ assert(/独立于这份 Lean 代码/.test(vp), 'only an INDEPENDENT refutation may be voted 0')
1198
+ assert(/Result/.test(vp) && !/verdict/.test(vp), '★ the voting prompt names Result, never verdict (§6 hard requirement 2)')
1199
+ assert(vp.indexOf('偏离 → 0') === -1, '★ no "偏离 → 0" instruction anywhere in the fidelity branch (contract §10 item 9)')
1200
+ }
1201
+ assert(await drive(RK, () => wakes.some((w) => w.rootId === RK.id && /交流群/.test(w.prompt)), 'debate prompt'), 'a non-consensus fidelity round moved to the debate')
1202
+ {
1203
+ const dp = wakes.filter((w) => w.rootId === RK.id).map((w) => w.prompt).join('\n')
1204
+ assert(/一致 → Result = 1/.test(dp) && /不要投 0/.test(dp), '★ the DEBATE prompt carries the same fidelity wording')
1205
+ assert(/Result/.test(dp) && !/verdict/.test(dp), 'the debate prompt names Result, never verdict')
1206
+ assert(dp.indexOf('偏离 → 0') === -1, 'the debate prompt also refuses "a deviation is a 0"')
1207
+ }
1208
+ await callTool('vibe_math_abort', {}, RK)
1209
+
1210
+ // ===============================================================
1211
+ // 9. reporting + persistence
1212
+ // ===============================================================
1213
+ // (d) a plain `used` judgement must NOT withdraw an ESTABLISHED proof (contract §4). Only a
1214
+ // fidelity defect retracts a proof; `used` just reports "this round touched the formalization".
1215
+ // v2 shipped the unconditional downgrade here and silently re-closed the require gate.
1216
+ section('8d a \`used\` reply must NOT downgrade an already-passed object')
1217
+ await restart(RG)
1218
+ await callTool('vibe_math_add_proposition', { id: 'p-usedkeep', 概述: '已有通过证明后再写一次 used 回执', 概率: 0.6, 分类: '数论' }, RG)
1219
+ {
1220
+ const pass = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'p-usedkeep', content: 'theorem p_usedkeep : 2 + 2 = 4 := by decide\n' }, RG)
1221
+ assert(pass.ok === true && pass.passed === true, 'used-keep: the object starts out Lean-passed')
1222
+ const re = verifyRe('p-usedkeep')
1223
+ assert(await drive(RG, () => unfiredVerifiers(RG, re).length >= 2, 'verifiers for r-p-usedkeep'), 'used-keep: the Lean-passed object is put to a fidelity review')
1224
+ const vs = unfiredVerifiers(RG, re).slice(0, 2)
1225
+ if (vs.length === 2) {
1226
+ for (const v of vs) firedChildren.add(v.childId)
1227
+ fireEnd(vs[0].childId, { Result: 0.5, Reason: '这一轮只是又写了一遍草稿', formal: { target: 'p-usedkeep', decision: 'used', file: 'Formal/p-usedkeep.lean' } })
1228
+ await sleep(150)
1229
+ const st = await callTool('vibe_math_status', {}, RG)
1230
+ const o = st.formal.objects.find((x) => x.target === 'p-usedkeep')
1231
+ assert(!!o && o.status === 'passed', '★ a `used` reply does NOT downgrade an already-passed object (got ' + JSON.stringify(o) + ')')
1232
+ assert(st.formal.passed.indexOf('p-usedkeep') !== -1, '★ and status still reports it as Lean-passed (the fidelity branch stays the reviewers\' subject)')
1233
+ assert(existsSync(join(replyProj, 'Verified', 'Lean', 'p-usedkeep.lean')), '★ and the archived proof is still on disk by that name')
1234
+ fireEnd(vs[1].childId, { Result: 0.5, Reason: '同样只是又写了一遍草稿' })
1235
+ await sleep(250)
1236
+ assert(!/p-usedkeep/.test(readIf(join(replyProj, 'Formal', 'TODO.md')) || ''), '★ no formal-required TODO was created: the surviving passed still opens the require gate')
1237
+ // The same rule covers the RUN path (contract §4): a run attributed to the object may not downgrade it.
1238
+ const rerun = await callTool('vibe_math_lean_run', { file: 'Formal/p-usedkeep.lean', target: 'p-usedkeep' }, RG)
1239
+ assert(rerun.ok === true, 'used-keep: the object work file runs green (the record assertion below is the point)')
1240
+ const o2 = (await callTool('vibe_math_status', {}, RG)).formal.objects.find((x) => x.target === 'p-usedkeep')
1241
+ assert(!!o2 && o2.status === 'passed', '★ and the record is still passed')
1242
+ }
1243
+ }
1244
+
1245
+ // (e) the same rule for `blocked`: a blocker is a gate-opening record, so a later `used` reply
1246
+ // must not push the object back to `attempted` and silently re-close the gate (v3 used to preserve
1247
+ // only `passed` here — v2/v4/v5 preserve both).
1248
+ await restart(RG)
1249
+ await callTool('vibe_math_add_proposition', { id: 'p-usedblocked', 概述: '已记录阻塞后再写一次 used 回执', 概率: 0.6, 分类: '数论' }, RG)
1250
+ {
1251
+ const blk = await callTool('vibe_math_lean_archive', { kind: 'blocked', target: 'p-usedblocked', note: '需要大量未形式化的实分析前置知识' }, RG)
1252
+ assert(blk.ok === true && blk.status === 'blocked', 'used-blocked: the object starts with an explicit blocker (the gate is open)')
1253
+ const re = verifyRe('p-usedblocked')
1254
+ assert(await drive(RG, () => unfiredVerifiers(RG, re).length >= 2, 'verifiers for r-p-usedblocked'), 'used-blocked: the object is put to a vote')
1255
+ const vs = unfiredVerifiers(RG, re).slice(0, 2)
1256
+ if (vs.length === 2) {
1257
+ for (const v of vs) firedChildren.add(v.childId)
1258
+ fireEnd(vs[0].childId, { Result: 0.5, Reason: '这一轮只是又写了一遍草稿', formal: { target: 'p-usedblocked', decision: 'used', file: 'Formal/p-usedblocked.lean' } })
1259
+ await sleep(150)
1260
+ const o = (await callTool('vibe_math_status', {}, RG)).formal.objects.find((x) => x.target === 'p-usedblocked')
1261
+ assert(!!o && o.status === 'blocked', '★ a `used` reply does not re-close a gate an explicit blocker opened (got ' + JSON.stringify(o) + ')')
1262
+ fireEnd(vs[1].childId, { Result: 0.5, Reason: '同上' })
1263
+ await sleep(200)
1264
+ }
1265
+ }
1266
+
1267
+ section('9 the office can audit formal strength')
1268
+ {
1269
+ const rep = await callTool('vibe_math_report', {}, RF)
1270
+ assert(rep.formal && rep.formal.mode === 'require', 'the JSON report carries the formal mode')
1271
+ assert(rep.formal.passed.indexOf('p-gate') !== -1 && rep.formal.blocked.indexOf('p-blocked-ok') !== -1, 'the JSON report lists both passed and blocked objects')
1272
+ assert(rep.params.formalVerify === 'require' && rep.params.leanCommand === 'lean', 'the readable parameter table carries the Lean parameters')
1273
+ const md = readIf(join(gateProj, 'Logs', '报告.md'))
1274
+ assert(/## Lean 形式化/.test(md), 'the human-readable report has a Lean formal-verification section')
1275
+ assert(/已通过:.*p-gate/.test(md), 'it lists Lean-passed objects')
1276
+ assert(/已记录阻塞:.*p-blocked-ok/.test(md), 'it lists blocked objects')
1277
+ assert(/可复用库:VibeMath\/Formal\/\{Lib,Proved\}\//.test(md), 'it documents the path layout')
1278
+ await callTool('vibe_math_report', {}, RA)
1279
+ assert(/未启用(`formalVerify` = off/.test(readIf(join(offProj, 'Logs', '报告.md'))), 'in off mode the report says the feature is not enabled')
1280
+ const offRep = await callTool('vibe_math_report', {}, RA)
1281
+ assert(offRep.formal.mode === 'off' && /off/.test(offRep.formal.note || ''), 'and the JSON report says the same')
1282
+ }
1283
+ {
1284
+ const before = JSON.parse(readIf(join(gateProj, 'State', 'formal.json')))
1285
+ assert(!!before && !!before.records && before.records['p-gate'] && before.records['p-gate'].status === 'passed', 'the formal record is persisted in State/formal.json, keyed by object id')
1286
+ assert(Array.isArray(before.todo), 'the formalization TODO is persisted alongside it')
1287
+ assert(before.records['p-blocked-ok'].status === 'blocked' && /分析学/.test(before.records['p-blocked-ok'].note), 'a blocker record keeps its reason across sessions')
1288
+ assert(existsSync(join(VIBE, 'Formal', 'Proved', 'Index.md')) && existsSync(join(libPath, 'Index.md')), 'both global indexes are framework-maintained')
1289
+ }
1290
+
1291
+ // ===============================================================
1292
+ // 9b. Lean 增量 + 异步(spec §1–§4 + 修订 §1–§5):与 v2 同一套断言强度
1293
+ // ===============================================================
1294
+ section('9b Lean 增量 + 异步:参数/argv/队列/去重/只读面/主动性/恢复/卸载')
1295
+ {
1296
+ const H = mod.__testHelpers
1297
+ const slash = (s) => String(s == null ? '' : s).replace(/\\/g, '/')
1298
+ const SAME = (a, b) => slash(a) === slash(b)
1299
+ const formalOf2 = (proj) => JSON.parse(readIf(join(projRoot(proj), 'State', 'formal.json')) || '{}')
1300
+ const jobFile = (proj, id) => join(projRoot(proj), 'Formal', 'Jobs', id + '.json')
1301
+ const readJob = (proj, id) => JSON.parse(readIf(jobFile(proj, id)) || '{}')
1302
+ const vibeCmd = async (rawInput, root) => JSON.parse(await cmdRegs[0].handler({ rawInput, agent: root }))
1303
+ const findJob = async (proj, id, states, tries = 100, ms = 60) => {
1304
+ for (let i = 0; i < tries; i++) { const r = readJob(proj, id); if (r.state && states.indexOf(r.state) !== -1) return r; await sleep(ms) }
1305
+ return undefined
1306
+ }
1307
+
1308
+ // ── 参数:默认值/归一化(字符串 'false' 绝不能滑过去)────────────────────────────
1309
+ const rp = makeRoot()
1310
+ await callTool('vibe_math_new_project', { name: 'lean-async-params' }, rp)
1311
+ await callTool('vibe_math_set_params', { leanAsync: true }, rp)
1312
+ const bad = await callTool('vibe_math_set_params', { leanAsync: 'false' }, rp)
1313
+ assert(bad.params.leanAsync === true, '★★★ leanAsync="false" (a STRING) does not slip through the `else out[k]=v` tail: it falls back to the boolean default')
1314
+ const wp = await callTool('vibe_math_set_params', { leanInitiative: 'banana', leanJobsMaxParallel: 0, leanSearchPaths: 'not-an-array' }, rp)
1315
+ assert(wp.params.leanInitiative === 'normal', 'leanInitiative rejects an unknown mode back to normal')
1316
+ assert(wp.params.leanJobsMaxParallel === 1, 'leanJobsMaxParallel floors at 1 (never 0)')
1317
+ assert(Array.isArray(wp.params.leanSearchPaths) && wp.params.leanSearchPaths.length === 0, 'leanSearchPaths rejects a non-array back to []')
1318
+ const dd = await callTool('vibe_math_set_params', { leanSearchPaths: [' /opt/lean-lib ', '/opt/lean-lib', ''] }, rp)
1319
+ assert(JSON.stringify(dd.params.leanSearchPaths) === JSON.stringify(['/opt/lean-lib']), 'leanSearchPaths trims, drops empties and de-duplicates')
1320
+
1321
+ // ── 纯函数:搜索路径计划(修订 §2)与指纹(修订 §4)──────────────────────────────
1322
+ const planDefault = H.leanSearchPathPlan([], [], '/root/vm')
1323
+ assert(JSON.stringify(planDefault.paths) === JSON.stringify(['/root/vm']) && planDefault.inject.length === 2, '★ the default plan injects exactly the automatic root')
1324
+ const planExtra = H.leanSearchPathPlan(['env', 'lean'], ['/a', '/b'], '/root/vm')
1325
+ assert(JSON.stringify(planExtra.paths) === JSON.stringify(['/a', '/b', '/root/vm']), '★★ user leanSearchPaths come FIRST, then the automatic root')
1326
+ assert(H.leanSearchPathPlan(['--search-path', '/x'], ['/a'], '/root/vm').inject.length === 0 && H.leanSearchPathPlan(['-R', '/x'], ['/a'], '/root/vm').inject.length === 0 && H.leanSearchPathPlan(['--root', '/x'], ['/a'], '/root/vm').inject.length === 0, '★ an explicit --search-path/-R/--root in leanArgs suppresses all injection (user wins)')
1327
+ const ctxA = H.leanBuildContext('lean', ['a'], ['/p'])
1328
+ const ctxB = H.leanBuildContext('lean', ['b'], ['/p'])
1329
+ assert(H.leanJobFingerprint('x\n', ctxA) !== H.leanJobFingerprint('x\n', ctxB), '★ the job fingerprint covers the build context (engine/args/search paths)')
1330
+
1331
+ // ── argv:注入在用户参数之后、文件名之前 ─────────────────────────────────────────
1332
+ const ra = makeRoot()
1333
+ await callTool('vibe_math_new_project', { name: 'lean-async-argv' }, ra)
1334
+ await callTool('vibe_math_set_params', { leanAsync: false, leanCommand: 'lake', leanArgs: ['env', 'lean'] }, ra)
1335
+ const projA = projRoot('lean-async-argv')
1336
+ mkdirSync(join(projA, 'Formal'), { recursive: true })
1337
+ writeFileSync(join(projA, 'Formal', 'srch.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1338
+ const runs0 = leanRuns.length
1339
+ await callTool('vibe_math_lean_run', { file: 'Formal/srch.lean' }, ra)
1340
+ const argv1 = leanRuns[leanRuns.length - 1].argv
1341
+ assert(leanRuns.length === runs0 + 1 && JSON.stringify(argv1.slice(0, 3)) === JSON.stringify(['lake', 'env', 'lean']), 'the user leanArgs are preserved in order (lake env lean)')
1342
+ assert(argv1[argv1.length - 2] === '--search-path' && SAME(argv1[argv1.length - 1], VIBE), '★★ --search-path + the VibeMath root are injected AFTER the user args and immediately BEFORE the file (' + JSON.stringify(argv1) + ')')
1343
+ await callTool('vibe_math_set_params', { leanArgs: ['env', 'lean', '--search-path', '/explicit'] }, ra)
1344
+ await callTool('vibe_math_lean_run', { file: 'Formal/srch.lean' }, ra)
1345
+ const argv2 = leanRuns[leanRuns.length - 1].argv
1346
+ assert(argv2.filter((x) => x === '--search-path').length === 1 && argv2[4] === '/explicit', '★ an explicit --search-path is respected verbatim (no second injection) — got ' + JSON.stringify(argv2))
1347
+ await callTool('vibe_math_set_params', { leanArgs: ['env', 'lean'], leanSearchPaths: ['/extra1', '/extra2'] }, ra)
1348
+ await callTool('vibe_math_lean_run', { file: 'Formal/srch.lean' }, ra)
1349
+ const argv3 = leanRuns[leanRuns.length - 1].argv
1350
+ assert(JSON.stringify(argv3.slice(-6)) === JSON.stringify(['--search-path', '/extra1', '--search-path', '/extra2', '--search-path', slash(VIBE)]), '★★ leanSearchPaths are injected first, then the automatic root (got ' + JSON.stringify(argv3.slice(-6)) + ')')
1351
+
1352
+ // ── 异步状态机:入队立即返回 → 心跳排空 → 只有 settled(ok) 才是通过 ───────────────
1353
+ const rq = makeRoot()
1354
+ await callTool('vibe_math_new_project', { name: 'lean-async-q' }, rq)
1355
+ await callTool('vibe_math_set_params', { leanAsync: true }, rq)
1356
+ const projQ = projRoot('lean-async-q')
1357
+ mkdirSync(join(projQ, 'Formal'), { recursive: true })
1358
+ writeFileSync(join(projQ, 'Formal', 'async-good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1359
+ writeFileSync(join(projQ, 'Formal', 'async-bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
1360
+ const nBefore = leanRuns.length
1361
+ const q1 = await callTool('vibe_math_lean_run', { file: 'Formal/async-good.lean', target: 'pAsync' }, rq)
1362
+ assert(q1.ok === true && !!q1.async && q1.async.state === 'queued' && typeof q1.async.jobId === 'string', '★★ 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 (enqueued, not run)')
1364
+ assert(q1.async.jobId.indexOf('pAsync-') === 0, 'the jobId is <target>-<fingerprint[0:12]> (got ' + q1.async.jobId + ')')
1365
+ assert(existsSync(jobFile('lean-async-q', q1.async.jobId)), 'the job record is mirrored to <project>/Formal/Jobs/<jobId>.json')
1366
+ const rec0 = readJob('lean-async-q', q1.async.jobId)
1367
+ assert(rec0.state === 'queued' && typeof rec0.sha256 === 'string' && typeof rec0.buildSha256 === 'string' && String(rec0.buildContext).length > 0, '★ the job record carries state + content hash + build-context hash')
1368
+ assert((formalOf2('lean-async-q').records || {}).pAsync === undefined || formalOf2('lean-async-q').records.pAsync.status !== 'passed', '★★ a queued job never marks the object passed')
1369
+ const done1 = await findJob('lean-async-q', q1.async.jobId, ['settled'])
1370
+ assert(!!done1, 'the apply-level heartbeat drained the queue and settled the job (got ' + readJob('lean-async-q', q1.async.jobId).state + ')')
1371
+ assert(done1 && done1.exitCode === 0, 'the settled record keeps exitCode 0')
1372
+ const recAfter = (formalOf2('lean-async-q').records || {}).pAsync
1373
+ assert(!!recAfter && recAfter.status === 'attempted' && recAfter.run && recAfter.run.ok === true, 'a green RUN lands as attempted with run.ok=true')
1374
+ assert(!!recAfter.async && recAfter.async.state === 'settled' && recAfter.async.jobId === q1.async.jobId, 'the object record carries the async job pointer/state')
1375
+ const q2 = await callTool('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' }, rq)
1376
+ const done2 = await findJob('lean-async-q', q2.async.jobId, ['failed'])
1377
+ assert(!!done2 && done2.exitCode === 1, '★ a red async run settles as failed with exitCode 1')
1378
+ const recBad = (formalOf2('lean-async-q').records || {}).pAsyncBad
1379
+ assert(!!recBad && recBad.status === 'attempted' && recBad.run.ok === false && /sorry/.test(String(recBad.run.stderrTail || '')), '★ a red async run is recorded as attempted with the compiler output kept (never passed)')
1380
+ // 归档证明:落地前不是 passed,落地 ok 才写 Verified/Lean/
1381
+ const q3 = await callTool('vibe_math_lean_archive', { kind: 'proof', target: 'pAsymProof', content: 'theorem pAsymProof : 1 = 1 := rfl\n' }, rq)
1382
+ assert(q3.ok === true && q3.async && q3.async.state === 'queued' && q3.status === 'attempted', '★★ kind=proof in async档 returns queued + attempted (NOT passed)')
1383
+ assert(!existsSync(join(projQ, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ the archived proof file does NOT exist while the job is still queued')
1384
+ const done3 = await findJob('lean-async-q', q3.async.jobId, ['settled', 'failed'])
1385
+ assert(!!done3 && done3.state === 'settled', 'the proof job settled green (got ' + (done3 && done3.state) + ')')
1386
+ assert(existsSync(join(projQ, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ only after settled(ok) is Verified/Lean/<id>.lean written')
1387
+ assert(((formalOf2('lean-async-q').records || {}).pAsymProof || {}).status === 'passed', '★★ and only then does the object become passed')
1388
+ assert(/pAsymProof/.test(readIf(join(projQ, 'Formal', 'Index.md'))), 'the index reflects the passed object')
1389
+ // ── lean_job(修订 §3):清单/单查/waitMs ────────────────────────────────────────
1390
+ const listJobs = await callTool('vibe_math_lean_job', {}, rq)
1391
+ assert(listJobs.ok === true && Array.isArray(listJobs.jobs) && listJobs.jobs.length >= 3, '★ lean_job without jobId returns the session job list')
1392
+ assert(listJobs.jobs.every((j) => !!j.paths && typeof j.paths.receipt === 'string'), 'every listed job carries its receipt path')
1393
+ const one = await callTool('vibe_math_lean_job', { jobId: q3.async.jobId }, rq)
1394
+ 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')
1395
+ assert((await callTool('vibe_math_lean_job', { jobId: 'nope-000000000000' }, rq)).code === 'V3_NOT_FOUND', 'an unknown jobId is refused with a typed code')
1396
+ const q4 = await callTool('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' }, rq)
1397
+ const waited = await callTool('vibe_math_lean_job', { jobId: q4.async.jobId, waitMs: 6000 }, rq)
1398
+ 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 + ')')
1399
+ assert((await callTool('vibe_math_lean_lib', {}, rq)).paths.searchPath === VIBE.replace(/\\/g, '/'), '★ lean_lib exposes paths.searchPath = the VibeMath root')
1400
+ assert(Array.isArray((await callTool('vibe_math_lean_lib', {}, rq)).jobs), '★ lean_lib exposes the job list')
1401
+
1402
+ // ── 超时:leanTimeoutMs 是每个作业的预算,主动 terminate,绝不 passed ─────────────
1403
+ const rt = makeRoot()
1404
+ await callTool('vibe_math_new_project', { name: 'lean-async-timeout' }, rt)
1405
+ await callTool('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 1000 }, rt)
1406
+ const projT = projRoot('lean-async-timeout')
1407
+ mkdirSync(join(projT, 'Formal'), { recursive: true })
1408
+ writeFileSync(join(projT, 'Formal', 'hang.lean'), '-- HANG\ntheorem t : 1 = 1 := rfl\n', 'utf8')
1409
+ const termBefore = terminated.length
1410
+ const q5 = await callTool('vibe_math_lean_run', { file: 'Formal/hang.lean', target: 'pHang' }, rt)
1411
+ const done5 = await findJob('lean-async-timeout', q5.async.jobId, ['timeout'], 150)
1412
+ assert(!!done5 && done5.timedOut === true, '★★ a hanging job times out (state=timeout) with leanTimeoutMs as its budget')
1413
+ assert(terminated.length > termBefore, '★★ the timeout ACTIVELY called handle.terminate()')
1414
+ assert(((formalOf2('lean-async-timeout').records || {}).pHang || {}).status !== 'passed', '★ a timed-out job never sets passed')
1415
+
1416
+ // ── 并发参数(修订 §5):leanJobsMaxParallel>1 时两个作业同时进入 running ──────────
1417
+ const rc = makeRoot()
1418
+ await callTool('vibe_math_new_project', { name: 'lean-async-par' }, rc)
1419
+ await callTool('vibe_math_set_params', { leanAsync: true, leanJobsMaxParallel: 2, leanTimeoutMs: 2500 }, rc)
1420
+ const projC = projRoot('lean-async-par')
1421
+ mkdirSync(join(projC, 'Formal'), { recursive: true })
1422
+ writeFileSync(join(projC, 'Formal', 'h1.lean'), '-- HANG\ntheorem h1 : 1 = 1 := rfl\n', 'utf8')
1423
+ writeFileSync(join(projC, 'Formal', 'h2.lean'), '-- HANG\ntheorem h2 : 1 = 1 := rfl\n', 'utf8')
1424
+ const j1 = await callTool('vibe_math_lean_run', { file: 'Formal/h1.lean', target: 'pH1' }, rc)
1425
+ const j2 = await callTool('vibe_math_lean_run', { file: 'Formal/h2.lean', target: 'pH2' }, rc)
1426
+ let both = false
1427
+ for (let i = 0; i < 120 && !both; i++) {
1428
+ const a = readJob('lean-async-par', j1.async.jobId)
1429
+ const b = readJob('lean-async-par', j2.async.jobId)
1430
+ both = (a.state === 'running' && b.state === 'running')
1431
+ if (!both) await sleep(50)
1432
+ }
1433
+ assert(both, '★★ with leanJobsMaxParallel=2 BOTH queued jobs are in running at the same time (concurrency is a parameter, not a constant)')
1434
+ for (const k of [j1, j2]) { try { await callTool('vibe_math_lean_job', { jobId: k.async.jobId, waitMs: 3000 }, rc) } catch (e) { /* ignore */ } }
1435
+
1436
+ // ── 去重(§4.3)+ 构建上下文进指纹(修订 §4)─────────────────────────────────────
1437
+ const rd = makeRoot()
1438
+ await callTool('vibe_math_new_project', { name: 'lean-async-dedupe' }, rd)
1439
+ await callTool('vibe_math_set_params', { leanAsync: true, leanCommand: 'lake', leanArgs: ['env', 'lean'] }, rd)
1440
+ const defBody = 'def reusableOne : Nat := 1\n'
1441
+ const r1 = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody }, rd)
1442
+ assert(r1.ok === true && r1.async && r1.deduped === false, 'the first archive of a definition is enqueued (not deduped)')
1443
+ await findJob('lean-async-dedupe', r1.async.jobId, ['settled'])
1444
+ const runsAfter1 = leanRuns.length
1445
+ const r2 = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody }, rd)
1446
+ assert(r2.ok === true && r2.deduped === true && r2.async === undefined, '★★★ re-archiving identical content is DEDUPED (no job, no recompile)')
1447
+ assert(leanRuns.length === runsAfter1, '★★ the deduped archive did NOT recompile')
1448
+ await callTool('vibe_math_set_params', { leanArgs: ['env', 'lean', '--threads=2'] }, rd)
1449
+ const r5 = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody }, rd)
1450
+ 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 gets a different jobId (not treated as already-verified)')
1451
+ if (r5.async) await findJob('lean-async-dedupe', r5.async.jobId, ['settled', 'failed'])
1452
+ await callTool('vibe_math_set_params', { leanArgs: ['env', 'lean'] }, rd)
1453
+ const changed = defBody + '\n-- 内容变了\n'
1454
+ const r3 = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed }, rd)
1455
+ assert(r3.ok === true && r3.deduped === false && !!r3.async, 'a changed body is NOT deduped (content hash differs)')
1456
+ if (r3.async) await findJob('lean-async-dedupe', r3.async.jobId, ['settled', 'failed'])
1457
+ const r6 = await callTool('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed }, rd)
1458
+ assert(r6.ok === true && r6.deduped === true, 'and the changed body dedupes on its own (content-based, not "first wins")')
1459
+
1460
+ // ── 崩溃恢复(§2.6 + 修订 §4):绝不置 passed ────────────────────────────────────
1461
+ const rr = makeRoot()
1462
+ await callTool('vibe_math_new_project', { name: 'lean-async-recover' }, rr)
1463
+ await callTool('vibe_math_set_params', { leanAsync: true }, rr)
1464
+ const projR = projRoot('lean-async-recover')
1465
+ mkdirSync(join(projR, 'Formal', 'Jobs'), { recursive: true })
1466
+ const goodBody = 'theorem recGood : 1 = 1 := rfl\n'
1467
+ writeFileSync(join(projR, 'Formal', 'recGood.lean'), goodBody, 'utf8')
1468
+ const ctxG = H.leanBuildContext('lean', [], [slash(VIBE)])
1469
+ const fpG = H.leanJobFingerprint(goodBody, ctxG)
1470
+ const jobIdG = H.leanJobId('pRecGood', fpG)
1471
+ writeFileSync(jobFile('lean-async-recover', jobIdG), JSON.stringify({
1472
+ jobId: jobIdG, kind: 'archive', state: 'running', attempts: 1, target: 'pRecGood', name: 'pRecGood',
1473
+ rel: 'Formal/recGood.lean', scope: 'project', sha256: H.leanContentSha(goodBody), buildSha256: fpG, buildContext: ctxG,
1474
+ engine: 'lean', argv: [], searchPaths: [slash(VIBE)], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1475
+ }), 'utf8')
1476
+ const otherJob = H.leanJobId('pRecOther', H.leanJobFingerprint('theorem other : 1 = 1 := rfl\n', ctxG))
1477
+ writeFileSync(jobFile('lean-async-recover', otherJob), JSON.stringify({
1478
+ jobId: otherJob, kind: 'archive', state: 'running', attempts: 3, target: 'pRecOther', name: 'pRecOther',
1479
+ rel: 'Formal/recMissing.lean', scope: 'project', sha256: H.leanContentSha('theorem other : 1 = 1 := rfl\n'), buildSha256: 'ffffffffffff', buildContext: ctxG,
1480
+ engine: 'lean', argv: [], searchPaths: [slash(VIBE)], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1481
+ }), 'utf8')
1482
+ await callTool('vibe_math_new_project', { name: 'lean-async-recover' }, rr) // setProject ⇒ recoverLeanJobs()
1483
+ const recsR = formalOf2('lean-async-recover').records || {}
1484
+ assert((recsR.pRecGood || {}).status === 'attempted', '★★ recovery marks a crashed running job as attempted (never passed)')
1485
+ assert((recsR.pRecOther || {}).status === 'attempted', 'recovery marks the hash-mismatched job object as attempted too')
1486
+ assert(!existsSync(join(projR, 'Verified', 'Lean', 'pRecGood.lean')), '★ recovery never writes an archived proof for a crashed job')
1487
+ const afterG = readJob('lean-async-recover', jobIdG)
1488
+ assert(Number(afterG.attempts) === 2 && ['queued', 'running', 'settled'].indexOf(afterG.state) !== -1, '★★ the matching-hash job was re-enqueued with attempts+1 (got ' + JSON.stringify({ state: afterG.state, attempts: afterG.attempts }) + ')')
1489
+ const afterO = readJob('lean-async-recover', otherJob)
1490
+ assert(afterO.state === 'interrupted' && Number(afterO.attempts) === 3, '★★ the mismatched job is only marked interrupted (NOT re-driven, attempts unchanged)')
1491
+ assert(!!(await findJob('lean-async-recover', jobIdG, ['settled'])), 'the re-enqueued job then settles normally on the heartbeat')
1492
+
1493
+ // ── lean_read(§1.2):只读、路径守卫、64KB 截断 ─────────────────────────────────
1494
+ mkdirSync(join(VIBE, 'Formal', 'Lib'), { recursive: true })
1495
+ writeFileSync(join(VIBE, 'Formal', 'Lib', 'recGood.lean'), goodBody, 'utf8')
1496
+ const rd1 = await callTool('vibe_math_lean_read', { name: 'recGood' }, rr)
1497
+ assert(rd1.ok === true && rd1.kind === 'lib' && rd1.sha256 === H.leanContentSha(goodBody) && rd1.truncated === false && rd1.text === goodBody, '★ lean_read returns the archived text + sha256 + bytes for a Lib file')
1498
+ assert(rd1.bytes === Buffer.byteLength(goodBody, 'utf8'), 'lean_read reports the byte length (keys=' + Object.keys(rd1).join(',') + ' got ' + JSON.stringify(rd1.bytes) + ' want ' + Buffer.byteLength(goodBody, 'utf8') + ')')
1499
+ assert((await callTool('vibe_math_lean_read', { name: 'recGood', kind: 'proved' }, rr)).code === 'V3_NOT_FOUND', 'kind=proved looks only in Formal/Proved')
1500
+ for (const badName of ['../etc/passwd', 'a/b', 'C:\\x\\y', '..']) {
1501
+ const rbad = await callTool('vibe_math_lean_read', { name: badName }, rr)
1502
+ assert(rbad.ok === false && rbad.code === 'V3_INVALID_ARGUMENT', '★ lean_read rejects the path-escape attempt ' + JSON.stringify(badName))
1503
+ }
1504
+ writeFileSync(join(VIBE, 'Formal', 'Lib', 'bigOne.lean'), '-- ' + 'x'.repeat(70 * 1024) + '\n', 'utf8')
1505
+ const rbig = await callTool('vibe_math_lean_read', { name: 'bigOne' }, rr)
1506
+ assert(rbig.ok === true && rbig.truncated === true && Buffer.byteLength(rbig.text, 'utf8') <= 64 * 1024, '★ lean_read truncates at 64KB and says so (truncated=true)')
1507
+ writeFileSync(join(VIBE, 'Formal', 'Lib', 'depUser.lean'), 'import Formal.Lib.bigOne\n\ndef depUser := 1\n', 'utf8')
1508
+ await callTool('vibe_math_lean_lib', {}, rr)
1509
+ const libIdx = readIf(join(VIBE, 'Formal', 'Lib', 'Index.md'))
1510
+ assert(/Formal\.Lib\.bigOne/.test(libIdx) || /bigOne/.test(libIdx), '★★ the Lib index carries the dependency scanned from import lines (idx=' + String(libIdx).slice(0, 500).replace(/\n/g, ' | ') + ')')
1511
+
1512
+ // ── 主动性档位(修订 §1 + v5 统一):提示词读 leanInitiative,不是 formalVerify ─────────
1513
+ // 第 4 项为 null = "日线完全不出现";第 6 项是该行的 formalVerify 档(默认 encourage)。
1514
+ for (const [mode, rootName, projName, want, notWant, fv] of [
1515
+ ['normal', 'lean-init-normal', 'lean-init-normal-proj', /主动性 normal/, null, 'encourage'],
1516
+ ['eager', 'lean-init-eager', 'lean-init-eager-proj', /主动档(leanInitiative=eager)/, null, 'encourage'],
1517
+ ['off', 'lean-init-off', 'lean-init-off-proj', null, /顺手形式化/, 'encourage'],
1518
+ // formalVerify:'off' + eager:日线仍要注入(主动性是独立轴),验证阶段文本仍关闭。
1519
+ ['eager', 'lean-init-off-eager', 'lean-init-off-eager-proj', /主动档(leanInitiative=eager)/, /【Lean 形式化验证(/, 'off'],
1520
+ ]) {
1521
+ const rw = makeRoot()
1522
+ await callTool('vibe_math_new_project', { name: projName }, rw)
1523
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: fv || 'encourage', leanInitiative: mode }), rw)
1524
+ await callTool('vibe_math_add_problem', { id: 'qInit' + mode + '-' + (fv || 'e'), 陈述: '主动性提示词 ' + mode }, rw)
1525
+ await callTool('vibe_math_start', {}, rw)
1526
+ const got = await drive(rw, () => spawnOf(rw, 'explorer:').length >= 1 || spawnOf(rw, 'solver:').length >= 1, 'work prompt for ' + mode, 12000)
1527
+ const text = promptText(rw)
1528
+ const ok4 = want ? want.test(text) : !/顺手形式化/.test(text)
1529
+ assert(got && ok4, '★★ leanInitiative=' + mode + (fv === 'off' ? ' (formalVerify:off)' : '') + ' is reflected in the work prompt' + (want ? '' : ':off ⇒ 连"顺手形式化"日线都不出现'))
1530
+ if (notWant) assert(!notWant.test(text), 'and the ' + mode + (fv === 'off' ? '/off' : '') + ' work prompt does not carry the verification-phase Lean block (验证要求仍由 formalVerify 表达)')
1531
+ if (fv === 'off') assert(text.indexOf('三条筛选判据') !== -1, '★★★ formalVerify:off + eager 仍带完整日线(三条筛选判据在),不是被 off 静默吞掉')
1532
+ await callTool('vibe_math_pause', {}, rw)
1533
+ }
1534
+
1535
+ // ── 卸载(spec §2.5):terminate + interrupted,绝不 passed ──────────────────────
1536
+ const rz = makeRoot()
1537
+ await callTool('vibe_math_new_project', { name: 'lean-async-dispose' }, rz)
1538
+ await callTool('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 60000 }, rz)
1539
+ const projZ = projRoot('lean-async-dispose')
1540
+ mkdirSync(join(projZ, 'Formal'), { recursive: true })
1541
+ writeFileSync(join(projZ, 'Formal', 'orphan.lean'), '-- HANG\ntheorem orphan : 1 = 1 := rfl\n', 'utf8')
1542
+ const termZ = terminated.length
1543
+ const qz = await callTool('vibe_math_lean_run', { file: 'Formal/orphan.lean', target: 'pOrphan' }, rz)
1544
+ const startedZ = await findJob('lean-async-dispose', qz.async.jobId, ['running'])
1545
+ assert(!!startedZ, 'the dispose case got a job into running')
1546
+ for (const off of disposers) { try { off() } catch (e) { /* ignore */ } }
1547
+ assert(terminated.length > termZ, '★★ unmounting the plugin terminated the in-flight compile (no orphan process)')
1548
+ assert(((formalOf2('lean-async-dispose').records || {}).pOrphan || {}).status !== 'passed', '★ dispose never marks the object passed')
1549
+ }
1550
+
1551
+ // ===============================================================
1552
+ // 10. interaction corpus (AUDIT-CHECKLIST §2.4) — a HUMAN must be able to re-read
1553
+ // every prompt the framework emitted, not just the assertions about them.
1554
+ // ===============================================================
1555
+ // ===============================================================
1556
+ // 5b. round-2 lens-1 RUNTIME: the initiative axis at EVERY v3 site
1557
+ // ===============================================================
1558
+ // Four sites: the three formalDailySection() call sites (explorer / rederive / solver) plus the
1559
+ // INLINE formalWorkLine() site inside methodKeeperPrompt. (1) formalVerify:'off' + leanInitiative:
1560
+ // 'eager' must carry the daily line at that site; (2) leanInitiative:'off' must drop it there while
1561
+ // the verification phase still follows formalVerify. Names carry the site so a single-site
1562
+ // regression names itself.
1563
+ const SITE_DAILY = /三条筛选判据/
1564
+ const SITE_VERIFY = /【Lean 形式化验证(/
1565
+ async function driveSiteV3(rw, opts, qid) {
1566
+ const q = qid
1567
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { methodKeepEvery: 1, maxExplorerRetries: 5 }, opts), rw)
1568
+ await callTool('vibe_math_add_problem', { id: q, 陈述: '站点级运行时用例' }, rw)
1569
+ await callTool('vibe_math_start', {}, rw)
1570
+ const exs = () => spawnOf(rw, 'explorer:' + q)
1571
+ await drive(rw, () => exs().length >= 1, 'explorer site ' + q)
1572
+ const out = { explorer: (exs()[0] || {}).prompt || '' }
1573
+ // answer explorers with directions until the solver appears (v3's fallback scheduler re-derives)
1574
+ fireEnd(exs()[0].childId, { meta: { kind: 'directions', qid: q, directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: '', feasibility: 0.8 }] } })
1575
+ for (let guard = 0; guard < 30 && !lastSpawn(rw, 'solver:' + q + ':d1'); guard++) {
1576
+ const pend = exs().filter((s) => !firedChildren.has(s.childId)).slice(-1)[0]
1577
+ if (pend) { firedChildren.add(pend.childId); fireEnd(pend.childId, { meta: { kind: 'directions', qid: q, directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: '', feasibility: 0.8 }] } }) }
1578
+ await pump(rw); await sleep(80)
1579
+ }
1580
+ const so = lastSpawn(rw, 'solver:' + q + ':d1')
1581
+ out.solver = (so && so.prompt) || ''
1582
+ // dead-end + a pending invention: the direction is exhausted (⇒ re-derive) AND the method keeper fires
1583
+ if (so) fireEnd(so.childId, { status: 'dead-end', survival_probability: 0.2, dead_end_reason: '站点用例:不可行', new_inventions: [{ 类型: '工具', 标题: '站点工具', 内容描述: 'x', 是否已入库: false }] })
1584
+ await drive(rw, () => exs().length >= 2, 'rederive site ' + q)
1585
+ out.rederive = (exs()[1] || {}).prompt || ''
1586
+ out.distinct = !!out.explorer && !!out.rederive && out.explorer !== out.rederive
1587
+ await drive(rw, () => !!lastSpawn(rw, 'method-keeper'), 'method-keeper site ' + q)
1588
+ out.methodKeeper = (lastSpawn(rw, 'method-keeper') || {}).prompt || ''
1589
+ await callTool('vibe_math_abort', {}, rw)
1590
+ return out
1591
+ }
1592
+ {
1593
+ const re = makeRoot()
1594
+ await callTool('vibe_math_new_project', { name: 'site-eager-off' }, re)
1595
+ const A = await driveSiteV3(re, { formalVerify: 'off', leanInitiative: 'eager' }, 'qS')
1596
+ assert(SITE_DAILY.test(A.explorer), '★ [explorer site] formalVerify:off + leanInitiative:eager injects the daily line')
1597
+ assert(SITE_DAILY.test(A.solver), '★ [solver site] formalVerify:off + leanInitiative:eager injects the daily line')
1598
+ assert(SITE_DAILY.test(A.rederive), '★ [rederive site] formalVerify:off + leanInitiative:eager injects the daily line')
1599
+ assert(SITE_DAILY.test(A.methodKeeper), '★ [methodKeeper site] formalVerify:off + leanInitiative:eager injects the daily line (inline site)')
1600
+ assert(A.explorer.indexOf('【Lean 形式化验证(') === -1 && A.solver.indexOf('【Lean 形式化验证(') === -1 && A.rederive.indexOf('【Lean 形式化验证(') === -1 && A.methodKeeper.indexOf('【Lean 形式化验证(') === -1, '★ [all sites] formalVerify:off keeps the verification-phase block closed while eager still injects the daily line')
1601
+
1602
+ const ro = makeRoot()
1603
+ await callTool('vibe_math_new_project', { name: 'site-init-off' }, ro)
1604
+ const B = await driveSiteV3(ro, { formalVerify: 'encourage', leanInitiative: 'off' }, 'qO')
1605
+ assert(!SITE_DAILY.test(B.explorer) && !/【顺手形式化/.test(B.explorer), '★ [explorer site] leanInitiative:off drops the daily line')
1606
+ assert(!SITE_DAILY.test(B.solver) && !/【顺手形式化/.test(B.solver), '★ [solver site] leanInitiative:off drops the daily line')
1607
+ assert(!SITE_DAILY.test(B.rederive) && !/【顺手形式化/.test(B.rederive), '★ [rederive site] leanInitiative:off drops the daily line')
1608
+ assert(!SITE_DAILY.test(B.methodKeeper) && !/【顺手形式化/.test(B.methodKeeper), '★ [methodKeeper site] leanInitiative:off drops the daily line (inline site)')
1609
+
1610
+ // verification phase still follows formalVerify under leanInitiative:'off'
1611
+ const rvv = makeRoot()
1612
+ await callTool('vibe_math_new_project', { name: 'site-init-off-verify' }, rvv)
1613
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage', leanInitiative: 'off' }), rvv)
1614
+ await callTool('vibe_math_add_problem', { id: 'qV', 陈述: '验证段仍在' }, rvv)
1615
+ await callTool('vibe_math_start', {}, rvv)
1616
+ await drive(rvv, () => !!lastSpawn(rvv, 'explorer:qV'), 'explorer qV')
1617
+ const exv = lastSpawn(rvv, 'explorer:qV')
1618
+ assert(!!exv && !SITE_DAILY.test(exv.prompt || ''), '★ [explorer site] 第二个 root:initiative off 时 explorer 仍无日线')
1619
+ if (exv) fireEnd(exv.childId, { meta: { kind: 'directions', qid: 'qV', directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: '', feasibility: 0.8 }] } })
1620
+ for (let guard = 0; guard < 30 && !lastSpawn(rvv, 'solver:qV:d1'); guard++) {
1621
+ const pend = spawnOf(rvv, 'explorer:qV').filter((s) => !firedChildren.has(s.childId)).slice(-1)[0]
1622
+ if (pend) { firedChildren.add(pend.childId); fireEnd(pend.childId, { meta: { kind: 'directions', qid: 'qV', directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: '', feasibility: 0.8 }] } }) }
1623
+ await pump(rvv); await sleep(80)
1624
+ }
1625
+ const sovv = lastSpawn(rvv, 'solver:qV:d1')
1626
+ if (sovv) fireEnd(sovv.childId, { status: 'success', solution: 'complete solution', survival_probability: 0.9 })
1627
+ await drive(rvv, () => spawnOf(rvv, 'verifier:').length >= 1, 'verifier qV')
1628
+ const vsp = spawnOf(rvv, 'verifier:')[0]
1629
+ assert(!!vsp && SITE_VERIFY.test(vsp.prompt || ''), '★ leanInitiative:off 时验证段仍按 formalVerify 注入(verifier 提示词含【Lean 形式化验证()')
1630
+ await callTool('vibe_math_abort', {}, rvv)
1631
+ }
1632
+
1633
+ // ===============================================================
1634
+ // 5c. round-2 D4: ONE vote-count source — a mid-verification verifierCount change must not re-derive
1635
+ // the in-flight task's quorum from live params (the audit's "same count, different code" bug).
1636
+ // ===============================================================
1637
+ section('5c D4 vote count: quorum comes from the task snapshot, not from live params')
1638
+ {
1639
+ const RD4 = makeRoot()
1640
+ await callTool('vibe_math_new_project', { name: 'd4-votecount' }, RD4)
1641
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { verifierCount: 2 }), RD4)
1642
+ await callTool('vibe_math_add_proposition', { id: 'p-d4', 概述: 'D4 票数单一来源', 概率: 0.6, 分类: '数论' }, RD4)
1643
+ await callTool('vibe_math_start', {}, RD4)
1644
+ const reD4 = verifyRe('p-d4')
1645
+ assert(await drive(RD4, () => unfiredVerifiers(RD4, reD4).length >= 2, 'two verifiers for p-d4'), 'two verifiers were dispatched for p-d4 (the creation-time snapshot)')
1646
+ // Mid-verification: the office asks for MORE verifiers. Intent-wise that is a NEW requirement for
1647
+ // FUTURE objects; this task's participant set was frozen when it was created.
1648
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { verifierCount: 4 }), RD4)
1649
+ await sleep(300)
1650
+ // Count PER EXACT rId: the proposition may legitimately have several candidate rIds
1651
+ // (r-p-d4, r-p-d4-pf0, …); only the in-flight task for one rId must stay pinned.
1652
+ const labels = spawnOf(RD4, 'verifier:').map((x) => x.label)
1653
+ const exact = {}
1654
+ for (const l of labels) { const m = /^verifier:(r-p-d4):(\d+)$/.exec(l); if (m) exact[m[1]] = (exact[m[1]] || 0) + 1 }
1655
+ const inFlight = exact['r-p-d4'] || 0
1656
+ assert(inFlight === 2, '★ [D4] raising verifierCount mid-verification does NOT re-staff the in-flight task (participant set = the snapshot;实测 ' + JSON.stringify({ inFlight: inFlight, labels: labels }) + ')')
1657
+ // Both dispatched votes satisfy the quorum ⇒ the object must conclude. A view that re-derived its
1658
+ // quorum from the live params would demand 4 votes and stall here forever.
1659
+ await runVerifyRound(RD4, 'p-d4', [1, 1])
1660
+ const cardOf = () => readIf(join(projRoot('d4-votecount'), 'Propos', '数论', 'p-d4.md'))
1661
+ const concluded = await drive(RD4, () => { const c = cardOf(); return /-\s*概率:\s*1(\D|$)/.test(c) || /状态:\s*已验证/.test(c) }, 'p-d4 concludes with the two votes it has', 15000)
1662
+ assert(concluded, '★★★ [D4] the two dispatched votes still satisfy the quorum and the object concludes (no view may re-derive its quorum from live params;card=' + JSON.stringify(cardOf().slice(0, 220)) + ')')
1663
+ await callTool('vibe_math_abort', {}, RD4)
1664
+ }
1665
+
1666
+ section('10 the captured prompt corpus is written for human review')
1667
+ {
1668
+ mkdirSync(CORPUS_DIR, { recursive: true })
1669
+ writeFileAtomic(join(CORPUS_DIR, 'formal-verify-v3.json'), JSON.stringify({ entries: corpus }, null, 2))
1670
+ const md = ['# V3 形式化验证交互语料(prompt corpus)', '',
1671
+ '> 由 `formal-verify-v3.test.mjs` 落盘:框架**真正发出**的每一条提示词原文。路径归一化:工作区 → `<WS>`,',
1672
+ '> VibeMath 根 → `<VIBEMATH>`(两者都按正/反斜杠两种写法替换,因此语料是确定性的、可 diff 的、不泄露本机路径)。',
1673
+ '> 覆盖:explorer / solver / method-keeper 的日常工作提示词(含「顺手形式化」与"归档前先跑通"),',
1674
+ '> `off`(零 Lean 文本)、`encourage`、**`require`** 三档下的表决初评与辩论提示词,`passed` 之后的忠实性审查分支',
1675
+ '> (含 `defect` 出口),以及规划提示词。', '']
1676
+ for (let i = 0; i < corpus.length; i++) {
1677
+ const c = corpus[i]
1678
+ md.push('## [' + i + '] ' + c.kind + ' · ' + c.label)
1679
+ md.push('')
1680
+ md.push('```text')
1681
+ md.push(c.prompt)
1682
+ md.push('```')
1683
+ md.push('')
1684
+ }
1685
+ writeFileAtomic(join(CORPUS_DIR, 'formal-verify-v3.md'), md.join('\n'))
1686
+ assert(existsSync(join(CORPUS_DIR, 'formal-verify-v3.json')) && existsSync(join(CORPUS_DIR, 'formal-verify-v3.md')), 'the prompt corpus was written (JSON + Markdown)')
1687
+ assert(corpus.length >= 25, 'the corpus covers the whole run (' + corpus.length + ' prompts)')
1688
+ assert(corpus.some((c) => c.label.startsWith('explorer:')) && corpus.some((c) => c.label.startsWith('solver:')) && corpus.some((c) => c.label.startsWith('method-keeper')) && corpus.some((c) => c.label.startsWith('verifier:')), 'the corpus covers every interaction type this suite drives')
1689
+ assert(corpus.some((c) => c.kind === 'wake'), 'the corpus also keeps the continuation prompts (debate rounds)')
1690
+ // generic sweep over EVERY captured prompt, not spot checks (AUDIT §2.1)
1691
+ const dirty = corpus.filter((c) => /\[object Object\]|\bNaN\b|:\s*undefined|["']undefined["']|undefined\s*[,}\]]/.test(c.prompt))
1692
+ assert(dirty.length === 0, 'no captured prompt contains placeholder garbage (' + dirty.map((d) => d.label).join(',') + ')')
1693
+ const joined = corpus.map((c) => c.prompt).join('\n')
1694
+ assert(joined.indexOf(WS) === -1 && joined.indexOf(WS.replace(/\\/g, '/')) === -1, 'every captured prompt normalises the workspace path to <WS> (the corpus stays diffable)')
1695
+ assert(joined.indexOf(VIBE) === -1 && joined.indexOf(VIBE.replace(/\\/g, '/')) === -1 && joined.indexOf('<VIBEMATH>') !== -1,
1696
+ '★ every captured prompt normalises the VibeMath root to <VIBEMATH> (no machine path leaks into the shipped corpus, contract §10 item 10)')
1697
+ assert(!corpus.some((c) => c.root === RA.id && /Lean|形式化/.test(c.prompt)),
1698
+ '★ the off-mode prompts captured in the corpus contain ZERO Lean text (off stays a true no-op)')
1699
+ // contract §10 item 10: the corpus must cover require AND the work round (not just encourage + fidelity)
1700
+ assert(corpus.some((c) => /【Lean 形式化验证(鼓励模式)】/.test(c.prompt)), '★ the corpus covers the encourage-mode verification prompt')
1701
+ assert(corpus.some((c) => /【Lean 形式化验证(强制模式)】/.test(c.prompt)), '★ the corpus covers the REQUIRE-mode verification prompt')
1702
+ assert(corpus.some((c) => /【顺手形式化(鼓励)】/.test(c.prompt)) && corpus.some((c) => /【顺手形式化(强制)】/.test(c.prompt)), '★ the corpus covers the work-round 顺手形式化 prompt in both modes')
1703
+ assert(corpus.some((c) => /一致 → Result = 1/.test(c.prompt)), '★ the corpus keeps the passed/fidelity branch verbatim for human review')
1704
+ // contract §6 hard requirements 1-2 + §10 item 9, swept over EVERY captured prompt
1705
+ const verifier = corpus.filter((c) => c.label.startsWith('verifier:'))
1706
+ assert(verifier.length >= 5 && verifier.every((c) => /Result/.test(c.prompt) && !/verdict/.test(c.prompt)),
1707
+ '★ every captured voting prompt names Result and never verdict (§6 hard requirement 2)')
1708
+ const bareTools = corpus.filter((c) => /(^|[^a-z_])lean_(run|archive|lib)/.test(c.prompt))
1709
+ assert(bareTools.length === 0, '★ no captured prompt abbreviates a Lean tool name (§6 hard requirement 1): ' + bareTools.map((b) => b.label).join(','))
1710
+ const zeroDeviation = corpus.filter((c) => c.prompt.indexOf('偏离 → 0') !== -1)
1711
+ assert(zeroDeviation.length === 0, '★ no captured prompt turns a fidelity defect into a 0 vote (§6 hard requirement 5 / §10 item 9): ' + zeroDeviation.map((b) => b.label).join(','))
1712
+ // Contract §10 item 10 + AUDIT-CHECKLIST §2.4: the shipped corpus must be BYTE-deterministic, so
1713
+ // the volatile run metadata the planner brief carries (random plan id, epoch timestamps, child
1714
+ // ids, free-slot count) must be normalised out — otherwise every run diffs and the corpus loses
1715
+ // its only purpose (human review of what agents actually read).
1716
+ const volatile = corpus.filter((c) => /plan-[0-9a-f]{8}/.test(c.label) || /plan-[0-9a-f]{8}|"at": \d{10,}|"childId": "c\d+"|"free_slots": \d+/.test(c.prompt))
1717
+ assert(volatile.length === 0, '★ no captured prompt/label keeps volatile run metadata (random plan id / epoch timestamps / child ids / slot count) — the corpus is byte-deterministic: ' + volatile.map((b) => b.label).join(','))
1718
+ assert(corpus.some((c) => /"free_slots": <SLOTS>/.test(c.prompt)) && corpus.some((c) => /plan-<ID>/.test(c.label)),
1719
+ '★ and the normalisation actually fired (a planner brief and its plan id were captured)')
1720
+ // The brief's instant-dependent snapshot is placeholdered, but its CONTRACT (key set + order) and
1721
+ // the stable configuration scalars stay, and the instruction text that references the brief is
1722
+ // untouched — so the human reviewer still sees the real planner prompt, not a blank.
1723
+ const briefEntries = corpus.filter((c) => /CURRENT STATE BRIEF \(JSON\):/.test(c.prompt))
1724
+ assert(briefEntries.length >= 3 && briefEntries.every((c) => SNAPSHOT_KEYS.every((k) => c.prompt.indexOf('"' + k + '": "<' + k.toUpperCase() + '>"') !== -1)),
1725
+ '★ every captured planner brief keeps all ' + SNAPSHOT_KEYS.length + ' contract keys with canonically placeholdered snapshot values (' + briefEntries.length + ' briefs)')
1726
+ assert(briefEntries.every((c) => /"horizon": \d+/.test(c.prompt) && /"free_slots": <SLOTS>/.test(c.prompt) && /"maxParallelThreshold": \d+/.test(c.prompt)),
1727
+ '★ the brief keeps its real configuration scalars (horizon / free_slots / maxParallelThreshold), not just placeholders')
1728
+ assert(briefEntries.every((c) => /brief\.problems\[\]\.running_solver_dirs and brief\.active_agents/.test(c.prompt) && /ACTION VOCABULARY/.test(c.prompt)),
1729
+ '★ and the planner INSTRUCTION text under review (ACTION VOCABULARY + the HARD RULES naming brief fields) is verbatim')
1730
+ {
1731
+ // Direct witness of the race the normaliser exists for: the two shapes a background tick can
1732
+ // produce (explorer registered before vs. after the forced planner read the brief, activity log
1733
+ // one entry longer, free slots one lower) must normalise to ONE byte-identical text. Without
1734
+ // this the corpus could only be checked by hoping the race fires.
1735
+ const briefText = (agents, events, slots, horizon) => 'PERSONA\nCURRENT STATE BRIEF (JSON):\n' + JSON.stringify({
1736
+ at: 1759200000000, horizon: horizon, free_slots: slots, maxParallelThreshold: 64,
1737
+ problems: [{ id: 'qE', 状态: '求解中', 方向数: agents.length, 活跃方向: agents.length ? ['d1'] : [], running_solver_dirs: [], 最高存活率: agents.length ? 0.7 : null, 解法数: 0 }],
1738
+ verify_candidates: agents.length ? [{ rId: 'r-qE', kind: 'problem', target: 'qE', prob: 0.5, priority: 1 }] : [],
1739
+ active_agents: agents, methods: [], pending_inventions: 0, last_plan: null, recent_events: events,
1740
+ }, null, 2) + '\n\nACTION VOCABULARY:\n- x'
1741
+ const withExplorer = briefText([{ childId: 'c7', role: 'explorer', target: 'qE', direction: '', round: '' }],
1742
+ [{ at: 1759200000001, event: 'start', detail: 'scheduler started' }, { at: 1759200000002, event: 'explorer', detail: 'problem qE explorer spawned (plan)' }], 63, 5)
1743
+ const withoutExplorer = briefText([], [{ at: 1759200000009, event: 'plan', detail: 'planner plan-abcdef12 called with 1 problem(s)' }], 64, 5)
1744
+ assert(scrub(withExplorer) === scrub(withoutExplorer),
1745
+ '★ the planner-brief normaliser maps the two racing snapshots (explorer present/absent, different activity order and free slots) to ONE byte-identical text')
1746
+ assert(scrub(withExplorer) !== withExplorer && /"active_agents": "<ACTIVE_AGENTS>"/.test(scrub(withExplorer)),
1747
+ '★ and it really rewrote the snapshot (the test above cannot pass just because nothing was normalised)')
1748
+ }
1749
+ }
1750
+
1751
+ console.log('')
1752
+ console.log('passed=' + passed + ' failed=' + failed)
1753
+ if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f) }
1754
+ rmSync(WS, { recursive: true, force: true })
1755
+ if (failed) process.exit(1)
1756
+ console.log('ALL GREEN')
1757
+ process.exit(0)