356 lines
20 KiB
Rust
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}");
|
|
}
|
|
}
|