igneum/infra/fast-time/fork-gate-gate.mjs
igneum-labs f1435e85f2 fast time: the three-node fork-gate harness, its six-case runner and a box wrapper for harness runs (mission item 4)
infra/fast-time/fork-gate.mjs: H1 and H2 honest and mining through every phase, B the third node; modes attack (B's
key fresh, 3 of 5 CPU threads in the split), third (B at about half of the table, accepted) and partition (H1 against
H2, the heavier side wins with the gate on as with it off). A reorg is read from the chain (getVirtualChainFromBlock
of the pre-cut tip lists removed blocks), never from a key; blue work compared as BigInt; leftovers stopped by pid
file, never by name. infra/fast-time/fork-gate-gate.mjs runs the six cases side by side (known-failed first) and is
GREEN only when every case gives the verdict it must and the two partition heals agree. tools/fast-time-remote.sh
runs a harness on a build box under a slot with a holder line and a JSONL row (the node binaries from a fork
worktree's target on the box, results fetched back); it carries the whole-body block and is listed in the check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7f3e75479e)
2026-10-07 12:48:16 +00:00

86 lines
6.9 KiB
JavaScript
Executable file

#!/usr/bin/env node
// The gate line of mission item 4 (docs/analysis/mission/mission.md 2.4): "fast-time: a 51 percent fresh-key fork from
// 15 min back is refused by every honest node; a one-third-weight fork is accepted; partition heal unchanged", with the
// known-failed cases first. Runs the six cases of infra/fast-time/fork-gate.mjs side by side (each on its own slot:
// ports and devnet suffix), reads each case's verdict against what it MUST be, prints the table and writes
// docs/plans/mission-item-4-gate/summary.json. Exit 0 only when every case's verdict is the one it must give.
//
// node infra/fast-time/fork-gate-gate.mjs [--parallel 6] [--only attack-gate-on-expect-hold,...] [--joint 240] [--split 180]
// [--watch 150] [--window 60] [--honest-threads 1] [--attacker-threads 3] [--attacker-joint-threads 2]
// IGNEUMD and IGNEUM_MINER name the binaries; the cases' logs land beside the summaries.
//
// The fork depth: the window is 60 DAA (the devnet's 600 s at 60x is 10 DAA, too few blocks to read; the window stays
// at 60 and the split at 180 s, three windows deep; "15 min back" on the devnet is one and a half windows, which the
// split covers from its 61st second on). The honest side mines through the split on every case (the 6 October harness
// idled it, and a withheld chain's victim keeps mining).
import { spawn } from 'node:child_process';
import { mkdirSync, openSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
const ROOT = new URL('../../', import.meta.url).pathname;
const OUT_DIR = `${ROOT}docs/plans/mission-item-4-gate`;
const args = process.argv.slice(2);
const sflag = (name, dflt = null) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
const PARALLEL = Number(sflag('parallel', '6'));
const ONLY = sflag('only') ? sflag('only').split(',') : null;
const PASS_THROUGH = ['joint', 'split', 'watch', 'window', 'honest-threads', 'attacker-threads', 'attacker-joint-threads'].flatMap(n => sflag(n) != null ? [`--${n}`, sflag(n)] : []);
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), 'gate', ...a);
// in the order the mission asks: the known-failed cases first, then the gate line, then the two sides the gate must leave alone
const CASES = [
{ mode: 'attack', gate: 'off', expect: 'reorg', must: 'PASS', line: "the rule's known-failed case: without the gate a fresh key's heavier deep fork wins on every honest node" },
{ mode: 'attack', gate: 'off', expect: 'hold', must: 'FAIL', line: "the harness's own failed shape: with the gate off it must not report a hold" },
{ mode: 'attack', gate: 'on', expect: 'hold', must: 'PASS', line: 'the gate line: a fresh-key fork from three windows back, heavier by blue work, refused by every honest node' },
{ mode: 'third', gate: 'on', expect: 'reorg', must: 'PASS', line: 'a fork whose builder holds at least a third of the table at the fork is accepted' },
{ mode: 'partition', gate: 'on', expect: 'reorg', must: 'PASS', line: 'partition heal with the gate on: the heavier side wins' },
{ mode: 'partition', gate: 'off', expect: 'reorg', must: 'PASS', line: 'partition heal with the gate off: the same outcome, so the heal is unchanged' },
].map((c, slot) => ({ ...c, slot, name: `${c.mode}-gate-${c.gate}-expect-${c.expect}` }));
const cases = ONLY ? CASES.filter(c => ONLY.includes(c.name)) : CASES;
if (!cases.length) { console.error(`no case matches --only ${ONLY}`); process.exit(2); }
mkdirSync(OUT_DIR, { recursive: true });
function run(c) {
return new Promise((resolve) => {
const logFile = `${OUT_DIR}/fork-gate-${c.name}.log`;
const out = openSync(logFile, 'w');
const a = [`${ROOT}infra/fast-time/fork-gate.mjs`, '--mode', c.mode, '--gate', c.gate, '--expect', c.expect, '--slot', String(c.slot), '--case', c.name, '--out', `${OUT_DIR}/fork-gate-${c.name}.json`, ...PASS_THROUGH];
const t0 = Date.now();
const p = spawn(process.execPath, a, { stdio: ['ignore', out, out], env: process.env });
log(`start ${c.name} (slot ${c.slot}, pid ${p.pid}): ${c.line}`);
p.on('exit', (code) => {
const secs = Math.round((Date.now() - t0) / 1000);
let summary = null;
try { summary = JSON.parse(readFileSync(`${OUT_DIR}/fork-gate-${c.name}.json`, 'utf8')); } catch { }
const lines = existsSync(logFile) ? readFileSync(logFile, 'utf8').split('\n') : [];
const summaryLine = lines.find(l => l.includes('SUMMARY ')) || lines.filter(Boolean).at(-1) || '';
const verdict = code === 0 ? 'PASS' : code === 1 ? 'FAIL' : `ERROR ${code}`;
log(`end ${c.name}: ${verdict} in ${secs} s (must ${c.must}) ${summaryLine.replace(/^.*?SUMMARY /, '')}`);
resolve({ ...c, verdict, as_it_must: verdict === c.must, secs, exit: code, summary_line: summaryLine.replace(/^\S+ fg\d+ /, ''), summary: summary ? { ...summary, samples: undefined } : null, log: logFile });
});
});
}
const results = [];
const queue = cases.slice();
async function worker() { while (queue.length) results.push(await run(queue.shift())); }
await Promise.all(Array.from({ length: Math.min(PARALLEL, cases.length) }, worker));
results.sort((x, y) => x.slot - y.slot);
const pad = (s, n) => String(s).padEnd(n);
console.log('');
console.log(`${pad('case', 34)} ${pad('must', 5)} ${pad('got', 8)} ${pad('ok', 4)} ${pad('side 2 at fork', 15)} ${pad('depth', 6)} ${pad('heavier', 8)} honest nodes`);
for (const r of results) {
const s = r.summary;
const nodes = s ? Object.entries(s.per_node).map(([n, p]) => `${n.toUpperCase()} ${p.reorged ? `reorged@${p.reorg_at_s}s` : 'held'}/${p.refusal_lines} refusals`).join(', ') : '(no summary)';
console.log(`${pad(r.name, 34)} ${pad(r.must, 5)} ${pad(r.verdict, 8)} ${pad(r.as_it_must ? 'yes' : 'NO', 4)} ${pad(s ? `${s.share_at_fork.side2_bps ?? 0} bps` : '?', 15)} ${pad(s ? s.fork_depth_daa_at_heal : '?', 6)} ${pad(s ? s.checks.side2_heavier_at_heal : '?', 8)} ${nodes}`);
}
const healUnchanged = (() => {
const on = results.find(r => r.name === 'partition-gate-on-expect-reorg'), off = results.find(r => r.name === 'partition-gate-off-expect-reorg');
if (!on || !off) return null;
return on.verdict === 'PASS' && off.verdict === 'PASS' && JSON.stringify(Object.values(on.summary?.per_node ?? {}).map(p => [p.reorged, p.refusal_lines])) === JSON.stringify(Object.values(off.summary?.per_node ?? {}).map(p => [p.reorged, p.refusal_lines]));
})();
const allGood = results.every(r => r.as_it_must) && healUnchanged !== false;
console.log('');
console.log(`GATE ${allGood ? 'GREEN' : 'RED'}: ${results.filter(r => r.as_it_must).length} of ${results.length} cases gave the verdict they must${healUnchanged == null ? '' : `; partition heal unchanged between gate on and off: ${healUnchanged}`}`);
writeFileSync(`${OUT_DIR}/summary.json`, JSON.stringify({ green: allGood, at: new Date().toISOString(), heal_unchanged: healUnchanged, node: process.env.IGNEUMD ?? null, miner: process.env.IGNEUM_MINER ?? null, cases: results }, null, 2));
process.exit(allGood ? 0 : 1);