dsh-vibe-math 2.3.0 → 2.3.1

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 (30) hide show
  1. package/AUDIT-CHECKLIST.md +33 -3
  2. package/README.md +21 -0
  3. package/RELEASE-NOTES-2.3.1.md +134 -0
  4. package/audit-formal-sensitivity.mjs +125 -39
  5. package/audit-v5-integrity.mjs +5 -3
  6. package/docs/formal-verification.md +92 -12
  7. package/docs/test-timing.md +79 -0
  8. package/formal-verify-v2.test.mjs +286 -7
  9. package/formal-verify-v3.test.mjs +215 -8
  10. package/formal-verify-v4.test.mjs +282 -3
  11. package/formal-verify-v5.test.mjs +72 -0
  12. package/package.json +9 -2
  13. package/prompt-corpus-v2/formal-verify-v2.json +394 -0
  14. package/prompt-corpus-v2/formal-verify-v2.md +4250 -0
  15. package/prompt-corpus-v3/formal-verify-v3.json +159 -57
  16. package/prompt-corpus-v3/formal-verify-v3.md +1302 -285
  17. package/prompt-corpus-v4/formal-verify-v4.json +84 -0
  18. package/prompt-corpus-v4/formal-verify-v4.md +255 -0
  19. package/prompt-corpus-v5/prompt-corpus-v5.json +54 -16
  20. package/prompt-corpus-v5/prompt-corpus-v5.md +378 -153
  21. package/prompt-v5-integrity.test.mjs +1158 -1085
  22. package/run-tests.mjs +99 -0
  23. package/vibe-math-v2/vibe-math-v2.js +204 -22
  24. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +77 -4
  25. package/vibe-math-v3/vibe-math-v3.js +82 -21
  26. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +17 -0
  27. package/vibe-math-v4/vibe-math-v4.js +114 -22
  28. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +29 -0
  29. package/vibe-math-v5/vibe-math-v5.js +81 -22
  30. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +27 -4
@@ -11,6 +11,12 @@
11
11
  // the object is Lean-passed or carries an explicit, reasoned blocker; then the
12
12
  // same verdict DOES write the card, and the card records the formal status
13
13
  // · the three tools (run / archive / lib) write the right things to the right paths
14
+ // · the REPLY CHANNEL is real, not dead code (contract §4 / §6.3 / §10.8): a verifier's
15
+ // `formal:{decision:'blocked'|'defect', note}` reply is actually absorbed into the durable
16
+ // record for BOTH id spaces, a missing note is refused, and `defect` (a fidelity defect,
17
+ // i.e. the Lean code does not say what the proposition says) downgrades the proof, deletes
18
+ // the archived file, writes the TODO and defers the verdict — it is NEVER recorded as
19
+ // "the proposition is false" (contract §4.1)
14
20
  //
15
21
  // The Lean toolchain is mocked through the subprocess SERVICE (a fake Lean: exit 0 unless the
16
22
  // file still contains `sorry` or the marker `-- FAIL`), so these tests exercise the REAL code
@@ -24,11 +30,20 @@
24
30
  // ============================================================
25
31
  import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, statSync, mkdirSync } from 'node:fs'
26
32
  import { tmpdir } from 'node:os'
27
- import { join, dirname, isAbsolute } from 'node:path'
33
+ import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
34
+ import { fileURLToPath } from 'node:url'
28
35
 
29
36
  const PLUGIN = process.env.V2_PLUGIN
30
37
  ? new URL('file:///' + String(process.env.V2_PLUGIN).replace(/\\/g, '/'))
31
38
  : new URL('./vibe-math-v2/vibe-math-v2.js', import.meta.url)
39
+ const HERE = dirname(fileURLToPath(import.meta.url))
40
+ // Human-reviewable corpus (contract §10.10). A sensitivity probe runs THIS suite against a
41
+ // MUTATED plugin copy: writing the repository corpus from such a run would replace the
42
+ // reviewed text with mutated text, so a mutated run goes to a scratch directory instead
43
+ // (and V2_CORPUS_DIR overrides both, exactly like the v3 suite's V3_CORPUS_DIR).
44
+ const CORPUS_DIR = process.env.V2_CORPUS_DIR
45
+ ? pathResolve(process.env.V2_CORPUS_DIR)
46
+ : (process.env.V2_PLUGIN ? join(tmpdir(), 'vibe-v2-prompt-corpus') : join(HERE, 'prompt-corpus-v2'))
32
47
 
33
48
  let passed = 0, failed = 0
34
49
  const failures = []
@@ -64,6 +79,20 @@ const subprocess = {
64
79
  if (m) m[1].split(/\s+/).forEach((p) => { const q = p.trim().replace(/^'|'$/g, ''); if (q) mkdirSync(q, { recursive: true }) })
65
80
  return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
66
81
  }
82
+ // v2 ALSO deletes files through this same service (`powershell … Remove-Item -LiteralPath 'x'`,
83
+ // POSIX `rm -f 'x'`). Without honouring it, the `defect` assertion "the archived proof is
84
+ // gone" would pass vacuously (nothing was ever deleted) instead of testing the real code path.
85
+ if (/Remove-Item/.test(script)) {
86
+ const m = /-LiteralPath\s+'((?:[^']|'')*)'/.exec(script)
87
+ if (m) { try { rmSync(m[1].replace(/''/g, "'"), { force: true }) } catch (e) { /* best effort */ } }
88
+ return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
89
+ }
90
+ if (/^\s*rm -f /.test(script)) {
91
+ const re = /'((?:[^']|'\\'')*)'/g
92
+ let m
93
+ while ((m = re.exec(script)) !== null) { try { rmSync(m[1].replace(/'\\''/g, "'"), { force: true }) } catch (e) { /* best effort */ } }
94
+ return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
95
+ }
67
96
  const text = existsSync(last) ? readFileSync(last, 'utf8') : ''
68
97
  const bad = /sorry|-- FAIL/.test(text)
69
98
  leanRuns.push({ argv: spec.argv.slice(0, -1), file: last, cwd: spec.cwd, graceMs: spec.graceMs, stdio: spec.stdio })
@@ -162,18 +191,37 @@ async function makeCase(label, opts = {}) {
162
191
  // The scheduler only picks objects up while it is RUNNING (scheduleTick early-returns).
163
192
  async function startScheduler(h) { await h.call('vibe_math_start', {}) }
164
193
 
194
+ // ── test speed: fast-forward the SCHEDULER POLL (test-only, no production impact) ─────────
195
+ // The plugin registers its scheduler poll as `setInterval(..., 1000)` at apply() time, so every
196
+ // suite-side `tick()` had to wait a full wall-clock second. With ~9 verification rounds × ~17
197
+ // ticks that alone accounted for ~99% of this suite's 186 s (its v3/v4/v5 siblings take seconds).
198
+ // Patching ONLY setInterval (the suite's own `sleep` uses setTimeout) makes a poll cost ~25 ms,
199
+ // while the plugin's due-ness logic still uses the real 200 ms `tickIntervalMs` floor — no
200
+ // scheduler behaviour depends on the poll period, and `tickTheScheduler` below stays above it.
201
+ const REAL_SET_INTERVAL = globalThis.setInterval
202
+ globalThis.setInterval = function (fn, ms, ...rest) {
203
+ return REAL_SET_INTERVAL(fn, Math.min(Number(ms) || 0, 25), ...rest)
204
+ }
205
+
165
206
  const projRoot = (h) => join(h.WS, 'VibeMath', 'Projects', 'proj')
166
207
  const vibeRoot = (h) => join(h.WS, 'VibeMath')
167
208
  const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '')
168
209
  const formalStateOf = (h) => JSON.parse(readIf(join(projRoot(h), 'VibeMath_State', 'formal.json')) || '{}')
169
- // One scheduler pass. v2's timer polls at 1s and ticks when tickDue() (200ms) — so one
170
- // wall-clock second advances roughly one tick, exactly like the other v2 suites assume.
171
- const tick = (ms = 1200) => sleep(ms)
172
- async function waitFor(pred, tries = 40, ms = 120) {
210
+ // One scheduler pass. With the poll fast-forwarded above, the poll fires every ~25 ms and a tick
211
+ // runs whenever the plugin's own 200 ms `tickIntervalMs` floor has elapsed, so 260 ms is one pass.
212
+ const tick = (ms = 260) => sleep(ms)
213
+ async function waitFor(pred, tries = 80, ms = 60) {
173
214
  for (let i = 0; i < tries; i++) { const v = pred(); if (v) return v; await sleep(ms) }
174
215
  return undefined
175
216
  }
176
217
  const verifiersOf = (h, rKind, exclude) => h.spawns.filter((s) => s.label.startsWith('verifier:' + rKind) && !(exclude || []).some((o) => o.childId === s.childId))
218
+ /** A reply exactly as an agent would emit it (a fenced JSON block) — the framework's real input. */
219
+ const fence = (obj) => '```json\n' + JSON.stringify(obj) + '\n```'
220
+ /** Feed ONE agent reply (a fresh turn's end) to the framework through the real dispatch path. */
221
+ const replyFrom = (h, childId, obj) => h.fireEnd({
222
+ id: childId, runId: 'r-' + childId, provider: 'spawn', local: true, stopReason: 'completed',
223
+ lastAssistantMessage: [{ type: 'text', text: fence(obj) }],
224
+ })
177
225
  const fireVerdicts = (h, kids, vale) => {
178
226
  for (let i = 0; i < kids.length; i++) {
179
227
  h.fireEnd({ id: kids[i].childId, runId: 'v' + i, provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n' + JSON.stringify({ Result: vale, Reason: 'review ' + i }) + '\n```' }] })
@@ -192,6 +240,12 @@ async function verifyWithDebate(h, rKind, vale, firstRounds = 1, settle = 1) {
192
240
  // v2 re-wakes the SAME verifier children for every debate round (it does not spawn new
193
241
  // ones), so a new round is detected by counting the followups written to those children.
194
242
  const kids = new Set()
243
+ // Early exit: a settled object stops producing followups. Without this the loop always spent
244
+ // its FULL 16 passes (~21 s at the old 1.3 s/pass), which is what made this suite take 3
245
+ // minutes: the debate is over, but the helper kept ticking at nothing. Three consecutive
246
+ // passes with no new followup = the framework has nothing left to ask.
247
+ let lastN = -1
248
+ let quiet = 0
195
249
  for (let i = 0; i < 16; i++) {
196
250
  // The scheduler may need a whole pass before it notices the object and another before it
197
251
  // fills the verifier quota, so each wait must span several timer ticks.
@@ -206,7 +260,8 @@ async function verifyWithDebate(h, rKind, vale, firstRounds = 1, settle = 1) {
206
260
  // verifier set each pass is safe; the extra answers land before the next round's wakes.
207
261
  fireVerdicts(h, cand, rounds <= firstRounds ? vale : settle)
208
262
  await sleep(200)
209
- await tick(1100)
263
+ await tick()
264
+ if (h.followups.length === lastN) { if (++quiet >= 3) break } else { quiet = 0; lastN = h.followups.length }
210
265
  }
211
266
  const rounds = 1 + h.followups.filter((f) => kids.has(f.childId)).length
212
267
  return { first: h.spawns.filter((s) => s.label.startsWith('verifier:' + rKind)), rounds: kids.size ? rounds : 0 }
@@ -318,8 +373,14 @@ section("3 'encourage' injects the Lean section into review AND debate prompts")
318
373
  assert(/【Lean 形式化验证(鼓励模式)】/.test(reviewText), '★ the REVIEW prompt carries the Lean section')
319
374
  assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(reviewText), 'the review prompt states that a passing Lean run shrinks the question to fidelity')
320
375
  assert(/实现难度/.test(reviewText), 'the review prompt asks for the implementation-difficulty judgement')
376
+ // NOTE: this assertion is only the WORDING half. Which is exactly the trap the first version
377
+ // of this feature fell into (a green suite guarding a dead channel). The behaviour — the reply
378
+ // really landing in the durable record — is asserted in section 11.
321
379
  assert(/可以不做,但请在回执的 formal 字段写明难度判断/.test(reviewText), "'encourage' explicitly allows skipping (with a recorded judgement)")
380
+ assert(/"decision":"used\|blocked\|defect"/.test(reviewText), '★ the review contract lists the real decision enum (incl. defect)')
322
381
  assert(/vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库)/.test(reviewText), 'the review prompt names the three v2 tools')
382
+ assert(/归档可复用定义\/引理前先跑通(vibe_math_lean_archive run=true 或先 vibe_math_lean_run)/.test(reviewText), '★ the review prompt says a reusable artifact must run green BEFORE it is archived')
383
+ assert(/LEAN_NOT_FOUND/.test(reviewText) && /宿主无 Lean 工具链/.test(reviewText), '★ the review prompt writes out the missing-toolchain escape hatch (a host without Lean must not deadlock the agent)')
323
384
  const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
324
385
  assert(/DEBATE/.test(debate), 'the debate round actually happened (a followup with the debate prompt was issued)')
325
386
  assert(/【Lean 形式化验证(鼓励模式)】/.test(debate), '★ the DEBATE prompt carries the Lean section too')
@@ -330,6 +391,12 @@ section("3 'encourage' injects the Lean section into review AND debate prompts")
330
391
  assert(!!ex && /【顺手形式化(鼓励)】/.test(ex.prompt || ''), '★ the explorer work prompt carries the 顺手形式化 line')
331
392
  assert(!!ex && /vibe_math_lean_archive kind='def'/.test(ex.prompt || ''), 'the work line points at the archive tool for reusable definitions')
332
393
  assert(!!ex && /vibe_math_lean_lib 查重/.test(ex.prompt || ''), 'the work line tells members to check the reuse library first')
394
+ assert(!!ex && /归档前先跑通(vibe_math_lean_run 或 run=true);跑不通的定义不要进可复用库。/.test(ex.prompt || ''), '★ the work line forbids archiving a definition that has not run green')
395
+ assert(!!ex && /"formal":\{"target":"<对象id>","decision":"used\|blocked\|defect"/.test(ex.prompt || ''), '★ the WORK-round contract advertises the formal reply field too (otherwise a working agent has nowhere to write its judgement)')
396
+ // Every agent-facing string must name the REGISTERED tools: an abbreviated `lean_archive`
397
+ // is not a tool that exists, and agents copy these literals verbatim (contract §6 hard req. 1).
398
+ const workText = (ex.prompt || '') + '\n' + reviewText + '\n' + debate
399
+ assert(!/(^|[^a-z_])lean_(run|archive|lib)/.test(workText), '★ no injected prompt names an abbreviated tool (every occurrence is prefixed)')
333
400
  const offHost = await makeCase('enc-off')
334
401
  await offHost.call('vibe_math_add_problem', { id: 'qN', description: 'x' })
335
402
  await startScheduler(offHost)
@@ -493,10 +560,15 @@ section('6 a passing proof flips the review subject to fidelity')
493
560
  assert(/忠实性审查/.test(vpText), '★ it tells reviewers the review subject is now fidelity')
494
561
  assert(/定义 \/ 对象 \/ 条件 \/ 假设 \/ 结论是否与命题原文\*\*完全一致\*\*/.test(vpText), 'it enumerates exactly what fidelity means')
495
562
  assert(/Verified\/Lean\/r-pFid\.lean/.test(vpText), 'it points at the archived proof')
496
- assert(/把 Result 用在\*\*忠实性\*\*上/.test(vpText), 'the verdict guidance switches to fidelity')
563
+ assert(/一致 → Result = 1/.test(vpText), '★ the fidelity guidance names the REAL field (v2\'s contract field is Result)')
564
+ assert(!/verdict/.test(vpText), '★ the fidelity guidance never names a `verdict` field (that vote would be silently dropped)')
565
+ assert(/发现任何偏差,不要投 0/.test(vpText), '★ a fidelity defect is explicitly NOT to be voted as 0 (it is not a refutation)')
566
+ assert(/formal:\{decision:'defect'/.test(vpText), '★ the reviewers are given the defect reply channel that withdraws the proof')
567
+ assert(!/偏离 → 0/.test(vpText), '★ the "any deviation → 0" instruction is gone (it would fabricate a false conclusion)')
497
568
  assert(!/请先判断该对象的\*\*实现难度\*\*/.test(vpText), 'the "judge the difficulty first" wording is gone when a proof already exists')
498
569
  const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
499
570
  assert(/你不需要重新检查推导/.test(debate), '★ the debate prompt for a Lean-passed object also asks for fidelity, not re-derivation')
571
+ assert(/发现任何偏差,不要投 0/.test(debate), '★ and it carries the same no-zero rule in the debate round')
500
572
  await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'r-pBlk2', note: '涉及未形式化的分析学前置' })
501
573
  await h.call('vibe_math_add_proposition', { id: 'pBlk2', 概述: '已记录阻塞的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
502
574
  const vs2 = await verifyWithDebate(h, 'r-pBlk2', 0.5, 1)
@@ -525,6 +597,9 @@ section("7 'require' withholds a verdict until the formal record exists")
525
597
  assert(/必须产出 Lean 形式化/.test(vp), "'require' states the formalization is mandatory")
526
598
  assert(/本次裁定不会生效/.test(vp), 'the prompt warns that the verdict will not take effect without it')
527
599
  assert(/formal-required/.test(vp), 'the prompt names the machine-readable reason')
600
+ assert(/vibe_math_lean_archive kind='blocked' note=… 或回执 formal\.note/.test(vp), '★ the require wording names BOTH blocking routes with the FULL tool name')
601
+ assert(/"formal":\{"target":"r-pGate","decision":"used\|blocked\|defect"/.test(vp), '★ the require review contract carries the formal reply field keyed by the verification id')
602
+ assert(!/(^|[^a-z_])lean_(run|archive|lib)/.test(vp), '★ the require prompt contains no abbreviated tool name either')
528
603
  fireVerdicts(h, vs, 1)
529
604
  // The deferral writes Formal/TODO.md from INSIDE the settle path; waiting for that file is
530
605
  // the observable proof that the round settled (the task is dropped right after).
@@ -662,6 +737,210 @@ section("10 'encourage' never gates (a verdict still lands with no Lean artifact
662
737
  assert(!existsSync(join(proj, 'Formal', 'TODO.md')) || !/pFree/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 'no formalization TODO is created in encourage mode')
663
738
  }
664
739
 
740
+ // ---------- 11. the reply channel is REAL (contract §4 / §6.3 / §10.8) ----------
741
+ // The first version of this feature only WROTE "请在回执的 formal 字段写明难度判断" into the prompt
742
+ // and never parsed it: 177 green assertions guarded a dead channel (AUDIT-CHECKLIST §2.2). These
743
+ // cases feed a genuine agent reply through the framework's own dispatch path (subagent/end →
744
+ // handleVerifier → absorb) and assert the DURABLE record, not the wording.
745
+ section('11 the reply channel really lands in the record (blocked / note validation)')
746
+ {
747
+ const h = await makeCase('reply')
748
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
749
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
750
+ await startScheduler(h)
751
+ await h.call('vibe_math_add_proposition', { id: 'pReply', 概述: '用回执记录阻塞', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
752
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pReply'); return x.length >= 2 ? x : undefined }, 60, 250)
753
+ assert(!!vs, 'verifiers were spawned for the reply-channel proposition')
754
+ const vp = (h.spawns.find((s) => s.label === 'verifier:r-pReply:0') || {}).prompt || ''
755
+ assert(/"formal":\{"target":"r-pReply","decision":"used\|blocked\|defect"/.test(vp), '★ the review contract itself advertises the formal field, keyed by the verification id')
756
+ if (vs) {
757
+ // ① `blocked` WITH a note, named by the VERIFICATION id → the OBJECT record must be synced too.
758
+ replyFrom(h, vs[0].childId, { Result: 0.5, Reason: '我判断形式化不划算', formal: { target: 'r-pReply', decision: 'blocked', note: '需要大量未形式化的实分析前置知识' } })
759
+ const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['r-pReply'] && r['r-pReply'].status === 'blocked') ? r : undefined }, 40, 150)
760
+ assert(!!rec, '★ a `formal.decision=blocked` reply is really absorbed — the channel is not dead code')
761
+ assert(!!rec && rec['r-pReply'].decision === 'blocked' && /实分析前置知识/.test(rec['r-pReply'].note || ''), 'the record keeps the decision AND the reason')
762
+ assert(!!rec && !!rec['pReply'] && rec['pReply'].status === 'blocked' && /实分析前置知识/.test(rec['pReply'].note || ''), '★ the OBJECT-id record is synced too (the two id spaces must not drift)')
763
+ assert(/实分析前置知识/.test(readIf(join(projRoot(h), 'Formal', 'Index.md'))), 'the reply-recorded blocker reaches Formal/Index.md')
764
+ // ② `blocked` WITHOUT a note (a different target) → refused with V2_INVALID_ARGUMENT, no record.
765
+ replyFrom(h, vs[1].childId, { Result: 0.5, Reason: '不想做', formal: { target: 'r-pNoNote', decision: 'blocked' } })
766
+ let acts = ''
767
+ for (let i = 0; i < 25; i++) {
768
+ const st = await h.call('vibe_math_status', {})
769
+ acts = (st.recentActivity || []).map((a) => a.detail).join('\n')
770
+ if (/V2_INVALID_ARGUMENT/.test(acts)) break
771
+ await sleep(120)
772
+ }
773
+ assert(/V2_INVALID_ARGUMENT/.test(acts), '★ a blocked/defect judgement without a note is REJECTED with V2_INVALID_ARGUMENT')
774
+ assert(/没有写明 note/.test(acts), 'the refusal says why (an explicit decision is required, never a silent skip)')
775
+ const recN = formalStateOf(h)
776
+ assert(!(recN.records || {})['r-pNoNote'] && !(recN.records || {})['pNoNote'], 'no record is invented for the refused judgement')
777
+ assert(!/pNoNote/.test(readIf(join(projRoot(h), 'Formal', 'Index.md'))), 'the refused judgement does not reach the index either')
778
+ // ③ a reply with NO target must not invent an object (safeId('') would fall back to 'anon').
779
+ // The 0.5/0.5 round has no consensus, so the debate round re-woke both children — that is the
780
+ // observable proof that these children are registered and can answer again.
781
+ const woke = await waitFor(() => (h.followups.filter((f) => /DEBATE/.test(f.prompt || '')).length >= 2 ? true : undefined), 40, 150)
782
+ assert(!!woke, 'the debate round re-woke the verifiers (round-2 replies reach the framework again)')
783
+ replyFrom(h, vs[0].childId, { Result: 0.5, Reason: '漏写 target', formal: { decision: 'blocked', note: '没有写 target' } })
784
+ let acts2 = ''
785
+ for (let i = 0; i < 25; i++) {
786
+ const st = await h.call('vibe_math_status', {})
787
+ acts2 = (st.recentActivity || []).map((a) => a.detail).join('\n')
788
+ if (/没有 target/.test(acts2)) break
789
+ await sleep(120)
790
+ }
791
+ assert(/没有 target/.test(acts2), '★ a `formal` reply without a target is refused, not guessed')
792
+ const recT = formalStateOf(h)
793
+ assert(!(recT.records || {}).anon, '★ a `formal` reply without a target cannot invent a record (no "anon" object)')
794
+ }
795
+ }
796
+
797
+ // ---------- 12. `defect`: a fidelity defect is NOT "the proposition is false" (§4.1) ----------
798
+ section('12 a defect reply withdraws the proof, writes the TODO and defers the verdict')
799
+ {
800
+ const h = await makeCase('defect')
801
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
802
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
803
+ await startScheduler(h)
804
+ const proj = projRoot(h)
805
+ // A PASSING Lean proof exists for the object — archived under the OBJECT id, the realistic path.
806
+ const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pDefect', content: 'theorem p_defect : 2 + 2 = 4 := by decide\n' })
807
+ assert(proof.ok === true && proof.passed === true, 'precondition: the object starts Lean-passed')
808
+ const proofFile = join(proj, 'Verified', 'Lean', 'pDefect.lean')
809
+ assert(existsSync(proofFile), 'the archived proof exists before the defect is reported')
810
+ await h.call('vibe_math_add_proposition', { id: 'pDefect', 概述: '形式化写窄了的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
811
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pDefect'); return x.length >= 2 ? x : undefined }, 60, 250)
812
+ assert(!!vs, 'verifiers were spawned for the Lean-passed object')
813
+ const vp = (h.spawns.find((s) => s.label === 'verifier:r-pDefect:0') || {}).prompt || ''
814
+ assert(/忠实性审查/.test(vp) && /不要投 0/.test(vp), 'the reviewers were told to audit fidelity and NOT to vote 0 on a defect')
815
+ const DEFECT = 'Lean 只证了 n>0 的情形,命题原文是 n≥0'
816
+ if (vs) {
817
+ // A fidelity defect: the voter ABSTAINS (0.3) and records it through the reply channel.
818
+ replyFrom(h, vs[0].childId, { Result: 0.3, Reason: '形式化写窄了(弃权)', formal: { target: 'pDefect', decision: 'defect', note: DEFECT } })
819
+ const rec = await waitFor(() => { const r = (formalStateOf(h).records || {}); return (r['pDefect'] && r['pDefect'].decision === 'defect') ? r : undefined }, 40, 150)
820
+ assert(!!rec, '★ the defect reply is absorbed')
821
+ assert(!!rec && rec['pDefect'].status === 'attempted', '★ the object is DOWNGRADED to attempted (the "passed" status is withdrawn)')
822
+ assert(!!rec && rec['pDefect'].proof === '', '★ the proof pointer is cleared')
823
+ assert(!!rec && rec['pDefect'].note === DEFECT, 'the concrete deviation is recorded on the object record')
824
+ assert(!!rec && !!rec['r-pDefect'] && rec['r-pDefect'].status === 'attempted' && rec['r-pDefect'].decision === 'defect', '★ the verification-id record is downgraded too (both id spaces)')
825
+ assert(!existsSync(proofFile), '★ the archived proof Verified/Lean/pDefect.lean is DELETED')
826
+ assert(existsSync(join(proj, 'Formal', 'pDefect.lean')), 'the working file Formal/pDefect.lean is kept (the code is not lost)')
827
+ const todo = readIf(join(proj, 'Formal', 'TODO.md'))
828
+ assert(/pDefect/.test(todo) && /defect/.test(todo), '★ the object is listed in Formal/TODO.md with the defect reason')
829
+ assert(/只证了 n>0 的情形/.test(todo), 'the TODO carries the concrete deviation, not just a flag')
830
+ let acts = ''
831
+ for (let i = 0; i < 25; i++) {
832
+ const st = await h.call('vibe_math_status', {})
833
+ acts = (st.recentActivity || []).map((a) => a.detail).join('\n')
834
+ if (/忠实性缺陷/.test(acts)) break
835
+ await sleep(120)
836
+ }
837
+ assert(/忠实性缺陷/.test(acts), 'the downgrade is announced on the v2-readable channel (activity log)')
838
+ assert(/撤回「已通过」状态/.test(acts), 'the announcement says the passed status was withdrawn')
839
+ assert(/本次裁定不定论/.test(acts), 'and that the verdict is undecided — a defect withdraws a proof, it does not refute the proposition')
840
+ // Now drive the same round to a UNANIMOUS "true": without the defect it would conclude.
841
+ const settled = await verifyWithDebate(h, 'r-pDefect', 1, 0)
842
+ assert(!!settled.first, 'the round was driven to a verdict after the defect')
843
+ const todo2 = await waitFor(() => { const t = readIf(join(proj, 'Formal', 'TODO.md')); return /formal-required/.test(t) ? t : undefined }, 40, 150)
844
+ assert(!!todo2 && /r-pDefect/.test(todo2), '★ require mode DEFERS after a defect (the object enters the formalization TODO as formal-required)')
845
+ await tick(400)
846
+ const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
847
+ const p = props.find((x) => x.id === 'pDefect') || {}
848
+ assert(p.布尔估计 === 0.5, '★★ the proposition is NOT recorded as false — 布尔估计 unchanged (got ' + p.布尔估计 + ')')
849
+ assert(!(p.证明列表 || []).some((x) => x.正确概率 === 1), 'no probability-1 proof entry was written')
850
+ assert(p.优先级 !== 'never', 'the priority was not pinned to never')
851
+ assert(!existsSync(join(proj, 'Verified', '数论_Verified.json')), '★ no Verified card: the verdict is UNDECIDED, not "false"')
852
+ }
853
+ // The reverse direction: a defect named by the VERIFICATION id must still downgrade the OBJECT
854
+ // record that actually holds the proof (this is the pair that silently drifts when only one side
855
+ // is written — formalGateRecord would read `passed` from the other side).
856
+ const h2 = await makeCase('defect-rid')
857
+ await h2.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
858
+ await h2.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
859
+ await startScheduler(h2)
860
+ const proj2 = projRoot(h2)
861
+ await h2.call('vibe_math_lean_archive', { kind: 'proof', target: 'pDefect2', content: 'theorem p_defect2 : 2 + 2 = 4 := by decide\n' })
862
+ await h2.call('vibe_math_add_proposition', { id: 'pDefect2', 概述: '回执用验证 id 命名的缺陷', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
863
+ const vs2 = await waitFor(() => { const x = verifiersOf(h2, 'r-pDefect2'); return x.length >= 2 ? x : undefined }, 60, 250)
864
+ assert(!!vs2, 'verifiers were spawned for the reverse-direction case')
865
+ if (vs2) {
866
+ replyFrom(h2, vs2[0].childId, { Result: 0.3, Reason: '写宽了', formal: { target: 'r-pDefect2', decision: 'defect', note: 'Lean 版本没有假设 n≥1,比原文更宽' } })
867
+ const rec2 = await waitFor(() => { const r = (formalStateOf(h2).records || {}); return (r['r-pDefect2'] && r['r-pDefect2'].decision === 'defect') ? r : undefined }, 40, 150)
868
+ assert(!!rec2, '★ a defect named by the verification id is absorbed')
869
+ assert(!!rec2 && !!rec2['pDefect2'] && rec2['pDefect2'].status === 'attempted', '★ and the OBJECT record that held `passed` is downgraded as well')
870
+ assert(!!rec2 && rec2['pDefect2'].proof === '', 'its proof pointer is cleared too')
871
+ assert(!existsSync(join(proj2, 'Verified', 'Lean', 'pDefect2.lean')), '★ the archived proof is deleted even though the reply named the other id')
872
+ assert(/pDefect2/.test(readIf(join(proj2, 'Formal', 'TODO.md'))), 'the object is on the formalization TODO')
873
+ }
874
+ }
875
+
876
+ // ---------- 13. the prompt corpus (contract §10.10) ----------
877
+ // A HUMAN must be able to re-read every prompt the framework emitted, not just the assertions
878
+ // about them. Paths are normalised so the dump is deterministic, diffable and machine-free.
879
+ section('13 the captured prompt corpus is written for human review')
880
+ {
881
+ // Freeze the scheduler in every case FIRST: a still-running tick loop could emit one more
882
+ // prompt between two runs and make the corpus non-deterministic.
883
+ for (const h of hosts) { try { await h.call('vibe_math_pause', {}) } catch (e) { /* ignore */ } }
884
+ const scrub = (h, s) => {
885
+ const ws = String(h.WS)
886
+ const slash = ws.replace(/\\/g, '/')
887
+ return String(s == null ? '' : s)
888
+ .split(slash + '/VibeMath').join('<VIBEMATH>')
889
+ .split(ws + '\\VibeMath').join('<VIBEMATH>')
890
+ .split(slash).join('<WS>')
891
+ .split(ws).join('<WS>')
892
+ }
893
+ const entries = []
894
+ for (const h of hosts) {
895
+ for (const s of h.spawns) entries.push({ kind: 'spawn', case: h.label, label: s.label, prompt: scrub(h, s.prompt) })
896
+ for (const f of h.followups) {
897
+ const owner = h.spawns.find((s) => s.childId === f.childId)
898
+ entries.push({ kind: 'wake', case: h.label, label: owner ? owner.label : f.childId, prompt: scrub(h, f.prompt) })
899
+ }
900
+ }
901
+ mkdirSync(CORPUS_DIR, { recursive: true })
902
+ writeFileSync(join(CORPUS_DIR, 'formal-verify-v2.json'), JSON.stringify({ entries: entries }, null, 2), 'utf8')
903
+ const md = ['# V2 形式化验证交互语料(prompt corpus)', '',
904
+ '> 由 `formal-verify-v2.test.mjs` 落盘:框架**真正发出**的每一条提示词原文。',
905
+ '> 工作区路径归一化为 `<WS>`、VibeMath 根归一化为 `<VIBEMATH>`,因此可 diff、不泄露本机路径。',
906
+ '> 覆盖:off 档(无任何 Lean 文字)、encourage 与 require 的表决初评/辩论、passed 后的忠实性分支、',
907
+ '> 以及平时工作轮的「顺手形式化」段落与 formal 回执契约。', '']
908
+ for (let i = 0; i < entries.length; i++) {
909
+ const e = entries[i]
910
+ md.push('## [' + i + '] ' + e.kind + ' · ' + e.label + ' · case=' + e.case)
911
+ md.push('')
912
+ md.push('```text')
913
+ md.push(e.prompt)
914
+ md.push('```')
915
+ md.push('')
916
+ }
917
+ writeFileSync(join(CORPUS_DIR, 'formal-verify-v2.md'), md.join('\n'), 'utf8')
918
+ assert(existsSync(join(CORPUS_DIR, 'formal-verify-v2.json')) && existsSync(join(CORPUS_DIR, 'formal-verify-v2.md')), 'the prompt corpus was written (JSON + Markdown)')
919
+ assert(entries.length >= 15, 'the corpus covers the whole run (' + entries.length + ' prompts)')
920
+ assert(entries.some((e) => /【Lean 形式化验证(鼓励模式)】/.test(e.prompt)), 'the corpus contains the encourage verify prompt')
921
+ assert(entries.some((e) => /【Lean 形式化验证(强制模式)】/.test(e.prompt)), '★ the corpus contains the REQUIRE verify prompt')
922
+ assert(entries.some((e) => /你不需要重新检查推导/.test(e.prompt) && /一致 → Result = 1/.test(e.prompt)), 'the corpus contains the passed/fidelity prompt')
923
+ assert(entries.some((e) => /【顺手形式化(鼓励)】/.test(e.prompt)), 'the corpus contains the work-round 顺手形式化 prompt')
924
+ assert(entries.some((e) => /【顺手形式化/.test(e.prompt) && /"formal":\{"target":"<对象id>","decision":"used\|blocked\|defect"/.test(e.prompt)), 'the corpus contains the formal reply contract line')
925
+ assert(entries.some((e) => !/Lean|形式化/.test(e.prompt)), 'the corpus contains off-mode prompts with no Lean text at all')
926
+ assert(entries.some((e) => e.kind === 'wake'), 'the corpus also keeps the continuation prompts (debate rounds)')
927
+ // Generic sweeps over EVERY captured prompt, not spot checks (AUDIT §2.1).
928
+ const joined = entries.map((e) => e.prompt).join('\n')
929
+ const bare = entries.filter((e) => /(^|[^a-z_])lean_(run|archive|lib)/.test(e.prompt))
930
+ assert(bare.length === 0, '★ no captured prompt names an abbreviated tool (' + bare.map((b) => b.label).join(',') + ')')
931
+ const zero = entries.filter((e) => /偏离\s*→\s*0/.test(e.prompt))
932
+ assert(zero.length === 0, '★ no captured prompt turns a fidelity defect into a 0 vote (' + zero.map((z) => z.label).join(',') + ')')
933
+ const wrongField = entries.filter((e) => /忠实性/.test(e.prompt) && /verdict/.test(e.prompt))
934
+ assert(wrongField.length === 0, '★ no fidelity prompt names a `verdict` field (' + wrongField.map((w) => w.label).join(',') + ')')
935
+ const dirty = entries.filter((e) => /\[object Object\]|\bNaN\b|:\s*undefined|["']undefined["']|undefined\s*[,}\]]/.test(e.prompt))
936
+ assert(dirty.length === 0, 'no captured prompt contains placeholder garbage (' + dirty.map((d) => d.label).join(',') + ')')
937
+ assert(joined.indexOf('<VIBEMATH>') !== -1, 'the VibeMath root is normalised to <VIBEMATH>')
938
+ for (const h of hosts) {
939
+ const ws = String(h.WS)
940
+ assert(joined.indexOf(ws) === -1 && joined.indexOf(ws.replace(/\\/g, '/')) === -1, 'no captured prompt leaks a machine path (' + h.label + ')')
941
+ }
942
+ }
943
+
665
944
  // cleanup
666
945
  for (const h of hosts) { try { rmSync(h.WS, { recursive: true, force: true }) } catch (e) { /* ignore */ } }
667
946