|
| 1 | +//! Estimator-side debias: how much of the champion's −7% settle gap can a |
| 2 | +//! belief multiplier recover, and at what §10-cost price? |
| 3 | +//! |
| 4 | +//! THEORY PREDICTION (docs/THEORY.md §5/§9): the SignPersist champion |
| 5 | +//! equilibrates with a persistent under-difficulty offset because the |
| 6 | +//! tighten threshold is high. Scaling h_estimate up by `bias>1` lifts that |
| 7 | +//! equilibrium toward truth (shrinks regret_under), but the SAME scaled |
| 8 | +//! belief is the retarget target, so it raises regret_over. With |
| 9 | +//! w_over:w_under = 3:1 the trade is priced AGAINST accuracy, so debias is |
| 10 | +//! expected to reduce the settle gap while INCREASING the §10 cost. This |
| 11 | +//! bin quantifies that curve and confirms whether −7% is a defect or the |
| 12 | +//! deliberate cost optimum (bias=1.0 wins => deliberate). |
| 13 | +//! |
| 14 | +//! Two readouts per bias level: |
| 15 | +//! - §10 cost components (the regret/effort grid, champion = anchor). |
| 16 | +//! - settle gap: signed % vs truth on a stable cell, so we SEE the |
| 17 | +//! accuracy move even when the cost says don't. |
| 18 | +//! |
| 19 | +//! Usage: `cargo run --release --bin confirm-debias` |
| 20 | +//! Env: VARDIFF_DB_TRIALS (default 1000), VARDIFF_DB_THREADS, |
| 21 | +//! VARDIFF_SWEEP_SEED, VARDIFF_DB_OUT_DIR (default "."). |
| 22 | +
|
| 23 | +use std::env; |
| 24 | +use std::fs; |
| 25 | +use std::path::PathBuf; |
| 26 | +use std::sync::atomic::{AtomicUsize, Ordering}; |
| 27 | +use std::sync::Arc; |
| 28 | +use std::sync::Mutex; |
| 29 | +use std::time::Instant; |
| 30 | + |
| 31 | +use channels_sv2::vardiff::composed::{ |
| 32 | + AcceleratingPartialRetarget, AdaptiveSignPersist, Composed, DebiasEstimator, EwmaEstimator, |
| 33 | + SignPersistenceCusumBoundary, |
| 34 | +}; |
| 35 | +use channels_sv2::vardiff::MockClock; |
| 36 | +use vardiff_sim::baseline::{Cell, Scenario, DEFAULT_BASELINE_SEED, TRUE_HASHRATE}; |
| 37 | +use vardiff_sim::grid::{run_cell_with_algorithm, AlgorithmSpec, VardiffBox}; |
| 38 | +use vardiff_sim::trial::run_trial_observed; |
| 39 | + |
| 40 | +const FLOOR: f64 = 0.05; |
| 41 | +const ETA_BASE: f32 = 0.2; |
| 42 | +const ETA_MAX: f32 = 0.8; |
| 43 | +const ACCEL: f32 = 0.05; |
| 44 | +const SENS: f64 = 0.3; |
| 45 | +const TIGHTEN: f64 = 6.0; |
| 46 | +const DISCOUNT: f64 = 0.06; |
| 47 | +const MAX_DISCOUNT: f64 = 0.6; |
| 48 | +const SPM_SWITCH: u32 = 6; |
| 49 | +const TAU: u64 = 150; |
| 50 | + |
| 51 | +struct Weights { |
| 52 | + w_over: f64, |
| 53 | + w_under: f64, |
| 54 | + rho_up: f64, |
| 55 | + rho_down: f64, |
| 56 | + rho: f64, |
| 57 | + w_det: f64, |
| 58 | +} |
| 59 | +impl Weights { |
| 60 | + fn from_env() -> Self { |
| 61 | + let g = |k: &str, d: f64| env::var(k).ok().and_then(|s| s.parse().ok()).unwrap_or(d); |
| 62 | + Weights { |
| 63 | + w_over: g("VARDIFF_W_OVER", 3.0), |
| 64 | + w_under: g("VARDIFF_W_UNDER", 1.0), |
| 65 | + rho_up: g("VARDIFF_RHO_UP", 3.0), |
| 66 | + rho_down: g("VARDIFF_RHO_DOWN", 1.0), |
| 67 | + rho: g("VARDIFF_RHO", 0.5), |
| 68 | + w_det: g("VARDIFF_W_DET", 0.5), |
| 69 | + } |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +fn champ_boundary() -> AdaptiveSignPersist { |
| 74 | + AdaptiveSignPersist::sign_persist( |
| 75 | + SignPersistenceCusumBoundary::new(SENS, FLOOR, TIGHTEN, DISCOUNT, MAX_DISCOUNT), |
| 76 | + SPM_SWITCH, |
| 77 | + ) |
| 78 | +} |
| 79 | + |
| 80 | +/// Champion with an estimator debias factor (bias=1.0 == the champion). |
| 81 | +fn debias_spec(bias: f32) -> AlgorithmSpec { |
| 82 | + let name = vardiff_sim::naming::triple_name( |
| 83 | + &DebiasEstimator::new(EwmaEstimator::new(TAU), bias), |
| 84 | + &champ_boundary(), |
| 85 | + &AcceleratingPartialRetarget::new(ETA_BASE, ETA_MAX, ACCEL), |
| 86 | + ); |
| 87 | + AlgorithmSpec::new(name, move |clock| { |
| 88 | + VardiffBox(Box::new(Composed::new( |
| 89 | + DebiasEstimator::new(EwmaEstimator::new(TAU), bias), |
| 90 | + champ_boundary(), |
| 91 | + AcceleratingPartialRetarget::new(ETA_BASE, ETA_MAX, ACCEL), |
| 92 | + 1.0, |
| 93 | + clock, |
| 94 | + ))) |
| 95 | + }) |
| 96 | +} |
| 97 | + |
| 98 | +struct Row { |
| 99 | + bias: f32, |
| 100 | + cost: f64, |
| 101 | + regret_over: f64, |
| 102 | + regret_under: f64, |
| 103 | + detection: f64, |
| 104 | + settle_gap_pct: f64, |
| 105 | +} |
| 106 | + |
| 107 | +fn cost(w: &Weights, ro: f64, ru: f64, eu: f64, ed: f64, det: f64) -> f64 { |
| 108 | + w.w_over * ro + w.w_under * ru + w.rho * (w.rho_up * eu + w.rho_down * ed) + w.w_det * (1.0 - det) |
| 109 | +} |
| 110 | + |
| 111 | +fn settle_gap_pct(bias: f32, trials: usize, base_seed: u64) -> f64 { |
| 112 | + // Stable scenario at 12 spm; median estimate over the back half vs truth. |
| 113 | + let spm = 12.0f32; |
| 114 | + let scen = Scenario::Stable; |
| 115 | + let (config, schedule) = scen.build(spm); |
| 116 | + let algo = debias_spec(bias); |
| 117 | + let n_ticks = (config.duration_secs / config.tick_interval_secs) as usize; |
| 118 | + let start = n_ticks / 2; |
| 119 | + let mut vals: Vec<f64> = Vec::with_capacity(trials); |
| 120 | + for i in 0..trials { |
| 121 | + let clock = Arc::new(MockClock::new(0)); |
| 122 | + let v = (algo.factory)(clock.clone()); |
| 123 | + let t = run_trial_observed(v, clock, config.clone(), &schedule, base_seed.wrapping_add(i as u64)); |
| 124 | + let back: Vec<f64> = t.ticks.iter().skip(start).map(|tk| tk.current_hashrate_before as f64).collect(); |
| 125 | + if !back.is_empty() { |
| 126 | + let mut b = back.clone(); |
| 127 | + b.sort_by(|a, c| a.partial_cmp(c).unwrap()); |
| 128 | + vals.push(b[b.len() / 2]); |
| 129 | + } |
| 130 | + } |
| 131 | + vals.sort_by(|a, c| a.partial_cmp(c).unwrap()); |
| 132 | + let med = if vals.is_empty() { TRUE_HASHRATE as f64 } else { vals[vals.len() / 2] }; |
| 133 | + (med / TRUE_HASHRATE as f64 - 1.0) * 100.0 |
| 134 | +} |
| 135 | + |
| 136 | +fn main() -> std::io::Result<()> { |
| 137 | + let trials: usize = env::var("VARDIFF_DB_TRIALS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000); |
| 138 | + let base_seed: u64 = env::var("VARDIFF_SWEEP_SEED") |
| 139 | + .ok() |
| 140 | + .and_then(|s| s.strip_prefix("0x").and_then(|h| u64::from_str_radix(h, 16).ok()).or_else(|| s.parse().ok())) |
| 141 | + .unwrap_or(DEFAULT_BASELINE_SEED); |
| 142 | + let n_threads: usize = env::var("VARDIFF_DB_THREADS") |
| 143 | + .ok() |
| 144 | + .and_then(|s| s.parse().ok()) |
| 145 | + .unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4)) |
| 146 | + .max(1); |
| 147 | + let out_dir = env::var("VARDIFF_DB_OUT_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")); |
| 148 | + let w = Weights::from_env(); |
| 149 | + |
| 150 | + let biases = [1.0f32, 1.02, 1.05, 1.08, 1.12, 1.18, 1.25]; |
| 151 | + |
| 152 | + let scenarios = vec![ |
| 153 | + Scenario::Stable, |
| 154 | + Scenario::Step { delta_pct: -50 }, |
| 155 | + Scenario::Step { delta_pct: -10 }, |
| 156 | + Scenario::Step { delta_pct: 10 }, |
| 157 | + Scenario::Step { delta_pct: 50 }, |
| 158 | + Scenario::SettledStep { settle_minutes: 60, delta_pct: -10 }, |
| 159 | + ]; |
| 160 | + let share_rates = vec![6.0f32, 8.0, 12.0, 20.0, 30.0]; |
| 161 | + let n_spm = share_rates.len(); |
| 162 | + let n_scen = scenarios.len(); |
| 163 | + |
| 164 | + eprintln!("confirm-debias: {} bias levels x {} cells x {} trials, {} threads", biases.len(), n_spm * n_scen, trials, n_threads); |
| 165 | + let started = Instant::now(); |
| 166 | + |
| 167 | + let next = AtomicUsize::new(0); |
| 168 | + let out: Mutex<Vec<Row>> = Mutex::new(Vec::with_capacity(biases.len())); |
| 169 | + std::thread::scope(|scope| { |
| 170 | + for _ in 0..n_threads { |
| 171 | + scope.spawn(|| loop { |
| 172 | + let bi = next.fetch_add(1, Ordering::Relaxed); |
| 173 | + if bi >= biases.len() { |
| 174 | + break; |
| 175 | + } |
| 176 | + let bias = biases[bi]; |
| 177 | + let algo = debias_spec(bias); |
| 178 | + let (mut ro, mut ru, mut eu, mut ed) = (0.0, 0.0, 0.0, 0.0); |
| 179 | + let mut n = 0u32; |
| 180 | + let (mut ds, mut dn) = (0.0, 0u32); |
| 181 | + for (si, &spm) in share_rates.iter().enumerate() { |
| 182 | + for (ci, scen) in scenarios.iter().enumerate() { |
| 183 | + let cell = Cell { shares_per_minute: spm, scenario: scen.clone() }; |
| 184 | + let cell_index = (bi * n_spm * n_scen + si * n_scen + ci) as u64; |
| 185 | + let r = run_cell_with_algorithm(&algo, &cell, trials, base_seed, cell_index); |
| 186 | + if matches!(scen, Scenario::Stable | Scenario::Step { delta_pct: -50 | -10 | 10 | 50 }) { |
| 187 | + ro += r.get("regret_over").unwrap_or(0.0); |
| 188 | + ru += r.get("regret_under").unwrap_or(0.0); |
| 189 | + eu += r.get("effort_up").unwrap_or(0.0); |
| 190 | + ed += r.get("effort_down").unwrap_or(0.0); |
| 191 | + n += 1; |
| 192 | + } |
| 193 | + if matches!(scen, Scenario::SettledStep { settle_minutes: 60, delta_pct: -10 }) { |
| 194 | + if let Some(rate) = r.get("settled_reaction_rate") { |
| 195 | + ds += rate; |
| 196 | + dn += 1; |
| 197 | + } |
| 198 | + } |
| 199 | + } |
| 200 | + } |
| 201 | + let nf = n.max(1) as f64; |
| 202 | + let (ro, ru, eu, ed) = (ro / nf, ru / nf, eu / nf, ed / nf); |
| 203 | + let det = if dn > 0 { ds / dn as f64 } else { 0.0 }; |
| 204 | + let gap = settle_gap_pct(bias, trials, base_seed ^ 0x5EeD); |
| 205 | + let row = Row { |
| 206 | + bias, |
| 207 | + cost: cost(&w, ro, ru, eu, ed, det), |
| 208 | + regret_over: ro, |
| 209 | + regret_under: ru, |
| 210 | + detection: det, |
| 211 | + settle_gap_pct: gap, |
| 212 | + }; |
| 213 | + out.lock().unwrap().push(row); |
| 214 | + }); |
| 215 | + } |
| 216 | + }); |
| 217 | + let mut rows = out.into_inner().unwrap(); |
| 218 | + eprintln!("Done in {:.1}s", started.elapsed().as_secs_f64()); |
| 219 | + rows.sort_by(|a, b| a.bias.partial_cmp(&b.bias).unwrap()); |
| 220 | + |
| 221 | + let mut md = String::new(); |
| 222 | + md.push_str("# Estimator debias sweep\n\n"); |
| 223 | + md.push_str(&format!("{} trials/cell, base_seed {:#x}. bias=1.0 is the champion. Cost = §10 (3:1). settle_gap = signed % vs truth, stable@12spm back-half median.\n\n", trials, base_seed)); |
| 224 | + md.push_str("| bias | cost | reg_over | reg_under | det% | settle gap |\n| --- | --- | --- | --- | --- | --- |\n"); |
| 225 | + for r in &rows { |
| 226 | + md.push_str(&format!( |
| 227 | + "| {:.2} | {:.4} | {:.4} | {:.4} | {:.0}% | {:+.1}% |\n", |
| 228 | + r.bias, r.cost, r.regret_over, r.regret_under, r.detection * 100.0, r.settle_gap_pct |
| 229 | + )); |
| 230 | + } |
| 231 | + let best = rows.iter().enumerate().min_by(|a, b| a.1.cost.partial_cmp(&b.1.cost).unwrap()).unwrap(); |
| 232 | + md.push_str(&format!("\nLowest §10 cost at bias={:.2}. ", rows[best.0].bias)); |
| 233 | + if (rows[best.0].bias - 1.0).abs() < 1e-6 { |
| 234 | + md.push_str("bias=1.0 wins => the −7% settle gap is the deliberate cost optimum; debias is cost-negative as theory predicted.\n"); |
| 235 | + } else { |
| 236 | + md.push_str("a debias > 1.0 wins => the settle offset was leaving cost on the table after all.\n"); |
| 237 | + } |
| 238 | + let out_path = out_dir.join("confirm_debias.md"); |
| 239 | + fs::write(&out_path, &md)?; |
| 240 | + eprintln!("Wrote {}", out_path.display()); |
| 241 | + |
| 242 | + println!("\n## Estimator debias sweep ({} trials) — cost vs settle-gap tradeoff\n", trials); |
| 243 | + println!("| bias | cost | reg_over | reg_under | det% | settle gap |"); |
| 244 | + println!("| --- | --- | --- | --- | --- | --- |"); |
| 245 | + for r in &rows { |
| 246 | + println!( |
| 247 | + "| {:.2} | {:.4} | {:.4} | {:.4} | {:.0}% | {:+.1}% |", |
| 248 | + r.bias, r.cost, r.regret_over, r.regret_under, r.detection * 100.0, r.settle_gap_pct |
| 249 | + ); |
| 250 | + } |
| 251 | + println!( |
| 252 | + "\nLowest §10 cost at bias={:.2} (cost {:.4}). {}", |
| 253 | + rows[best.0].bias, rows[best.0].cost, |
| 254 | + if (rows[best.0].bias - 1.0).abs() < 1e-6 { |
| 255 | + "bias=1.0 wins -> -7% gap is the deliberate optimum; debias trades accuracy for cost as predicted." |
| 256 | + } else { |
| 257 | + "a debias>1 wins -> the settle offset was leaving cost on the table." |
| 258 | + } |
| 259 | + ); |
| 260 | + Ok(()) |
| 261 | +} |
0 commit comments