diff --git a/docs/bench-log.md b/docs/bench-log.md index d56780f01..a2fd40688 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -690,3 +690,22 @@ CPU proofs (`SP1_PROVER=cpu`), block-56-transfers-3shards: | pending | | | | Reading: the shard at `S_p` is 60 M cycles on the prototype table, 9 cycles per pgas against the unit's 1,000 (the modexp entry is about 100x its SP1 cost: R1, one number); a plain transfer shard is 1,400 to 1,600 cycles per pgas because the 200-pgas intrinsic charge carries the fixed cost of the witness check and the two root computations. The aggregator statement is 1.5 to 1.7 M cycles (bincode, an unpatched sha256 of each shard's public values, the keccaks), small next to a shard. The CPU proof times are 3.8x and 4.9x the 3 October v0 numbers on a comparable statement, on a machine three times as loaded; the GPU row stays empty until the PC runs. The devnet was not touched; the simnet ran on ports 29300, 29301 and 29390 and was stopped. + +## 2026-10-04, fast time (60x test profile), Linux cross-compile from the Mac, CI on every push (consensus-engineer) + +Machine: Apple M5 Max, 64 GB, shared with four other agents and the live devnet (load 40 to 76 throughout), every job at nice 19 with at most 4 cargo jobs. The live devnet (26610/26611, 26640/26641, 28640), the Metal worker and the seed's `/opt/igneum/v4/bin` were not touched. + +Fast time. `infra/fast-time/override-60x.json` is the devnet with every clock-like consensus parameter divided by 60 and every block count unchanged (`infra/fast-time/README.md` lists each field and why it scales or not). The hourly program epoch and its lead are now consensus parameters carried by the override file (`pow_epoch_blocks`, `pow_epoch_lead`, plus `pow_day_ms` for the dataset day; devnet-v4 `a5ef8b07`, devnet defaults unchanged: kaspa-consensus-core 79 tests, kaspa-pow 7, igneum-pow 39 pass, and a new test reads the 60x file and checks every rule against `DEVNET_PARAMS`). Proof (`infra/fast-time/simnet.mjs`, three devnet-v4 nodes on ports 29500+, `igneum-devnet-950`, three vmine voters sharing 1 block/s, one real-hash CPU miner thread): next epoch seed in the template at 56.1 s (DAA 53), program swap at 65.1 s wall (DAA 60; the CPU miner's new program and cache ready 397 ms later), first finality lock at 185.5 s wall (checkpoint 5, blue score 150, DAA 149; checkpoint 4 sat one DAA under `min_daa` 120), sinks identical on all three nodes. The devnet reaches the same two events at DAA 3,600 and DAA 7,200 plus a checkpoint. + +| Harness run, same binaries | Devnet profile | 60x profile (`--fast-time`) | +|---|---|---| +| `tools/finality-attacks` s3, dishonest aggregators | catalogue 32 min at SCALE 0.6 on the 3 Oct node (above); on today's rule the window fills at DAA 7,200 = 20 min at 6 blocks/s before any lock | 113 s wall: 16 locks per node, 0 conflicting certificates, lock hashes agree, median lock latency 1,018 ms, PASS | +| `tools/harness` s3, partition and heal (in-process simulator of the devnet-v4 line) | 983 s wall, four cuts of 120 / 600 / 1,800 / 3,700 virtual s (46 / 151 / 400 / 831 s wall), all PASS | 43 s wall, three cuts of 10 / 30 / 62 virtual s (18 / 20 / 26 s wall), all PASS; the 62-s cut beyond the 60-s merge depth converged with a 34-block reorg | + +Both harnesses take `--fast-time` (`tools/harness/lib/net.mjs`, `tools/finality-attacks/lib/net.mjs`): the node and the simulator then come from `vendor/igneum-node/target-integration` (the file carries fields only the devnet-v4 line knows), the merge-depth scenarios scale their cuts with the profile, and the finality runs default to the `--quick` scale. + +Linux cross-compile. `infra/cross/build-linux.sh`: cargo-zigbuild 0.23.4 with zig 0.17.0 (`brew install zig`, `cargo install cargo-zigbuild`, `rustup target add x86_64-unknown-linux-gnu`), target `x86_64-unknown-linux-gnu.2.36` (Debian 12 on the servers), `-p kaspad -p igneum-miner --features kaspad/igneum-pow`, target dir `vendor/igneum-node/target-linux`. Cold build: 1,856 s (30 min 56 s) at 4 jobs, nice 19, on this loaded machine; rocksdb (librocksdb-sys C++), lz4, blst, secp256k1 and the execution layer's crates all linked through zig; no crate failed, so `cross` was not needed (Docker is not installed here anyway). Output: ELF x86-64 PIE, igneumd 46,883,304 B and igneum-miner 8,978,424 B, dynamically linked against libc and libm only. Verified on igneum-seed-1 (Debian 12, glibc 2.36, 2 vCPU) in `/root/xbuild-test`: `igneumd --version` = igneumd 2.1.0, `igneum-miner --help` prints its usage, and a 60-s run of the cross-compiled node on `igneum-devnet-951` (the 60x profile at genesis bits 2^16, real proof of work) with the cross-compiled miner on one CPU thread: the miner adopted the node's 60-block epoch from the template, built its 256 MiB cache in 395 ms, found 7 blocks at 0.011 MH/s, all 7 accepted by the node's own PoW check, 0 rejected (the x86 build of the lottery hash agrees between miner and node; neither binary exposes a standalone vector check). Against the alternatives: the seed's own build took 55 min on its 2 vCPU (4 Oct 2026, above), the builder VM 10 to 25 min on 8 vCPU plus its creation and deletion. `BIN_SOURCE=mac` is wired into `infra/cloud-devnet/provision.sh` (no builder VM, build/bin from the cross-compile) and `infra/seed-nodes/stage-v4.sh` (upload to /root/v4/out, install as before). + +CI. `.github/workflows/ci.yml` runs on every push and pull request of the private repository: igneum-pow `cargo test --release` and the igneum-census build (49 s), the two simulators' `--quick` modes under a 120-s timeout (49 s for the job; `sim/finality_v2.py --quick` is now a true smoke run, 149 s at nice 19 on this loaded Mac and under 40 s on the runner, was 745 s; `sim/difficulty/sim.py --quick` is new, 36 s here), the site build, an internal link check of `site/*.html` (151 links, 0 broken) and a gh-free identity grep of the public export list after the generic scrub (`tools/ci/identity-check.sh`, `tools/ci/forbidden-strings.txt`: 157 files, 0 hits). First run green: https://github.com/igneum-network/igneum/actions/runs/37193811336, 54 s from trigger to completion. The node fork is gitignored and too big for the free runners today; the workflow says so. + +Not done: `igneum-harness-sim`'s per-block cost on the devnet-v4 line (about 70 ms here against 3 ms on the ordering-layer branch, the execution layer's follower) is what still bounds the harness, not the clocks; the fast-time presence window floors at one checkpoint; the GPU workers were not run on fast time (the CPU miner proved the swap). diff --git a/infra/fast-time/README.md b/infra/fast-time/README.md new file mode 100644 index 000000000..1e4e86b51 --- /dev/null +++ b/infra/fast-time/README.md @@ -0,0 +1,101 @@ +# Fast time: the devnet at 60x for test networks + +`override-60x.json` is the Igneum devnet with every clock-like consensus parameter divided by 60 and every block +count unchanged. A test network on it reaches its first finality lock in about three minutes instead of two hours and +crosses an hourly program epoch every minute, so a scenario that took hours of wall time takes minutes. 4 October 2026. + +## Flags + +Node (devnet-v4 line, `vendor/igneum-node-v4`, built in `vendor/igneum-node/target-integration`): + +``` +igneumd --devnet --devnet-suffix=950 --override-params-file=infra/fast-time/override-60x.json ... +``` + +The file is complete: every field `OverrideParams` accepts is present (`consensus/core/src/config/params.rs`), so a +reader sees the whole profile in one place. Merge it with `skip_proof_of_work: true` for a network without miners +(the harnesses do this), or lower `genesis_bits` for CPU miners (the devnet value 0x1d100000 is the GPU difficulty). + +Harnesses: `node tools/finality-attacks/run.mjs s3 --fast-time`, `node tools/harness/run.mjs s3 s4 --fast-time` +(or `IGNEUM_FAST_TIME=1`). Both then take the node from `vendor/igneum-node/target-integration/release` because the +file carries the PoW schedule fields that only the devnet-v4 line knows; `IGNEUMD=...` overrides. The in-process +simulator takes the same file: `igneum-harness-sim --override-params-file infra/fast-time/override-60x.json`. + +Proof script: `node infra/fast-time/simnet.mjs` (3 nodes on ports 29500 and up, `igneum-devnet-950`, data +`/tmp/igneum-fast-time`; records the first lock and the first program swap; see "Measured" below). + +Miners: `igneum-miner` follows the epoch length and lead its node reports in every template (`pow_epoch`), no flag. +The dataset day is not in the template, so a real-hash miner on a fast-time network takes `IGNEUM_POW_DAY_MS=1440000` +in its environment (the node reads it from the file). The environment variables `IGNEUM_POW_EPOCH_BLOCKS`, +`IGNEUM_POW_EPOCH_LEAD` and `IGNEUM_POW_DAY_MS` remain the fallback for a node without a params file; the file wins. + +## What scales and what does not + +Time parameters, divided by 60 (devnet value, 60x value): + +| Parameter | Devnet | 60x | Note | +|---|---|---|---| +| `finality.weight_window` (W2, DAA s) | 7,200 | 120 | the 2-hour weight window becomes 2 minutes | +| `finality.min_daa` (C5, F1, DAA s) | 7,200 | 120 | equal to the window on every network, so the first lock waits for a full window | +| `finality.equivocation_ban` (3.6, DAA s) | 7,200 | 120 | | +| `finality.aggregator_fallback` (DAA s) | 15 | 1 | 15/60 rounds up to the floor of one second | +| `finality.presence_window` (Q1, checkpoints) | 20 | 1 | a count of checkpoints, but it stands for 20 x 30 = 600 DAA s of presence; 10 s is under one checkpoint, so the floor of one | +| `pow_epoch_blocks` (DAA s) | 3,600 | 60 | the hourly program epoch becomes a minute (new in the node, 4 Oct 2026: a consensus param carried by the override file; before, only an environment variable) | +| `pow_epoch_lead` (DAA s) | 600 | 10 | the seed is knowable one lead before the boundary; the miner confirms it a quarter of the lead later (3 DAA s at 60x, 150 on the devnet) | +| `pow_day_ms` (ms) | 86,400,000 | 1,440,000 | the dataset day of the 256 MiB cache becomes 24 minutes (new consensus param, same change) | +| `blockrate.merge_depth` (DAA s) | 3,600 | 60 | Kaspa's merge-depth bound, one hour of DAA score | +| `blockrate.finality_depth` (DAA s) | 43,200 | 720 | Kaspa's pruning-side finality, 12 hours | +| `blockrate.pruning_depth` (DAA s) | 108,000 | 13,838 | 30 hours / 60 = 1,800 is under Kaspa's anticone finalisation bound (finality + 2 x merge + 4 x mergeset x k + 2k + 2 = 13,838 at k 18, mergeset 180), and `Bps::pruning_depth` takes the larger, so this does | +| `blockrate.coinbase_maturity` (s) | 100 | 2 | 100 s / 60 rounds up | +| `deflationary_phase_daa_score` | 0 | 0 | the devnet has no deflationary phase | + +Unchanged, and why: + +| Parameter | Value | Why it stays | +|---|---|---| +| `blockrate.target_time_per_block` | 1,000 ms | the unit: a 60x network still makes one block per second; 60 blocks per second is a different DAG (k, parents, mergeset) and not what is being tested | +| `blockrate.ghostdag_k`, `max_block_parents`, `mergeset_size_limit` | 18, 10, 180 | DAG shape at 1 block/s | +| `finality.checkpoint_interval`, `checkpoint_depth` | 30, 20 | blue-score counts: a checkpoint every 30 blocks, determined 20 blocks later | +| `finality.dust`, `aggregators` | 5, 8 | a block count and a count of keys | +| `difficulty_window_size`, `min_difficulty_window_size`, `difficulty_sample_rate` | 661, 150, 4 | sample counts of Kaspa's sampled DAA (the reference lane of the dual rule); the controller needs its samples to estimate hash rate, and the 120-block LWMA lane, the 8-block warm-up, the 600-block reference start and the per-block clamps (`igneum::difficulty`) are block counts by design | +| `past_median_time_window_size`, `past_median_time_sample_rate`, `timestamp_deviation_tolerance` | 27, 10, 132 | the past-median rule's window in samples; Igneum's own 10 s future and back bounds are constants of the rule | +| `genesis_bits` | 0x1d100000 | the devnet's GPU difficulty; a CPU test network sets its own (0x1f010000 = 2^16 hashes per block) | +| `skip_proof_of_work` | false | the harnesses turn it on themselves | +| mass, lane, script and payload limits, `pruning_proof_m`, `max_block_level`, `crescendo_activation`, `storage_mass_parameter`, `pre_deflationary_phase_base_subsidy` | devnet | sizes and rule switches, not clocks | + +Consequences to remember when reading a fast-time result: the merge depth (60 s) is now far under the difficulty +window (2,641 s), so a low-difficulty side chain older than a minute cannot merge, which the devnet tolerates for an +hour; the presence window of one checkpoint means "finality active" needs a lock within the last checkpoint; the +partition bound of spec 3.3.1 is W / (3R) = 13 s at 1 block/s per side (200 s on the devnet), so a split longer than +that locks on both sides as the arithmetic says (`docs/bench-log.md`, 4 Oct 2026, 6A long heal). + +The node unit test `fast_time_60x_file_is_the_devnet_at_60x` (`consensus/core/src/config/params.rs`) reads this +file and checks every rule above against `DEVNET_PARAMS`; `override_params_carry_the_pow_schedule` and +`pow_schedule_defaults_and_clamps` check the new fields and that the devnet defaults are untouched. + +## Measured (4 October 2026, Apple M5 Max shared with four other agents and the live devnet, load 40 to 60) + +`node infra/fast-time/simnet.mjs --secs 240`: three devnet-v4 nodes (`target-integration/release/igneumd`, built +from devnet-v4 `a5ef8b07`), three vmine voters sharing 1 block/s, one real-hash CPU miner thread on node 0. + +| Event | Wall time from the first node's start | Chain position | +|---|---|---| +| next epoch seed reported in the template | 56.1 s | DAA 53 (boundary 60, lead 10, quarter-lead confirm 3) | +| first hourly program swap: template epoch index 0 to 1 | 65.1 s | DAA 60 | +| CPU miner's new program and 256 MiB cache ready for the new seed | 66.1 s (397 ms compile) | DAA 60 | +| first finality lock | 185.5 s | checkpoint 5, blue score 150, DAA 149 (checkpoint 4 sat at DAA 119, one under `min_daa` 120, so it could not certify) | + +Sinks agreed on all three nodes at the end (DAA 171). On the devnet the same two events come at DAA 3,600 (one hour) +and DAA 7,200 plus a checkpoint (two hours and change). + +Harness scenarios, same binaries, before and after: + +| Run | Devnet profile | 60x profile | +|---|---|---| +| `tools/finality-attacks` s3 (dishonest aggregators, six voters at 6 blocks/s) | 32 min for the seven-scenario catalogue at SCALE 0.6 on the 3 Oct node (bench log 4 Oct 2026, s3 alone 180 s plus bring-up); on today's rule (`min_daa` = window) the window fills at DAA 7,200 = 20 min at 6 blocks/s, so s3 at full scale shows no lock inside its 300 s | 113 s wall, 16 locks per node, 0 conflicting certificates, lock hashes agree across nodes, median lock latency 1,018 ms: PASS | +| `tools/harness` s3 (partition and heal in the in-process simulator, `igneum-harness-sim` of the devnet-v4 line) | 983 s wall for the four cuts (120, 600, 1,800 and 3,700 s virtual: 46, 151, 400 and 831 s wall, two at a time), all PASS | 43 s wall for the three cuts (10, 30 and 62 s virtual: 18, 20 and 26 s wall), all PASS, the 62-s cut beyond the 60-s merge depth converged with a 34-block reorg | + +The 3 Oct catalogue run reported 3 to 47 s wall per s3 cut on the ordering-layer-only `harness` branch; the +devnet-v4 simulator carries the execution layer and costs about 70 ms per block on this loaded machine, hence the +983 s. The before and after above are the same binary on the same day. + diff --git a/infra/fast-time/override-60x.json b/infra/fast-time/override-60x.json new file mode 100644 index 000000000..d7846a29a --- /dev/null +++ b/infra/fast-time/override-60x.json @@ -0,0 +1,53 @@ +{ + "timestamp_deviation_tolerance": 132, + "past_median_time_window_size": 27, + "difficulty_window_size": 661, + "min_difficulty_window_size": 150, + "difficulty_rule": "igneum-dual", + "coinbase_payload_script_public_key_max_len": 150, + "max_coinbase_payload_len": 16384, + "max_tx_inputs": 1000, + "max_tx_outputs": 1000, + "max_signature_script_len": 250000, + "max_script_public_key_len": 10000, + "mass_per_tx_byte": 1, + "mass_per_script_pub_key_byte": 10, + "mass_per_sig_op": 1000, + "block_mass_limits": { "compute": 500000, "storage": 500000, "transient": 1000000 }, + "block_lane_limits": { "lanes_per_block": 50, "gas_per_lane": 1000000000 }, + "storage_mass_parameter": 1000000000000, + "deflationary_phase_daa_score": 0, + "pre_deflationary_phase_base_subsidy": 50000000000, + "skip_proof_of_work": false, + "max_block_level": 250, + "pruning_proof_m": 1000, + "blockrate": { + "target_time_per_block": 1000, + "ghostdag_k": 18, + "past_median_time_sample_rate": 10, + "difficulty_sample_rate": 4, + "max_block_parents": 10, + "mergeset_size_limit": 180, + "merge_depth": 60, + "finality_depth": 720, + "pruning_depth": 13838, + "coinbase_maturity": 2 + }, + "pre_crescendo_target_time_per_block": 1000, + "crescendo_activation": 0, + "genesis_bits": 487587840, + "finality": { + "checkpoint_interval": 30, + "checkpoint_depth": 20, + "weight_window": 120, + "dust": 5, + "presence_window": 1, + "aggregators": 8, + "equivocation_ban": 120, + "min_daa": 120, + "aggregator_fallback": 1 + }, + "pow_epoch_blocks": 60, + "pow_epoch_lead": 10, + "pow_day_ms": 1440000 +} diff --git a/infra/fast-time/simnet.mjs b/infra/fast-time/simnet.mjs new file mode 100644 index 000000000..f70fc7d53 --- /dev/null +++ b/infra/fast-time/simnet.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// Proof of the 60x fast-time profile: a 3-node private network on 127.0.0.1 ports 29500 and up, data under +// /tmp/igneum-fast-time, network id igneum-devnet-950, every node on infra/fast-time/override-60x.json with +// skip_proof_of_work. Three voting vmine miners (the fin-attacks igneum-miner, one per node) share 1 block/s, the +// devnet rate, so DAA score advances one per second as on the devnet. One real CPU miner (devnet-v4 igneum-miner, +// --engine igneum-pow, 1 thread) follows node 0's templates and prints its program swap at every epoch boundary; +// at the devnet genesis difficulty a CPU thread finds no block, so it adds nothing to the chain. +// +// Records, in wall seconds from the first node's start: the first finality lock (getFinalityCheckpoints) and the +// first hourly program swap (template epoch index 0 -> 1 at DAA 60, and the CPU miner's "program and cache ready" +// line for the new seed). Expected: lock within about 3 minutes (window 120 DAA + checkpoint depth), swap within +// about 2 minutes. Never touches the live devnet (26610/26611, 26640/26641, 28640). +// +// node infra/fast-time/simnet.mjs [--secs 300] [--no-cpu-miner] + +import { spawn } from 'node:child_process'; +import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs'; +import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs'; +import { devAddress } from '../../tools/harness/lib/address.mjs'; + +const ROOT = new URL('../../', import.meta.url).pathname; +const FILE = `${ROOT}infra/fast-time/override-60x.json`; +const IGNEUMD = process.env.IGNEUMD || `${ROOT}vendor/igneum-node/target-integration/release/igneumd`; +const VMINE = process.env.IGNEUM_VMINE || `${ROOT}vendor/igneum-node-fin-attacks/target/release/igneum-miner`; +const CPU_MINER = process.env.IGNEUM_MINER || `${ROOT}vendor/igneum-node/target-integration/release/igneum-miner`; +const TMP = '/tmp/igneum-fast-time'; +const BASE = 29500, SUFFIX = 950; +const args = process.argv.slice(2); +const SECS = +(args[args.indexOf('--secs') + 1] || 300); +const CPU = !args.includes('--no-cpu-miner'); +const started = []; +const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a); +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); +for (const b of [IGNEUMD, VMINE]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); } + +rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); +const override = `${TMP}/override.json`; +writeFileSync(override, JSON.stringify({ ...JSON.parse(readFileSync(FILE, 'utf8')), skip_proof_of_work: true })); + +class Node { + constructor(i, connect = []) { + this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; + this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; + } + get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; } + async start() { + mkdirSync(this.dir, { recursive: true }); + const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', + `--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, + `--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes']; + if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0'); + const out = openSync(this.logFile, 'a'); + this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] }); + started.push(this.proc); + await sleep(800); + this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`); + log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}`); + return this; + } + grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } } +} +function miner(bin, argv, name, env = {}) { + const out = openSync(`${TMP}/${name}.log`, 'a'); + const p = spawn(bin, argv, { stdio: ['ignore', out, out], env: { ...process.env, ...env } }); + started.push(p); + return p; +} +async function stopAll() { + for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } + await sleep(1500); + for (const p of started) { try { p.kill('SIGKILL'); } catch { } } +} +process.on('SIGINT', async () => { await stopAll(); process.exit(130); }); + +const t0 = Date.now(); +const since = () => ((Date.now() - t0) / 1000).toFixed(1); +const n0 = await new Node(0).start(); +const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`]).start(); +const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`]).start(); +const nodes = [n0, n1, n2]; +log(`override: ${readFileSync(override, 'utf8').length} bytes; node log says: ${n0.grepLog(/PoW schedule/).join(' | ') || '(no PoW schedule line)'}`); + +// three voters sharing 1 block/s (the devnet rate): DAA advances 1 per second +nodes.forEach((n, i) => miner(VMINE, ['vmine', n.grpc, String(SECS), '--share', String(1 / 3), '--bps', '1', '--label', `v${i}`], `vmine-v${i}`)); +if (CPU) miner(CPU_MINER, ['mine', n0.grpc, '1', String(SECS), 'cpu0', '--engine', 'igneum-pow', '--status-secs', '30'], 'cpu-miner', { IGNEUM_POW_DAY_MS: '1440000' }); +const pay = devAddress('fast-time'); + +let firstLock = null, firstSwap = null, firstPrepare = null, cpuSwap = null, lastDaa = 0, lastEpoch = 0, lastReport = 0; +const samples = []; +while (Date.now() - t0 < SECS * 1000) { + await sleep(1000); + let daa = null, epoch = null, nextSeed = null, cps = null; + try { + const t = await n0.rpc.call('getBlockTemplate', { payAddress: pay, extraData: [] }); + const pe = t.powEpoch || t.pow_epoch || {}; + daa = pe.virtualDaaScore ?? t.block?.header?.daaScore; epoch = pe.epochIndex; nextSeed = pe.nextEpochSeed; + } catch (e) { log(`template: ${e.message}`); } + try { cps = await n0.rpc.call('getFinalityCheckpoints', { last: 20 }); } catch { } + const locked = (cps?.checkpoints || []).filter(c => c.state === 'locked'); + if (firstPrepare == null && nextSeed) { firstPrepare = +since(); log(`next epoch seed known at ${firstPrepare} s (daa ${daa})`); } + if (firstSwap == null && epoch >= 1) { firstSwap = +since(); log(`PROGRAM SWAP: template epoch index ${epoch} at ${firstSwap} s wall (daa ${daa})`); } + if (firstLock == null && locked.length) { + firstLock = +since(); + const c = locked[0]; + log(`FIRST LOCK: checkpoint ${c.index} (blue score ${c.blueScore ?? c.blue_score}, daa ${c.daaScore ?? c.daa_score}) locked at ${firstLock} s wall`); + } + if (cpuSwap == null && CPU) { + const lines = (() => { try { return readFileSync(`${TMP}/cpu-miner.log`, 'utf8').split('\n').filter(l => /program and 256 MiB cache ready/.test(l)); } catch { return []; } })(); + if (lines.length >= 2) { cpuSwap = +since(); log(`CPU MINER SWAP at ${cpuSwap} s wall: ${lines[1].trim()}`); } + } + if (epoch != null && epoch !== lastEpoch) { log(`epoch ${lastEpoch} -> ${epoch} at daa ${daa}, ${since()} s`); lastEpoch = epoch; } + lastDaa = daa ?? lastDaa; + if (Date.now() - lastReport > 15000) { + lastReport = Date.now(); + const lockedPer = await Promise.all(nodes.map(async n => { try { const c = await n.rpc.call('getFinalityCheckpoints', { last: 400 }); return (c.checkpoints || []).filter(x => x.state === 'locked').length; } catch { return '?'; } })); + log(`t=${since()} s daa ${daa} epoch ${epoch} nextSeed ${nextSeed ? 'known' : 'no'} locked per node ${lockedPer.join('/')}`); + samples.push({ t: +since(), daa, epoch, locked: lockedPer }); + } + if (firstLock != null && firstSwap != null && (cpuSwap != null || !CPU) && Date.now() - t0 > 150_000) break; +} +const sinks = await Promise.all(nodes.map(async n => { try { return (await n.rpc.call('getBlockDagInfo')).sink.slice(0, 12); } catch { return '?'; } })); +const summary = { first_lock_s: firstLock, first_swap_s: firstSwap, next_seed_known_s: firstPrepare, cpu_miner_swap_s: cpuSwap, final_daa: lastDaa, sinks_agree: new Set(sinks).size === 1, sinks, samples, secs: SECS, node: IGNEUMD }; +writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2)); +log(`SUMMARY first lock ${firstLock} s, first program swap ${firstSwap} s (next seed known at ${firstPrepare} s), CPU miner swap ${cpuSwap} s, final daa ${lastDaa}, sinks agree ${summary.sinks_agree}`); +await stopAll(); +process.exit(firstLock != null && firstSwap != null ? 0 : 1); diff --git a/site/bench.html b/site/bench.html index e846b0037..834fc42ae 100644 --- a/site/bench.html +++ b/site/bench.html @@ -68,7 +68,7 @@ footer{border-top:1px solid var(--line);padding-block:32px 48px;font-size:13px;c

Engineering log

Every measurement the project has made, newest at the bottom, written by the people and agents who ran it, with the commands and hardware. Prototype numbers are not mining numbers and say so.

- +

Igneum bench log

Append-only. Every number here was measured on the machine named, on the date given.

2026-10-03 proto-metal / igneum-bench, first run

@@ -217,7 +217,25 @@ footer{border-top:1px solid var(--line);padding-block:32px 48px;font-size:13px;c

4 October 2026, first hourly program swap on the live devnet: compile-ahead, no pause, two cards

Live devnet v4, epoch boundary at DAA 3,600 (11:05:07 BST). The node announced the next seed once the sink was 150 DAA past the seed score (next_epoch_seed, confirm = lead/4); each miner sent prepare to its worker and the worker built the next program in the background while the current one mined.

MachinePrepare sentCompileSwap at 3,600RestartRate before / afterRejected
Mac M5 Max (Metal, prepare 1)449 DAA before the boundary82 ms0.01 ms, resident 2 programsnone26.7 / 26.7 MH/s0
RTX 5090 (CUDA, nvcc in the background)same template1,285 ms (nvcc 1,129, cache 4, dataset 23)0.00 ms, resident 2 programs 2 datasetsnone121.8 / 123.4 MH/s0
AMD Radeon integrated (OpenCL)samepreparedno restartnone2.74 / 2.74 MH/s0
-

Reading: the compile-ahead rule (3 October 2026 decision: never restart every miner at once) holds on real values with three vendors; the hash rate is unbroken through the boundary and the launcher's exit-42 rebuild path was not used (rebuilds 0). The next boundary is DAA 7,200, which is also the first finality lock (weight window 7,200).

+

Reading: the compile-ahead rule (3 October 2026 decision: never restart every miner at once) holds on real values with three vendors; the hash rate is unbroken through the boundary and the launcher's exit-42 rebuild path was not used (rebuilds 0). The next boundary is DAA 7,200, which is also the first finality lock (weight window 7,200).

+

4 October 2026, proving: devnet v4 shards on the Apple M5 Max CPU, loaded machine (execution-engineer, proving)

+

Machine: Apple M5 Max (18 cores, 64 GB), macOS Darwin 25.6.0, load average 38 to 47 during the runs (the live devnet node and Metal miner, another agent's cross-builds, this agent's exports), everything under nice -n 19. Toolchain: SP1 v6.8.1 (cargo-prove c84ada1, succinct rustc 1.96.0-dev, circuit v6.1.0), sp1-sdk 6.8.1 CPU prover, revm 43.0.3, alloy-trie 0.9.8. Code: proving/igneum-prove at the "Proving fixtures: blocks of one, two and four shards" commit: two guests (shard program id 0x7b274fc9..., aggregator id 0x36e952f0...), port of igneum-exec b7fca5a0. Fixtures: proving/fixtures/block-{338,341,344} cut by igneum-prove-export from tools/prove-fixtures/seq.json (a private one-node simnet, execution-layer b7fca5a0 binaries whose exec crate is byte-identical on devnet-v4; the devnet-v4 worktree had another agent's uncommitted edits and was not built), which replayed all 346 segments from genesis and matched every node state root (final root 0xe0f269dc...); block-56-transfers-3shards is the v0 block 56 cut at a test budget of 200 pgas. S_p = 7,500,000 pgas (provisional, B_p / 4).

+

Statement per shard: from the carried-in position and a witness of the touched accounts, slots and trie nodes (checked against the pre-root), execute the shard's transactions and commit the post-root, the shard's receipts root, gas, pgas, the carry links and the prover's payout address; the aggregator verifies the shard proofs in order and commits the block. The host checks the native cut (shards chain and sum to the block) and rejects three tampered witnesses before any proof, on every fixture.

+
FixtureTxsEVM gaspgasShards (pgas each)Witness per shard: accounts / slots / leaves / hashesInput bytes per shard
block-338-shard1 (3 modexp calls of 652 iterations, 6 transfers, 2 Counter increments)111,390,7736,751,5681 (6.75 M)19 / 3 / 24 / 1321,447
block-341-shards2142,564,83813,499,3602 (6.75 M, 6.75 M)11 / 1 / 12 / 17; 16 / 3 / 22 / 1218,372; 20,335
block-344-shards4 (near B_p)204,947,16826,994,9444 (6.75 M each)11 / 1 / 12 / 17; 7 / 1 / 8 / 18; 7 / 1 / 7 / 21; 16 / 3 / 23 / 1018,371; 17,125; 17,150; 20,362
block-56-transfers-3shards (test cut at 200 pgas)363,0006003 (200 each)4 / 0 / 5 / 0; 2 / 0 / 1 / 4; 2 / 0 / 1 / 54,298; 3,671; 3,720
+

SP1 executor (--mode execute, no proof):

+
ShardCyclesProver gasCycles per EVM gasCycles per pgasExecute s
block-344 shard 0 (6 txs)60,015,75549,669,6174896.9
block-344 shard 1 (3 modexp calls)59,619,77849,236,4585097.7
block-344 shard 2 (3 modexp calls)59,628,33449,243,9005094.6
block-344 shard 3 (8 txs)60,417,38250,172,2794696.5
block-344 aggregator over 4 shards (deferred verification off)1,664,2550.1
block-56 test shards 0, 1, 2 (one transfer each)315,235; 271,190; 274,95413 to 151,356 to 1,5760.1
block-56 aggregator over 3 shards1,544,6810.1
+

CPU proofs (SP1_PROVER=cpu), block-56-transfers-3shards:

+
StageProve sProof bytesVerify sVerified
setup (prover client plus two key setups)39.4 to 60.6 (keys 3.3 + 3.2 of it; the rest is the client)
shard 0: core83.17,310,2570.368yes
shard 0: compressed272.31,272,8970.075yes
block mode, shard 0: compressed336.91,272,8970.068yes
block mode, shard 1: compressed305.41,272,8970.066yes
block mode, shard 2: compressed245.31,272,8970.064yes
block mode, aggregation over the 3 shard proofs (recursion, deferred proofs)244.51,272,9090.084yes, shard program id and claim checked
block mode, end to end (first shard proof to the verified block proof, 10:07:22 to 10:26:21 UTC)1,139
+
RTX 5090 (PROVE-SHARD.bat)shard at S_p: execute, core, compressedtwo-shard block end to endfour-shard block end to end
pending
+

Reading: the shard at S_p is 60 M cycles on the prototype table, 9 cycles per pgas against the unit's 1,000 (the modexp entry is about 100x its SP1 cost: R1, one number); a plain transfer shard is 1,400 to 1,600 cycles per pgas because the 200-pgas intrinsic charge carries the fixed cost of the witness check and the two root computations. The aggregator statement is 1.5 to 1.7 M cycles (bincode, an unpatched sha256 of each shard's public values, the keccaks), small next to a shard. The CPU proof times are 3.8x and 4.9x the 3 October v0 numbers on a comparable statement, on a machine three times as loaded; the GPU row stays empty until the PC runs. The devnet was not touched; the simnet ran on ports 29300, 29301 and 29390 and was stopped.

+

2026-10-04, fast time (60x test profile), Linux cross-compile from the Mac, CI on every push (consensus-engineer)

+

Machine: Apple M5 Max, 64 GB, shared with four other agents and the live devnet (load 40 to 76 throughout), every job at nice 19 with at most 4 cargo jobs. The live devnet (26610/26611, 26640/26641, 28640), the Metal worker and the seed's /opt/igneum/v4/bin were not touched.

+

Fast time. infra/fast-time/override-60x.json is the devnet with every clock-like consensus parameter divided by 60 and every block count unchanged (infra/fast-time/README.md lists each field and why it scales or not). The hourly program epoch and its lead are now consensus parameters carried by the override file (pow_epoch_blocks, pow_epoch_lead, plus pow_day_ms for the dataset day; devnet-v4 a5ef8b07, devnet defaults unchanged: kaspa-consensus-core 79 tests, kaspa-pow 7, igneum-pow 39 pass, and a new test reads the 60x file and checks every rule against DEVNET_PARAMS). Proof (infra/fast-time/simnet.mjs, three devnet-v4 nodes on ports 29500+, igneum-devnet-950, three vmine voters sharing 1 block/s, one real-hash CPU miner thread): next epoch seed in the template at 56.1 s (DAA 53), program swap at 65.1 s wall (DAA 60; the CPU miner's new program and cache ready 397 ms later), first finality lock at 185.5 s wall (checkpoint 5, blue score 150, DAA 149; checkpoint 4 sat one DAA under min_daa 120), sinks identical on all three nodes. The devnet reaches the same two events at DAA 3,600 and DAA 7,200 plus a checkpoint.

+
Harness run, same binariesDevnet profile60x profile (--fast-time)
tools/finality-attacks s3, dishonest aggregatorscatalogue 32 min at SCALE 0.6 on the 3 Oct node (above); on today's rule the window fills at DAA 7,200 = 20 min at 6 blocks/s before any lock113 s wall: 16 locks per node, 0 conflicting certificates, lock hashes agree, median lock latency 1,018 ms, PASS
tools/harness s3, partition and heal (in-process simulator of the devnet-v4 line)983 s wall, four cuts of 120 / 600 / 1,800 / 3,700 virtual s (46 / 151 / 400 / 831 s wall), all PASS43 s wall, three cuts of 10 / 30 / 62 virtual s (18 / 20 / 26 s wall), all PASS; the 62-s cut beyond the 60-s merge depth converged with a 34-block reorg
+

Both harnesses take --fast-time (tools/harness/lib/net.mjs, tools/finality-attacks/lib/net.mjs): the node and the simulator then come from vendor/igneum-node/target-integration (the file carries fields only the devnet-v4 line knows), the merge-depth scenarios scale their cuts with the profile, and the finality runs default to the --quick scale.

+

Linux cross-compile. infra/cross/build-linux.sh: cargo-zigbuild 0.23.4 with zig 0.17.0 (brew install zig, cargo install cargo-zigbuild, rustup target add x86_64-unknown-linux-gnu), target x86_64-unknown-linux-gnu.2.36 (Debian 12 on the servers), -p kaspad -p igneum-miner --features kaspad/igneum-pow, target dir vendor/igneum-node/target-linux. Cold build: 1,856 s (30 min 56 s) at 4 jobs, nice 19, on this loaded machine; rocksdb (librocksdb-sys C++), lz4, blst, secp256k1 and the execution layer's crates all linked through zig; no crate failed, so cross was not needed (Docker is not installed here anyway). Output: ELF x86-64 PIE, igneumd 46,883,304 B and igneum-miner 8,978,424 B, dynamically linked against libc and libm only. Verified on igneum-seed-1 (Debian 12, glibc 2.36, 2 vCPU) in /root/xbuild-test: igneumd --version = igneumd 2.1.0, igneum-miner --help prints its usage, and a 60-s run of the cross-compiled node on igneum-devnet-951 (the 60x profile at genesis bits 2^16, real proof of work) with the cross-compiled miner on one CPU thread: the miner adopted the node's 60-block epoch from the template, built its 256 MiB cache in 395 ms, found 7 blocks at 0.011 MH/s, all 7 accepted by the node's own PoW check, 0 rejected (the x86 build of the lottery hash agrees between miner and node; neither binary exposes a standalone vector check). Against the alternatives: the seed's own build took 55 min on its 2 vCPU (4 Oct 2026, above), the builder VM 10 to 25 min on 8 vCPU plus its creation and deletion. BIN_SOURCE=mac is wired into infra/cloud-devnet/provision.sh (no builder VM, build/bin from the cross-compile) and infra/seed-nodes/stage-v4.sh (upload to /root/v4/out, install as before).

+

CI. .github/workflows/ci.yml runs on every push and pull request of the private repository: igneum-pow cargo test --release and the igneum-census build (49 s), the two simulators' --quick modes under a 120-s timeout (49 s for the job; sim/finality_v2.py --quick is now a true smoke run, 149 s at nice 19 on this loaded Mac and under 40 s on the runner, was 745 s; sim/difficulty/sim.py --quick is new, 36 s here), the site build, an internal link check of site/*.html (151 links, 0 broken) and a gh-free identity grep of the public export list after the generic scrub (tools/ci/identity-check.sh, tools/ci/forbidden-strings.txt: 157 files, 0 hits). First run green: https://github.com/igneum-network/igneum/actions/runs/37193811336, 54 s from trigger to completion. The node fork is gitignored and too big for the free runners today; the workflow says so.

+

Not done: igneum-harness-sim's per-block cost on the devnet-v4 line (about 70 ms here against 3 ms on the ordering-layer branch, the execution layer's follower) is what still bounds the harness, not the clocks; the fast-time presence window floors at one checkpoint; the GPU workers were not run on fast time (the CPU miner proved the swap).

diff --git a/site/journey.json b/site/journey.json index 40f40b7f6..a5dcefe60 100644 --- a/site/journey.json +++ b/site/journey.json @@ -50,6 +50,11 @@ } ], "log": [ + { + "date": "2026-10-04", + "text": "Proving: devnet v4 shards on the Apple M5 Max CPU, loaded machine", + "short": "Proving: devnet v4 shards on the Apple M5 Max CPU, loaded machine" + }, { "date": "2026-10-04", "text": "One-click Windows workers: what the Mac could measure", diff --git a/tools/finality-attacks/README.md b/tools/finality-attacks/README.md index a3378254b..d9f6e92b0 100644 --- a/tools/finality-attacks/README.md +++ b/tools/finality-attacks/README.md @@ -47,8 +47,15 @@ node tools/finality-attacks/run.mjs # the achievable catalogue, node tools/finality-attacks/run.mjs s1 s6 # named scenarios node tools/finality-attacks/run.mjs --quick # short durations (smoke) SCALE=0.7 node tools/finality-attacks/run.mjs # scale every duration +node tools/finality-attacks/run.mjs s3 --fast-time # the 60x fast-time profile (infra/fast-time/README.md): the weight + # window and min_daa are 120 DAA, so the first lock comes at about + # DAA 150 instead of 7,200; the node is the devnet-v4 integration build ``` +Under the devnet parameters of 4 October 2026 (`min_daa` = window = 7,200 DAA) a scenario cannot see a lock before +DAA 7,200, which at the six miners' 6 blocks/s is 20 minutes of warm-up per scenario; `--fast-time` brings that to +20 s (s3 measured: 16 locks per node and PASS in 113 s wall, 4 Oct 2026). + Results print as a table and are written to `/tmp/igneum-fin-attacks/results.{txt,json}`; the S8 transcript is at `/tmp/igneum-fin-attacks/s8-fin-rpc-attack.out`. Exit code is non-zero if any scenario failed. diff --git a/tools/finality-attacks/lib/net.mjs b/tools/finality-attacks/lib/net.mjs index 3f050c832..a2ceb5293 100644 --- a/tools/finality-attacks/lib/net.mjs +++ b/tools/finality-attacks/lib/net.mjs @@ -13,9 +13,15 @@ import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync, openSync } import { createServer, connect as tcpConnect } from 'node:net'; import { connectRpc } from './rpc.mjs'; -export const ROOT = new URL('../../../', import.meta.url).pathname; // /Users/joshm/Projects/igneum/ +export const ROOT = new URL('../../../', import.meta.url).pathname; // the repository root export const TARGET = process.env.IGNEUM_FIN_TARGET || `${ROOT}vendor/igneum-node-fin-attacks/target/release`; -export const IGNEUMD = process.env.IGNEUMD || `${TARGET}/igneumd`; +// --fast-time (or IGNEUM_FAST_TIME=1): the 60x profile (infra/fast-time/README.md): weight window, ban and min_daa +// 120 DAA instead of 7,200, 60-block epochs. The file carries the PoW schedule fields that only the devnet-v4 node +// knows, so the node then defaults to the integration build of that line; the hostile vmine miner stays the +// fin-attacks one (its RPCs are additive, it drove the v4 node before: docs/bench-log.md, 4 Oct 2026). +export const FAST_TIME = process.argv.includes('--fast-time') || process.env.IGNEUM_FAST_TIME === '1'; +export const FAST_TIME_FILE = `${ROOT}infra/fast-time/override-60x.json`; +export const IGNEUMD = process.env.IGNEUMD || (FAST_TIME ? `${ROOT}vendor/igneum-node/target-integration/release/igneumd` : `${TARGET}/igneumd`); export const MINER = process.env.IGNEUM_MINER || `${TARGET}/igneum-miner`; export const TMP = '/tmp/igneum-fin-attacks'; export const BASE_PORT = 27800; // gRPC/p2p/json for node i at BASE+i*10 (+0/+1/+2) @@ -29,7 +35,8 @@ export const sleep = (ms) => new Promise(r => setTimeout(r, ms)); export function overrideParams() { mkdirSync(TMP, { recursive: true }); const file = `${TMP}/override.json`; - writeFileSync(file, JSON.stringify({ skip_proof_of_work: true })); + const base = FAST_TIME ? JSON.parse(readFileSync(FAST_TIME_FILE, 'utf8')) : {}; + writeFileSync(file, JSON.stringify({ ...base, skip_proof_of_work: true })); return file; } diff --git a/tools/finality-attacks/run.mjs b/tools/finality-attacks/run.mjs index 796aa63e3..3e276f58d 100644 --- a/tools/finality-attacks/run.mjs +++ b/tools/finality-attacks/run.mjs @@ -7,12 +7,15 @@ // node tools/finality-attacks/run.mjs s1 s6 # named scenarios // node tools/finality-attacks/run.mjs --quick # short durations (smoke) // SCALE=0.5 node tools/finality-attacks/run.mjs # scale every duration +// node tools/finality-attacks/run.mjs s3 --fast-time # the 60x profile (infra/fast-time): the weight window fills +// # at DAA 120 instead of 7,200, so locks start within a minute; +// # durations default to the --quick scale -import { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP } from './lib/net.mjs'; +import { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP, FAST_TIME, IGNEUMD } from './lib/net.mjs'; import { mkdirSync, writeFileSync } from 'node:fs'; const QUICK = process.argv.includes('--quick'); -const SCALE = QUICK ? 0.35 : (parseFloat(process.env.SCALE || '1') || 1); +const SCALE = process.env.SCALE ? (parseFloat(process.env.SCALE) || 1) : (QUICK || FAST_TIME ? 0.35 : 1); const dur = (s) => Math.max(20, Math.round(s * SCALE)); const results = []; mkdirSync(TMP, { recursive: true }); @@ -323,6 +326,7 @@ const ORDER = ['s3', 's2', 's1', 's6', 's4', 's8', 's5']; async function main() { assertBinaries(); + if (FAST_TIME) log(`fast-time 60x profile: node ${IGNEUMD}`); const asked = process.argv.slice(2).filter(a => !a.startsWith('--')); const run = asked.length ? asked : ORDER; for (const key of run) { diff --git a/tools/harness/README.md b/tools/harness/README.md index 95e590946..b492a6d5d 100644 --- a/tools/harness/README.md +++ b/tools/harness/README.md @@ -48,6 +48,9 @@ node tools/harness/run.mjs # the full catalogue, priority order 5 node tools/harness/run.mjs s5 s2 --quick # named scenarios, short durations node tools/harness/run.mjs --no-bench-log # do not append to docs/bench-log.md node tools/harness/scenarios/s1-withhold.mjs --quick # one scenario on its own +node tools/harness/run.mjs s3 s4 --fast-time # the 60x fast-time profile (infra/fast-time): merge depth 60 s, so the + # partition and eclipse cuts are 10, 30 and 62 s instead of 600, 1,800 and + # 3,700; nodes and the simulator come from vendor/igneum-node/target-integration ``` Each run appends one dated entry to `docs/bench-log.md` with a row per scenario (criterion, measured result, pass diff --git a/tools/harness/lib/net.mjs b/tools/harness/lib/net.mjs index 891888605..f0317bc39 100644 --- a/tools/harness/lib/net.mjs +++ b/tools/harness/lib/net.mjs @@ -14,8 +14,13 @@ import { execSync } from 'node:child_process'; import { connectRpc } from './rpc.mjs'; export const ROOT = new URL('../../../', import.meta.url).pathname; +// --fast-time (or IGNEUM_FAST_TIME=1): every clock-like consensus parameter divided by 60 (infra/fast-time/README.md). +// The 60x file carries the PoW schedule fields, which only the devnet-v4 node and later know, so the binaries then +// default to the integration build of that line (vendor/igneum-node/target-integration) instead of the harness worktree. +export const FAST_TIME = process.argv.includes('--fast-time') || process.env.IGNEUM_FAST_TIME === '1'; +export const FAST_TIME_FILE = `${ROOT}infra/fast-time/override-60x.json`; export const WORKTREE = `${ROOT}vendor/igneum-node-harness`; -export const TARGET = process.env.IGNEUM_HARNESS_TARGET || `${WORKTREE}/target/release`; +export const TARGET = process.env.IGNEUM_HARNESS_TARGET || (FAST_TIME ? `${ROOT}vendor/igneum-node/target-integration/release` : `${WORKTREE}/target/release`); export const IGNEUMD = process.env.IGNEUMD || `${TARGET}/igneumd`; export const PROBE = process.env.IGNEUM_P2P_PROBE || `${TARGET}/igneum-p2p-probe`; export const SIM = process.env.IGNEUM_HARNESS_SIM || `${TARGET}/igneum-harness-sim`; @@ -31,11 +36,22 @@ export function overrideParams(extra = {}) { mkdirSync(TMP, { recursive: true }); const file = `${TMP}/override-params.json`; // skip_proof_of_work: the harness miner never hashes; every other rule (timestamps, DAA, GHOSTDAG, merge - // depth, mass, coinbase) runs unchanged. Devnet parameters otherwise (1 BPS, k 18, merge depth 3,600). - writeFileSync(file, JSON.stringify({ skip_proof_of_work: true, ...extra })); + // depth, mass, coinbase) runs unchanged. Devnet parameters otherwise (1 BPS, k 18, merge depth 3,600), or the + // 60x fast-time profile under --fast-time (merge depth 60, finality window 120 DAA, 60-block epochs). + const base = FAST_TIME ? JSON.parse(readFileSync(FAST_TIME_FILE, 'utf8')) : {}; + writeFileSync(file, JSON.stringify({ ...base, skip_proof_of_work: true, ...extra })); return file; } +// Consensus clock parameters the scenarios scale their durations by: merge depth in DAA seconds (3,600 on the +// devnet, 60 under --fast-time). A scenario cut "beyond merge depth" is mergeDepth + 100 s on the devnet and +// mergeDepth + 2 s at 60x. +export function clockParams() { + const o = FAST_TIME ? JSON.parse(readFileSync(FAST_TIME_FILE, 'utf8')) : {}; + const mergeDepth = o.blockrate?.merge_depth ?? 3600; + return { fastTime: FAST_TIME, mergeDepth, scale: 3600 / mergeDepth }; +} + export class Node { constructor(index, { connect = [], extraArgs = [], name } = {}) { this.index = index; diff --git a/tools/harness/lib/sim.mjs b/tools/harness/lib/sim.mjs index 35f500931..b39edf5de 100644 --- a/tools/harness/lib/sim.mjs +++ b/tools/harness/lib/sim.mjs @@ -1,13 +1,16 @@ // Runs the in-process simulator (vendor worktree crate igneum/harness-sim) and reads its JSON report. import { spawnSync, spawn } from 'node:child_process'; import { readFileSync, mkdirSync, existsSync } from 'node:fs'; -import { SIM, TMP, log } from './net.mjs'; +import { SIM, TMP, log, FAST_TIME, FAST_TIME_FILE } from './net.mjs'; + +// Under --fast-time the simulator runs the 60x profile too (igneum-harness-sim --override-params-file). +const fastArgs = () => (FAST_TIME ? ['--override-params-file', FAST_TIME_FILE] : []); export function runSim(name, args, { timeoutMs = 60 * 60_000 } = {}) { if (!existsSync(SIM)) throw new Error(`missing ${SIM}; build igneum-harness-sim in the harness worktree`); mkdirSync(`${TMP}/sim`, { recursive: true }); const out = `${TMP}/sim/${name}.json`; - const argv = [...args, '--out', out]; + const argv = [...args, ...fastArgs(), '--out', out]; log(`sim ${name}: igneum-harness-sim ${argv.join(' ')}`); const t0 = Date.now(); const r = spawnSync('nice', ['-n', '19', SIM, ...argv], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 }); @@ -28,7 +31,7 @@ export async function runSims(jobs, { parallel = 3 } = {}) { const { name, args } = jobs[i]; mkdirSync(`${TMP}/sim`, { recursive: true }); const out = `${TMP}/sim/${name}.json`; - const argv = [...args, '--out', out]; + const argv = [...args, ...fastArgs(), '--out', out]; log(`sim ${name}: igneum-harness-sim ${argv.join(' ')}`); const t0 = Date.now(); await new Promise((resolve) => { diff --git a/tools/harness/run.mjs b/tools/harness/run.mjs index c5c9359dd..014a898ec 100644 --- a/tools/harness/run.mjs +++ b/tools/harness/run.mjs @@ -3,13 +3,16 @@ // 127.0.0.1 ports 27200+ and /tmp/igneum-harness, writes a results table per run to docs/bench-log.md, and leaves // the test network stopped. The live devnet (26610/26611, 26640/26641, 28640) and the PC node are never touched. // -// node tools/harness/run.mjs [scenario ...] [--quick] [--no-bench-log] +// node tools/harness/run.mjs [scenario ...] [--quick] [--no-bench-log] [--fast-time] // scenarios: s5 s2 s1 s3 s6 s4 s7 (default: priority order 5,2,1,3,6,4,7) // --quick runs shorter block counts and durations for a smoke run. +// --fast-time runs the network and the simulator on infra/fast-time/override-60x.json (every clock-like consensus +// parameter divided by 60: merge depth 60, finality window 120 DAA, 60-block epochs) with the devnet-v4 binaries +// from vendor/igneum-node/target-integration; the merge-depth scenarios (3, 4) scale their cuts with it. // // See tools/harness/README.md. -import { stopAll, assertBinaries } from './lib/net.mjs'; +import { stopAll, assertBinaries, FAST_TIME, TARGET } from './lib/net.mjs'; import { benchLogEntry, appendBenchLog } from './lib/report.mjs'; import { stubRows } from './scenarios/stubs.mjs'; import { execSync } from 'node:child_process'; @@ -30,7 +33,8 @@ function machineLine() { try { cpu = execSync('sysctl -n machdep.cpu.brand_string').toString().trim(); } catch { } try { mem = (parseInt(execSync('sysctl -n hw.memsize').toString().trim(), 10) / 2 ** 30).toFixed(0) + ' GB'; } catch { } let load = ''; try { load = execSync('uptime').toString().match(/load averages?: ([\d. ]+)/)?.[1] || ''; } catch { } - return `Machine: ${cpu}, ${mem}, load ${load.trim()}. Private test network of igneumd (release, skip_proof_of_work devnet) on 127.0.0.1 ports 27200+, data /tmp/igneum-harness; the live devnet and the PC node were not touched. Harness: tools/harness/, node fork worktree vendor/igneum-node-harness.`; + const profile = FAST_TIME ? 'skip_proof_of_work devnet on the 60x fast-time profile, infra/fast-time/override-60x.json' : 'skip_proof_of_work devnet'; + return `Machine: ${cpu}, ${mem}, load ${load.trim()}. Private test network of igneumd (release, ${profile}) on 127.0.0.1 ports 27200+, data /tmp/igneum-harness; the live devnet and the PC node were not touched. Harness: tools/harness/, binaries ${TARGET}.`; } // Assemble one bench-log entry from result JSONs already written under /tmp/igneum-harness/results, without @@ -82,7 +86,7 @@ async function main() { const allRows = []; for (const key of picks) { if (!SCENARIOS[key]) { console.error(`unknown scenario ${key}`); continue; } - console.log(`\n==== scenario ${key}${quick ? ' (quick)' : ''} ====`); + console.log(`\n==== scenario ${key}${quick ? ' (quick)' : ''}${FAST_TIME ? ' (fast-time 60x)' : ''} ====`); try { const mod = await SCENARIOS[key](); const { rows } = await mod.run({ quick }); @@ -102,7 +106,7 @@ async function main() { const date = new Date().toISOString().slice(0, 10); const entry = benchLogEntry({ date: `${date}, consensus attack harness (consensus-engineer)`, - title: `catalogue run${quick ? ' (quick)' : ''} on the ordering-layer node`, + title: `catalogue run${quick ? ' (quick)' : ''}${FAST_TIME ? ' (fast-time 60x)' : ''} on the ordering-layer node`, machine: machineLine(), rows: allRows, notes: [ diff --git a/tools/harness/scenarios/s3-partition.mjs b/tools/harness/scenarios/s3-partition.mjs index 5e25d0fce..71d6db25f 100644 --- a/tools/harness/scenarios/s3-partition.mjs +++ b/tools/harness/scenarios/s3-partition.mjs @@ -8,11 +8,19 @@ // Runs in the in-process simulator. import { runSims, hist, maxDepth } from '../lib/sim.mjs'; +import { clockParams } from '../lib/net.mjs'; import { saveResult } from '../lib/report.mjs'; export async function run({ quick = false } = {}) { - const cuts = quick ? [120, 600] : [120, 600, 1800, 3700]; - const jobs = cuts.map(c => ({ name: `s3-cut${c}`, args: ['--scenario', 'partition', '--cut-at', '300', '--cut-secs', String(c), '--run-after', '600', '--sample-secs', '10', '--seed', '3'] })); + // Cuts as fractions of the merge depth: 1/30, 1/6, 1/2 and one beyond it (devnet 120, 600, 1,800, 3,700 s). Under + // --fast-time (merge depth 60) the same fractions are 2, 10, 30 and 62 s, with the 2-s cut dropped (it equals the + // 2-s network delay), and the warm-up and run-after shrink with the clock. + const { fastTime, mergeDepth } = clockParams(); + const fractions = quick ? [1 / 30, 1 / 6] : [1 / 30, 1 / 6, 1 / 2, null]; + let cuts = fractions.map(f => (f == null ? mergeDepth + (fastTime ? 2 : 100) : Math.round(mergeDepth * f))); + if (fastTime) cuts = cuts.filter(c => c > 2); + const cutAt = fastTime ? '120' : '300', runAfter = fastTime ? '120' : '600', sample = fastTime ? '2' : '10'; + const jobs = cuts.map(c => ({ name: `s3-cut${c}`, args: ['--scenario', 'partition', '--cut-at', cutAt, '--cut-secs', String(c), '--run-after', runAfter, '--sample-secs', sample, '--seed', '3'] })); const reports = await runSims(jobs, { parallel: 2 }); const rows = []; const data = []; reports.forEach((r, i) => { @@ -26,7 +34,7 @@ export async function run({ quick = false } = {}) { // one-block tip churn at 4 miners and a 2 s delay; the instantaneous sink snapshot (one_chain) is noisier. const pass = r.blue_converged && timeToHeal != null; data.push({ cut_s: c, heal_at_s: healS, converged_blue_at_s: convS, time_to_heal_s: timeToHeal, final_blue_spread: r.final_blue_spread, reorg_depth_at_heal: depths, max_reorg_per_node: r.reorg_hist.map(maxDepth), reorg_hist: r.reorg_hist, rejects: r.rejects, one_chain: r.one_chain, blue_converged: r.blue_converged, final_sinks: r.final_sinks.map(s => s.slice(0, 12)), counts: r.counts, wall_s: r.wall_s }); - rows.push({ scenario: `3 partition ${c} s${c > 3600 ? ' (beyond merge depth)' : ''}`, criterion: 'one chain after the merge-depth rule; reorg depth and time to heal recorded', result: `one chain: ${r.blue_converged} (blue scores within ${r.final_blue_spread} at the end); healed in ${timeToHeal ?? 'never'} s; losing-side reorg at heal ${Math.max(...depths)} chain blocks (per node ${depths.join('/')}); rejects ${rejects}`, pass }); + rows.push({ scenario: `3 partition ${c} s${c > mergeDepth ? ' (beyond merge depth)' : ''}${fastTime ? ' [fast-time 60x]' : ''}`, criterion: 'one chain after the merge-depth rule; reorg depth and time to heal recorded', result: `one chain: ${r.blue_converged} (blue scores within ${r.final_blue_spread} at the end); healed in ${timeToHeal ?? 'never'} s; losing-side reorg at heal ${Math.max(...depths)} chain blocks (per node ${depths.join('/')}); rejects ${rejects}`, pass }); }); saveResult('s3-partition', { rows, data }); return { rows, data }; diff --git a/tools/harness/scenarios/s4-eclipse.mjs b/tools/harness/scenarios/s4-eclipse.mjs index 3d56bb166..e2b2e26eb 100644 --- a/tools/harness/scenarios/s4-eclipse.mjs +++ b/tools/harness/scenarios/s4-eclipse.mjs @@ -6,11 +6,15 @@ // Runs in the in-process simulator. import { runSims, maxDepth } from '../lib/sim.mjs'; +import { clockParams } from '../lib/net.mjs'; import { saveResult } from '../lib/report.mjs'; export async function run({ quick = false } = {}) { - const cuts = quick ? [600] : [600, 1800]; - const jobs = cuts.map(c => ({ name: `s4-eclipse${c}`, args: ['--scenario', 'eclipse', '--cut-at', '60', '--cut-secs', String(c), '--run-after', '600', '--sample-secs', '10', '--seed', '4'] })); + // Eclipses of 1/6 and 1/2 of the merge depth (devnet 600 and 1,800 s; 10 and 30 s under --fast-time). + const { fastTime, mergeDepth } = clockParams(); + const cuts = (quick ? [1 / 6] : [1 / 6, 1 / 2]).map(f => Math.round(mergeDepth * f)); + const runAfter = fastTime ? '120' : '600', sample = fastTime ? '2' : '10'; + const jobs = cuts.map(c => ({ name: `s4-eclipse${c}`, args: ['--scenario', 'eclipse', '--cut-at', '60', '--cut-secs', String(c), '--run-after', runAfter, '--sample-secs', sample, '--seed', '4'] })); const reports = await runSims(jobs, { parallel: 2 }); const rows = []; const data = []; reports.forEach((r, i) => { @@ -26,7 +30,7 @@ export async function run({ quick = false } = {}) { const onHonestSink = lastSample.sinks[3] === lastSample.sinks[0]; const pass = victimGap <= 18 && rejoin != null; data.push({ eclipse_s: c, heal_at_s: r.heal_at_s, victim_rejoin_after_s: rejoin, victim_reorg_depth: victimDepth, victim_blue_gap_end: victimGap, victim_on_honest_sink: onHonestSink, reorg_depth_at_heal: r.heal_reorg_depth, victim_max_reorg: maxDepth(r.reorg_hist[3]), counts: r.counts, rejects: r.rejects, final_sinks: r.final_sinks.map(s => s.slice(0, 12)), wall_s: r.wall_s }); - rows.push({ scenario: `4 eclipse ${c} s`, criterion: 'victim rejoins the honest chain on reconnection within the merge-depth bound; reorg depth recorded', result: `victim rejoined ${rejoin ?? 'never'} s after reconnection (blue-score gap to honest ${victimGap} at the end); victim reorg depth ${victimDepth} chain blocks; adversary built ${r.counts[2].created} blocks that never entered the honest chain`, pass }); + rows.push({ scenario: `4 eclipse ${c} s${fastTime ? ' [fast-time 60x]' : ''}`, criterion: 'victim rejoins the honest chain on reconnection within the merge-depth bound; reorg depth recorded', result: `victim rejoined ${rejoin ?? 'never'} s after reconnection (blue-score gap to honest ${victimGap} at the end); victim reorg depth ${victimDepth} chain blocks; adversary built ${r.counts[2].created} blocks that never entered the honest chain`, pass }); }); saveResult('s4-eclipse', { rows, data }); return { rows, data };