diff --git a/igneum-pow/src/accept.rs b/igneum-pow/src/accept.rs index 198871a2..5777be4b 100644 --- a/igneum-pow/src/accept.rs +++ b/igneum-pow/src/accept.rs @@ -15,13 +15,31 @@ //! 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, Op, Program, INSTR_COUNT, ITERATIONS, LANES}; +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, rule (c''): the per-site distinct-index RATIO (AP-F8-1's low-entropy-band class, 7 October +/// 2026, the attack-pass gate's numbers through main), keyed on the class v4 shape. Over [`ACCEPT_UNITS_DISTINCT_V4`] +/// units (2^20 evaluations per site) the count of distinct dataset word indices a load site reads, against the +/// expectation of a uniform source on the site's window (N - N^2 / 2W), must reach [`MIN_DISTINCT_RATIO_V4`]. The +/// floor sits between the 55 clean F8 seeds' minimum over their site rows (0.9960; the clean p1 0.9990, the median +/// 1.0000) and the strong failing seeds' maximum (p56 0.9654; p23 0.8361, p18 0.9274, p19 0.9335, p15 0.9432), +/// 0.015 from each. The open tail: F8's p4, p8, p10 and p34 (1.22x to 1.50x on the gate) read 0.9927 to 0.9963 at +/// 2^20, inside the clean spread, and a 2^24 pass does not separate them either (p34 0.9181, p4 0.9614, p8 0.9630, +/// p10 0.9612 against the clean p44 0.9612, p52 0.9613, p3 0.9971, p2 and p5 1.0004); they stay unattributed and +/// chased in `docs/fud-ledger.md` AP-F8-1. A candidate under the floor is rejected and the next attempt drawn under +/// the 256 cap and the last resort. Measured on one box-2 core with the shadow executed: 2.8 s per chosen candidate. +pub const ACCEPT_UNITS_DISTINCT_V4: usize = 4096; +/// The ratio floor at 2^20. +pub const MIN_DISTINCT_RATIO_V4: f64 = 0.98; +/// Kept for the record and the driver, not wired: the most repeated source value per site over the (c) units' +/// 16,384 evaluations (a uniform site repeats a value 2 or 3 times; the finding's bands sit under the ratio instead). +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. @@ -57,6 +75,19 @@ pub enum Reject { 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''), class v4 sub-version 3: the load at `site` read `distinct` distinct dataset word indices over + /// `evaluations`, `ratio_milli` / 1000 of a uniform source on its window, under the floor: a low-entropy index band + /// (F8's p23, p18, p19, p15, p56). + LowEntropySite { site: u8, distinct: u32, evaluations: u32, ratio_milli: 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`. @@ -75,6 +106,10 @@ impl std::fmt::Display for Reject { 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, evaluations, ratio_milli } => write!(f, "(c'') load site {site} read {distinct} distinct word indices over {evaluations} evaluations, {}.{:03} of a uniform source on its window (floor {MIN_DISTINCT_RATIO_V4} at 2^20)", ratio_milli / 1000, ratio_milli % 1000), + 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) @@ -136,28 +171,170 @@ fn check_injecting_writes(instrs: &[Instr]) -> Result<(), Reject> { Ok(()) } -/// Parts (a) and (b). +/// The most repeated value of `values` (sorted in place) and that value: (B), kept for the driver, not wired. +#[allow(dead_code)] +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''), class v4 sub-version 3: the 2^20 ratio pass on the chosen candidate (the constants above). +pub fn check_distinct_indices_v4(p: &Program) -> Result<(), Reject> { + distinct_ratio_pass(p, ACCEPT_UNITS_DISTINCT_V4, MIN_DISTINCT_RATIO_V4).map(|_| ()) +} + +/// One ratio pass over `units`: every load site's distinct word indices against the uniform expectation on its +/// window (`N - N^2 / 2W`, the window `2^28 >> min(win, 2)` words of the closed-form dataset), `Err` at the first +/// site under `floor`, else the minimum ratio and its site. +pub fn distinct_ratio_pass(p: &Program, units: usize, floor: f64) -> Result<(f64, usize), Reject> { + let n = (units * LANES * ITERATIONS) as f64; + let d = distinct_indices_v4(p, units)?; + let mut min = (f64::MAX, 0usize); + let mut site = 0usize; + for i in &p.instrs { + if !i.op.is_load() { + continue; + } + let wsize = ((1u64 << ACCEPT_DATASET_LOG2) >> (i.win as u64).min(2)) as f64; + let ratio = d[site] as f64 / (n - n * n / (2.0 * wsize)); + if ratio < floor { + return Err(Reject::LowEntropySite { site: site as u8, distinct: d[site], evaluations: n as u32, ratio_milli: (ratio * 1000.0) as u32 }); + } + if ratio < min.0 { + min = (ratio, site); + } + site += 1; + } + Ok(min) +} + +/// 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, 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], pair_op: &mut [Option<(Op, usize)>; 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 }); + } + // the shared-operand idiom (sub-version 3): or-then-xor or or-then-sub on one operand is `d & ~s`, xor-then-or + // is `d | s`: lossy, though the second op would inject on its own (F8's p23: `or r6 |= r4; xor r6 ^= r4`) + let masked = matches!((pair_op[d], i.op), (Some((Op::Or, s)), Op::Xor) | (Some((Op::Or, s)), Op::Sub) | (Some((Op::Xor, s)), Op::Or) if s == a); + fresh[d] = !masked + && 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, + }; + pair_op[d] = if matches!(i.op, Op::Or | Op::Xor) && !masked { Some((i.op, a)) } else { None }; + for r in 0..8 { + if r != d { + if let Some((_, s)) = pair_op[r] { + if s == d { + pair_op[r] = None; + } + } + } + } + } + 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]; + let mut pair_op: [Option<(Op, usize)>; 8] = [None; 8]; + for _ in 0..9 { + let before = (fresh, pair_op); + freshness_pass(p, &mut fresh, &mut pair_op, false)?; + if (fresh, pair_op) == before { + break; + } + } + freshness_pass(p, &mut fresh, &mut pair_op, 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_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 { 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)); - let mut out = [0u32; ACCEPT_UNITS]; - for o in out.iter_mut() { - *o = (rng.next() as u32) & !31; - } - out + (0..n).map(|_| (rng.next() as u32) & !31).collect() } #[inline(always)] @@ -167,6 +344,13 @@ fn mulhi32(a: u32, b: u32) -> 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>>, + /// (c'') (A): every load's dataset index per site, recorded when present (the distinct-index pass only). + indices: Option>>, + /// (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, and_acc: [u32; 8], or_acc: [u32; 8], saturated: u32, @@ -198,9 +382,15 @@ fn run_unit(p: &Program, unit: usize, base: u32, acc: &mut Acc, lane_addrs: &mut 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]; - for (k, ins) in p.instrs.iter().enumerate() { + 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 { @@ -289,8 +479,17 @@ fn run_unit(p: &Program, unit: usize, base: u32, acc: &mut Acc, lane_addrs: &mut // 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 { - idx[lane] = load_index(era.as_ref(), ins, r[a][lane], mask, ACCEPT_DATASET_LOG2) & align; + 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 }); @@ -368,7 +567,18 @@ fn run_unit(p: &Program, unit: usize, base: u32, acc: &mut Acc, lane_addrs: &mut /// Part (c). pub fn check_dynamic(p: &Program) -> Result { let loads = p.loads_per_hash(); - let mut acc = Acc { and_acc: [u32::MAX; 8], or_acc: [0; 8], saturated: 0, bit_ones: [0; 64], distinct_sum: 0 }; + let v4 = is_class_v4_shape(&p.class); + let sites = loads / ITERATIONS; + let mut acc = Acc { + sources: 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)?; @@ -382,6 +592,17 @@ pub fn check_dynamic(p: &Program) -> Result { 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''), the ratio on the candidate that passed everything else (the draw's last and dearest 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() { @@ -435,7 +656,7 @@ mod tests { 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 { and_acc: [u32::MAX; 8], or_acc: [0; 8], saturated: 0, bit_ones: [0; 64], distinct_sum: 0 }; + 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() { @@ -450,6 +671,49 @@ mod tests { } } + /// 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 { (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() { @@ -458,7 +722,7 @@ mod tests { 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 { and_acc: [u32::MAX; 8], or_acc: [0; 8], saturated: 0, bit_ones: [0; 64], distinct_sum: 0 }; + 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; @@ -501,7 +765,7 @@ mod tests { 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 { and_acc: [u32::MAX; 8], or_acc: [0; 8], saturated: 0, bit_ones: [0; 64], distinct_sum: 0 }; + 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; diff --git a/igneum-pow/src/generator.rs b/igneum-pow/src/generator.rs index 13ac517a..9c2ebe05 100644 --- a/igneum-pow/src/generator.rs +++ b/igneum-pow/src/generator.rs @@ -54,6 +54,18 @@ pub const LOAD_SLOTS: usize = 16; /// Attempts before an implementation may treat the seed as a consensus fault (spec 01 section 1.4.6). At the /// measured 5.14 percent rejection rate the chance of 32 consecutive rejections is below 2^-136. pub const MAX_ATTEMPTS: u32 = 32; + +/// The attempt cap of class v4 sub-version 2 (AP-F8-2, 7 October 2026): rules (a') and (c') reject about two thirds of +/// candidates, so 32 attempts exhaust with probability about (2/3)^32, 2e-6 per epoch seed, one epoch no node could +/// draw every few decades at one epoch an hour (seen at chain-shaped seed igneum-f9/331672). At 256 attempts the +/// exhaustion probability is (2/3)^256, under 1e-45; the cost of a rejected attempt is one draw and the 64-unit check, +/// about 2 ms on one core, so the worst case is half a second. Keyed on the class v4 shape, so v2 and v3 keep 32. +pub const MAX_ATTEMPTS_V4: u32 = 256; + +/// The attempt cap of a class: [`MAX_ATTEMPTS_V4`] for the class v4 shape, [`MAX_ATTEMPTS`] otherwise. +pub fn max_attempts_for(class: &LoadClass) -> u32 { + if crate::accept::is_class_v4_shape(class) { MAX_ATTEMPTS_V4 } else { MAX_ATTEMPTS } +} /// Domain tag of the program id. pub const PROGRAM_ID_TAG: &[u8] = b"igneum-program/"; @@ -1095,9 +1107,11 @@ pub fn program_id(generator: u32, seed: &[u32; 8], attempt: u32) -> u64 { fnv1a64(&b) } -/// The sub-version of class v4's program stream, in every generator-4 program id and pack (AP-F8-1: 1 = the -/// load-source rule of `candidate_from_words_class`; 0 was the stream of 6 October 2026, never stamped). -pub const PROGRAM_SUBVERSION_V4: u16 = 1; +/// The sub-version of class v4's program stream, in every generator-4 program id and pack (AP-F8-3: 3 = the +/// acceptance executing the shadow block as the hash does, so its verdicts judge the program the chain hashes; +/// 2 = the dataflow load-source rule and the saturated-source check (c'), never shipped; 1 = the one-writer rule, +/// the stream 0.3.20 and 0.3.21 ship as object byte 5; 0 was the stream of 6 October 2026, never stamped). +pub const PROGRAM_SUBVERSION_V4: u16 = 3; /// Domain tag of the program id of a read-width class (never collides with [`PROGRAM_ID_TAG`]). pub const PROGRAM_ID_TAG_RW: &[u8] = b"igneum-program-rw/"; @@ -1237,19 +1251,26 @@ pub fn candidate_from_words_class( is_hot[slot as usize] = true; } // (2) The instructions. `fresh[r]`: r was written by an earlier instruction and no load has read it since. - // Class v4's chain draw (AP-F8-1, 7 October 2026, `docs/analysis/ca3-v4-uniform.md`): a load's source is drawn - // only from registers whose last writer injects or is a rotate (`entropy_kept[r]`), never from one last written - // by `or`, `mul` or `mulhi` (an `or`-written source is all-ones with probability (3/4)^32 per read and made the - // 153x item of the finding). Keyed on the era-composed V4_CLASS so the generator-2 ladder packs keep their stream; - // v2 and v3 take no part. The draw order and the stream are otherwise the same, draw for draw. - // Keyed on the class with the shadow's pass count set aside (the latency ladder draws the same base program at - // every rung: `of_load_class` compares the pass count too and answered None at every rung but 27, found by the - // fork's ladder test, 7 October 2026 10:06Z), the same comparison the fork's "v4 is v3 plus the shadow" test makes. - let source_rule_v4 = class.era.is_some() - && matches!(class.shadow, Some(ShadowClass { instrs: V4_SHADOW_INSTRS, .. })) + // Class v4's load-source rule (AP-F8-1, 7 October 2026, `docs/analysis/ca3-v4-uniform.md`; sub-version 2): + // a load's source is drawn only from registers that are FRESH by dataflow. Fresh at the program start (the init + // words are a per-lane hash of the nonce); after an op, the destination is fresh when: a load's source was fresh + // (a saturated source reads one fixed word and leaves a constant); add, sub, xor, mad or shfl had a fresh operand + // (dst or src); rotl or rotr rotated a fresh value (a rotate maps all-ones to all-ones); never after or, mul or + // mulhi (an or-written value is all-ones with probability (3/4)^32 per read, the 153x item of the finding; mul + // zeroes low bits; mulhi is dense near zero). Sub-version 1 looked one writer back and counted every load and + // rotate as fresh, which let an or-saturated value through a rotate or a load-after-load chain (F8's p6, p31). + // Keyed on the class v4 shape (the 256-instruction shadow block over the class v3 base, the pass count and the + // era set aside) on EVERY draw path, era or not, so a census through candidate_class reads the same stream as + // the chain; v2, v3 and every other class take no part. The draw order and the stream are otherwise the same. + let source_rule_v4 = matches!(class.shadow, Some(ShadowClass { instrs: V4_SHADOW_INSTRS, .. })) && LoadClass { era: None, shadow: None, ..class } == LoadClass { shadow: None, ..V4_CLASS }; let mut fresh = [false; 8]; - let mut entropy_kept = [false; 8]; + let mut fresh_value = [true; 8]; + // the shared-operand idiom (AP-F8-1, sub-version 3): after `or d |= s`, a later `xor d ^= s` or `sub d -= s` with + // s unwritten since is `d & ~s`; after `xor d ^= s`, a later `or d |= s` is `d | s`: lossy either way, though + // the second op would count as injecting on its own. `pair_op[d]` holds the (op, s) of the last or/xor on d + // while neither d nor s has been written since. + let mut pair_op: [Option<(Op, usize)>; 8] = [None; 8]; let mut instrs = Vec::with_capacity(INSTR_COUNT); for k in 0..INSTR_COUNT { let mut roll = rng.below(75); @@ -1275,7 +1296,7 @@ pub fn candidate_from_words_class( let mut eligible = [0u64; 8]; let mut n = 0usize; for r in 0..8u64 { - if r != dst && fresh[r as usize] && (!source_rule_v4 || entropy_kept[r as usize]) { + if r != dst && fresh[r as usize] && (!source_rule_v4 || fresh_value[r as usize]) { eligible[n] = r; n += 1; } @@ -1323,7 +1344,26 @@ pub fn candidate_from_words_class( fresh[src as usize] = false; } fresh[dst as usize] = true; - entropy_kept[dst as usize] = op.injects() || matches!(op, Op::Rotl | Op::Rotr); + let (d, a) = (dst as usize, src as usize); + let masked = matches!((pair_op[d], op), (Some((Op::Or, s)), Op::Xor) | (Some((Op::Or, s)), Op::Sub) | (Some((Op::Xor, s)), Op::Or) if s == a); + fresh_value[d] = !masked + && match op { + Op::Load | Op::WLoad | Op::Scratch | Op::Hot => fresh_value[a], + Op::Add | Op::Sub | Op::Xor | Op::Mad | Op::Shfl => fresh_value[d] || fresh_value[a], + Op::Rotl | Op::Rotr => fresh_value[d], + Op::Or | Op::Mul | Op::MulHi => false, + }; + // a write to d sets or clears d's pair; a write to any register clears every pair that names it as operand + pair_op[d] = if matches!(op, Op::Or | Op::Xor) && !masked { Some((op, a)) } else { None }; + for r in 0..8 { + if r != d { + if let Some((_, s)) = pair_op[r] { + if s == d { + pair_op[r] = None; + } + } + } + } instrs.push(Instr { op, dst: dst as u8, src: src as u8, src2: b as u8, imm, imm2, rot, bit: bit as u8, mask, width, win, off }); } // (3) The latency-shadow block (Counter ASIC 3.0 item 8): drawn after the base program from the same stream, so @@ -1410,14 +1450,38 @@ pub fn try_generate_from_seed_bytes(seed_string: &str, seed_bytes: &[u8]) -> Res /// [`try_generate_from_seed_bytes`] for a load class. pub fn try_generate_class(seed_string: &str, seed_bytes: &[u8], class: LoadClass) -> Result { let mut last = None; - for attempt in 0..MAX_ATTEMPTS { + let cap = max_attempts_for(&class); + for attempt in 0..cap { let p = candidate_class(seed_string, seed_bytes, attempt, class); match check(&p) { Ok(_) => return Ok(p), Err(r) => last = Some(r), } } - Err(Exhausted { seed_string: seed_string.to_string(), attempts: MAX_ATTEMPTS, last: last.unwrap() }) + if crate::accept::is_class_v4_shape(&class) { + // AP-F8-2 (7 October 2026, main's ruling: the draw is total and no consensus path panics): a class v4 seed that + // exhausts its attempts takes the last-resort program, deterministic and accepted as drawn + return Ok(last_resort_v4(candidate_class(seed_string, seed_bytes, cap, class))); + } + Err(Exhausted { seed_string: seed_string.to_string(), attempts: cap, last: last.unwrap() }) +} + +/// The last-resort program of a class v4 seed whose [`MAX_ATTEMPTS_V4`] candidates were all rejected (AP-F8-2): +/// the candidate at attempt [`MAX_ATTEMPTS_V4`] with every `or`, `mul` and `mulhi` of its base program and its shadow +/// block rewritten to `xor` (dst, src and the other fields kept). With no lossy op left every register stays fresh by dataflow from the +/// init words on, so rule (a') holds by construction; the program is the seed's consensus program as drawn, with no +/// further check, so the draw is total. It is reached with probability about (2/3)^256 per epoch seed (the measured +/// (a') plus (c') rejection rate of about two thirds per attempt), under 1e-45: the chain never sees it, and a test +/// walks it on real rejected candidates so the path is known to run. +pub fn last_resort_v4(mut p: Program) -> Program { + // the shadow block runs at the end of every iteration and its own lossy ops feed the next iteration's loads + // (rule (a') walks base then shadow to its fixpoint), so both are rewritten + for i in p.instrs.iter_mut().chain(p.shadow.iter_mut()) { + if matches!(i.op, Op::Or | Op::Mul | Op::MulHi) { + i.op = Op::Xor; + } + } + p } /// [`try_generate_from_seed_bytes`], treating exhaustion as the consensus fault it is. @@ -1815,6 +1879,184 @@ mod tests { /// from registers whose last writer keeps entropy, so it is its own stream over class v3's load slots and era /// draw; its generator is 4, its id `program_id(4, seed, attempt)` with the sub-version suffix, its era recorded; /// v2 and v3 are untouched. + /// Every load's source fresh by dataflow in the loop's steady state: the crate's own rule (a') of `accept.rs`. + fn every_load_source_fresh(p: &Program) -> bool { + crate::accept::check_fresh_sources_v4(p).is_ok() + } + + /// Class v4 sub-version 3, rule (c''): the distinct-index ratio refuses F8's low-entropy band on the chain's own + /// candidates (the first attempt of each strong seed past (a), (b), (c) and (c') fails the 2^20 pass), and the + /// shared-operand rule removes p23's value constant at the draw: the chain's attempt 1 (id d65122675f16a1c7) draws + /// site 7 from r5 and passes; the same program with that source put back to r6 (`or r6 |= r4; xor r6 ^= r4` + /// upstream) is refused by the static rule and, run anyway, by the ratio. + #[test] + fn class_v4_distinct_ratio_rejects_the_low_entropy_band() { + use crate::accept::{check_dynamic, check_static, Reject}; + use crate::seed::seed_words_from_bytes; + let w = |s: String| -> Vec { seed_words_from_bytes(s.as_bytes()).iter().flat_map(|x| x.to_le_bytes()).collect() }; + let f8 = |k: u32| (w(format!("igneum-attack-f8/program/{k}")), w(format!("igneum-attack-f8/era/{k}"))); + let candidate = |epoch: &[u8], era: &[u8], attempt: u32| { + let label = format!("igneum-epoch/{}", epoch.iter().map(|b| format!("{b:02x}")).collect::()); + let mut p = candidate_class(&label, epoch, attempt, LoadClass::era(V4_CLASS, era, &V3_ALLOWED)); + p.generator = GENERATOR_VERSION_V4; + p.era_bytes = Some(era.to_vec()); + p + }; + for k in [15u32, 18, 19, 56] { + let (epoch, era) = f8(k); + let mut seen = None; + for attempt in 0..MAX_ATTEMPTS_V4 { + let p = candidate(&epoch, &era, attempt); + let t0 = std::time::Instant::now(); + match check_static(&p).and_then(|_| check_dynamic(&p).map(|_| ())) { + Err(Reject::LowEntropySite { site, distinct, evaluations, ratio_milli }) => { + println!("p{k} attempt {attempt} id {:016x}: (c'') site {site} read {distinct} distinct over {evaluations}, ratio {}.{:03}, {:.1} s", p.program_id(), ratio_milli / 1000, ratio_milli % 1000, t0.elapsed().as_secs_f64()); + seen = Some(attempt); + break; + } + Err(_) => continue, + Ok(()) => break, + } + } + assert!(seen.is_some(), "p{k}: the chain reaches a candidate only the ratio refuses"); + } + let (epoch, era) = f8(23); + let p = candidate(&epoch, &era, 1); + assert_eq!(p.program_id(), 0xd65122675f16a1c7); + let site7 = p.instrs.iter().enumerate().filter(|(_, i)| i.op.is_load()).nth(7).map(|(k, _)| k).unwrap(); + println!("p23 attempt 1: site 7 is instruction {site7}, source r{}", p.instrs[site7].src); + assert_eq!(p.instrs[site7].src, 5, "the shared-operand rule moved site 7 off r6"); + assert!(check_static(&p).is_ok(), "p23 attempt 1 passes the static rule"); + let t0 = std::time::Instant::now(); + let v = check_dynamic(&p).map(|_| ()); + println!("p23 attempt 1 dynamic: {:?} in {:.1} s", v.as_ref().err().map(|x| x.to_string()), t0.elapsed().as_secs_f64()); + assert!(v.is_ok(), "p23 attempt 1 passes the dynamic rule"); + let mut q = p.clone(); + q.instrs[site7].src = 6; + assert!(matches!(check_static(&q), Err(Reject::UnfreshLoadSource { .. })), "the value constant's load is refused by the static rule"); + let v = check_dynamic(&q).map(|_| ()); + println!("p23 attempt 1 with site 7 from r6: {:?}", v.as_ref().err().map(|x| x.to_string())); + assert!(matches!(v, Err(Reject::LowEntropySite { .. })), "and by the ratio when run"); + } + + /// Diagnostic (AP-F8-1, p23): the distinct word indices per site on the closed-form words at 2^20 and 2^24 + /// evaluations, for the program F8 measured (attempt 1, id d64dbc675f13be9e): site 7 (instruction 38) reads + /// r6 = (mulhi(..) | r4) ^ r4 = r6 & ~r4, an andnot idiom the lineage rule counts as fresh. + #[test] + #[ignore] + fn diag_p23_distinct_indices_per_site() { + let hx = |h: &str| -> Vec { (0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect() }; + let e = hx("01aa1485fcb5d59223ca40602086e618277decf440188c5a55898f635f7be34f"); + let r = hx("00951c99e7ef952fd52611b8de7c07cc705cdec9e129a31a19d696c99909f052"); + let mut p = candidate_class("igneum-epoch/01aa1485fcb5d59223ca40602086e618277decf440188c5a55898f635f7be34f", &e, 1, LoadClass::era(V4_CLASS, &r, &V3_ALLOWED)); + p.generator = GENERATOR_VERSION_V4; + p.era_bytes = Some(r.clone()); + assert_eq!(p.program_id(), 0xd64dbc675f13be9e); + for units in [4096usize, 65536] { + let t0 = std::time::Instant::now(); + let d = crate::accept::distinct_indices_v4(&p, units).unwrap(); + println!("p23 d64dbc675f13be9e at {} evaluations per site ({:.1} s): distinct word indices per site {:?}; site 7 = {} (log2 {:.1})", units * 32 * 8, t0.elapsed().as_secs_f64(), d, d[7], (d[7] as f64).log2()); + } + } + + /// The threshold measurement for the second sub-version 3 commit: every F8 program (p1 = the devnet epoch-0 seeds, + /// p2 to p64 = the attack-pass harness's label-derived seeds), drawn under this commit's verdicts, each load + /// site's distinct word indices over 2^20 evaluations against the window expectation N - N^2 / 2W, the minimum + /// ratio per seed. Clean seeds set the threshold; the failing seeds of F8's table must sit below it. + #[test] + #[ignore] + fn diag_f8_64_distinct_index_ratios() { + use crate::seed::seed_words_from_bytes; + let w = |s: String| -> Vec { seed_words_from_bytes(s.as_bytes()).iter().flat_map(|x| x.to_le_bytes()).collect() }; + let hx = |h: &str| -> Vec { (0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect() }; + let n = 1u64 << 20; + for k in 1..=64u32 { + let (epoch, era) = if k == 1 { + let g = hx("edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07"); + (g.clone(), g) + } else { + (w(format!("igneum-attack-f8/program/{k}")), w(format!("igneum-attack-f8/era/{k}"))) + }; + let label = format!("igneum-epoch/{}", epoch.iter().map(|b| format!("{b:02x}")).collect::()); + let p = generate_era(&label, &epoch, V4_CLASS, &era, &V3_ALLOWED); + let t0 = std::time::Instant::now(); + let d = crate::accept::distinct_indices_v4(&p, 4096).unwrap(); + let mut ratios = Vec::new(); + let mut site = 0usize; + for i in &p.instrs { + if !i.op.is_load() { continue; } + let k_off = (i.win as u64).min(2); + let wsize = (1u64 << 28) >> k_off; + let expected = n as f64 - (n as f64) * (n as f64) / (2.0 * wsize as f64); + ratios.push((d[site] as f64 / expected, site, i.win)); + site += 1; + } + let (min_ratio, min_site, min_win) = ratios.iter().cloned().fold((9.0, 0, 0), |a, b| if b.0 < a.0 { b } else { a }); + println!("RATIO p{k} attempt {} id {:016x}: min {:.4} at site {} (win {}) ; all {} ; {:.1} s", p.attempt, p.program_id(), min_ratio, min_site, min_win, ratios.iter().map(|r| format!("{:.3}", r.0)).collect::>().join(" "), t0.elapsed().as_secs_f64()); + } + } + + /// The 2^24 reach of the ratio for the weak failing seeds (p34, p4, p8, p10 at 1.22x to 1.50x sit inside the + /// clean spread at 2^20), with p23 and five clean seeds as the scale. + #[test] + #[ignore] + fn diag_f8_weak_seeds_at_2e24() { + use crate::seed::seed_words_from_bytes; + let w = |s: String| -> Vec { seed_words_from_bytes(s.as_bytes()).iter().flat_map(|x| x.to_le_bytes()).collect() }; + let n = 1u64 << 24; + for k in [34u32, 4, 8, 10, 23, 2, 3, 5, 44, 52] { + let (epoch, era) = (w(format!("igneum-attack-f8/program/{k}")), w(format!("igneum-attack-f8/era/{k}"))); + let label = format!("igneum-epoch/{}", epoch.iter().map(|b| format!("{b:02x}")).collect::()); + let p = generate_era(&label, &epoch, V4_CLASS, &era, &V3_ALLOWED); + let t0 = std::time::Instant::now(); + let d = crate::accept::distinct_indices_v4(&p, 65536).unwrap(); + let mut ratios = Vec::new(); + let mut site = 0usize; + for i in &p.instrs { + if !i.op.is_load() { continue; } + let wsize = (1u64 << 28) >> (i.win as u64).min(2); + let expected = n as f64 - (n as f64) * (n as f64) / (2.0 * wsize as f64); + ratios.push(d[site] as f64 / expected); + site += 1; + } + let min = ratios.iter().cloned().fold(9.0f64, f64::min); + println!("RATIO24 p{k} attempt {} id {:016x}: min {:.4} ; all {} ; {:.1} s", p.attempt, p.program_id(), min, ratios.iter().map(|r| format!("{:.3}", r)).collect::>().join(" "), t0.elapsed().as_secs_f64()); + } + } + + #[test] + fn class_v4_draw_is_total_with_the_last_resort() { + assert_eq!(max_attempts_for(&V4_CLASS), MAX_ATTEMPTS_V4); + assert_eq!(max_attempts_for(&LoadClass::era(V4_CLASS, &[7u8; 32], &V3_ALLOWED)), MAX_ATTEMPTS_V4); + assert_eq!(max_attempts_for(&V3_CLASS), MAX_ATTEMPTS); + assert_eq!(max_attempts_for(&LoadClass::V2), MAX_ATTEMPTS); + let class = LoadClass::era(V4_CLASS, &EraParams::test_era_bytes("igneum-era-test/0"), &V3_ALLOWED); + // real candidates that rule (a') rejects (the exhausting shape of seed igneum-f9/331672 at 07a809a7: 32 in a + // row), repaired by the last resort: every load's source fresh in the loop's steady state + let mut rejected = 0; + for i in 0..64u32 { + let seed = format!("igneum-ca3-v4-amend/total/{i}"); + for attempt in 0..4u32 { + let c = candidate_class(&seed, seed.as_bytes(), attempt, class); + if matches!(crate::accept::check_fresh_sources_v4(&c), Err(crate::accept::Reject::UnfreshLoadSource { .. })) { + rejected += 1; + let fixed = last_resort_v4(c.clone()); + assert!(crate::accept::check_fresh_sources_v4(&fixed).is_ok(), "{seed} attempt {attempt}: the last resort is fresh at every load"); + assert!(fixed.instrs.iter().chain(fixed.shadow.iter()).all(|i| !matches!(i.op, Op::Or | Op::Mul | Op::MulHi))); + assert_eq!((fixed.instrs.len(), fixed.shadow.len()), (c.instrs.len(), c.shadow.len())); + } + } + } + assert!(rejected > 0, "the sample holds (a')-rejected candidates (about two thirds of attempts do)"); + // the chain path is total: 64 seeds, every one a program + for i in 0..64u32 { + let seed = format!("igneum-ca3-v4-amend/total/{i}"); + let p = try_generate_class(&seed, seed.as_bytes(), class).expect("a class v4 seed always draws"); + assert!(crate::accept::check_fresh_sources_v4(&p).is_ok()); + assert!(p.attempt < MAX_ATTEMPTS_V4 || p.instrs.iter().chain(p.shadow.iter()).all(|i| !matches!(i.op, Op::Or | Op::Mul | Op::MulHi))); + } + } + #[test] fn program_class_v4_is_class_v3_with_the_shadow_block() { assert_eq!(V4_CLASS, V3_CLASS.with_shadow(256, 27)); @@ -1834,20 +2076,28 @@ mod tests { // whose last writer injects or rotates, so its base program is its own stream (the 6 October stream, equal // to class v3's draw for draw, is sub-version 0 and never stamped); the load slots, the op draws and the era // draw are still class v3's, and every load site obeys the rule - assert_eq!(v4.seed, v3.seed); + // sub-version 2 rejects candidates (rules (a') and (c')), so the accepted attempt can differ from class v3's and + // the seed words carry the attempt: compare with the class v3 candidate at the SAME attempt + let v3c = candidate_class("igneum-genesis", b"igneum-genesis", v4.attempt, LoadClass::era(V3_CLASS, &era, &V3_ALLOWED)); + assert_eq!(v4.seed, v3c.seed); assert_eq!( v4.instrs.iter().map(|i| i.op.is_load()).collect::>(), - v3.instrs.iter().map(|i| i.op.is_load()).collect::>(), - "the load slots are class v3's" + v3c.instrs.iter().map(|i| i.op.is_load()).collect::>(), + "the load slots are class v3's at the same attempt" ); - let mut kept = [false; 8]; - for (k, i) in v4.instrs.iter().enumerate() { - if i.op.is_load() { - assert!(kept[i.src as usize], "load #{k} reads r{} whose last writer does not keep entropy", i.src); - } - kept[i.dst as usize] = i.op.injects() || matches!(i.op, Op::Rotl | Op::Rotr); + assert!(every_load_source_fresh(&v4), "every load of the amended v4 program reads a fresh register"); + let v3c_as_v4 = Program { class: v4.class, shadow: v4.shadow.clone(), ..v3c.clone() }; + if !every_load_source_fresh(&v3c_as_v4) { + assert_ne!(v4.instrs, v3c.instrs, "the amended v4 base program is not class v3's (a lossy-sourced load was redrawn)"); } - assert_ne!(v4.instrs, v3.instrs, "the amended v4 base program is not class v3's (a lossy-sourced load was redrawn)"); + // the same seed without an era draws under the rule too (sub-version 2: the rule is keyed on the class shape on + // every draw path), and the v3 program of the seed is the known-failed case + let v4_no_era = generate_from_seed_bytes_class("igneum-genesis", b"igneum-genesis", V4_CLASS); + assert!(every_load_source_fresh(&v4_no_era)); + // the known-failed case: the v3 program of the same seed and era, re-labelled with the v4 shape so the rule + // applies, carries an unfresh load source (96.6 percent of chain-shaped seeds do, ca3-v4-uniform.md section 3) + let v3_as_v4 = Program { class: v4.class, shadow: v4.shadow.clone(), ..v3.clone() }; + println!("class v3 of igneum-genesis under era [7; 32] (attempt {}): every load source fresh = {}; v4 accepted at attempt {}", v3.attempt, every_load_source_fresh(&v3_as_v4), v4.attempt); assert!(v3.shadow.is_empty() && !v3.has_shadow()); assert_eq!(v4.shadow.len(), 256); assert_eq!(v4.shadow_reps(), 27); @@ -2105,10 +2355,16 @@ mod tests { fn shadow_class_leaves_the_base_program_and_class_v3_untouched() { // Counter ASIC 3.0 item 8: the shadow is drawn after the 64 base instructions, so the base program, its // attempt and its acceptance verdict are the class's without the shadow; v2 and v3 draw nothing. + // Since class v4 sub-version 2 (AP-F8-1) a 256-instruction block over MX8 is the class v4 shape and draws its + // load sources under the dataflow rule on every path, so the "untouched" property is shown on a 64-instruction + // block (a measurement class, no rule) and the 256-block is shown to obey the rule instead let base = generate_class("igneum-genesis", LoadClass::MX8); + let sh64 = generate_class("igneum-genesis", LoadClass::MX8.with_shadow(64, 13)); + assert_eq!(sh64.instrs, base.instrs); + assert_eq!(sh64.attempt, base.attempt); + assert_eq!(sh64.shadow.len(), 64); let sh = generate_class("igneum-genesis", LoadClass::MX8.with_shadow(256, 13)); - assert_eq!(sh.instrs, base.instrs); - assert_eq!(sh.attempt, base.attempt); + assert!(crate::accept::check_fresh_sources_v4(&sh).is_ok(), "a 256-block over MX8 draws under the class v4 source rule"); assert!(base.shadow.is_empty() && !base.has_shadow() && base.shadow_instrs_per_hash() == 0); assert_eq!(sh.shadow.len(), 256); assert!(sh.has_shadow()); diff --git a/igneum-pow/tests/mixer.rs b/igneum-pow/tests/mixer.rs index 6230ee11..61e32aa0 100644 --- a/igneum-pow/tests/mixer.rs +++ b/igneum-pow/tests/mixer.rs @@ -18,7 +18,7 @@ //! shadow, draw for draw. The edge test is the dataset's alone (the shadow touches no dataset word) and takes no class. use igneum_pow::emit::{export_pack, vectors_json}; -use igneum_pow::generator::{era_generator_of, generate_era, generate_from_seed_bytes, generate_from_seed_bytes_class, generate_from_seed_bytes_program_class, EraParams, LoadClass, Op, Program, ProgramClass, GENERATOR_VERSION_V3, GENERATOR_VERSION_V4, INSTR_COUNT, V3_ALLOWED, V3_CLASS}; +use igneum_pow::generator::{era_generator_of, generate_era, generate_from_seed_bytes, generate_from_seed_bytes_class, generate_from_seed_bytes_program_class, EraParams, LoadClass, Op, Program, ProgramClass, GENERATOR_VERSION_V3, GENERATOR_VERSION_V4, INSTR_COUNT, V3_ALLOWED, V3_CLASS, V4_CLASS, V4_SHADOW_INSTRS}; use igneum_pow::memhard::{derive_item, mixer, round_key, Cache, MixParams, Shape}; use igneum_pow::seed::{day_key, SplitMix64}; use igneum_pow::verify::{DatasetMode, DatasetSource, Epoch}; @@ -92,16 +92,24 @@ fn contract(p: &Program, seed: &str, class: LoadClass, era: Option<[u8; 32]>) { assert_eq!((i.width, i.win, i.off), (1, 0, 0), "shadow #{k}: no load fields"); } let base = program_of(seed, LoadClass { shadow: None, ..class }, era); - if p.generator == GENERATOR_VERSION_V4 { - // the amended class v4 (AP-F8-1): the chain draw takes a load's source only from registers whose - // last writer injects or rotates, so its base program is its own stream, not class v3's; what holds - // is the rule itself, checked here on every load site in draw order - let mut kept = [false; 8]; + let v4_shape = LoadClass { era: None, shadow: None, ..class } == LoadClass { shadow: None, ..V4_CLASS } && sh.instrs == V4_SHADOW_INSTRS; + if v4_shape { + // the amended class v4 (AP-F8-1, sub-version 2): on every draw path a load's source is a register + // fresh by dataflow (a load keeps freshness only from a fresh source; add, sub, xor, mad, shfl from + // either operand; rotates from their operand; or, mul, mulhi never), so its base program is its own + // stream, not class v3's; what holds is the rule itself, checked here on every load site in draw order + let mut fresh = [true; 8]; for (k, i) in p.instrs.iter().enumerate() { + let (d, a) = (i.dst as usize, i.src as usize); if i.op.is_load() { - assert!(kept[i.src as usize], "{seed}: load #{k} reads r{} whose last writer does not keep entropy", i.src); + assert!(fresh[a], "{seed}: load #{k} reads r{} which is not fresh by dataflow", i.src); } - kept[i.dst as usize] = i.op.injects() || matches!(i.op, Op::Rotl | Op::Rotr); + fresh[d] = match i.op { + Op::Load | Op::WLoad | Op::Scratch | Op::Hot => fresh[a], + Op::Add | Op::Sub | Op::Xor | Op::Mad | Op::Shfl => fresh[d] || fresh[a], + Op::Rotl | Op::Rotr => fresh[d], + Op::Or | Op::Mul | Op::MulHi => false, + }; } } else { assert_eq!(p.instrs, base.instrs, "{seed}: the base program is the class's without the shadow"); diff --git a/igneum-pow/tests/recheck.rs b/igneum-pow/tests/recheck.rs index ad0210b1..9b2f4f76 100644 --- a/igneum-pow/tests/recheck.rs +++ b/igneum-pow/tests/recheck.rs @@ -132,9 +132,12 @@ fn program_ids_differ_between_class_v3_and_class_v4_of_one_seed() { let v3 = load("packs-ca2-mixer/mx8-devnet-epoch0", ProgramClass::V3); let v4 = load("packs-ca3-v4/v4-devnet-epoch0", ProgramClass::V4); assert_eq!(v3.reference.program.program_id(), 0x73bc_bfe8_ccf9_88f1, "the v3 control's id as pinned"); - // the amended class v4 (AP-F8-1, sub-version 1): the id of 6 October 2026, c120d7963abdcd96, is the must-differ - // vector (a binary from before the load-source rule), the pinned id below the must-equal one + // the amended class v4 (AP-F8-1, AP-F8-3): sub-version 3 (object byte 7; the acceptance executes the shadow block) + // is the pinned id below; c120d7963abdcd96 (the 6 October stream, byte 4), 1a4230699a6b9c60 (sub-version 1, the + // one-writer rule, 0.3.20's and 0.3.21's byte 5) and a788661687db4bb3 (sub-version 2, never shipped) must differ assert_ne!(v4.reference.program.program_id(), 0xc120_d796_3abd_cd96, "the pre-amendment v4 id must differ"); - assert_eq!(v4.reference.program.program_id(), 0x1a42_3069_9a6b_9c60, "the amended v4 candidate's id as pinned"); + assert_ne!(v4.reference.program.program_id(), 0x1a42_3069_9a6b_9c60, "the sub-version-1 id must differ"); + assert_ne!(v4.reference.program.program_id(), 0xa788_6616_87db_4bb3, "the sub-version-2 id must differ"); + assert_eq!(v4.reference.program.program_id(), 0xa785_0016_87d8_688a, "the sub-version-3 v4 id as pinned"); assert_ne!(v3.reference.program.program_id(), v4.reference.program.program_id()); }