ca2-coord: verify.rs and tests/scratch.rs set to the release tree's versions (3f6f23e carries them through ca2-mixer), so the docs merge changes no shipped code

This commit is contained in:
igneum-labs 2026-10-05 23:04:59 +00:00
parent a2b35fb1f8
commit 09d6bf5f05
2 changed files with 292 additions and 25 deletions

View file

@ -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<u8>,
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<HotTable>,
}
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<ScratchEvent>) {
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"] {

View file

@ -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<Instr>) -> Program {
generator: GENERATOR_VERSION,
attempt: 0,
class: c,
era_bytes: None,
instrs,
}
}