igneum/igneum-pow/tests/mixer.rs
igneum-labs fdac338dbc Counter ASIC 3.0 gates (hash): class v4 sub-version 2 (AP-F8-1, main's ruling B2: 0.3.20 ships sub-version 1 untouched; this stream is object byte 7). F1, the draw: a load's source is drawn only from registers fresh by dataflow (fresh at the start; a load keeps freshness only from a fresh source; add, sub, xor, mad, shfl from either operand; rotl, rotr from their operand; or, mul, mulhi never), keyed on the class v4 shape on EVERY draw path (era or not, the pass count set aside), so a census through candidate_class reads the chain's stream. (a'), accept.rs: the same freshness run to its fixpoint over the loop (base then shadow block) and every load's source fresh in the steady state, else the candidate is rejected and the next attempt drawn (closes the iteration boundary the draw cannot see: F8's p11, an or at 63 feeding a load at 1, and the load-after-load and rotate-of-saturated chains of p6, p23, p26, p31, p34). (c'), accept.rs: per load site, the count of source values equal to 0 or all-ones over the 64 units' 16,384 evaluations, rejected at 164 or more (the (c) limit), the backstop for any delivery of saturation (zero and all-ones alike: p45's mulhi zero). Both keyed on the class v4 shape, so v2 and v3 verdicts and ids do not move. PROGRAM_SUBVERSION_V4 = 2 in the id suffix and the pack lines. The seven gate packs re-exported: the devnet epoch-0 seed's attempt 0 is now rejected and attempt 1 accepted, id a788661687db4bb3 (must-differ: c120d7963abdcd96 the 6 October stream, 1a4230699a6b9c60 sub-version 1); the seven 256-block ladder packs of packs-ca3-shadow re-exported under the rule (the no-era path moves too; their measured rates stand as the old stream's). Tests: the generator test checks the fixpoint rule on the amended program and the known-failed case (the v3 stream re-labelled v4); the mixer contract checks the dataflow rule on every V4-shaped class, era or not
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:01:40 +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 });
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}");
}
}