igneum/igneum-pow/src/accept.rs
2026-10-07 14:24:55 +00:00

838 lines
41 KiB
Rust

//! Program acceptance (spec 01 section 1.4.6, adopted 4 October 2026 from the weak-program census of
//! `docs/analysis/weak-program-census-2026-10-03.md`, section 6).
//!
//! A candidate program is accepted only if every test below holds. Every conforming implementation evaluates
//! exactly these tests on exactly these inputs, so every node skips the same seeds.
//!
//! | Part | Test |
//! |---|---|
//! | (a) static | for every `load`, some instruction between the previous `load` from the same source register and this one, in cyclic order over the 64 instructions, writes that register |
//! | (b) static | every register `r0..r7` is the destination of at least one `add`, `sub`, `xor`, `mad`, `shfl` or `load` |
//! | (c) dynamic | the program is interpreted for [`ACCEPT_UNITS`] (64) units of 32 lanes at base nonces drawn from SplitMix64 seeded with `FNV-1a-64("igneum-accept/" \|\| seed words as little-endian bytes)`, each `low32(next()) AND NOT 31`, with init words equal to the seed words and the closed-form dataset `dataset_elem(idx, S[0], S[1])` at [`ACCEPT_DATASET_LOG2`] (2^28 words) in place of the memory-hard dataset. Over the 2,048 evaluations: no register has a bit equal in every final value; no load site (iteration, instruction) reads one address in all 32 lanes of any unit; fewer than [`MAX_SATURATED`] (164, 1 percent of 16,384) final register values are 0 or 2^32 - 1; every output bit's ones count is within [`BIAS_TOLERANCE`] (136, 6 sigma) of 1,024; the distinct masked addresses read by one lane in one evaluation, summed over the 2,048 evaluations, exceed [`MIN_DISTINCT_SUM`] (245,760, a mean above 120 of the 128 loads) |
//!
//! The dynamic test uses the closed form so that it is a pure function of the program (no cache, no day) and
//! costs about a millisecond on one core. A hot-table load (`docs/plans/hot-table.md`) reads the closed form keyed by
//! seed words 2 and 3 at its multiply-shift index, a second pure table beside the dataset stand-in (words 0 and 1). The census (section 7.3) checked on 100,000 programs that the
//! closed-form verdict agrees with the memory-hard one on all but 39 threshold-edge cases.
use crate::generator::{Instr, LoadClass, Op, Program, ShadowClass, INSTR_COUNT, ITERATIONS, LANES, V4_CLASS, V4_SHADOW_INSTRS};
use crate::seed::{fnv1a64, SplitMix64};
use crate::memhard::hot_index;
use crate::verify::{dataset_elem, fold_words, load_index, splitmix32, ScratchModel};
/// Units (32-lane warps) the dynamic test interprets.
pub const ACCEPT_UNITS: usize = 64;
/// Class v4 sub-version 3 (AP-F8-1's low-entropy-band class, 7 October 2026, the attack-pass gate's numbers through
/// main): rule (c''), two parts, both keyed on the class v4 shape. (A) The distinct-index bound: over
/// [`ACCEPT_UNITS_DISTINCT_V4`] units (2^20 evaluations per site) the count of DISTINCT dataset indices a load site
/// reads must be at least [`MIN_DISTINCT_INDICES_V4`] (2^19.5): a site with k bits of index entropy reads about 2^k
/// distinct, and the gate's 1.2x at the top 0.1 percent corresponds to about 18.5 bits (k = 12 reads about 45x, 15
/// 6.7x, 17 2.4x, 18 about 1.2x). (B) The most-repeated-value bound: over the (c) units' 16,384 evaluations no load
/// site reads one source value [`MAX_SOURCE_REPEAT_V4`] times or more (a single item trips the gate alone at about
/// 78 repeats per 16,384; a uniform source repeats at most 2 or 3). A candidate failing either is rejected and the
/// next attempt drawn under the 256 cap and the last resort.
pub const ACCEPT_UNITS_DISTINCT_V4: usize = 4096;
/// (A): the floor on distinct indices per site over 2^20 evaluations, 2^19.5 rounded.
pub const MIN_DISTINCT_INDICES_V4: u32 = 741_455;
/// (B): the most repeated source value per site over the (c) units' 16,384 evaluations is rejected at this count.
pub const MAX_SOURCE_REPEAT_V4: u32 = 8;
/// Hashes the dynamic test evaluates: 2,048.
pub const ACCEPT_HASHES: usize = ACCEPT_UNITS * LANES;
/// Domain tag of the base-nonce stream.
pub const ACCEPT_TAG: &[u8] = b"igneum-accept/";
/// log2 of the closed-form dataset the test addresses: the prototype's 2^28 words, MASK 0x0fffffff.
pub const ACCEPT_DATASET_LOG2: u32 = 28;
/// Final register values equal to 0 or 2^32 - 1 must number fewer than this (1 percent of 8 x 2,048).
pub const MAX_SATURATED: u32 = 164;
/// Every output bit's ones count must be within this of 1,024 (6 x sqrt(2048) / 2, rounded).
pub const BIAS_TOLERANCE: u32 = 136;
/// Distinct addresses per lane per evaluation, summed over 2,048 evaluations, must exceed this (mean above 120).
pub const MIN_DISTINCT_SUM: u64 = 245_760;
/// The distinct-address bound for a program with `loads` dataset loads per hash: the same 120 of 128 ratio, so
/// [`MIN_DISTINCT_SUM`] for the lottery hash and `loads x 1,920` for the read-width classes with other counts.
/// Variant 5's scratch read-modify-writes are not dataset loads: their slots repeat by design (a later
/// read-modify-write sees an earlier write), so they are neither counted nor bounded here.
pub fn min_distinct_sum(loads: usize) -> u64 {
loads as u64 * ACCEPT_HASHES as u64 * 120 / 128
}
/// Why a candidate was rejected. The verdict (accept or reject) is what consensus depends on; the reason is the
/// first failing test in the order of the module table.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Reject {
/// (a): instruction `instr` loads from `reg`, which no instruction wrote since the previous load from it.
StaleLoadSource { instr: u8, reg: u8 },
/// (b): no injecting op writes `reg`.
NoInjectingWrite { reg: u8 },
/// (c): `reg` has `bits` bits equal in all 2,048 final values.
ConstantBit { reg: u8, bits: u8 },
/// (c): the load at `instr` in `iteration` read one address in all 32 lanes of `unit`.
LaneConstantSite { iteration: u8, instr: u8, unit: u8 },
/// (c): `count` final register values were 0 or all ones.
Saturated { count: u32 },
/// (a'), class v4 sub-version 2 (AP-F8-1): the load at `instr` reads `reg`, which is not fresh by dataflow in the
/// steady state of the loop (the freshness fixpoint over the base program and the shadow block).
UnfreshLoadSource { instr: u8, reg: u8 },
/// (c'), class v4 sub-version 2 (AP-F8-1): the load at `site` read a source value of 0 or all ones in `count` of
/// its 16,384 evaluations (64 units x 32 lanes x 8 iterations); limit [`MAX_SATURATED`] - 1, the same 1 percent as (c).
SaturatedSource { site: u8, count: u32 },
/// (c'') (B), class v4 sub-version 3: the load at `site` read the value `value` in `count` of its 16,384 (c)
/// evaluations (limit [`MAX_SOURCE_REPEAT_V4`] - 1): one constant upstream that the lineage rule cannot see.
RepeatedSource { site: u8, value: u32, count: u32 },
/// (c'') (A), class v4 sub-version 3: the load at `site` read only `distinct` distinct dataset indices over 2^20
/// evaluations (floor [`MIN_DISTINCT_INDICES_V4`]): a low-entropy index band (F8's p23, p15, p18, p19).
LowEntropySite { site: u8, distinct: u32 },
/// (c): output bit `bit` was set in `ones` of 2,048 hashes.
OutputBias { bit: u8, ones: u32 },
/// (c): the distinct-address sum was `sum`.
DistinctAddresses { sum: u64 },
}
impl std::fmt::Display for Reject {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Reject::StaleLoadSource { instr, reg } => {
write!(f, "(a) load at instruction {instr} reads r{reg}, unwritten since the previous load from it")
}
Reject::NoInjectingWrite { reg } => write!(f, "(b) r{reg} has no add, sub, xor, mad, shfl or load write"),
Reject::ConstantBit { reg, bits } => write!(f, "(c) r{reg} has {bits} nonce-independent bits"),
Reject::LaneConstantSite { iteration, instr, unit } => {
write!(f, "(c) load at iteration {iteration} instruction {instr} reads one address in all lanes of unit {unit}")
}
Reject::Saturated { count } => write!(f, "(c) {count} of 16384 final register values saturated (limit 163)"),
Reject::UnfreshLoadSource { instr, reg } => write!(f, "(a') load at {instr} reads r{reg}, not fresh by dataflow in the loop's steady state (class v4 sub-version 2)"),
Reject::RepeatedSource { site, value, count } => write!(f, "(c'') load site {site} read the value {value:#010x} in {count} of 16384 evaluations (limit {})", MAX_SOURCE_REPEAT_V4 - 1),
Reject::LowEntropySite { site, distinct } => write!(f, "(c'') load site {site} read {distinct} distinct indices over {} evaluations (floor {})", ACCEPT_UNITS_DISTINCT_V4 * LANES * ITERATIONS, MIN_DISTINCT_INDICES_V4),
Reject::SaturatedSource { site, count } => write!(f, "(c') load site {site} read a saturated source value in {count} of 16384 evaluations (limit 163)"),
Reject::OutputBias { bit, ones } => write!(f, "(c) output bit {bit} set in {ones} of 2048 hashes"),
Reject::DistinctAddresses { sum } => {
write!(f, "(c) distinct dataset addresses {sum} over 2048 hashes (mean {:.2}, needs above 120 of 128 of the dataset loads)", *sum as f64 / 2048.0)
}
}
}
}
/// What the dynamic test measured on an accepted program.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct AcceptReport {
/// Distinct masked addresses per lane per evaluation, summed over the 2,048 evaluations.
pub distinct_sum: u64,
/// Final register values equal to 0 or all ones.
pub saturated: u32,
/// The largest `|ones - 1024|` over the 64 output bits.
pub bias_max: u32,
}
impl AcceptReport {
/// Mean distinct addresses per hash (128 at most).
pub fn distinct_mean(&self) -> f64 {
self.distinct_sum as f64 / ACCEPT_HASHES as f64
}
}
/// Part (a): no load whose source is unwritten since the previous load from it, cyclically.
fn check_stale_loads(instrs: &[Instr]) -> Result<(), Reject> {
// `pending[r]`: a load has read r and nothing has written r since. Two passes over the list so the second
// pass sees the state carried over the iteration boundary.
let mut pending = [false; 8];
for _pass in 0..2 {
for (k, ins) in instrs.iter().enumerate() {
if ins.op.is_load() && pending[ins.src as usize] {
return Err(Reject::StaleLoadSource { instr: k as u8, reg: ins.src });
}
pending[ins.dst as usize] = false;
if ins.op.is_load() {
pending[ins.src as usize] = true;
}
}
}
Ok(())
}
/// Part (b): every register has an injecting write.
fn check_injecting_writes(instrs: &[Instr]) -> Result<(), Reject> {
let mut injected = [false; 8];
for ins in instrs {
if ins.op.injects() {
injected[ins.dst as usize] = true;
}
}
for (reg, ok) in injected.iter().enumerate() {
if !ok {
return Err(Reject::NoInjectingWrite { reg: reg as u8 });
}
}
Ok(())
}
/// The most repeated value of `values` (sorted in place) and that value.
fn most_repeated(values: &mut [u32]) -> (u32, u32) {
values.sort_unstable();
let (mut best, mut best_v, mut run) = (0u32, 0u32, 0u32);
for i in 0..values.len() {
run = if i > 0 && values[i] == values[i - 1] { run + 1 } else { 1 };
if run > best {
best = run;
best_v = values[i];
}
}
(best, best_v)
}
/// (c'') (A), class v4 sub-version 3: the program interpreted for [`ACCEPT_UNITS_DISTINCT_V4`] units on the seed's
/// acceptance stream (the (c) units first) with every load's dataset index recorded per site; a site reading fewer
/// than [`MIN_DISTINCT_INDICES_V4`] distinct indices over its 2^20 evaluations is a low-entropy band (a constant or a
/// forced equality upstream that the lineage rule cannot see) and the candidate is rejected.
pub fn check_distinct_indices_v4(p: &Program) -> Result<(), Reject> {
for (site, &distinct) in distinct_indices_v4(p, ACCEPT_UNITS_DISTINCT_V4)?.iter().enumerate() {
if distinct < MIN_DISTINCT_INDICES_V4 {
return Err(Reject::LowEntropySite { site: site as u8, distinct });
}
}
Ok(())
}
/// The distinct dataset word indices every load site reads over `units` units of the seed's acceptance stream on
/// the closed-form words (the sample caps the count near `units x 32 x 8`, so a site's index entropy is read only
/// below about log2 of that).
pub fn distinct_indices_v4(p: &Program, units: usize) -> Result<Vec<u32>, Reject> {
let loads = p.loads_per_hash();
let sites = loads / ITERATIONS;
let mut acc = Acc {
sources: None,
indices: Some(vec![Vec::with_capacity(units * LANES * ITERATIONS); sites]),
sat_source: vec![0; sites],
and_acc: [u32::MAX; 8],
or_acc: [0; 8],
saturated: 0,
bit_ones: [0; 64],
distinct_sum: 0,
};
let mut lane_addrs = vec![0u32; LANES * loads];
for (unit, &base) in accept_base_nonces_n(&p.seed, units).iter().enumerate() {
run_unit(p, unit, base, &mut acc, &mut lane_addrs)?;
}
let mut out = Vec::with_capacity(sites);
for ix in acc.indices.take().unwrap().iter_mut() {
ix.sort_unstable();
ix.dedup();
out.push(ix.len() as u32);
}
Ok(out)
}
/// Whether `class` is the class v4 shape (the 256-instruction shadow block over the class v3 base, the pass count and
/// the era set aside): the shape the sub-version 2 rules (a') and (c') apply to, on every draw path.
pub fn is_class_v4_shape(class: &LoadClass) -> bool {
matches!(class.shadow, Some(ShadowClass { instrs: V4_SHADOW_INSTRS, .. }))
&& LoadClass { era: None, shadow: None, ..*class } == LoadClass { shadow: None, ..V4_CLASS }
}
/// One pass of the dataflow freshness over the base program then the shadow block (the order of one iteration),
/// from `fresh`; `check` reports the first load that reads a register that is not fresh. The rule (AP-F8-1,
/// `docs/analysis/ca3-v4-uniform.md`): a load leaves its destination fresh only if its source was (a saturated
/// source reads one fixed word); add, sub, xor, mad and shfl if either operand was; rotl and rotr if the operand
/// was (a rotate maps all-ones and zero to themselves); or, mul and mulhi never.
fn freshness_pass(p: &Program, fresh: &mut [bool; 8], check: bool) -> Result<(), Reject> {
for (k, i) in p.instrs.iter().chain(p.shadow.iter()).enumerate() {
let (d, a) = (i.dst as usize, i.src as usize);
if check && i.op.is_load() && !fresh[a] {
return Err(Reject::UnfreshLoadSource { instr: k as u8, reg: 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,
};
}
Ok(())
}
/// Part (a'), class v4 sub-version 2: every load's source is fresh by dataflow in the loop's steady state. The draw
/// of `candidate_from_words_class` keeps in-pass sources fresh; this closes the iteration boundary (a source last
/// written late in the previous iteration or in the shadow block, which the draw's no-eligible fallback can pick:
/// F8's p11, an `or` at 63 feeding a load at 1). The state starts all fresh (the init words are a per-lane hash of
/// the nonce) and is run to its fixpoint (it only ever falls, so at most 8 passes change it), then one checking pass.
pub fn check_fresh_sources_v4(p: &Program) -> Result<(), Reject> {
if !is_class_v4_shape(&p.class) {
return Ok(());
}
let mut fresh = [true; 8];
for _ in 0..9 {
let before = fresh;
freshness_pass(p, &mut fresh, false)?;
if fresh == before {
break;
}
}
freshness_pass(p, &mut fresh, true)
}
/// Parts (a), (b) and, for class v4 sub-version 2, (a').
pub fn check_static(p: &Program) -> Result<(), Reject> {
if p.instrs.len() != INSTR_COUNT {
panic!("acceptance needs a {INSTR_COUNT}-instruction program");
}
check_stale_loads(&p.instrs)?;
check_injecting_writes(&p.instrs)?;
check_fresh_sources_v4(p)
}
/// The 64 base nonces of the dynamic test for seed words `seed`.
pub fn accept_base_nonces(seed: &[u32; 8]) -> [u32; ACCEPT_UNITS] {
let v = accept_base_nonces_n(seed, ACCEPT_UNITS);
let mut out = [0u32; ACCEPT_UNITS];
out.copy_from_slice(&v);
out
}
/// The first `n` base nonces of the seed's acceptance stream (the (c) units are the first [`ACCEPT_UNITS`]).
pub fn accept_base_nonces_n(seed: &[u32; 8], n: usize) -> Vec<u32> {
let mut b = Vec::with_capacity(ACCEPT_TAG.len() + 32);
b.extend_from_slice(ACCEPT_TAG);
for w in seed {
b.extend_from_slice(&w.to_le_bytes());
}
let mut rng = SplitMix64::new(fnv1a64(&b));
(0..n).map(|_| (rng.next() as u32) & !31).collect()
}
#[inline(always)]
fn mulhi32(a: u32, b: u32) -> u32 {
((a as u64 * b as u64) >> 32) as u32
}
/// Accumulators of the dynamic test over the 64 units.
struct Acc {
/// (c'') (B): every load's source value per site, recorded when present.
sources: Option<Vec<Vec<u32>>>,
/// (c'') (A): every load's dataset index per site, recorded when present (the distinct-index pass only).
indices: Option<Vec<Vec<u32>>>,
/// (c'): per load site (the load's index within the iteration), how many of its evaluations read a source value
/// of 0 or all ones (class v4 sub-version 2; counted for every class, judged for class v4 only).
sat_source: Vec<u32>,
and_acc: [u32; 8],
or_acc: [u32; 8],
saturated: u32,
bit_ones: [u32; 64],
distinct_sum: u64,
}
/// One unit of the dynamic test: the interpreter of `verify.rs` with the closed-form dataset, instrumented.
/// Returns the first lane-constant load site, if any.
fn run_unit(p: &Program, unit: usize, base: u32, acc: &mut Acc, lane_addrs: &mut [u32]) -> Result<(), Reject> {
let seed = &p.seed;
let mask: u32 = (1u32 << ACCEPT_DATASET_LOG2) - 1;
let (d0, d1) = (seed[0], seed[1]);
let (h0, h1) = (seed[2], seed[3]);
let hot_words = p.hot_words();
let loads = p.loads_per_hash();
let mut r = [[0u32; LANES]; 8];
for lane in 0..LANES {
let nonce = base.wrapping_add(lane as u32);
for i in 0..8 {
let mut x = nonce ^ seed[i];
x = x.wrapping_add(0x9e3779b9u32.wrapping_mul(i as u32 + 1));
x = splitmix32(x);
r[i][lane] = x ^ seed[(i + 1) & 7];
}
}
let mut idx = [0u32; LANES];
let mut nload = 0usize;
let mut scratch = if p.has_scratch() { Some(ScratchModel::new(p.class.scratch_slots_per_lane())) } else { None };
let slot_mask = p.class.scratch_slot_mask();
let era = p.class.era;
// Class v4 sub-version 3 (AP-F8-3, 7 October 2026): the acceptance interpreter runs the latency-shadow block
// after instruction 63 of every iteration, `reps` times with the iteration's `sel`, exactly as the hash does
// (verify.rs). Until this commit it ran the 64 base instructions only, so every dynamic test (c) judged a class v4
// program the chain never hashes. The shadow block holds no load, so its instructions take the same arms.
let shadow_reps = p.shadow_reps();
for it in 0..ITERATIONS {
let sel = r[0];
let shadow_pass = (0..shadow_reps).flat_map(|_| p.shadow.iter().enumerate().map(|(k, i)| (INSTR_COUNT + k, i)));
for (k, ins) in p.instrs.iter().enumerate().chain(shadow_pass) {
let d = ins.dst as usize;
let a = ins.src as usize;
match ins.op {
Op::Scratch => {
// Variant 5: the slot stands in for the address (bit 31 set so it never aliases a dataset word).
let m = scratch.as_mut().expect("a scratch op needs a scratch class");
for lane in 0..LANES {
idx[lane] = r[a][lane] & slot_mask;
}
if idx.iter().all(|&x| x == idx[0]) {
return Err(Reject::LaneConstantSite { iteration: it as u8, instr: k as u8, unit: unit as u8 });
}
for lane in 0..LANES {
r[d][lane] = m.rmw(&p.seed, base, lane, idx[lane], r[d][lane]);
lane_addrs[lane * loads + nload] = 0x8000_0000 | idx[lane];
}
nload += 1;
}
Op::Add => {
let (imm, imm2, bit) = (ins.imm, ins.imm2, ins.bit as u32);
let src = r[a];
for lane in 0..LANES {
let s = (sel[lane] >> bit) & 1;
let c = if s != 0 { imm2 } else { imm };
r[d][lane] = r[d][lane].wrapping_add(src[lane]).wrapping_add(c);
}
}
Op::Sub => {
let src = r[a];
for lane in 0..LANES {
r[d][lane] = r[d][lane].wrapping_sub(src[lane]);
}
}
Op::Mul => {
let src = r[a];
for lane in 0..LANES {
r[d][lane] = r[d][lane].wrapping_mul(src[lane]);
}
}
Op::MulHi => {
let src = r[a];
for lane in 0..LANES {
r[d][lane] = mulhi32(r[d][lane], src[lane]);
}
}
Op::Xor => {
let src = r[a];
for lane in 0..LANES {
r[d][lane] ^= src[lane];
}
}
Op::Or => {
let src = r[a];
for lane in 0..LANES {
r[d][lane] |= src[lane];
}
}
Op::Rotl => {
let n = ins.rot;
for lane in 0..LANES {
r[d][lane] = r[d][lane].rotate_left(n);
}
}
Op::Rotr => {
let src = r[a];
for lane in 0..LANES {
r[d][lane] = r[d][lane].rotate_right(src[lane] & 31);
}
}
Op::Mad => {
let src = r[a];
let src2 = r[ins.src2 as usize];
for lane in 0..LANES {
r[d][lane] = src[lane].wrapping_mul(src2[lane]).wrapping_add(r[d][lane]);
}
}
Op::Shfl => {
let src = r[a];
let m = ins.mask as usize;
for lane in 0..LANES {
r[d][lane] ^= src[lane ^ m];
}
}
Op::Load => {
// Read-width experiment: a load of `width` words reads from the aligned address and folds every
// word (verify::fold_words); width 1 is the lottery hash's xor of one word.
let width = ins.width as usize;
let align = !(ins.width as u32 - 1);
let site = nload % (loads / ITERATIONS);
for lane in 0..LANES {
let v = r[a][lane];
acc.sat_source[site] += (v == 0 || v == u32::MAX) as u32;
if let Some(src) = acc.sources.as_mut() {
src[site].push(v);
}
idx[lane] = load_index(era.as_ref(), ins, v, mask, ACCEPT_DATASET_LOG2) & align;
if let Some(ix) = acc.indices.as_mut() {
ix[site].push(idx[lane]);
}
}
if idx.iter().all(|&x| x == idx[0]) {
return Err(Reject::LaneConstantSite { iteration: it as u8, instr: k as u8, unit: unit as u8 });
}
for lane in 0..LANES {
if width == 1 {
r[d][lane] ^= dataset_elem(idx[lane], d0, d1);
} else {
let mut w = [0u32; 16];
for j in 0..width {
w[j] = dataset_elem(idx[lane] + j as u32, d0, d1);
}
r[d][lane] = fold_words(r[d][lane], &w[..width]);
}
lane_addrs[lane * loads + nload] = idx[lane];
}
nload += 1;
}
Op::Hot => {
// Hot table: the stand-in is dataset_elem keyed by seed words 2 and 3; the address is tagged with
// bit 30 so a hot word and a dataset word at one index count as two addresses.
for lane in 0..LANES {
idx[lane] = hot_index(r[a][lane], hot_words);
}
if idx.iter().all(|&x| x == idx[0]) {
return Err(Reject::LaneConstantSite { iteration: it as u8, instr: k as u8, unit: unit as u8 });
}
for lane in 0..LANES {
r[d][lane] ^= dataset_elem(idx[lane], h0, h1);
lane_addrs[lane * loads + nload] = 0x4000_0000 | idx[lane];
}
nload += 1;
}
Op::WLoad => {
let b = (r[a][0] & mask) & !31;
for lane in 0..LANES {
idx[lane] = b + lane as u32;
r[d][lane] ^= dataset_elem(idx[lane], d0, d1);
lane_addrs[lane * loads + nload] = idx[lane];
}
nload += 1;
}
}
}
}
for i in 0..8 {
for lane in 0..LANES {
let v = r[i][lane];
acc.and_acc[i] &= v;
acc.or_acc[i] |= v;
acc.saturated += (v == 0 || v == u32::MAX) as u32;
}
}
for lane in 0..LANES {
let lo = r[0][lane] ^ r[1][lane].rotate_left(7) ^ r[2][lane].rotate_left(14) ^ r[3][lane].rotate_left(21);
let hi = r[4][lane] ^ r[5][lane].rotate_left(9) ^ r[6][lane].rotate_left(18) ^ r[7][lane].rotate_left(27);
let h = ((hi as u64) << 32) | lo as u64;
for j in 0..64 {
acc.bit_ones[j] += ((h >> j) & 1) as u32;
}
let sl = &mut lane_addrs[lane * loads..(lane + 1) * loads];
sl.sort_unstable();
let mut distinct = 0u64;
for k in 0..loads {
// scratch slots carry bit 31 (variant 5) and are not dataset addresses
if sl[k] & 0x8000_0000 == 0 && (k == 0 || sl[k] != sl[k - 1]) {
distinct += 1;
}
}
acc.distinct_sum += distinct;
}
Ok(())
}
/// Part (c).
pub fn check_dynamic(p: &Program) -> Result<AcceptReport, Reject> {
let loads = p.loads_per_hash();
let v4 = is_class_v4_shape(&p.class);
let sites = loads / ITERATIONS;
let mut acc = Acc {
sources: if v4 { Some(vec![Vec::with_capacity(ACCEPT_HASHES * ITERATIONS); sites]) } else { None },
indices: None,
sat_source: vec![0; sites],
and_acc: [u32::MAX; 8],
or_acc: [0; 8],
saturated: 0,
bit_ones: [0; 64],
distinct_sum: 0,
};
let mut lane_addrs = vec![0u32; LANES * loads];
for (unit, &base) in accept_base_nonces(&p.seed).iter().enumerate() {
run_unit(p, unit, base, &mut acc, &mut lane_addrs)?;
}
for reg in 0..8 {
let bits = (acc.and_acc[reg] | !acc.or_acc[reg]).count_ones();
if bits != 0 {
return Err(Reject::ConstantBit { reg: reg as u8, bits: bits as u8 });
}
}
if acc.saturated >= MAX_SATURATED {
return Err(Reject::Saturated { count: acc.saturated });
}
// (c'), class v4 sub-version 2 (AP-F8-1, 7 October 2026): a load whose source is saturated reads one fixed word,
// whatever delivered the saturation (an or-written value, a rotate of one, a load after a saturated load); the
// source rule of the draw removes the writers it can see and this count catches every delivery. Keyed on the
// class v4 shape as the draw's rule is, so v2 and v3 verdicts do not move.
if v4 {
if let Some((site, &count)) = acc.sat_source.iter().enumerate().find(|(_, &c)| c >= MAX_SATURATED) {
return Err(Reject::SaturatedSource { site: site as u8, count });
}
// (c'') (B) on the (c) units' own source values
for (site, values) in acc.sources.take().unwrap().iter_mut().enumerate() {
let (best, best_v) = most_repeated(values);
if best >= MAX_SOURCE_REPEAT_V4 {
return Err(Reject::RepeatedSource { site: site as u8, value: best_v, count: best });
}
}
// (c'') (A) on 2^20 evaluations per site, the chosen candidate only (after every other test)
check_distinct_indices_v4(p)?;
}
let half = (ACCEPT_HASHES / 2) as u32;
let mut bias_max = 0u32;
for (bit, &ones) in acc.bit_ones.iter().enumerate() {
let d = ones.abs_diff(half);
if d > BIAS_TOLERANCE {
return Err(Reject::OutputBias { bit: bit as u8, ones });
}
bias_max = bias_max.max(d);
}
if acc.distinct_sum <= min_distinct_sum(loads - p.scratch_ops_per_hash()) {
return Err(Reject::DistinctAddresses { sum: acc.distinct_sum });
}
Ok(AcceptReport { distinct_sum: acc.distinct_sum, saturated: acc.saturated, bias_max })
}
/// The whole rule: (a), (b), then (c).
pub fn check(p: &Program) -> Result<AcceptReport, Reject> {
check_static(p)?;
check_dynamic(p)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generator::{candidate, candidate_class, generate, generate_class, GeneratorConfig, generate_v1, LoadClass};
use crate::verify::{DatasetMode, DatasetSource};
#[test]
fn distinct_bound_scales_with_the_load_count() {
assert_eq!(min_distinct_sum(128), MIN_DISTINCT_SUM);
assert_eq!(min_distinct_sum(32), 61_440);
}
/// The read-width classes pass the rule at about the version 2 rate, and the instrumented interpreter agrees
/// with `verify.rs` on every class (the fold is shared, the addresses are aligned the same way).
#[test]
fn classes_pass_and_match_verify() {
for name in ["w16", "w64", "w64x4", "50,35,15", "25,50,25", "scr2k32", "scr8k128"] {
let c = LoadClass::parse(name).unwrap();
let p = generate_class("igneum-genesis", c);
assert!(check(&p).is_ok(), "{name}");
let mut rejected = 0;
for i in 0..60u32 {
let s = format!("igneum-rw-accept/{i}");
let q = candidate_class(&s, s.as_bytes(), 0, c);
if check(&q).is_err() {
rejected += 1;
}
}
assert!(rejected < 15, "{name}: {rejected} of 60 rejected");
let ds = DatasetSource::from_key(p.seed, DatasetMode::ClosedForm, ACCEPT_DATASET_LOG2);
let bases = accept_base_nonces(&p.seed);
let loads = p.loads_per_hash();
let mut acc = Acc { sources: None, indices: None, sat_source: vec![0; 64], and_acc: [u32::MAX; 8], or_acc: [0; 8], saturated: 0, bit_ones: [0; 64], distinct_sum: 0 };
let mut la = vec![0u32; LANES * loads];
let mut ones = [0u32; 64];
for (u, &b) in bases.iter().enumerate() {
run_unit(&p, u, b, &mut acc, &mut la).unwrap();
for h in crate::verify::hash_warp(&p, b, &ds) {
for j in 0..64 {
ones[j] += ((h >> j) & 1) as u32;
}
}
}
assert_eq!(acc.bit_ones, ones, "{name}: bit counts match the reference interpreter");
}
}
/// AP-F8-3 (7 October 2026): the acceptance's execution and `verify.rs` agree on a class v4 program WITH its
/// shadow block (the output bit counts over the 64 units on the closed-form dataset, the same sel per iteration),
/// so the two paths cannot diverge again: until sub-version 3 the acceptance ran the base program only and judged
/// a program the chain never hashes. The devnet epoch-0 seed and the six test eras, 8 x 256 x 27 shadow
/// instructions per hash each; the same program with its shadow stripped gives other counts.
#[test]
fn acceptance_executes_the_shadow_block_as_the_verifier_does() {
use crate::generator::{generate_era, EraParams, V3_ALLOWED, V4_CLASS};
let hx = |h: &str| -> Vec<u8> { (0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect() };
let g = hx("edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07");
let mut eras = vec![g.clone()];
for n in 0..6 {
eras.push(EraParams::test_era_bytes(&format!("igneum-era-test/{n}")).to_vec());
}
for era in &eras {
let p = generate_era("igneum-epoch/edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07", &g, V4_CLASS, era, &V3_ALLOWED);
assert_eq!(p.shadow.len(), 256);
assert_eq!(p.shadow_reps(), 27);
let ds = DatasetSource::from_key(p.seed, DatasetMode::ClosedForm, ACCEPT_DATASET_LOG2);
let bases = accept_base_nonces(&p.seed);
let mut acc = Acc { sources: None, indices: None, sat_source: vec![0; 64], and_acc: [u32::MAX; 8], or_acc: [0; 8], saturated: 0, bit_ones: [0; 64], distinct_sum: 0 };
let mut la = vec![0u32; LANES * p.loads_per_hash()];
let mut ones = [0u32; 64];
for (u, &b) in bases.iter().enumerate() {
run_unit(&p, u, b, &mut acc, &mut la).unwrap();
for h in crate::verify::hash_warp(&p, b, &ds) {
for j in 0..64 {
ones[j] += ((h >> j) & 1) as u32;
}
}
}
assert_eq!(acc.bit_ones, ones, "the acceptance's execution of a class v4 program (shadow block included) matches the verifier's hashes");
// and the same program with its shadow stripped hashes differently: the shadow is executed, not skipped
let mut bare = p.clone();
bare.shadow.clear();
let mut acc2 = Acc { sources: None, indices: None, sat_source: vec![0; 64], and_acc: [u32::MAX; 8], or_acc: [0; 8], saturated: 0, bit_ones: [0; 64], distinct_sum: 0 };
for (u, &b) in bases.iter().enumerate() {
let _ = run_unit(&bare, u, b, &mut acc2, &mut la);
}
assert_ne!(acc.bit_ones, acc2.bit_ones, "the shadow block changes the acceptance's execution");
}
}
/// The instrumented interpreter agrees with `verify.rs` on the closed-form dataset keyed by the seed words.
#[test]
fn instrumented_interpreter_matches_verify() {
for i in 0..20u32 {
let s = format!("igneum-accept-test/{i}");
let p = candidate(&s, s.as_bytes(), 0);
let ds = DatasetSource::from_key(p.seed, DatasetMode::ClosedForm, ACCEPT_DATASET_LOG2);
let bases = accept_base_nonces(&p.seed);
let mut acc = Acc { sources: None, indices: None, sat_source: vec![0; 64], and_acc: [u32::MAX; 8], or_acc: [0; 8], saturated: 0, bit_ones: [0; 64], distinct_sum: 0 };
let mut la = vec![0u32; LANES * p.loads_per_hash()];
let mut ones = [0u32; 64];
let mut any = false;
for (u, &b) in bases.iter().enumerate() {
if run_unit(&p, u, b, &mut acc, &mut la).is_err() {
continue;
}
any = true;
let w = crate::verify::hash_warp(&p, b, &ds);
for h in w {
for j in 0..64 {
ones[j] += ((h >> j) & 1) as u32;
}
}
}
if any {
assert_eq!(acc.bit_ones, ones, "bit counts of {s} match the reference interpreter's hashes");
}
}
}
/// Hot-table experiment: the hot classes pass the rule at about the version 2 rate, and the hot addresses are
/// uniform over the table (16 buckets of the index over 64 units x 32 lanes x 32 hot loads).
#[test]
fn hot_classes_pass_and_hot_loads_are_uniform() {
for name in ["hot32k4", "hot64k4", "hot96k4", "hot64k2", "hot64k8", "scr4k32+hot64k4", "hot32k4a", "hot64k4a", "hot96k4a"] {
let c = LoadClass::parse(name).unwrap();
let p = generate_class("igneum-genesis", c);
assert!(check(&p).is_ok(), "{name}");
let mut rejected = 0;
for i in 0..60u32 {
let s = format!("igneum-hot-accept/{i}");
let q = candidate_class(&s, s.as_bytes(), 0, c);
if check(&q).is_err() {
rejected += 1;
}
}
assert!(rejected < 15, "{name}: {rejected} of 60 rejected");
}
let p = generate_class("igneum-genesis", LoadClass::hot(96, 4));
let words = p.hot_words();
let loads = p.loads_per_hash();
let mut acc = Acc { sources: None, indices: None, sat_source: vec![0; 64], and_acc: [u32::MAX; 8], or_acc: [0; 8], saturated: 0, bit_ones: [0; 64], distinct_sum: 0 };
let mut la = vec![0u32; LANES * loads];
let mut buckets = [0u64; 16];
let mut hot_count = 0u64;
for (u, &b) in accept_base_nonces(&p.seed).iter().enumerate() {
run_unit(&p, u, b, &mut acc, &mut la).unwrap();
for &a in &la {
if a & 0xC000_0000 == 0x4000_0000 {
let idx = a & 0x3FFF_FFFF;
assert!(idx < words);
buckets[(idx as u64 * 16 / words as u64) as usize] += 1;
hot_count += 1;
}
}
}
assert_eq!(hot_count, 64 * 32 * 32, "32 hot loads per hash over 2,048 hashes");
let mean = hot_count as f64 / 16.0;
for (i, &b) in buckets.iter().enumerate() {
assert!((b as f64 - mean).abs() < 0.15 * mean, "bucket {i}: {b} against a mean of {mean}");
}
// the dataset distinct count still holds for the dataset loads alone
let r = check(&p).unwrap();
assert!(r.distinct_mean() > 120.0);
}
#[test]
fn base_nonces_are_aligned_and_seed_dependent() {
let a = accept_base_nonces(&[1, 2, 3, 4, 5, 6, 7, 8]);
let b = accept_base_nonces(&[1, 2, 3, 4, 5, 6, 7, 9]);
assert!(a.iter().all(|x| x & 31 == 0));
assert_ne!(a, b);
assert_eq!(a, accept_base_nonces(&[1, 2, 3, 4, 5, 6, 7, 8]));
}
#[test]
fn stale_load_detection_is_cyclic() {
let mut p = candidate("igneum-genesis", b"igneum-genesis", 0);
assert!(check_stale_loads(&p.instrs).is_ok(), "an accepted candidate has no stale load");
// Make the last instruction a load from r3 and the first a load from r3 with no write between (wrap).
let (first, last) = (0usize, INSTR_COUNT - 1);
p.instrs[last].op = Op::Load;
p.instrs[last].src = 3;
p.instrs[last].dst = 4;
p.instrs[first].op = Op::Load;
p.instrs[first].src = 3;
p.instrs[first].dst = 5;
assert_eq!(check_stale_loads(&p.instrs), Err(Reject::StaleLoadSource { instr: 0, reg: 3 }));
}
#[test]
fn injecting_write_detection() {
let mut p = candidate("igneum-genesis", b"igneum-genesis", 0);
for ins in p.instrs.iter_mut() {
if ins.dst == 6 && ins.op.injects() {
ins.op = Op::Rotl;
}
}
assert_eq!(check_injecting_writes(&p.instrs), Err(Reject::NoInjectingWrite { reg: 6 }));
}
/// The census's measured rates: about 5 percent of candidates rejected, 128 distinct loads for the rest.
#[test]
fn rejection_rate_and_distinct_loads_on_a_sample() {
let mut rejected = 0;
let mut dsum = 0.0;
let mut accepted = 0;
for i in 0..400u32 {
let s = format!("igneum-census-2026-10-03/{i}");
match check(&candidate(&s, s.as_bytes(), 0)) {
Ok(r) => {
accepted += 1;
dsum += r.distinct_mean();
assert!(r.distinct_mean() > 120.0);
}
Err(_) => rejected += 1,
}
}
assert!(rejected < 50, "{rejected} of 400 rejected");
assert!(dsum / accepted as f64 > 127.0, "mean distinct {}", dsum / accepted as f64);
}
/// The retired generator fails the rule on nearly every program (the census: 95 percent).
#[test]
fn v1_programs_are_mostly_rejected() {
let mut rejected = 0;
for i in 0..100u32 {
let s = format!("igneum-census-2026-10-03/{i}");
if check(&generate_v1(&s, &GeneratorConfig::default())).is_err() {
rejected += 1;
}
}
assert!(rejected > 80, "{rejected} of 100 rejected");
}
#[test]
fn generated_programs_pass() {
for s in ["igneum-genesis", "igneum-hourly", "igneum-second-seed"] {
assert!(check(&generate(s)).is_ok());
}
}
}