diff --git a/igneum-pow/src/verify.rs b/igneum-pow/src/verify.rs index f03bc460..91f9ee31 100644 --- a/igneum-pow/src/verify.rs +++ b/igneum-pow/src/verify.rs @@ -1,10 +1,36 @@ //! The CPU reference interpreter for one 32-lane warp (`cpuWarpTraced` in the Swift) and the API the node //! calls. Dataset words come from the memory-hard cache (default) or from the closed form (old packs). -use crate::generator::{generate, generate_class, Instr, LoadClass, Op, Program, ITERATIONS, LANES}; -use crate::memhard::MemhardCpu; +use crate::generator::{generate, generate_class, EraParams, Instr, LoadClass, Op, Program, ProgramClass, ITERATIONS, LANES}; +use crate::memhard::{hot_index, HotTable, Layout, MemhardCpu, Shape}; use crate::seed::day_key; +/// The load address of an era program (`docs/plans/era-layout.md` section 1.3): `y = rotl(x * M, R)`, then the +/// window of the load site, `k = min(win, D - 26)` (0 when `D <= 26`), `idx = ((y & (MASK >> k)) | ((off & +/// (2^k - 1)) << (D - k))) & MASK`. For every other class `idx = x & MASK`, the lottery hash's address. `mask` is +/// `2^D - 1`. The acceptance mirror calls this at the rule's constant `D = 28`. +#[inline(always)] +pub fn load_index(era: Option<&EraParams>, ins: &Instr, x: u32, mask: u32, log2: u32) -> u32 { + match era { + None => x & mask, + Some(e) => { + let (wm, off) = window(ins, mask, log2); + let y = x.wrapping_mul(e.stride_mul).rotate_left(e.stride_rot); + ((y & wm) | off) & mask + } + } +} + +/// The window of a load site at a dataset of `2^log2` words: `(window mask, offset)` such that +/// `idx = (y & window mask) | offset` lies in the site's aligned window of `2^(log2 - k)` words. +#[inline(always)] +pub fn window(ins: &Instr, mask: u32, log2: u32) -> (u32, u32) { + let k = (ins.win as u32).min(log2.saturating_sub(26)); + let wm = mask >> k; + let off = ((ins.off as u32) & ((1u32 << k) - 1)) << (log2 - k); + (wm, off) +} + /// Read-width experiment (5 October 2026): a `load` of `W` words folds every word into `dst`: /// `x = dst XOR w[0]; for j in 1..W: x = (rotl(x, FOLD_ROT) * FOLD_MUL) XOR w[j]; dst = x`. For `W = 1` this is the /// lottery hash's `dst XOR dataset[...]`. The fold is state-dependent (the rotate-multiply sits between the words), @@ -166,24 +192,55 @@ pub struct DatasetSource { /// in packs so any implementation can rebuild the key. Empty when the key was given directly. pub key_bytes: Vec, pub dataset: Dataset, + /// The hot table of the epoch (hot-table experiment, `docs/plans/hot-table.md`): `Some` when the program's + /// class has one; filled by [`Epoch::new_class`] and [`Epoch::from_seed_bytes_class`] from the program's seed + /// bytes. A hot load reads `hot[hot_index(src, words)]`. + pub hot: Option, } impl DatasetSource { /// Build the source for a day. Memory-hard mode fills the 256 MiB cache on the calling thread. pub fn new(day: &str, mode: DatasetMode, log2_words: u32) -> Self { - let mut ds = Self::from_key(day_key(day), mode, log2_words); + Self::new_shape(day, mode, log2_words, Shape::V2) + } + + /// [`DatasetSource::new`] with the construction's shape (mixer multiplier, cache size; Counter ASIC 2.0). + pub fn new_shape(day: &str, mode: DatasetMode, log2_words: u32, shape: Shape) -> Self { + let mut ds = Self::from_key_shape(day_key(day), mode, log2_words, shape); ds.key_bytes = format!("day/{day}").into_bytes(); ds } pub fn from_key(key: [u32; 8], mode: DatasetMode, log2_words: u32) -> Self { + Self::from_key_shape(key, mode, log2_words, Shape::V2) + } + + /// [`DatasetSource::from_key`] with the construction's shape. Memory-hard mode fills a cache of + /// `2^shape.cache_log2_words` words on the calling thread. + pub fn from_key_shape(key: [u32; 8], mode: DatasetMode, log2_words: u32, shape: Shape) -> Self { assert!((4..=32).contains(&log2_words), "dataset log2 must be in 4..=32"); let mask = if log2_words == 32 { u32::MAX } else { (1u32 << log2_words) - 1 }; let dataset = match mode { DatasetMode::ClosedForm => Dataset::ClosedForm { d0: key[0], d1: key[1] }, - DatasetMode::MemoryHard => Dataset::MemoryHard(MemhardCpu::new(key)), + DatasetMode::MemoryHard => Dataset::MemoryHard(MemhardCpu::with_shape(key, shape)), }; - Self { log2_words, mask, key, key_bytes: Vec::new(), dataset } + Self { log2_words, mask, key, key_bytes: Vec::new(), dataset, hot: None } + } + + /// This source with the hot table of the epoch whose program seed bytes are `seed_bytes` (`mb` MiB). + pub fn with_hot(mut self, seed_bytes: &[u8], mb: u32) -> Self { + self.hot = Some(HotTable::for_seed_bytes(seed_bytes, mb)); + self + } + + /// The hot table of a program's class, filled from its seed bytes (none for a class without one). + pub fn attach_hot_for(&mut self, program: &Program) { + self.hot = program.class.hot.map(|h| HotTable::for_seed_bytes(&program.seed_bytes, h.mb as u32)); + } + + /// The shape of the memory-hard construction ([`Shape::V2`] for the closed form, which has none). + pub fn shape(&self) -> Shape { + self.memhard().map(|m| m.shape()).unwrap_or(Shape::V2) } pub fn mode(&self) -> DatasetMode { @@ -200,18 +257,23 @@ impl DatasetSource { } } - /// `dataset[w & mask]`. + /// `dataset[w & mask]` under the linear layout (the lottery hash). pub fn word(&self, w: u32) -> u32 { + self.word_at(Layout::LINEAR, w) + } + + /// `dataset[w & mask]` under a program's layout (era layout). The closed form has no items and ignores it. + pub fn word_at(&self, layout: Layout, w: u32) -> u32 { let w = w & self.mask; match &self.dataset { Dataset::ClosedForm { d0, d1 } => dataset_elem(w, *d0, *d1), - Dataset::MemoryHard(m) => m.word(w), + Dataset::MemoryHard(m) => m.word_at(layout, w), } } /// `out[k] = dataset[idx[k]]`; indices are already masked. Returns items derived (0 for the closed form). #[inline] - fn fetch(&self, idx: &[u32; LANES], out: &mut [u32; LANES]) -> usize { + fn fetch(&self, idx: &[u32; LANES], out: &mut [u32; LANES], layout: Layout) -> usize { match &self.dataset { Dataset::ClosedForm { d0, d1 } => { for k in 0..LANES { @@ -219,14 +281,14 @@ impl DatasetSource { } 0 } - Dataset::MemoryHard(m) => m.fetch(idx, out), + Dataset::MemoryHard(m) => m.fetch(idx, out, layout), } } /// `out[k][j] = dataset[base[k] + j]` for `j < width`; bases are masked and aligned to `width` words /// (`width` 4 or 16, so a lane's words lie in one item). Returns items derived (0 for the closed form). #[inline] - fn fetch_wide(&self, base: &[u32; LANES], width: usize, out: &mut [[u32; 16]; LANES]) -> usize { + fn fetch_wide(&self, base: &[u32; LANES], width: usize, out: &mut [[u32; 16]; LANES], layout: Layout) -> usize { match &self.dataset { Dataset::ClosedForm { d0, d1 } => { for k in 0..LANES { @@ -236,7 +298,7 @@ impl DatasetSource { } 0 } - Dataset::MemoryHard(m) => m.fetch_wide(base, width, out), + Dataset::MemoryHard(m) => m.fetch_wide(base, width, out, layout), } } } @@ -278,6 +340,9 @@ pub fn interpret_warp_scratch( trace: bool, ) -> (WarpResult, Vec) { let mask = ds.mask; + let log2 = ds.log2_words; + let era = program.class.era; + let layout = program.class.layout(); let mut r = [[0u32; LANES]; 8]; for lane in 0..LANES { let nonce = base_nonce.wrapping_add(lane as u32); @@ -298,10 +363,14 @@ pub fn interpret_warp_scratch( } } let slot_mask = program.class.scratch_slot_mask(); + if program.has_hot() { + let h = ds.hot.as_ref().expect("a hot-table program needs the epoch's hot table on the dataset source"); + assert_eq!(h.n_words(), program.hot_words(), "the hot table's size is the class's"); + } for _ in 0..ITERATIONS { let sel = r[0]; for ins in &program.instrs { - step(ins, &mut r, &sel, mask, ds, &mut idx, &mut val, &mut items_derived); + step(ins, &mut r, &sel, mask, log2, era.as_ref(), layout, ds, &mut idx, &mut val, &mut items_derived); if ins.op == Op::Scratch { let m = scratch.as_mut().expect("a scratch op needs a scratch class"); let (d, a) = (ins.dst as usize, ins.src as usize); @@ -329,6 +398,9 @@ fn step( r: &mut [[u32; LANES]; 8], sel: &[u32; LANES], mask: u32, + log2: u32, + era: Option<&EraParams>, + layout: Layout, ds: &DatasetSource, idx: &mut [u32; LANES], val: &mut [u32; LANES], @@ -404,9 +476,9 @@ fn step( } Op::Load if ins.width == 1 => { for lane in 0..LANES { - idx[lane] = r[a][lane] & mask; + idx[lane] = load_index(era, ins, r[a][lane], mask, log2); } - *items_derived += ds.fetch(idx, val); + *items_derived += ds.fetch(idx, val, layout); for lane in 0..LANES { r[d][lane] ^= val[lane]; } @@ -416,10 +488,10 @@ fn step( let width = ins.width as usize; let align = !(ins.width as u32 - 1); for lane in 0..LANES { - idx[lane] = (r[a][lane] & mask) & align; + idx[lane] = load_index(era, ins, r[a][lane], mask, log2) & align; } let mut vals = [[0u32; 16]; LANES]; - *items_derived += ds.fetch_wide(idx, width, &mut vals); + *items_derived += ds.fetch_wide(idx, width, &mut vals, layout); for lane in 0..LANES { r[d][lane] = fold_words(r[d][lane], &vals[lane][..width]); } @@ -427,13 +499,21 @@ fn step( Op::Scratch => { // handled by the caller (interpret_warp_init), which owns the unit's scratch model } + Op::Hot => { + // Hot-table experiment: one word of the epoch table at the multiply-shift index, plain xor fold. + let h = ds.hot.as_ref().expect("a hot load needs the hot table"); + let n = h.n_words(); + for lane in 0..LANES { + r[d][lane] ^= h.at(hot_index(r[a][lane], n)); + } + } Op::WLoad => { // Lane 0's register, masked, aligned down to 32 words; lane l reads word base + l. let base = (r[a][0] & mask) & !31; for lane in 0..LANES { idx[lane] = base + lane as u32; } - *items_derived += ds.fetch(idx, val); + *items_derived += ds.fetch(idx, val, Layout::LINEAR); for lane in 0..LANES { r[d][lane] ^= val[lane]; } @@ -457,14 +537,37 @@ pub struct Epoch { /// Default dataset size: 2^28 words = 1 GiB. pub const DEFAULT_DATASET_LOG2: u32 = 28; +/// Days a day index lies after the network's genesis day (0 for the genesis day and any day before it). The node's +/// entry; the same function as `memhard::days_since_genesis`. +pub fn days_since_genesis(day_index: u64, genesis_day_index: u64) -> u64 { + crate::memhard::days_since_genesis(day_index, genesis_day_index) +} + impl Epoch { pub fn new(seed: &str, day: &str, mode: DatasetMode, dataset_log2: u32) -> Self { Self { program: generate(seed), dataset: DatasetSource::new(day, mode, dataset_log2) } } - /// [`Epoch::new`] with a load class (read-width experiment). + /// [`Epoch::new`] with a load class (read-width experiment; Counter ASIC 2.0: the class's mixer multiplier + /// shapes the dataset, the cache is the genesis size since a string day has no day index). pub fn new_class(seed: &str, day: &str, mode: DatasetMode, dataset_log2: u32, class: LoadClass) -> Self { - Self { program: generate_class(seed, class), dataset: DatasetSource::new(day, mode, dataset_log2) } + Self::new_class_day(seed, day, mode, dataset_log2, class, 0) + } + + /// [`Epoch::new_class`] on day `days_since_genesis` of the growth schedule (the cache of + /// `memhard::cache_log2_words` for a class with the growth rule; the dataset size is the caller's). + pub fn new_class_day(seed: &str, day: &str, mode: DatasetMode, dataset_log2: u32, class: LoadClass, days_since_genesis: u64) -> Self { + let shape = Shape::for_class_day(&class, days_since_genesis); + let program = generate_class(seed, class); + let mut dataset = DatasetSource::new_shape(day, mode, dataset_log2, shape); + // hot-table experiment: a hot class fills its table from the seed bytes + dataset.attach_hot_for(&program); + Self { program, dataset } + } + + /// `dataset[w]` as this epoch's program reads it: under the program's layout (era layout; linear for v2). + pub fn dataset_word(&self, w: u32) -> u32 { + self.dataset.word_at(self.program.class.layout(), w) } /// The production shape: memory-hard, 1 GiB dataset. @@ -480,15 +583,68 @@ impl Epoch { Self::from_seed_bytes_class(epoch_seed, day_bytes, label, LoadClass::V2) } - /// [`Epoch::from_seed_bytes`] with a load class (read-width experiment). + /// [`Epoch::from_seed_bytes`] with a load class (read-width experiment; Counter ASIC 2.0: the class's mixer + /// multiplier shapes the dataset). Day 0 of the growth schedule: the 2^26-word cache and the 2^28-word dataset, + /// which is every devnet pack and vector. A node past the first doubling calls [`Epoch::from_seed_bytes_day`]. pub fn from_seed_bytes_class(epoch_seed: &[u8], day_bytes: &[u8], label: &str, class: LoadClass) -> Self { + Self::from_seed_bytes_day(epoch_seed, day_bytes, label, class, 0, DEFAULT_DATASET_LOG2) + } + + /// The chain's shape on day `days_since_genesis` (`memhard::days_since_genesis(day_index(header), day_index(genesis))`, + /// the node's two day indices): the program of the class, and under the class's growth rule the cache of + /// `memhard::cache_log2_words(d)` and the dataset of `memhard::dataset_log2_words(genesis_dataset_log2, d)` + /// (the genesis size is 28 for the 1 GiB devnet, 29 for the designed 2 GiB). Without the growth rule the cache + /// is 2^26 words and the dataset `2^genesis_dataset_log2` on every day. + pub fn from_seed_bytes_day(epoch_seed: &[u8], day_bytes: &[u8], label: &str, class: LoadClass, days_since_genesis: u64, genesis_dataset_log2: u32) -> Self { let program = crate::generator::generate_from_seed_bytes_class(label, epoch_seed, class); let key = crate::seed::seed_words_from_bytes(day_bytes); - let mut dataset = DatasetSource::from_key(key, DatasetMode::MemoryHard, DEFAULT_DATASET_LOG2); + let shape = Shape::for_class_day(&class, days_since_genesis); + let dataset_log2 = if class.growth { crate::memhard::dataset_log2_words(genesis_dataset_log2, days_since_genesis) } else { genesis_dataset_log2 }; + let mut dataset = DatasetSource::from_key_shape(key, DatasetMode::MemoryHard, dataset_log2, shape); dataset.key_bytes = day_bytes.to_vec(); + dataset.attach_hot_for(&program); Self { program, dataset } } + /// The chain's shape with the program class (Counter ASIC 2.0, 5 October 2026): what the node's engine and the + /// miner's pack export build from the seeds a block template carries. Class v2 is [`Epoch::from_seed_bytes`] + /// exactly (the era bytes are ignored and not recorded); class v3 draws from [`crate::generator::V3_CLASS`] + /// with generator version 3 and records the era seed bytes (`E_n`) in the program for the pack. + pub fn from_chain_seeds(epoch_seed: &[u8], day_bytes: &[u8], era_bytes: Option<&[u8]>, class: ProgramClass, label: &str) -> Self { + Self { program: Self::chain_program(epoch_seed, era_bytes, class, label), dataset: Self::chain_dataset(day_bytes, class) } + } + + /// The program alone of [`Epoch::from_chain_seeds`] (no cache fill): for an engine that shares the day's cache. + pub fn chain_program(epoch_seed: &[u8], era_bytes: Option<&[u8]>, class: ProgramClass, label: &str) -> Program { + crate::generator::generate_from_seed_bytes_program_class(label, epoch_seed, class, era_bytes) + } + + /// 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 + /// [`Epoch::from_seed_bytes`], and the engine keys its day caches on `(day, class)` so the two never share one. + pub fn chain_dataset(day_bytes: &[u8], class: ProgramClass) -> DatasetSource { + Self::chain_dataset_day(day_bytes, class, 0, DEFAULT_DATASET_LOG2) + } + + /// [`Epoch::chain_dataset`] with the day's position since genesis and the network's genesis dataset size: the + /// entry the node's engine and the miner's export build every day cache through, so the cache growth schedule + /// of spec 01 section 1.13.3 has one place to act (ca2-mixer, 5 October 2026, `docs/plans/mixer-x4.md`): the + /// class's load class gives the mixer multiplier and whether the growth rule applies (`Shape::for_class_day`); + /// under the rule the cache is `2^memhard::cache_log2_words(d)` words and the dataset + /// `2^memhard::dataset_log2_words(genesis_dataset_log2, d)`; without it (class v2) the cache is 2^26 words and + /// the dataset the genesis size on every day. `days_since_genesis` is [`days_since_genesis`] of the block's and + /// the genesis header's day indices. + pub fn chain_dataset_day(day_bytes: &[u8], class: ProgramClass, days_since_genesis: u64, genesis_dataset_log2: u32) -> DatasetSource { + let lc = class.load_class(); + let shape = Shape::for_class_day(&lc, days_since_genesis); + let dataset_log2 = if lc.growth { crate::memhard::dataset_log2_words(genesis_dataset_log2, days_since_genesis) } else { genesis_dataset_log2 }; + let key = crate::seed::seed_words_from_bytes(day_bytes); + let mut dataset = DatasetSource::from_key_shape(key, DatasetMode::MemoryHard, dataset_log2, shape); + dataset.key_bytes = day_bytes.to_vec(); + dataset + } + /// The 32 hashes of the warp starting at `base_nonce`. pub fn hash_warp(&self, base_nonce: u32) -> [u64; LANES] { hash_warp(&self.program, base_nonce, &self.dataset) @@ -548,7 +704,7 @@ mod tests { *b = ((k as u32).wrapping_mul(0x9E37_79B1) & ds.mask) & !15; } let mut out = [[0u32; 16]; LANES]; - let items = ds.fetch_wide(&base, 16, &mut out); + let items = ds.fetch_wide(&base, 16, &mut out, Layout::LINEAR); assert!(items >= 1 && items <= LANES); for k in 0..LANES { for j in 0..16 { @@ -559,7 +715,7 @@ mod tests { for b in base4.iter_mut() { *b += 8; } - let items4 = ds.fetch_wide(&base4, 4, &mut out); + let items4 = ds.fetch_wide(&base4, 4, &mut out, Layout::LINEAR); assert_eq!(items4, items); for k in 0..LANES { for j in 0..4 { @@ -590,6 +746,116 @@ mod tests { assert_eq!(e.hash_warp(0), e.hash_warp(0)); } + /// Era layout: the load address stays inside the site's window and below the mask at every dataset size (the + /// window floor of 2^26 words clamps the shrink), the interleaved memory-hard dataset reads item(t(w))[j(w)] and + /// is the same prefix at 2^20 and 2^22 words, the wide fetch agrees word for word, and an era epoch hashes + /// deterministically through the interpreter and the single-nonce API. + #[test] + fn era_windows_layout_and_epochs() { + let eb = EraParams::test_era_bytes("igneum-era-test/1"); + let c = LoadClass::era(LoadClass::V2, &eb, &[1]); + let e = c.era.unwrap(); + let mut ins = Instr { op: Op::Load, dst: 0, src: 1, src2: 0, imm: 0, imm2: 0, rot: 1, bit: 0, mask: 1, width: 1, win: 2, off: 3 }; + let mut s = crate::seed::SplitMix64::new(7); + for log2 in [20u32, 26, 27, 28, 29] { + let mask = (1u64 << log2) as u32 - 1; + let k = ins.win.min(log2.saturating_sub(26) as u8) as u32; + for _ in 0..1000 { + let x = s.next() as u32; + let idx = load_index(Some(&e), &ins, x, mask, log2); + assert!(idx <= mask); + let (wm, off) = window(&ins, mask, log2); + assert_eq!(idx & !wm, off, "log2 {log2}"); + assert_eq!(wm, mask >> k); + assert_eq!(idx, ((x.wrapping_mul(e.stride_mul).rotate_left(e.stride_rot) & wm) | off) & mask); + } + } + ins.win = 0; + assert_eq!(load_index(None, &ins, 0xdead_beef, 0x0fff_ffff, 28), 0xdead_beef & 0x0fff_ffff); + // the interleaved dataset: one day cache, the layout per program + let l = e.layout(); + assert_eq!(l.pos, [1, 3, 8, 13]); + let small = DatasetSource::new("2026-10-03", DatasetMode::MemoryHard, 20); + let big = DatasetSource::new("2026-10-03", DatasetMode::MemoryHard, 22); + let m = small.memhard().unwrap(); + for w in [0u32, 1, 4, 5, 255, 256, 4095, 8192, 0x0f_ffff] { + let (t, j) = l.split(w); + assert_eq!(small.word_at(l, w), crate::memhard::derive_item(t, &m.params, &m.cache)[j as usize], "w {w}"); + assert_eq!(small.word_at(l, w), big.word_at(l, w), "prefix at w {w}"); + assert_eq!(small.word(w), small.word_at(Layout::LINEAR, w)); + } + let mut idx = [0u32; LANES]; + for (k, i) in idx.iter_mut().enumerate() { + *i = (k as u32).wrapping_mul(0x9E37_79B1) & small.mask; + } + let mut out = [0u32; LANES]; + small.fetch(&idx, &mut out, l); + for k in 0..LANES { + assert_eq!(out[k], small.word_at(l, idx[k])); + } + // a 16-byte era: the wide fetch keeps a lane's four words in one item + let eb3 = EraParams::test_era_bytes("igneum-era-test/3"); + let c3 = LoadClass::era(LoadClass::fixed(4, 16), &eb3, &[4]); + let l3 = c3.layout(); + assert_eq!(l3.pos[..2], [0, 1]); + let mut base = [0u32; LANES]; + for (k, b) in base.iter_mut().enumerate() { + *b = ((k as u32).wrapping_mul(0x9E37_79B1) & small.mask) & !3; + } + let mut wide = [[0u32; 16]; LANES]; + small.fetch_wide(&base, 4, &mut wide, l3); + for k in 0..LANES { + for j in 0..4 { + assert_eq!(wide[k][j], small.word_at(l3, base[k] + j as u32), "lane {k} word {j}"); + } + } + // era epochs hash deterministically, differ per era, and the single-nonce API agrees with the warp + let mut seen = std::collections::HashSet::new(); + for (n, c) in [(1u64, c), (3, c3)] { + let ep = Epoch::new_class("igneum-genesis", "2026-10-03", DatasetMode::MemoryHard, 20, c); + assert_eq!(ep.dataset_word(5), ep.dataset.word_at(c.layout(), 5)); + let a = ep.hash_warp(64); + assert_eq!(a, ep.hash_warp(64)); + assert_eq!(ep.hash(64 + 5), a[5]); + assert!(seen.insert(a[0]), "era {n}"); + let closed = Epoch::new_class("igneum-genesis", "2026-10-03", DatasetMode::ClosedForm, 20, c); + assert_ne!(closed.hash_warp(64), a); + } + } + + /// Hot-table experiment: an epoch of a hot class carries the table, hashes deterministically and differs from + /// version 2; the reference interpreter agrees with a hand-stepped hot load; a hot program without its table is + /// refused. + #[test] + fn hot_epochs_hash() { + let e = Epoch::new_class("igneum-genesis", "2026-10-03", DatasetMode::ClosedForm, 20, LoadClass::hot(32, 4)); + let h = e.dataset.hot.as_ref().expect("the epoch fills the hot table"); + assert_eq!(h.n_words(), 1 << 23); + assert_eq!(h.key, crate::memhard::hot_key(b"igneum-genesis")); + let v2 = Epoch::new_class("igneum-genesis", "2026-10-03", DatasetMode::ClosedForm, 20, LoadClass::V2); + let a = e.hash_warp(0); + assert_eq!(a, e.hash_warp(0)); + assert_ne!(a, v2.hash_warp(0)); + assert_ne!(a[0], a[1]); + // the same program under a 64 MiB table reads other words + let e64 = Epoch::new_class("igneum-genesis", "2026-10-03", DatasetMode::ClosedForm, 20, LoadClass::hot(64, 4)); + assert_eq!(e64.program.instrs, e.program.instrs); + assert_ne!(e64.hash_warp(0), a); + // from seed bytes, the chain's shape, with a hot class + let genesis = crate::bind::unhex("edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07").unwrap(); + let ec = Epoch::from_seed_bytes_class(&genesis, &crate::bind::day_bytes(20_730), "devnet", LoadClass::hot(32, 2)); + assert_eq!(ec.dataset.hot.as_ref().unwrap().key, crate::memhard::hot_key(&genesis)); + assert_eq!(ec.hash_warp(0), ec.hash_warp(0)); + } + + #[test] + #[should_panic(expected = "needs the epoch's hot table")] + fn hot_program_without_a_table_is_refused() { + let p = generate_class("igneum-genesis", LoadClass::hot(32, 4)); + let ds = DatasetSource::new("2026-10-03", DatasetMode::ClosedForm, 20); + let _ = hash_warp(&p, 0, &ds); + } + #[test] fn wide_class_epochs_hash() { for name in ["w16", "w64x4", "50,35,15"] { diff --git a/igneum-pow/tests/scratch.rs b/igneum-pow/tests/scratch.rs index d00bbfe4..acf376d1 100644 --- a/igneum-pow/tests/scratch.rs +++ b/igneum-pow/tests/scratch.rs @@ -240,10 +240,10 @@ fn written_words_unbiased_and_rehit_rates() { // --------------------------------------------------------------------------------------------------------------- fn ins(op: Op, dst: u8, src: u8) -> Instr { - Instr { op, dst, src, src2: 0, imm: 0, imm2: 0, rot: 1, bit: 0, mask: 1, width: 1 } + Instr { op, dst, src, src2: 0, imm: 0, imm2: 0, rot: 1, bit: 0, mask: 1, width: 1, win: 0, off: 0 } } fn add_imm(dst: u8, src: u8, imm: u32) -> Instr { - Instr { op: Op::Add, dst, src, src2: 0, imm, imm2: imm, rot: 1, bit: 0, mask: 1, width: 1 } + Instr { op: Op::Add, dst, src, src2: 0, imm, imm2: imm, rot: 1, bit: 0, mask: 1, width: 1, win: 0, off: 0 } } /// A hand-built program of class `c` named `name` (its seed is the name, so its fill words and init words are @@ -261,6 +261,7 @@ fn edge(name: &str, c: LoadClass, instrs: Vec) -> Program { generator: GENERATOR_VERSION, attempt: 0, class: c, + era_bytes: None, instrs, } }