diff --git a/docs/plans/mixer-x4.md b/docs/plans/mixer-x4.md
index 2635da058..3f812f562 100644
--- a/docs/plans/mixer-x4.md
+++ b/docs/plans/mixer-x4.md
@@ -125,8 +125,57 @@ node agent).
## 4. Vectors (class v3, `proto-cuda/packs-ca2-mixer/`)
-Filled in section 6 from the exported packs: `mx4-genesis` (seed `igneum-genesis`, day `2026-10-03`, 2^28 words,
-2^26-word cache) and `mx4-devnet-epoch0` (the devnet genesis hash as the epoch seed, day bytes of 2026-10-04).
+Produced by `igneum-pow export --program-class v3` (the Rust CPU interpreter, 5 October 2026, commit 66eeba3) and
+checked on the GPUs in section 6. The program of each pack is the version 2 program of the same seed instruction
+for instruction (`tests/packs.rs`, `v3_packs_are_the_v2_seeds_under_mixer_x4`); the cache is the version 2 cache
+(day 0 of the growth rule); the dataset words and the hashes are new. The 64 sampled indices are those of every
+pack (`emit::sample_indices`).
+
+Pack `mx4-genesis` (seed igneum-genesis, day 2026-10-03, generator 3, class mx4, program id e323b9dcaf283a6f, 2^28 words, 2^26-word cache, cache FNV-1a 64 `48c4f5bf24166b2e` as under v2):
+
+```
+dataset words 0..15 (item 0):
+ 61ff2180 0d4c7e6c 2177d443 60df9025 cf8b2e10 63675bfb 25289e58 9c45dc42
+ 2d271c54 9652369b 2dd77508 5921392c 3afa60ee c640ad68 f2bb56ff cfa46438
+dataset[0x0fffffff] = 5020180e
+sampled words (the first 8 of the 64 in vectors.json):
+ dataset[59471966] = de85726d dataset[217795994] = 7cfc31c7 dataset[208353206] = d3cd5289 dataset[42483309] = 6ebbeef1
+ dataset[172547758] = 9858413b dataset[148076330] = e786f141 dataset[183853158] = 64f13833 dataset[214389424] = ca229d04
+unit at base nonce 0, lanes 0..31:
+ 63acd2d273f475ba e929c78b34b80d4b 0b1011cb19982558 1457a0df5497aa11
+ 957d0f3bb71d98fb ac16901e6e6f6057 8ea1c6279f4b177a f28146e60bd08ba9
+ fe5b8cfe87f8e65b 49f87240566ace62 6ef6d6b7bdea8e41 46d9c0dc29a97b9c
+ 111fe30128db9398 66dc39084f0946d4 8ee11bdfd35fecf2 2861fcfc75db6677
+ 31c7667d4bde8556 c5989c48858b4ce0 276395e734a9d30d 84217b41e91368ff
+ 3604861e34d9f697 9f51d8ee16bf3639 c89e47bafa84401c 7ae78c1f10b70e19
+ 0b8c947157a29a48 d67192e8cfb43842 05a4c6d182c8c675 188e2661f3263f2e
+ a2df24238f7fea2e ed69ea7e13ad3a48 2d44ae509bab91b8 adad61931ea4fb70
+unit at base nonce 4096: lane 0 edd508ac57e5699a, lane 1 4eefd56d526cdaeb, lane 31 8892f8604733b1e0
+unit at base nonce 1000000: lane 0 8b3183778a49f59c, lane 1 1831b72a8797e895, lane 31 75eae55eba53a506
+```
+
+Pack `mx4-devnet-epoch0` (seed the devnet genesis hash, day bytes of 2026-10-04, generator 3, class mx4, program id 73bcbfe8ccf988f1, 2^28 words, 2^26-word cache, cache FNV-1a 64 `448274a57f508cbc` as under v2):
+
+```
+dataset words 0..15 (item 0):
+ afe80d67 b9fbd029 6c79f193 95139ad9 96310aff 4609f8b1 75279e63 28235be1
+ 47b17dcb 718e0ef2 a52588c8 a8bf49d5 19cf243e 5ec8905e a4851f66 af9cd9f3
+dataset[0x0fffffff] = e6a99c7a
+sampled words (the first 8 of the 64 in vectors.json):
+ dataset[59471966] = 57642b58 dataset[217795994] = c279badd dataset[208353206] = cbccbaad dataset[42483309] = 32cce392
+ dataset[172547758] = 71fdb4c6 dataset[148076330] = f5a268ce dataset[183853158] = 3ca1d676 dataset[214389424] = 7977b03d
+unit at base nonce 0, lanes 0..31:
+ 212c6442b51e87ae c374795c00839331 b6036a220a98f4b3 eb8b8013e637367b
+ db5866e9b73930fd f3f3d01f46e90333 9d913991ab8ed428 7ccb1d8fa100a800
+ 3cf45ba44f09a0fe 91acf48ef1a63082 6ea46c69fb082f99 581f0218977a9d72
+ 9a4623a5c62ddf2d ab6eb5e768f0feb4 07b70bdccf8aca12 d666311ae5e4311e
+ 53114757d669f0a4 bd5d6ace87ce2ce4 fb712015e8189192 a32cec81103e134b
+ 83f3d18c3289c124 fe29f1984b132b3d c9ffcf4e3774497a ac99c9243dc63809
+ d78a7e8217a32f3c ab81ad63d242fc31 0e4c30b7e00024af ce014289fff6778d
+ 64a1292e2a8b4a91 d5b8c90e681d7e3a 06078117673030fd 51bf77b280173930
+unit at base nonce 4096: lane 0 3c797978566b5950, lane 1 7c759e60185b6411, lane 31 96a903eb9a0ca390
+unit at base nonce 1000000: lane 0 d5a8da0568df8ee7, lane 1 68cb69c04208285a, lane 31 f8ca84a1a5d78cf5
+```
## 5. The v2 path is byte-identical
@@ -136,7 +185,56 @@ section 6 also records a fresh `igneum-pow export` of both packs diffed against
## 6. Measurements
-Filled as they land. Every row names the machine, the date, the command and the lock mode.
+Machine: Apple M5 Max, 64 GiB, Darwin 25.6.0, 5 October 2026 (night), other agents' builds running beside every
+run; a timing row says which lock it ran under (`measure` is exclusive; `run` and `build` are not timings).
+
+### 6.1 The v2 path, byte for byte (no lock needed)
+
+`igneum-pow export --seed igneum-genesis --day 2026-10-03` and `igneum-pow export --epoch-hex edc4fa84...fb07
+--day-hex 69676e65756d2d6461792ffa50000000000000` on commit 66eeba3, `diff -r` against
+`proto-cuda/packs/igneum-genesis-mh` and `igneum-devnet-v4-epoch0`: IDENTICAL, both (twelve files each). The
+crate tests regenerate the same files and compare them on every run (`tests/packs.rs`, 12 of 12 pass).
+
+### 6.2 Bit-exactness of the class v3 construction on the GPUs (`with-lock.sh run`, 22:05 UTC)
+
+`packbench --pack
--batches 1 --batch-log2 24 --group 256` (Metal, built from this branch) and
+`igneum-bench-cl-igneum-genesis-mh --bench-pack --pack --batches 1 --batch-log2 24` (Apple OpenCL, built from
+this branch's host.c). Vectors are the Rust interpreter's; the fingerprint is FNV-1a 64 over the 2^24 outputs at
+base nonce 0.
+
+| Pack | Harness | Cache FNV-1a 64 | Dataset head, word MASK, 64 samples | Vectors standalone / in batch | Fingerprint 2^24 | MH/s (GPU time; not a measurement, the run lock) |
+|---|---|---|---|---|---|---|
+| mx4-genesis | Metal | 48c4f5bf24166b2e PASS | PASS | 3/3, 3/3 | 6f48d5a2aa0dbe5f | 27.6 |
+| mx4-genesis | Apple OpenCL | PASS | PASS | 96 of 96 lanes | 6f48d5a2aa0dbe5f | 27.7 (wall) |
+| mx4-devnet-epoch0 | Metal | 448274a57f508cbc PASS | PASS | 3/3, 3/3 | 73caaebb28e808fe | 27.5 |
+| mx4-devnet-epoch0 | Apple OpenCL | PASS | PASS | 96 of 96 lanes | 73caaebb28e808fe | 27.6 (wall) |
+
+Reading: the Rust interpreter, Metal and Apple OpenCL agree on the v3 dataset (head, MASK word, 64 samples), on
+every vector lane and on the 2^24-output fingerprint of each pack; the hash rate is the v2 rate (27.7 MH/s on this
+card tonight, readwidth table), as it must be: the hash kernel only loads, the mixer is paid in the build.
+
+Dataset build under the run lock (indicative only; the measured rows are in 6.4): Metal 30.2 ms GPU (mx4-genesis)
+and 21.7 ms GPU (mx4-devnet-epoch0) for 1 GiB; Apple OpenCL 55 and 56 ms wall. The readwidth entry's v2 figure on
+this card is 0.6 to 2.1 ms cache fill and a 1 GiB build the bench-log's memory-hard entry puts at 13 to 30 ms; the
+x4 build on the GPU is the row the measure lock will settle.
+
+### 6.3 Soundness suite on the v3 construction (commit 66eeba3 and after; `with-lock.sh build` for cargo, `run` for the GPU)
+
+| Suite | Command | Result |
+|---|---|---|
+| Crate lib tests (memhard schedule table, the by-hand multiplied mixer, the seam, the generator) | `cargo test -j4 --release` | 44 of 44 pass |
+| Pinned packs, v2 and v3 (`tests/packs.rs`: programs, ids, dataset words, 96 vectors per pack, every emitted file byte for byte, 16 masked loads per kernel, the v3 packs as the v2 seeds under mixer x4) | same | 12 of 12 pass |
+| Scratch soundness tests of branch ca2-soundness (cherry-pick 0d8f745, one conflict in packbench.swift's RESULT line resolved by hand, `era_bytes: None` added to the edge program literal) | `cargo test -j4 --release --test scratch` | 7 of 7 pass: bijections, re-hit rates, 56 of 56 edge units, 42 of 42 emitted scr kernels, 200 scratch programs and 800 units on the CPU |
+| v3 fuzz on the CPU (`tests/mixer.rs`): 200 programs through the seam, the contract on every instruction (each is the v2 program of its seed), 4 units each across the 32-bit range with one unit in the top 256 nonces, interpreted twice | `IGNEUM_MIXER_PACKS_OUT= cargo test -j4 --release --test mixer` | 200 of 200, 800 of 800 units; 200 packs written for the GPU runs (82 s with the three cache fills) |
+| v3 stats beside v2 (`tests/mixer.rs`): 8,192 outputs per seed, bit balance, single-bit avalanche within the unit and across units, duplicates | same | igneum-genesis v3: avalanche 49.99 percent, worst bit z 1.92, 0 duplicates (v2: 49.87, z 2.25); igneum-genesis/stats1 v3: 49.97, z 3.09 (v2: 49.98, z 2.30) |
+| v3 edge (`tests/mixer.rs`): items 0, 1, 2^28 - 1 and 2^32 - 1 by hand at m = 1, 2, 4, 8 on a 2^14-word cache; words 0, 15, 16, 17, MASK - 1, MASK through the interpreter's fetch path; the index wrap at MASK + 1 | same | pass |
+| v3 determinism (`tests/mixer.rs`): two independent epochs, every vector and every emitted file equal, and equal to the pinned pack | same | pass |
+| Metal and Apple OpenCL on the two pinned v3 packs | section 6.2 | 3/3 standalone, 3/3 in batch, 96 of 96 lanes, dataset head, MASK word and 64 samples, one fingerprint per pack across both harnesses |
+| Metal fuzz: the 200 packs, 4 units each standalone and the top-256 unit inside a 512-nonce batch at base 4,294,967,040; every tenth pack on Apple OpenCL as well | `packbench --pack --batches 1 --batch-log2 9 --batch-base 4294967040` | see below |
+
+### 6.4 Timings (`with-lock.sh measure`)
+
+Owed until the measure lock frees.
## 7. The chip model
diff --git a/igneum-pow/tests/mixer.rs b/igneum-pow/tests/mixer.rs
new file mode 100644
index 000000000..bd40c1881
--- /dev/null
+++ b/igneum-pow/tests/mixer.rs
@@ -0,0 +1,237 @@
+//! 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=` 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.
+
+use igneum_pow::emit::{export_pack, vectors_json};
+use igneum_pow::generator::{generate_from_seed_bytes, generate_from_seed_bytes_program_class, Op, Program, ProgramClass, GENERATOR_VERSION_V3, INSTR_COUNT, V3_CLASS};
+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";
+
+fn contract(p: &Program, seed: &str) {
+ assert_eq!(p.generator, GENERATOR_VERSION_V3);
+ assert_eq!(p.class, V3_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");
+ 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);
+}
+
+/// 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);
+ let out = std::env::var("IGNEUM_MIXER_PACKS_OUT").ok().map(PathBuf::from);
+ let mut rng = SplitMix64::new(0x6967_6e65_756d_2d6d); // "igneum-m"
+ let shape = Shape::for_class(&V3_CLASS);
+ assert_eq!(shape, Shape { mixer_mult: 4, cache_log2_words: 26 });
+ // one memory-hard source per dataset size (the 256 MiB cache fill is 0.2 s each)
+ let mut mh: HashMap = HashMap::new();
+ let mut manifest = String::from("pack\tlog2\tprogram_id\tbases\n");
+ let mut units = 0usize;
+ let mut wraps = 0usize;
+ for i in 0..n {
+ let seed = format!("igneum-mixer-fuzz/{i}");
+ let p = generate_from_seed_bytes_program_class(&seed, seed.as_bytes(), ProgramClass::V3, None);
+ contract(&p, &seed);
+ 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}-mx4-l{log2}");
+ 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::>().join(",")
+ ));
+ }
+ mh.insert(log2, e.dataset);
+ }
+ println!("fuzz: {n} v3 programs, {units} units on the CPU, {wraps} units in the top 256 nonces");
+ 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
+ for seed in ["igneum-genesis", "igneum-genesis/stats1"] {
+ let v3 = Epoch {
+ program: generate_from_seed_bytes_program_class(seed, seed.as_bytes(), ProgramClass::V3, None),
+ dataset: DatasetSource::new_shape(DAY, DatasetMode::MemoryHard, 24, Shape::for_class(&V3_CLASS)),
+ };
+ let v2 = Epoch::new(seed, DAY, DatasetMode::MemoryHard, 24);
+ for (name, e) in [("v3", &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 });
+ 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 build = || Epoch {
+ program: generate_from_seed_bytes_program_class("igneum-genesis", b"igneum-genesis", ProgramClass::V3, None),
+ dataset: DatasetSource::new_shape(DAY, DatasetMode::MemoryHard, 28, Shape::for_class(&V3_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]);
+ }
+ let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../proto-cuda/packs-ca2-mixer/mx4-genesis");
+ 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}");
+ }
+}
diff --git a/igneum-pow/tests/scratch.rs b/igneum-pow/tests/scratch.rs
index d00bbfe4e..75e48f583 100644
--- a/igneum-pow/tests/scratch.rs
+++ b/igneum-pow/tests/scratch.rs
@@ -261,6 +261,7 @@ fn edge(name: &str, c: LoadClass, instrs: Vec) -> Program {
generator: GENERATOR_VERSION,
attempt: 0,
class: c,
+ era_bytes: None,
instrs,
}
}