From 81b4aaca74a892eaf7022077bdae52058078ba91 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:04:33 +0000 Subject: [PATCH] adv-accept-3: harness adv3 (idcheck, ids, margins, lastresort, exhaust-mirror, verdicts, steer, static, era-steer) Co-Authored-By: Claude Fable 5.1 --- tools/attack/adv-accept-3/Cargo.toml | 21 + tools/attack/adv-accept-3/run-box.sh | 13 + tools/attack/adv-accept-3/src/main.rs | 972 ++++++++++++++++++++++++++ 3 files changed, 1006 insertions(+) create mode 100644 tools/attack/adv-accept-3/Cargo.toml create mode 100755 tools/attack/adv-accept-3/run-box.sh create mode 100644 tools/attack/adv-accept-3/src/main.rs diff --git a/tools/attack/adv-accept-3/Cargo.toml b/tools/attack/adv-accept-3/Cargo.toml new file mode 100644 index 000000000..f43a8a89e --- /dev/null +++ b/tools/attack/adv-accept-3/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "adv-accept-3" +version = "0.1.0" +edition = "2021" +description = "Adversarial lane adv-accept-3: exhaustion and steering of the program draw, program id collisions, determinism of the acceptance rule (internal adversarial pass, not an independent review)" +license = "MIT" +publish = false + +[[bin]] +name = "adv3" +path = "src/main.rs" + +[dependencies] +igneum-pow = { path = "../../../igneum-pow" } + +[workspace] + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/tools/attack/adv-accept-3/run-box.sh b/tools/attack/adv-accept-3/run-box.sh new file mode 100755 index 000000000..e8a842a9b --- /dev/null +++ b/tools/attack/adv-accept-3/run-box.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# adv-accept-3: start one sweep on the box under the per-box sweep lock (coordinator rule, 7 Oct 2026 19:55 BST: one sweep per +# box at a time, nice 10, cores 8 to 95, up to 88 threads). Logs, pid files and results live under /srv/builds/_adv-accept-3/, +# never under the worktree mirror. Kill by pid file only. +# run-box.sh log /srv/builds/_adv-accept-3/.log, pid file beside it +set -u +name="$1"; shift +dir=/srv/builds/_adv-accept-3; mkdir -p "$dir" +log="$dir/$name.log" +q=""; for a in "$@"; do q="$q $(printf '%q' "$a")"; done +nohup setsid flock /srv/builds/_adv/locks/sweep.lock -c "echo \"start \$(date -u +%FT%TZ) pid \$\$\" > '$log.start'; nice -n 10 taskset -c 8-95 $q > '$log' 2>&1" > /dev/null 2>&1 & +echo $! > "$log.pid" +echo "queued $name: pid $(cat "$log.pid") (waits on the sweep lock), log $log" diff --git a/tools/attack/adv-accept-3/src/main.rs b/tools/attack/adv-accept-3/src/main.rs new file mode 100644 index 000000000..c82591ab6 --- /dev/null +++ b/tools/attack/adv-accept-3/src/main.rs @@ -0,0 +1,972 @@ +//! adv3: lane adv-accept-3 of the lottery-hash adversarial pass (internal adversarial pass, not an independent +//! review). Target: the acceptance rule and the program draw at commit 017e7037 (class v4 sub-version 3), the +//! question class "exhaustion or steering of the draw". Nothing in `igneum-pow` is modified; the draw, the rule and +//! the id come from the library by path. `verdicts` carries a SECOND interpretation of the rule written from the +//! module table of accept.rs and spec 01 section 1.4.6, compared verdict by verdict with the library's. +//! +//! Commands (every sweep writes one TSV row per seed as it lands): +//! adv3 idcheck the two public packs re-derived; the id by three derivations +//! adv3 ids --seeds N --attempts A --threads T FNV-1a-64 id collisions over N x A (seed, attempt) pairs +//! adv3 margins the (c'') f64 ratio against an integer rule, every d +//! adv3 lastresort --from F --count N --threads T the last-resort program of N seeds under the REAL rule +//! adv3 exhaust-mirror --seed i a reject-everything loop must hit the cap and print the last resort +//! adv3 verdicts --from F --count N --threads T [--variant faithful|noncyclic|floor97|spec] +//! adv3 steer --from F --count N --threads T [--plant hot] [--check-every 50] +//! adv3 static --from F --count N --threads T +//! adv3 era-steer --from F --count N --threads T the era attacker: stride and windows over N era seeds (static) + +use igneum_pow::accept::{self, check, check_static, distinct_ratio_pass, AcceptReport, Reject, ACCEPT_DATASET_LOG2, BIAS_TOLERANCE, MAX_SATURATED, MIN_DISTINCT_RATIO_V4, MIN_DISTINCT_SUM}; +use igneum_pow::generator::{attempt_words, candidate_class, last_resort_v4, max_attempts_for, program_id, EraParams, Instr, LoadClass, Op, Program, ProgramClass, GENERATOR_VERSION_V4, INSTR_COUNT, ITERATIONS, LANES, PROGRAM_SUBVERSION_V4, V3_ALLOWED, V4_CLASS}; +use igneum_pow::seed::{fnv1a64, program_rng, seed_words_from_bytes, SplitMix64}; +use igneum_pow::verify::{dataset_elem, load_index, splitmix32, Epoch}; +use std::collections::HashSet; +use std::io::Write as _; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::Instant; + +/// Devnet 3 epoch-0 seed and era (the era seed is the epoch seed at epoch 0), program id 0xfce15bf61030be57 at attempt 0. +const DEVNET3_HEX: &str = "4020cb4382e3fe4b281c817c02582e147d8f851f566ae9172b28912b8e68b925"; +/// The shared devnet epoch-0 seed of the kit pack, id 0xa785001687d8688a at attempt 1. +const DEVNET_KIT_HEX: &str = "edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07"; + +fn unhex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +fn words_bytes(w: &[u32; 8]) -> Vec { + w.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// A chain-shaped 32-byte epoch seed of this lane's label space. +fn steer_seed(i: u64) -> Vec { + words_bytes(&seed_words_from_bytes(format!("igneum-adv-accept-3/steer/{i}").as_bytes())) +} + +fn era_bytes() -> Vec { + unhex(DEVNET3_HEX) +} + +fn v4_era_class(era: &[u8]) -> LoadClass { + LoadClass::era(V4_CLASS, era, &V3_ALLOWED) +} + +fn utc() -> String { + let s = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let (d, t) = (s / 86400, s % 86400); + // civil from days (Howard Hinnant) + let z = d as i64 + 719468; + let era = z.div_euclid(146097); + let doe = z.rem_euclid(146097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let dd = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}-{dd:02}T{:02}:{:02}:{:02}Z", t / 3600, (t % 3600) / 60, t % 60) +} + +struct Args { + cmd: String, + kv: Vec<(String, String)>, +} + +impl Args { + fn parse() -> Args { + let v: Vec = std::env::args().skip(1).collect(); + if v.is_empty() { + usage(); + } + let mut kv = Vec::new(); + let mut i = 1; + while i < v.len() { + if let Some(k) = v[i].strip_prefix("--") { + if i + 1 < v.len() && !v[i + 1].starts_with("--") { + kv.push((k.to_string(), v[i + 1].clone())); + i += 2; + } else { + kv.push((k.to_string(), "1".to_string())); + i += 1; + } + } else { + usage(); + } + } + Args { cmd: v[0].clone(), kv } + } + fn get(&self, k: &str, d: &str) -> String { + self.kv.iter().rev().find(|(a, _)| a == k).map(|(_, b)| b.clone()).unwrap_or_else(|| d.to_string()) + } + fn u(&self, k: &str, d: u64) -> u64 { + self.get(k, &d.to_string()).parse().unwrap_or_else(|_| usage()) + } +} + +fn usage() -> ! { + eprintln!("adv3 idcheck | ids --seeds N --attempts A --threads T | margins | lastresort --from F --count N --threads T | exhaust-mirror --seed i | verdicts --from F --count N --threads T [--variant faithful|noncyclic|floor97|spec] | steer --from F --count N --threads T [--plant hot] [--check-every 50] | static --from F --count N --threads T | era-steer --from F --count N --threads T"); + std::process::exit(2); +} + +// ------------------------------------------------------------------------------------------------------------ +// Static properties a seed grinder would want (Q2, Q2b, Q1b) +// ------------------------------------------------------------------------------------------------------------ + +#[derive(Clone, Copy, Debug, Default)] +struct Props { + /// loads on the longest dependency path per hash (8 iterations, steady state), 128 at most + cp_loads: u32, + /// ALU instructions (base and shadow, every rep) on the longest dependency path per hash + cp_alu: u32, + /// load sites of iteration 0 whose address depends on the init words only (no earlier load on its path) + predictable_sites_it0: u32, + /// chi-square of the 48 non-load base ops against the weights of spec 1.4.2 + opmix_chi2_milli: u32, + /// the largest single-op share of the 48 non-load base ops, in percent + opmix_max_pct: u32, + /// output registers whose last write of the iteration (base then shadow) is or, mul or mulhi + lossy_last_writes: u32, + /// count of or, mul, mulhi in the base program + lossy_base: u32, + /// longest dependency path through one shadow pass (256 instructions) + shadow_pass_depth: u32, +} + +/// Dependency depth over a sequence of instructions, carrying per-register (alu depth, load depth). `shfl` reads +/// `src` of another lane: the same register, the same depth. `predictable` counts loads whose source has no load +/// on its path (an address computable from the init words alone). +fn depth_pass(instrs: &[Instr], st: &mut [(u32, u32); 8], mut predictable: Option<&mut u32>) { + for i in instrs { + let (d, a) = (i.dst as usize, i.src as usize); + let mut m = st[d]; + if !matches!(i.op, Op::Rotl) { + m = (m.0.max(st[a].0), m.1.max(st[a].1)); + } + if i.op == Op::Mad { + let b = i.src2 as usize; + m = (m.0.max(st[b].0), m.1.max(st[b].1)); + } + if i.op.is_load() { + if let Some(p) = predictable.as_deref_mut() { + if st[a].1 == 0 { + *p += 1; + } + } + st[d] = (m.0 + 1, m.1 + 1); + } else { + st[d] = (m.0 + 1, m.1); + } + } +} + +fn props(p: &Program) -> Props { + let mut st = [(0u32, 0u32); 8]; + let reps = p.shadow_reps(); + let mut pred_it0 = 0u32; + for it in 0..ITERATIONS { + depth_pass(&p.instrs, &mut st, if it == 0 { Some(&mut pred_it0) } else { None }); + for _ in 0..reps { + depth_pass(&p.shadow, &mut st, None); + } + } + let cp_alu = st.iter().map(|s| s.0).max().unwrap_or(0); + let cp_loads = st.iter().map(|s| s.1).max().unwrap_or(0); + let mut sd = [(0u32, 0u32); 8]; + depth_pass(&p.shadow, &mut sd, None); + let shadow_pass_depth = sd.iter().map(|s| s.0).max().unwrap_or(0); + let weights: [(Op, u32); 10] = [(Op::Add, 12), (Op::Xor, 10), (Op::Mul, 8), (Op::Mad, 8), (Op::Shfl, 8), (Op::Rotl, 7), (Op::Sub, 6), (Op::MulHi, 6), (Op::Rotr, 6), (Op::Or, 4)]; + let mut counts = [0u32; 10]; + let mut nonload = 0u32; + for i in &p.instrs { + if i.op.is_load() { + continue; + } + nonload += 1; + for (k, (o, _)) in weights.iter().enumerate() { + if *o == i.op { + counts[k] += 1; + } + } + } + let mut chi2 = 0.0f64; + let mut maxc = 0u32; + for (k, (_, w)) in weights.iter().enumerate() { + let e = nonload as f64 * *w as f64 / 75.0; + chi2 += (counts[k] as f64 - e).powi(2) / e; + maxc = maxc.max(counts[k]); + } + let lossy_base = p.instrs.iter().filter(|i| matches!(i.op, Op::Or | Op::Mul | Op::MulHi)).count() as u32; + let mut last = [None::; 8]; + for i in p.instrs.iter().chain(p.shadow.iter()) { + last[i.dst as usize] = Some(i.op); + } + let lossy_last_writes = last.iter().filter(|o| matches!(o, Some(Op::Or | Op::Mul | Op::MulHi))).count() as u32; + Props { + cp_loads, + cp_alu, + predictable_sites_it0: pred_it0, + opmix_chi2_milli: (chi2 * 1000.0) as u32, + opmix_max_pct: if nonload > 0 { maxc * 100 / nonload } else { 0 }, + lossy_last_writes, + lossy_base, + shadow_pass_depth, + } +} + +fn props_header() -> &'static str { + "cp_loads\tcp_alu\tpred_it0\tchi2_milli\topmax_pct\tlossy_last\tlossy_base\tshadow_depth" +} + +fn props_row(q: &Props) -> String { + format!("{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", q.cp_loads, q.cp_alu, q.predictable_sites_it0, q.opmix_chi2_milli, q.opmix_max_pct, q.lossy_last_writes, q.lossy_base, q.shadow_pass_depth) +} + +fn reject_part(r: &Reject) -> &'static str { + match r { + Reject::StaleLoadSource { .. } => "a", + Reject::NoInjectingWrite { .. } => "b", + Reject::UnfreshLoadSource { .. } => "a'", + Reject::ConstantBit { .. } => "c-constbit", + Reject::LaneConstantSite { .. } => "c-lanesite", + Reject::Saturated { .. } => "c-saturated", + Reject::SaturatedSource { .. } => "c'", + Reject::RepeatedSource { .. } => "c''-repeat", + Reject::LowEntropySite { .. } => "c''", + Reject::OutputBias { .. } => "c-bias", + Reject::DistinctAddresses { .. } => "c-distinct", + } +} + +fn program_fingerprint(p: &Program) -> u64 { + let mut b = Vec::new(); + for i in p.instrs.iter().chain(p.shadow.iter()) { + b.push(i.op as u8); + b.push(i.dst); + b.push(i.src); + b.push(i.src2); + b.extend_from_slice(&i.imm.to_le_bytes()); + b.extend_from_slice(&i.imm2.to_le_bytes()); + b.push(i.rot as u8); + b.push(i.bit); + b.push(i.mask); + b.push(i.win); + b.push(i.off); + } + fnv1a64(&b) +} + +// ------------------------------------------------------------------------------------------------------------ +// Parallel driver: seeds [from, from + count) over T threads, rows written as they land +// ------------------------------------------------------------------------------------------------------------ + +fn run_seeds(from: u64, count: u64, threads: usize, out: &Mutex, f: F) +where + F: Fn(u64) -> String + Sync, +{ + let next = AtomicUsize::new(0); + std::thread::scope(|s| { + for _ in 0..threads { + s.spawn(|| loop { + let k = next.fetch_add(1, Ordering::Relaxed) as u64; + if k >= count { + break; + } + let row = f(from + k); + let mut o = out.lock().unwrap(); + writeln!(o, "{row}").unwrap(); + o.flush().unwrap(); + }); + } + }); +} + +// ------------------------------------------------------------------------------------------------------------ +// Q3 (i): idcheck +// ------------------------------------------------------------------------------------------------------------ + +/// The id as program.json and spec 01 section 1.4.6 STATE it: no sub-version suffix. +fn program_id_stated(generator: u32, seed: &[u32; 8], attempt: u32) -> u64 { + let mut b = Vec::new(); + b.extend_from_slice(b"igneum-program/"); + b.extend_from_slice(&generator.to_le_bytes()); + b.extend_from_slice(&words_bytes(seed)); + b.extend_from_slice(&attempt.to_le_bytes()); + fnv1a64(&b) +} + +/// The id as the code computes it, re-done here byte for byte (so a change in the library would show). +fn program_id_code(generator: u32, seed: &[u32; 8], attempt: u32) -> u64 { + let mut b = Vec::new(); + b.extend_from_slice(b"igneum-program/"); + b.extend_from_slice(&generator.to_le_bytes()); + b.extend_from_slice(&words_bytes(seed)); + b.extend_from_slice(&attempt.to_le_bytes()); + if generator == 4 { + b.extend_from_slice(b"sub/"); + b.extend_from_slice(&PROGRAM_SUBVERSION_V4.to_le_bytes()); + } + fnv1a64(&b) +} + +fn idcheck() { + println!("# adv3 idcheck {} (internal adversarial pass, not an independent review)", utc()); + for (name, hx, want_id, want_attempt) in [("devnet3-epoch0", DEVNET3_HEX, 0xfce15bf61030be57u64, 0u32), ("kit v4-devnet-epoch0", DEVNET_KIT_HEX, 0xa785001687d8688au64, 1u32)] { + let seed = unhex(hx); + let t0 = Instant::now(); + let p = Epoch::chain_program(&seed, Some(&seed), ProgramClass::V4, &format!("igneum-epoch/{hx}")); + let id = p.program_id(); + let lib = program_id(GENERATOR_VERSION_V4, &p.seed, p.attempt); + let code = program_id_code(4, &p.seed, p.attempt); + let stated = program_id_stated(4, &p.seed, p.attempt); + let stated3 = program_id_stated(3, &p.seed, p.attempt); + println!("{name}: chain draw attempt {} id {id:016x} (want {want_id:016x} at attempt {want_attempt}) class {} in {:.1} s: {}", p.attempt, p.class.name(), t0.elapsed().as_secs_f64(), if id == want_id && p.attempt == want_attempt { "MATCH" } else { "MISMATCH" }); + println!(" library program_id(4, seed, attempt) = {lib:016x}; re-done with sub/3 suffix = {code:016x} ({}); as program.json and spec 1.4.6 STATE it (no suffix) = {stated:016x} ({}); the stated form with generator 3 = {stated3:016x}", if code == want_id { "matches the pack" } else { "does NOT match" }, if stated == want_id { "matches the pack" } else { "does NOT match the pack" }); + // known-failed shape: one attempt bit flipped changes the id; the words of attempt k differ from attempt 0 + let flipped = program_id(GENERATOR_VERSION_V4, &p.seed, p.attempt ^ 1); + println!(" attempt bit flipped: {flipped:016x} ({})", if flipped != id { "changed, as it must" } else { "UNCHANGED" }); + println!(" seed words of attempt {}: {}", p.attempt, p.seed.iter().map(|w| format!("{w:08x}")).collect::>().join(" ")); + println!(" op mix {}; props {}", p.op_mix(), props_row(&props(&p))); + } +} + +// ------------------------------------------------------------------------------------------------------------ +// Q3 (ii): ids, collisions over (seed, attempt) pairs +// ------------------------------------------------------------------------------------------------------------ + +fn count_dups(v: &mut [T]) -> usize { + v.sort_unstable(); + v.windows(2).filter(|w| w[0] == w[1]).count() +} + +fn ids(seeds: u64, attempts: u32, threads: usize) { + println!("# adv3 ids {} seeds {seeds} attempts {attempts} (internal adversarial pass, not an independent review)", utc()); + let t0 = Instant::now(); + let per = (seeds as usize + threads - 1) / threads; + let parts: Vec<(Vec, Vec, Vec<(u64, u64, u64, u64)>)> = std::thread::scope(|s| { + let hs: Vec<_> = (0..threads) + .map(|t| { + s.spawn(move || { + let lo = (t * per) as u64; + let hi = (((t + 1) * per) as u64).min(seeds); + let mut ids = Vec::with_capacity(((hi.saturating_sub(lo)) as usize) * attempts as usize); + let mut states = Vec::with_capacity(ids.capacity()); + let mut octets = Vec::with_capacity(ids.capacity()); + for i in lo..hi { + let b = steer_seed(i); + for k in 0..attempts { + let w = attempt_words(&b, k); + ids.push(program_id(GENERATOR_VERSION_V4, &w, k)); + states.push(program_rng(&w).s); + octets.push((w[0] as u64 | (w[1] as u64) << 32, w[2] as u64 | (w[3] as u64) << 32, w[4] as u64 | (w[5] as u64) << 32, w[6] as u64 | (w[7] as u64) << 32)); + } + } + (ids, states, octets) + }) + }) + .collect(); + hs.into_iter().map(|h| h.join().unwrap()).collect() + }); + let mut all_ids = Vec::new(); + let mut all_states = Vec::new(); + let mut all_oct = Vec::new(); + for (a, b, c) in parts { + all_ids.extend(a); + all_states.extend(b); + all_oct.extend(c); + } + let n = all_ids.len(); + let d_ids = count_dups(&mut all_ids); + let d_states = count_dups(&mut all_states); + let d_oct = count_dups(&mut all_oct); + // the stream state's low 32 bits and the id's low 32 bits: the birthday count at 2^32 is the control that the + // counter sees collisions when they exist (expected n^2 / 2^33) + let mut lo_ids: Vec = all_ids.iter().map(|x| *x as u32).collect(); + let mut lo_states: Vec = all_states.iter().map(|x| *x as u32).collect(); + let d_lo_ids = count_dups(&mut lo_ids); + let d_lo_states = count_dups(&mut lo_states); + let exp32 = (n as f64).powi(2) / 2f64.powi(33); + let exp64 = (n as f64).powi(2) / 2f64.powi(65); + println!("pairs {n}: id collisions {d_ids} (expected {exp64:.2e} at random); program-stream state collisions {d_states} (two seeds with one state draw one base program; expected {exp64:.2e}); seed-word octet collisions {d_oct}"); + println!("control (known-failed shape): low-32 id collisions {d_lo_ids} and low-32 state collisions {d_lo_states} against {exp32:.1} expected at random: the counter fires when collisions exist"); + println!("done in {:.1} s", t0.elapsed().as_secs_f64()); +} + +// ------------------------------------------------------------------------------------------------------------ +// Q4 (iii): margins of the f64 ratio in (c'') +// ------------------------------------------------------------------------------------------------------------ + +fn margins() { + println!("# adv3 margins {} (internal adversarial pass, not an independent review)", utc()); + let n: u64 = (accept::ACCEPT_UNITS_DISTINCT_V4 * LANES * ITERATIONS) as u64; + println!("n = {n} evaluations per site; floor {MIN_DISTINCT_RATIO_V4}"); + for k in 0..=2u32 { + let w: u64 = (1u64 << ACCEPT_DATASET_LOG2) >> k; + let e_int: u64 = n - n * n / (2 * w); + let e_f = n as f64 - (n as f64) * (n as f64) / (2.0 * w as f64); + let thr = 0.98 * e_int as f64; + let nearest = thr.round(); + println!("window 2^{} words: E = {e_int} (f64 {e_f}); 0.98 E = {thr:.3}; nearest integer {nearest}; margin {:.3} counts; relative margin {:.2e}", ACCEPT_DATASET_LOG2 - k, (thr - nearest).abs(), (thr - nearest).abs() / thr); + // every d: the code's f64 compare against the exact integer compare 50 d >= 49 E, and an f32 compare + let (mut flips64, mut flips32) = (0u64, 0u64); + for d in 0..=n { + let code_pass = (d as f64 / e_f) >= MIN_DISTINCT_RATIO_V4; + let int_pass = 50 * d >= 49 * e_int; + let f32_pass = (d as f32 / e_f as f32) >= 0.98f32; + flips64 += (code_pass != int_pass) as u64; + flips32 += (f32_pass != int_pass) as u64; + } + println!(" over all d in 0..={n}: f64 compare disagrees with the integer rule on {flips64} values; an f32 compare on {flips32}"); + } +} + +// ------------------------------------------------------------------------------------------------------------ +// Q1b: the last resort under the real rule; the reject-everything mirror +// ------------------------------------------------------------------------------------------------------------ + +fn lastresort(from: u64, count: u64, threads: usize) { + let out = Mutex::new(std::io::stdout()); + println!("# adv3 lastresort {} seeds {from}..{} (internal adversarial pass, not an independent review)", utc(), from + count); + println!("seed\tverdict\tpart\tfingerprint\tid\tkept_mad\t{}\tsecs", props_header()); + let era = era_bytes(); + let class = v4_era_class(&era); + run_seeds(from, count, threads, &out, |i| { + let b = steer_seed(i); + let t0 = Instant::now(); + let c = candidate_class(&format!("adv3/steer/{i}"), &b, max_attempts_for(&class), class); + let lr = last_resort_v4(c); + let v = check(&lr); + let (verdict, part) = match &v { + Ok(_) => ("accept", "-"), + Err(r) => ("reject", reject_part(r)), + }; + let mads = lr.instrs.iter().chain(lr.shadow.iter()).filter(|x| x.op == Op::Mad).count(); + format!("{i}\t{verdict}\t{part}\t{:016x}\t{:016x}\t{mads}\t{}\t{:.1}", program_fingerprint(&lr), program_id(GENERATOR_VERSION_V4, &lr.seed, lr.attempt), props_row(&props(&lr)), t0.elapsed().as_secs_f64()) + }); +} + +fn exhaust_mirror(i: u64) { + println!("# adv3 exhaust-mirror {} seed {i} (internal adversarial pass, not an independent review)", utc()); + let era = era_bytes(); + let class = v4_era_class(&era); + let b = steer_seed(i); + let label = format!("adv3/steer/{i}"); + // the mirror of try_generate_class with a rule patched to reject everything + let cap = max_attempts_for(&class); + let reject_all = |_p: &Program| -> Result { Err(Reject::NoInjectingWrite { reg: 0 }) }; + let mut tried = 0u32; + let mut mirror = None; + for attempt in 0..cap { + let p = candidate_class(&label, &b, attempt, class); + tried += 1; + if reject_all(&p).is_ok() { + mirror = Some(p); + break; + } + } + let mirror = mirror.unwrap_or_else(|| last_resort_v4(candidate_class(&label, &b, cap, class))); + let lib = last_resort_v4(candidate_class(&label, &b, cap, class)); + println!("cap {cap}; attempts tried {tried}; mirror reached the cap: {}; mirror program == library last_resort_v4(candidate(seed, {cap})): {}", tried == cap, mirror == lib); + println!("last-resort attempt {} id {:016x} fingerprint {:016x} op mix {}; shadow op mix {}", mirror.attempt, mirror.program_id(), program_fingerprint(&mirror), mirror.op_mix(), mirror.shadow_op_mix()); + let real = check(&mirror); + println!("the REAL rule on the last resort: {}", match &real { Ok(r) => format!("accept (distinct mean {:.3}, saturated {}, bias max {})", r.distinct_mean(), r.saturated, r.bias_max), Err(r) => format!("reject: {r}") }); + println!("props {}", props_row(&props(&mirror))); + for (k, ins) in mirror.instrs.iter().enumerate() { + println!("{k:2}: {:5} dst={} src={} src2={} rot={} bit={} mask={} win={} off={}", ins.op.name(), ins.dst, ins.src, ins.src2, ins.rot, ins.bit, ins.mask, ins.win, ins.off); + } + // and the real rule's own path: the chain draw of the same seed (the accepted attempt, not the last resort) + let chain = Epoch::chain_program(&b, Some(&era), ProgramClass::V4, &label); + println!("the chain's own program of this seed: attempt {} id {:016x}", chain.attempt, chain.program_id()); +} + +// ------------------------------------------------------------------------------------------------------------ +// Q4 (i), (ii): the second interpretation of the rule +// ------------------------------------------------------------------------------------------------------------ + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Variant { + /// the module table of accept.rs, written again from its text, integer ratio compare + Faithful, + /// known-failed shape: (a) checked in a single pass (not cyclic) + NonCyclic, + /// known-failed shape: the (c'') floor at 0.97 + Floor97, + /// spec 01 section 1.4.6 as written at 017e7037: (a), (b), (c) only, no shadow block in the execution + Spec, +} + +struct Second { + variant: Variant, +} + +impl Second { + fn base_nonces(seed: &[u32; 8], n: usize) -> Vec { + let mut b = b"igneum-accept/".to_vec(); + b.extend_from_slice(&words_bytes(seed)); + let mut rng = SplitMix64::new(fnv1a64(&b)); + (0..n).map(|_| (rng.next() as u32) & !31).collect() + } + + /// (a): cyclic (two passes) or single pass. + fn rule_a(&self, p: &Program) -> bool { + let passes = if self.variant == Variant::NonCyclic { 1 } else { 2 }; + let mut read_unwritten = [false; 8]; + for _ in 0..passes { + for ins in &p.instrs { + if ins.op.is_load() && read_unwritten[ins.src as usize] { + return false; + } + read_unwritten[ins.dst as usize] = false; + if ins.op.is_load() { + read_unwritten[ins.src as usize] = true; + } + } + } + true + } + + fn rule_b(&self, p: &Program) -> bool { + let mut inj = [false; 8]; + for ins in &p.instrs { + if matches!(ins.op, Op::Add | Op::Sub | Op::Xor | Op::Mad | Op::Shfl | Op::Load) { + inj[ins.dst as usize] = true; + } + } + inj.iter().all(|&x| x) + } + + /// (a'): freshness by dataflow over base then shadow, from all-fresh, to a fixpoint, then a checking pass. + /// Written from the table in accept.rs: load keeps its source's freshness; add, sub, xor, mad, shfl fresh if dst + /// or src was; rotl, rotr keep dst's; or, mul, mulhi never. The shared-operand idiom: or then xor or sub on one + /// operand, or xor then or, is lossy. + fn rule_a_prime(&self, p: &Program) -> bool { + if self.variant == Variant::Spec { + return true; + } + let seq: Vec<&Instr> = p.instrs.iter().chain(p.shadow.iter()).collect(); + let mut fresh = [true; 8]; + let mut pair: [Option<(Op, u8)>; 8] = [None; 8]; + let step = |fresh: &mut [bool; 8], pair: &mut [Option<(Op, u8)>; 8], checking: bool| -> bool { + for ins in &seq { + let (d, a) = (ins.dst as usize, ins.src); + if checking && ins.op.is_load() && !fresh[a as usize] { + return false; + } + let lossy_pair = match (pair[d], ins.op) { + (Some((Op::Or, s)), Op::Xor) | (Some((Op::Or, s)), Op::Sub) | (Some((Op::Xor, s)), Op::Or) => s == a, + _ => false, + }; + let f = if lossy_pair { + false + } else { + match ins.op { + Op::Load => fresh[a as usize], + Op::Add | Op::Sub | Op::Xor | Op::Mad | Op::Shfl => fresh[d] || fresh[a as usize], + Op::Rotl | Op::Rotr => fresh[d], + _ => false, + } + }; + fresh[d] = f; + pair[d] = if matches!(ins.op, Op::Or | Op::Xor) && !lossy_pair { Some((ins.op, a)) } else { None }; + for r in 0..8 { + if r != d { + if let Some((_, s)) = pair[r] { + if s as usize == d { + pair[r] = None; + } + } + } + } + } + true + }; + for _ in 0..16 { + let before = (fresh, pair); + step(&mut fresh, &mut pair, false); + if (fresh, pair) == before { + break; + } + } + step(&mut fresh, &mut pair, true) + } + + /// One evaluation of one lane group, scalar per lane. Returns per-lane final registers; records per load + /// position the 32 indices through `on_load(position, iteration, instr, &idx[32], &src_values[32])`. + fn run_unit(&self, p: &Program, era: Option<&EraParams>, base: u32, mut on_load: F) -> [[u32; 8]; 32] { + let s = &p.seed; + let mask = (1u32 << 28) - 1; + let mut r = [[0u32; 8]; 32]; + for lane in 0..32u32 { + let nonce = base.wrapping_add(lane); + for i in 0..8 { + let x = splitmix32((nonce ^ s[i]).wrapping_add(0x9e3779b9u32.wrapping_mul(i as u32 + 1))); + r[lane as usize][i] = x ^ s[(i + 1) & 7]; + } + } + let reps = if self.variant == Variant::Spec { 0 } else { p.shadow_reps() }; + let mut pos = 0usize; + for it in 0..ITERATIONS { + let sel: [u32; 32] = std::array::from_fn(|l| r[l][0]); + let mut exec = |ins: &Instr, k: usize, r: &mut [[u32; 8]; 32], pos: &mut usize| { + let (d, a) = (ins.dst as usize, ins.src as usize); + match ins.op { + Op::Add => { + for l in 0..32 { + let c = if (sel[l] >> ins.bit) & 1 == 1 { ins.imm2 } else { ins.imm }; + r[l][d] = r[l][d].wrapping_add(r[l][a]).wrapping_add(c); + } + } + Op::Sub => (0..32).for_each(|l| r[l][d] = r[l][d].wrapping_sub(r[l][a])), + Op::Mul => (0..32).for_each(|l| r[l][d] = r[l][d].wrapping_mul(r[l][a])), + Op::MulHi => (0..32).for_each(|l| r[l][d] = ((r[l][d] as u64 * r[l][a] as u64) >> 32) as u32), + Op::Xor => (0..32).for_each(|l| r[l][d] ^= r[l][a]), + Op::Or => (0..32).for_each(|l| r[l][d] |= r[l][a]), + Op::Rotl => (0..32).for_each(|l| r[l][d] = r[l][d].rotate_left(ins.rot)), + Op::Rotr => (0..32).for_each(|l| r[l][d] = r[l][d].rotate_right(r[l][a] & 31)), + Op::Mad => (0..32).for_each(|l| r[l][d] = r[l][a].wrapping_mul(r[l][ins.src2 as usize]).wrapping_add(r[l][d])), + Op::Shfl => { + let src: [u32; 32] = std::array::from_fn(|l| r[l][a]); + for l in 0..32 { + r[l][d] ^= src[l ^ ins.mask as usize]; + } + } + Op::Load => { + let srcv: [u32; 32] = std::array::from_fn(|l| r[l][a]); + let idx: [u32; 32] = std::array::from_fn(|l| load_index(era, ins, srcv[l], mask, 28)); + on_load(*pos, it, k, &idx, &srcv); + for l in 0..32 { + r[l][d] ^= dataset_elem(idx[l], s[0], s[1]); + } + *pos += 1; + } + _ => panic!("op outside the lottery hash"), + } + }; + for (k, ins) in p.instrs.iter().enumerate() { + exec(ins, k, &mut r, &mut pos); + } + for _ in 0..reps { + for (k, ins) in p.shadow.iter().enumerate() { + exec(ins, INSTR_COUNT + k, &mut r, &mut pos); + } + } + } + r + } + + /// (c), (c') and (c''). Returns accept or the name of the failing part. + fn dynamic(&self, p: &Program) -> Result<(), &'static str> { + let era = p.class.era; + let era = era.as_ref(); + let bases = Self::base_nonces(&p.seed, 64); + let mut and_acc = [u32::MAX; 8]; + let mut or_acc = [0u32; 8]; + let mut saturated = 0u32; + let mut ones = [0u32; 64]; + let mut distinct_sum = 0u64; + let mut sat_source = [0u32; 16]; + let mut lane_const = false; + for &base in &bases { + let mut per_lane: Vec> = vec![Vec::with_capacity(128); 32]; + let regs = self.run_unit(p, era, base, |pos, _it, _k, idx, srcv| { + if idx.iter().all(|&x| x == idx[0]) { + lane_const = true; + } + for l in 0..32 { + per_lane[l].push(idx[l]); + if srcv[l] == 0 || srcv[l] == u32::MAX { + sat_source[pos % 16] += 1; + } + } + }); + if lane_const { + return Err("c-lanesite"); + } + for l in 0..32 { + for i in 0..8 { + let v = regs[l][i]; + and_acc[i] &= v; + or_acc[i] |= v; + saturated += (v == 0 || v == u32::MAX) as u32; + } + let lo = regs[l][0] ^ regs[l][1].rotate_left(7) ^ regs[l][2].rotate_left(14) ^ regs[l][3].rotate_left(21); + let hi = regs[l][4] ^ regs[l][5].rotate_left(9) ^ regs[l][6].rotate_left(18) ^ regs[l][7].rotate_left(27); + let h = (hi as u64) << 32 | lo as u64; + for j in 0..64 { + ones[j] += ((h >> j) & 1) as u32; + } + let set: HashSet = per_lane[l].iter().copied().collect(); + distinct_sum += set.len() as u64; + } + } + for i in 0..8 { + if (and_acc[i] | !or_acc[i]) != 0 { + return Err("c-constbit"); + } + } + if saturated >= MAX_SATURATED { + return Err("c-saturated"); + } + if self.variant != Variant::Spec { + if sat_source.iter().any(|&c| c >= MAX_SATURATED) { + return Err("c'"); + } + // (c''): 4096 units, per-site distinct indices against N - N^2 / 2W, integer compare + let bases = Self::base_nonces(&p.seed, 4096); + let mut per_site: Vec> = vec![Vec::with_capacity(4096 * 32 * 8); 16]; + for &base in &bases { + self.run_unit(p, era, base, |pos, _it, _k, idx, _s| { + per_site[pos % 16].extend_from_slice(idx); + }); + } + let n: u64 = 4096 * 32 * 8; + let wins: Vec = p.instrs.iter().filter(|i| i.op == Op::Load).map(|i| i.win.min(2)).collect(); + for (site, v) in per_site.iter_mut().enumerate() { + v.sort_unstable(); + v.dedup(); + let w: u64 = (1u64 << 28) >> wins[site]; + let e = n - n * n / (2 * w); + let pass = match self.variant { + Variant::Floor97 => 100 * v.len() as u64 >= 97 * e, + _ => 50 * v.len() as u64 >= 49 * e, + }; + if !pass { + return Err("c''"); + } + } + } + for &o in &ones { + if o.abs_diff(1024) > BIAS_TOLERANCE { + return Err("c-bias"); + } + } + if distinct_sum <= MIN_DISTINCT_SUM { + return Err("c-distinct"); + } + Ok(()) + } + + fn verdict(&self, p: &Program) -> Result<(), &'static str> { + if !self.rule_a(p) { + return Err("a"); + } + if !self.rule_b(p) { + return Err("b"); + } + if !self.rule_a_prime(p) { + return Err("a'"); + } + self.dynamic(p) + } +} + +fn verdicts(from: u64, count: u64, threads: usize, variant: &str) { + let v = match variant { + "faithful" => Variant::Faithful, + "noncyclic" => Variant::NonCyclic, + "floor97" => Variant::Floor97, + "spec" => Variant::Spec, + _ => usage(), + }; + let second = Second { variant: v }; + let out = Mutex::new(std::io::stdout()); + println!("# adv3 verdicts {} variant {variant} seeds {from}..{} (internal adversarial pass, not an independent review)", utc(), from + count); + println!("seed\tattempt\tlib_verdict\tlib_part\tsecond_verdict\tsecond_part\tagree\tchosen_lib\tchosen_second\tsecs"); + let era = era_bytes(); + let class = v4_era_class(&era); + let cap = if v == Variant::Spec { 32 } else { max_attempts_for(&class) }; + run_seeds(from, count, threads, &out, |i| { + let b = steer_seed(i); + let label = format!("adv3/steer/{i}"); + let t0 = Instant::now(); + let mut rows = Vec::new(); + let (mut chosen_lib, mut chosen_second) = (None::, None::); + let mut attempt = 0u32; + while attempt < cap && (chosen_lib.is_none() || chosen_second.is_none()) { + let p = candidate_class(&label, &b, attempt, class); + let lv = check(&p); + let sv = second.verdict(&p); + let (lverd, lpart) = match &lv { Ok(_) => ("accept", "-"), Err(r) => ("reject", reject_part(r)) }; + let (sverd, spart) = match &sv { Ok(_) => ("accept", "-"), Err(e) => ("reject", *e) }; + if lv.is_ok() && chosen_lib.is_none() { + chosen_lib = Some(attempt); + } + if sv.is_ok() && chosen_second.is_none() { + chosen_second = Some(attempt); + } + rows.push(format!("{i}\t{attempt}\t{lverd}\t{lpart}\t{sverd}\t{spart}\t{}", if lv.is_ok() == sv.is_ok() { "yes" } else { "NO" })); + attempt += 1; + } + let secs = t0.elapsed().as_secs_f64(); + let cl = chosen_lib.map(|x| x.to_string()).unwrap_or_else(|| "cap".into()); + let cs = chosen_second.map(|x| x.to_string()).unwrap_or_else(|| "cap".into()); + rows.iter().map(|r| format!("{r}\t{cl}\t{cs}\t{secs:.1}")).collect::>().join("\n") + }); +} + +// ------------------------------------------------------------------------------------------------------------ +// Q2: steering, the real chain draw per seed +// ------------------------------------------------------------------------------------------------------------ + +/// The plant for the property measurement: on an accepted program, the two instructions that last wrote the source +/// register of one load (scanning back cyclically) become `or`, so the source saturates toward all ones and the +/// site's distinct-index ratio must fall far below the population. The real rule is run on the planted program too. +fn plant_hot(p: &Program) -> Program { + let mut q = p.clone(); + let n = q.instrs.len(); + let site = q.instrs.iter().position(|i| i.op == Op::Load && i.src != 0).or_else(|| q.instrs.iter().position(|i| i.op == Op::Load)).unwrap(); + let reg = q.instrs[site].src; + let mut changed = 0; + let mut k = (site + n - 1) % n; + while changed < 2 && k != site { + if q.instrs[k].dst == reg && !q.instrs[k].op.is_load() { + q.instrs[k].op = Op::Or; + changed += 1; + } + k = (k + n - 1) % n; + } + q +} + +fn steer(from: u64, count: u64, threads: usize, plant: bool, check_every: u64) { + let out = Mutex::new(std::io::stdout()); + println!("# adv3 steer {} seeds {from}..{} plant {plant} (internal adversarial pass, not an independent review)", utc(), from + count); + println!("seed\tattempt\tid\tmin_ratio256_milli\tmin_site\tdistinct_mean_milli\tsaturated\tbias_max\trejects\t{}\tchain_eq\tsecs{}", props_header(), if plant { "\tplant_ratio_milli\tplant_verdict" } else { "" }); + let era = era_bytes(); + let class = v4_era_class(&era); + let cap = max_attempts_for(&class); + run_seeds(from, count, threads, &out, |i| { + let b = steer_seed(i); + let label = format!("adv3/steer/{i}"); + let t0 = Instant::now(); + let mut rejects = String::new(); + let mut accepted = None; + for attempt in 0..cap { + let p = candidate_class(&label, &b, attempt, class); + match check(&p) { + Ok(r) => { + accepted = Some((p, r)); + break; + } + Err(r) => { + if !rejects.is_empty() { + rejects.push(','); + } + rejects.push_str(reject_part(&r)); + } + } + } + let (p, r) = match accepted { + Some(x) => x, + None => { + let lr = last_resort_v4(candidate_class(&label, &b, cap, class)); + return format!("{i}\tLASTRESORT\t{:016x}\t-\t-\t-\t-\t-\t{rejects}\t{}\t-\t{:.1}", lr.program_id(), props_row(&props(&lr)), t0.elapsed().as_secs_f64()); + } + }; + let (min_ratio, min_site) = distinct_ratio_pass(&p, 256, 0.0).unwrap_or((0.0, 99)); + let chain_eq = if check_every > 0 && i % check_every == 0 { + let c = Epoch::chain_program(&b, Some(&era), ProgramClass::V4, &label); + if c.instrs == p.instrs && c.shadow == p.shadow && c.attempt == p.attempt && c.program_id() == p.program_id() { "eq" } else { "DIFF" } + } else { + "-" + }; + let mut row = format!( + "{i}\t{}\t{:016x}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{chain_eq}\t{:.1}", + p.attempt, + p.program_id(), + (min_ratio * 1000.0) as u32, + min_site, + (r.distinct_mean() * 1000.0) as u32, + r.saturated, + r.bias_max, + if rejects.is_empty() { "-".to_string() } else { rejects }, + props_row(&props(&p)), + t0.elapsed().as_secs_f64() + ); + if plant { + let q = plant_hot(&p); + let (pr, _) = distinct_ratio_pass(&q, 256, 0.0).unwrap_or((0.0, 99)); + let pv = match check(&q) { Ok(_) => "accept".to_string(), Err(e) => format!("reject:{}", reject_part(&e)) }; + row.push_str(&format!("\t{}\t{pv}", (pr * 1000.0) as u32)); + } + row + }); +} + +// ------------------------------------------------------------------------------------------------------------ +// Q2b: static steering over many seeds (the first candidate that passes the static rule) +// ------------------------------------------------------------------------------------------------------------ + +fn static_sweep(from: u64, count: u64, threads: usize) { + let out = Mutex::new(std::io::stdout()); + println!("# adv3 static {} seeds {from}..{} (internal adversarial pass, not an independent review)", utc(), from + count); + println!("seed\tstatic_attempt\tstatic_rejects\t{}", props_header()); + let era = era_bytes(); + let class = v4_era_class(&era); + let cap = max_attempts_for(&class); + run_seeds(from, count, threads, &out, |i| { + let b = steer_seed(i); + let label = format!("adv3/steer/{i}"); + let mut rejects = [0u32; 3]; + for attempt in 0..cap { + let p = candidate_class(&label, &b, attempt, class); + match check_static(&p) { + Ok(()) => return format!("{i}\t{attempt}\t{}/{}/{}\t{}", rejects[0], rejects[1], rejects[2], props_row(&props(&p))), + Err(Reject::StaleLoadSource { .. }) => rejects[0] += 1, + Err(Reject::NoInjectingWrite { .. }) => rejects[1] += 1, + Err(_) => rejects[2] += 1, + } + } + format!("{i}\tcap\t{}/{}/{}\t-", rejects[0], rejects[1], rejects[2]) + }); +} + +// ------------------------------------------------------------------------------------------------------------ +// Q5: the era attacker (static): the stride and the windows of N era seeds +// ------------------------------------------------------------------------------------------------------------ + +fn era_steer(from: u64, count: u64, threads: usize) { + let out = Mutex::new(std::io::stdout()); + println!("# adv3 era-steer {} eras {from}..{} (internal adversarial pass, not an independent review)", utc(), from + count); + println!("era\tstride_mul\tstride_rot\tmul_popcount\tmul_naf\tinterleave\tepoch0_attempt\tepoch0_min_ratio256_milli"); + run_seeds(from, count, threads, &out, |i| { + let e = words_bytes(&seed_words_from_bytes(format!("igneum-adv-accept-3/era/{i}").as_bytes())); + let class = v4_era_class(&e); + let era = class.era.unwrap(); + // the first epoch of the era under this lane's epoch seed 0: the stride's effect on the accepted program + let b = steer_seed(0); + let p = Epoch::chain_program(&b, Some(&e), ProgramClass::V4, &format!("adv3/era/{i}")); + let (mr, _) = distinct_ratio_pass(&p, 256, 0.0).unwrap_or((0.0, 99)); + format!("{i}\t{:#010x}\t{}\t{}\t{}\t{:?}\t{}\t{}", era.stride_mul, era.stride_rot, era.stride_mul.count_ones(), naf_weight(era.stride_mul), era.pos, p.attempt, (mr * 1000.0) as u32) + }); +} + +fn naf_weight(v: u32) -> u32 { + let mut n = v as u64; + let mut w = 0; + while n != 0 { + if n & 1 == 1 { + if n & 3 == 3 { n += 1 } else { n -= 1 } + w += 1; + } + n >>= 1; + } + w +} + +fn main() { + let a = Args::parse(); + let threads = a.u("threads", 8) as usize; + match a.cmd.as_str() { + "idcheck" => idcheck(), + "ids" => ids(a.u("seeds", 1_000_000), a.u("attempts", 10) as u32, threads), + "margins" => margins(), + "lastresort" => lastresort(a.u("from", 0), a.u("count", 1000), threads), + "exhaust-mirror" => exhaust_mirror(a.u("seed", 0)), + "verdicts" => verdicts(a.u("from", 0), a.u("count", 1000), threads, &a.get("variant", "faithful")), + "steer" => steer(a.u("from", 0), a.u("count", 1000), threads, a.get("plant", "none") == "hot", a.u("check-every", 50)), + "static" => static_sweep(a.u("from", 0), a.u("count", 1000), threads), + "era-steer" => era_steer(a.u("from", 0), a.u("count", 1000), threads), + _ => usage(), + } +}