dsh-vibe-math 2.7.2 → 2.7.3

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 (112) hide show
  1. package/README.en.md +11 -10
  2. package/README.md +13 -12
  3. package/cordis.patch.yml +112 -16
  4. package/docs/AUDIT-CHECKLIST.md +229 -3
  5. package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
  6. package/docs/final-paper.md +2 -2
  7. package/docs/math-computation.md +13 -6
  8. package/docs/parameter-schema.md +37 -0
  9. package/docs/release-notes/README.en.md +1 -0
  10. package/docs/release-notes/README.md +1 -0
  11. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +2 -2
  12. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +3 -3
  13. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  14. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  15. package/docs/release-notes/TEMPLATE.md +6 -0
  16. package/docs/status-report-fields.md +167 -0
  17. package/docs/test-timing.md +8 -5
  18. package/installer.js +27 -17
  19. package/package.json +250 -203
  20. package/prompt-corpus-persona/persona-corpus.json +9 -9
  21. package/prompt-corpus-persona/persona-corpus.md +225 -33
  22. package/prompt-corpus-v2/formal-verify-v2.json +295 -115
  23. package/prompt-corpus-v2/formal-verify-v2.md +2287 -283
  24. package/prompt-corpus-v3/formal-verify-v3.json +105 -105
  25. package/prompt-corpus-v3/formal-verify-v3.md +213 -143
  26. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  27. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  28. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  29. package/prompt-corpus-v5/prompt-corpus-v5.md +451 -207
  30. package/scripts/update-doc-counts.mjs +71 -0
  31. package/tests/audit-artifact-docs.mjs +72 -0
  32. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  33. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  34. package/tests/audit-installer-compat.test.mjs +52 -4
  35. package/tests/audit-installer-policy.test.mjs +41 -8
  36. package/tests/audit-math-computation-contract.mjs +64 -8
  37. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  38. package/tests/audit-math-computation-parity.mjs +164 -3
  39. package/tests/audit-math-computation-sensitivity.mjs +4 -2
  40. package/tests/audit-package-membership.mjs +57 -0
  41. package/tests/audit-package-membership.mutants.mjs +28 -0
  42. package/tests/audit-participant-set-parity.mjs +160 -0
  43. package/tests/audit-path-discipline.mjs +83 -0
  44. package/tests/audit-path-discipline.mutants.mjs +51 -0
  45. package/tests/audit-persona-sensitivity.mjs +14 -0
  46. package/tests/audit-persona-surface.mutants.mjs +69 -0
  47. package/tests/audit-persona-surface.test.mjs +63 -22
  48. package/tests/audit-prompt-duplication.mjs +143 -0
  49. package/tests/audit-prompt-duplication.mutants.mjs +50 -0
  50. package/tests/audit-prompt-invariants.mjs +43 -5
  51. package/tests/audit-readme-bilingual.test.mjs +152 -0
  52. package/tests/audit-readme-counts.mjs +95 -0
  53. package/tests/audit-readme-counts.mutants.mjs +52 -0
  54. package/tests/audit-registration.mjs +78 -0
  55. package/tests/audit-status-report-fields.mjs +172 -0
  56. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  57. package/tests/audit-v3-registration-parity.mjs +156 -0
  58. package/tests/audit-v5-integrity.mjs +300 -13
  59. package/tests/audit-v5-lean-abstention.mjs +392 -0
  60. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  61. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  62. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  63. package/tests/audit-v5-sensitivity.mjs +12 -7
  64. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  65. package/tests/e2e-v5-round2.test.mjs +458 -13
  66. package/tests/formal-verify-v2.test.mjs +91 -43
  67. package/tests/formal-verify-v3.mutants.mjs +117 -0
  68. package/tests/formal-verify-v3.test.mjs +85 -39
  69. package/tests/formal-verify-v4.mutants.mjs +152 -0
  70. package/tests/formal-verify-v4.test.mjs +541 -9
  71. package/tests/formal-verify-v5.test.mjs +68 -9
  72. package/tests/helpers/doc-counts-format.mjs +41 -0
  73. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  74. package/tests/math-computation-a1.mutants.mjs +72 -0
  75. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  76. package/tests/math-computation-discovery.mutants.mjs +76 -0
  77. package/tests/math-computation-shared.test.mjs +274 -5
  78. package/tests/math-computation-v2.test.mjs +767 -0
  79. package/tests/math-computation-v3.test.mjs +759 -0
  80. package/tests/math-computation-v4.mutants.mjs +133 -0
  81. package/tests/math-computation-v4.test.mjs +739 -0
  82. package/tests/prompt-v5-integrity.test.mjs +13 -2
  83. package/tests/run-tests.mjs +139 -9
  84. package/tests/run-tests.mutants.mjs +70 -0
  85. package/tests/selfdrive-v5.mjs +53 -1
  86. package/tests/selfdrive-v5.mutants.mjs +132 -0
  87. package/tests/v2-fix-probes.mutants.mjs +100 -0
  88. package/tests/v2-fix-probes.test.mjs +1039 -0
  89. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  90. package/tests/v3-fix-probes.mutants.mjs +95 -0
  91. package/tests/v3-fix-probes.test.mjs +891 -0
  92. package/tests/v5-institute-fixes.mutants.mjs +190 -0
  93. package/vibe-math-v2/agent.cordis.yml +62 -6
  94. package/vibe-math-v2/math-computation.js +130 -41
  95. package/vibe-math-v2/math-engines.js +12 -12
  96. package/vibe-math-v2/vibe-math-v2.js +388 -113
  97. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
  98. package/vibe-math-v3/agent.cordis.yml +72 -6
  99. package/vibe-math-v3/math-computation.js +130 -41
  100. package/vibe-math-v3/math-engines.js +12 -12
  101. package/vibe-math-v3/vibe-math-v3.js +422 -126
  102. package/vibe-math-v4/agent.cordis.yml +44 -8
  103. package/vibe-math-v4/math-computation.js +130 -41
  104. package/vibe-math-v4/math-engines.js +12 -12
  105. package/vibe-math-v4/vibe-math-v4.js +196 -43
  106. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
  107. package/vibe-math-v5/agent.cordis.yml +46 -12
  108. package/vibe-math-v5/math-computation.js +130 -41
  109. package/vibe-math-v5/math-engines.js +12 -12
  110. package/vibe-math-v5/vibe-math-v5.js +1173 -218
  111. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +80 -12
  112. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +2 -2
@@ -0,0 +1,392 @@
1
+ // ============================================================================
2
+ // audit-v5-lean-abstention.mjs — IN-REPO audit for L2 option A (the framework
3
+ // enforces an abstention when a reply records `formal:{decision:'defect'}` for an
4
+ // object AND votes on it in the same round).
5
+ //
6
+ // Owned by the v5 preset owner (leased by the Lead). Drives ONLY the public tool
7
+ // API + the `/v5` command handler, exactly like every other v5 suite.
8
+ // Scenarios (each with NAMED assertions):
9
+ // S1 formalVerify:'off' => the whole reply channel is a TRUE no-op
10
+ // S2 multi-defect ARRAY => per-object enforcement for every declared target
11
+ // S3 two members defect => the marker is an ARRAY with length 2
12
+ // S4 per-object enforcement => a defect on A does not abstain a vote on B
13
+ // S5 proper abstention => no false marker (no double counting)
14
+ // S6 `require` mode unaffected => the gate neither loosened nor bypassed
15
+ // S7 no-vote / no-rights (temp) => no abstention marker for a non-voter
16
+ // S8 no cross-round carryover => a later plain 1 still concludes
17
+ // S9 observability => marker visible on status() + lean_lib + report()
18
+ // Falsifiability: `tests/audit-v5-lean-abstention.mutants.mjs` (single-site mutants).
19
+ // Run: node tests/audit-v5-lean-abstention.mjs [V5_PLUGIN=… for a mutant copy]
20
+ // ============================================================================
21
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
22
+ import { tmpdir } from 'node:os'
23
+ import { fileURLToPath, pathToFileURL } from 'node:url'
24
+ import { join, dirname, isAbsolute } from 'node:path'
25
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url))
27
+ const PRESET_DIR = process.env.V5_PRESET_DIR ? String(process.env.V5_PRESET_DIR) : join(HERE, '..', 'vibe-math-v5')
28
+ const PLUGIN_PATH = process.env.V5_PLUGIN ? String(process.env.V5_PLUGIN) : join(PRESET_DIR, 'vibe-math-v5.js')
29
+ const TARGET_A = 'p-a'
30
+ const TARGET_B = 'p-b'
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
32
+ const noop = new Proxy(function () { return noop }, { get: () => noop, apply: () => noop })
33
+ let passed = 0, failed = 0
34
+ const failures = []
35
+ const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
36
+ const section = (t) => console.log('\n' + t)
37
+
38
+ const mkSession = (id, parent) => ({ id, header: { version: 1, id, createdAt: 1, cwd: null, parentSession: parent, isSeeded: false }, inheritedEventCount: 0, get seq() { return 0 }, append() { return {} }, deriveMessages() { return [] }, snapshotEvents() { return [] }, ownEvents() { return [] } })
39
+ function makeHost(ws) {
40
+ const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], liveAgents = new Map()
41
+ const base = {
42
+ get(n) {
43
+ if (n === 'sandboxPolicy') return undefined
44
+ if (n === 'compaction') return undefined
45
+ if (n === 'subprocess') return { async resolveExecutable(e) { return 'C:/fake/' + e }, spawn() { return { done: Promise.resolve({ exitCode: 0 }) } } }
46
+ return undefined
47
+ },
48
+ on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
49
+ effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
50
+ logger: { info() {}, warn() {}, error() {} },
51
+ timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
52
+ tools: { register(spec) { toolRegs.push(spec); return () => {} } },
53
+ commands: { register(spec) { commandRegs.push(spec); return () => {} } },
54
+ subagents: {
55
+ list() { return ['spawn'] },
56
+ async startContinuable({ label, request }) { const id = 'c' + (spawns.length + 1); const cs = mkSession(id, 'sess-audit'); cs.header.cwd = ws; liveAgents.set(id, { id, session: cs }); spawns.push({ label, request, childId: id }); return { childId: id, messageId: 'm' + spawns.length } },
57
+ async sendMessage(p, c, b) { wakes.push({ childId: c, blocks: b }); return 'w' + wakes.length },
58
+ interrupt() {}, async drainContinuableChildren(p, ids) { for (const i of ids) liveAgents.delete(i) },
59
+ },
60
+ agents: { roots() { return [ROOT] }, get(id) { return id === ROOT.id ? ROOT : liveAgents.get(id) }, list() { return [ROOT, ...liveAgents.values()] } },
61
+ fs: {
62
+ async resolve(rel, o2) { const b = (o2 && o2.cwd) || ws; const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/')); return { targetKey: p, displayPath: p } },
63
+ async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
64
+ async readText(t) { return readFileSync(t.targetKey, 'utf8') },
65
+ async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
66
+ async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
67
+ },
68
+ }
69
+ const ctx = new Proxy(base, { get(t, k) { if (k in t) return t[k]; if (k === 'then') return undefined; return noop } })
70
+ const ROOT_SESSION = mkSession('sess-audit', undefined)
71
+ ROOT_SESSION.header.cwd = ws
72
+ const ROOT = { id: 'sess-audit', options: {}, session: ROOT_SESSION }
73
+ return { ws, ctx, ROOT, toolRegs, commandRegs, listeners, spawns, wakes }
74
+ }
75
+ const instRootOf = (ws) => join(ws, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute')
76
+ const statePathOf = (ws) => join(instRootOf(ws), 'State', 'institute.v5state.json')
77
+
78
+ function writeState(ws, o) {
79
+ const members = o.members || [{ id: 'acad', kind: 'academician', childId: '', phase: 'active', direction: '', hiredBy: '', term: '', provider: 'spawn', persona: '', error: '', createdAt: 1, dismissedAt: 0, dismissReason: '' }]
80
+ const inst = {
81
+ key: 'default::institute', project: 'default', institute: 'institute', createdAt: 1, phase: 'active',
82
+ problem: { id: 'p-prime', statement: 'x' },
83
+ params: Object.assign({ researcherCount: 0, academician: true, quorumCap: 1, quorumMode: 'm-unanimous', formalVerify: 'encourage', leanAsync: false }, o.params || {}),
84
+ members, tasks: [], messages: [], delivered: [], meetings: [], debates: [],
85
+ verdicts: {}, formal: {}, todo: [], queue: [], solve: {},
86
+ counters: { academician: 1, researcher: 0, temp: 0, task: 0, meeting: 0, message: 0, verify: 0 },
87
+ runId: 'run-audit', lastProgressAt: 1, artifactCount: 0, diagnostics: [], paper: null, meetingOpen: null, officeRequestsDropped: 0,
88
+ }
89
+ const t = o.target || TARGET_A
90
+ inst.verdicts[t] = { target: t, kind: 'proposition', stage: 'vote', round: 1, votes: {}, history: {}, closed: false, proposer: 'acad', openedAt: 1, lastVoteAt: 1 }
91
+ inst.formal[t] = { status: 'passed', file: 'Formal/' + t + '.lean', proof: 'Verified/Lean/' + t + '.lean', decision: 'used', decisionSource: 'job-settle', run: { ok: true, exitCode: 0 }, updatedAt: 2 }
92
+ if (o.targetB) {
93
+ inst.verdicts[TARGET_B] = { target: TARGET_B, kind: 'proposition', stage: 'vote', round: 1, votes: {}, history: {}, closed: false, proposer: 'acad', openedAt: 1, lastVoteAt: 1 }
94
+ inst.formal[TARGET_B] = { status: 'passed', file: 'Formal/' + TARGET_B + '.lean', proof: 'Verified/Lean/' + TARGET_B + '.lean', decision: 'used', decisionSource: 'job-settle', run: { ok: true, exitCode: 0 }, updatedAt: 2 }
95
+ }
96
+ mkdirSync(dirname(statePathOf(ws)), { recursive: true })
97
+ writeFileSync(statePathOf(ws), JSON.stringify({ v: 1, institutes: { 'default::institute': inst } }, null, 2), 'utf8')
98
+ mkdirSync(join(instRootOf(ws), 'Formal'), { recursive: true })
99
+ for (const tt of [t].concat(o.targetB ? [TARGET_B] : [])) writeFileSync(join(instRootOf(ws), 'Formal', tt + '.lean'), 'theorem ' + tt.replace(/-/g, '_') + ' : True := trivial\n', 'utf8')
100
+ }
101
+
102
+ async function mkScenario(tag, o) {
103
+ const WS = mkdtempSync(join(tmpdir(), 'vibe-v5abs-'))
104
+ writeState(WS, o || {})
105
+ const mod = await import(pathToFileURL(PLUGIN_PATH).href + '?t=' + Date.now() + tag)
106
+ const h = makeHost(WS)
107
+ mod.apply(h.ctx)
108
+ const cmd = h.commandRegs.find((c) => c.name === 'v5')
109
+ const call = async (raw) => JSON.parse((await cmd.handler({ rawInput: raw, agent: h.ROOT })).text)
110
+ const toolSpec = (name) => h.toolRegs.find((t) => t.name === name)
111
+ const tool = async (name, args, agent) => JSON.parse(await toolSpec(name).execute(args || {}, { agent: agent || h.ROOT }))
112
+ const child = (id) => h.liveAgents && h.liveAgents.get && h.liveAgents.get(id) ? h.liveAgents.get(id) : { id, session: { header: { parentSession: h.ROOT.id } } }
113
+ const fire = (childId, reply) => { const blocks = [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]; for (const fn of (h.listeners['subagent/end'] || [])) fn({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: blocks }) }
114
+ return { WS, h, call, tool, child, fire }
115
+ }
116
+ const stateOf = (ws) => JSON.parse(readFileSync(statePathOf(ws), 'utf8')).institutes['default::institute']
117
+ const clean = (ws) => { try { rmSync(ws, { recursive: true, force: true }) } catch (e) { /* best effort */ } }
118
+
119
+ section('[S1] formalVerify:\'off\' — the reply channel is a TRUE no-op')
120
+ {
121
+ const { WS, call, tool, fire, h } = await mkScenario('off', { params: { formalVerify: 'off' } })
122
+ await call('resume'); await sleep(40)
123
+ if (h.spawns.length) { fire(h.spawns[0].childId, { progress: 'ok', solved: false, contextPct: 10 }); await sleep(60) }
124
+ await call('set maxParallel=1'); await sleep(120)
125
+ let asked = false
126
+ for (let i = 0; i < h.wakes.length; i++) {
127
+ const t = ((h.wakes[i].blocks || [])[0] || {}).text || ''
128
+ if (t.indexOf('【求真表决') !== -1) { const w = h.wakes.splice(i, 1)[0]; fire(w.childId, { formal: { target: TARGET_A, decision: 'defect', note: '不应生效' }, verdict: { target: TARGET_A, verdict: 1, reason: 'off 模式' }, progress: 'x', solved: false, contextPct: 10 }); asked = true; break }
129
+ }
130
+ await sleep(150)
131
+ const st = await call('status')
132
+ const s = stateOf(WS)
133
+ const obj = ((st.formal || {}).objects || []).find((o) => o.target === TARGET_A) || {}
134
+ console.log(' asked=' + asked + ' formal.mode=' + st.formal.mode + ' status=' + JSON.stringify(obj.status) + ' fidelity=' + JSON.stringify(obj.fidelity) + ' closed/outcome=' + JSON.stringify([(s.verdicts[TARGET_A] || {}).closed, (s.verdicts[TARGET_A] || {}).outcome]))
135
+ assert(asked, 'S1: the voter was asked (the reply path really ran)')
136
+ assert(st.formal.mode === 'off', 'S1: the mode is off')
137
+ assert(obj.status === 'passed' && !obj.fidelity, '★ S1: a `formal:{…}` reply changes NOTHING observable in off mode (status stays passed, no fidelity marker)')
138
+ assert(!obj.abstainedCount, '★ S1: no enforced abstention is recorded in off mode')
139
+ assert((s.verdicts[TARGET_A] || {}).outcome === 'true', '★ S1: the plain 1 ballot is what counted (off mode is inert, not silently abstaining)')
140
+ clean(WS)
141
+ }
142
+
143
+ section('[S2] multi-defect ARRAY — every declared target is enforced (adv-F2)')
144
+ {
145
+ const { WS, call, tool, fire, h } = await mkScenario('arr', { targetB: true })
146
+ await call('resume'); await sleep(40)
147
+ if (h.spawns.length) { fire(h.spawns[0].childId, { progress: 'ok', solved: false, contextPct: 10 }); await sleep(60) }
148
+ await call('set maxParallel=1'); await sleep(120)
149
+ let asked = false
150
+ for (let i = 0; i < h.wakes.length; i++) {
151
+ const t = ((h.wakes[i].blocks || [])[0] || {}).text || ''
152
+ if (t.indexOf('【求真表决') !== -1) {
153
+ const w = h.wakes.splice(i, 1)[0]
154
+ fire(w.childId, { formal: [{ target: TARGET_A, decision: 'defect', note: 'A 的形式化偏离' }, { target: TARGET_B, decision: 'defect', note: 'B 的形式化偏离' }], verdict: { target: TARGET_A, verdict: 1, reason: '同回复数组' }, progress: 'x', solved: false, contextPct: 10 })
155
+ asked = true; break
156
+ }
157
+ }
158
+ await sleep(150)
159
+ const st = await call('status')
160
+ const s = stateOf(WS)
161
+ const a = ((st.formal || {}).objects || []).find((o) => o.target === TARGET_A) || {}
162
+ const b = ((st.formal || {}).objects || []).find((o) => o.target === TARGET_B) || {}
163
+ console.log(' asked=' + asked + ' A=' + JSON.stringify(a.status) + '/marker=' + JSON.stringify(a.abstainedCount) + ' B=' + JSON.stringify(b.status) + '/marker=' + JSON.stringify(b.abstainedCount) + ' A closed=' + JSON.stringify((s.verdicts[TARGET_A] || {}).closed))
164
+ assert(asked, 'S2: the voter was asked')
165
+ assert(a.status === 'attempted' && b.status === 'attempted', '★ S2: BOTH array entries were recorded (both retracted to attempted)')
166
+ assert(a.abstainedCount === 1, '★ S2: the array form enforces the abstention for the voted target (marker length 1)')
167
+ assert(!((s.verdicts[TARGET_A] || {}).closed === true && (s.verdicts[TARGET_A] || {}).outcome === 'true'), '★ S2: the same reply cannot conclude the object it declared defective')
168
+ clean(WS)
169
+ }
170
+
171
+ section('[S3] two members each recording a defect — the marker is an ARRAY of length 2 (adv-F1)')
172
+ {
173
+ const { WS, call, tool, fire, h } = await mkScenario('two', { members: [
174
+ { id: 'acad', kind: 'academician', childId: '', phase: 'active', direction: '', hiredBy: '', term: '', provider: 'spawn', persona: '', error: '', createdAt: 1, dismissedAt: 0, dismissReason: '' },
175
+ // TWO RESEARCHERS: the academician is the office and does not hold a ballot once researchers
176
+ // exist, so the two-marker scenario needs two real voters.
177
+ { id: 'r-1', kind: 'researcher', childId: '', phase: 'active', direction: '', hiredBy: '', term: '', provider: 'spawn', persona: '', error: '', createdAt: 2, dismissedAt: 0, dismissReason: '' },
178
+ { id: 'r-2', kind: 'researcher', childId: '', phase: 'active', direction: '', hiredBy: '', term: '', provider: 'spawn', persona: '', error: '', createdAt: 3, dismissedAt: 0, dismissReason: '' },
179
+ ], params: { researcherCount: 2, quorumCap: 2, maxParallel: 2 } })
180
+ await call('resume'); await sleep(60)
181
+ for (const sp of h.spawns.slice()) { fire(sp.childId, { progress: 'ok', solved: false, contextPct: 10 }); await sleep(40) }
182
+ await call('set maxParallel=2'); await sleep(200)
183
+ // Fire BOTH voters' replies directly (their ids come from the session, not from wake labels):
184
+ // wake routing is the host's business, and this scenario is about the MARKER accumulating.
185
+ const st0 = await call('status')
186
+ const voterChildren = (st0.members || []).filter((m) => m.id === 'r-1' || m.id === 'r-2').map((m) => ({ id: m.id, childId: String(m.childId || '') }))
187
+ let answers = 0
188
+ for (const v of voterChildren) {
189
+ if (!v.childId) continue
190
+ fire(v.childId, { formal: { target: TARGET_A, decision: 'defect', note: v.id + ' 认为形式化偏离' }, verdict: { target: TARGET_A, verdict: 1, reason: v.id + ' 投 1' }, progress: 'x', solved: false, contextPct: 10 })
191
+ answers += 1
192
+ await sleep(120)
193
+ }
194
+ await sleep(150)
195
+ const st = await call('status')
196
+ const s = stateOf(WS)
197
+ const a = ((st.formal || {}).objects || []).find((o) => o.target === TARGET_A) || {}
198
+ const list = (a.fidelity || {}).voteAbstainedByFramework
199
+ const len = Array.isArray(list) ? list.length : (list ? 1 : 0)
200
+ const lib = await tool('vibe_v5_lean_lib', { refresh: false })
201
+ const libObj = ((lib.objects || [])[0]) || {}
202
+ const rp = String((await call('report')).report || '')
203
+ console.log(' answers=' + answers + ' markerLen=' + len + ' abstainedCount=' + a.abstainedCount + ' libLen=' + (Array.isArray((libObj.fidelity || {}).voteAbstainedByFramework) ? libObj.fidelity.voteAbstainedByFramework.length : 0))
204
+ assert(answers === 2, 'S3: two members answered (' + answers + ')')
205
+ assert(Array.isArray(list) && list.length === 2, '★ S3: the enforced-abstention marker is an ARRAY of length 2 (both members auditable)')
206
+ assert(a.abstainedCount === 2, '★ S3: abstainedCount === 2')
207
+ assert(list.map((x) => x.by).sort().join(',') === 'r-1,r-2', '★ S3: both VOTERS are recorded (' + JSON.stringify(list.map((x) => x.by)) + ')')
208
+ assert(!((s.verdicts[TARGET_A] || {}).closed === true && (s.verdicts[TARGET_A] || {}).outcome === 'true'), '★ S3: the round still cannot conclude the defective object')
209
+ clean(WS)
210
+ }
211
+
212
+ section('[S4] per-object enforcement — a defect on A does not abstain a vote on B')
213
+ {
214
+ const { WS, call, fire, h } = await mkScenario('perobj', { targetB: true, params: { quorumCap: 1 } })
215
+ await call('resume'); await sleep(40)
216
+ if (h.spawns.length) { fire(h.spawns[0].childId, { progress: 'ok', solved: false, contextPct: 10 }); await sleep(60) }
217
+ await call('set maxParallel=1'); await sleep(120)
218
+ let answered = 0
219
+ const deadline = Date.now() + 2500
220
+ while (answered < 2 && Date.now() < deadline) {
221
+ const i = h.wakes.findIndex((w) => (((w.blocks || [])[0] || {}).text || '').indexOf('【求真表决') !== -1)
222
+ if (i === -1) { if (h.wakes.length) { const w = h.wakes.shift(); fire(w.childId, { progress: 'x', solved: false, contextPct: 10 }) } await sleep(25); continue }
223
+ const w = h.wakes.splice(i, 1)[0]
224
+ const text = (w.blocks[0] || {}).text || ''
225
+ if (text.indexOf(TARGET_A) !== -1 && answered === 0) fire(w.childId, { formal: { target: TARGET_A, decision: 'defect', note: 'A 偏离' }, verdict: { target: TARGET_A, verdict: 1, reason: 'A' }, progress: 'x', solved: false, contextPct: 10 })
226
+ else fire(w.childId, { verdict: { target: text.indexOf(TARGET_B) !== -1 ? TARGET_B : TARGET_A, verdict: 1, reason: 'B' }, progress: 'x', solved: false, contextPct: 10 })
227
+ answered += 1
228
+ await sleep(60)
229
+ }
230
+ await sleep(150)
231
+ const st = await call('status')
232
+ const s = stateOf(WS)
233
+ const a = ((st.formal || {}).objects || []).find((o) => o.target === TARGET_A) || {}
234
+ const b = ((st.formal || {}).objects || []).find((o) => o.target === TARGET_B) || {}
235
+ console.log(' answered=' + answered + ' A.marker=' + JSON.stringify(a.abstainedCount) + ' B.marker=' + JSON.stringify(b.abstainedCount) + ' B=' + JSON.stringify([(s.verdicts[TARGET_B] || {}).closed, (s.verdicts[TARGET_B] || {}).outcome]))
236
+ assert(!a.abstainedCount && !b.abstainedCount || a.abstainedCount === 1, 'S4: the marker belongs to the object that was declared defective')
237
+ assert(b.abstainedCount === 0 || b.abstainedCount === undefined, '★ S4: the OTHER object gets no abstention marker')
238
+ clean(WS)
239
+ }
240
+
241
+ section('[S5] a PROPER abstention (0.5) gets no false marker')
242
+ {
243
+ const { WS, call, fire, h } = await mkScenario('prop', {})
244
+ await call('resume'); await sleep(40)
245
+ if (h.spawns.length) { fire(h.spawns[0].childId, { progress: 'ok', solved: false, contextPct: 10 }); await sleep(60) }
246
+ await call('set maxParallel=1'); await sleep(120)
247
+ for (let i = 0; i < h.wakes.length; i++) {
248
+ const t = ((h.wakes[i].blocks || [])[0] || {}).text || ''
249
+ if (t.indexOf('【求真表决') !== -1) { const w = h.wakes.splice(i, 1)[0]; fire(w.childId, { formal: { target: TARGET_A, decision: 'defect', note: '偏离' }, verdict: { target: TARGET_A, verdict: 0.5, reason: '主动弃权' }, progress: 'x', solved: false, contextPct: 10 }); break }
250
+ }
251
+ await sleep(150)
252
+ const st = await call('status')
253
+ const s = stateOf(WS)
254
+ const a = ((st.formal || {}).objects || []).find((o) => o.target === TARGET_A) || {}
255
+ console.log(' status=' + JSON.stringify(a.status) + ' marker=' + JSON.stringify(a.fidelity && a.fidelity.voteAbstainedByFramework) + ' closed/outcome=' + JSON.stringify([(s.verdicts[TARGET_A] || {}).closed, (s.verdicts[TARGET_A] || {}).outcome]))
256
+ assert(a.status === 'attempted', 'S5: the defect still retracted the proof')
257
+ assert(!a.fidelity || !a.fidelity.voteAbstainedByFramework, '★ S5: NO false marker when the value was already an abstention (no double counting)')
258
+ assert(!((s.verdicts[TARGET_A] || {}).closed === true && (s.verdicts[TARGET_A] || {}).outcome === 'true'), '★ S5: an abstention cannot conclude the object')
259
+ clean(WS)
260
+ }
261
+
262
+ section('[S6] `require` mode: the gate is neither loosened nor bypassed')
263
+ {
264
+ const { WS, call, tool, fire, h } = await mkScenario('req', { params: { formalVerify: 'require' } })
265
+ await call('resume'); await sleep(40)
266
+ if (h.spawns.length) { fire(h.spawns[0].childId, { progress: 'ok', solved: false, contextPct: 10 }); await sleep(60) }
267
+ await call('set maxParallel=1'); await sleep(120)
268
+ for (let i = 0; i < h.wakes.length; i++) {
269
+ const t = ((h.wakes[i].blocks || [])[0] || {}).text || ''
270
+ if (t.indexOf('【求真表决') !== -1) { const w = h.wakes.splice(i, 1)[0]; fire(w.childId, { formal: { target: TARGET_A, decision: 'defect', note: '偏离' }, verdict: { target: TARGET_A, verdict: 1, reason: 'x' }, progress: 'x', solved: false, contextPct: 10 }); break }
271
+ }
272
+ await sleep(180)
273
+ const s = stateOf(WS)
274
+ const v = s.verdicts[TARGET_A] || {}
275
+ console.log(' require: closed=' + v.closed + ' outcome=' + v.outcome + ' reason=' + String(v.reason || '').slice(0, 60))
276
+ assert(!(v.closed === true && v.outcome === 'true'), '★ S6: `require` still withholds a TRUE conclusion after a defect')
277
+ assert(v.closed !== true || v.outcome === 'undecided', '★ S6: a closure in `require` mode can only be 未定论 here (the gate is not bypassed)')
278
+ clean(WS)
279
+ }
280
+
281
+ section('[S7] a temp worker (no vote rights) records a defect — no abstention marker for a non-voter')
282
+ {
283
+ const { WS, call, fire, h } = await mkScenario('temp', { members: [
284
+ { id: 'acad', kind: 'academician', childId: '', phase: 'active', direction: '', hiredBy: '', term: '', provider: 'spawn', persona: '', error: '', createdAt: 1, dismissedAt: 0, dismissReason: '' },
285
+ { id: 't-1', kind: 'temp', childId: '', phase: 'active', direction: '', hiredBy: 'acad', term: '', provider: 'spawn', persona: '', error: '', createdAt: 2, dismissedAt: 0, dismissReason: '' },
286
+ ] })
287
+ await call('resume'); await sleep(60)
288
+ for (const sp of h.spawns.slice()) { fire(sp.childId, { progress: 'ok', solved: false, contextPct: 10 }); await sleep(40) }
289
+ await call('set maxParallel=2'); await sleep(200)
290
+ const st0 = await call('status')
291
+ const childOf = (id) => { const m = (st0.members || []).find((x) => x.id === id); return m ? String(m.childId || '') : '' }
292
+ const tempChild = (st0.members || []).filter((m) => m.id === 't-1' && m.childId).map((m) => m.childId)
293
+ .concat(h.spawns.map((s) => String(s.childId).slice(0, 12)).filter((c) => c && c !== childOf('acad')))
294
+ // A temp worker has NO vote rights, so the framework never asks it to vote — the honest scenario is
295
+ // an OPINION from a non-voter while a verify is open: it must be recordable, must not be counted,
296
+ // and must not produce an "abstention" claim.
297
+ let acadChild = ''
298
+ const deadline = Date.now() + 3000
299
+ while (!acadChild && Date.now() < deadline) {
300
+ const i = h.wakes.findIndex((w) => (((w.blocks || [])[0] || {}).text || '').indexOf('【求真表决') !== -1)
301
+ if (i === -1) { if (h.wakes.length) { const w = h.wakes.shift(); fire(w.childId, { progress: 'x', solved: false, contextPct: 10 }) } await sleep(25); continue }
302
+ acadChild = h.wakes[i].childId
303
+ const w = h.wakes.splice(i, 1)[0]
304
+ fire(w.childId, { verdict: { target: TARGET_A, verdict: 1, reason: '唯一有表决权者同意' }, progress: 'x', solved: false, contextPct: 10 })
305
+ await sleep(120)
306
+ }
307
+ const stV = await call('status')
308
+ // The temp worker has NO rights: its "vote" must be relayed, never counted — and it must not
309
+ // produce an abstention marker (there was no boolean ballot to abstain from).
310
+ const tcid = tempChild.find((c) => c !== String(acadChild).slice(0, 12)) || (tempChild[0] || '')
311
+ let tempAnswered = false
312
+ if (tcid) {
313
+ fire(tcid, { verdict: { target: TARGET_A, verdict: 0, reason: '临时工认为假(无权)' }, progress: 'x', solved: false, contextPct: 10 })
314
+ tempAnswered = true
315
+ await sleep(120)
316
+ }
317
+ const st = await call('status')
318
+ const s = stateOf(WS)
319
+ const a = ((st.formal || {}).objects || []).find((o) => o.target === TARGET_A) || {}
320
+ const voted = ((stV.verify || {}).voted || [])
321
+ console.log(' tempChild=' + JSON.stringify(tcid) + ' tempAnswered=' + tempAnswered + ' votedAtBallot=' + JSON.stringify(voted) + ' marker=' + JSON.stringify(a.abstainedCount) + ' closed/outcome=' + JSON.stringify([(s.verdicts[TARGET_A] || {}).closed, (s.verdicts[TARGET_A] || {}).outcome]))
322
+ assert(!!tcid && tempAnswered, 'S7: the temp worker could still speak (a non-voter opinion is delivered)')
323
+ assert(!voted.includes('t-1'), '★ S7: the temp worker is never a voter in the verify')
324
+ assert(!a.abstainedCount, '★ S7: no abstention marker for a non-voter (there was no boolean ballot to abstain from)')
325
+ assert(!((s.verdicts[TARGET_A] || {}).closed === true && (s.verdicts[TARGET_A] || {}).outcome === 'false'), '★ S7: the non-voter\'s 0 did not decide the object')
326
+ clean(WS)
327
+ }
328
+
329
+ section('[S8] no cross-round carryover — a later plain 1 still concludes')
330
+ {
331
+ const { WS, call, fire, h } = await mkScenario('carry', {})
332
+ await call('resume'); await sleep(40)
333
+ if (h.spawns.length) { fire(h.spawns[0].childId, { progress: 'ok', solved: false, contextPct: 10 }); await sleep(60) }
334
+ await call('set maxParallel=1'); await sleep(120)
335
+ // round 1: defect + 1 (enforced abstention, no conclusion)
336
+ for (let i = 0; i < h.wakes.length; i++) {
337
+ const t = ((h.wakes[i].blocks || [])[0] || {}).text || ''
338
+ if (t.indexOf('【求真表决') !== -1) { const w = h.wakes.splice(i, 1)[0]; fire(w.childId, { formal: { target: TARGET_A, decision: 'defect', note: '偏离' }, verdict: { target: TARGET_A, verdict: 1, reason: 'r1' }, progress: 'x', solved: false, contextPct: 10 }); break }
339
+ }
340
+ await sleep(150)
341
+ const mid = stateOf(WS).verdicts[TARGET_A] || {}
342
+ // round 2: a plain 1 (no formal field) must be able to conclude
343
+ await call('set maxParallel=1'); await sleep(150)
344
+ let answered2 = false
345
+ const deadline = Date.now() + 2500
346
+ while (!answered2 && Date.now() < deadline) {
347
+ const i = h.wakes.findIndex((w) => (((w.blocks || [])[0] || {}).text || '').indexOf('【求真表决') !== -1)
348
+ if (i === -1) { if (h.wakes.length) { const w = h.wakes.shift(); fire(w.childId, { progress: 'x', solved: false, contextPct: 10 }) } await sleep(25); continue }
349
+ const w = h.wakes.splice(i, 1)[0]
350
+ fire(w.childId, { verdict: { target: TARGET_A, verdict: 1, reason: 'r2 无缺陷' }, progress: 'x', solved: false, contextPct: 10 })
351
+ answered2 = true
352
+ await sleep(120)
353
+ }
354
+ const s = stateOf(WS).verdicts[TARGET_A] || {}
355
+ console.log(' after round1 closed=' + mid.closed + '/marker kept; round2 answered=' + answered2 + ' closed=' + s.closed + ' outcome=' + s.outcome)
356
+ assert(!(mid.closed === true && mid.outcome === 'true'), 'S8: round 1 (defect) did not conclude')
357
+ assert(s.closed === true && s.outcome === 'true', '★ S8: a LATER reply with a plain 1 concludes normally (no cross-round carryover)')
358
+ clean(WS)
359
+ }
360
+
361
+ section('[S9] observability — the marker is on status(), lean_lib and report()')
362
+ {
363
+ const { WS, call, tool, fire, h } = await mkScenario('obs', {})
364
+ await call('resume'); await sleep(40)
365
+ if (h.spawns.length) { fire(h.spawns[0].childId, { progress: 'ok', solved: false, contextPct: 10 }); await sleep(60) }
366
+ await call('set maxParallel=1'); await sleep(120)
367
+ for (let i = 0; i < h.wakes.length; i++) {
368
+ const t = ((h.wakes[i].blocks || [])[0] || {}).text || ''
369
+ if (t.indexOf('【求真表决') !== -1) { const w = h.wakes.splice(i, 1)[0]; fire(w.childId, { formal: { target: TARGET_A, decision: 'defect', note: '偏离' }, verdict: { target: TARGET_A, verdict: 1, reason: 'x' }, progress: 'x', solved: false, contextPct: 10 }); break }
370
+ }
371
+ await sleep(150)
372
+ const st = await call('status')
373
+ const a = ((st.formal || {}).objects || []).find((o) => o.target === TARGET_A) || {}
374
+ const lib = await tool('vibe_v5_lean_lib', { refresh: false })
375
+ const libObj = ((lib.objects || [])[0]) || {}
376
+ const rp = String((await call('report')).report || '')
377
+ const line = (rp.split('\n').find((l) => l.includes('框架强制弃权')) || '').trim()
378
+ // fourth surface: the member-facing archive receipt (dedupe path returns the stored record)
379
+ const arch = await tool('vibe_v5_lean_archive', { kind: 'proof', target: TARGET_A, content: existsSync(join(instRootOf(WS), 'Formal', TARGET_A + '.lean')) ? readFileSync(join(instRootOf(WS), 'Formal', TARGET_A + '.lean'), 'utf8') : 'theorem p_a : True := trivial\n', run: false })
380
+ console.log(' status.fidelity=' + JSON.stringify(a.fidelity && a.fidelity.voteAbstainedByFramework) + ' lib.count=' + JSON.stringify(libObj.abstainedCount) + ' archive.count=' + JSON.stringify(arch.abstainedCount) + ' report=' + line.slice(0, 110))
381
+ assert(!!(a.fidelity && Array.isArray(a.fidelity.voteAbstainedByFramework) && a.fidelity.voteAbstainedByFramework.length === 1), '★ S9: status().formal.objects[].fidelity exposes the marker array')
382
+ assert(libObj.abstainedCount === 1, '★ S9: vibe_v5_lean_lib.objects exposes abstainedCount')
383
+ assert(arch.abstainedCount === 1 && !!(arch.fidelity && arch.fidelity.voteAbstainedByFramework), '★ S9: vibe_v5_lean_archive returns the marker on its receipt')
384
+ assert(/框架强制弃权/.test(line), '★ S9: report() has a 框架强制弃权 line naming the object and the member')
385
+ clean(WS)
386
+ }
387
+
388
+ console.log('')
389
+ console.log('passed=' + passed + ' failed=' + failed)
390
+ if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
391
+ console.log('ALL GREEN')
392
+ process.exit(0)
@@ -0,0 +1,106 @@
1
+ // ============================================================================
2
+ // audit-v5-lean-abstention.mutants.mjs — falsifiability for the L2 abstention audit.
3
+ // Each mutant is a SINGLE-SITE edit; every one must turn a NAMED assertion red in
4
+ // `tests/audit-v5-lean-abstention.mjs` (and, for the gate mutant, also the I8
5
+ // invariant in `tests/audit-prompt-invariants.mjs`).
6
+ // Run: node tests/audit-v5-lean-abstention.mutants.mjs
7
+ // ============================================================================
8
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync } from 'node:fs'
9
+ import { spawnSync } from 'node:child_process'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { tmpdir } from 'node:os'
12
+ import { join, dirname } from 'node:path'
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url))
15
+ const REPO = join(HERE, '..')
16
+ const PRESET = join(REPO, 'vibe-math-v5')
17
+ const AUDIT = join(HERE, 'audit-v5-lean-abstention.mjs')
18
+ const INVARIANTS = process.env.PROMPT_INVARIANTS_SUITE ? String(process.env.PROMPT_INVARIANTS_SUITE) : join(HERE, 'audit-prompt-invariants.mjs')
19
+ const ONLY = process.env.MUTANTS_ONLY ? String(process.env.MUTANTS_ONLY).split(',').map((x) => x.trim()).filter(Boolean) : null
20
+ const REL = 'vibe-math-v5/vibe-math-v5.js'
21
+ const SRC = readFileSync(join(PRESET, 'vibe-math-v5.js'), 'utf8')
22
+
23
+ const MUTANTS = [
24
+ {
25
+ name: 'M1 formalOn() gate removed',
26
+ from: 'if (formalOn() && p.formal) {',
27
+ to: 'if (p.formal) { // MUTANT: the off-mode gate is gone',
28
+ expectAudit: 'S1: a `formal:{…}` reply changes NOTHING observable in off mode',
29
+ // The cross-suite expectation is an INVARIANT ID, not just "something went red": removing the
30
+ // gate must redden `audit-prompt-invariants`'s I8 (guard-load-bearing F3: this field used to be
31
+ // dead — any failing invariant would have been accepted as the claimed red).
32
+ expectInvariant: 'I8',
33
+ },
34
+ {
35
+ name: 'M2 enforcement disabled',
36
+ from: 'const enforced = isVoterHere && defectTargetsThisReply.has(vTarget) && (declared === 0 || declared === 1)',
37
+ to: 'const enforced = false // MUTANT: no enforced abstention',
38
+ expectAudit: 'S2: the same reply cannot conclude the object it declared defective',
39
+ },
40
+ {
41
+ name: 'M3 marker collapses to a single value',
42
+ from: 'fid.voteAbstainedByFramework = list',
43
+ to: 'fid.voteAbstainedByFramework = list[list.length - 1] // MUTANT: single-value marker',
44
+ expectAudit: 'S3: the enforced-abstention marker is an ARRAY of length 2',
45
+ },
46
+ {
47
+ name: 'M4 defect record wipes the marker history',
48
+ from: 'fidelity: Object.assign({}, (prev0 && prev0.fidelity) || {}, { at: now(), by: String(memberId || \'\'), note: why }),',
49
+ to: 'fidelity: { at: now(), by: String(memberId || \'\'), note: why }, // MUTANT: replace, not merge',
50
+ expectAudit: 'S3: the enforced-abstention marker is an ARRAY of length 2',
51
+ },
52
+ ]
53
+
54
+ const runAudit = (plugin) => {
55
+ const r = spawnSync(process.execPath, [AUDIT], { encoding: 'utf8', timeout: 600000, env: Object.assign({}, process.env, { V5_PLUGIN: plugin }) })
56
+ return { code: r.status, out: String(r.stdout || '') + String(r.stderr || '') }
57
+ }
58
+ const reds = (out) => out.split('\n').filter((l) => l.trim().startsWith('FAIL -')).map((l) => l.replace(/^\s*FAIL - /, '').trim())
59
+
60
+ // control: the unmutated audit must be green
61
+ const control = runAudit(join(PRESET, 'vibe-math-v5.js'))
62
+ const controlGreen = control.code === 0 && /passed=\d+ failed=0/.test(control.out)
63
+ console.log('control (unmutated): exit=' + control.code + ' | ' + (control.out.split('\n').find((l) => l.startsWith('passed=')) || '').trim())
64
+ let problems = controlGreen ? 0 : 1
65
+
66
+ for (const m of MUTANTS) {
67
+ if (ONLY && !ONLY.includes(m.name.split(' ')[0])) continue
68
+ if (!SRC.includes(m.from)) { console.log(m.name + ': ANCHOR MISSING'); problems += 1; continue }
69
+ const dir = join(tmpdir(), 'v5-abs-mut-' + m.name.split(' ')[0])
70
+ rmSync(dir, { recursive: true, force: true })
71
+ mkdirSync(dir, { recursive: true })
72
+ for (const f of ['math-computation.js', 'math-engines.js']) copyFileSync(join(PRESET, f), join(dir, f))
73
+ writeFileSync(join(dir, 'vibe-math-v5.js'), SRC.replace(m.from, m.to), 'utf8')
74
+ const r = runAudit(join(dir, 'vibe-math-v5.js'))
75
+ const list = reds(r.out)
76
+ const hit = list.some((x) => x.indexOf(m.expectAudit) !== -1)
77
+ const ok = r.code !== 0 && hit
78
+ let invariantNote = ''
79
+ if (m.expectInvariant) {
80
+ const ir = spawnSync(process.execPath, [INVARIANTS, '--json'], {
81
+ cwd: REPO, encoding: 'utf8', timeout: 600000,
82
+ env: Object.assign({}, process.env, { PROMPT_INVARIANTS_MUTATE: JSON.stringify([REL, m.from, m.to]) }),
83
+ })
84
+ let parsed = null
85
+ try { parsed = JSON.parse(ir.stdout) } catch (e) { /* reported below */ }
86
+ // Assert the EXPECTED invariant, not merely "something failed": the JSON must carry a
87
+ // `failures` array and `expectInvariant` must appear in it. A missing array or a different
88
+ // invariant failing is a harness failure, loudly.
89
+ const failures = parsed && Array.isArray(parsed.failures) ? parsed.failures : null
90
+ const named = !!failures && failures.some((f) => String(f).includes(m.expectInvariant))
91
+ const invariantRed = ir.status !== 0 && parsed && parsed.failed > 0 && named
92
+ invariantNote = ' | audit-prompt-invariants: ' + (invariantRed
93
+ ? 'RED on ' + m.expectInvariant + ' (failed=' + parsed.failed + ': ' + String(failures[0]).slice(0, 70) + ')'
94
+ : (failures === null
95
+ ? 'NO failures[] ARRAY in --json output (cannot verify ' + m.expectInvariant + ')'
96
+ : 'expected ' + m.expectInvariant + ' but got ' + JSON.stringify(failures.map((f) => String(f).slice(0, 40)).slice(0, 3))))
97
+ if (!invariantRed) problems += 1
98
+ }
99
+ if (!ok) problems += 1
100
+ console.log(m.name.padEnd(42) + ': audit exit=' + r.code + (ok ? ' RED (as required)' : ' NOT RED') + invariantNote)
101
+ for (const x of list.slice(0, 2)) console.log(' → ' + x.slice(0, 140))
102
+ rmSync(dir, { recursive: true, force: true })
103
+ }
104
+ console.log('')
105
+ console.log(problems === 0 ? 'ALL MUTANTS RED AS REQUIRED' : problems + ' problem(s)')
106
+ process.exit(problems === 0 ? 0 : 1)
@@ -0,0 +1,88 @@
1
+ // ============================================================================
2
+ // audit-v5-prompt-duplication.mjs — pins the invariant that the v5 toolkit prompt
3
+ // text exists TWICE (two `agent-instructions` entries: office + members) and that
4
+ // both copies are IDENTICAL after normalisation, plus that the three review-8
5
+ // wording fixes (P1/P2/P3) landed in BOTH copies.
6
+ //
7
+ // Owned by the v5 preset owner (proposed to the shared-layer owner for an index row).
8
+ // Falsifiability: `node tests/audit-v5-prompt-duplication.mjs --self-probe` mutates ONE
9
+ // copy in memory (single site) and requires the named check to go red.
10
+ // Run: node tests/audit-v5-prompt-duplication.mjs [--json] [--self-probe]
11
+ // ============================================================================
12
+ import { readFileSync, existsSync } from 'node:fs'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { dirname, join } from 'node:path'
15
+
16
+ const HERE = dirname(fileURLToPath(import.meta.url))
17
+ const REL = 'vibe-math-v5/agent.cordis.yml'
18
+ const YML = process.env.V5_PROMPT_YML ? String(process.env.V5_PROMPT_YML) : join(HERE, '..', REL)
19
+ // In-memory single-site mutation for --self-probe: `[from, to]` applied to the FIRST copy only.
20
+ const MUT = process.env.V5_PROMPT_MUTATE ? JSON.parse(process.env.V5_PROMPT_MUTATE) : null
21
+
22
+ const failures = []
23
+ let passed = 0
24
+ const check = (cond, msg) => { if (cond) { passed += 1 } else { failures.push(msg) } }
25
+ const norm = (s) => s.replace(/\s+/g, ' ').replace(/\{\{cwd\}\}|\{\{model\}\}/g, '<T>').trim()
26
+
27
+ function loadBlocks() {
28
+ if (!existsSync(YML)) return null
29
+ let text = readFileSync(YML, 'utf8')
30
+ if (MUT && Array.isArray(MUT) && MUT.length === 2) {
31
+ const i = text.indexOf(MUT[0])
32
+ if (i !== -1) text = text.slice(0, i) + MUT[1] + text.slice(i + MUT[0].length) // FIRST copy only
33
+ }
34
+ const lines = text.split('\n')
35
+ const starts = []
36
+ lines.forEach((l, i) => { if (l.indexOf('This session includes "Vibe Math V5"') !== -1) starts.push(i) })
37
+ // The prompt text is NOT ended by the `/v5 slash command …` line (that line sits in the MIDDLE:
38
+ // the LEAN FORMAL VERIFICATION and TRUST RULE sections follow it). End each copy at its last
39
+ // sentence instead, so the invariant covers the whole text (a truncated comparison would miss
40
+ // exactly the sections that drift most easily).
41
+ const endAt = (s) => {
42
+ for (let i = s; i < lines.length; i++) if (/Never present an unverified claim as established/.test(lines[i])) return i
43
+ for (let i = s; i < lines.length; i++) if (/\/v5 slash command mirrors these/.test(lines[i])) return i
44
+ return lines.length - 1
45
+ }
46
+ return starts.map((s) => ({ from: s + 1, to: endAt(s) + 1, text: norm(lines.slice(s, endAt(s) + 1).join(' ')) }))
47
+ }
48
+
49
+ const blocks = loadBlocks()
50
+ check(!!blocks, 'agent.cordis.yml is readable at ' + YML)
51
+ if (blocks) {
52
+ check(blocks.length === 2, 'exactly TWO toolkit prompt copies exist (got ' + blocks.length + ')')
53
+ if (blocks.length === 2) {
54
+ check(blocks[0].text === blocks[1].text,
55
+ 'the two prompt copies are IDENTICAL after normalisation (L' + blocks[0].from + '-' + blocks[0].to + ' vs L' + blocks[1].from + '-' + blocks[1].to
56
+ + '; lengths ' + blocks[0].text.length + '/' + blocks[1].text.length + ') — a single-site edit in one copy must be mirrored in the other')
57
+ // The review-8 wording fixes must be present in BOTH copies.
58
+ const NEEDED = [
59
+ ['P1 exception', /when a written flow requires it/],
60
+ ['P1 paraphrase clause', /PARAPHRASE of the institute/],
61
+ ['P2 disclosure', /YOUR OWN boolean vote on that object in the SAME reply is counted as an ABSTENTION/],
62
+ ['P2 scope note', /Only a voter's ballot is affected/],
63
+ ['P3 base statement', /resolved against the SESSION WORKING DIRECTORY/],
64
+ ['P3 short-form equivalence', /names the SAME file/],
65
+ ]
66
+ for (const [name, re] of NEEDED) {
67
+ blocks.forEach((b, i) => check(re.test(b.text), name + ' present in copy ' + (i + 1) + ' (L' + b.from + '-' + b.to + ')'))
68
+ }
69
+ // the disclosure must NOT contradict the enforced behaviour
70
+ check(/ABSTENTION/.test(blocks[1].text) && /retracts the proof/.test(blocks[1].text), 'the disclosure explains that the defect still retracts the proof')
71
+ }
72
+ }
73
+
74
+ if (process.argv.includes('--json')) {
75
+ console.log(JSON.stringify({ passed, failed: failures.length, failures }))
76
+ } else {
77
+ console.log('v5 prompt duplication audit — ' + REL)
78
+ for (const f of failures) console.log(' FAIL - ' + f)
79
+ console.log((failures.length ? 'FAILED' : 'ALL GREEN') + ' — passed=' + passed + ' failed=' + failures.length)
80
+ if (process.argv.includes('--self-probe')) {
81
+ console.log('\nself-probe: one copy mutated in memory ⇒ the expected check must go red')
82
+ if (!MUT) {
83
+ const needle = 'There is no forced closure.'
84
+ console.log(' (no V5_PROMPT_MUTATE set; the suite driver runs this with a single-site mutation)')
85
+ }
86
+ }
87
+ }
88
+ process.exit(failures.length ? 1 : 0)
@@ -0,0 +1,34 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ // Falsifiability for tests/audit-v5-prompt-duplication.mjs: a SINGLE-SITE edit in ONE prompt copy
7
+ // must turn the equality check (and the wording checks) NAMED-red, while the unmutated run is green.
8
+ const SUITE = 'D:/wd/vibemath开发/Vibe-Mathematics/tests/audit-v5-prompt-duplication.mjs'
9
+ const YML = 'D:/wd/vibemath开发/Vibe-Mathematics/vibe-math-v5/agent.cordis.yml'
10
+ const run = (env) => {
11
+ const r = spawnSync(process.execPath, [SUITE, '--json'], { encoding: 'utf8', timeout: 120000, env: Object.assign({}, process.env, env || {}) })
12
+ let parsed = null
13
+ try { parsed = JSON.parse(r.stdout) } catch (e) { /* reported below */ }
14
+ return { code: r.status, parsed, out: String(r.stdout || '') + String(r.stderr || '') }
15
+ }
16
+ const control = run({})
17
+ const MUTS = [
18
+ ['single-copy wording drift', ['YOUR OWN boolean vote on that object in the SAME reply is counted as an\n ABSTENTION', 'YOUR OWN boolean vote on that object in the SAME reply is NOT counted at all']],
19
+ ['single-copy P1 removal', ['or when a written\n flow requires it', 'or never']],
20
+ ]
21
+ let problems = 0
22
+ console.log('control: exit=' + control.code + ' | ' + JSON.stringify(control.parsed && { passed: control.parsed.passed, failed: control.parsed.failed }))
23
+ if (control.code !== 0) problems += 1
24
+ for (const [name, mut] of MUTS) {
25
+ const r = run({ V5_PROMPT_MUTATE: JSON.stringify(mut) })
26
+ const failed = r.parsed ? r.parsed.failed : -1
27
+ const named = !!(r.parsed && (r.parsed.failures || []).some((f) => /IDENTICAL after normalisation|ABSTENTION|written flow requires it/.test(f)))
28
+ const ok = r.code !== 0 && failed > 0 && named
29
+ if (!ok) problems += 1
30
+ console.log(name.padEnd(28) + ': exit=' + r.code + ' failed=' + failed + (ok ? ' RED (as required)' : ' NOT RED'))
31
+ for (const f of ((r.parsed && r.parsed.failures) || []).slice(0, 2)) console.log(' → ' + String(f).slice(0, 140))
32
+ }
33
+ console.log(problems === 0 ? '\nPROMPT-DUPLICATION MUTANTS: every mutation turns a NAMED check red' : '\nPROMPT-DUPLICATION MUTANTS: ' + problems + ' problem(s)')
34
+ process.exit(problems === 0 ? 0 : 1)