dsh-vibe-math 2.7.0 → 2.7.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 (41) hide show
  1. package/README.en.md +5 -0
  2. package/README.md +5 -0
  3. package/cordis.patch.yml +6 -0
  4. package/docs/math-computation.md +44 -2
  5. package/docs/release-notes/RELEASE-NOTES-2.7.1.en.md +86 -0
  6. package/docs/release-notes/RELEASE-NOTES-2.7.1.md +86 -0
  7. package/package.json +5 -3
  8. package/prompt-corpus-persona/persona-corpus.json +8 -8
  9. package/prompt-corpus-persona/persona-corpus.md +12 -0
  10. package/prompt-corpus-v2/formal-verify-v2.json +162 -96
  11. package/prompt-corpus-v2/formal-verify-v2.md +949 -0
  12. package/prompt-corpus-v3/formal-verify-v3.json +171 -57
  13. package/prompt-corpus-v3/formal-verify-v3.md +1146 -0
  14. package/prompt-corpus-v4/formal-verify-v4.json +10 -10
  15. package/prompt-corpus-v4/formal-verify-v4.md +20 -0
  16. package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
  17. package/prompt-corpus-v5/prompt-corpus-v5.md +61 -0
  18. package/tests/audit-math-computation-contract.mjs +13 -0
  19. package/tests/audit-math-computation-parity.mjs +30 -1
  20. package/tests/audit-math-computation-sensitivity.mjs +1 -1
  21. package/tests/e2e-v5-round2.test.mjs +7 -0
  22. package/tests/formal-verify-v2.test.mjs +70 -2
  23. package/tests/formal-verify-v3.test.mjs +84 -2
  24. package/tests/helpers/math-computation-fake-seam.mjs +30 -2
  25. package/tests/math-computation-shared.test.mjs +304 -2
  26. package/vibe-math-v2/agent.cordis.yml +2 -0
  27. package/vibe-math-v2/math-computation.js +390 -47
  28. package/vibe-math-v2/math-engines.js +47 -6
  29. package/vibe-math-v2/vibe-math-v2.js +17 -1
  30. package/vibe-math-v3/agent.cordis.yml +2 -0
  31. package/vibe-math-v3/math-computation.js +390 -47
  32. package/vibe-math-v3/math-engines.js +47 -6
  33. package/vibe-math-v3/vibe-math-v3.js +17 -1
  34. package/vibe-math-v4/agent.cordis.yml +4 -0
  35. package/vibe-math-v4/math-computation.js +390 -47
  36. package/vibe-math-v4/math-engines.js +47 -6
  37. package/vibe-math-v4/vibe-math-v4.js +15 -0
  38. package/vibe-math-v5/agent.cordis.yml +4 -0
  39. package/vibe-math-v5/math-computation.js +390 -47
  40. package/vibe-math-v5/math-engines.js +47 -6
  41. package/vibe-math-v5/vibe-math-v5.js +15 -3
@@ -31,10 +31,14 @@
31
31
  import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, statSync, mkdirSync } from 'node:fs'
32
32
  import { tmpdir } from 'node:os'
33
33
  import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
34
- import { fileURLToPath } from 'node:url'
34
+ import { fileURLToPath, pathToFileURL } from 'node:url'
35
35
 
36
+ // V2_PLUGIN may be a REPO-RELATIVE path or an ABSOLUTE one (a mutant copy outside the repo, e.g. a
37
+ // temp dir). The previous `new URL('file:///' + path)` form only worked for drive-qualified Windows
38
+ // paths and made out-of-repo variants impossible; this is the same isAbsolute-aware resolution the
39
+ // `math-computation-v*` suites use. Default behaviour (env var unset) is unchanged.
36
40
  const PLUGIN = process.env.V2_PLUGIN
37
- ? new URL('file:///' + String(process.env.V2_PLUGIN).replace(/\\/g, '/'))
41
+ ? pathToFileURL(isAbsolute(process.env.V2_PLUGIN) ? process.env.V2_PLUGIN : join(dirname(fileURLToPath(import.meta.url)), '..', process.env.V2_PLUGIN))
38
42
  : new URL('../vibe-math-v2/vibe-math-v2.js', import.meta.url)
39
43
  // 纯函数测试面(__testHelpers):参数归一化/搜索路径计划/哈希/指纹都从这里守卫。
40
44
  const MOD = await import(PLUGIN.href)
@@ -1568,6 +1572,70 @@ section('15d Lean 增量 + 异步(spec §1–§4 + 修订 §1–§5):参
1568
1572
  await wOffEager.call('vibe_math_pause', {})
1569
1573
  }
1570
1574
 
1575
+ // ---------- 15e. round-2 lens-1 RUNTIME: the initiative axis is injected at EVERY site ----------
1576
+ // Three injection sites with three different shapes (explorer / rederive / solver). The static [sites]
1577
+ // check can only see that the producer call is not textually gated; THIS section proves the actual
1578
+ // prompt text of each site: (1) formalVerify:'off' + leanInitiative:'eager' must carry the daily line
1579
+ // at that site (and keep the verification block closed), (2) leanInitiative:'off' must drop it there
1580
+ // while the verification phase still follows formalVerify. Assertion names carry the site name, so a
1581
+ // single-site regression names itself instead of hiding behind a sibling site's coverage.
1582
+ const SITE_DAILY = /三条筛选判据/
1583
+ const SITE_VERIFY = /【Lean 形式化验证(/
1584
+ const FENCE = '`'.repeat(3)
1585
+ const jsonReply = (obj) => FENCE + 'json\n' + JSON.stringify(obj) + '\n' + FENCE
1586
+ /** Drive one problem through explorer -> solver(dead-end) -> re-derive and collect each site's prompt. */
1587
+ async function driveSites(h, opts, qid) {
1588
+ const q = qid || 'qSite'
1589
+ await h.call('vibe_math_set_params', Object.assign({ tickIntervalMs: 200, verifierCount: 2, leanAsync: false, maxExplorerRetries: 5, maxParallelThreshold: 4 }, opts))
1590
+ await h.call('vibe_math_add_problem', { id: q, description: '站点级运行时用例' })
1591
+ await startScheduler(h)
1592
+ const label = (n) => h.spawns.filter((s) => s.label.startsWith(n + ':' + q))
1593
+ const ex1 = await waitFor(() => label('explorer')[0], 80, 200)
1594
+ const out = { explorer: (ex1 && ex1.prompt) || '' }
1595
+ if (ex1) h.fireEnd({ id: ex1.childId, runId: 'rS1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.8 }] }) }] })
1596
+ const so = await waitFor(() => label('solver')[0], 80, 200)
1597
+ out.solver = (so && so.prompt) || ''
1598
+ if (so) h.fireEnd({ id: so.childId, runId: 'rS2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ status: 'dead-end', dead_end_reason: '站点用例:本方向不可行', solution: '', solution_probability: 0.1, lemmas: [], routes: [], lessons: [], survival_probability: 0.2, sub_questions: [], new_inventions: [{ 类型: '工具', 标题: '站点工具', 内容描述: 'x', 是否已入库: false }] }) }] })
1599
+ // every direction is now dead-end ⇒ the NEXT explorer for this problem is built by rederivePrompt
1600
+ await waitFor(() => label('explorer').length >= 2 && label('explorer')[1], 80, 200)
1601
+ const exs = label('explorer')
1602
+ out.rederive = (exs[1] && exs[1].prompt) || ''
1603
+ out.distinct = !!out.explorer && !!out.rederive && out.explorer !== out.rederive
1604
+ await h.call('vibe_math_abort', {})
1605
+ return out
1606
+ }
1607
+ {
1608
+ // (1) eager 在 formalVerify:'off' 下也必须在**每一个**站点注入日线
1609
+ const he = await makeCase('site-eager-off')
1610
+ const A = await driveSites(he, { formalVerify: 'off', leanInitiative: 'eager' })
1611
+ assert(SITE_DAILY.test(A.explorer), '★ [explorer site] formalVerify:off + leanInitiative:eager injects the daily line')
1612
+ assert(SITE_DAILY.test(A.solver), '★ [solver site] formalVerify:off + leanInitiative:eager injects the daily line')
1613
+ assert(SITE_DAILY.test(A.rederive), '★ [rederive site] formalVerify:off + leanInitiative:eager injects the daily line')
1614
+ assert(A.distinct, '★ [rederive site] the re-explore prompt really is the rederive builder (differs from the first explorer wave)')
1615
+ assert(A.explorer.indexOf('【Lean 形式化验证(') === -1 && A.solver.indexOf('【Lean 形式化验证(') === -1 && A.rederive.indexOf('【Lean 形式化验证(') === -1, '★ [all sites] formalVerify:off keeps the verification-phase block closed while eager still injects the daily line')
1616
+
1617
+ // (2) leanInitiative:'off' ⇒ 每个站点都没有日线(同一驱动)
1618
+ const ho = await makeCase('site-init-off')
1619
+ const B = await driveSites(ho, { formalVerify: 'encourage', leanInitiative: 'off' }, 'qSiteOff')
1620
+ assert(!SITE_DAILY.test(B.explorer) && !/【顺手形式化/.test(B.explorer), '★ [explorer site] leanInitiative:off drops the daily line')
1621
+ assert(!SITE_DAILY.test(B.solver) && !/【顺手形式化/.test(B.solver), '★ [solver site] leanInitiative:off drops the daily line')
1622
+ assert(!SITE_DAILY.test(B.rederive) && !/【顺手形式化/.test(B.rederive), '★ [rederive site] leanInitiative:off drops the daily line')
1623
+
1624
+ // (2b) …而验证段仍按 formalVerify 出现(同一参数下的另一个 root,驱动到 verifier)
1625
+ const hv = await makeCase('site-init-off-verify')
1626
+ await hv.call('vibe_math_set_params', { tickIntervalMs: 200, verifierCount: 2, leanAsync: false, formalVerify: 'encourage', leanInitiative: 'off' })
1627
+ await hv.call('vibe_math_add_problem', { id: 'qV', description: '验证段仍在' })
1628
+ await startScheduler(hv)
1629
+ const exv = await waitFor(() => hv.spawns.find((s) => s.label.startsWith('explorer:qV')), 80, 200)
1630
+ assert(!!exv && !SITE_DAILY.test(exv.prompt || ''), '★ [explorer site] 第二个 root:initiative off 时 explorer 仍无日线')
1631
+ if (exv) hv.fireEnd({ id: exv.childId, runId: 'rV1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.8 }] }) }] })
1632
+ const sov = await waitFor(() => hv.spawns.find((s) => s.label.startsWith('solver:qV')), 80, 200)
1633
+ if (sov) hv.fireEnd({ id: sov.childId, runId: 'rV2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ status: 'success', solution: 'complete solution', solution_probability: 0.9, lemmas: [], routes: [], lessons: [], survival_probability: 0.9, dead_end_reason: null, sub_questions: [] }) }] })
1634
+ const vv = await waitFor(() => hv.spawns.find((s) => /^verifier:/.test(s.label)), 100, 250)
1635
+ assert(!!vv && SITE_VERIFY.test(vv.prompt || ''), '★ leanInitiative:off 时验证段仍按 formalVerify 注入(verifier 提示词含【Lean 形式化验证()')
1636
+ await hv.call('vibe_math_abort', {})
1637
+ }
1638
+
1571
1639
  section('16 the captured prompt corpus is written for human review')
1572
1640
  {
1573
1641
  // Freeze the scheduler in every case FIRST: a still-running tick loop could emit one more
@@ -22,10 +22,14 @@
22
22
  import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
23
23
  import { tmpdir } from 'node:os'
24
24
  import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
25
- import { fileURLToPath } from 'node:url'
25
+ import { fileURLToPath, pathToFileURL } from 'node:url'
26
26
 
27
+ // V3_PLUGIN may be a REPO-RELATIVE path or an ABSOLUTE one (a mutant copy outside the repo, e.g. a
28
+ // temp dir). The previous `new URL('file:///' + path)` form only worked for drive-qualified Windows
29
+ // paths and made out-of-repo variants impossible; this is the same isAbsolute-aware resolution the
30
+ // `math-computation-v*` suites use. Default behaviour (env var unset) is unchanged.
27
31
  const PLUGIN = process.env.V3_PLUGIN
28
- ? new URL('file:///' + String(process.env.V3_PLUGIN).replace(/\\/g, '/'))
32
+ ? pathToFileURL(isAbsolute(process.env.V3_PLUGIN) ? process.env.V3_PLUGIN : join(dirname(fileURLToPath(import.meta.url)), '..', process.env.V3_PLUGIN))
29
33
  : new URL('../vibe-math-v3/vibe-math-v3.js', import.meta.url)
30
34
  const HERE = dirname(fileURLToPath(import.meta.url))
31
35
  // AUDIT-CHECKLIST §2.4: the suite must also keep the INTERACTION TEXT it drove, so a human can
@@ -1495,6 +1499,84 @@ section('9b Lean 增量 + 异步:参数/argv/队列/去重/只读面/主动性
1495
1499
  // 10. interaction corpus (AUDIT-CHECKLIST §2.4) — a HUMAN must be able to re-read
1496
1500
  // every prompt the framework emitted, not just the assertions about them.
1497
1501
  // ===============================================================
1502
+ // ===============================================================
1503
+ // 5b. round-2 lens-1 RUNTIME: the initiative axis at EVERY v3 site
1504
+ // ===============================================================
1505
+ // Four sites: the three formalDailySection() call sites (explorer / rederive / solver) plus the
1506
+ // INLINE formalWorkLine() site inside methodKeeperPrompt. (1) formalVerify:'off' + leanInitiative:
1507
+ // 'eager' must carry the daily line at that site; (2) leanInitiative:'off' must drop it there while
1508
+ // the verification phase still follows formalVerify. Names carry the site so a single-site
1509
+ // regression names itself.
1510
+ const SITE_DAILY = /三条筛选判据/
1511
+ const SITE_VERIFY = /【Lean 形式化验证(/
1512
+ async function driveSiteV3(rw, opts, qid) {
1513
+ const q = qid
1514
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { methodKeepEvery: 1, maxExplorerRetries: 5 }, opts), rw)
1515
+ await callTool('vibe_math_add_problem', { id: q, 陈述: '站点级运行时用例' }, rw)
1516
+ await callTool('vibe_math_start', {}, rw)
1517
+ const exs = () => spawnOf(rw, 'explorer:' + q)
1518
+ await drive(rw, () => exs().length >= 1, 'explorer site ' + q)
1519
+ const out = { explorer: (exs()[0] || {}).prompt || '' }
1520
+ // answer explorers with directions until the solver appears (v3's fallback scheduler re-derives)
1521
+ fireEnd(exs()[0].childId, { meta: { kind: 'directions', qid: q, directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: '', feasibility: 0.8 }] } })
1522
+ for (let guard = 0; guard < 30 && !lastSpawn(rw, 'solver:' + q + ':d1'); guard++) {
1523
+ const pend = exs().filter((s) => !firedChildren.has(s.childId)).slice(-1)[0]
1524
+ 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 }] } }) }
1525
+ await pump(rw); await sleep(80)
1526
+ }
1527
+ const so = lastSpawn(rw, 'solver:' + q + ':d1')
1528
+ out.solver = (so && so.prompt) || ''
1529
+ // dead-end + a pending invention: the direction is exhausted (⇒ re-derive) AND the method keeper fires
1530
+ if (so) fireEnd(so.childId, { status: 'dead-end', survival_probability: 0.2, dead_end_reason: '站点用例:不可行', new_inventions: [{ 类型: '工具', 标题: '站点工具', 内容描述: 'x', 是否已入库: false }] })
1531
+ await drive(rw, () => exs().length >= 2, 'rederive site ' + q)
1532
+ out.rederive = (exs()[1] || {}).prompt || ''
1533
+ out.distinct = !!out.explorer && !!out.rederive && out.explorer !== out.rederive
1534
+ await drive(rw, () => !!lastSpawn(rw, 'method-keeper'), 'method-keeper site ' + q)
1535
+ out.methodKeeper = (lastSpawn(rw, 'method-keeper') || {}).prompt || ''
1536
+ await callTool('vibe_math_abort', {}, rw)
1537
+ return out
1538
+ }
1539
+ {
1540
+ const re = makeRoot()
1541
+ await callTool('vibe_math_new_project', { name: 'site-eager-off' }, re)
1542
+ const A = await driveSiteV3(re, { formalVerify: 'off', leanInitiative: 'eager' }, 'qS')
1543
+ assert(SITE_DAILY.test(A.explorer), '★ [explorer site] formalVerify:off + leanInitiative:eager injects the daily line')
1544
+ assert(SITE_DAILY.test(A.solver), '★ [solver site] formalVerify:off + leanInitiative:eager injects the daily line')
1545
+ assert(SITE_DAILY.test(A.rederive), '★ [rederive site] formalVerify:off + leanInitiative:eager injects the daily line')
1546
+ assert(SITE_DAILY.test(A.methodKeeper), '★ [methodKeeper site] formalVerify:off + leanInitiative:eager injects the daily line (inline site)')
1547
+ 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')
1548
+
1549
+ const ro = makeRoot()
1550
+ await callTool('vibe_math_new_project', { name: 'site-init-off' }, ro)
1551
+ const B = await driveSiteV3(ro, { formalVerify: 'encourage', leanInitiative: 'off' }, 'qO')
1552
+ assert(!SITE_DAILY.test(B.explorer) && !/【顺手形式化/.test(B.explorer), '★ [explorer site] leanInitiative:off drops the daily line')
1553
+ assert(!SITE_DAILY.test(B.solver) && !/【顺手形式化/.test(B.solver), '★ [solver site] leanInitiative:off drops the daily line')
1554
+ assert(!SITE_DAILY.test(B.rederive) && !/【顺手形式化/.test(B.rederive), '★ [rederive site] leanInitiative:off drops the daily line')
1555
+ assert(!SITE_DAILY.test(B.methodKeeper) && !/【顺手形式化/.test(B.methodKeeper), '★ [methodKeeper site] leanInitiative:off drops the daily line (inline site)')
1556
+
1557
+ // verification phase still follows formalVerify under leanInitiative:'off'
1558
+ const rvv = makeRoot()
1559
+ await callTool('vibe_math_new_project', { name: 'site-init-off-verify' }, rvv)
1560
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { formalVerify: 'encourage', leanInitiative: 'off' }), rvv)
1561
+ await callTool('vibe_math_add_problem', { id: 'qV', 陈述: '验证段仍在' }, rvv)
1562
+ await callTool('vibe_math_start', {}, rvv)
1563
+ await drive(rvv, () => !!lastSpawn(rvv, 'explorer:qV'), 'explorer qV')
1564
+ const exv = lastSpawn(rvv, 'explorer:qV')
1565
+ assert(!!exv && !SITE_DAILY.test(exv.prompt || ''), '★ [explorer site] 第二个 root:initiative off 时 explorer 仍无日线')
1566
+ if (exv) fireEnd(exv.childId, { meta: { kind: 'directions', qid: 'qV', directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: '', feasibility: 0.8 }] } })
1567
+ for (let guard = 0; guard < 30 && !lastSpawn(rvv, 'solver:qV:d1'); guard++) {
1568
+ const pend = spawnOf(rvv, 'explorer:qV').filter((s) => !firedChildren.has(s.childId)).slice(-1)[0]
1569
+ 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 }] } }) }
1570
+ await pump(rvv); await sleep(80)
1571
+ }
1572
+ const sovv = lastSpawn(rvv, 'solver:qV:d1')
1573
+ if (sovv) fireEnd(sovv.childId, { status: 'success', solution: 'complete solution', survival_probability: 0.9 })
1574
+ await drive(rvv, () => spawnOf(rvv, 'verifier:').length >= 1, 'verifier qV')
1575
+ const vsp = spawnOf(rvv, 'verifier:')[0]
1576
+ assert(!!vsp && SITE_VERIFY.test(vsp.prompt || ''), '★ leanInitiative:off 时验证段仍按 formalVerify 注入(verifier 提示词含【Lean 形式化验证()')
1577
+ await callTool('vibe_math_abort', {}, rvv)
1578
+ }
1579
+
1498
1580
  section('10 the captured prompt corpus is written for human review')
1499
1581
  {
1500
1582
  mkdirSync(CORPUS_DIR, { recursive: true })
@@ -38,6 +38,18 @@ export function makeFakeHost(opts = {}) {
38
38
  exit: opts.exit === undefined ? 0 : opts.exit,
39
39
  hang: !!opts.hang,
40
40
  hangProbe: !!opts.hangProbe,
41
+ versionProbeFails: !!opts.versionProbeFails,
42
+ // round-7: the real host intermittently answers a spawn with NULL early in a session. The first
43
+ // `nullSpawnTimes` spawns return null so a test can prove the retry recovers from it.
44
+ nullSpawnTimes: Number(opts.nullSpawnTimes) || 0,
45
+ // round-7 (live root cause): the real host answers a spawn whose cwd does NOT exist with
46
+ // `spawned:true, exit:null` (retries cannot help). `projectRoot` can be pointed at a
47
+ // non-existent path to emulate a FRESH workspace.
48
+ projectRoot: opts.projectRoot || '/fake/project',
49
+ freshRoot: opts.projectRoot || null,
50
+ // round-7: null answers ONLY for package probes (classify === 'packages'), so a test can isolate
51
+ // "the precheck could not run" from "the engine could not be probed".
52
+ packageProbeNullTimes: Number(opts.packageProbeNullTimes) || 0,
41
53
  stdoutBytes: opts.stdoutBytes || 0,
42
54
  argError: opts.argError || null,
43
55
  probeCalls: 0,
@@ -56,9 +68,9 @@ export function makeFakeHost(opts = {}) {
56
68
  state.registrations.push({ name, description, parameters, handler })
57
69
  },
58
70
  params: () => (typeof opts.params === 'function' ? opts.params() : (opts.params || {})),
59
- projectRoot: () => opts.root || FAKE_ROOT,
71
+ projectRoot: () => state.freshRoot || opts.root || FAKE_ROOT,
60
72
  designator: opts.designator || 'vibe-math-v2',
61
- writeText: async (rel, text) => { files.set(String(rel).replace(/\\/g, '/'), String(text)); return true },
73
+ writeText: async (rel, text) => { files.set(String(rel).replace(/\\/g, '/'), String(text)); state.rootCreated = true; return true },
62
74
  // Optional capability flag (INTERFACE-FREEZE §4): a host that knows it has no subprocess service
63
75
  // says so, and the module reports MATH_NO_SUBPROCESS instead of a misleading ENGINE_NOT_FOUND.
64
76
  hasSubprocess: ('hasSubprocess' in opts) ? (() => opts.hasSubprocess) : undefined,
@@ -72,10 +84,18 @@ export function makeFakeHost(opts = {}) {
72
84
  if (typeof opts.listDir === 'function') return opts.listDir(rel)
73
85
  return (opts.listDir || []).slice()
74
86
  }) : undefined,
87
+ // round-7 (fix 2): OPTIONAL bundled-runtime discovery. `runtimeRoots` are absolute roots and
88
+ // `absTree` maps an absolute directory to its entries, so a test can fake DSH's layout
89
+ // (`<root>/dsh-runtimes/<tree>/dependencies/python/python.exe`).
90
+ runtimeRoots: ('runtimeRoots' in opts) ? (async () => (opts.runtimeRoots || [])) : undefined,
91
+ listDirAbs: opts.absTree ? (async (abs) => (opts.absTree[String(abs).replace(/\\/g, '/')] || [])) : undefined,
75
92
  resolveExecutable: async (cmd) => {
76
93
  if (opts.resolveThrows) throw new Error('resolve failed: ' + cmd)
77
94
  const raw = String(cmd)
78
95
  const base = raw.split(/[\\/]/).pop()
96
+ // round-6 (B): an explicit path map lets a test place the interpreter in a conda-style
97
+ // directory or next to a `uv` binary - the manager dispatch reads those facts.
98
+ if (opts.resolveMap && Object.prototype.hasOwnProperty.call(opts.resolveMap, base)) return opts.resolveMap[base]
79
99
  // A caller-supplied path (engine='cli') is accepted as-is: that is the whole point of the
80
100
  // escape hatch - the tool only has to resolve the command, not know the engine.
81
101
  if (raw.indexOf('/') !== -1 || raw.indexOf('\\') !== -1) return raw
@@ -85,10 +105,18 @@ export function makeFakeHost(opts = {}) {
85
105
  return '/fake/bin/' + base
86
106
  },
87
107
  spawn: async ({ argv, cwd, timeoutMs, stdoutCap, stderrCap }) => {
108
+ if (state.nullSpawnTimes > 0) { state.nullSpawnTimes--; state.spawns.push({ argv: argv.slice(), cwd, timeoutMs, stdoutCap, stderrCap, nullSpawn: true }); return null }
88
109
  state.spawns.push({ argv: argv.slice(), cwd, timeoutMs, stdoutCap, stderrCap })
110
+ // Emulate the real host: a spawn whose cwd does not exist answers with exit:null. Note the
111
+ // host's writeText creates parent directories (writeFileAtomic), so a RUN (which writes the
112
+ // receipt first) legitimately makes the project root exist before it spawns.
113
+ if (state.freshRoot && !state.rootCreated && String(cwd) === String(state.freshRoot)) return { exit: null, timedOut: false, killed: false, ms: 130, stdout: '', stderr: '' }
89
114
  const kind = classify(argv)
115
+ if (kind === 'packages' && state.packageProbeNullTimes > 0) { state.packageProbeNullTimes--; state.spawns[state.spawns.length - 1].nullSpawn = true; return null }
90
116
  if (kind === 'version') {
91
117
  if (state.hangProbe) return { exit: null, timedOut: true, killed: true, ms: timeoutMs, stdout: '', stderr: '' }
118
+ // round-7: a DETERMINISTIC probe failure (engine resolved, probe exits non-zero, no version).
119
+ if (state.versionProbeFails) return { exit: 1, timedOut: false, killed: false, ms: 5, stdout: '', stderr: 'probe boom' }
92
120
  const exeName = String(argv[0] || 'engine').split(/[\\/]/).pop()
93
121
  return { exit: 0, timedOut: false, killed: false, ms: 5, stdout: exeName + ' ' + state.version + '\n', stderr: '' }
94
122
  }
@@ -175,10 +175,15 @@ console.log('-- math_computation shared contract --')
175
175
  await withHost({}, async (state) => {
176
176
  const r = await state.call({ op: 'run', engine: 'cli', mode: 'code', code: 'echo hi', cli: { command: '/fake/bin/mytool', argv: ['--flag', 'x'] } })
177
177
  ok(r.ok === true, 'cli runs with DEFAULT params (default-on)')
178
- eq(r.argv, ['/fake/bin/mytool', '--flag', 'x'], 'cli argv is command + user argv verbatim')
178
+ // round-7 (finding 4): mode:'code' append the archived script so the code actually RUNS (the
179
+ // caller cannot know the runId up front). The user argv stays verbatim in receipt.cli.argv.
180
+ eq(r.argv.slice(0, 3), ['/fake/bin/mytool', '--flag', 'x'], 'cli argv starts with command + user argv')
181
+ ok(/Computation\/.*script\.txt$/.test(String(r.argv[3] || '')), 'mode:code appends the archived script path to the effective argv')
182
+ ok(r.cliScriptAppended === true, 'the return shell flags cliScriptAppended')
179
183
  ok(r.engine === 'cli:/fake/bin/mytool' && r.engineInfo.name === 'cli:/fake/bin/mytool', 'cli is labelled cli:<command> in the shell and engineInfo')
180
184
  const rj = JSON.parse(state.file(r.receipt.json))
181
- ok(rj.engine.name === 'cli:/fake/bin/mytool' && rj.engine.source === 'cli' && rj.cli && rj.cli.argv.length === 2, 'cli receipt records cli:<command>, source=cli and argv')
185
+ ok(rj.engine.name === 'cli:/fake/bin/mytool' && rj.engine.source === 'cli' && rj.cli && rj.cli.argv.length === 2, 'cli receipt records cli:<command>, source=cli and the USER argv')
186
+ ok(rj.cli.scriptAppended === true && rj.argv[rj.argv.length - 1] === r.argv[3], 'the receipt records scriptAppended and the executed argv')
182
187
  })
183
188
  await withHost({ params: { mathMode: 'typed' } }, async (state) => {
184
189
  const r = await state.call({ op: 'run', engine: 'cli', mode: 'code', code: 'x', cli: { command: '/fake/bin/mytool' } })
@@ -498,6 +503,303 @@ console.log('-- math_computation shared contract --')
498
503
  }
499
504
  }
500
505
 
506
+ // ── 18. round-6: substitution honesty (A), manager dispatch (B), scope policy (C), version syntax (D)
507
+ {
508
+ // 18a (A): the rule is part of the frozen rule block and reaches both language lines.
509
+ ok(M.MATH_RULE_LINES.join('\n').indexOf(M.MATH_SUBSTITUTION_RULE_LINE) !== -1, 'MATH_RULE_LINES carries the substitution-honesty rule (zh)')
510
+ ok(M.MATH_RULE_LINES_EN.join('\n').indexOf(M.MATH_SUBSTITUTION_RULE_LINE_EN) !== -1, 'MATH_RULE_LINES_EN carries the substitution-honesty rule (en)')
511
+ ok(/替代方案若改变精确性/.test(String(M.MATH_TOOL_DESCRIPTION)), 'the tool description states the substitution duty (paraphrase)')
512
+ {
513
+ const p = await M.probeMathEngines(makeFakeHost({ installed: ['python3'] }).host)
514
+ const zh = M.mathAvailabilityLine(p, 'zh', 'typed+shell')
515
+ const en = M.mathAvailabilityLine(p, 'en', 'typed+shell')
516
+ ok(zh.indexOf('替代必须声明') !== -1, 'the injected (zh) availability line carries the substitution rule')
517
+ ok(en.indexOf('Declare substitutions') !== -1, 'the injected (en) availability line carries the substitution rule')
518
+ }
519
+
520
+ // 18b (B): python dispatch follows the DETECTED environment, never a guess.
521
+ {
522
+ const condaHost = makeFakeHost({ resolveMap: { python3: 'C:/miniconda3/envs/math/python.exe', conda: 'C:/miniconda3/Scripts/conda.exe' } })
523
+ M.registerMathComputation(condaHost.host)
524
+ const c = await condaHost.call({ op: 'install', engine: 'python', packages: ['numpy'] })
525
+ ok(c.ok === true && c.plan.manager === 'conda' && c.plan.managerAssumed === false, 'a conda-style interpreter plans with conda (not guessed)')
526
+ eq(c.plan.commands[0].argv, ['conda', 'install', '-y', '-c', 'conda-forge', 'numpy'], 'conda install comes from conda-forge')
527
+ ok(/conda/.test(String(c.message)), 'the plan message names the manager')
528
+
529
+ const uvHost = makeFakeHost({ resolveMap: { python3: '/venv/bin/python3', uv: '/venv/bin/uv' } })
530
+ M.registerMathComputation(uvHost.host)
531
+ const u = await uvHost.call({ op: 'install', engine: 'python', packages: ['numpy'] })
532
+ ok(u.ok === true && u.plan.manager === 'uv', 'a uv next to the interpreter plans with uv')
533
+ eq(u.plan.commands[0].argv, ['uv', 'pip', 'install', 'numpy'], 'uv install uses `uv pip install`')
534
+
535
+ const pipHost = makeFakeHost({ resolveMap: { python3: '/usr/bin/python3' } })
536
+ M.registerMathComputation(pipHost.host)
537
+ const d = await pipHost.call({ op: 'install', engine: 'python', packages: ['numpy'] })
538
+ ok(d.ok === true && d.plan.manager === 'pip' && d.plan.managerAssumed === true, 'no marker -> pip fallback, flagged as an ASSUMPTION')
539
+ eq(d.plan.commands[0].argv, ['/usr/bin/python3', '-m', 'pip', 'install', '--user', 'numpy'], 'the pip fallback keeps the user-scope form')
540
+ ok(/假设|assum/i.test(String(d.message)), 'the message says the manager is an assumption')
541
+ }
542
+
543
+ // 18c (C): R/Octave/Julia refuse system scope WITH a per-engine reason; conda too.
544
+ {
545
+ const h = makeFakeHost({ resolveMap: { Rscript: '/usr/bin/Rscript', octave: '/usr/bin/octave', julia: '/usr/bin/julia' } })
546
+ M.registerMathComputation(h.host)
547
+ for (const [engine, marker] of [['r', 'R_LIBS_USER'], ['octave', 'share/packages'], ['julia', 'JULIA_DEPOT_PATH']]) {
548
+ const r = await h.call({ op: 'install', engine: engine, packages: ['x'], scope: 'system' })
549
+ ok(r.ok === false && r.code === 'MATH_REFUSED' && r.next && r.next.reason === 'system-scope-unsupported', engine + ': system scope -> MATH_REFUSED(system-scope-unsupported)')
550
+ ok(String(r.message).indexOf(marker) !== -1, engine + ': the refusal states WHY (mentions ' + marker + ')')
551
+ }
552
+ const condaHost = makeFakeHost({ resolveMap: { python3: 'C:/miniconda3/envs/math/python.exe', conda: 'C:/miniconda3/Scripts/conda.exe' } })
553
+ M.registerMathComputation(condaHost.host)
554
+ const cs = await condaHost.call({ op: 'install', engine: 'python', packages: ['numpy'], scope: 'system' })
555
+ ok(cs.ok === false && cs.next.reason === 'system-scope-unsupported' && /conda/.test(String(cs.message)), 'conda has no system scope and says so')
556
+ }
557
+
558
+ // 18d (D): version syntax is passed through where the manager understands it, refused otherwise.
559
+ {
560
+ ok(M.validateMathArgs({ op: 'run', mode: 'code', code: 'x', packages: ['numpy==1.2'] }, {}).ok === true, 'pkg==1.2 is accepted (pass-through)')
561
+ ok(M.validateMathArgs({ op: 'run', mode: 'code', code: 'x', packages: ['numpy=1.2'] }, {}).ok === true, 'pkg=1.2 (conda form) is accepted')
562
+ ok(M.validateMathArgs({ op: 'run', mode: 'code', code: 'x', packages: ['numpy[extra]>=1.2'] }, {}).ok === true, 'extras + comparator are accepted')
563
+ const badSpec = M.validateMathArgs({ op: 'run', mode: 'code', code: 'x', packages: ['numpy; rm -rf /'] }, {})
564
+ ok(badSpec.ok === false && badSpec.code === 'MATH_INVALID_ARGUMENT' && badSpec.next && badSpec.next.reason === 'unsupported-version-syntax', 'a shell-ish spec is refused with unsupported-version-syntax')
565
+
566
+ const ph = makeFakeHost({ packages: { sympy: 'present' } })
567
+ M.registerMathComputation(ph.host)
568
+ await ph.call({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['sympy==1.12'] })
569
+ const pkgSpawns = ph.spawns.filter((s) => s.argv.join(' ').indexOf('sympy') !== -1)
570
+ ok(pkgSpawns.length > 0 && pkgSpawns.every((s) => s.argv.join(' ').indexOf('sympy==1.12') === -1), 'presence is probed by BASE NAME (the constraint never reaches the probe)')
571
+ const plan = await ph.call({ op: 'install', engine: 'python', packages: ['sympy==1.12'] })
572
+ ok(plan.ok === true && plan.plan.commands[0].argv[plan.plan.commands[0].argv.length - 1] === 'sympy==1.12', 'the install plan passes the spec through verbatim')
573
+ ok(!!plan.plan.versionPolicy, 'the plan states the version policy (solving belongs to the manager)')
574
+ }
575
+ }
576
+
577
+ // ── 19. round-7: probe reports the REQUESTED engine (fix 1) + DSH bundled-runtime discovery (fix 2)
578
+ {
579
+ // 19a (fix 1): `probe {engine:'r'}` must describe r - not the first allowed engine (python).
580
+ {
581
+ const hr = makeFakeHost({ installed: ['python3', 'python', 'Rscript'] })
582
+ M.registerMathComputation(hr.host)
583
+ const p = await hr.call({ op: 'probe', engine: 'r' })
584
+ ok(p.ok === true && p.engine === 'r', 'probe with engine:r reports engine:r (not python)')
585
+ ok(!!p.engineInfo && p.engineInfo.name === 'r' && !!p.engineInfo.version, "probe reports the requested engine's version/path")
586
+ ok(!p.next, 'a usable requested engine yields no install guidance')
587
+ const pp = await hr.call({ op: 'probe', engine: 'python' })
588
+ ok(pp.ok === true && pp.engine === 'python' && pp.engineInfo.name === 'python', 'probe with engine:python reports python (each request is honoured)')
589
+ }
590
+ // 19b (fix 1): a MISSING requested engine must not blame python in the guidance.
591
+ {
592
+ const hm = makeFakeHost({ installed: ['python3', 'python'] })
593
+ M.registerMathComputation(hm.host)
594
+ const p = await hm.call({ op: 'probe', engine: 'r' })
595
+ ok(p.ok === false && p.code === 'MATH_ENGINE_NOT_FOUND' && p.engine === 'r', 'a missing requested engine is reported as THAT engine (r, not python)')
596
+ ok(p.next && p.next.kind === 'user-install' && p.next.engine === 'r' && !!p.next.perOs, 'the install guidance is for the REQUESTED engine (r), with per-OS commands')
597
+ const msg = String(p.message)
598
+ ok(/试过:r|请求:r/.test(msg) && !/python/i.test(msg), 'the message names the requested engine (r), never a python fallback')
599
+ }
600
+ // 19c (fix 2): a DSH-bundled runtime is discovered as a LAST resort and used like any engine.
601
+ {
602
+ const absTree = {
603
+ 'X:/home/.dsh/dsh-runtimes': [{ name: 'dsh-primary-runtime', type: 'directory' }, { name: 'dsh-other-runtime', type: 'directory' }],
604
+ 'X:/home/.dsh/dsh-runtimes/dsh-primary-runtime/dependencies/python': [{ name: 'python.exe', type: 'file' }, { name: 'python3.exe', type: 'file' }],
605
+ 'X:/home/.dsh/dsh-runtimes/dsh-other-runtime/dependencies/python': [],
606
+ }
607
+ const hb = makeFakeHost({ installed: [], runtimeRoots: ['X:/home/.dsh'], absTree: absTree })
608
+ M.registerMathComputation(hb.host)
609
+ const p = await hb.call({ op: 'probe', engine: 'python' })
610
+ ok(p.ok === true && p.engine === 'python' && p.engineInfo.name === 'python', 'a bundled DSH runtime is found when PATH has nothing')
611
+ ok(/dsh-runtimes\/dsh-primary-runtime\/dependencies\/python\/python/.test(p.engineInfo.path), 'the discovered path comes from the generically globbed runtime tree')
612
+ const r = await hb.call({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' })
613
+ ok(r.ok === true && !!r.engineInfo.version, 'a run on the bundled runtime succeeds and reports its version')
614
+ ok(!r.next, 'a found bundled runtime emits NO install guidance')
615
+ // The bundled runtime must not be invented when the tree is absent.
616
+ const hn = makeFakeHost({ installed: [] })
617
+ M.registerMathComputation(hn.host)
618
+ const n = await hn.call({ op: 'probe', engine: 'python' })
619
+ ok(n.ok === false && n.code === 'MATH_ENGINE_NOT_FOUND' && n.next && n.next.kind === 'user-install', 'with no PATH engine and no runtime tree the per-OS guidance still fires')
620
+ }
621
+ // 19d (finding 3): the cli package precheck runs against the FAMILY of `cli.command`, not the
622
+ // generic cli descriptor (which has no packageProbe and therefore reported a false "missing").
623
+ {
624
+ const hc = makeFakeHost({ installed: [], packages: { numpy: 'present' } })
625
+ M.registerMathComputation(hc.host)
626
+ const r = await hc.call({ op: 'run', engine: 'cli', mode: 'code', code: 'print(1)\n', cli: { command: '/fake/bin/python3' }, packages: ['numpy'] })
627
+ ok(r.ok === true, 'cli + packages: a python-family command prechecks with the python probe (numpy present => the run proceeds)')
628
+ const probes = hc.spawns.filter((s) => s.argv.join(' ').indexOf('numpy') !== -1)
629
+ ok(probes.length > 0 && probes.every((s) => String(s.argv[0]).indexOf('python3') !== -1), 'the package precheck ran against cli.command itself')
630
+ // An unrecognisable command must SKIP the precheck (never a false missing) and say so.
631
+ const hu = makeFakeHost({ installed: [] })
632
+ M.registerMathComputation(hu.host)
633
+ const ru = await hu.call({ op: 'run', engine: 'cli', mode: 'code', code: 'print(1)\n', cli: { command: '/fake/bin/mytool' }, packages: ['numpy'] })
634
+ ok(ru.ok === true && (ru.warnings || []).some((w) => w.code === 'PACKAGE_PRECHECK_SKIPPED'), 'an unknown cli family skips the precheck with an explicit warning (no false missing-packages)')
635
+ }
636
+ // 19e (finding 5): cli reports the REAL version of the resolved command when its family is known.
637
+ {
638
+ const hv = makeFakeHost({ installed: [] })
639
+ M.registerMathComputation(hv.host)
640
+ const p = await hv.call({ op: 'probe', engine: 'cli', cli: { command: '/fake/bin/python3' } })
641
+ ok(p.ok === true && p.engineInfo && p.engineInfo.version === '1.2.3', 'probe(engine:cli) reports the real version of the resolved command')
642
+ const r = await hv.call({ op: 'run', engine: 'cli', mode: 'code', code: 'print(1)\n', cli: { command: '/fake/bin/python3' } })
643
+ ok(r.engineInfo.version === '1.2.3', 'run(engine:cli) reports the real command version (not "unknown")')
644
+ }
645
+ // 19f (finding 6, found on the real bundled python): the probe code iterates `sys.argv[1:]`, so the
646
+ // names must arrive as SEPARATE argv items - a comma-joined single item makes python look up one
647
+ // package literally named "numpy,pandas" and report everything missing.
648
+ {
649
+ const hp = makeFakeHost({ installed: ['python3'], packages: { numpy: 'present', pandas: 'present' } })
650
+ M.registerMathComputation(hp.host)
651
+ await hp.call({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['numpy', 'pandas'] })
652
+ const probe = hp.spawns.find((s) => s.argv.join(' ').indexOf('numpy') !== -1)
653
+ ok(!!probe && probe.argv.indexOf('numpy') !== -1 && probe.argv.indexOf('pandas') !== -1, 'the python package probe passes ONE argv item per package (sys.argv-based probe)')
654
+ ok(!!probe && probe.argv.join(' ').indexOf('numpy,pandas') === -1, 'the probe never passes the comma-joined list as a single argv item')
655
+ // round-7 (live): a real host rejected probe argv containing shell metacharacters (| %), so no
656
+ // probe argv may carry them - the parsers accept both `name:state` and `name state` shapes.
657
+ ok(!!probe && !/[|%]/.test(probe.argv.join(' ')), 'no probe argv contains a shell metacharacter (| or %)')
658
+ for (const name of ['python', 'r', 'octave', 'julia']) {
659
+ const d = (await import('../vibe-math-v2/math-engines.js')).MATH_ENGINES[name]
660
+ for (const a of (d.packageProbe.argv || [])) ok(!/[|%]/.test(String(a)), name + ' probe template has no | or %: ' + String(a).slice(0, 40))
661
+ ok(!/[|%]/.test(String(d.probeCode || 'numpy ok line')), name + ' probeCode has no | or %')
662
+ }
663
+ }
664
+ // 19f-bis: cli + mode:'code' actually PASSES the script (finding 4) - verified through argv shape.
665
+ {
666
+ const hs = makeFakeHost({ installed: [] })
667
+ M.registerMathComputation(hs.host)
668
+ const r = await hs.call({ op: 'run', engine: 'cli', mode: 'code', code: 'print(1)\n', cli: { command: '/fake/bin/python3' } })
669
+ const last = String(r.argv[r.argv.length - 1] || '')
670
+ ok(r.ok === true && /script\.txt$/.test(last), 'cli + code: the archived script is last in argv (it is really executed, not a REPL)')
671
+ const runSpawn = hs.spawns.filter((s) => String(s.argv[s.argv.length - 1] || '').indexOf('script.txt') !== -1)
672
+ ok(runSpawn.length === 1, 'the executed argv carried the script path to the process')
673
+ // …but when the CALLER's argv already runs code (-c/-m/-e/--eval), the caller's argv wins: the
674
+ // archived script must NOT be appended after it (python would read it as sys.argv[1]).
675
+ const rc = await hs.call({ op: 'run', engine: 'cli', mode: 'code', code: 'print(1)\n', cli: { command: '/fake/bin/python3', argv: ['-c', 'print(1)'] } })
676
+ ok(rc.ok === true && rc.cliScriptAppended === false, 'cli + code with a caller program slot (-c) does NOT append the archived script')
677
+ ok(rc.argv.join(' ').indexOf('script.txt') === -1, 'the executed argv is exactly the caller argv (no script path)')
678
+ ok(!!rc.receipt && !!rc.receipt.json, 'the skipped-append run still writes a receipt')
679
+ }
680
+ // 19g (live-session fix): an engine that RESOLVED but whose version probe failed must NOT come with
681
+ // install guidance (the user does not need to install anything) and must carry the diagnostic.
682
+ {
683
+ const hh = makeFakeHost({ installed: ['python3', 'python'], versionProbeFails: true })
684
+ M.registerMathComputation(hh.host)
685
+ const p = await hh.call({ op: 'probe', engine: 'python' })
686
+ ok(p.ok === false && p.code === 'MATH_ENGINE_UNUSABLE', 'a resolved engine whose probe fails => MATH_ENGINE_UNUSABLE')
687
+ ok(!p.next || p.next.kind !== 'user-install', 'unusable (but FOUND) engine carries NO install guidance')
688
+ ok(!!p.probe && Array.isArray(p.probe.argv) && p.probe.argv[0].indexOf('python3') !== -1, 'the failure carries the probe diagnostic (argv)')
689
+ ok(!!p.probe && p.probe.exit === 1 && p.probe.stderrTail === 'probe boom', 'the diagnostic carries exit + stderr tail')
690
+ ok(/MATH_ENGINE_UNUSABLE/.test(p.code) && /exit=1/.test(String(p.message)), 'the message states the probe exit code (never an opaque "unusable")')
691
+ // A deterministic failure is NOT retried (no retryDiag, no retry storm), only a cold/null/timeout
692
+ // signature is - see 19h.
693
+ ok(!(p.probe && p.probe.retried), 'a deterministic probe failure is NOT retried (no retryDiag)')
694
+ }
695
+ // 19h (live-session fix): the real host intermittently returns a NULL spawn early in a session.
696
+ // The retry must cover spawned:false / exit:null (not just timeouts) so the probe still succeeds.
697
+ {
698
+ const hN = makeFakeHost({ installed: ['python3', 'python'], nullSpawnTimes: 2 })
699
+ M.registerMathComputation(hN.host)
700
+ const p = await hN.call({ op: 'probe', engine: 'python' })
701
+ ok(p.ok === true && p.engine === 'python' && p.engineInfo.version === '1.2.3', 'a transient NULL spawn is retried and the probe still succeeds')
702
+ ok(hN.spawns.filter((s) => s.nullSpawn).length === 2, 'the null spawns are visible in the seam (2 consumed)')
703
+ }
704
+ // 19i (live-session fix): a package precheck that could not RUN must never be reported as
705
+ // "missing package", and the skip must be explicit (fail-open + named warning).
706
+ {
707
+ const hP = makeFakeHost({ installed: ['python3'], packageProbeNullTimes: 9 })
708
+ M.registerMathComputation(hP.host)
709
+ const r = await hP.call({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['numpy'] })
710
+ ok(r.ok !== false || r.code !== 'MATH_MISSING_PACKAGES', 'a precheck that could not run is NEVER reported as a missing package')
711
+ ok((r.warnings || []).some((w) => w.code === 'PACKAGE_PRECHECK_UNKNOWN'), 'the un-runnable precheck is reported explicitly (PACKAGE_PRECHECK_UNKNOWN)')
712
+ }
713
+ }
714
+
715
+ // ── 20. round-7 cross-cutting invariant: EVERY response must be JSON-serialisable
716
+ // (a real session got `Converting circular structure to JSON ... property 'retryDiag' closes the
717
+ // circle` from a probe/run response, which meant the caller saw no result at all). This applies to
718
+ // all four presets because they all ship this module.
719
+ {
720
+ const cases = []
721
+ const push = (label, r) => cases.push({ label, r })
722
+ {
723
+ const h = makeFakeHost({ installed: ['python3', 'python', 'Rscript'], packages: { numpy: 'present' } })
724
+ M.registerMathComputation(h.host)
725
+ push('probe ok', await h.call({ op: 'probe', engine: 'python' }))
726
+ push('probe missing engine', await h.call({ op: 'probe', engine: 'octave' }))
727
+ push('run ok', await h.call({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' }))
728
+ push('run bad mode', await h.call({ op: 'run', engine: 'python', mode: 'nope' }))
729
+ push('run missing pkg', await h.call({ op: 'run', engine: 'python', mode: 'code', code: 'x\n', packages: ['pandas'] }))
730
+ push('receipt missing', await h.call({ op: 'receipt', id: 'nope' }))
731
+ push('install plan', await h.call({ op: 'install', engine: 'python', packages: ['numpy'] }))
732
+ push('install commercial', await h.call({ op: 'install', engine: 'matlab', packages: ['MATLAB'] }))
733
+ push('install system scope r', await h.call({ op: 'install', engine: 'r', packages: ['x'], scope: 'system' }))
734
+ push('unsupported spec', await h.call({ op: 'run', engine: 'python', mode: 'code', code: 'x\n', packages: ['numpy; rm -rf /'] }))
735
+ push('unknown engine', await h.call({ op: 'run', engine: 'nope', mode: 'code', code: 'x\n' }))
736
+ }
737
+ {
738
+ const h = makeFakeHost({ installed: ['python3'], versionProbeFails: true })
739
+ M.registerMathComputation(h.host)
740
+ push('probe unusable (probe diagnostics)', await h.call({ op: 'probe', engine: 'python' }))
741
+ }
742
+ {
743
+ // A RETRIED probe carries retryDiag: that is exactly where the circular payload came from.
744
+ const h = makeFakeHost({ installed: ['python3'], nullSpawnTimes: 40 })
745
+ M.registerMathComputation(h.host)
746
+ push('probe unusable after retries (retryDiag present)', await h.call({ op: 'probe', engine: 'python' }))
747
+ }
748
+ {
749
+ const h = makeFakeHost({ installed: ['python3'], packageProbeNullTimes: 9 })
750
+ M.registerMathComputation(h.host)
751
+ push('run with un-runnable precheck', await h.call({ op: 'run', engine: 'python', mode: 'code', code: 'x\n', packages: ['numpy'] }))
752
+ }
753
+ {
754
+ const h = makeFakeHost({ installed: [] })
755
+ M.registerMathComputation(h.host)
756
+ push('probe nothing', await h.call({ op: 'probe', engine: 'python' }))
757
+ }
758
+ {
759
+ const h = makeFakeHost({ resolveThrows: true })
760
+ M.registerMathComputation(h.host)
761
+ push('probe resolve throws', await h.call({ op: 'probe', engine: 'python' }))
762
+ }
763
+ for (const c of cases) {
764
+ let json = null
765
+ let err = null
766
+ try { json = JSON.stringify(c.r) } catch (e) { err = String((e && e.message) || e) }
767
+ ok(json !== null, '★ response is JSON-serialisable: ' + c.label + (err ? ' (' + err + ')' : ''))
768
+ }
769
+ // The diagnostics themselves must never carry a back-reference.
770
+ {
771
+ const h = makeFakeHost({ installed: ['python3'], nullSpawnTimes: 2 })
772
+ M.registerMathComputation(h.host)
773
+ const r = await h.call({ op: 'probe', engine: 'python' })
774
+ ok(r.ok === true, 'the null-spawn retry recovers (JSON invariant context)')
775
+ const h2 = makeFakeHost({ installed: ['python3'], versionProbeFails: true })
776
+ M.registerMathComputation(h2.host)
777
+ const u = await h2.call({ op: 'probe', engine: 'python' })
778
+ ok(!!u.probe && JSON.stringify(u.probe).indexOf('retryDiag') === -1 ? true : !!u.probe, 'a deterministic failure still exposes its probe diagnostics')
779
+ }
780
+ }
781
+
782
+ // ── 21. round-7 (live root cause): probes must not depend on the project root EXISTING
783
+ // The real host answers a spawn whose cwd does not exist with `spawned:true, exit:null` (retries
784
+ // cannot help). On a brand-new session the project root does not exist yet, so the version probe and
785
+ // the package precheck must run from a cwd that is guaranteed to exist.
786
+ {
787
+ const FRESH = '/fresh/nonexistent/workspace'
788
+ const h = makeFakeHost({ installed: ['python3', 'python'], projectRoot: FRESH, packages: { numpy: 'present' } })
789
+ M.registerMathComputation(h.host)
790
+ const p = await h.call({ op: 'probe', engine: 'python' })
791
+ ok(p.ok === true && p.engine === 'python' && p.engineInfo.version === '1.2.3', '★ a probe on a FRESH (non-existent) project root still succeeds with a real version')
792
+ const versionSpawns = h.spawns.filter((s) => /--version/.test(s.argv.join(' ')))
793
+ ok(versionSpawns.length > 0, 'the version probe actually spawned')
794
+ ok(versionSpawns.every((s) => String(s.cwd) !== FRESH), '★ no version probe spawn uses the non-existent project root as cwd')
795
+ ok(versionSpawns.every((s) => !s.nullSpawn), 'no version probe spawn reported exit:null (fresh-root signature)')
796
+ const r = await h.call({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['numpy'] })
797
+ const pkgSpawns = h.spawns.filter((s) => s.argv.join(' ').indexOf('numpy') !== -1)
798
+ ok(r.ok === true, 'a run with packages on a FRESH root passes the precheck')
799
+ ok(pkgSpawns.length > 0 && pkgSpawns.every((s) => String(s.cwd) !== FRESH), '★ the package precheck also avoids the non-existent project root')
800
+ ok(!(r.warnings || []).some((w) => w.code === 'PACKAGE_PRECHECK_UNKNOWN'), 'the precheck RAN (no PACKAGE_PRECHECK_UNKNOWN on a fresh root)')
801
+ }
802
+
501
803
  console.log('')
502
804
  console.log('=== MATH COMPUTATION SHARED: ' + passed + ' passed, ' + failed + ' failed ===')
503
805
  if (failures.length) for (const f of failures) console.error(' - ' + f)
@@ -41,6 +41,7 @@
41
41
  mode.
42
42
 
43
43
  - math_computation {op: probe|run|receipt|install, engine, mode: code|file|expr, …} — 先 probe 预检引擎/包/许可,再调引擎计算并把脚本与输出归档成可复核回执;缺引擎/缺包只报告与给安装指引/计划;shell 兜底不算归档。
44
+ - 替代必须声明(诚实性):当替代方案改变了**精确性或结论强度**时(精确符号解 → 数值近似、闭式解 → 采样/求积、改了精度/容差/假设、换了算法类),结论里**必须写明**,不得读起来像得到了原本(精确/所要求的)结果;拿不到精确结果就直说。
44
45
  - 归档→编辑→重跑:mode:'code' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>),你可以用普通文件工具打开并编辑它;编辑后用 mode:'file' 指向它重跑,会写出一份**新回执/新 attempt**(含新的 scriptHash)。**旧回执对修改后的代码无效**——报告里必须引用与当前代码哈希一致的那份回执;工具会在 scriptChanged / scriptChangedDuringRun 为 true 时显式告警。
45
46
 
46
47
  Final paper: strict termination writes Paper/<project>/{paper.md,paper.tex,paper.pdf,paper.meta.json,paper.log.md}
@@ -118,6 +119,7 @@
118
119
  mode.
119
120
 
120
121
  - math_computation {op: probe|run|receipt|install, engine, mode: code|file|expr, …} — 先 probe 预检引擎/包/许可,再调引擎计算并把脚本与输出归档成可复核回执;缺引擎/缺包只报告与给安装指引/计划;shell 兜底不算归档。
122
+ - 替代必须声明(诚实性):当替代方案改变了**精确性或结论强度**时(精确符号解 → 数值近似、闭式解 → 采样/求积、改了精度/容差/假设、换了算法类),结论里**必须写明**,不得读起来像得到了原本(精确/所要求的)结果;拿不到精确结果就直说。
121
123
  - 归档→编辑→重跑:mode:'code' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>),你可以用普通文件工具打开并编辑它;编辑后用 mode:'file' 指向它重跑,会写出一份**新回执/新 attempt**(含新的 scriptHash)。**旧回执对修改后的代码无效**——报告里必须引用与当前代码哈希一致的那份回执;工具会在 scriptChanged / scriptChangedDuringRun 为 true 时显式告警。
122
124
 
123
125
  Final paper: strict termination writes Paper/<project>/{paper.md,paper.tex,paper.pdf,paper.meta.json,paper.log.md}