dsh-vibe-math 2.7.1 → 2.7.2

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 (47) hide show
  1. package/README.en.md +681 -690
  2. package/README.md +8 -17
  3. package/cordis.patch.yml +6 -6
  4. package/docs/AUDIT-CHECKLIST.md +518 -518
  5. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  6. package/docs/final-paper.md +172 -172
  7. package/docs/formal-verification.md +588 -588
  8. package/docs/math-computation.md +187 -163
  9. package/docs/release-notes/README.en.md +48 -0
  10. package/docs/release-notes/README.md +47 -0
  11. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +72 -0
  12. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +72 -0
  13. package/docs/release-notes/TEMPLATE.md +120 -0
  14. package/docs/test-timing.md +154 -154
  15. package/package.json +208 -203
  16. package/prompt-corpus-persona/persona-corpus.json +8 -8
  17. package/prompt-corpus-persona/persona-corpus.md +12 -12
  18. package/prompt-corpus-v2/formal-verify-v2.json +121 -109
  19. package/prompt-corpus-v2/formal-verify-v2.md +223 -107
  20. package/prompt-corpus-v3/formal-verify-v3.json +100 -76
  21. package/prompt-corpus-v3/formal-verify-v3.md +268 -70
  22. package/prompt-corpus-v4/formal-verify-v4.json +10 -10
  23. package/prompt-corpus-v4/formal-verify-v4.md +20 -20
  24. package/prompt-corpus-v5/prompt-corpus-v5.json +81 -81
  25. package/prompt-corpus-v5/prompt-corpus-v5.md +138 -128
  26. package/tests/audit-math-computation-contract.mjs +43 -1
  27. package/tests/audit-math-computation-sensitivity.mjs +2 -2
  28. package/tests/audit-v5-sensitivity.mjs +3 -1
  29. package/tests/e2e-v5-round2.test.mjs +114 -5
  30. package/tests/formal-verify-v2.test.mjs +38 -0
  31. package/tests/formal-verify-v3.test.mjs +33 -0
  32. package/tests/formal-verify-v4.test.mjs +87 -0
  33. package/tests/math-computation-shared.test.mjs +126 -1
  34. package/vibe-math-v2/agent.cordis.yml +2 -2
  35. package/vibe-math-v2/math-computation.js +122 -11
  36. package/vibe-math-v2/vibe-math-v2.js +75 -11
  37. package/vibe-math-v3/agent.cordis.yml +2 -2
  38. package/vibe-math-v3/math-computation.js +122 -11
  39. package/vibe-math-v3/vibe-math-v3.js +60 -13
  40. package/vibe-math-v4/agent.cordis.yml +4 -4
  41. package/vibe-math-v4/math-computation.js +122 -11
  42. package/vibe-math-v4/vibe-math-v4.js +55 -15
  43. package/vibe-math-v5/agent.cordis.yml +4 -4
  44. package/vibe-math-v5/math-computation.js +122 -11
  45. package/vibe-math-v5/vibe-math-v5.js +137 -28
  46. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1660 -1655
  47. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -44,6 +44,12 @@ function ok(cond, label, detail) {
44
44
  }
45
45
  const read = (rel) => readFileSync(join(ROOT, rel), 'utf8')
46
46
 
47
+ // round-8: the persona rule lines must be VERBATIM copies of the shared module constants, so a module
48
+ // wording change can never silently leave one preset behind. Import the module once (ESM top level).
49
+ const MATH_MODULE = await import(new URL('../vibe-math-v2/math-computation.js', import.meta.url).href)
50
+ const ARCHIVE_RULE_ZH = MATH_MODULE.MATH_ARCHIVE_WORKFLOW_LINE
51
+ const ARCHIVE_RULE_EN = MATH_MODULE.MATH_ARCHIVE_WORKFLOW_LINE_EN
52
+
47
53
  function block(src, startMarker, endMarker) {
48
54
  const i = src.indexOf(startMarker)
49
55
  if (i === -1) return ''
@@ -181,6 +187,34 @@ for (const P of PRESETS) {
181
187
  // 4d. round-7 (fix 2): every preset must wire the bundled-runtime discovery seam, otherwise a
182
188
  // machine whose only interpreter is DSH's own runtime still reports MATH_ENGINE_NOT_FOUND.
183
189
  ok(js.indexOf('runtimeRoots') !== -1 && js.indexOf('listDirAbs') !== -1, tag + 'wires runtimeRoots + listDirAbs (DSH bundled-runtime discovery)')
190
+
191
+ // 4e. round-8 (P0/D3): every persona block mentioning the archived scriptPath must ALSO say how to
192
+ // open it (absolute `receipt.scriptAbs` / join with `receipt.cwd`): member file tools resolve
193
+ // against the SESSION CWD, while scriptPath is project-root relative.
194
+ const scriptBlocks = blocks.filter((b) => b.indexOf('scriptPath') !== -1)
195
+ const openableBlocks = scriptBlocks.filter((b) => /receipt\.scriptAbs/.test(b))
196
+ ok(scriptBlocks.length >= 2, tag + 'both persona blocks mention the archived scriptPath', 'blocks: ' + scriptBlocks.length)
197
+ ok(openableBlocks.length === scriptBlocks.length && openableBlocks.length >= 2, tag + '★ every scriptPath mention also gives the ABSOLUTE path receipt.scriptAbs', 'openable: ' + openableBlocks.length + '/' + scriptBlocks.length)
198
+
199
+ // 4e-ter. VERBATIM-from-constant guard: the archive-workflow rule must appear in BOTH persona
200
+ // blocks exactly as the shared module constant defines it (a wording change in the module that is
201
+ // not re-synced into a preset is a silent drift - this assertion reports the found count).
202
+ const zhRule = String(ARCHIVE_RULE_ZH).trim()
203
+ const enRule = String(ARCHIVE_RULE_EN).trim()
204
+ const zhFound = blocks.filter((b) => b.indexOf(zhRule) !== -1).length
205
+ ok(zhFound >= 2, tag + 'the zh archive-workflow rule appears VERBATIM in BOTH persona blocks', 'found ' + zhFound)
206
+ if (P.dir === 'vibe-math-v4' || P.dir === 'vibe-math-v5') {
207
+ const enFound = blocks.filter((b) => b.indexOf(enRule) !== -1).length
208
+ ok(enFound >= 2, tag + 'the en archive-workflow rule appears VERBATIM in BOTH persona blocks', 'found ' + enFound)
209
+ }
210
+ }
211
+ // 4e-bis. the SHARED module's rule text must carry the same instruction (zh + en), and the receipt
212
+ // fields it points at must exist in the module.
213
+ {
214
+ const mod = read('vibe-math-v2/math-computation.js')
215
+ ok(/receipt\.scriptAbs/.test(mod), 'the shared module rule mentions receipt.scriptAbs')
216
+ ok(/会话 cwd/.test(mod) && /SESSION CWD/.test(mod), 'the shared module rule states the session-cwd fact in BOTH languages')
217
+ ok(/scriptAbs: scriptAbs, cwd: root/.test(mod), 'the shared module WRITES scriptAbs + cwd into the receipt')
184
218
  }
185
219
 
186
220
  // 5. README (both languages) document the six parameters
@@ -194,7 +228,15 @@ for (const f of ['README.md', 'README.en.md']) {
194
228
  {
195
229
  const doc = existsSync(join(ROOT, 'docs/math-computation.md')) ? read('docs/math-computation.md') : ''
196
230
  ok(doc.indexOf('字符串级') !== -1 && doc.indexOf('不解析符号链接') !== -1, 'docs §6 states the path guard is string-level and does not resolve links/junctions')
197
- ok(doc.indexOf('只通过假 subprocess seam 验证') !== -1, 'docs §6 states engine execution is verified only through the fake subprocess seam')
231
+ // round-9 (F0): the boundary paragraph must pin the CORRECTED truth - real engines were verified on
232
+ // this machine, AND the still-unverified set is named. (The old claim "only through the fake
233
+ // subprocess seam" is stale and must not be pinned any more.)
234
+ ok(doc.indexOf('假 subprocess seam') !== -1 && doc.indexOf('不再') !== -1, 'docs §6 keeps the fake-subprocess seam but states it is no longer the only evidence')
235
+ ok(doc.indexOf('python 3.12.10') !== -1 && doc.indexOf('R 4.6.1') !== -1, 'docs §6 names the real engines/versions verified on this machine')
236
+ ok(doc.indexOf('scriptHash') !== -1 && doc.indexOf('native') !== -1, 'docs §6 states the native-vs-cli parity (same numbers + same scriptHash)')
237
+ ok(doc.indexOf('MATH_TIMEOUT') !== -1 && doc.indexOf('MATH_NONZERO_EXIT') !== -1 && doc.indexOf('MATH_MISSING_PACKAGES') !== -1, 'docs §6 states the real-engine edge codes that were confirmed')
238
+ ok(doc.indexOf('Octave / Julia 未安装') !== -1 && doc.indexOf('VERIFY') !== -1 && doc.indexOf('无法强制禁网') !== -1, 'docs §6 keeps the still-unverified set (no Octave/Julia, commercial VERIFY, no network/write enforcement)')
239
+ ok(doc.indexOf('只通过假 subprocess seam 验证') === -1, 'docs §6 no longer claims engine execution was verified ONLY through the fake seam (stale claim removed)')
198
240
  ok(doc.indexOf('串行') !== -1 && doc.indexOf('跨进程') !== -1, 'docs §6 states the in-process serialisation and the cross-process limitation')
199
241
  }
200
242
 
@@ -77,8 +77,8 @@ const PROBES = [
77
77
  {
78
78
  name: 'script-fields-dropped-from-return',
79
79
  file: 'math-computation.js',
80
- from: ' scriptPath: scriptRel, scriptHash: scriptHash,\n attempt: attempt, attemptDir: dir, baseRunDir: baseDir,\n scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,\n sourceFile: fileRel || null,\n previousReceipt: receipt.previousReceipt,\n cliScriptAppended: !!(receipt.cli && receipt.cli.scriptAppended),\n packages: pk.found,',
81
- to: ' packages: pk.found,',
80
+ from: ' engineInfo: receipt.engine,\n argv: assembled.argv.slice(),\n scriptPath: scriptRel, scriptHash: scriptHash,',
81
+ to: ' engineInfo: receipt.engine,\n argv: assembled.argv.slice(),',
82
82
  expect: 'scriptPath + scriptHash are in the return value AND the receipt',
83
83
  },
84
84
  {
@@ -68,7 +68,9 @@ const probes = [
68
68
  name: 'quorum-forced-to-one',
69
69
  ref: 'selfdrive-v5.mjs',
70
70
  guarantee: '④ only >= m boolean votes may verify (m must be min(quorumCap, |voters|))',
71
- from: " return Math.max(1, Math.min(cap, voterCount()))",
71
+ // The floor moved into `quorumMFrom(vc)` when the quorum became single-sourced (defect B of
72
+ // the 3rd self-test); the anchor follows the implementation, it is not relaxed.
73
+ from: " return Math.max(1, Math.min(cap, vc))",
72
74
  to: " return 1",
73
75
  },
74
76
  {
@@ -22,11 +22,16 @@ import { tmpdir } from 'node:os'
22
22
  import { join, dirname, isAbsolute } from 'node:path'
23
23
 
24
24
  // round-7 (fix 2) test isolation: engine discovery now also scans DSH's own runtime trees, and this
25
- // machine HAS a bundled python. This suite injects ONE fake subprocess for everything (LaTeX + math),
26
- // so an unexpected engine probe would be recorded as a compiler call and would even write a fake
27
- // paper.pdf. Pinning DSH_HOME at an empty dir keeps the fixture's premise ("no math engine here")
28
- // true and the suite deterministic; production behaviour is unaffected.
29
- process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'vibe-v5-e2e-nodsh-'))
25
+ // machine HAS a bundled python. round-9 adds the known per-OS INSTALL dirs, and this machine now really
26
+ // has python/R installed. This suite injects ONE fake subprocess for everything (LaTeX + math), so an
27
+ // unexpected engine probe would be recorded as a compiler call and would even write a fake paper.pdf.
28
+ // Pinning all of those roots at an empty dir keeps the fixture's premise ("no math engine here") true
29
+ // and the suite deterministic; production behaviour is unaffected.
30
+ const EMPTY_ENGINE_ROOT = mkdtempSync(join(tmpdir(), 'vibe-v5-e2e-nodsh-'))
31
+ process.env.DSH_HOME = EMPTY_ENGINE_ROOT
32
+ process.env.ProgramFiles = EMPTY_ENGINE_ROOT
33
+ process.env['ProgramFiles(x86)'] = EMPTY_ENGINE_ROOT
34
+ process.env.LOCALAPPDATA = EMPTY_ENGINE_ROOT
30
35
 
31
36
  // V5_PLUGIN lets a sensitivity probe point this suite at a deliberately broken copy.
32
37
  // Without it every probe against this suite silently tested the UNMUTATED plugin and was
@@ -1865,6 +1870,110 @@ console.log('\n[42] every Lean prompt site obeys the leanInitiative gate in both
1865
1870
  assert(s3on.indexOf(DAILY) === -1, 'S3 verifyPrompt: …and the daily line is still absent there')
1866
1871
  }
1867
1872
 
1873
+ // ---------- 43. pre-start diagnosis + coherent quorum (architecture self-test defects 1/2) -------
1874
+ // The self-test (§18 of _oneoff/method3-scripted-driving.md) found that before `start`:
1875
+ // (1) record_progress / record_proposition / verdict answered a BARE `V5_MEMBER_NOT_FOUND`
1876
+ // with no hint about why or what to do first;
1877
+ // (2) `vibe_v5_status` reported `quorum.m=1` with `voters=0` — reading like a satisfied quorum.
1878
+ // Both are asserted here, INCLUDING the actionable `next` step, and the normal (post-start) path
1879
+ // is exercised in the same section so a follow-up self-test can see a non-error path.
1880
+ console.log('\n[43] pre-start tools explain themselves; the quorum is coherent before and after start')
1881
+ {
1882
+ const h = makeHost({ pluginModule })
1883
+ const pre = async (name, args) => await h.callTool(name, args, h.ROOT)
1884
+ const rProg = await pre('vibe_v5_record_progress', { content: '自我测试' })
1885
+ assert(rProg.ok === false && rProg.code === 'V5_MEMBER_NOT_FOUND' && /尚未启动/.test(String(rProg.message || '')) &&
1886
+ rProg.next && rProg.next.kind === 'start' && rProg.next.tool === 'vibe_v5_start',
1887
+ '★ pre-start record_progress: the code is unchanged but the answer names the state + the next step (' + JSON.stringify(rProg).slice(0, 200) + ')')
1888
+ const rProp = await pre('vibe_v5_record_proposition', { statement: 's', value: 1, motive: 'm', p: 0.5 })
1889
+ assert(rProp.ok === false && rProp.code === 'V5_MEMBER_NOT_FOUND' && /尚未启动/.test(String(rProp.message || '')) && rProp.next && rProp.next.kind === 'start',
1890
+ '★ pre-start record_proposition: same actionable diagnosis (' + JSON.stringify(rProp).slice(0, 160) + ')')
1891
+ const rVer = await pre('vibe_v5_verdict', { target: 'p-x', verdict: 1 })
1892
+ assert(rVer.ok === false && rVer.code === 'V5_MEMBER_NOT_FOUND' && /尚未启动/.test(String(rVer.message || '')) && rVer.next && rVer.next.kind === 'start',
1893
+ '★ pre-start verdict: same actionable diagnosis (' + JSON.stringify(rVer).slice(0, 160) + ')')
1894
+ const st0 = await pre('vibe_v5_status', {})
1895
+ assert(st0.quorum && st0.quorum.started === false && st0.quorum.m === 0 && st0.quorum.voterCount === 0 && st0.quorum.phase === 'not-started',
1896
+ '★ pre-start quorum is COHERENT: started=false, m=0, voters=0 (' + JSON.stringify(st0.quorum) + ')')
1897
+ assert(st0.quorum.next && st0.quorum.next.kind === 'start' && st0.quorum.next.tool === 'vibe_v5_start',
1898
+ '★ …and it says what to do first (' + JSON.stringify(st0.quorum.next) + ')')
1899
+ assert(st0.params && st0.params.m === 0 && st0.params.started === false,
1900
+ 'the params view agrees: m=0, started=false (' + JSON.stringify({ m: st0.params && st0.params.m, started: st0.params && st0.params.started }) + ')')
1901
+ assert(st0.quorum.m !== 1 || st0.quorum.voterCount !== 0,
1902
+ 'the old inconsistent `m=1 with voters=0` reading is gone')
1903
+ // ── the NORMAL path: after start, quorum is defined and the member tools work ──
1904
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
1905
+ await h.settleSpawns()
1906
+ const st1 = await h.callTool('vibe_v5_status', {})
1907
+ assert(st1.quorum && st1.quorum.started === true && st1.quorum.m >= 1 && st1.quorum.voterCount >= 1 && st1.quorum.voters.length === st1.quorum.voterCount,
1908
+ '★ after start the quorum is real: started=true, m>=1, voters listed (' + JSON.stringify(st1.quorum) + ')')
1909
+ const member = h.childAgent(h.childOf('r-1'))
1910
+ const okProg = await h.callTool('vibe_v5_record_progress', { content: '启动后写进度' }, member)
1911
+ assert(okProg.ok === true && /Members\/r-1\/Progress/.test(String(okProg.file || '')),
1912
+ '★ after start the SAME tool succeeds for a member (normal path reachable) (' + JSON.stringify(okProg).slice(0, 120) + ')')
1913
+ }
1914
+
1915
+ // ---------- 44. workspace containment + single-sourced quorum (3rd self-test defects A/B) --------
1916
+ // Defect A: member-facing text said the library root was `Members/<id>/` while a member's OWN
1917
+ // file tools resolve relative paths against the SESSION CWD ⇒ a stray `<cwd>/Members/acad/Propos/
1918
+ // p-*.md` appeared outside `VibeMath/Projects/…`. Every member-facing path is now CWD-relative,
1919
+ // and this section proves that a member card write cannot land outside the project tree.
1920
+ // Defect B: the office digest said m=2 while status/acad said m=3 in the same window (two roster
1921
+ // snapshots). Every view now reads ONE snapshot and carries `rosterVersion`, so all views at one
1922
+ // point in time must agree — including after a roster change.
1923
+ console.log('\n[44] paths stay inside the project root; every quorum view agrees')
1924
+ {
1925
+ const h = makeHost({ pluginModule })
1926
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
1927
+ // (A1) the member brief must name the CWD-relative library root (the member's real baseline).
1928
+ const brief = h.spawns.map((sp) => JSON.stringify(sp.request || {})).join('\n')
1929
+ assert(brief.indexOf('VibeMath/Projects/default/Institutes/institute/Members/') !== -1,
1930
+ '★ the member brief carries the CWD-relative library root (' + (brief.match(/Members\/[^"\\]{0,40}/) || [''])[0] + ')')
1931
+ // A bare `Members/…` NOT preceded by a path separator is an institute-relative path: a member
1932
+ // copying it into its own file tool would write it against the session cwd (defect A).
1933
+ const bareLib = (brief.match(/(^|[^A-Za-z0-9/_.-])Members\//g) || [])
1934
+ assert(bareLib.length === 0,
1935
+ 'no member-facing path is left institute-relative (bare ' + JSON.stringify(bareLib) + ')')
1936
+ await h.settleSpawns()
1937
+ // (A2) a member card write must not add anything at the workspace TOP level.
1938
+ const topBefore = readdirSync(h.WS).sort().join(',')
1939
+ const card = await h.callTool('vibe_v5_record_proposition', { id: 'p-stray-check', statement: '包层测试', value: 1, motive: 'm', p: 0.5 }, h.childAgent(h.childOf('r-1')))
1940
+ const topAfter = readdirSync(h.WS).sort().join(',')
1941
+ assert(card.ok === true && /Members\/r-1\/Propos\/p-stray-check\.md/.test(String(card.file || '')),
1942
+ 'the member card is written through the v5 tool (' + JSON.stringify(card).slice(0, 120) + ')')
1943
+ assert(existsSync(join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Members', 'r-1', 'Propos', 'p-stray-check.md')),
1944
+ '…and it exists under VibeMath/Projects/<project>/…')
1945
+ assert(topAfter === topBefore && !existsSync(join(h.WS, 'Members')),
1946
+ '★ NO path outside VibeMath/Projects/… gained files (top level unchanged: ' + topBefore + ' → ' + topAfter + ')')
1947
+ // (B) every quorum view agrees at one point in time, and they move TOGETHER after a hire.
1948
+ const views = async () => {
1949
+ const st = await h.callTool('vibe_v5_status', {})
1950
+ const rp = await h.callTool('vibe_v5_report', {})
1951
+ await h.callTool('vibe_v5_say', { to: 'r-1', text: '请继续推进。' }, h.childAgent(h.childOf('acad')))
1952
+ const w = await h.peekWakeOf('r-1', 3000)
1953
+ const pm = w ? /法定票数 m=(\d+)/.exec(w.text) : null
1954
+ if (w) h.fireEnd(w.childId, { progress: '收到。', solved: false, contextPct: 10 })
1955
+ await sleep(20)
1956
+ return { st, rp, promptM: pm ? Number(pm[1]) : null }
1957
+ }
1958
+ const v1 = await views()
1959
+ assert(v1.st.quorum && v1.rp.quorum && v1.st.quorum.m === v1.rp.quorum.m,
1960
+ '★ status and report agree on m at one point in time (' + JSON.stringify({ status: v1.st.quorum && v1.st.quorum.m, report: v1.rp.quorum && v1.rp.quorum.m }) + ')')
1961
+ assert(v1.st.quorum.rosterVersion === v1.rp.quorum.rosterVersion,
1962
+ 'both views carry the SAME rosterVersion (' + JSON.stringify({ s: v1.st.quorum.rosterVersion, r: v1.rp.quorum.rosterVersion }) + ')')
1963
+ assert(v1.promptM === v1.st.quorum.m,
1964
+ 'the member-facing state block shows the same m (' + JSON.stringify({ prompt: v1.promptM, status: v1.st.quorum.m }) + ')')
1965
+ // A roster change must reach EVERY view (the stale-snapshot bug). Dismissing a permanent
1966
+ // researcher shrinks the voter set synchronously, so m MUST move in every view at once.
1967
+ const rm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' })
1968
+ assert(rm.ok === true, 'precondition: the roster change succeeded (' + JSON.stringify(rm).slice(0, 140) + ')')
1969
+ await sleep(30)
1970
+ const v2 = await views()
1971
+ assert(v2.st.quorum.m === v2.rp.quorum.m,
1972
+ '★ after the roster changes, status and report STILL agree (status=' + v2.st.quorum.m + ', report=' + v2.rp.quorum.m + ')')
1973
+ assert(v2.st.quorum.rosterVersion !== v1.st.quorum.rosterVersion && v2.st.quorum.m !== v1.st.quorum.m,
1974
+ 'the roster change moved both m and rosterVersion (' + JSON.stringify({ before: v1.st.quorum.m, after: v2.st.quorum.m, v: [v1.st.quorum.rosterVersion, v2.st.quorum.rosterVersion] }) + ')')
1975
+ }
1976
+
1868
1977
  console.log('')
1869
1978
  console.log('passed=' + passed + ' failed=' + failed)
1870
1979
  if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
@@ -1636,6 +1636,44 @@ async function driveSites(h, opts, qid) {
1636
1636
  await hv.call('vibe_math_abort', {})
1637
1637
  }
1638
1638
 
1639
+ // ---------- 15f. P2-v2 D4: ONE participant set / ONE count across the two views ----------
1640
+ // Pinned definition (must stay in sync with the plugin's voteCount() doc comment):
1641
+ // * participant set of a verify task = `t.children` of the CURRENT round (their `childResults`
1642
+ // entries are that round's votes);
1643
+ // * "how many votes are required" = the task's **creation-time snapshot** `t.expectedCount`
1644
+ // (written when processVerify creates the task) — NEVER live `params.verifierCount` /
1645
+ // `params.maxParallelThreshold`.
1646
+ // Before the convergence the answer lived in three places (`consensus()` over reported votes with
1647
+ // the MIN_REVIEWERS constant, `finalizeVerification()` counting again, and two inline `allReported`
1648
+ // checks over `t.children`), so a mid-round hire/fire could make the debate view and the verdict
1649
+ // view disagree about whether the round is complete.
1650
+ section('15f P2-v2 D4: both views read ONE participant set (snapshot + round)')
1651
+ {
1652
+ const h = await makeCase('d4-v2-midvote')
1653
+ await h.call('vibe_math_set_params', { verifierCount: 2, debateMaxRounds: 2, verdictMode: 'flat' })
1654
+ await h.call('vibe_math_add_proposition', { id: 'p-d4v2', 概述: 'D4 v2 参与集', 概率: 0.6, 分类: '数论' })
1655
+ await startScheduler(h)
1656
+ const staffed = await waitFor(() => { const x = h.spawns.filter((s) => s.label.startsWith('verifier:r-p-d4v2')); return x.length >= 2 ? x : undefined }, 60, 250)
1657
+ assert(!!staffed, 'two reviewers were staffed for the round (the creation-time snapshot)')
1658
+ const pick = (o) => ((o && o.verifyTasks) || []).filter((t) => String(t.rId).indexOf('p-d4v2') !== -1)[0]
1659
+ const st1 = pick(await h.call('vibe_math_status', {}))
1660
+ const rp1 = pick(await h.call('vibe_math_report', {}))
1661
+ assert(!!st1 && !!rp1 && st1.expected === 2 && st1.dispatched === 2 && st1.round === 1,
1662
+ '★★ [D4-v2] the projection reports the round snapshot: expected=2 dispatched=2 round=1(实测 ' + JSON.stringify({ status: st1, report: rp1 }) + ')')
1663
+ assert(JSON.stringify(st1) === JSON.stringify(rp1),
1664
+ '★★★ [D4-v2] status 与 report 两个视图对同一任务的参与集/票数**逐字段相同**(一个来源;实测 ' + JSON.stringify({ status: st1, report: rp1 }) + ')')
1665
+ // mid-vote "hire more": the office raises verifierCount. The in-flight round keeps its snapshot —
1666
+ // a view that re-read the live roster/params would now claim 4.
1667
+ await h.call('vibe_math_set_params', { verifierCount: 4 })
1668
+ const after = pick(await h.call('vibe_math_status', {}))
1669
+ const afterR = pick(await h.call('vibe_math_report', {}))
1670
+ assert(!!after && after.expected === 2 && after.dispatched === 2,
1671
+ '★★★ [D4-v2] 中途调高 verifierCount **不改变**在飞轮次的参与集/需求票数(快照仍是 2;实测 ' + JSON.stringify(after) + ')')
1672
+ assert(JSON.stringify(after) === JSON.stringify(afterR),
1673
+ '★★ [D4-v2] 调参后两个视图仍然逐字段相同(实测 ' + JSON.stringify({ status: after, report: afterR }) + ')')
1674
+ await h.call('vibe_math_abort', {})
1675
+ }
1676
+
1639
1677
  section('16 the captured prompt corpus is written for human review')
1640
1678
  {
1641
1679
  // Freeze the scheduler in every case FIRST: a still-running tick loop could emit one more
@@ -1577,6 +1577,39 @@ async function driveSiteV3(rw, opts, qid) {
1577
1577
  await callTool('vibe_math_abort', {}, rvv)
1578
1578
  }
1579
1579
 
1580
+ // ===============================================================
1581
+ // 5c. round-2 D4: ONE vote-count source — a mid-verification verifierCount change must not re-derive
1582
+ // the in-flight task's quorum from live params (the audit's "same count, different code" bug).
1583
+ // ===============================================================
1584
+ section('5c D4 vote count: quorum comes from the task snapshot, not from live params')
1585
+ {
1586
+ const RD4 = makeRoot()
1587
+ await callTool('vibe_math_new_project', { name: 'd4-votecount' }, RD4)
1588
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { verifierCount: 2 }), RD4)
1589
+ await callTool('vibe_math_add_proposition', { id: 'p-d4', 概述: 'D4 票数单一来源', 概率: 0.6, 分类: '数论' }, RD4)
1590
+ await callTool('vibe_math_start', {}, RD4)
1591
+ const reD4 = verifyRe('p-d4')
1592
+ assert(await drive(RD4, () => unfiredVerifiers(RD4, reD4).length >= 2, 'two verifiers for p-d4'), 'two verifiers were dispatched for p-d4 (the creation-time snapshot)')
1593
+ // Mid-verification: the office asks for MORE verifiers. Intent-wise that is a NEW requirement for
1594
+ // FUTURE objects; this task's participant set was frozen when it was created.
1595
+ await callTool('vibe_math_set_params', Object.assign({}, VPARAMS, { verifierCount: 4 }), RD4)
1596
+ await sleep(300)
1597
+ // Count PER EXACT rId: the proposition may legitimately have several candidate rIds
1598
+ // (r-p-d4, r-p-d4-pf0, …); only the in-flight task for one rId must stay pinned.
1599
+ const labels = spawnOf(RD4, 'verifier:').map((x) => x.label)
1600
+ const exact = {}
1601
+ for (const l of labels) { const m = /^verifier:(r-p-d4):(\d+)$/.exec(l); if (m) exact[m[1]] = (exact[m[1]] || 0) + 1 }
1602
+ const inFlight = exact['r-p-d4'] || 0
1603
+ assert(inFlight === 2, '★ [D4] raising verifierCount mid-verification does NOT re-staff the in-flight task (participant set = the snapshot;实测 ' + JSON.stringify({ inFlight: inFlight, labels: labels }) + ')')
1604
+ // Both dispatched votes satisfy the quorum ⇒ the object must conclude. A view that re-derived its
1605
+ // quorum from the live params would demand 4 votes and stall here forever.
1606
+ await runVerifyRound(RD4, 'p-d4', [1, 1])
1607
+ const cardOf = () => readIf(join(projRoot('d4-votecount'), 'Propos', '数论', 'p-d4.md'))
1608
+ const concluded = await drive(RD4, () => { const c = cardOf(); return /-\s*概率:\s*1(\D|$)/.test(c) || /状态:\s*已验证/.test(c) }, 'p-d4 concludes with the two votes it has', 15000)
1609
+ assert(concluded, '★★★ [D4] the two dispatched votes still satisfy the quorum and the object concludes (no view may re-derive its quorum from live params;card=' + JSON.stringify(cardOf().slice(0, 220)) + ')')
1610
+ await callTool('vibe_math_abort', {}, RD4)
1611
+ }
1612
+
1580
1613
  section('10 the captured prompt corpus is written for human review')
1581
1614
  {
1582
1615
  mkdirSync(CORPUS_DIR, { recursive: true })
@@ -270,6 +270,24 @@ async function proposeAndVote(h, target, opts = {}) {
270
270
  // The verification may only have been QUEUED by the proposal round; finish it.
271
271
  return await settleVerify(h, opts.plan || (() => (opts.verdict === undefined ? 1 : opts.verdict)))
272
272
  }
273
+ /**
274
+ * Start a verification and KEEP IT IN FLIGHT (the first half of `proposeAndVote`): record a card, have
275
+ * r-1 propose verifying it, and stop driving as soon as the verify view is ACTIVE. Note the trap:
276
+ * `verifyInProgress` is already true while the verification is merely QUEUED, so `consensus.kind`
277
+ * is the only reliable "it is really running" signal.
278
+ */
279
+ async function startVerifyOnly(h, target) {
280
+ const card = { id: target, title: target, statement: '待验证对象 ' + target, prob: 0.8, value: 0.6, motivation: 'm' }
281
+ await h.callTool('vibe_v4_record_proposition', card, h.resAgent(h.childOf('r-1')))
282
+ const before = h.followups.length
283
+ await h.callTool('vibe_v4_message', { to: 'r-1', content: '请处理本轮工作。' })
284
+ for (let i = 0; i < 300 && h.followups.length === before; i++) await sleep(10)
285
+ await drive(h, () => ({ summary: '提议验证 ' + target + '。', solved: false, propose_verify: target, contextPct: 20 }), async () => {
286
+ const s = await h.callTool('vibe_v4_status', {})
287
+ return !!(s.consensus && s.consensus.kind === 'verify')
288
+ })
289
+ return await h.callTool('vibe_v4_status', {})
290
+ }
273
291
  /** Wake exactly the given resident with an ordinary work round and return that wake. */
274
292
  async function workWake(h, rId) {
275
293
  const before = h.followups.length
@@ -1479,6 +1497,75 @@ section('18 every daily-line injection site (coreRules/normal/heartbeat) obeys t
1479
1497
  await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'normal' })
1480
1498
  }
1481
1499
 
1500
+ // ===============================================================
1501
+ // 22. cross-preset audit P0 (v4 station): the member-visible library text must state that the
1502
+ // residents' file tools resolve against the SESSION CWD, so the listed sub-paths must be used
1503
+ // with the ABSOLUTE prefix (and computation artifacts via receipt.scriptAbs / receipt.cwd).
1504
+ // ===============================================================
1505
+ section('22 audit P0: member-facing paths carry the session-cwd / absolute-prefix instruction')
1506
+ {
1507
+ const N = await establish()
1508
+ const texts = [await N.prompts('brainstorm', 'r-1'), await N.prompts('coreRules'), await N.prompts('normal', 'r-1')]
1509
+ const ok = texts.filter((t) => t.indexOf('会话 cwd') !== -1 && t.indexOf('绝对前缀') !== -1)
1510
+ assert(ok.length >= 1, '★ the member-visible library text says file tools resolve against the SESSION CWD and that listed paths need the ABSOLUTE prefix (found in ' + ok.length + '/3 variants)')
1511
+ assert(ok.some((t) => t.indexOf('receipt.scriptAbs') !== -1 || t.indexOf('receipt.cwd') !== -1), '★ …and names the absolute receipt field (receipt.scriptAbs / receipt.cwd) for computation artifacts')
1512
+ }
1513
+
1514
+ // ===============================================================
1515
+ // 23. cross-preset audit P1 (v4 stations): a member-facing not-found must be diagnosable — code +
1516
+ // live roster status + an actionable next step — including `removeMember` (previously a bare
1517
+ // {ok:false}) and the not-yet-started case.
1518
+ // ===============================================================
1519
+ section('23 audit P1: member-facing not-found carries code + status + next')
1520
+ {
1521
+ const N = await establish()
1522
+ const wrong = await N.callTool('vibe_v4_send_message', { to: 'r-404', content: 'hi' }, N.resAgent(N.childOf('r-1')))
1523
+ assert(wrong.ok === false && wrong.code === 'V4_NO_SUCH_RESIDENT' && !!wrong.next && !!wrong.next.tool, '★ wrong resident id ⇒ code V4_NO_SUCH_RESIDENT + next{tool} (not a bare message)')
1524
+ assert(wrong.status && wrong.status.residentCount >= 1 && wrong.status.running === true, '★ …plus LIVE roster status, so "wrong id" is distinguishable from "not started"')
1525
+ const kicked = await N.callTool('vibe_v4_remove_member', { id: 'r-404' })
1526
+ assert(kicked.ok === false && kicked.code === 'V4_NO_SUCH_RESIDENT' && !!kicked.next && kicked.next.tool === 'vibe_v4_remove_member', '★ vibe_v4_remove_member {id:r-404} ⇒ code + next naming that tool (was a bare {ok:false})')
1527
+ const fresh = await mount()
1528
+ const notStarted = await fresh.callTool('vibe_v4_publish_progress', { content: 'x' })
1529
+ assert(notStarted.ok === false && notStarted.code === 'V4_NO_SUCH_RESIDENT' && !!notStarted.next && !!notStarted.status && notStarted.status.running === false, '★ run not started ⇒ code + next + status.running=false')
1530
+ }
1531
+
1532
+ // ---------- D4 cross-view: mid-verify hire/fire must not split the views ----------
1533
+ section('D4 cross-view: one frozen participant set, seen identically by every view')
1534
+ {
1535
+ const h = await establish()
1536
+ const st = await startVerifyOnly(h, 'p-d4')
1537
+ assert(st.consensus && st.consensus.kind === 'verify', 'precondition: an ACTIVE verification for the cross-view check (' + JSON.stringify({ c: st.consensus && st.consensus.kind, ip: st.verifyInProgress }) + ')')
1538
+ const c0 = st.consensus
1539
+ const rp0 = await h.callTool('vibe_v4_report', {})
1540
+ const rv0 = rp0.verify
1541
+ assert(Array.isArray(st.frozenParticipants) && rv0, 'the frozen participant set and the report() verify view are both exposed')
1542
+ assert(c0.expected === st.frozenParticipants.length, '★ consensus.expected === the frozen participant set size')
1543
+ assert(Number(String(rv0.voted).split('/')[1]) === c0.expected, '★ the two views agree on the DENOMINATOR (status().consensus.expected vs report().verify k/N)')
1544
+ assert(rv0.rosterVersion === c0.rosterVersion && c0.rosterVersion === st.rosterVersion, '★ both views (and status) report the SAME rosterVersion')
1545
+ // HIRE mid-verify: the newcomer must NOT join the frozen set (that is what freezing means).
1546
+ const hire = await h.callTool('vibe_v4_add_member', { direction: 'D4 cross-view assertion' })
1547
+ const st2 = await h.callTool('vibe_v4_status', {})
1548
+ const rv2 = (await h.callTool('vibe_v4_report', {})).verify
1549
+ assert(st2.rosterVersion > c0.rosterVersion, 'hiring mid-verify bumps rosterVersion (' + c0.rosterVersion + ' -> ' + st2.rosterVersion + ')')
1550
+ assert(st2.consensus.expected === st2.frozenParticipants.length && st2.consensus.expected === c0.expected,
1551
+ '★ after a mid-verify HIRE the frozen set is UNCHANGED and both views still agree (newcomer did not join)')
1552
+ assert(rv2.rosterVersion === st2.consensus.rosterVersion,
1553
+ '★ after the HIRE the two FROZEN views (status().consensus + report().verify) still report the SAME rosterVersion')
1554
+ assert(st2.rosterVersion > rv2.rosterVersion,
1555
+ '★ …while status().rosterVersion is the LIVE counter and has moved past the snapshot (staleness is visible)')
1556
+ // FIRE mid-verify: the frozen set is pruned so the in-flight verification can still conclude.
1557
+ if (hire && hire.ok && hire.id) await h.callTool('vibe_v4_remove_member', { id: hire.id })
1558
+ const st3 = await h.callTool('vibe_v4_status', {})
1559
+ const rv3 = (await h.callTool('vibe_v4_report', {})).verify
1560
+ assert(st3.consensus.expected === st3.frozenParticipants.length && Number(String(rv3.voted).split('/')[1]) === st3.consensus.expected && rv3.rosterVersion === st3.consensus.rosterVersion,
1561
+ '★ after a mid-verify FIRE the two views STILL agree (the frozen set was pruned together)')
1562
+ assert(st3.frozenParticipants.indexOf(hire && hire.id) === -1, '★ the removed member is not in the frozen set any more (pruned on removal)')
1563
+ // …and the verification really does conclude after that churn (T26/T31 contract).
1564
+ await settleVerify(h, () => 1)
1565
+ const st4 = await h.callTool('vibe_v4_status', {})
1566
+ assert(st4.verifyInProgress === false, '★ the mid-verify hire/fire did NOT prevent the verification from concluding')
1567
+ }
1568
+
1482
1569
  // ===============================================================
1483
1570
  console.log('')
1484
1571
  console.log('passed=' + passed + ' failed=' + failed)
@@ -116,7 +116,11 @@ console.log('-- math_computation shared contract --')
116
116
  await withHost({ installed: [] }, async (state) => {
117
117
  const r = await state.call({ op: 'probe' })
118
118
  ok(r.ok === false && r.code === 'MATH_ENGINE_NOT_FOUND', 'no engine -> ENGINE_NOT_FOUND')
119
- ok(r.next && r.next.kind === 'user-install' && r.next.command, 'ENGINE_NOT_FOUND carries the per-OS user-install guide')
119
+ // round-9: the guide is per-OS and always carries the SUGGESTED command; `command` itself is only
120
+ // populated when that OS's package manager is actually resolvable on this machine.
121
+ ok(r.next && r.next.kind === 'user-install' && r.next.perOs && r.next.perOs.windows && r.next.perOs.linux, 'ENGINE_NOT_FOUND carries the per-OS user-install guide')
122
+ ok(r.next.suggestedCommand === r.next.perOs[r.next.platform], 'the guide keeps the suggested per-OS command alongside the executable one')
123
+ ok(r.next.packageManagerAvailable === false ? r.next.command === '' : typeof r.next.command === 'string', 'command ⇔ package-manager availability (never a command that cannot run)')
120
124
  })
121
125
  await withHost({ packages: { numpy: 'present', sympy: 'missing' } }, async (state) => {
122
126
  const r = await state.call({ op: 'probe', packages: ['numpy', 'sympy'] })
@@ -800,6 +804,127 @@ console.log('-- math_computation shared contract --')
800
804
  ok(!(r.warnings || []).some((w) => w.code === 'PACKAGE_PRECHECK_UNKNOWN'), 'the precheck RAN (no PACKAGE_PRECHECK_UNKNOWN on a fresh root)')
801
805
  }
802
806
 
807
+ // ── 22. round-8 (P0/D3): member-facing artifact paths must be OPENABLE by members
808
+ // The receipt's scriptPath is PROJECT-ROOT relative, but members' file tools resolve against the
809
+ // SESSION CWD. The receipt/return therefore carry the absolute `scriptAbs` and the `cwd` to join with.
810
+ {
811
+ const h = makeFakeHost({ installed: ['python3'] })
812
+ M.registerMathComputation(h.host)
813
+ const r = await h.call({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' })
814
+ ok(r.ok === true, 'a run succeeds (P0 context)')
815
+ ok(typeof r.scriptAbs === 'string' && r.scriptAbs.length > 0, '★ the return carries an ABSOLUTE scriptAbs (members can open it directly)')
816
+ ok(typeof r.cwd === 'string' && r.cwd.length > 0, '★ the return carries cwd (the project root to join with)')
817
+ ok(String(r.scriptAbs).replace(/\\/g, '/').endsWith(String(r.scriptPath).replace(/\\/g, '/')), '★ scriptAbs ends with the receipt-relative scriptPath (join consistency)')
818
+ ok(!!h.files && h.files.has(String(r.scriptPath).replace(/\\/g, '/')), '★ the archived script really exists at scriptPath (the path handed to members is real)')
819
+ const rj = JSON.parse(h.files.get(String(r.receipt.json).replace(/\\/g, '/')))
820
+ ok(rj.scriptAbs === r.scriptAbs && rj.cwd === r.cwd, 'the RECEIPT carries scriptAbs + cwd too (a later reader can reconstruct the location)')
821
+ ok(String(M.MATH_ARCHIVE_WORKFLOW_LINE).indexOf('receipt.scriptAbs') !== -1 && String(M.MATH_ARCHIVE_WORKFLOW_LINE_EN).indexOf('receipt.scriptAbs') !== -1, 'the injected archive rule tells members HOW to open the script (zh+en)')
822
+ ok(/会话 cwd/.test(String(M.MATH_ARCHIVE_WORKFLOW_LINE)) && /SESSION CWD/.test(String(M.MATH_ARCHIVE_WORKFLOW_LINE_EN)), 'the rule states the session-cwd resolution fact (zh+en)')
823
+ }
824
+
825
+ // ── 23. round-9 (real-engine): an engine INSTALLED but not on PATH must still be discovered
826
+ // R 4.6.1 went to `C:\Program Files\R\R-4.6.1\bin` without touching PATH, so `probe` used to say R
827
+ // was missing even though it was installed.
828
+ {
829
+ const absTree = {
830
+ 'C:/Program Files/R': [{ name: 'R-4.6.1', type: 'directory' }],
831
+ 'C:/Program Files/R/R-4.6.1/bin': [{ name: 'Rscript.exe', type: 'file' }, { name: 'R.exe', type: 'file' }],
832
+ }
833
+ const h = makeFakeHost({ installed: [], absTree: absTree, runtimeRoots: [] })
834
+ M.registerMathComputation(h.host)
835
+ const p = await h.call({ op: 'probe', engine: 'r' })
836
+ ok(p.ok === true && p.engine === 'r', '★ a Rscript that exists ONLY in the default install dir is discovered (not on PATH)')
837
+ ok(!!p.engineInfo && /Program Files\/R\/R-4\.6\.1\/bin\/Rscript/.test(String(p.engineInfo.path)), 'the discovered path is the versioned install dir (' + String(p.engineInfo && p.engineInfo.path).slice(0, 60) + ')')
838
+ }
839
+ // ── 24. round-9 (real-engine): never suggest an install command that cannot run on this machine
840
+ {
841
+ const h = makeFakeHost({ installed: [] }) // `winget` is NOT resolvable here
842
+ M.registerMathComputation(h.host)
843
+ const p = await h.call({ op: 'probe', engine: 'octave' })
844
+ ok(p.ok === false && p.code === 'MATH_ENGINE_NOT_FOUND', 'a missing engine still fails with ENGINE_NOT_FOUND')
845
+ ok(!!p.next && p.next.kind === 'user-install', 'the guidance is still user-install shaped')
846
+ ok(String(p.next.command) === '', '★ no runnable-looking command when its package manager is absent (command is empty)')
847
+ ok(!/winget|brew|apt|choco|scoop/i.test(String(p.next.command) + ' ' + String(p.message)), '★ no winget/brew/apt suggestion anywhere when that manager is absent')
848
+ ok(p.next.packageManager === 'winget' && p.next.packageManagerAvailable === false, 'the response SAYS which manager was assumed and that it is unavailable')
849
+ ok(typeof p.next.note === 'string' && p.next.note.length > 0, 'an explicit note explains that the suggested command cannot run')
850
+ const h2 = makeFakeHost({ installed: [], resolveMap: { winget: 'C:/fake/winget.exe' } })
851
+ M.registerMathComputation(h2.host)
852
+ const p2 = await h2.call({ op: 'probe', engine: 'octave' })
853
+ ok(/winget/i.test(String(p2.next.command)) && p2.next.packageManagerAvailable === true, 'when the manager IS present the command is offered as before')
854
+ }
855
+
856
+ // ── 25. round-9 F3/F5/F7: probe completeness, uniform failure evidence, explicit constraint policy
857
+ {
858
+ // F3: a partial availability line must NAME the configured-but-absent engines.
859
+ const h = makeFakeHost({ installed: ['python3'] })
860
+ M.registerMathComputation(h.host)
861
+ const p = await h.call({ op: 'probe', engine: 'python' })
862
+ ok(p.ok === true && Array.isArray(p.absent) && p.absent.length > 0, '★ probe names the configured-but-absent engines instead of being silently partial')
863
+ ok(p.absent.some((a) => a.engine === 'r' && a.why === 'not-found-on-this-machine'), 'the absent list carries a reason (key `why`) per engine')
864
+ ok(p.absent.some((a) => a.engine === 'cli' && /caller-supplied/.test(a.why)), 'cli is explained as needing a caller-supplied command')
865
+ ok(/未发现/.test(String(p.message)), 'the message states which configured engines were not found')
866
+ // F5: MATH_TIMEOUT carries the SAME evidence fields a non-zero exit does.
867
+ const h2 = makeFakeHost({ installed: ['python3'], hang: true })
868
+ M.registerMathComputation(h2.host)
869
+ const t = await h2.call({ op: 'run', engine: 'python', mode: 'code', code: 'while True: pass\n', timeoutMs: 1000 })
870
+ ok(t.ok === false && t.code === 'MATH_TIMEOUT', 'a hanging run reports MATH_TIMEOUT')
871
+ ok(t.timedOut === true && 'exit' in t && t.exit === null, '★ MATH_TIMEOUT carries timedOut:true and an explicit exit field (uniform with NONZERO_EXIT)')
872
+ ok('stderr' in t && typeof t.stderr === 'string', '★ MATH_TIMEOUT carries the captured stderr like NONZERO_EXIT does')
873
+ // F7: the response says the constraints are existence-only, and lists the ones that were NOT checked.
874
+ const h3 = makeFakeHost({ installed: ['python3'], packages: { numpy: 'present' } })
875
+ M.registerMathComputation(h3.host)
876
+ const c = await h3.call({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['numpy>=1'] })
877
+ ok(c.ok === true && /existence-only/.test(String(c.versionPolicy)), '★ the run response states the version constraints are existence-only')
878
+ ok(Array.isArray(c.constraintsNotEnforced) && c.constraintsNotEnforced.indexOf('numpy>=1') !== -1, '★ the response names the constraint it did NOT check (no silent drop)')
879
+ const c2 = await h3.call({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['numpy'] })
880
+ ok(Array.isArray(c2.constraintsNotEnforced) && c2.constraintsNotEnforced.length === 0, 'an unconstrained request reports an empty not-enforced list')
881
+ const m = await h3.call({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['pandas>=2'] })
882
+ ok(m.ok === false && /existence-only/.test(String(m.versionPolicy)), 'the missing-package failure also states the constraint policy')
883
+ }
884
+
885
+ // ── 26. round-9 (N1): edited-archived-script evidence integrity
886
+ // The documented P2a flow: edit the ORIGINAL source, re-run mode:'file' ⇒ SAME archive id, attempt>=2,
887
+ // scriptChanged:true, previousReceipt at the prior attempt, prior attempt untouched. Pointing mode:'file'
888
+ // at an ARCHIVED script is a NEW archive id by design (the id is keyed by the SOURCE PATH) - which must
889
+ // be SAID (fileIsArchivedScript + a warning), not look like broken change detection.
890
+ {
891
+ const h = makeFakeHost({ installed: ['python3'], files: { 'Problems/x.py': 'print(1)\n' } })
892
+ M.registerMathComputation(h.host)
893
+ const r1 = await h.call({ op: 'run', engine: 'python', mode: 'file', file: 'Problems/x.py' })
894
+ ok(r1.ok === true && r1.attempt === 1 && r1.scriptChanged === false, 'first run of a source file: attempt 1, no change')
895
+ const rj1 = JSON.parse(h.files.get(String(r1.receipt.json).replace(/\\/g, '/')))
896
+ ok(typeof r1.runId === 'string' && rj1.runId === r1.runId, '★ the run response exposes its archive id (matches the durable receipt.runId) - needed to check "same archive id"')
897
+ h.files.set('Problems/x.py', 'print(2)\n')
898
+ const r2 = await h.call({ op: 'run', engine: 'python', mode: 'file', file: 'Problems/x.py' })
899
+ ok(r2.ok === true && r2.runId === r1.runId, '★ editing the ORIGINAL source and re-running lands on the SAME archive id')
900
+ ok(r2.attempt >= 2 && /\/attempts\/\d+/.test(String(r2.attemptDir || '')), '★ the re-run is attempt>=2 under attempts/<n>/')
901
+ ok(r2.scriptChanged === true, '★ the re-run reports scriptChanged:true')
902
+ ok(!!r2.previousReceipt && r2.previousReceipt.attempt === r1.attempt && r2.previousReceipt.scriptHash === r1.scriptHash, '★ previousReceipt points at the prior attempt (attempt + scriptHash)')
903
+ ok(h.files.get(String(r1.scriptPath).replace(/\\/g, '/')) === 'print(1)\n', '★ the prior attempt\'s script is NOT overwritten (append-only)')
904
+ // A DIFFERENT source path (e.g. a copy of the old script, or a brand-new file) is a NEW archive id BY
905
+ // DESIGN: the id is keyed by the source path, so there is no history for that id and
906
+ // scriptChanged/previousReceipt are - correctly - empty. This is the case a finder can mistake for
907
+ // "broken change detection"; it is not, and the docs now say so.
908
+ h.files.set('Problems/copy_of_old.py', 'print(1)\n')
909
+ const r3 = await h.call({ op: 'run', engine: 'python', mode: 'file', file: 'Problems/copy_of_old.py' })
910
+ ok(r3.ok === true, 'a run of a different source path succeeds (' + JSON.stringify({ ok: r3.ok, code: r3.code }) + ')')
911
+ ok(r3.runId !== r1.runId, '★ a DIFFERENT source path gets a DIFFERENT archive id')
912
+ ok(r3.attempt === 1, '★ …and therefore starts at attempt 1 (fresh archive, by design)')
913
+ ok(r3.scriptChanged === false, '★ …and reports scriptChanged:false (no history FOR THIS id)')
914
+ ok(r3.previousReceipt === null || r3.previousReceipt === undefined, '★ …and has no previousReceipt (expected, not broken change detection)')
915
+ ok(h.files.get(String(r1.scriptPath).replace(/\\/g, '/')) === 'print(1)\n', 'the earlier attempt is still untouched after that run')
916
+ }
917
+ // ── 27. round-9 (N2): a TIMEOUT carries its partial output in BOTH the response and the receipt
918
+ {
919
+ const h = makeFakeHost({ installed: ['python3'], hang: true, stdoutBytes: 16 })
920
+ M.registerMathComputation(h.host)
921
+ const t = await h.call({ op: 'run', engine: 'python', mode: 'code', code: 'print("before-timeout")\n', timeoutMs: 1000 })
922
+ ok(t.ok === false && t.code === 'MATH_TIMEOUT', 'the hanging run reports MATH_TIMEOUT')
923
+ ok('stdout' in t && 'stderr' in t, '★ the TIMEOUT response carries stdout AND stderr (not just the archived file)')
924
+ const rj = JSON.parse(h.files.get(String(t.receipt.json).replace(/\\/g, '/')))
925
+ ok(typeof rj.partialStdout === 'string' && typeof rj.partialStderr === 'string', '★ the TIMEOUT receipt JSON carries the capped partial output (as strings)')
926
+ }
927
+
803
928
  console.log('')
804
929
  console.log('=== MATH COMPUTATION SHARED: ' + passed + ' passed, ' + failed + ' failed ===')
805
930
  if (failures.length) for (const f of failures) console.error(' - ' + f)
@@ -42,7 +42,7 @@
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
+ - 归档→编辑→重跑:mode:'code' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>,**相对项目根**);**成员的文件工具是按会话 cwd 解析的**,所以打开它要用**绝对路径** `receipt.scriptAbs`,或把 `receipt.cwd` 与 `receipt.scriptPath` 拼起来(回执两个字段都有);编辑后用 mode:'file' 指向它重跑,会写出一份**新回执/新 attempt**(含新的 scriptHash)。**旧回执对修改后的代码无效**——报告里必须引用与当前代码哈希一致的那份回执;工具会在 scriptChanged / scriptChangedDuringRun 为 true 时显式告警。
46
46
 
47
47
  Final paper: strict termination writes Paper/<project>/{paper.md,paper.tex,paper.pdf,paper.meta.json,paper.log.md}
48
48
  automatically — a dedicated paper-writing child assembles the solved material (finalPaper=false disables only that
@@ -120,7 +120,7 @@
120
120
 
121
121
  - math_computation {op: probe|run|receipt|install, engine, mode: code|file|expr, …} — 先 probe 预检引擎/包/许可,再调引擎计算并把脚本与输出归档成可复核回执;缺引擎/缺包只报告与给安装指引/计划;shell 兜底不算归档。
122
122
  - 替代必须声明(诚实性):当替代方案改变了**精确性或结论强度**时(精确符号解 → 数值近似、闭式解 → 采样/求积、改了精度/容差/假设、换了算法类),结论里**必须写明**,不得读起来像得到了原本(精确/所要求的)结果;拿不到精确结果就直说。
123
- - 归档→编辑→重跑:mode:'code' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>),你可以用普通文件工具打开并编辑它;编辑后用 mode:'file' 指向它重跑,会写出一份**新回执/新 attempt**(含新的 scriptHash)。**旧回执对修改后的代码无效**——报告里必须引用与当前代码哈希一致的那份回执;工具会在 scriptChanged / scriptChangedDuringRun 为 true 时显式告警。
123
+ - 归档→编辑→重跑:mode:'code' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>,**相对项目根**);**成员的文件工具是按会话 cwd 解析的**,所以打开它要用**绝对路径** `receipt.scriptAbs`,或把 `receipt.cwd` 与 `receipt.scriptPath` 拼起来(回执两个字段都有);编辑后用 mode:'file' 指向它重跑,会写出一份**新回执/新 attempt**(含新的 scriptHash)。**旧回执对修改后的代码无效**——报告里必须引用与当前代码哈希一致的那份回执;工具会在 scriptChanged / scriptChangedDuringRun 为 true 时显式告警。
124
124
 
125
125
  Final paper: strict termination writes Paper/<project>/{paper.md,paper.tex,paper.pdf,paper.meta.json,paper.log.md}
126
126
  automatically — a dedicated paper-writing child assembles the solved material (finalPaper=false disables only that