dsh-vibe-math 2.6.0 → 2.7.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 (57) hide show
  1. package/README.en.md +156 -389
  2. package/README.md +156 -390
  3. package/cordis.patch.yml +56 -14
  4. package/docs/AUDIT-CHECKLIST.md +2 -2
  5. package/docs/final-paper.md +1 -0
  6. package/docs/formal-verification.md +184 -29
  7. package/docs/math-computation.md +121 -0
  8. package/docs/release-notes/RELEASE-NOTES-2.7.0.en.md +151 -0
  9. package/docs/release-notes/RELEASE-NOTES-2.7.0.md +115 -0
  10. package/docs/test-timing.md +24 -12
  11. package/installer.js +7 -4
  12. package/package.json +18 -2
  13. package/prompt-corpus-persona/persona-corpus.json +12 -12
  14. package/prompt-corpus-persona/persona-corpus.md +116 -32
  15. package/prompt-corpus-v2/formal-verify-v2.json +116 -92
  16. package/prompt-corpus-v2/formal-verify-v2.md +1430 -55
  17. package/prompt-corpus-v3/formal-verify-v3.json +101 -53
  18. package/prompt-corpus-v3/formal-verify-v3.md +1001 -22
  19. package/prompt-corpus-v4/formal-verify-v4.json +14 -14
  20. package/prompt-corpus-v4/formal-verify-v4.md +247 -14
  21. package/prompt-corpus-v5/prompt-corpus-v5.json +62 -62
  22. package/prompt-corpus-v5/prompt-corpus-v5.md +570 -3
  23. package/tests/audit-math-computation-contract.mjs +191 -0
  24. package/tests/audit-math-computation-parity.mjs +169 -0
  25. package/tests/audit-math-computation-sensitivity.mjs +231 -0
  26. package/tests/audit-persona-sensitivity.mjs +4 -4
  27. package/tests/audit-persona-surface.test.mjs +38 -10
  28. package/tests/audit-v5-integrity.mjs +14 -0
  29. package/tests/e2e-v5-round2.test.mjs +452 -2
  30. package/tests/formal-verify-v2.test.mjs +292 -6
  31. package/tests/formal-verify-v3.test.mjs +271 -4
  32. package/tests/formal-verify-v4.test.mjs +295 -6
  33. package/tests/formal-verify-v5.test.mjs +9 -1
  34. package/tests/helpers/math-computation-fake-seam.mjs +135 -0
  35. package/tests/math-computation-shared.test.mjs +504 -0
  36. package/tests/prompt-v5-integrity.test.mjs +5 -5
  37. package/vibe-math-v2/agent.cordis.yml +20 -8
  38. package/vibe-math-v2/math-computation.js +936 -0
  39. package/vibe-math-v2/math-engines.js +156 -0
  40. package/vibe-math-v2/vibe-math-v2.js +837 -40
  41. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  42. package/vibe-math-v3/agent.cordis.yml +20 -8
  43. package/vibe-math-v3/math-computation.js +936 -0
  44. package/vibe-math-v3/math-engines.js +156 -0
  45. package/vibe-math-v3/vibe-math-v3.js +828 -47
  46. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  47. package/vibe-math-v4/agent.cordis.yml +34 -6
  48. package/vibe-math-v4/math-computation.js +936 -0
  49. package/vibe-math-v4/math-engines.js +156 -0
  50. package/vibe-math-v4/vibe-math-v4.js +801 -31
  51. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  52. package/vibe-math-v5/agent.cordis.yml +38 -6
  53. package/vibe-math-v5/math-computation.js +936 -0
  54. package/vibe-math-v5/math-engines.js +156 -0
  55. package/vibe-math-v5/vibe-math-v5.js +1120 -37
  56. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +83 -3
  57. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +251 -0
@@ -36,6 +36,8 @@ import { fileURLToPath } from 'node:url'
36
36
  const PLUGIN = process.env.V2_PLUGIN
37
37
  ? new URL('file:///' + String(process.env.V2_PLUGIN).replace(/\\/g, '/'))
38
38
  : new URL('../vibe-math-v2/vibe-math-v2.js', import.meta.url)
39
+ // 纯函数测试面(__testHelpers):参数归一化/搜索路径计划/哈希/指纹都从这里守卫。
40
+ const MOD = await import(PLUGIN.href)
39
41
  const HERE = dirname(fileURLToPath(import.meta.url))
40
42
  // Human-reviewable corpus (contract §10.10). A sensitivity probe runs THIS suite against a
41
43
  // MUTATED plugin copy: writing the repository corpus from such a run would replace the
@@ -143,6 +145,7 @@ function makeHost(WS, opts = {}) {
143
145
  const spawns = []
144
146
  const followups = []
145
147
  const interrupts = []
148
+ const disposers = []
146
149
  const ctx = {
147
150
  get(name) {
148
151
  if (name === 'subprocess') return opts.noSubprocess ? undefined : subprocess
@@ -150,7 +153,7 @@ function makeHost(WS, opts = {}) {
150
153
  return undefined
151
154
  },
152
155
  on(event, fn) { (listeners[event] = listeners[event] || []).push(fn) },
153
- effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
156
+ effect(fn) { const d = fn(); const off = () => { if (typeof d === 'function') d() }; disposers.push(off); return off },
154
157
  logger: { info() {}, warn() {}, error() {} },
155
158
  tools: { register(spec) { toolRegs.push(spec); return () => {} } },
156
159
  commands: { register(spec) { cmdRegs.push(spec); return () => {} } },
@@ -180,7 +183,7 @@ function makeHost(WS, opts = {}) {
180
183
  },
181
184
  }
182
185
  const fireEnd = (info) => { for (const h of (listeners['subagent/end'] || [])) h(info) }
183
- return { ctx, toolRegs, cmdRegs, spawns, followups, interrupts, fireEnd, WS }
186
+ return { ctx, toolRegs, cmdRegs, spawns, followups, interrupts, disposers, unmount: () => { for (const d of disposers) { try { d() } catch (e) { /* ignore */ } } }, fireEnd, WS }
184
187
  }
185
188
 
186
189
  function makeRoot(id, WS) {
@@ -212,6 +215,10 @@ async function makeCase(label, opts = {}) {
212
215
  // 200ms is the sanitizer floor; verifierCount=2 (the sanitizer floor too) keeps each
213
216
  // verification exactly two children, so a round settles predictably.
214
217
  await call('vibe_math_set_params', { tickIntervalMs: 200, verifierCount: 2 })
218
+ // leanAsync defaults to TRUE (background queue). This suite's Lean sections guard the
219
+ // **synchronous** path verbatim, so every case pins leanAsync=false here; the async state
220
+ // machine (default档) is guarded by its own section, which flips it back on explicitly.
221
+ await call('vibe_math_set_params', { leanAsync: false })
215
222
  const c = Object.assign(h, { WS, root, call, label })
216
223
  hosts.push(c)
217
224
  return c
@@ -407,7 +414,7 @@ section('2 parameter validation and runtime switching')
407
414
  await startScheduler(h)
408
415
  await tick(2000)
409
416
  const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:q2')), 60, 200)
410
- assert(!!ex && /【顺手形式化(鼓励)】/.test(ex.prompt || ''), '★ switching to encourage changes the NEXT prompt immediately')
417
+ assert(!!ex && /【顺手形式化(鼓励[^)]*)】/.test(ex.prompt || ''), '★ switching to encourage changes the NEXT prompt immediately')
411
418
  if (ex) h.fireEnd({ id: ex.childId, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"directions":[{"id":"d1","title":"D","method":"m","core_assumption":"c","feasibility":0.5}]}\n```' }] })
412
419
  await h.call('vibe_math_set_params', { formalVerify: 'off' })
413
420
  await sleep(400)
@@ -440,7 +447,7 @@ section("3 'encourage' injects the Lean section into review AND debate prompts")
440
447
  // really landing in the durable record — is asserted in section 11.
441
448
  assert(/可以不做,但请在回执的 formal 字段写明难度判断/.test(reviewText), "'encourage' explicitly allows skipping (with a recorded judgement)")
442
449
  assert(/"decision":"used\|blocked\|defect"/.test(reviewText), '★ the review contract lists the real decision enum (incl. defect)')
443
- assert(/vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库)/.test(reviewText), 'the review prompt names the three v2 tools')
450
+ assert(/vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库\/jobs)· vibe_math_lean_read(取回归档原文)/.test(reviewText), 'the review prompt names all four v2 tools (incl. the new read-only one)')
444
451
  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')
445
452
  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)')
446
453
  const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
@@ -450,7 +457,7 @@ section("3 'encourage' injects the Lean section into review AND debate prompts")
450
457
  await h.call('vibe_math_add_problem', { id: 'qW', description: '顺手形式化测试' })
451
458
  await tick(2000)
452
459
  const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:qW')), 60, 200)
453
- assert(!!ex && /【顺手形式化(鼓励)】/.test(ex.prompt || ''), '★ the explorer work prompt carries the 顺手形式化 line')
460
+ assert(!!ex && /【顺手形式化(鼓励[^)]*)】/.test(ex.prompt || ''), '★ the explorer work prompt carries the 顺手形式化 line')
454
461
  assert(!!ex && /vibe_math_lean_archive kind='def'/.test(ex.prompt || ''), 'the work line points at the archive tool for reusable definitions')
455
462
  assert(!!ex && /vibe_math_lean_lib 查重/.test(ex.prompt || ''), 'the work line tells members to check the reuse library first')
456
463
  assert(!!ex && /归档前先跑通(vibe_math_lean_run 或 run=true);跑不通的定义不要进可复用库。/.test(ex.prompt || ''), '★ the work line forbids archiving a definition that has not run green')
@@ -472,6 +479,10 @@ section('4 lean_run executes through the subprocess service and reports honestly
472
479
  {
473
480
  const h = await makeCase('run')
474
481
  await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
482
+ // 这一节把 leanAsync 固定为 false:它逐字守卫**同步路径**(旧的 await 语义)。
483
+ // 异步(默认档)的接口与状态机由后面的「Lean async」小节守卫——两条路径都要有绿灯。
484
+ await h.call('vibe_math_set_params', { leanAsync: false })
485
+ assert((await h.call('vibe_math_status', {})).params.leanAsync === false, 'the sync section pins leanAsync=false explicitly')
475
486
  const proj = projRoot(h)
476
487
  mkdirSync(join(proj, 'Formal'), { recursive: true })
477
488
  writeFileSync(join(proj, 'Formal', 'good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
@@ -1282,6 +1293,281 @@ section('15c the framework push report and the gate notice are captured')
1282
1293
  await g.call('vibe_math_pause', {})
1283
1294
  }
1284
1295
 
1296
+ section('15d Lean 增量 + 异步(spec §1–§4 + 修订 §1–§5):参数、argv、队列、去重、只读面、提示词')
1297
+ {
1298
+ const H = MOD.__testHelpers
1299
+ const slash = (s) => String(s == null ? '' : s).replace(/\\/g, '/') // 插件内部用 '/',测试用 path.join ⇒ 比较前统一
1300
+ const SAME = (a, b) => slash(a) === slash(b)
1301
+ // ── 参数:默认值 + 归一化(字符串 'false' 绝不能滑过去) ────────────────────────────
1302
+ const a = await makeCase('lean-params')
1303
+ const st0 = await a.call('vibe_math_status', {})
1304
+ assert(st0.params.leanAsync === false, 'the suite pins leanAsync=false by default (sync-path guard)')
1305
+ await a.call('vibe_math_set_params', { leanAsync: true })
1306
+ assert((await a.call('vibe_math_status', {})).params.leanAsync === true, 'leanAsync=true is accepted (the async档)')
1307
+ const bad = await a.call('vibe_math_set_params', { leanAsync: 'false' })
1308
+ assert(bad.ok === true, 'set_params accepts a (badly typed) leanAsync')
1309
+ assert((await a.call('vibe_math_status', {})).params.leanAsync === true, '★★★ leanAsync="false" (a STRING) does not slip through the `else out[k]=v` tail: it falls back to the boolean default (true) — a truthy string must never be read as a boolean')
1310
+ const defs = (await a.call('vibe_math_status', {})).params
1311
+ assert(defs.leanJobsMaxParallel === 1 && defs.leanInitiative === 'normal' && Array.isArray(defs.leanSearchPaths) && defs.leanSearchPaths.length === 0, 'the amendment defaults are leanJobsMaxParallel=1 / leanInitiative=normal / leanSearchPaths=[]')
1312
+ await a.call('vibe_math_set_params', { leanInitiative: 'banana', leanJobsMaxParallel: 0, leanSearchPaths: 'not-an-array' })
1313
+ const p1 = (await a.call('vibe_math_status', {})).params
1314
+ assert(p1.leanInitiative === 'normal', 'leanInitiative rejects an unknown mode back to normal')
1315
+ assert(p1.leanJobsMaxParallel === 1, 'leanJobsMaxParallel is floored at 1 (never 0/unbounded)')
1316
+ assert(Array.isArray(p1.leanSearchPaths) && p1.leanSearchPaths.length === 0, 'leanSearchPaths rejects a non-array back to []')
1317
+ await a.call('vibe_math_set_params', { leanSearchPaths: [' /opt/lean-lib ', '/opt/lean-lib', ''] })
1318
+ assert(JSON.stringify((await a.call('vibe_math_status', {})).params.leanSearchPaths) === JSON.stringify(['/opt/lean-lib']), 'leanSearchPaths trims, drops empties and de-duplicates')
1319
+ // 纯函数:搜索路径计划(修订 §2)
1320
+ const planDefault = H.leanSearchPathPlan([], [], '/root/vm')
1321
+ assert(JSON.stringify(planDefault.paths) === JSON.stringify(['/root/vm']) && planDefault.inject.length === 2, '★ default plan injects exactly the automatic VibeMath root')
1322
+ const planExtra = H.leanSearchPathPlan(['env', 'lean'], ['/a', '/b'], '/root/vm')
1323
+ assert(JSON.stringify(planExtra.paths) === JSON.stringify(['/a', '/b', '/root/vm']), '★★ user leanSearchPaths come FIRST, then the automatic root')
1324
+ assert(JSON.stringify(planExtra.inject) === JSON.stringify(['--search-path', '/a', '--search-path', '/b', '--search-path', '/root/vm']), 'and each path is injected with its own --search-path flag')
1325
+ assert(H.leanSearchPathPlan(['--search-path', '/x'], ['/a'], '/root/vm').inject.length === 0, '★ an explicit --search-path in leanArgs suppresses all injection (user wins)')
1326
+ assert(H.leanSearchPathPlan(['-R', '/x'], ['/a'], '/root/vm').inject.length === 0 && H.leanSearchPathPlan(['--root', '/x'], ['/a'], '/root/vm').inject.length === 0, '★ -R / --root are honoured too')
1327
+
1328
+ // ── argv:注入位置在用户参数之后、文件名之前 ──────────────────────────────────────
1329
+ const b = await makeCase('lean-argv')
1330
+ await b.call('vibe_math_set_params', { leanAsync: false, leanArgs: ['env', 'lean'], leanCommand: 'lean' })
1331
+ const projB = projRoot(b)
1332
+ mkdirSync(join(projB, 'Formal'), { recursive: true })
1333
+ writeFileSync(join(projB, 'Formal', 'srch.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1334
+ const runsBefore = leanRuns.length
1335
+ await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1336
+ const argv1 = leanRuns[leanRuns.length - 1].argv
1337
+ assert(leanRuns.length === runsBefore + 1, 'the sync run really reached the compiler')
1338
+ assert(JSON.stringify(argv1.slice(0, 2)) === JSON.stringify(['lean', 'env']), 'the user leanArgs are preserved in order')
1339
+ assert(argv1[argv1.length - 2] === '--search-path' && SAME(argv1[argv1.length - 1], vibeRoot(b)), '★★ --search-path + the VibeMath root are injected AFTER the user args and immediately BEFORE the file (argv=' + JSON.stringify(argv1) + ')')
1340
+ await b.call('vibe_math_set_params', { leanArgs: ['env', 'lean', '--search-path', '/explicit'] })
1341
+ await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1342
+ const argv2 = leanRuns[leanRuns.length - 1].argv
1343
+ assert(argv2.indexOf('--search-path') === 3 && argv2[4] === '/explicit' && argv2.filter((x) => x === '--search-path').length === 1, '★ an explicit --search-path is respected verbatim (no second injection) — got ' + JSON.stringify(argv2))
1344
+ await b.call('vibe_math_set_params', { leanArgs: ['env', 'lean'], leanSearchPaths: ['/extra1', '/extra2'] })
1345
+ await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1346
+ const argv3 = leanRuns[leanRuns.length - 1].argv
1347
+ assert(JSON.stringify(argv3.slice(-6)) === JSON.stringify(['--search-path', '/extra1', '--search-path', '/extra2', '--search-path', slash(vibeRoot(b))]), '★★ leanSearchPaths are injected first, then the automatic root (got ' + JSON.stringify(argv3.slice(-6)) + ')')
1348
+
1349
+ // ── 异步状态机:入队立即返回 → 心跳排空 → 只有 settled(ok) 才是通过 ────────────────
1350
+ const c = await makeCase('lean-async')
1351
+ await c.call('vibe_math_set_params', { leanAsync: true, tickIntervalMs: 200 })
1352
+ const projC = projRoot(c)
1353
+ mkdirSync(join(projC, 'Formal'), { recursive: true })
1354
+ writeFileSync(join(projC, 'Formal', 'async-good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1355
+ writeFileSync(join(projC, 'Formal', 'async-bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
1356
+ const nBefore = leanRuns.length
1357
+ const q1 = await c.call('vibe_math_lean_run', { file: 'Formal/async-good.lean', target: 'pAsync' })
1358
+ assert(q1.ok === true && !!q1.async && q1.async.state === 'queued' && typeof q1.async.jobId === 'string' && q1.async.jobId.length > 3, '★★ leanAsync=true returns IMMEDIATELY with {async:{jobId,state:queued}} (got ' + JSON.stringify(q1.async) + ')')
1359
+ assert(leanRuns.length === nBefore, '★★ the tool call itself did NOT block on the compiler (it is enqueued, not run)')
1360
+ assert(q1.async.jobId.indexOf('pAsync-') === 0, 'the jobId is <target>-<fingerprint[0:12]> (got ' + q1.async.jobId + ')')
1361
+ const jobFile = join(projC, 'Formal', 'Jobs', q1.async.jobId + '.json')
1362
+ assert(existsSync(jobFile), 'the job record is mirrored to <project>/Formal/Jobs/<jobId>.json')
1363
+ const rec0 = JSON.parse(readIf(jobFile))
1364
+ assert(rec0.state === 'queued' && typeof rec0.sha256 === 'string' && typeof rec0.buildSha256 === 'string' && rec0.buildContext.length > 0, '★ the job record carries state + content hash + build-context hash')
1365
+ assert((formalStateOf(c).records || {}).pAsync === undefined || formalStateOf(c).records.pAsync.status !== 'passed', '★★ a queued job never marks the object passed')
1366
+ const done1 = await waitFor(() => { const r = JSON.parse(readIf(jobFile) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1367
+ assert(!!done1, 'the apply-level heartbeat drained the queue and settled the job (got ' + JSON.parse(readIf(jobFile) || '{}').state + ')')
1368
+ assert(done1 && done1.exitCode === 0, 'the settled record keeps exitCode 0')
1369
+ const recAfter = formalStateOf(c).records.pAsync
1370
+ assert(!!recAfter && recAfter.status === 'attempted' && recAfter.run && recAfter.run.ok === true, 'a green RUN record lands as attempted (stored, not promoted) with run.ok=true')
1371
+ assert(!!recAfter.async && recAfter.async.state === 'settled' && recAfter.async.jobId === q1.async.jobId, 'the object record carries the async job pointer/state')
1372
+ // 红色作业:attempted + 编译器输出入库,绝不 passed
1373
+ const q2 = await c.call('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' })
1374
+ const done2 = await waitFor(() => { const r = JSON.parse(readIf(join(projC, 'Formal', 'Jobs', q2.async.jobId + '.json')) || '{}'); return r.state === 'failed' ? r : undefined }, 80, 60)
1375
+ assert(!!done2 && done2.exitCode === 1, '★ a red async run settles as failed with exitCode 1')
1376
+ const recBad = formalStateOf(c).records.pAsyncBad
1377
+ assert(!!recBad && recBad.status === 'attempted' && recBad.run.ok === false, '★ a red async run is recorded as attempted (never passed)')
1378
+ assert(/sorry/.test(String(recBad.run.stderrTail || '')), 'and the compiler output is kept in the record for the member to fix')
1379
+ // ── 归档证明(异步):落地前不是 passed;落地 ok 才写 Verified/Lean/ ────────────────
1380
+ await c.call('vibe_math_add_proposition', { id: 'pAsymProof', 概述: '异步归档证明', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1381
+ mkdirSync(join(projC, 'Formal'), { recursive: true })
1382
+ const q3 = await c.call('vibe_math_lean_archive', { kind: 'proof', target: 'pAsymProof', content: 'theorem pAsymProof : 1 = 1 := rfl\n' })
1383
+ assert(q3.ok === true && q3.async && q3.async.state === 'queued' && q3.status === 'attempted', '★★ kind=proof in async档 returns queued + attempted (NOT passed)')
1384
+ assert(!existsSync(join(projC, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ the archived proof file does NOT exist while the job is still queued')
1385
+ assert((formalStateOf(c).records.pAsymProof || {}).status !== 'passed', 'and the record is not passed yet')
1386
+ const done3 = await waitFor(() => { const r = JSON.parse(readIf(join(projC, 'Formal', 'Jobs', q3.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1387
+ assert(!!done3, 'the proof job settled green')
1388
+ assert(existsSync(join(projC, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ only after settled(ok) is Verified/Lean/<id>.lean written')
1389
+ assert((formalStateOf(c).records.pAsymProof || {}).status === 'passed', '★★ and only then does the object become passed')
1390
+ assert(/pAsymProof/.test(readIf(join(projC, 'Formal', 'Index.md'))), 'the index reflects the passed object')
1391
+ // ── lean_job(修订 §3):清单 / 单查 / waitMs ─────────────────────────────────────
1392
+ const listJobs = await c.call('vibe_math_lean_job', {})
1393
+ assert(listJobs.ok === true && Array.isArray(listJobs.jobs) && listJobs.jobs.length >= 3, '★ lean_job without jobId returns the session job list')
1394
+ assert(listJobs.jobs.every((j) => !!j.paths && typeof j.paths.receipt === 'string'), 'every listed job carries its receipt path')
1395
+ const one = await c.call('vibe_math_lean_job', { jobId: q3.async.jobId })
1396
+ 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')
1397
+ assert((await c.call('vibe_math_lean_job', { jobId: 'nope-000000000000' })).code === 'V2_NOT_FOUND', 'an unknown jobId is refused with a typed code')
1398
+ const q4 = await c.call('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' })
1399
+ const waited = await c.call('vibe_math_lean_job', { jobId: q4.async.jobId, waitMs: 4000 })
1400
+ 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 + ')')
1401
+ // ── 超时:leanTimeoutMs 是每个作业的预算,主动 terminate,绝不 passed ─────────────
1402
+ const d = await makeCase('lean-timeout')
1403
+ await d.call('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 1000 })
1404
+ const projD = projRoot(d)
1405
+ mkdirSync(join(projD, 'Formal'), { recursive: true })
1406
+ writeFileSync(join(projD, 'Formal', 'hang.lean'), '-- HANG\ntheorem t : 1 = 1 := rfl\n', 'utf8')
1407
+ const termBefore = terminations.length
1408
+ const q5 = await d.call('vibe_math_lean_run', { file: 'Formal/hang.lean', target: 'pHang' })
1409
+ const done5 = await waitFor(() => { const r = JSON.parse(readIf(join(projD, 'Formal', 'Jobs', q5.async.jobId + '.json')) || '{}'); return r.state === 'timeout' ? r : undefined }, 120, 60)
1410
+ assert(!!done5 && done5.timedOut === true, '★★ a hanging job times out (state=timeout, timedOut=true) with leanTimeoutMs as its budget')
1411
+ assert(terminations.length > termBefore, '★★ the timeout ACTIVELY called handle.terminate()')
1412
+ assert((formalStateOf(d).records.pHang || {}).status !== 'passed', '★ a timed-out job never sets passed')
1413
+ // ── 并发参数(修订 §5):leanJobsMaxParallel>1 时两个作业同时进入 running ──────────
1414
+ const e = await makeCase('lean-parallel')
1415
+ await e.call('vibe_math_set_params', { leanAsync: true, leanJobsMaxParallel: 2 })
1416
+ const projE = projRoot(e)
1417
+ mkdirSync(join(projE, 'Formal'), { recursive: true })
1418
+ writeFileSync(join(projE, 'Formal', 'h1.lean'), '-- HANG\ntheorem h1 : 1 = 1 := rfl\n', 'utf8')
1419
+ writeFileSync(join(projE, 'Formal', 'h2.lean'), '-- HANG\ntheorem h2 : 1 = 1 := rfl\n', 'utf8')
1420
+ await e.call('vibe_math_set_params', { leanTimeoutMs: 3000 })
1421
+ const j1 = await e.call('vibe_math_lean_run', { file: 'Formal/h1.lean', target: 'pH1' })
1422
+ const j2 = await e.call('vibe_math_lean_run', { file: 'Formal/h2.lean', target: 'pH2' })
1423
+ // Both are HANG jobs: with concurrency 2 they must be in `running` AT THE SAME TIME. A serial
1424
+ // queue can never reach that state, so the probe discriminates the `leanJobsMaxParallel` parameter.
1425
+ const both = await waitFor(() => {
1426
+ const a = JSON.parse(readIf(join(projE, 'Formal', 'Jobs', j1.async.jobId + '.json')) || '{}')
1427
+ const b = JSON.parse(readIf(join(projE, 'Formal', 'Jobs', j2.async.jobId + '.json')) || '{}')
1428
+ return (a.state === 'running' && b.state === 'running') ? { a: a, b: b } : undefined
1429
+ }, 120, 50)
1430
+ assert(!!both, '★★ with leanJobsMaxParallel=2 BOTH queued jobs are in running at the same time (concurrency is a parameter, not a constant)')
1431
+ for (const k of [j1, j2]) { try { await e.call('vibe_math_lean_job', { jobId: k.async.jobId, waitMs: 3500 }) } catch (err) { /* ignore */ } }
1432
+ // ── 卸载(spec §2.5 / §7-6):terminate + interrupted,绝不 passed、绝不留孤儿 ────────
1433
+ const dz = await makeCase('lean-dispose')
1434
+ await dz.call('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 60000 })
1435
+ const projZ = projRoot(dz)
1436
+ mkdirSync(join(projZ, 'Formal'), { recursive: true })
1437
+ writeFileSync(join(projZ, 'Formal', 'orphan.lean'), '-- HANG\ntheorem orphan : 1 = 1 := rfl\n', 'utf8')
1438
+ const termZ = terminations.length
1439
+ const qz = await dz.call('vibe_math_lean_run', { file: 'Formal/orphan.lean', target: 'pOrphan' })
1440
+ const startedZ = await waitFor(() => { const r = JSON.parse(readIf(join(projZ, 'Formal', 'Jobs', qz.async.jobId + '.json')) || '{}'); return r.state === 'running' ? r : undefined }, 80, 60)
1441
+ assert(!!startedZ, 'the dispose case got a job into running')
1442
+ dz.unmount() // ctx.effect disposer ⇒ disposeLeanJobs()
1443
+ assert(terminations.length > termZ, '★★ unmounting the plugin terminated the in-flight compile (no orphan process)')
1444
+ assert((formalStateOf(dz).records.pOrphan || {}).status !== 'passed', '★ dispose never marks the object passed')
1445
+ const afterZ = JSON.parse(readIf(join(projZ, 'Formal', 'Jobs', qz.async.jobId + '.json')) || '{}')
1446
+ assert(afterZ.interrupted === true || afterZ.state === 'interrupted' || afterZ.state === 'running', '★ the disposed job is recorded as interrupted (or left running for the next recovery to mark)')
1447
+ // ── 去重(§4.3):同内容 + 同构建上下文 ⇒ 第二次不再编译 ──────────────────────────
1448
+ const f = await makeCase('lean-dedupe')
1449
+ await f.call('vibe_math_set_params', { leanAsync: true })
1450
+ const defBody = 'def reusableOne : Nat := 1\n'
1451
+ const r1 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1452
+ assert(r1.ok === true && r1.async && r1.deduped === false, 'the first archive of a definition is enqueued (not deduped)')
1453
+ await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r1.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1454
+ const runsAfter1 = leanRuns.length
1455
+ const r2 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1456
+ assert(r2.ok === true && r2.deduped === true && r2.async === undefined, '★★★ re-archiving identical content is DEDUPED (no job, no recompile) — got ' + JSON.stringify({ deduped: r2.deduped, async: r2.async }))
1457
+ assert(leanRuns.length === runsAfter1, '★★ the deduped archive did NOT recompile')
1458
+ // 构建上下文进指纹(修订 §4):同内容、不同 leanArgs ⇒ 不同 jobId / 不命中"已验证"
1459
+ await f.call('vibe_math_set_params', { leanArgs: ['--threads=2'] })
1460
+ const r5 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1461
+ assert(r5.ok === true && r5.deduped === false && !!r5.async && !!r1.async && r5.async.jobId !== r1.async.jobId, '★★★ the SAME content under a DIFFERENT build context (leanArgs) gets a different jobId — it is not treated as already-verified (got ' + JSON.stringify({ deduped: r5.deduped, async: r5.async }) + ')')
1462
+ if (r5.async) await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r5.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1463
+ await f.call('vibe_math_set_params', { leanArgs: [] })
1464
+ const changed = defBody + '\n-- 内容变了\n'
1465
+ const r3 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed })
1466
+ assert(r3.ok === true && r3.deduped === false && !!r3.async, 'a changed body is NOT deduped (content hash differs)')
1467
+ if (r3.async) await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r3.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1468
+ const r6 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed })
1469
+ assert(r6.ok === true && r6.deduped === true, 'and the changed body dedupes on its own (content-based, not "first wins")')
1470
+ // ── 崩溃恢复(§2.6 + 修订 §4):绝不置 passed ─────────────────────────────────────
1471
+ const g = await makeCase('lean-recover')
1472
+ await g.call('vibe_math_set_params', { leanAsync: true })
1473
+ const projG = projRoot(g)
1474
+ mkdirSync(join(projG, 'Formal', 'Jobs'), { recursive: true })
1475
+ const goodBody = 'theorem recGood : 1 = 1 := rfl\n'
1476
+ writeFileSync(join(projG, 'Formal', 'recGood.lean'), goodBody, 'utf8')
1477
+ const ctxG = H.leanBuildContext('lean', [], [slash(vibeRoot(g))])
1478
+ const fpG = H.leanJobFingerprint(goodBody, ctxG)
1479
+ const jobIdG = H.leanJobId('pRecGood', fpG)
1480
+ writeFileSync(join(projG, 'Formal', 'Jobs', jobIdG + '.json'), JSON.stringify({
1481
+ jobId: jobIdG, kind: 'archive', state: 'running', attempts: 1, target: 'pRecGood', name: 'pRecGood',
1482
+ rel: 'Formal/recGood.lean', scope: 'project', sha256: H.leanContentSha(goodBody), buildSha256: fpG, buildContext: ctxG,
1483
+ engine: 'lean', argv: [], searchPaths: [slash(vibeRoot(g))], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1484
+ }), 'utf8')
1485
+ // 哈希不匹配的遗留作业:只标记,绝不重驱、绝不 passed
1486
+ const otherJob = H.leanJobId('pRecOther', H.leanJobFingerprint('theorem other : 1 = 1 := rfl\n', ctxG))
1487
+ writeFileSync(join(projG, 'Formal', 'Jobs', otherJob + '.json'), JSON.stringify({
1488
+ jobId: otherJob, kind: 'archive', state: 'running', attempts: 3, target: 'pRecOther', name: 'pRecOther',
1489
+ rel: 'Formal/recMissing.lean', scope: 'project', sha256: H.leanContentSha('theorem other : 1 = 1 := rfl\n'), buildSha256: 'ffffffffffff', buildContext: ctxG,
1490
+ engine: 'lean', argv: [], searchPaths: [slash(vibeRoot(g))], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1491
+ }), 'utf8')
1492
+ await g.call('vibe_math_new_project', { name: 'proj' }) // 触发 setProject ⇒ recoverLeanJobs()
1493
+ const recG = formalStateOf(g).records.pRecGood || {}
1494
+ assert(recG.status === 'attempted', '★★ recovery marks a crashed running job as attempted (never passed)')
1495
+ const recOther = formalStateOf(g).records.pRecOther || {}
1496
+ assert(recOther.status === 'attempted', 'recovery marks the hash-mismatched job object as attempted too')
1497
+ assert(!existsSync(join(projG, 'Verified', 'Lean', 'pRecGood.lean')), '★ recovery never writes an archived proof for a crashed job')
1498
+ const recJobG = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', jobIdG + '.json')) || '{}')
1499
+ assert(Number(recJobG.attempts) === 2 && ['queued', 'running', 'settled'].indexOf(recJobG.state) !== -1, '★★ the matching-hash job was re-enqueued with attempts+1 (got ' + JSON.stringify({ state: recJobG.state, attempts: recJobG.attempts }) + ')')
1500
+ const otherAfter = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', otherJob + '.json')) || '{}')
1501
+ assert(otherAfter.state === 'interrupted' && Number(otherAfter.attempts) === 3, '★★ the mismatched job is only marked interrupted (NOT re-driven, attempts unchanged)')
1502
+ const recDone = await waitFor(() => { const r = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', jobIdG + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1503
+ assert(!!recDone, 'the re-enqueued job then settles normally on the heartbeat (the queue really resumed)')
1504
+ // ── lean_read(§1.2):只读、路径守卫、64KB 截断 ─────────────────────────────────
1505
+ mkdirSync(join(vibeRoot(g), 'Formal', 'Lib'), { recursive: true })
1506
+ writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', 'recGood.lean'), goodBody, 'utf8')
1507
+ const rd = await g.call('vibe_math_lean_read', { name: 'recGood' })
1508
+ assert(rd.ok === true && rd.kind === 'lib' && rd.sha256 === H.leanContentSha(goodBody) && rd.truncated === false && rd.text === goodBody, '★ lean_read returns the archived text + sha256 + bytes for a Lib file (got ' + JSON.stringify(rd).slice(0, 200) + ')')
1509
+ assert(typeof rd.bytes === 'number' && rd.bytes === Buffer.byteLength(goodBody, 'utf8'), 'lean_read reports the byte length')
1510
+ assert((await g.call('vibe_math_lean_read', { name: 'recGood', kind: 'proved' })).code === 'V2_NOT_FOUND', 'kind=proved looks only in Formal/Proved')
1511
+ for (const badName of ['../etc/passwd', 'a/b', 'C:\\x\\y', '..']) {
1512
+ const rbad = await g.call('vibe_math_lean_read', { name: badName })
1513
+ assert(rbad.ok === false && rbad.code === 'V2_INVALID_ARGUMENT', '★ lean_read rejects the path-escape attempt ' + JSON.stringify(badName))
1514
+ }
1515
+ const bigName = 'bigOne'
1516
+ mkdirSync(join(vibeRoot(g), 'Formal', 'Lib'), { recursive: true })
1517
+ writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', bigName + '.lean'), '-- ' + 'x'.repeat(70 * 1024) + '\n', 'utf8')
1518
+ const rbig = await g.call('vibe_math_lean_read', { name: bigName })
1519
+ assert(rbig.ok === true && rbig.truncated === true && Buffer.byteLength(rbig.text, 'utf8') <= 64 * 1024, '★ lean_read truncates at 64KB and says so (truncated=true)')
1520
+ // ── 索引依赖列(§4.4)与 lean_lib 的 jobs/searchPath ─────────────────────────────
1521
+ writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', 'depUser.lean'), 'import Formal.Lib.bigOne\n\ndef depUser := 1\n', 'utf8')
1522
+ const lib = await g.call('vibe_math_lean_lib', {})
1523
+ assert(!!lib.paths && SAME(lib.paths.searchPath, vibeRoot(g)), '★ lean_lib exposes paths.searchPath = the VibeMath root (got ' + JSON.stringify(lib.paths) + ')')
1524
+ assert(Array.isArray(lib.jobs) && lib.jobs.length >= 1, '★ lean_lib exposes the job list')
1525
+ assert(lib.async === true, 'lean_lib reports the current async mode')
1526
+ const libIdx = readIf(join(vibeRoot(g), 'Formal', 'Lib', 'Index.md'))
1527
+ assert(/依赖(import)/.test(libIdx) && /Formal\.Lib\.bigOne/.test(libIdx), '★★ the Lib index carries the dependency column scanned from `import` lines')
1528
+ // ── 主动性档位(修订 §1):提示词读 leanInitiative,不是 formalVerify ──────────────
1529
+ const wNormal = await makeCase('lean-init-normal')
1530
+ await wNormal.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'normal' })
1531
+ await wNormal.call('vibe_math_add_problem', { id: 'qInit', description: '主动性提示词 normal' })
1532
+ await startScheduler(wNormal)
1533
+ const exN = await waitFor(() => wNormal.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1534
+ const promptNormal = (exN && exN.prompt) || ''
1535
+ assert(/主动性 normal/.test(promptNormal) && /三条筛选判据/.test(promptNormal), '★ the work prompt names the initiative档位 and the three selection criteria')
1536
+ await wNormal.call('vibe_math_pause', {})
1537
+
1538
+ const wEager = await makeCase('lean-init-eager')
1539
+ await wEager.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'eager' })
1540
+ await wEager.call('vibe_math_add_problem', { id: 'qInitE', description: '主动性提示词 eager' })
1541
+ await startScheduler(wEager)
1542
+ const exE = await waitFor(() => wEager.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1543
+ assert(!!exE && /主动档(leanInitiative=eager)/.test(exE.prompt || ''), '★★ leanInitiative=eager adds the eager line (the prompt reads the initiative parameter)')
1544
+ await wEager.call('vibe_math_pause', {})
1545
+
1546
+ // v5 统一口径:主动性(leanInitiative)与验证要求强度(formalVerify)是两根独立的轴。
1547
+ // (a)leanInitiative:'off' ⇒ 连日线都不注入(真无操作)。
1548
+ const wOff = await makeCase('lean-init-off')
1549
+ await wOff.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'off' })
1550
+ await wOff.call('vibe_math_add_problem', { id: 'qInitO', description: '主动性提示词 off' })
1551
+ await startScheduler(wOff)
1552
+ const exO = await waitFor(() => wOff.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1553
+ assert(!!exO && !/顺手形式化/.test(exO.prompt || ''), '★★ leanInitiative=off drops the daily line entirely (initiative is a separate axis from formalVerify, which is still encourage here)')
1554
+ await wOff.call('vibe_math_pause', {})
1555
+
1556
+ // (b)formalVerify:'off' + leanInitiative:'eager' ⇒ 日线仍必须注入(eager 是独立轴,不许被 off 静默吞掉),
1557
+ // 而**验证阶段**的 Lean 文本仍然关闭。
1558
+ const wOffEager = await makeCase('lean-init-off-eager')
1559
+ await wOffEager.call('vibe_math_set_params', { formalVerify: 'off', leanInitiative: 'eager' })
1560
+ await wOffEager.call('vibe_math_add_problem', { id: 'qInitOE', description: 'off+eager 主动性' })
1561
+ await startScheduler(wOffEager)
1562
+ const exOE = await waitFor(() => wOffEager.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1563
+ const pOE = (exOE && exOE.prompt) || ''
1564
+ assert(/主动档(leanInitiative=eager)/.test(pOE) && /三条筛选判据/.test(pOE), '★★★ formalVerify:off + leanInitiative:eager STILL injects the full daily line (v5 rule: the initiative axis is independent)')
1565
+ assert(!/【Lean 形式化验证(/.test(pOE) && !wOffEager.spawns.some((s) => /【Lean 形式化验证(/.test(s.prompt || '')), '★★★ …while the verification-phase Lean block stays closed (formalVerify:off) in every captured prompt of that root')
1566
+ const stOE = await wOffEager.call('vibe_math_status', {})
1567
+ assert(stOE.params.formalVerify === 'off' && stOE.params.leanInitiative === 'eager', 'the off+eager case really ran with formalVerify:off + leanInitiative:eager')
1568
+ await wOffEager.call('vibe_math_pause', {})
1569
+ }
1570
+
1285
1571
  section('16 the captured prompt corpus is written for human review')
1286
1572
  {
1287
1573
  // Freeze the scheduler in every case FIRST: a still-running tick loop could emit one more
@@ -1337,7 +1623,7 @@ section('16 the captured prompt corpus is written for human review')
1337
1623
  assert(entries.some((e) => /【Lean 形式化验证(鼓励模式)】/.test(e.prompt)), 'the corpus contains the encourage verify prompt')
1338
1624
  assert(entries.some((e) => /【Lean 形式化验证(强制模式)】/.test(e.prompt)), '★ the corpus contains the REQUIRE verify prompt')
1339
1625
  assert(entries.some((e) => /你不需要重新检查推导/.test(e.prompt) && /一致 → Result = 1/.test(e.prompt)), 'the corpus contains the passed/fidelity prompt')
1340
- assert(entries.some((e) => /【顺手形式化(鼓励)】/.test(e.prompt)), 'the corpus contains the work-round 顺手形式化 prompt')
1626
+ assert(entries.some((e) => /【顺手形式化(鼓励[^)]*)】/.test(e.prompt)), 'the corpus contains the work-round 顺手形式化 prompt')
1341
1627
  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')
1342
1628
  assert(entries.some((e) => !/Lean|形式化/.test(e.prompt)), 'the corpus contains off-mode prompts with no Lean text at all')
1343
1629
  assert(entries.some((e) => e.kind === 'wake'), 'the corpus also keeps the continuation prompts (debate rounds)')