igneum/igneum-pow/tests/mixer.rs
igneum-labs 3a7ba78349 Merge remote-tracking branch 'build/ca3-v4-amend' into class-v5
# Conflicts:
#	igneum-pow/src/generator.rs
2026-10-07 17:53:53 +00:00

356 lines
20 KiB
Rust

//! The class v3 dataset construction (mixer x4, cache growth option C; `docs/plans/mixer-x4.md`): the soundness
//! runs the brief asks for, on the CPU, with the packs for the GPU runs written on request.
//!
//! 1. Fuzz: `IGNEUM_MIXER_FUZZ` (default 200) programs through the seam (`ProgramClass::V3`), the contract on every
//! instruction (the v2 program of the seed, instruction for instruction), 4 units each across the 32-bit range
//! including the wrap, interpreted twice on the CPU; with `IGNEUM_MIXER_PACKS_OUT=<dir>` every program is written
//! as a pack with its 4 bases in vectors.json for `packbench` and the OpenCL host (the Metal fuzz).
//! 2. Stats: bit balance and single-bit-flip avalanche of the v3 hash against v2 on the same programs and nonces.
//! 3. Edge: the dataset at word 0, word MASK and the item boundary, derived through the interpreter's fetch path and
//! by hand at every multiplier 1, 2, 4, 8, on a small cache.
//! 4. Determinism: two independent epochs of the same seed and day agree on every vector and every emitted file.
//!
//! Counter ASIC 3.0 gate run (6 October 2026): `IGNEUM_MIXER_CLASS=<class>` (a `LoadClass::parse` name, for example
//! `mx8+sh256x27`) runs the fuzz, the stats and the determinism test on that class instead of `V3_CLASS`;
//! `IGNEUM_MIXER_ERA=igneum-era-test/<n>` composes that era over the class as the chain composes it inside class v3
//! (`LoadClass::era(class, E_n, &V3_ALLOWED)`, generator 3). The fuzz contract on a shadow class also checks the block:
//! `S` instructions, none a load, every field in range, and the 64 base instructions equal to the class's without the
//! shadow, draw for draw. The edge test is the dataset's alone (the shadow touches no dataset word) and takes no class.
use igneum_pow::emit::{export_pack, vectors_json};
use igneum_pow::generator::{era_generator_of, generate_era, generate_from_seed_bytes, generate_from_seed_bytes_class, generate_from_seed_bytes_program_class, EraParams, LoadClass, Op, Program, ProgramClass, GENERATOR_VERSION_V3, GENERATOR_VERSION_V4, INSTR_COUNT, V3_ALLOWED, V3_CLASS, V4_CLASS, V4_SHADOW_INSTRS};
use igneum_pow::memhard::{derive_item, mixer, round_key, Cache, MixParams, Shape};
use igneum_pow::seed::{day_key, SplitMix64};
use igneum_pow::verify::{DatasetMode, DatasetSource, Epoch};
use std::collections::HashMap;
use std::path::PathBuf;
const DAY: &str = "2026-10-03";
/// The class under test (`IGNEUM_MIXER_CLASS`, default `V3_CLASS`) and the era composed over it (`IGNEUM_MIXER_ERA`,
/// `igneum-era-test/<n>`, default none). With an era the class returned is the era class (the name carries `-era<hex>`).
fn class_under_test() -> (LoadClass, Option<[u8; 32]>) {
let class = std::env::var("IGNEUM_MIXER_CLASS").ok().map(|s| LoadClass::parse(&s).expect("a load class")).unwrap_or(V3_CLASS);
let era = std::env::var("IGNEUM_MIXER_ERA").ok().map(|s| {
assert!(s.starts_with("igneum-era-test/"), "IGNEUM_MIXER_ERA is igneum-era-test/<n>");
EraParams::test_era_bytes(&s)
});
match era {
Some(eb) => (LoadClass::era(class, &eb, &V3_ALLOWED), Some(eb)),
None => (class, None),
}
}
/// The program of `seed` under `class` (an era class carries its era bytes): the seam for `V3_CLASS`, `generate_era`
/// for an era class, the plain class generator otherwise.
fn program_of(seed: &str, class: LoadClass, era: Option<[u8; 32]>) -> Program {
match era {
Some(eb) => generate_era(seed, seed.as_bytes(), LoadClass { era: None, ..class }, &eb, &V3_ALLOWED),
None if class == V3_CLASS => generate_from_seed_bytes_program_class(seed, seed.as_bytes(), ProgramClass::V3, None),
None => generate_from_seed_bytes_class(seed, seed.as_bytes(), class),
}
}
fn contract(p: &Program, seed: &str, class: LoadClass, era: Option<[u8; 32]>) {
if class == V3_CLASS {
assert_eq!(p.generator, GENERATOR_VERSION_V3);
} else if era.is_some() {
// the generator of an era class is the class's (ca3-v4-node 7c22d0d): 4 on V4_CLASS, 3 otherwise
assert_eq!(p.generator, era_generator_of(&LoadClass { era: None, ..class }));
}
assert_eq!(p.class, class);
assert_eq!(p.instrs.len(), INSTR_COUNT);
assert_eq!(p.instrs.iter().filter(|i| i.op == Op::Load).count(), 16);
for (k, i) in p.instrs.iter().enumerate() {
assert!(i.src != i.dst, "#{k}: src == dst");
assert!((1..=31).contains(&i.rot), "#{k}: rot {}", i.rot);
assert!([1u8, 2, 4, 8, 16].contains(&i.mask), "#{k}: mask {}", i.mask);
assert!(i.dst < 8 && i.src < 8 && i.src2 < 8);
assert_eq!(i.width, 1, "#{k}: a v3 load reads one word");
}
assert!(igneum_pow::accept::check(p).is_ok(), "an accepted program");
if era.is_none() {
// no era: the base program is the v2 program of the seed (the mixer and the shadow draw nothing before it)
let v2 = generate_from_seed_bytes(seed, seed.as_bytes());
assert_eq!(p.instrs, v2.instrs, "the v2 program of the seed under the v3 construction");
assert_eq!(p.attempt, v2.attempt);
}
match class.shadow {
None => assert!(p.shadow.is_empty() && !p.has_shadow()),
Some(sh) => {
// the latency-shadow block (Counter ASIC 3.0 item 8): S ALU instructions, no load, every field in range,
// and the base program is the class's without the shadow, draw for draw (the block is drawn after it)
assert_eq!(p.shadow.len(), sh.instrs as usize, "{seed}: shadow block length");
assert!(p.has_shadow());
assert_eq!(p.shadow_instrs_per_hash(), sh.instrs as usize * sh.reps as usize * 8);
for (k, i) in p.shadow.iter().enumerate() {
assert!(!i.op.is_load() && i.op != Op::WLoad, "shadow #{k}: a load");
assert!(i.src != i.dst, "shadow #{k}: src == dst");
assert!((1..=31).contains(&i.rot), "shadow #{k}: rot {}", i.rot);
assert!([1u8, 2, 4, 8, 16].contains(&i.mask), "shadow #{k}: mask {}", i.mask);
assert!(i.dst < 8 && i.src < 8 && i.src2 < 8 && i.bit < 32, "shadow #{k}: register or bit out of range");
assert_eq!((i.width, i.win, i.off), (1, 0, 0), "shadow #{k}: no load fields");
}
let base = program_of(seed, LoadClass { shadow: None, ..class }, era);
let v4_shape = LoadClass { era: None, shadow: None, ..class } == LoadClass { shadow: None, ..V4_CLASS } && sh.instrs == V4_SHADOW_INSTRS;
if v4_shape {
// the amended class v4 (AP-F8-1, sub-version 2): on every draw path a load's source is a register
// fresh by dataflow (a load keeps freshness only from a fresh source; add, sub, xor, mad, shfl from
// either operand; rotates from their operand; or, mul, mulhi never), so its base program is its own
// stream, not class v3's; what holds is the rule itself, checked here on every load site in draw order
let mut fresh = [true; 8];
for (k, i) in p.instrs.iter().enumerate() {
let (d, a) = (i.dst as usize, i.src as usize);
if i.op.is_load() {
assert!(fresh[a], "{seed}: load #{k} reads r{} which is not fresh by dataflow", i.src);
}
fresh[d] = match i.op {
Op::Load | Op::WLoad | Op::Scratch | Op::Hot => fresh[a],
Op::Add | Op::Sub | Op::Xor | Op::Mad | Op::Shfl => fresh[d] || fresh[a],
Op::Rotl | Op::Rotr => fresh[d],
Op::Or | Op::Mul | Op::MulHi => false,
};
}
} else {
assert_eq!(p.instrs, base.instrs, "{seed}: the base program is the class's without the shadow");
assert_eq!(p.attempt, base.attempt);
}
if era.is_none() || p.generator == GENERATOR_VERSION_V4 {
// a generator-2 program carries the shadow in its id bytes; a class v4 program is generator 4
// (ca3-v4-node 7c22d0d), so its id differs from the generator-3 id of the same seeds
assert_ne!(p.program_id(), base.program_id(), "{seed}: the shadow is in the program id");
} else {
// a generator-3 program's id is program_id(3, seed, attempt), class-independent by construction
// (generator.rs program_id): under the chain's path a class v4 program shares its id with the class
// v3 program of the same seeds until the v4 seam gives it its own generator or puts the class in the
// id (Counter ASIC 3.0 gate run, 6 October 2026: an item for gates G4 and G6, not a hash fault)
assert_eq!(p.generator, GENERATOR_VERSION_V3);
if p.program_id() == base.program_id() {
println!("{seed}: generator-3 program id {:016x} is the same with and without the shadow (the v4 seam item)", p.program_id());
}
}
}
}
}
/// Write a pack whose vectors.json carries `bases` instead of the three standard bases (packbench and the OpenCL
/// host check every unit standalone and the ones inside the batch window).
fn write_pack_with_bases(dir: &PathBuf, e: &Epoch, day: &str, bases: &[u32], source: &str) {
let mut pack = export_pack(e, day, source);
let outs: Vec<[u64; 32]> = bases.iter().map(|&b| e.hash_warp(b)).collect();
let vj = vectors_json(&e.program, day, e.dataset.log2_words, bases, &outs, &pack.vectors, e.dataset.mask, source, true);
for f in pack.files.iter_mut() {
if f.0 == "vectors.json" {
f.1 = vj.clone();
}
}
pack.write_to(dir).unwrap();
}
#[test]
fn fuzz_v3_programs_cpu() {
let n: usize = std::env::var("IGNEUM_MIXER_FUZZ").ok().and_then(|s| s.parse().ok()).unwrap_or(200);
// IGNEUM_FUZZ_SEED_BASE (default 0) offsets the seed index so a continuous fuzzer (the box's capacity layer,
// infra/build-server/capacity) walks fresh programs round after round; the default run is unchanged
let base: usize = std::env::var("IGNEUM_FUZZ_SEED_BASE").ok().and_then(|s| s.parse().ok()).unwrap_or(0);
let out = std::env::var("IGNEUM_MIXER_PACKS_OUT").ok().map(PathBuf::from);
// IGNEUM_MIXER_CLASS=mx8 fuzzes the x8 candidate as a load class (generator 2 with the class in the id); the
// default is V3_CLASS through the seam; IGNEUM_MIXER_ERA composes a test era over the class (class_under_test)
let (class, era) = class_under_test();
let mut rng = SplitMix64::new(0x6967_6e65_756d_2d6d); // "igneum-m"
let shape = Shape::for_class(&class);
assert_eq!(shape.cache_log2_words, 26);
assert!(shape.mixer_mult > 1);
// one memory-hard source per dataset size (the 256 MiB cache fill is 0.2 s each)
let mut mh: HashMap<u32, DatasetSource> = HashMap::new();
let mut manifest = String::from("pack\tlog2\tprogram_id\tbases\n");
let mut units = 0usize;
let mut wraps = 0usize;
for i in base..base + n {
let seed = format!("igneum-mixer-fuzz/{i}");
let p = program_of(&seed, class, era);
contract(&p, &seed, class, era);
let b0 = (rng.below(8) as u32) * 32;
let b1 = 0x8000_0000u32.wrapping_sub(256).wrapping_add((rng.below(16) as u32) * 32);
let b2 = 0xffff_ff00u32.wrapping_add((rng.below(8) as u32) * 32);
let b3 = (rng.next() as u32) & !31;
let bases = [b0, b1, b2, b3];
wraps += bases.iter().filter(|&&b| b >= 0xffff_ff00).count();
let log2 = [24u32, 26, 28][rng.below(3) as usize];
let ds = mh.remove(&log2).unwrap_or_else(|| DatasetSource::new_shape(DAY, DatasetMode::MemoryHard, log2, shape));
let e = Epoch { program: p, dataset: ds };
for &b in &bases {
let r1 = e.interpret_warp(b);
let r2 = e.interpret_warp(b);
assert_eq!(r1.hashes, r2.hashes);
assert!(r1.items_derived >= 120 * 32 / 32 && r1.items_derived <= 4_096, "{seed}: {} items", r1.items_derived);
units += 1;
}
if let Some(dir) = &out {
let pack_name = format!("fuzz-{i:03}-{}-l{log2}", class.name());
write_pack_with_bases(&dir.join(&pack_name), &e, DAY, &bases, "igneum-pow tests/mixer.rs fuzz");
manifest.push_str(&format!(
"{pack_name}\t{log2}\t{:016x}\t{}\n",
e.program.program_id(),
bases.iter().map(|b| format!("{b}")).collect::<Vec<_>>().join(",")
));
}
mh.insert(log2, e.dataset);
}
println!("fuzz: {n} {} programs, {units} units on the CPU, {wraps} units in the top 256 nonces, seeds {base}..{}", class.name(), base + n);
assert_eq!(units, 4 * n);
assert_eq!(wraps, n);
if let Some(dir) = &out {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("manifest.tsv"), manifest).unwrap();
println!("packs written to {}", dir.display());
}
}
/// Bit balance and avalanche of the v3 hash beside v2 on the same program (the TESTS.md section 3 shape, on the
/// CPU, 2^13 nonces per seed): every output bit within 5 sigma of half ones; a single nonce-bit flip moves 50 percent
/// of the output bits within 2 points; no duplicate among the outputs.
#[test]
fn stats_v3_against_v2() {
let n_warps = 256usize; // 8,192 nonces
let (class, era) = class_under_test();
let class_name = if class == V3_CLASS { "v3".to_string() } else { class.name() };
for seed in ["igneum-genesis", "igneum-genesis/stats1"] {
let v3 = Epoch {
program: program_of(seed, class, era),
dataset: DatasetSource::new_shape(DAY, DatasetMode::MemoryHard, 24, Shape::for_class(&class)),
};
let v2 = Epoch::new(seed, DAY, DatasetMode::MemoryHard, 24);
for (name, e) in [(class_name.as_str(), &v3), ("v2", &v2)] {
let mut ones = [0u64; 64];
let mut outs = Vec::with_capacity(n_warps * 32);
for w in 0..n_warps {
let h = e.hash_warp(w as u32 * 32);
for &x in &h {
outs.push(x);
for b in 0..64 {
ones[b] += (x >> b) & 1;
}
}
}
let total = (n_warps * 32) as f64;
let sigma = (total / 4.0).sqrt();
for (b, &c) in ones.iter().enumerate() {
let z = (c as f64 - total / 2.0).abs() / sigma;
assert!(z < 5.0, "{seed} {name}: bit {b} ones {c} of {total}, z {z:.2}");
}
// avalanche: flip one bit of the nonce within the unit (lanes 0..31 differ in the low 5 bits) and across
// units (bit 5 and up): compare lane l of unit u with lane l ^ (1 << k) and with unit u ^ (1 << k)
let mut flips = 0u64;
let mut moved = 0u64;
for w in 0..64usize {
let h = e.hash_warp(w as u32 * 32);
for k in 0..5 {
for l in 0..32usize {
moved += (h[l] ^ h[l ^ (1 << k)]).count_ones() as u64;
flips += 1;
}
}
let h2 = e.hash_warp((w ^ 1) as u32 * 32);
for l in 0..32usize {
moved += (h[l] ^ h2[l]).count_ones() as u64;
flips += 1;
}
}
let avg = moved as f64 / flips as f64 / 64.0 * 100.0;
assert!((avg - 50.0).abs() < 2.0, "{seed} {name}: avalanche {avg:.2} percent");
outs.sort_unstable();
let dups = outs.windows(2).filter(|p| p[0] == p[1]).count();
assert_eq!(dups, 0, "{seed} {name}: duplicate outputs");
println!("{seed} {name}: {} outputs, avalanche {avg:.2} percent, worst bit z {:.2}", outs.len(), ones.iter().map(|&c| (c as f64 - total / 2.0).abs() / sigma).fold(0.0, f64::max));
}
assert_ne!(v3.hash_warp(0), v2.hash_warp(0));
}
}
/// The dataset edges under every multiplier on a small cache: word 0, word MASK, the last word of item 0 and the
/// first of item 1, through `DatasetSource::word` and by hand.
#[test]
fn edge_items_every_multiplier() {
let key = day_key(DAY);
let cache = Cache::fill_log2(key, 14);
for m in [1u32, 2, 4, 8] {
let mp = MixParams::with_shape(key, Shape { mixer_mult: m, cache_log2_words: 14, derive_len: 0, state: false });
let by_hand = |t: u32| -> [u32; 16] {
let mut s = [0u32; 16];
s[..8].copy_from_slice(&key);
for i in 0..8 {
s[8 + i] = t.wrapping_mul(mp.mul[i]).wrapping_add(mp.rc[i]);
}
for r in 0..8usize {
for j in 0..m as usize {
mixer(&mut s, round_key(r * m as usize + j), &mp);
}
let line = cache.line(s[0]);
for i in 0..16 {
s[i] ^= line[i];
}
}
for j in 0..m as usize {
mixer(&mut s, round_key(8 * m as usize + j), &mp);
}
s
};
for t in [0u32, 1, 0x0fff_ffff, 0xffff_ffff] {
assert_eq!(derive_item(t, &mp, &cache), by_hand(t), "m {m} item {t}");
}
}
// the interpreter's fetch path at the genesis cache: words 0, 15, 16 and MASK of a 2^20-word dataset agree with
// the item derivation, under v3
let ds = DatasetSource::new_shape(DAY, DatasetMode::MemoryHard, 20, Shape::for_class(&V3_CLASS));
let m = ds.memhard().unwrap();
for w in [0u32, 15, 16, 17, ds.mask - 1, ds.mask] {
assert_eq!(ds.word(w), derive_item(w >> 4, &m.params, &m.cache)[(w & 15) as usize]);
assert_eq!(ds.word(w), m.word(w));
}
// a load at an out-of-range register masks to the dataset: the word at mask + 1 is the word at 0
assert_eq!(ds.word(ds.mask.wrapping_add(1)), ds.word(0));
}
/// Two independent epochs of the same seed and day: every vector and every emitted file identical; the pinned v3
/// pack is what a third export writes.
#[test]
fn determinism_v3() {
let (class, era) = class_under_test();
let build = || Epoch {
program: program_of("igneum-genesis", class, era),
dataset: DatasetSource::new_shape(DAY, DatasetMode::MemoryHard, 28, Shape::for_class(&class)),
};
let a = build();
let b = build();
let pa = export_pack(&a, DAY, "a");
let pb = export_pack(&b, DAY, "a");
assert_eq!(pa.outs, pb.outs);
assert_eq!(pa.vectors, pb.vectors);
assert_eq!(pa.files, pb.files);
for (w, warp) in [(0u32, 0usize), (4096, 1), (1_000_000, 2)] {
assert_eq!(a.hash_warp(w), pa.outs[warp]);
}
// the pinned pack of the class: mx8-genesis for V3_CLASS, packs-ca3-shadow/<block> for a shadow class over mx8
// (igneum-genesis, the same day); a class with no pinned pack skips the on-disk comparison and says so
let pinned = if class == V3_CLASS {
Some("../proto-cuda/packs-ca2-mixer/mx8-genesis".to_string())
} else {
class.name().strip_prefix("mx8+").map(|b| format!("../proto-cuda/packs-ca3-shadow/{b}"))
};
let dir = match pinned.map(|d| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(d)).filter(|d| d.is_dir()) {
Some(d) => d,
None => {
println!("determinism {}: two builds equal; no pinned pack on disk for this class", class.name());
return;
}
};
println!("determinism {}: two builds equal, against the pinned pack {}", class.name(), dir.display());
for (name, text) in &pa.files {
if name == "vectors.json" || name == "vectors.h" {
continue; // the source string differs ("a" here)
}
let on_disk = std::fs::read_to_string(dir.join(name)).unwrap();
assert_eq!(&on_disk, text, "{name}");
}
}