onod3000/examples/calibrate.rs
2026-10-25 17:30:41 +02:00

417 lines
16 KiB
Rust

// Copyright 2025 Tobias Raayoni Last (@randogoth)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Builds the null calibration tables in `src/calibration/table.rs`, or checks them.
//!
//! cargo run --release --example calibrate -- [--reps N] [--seed S] [--urandom] [--out PATH] [--dump PATH]
//! cargo run --release --example calibrate -- --from-dump PATH [--out PATH]
//! cargo run --release --example calibrate -- --check N [--sizes A,B,..] [--seed S] [--urandom]
//!
//! Generation runs every test on N random samples at each calibrated size and stores the
//! quantiles of the z-scores, then the quantiles of the Cauchy combination statistic. The
//! raw z-scores are dumped so the tables can be rebuilt with `--from-dump` without
//! simulating again. `--check` scores N fresh samples per size with the compiled-in
//! tables and reports how often the calibrated p-values fall below 0.05 ... 0.0001.
use std::fs::File;
use std::io::{self, Read, Write};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use onod3000::calibration::{self, LEVELS, MAX_LOG2, MIN_LOG2, SIZES, TWO_SIDED};
use onod3000::score::acat;
use onod3000::{Onod, TESTS};
const DEFAULT_REPS: usize = 20_000;
const DEFAULT_SEED: u64 = 0x0d3000;
/// xoshiro256** seeded through SplitMix64.
struct Xoshiro([u64; 4]);
impl Xoshiro {
fn new(seed: u64) -> Self {
let mut x = seed;
Xoshiro(std::array::from_fn(|_| {
x = x.wrapping_add(0x9e3779b97f4a7c15);
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
z ^ (z >> 31)
}))
}
fn next(&mut self) -> u64 {
let s = &mut self.0;
let result = s[1].wrapping_mul(5).rotate_left(7).wrapping_mul(9);
let t = s[1] << 17;
s[2] ^= s[0];
s[3] ^= s[1];
s[1] ^= s[2];
s[0] ^= s[3];
s[2] ^= t;
s[3] = s[3].rotate_left(45);
result
}
fn fill(&mut self, buf: &mut [u8]) {
for chunk in buf.chunks_mut(8) {
let bytes = self.next().to_le_bytes();
chunk.copy_from_slice(&bytes[..chunk.len()]);
}
}
}
struct Args {
reps: usize,
seed: u64,
out: String,
dump: String,
from_dump: Option<String>,
urandom: bool,
check: Option<usize>,
sizes: Option<Vec<usize>>,
}
fn parse_args() -> Args {
let mut args = Args {
reps: DEFAULT_REPS,
seed: DEFAULT_SEED,
out: String::from("src/calibration/table.rs"),
dump: String::from("target/calibration-runs.bin"),
from_dump: None,
urandom: false,
check: None,
sizes: None,
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
let mut value = || it.next().unwrap_or_else(|| usage());
match arg.as_str() {
"--reps" => args.reps = value().parse().unwrap_or_else(|_| usage()),
"--seed" => args.seed = value().parse().unwrap_or_else(|_| usage()),
"--out" => args.out = value(),
"--dump" => args.dump = value(),
"--from-dump" => args.from_dump = Some(value()),
"--check" => args.check = Some(value().parse().unwrap_or_else(|_| usage())),
"--sizes" => {
args.sizes = Some(value().split(',').map(|n| n.parse().unwrap_or_else(|_| usage())).collect())
}
"--urandom" => args.urandom = true,
_ => usage(),
}
}
args
}
fn usage() -> ! {
eprintln!("Usage: calibrate [--reps N] [--seed S] [--urandom] [--out PATH] [--dump PATH]");
eprintln!(" calibrate --from-dump PATH [--out PATH]");
eprintln!(" calibrate --check N [--sizes A,B,..] [--seed S] [--urandom]");
std::process::exit(1);
}
/// Fills `buf` with the random sample for repetition `rep` at size index `size`.
fn sample(buf: &mut [u8], urandom: &Option<Mutex<File>>, seed: u64, size: usize, rep: usize) {
match urandom {
Some(file) => file.lock().unwrap().read_exact(buf).expect("reading /dev/urandom"),
None => Xoshiro::new(seed ^ ((size as u64) << 40) ^ rep as u64).fill(buf),
}
}
/// Runs `job(rep, buf)` for every repetition on all cores, collecting the results in order.
fn parallel<T: Send>(reps: usize, n: usize, job: impl Fn(usize, &mut [u8]) -> T + Sync) -> Vec<T> {
let next = AtomicUsize::new(0);
let results: Mutex<Vec<Option<T>>> = Mutex::new((0..reps).map(|_| None).collect());
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
std::thread::scope(|scope| {
for _ in 0..threads {
scope.spawn(|| {
let mut buf = vec![0u8; n];
loop {
let rep = next.fetch_add(1, Ordering::Relaxed);
if rep >= reps {
break;
}
let result = job(rep, &mut buf);
results.lock().unwrap()[rep] = Some(result);
}
});
}
});
results.into_inner().unwrap().into_iter().map(Option::unwrap).collect()
}
/// Quantiles of `values` at the calibration levels (linear between order statistics).
fn quantiles(values: &mut [f64]) -> [f64; LEVELS] {
values.sort_by(f64::total_cmp);
let last = (values.len() - 1) as f64;
calibration::levels().map(|p| {
let h = p * last;
let (i, w) = (h.floor() as usize, h.fract());
let next = values[(i + 1).min(values.len() - 1)];
if w == 0.0 || next == values[i] { values[i] } else { values[i] + w * (next - values[i]) }
})
}
/// z-score and raw p of every test per repetition and size, NaN where the test skipped.
type Runs = Vec<Vec<[(f64, f64); 15]>>;
fn simulate(args: &Args, urandom: &Option<Mutex<File>>) -> Runs {
(0..SIZES)
.map(|s| {
let n = 1usize << (MIN_LOG2 as usize + s);
let started = std::time::Instant::now();
let runs = parallel(args.reps, n, |rep, buf| {
sample(buf, urandom, args.seed, s, rep);
std::array::from_fn(|t| {
let (stat, z, p) = Onod::run(TESTS[t].1, buf);
calibration::calibration_z(t, stat, z, n).map_or((f64::NAN, f64::NAN), |z| (z, p))
})
});
eprintln!("{:>8} B {} reps in {:.1} s", n, args.reps, started.elapsed().as_secs_f64());
runs
})
.collect()
}
/// Dump format: header line "<source>\n", then per size a u64 count followed by
/// count * 15 * (z, p) little-endian f64 values.
fn save_runs(path: &str, source: &str, runs: &Runs) -> io::Result<()> {
let mut out = io::BufWriter::new(File::create(path)?);
writeln!(out, "{}", source)?;
for size in runs {
out.write_all(&(size.len() as u64).to_le_bytes())?;
for run in size {
for &(z, p) in run {
out.write_all(&z.to_le_bytes())?;
out.write_all(&p.to_le_bytes())?;
}
}
}
eprintln!("Saved raw runs to {}", path);
Ok(())
}
fn load_runs(path: &str) -> io::Result<(String, Runs)> {
let mut data = Vec::new();
File::open(path)?.read_to_end(&mut data)?;
let newline = data.iter().position(|&b| b == b'\n').expect("dump header");
let source = String::from_utf8_lossy(&data[..newline]).into_owned();
let mut pos = newline + 1;
let mut next = || {
let v = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
pos += 8;
v
};
let runs = (0..SIZES)
.map(|_| {
let count = next() as usize;
(0..count)
.map(|_| std::array::from_fn(|_| (f64::from_bits(next()), f64::from_bits(next()))))
.collect()
})
.collect();
Ok((source, runs))
}
fn generate(args: &Args, urandom: &Option<Mutex<File>>) -> io::Result<()> {
let (source, runs) = match &args.from_dump {
Some(path) => load_runs(path)?,
None => {
let source = if args.urandom {
String::from("/dev/urandom")
} else {
format!("xoshiro256** seed {:#x}", args.seed)
};
let runs = simulate(args, urandom);
save_runs(&args.dump, &source, &runs)?;
(source, runs)
}
};
let reps = runs[0].len();
let mut z_rows = vec![[[f64::NAN; LEVELS]; 15]; SIZES];
let mut t_rows = vec![[f64::NAN; LEVELS]; SIZES];
let mut raw_fail = vec![[f64::NAN; 15]; SIZES];
let mut notes = Vec::new();
for (s, runs) in runs.iter().enumerate() {
let n = 1usize << (MIN_LOG2 as usize + s);
for t in 0..15 {
let mut zs: Vec<f64> = runs.iter().map(|r| r[t].0).filter(|z| !z.is_nan()).collect();
let skipped = 1.0 - zs.len() as f64 / runs.len() as f64;
if skipped > 0.5 {
notes.push(format!("{:>8} B {:<14} skipped in {:.0}% of runs, not calibrated", n, TESTS[t].0, 100.0 * skipped));
continue;
}
raw_fail[s][t] = runs.iter().filter(|r| r[t].1 < 0.01).count() as f64 / zs.len() as f64;
z_rows[s][t] = quantiles(&mut zs);
if z_rows[s][t][LEVELS - 1] - z_rows[s][t][0] <= 1e-9 * (1.0 + z_rows[s][t][0].abs()) {
notes.push(format!("{:>8} B {:<14} constant on random data (z = {})", n, TESTS[t].0, z_rows[s][t][0]));
}
}
// Null distribution of the combination statistic, with the same lookup as at runtime.
// Cross-fitted: each run is scored against tables built from the other half of the
// runs, since runs look less extreme against tables that include themselves.
let halves: [Vec<[f64; LEVELS]>; 2] = std::array::from_fn(|h| {
(0..15)
.map(|t| {
let mut zs: Vec<f64> =
runs.iter().skip(h).step_by(2).map(|r| r[t].0).filter(|z| !z.is_nan()).collect();
if zs.is_empty() { [f64::NAN; LEVELS] } else { quantiles(&mut zs) }
})
.collect()
});
let mut ts: Vec<f64> = runs
.iter()
.enumerate()
.map(|(rep, r)| {
let other = &halves[1 - rep % 2];
let ps: Vec<f64> = (0..15)
.filter(|&t| !r[t].0.is_nan() && !z_rows[s][t][0].is_nan() && !other[t][0].is_nan())
.map(|t| calibration::tail_p(&other[t], r[t].0, TWO_SIDED[t]))
.collect();
acat(&ps)
})
.filter(|t| !t.is_nan())
.collect();
t_rows[s] = quantiles(&mut ts);
}
report(&raw_fail);
for note in &notes {
println!("{}", note);
}
write_table(&args.out, &source, reps, &z_rows, &t_rows)
}
/// Prints how often each raw p-value falls below 0.01 on random data (should be 1%).
fn report(raw_fail: &[[f64; 15]]) {
println!("\nRaw p < 0.01 on random data, in % (1.0 if the raw p-value were right):");
print!("{:<14}", "bytes");
for s in 0..SIZES {
print!("{:>7}", format!("2^{}", MIN_LOG2 as usize + s));
}
println!();
for t in 0..15 {
print!("{:<14}", TESTS[t].0);
for row in raw_fail {
if row[t].is_nan() { print!("{:>7}", "-") } else { print!("{:>7.1}", 100.0 * row[t]) }
}
println!();
}
println!();
}
fn write_table(path: &str, source: &str, reps: usize, z_rows: &[[[f64; LEVELS]; 15]], t_rows: &[[f64; LEVELS]]) -> io::Result<()> {
let row = |q: &[f64; LEVELS]| -> String {
if q[0].is_nan() {
return format!("[f64::NAN; {}]", LEVELS);
}
let values: Vec<String> = q.iter().map(|v| format!("{:?}", v)).collect();
format!("[{}]", values.join(", "))
};
let mut out = String::new();
out.push_str("// Generated by `cargo run --release --example calibrate`. Do not edit.\n");
out.push_str(&format!("// Null source: {}, {} samples per size, 2^{}..2^{} bytes.\n\n", source, reps, MIN_LOG2, MAX_LOG2));
out.push_str("use super::{LEVELS, SIZES};\n\n");
out.push_str(&format!("pub const REPS: usize = {};\n", reps));
out.push_str(&format!("pub const SOURCE: &str = {:?};\n\n", source));
out.push_str("/// Quantiles of each test's z-score on random data, per size and test.\n");
out.push_str("#[rustfmt::skip]\npub static Z: [[[f64; LEVELS]; 15]; SIZES] = [\n");
for (s, tests) in z_rows.iter().enumerate() {
out.push_str(&format!(" // 2^{} bytes\n [\n", MIN_LOG2 as usize + s));
for (t, q) in tests.iter().enumerate() {
out.push_str(&format!(" // {}\n {},\n", TESTS[t].0, row(q)));
}
out.push_str(" ],\n");
}
out.push_str("];\n\n/// Quantiles of the Cauchy combination statistic on random data, per size.\n");
out.push_str("#[rustfmt::skip]\npub static T: [[f64; LEVELS]; SIZES] = [\n");
for (s, q) in t_rows.iter().enumerate() {
out.push_str(&format!(" // 2^{} bytes\n {},\n", MIN_LOG2 as usize + s, row(q)));
}
out.push_str("];\n");
File::create(path)?.write_all(out.as_bytes())?;
println!("Wrote {}", path);
Ok(())
}
fn check(reps: usize, args: &Args, urandom: &Option<Mutex<File>>) {
if calibration::REPS == 0 {
eprintln!("No calibration table compiled in. Generate one first.");
std::process::exit(1);
}
// Calibrated sizes plus some in between and beyond, to exercise interpolation and extrapolation.
let default_sizes = vec![256, 1000, 1024, 4096, 10_000, 65_536, 100_000, 259_200, 1 << 20, 1_500_000, 4_000_000];
let sizes = args.sizes.clone().unwrap_or(default_sizes);
const ALPHAS: [f64; 4] = [0.05, 0.01, 1e-3, 1e-4];
const BATCH: usize = 100_000;
println!("Calibrated p below 0.05 / 0.01 / 0.001 / 0.0001 on fresh random data, in % of samples.");
for (i, &n) in sizes.iter().enumerate() {
// Counts per test (and combined, last) per alpha, plus the number of non-skipped runs.
let mut below = [[0usize; 4]; 16];
let mut total = [0usize; 16];
for start in (0..reps).step_by(BATCH) {
let batch = BATCH.min(reps - start);
// Seeds offset from generation, so these samples were not used to build the table.
let scores = parallel(batch, n, |rep, buf| {
sample(buf, urandom, args.seed.wrapping_add(0x5eed), 100 + i, start + rep);
let score = Onod::score(buf, false);
let mut ps = [f64::NAN; 16];
for (p, r) in ps.iter_mut().zip(&score.tests) {
*p = r.p_cal.unwrap_or(f64::NAN);
}
ps[15] = score.p_combined;
ps
});
for ps in &scores {
for k in 0..16 {
if ps[k].is_nan() {
continue;
}
total[k] += 1;
for (a, &alpha) in ALPHAS.iter().enumerate() {
below[k][a] += (ps[k] < alpha) as usize;
}
}
}
}
println!("\n{} bytes, {} samples (expected {:.2} / {:.2} / {:.3} / {:.4}):", n, reps, 5.0, 1.0, 0.1, 0.01);
for k in 0..16 {
let name = if k < 15 { TESTS[k].0 } else { "Combined" };
if total[k] == 0 {
println!(" {:<14} -", name);
continue;
}
let rate = |a: usize| 100.0 * below[k][a] as f64 / total[k] as f64;
println!(" {:<14} {:>6.2} {:>6.2} {:>7.3} {:>8.4}", name, rate(0), rate(1), rate(2), rate(3));
}
}
}
fn main() -> io::Result<()> {
let args = parse_args();
let urandom = if args.urandom { Some(Mutex::new(File::open("/dev/urandom")?)) } else { None };
match args.check {
Some(reps) => check(reps, &args, &urandom),
None => generate(&args, &urandom)?,
}
Ok(())
}