From ee11da0ecec8bf017848281d1b8d070d32232438 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:51:06 +0100 Subject: [PATCH] tempo sim: vote cases at 60 s (1 h, 4 h, 24 h), 300 s, 7200 s and 86400 s; final tolerance back to 30 permille Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/tempo/sim.rs | 53 +++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/crates/mind-body/src/tempo/sim.rs b/crates/mind-body/src/tempo/sim.rs index d92b1a8..652e27f 100644 --- a/crates/mind-body/src/tempo/sim.rs +++ b/crates/mind-body/src/tempo/sim.rs @@ -63,6 +63,9 @@ struct Scenario { gap: Option<(f64, f64)>, /// The cost is too low to reach the target even at the minimum beat. unreachable: bool, + /// The vote itself spends the week: no controller can end on target, it + /// can only stop burning once the vote is gone. + overspent: bool, } const BASE: Scenario = Scenario { @@ -75,6 +78,7 @@ const BASE: Scenario = Scenario { vote: None, gap: None, unreachable: false, + overspent: false, }; fn scenarios() -> Vec { @@ -105,6 +109,32 @@ fn scenarios() -> Vec { vote: Some((48.0, 72.0, 300)), ..BASE }, + Scenario { + name: "vote-60s-1h", + vote: Some((48.0, 49.0, 60)), + ..BASE + }, + Scenario { + name: "vote-60s-4h", + vote: Some((48.0, 52.0, 60)), + ..BASE + }, + Scenario { + name: "vote-60s-24h", + vote: Some((48.0, 72.0, 60)), + overspent: true, + ..BASE + }, + Scenario { + name: "vote-7200s-24h", + vote: Some((48.0, 72.0, 7200)), + ..BASE + }, + Scenario { + name: "vote-86400s-24h", + vote: Some((48.0, 72.0, 86_400)), + ..BASE + }, Scenario { name: "rollover", windows: 2, @@ -142,6 +172,8 @@ struct Outcome { band_crossings: u32, /// Share of the last day of the last window spent at the minimum beat. frac_at_min: f64, + /// The same at the maximum beat. + frac_at_max: f64, /// The beat on the last minute. last_beat: u32, } @@ -161,7 +193,7 @@ fn run(scenario: &Scenario, sensor: Sensor, gains: &Gains, seed: u64) -> Outcome let (mut last_turn, mut turns, mut prev_beat) = (W0, 0u32, DEFAULT_BEAT_S); let steps = i64::from(scenario.windows) * WINDOW_MS / STEP_MS; let mut out = Outcome::default(); - let (mut jumps, mut at_min, mut last_day) = (0u32, 0u32, 0u32); + let (mut jumps, mut at_min, mut at_max, mut last_day) = (0u32, 0u32, 0u32, 0u32); let mut side = 0i8; let mut worst_day = 0f64; for i in 1..steps { @@ -202,6 +234,7 @@ fn run(scenario: &Scenario, sensor: Sensor, gains: &Gains, seed: u64) -> Outcome if window + 1 == scenario.windows && in_window >= 144.0 { last_day += 1; at_min += u32::from(beat == FALLBACK_MIN_S); + at_max += u32::from(beat == FALLBACK_MAX_S); } jumps += u32::from(f64::from(beat.abs_diff(prev_beat)) > 0.05 * f64::from(prev_beat)); } @@ -250,6 +283,7 @@ fn run(scenario: &Scenario, sensor: Sensor, gains: &Gains, seed: u64) -> Outcome out.worst_error_last_day = worst_day; out.jumps_per_h = f64::from(jumps) / (f64::from(scenario.windows) * 168.0); out.frac_at_min = f64::from(at_min) / f64::from(last_day.max(1)); + out.frac_at_max = f64::from(at_max) / f64::from(last_day.max(1)); out } @@ -258,11 +292,9 @@ const SEEDS: [u64; 3] = [11, 23, 47]; /// The most chatter, in jumps per hour, `GAINS` may show on any scenario. const JUMPS_PER_H_BOUND: f64 = 0.5; -/// How far from the target, in permille, a reachable scenario may end. The -/// plant's own lumps (a Self run is five turns' cost) are ~15-60 permille in -/// the dear-turns scenario, so this is wider than the spec's 30. -const FINAL_TOLERANCE: f64 = 35.0; -/// The worst |error| on the last day: the vote scenario's recovery sets it. +/// How far from the target, in permille, a reachable scenario may end. +const FINAL_TOLERANCE: f64 = 30.0; +/// The worst |error| on the last day: the vote scenarios' recovery sets it. const LAST_DAY_ERROR_BOUND: f64 = 75.0; #[test] @@ -282,6 +314,11 @@ fn sim_scenarios_meet_target() { ); continue; } + if scenario.overspent { + assert_eq!(o.last_beat, FALLBACK_MAX_S, "backs off {tag}"); + assert!(o.frac_at_max > 0.9, "backs off {tag}"); + continue; + } for used in &o.final_used { assert!( (used - f64::from(TARGET)).abs() <= FINAL_TOLERANCE, @@ -320,6 +357,8 @@ fn sweep() { j = j.max(o.jumps_per_h); if s.unreachable { sat_ok &= o.last_beat == FALLBACK_MIN_S && o.frac_at_min > 0.9; + } else if s.overspent { + sat_ok &= o.last_beat == FALLBACK_MAX_S && o.frac_at_max > 0.9; } else { f = o .final_used @@ -346,7 +385,7 @@ fn sweep() { } rows.sort_by(|a, b| a.0.total_cmp(&b.0)); println!( - "kp ki kd | worst final err | worst err last day | worst jumps/h | worst crossings | saturates when unreachable" + "kp ki kd | worst final err | worst err last day | worst jumps/h | worst crossings | saturates when unreachable or overspent" ); for r in &rows { println!(