838 lines
41 KiB
Rust
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());
|
|
}
|
|
}
|
|
}
|