From b46f4c82bccafcb7a5d18e3f7c1c1f865491ae13 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:27:53 +0000 Subject: [PATCH] class v5: the AP-F4-1 mixer-draw rule behind Shape.state (a mixer block with NAF sum under 163, a word under NAF weight 4 or under 4 distinct rotation amounts is redrawn from the next stream values; naf_weight, mixer_block_admissible; the known-failed case a block of two-adder multipliers, then a day scan that finds the census's 6.1e-4 redraws with v4's constants unmoved) and the AP-F1-1 shadow rule behind LoadClass.state (a block whose peephole-removable share, shadow_removable_count: xor-cancel, or-idempotence, rotate merges, sum-cancel, exceeds 3.0 percent is redrawn from the continuing stream; the known-failed case a block of or pairs, then a seed scan that finds the census's 4e-3 redraws with the base program unmoved) Co-Authored-By: Claude Fable 5.1 --- igneum-pow/src/generator.rs | 89 ++++++++++++++++++++++++++++++ igneum-pow/src/memhard.rs | 106 +++++++++++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/igneum-pow/src/generator.rs b/igneum-pow/src/generator.rs index d08f7f016..09d430a0f 100644 --- a/igneum-pow/src/generator.rs +++ b/igneum-pow/src/generator.rs @@ -1293,6 +1293,41 @@ pub fn candidate_from_words(seed_string: &str, seed_bytes: &[u8], seed: [u32; 8] /// [`candidate_from_words`] for a load class. For [`LoadClass::V2`] this is the version 2 draw stream exactly; /// for any other class the slot count is the class's and every instruction takes a tenth draw, `below(100)`, /// the width roll (used only on a load slot, drawn on every slot so the stream stays uniform). +/// Class v5's shadow rule (AP-F1-1): the peephole-removable share a block may have, per mille of its instructions. +pub const SHADOW_REMOVABLE_MAX_PERMILLE: usize = 30; +/// Class v5's shadow rule: redraws before the last block stands as drawn (never reached at 4e-3 per try). +pub const SHADOW_REDRAW_CAP: u32 = 64; + +/// The instructions of a shadow block an honest compiler removes (the AP-F1-1 census's classes): an instruction whose +/// destination's last writer, with no write to the destination or to the source in between, is the same op on the +/// same source and immediates and the pair cancels (xor: `x ^= s` twice) or is idempotent (or: `x |= s` twice), or a +/// rotate of a register last written by a rotate (the two merge into one), or an add followed by a sub (or a sub by an +/// add) of the same source and immediate (sum-cancel). Counted per removable instruction, never across a pass. +pub fn shadow_removable_count(block: &[Instr]) -> usize { + let mut last: [Option; 8] = [None; 8]; + let mut removable = 0; + for (k, ins) in block.iter().enumerate() { + let d = ins.dst as usize; + if let Some(j) = last[d] { + let prev = &block[j]; + // the source must not have been written between j and k (its value is the same) + let src_untouched = !block[j + 1..k].iter().any(|i| i.dst == ins.src); + let same_operands = prev.src == ins.src && prev.imm == ins.imm && prev.imm2 == ins.imm2 && prev.src2 == ins.src2 && prev.rot == ins.rot && prev.bit == ins.bit && prev.mask == ins.mask; + let hit = match (prev.op, ins.op) { + (Op::Xor, Op::Xor) | (Op::Or, Op::Or) => same_operands && src_untouched, + (Op::Rotl, Op::Rotl) | (Op::Rotr, Op::Rotr) | (Op::Rotl, Op::Rotr) | (Op::Rotr, Op::Rotl) => true, + (Op::Add, Op::Sub) | (Op::Sub, Op::Add) => same_operands && src_untouched, + _ => false, + }; + if hit { + removable += 1; + } + } + last[d] = Some(k); + } + removable +} + pub fn candidate_from_words_class( seed_string: &str, seed_bytes: &[u8], @@ -1447,7 +1482,14 @@ pub fn candidate_from_words_class( // op from the non-load table, the source as on an ALU slot, the same per-instruction draws (the width roll and // the era windows included when the class takes them, drawn and ignored) so the stream shape is the program's. let mut shadow = Vec::new(); + let mut shadow_redraws = 0u32; if let Some(sh) = class.shadow { + // Class v5 (docs/design/class-v5-stored-state.md section 11, AP-F1-1): a shadow block whose peephole-removable + // share exceeds SHADOW_REMOVABLE_MAX_PERMILLE (3.0 percent of the block; the census's histogram puts 384 of 100,000 + // draws there) is redrawn from the continuing stream, so an honest compiler's simplification cannot take more + // than 3 percent of the shadow's useful work. Every other class keeps its first draw. + loop { + shadow.clear(); for _ in 0..sh.instrs { let mut roll = rng.below(75); let mut op = Op::Add; @@ -1476,7 +1518,13 @@ pub fn candidate_from_words_class( } shadow.push(Instr { op, dst: dst as u8, src: src as u8, src2: b as u8, imm, imm2, rot, bit: bit as u8, mask, width: 1, win: 0, off: 0 }); } + if !class.state || shadow_removable_count(&shadow) * 1000 <= sh.instrs as usize * SHADOW_REMOVABLE_MAX_PERMILLE || shadow_redraws >= SHADOW_REDRAW_CAP { + break; + } + shadow_redraws += 1; + } } + let _ = shadow_redraws; Program { seed_string: seed_string.to_string(), seed_bytes: seed_bytes.to_vec(), @@ -1954,6 +2002,47 @@ mod tests { assert!(!ProgramClass::V4.has_state() && ProgramClass::V5.has_state()); } + /// Class v5's shadow rule (AP-F1-1), the known-failed case first: a synthetic block of xor-cancel pairs is counted and + /// is over the bound; a scan of seeds finds a v5 draw whose first block was redrawn (the census's 4e-3), every v5 + /// block is under 3.0 percent removable, and the same seed's class v4 block (never redrawn) is the first draw. + #[test] + fn class_v5_shadow_redundancy_rule() { + let mk = |op: Op, dst: u8, src: u8| Instr { op, dst, src, src2: 0, imm: 7, imm2: 9, rot: 3, bit: 0, mask: 1, width: 1, win: 0, off: 0 }; + let pair = vec![mk(Op::Xor, 1, 2), mk(Op::Xor, 1, 2)]; + assert_eq!(shadow_removable_count(&pair), 1, "the known-failed case: an xor-cancel pair"); + let broken = vec![mk(Op::Xor, 1, 2), mk(Op::Add, 2, 3), mk(Op::Xor, 1, 2)]; + assert_eq!(shadow_removable_count(&broken), 0, "the source moved between the two"); + let rot = vec![mk(Op::Rotl, 4, 0), mk(Op::Rotr, 4, 5)]; + assert_eq!(shadow_removable_count(&rot), 1, "two rotates merge"); + let sum = vec![mk(Op::Add, 6, 7), mk(Op::Sub, 6, 7)]; + assert_eq!(shadow_removable_count(&sum), 1, "sum-cancel"); + let mut bad = Vec::new(); + for _ in 0..128 { + bad.push(mk(Op::Or, 3, 5)); + bad.push(mk(Op::Or, 3, 5)); + } + assert!(shadow_removable_count(&bad) * 1000 > bad.len() * SHADOW_REMOVABLE_MAX_PERMILLE, "a block of or-idempotent pairs is over the bound"); + let era = [7u8; 32]; + let mut redrawn = 0; + let mut scanned = 0; + for i in 0..6_000u32 { + let seed = format!("igneum-shadow-scan/{i}"); + let v5 = generate_from_seed_bytes_program_class(&seed, seed.as_bytes(), ProgramClass::V5, Some(&era)); + assert!(shadow_removable_count(&v5.shadow) * 1000 <= v5.shadow.len() * SHADOW_REMOVABLE_MAX_PERMILLE, "{seed}: a v5 block over the bound"); + let v4 = generate_from_seed_bytes_program_class(&seed, seed.as_bytes(), ProgramClass::V4, Some(&era)); + assert_eq!(v5.instrs, v4.instrs, "{seed}: the base program never moves"); + if v5.shadow != v4.shadow { + redrawn += 1; + assert!(shadow_removable_count(&v4.shadow) * 1000 > v4.shadow.len() * SHADOW_REMOVABLE_MAX_PERMILLE, "{seed}: v5 redrew a block the rule admits"); + } + scanned += 1; + if redrawn >= 2 && scanned >= 1_000 { + break; + } + } + assert!(redrawn >= 1, "no redraw in {scanned} seeds (the census says about 4e-3 per draw)"); + } + /// Class v5 (docs/design/class-v5-stored-state.md) takes the amended class v4 draw (AP-F8-1) as the chain draws it: /// with an era present every load's source was last written by an injecting op or a rotate, the base program and /// the shadow block equal the amended v4's of the same seed, and the same holds at a ladder rung; the state flag diff --git a/igneum-pow/src/memhard.rs b/igneum-pow/src/memhard.rs index 56167e2e9..63a35ee50 100644 --- a/igneum-pow/src/memhard.rs +++ b/igneum-pow/src/memhard.rs @@ -206,6 +206,46 @@ pub struct MixParams { pub shape: Shape, /// The per-day derivation program when `shape.derive_len != 0`, else `None`. pub derive: Option, + /// Class v5: how many mixer blocks the AP-F4-1 rule redrew before this one (0 on every other class, and on most days). + pub redraws: u32, +} + +/// Class v5's mixer-draw rule (AP-F4-1): the NAF sum of the 16 multipliers at least this. +pub const MIXER_NAF_SUM_MIN: u32 = 163; +/// Class v5's mixer-draw rule: every multiplier's NAF weight at least this. +pub const MIXER_NAF_WORD_MIN: u32 = 4; +/// Class v5's mixer-draw rule: at least this many distinct rotation amounts among the eight. +pub const MIXER_DISTINCT_ROT_MIN: usize = 4; +/// Class v5's mixer-draw rule: redraws before the last block stands as drawn (never reached at 6.1e-4 per try). +pub const MIXER_REDRAW_CAP: u32 = 64; + +/// The non-adjacent-form weight of a 32-bit word: the number of non-zero digits of its NAF, the adders a +/// shift-and-add multiplier by that constant needs (the M1 metric of the weak-day census). +pub fn naf_weight(mut x: u64) -> u32 { + let mut w = 0; + while x != 0 { + if x & 1 == 1 { + w += 1; + // the digit is +1 or -1: take x to the nearest multiple of 4 + if x & 3 == 3 { + x += 1; + } else { + x -= 1; + } + } + x >>= 1; + } + w +} + +/// Whether a mixer block passes class v5's draw rule (AP-F4-1). +pub fn mixer_block_admissible(rot: &[u32; 8], mul: &[u32; 16]) -> bool { + let sum: u32 = mul.iter().map(|&m| naf_weight(m as u64)).sum(); + let words = mul.iter().all(|&m| naf_weight(m as u64) >= MIXER_NAF_WORD_MIN); + let mut distinct = rot.to_vec(); + distinct.sort_unstable(); + distinct.dedup(); + sum >= MIXER_NAF_SUM_MIN && words && distinct.len() >= MIXER_DISTINCT_ROT_MIN } impl MixParams { @@ -227,8 +267,28 @@ impl MixParams { for c in rc.iter_mut() { *c = rng.next() as u32; } + let mut redraws = 0u32; + if shape.state { + // Class v5 (docs/design/class-v5-stored-state.md section 11, AP-F4-1, the attack-pass lane's weak-day census): + // a mixer block whose multipliers are cheap on an adder datapath (NAF sum under 163, a word under NAF weight 4) + // or whose rotations repeat (under 4 distinct amounts) is redrawn from the next stream values, so no day is a + // weak day for a per-day LUT-recompute FPGA (the worst calendar day of the census, chain day 29,337, was 1.121x). + // About 6.1e-4 of days redraw. The derive program's draws (none under v5) come after, as before. + while !mixer_block_admissible(&rot, &mul) && redraws < MIXER_REDRAW_CAP { + for r in rot.iter_mut() { + *r = 1 + rng.below(31) as u32; + } + for m in mul.iter_mut() { + *m = (rng.next() as u32) | 1; + } + for c in rc.iter_mut() { + *c = rng.next() as u32; + } + redraws += 1; + } + } let derive = if shape.is_derived() { Some(DeriveProgram::draw(&mut rng, shape.derive_len)) } else { None }; - Self { key, rot, mul, rc, shape, derive } + Self { key, rot, mul, rc, shape, derive, redraws } } /// Parameters for a day string: the key is `seed_words("day/" + day)`. pub fn for_day(day: &str) -> Self { @@ -758,6 +818,50 @@ impl MemhardCpu { mod tests { use super::*; + /// Class v5's mixer-draw rule (AP-F4-1), the known-failed case first: a block of cheap multipliers (NAF sum under + /// 163) or repeated rotations is inadmissible; a scan of day keys finds days the rule redraws (the census's 6.1e-4), + /// every v5 block passes after the draw, and the v4 constants of the same keys never move. + #[test] + fn class_v5_mixer_draw_rule() { + assert_eq!(naf_weight(0), 0); + assert_eq!(naf_weight(1), 1); + assert_eq!(naf_weight(3), 2, "11 = 100 - 1"); + assert_eq!(naf_weight(7), 2, "111 = 1000 - 1"); + assert_eq!(naf_weight(0xffff_ffff), 2); + assert_eq!(naf_weight(0b1010_1010), 4); + let good_rot = [1u32, 5, 9, 13, 17, 21, 25, 29]; + let cheap = [0x8000_0001u32; 16]; + assert!(!mixer_block_admissible(&good_rot, &cheap), "the known-failed case: 16 two-adder multipliers"); + let dense = [0xaaaa_aaabu32; 16]; + assert!(mixer_block_admissible(&good_rot, &dense)); + assert!(!mixer_block_admissible(&[7u32; 8], &dense), "one rotation amount"); + assert!(!mixer_block_admissible(&[1u32, 2, 3, 3, 3, 3, 3, 3], &dense), "three distinct amounts"); + let v5 = Shape { mixer_mult: 8, cache_log2_words: 26, derive_len: 0, state: true }; + let v4 = Shape { mixer_mult: 8, cache_log2_words: 26, derive_len: 0, state: false }; + let mut redrawn = 0; + let mut scanned = 0; + for d in 0..60_000u64 { + let key = crate::seed::seed_words_from_bytes(&crate::bind::day_bytes(20_000 + d)); + let a = MixParams::with_shape(key, v5); + assert!(mixer_block_admissible(&a.rot, &a.mul), "day {d}: a v5 block fails the rule after the draw"); + if a.redraws > 0 { + redrawn += 1; + let b = MixParams::with_shape(key, v4); + assert_eq!(b.redraws, 0, "v4 never redraws"); + assert_ne!((a.rot, a.mul), (b.rot, b.mul), "day {d}: v5 redrew, v4 kept the block"); + assert!(!mixer_block_admissible(&b.rot, &b.mul), "day {d}: the v4 block was the inadmissible one"); + } else { + let b = MixParams::with_shape(key, v4); + assert_eq!((a.rot, a.mul, a.rc), (b.rot, b.mul, b.rc), "day {d}: an admissible day is byte for byte v4's"); + } + scanned += 1; + if redrawn >= 3 && scanned >= 2_000 { + break; + } + } + assert!(redrawn >= 1, "no redraw in {scanned} days (the census says about 6.1e-4 per day)"); + } + #[test] fn mix_params_for_day() { // MEMHARD.md section 1.4 and the igneum-genesis-mh pack.