dsh-vibe-math 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.en.md +14 -14
  2. package/README.md +15 -14
  3. package/cordis.patch.yml +33 -10
  4. package/docs/AUDIT-CHECKLIST.md +25 -6
  5. package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
  6. package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
  7. package/docs/test-timing.md +73 -40
  8. package/installer.js +225 -45
  9. package/package.json +3 -2
  10. package/preset-declaration.js +38 -7
  11. package/prompt-corpus-persona/persona-corpus.json +9 -9
  12. package/prompt-corpus-persona/persona-corpus.md +67 -21
  13. package/prompt-corpus-v2/formal-verify-v2.json +116 -92
  14. package/prompt-corpus-v2/formal-verify-v2.md +240 -93
  15. package/prompt-corpus-v3/formal-verify-v3.json +81 -81
  16. package/prompt-corpus-v3/formal-verify-v3.md +288 -1111
  17. package/prompt-corpus-v4/formal-verify-v4.json +38 -8
  18. package/prompt-corpus-v4/formal-verify-v4.md +157 -25
  19. package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
  20. package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
  21. package/tests/audit-installer-compat.test.mjs +70 -4
  22. package/tests/audit-installer-policy.test.mjs +229 -4
  23. package/tests/audit-persona-sensitivity.mjs +11 -4
  24. package/tests/audit-persona-surface.test.mjs +5 -1
  25. package/tests/audit-v5-sensitivity.mjs +47 -6
  26. package/tests/e2e-v5-round2.test.mjs +354 -0
  27. package/tests/formal-verify-v2.test.mjs +54 -2
  28. package/tests/formal-verify-v3.test.mjs +63 -2
  29. package/tests/formal-verify-v4.test.mjs +120 -4
  30. package/tests/run-tests.mjs +104 -23
  31. package/vibe-math-v2/agent.cordis.yml +6 -0
  32. package/vibe-math-v2/vibe-math-v2.js +503 -99
  33. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
  34. package/vibe-math-v3/agent.cordis.yml +26 -10
  35. package/vibe-math-v3/vibe-math-v3.js +555 -175
  36. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
  37. package/vibe-math-v4/agent.cordis.yml +26 -6
  38. package/vibe-math-v4/vibe-math-v4.js +404 -51
  39. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
  40. package/vibe-math-v5/agent.cordis.yml +8 -4
  41. package/vibe-math-v5/vibe-math-v5.js +513 -105
  42. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +73 -40
@@ -72,11 +72,43 @@ const corpus = []
72
72
  // same class of bug is recorded in AUDIT-CHECKLIST §2.4 as a real accident): the random plan id in
73
73
  // the label, epoch-millisecond `at` timestamps, child ids, and the free-slot count. They are run
74
74
  // metadata, not the text under review, so they are normalised to placeholders.
75
- const scrub = (s) => String(s == null ? '' : s)
75
+ //
76
+ // The brief is not only run metadata, it is a LIVE SNAPSHOT of mutable scheduler state
77
+ // (`buildBrief()` reads `agentRegistry`, `activityLog`, the verify candidates and the free-slot
78
+ // count at the instant the planner is spawned). This suite drives the plugin from OUTSIDE, and the
79
+ // plugin's own scheduler keeps running concurrently: `vibe_math_start` fires `scheduleTick()`
80
+ // without awaiting it, and the plugin's 1s tick timer calls `scheduleTick()` on its own schedule, so
81
+ // a background tick can register the explorer (or append its activity event, or close a verify)
82
+ // BETWEEN the forced planner's snapshot and the next one. Two runs then legitimately disagree about
83
+ // `active_agents` (the explorer is in flight or it is not), about `recent_events` (the last-eight
84
+ // window and its order), and about every other instant-dependent field of the brief.
85
+ //
86
+ // A byte-stable corpus therefore cannot carry that snapshot's values — the disagreement is a
87
+ // property of the INSTANT, not of the prompt text a human must review (this is exactly the "语料
88
+ // 每次运行都变" accident §2.4 records). What the corpus carries is the brief's CONTRACT: the same
89
+ // keys in the same order, with the instant-dependent values canonically placeholdered and the
90
+ // stable configuration scalars kept. The prompt TEXT under review — the persona, the ACTION
91
+ // VOCABULARY, and the HARD RULES that name `brief.problems[].running_solver_dirs`,
92
+ // `brief.active_agents` and `brief.free_slots` — is untouched.
93
+ const SNAPSHOT_KEYS = ['problems', 'verify_candidates', 'active_agents', 'methods', 'pending_inventions', 'last_plan', 'recent_events']
94
+ /** Replace every instant-dependent collection of the planner's state brief with a canonical
95
+ * placeholder, keeping the key set, the key order and the configuration scalars. Only called on a
96
+ * valid-JSON brief (`buildBrief` is serialised with JSON.stringify(brief, null, 2)); anything that
97
+ * does not match the exact planner framing is returned unchanged, so the suite can never silently
98
+ * mangle an unrelated prompt. */
99
+ const canonBrief = (s) => {
100
+ const m = s.match(/(CURRENT STATE BRIEF \(JSON\):\n)(\{[\s\S]*?\n\})(\n\nACTION VOCABULARY)/)
101
+ if (!m) return s
102
+ let brief
103
+ try { brief = JSON.parse(m[2]) } catch (e) { return s }
104
+ for (const k of SNAPSHOT_KEYS) if (Object.prototype.hasOwnProperty.call(brief, k)) brief[k] = '<' + k.toUpperCase() + '>'
105
+ return s.slice(0, m.index) + m[1] + JSON.stringify(brief, null, 2) + m[3] + s.slice(m.index + m[0].length)
106
+ }
107
+ const scrub = (s) => canonBrief(String(s == null ? '' : s)
76
108
  .split(VIBE).join('<VIBEMATH>')
77
109
  .split(VIBE.replace(/\\/g, '/')).join('<VIBEMATH>')
78
110
  .split(WS).join('<WS>')
79
- .split(WS.replace(/\\/g, '/')).join('<WS>')
111
+ .split(WS.replace(/\\/g, '/')).join('<WS>'))
80
112
  .replace(/plan-[0-9a-f]{8}/g, 'plan-<ID>')
81
113
  .replace(/("at"\s*:\s*)\d{10,16}/g, '$1"<TIME>"')
82
114
  .replace(/("childId"\s*:\s*")c\d+(")/g, '$1<CHILD>$2')
@@ -1246,6 +1278,35 @@ section('10 the captured prompt corpus is written for human review')
1246
1278
  assert(volatile.length === 0, '★ no captured prompt/label keeps volatile run metadata (random plan id / epoch timestamps / child ids / slot count) — the corpus is byte-deterministic: ' + volatile.map((b) => b.label).join(','))
1247
1279
  assert(corpus.some((c) => /"free_slots": <SLOTS>/.test(c.prompt)) && corpus.some((c) => /plan-<ID>/.test(c.label)),
1248
1280
  '★ and the normalisation actually fired (a planner brief and its plan id were captured)')
1281
+ // The brief's instant-dependent snapshot is placeholdered, but its CONTRACT (key set + order) and
1282
+ // the stable configuration scalars stay, and the instruction text that references the brief is
1283
+ // untouched — so the human reviewer still sees the real planner prompt, not a blank.
1284
+ const briefEntries = corpus.filter((c) => /CURRENT STATE BRIEF \(JSON\):/.test(c.prompt))
1285
+ assert(briefEntries.length >= 3 && briefEntries.every((c) => SNAPSHOT_KEYS.every((k) => c.prompt.indexOf('"' + k + '": "<' + k.toUpperCase() + '>"') !== -1)),
1286
+ '★ every captured planner brief keeps all ' + SNAPSHOT_KEYS.length + ' contract keys with canonically placeholdered snapshot values (' + briefEntries.length + ' briefs)')
1287
+ assert(briefEntries.every((c) => /"horizon": \d+/.test(c.prompt) && /"free_slots": <SLOTS>/.test(c.prompt) && /"maxParallelThreshold": \d+/.test(c.prompt)),
1288
+ '★ the brief keeps its real configuration scalars (horizon / free_slots / maxParallelThreshold), not just placeholders')
1289
+ assert(briefEntries.every((c) => /brief\.problems\[\]\.running_solver_dirs and brief\.active_agents/.test(c.prompt) && /ACTION VOCABULARY/.test(c.prompt)),
1290
+ '★ and the planner INSTRUCTION text under review (ACTION VOCABULARY + the HARD RULES naming brief fields) is verbatim')
1291
+ {
1292
+ // Direct witness of the race the normaliser exists for: the two shapes a background tick can
1293
+ // produce (explorer registered before vs. after the forced planner read the brief, activity log
1294
+ // one entry longer, free slots one lower) must normalise to ONE byte-identical text. Without
1295
+ // this the corpus could only be checked by hoping the race fires.
1296
+ const briefText = (agents, events, slots, horizon) => 'PERSONA\nCURRENT STATE BRIEF (JSON):\n' + JSON.stringify({
1297
+ at: 1759200000000, horizon: horizon, free_slots: slots, maxParallelThreshold: 64,
1298
+ problems: [{ id: 'qE', 状态: '求解中', 方向数: agents.length, 活跃方向: agents.length ? ['d1'] : [], running_solver_dirs: [], 最高存活率: agents.length ? 0.7 : null, 解法数: 0 }],
1299
+ verify_candidates: agents.length ? [{ rId: 'r-qE', kind: 'problem', target: 'qE', prob: 0.5, priority: 1 }] : [],
1300
+ active_agents: agents, methods: [], pending_inventions: 0, last_plan: null, recent_events: events,
1301
+ }, null, 2) + '\n\nACTION VOCABULARY:\n- x'
1302
+ const withExplorer = briefText([{ childId: 'c7', role: 'explorer', target: 'qE', direction: '', round: '' }],
1303
+ [{ at: 1759200000001, event: 'start', detail: 'scheduler started' }, { at: 1759200000002, event: 'explorer', detail: 'problem qE explorer spawned (plan)' }], 63, 5)
1304
+ const withoutExplorer = briefText([], [{ at: 1759200000009, event: 'plan', detail: 'planner plan-abcdef12 called with 1 problem(s)' }], 64, 5)
1305
+ assert(scrub(withExplorer) === scrub(withoutExplorer),
1306
+ '★ the planner-brief normaliser maps the two racing snapshots (explorer present/absent, different activity order and free slots) to ONE byte-identical text')
1307
+ assert(scrub(withExplorer) !== withExplorer && /"active_agents": "<ACTIVE_AGENTS>"/.test(scrub(withExplorer)),
1308
+ '★ and it really rewrote the snapshot (the test above cannot pass just because nothing was normalised)')
1309
+ }
1249
1310
  }
1250
1311
 
1251
1312
  console.log('')
@@ -965,6 +965,11 @@ const K = await establish()
965
965
  const scrub = (s) => String(s == null ? '' : s)
966
966
  .split(vibe).join('<VIBEMATH>').split(vibe.replace(/\\/g, '/')).join('<VIBEMATH>')
967
967
  .split(K.WS).join('<WS>').split(K.WS.replace(/\\/g, '/')).join('<WS>')
968
+ // `propose_task` mints `t-<8 hex>` from Math.random (vibe-math-v4.js:1317 + shortId :2582).
969
+ // The task-frame entry embeds that id, so without this the SHIPPED corpus changed on every
970
+ // run (AUDIT-CHECKLIST §2.4: two consecutive runs must hash identically) and `git status`
971
+ // always showed prompt-corpus-v4/* as modified. Normalise it like the machine paths.
972
+ .replace(/\bt-[0-9a-f]{8}\b/g, '<TASKID>')
968
973
  const corpus = []
969
974
  const add = (kind, label, prompt) => corpus.push({ kind, label, prompt: scrub(prompt) })
970
975
  // (a) off: a TRUE no-op must be visible in the corpus, not merely asserted
@@ -1008,6 +1013,91 @@ const K = await establish()
1008
1013
  // (h) the tool hints are injected text too (docs §6 hard requirement 1)
1009
1014
  add('hint', 'lean_run hint (green)', (await K.callTool('vibe_v4_lean_run', { file: 'Formal/p-corpus-passed.lean' }, k1)).hint)
1010
1015
  add('hint', 'lean_lib hint', (await K.callTool('vibe_v4_lean_lib', {}, k1)).hint)
1016
+ // (i) ── THE NON-FORMAL SURFACES (audit M6/F15) ─────────────────────────────────────────────
1017
+ // The corpus used to cover ONLY Lean/formal variants, so the meeting prompt — this preset's ONLY
1018
+ // group-chat / task-allocation / stop-vote carrier — and every message frame had NO reviewable
1019
+ // artefact at all: a defect in `meetingPrompt` (e.g. its stop-vote contract) could not be read by
1020
+ // a human reviewer. These entries close that gap. `st` is passed explicitly so the capture does
1021
+ // not depend on the scheduler's state, and both the meeting's two perspectives (first speaker with
1022
+ // no prior speech / a later speaker seeing a relayed contribution) are captured.
1023
+ {
1024
+ const st = {
1025
+ id: 'mt-corpus', agenda: '分工与是否需要验证', type: 'general', targetId: null, round: 0, asked: [], order: ['r-1', 'r-2'],
1026
+ inputs: { 'r-1': { input: '我建议先验证 p-corpus,并认领引理 A 的整理。', voteSolved: false, propose_verify: 'p-corpus', propose_task: '整理引理 A', task_desc: '把已有引理写成可复用形式', claim_task: null } },
1027
+ transcript: [], at: Date.now(), lastInputAt: Date.now(),
1028
+ }
1029
+ add('meeting', 'meeting/first speaker (no prior speech)', await K.prompts('meeting', 'r-2', { meeting: { agenda: st.agenda, type: st.type, inputs: {}, order: st.order } }))
1030
+ add('meeting', 'meeting/later speaker (others relayed)', await K.prompts('meeting', 'r-2', { meeting: st }))
1031
+ }
1032
+ // (i) ── the private-message frame ──────────────────────────────────────────────────────────────
1033
+ // A resident reads a message in TWO shapes, and both must be reviewable:
1034
+ // · `[MESSAGE from <sender>] <content>` — the frame `deliverNextMailbox` prepends when it wakes an
1035
+ // idle recipient with a QUEUED message (captured here from a REAL delivery, not re-typed);
1036
+ // · `[<sender>] <content>` — the inbox line every prompt's "New items:" block renders.
1037
+ // r-1 is put in flight first (a message to an IDLE resident is delivered immediately as its own
1038
+ // wake instead of queueing), so the second message is queued and the prompt shows the inbox line.
1039
+ {
1040
+ const before = K.followups.length
1041
+ await K.callTool('vibe_v4_message', { to: 'r-1', content: '先做一轮常规工作。' })
1042
+ for (let i = 0; i < 200 && K.followups.length === before; i++) await sleep(10)
1043
+ const inFlight = K.followups[K.followups.length - 1]
1044
+ assert(!!inFlight, 'corpus: r-1 is in flight so the next message is QUEUED (mailbox frame prerequisite)')
1045
+ await K.callTool('vibe_v4_message', { to: 'r-1', content: '请优先核对引理 B 的假设条件。' })
1046
+ add('work', 'normal + queued inbox line ([<sender>] content)', await K.prompts('normal', 'r-1'))
1047
+ K.fireEnd(inFlight.childId, { summary: '推进。', solved: false, input: '我对引理 B 的假设有异议:需要 n≥1。', contextPct: 20 })
1048
+ await sleep(60)
1049
+ }
1050
+ // the REAL delivery frames. A message that arrives while its recipient is idle is delivered as a
1051
+ // wake whose body is `<ordinary round>\n\n[MESSAGE from <sender>]\n<content>`, and a message that
1052
+ // was relayed by a teammate (`relayToGroup` posts it with the sender's own id and the `[群聊]`
1053
+ // prefix — the exact same `postMessage` path the human messenger uses) renders as `[群聊] …`.
1054
+ // Each frame is captured from a REAL delivery on its own host, so the corpus text is generated by
1055
+ // the code under review rather than re-typed here.
1056
+ const deliveredFrame = async (content, sender) => {
1057
+ const L = await establish()
1058
+ await L.callTool('vibe_v4_set', { activityTimeoutMs: 600000 }) // keep the heartbeat out of the way
1059
+ const before = L.followups.length
1060
+ await (sender === 'facilitator'
1061
+ ? L.callTool('vibe_v4_message', { to: 'r-1', content })
1062
+ : L.callTool('vibe_v4_send_message', { to: 'r-1', content }, L.resAgent(L.childOf(sender))))
1063
+ let hit = null
1064
+ for (let i = 0; i < 300 && !hit; i++) { hit = L.followups.slice(before).find((f) => /\[(NEW )?MESSAGE from /.test(promptOf(f))) || null; if (!hit) await sleep(10) }
1065
+ assert(!!hit, 'corpus: a real delivery wake carrying "' + content.slice(0, 16) + '…" was produced (saw ' + L.followups.slice(before).map((f) => promptOf(f).slice(-90).replace(/\n/g, ' ')).join(' | ') + ')')
1066
+ if (hit) L.fireEnd(hit.childId, { summary: '收到。', solved: false, contextPct: 20 })
1067
+ await sleep(30)
1068
+ return hit ? promptOf(hit) : ''
1069
+ }
1070
+ add('work', 'normal + delivered message frame ([NEW MESSAGE from ...])', await deliveredFrame('请优先核对引理 B 的假设条件。', 'facilitator'))
1071
+ // the group-chat frame: the recipient's inbox line for a message RELAYED by a teammate
1072
+ // (`relayToGroup` pushes `[群聊] <text>` into every other resident's mailbox with the speaker's own
1073
+ // id, and the next delivery renders `[<sender>] [群聊] <text>` — the same `postMessage` path, run
1074
+ // with the sender the resident actually sees). Deterministic on the same host: the speaker's own
1075
+ // end handler is what triggers the relay, and reading the recipient's next prompt shows it.
1076
+ {
1077
+ const L = await establish()
1078
+ await L.callTool('vibe_v4_set', { activityTimeoutMs: 600000 })
1079
+ const before = L.followups.length
1080
+ await L.callTool('vibe_v4_message', { to: 'r-2', content: '先做一轮常规工作。' })
1081
+ for (let i = 0; i < 300 && L.followups.length === before; i++) await sleep(10)
1082
+ const speaker = L.followups[L.followups.length - 1]
1083
+ assert(!!speaker, 'corpus: the relaying resident is in flight')
1084
+ const mark = L.followups.length
1085
+ L.fireEnd(speaker.childId, { summary: '推进。', solved: false, input: '我对引理 B 的假设有异议:需要 n≥1。', contextPct: 20 })
1086
+ let relay = null
1087
+ for (let i = 0; i < 300 && !relay; i++) { relay = L.followups.slice(mark).find((f) => /群聊/.test(promptOf(f))) || null; if (!relay) await sleep(10) }
1088
+ assert(!!relay, 'corpus: the relayed group-chat message was really delivered (saw ' + L.followups.slice(mark).map((f) => promptOf(f).slice(-90).replace(/\n/g, ' ')).join(' | ') + ')')
1089
+ add('work', 'normal + group-chat frame ([群聊])', relay ? promptOf(relay) : '')
1090
+ if (relay) L.fireEnd(relay.childId, { summary: '收到。', solved: false, contextPct: 20 })
1091
+ await sleep(30)
1092
+ }
1093
+ // the task-claim frame. The task is claimed while its claimer is idle, so the claim wake carries
1094
+ // `[YOU CLAIMED TASK …]`; the corpus entry is the ordinary round plus that exact frame.
1095
+ {
1096
+ const pt = await K.callTool('vibe_v4_propose_task', { title: '核对引理 B 的假设', description: '确认 n≥1 是否必要' })
1097
+ assert(pt.ok === true && !!pt.id, 'corpus: a task was proposed for the task-frame entry')
1098
+ const claimed = await K.callTool('vibe_v4_claim_task', { id: pt.id }, k1)
1099
+ add('work', 'task frame ([YOU CLAIMED TASK ...])', claimed.ok === true ? (await K.prompts('normal', 'r-1')) + '\n\n[YOU CLAIMED TASK ' + pt.id + '] 核对引理 B 的假设 — 确认 n≥1 是否必要' : '')
1100
+ }
1011
1101
 
1012
1102
  // The corpus is REGENERATED on every run. Dumping it while V4_PLUGIN points at a MUTATED copy
1013
1103
  // would let a sensitivity probe overwrite the SHIPPED corpus with mutated text (the probe's job
@@ -1018,13 +1108,16 @@ const K = await establish()
1018
1108
  if (writeCorpus) {
1019
1109
  mkdirSync(CORPUS_DIR, { recursive: true })
1020
1110
  writeFileSync(join(CORPUS_DIR, 'formal-verify-v4.json'), JSON.stringify({ entries: corpus }, null, 2), 'utf8')
1021
- const md = ['# V4 形式化验证交互语料(prompt corpus)', '',
1022
- '> 由 `formal-verify-v4.test.mjs` 落盘:非 `off` 模式下常驻**真正会读到**的 Lean 提示词原文',
1111
+ const md = ['# V4 交互语料(prompt corpus)', '',
1112
+ '> 由 `formal-verify-v4.test.mjs` 落盘:常驻**真正会读到**的提示词原文',
1023
1113
  '> (`vibe_v4_prompts` 的只读回显 + 一条真实投递的工作轮 + 工具 `hint`)。',
1024
- '> 工作区路径归一化为 `<WS>`,VibeMath 根归一化为 `<VIBEMATH>`:确定、可 diff、不含任何本机路径。', '',
1114
+ '> 工作区路径归一化为 `<WS>`,VibeMath 根归一化为 `<VIBEMATH>`,随机任务号归一化为',
1115
+ '> `<TASKID>`(`propose_task` 的 `t-<8 位 hex>` 来自 `Math.random`):确定、可 diff、不含任何本机路径或随机数。', '',
1025
1116
  '> 覆盖:`off`(无 Lean 文本)、`encourage`、**`require`**、对象 `passed` 后的**忠实性分支**',
1026
1117
  '> (`encourage` / `require` 两种措辞各一份:只有 `require` 会声称"不定论")、',
1027
- '> `blocked` 分支、平时工作轮的「顺手形式化」,以及回执契约里的 `formal` 字段。', '']
1118
+ '> `blocked` 分支、平时工作轮的「顺手形式化」,以及回执契约里的 `formal` 字段;',
1119
+ '> **以及非形式化的交互面**:会议提示(首位发言者 / 已看到他人发言者各一份,含停止表决契约)、',
1120
+ '> 私信框头 `[MESSAGE from …]`、群聊框头 `[群聊]`、认领任务框头 `[YOU CLAIMED TASK …]`。', '']
1028
1121
  for (let i = 0; i < corpus.length; i++) {
1029
1122
  const c = corpus[i]
1030
1123
  md.push('## [' + i + '] ' + c.kind + ' · ' + c.label)
@@ -1049,8 +1142,31 @@ const K = await establish()
1049
1142
  assert(/【顺手形式化(强制)】/.test(byLabel('require/normal').prompt), 'the corpus carries the ordinary work-round line')
1050
1143
  assert(/"decision":"used\|blocked\|defect"/.test(byLabel('formal reply contract (voting prompt)').prompt), '★ the corpus carries the `formal` reply contract line with decision=defect')
1051
1144
  assert(/【顺手形式化/.test(byLabel('require/real work wake').prompt), 'the corpus also keeps a prompt the framework REALLY delivered')
1145
+ // ---- the non-formal interaction surfaces (audit M6 / F15) --------------------------------
1146
+ // The corpus used to be Lean-only, so `meetingPrompt` (the ONLY group-chat / allocation /
1147
+ // stop-vote carrier this preset has) had no reviewable artefact. These assertions pin both the
1148
+ // coverage and the CONTENT of the stop-vote contract that `finalizeMeeting` now enforces.
1149
+ {
1150
+ const first = byLabel('meeting/first speaker (no prior speech)')
1151
+ const later = byLabel('meeting/later speaker (others relayed)')
1152
+ assert(!!first && !!later, '★ the corpus carries the MEETING prompt (two perspectives: no prior speech / others already relayed)')
1153
+ assert(/meeting is in progress/.test(first.prompt) && /meeting is in progress/.test(later.prompt), 'the meeting entries are built by the real meeting prompt builder')
1154
+ assert(/目前还没人发言/.test(first.prompt), 'the first-speaker entry shows the "nobody has spoken yet" branch')
1155
+ assert(/已有发言(他人 input,已转发给你)/.test(later.prompt) && /我建议先验证 p-corpus/.test(later.prompt), '★ the later-speaker entry carries a teammate contribution VERBATIM (the group-chat relay is reviewable)')
1156
+ assert(/\[r-1\]/.test(later.prompt) && !/\[r-2\]/.test(later.prompt), 'the relayed-speech list excludes the reader and names the real sender id')
1157
+ assert(/voteSolved/.test(first.prompt) && /停止表决必须是绝对票/.test(first.prompt), '★ the meeting prompt states the stop-vote contract the code enforces (every speaker must answer true)')
1158
+ assert(!!byLabel('normal + queued inbox line ([<sender>] content)') && /\[facilitator(框架\/人类信使/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt) && /请优先核对引理 B 的假设条件。/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt), '★ the corpus carries the inbox line that names the real sender of a queued message')
1159
+ assert(/\[(NEW )?MESSAGE from facilitator\]/.test(byLabel('normal + delivered message frame ([NEW MESSAGE from ...])').prompt), '★ the corpus carries the [NEW MESSAGE from …] delivery frame, captured from a REAL delivery')
1160
+ assert(/\[群聊\] 我对引理 B 的假设有异议/.test(byLabel('normal + group-chat frame ([群聊])').prompt), '★ the corpus carries the group-chat frame relayed by a teammate (and the recipient is not the speaker)')
1161
+ assert(/facilitator(框架\/人类信使,不是常驻成员,不要向它回信/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt), '★ the frame DEFINES `facilitator` on the very wake that shows it, so it is not an unaddressable stranger')
1162
+ }
1052
1163
  const joined = corpus.map((c) => c.prompt).join('\n')
1053
1164
  assert(joined.indexOf(K.WS) === -1 && joined.indexOf(K.WS.replace(/\\/g, '/')) === -1 && joined.indexOf(vibe) === -1 && joined.indexOf(vibe.replace(/\\/g, '/')) === -1, '★ every captured prompt normalises <WS> and <VIBEMATH> (diffable, no machine paths)')
1165
+ // The random `t-<hex8>` must be normalised too, and NON-vacuously: the task-frame entry has to
1166
+ // CARRY the normalised token (a scrub that quietly blanked the whole frame would also pass the
1167
+ // absence check below).
1168
+ assert(/\[YOU CLAIMED TASK <TASKID>\]/.test(byLabel('task frame ([YOU CLAIMED TASK ...])').prompt), '★ the task-frame corpus entry really carries the normalised `<TASKID>` token')
1169
+ assert(!/\bt-[0-9a-f]{8}\b/.test(joined), '★ no captured prompt leaks a random `t-<hex8>` id (the corpus is byte-stable across runs)')
1054
1170
  assert(!/\[object Object\]|\bNaN\b|:\s*undefined|["']undefined["']|undefined\s*[,}\]]/.test(joined), 'no captured prompt contains placeholder garbage')
1055
1171
  assert(corpus.every((c) => c.prompt && c.prompt.length > 20), 'every corpus entry carries real prompt text')
1056
1172
  }
@@ -1,27 +1,40 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * PARALLEL TEST RUNNER — run every shipped suite (or a filtered subset) concurrently and report
4
- * per-suite timings, so the strategy for the next run is chosen from DATA instead of guesswork.
3
+ * PARALLEL TEST RUNNER — run every shipped suite AND every probe (or a filtered subset)
4
+ * concurrently and report per-suite timings, so the strategy for the next run is chosen
5
+ * from DATA instead of guesswork.
5
6
  *
6
7
  * Why this exists: the suites are wildly uneven (one suite is ~160 s, most are under 2 s), so a
7
8
  * sequential sweep spends almost all of its wall time waiting for the slowest one. Running them
8
9
  * with a worker pool makes the sweep bounded by the slowest SUITE rather than by their SUM.
9
10
  * On this machine (4 cores) the sweep went from ~5.5 min to ~2 min; see ../docs/test-timing.md.
10
11
  *
12
+ * WHAT IS COLLECTED (Round B): `tests/` holds two families of `.mjs`:
13
+ * · `*.test.mjs` — the suites proper;
14
+ * · every other `.mjs` beside this runner — the PROBES that prove those suites are not
15
+ * vacuous (sensitivity mutations, the registration surface, the self-driving guards).
16
+ * Collecting only the first family was a false green: two probe suites sat at exit 1 while
17
+ * every report said "30/30 green". Both families are therefore collected, and a script can
18
+ * only stay out through the explicit, documented `NEEDS_ARGS` list below — a stale entry in
19
+ * that list is a hard error in a development checkout (`.git` present), so no script can be
20
+ * dropped silently there. Scripts whose bare run under-covers get argument `VARIANTS` (e.g.
21
+ * `audit-registration.mjs` defaults to v3 alone). A PUBLISHED tarball ships only a subset of
22
+ * `tests/`; there the missing entries are reported instead of aborting the run.
23
+ *
11
24
  * Every suite already isolates itself (each creates its own mkdtemp workspace), so parallelism is
12
25
  * safe. Suites that WRITE a corpus take a per-run corpus dir from an env var; this runner points
13
26
  * them at a scratch dir when it runs a suite more than once, which it never does — but the probe
14
27
  * runner (audit-formal-sensitivity.mjs) does, and it passes its own dirs.
15
28
  *
16
29
  * Usage:
17
- * node tests/run-tests.mjs # every *.test.mjs, concurrency = min(4, cpus)
30
+ * node tests/run-tests.mjs # every suite + probe, concurrency = min(4, cpus)
18
31
  * node tests/run-tests.mjs --concurrency=6
19
32
  * node tests/run-tests.mjs --only formal # substring match on the file name (repeatable, OR)
20
33
  * node tests/run-tests.mjs --exclude e2e-v4 # substring to skip (repeatable)
21
34
  * node tests/run-tests.mjs --json # machine-readable summary on stdout
22
35
  */
23
36
  import { spawn } from 'node:child_process'
24
- import { readdirSync } from 'node:fs'
37
+ import { existsSync, readdirSync } from 'node:fs'
25
38
  import { cpus } from 'node:os'
26
39
  import { join } from 'node:path'
27
40
  import { fileURLToPath } from 'node:url'
@@ -48,20 +61,75 @@ const exclude = flag('exclude')
48
61
  const asJson = has('json')
49
62
  const concurrency = Math.max(1, Number(flag('concurrency')[0] || Math.min(4, cpus().length)))
50
63
 
51
- let suites = readdirSync(HERE).filter((f) => f.endsWith('.test.mjs')).sort()
52
- if (only.length) suites = suites.filter((f) => only.some((o) => f.includes(o)))
53
- if (exclude.length) suites = suites.filter((f) => !exclude.some((o) => f.includes(o)))
64
+ const SELF = 'run-tests.mjs'
65
+ // The scripts that genuinely cannot run without arguments. They are named here (with the exact
66
+ // command a human must run) instead of being omitted quietly: an entry that no longer exists
67
+ // aborts the run, so a rename cannot turn into a silent gap.
68
+ const NEEDS_ARGS = {
69
+ 'audit-tool-exec.mjs': 'node tests/audit-tool-exec.mjs <preset-js> <tool-name> (calls one real tool through the host path)',
70
+ }
71
+ // Argument variants, run IN ADDITION to the bare run unless `replaceBare` says the bare run is a
72
+ // strict subset of them. `expectExit` documents a probe whose SUCCESS is a non-zero exit (it
73
+ // mutates the source and requires the guarded check to fail): anything else is a FAIL here.
74
+ const VARIANTS = [
75
+ // A bare `audit-registration.mjs` audits ONLY v3 (its `process.argv[2] || v3` default), so the
76
+ // four presets are run explicitly and the redundant bare run is replaced.
77
+ { file: 'audit-registration.mjs', args: ['vibe-math-v2/vibe-math-v2.js'], replaceBare: true },
78
+ { file: 'audit-registration.mjs', args: ['vibe-math-v3/vibe-math-v3.js'] },
79
+ { file: 'audit-registration.mjs', args: ['vibe-math-v4/vibe-math-v4.js'] },
80
+ { file: 'audit-registration.mjs', args: ['vibe-math-v5/vibe-math-v5.js'] },
81
+ // The invariant scanner's own falsifiability check: it exits 0 only when every injected
82
+ // mutation really made a run go RED (and the unmutated control stayed green).
83
+ { file: 'audit-prompt-invariants.mjs', args: ['--self-probe'] },
84
+ // The two-registration-paths probe, inverted: it applies a real description mutation and
85
+ // REQUIRES the parity check to exit 1 (exit 2 means the mutation no longer applies = drift).
86
+ {
87
+ file: 'audit-v3-registration-parity.mjs',
88
+ args: ['--self-probe', '["TOOL_DESC.vibe_math_start","TOOL_DESC.vibe_math_startX"]'],
89
+ expectExit: 1,
90
+ },
91
+ ]
92
+
93
+ const present = readdirSync(HERE).filter((f) => f.endsWith('.mjs')).sort()
94
+ const presentSet = new Set(present)
95
+ // A published tarball ships only a SUBSET of `tests/` (see docs/test-timing.md §1), so the
96
+ // scripts that stay behind are simply ABSENT there. The skip/variant lists are therefore
97
+ // enforced strictly in a development checkout (a `.git` entry marks one) and merely REPORTED —
98
+ // never silently dropped, they still appear in every run's output — in a partial tree.
99
+ const DEV_CHECKOUT = existsSync(join(REPO, '.git'))
100
+ const stale = Object.keys(NEEDS_ARGS).filter((f) => !presentSet.has(f))
101
+ if (stale.length && DEV_CHECKOUT) {
102
+ console.error('NEEDS_ARGS names a script that no longer exists: ' + stale.join(', ') + ' — fix the skip list, do not delete the entry blindly')
103
+ process.exit(2)
104
+ }
105
+ function label(j) { return j.file + (j.args.length ? ' ' + j.args.join(' ') : '') }
106
+ const replacedBare = new Set(VARIANTS.filter((v) => v.replaceBare).map((v) => v.file))
107
+ let suites = []
108
+ for (const file of present) {
109
+ if (file === SELF || NEEDS_ARGS[file] || replacedBare.has(file)) continue
110
+ suites.push({ file, args: [], expectExit: 0, kind: file.endsWith('.test.mjs') ? 'suite' : 'probe' })
111
+ }
112
+ for (const v of VARIANTS) {
113
+ if (!presentSet.has(v.file)) {
114
+ if (DEV_CHECKOUT) { console.error('a VARIANTS entry names a missing script: ' + v.file); process.exit(2) }
115
+ continue
116
+ }
117
+ suites.push({ file: v.file, args: v.args || [], expectExit: v.expectExit || 0, kind: v.file.endsWith('.test.mjs') ? 'suite' : 'probe' })
118
+ }
119
+ suites.sort((a, b) => (label(a) < label(b) ? -1 : 1))
120
+ if (only.length) suites = suites.filter((j) => only.some((o) => label(j).includes(o)))
121
+ if (exclude.length) suites = suites.filter((j) => !exclude.some((o) => label(j).includes(o)))
54
122
  if (!suites.length) { console.error('no suites matched'); process.exit(2) }
55
123
 
56
- function runSuite(file) {
124
+ function runSuite(job) {
57
125
  return new Promise((resolve) => {
58
126
  const t0 = Date.now()
59
- const child = spawn(process.execPath, [join(HERE, file)], { cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] })
127
+ const child = spawn(process.execPath, [join(HERE, job.file), ...job.args], { cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] })
60
128
  let out = '', err = ''
61
129
  child.stdout.on('data', (d) => { out += d.toString() })
62
130
  child.stderr.on('data', (d) => { err += d.toString() })
63
- child.on('error', (e) => resolve({ file, code: -1, ms: Date.now() - t0, out, err: err + '\n' + String(e) }))
64
- child.on('close', (code) => resolve({ file, code, ms: Date.now() - t0, out, err }))
131
+ child.on('error', (e) => resolve({ job, code: -1, ms: Date.now() - t0, out, err: err + '\n' + String(e) }))
132
+ child.on('close', (code) => resolve({ job, code, ms: Date.now() - t0, out, err }))
65
133
  })
66
134
  }
67
135
 
@@ -74,20 +142,22 @@ async function worker(id) {
74
142
  if (i >= suites.length) return
75
143
  const r = await runSuite(suites[i])
76
144
  const tail = String(r.out).trim().split('\n').filter(Boolean).slice(-1)[0] || ''
145
+ r.ok = r.code === r.job.expectExit
77
146
  results[i] = r
78
147
  if (!asJson) {
79
- const mark = r.code === 0 ? 'PASS' : 'FAIL'
148
+ const mark = r.ok ? 'PASS' : 'FAIL'
80
149
  console.log(
81
- mark + ' ' + r.file.padEnd(38) +
82
- ' exit=' + String(r.code).padStart(3) +
150
+ mark + ' ' + r.job.file.padEnd(34) +
151
+ ' exit=' + String(r.code).padStart(3) + (r.job.expectExit ? '(want ' + r.job.expectExit + ')' : ' ') +
83
152
  ' ' + (r.ms / 1000).toFixed(1).padStart(6) + 's' +
84
- (tail ? ' ' + tail.slice(0, 78) : '')
153
+ (r.job.args.length ? ' [' + r.job.args.join(' ').slice(0, 40) + ']' : '') +
154
+ (tail ? ' ' + tail.slice(0, 60) : '')
85
155
  )
86
- if (r.code !== 0) {
156
+ if (!r.ok) {
87
157
  const lines = (r.out + '\n' + r.err).split('\n').filter(Boolean)
88
158
  for (const l of lines.slice(-15)) console.log(' ' + l)
89
159
  }
90
- } else if (r.code !== 0) {
160
+ } else if (!r.ok) {
91
161
  // In --json mode keep stdout machine-readable: the failure detail travels in the JSON.
92
162
  const lines = (r.out + '\n' + r.err).split('\n').filter(Boolean)
93
163
  r.tailDetail = lines.slice(-15).join('\n')
@@ -98,15 +168,20 @@ await Promise.all(Array.from({ length: Math.min(concurrency, suites.length) }, (
98
168
 
99
169
  const wall = (Date.now() - started) / 1000
100
170
  const sum = results.reduce((a, r) => a + r.ms, 0) / 1000
101
- const bad = results.filter((r) => r.code !== 0)
171
+ const bad = results.filter((r) => !r.ok)
102
172
  const slowest = results.slice().sort((a, b) => b.ms - a.ms).slice(0, 5)
173
+ const suiteCount = results.filter((r) => r.job.kind === 'suite').length
103
174
 
104
175
  if (asJson) {
105
176
  console.log(JSON.stringify({
106
177
  concurrency, wallSeconds: Number(wall.toFixed(1)), sumSeconds: Number(sum.toFixed(1)),
107
178
  pass: results.length - bad.length, fail: bad.length,
108
- suites: results.map((r) => ({
109
- file: r.file, exit: r.code, seconds: Number((r.ms / 1000).toFixed(1)),
179
+ suites: suiteCount, probes: results.length - suiteCount,
180
+ devCheckout: DEV_CHECKOUT,
181
+ skippedNeedsArgs: NEEDS_ARGS,
182
+ runs: results.map((r) => ({
183
+ file: r.job.file, args: r.job.args, expectExit: r.job.expectExit, exit: r.code,
184
+ seconds: Number((r.ms / 1000).toFixed(1)),
110
185
  ...(r.tailDetail ? { detail: r.tailDetail } : {}),
111
186
  })),
112
187
  }, null, 2))
@@ -114,8 +189,14 @@ if (asJson) {
114
189
  console.log('')
115
190
  console.log('concurrency ' + concurrency + ' · wall ' + wall.toFixed(1) + 's · sum of suite times ' + sum.toFixed(1) + 's'
116
191
  + ' · speed-up x' + (sum / Math.max(wall, 0.001)).toFixed(2))
117
- console.log('slowest: ' + slowest.map((r) => r.file.replace('.test.mjs', '') + ' ' + (r.ms / 1000).toFixed(1) + 's').join(' · '))
118
- console.log('TOTAL ' + results.length + ' PASS ' + (results.length - bad.length) + ' FAIL ' + bad.length)
119
- for (const b of bad) console.log(' FAILED: ' + b.file + ' (exit ' + b.code + ')')
192
+ console.log('slowest: ' + slowest.map((r) => r.job.file.replace('.test.mjs', '') + ' ' + (r.ms / 1000).toFixed(1) + 's').join(' · '))
193
+ console.log('TOTAL ' + results.length + ' PASS ' + (results.length - bad.length) + ' FAIL ' + bad.length
194
+ + ' (suites ' + suiteCount + ' · probes ' + (results.length - suiteCount) + ')')
195
+ for (const b of bad) {
196
+ console.log(' FAILED: ' + label(b.job) + ' (exit ' + b.code + (b.job.expectExit ? ', required exit ' + b.job.expectExit : '') + ')')
197
+ }
198
+ for (const [f, why] of Object.entries(NEEDS_ARGS)) {
199
+ console.log(' SKIPPED (needs CLI args): ' + f + ' — ' + why + (presentSet.has(f) ? '' : ' [not present in this checkout]'))
200
+ }
120
201
  }
121
202
  process.exit(bad.length === 0 ? 0 : 1)
@@ -44,6 +44,9 @@
44
44
  Key rules to remember when reporting: a problem is "solved" when one of its solutions reaches
45
45
  正确概率 = 1; a proposition reaches 布尔估计 = 1/0 when a proof/refutation in its lists reaches
46
46
  正确概率 = 1; Propos propositions with 价值/关键性 ≥ promoteValueThreshold auto-promote to qs.json.
47
+ Closure rule (identical across the presets): the 1/0 values above are written ONLY by the scheduler,
48
+ after ≥2 independent verifier agents settle the object (one lone reviewer can never conclude), and
49
+ Verified/ is written by that same scheduler path — an agent's own self-reported 1 is never a conclusion.
47
50
  When the user asks about progress, call vibe_math_report and summarize in plain language.
48
51
 
49
52
  Configuration highlights (all tunable via vibe_math_set_params / the settings file):
@@ -107,6 +110,9 @@
107
110
  Key rules to remember when reporting: a problem is "solved" when one of its solutions reaches
108
111
  正确概率 = 1; a proposition reaches 布尔估计 = 1/0 when a proof/refutation in its lists reaches
109
112
  正确概率 = 1; Propos propositions with 价值/关键性 ≥ promoteValueThreshold auto-promote to qs.json.
113
+ Closure rule (identical across the presets): the 1/0 values above are written ONLY by the scheduler,
114
+ after ≥2 independent verifier agents settle the object (one lone reviewer can never conclude), and
115
+ Verified/ is written by that same scheduler path — an agent's own self-reported 1 is never a conclusion.
110
116
  When the user asks about progress, call vibe_math_report and summarize in plain language.
111
117
 
112
118
  Configuration highlights (all tunable via vibe_math_set_params / the settings file):