diff --git a/igneum-pow/src/generator.rs b/igneum-pow/src/generator.rs index b55e2c954..8bbc72c9f 100644 --- a/igneum-pow/src/generator.rs +++ b/igneum-pow/src/generator.rs @@ -326,6 +326,11 @@ pub fn era_draw(era_bytes: &[u8], allowed: &[u8]) -> EraParams { let j = i + (r[i] % n as u64) as usize; c.swap(i, j); } + // Draws 8 and 9, consumed and not used (spec 01 section 1.13.1 for `epoch_len`; `docs/design/latency-ladder.md` + // section 2 for the latency ladder): both parameters are set by miner signal, and consuming their slots here means a + // later use of either changes no other draw. Nothing below reads them, so every value drawn above is what it was. + let _epoch_len_draw = s.next(); + let _latency_ladder_draw = s.next(); let mut chosen: Vec = c[..free].to_vec(); chosen.sort_unstable(); let mut pos = [0u8; 4]; @@ -804,7 +809,43 @@ pub const V3_CLASS: LoadClass = LoadClass { era: None, hot: None, ..LoadClass::M /// of 256 ALU instructions run 27 times per iteration ("mx8+sh256x27", 55,296 shadow instructions per hash). The /// base program, the 16 loads, the item construction, the cache growth rule and the era draw are class v3's, draw /// for draw, so a v4 epoch's day cache and dataset are the v3 day's. Composed with the era exactly as V3 is. -pub const V4_CLASS: LoadClass = LoadClass { shadow: Some(ShadowClass { instrs: 256, reps: 27 }), ..V3_CLASS }; +pub const V4_CLASS: LoadClass = LoadClass { shadow: Some(ShadowClass { instrs: V4_SHADOW_INSTRS, reps: V4_SHADOW_REPS }), ..V3_CLASS }; + +/// The shadow block size of class v4 at every rung of the latency ladder (`docs/design/latency-ladder.md`): 256 +/// instructions. The ladder moves the pass count alone. +pub const V4_SHADOW_INSTRS: u16 = 256; + +/// The shadow passes of class v4 at rung 0 of the latency ladder: 27 (`mx8+sh256x27`, about 102,100 counted ops). +pub const V4_SHADOW_REPS: u16 = 27; + +/// Class v4 at a rung of the latency ladder (`docs/design/latency-ladder.md` section 2): [`V4_CLASS`] with the +/// 256-instruction shadow block run `reps` times per iteration. `reps` 0 means the class's own count, so +/// `v4_class_at(0) == v4_class_at(27) == V4_CLASS` and a caller that knows no rung changes nothing. +pub fn v4_class_at(reps: u16) -> LoadClass { + if reps == 0 { + V4_CLASS + } else { + LoadClass { shadow: Some(ShadowClass { instrs: V4_SHADOW_INSTRS, reps }), ..V3_CLASS } + } +} + +/// The shadow passes of a class v4 load class at any rung of the ladder, the era draw set aside (`Some(27)` for +/// [`V4_CLASS`] itself); `None` for every other class, a measurement class with another block size included. +pub fn v4_rung_reps(class: &LoadClass) -> Option { + let base = LoadClass { era: None, ..*class }; + match base.shadow { + Some(ShadowClass { instrs: V4_SHADOW_INSTRS, reps }) if LoadClass { shadow: None, ..base } == V3_CLASS => Some(reps), + _ => None, + } +} + +/// Counted integer ops per hash of class v4 at `reps` shadow passes, the convention of +/// `docs/analysis/latency-shadow-2026-10-06.md` section 1: 930 for the base program and its loads, 1.83 ops per +/// shadow instruction (137 / 75 over the non-load weights), 8 iterations x 256 instructions x `reps` shadow +/// instructions. Approximate by construction; the label of a rung, never a consensus value. +pub fn v4_counted_ops(reps: u16) -> u64 { + 930 + (ITERATIONS as u64 * V4_SHADOW_INSTRS as u64 * reps as u64 * 183).div_ceil(100) +} /// The width set class v3's era draw chooses from: 4 bytes only (the read-width decision of 5 October 2026; the /// draw is consumed, so widening the set at genesis keeps the derivation). @@ -1009,7 +1050,12 @@ impl Program { /// || attempt_le32`. Written into every pack so a version 1 program, or another attempt of the same seed, /// can never be mistaken for this one. pub fn program_id(&self) -> u64 { - if self.class.is_v2() || self.generator == GENERATOR_VERSION_V3 || self.generator == GENERATOR_VERSION_V4 { + // Latency ladder (docs/design/latency-ladder.md section 7): a class v4 program above rung 0 carries its shadow + // size in the id (`program_id_class`, the "shadow/" bytes), so two rungs of one seed never share an id and a + // pack of another rung is refused as a pack of another class is. Rung 0 keeps `program_id(4, seed, attempt)` + // byte for byte, so every v4 id written before the ladder stands. + let v4_rung_0 = self.generator == GENERATOR_VERSION_V4 && LoadClass { era: None, ..self.class } == V4_CLASS; + if self.class.is_v2() || self.generator == GENERATOR_VERSION_V3 || v4_rung_0 { // Spec 01 section 1.4.6: a class v3 program's id is `program_id(3, seed, attempt)`, a class v4 program's // `program_id(4, seed, attempt)` (Counter ASIC 3.0); the generator version in the preimage separates // them from every version 2 program of the same seed @@ -1378,6 +1424,27 @@ pub fn generate_from_seed_bytes_program_class(seed_string: &str, seed_bytes: &[u p } +/// [`generate_from_seed_bytes_program_class`] at a rung of the latency ladder (`docs/design/latency-ladder.md` +/// section 2): `shadow_reps` is the shadow pass count the chain's step gives the epoch, 0 for the class's own. Class +/// v4 at a rung above 0 draws from [`v4_class_at`] with the era inside and generator 4 stamped; every other class, +/// and class v4 at rung 0, is [`generate_from_seed_bytes_program_class`] byte for byte. The base program, the 16 loads +/// and the era draw do not move with the rung: only the pass count of the shadow block does. +pub fn generate_from_seed_bytes_program_class_shadow(seed_string: &str, seed_bytes: &[u8], class: ProgramClass, era_bytes: Option<&[u8]>, shadow_reps: u16) -> Program { + if class != ProgramClass::V4 || shadow_reps == 0 || shadow_reps == V4_SHADOW_REPS { + return generate_from_seed_bytes_program_class(seed_string, seed_bytes, class, era_bytes); + } + let base = v4_class_at(shadow_reps); + match era_bytes { + Some(era) => generate_era_generator(seed_string, seed_bytes, base, era, &V3_ALLOWED, GENERATOR_VERSION_V4), + None => { + let mut p = generate_from_seed_bytes_class(seed_string, seed_bytes, base); + p.generator = GENERATOR_VERSION_V4; + p.era_bytes = None; + p + } + } +} + /// The program of a seed string (its UTF-8 bytes are the program seed). pub fn generate(seed_string: &str) -> Program { generate_from_seed_bytes(seed_string, seed_string.as_bytes()) @@ -2027,4 +2094,72 @@ mod tests { assert_eq!(again.shadow, sh.shadow); assert_eq!(crate::verify::hash_warp(&again, 0, &ds), h1); } + + /// Latency ladder (`docs/design/latency-ladder.md`), the known-failed case first: before the ladder a changed N + /// was a hard fork. Two nodes drawing class v4 at 27 and at 35 passes from the same seeds build the same base + /// program and the same shadow block, carry generator 4 on both and, under the id rule as it stood, the SAME id + /// (`program_id(4, seed, attempt)` reads no shadow size), yet their warps hash differently: every block of one is + /// invalid to the other and no pack line told them apart. After: the rung is a parameter of the chain's step, + /// rung 0 is `V4_CLASS` byte for byte, and a rung above carries its pass count in the id. + #[test] + fn latency_ladder_known_failed_a_changed_n_was_a_hard_fork_and_rungs_are_class_v4() { + let era = [7u8; 32]; + let today = generate_from_seed_bytes_program_class("igneum-genesis", b"igneum-genesis", ProgramClass::V4, Some(&era)); + let r0 = generate_from_seed_bytes_program_class_shadow("igneum-genesis", b"igneum-genesis", ProgramClass::V4, Some(&era), 0); + let r27 = generate_from_seed_bytes_program_class_shadow("igneum-genesis", b"igneum-genesis", ProgramClass::V4, Some(&era), V4_SHADOW_REPS); + assert_eq!(r0, today, "rung 0 is class v4 byte for byte"); + assert_eq!(r27, today, "27 passes is rung 0"); + let r1 = generate_from_seed_bytes_program_class_shadow("igneum-genesis", b"igneum-genesis", ProgramClass::V4, Some(&era), 35); + // the failed case, as it stood: the same base, the same block, the same seed words, generator 4 on both, one id + assert_eq!(r1.instrs, today.instrs, "the base program is the class's, draw for draw"); + assert_eq!(r1.shadow, today.shadow, "the block is the same draw; only the pass count moves"); + assert_eq!((r1.seed, r1.attempt, r1.generator, r1.era_bytes.clone()), (today.seed, today.attempt, today.generator, today.era_bytes.clone())); + assert_eq!(program_id(GENERATOR_VERSION_V4, &r1.seed, r1.attempt), program_id(GENERATOR_VERSION_V4, &today.seed, today.attempt), "the old rule gave both nodes one id"); + assert_eq!(r1.shadow_reps(), 35); + assert_eq!(r1.shadow_instrs_per_hash(), ITERATIONS * 256 * 35); + let ds = crate::verify::DatasetSource::new("2026-10-06", crate::verify::DatasetMode::ClosedForm, 20); + let h0 = crate::verify::hash_warp(&today, 0, &ds); + let h1 = crate::verify::hash_warp(&r1, 0, &ds); + assert_ne!(h0, h1, "a changed N is another hash: before the ladder, a hard fork"); + // after: the rung is in the id above rung 0; rung 0 keeps the id written before the ladder + assert_ne!(r1.program_id(), today.program_id(), "the rung is in the id"); + assert_eq!(r1.program_id(), program_id_class(GENERATOR_VERSION_V4, &r1.seed, r1.attempt, &r1.class)); + assert_eq!(today.program_id(), program_id(GENERATOR_VERSION_V4, &today.seed, today.attempt), "rung 0 keeps the v4 id"); + assert_eq!(r1.program_class(), ProgramClass::V4, "a rung is class v4"); + assert_eq!(v4_rung_reps(&r1.class), Some(35)); + assert_eq!(v4_rung_reps(&today.class), Some(27)); + assert_eq!(v4_rung_reps(&V4_CLASS), Some(27)); + assert_eq!(v4_rung_reps(&V3_CLASS), None); + assert_eq!(v4_rung_reps(&LoadClass::V2), None); + assert_eq!(v4_rung_reps(&LoadClass::MX8.with_shadow(64, 52)), None, "another block size is a measurement class, not a rung"); + assert_eq!(v4_class_at(0), V4_CLASS); + assert_eq!(v4_class_at(27), V4_CLASS); + assert_eq!(v4_class_at(35), LoadClass::parse("mx8+sh256x35").unwrap()); + assert_eq!(LoadClass { era: None, ..r1.class }, v4_class_at(35), "the era rides inside the rung's class"); + assert_eq!(r1.class.era, today.class.era, "the same era draw at every rung"); + assert!(check(&r1).is_ok(), "the acceptance rule reads the base program, which did not move"); + // every rung of the designed ladder is another program with its own id + let rungs = [27u16, 35, 53, 88, 173, 267]; + let ids: Vec = rungs.iter().map(|&r| generate_from_seed_bytes_program_class_shadow("igneum-genesis", b"igneum-genesis", ProgramClass::V4, Some(&era), r).program_id()).collect(); + for i in 0..ids.len() { + for j in 0..i { + assert_ne!(ids[i], ids[j], "rungs {} and {} share an id", rungs[i], rungs[j]); + } + } + // the ops labels of the rungs, within 1 percent of the measured rungs of algorithm.md 5.3a + for (r, ops) in [(27u16, 102_100u64), (35, 132_100), (53, 199_600), (88, 330_700), (173, 649_400), (267, 1_001_600)] { + let got = v4_counted_ops(r); + assert!(got.abs_diff(ops) * 100 < ops, "reps {r}: {got} counted ops against the label {ops}"); + } + // other classes ignore the rung; class v4 without an era takes it + assert_eq!(generate_from_seed_bytes_program_class_shadow("igneum-genesis", b"igneum-genesis", ProgramClass::V3, Some(&era), 35), generate_from_seed_bytes_program_class("igneum-genesis", b"igneum-genesis", ProgramClass::V3, Some(&era))); + assert_eq!(generate_from_seed_bytes_program_class_shadow("igneum-genesis", b"igneum-genesis", ProgramClass::V2, None, 35), generate_from_seed_bytes_program_class("igneum-genesis", b"igneum-genesis", ProgramClass::V2, None)); + let bare = generate_from_seed_bytes_program_class_shadow("igneum-genesis", b"igneum-genesis", ProgramClass::V4, None, 35); + assert_eq!((bare.generator, bare.shadow_reps(), bare.era_bytes.is_none()), (GENERATOR_VERSION_V4, 35, true)); + // the era stream consumes draws 8 and 9 after the seven it uses, so the seven are what they were: the pinned era + // packs of tests/packs.rs hold the values; here, the draw is a function of the bytes and the set alone + let e = era_draw(&era, &V3_ALLOWED); + assert_eq!(e, era_draw(&era, &V3_ALLOWED)); + assert_eq!(e.width_words, 1); + } } diff --git a/igneum-pow/src/lib.rs b/igneum-pow/src/lib.rs index 1b398fab5..244c4ab17 100644 --- a/igneum-pow/src/lib.rs +++ b/igneum-pow/src/lib.rs @@ -34,7 +34,7 @@ pub mod verify; pub use bind::{block_init_words, day_bytes, pow256_from_lane, target64_from_le256}; pub use accept::{check as accept_program, AcceptReport, Reject}; -pub use generator::{generate, generate_from_seed_bytes, generate_from_seed_bytes_program_class, Instr, LoadClass, Op, Program, ProgramClass, GENERATOR_VERSION, GENERATOR_VERSION_V3, GENERATOR_VERSION_V4, V3_CLASS, V4_CLASS}; +pub use generator::{generate, generate_from_seed_bytes, generate_from_seed_bytes_program_class, generate_from_seed_bytes_program_class_shadow, v4_class_at, v4_counted_ops, v4_rung_reps, Instr, LoadClass, Op, Program, ProgramClass, GENERATOR_VERSION, GENERATOR_VERSION_V3, GENERATOR_VERSION_V4, V3_CLASS, V4_CLASS, V4_SHADOW_INSTRS, V4_SHADOW_REPS}; pub use memhard::{cache_log2_words, dataset_log2_words, days_since_genesis, growth_doublings, Cache, MemhardCpu, MixParams, Shape}; pub use seed::{fnv1a64, seed_words, SplitMix64}; pub use verify::{hash_warp, interpret_warp_init, verify_block, DatasetMode, DatasetSource, Epoch}; diff --git a/igneum-pow/src/main.rs b/igneum-pow/src/main.rs index 3d32504ee..29bb77d80 100644 --- a/igneum-pow/src/main.rs +++ b/igneum-pow/src/main.rs @@ -44,6 +44,9 @@ struct Args { days: u64, /// The program class (Counter ASIC 2.0 seam): v2 (default), v3 (V3_CLASS, generator 3) or v4 (V4_CLASS, generator 4). program_class: Option, + /// The shadow pass count of a class v4 program at a rung of the latency ladder (`--shadow-reps`, 0 = the class's + /// own 27; `docs/design/latency-ladder.md`); read under `--program-class v4` only. + shadow_reps: u16, /// The era seed bytes a class v3 chain program records (`--era-hex`). era_hex: Option, /// Era layout: `--era igneum-era-test/` or `--era :<64 hex>` composes the era class over `--class` with @@ -99,6 +102,7 @@ fn usage() -> ! { \x20 also: w4, w16, w64, w64x4, p4,p16,p64[xN], m[g], +shx (latency-shadow block of S ALU instructions x R passes per iteration, Counter ASIC 3.0 item 8)\n\ \x20 --days N days since genesis for the cache growth rule of a class with it (default 0: the 2^26-word cache)\n\ \x20 --program-class v2|v3|v4 the program class of the seam (v3 = generator 3 on V3_CLASS, v4 = generator 4 on V4_CLASS = mx8+sh256x27, the chain's own derivation; --era-hex records the era seed)\n\ + \x20 --shadow-reps N class v4 at a rung of the latency ladder: the shadow block's pass count (0 = the class's own 27; docs/design/latency-ladder.md), with --program-class v4\n\ \x20 --era E era layout over --class: igneum-era-test/ or :<64 hex> (the 32-byte era seed E_n)\n\ \x20 --era-widths 4[,16,64] the width set the era draws from, in bytes (default 4: pinned; more lets the era draw it)" ); @@ -123,6 +127,7 @@ fn parse() -> Args { days: 0, program_class: None, era_hex: None, + shadow_reps: 0, era: None, era_widths: vec![1], }; @@ -146,6 +151,7 @@ fn parse() -> Args { "--days" => a.days = val().parse().unwrap_or_else(|_| usage()), "--program-class" => a.program_class = Some(ProgramClass::parse(&val()).unwrap_or_else(|| usage())), "--era-hex" => a.era_hex = Some(val()), + "--shadow-reps" => a.shadow_reps = val().parse().unwrap_or_else(|_| usage()), "--era" => a.era = Some(parse_era(&val()).unwrap_or_else(|| usage())), "--era-widths" => a.era_widths = parse_widths(&val()).unwrap_or_else(|| usage()), _ => usage(), @@ -222,7 +228,7 @@ fn epoch_of_class(a: &Args, mode: DatasetMode) -> (Epoch, String) { let label = format!("igneum-epoch/{eh}/day/{dh}"); let e = match a.program_class { Some(pc) => Epoch { - program: Epoch::chain_program(&eb, era.as_deref(), pc, &label), + program: Epoch::chain_program_shadow(&eb, era.as_deref(), pc, a.shadow_reps, &label), dataset: Epoch::chain_dataset_day(&db, pc, a.days, a.dataset_log2), }, None => Epoch::from_seed_bytes_day(&eb, &db, &label, a.class, a.days, a.dataset_log2), @@ -232,7 +238,7 @@ fn epoch_of_class(a: &Args, mode: DatasetMode) -> (Epoch, String) { _ => { let e = match a.program_class { Some(pc) => { - let program = igneum_pow::generator::generate_from_seed_bytes_program_class(&a.seed, a.seed.as_bytes(), pc, era.as_deref()); + let program = igneum_pow::generator::generate_from_seed_bytes_program_class_shadow(&a.seed, a.seed.as_bytes(), pc, era.as_deref(), a.shadow_reps); let lc = pc.load_class(); let shape = Shape::for_class_day(&lc, a.days); let log2 = if lc.growth { igneum_pow::memhard::dataset_log2_words(a.dataset_log2, a.days) } else { a.dataset_log2 }; @@ -410,7 +416,7 @@ fn show(a: &Args) { // the load class of --class, the era of --era composed over it let era_hex_bytes = a.era_hex.as_deref().map(|h| igneum_pow::bind::unhex(h).unwrap_or_else(|| usage())); let mut p = match a.program_class { - Some(pc) => igneum_pow::generator::generate_from_seed_bytes_program_class(&label, &bytes, pc, era_hex_bytes.as_deref()), + Some(pc) => igneum_pow::generator::generate_from_seed_bytes_program_class_shadow(&label, &bytes, pc, era_hex_bytes.as_deref(), a.shadow_reps), None => igneum_pow::generator::generate_from_seed_bytes_class(&label, &bytes, a.class), }; if let (None, Some((_, eb, _))) = (a.program_class, &a.era) { diff --git a/igneum-pow/src/verify.rs b/igneum-pow/src/verify.rs index 4b22630c4..34692b684 100644 --- a/igneum-pow/src/verify.rs +++ b/igneum-pow/src/verify.rs @@ -626,6 +626,13 @@ impl Epoch { crate::generator::generate_from_seed_bytes_program_class(label, epoch_seed, class, era_bytes) } + /// [`Epoch::chain_program`] at a rung of the latency ladder (`docs/design/latency-ladder.md`): `shadow_reps` is + /// the shadow pass count the chain's step gives the epoch (0 = the class's own, which is [`Epoch::chain_program`] + /// byte for byte). The node's engine and the miner's pack export call this with the step the template names. + pub fn chain_program_shadow(epoch_seed: &[u8], era_bytes: Option<&[u8]>, class: ProgramClass, shadow_reps: u16, label: &str) -> Program { + crate::generator::generate_from_seed_bytes_program_class_shadow(label, epoch_seed, class, era_bytes, shadow_reps) + } + /// The day's cache and dataset of [`Epoch::from_chain_seeds`], the one entry the node's engine builds a day /// cache through. The class is an argument because the Counter ASIC 2.0 integration gives class v3 its own item /// construction (the mixer multiplier) and cache size schedule (ca2-mixer); today both classes build the day of diff --git a/infra/fast-time/latency-ladder.mjs b/infra/fast-time/latency-ladder.mjs new file mode 100644 index 000000000..be95e4372 --- /dev/null +++ b/infra/fast-time/latency-ladder.mjs @@ -0,0 +1,277 @@ +#!/usr/bin/env node +// The latency ladder's fast-time gate (docs/design/latency-ladder.md section 9; the class-v4-signal.mjs shape): a 3-node +// network on override-60x.json, class v4 from genesis (v3 and the v4 floor at 0, the class window 0: class signalling off, +// so the ladder opens the header's high byte on its own), the ladder active from DAA 0 with one window of --window DAA +// (default 60, one epoch; seven windows = 420 DAA, so the first epoch whose seed block has seven full windows below it is +// epoch 8 at DAA 480), each node's ladder signal set by IGNEUM_LADDER_SIGNAL (--signal a,b,c of up|down|none), one real CPU +// miner per node. Ports 29720 and up, network igneum-devnet-972, data /tmp/igneum-fast-time-ladder. +// +// The cases and the known-failed case: +// --signal up,up,none --expect no-step two of three miners signal up: about 67 percent, rung 0 must hold (run 10 epochs) +// --signal up,up,up --expect step all three: rung 1 (35 shadow passes) from epoch 8, the first with seven full +// windows, every miner's rung-1 program id equal to the CLI's --shadow-reps 35 id and +// unequal to the rung-0 id, and NO second step inside the next two epochs +// --signal up,up,none --expect step the known-failed case: the harness must report FAIL (no step happened) +// +// node infra/fast-time/latency-ladder.mjs --signal a,b,c --expect step|no-step [--window 60] [--secs 900] [--epochs 10] +// IGNEUMD, IGNEUM_MINER, IGNEUM_POW name the binaries (defaults: the ladder fork worktree's target/release and +// igneum-pow/target/release/igneum-pow, the layout on igneum-build-1 under /srv/builds/igneum-wt-ladder). + +import { spawn, spawnSync } 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 BIN = process.env.IGNEUM_LADDER_BIN || `${ROOT}vendor/igneum-node-ladder/target/release`; +const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`; +const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`; +const IGNEUM_POW = process.env.IGNEUM_POW || `${ROOT}igneum-pow/target/release/igneum-pow`; +const TMP = process.env.IGNEUM_LADDER_TMP || '/tmp/igneum-fast-time-ladder'; +const BASE = 29720, SUFFIX = 972; +const NEVER = '18446744073709551615'; +const RUNG0 = 27, RUNG1 = 35, WINDOWS = 7, THRESHOLD = 9000; +const args = process.argv.slice(2); +const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? +args[i + 1] : dflt; }; +const sflag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; }; +const GENESIS_BITS = flag('genesis-bits', 0x1f010000); +const SECS = flag('secs', 900); +const EPOCHS = flag('epochs', 10); +const WINDOW = flag('window', 60); +const SIGNAL = (sflag('signal') || 'up,up,up').split(',').map(s => s.trim().toLowerCase()); +const EXPECT = sflag('expect') || 'step'; +if (!['step', 'no-step'].includes(EXPECT) || SIGNAL.length !== 3 || !SIGNAL.every(s => ['up', 'down', 'none'].includes(s))) { console.error('usage: --signal a,b,c (up|down|none) --expect step|no-step'); process.exit(2); } +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, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); } + +rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); +const baseText = readFileSync(FILE, 'utf8'); +const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; }; +const EPOCH = field('pow_epoch_blocks'); +const LEAD = field('pow_epoch_lead'); +const DAY_MS = field('pow_day_ms'); +// the first epoch whose seed block (the last chain block below L*e - lead) can have DAA >= 7 x WINDOW: L*e - lead - 1 >= 7W +let FIRST_STEP_EPOCH = 0; +while (FIRST_STEP_EPOCH * EPOCH - LEAD - 1 < WINDOWS * WINDOW) FIRST_STEP_EPOCH++; +function mergeOverrideText(text, fields) { + let out = text; + for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), ''); + const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) ? JSON.stringify(v) : v}`).join(', '); + return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`); +} +const override = `${TMP}/override.json`; +writeFileSync(override, mergeOverrideText(baseText, { + genesis_bits: GENESIS_BITS, skip_proof_of_work: false, + program_class_v3_activation_daa: '0', program_class_v4_activation_daa: '0', program_class_v4_signal_window_daa: '0', + latency_ladder_activation_daa: '0', latency_ladder_window_daa: String(WINDOW), +})); +log(`signals ${SIGNAL.join('/')}, expect ${EXPECT}; class v4 from genesis, the ladder active from DAA 0, window ${WINDOW} DAA x ${WINDOWS} (the first epoch that can step is ${FIRST_STEP_EPOCH}, DAA ${FIRST_STEP_EPOCH * EPOCH}); ${EPOCH} DAA per epoch, lead ${LEAD}; run ${SECS} s or ${EPOCHS} epochs`); + +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'); + // the node's own ladder signal: what its templates carry in bits 15 and 14 + this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_LADDER_SIGNAL: SIGNAL[this.i] } }); + started.push(this.proc); + await sleep(1200); + 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}, signals ${SIGNAL[this.i]}`); + 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); }); +process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); }); +const minerLog = (i) => { try { return readFileSync(`${TMP}/cpu${i}.log`, 'utf8').split('\n'); } catch { return []; } }; +const STEP_LINE = /Latency ladder step by miner signal: epoch (\d+) moves to rung (\d+) \((\d+) shadow passes, from rung (\d+)\): (up|down) in each of (\d+) consecutive windows of (\d+) DAA .*weakest up (\d+) bps, weakest down (\d+) bps/; +const LADDER_LINE = /Latency ladder from the override file/; +const ACTIVE_LINE = /Latency ladder active: rungs/; +const OWN_LINE = /Latency ladder signal from IGNEUM_LADDER_SIGNAL: this node signals (\w+)/; + +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]; +for (const n of nodes) log(`n${n.i}: ${n.grepLog(LADDER_LINE).map(l => l.replace(/^.*?(Latency ladder from)/, '$1'))[0] || '(no ladder line)'} | ${n.grepLog(OWN_LINE).map(l => l.replace(/^.*?(this node signals)/, '$1'))[0] || '(no signal line)'}`); +log(`n0 digest: ${n0.grepLog(/Consensus params digest/).map(l => l.replace(/^.*?digest: /, '').slice(0, 16)).join(' ')}`); +nodes.forEach((n, i) => miner(CPU_MINER, ['mine', n.grpc, '1', String(SECS), `cpu${i}`, '--engine', 'igneum-pow', '--payout-label', `cpu${i}`, '--status-secs', '30', '--no-vote'], `cpu${i}`, { IGNEUM_POW_DAY_MS: String(DAY_MS) })); +const pay = devAddress('fast-time-ladder'); + +const epochs = new Map(); +let firstStep = null, lastEpoch = -1, lastReport = 0, lastDaa = 0, endAt = null; +const samples = []; +while (Date.now() - t0 < SECS * 1000) { + await sleep(1000); + let daa = null, epoch = null, cls = null, reps = null, nextReps = null, step = null, nextStep = null, up = null, upWeak = null, down = null, sig = null, stepEpoch = null, eraSeed = 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; cls = pe.programClass; eraSeed = pe.eraSeed; + reps = pe.latencyLadderReps; nextReps = pe.nextLatencyLadderReps; step = pe.latencyLadderStep; nextStep = pe.nextLatencyLadderStep; + up = pe.latencyLadderUpBps; upWeak = pe.latencyLadderUpWeakestBps; down = pe.latencyLadderDownBps; sig = pe.latencyLadderSignal; stepEpoch = pe.latencyLadderStepEpoch; + } catch (e) { log(`template: ${e.message}`); } + if (epoch != null && epoch !== lastEpoch) { + epochs.set(epoch, { class: cls, reps, step, firstSeenDaa: daa, at: +since(), eraSeed: eraSeed == null ? null : String(eraSeed), up_bps: up, up_weakest_bps: upWeak, down_bps: down, step_epoch: stepEpoch ?? null }); + log(`epoch ${lastEpoch} -> ${epoch} at daa ${daa}, ${since()} s: template class ${cls} rung ${step} (${reps} passes), next rung ${nextStep} (${nextReps}), up ${up} bps (weakest of ${WINDOWS}: ${upWeak}), down ${down} bps, this node signals ${sig}, step took effect at epoch ${stepEpoch ?? 'none'}`); + if (firstStep == null && step > 0) { firstStep = { epoch, daa, step, reps, at: +since() }; log(`LADDER STEP: the template is rung ${step} (${reps} shadow passes) from epoch ${epoch} (daa ${daa}) at ${since()} s wall`); } + lastEpoch = epoch; + } + lastDaa = daa ?? lastDaa; + if (Date.now() - lastReport > 15000) { + lastReport = Date.now(); + const counts = await Promise.all(nodes.map(async n => { try { const d = await n.rpc.call('getBlockDagInfo'); return `${d.blockCount}/${String(d.sink).slice(0, 8)}`; } catch { return '?'; } })); + log(`t=${since()} s daa ${daa} epoch ${epoch} rung ${step} (${reps}) up ${up} bps weakest ${upWeak} blocks/sink per node ${counts.join(' ')}`); + samples.push({ t: +since(), daa, epoch, step, reps, up_bps: up, up_weakest_bps: upWeak, nodes: counts }); + } + // the end: two epochs after a step (to show no second step), or --epochs epochs when no step is expected + if (firstStep != null && daa != null && daa >= (firstStep.epoch + 2) * EPOCH + LEAD) { endAt = +since(); break; } + if (firstStep == null && daa != null && daa >= EPOCHS * EPOCH) { endAt = +since(); break; } +} +await sleep(3000); + +const dag = await Promise.all(nodes.map(async n => { try { return await n.rpc.call('getBlockDagInfo'); } catch (e) { return { error: e.message }; } })); +const genesis = dag[0].pruningPointHash; +async function allBlocks(n) { + const out = []; let low = genesis; const seen = new Set(); + for (let round = 0; round < 500; round++) { + const r = await n.rpc.call('getBlocks', { lowHash: low, includeBlocks: true, includeTransactions: false }); + const blocks = r.blocks || []; + let added = 0; + for (const b of blocks) { const h = b.verboseData?.hash || b.header?.hash; if (seen.has(h)) continue; seen.add(h); out.push({ hash: h, daa: +b.header.daaScore, version: +b.header.version, chain: !!b.verboseData?.isChainBlock }); added++; } + if (!blocks.length || added === 0) break; + low = (r.blockHashes || []).at(-1) || blocks.at(-1).verboseData?.hash; if (!low) break; + } + return out; +} +let blocks = []; +try { blocks = await allBlocks(n0); } catch (e) { log(`getBlocks: ${e.message}`); } +const BOUNDARY = firstStep ? firstStep.epoch * EPOCH : Infinity; +const before = blocks.filter(b => b.daa < BOUNDARY), after = blocks.filter(b => b.daa >= BOUNDARY); +// the ladder bits on the chain: bit 15 up, bit 14 down; the object byte (bits 8 to 13) must be 0 (class signalling off) +const bitsOf = (v) => (v & 0x8000) ? 'up' : (v & 0x4000) ? 'down' : 'none'; +const ladderBits = blocks.reduce((m, b) => { const k = bitsOf(b.version); m[k] = (m[k] || 0) + 1; return m; }, {}); +const upShareOnChain = blocks.length ? Math.round(10000 * (blocks.filter(b => bitsOf(b.version) === 'up').length) / blocks.length) : 0; +const objectBytes = blocks.reduce((m, b) => { const v = (b.version >> 8) & 0x3f; m[v] = (m[v] || 0) + 1; return m; }, {}); +// genesis carries header version 0 (genesis.rs), every mined block the block version 2 in its low byte +const lowBytes = new Set(blocks.filter(b => b.daa > 0).map(b => b.version & 0xff)); + +const programs = new Map(); +for (const i of [0, 1, 2]) for (const l of minerLog(i)) { + const m = /epoch seed ([0-9a-f]{64}) day (\d+) \(daa (\d+)\): program and 256 MiB cache ready in ([\d.]+) ms; class (v\d) program id ([0-9a-f]{16})/.exec(l); + if (!m) continue; + const k = m[1]; const e = programs.get(k) || { seed: k.slice(0, 16), epoch: Math.floor(+m[3] / EPOCH), class: m[5], id: m[6], miners: new Set() }; + if (e.id !== m[6] || e.class !== m[5]) e.disagree = true; + e.miners.add(i); programs.set(k, e); +} +const programRows = [...programs.values()].sort((a, b) => a.epoch - b.epoch).map(p => ({ epoch: p.epoch, class: p.class, program_id: p.id, seed: p.seed, miners: p.miners.size, disagree: !!p.disagree })); +function cliId(seedHex, eraHex, reps) { + if (!existsSync(IGNEUM_POW)) return null; + const r = spawnSync(IGNEUM_POW, ['show', '--epoch-hex', seedHex, '--program-class', 'v4', '--era-hex', eraHex, '--shadow-reps', String(reps)], { encoding: 'utf8' }); + const m = /program id ([0-9a-f]{16})/.exec(r.stdout || ''); + return m ? m[1] : null; +} +const idRows = []; +for (const [k, e] of programs) { + const ep = epochs.get(e.epoch); + if (!ep || ep.eraSeed == null) continue; + const reps = ep.reps ?? 0; + idRows.push({ epoch: e.epoch, seed: e.seed, reps, miners_id: e.id, miners: e.miners.size, cli_rung0: cliId(k, ep.eraSeed, 0), cli_at_reps: cliId(k, ep.eraSeed, reps) }); +} +const steppedRows = idRows.filter(r => r.reps !== RUNG0 && r.reps !== 0); +const accepted = [0, 1, 2].map(i => minerLog(i).filter(l => /ACCEPTED block/.test(l)).length); +const rejectedMiner = [0, 1, 2].map(i => minerLog(i).filter(l => /rejected nonce=|submit error/.test(l))); +const rejectedNode = nodes.map(n => n.grepLog(/PoW rejected|Rejected block|rejected block/i)); +const stepLines = nodes.map(n => n.grepLog(STEP_LINE).map(l => l.replace(/^.*?(Latency ladder step by miner signal)/, '$1'))); +const firstStepLine = stepLines.map(ls => ls[0] || null); +const stepEpochs = firstStepLine.map(l => { const m = l && STEP_LINE.exec(l); return m ? +m[1] : null; }); +const stepRungs = firstStepLine.map(l => { const m = l && STEP_LINE.exec(l); return m ? +m[2] : null; }); +const stepWeakestUp = firstStepLine.map(l => { const m = l && STEP_LINE.exec(l); return m ? +m[8] : null; }); +const sinks = dag.map(d => String(d.sink || '?').slice(0, 16)); +const counts = dag.map(d => d.blockCount ?? '?'); +const maxEpochSeen = Math.max(-1, ...epochs.keys()); +const repsSeen = [...epochs.values()].map(e => e.reps); +const afterStep = firstStep ? [...epochs.entries()].filter(([e]) => e > firstStep.epoch).map(([, v]) => v.step) : []; + +const common = { + zero_rejected_by_miners: rejectedMiner.every(r => r.length === 0), + zero_rejected_by_nodes: rejectedNode.every(r => r.length === 0), + sinks_agree: new Set(sinks).size === 1, + block_counts_agree: new Set(counts.map(String)).size === 1, + miners_agree_on_every_program: programRows.every(p => !p.disagree), + ladder_line_on_every_node: nodes.every(n => n.grepLog(LADDER_LINE).length > 0 && n.grepLog(ACTIVE_LINE).length > 0), + every_node_signals_its_bits: nodes.every((n, i) => n.grepLog(OWN_LINE).some(l => OWN_LINE.exec(l)[1] === SIGNAL[i])), + every_epoch_class_v4: [...epochs.values()].every(e => e.class === 4), + rung0_ids_equal_the_cli_rung0_id: idRows.filter(r => r.reps === RUNG0).length > 0 && idRows.filter(r => r.reps === RUNG0).every(r => r.cli_rung0 != null && r.cli_rung0 === r.miners_id), +}; +// every block carries block version 2, an object byte of 0 (class signalling off) and the ladder bits of one of the three +// nodes; genesis, made before any node, is the one bit-less block when every node signals (the first run of the known-failed +// case, 22:26Z, failed this check on genesis's version 0 in the low-byte test, a harness fault, not a chain one) +const noneNodes = SIGNAL.filter(s => s === 'none').length; +common.chain_carries_the_bits = blocks.length > 0 && [...lowBytes].every(v => v === 2) && Object.keys(objectBytes).every(v => +v === 0) + && Object.keys(ladderBits).every(k => SIGNAL.includes(k) || k === 'none') && (noneNodes > 0 || (ladderBits.none || 0) === 1); +let checks; +if (EXPECT === 'step') { + checks = { + ...common, + template_stepped_to_rung_1: firstStep != null && firstStep.step === 1 && firstStep.reps === RUNG1, + stepped_at_the_first_full_window_epoch: firstStep != null && firstStep.epoch === FIRST_STEP_EPOCH, + step_line_on_every_node_same_epoch: stepEpochs.every(e => e != null) && new Set(stepEpochs).size === 1 && stepEpochs[0] === (firstStep && firstStep.epoch) && stepRungs.every(r => r === 1), + weakest_up_at_or_above_threshold: stepWeakestUp.every(s => s != null && s >= THRESHOLD), + no_second_step_inside_seven_windows: firstStep != null && afterStep.length >= 2 && afterStep.every(s => s === 1) && stepLines.every(ls => ls.length === 1), + blocks_on_both_sides: before.length > 0 && after.length > 0, + rung1_ids_equal_the_cli_rung1_id: steppedRows.length > 0 && steppedRows.every(r => r.reps === RUNG1 && r.cli_at_reps != null && r.cli_at_reps === r.miners_id && r.miners === 3), + rung1_ids_differ_from_the_same_seed_rung0_id: steppedRows.length > 0 && steppedRows.every(r => r.cli_rung0 != null && r.cli_rung0 !== r.miners_id), + }; +} else { + checks = { + ...common, + template_never_above_rung_0: firstStep == null && repsSeen.every(r => r === RUNG0 || r === 0), + no_step_line_on_any_node: stepLines.every(ls => ls.length === 0), + ran_the_epochs: maxEpochSeen >= EPOCHS - 1, + passed_the_first_full_window_epoch: maxEpochSeen >= FIRST_STEP_EPOCH, + up_share_under_threshold_on_chain: upShareOnChain < THRESHOLD, + }; +} +const pass = Object.values(checks).every(Boolean); +const summary = { + pass, expect: EXPECT, signals: SIGNAL, checks, window: WINDOW, windows: WINDOWS, threshold_bps: THRESHOLD, epoch_blocks: EPOCH, lead: LEAD, first_step_epoch: FIRST_STEP_EPOCH, + node: IGNEUMD, miner: CPU_MINER, pow: IGNEUM_POW, template_step: firstStep, run_ended_at_s: endAt, final_daa: lastDaa, max_epoch_seen: maxEpochSeen, + epochs: Object.fromEntries([...epochs.entries()].map(([k, v]) => [k, v])), + blocks: { total: blocks.length, before_boundary: before.length, after_boundary: after.length, ladder_bits: ladderBits, object_bytes: objectBytes, up_share_bps_on_chain: upShareOnChain }, + programs: programRows, program_id_rows: idRows, accepted_per_miner: accepted, + rejected_by_miners: rejectedMiner.map(r => r.length), rejected_by_nodes: rejectedNode.map(r => r.length), + sinks, block_counts: counts, step_lines: stepLines, samples, +}; +writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2)); +log(`SUMMARY ${pass ? 'PASS' : 'FAIL'} (expect ${EXPECT}, signals ${SIGNAL.join('/')}): ${firstStep ? `rung ${firstStep.step} (${firstStep.reps} passes) from epoch ${firstStep.epoch} at DAA ${firstStep.daa}` : 'no step'}; epochs seen ${[...epochs.entries()].map(([e, v]) => `e${e}:r${v.step}:${v.up_weakest_bps}bps`).join(' ')}; chain bits ${JSON.stringify(ladderBits)} (${upShareOnChain} bps up); blocks ${before.length} / ${after.length}; rejected miners ${rejectedMiner.map(r => r.length).join('/')} nodes ${rejectedNode.map(r => r.length).join('/')}; sinks ${sinks.join(' ')} at ${counts.join('/')}`); +for (const r of idRows) log(`PROGRAM ID epoch ${r.epoch} seed ${r.seed} reps ${r.reps}: miners ${r.miners_id} (${r.miners} of 3) cli at reps ${r.cli_at_reps} cli rung 0 ${r.cli_rung0}`); +for (const [k, v] of Object.entries(checks)) if (!v) log(`FAILED CHECK ${k}`); +log(`summary: ${TMP}/summary.json`); +await stopAll(); +process.exit(pass ? 0 : 1); diff --git a/infra/fast-time/override-60x.json b/infra/fast-time/override-60x.json index f232111df..75a0e2cc5 100644 --- a/infra/fast-time/override-60x.json +++ b/infra/fast-time/override-60x.json @@ -52,14 +52,20 @@ "pow_epoch_lead": 10, "pow_day_ms": 1440000, "difficulty_v2_activation_daa": 18446744073709551615, + "difficulty_v3_activation_daa": 18446744073709551615, + "finality_daa_rule_activation_daa": 18446744073709551615, "proving_v0_activation_daa": 18446744073709551615, "finality_v3_activation_daa": 18446744073709551615, "program_class_v3_activation_daa": 18446744073709551615, "program_class_v4_activation_daa": 18446744073709551615, "program_class_v4_signal_window_daa": 120, + "latency_ladder": [{"reps": 27, "admissible": true}, {"reps": 35, "admissible": true}, {"reps": 53, "admissible": true}, {"reps": 88, "admissible": false}, {"reps": 173, "admissible": false}, {"reps": 267, "admissible": false}], + "latency_ladder_activation_daa": 18446744073709551615, + "latency_ladder_window_daa": 120, "proving_v1_fresh_rule_daa": 18446744073709551615, "exec_restart_number": 18446744073709551615, "exec_restart_hash": "", + "exec_restart_state_root": "", "exec_restart_trust_daa": 18446744073709551615, "pow_genesis_dataset_log2": 28, "proving_v1_activation_daa": 18446744073709551615, diff --git a/tools/ladder/verify-bench-remote.sh b/tools/ladder/verify-bench-remote.sh new file mode 100755 index 000000000..35adfc35f --- /dev/null +++ b/tools/ladder/verify-bench-remote.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# The latency ladder's verifier bound, measured on igneum-build-1 (docs/design/latency-ladder.md section 4): for every rung +# of the ladder, the cold verify of one 32-lane warp of class v4 at that rung on the reference core (core 40, 3.8 GHz under +# schedutil, nice 19), alone and with its SMT sibling (core 88) running the same bench, plus the average of 50 warps. The +# figure a rung's admissibility reads is the cold run with the sibling loaded; the gate is 10 ms. +# +# tools/ladder/verify-bench-remote.sh [--rungs "27 35 53 88 173 267"] [--pow ] [--out ] +# +# Runs ON the box under its measure hold (infra/build-server/remote-run.sh BR_MEASURE=1: waits for every running build, blocks +# new ones and the capacity layer until it ends; one JSONL line of kind measure in /srv/builds/_log/builds.jsonl). The binary is +# the box's own build of igneum-pow from this worktree (tools/build-remote.sh from igneum-pow/ puts it at +# /srv/builds//igneum-pow/target/release/igneum-pow); the results come back as one text file per run plus a table. +# A number taken beside another build is not a number (CLAUDE.md), which is what the hold is for. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BS_TOOL=ladder-verify-bench +# shellcheck source=../../infra/build-server/lib.sh +. "$HERE/../../infra/build-server/lib.sh" +RUNGS="27 35 53 88 173 267"; POW=""; OUT="" +while [ $# -gt 0 ]; do + case "$1" in + --rungs) RUNGS="$2"; shift 2 ;; --pow) POW="$2"; shift 2 ;; --out) OUT="$2"; shift 2 ;; + *) echo "unknown argument $1" >&2; exit 2 ;; + esac +done +bs_host +# the context of the igneum-pow crate (BS_WT, BS_WT_ROOT, branch and sha for the JSONL line); the binary is already on the box +( cd "$HERE/../../igneum-pow" ) || bs_die "no igneum-pow beside tools/" +pushd "$HERE/../../igneum-pow" > /dev/null; bs_context; popd > /dev/null +WT_ROOT="$BS_WT_ROOT" +WT_NAME="$BS_WT" +POW="${POW:-/srv/builds/$WT_NAME/igneum-pow/target/release/igneum-pow}" +OUT="${OUT:-$WT_ROOT/docs/design/latency-ladder-bench}" +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +REMOTE_OUT="/srv/builds/_log/ladder-bench/$STAMP" +mkdir -p "$OUT" +# the command remote-run.sh evals under the measure hold: per rung, the quiet-core run, then the two siblings at once +read -r -d '' CMD </dev/null || echo ?) kHz siblings \$(cat /sys/devices/system/cpu/cpu40/topology/thread_siblings_list) pow \$(sha256sum '$POW' | cut -c1-16)" > meta.txt +for r in $RUNGS; do + nice -n 19 taskset -c 40 '$POW' bench --seed igneum-genesis --day 2026-10-03 --class mx8+sh256x\$r --warps 50 > alone-\$r.txt 2>&1 + # the sibling's load must outlast the measured run (the first run of this script, 22:06Z: a 50-warp sibling finished during + # the measured run's own cache fill, so the "loaded" warps ran alone; 4,000 warps is 20 to 40 s, killed when the run ends) + nice -n 19 taskset -c 88 '$POW' bench --seed igneum-genesis --day 2026-10-03 --class mx8+sh256x\$r --warps 4000 > sibling-\$r.txt 2>&1 & + sib=\$! + sleep 1.5 + nice -n 19 taskset -c 40 '$POW' bench --seed igneum-genesis --day 2026-10-03 --class mx8+sh256x\$r --warps 50 > loaded-\$r.txt 2>&1 + kill \$sib 2>/dev/null; wait \$sib 2>/dev/null + echo "rung reps=\$r done \$(date -u +%H:%M:%SZ)" +done +echo "load after \$(cut -d' ' -f1-3 /proc/loadavg)" >> meta.txt +EOF +bs_log "measuring rungs $RUNGS on $BS_HOST under the measure hold (pow $POW); results to $OUT" +BR_MEASURE=1 BR_KIND=measure BR_COMMAND="igneum-pow bench per ladder rung (core 40 alone, then with core 88 loaded)" BR_TARGET=x86_64-unknown-linux-gnu \ + bs_remote_run "$(dirname "$POW")" "latency ladder verifier bench rungs $RUNGS" "$CMD" +bs_rsync -a "$BS_HOST:$REMOTE_OUT/" "$OUT/$STAMP/" +# the table: cold = the "warp base 0: single cold run" line, avg = the "CPU verify" line +python3 - "$OUT/$STAMP" $RUNGS <<'PY' +import re, sys, os +d = sys.argv[1]; rungs = sys.argv[2:] +def read(name): + try: t = open(os.path.join(d, name)).read() + except FileNotFoundError: return (None, None, None) + cold = re.search(r"warp base 0: single cold run ([\d.]+) ms", t) + avg = re.search(r"CPU verify: ([\d.]+) ms per 32-lane warp", t) + shadow = re.search(r"(\d+) shadow instructions per hash", t) + return (float(cold.group(1)) if cold else None, float(avg.group(1)) if avg else None, int(shadow.group(1)) if shadow else None) +out = [open(os.path.join(d, "meta.txt")).read().strip()] +out.append("| Rung | reps | Shadow instrs per hash | Counted ops (approx) | Cold, core alone (ms) | Avg of 50, alone (ms) | Cold, sibling loaded (ms) | Avg of 50, sibling loaded (ms) | Under 10 ms loaded |") +out.append("|---|---|---|---|---|---|---|---|---|") +for i, r in enumerate(rungs): + a = read(f"alone-{r}.txt"); l = read(f"loaded-{r}.txt") + ops = 930 + (8 * 256 * int(r) * 183 + 99) // 100 + f = lambda x: "?" if x is None else f"{x:.2f}" + ok = "?" if l[0] is None else ("yes" if l[0] < 10.0 else "NO") + out.append(f"| {i} | {r} | {a[2] or '?'} | {ops:,} | {f(a[0])} | {f(a[1])} | {f(l[0])} | {f(l[1])} | {ok} |") +print("\n".join(out)) +open(os.path.join(d, "table.md"), "w").write("\n".join(out) + "\n") +PY +bs_log "done; raw lines in $OUT/$STAMP"