From 965d9e96f4d60a8f1a4ca37d78eb0f700ef2233f Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:08:43 +0100 Subject: [PATCH] Counter ASIC 3.0 gates (hash): igneum-pow taken from release-0.3.20 (3f1652bf: the latency ladder's chain_program_shadow with the reps argument, never merged to master) with the AP-F8-1 amendment re-applied on top (the diff of a0aaca92 and the test fix, applied without offset); the seven packs re-export byte-identical (checked on v4-devnet-epoch0 and v4-era-5), so the ids and fingerprints stand; the pairing is against the fork's release-0.3.20-node dc141409 Co-Authored-By: Claude Fable 5.1 --- igneum-pow/src/generator.rs | 139 +++++++++++++++++++++++++++++++++++- igneum-pow/src/lib.rs | 2 +- igneum-pow/src/main.rs | 12 +++- igneum-pow/src/verify.rs | 7 ++ 4 files changed, 154 insertions(+), 6 deletions(-) diff --git a/igneum-pow/src/generator.rs b/igneum-pow/src/generator.rs index c29caf159..f6db262c7 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 @@ -1399,6 +1445,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()) @@ -2064,4 +2131,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