From 534fb1d6176964c837dd55abd8a27668f46e5e0e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:38:55 +0100 Subject: [PATCH] tempo sim: stop before the reset step; vote test pins the release, not the tracked integral Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/tempo/sim.rs | 572 ++++++++++++------------- crates/mind-body/src/tempo/tests.rs | 620 ++++++++++++++-------------- 2 files changed, 597 insertions(+), 595 deletions(-) diff --git a/crates/mind-body/src/tempo/sim.rs b/crates/mind-body/src/tempo/sim.rs index 6c5318a..71f7514 100644 --- a/crates/mind-body/src/tempo/sim.rs +++ b/crates/mind-body/src/tempo/sim.rs @@ -1,286 +1,286 @@ -//! The plant the fallback controller is tuned against: one seven-day plan -//! window at one-minute steps, each beat a Persona turn costing a lognormal -//! number of permille, plus the operator's own usage on the same subscription. -//! Test-only. The controller here is `super::control`, the same one the -//! heartbeat will call through `fallback_beat`; the sweep passes it candidate -//! gains, `GAINS` is committed. Rerun the sweep with -//! `cargo test -p mind-body tempo::sim::sweep -- --ignored --nocapture`. - -use super::*; - -const STEP_MS: i64 = 60_000; -const POLL_MS: i64 = 15 * 60_000; -const W0: i64 = 1_000 * 3_600_000; -const TARGET: u32 = 900; -/// Sigma of ln(turn cost). -const COST_SIGMA: f64 = 0.5; -/// Every this-many turns is a Self run, costing `SELF_FACTOR` times a Persona turn. -const SELF_EVERY: u32 = 25; -const SELF_FACTOR: f64 = 5.0; -/// Half-width, in permille, of the band the error must leave to count as a side. -const BAND: f64 = 15.0; - -struct Rng(u64); - -impl Rng { - fn next(&mut self) -> f64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64 - } - - fn lognormal(&mut self, mean: f64) -> f64 { - let (a, b) = (self.next().max(1e-12), self.next()); - let normal = (-2.0 * a.ln()).sqrt() * (std::f64::consts::TAU * b).cos(); - (mean.ln() - COST_SIGMA * COST_SIGMA / 2.0 + COST_SIGMA * normal).exp() - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Sensor { - /// The reading refreshes when a turn ends. - Stream, - /// The reading refreshes every fifteen minutes. - Poll, -} - -#[derive(Clone, Debug)] -struct Scenario { - name: &'static str, - windows: u32, - start_used: f64, - /// Permille per Persona turn. - mean_cost: f64, - /// (from hour, to hour, permille spread evenly) of the operator's own usage. - burst: Option<(f64, f64, f64)>, - /// (at hour, factor) on the per-turn cost. - cost_step: Option<(f64, f64)>, - /// (from hour, to hour, beat) of a standing vote. - vote: Option<(f64, f64, u32)>, - /// (from hour, to hour) with no reading. - gap: Option<(f64, f64)>, - /// The cost is too low to reach the target even at the minimum beat. - unreachable: bool, -} - -const BASE: Scenario = Scenario { - name: "quiet", - windows: 1, - start_used: 0.0, - mean_cost: 1.3, - burst: None, - cost_step: None, - vote: None, - gap: None, - unreachable: false, -}; - -fn scenarios() -> Vec { - vec![ - BASE, - Scenario { name: "cheap-turns", mean_cost: 0.6, ..BASE }, - Scenario { name: "dear-turns", mean_cost: 3.0, ..BASE }, - Scenario { name: "burst", burst: Some((48.0, 52.0, 200.0)), ..BASE }, - Scenario { name: "cost-x3", cost_step: Some((84.0, 3.0)), ..BASE }, - Scenario { name: "vote-300s-24h", vote: Some((48.0, 72.0, 300)), ..BASE }, - Scenario { name: "rollover", windows: 2, ..BASE }, - Scenario { name: "gap-6h", gap: Some((72.0, 78.0)), ..BASE }, - Scenario { name: "start-ahead", start_used: 500.0, ..BASE }, - Scenario { name: "unreachable", mean_cost: 0.05, unreachable: true, ..BASE }, - ] -} - -#[derive(Clone, Debug, Default)] -struct Outcome { - /// Used at each window's reset. - final_used: Vec, - /// Worst |trajectory - used| over the last day of the last window. - worst_error_last_day: f64, - /// Beat moves of more than 5% between minutes, per hour, outside votes. - jumps_per_h: f64, - /// Times the error crossed from beyond +BAND to beyond -BAND (or back) - /// during the last three days. - band_crossings: u32, - frac_at_min: f64, - /// The beat on the last minute. - last_beat: u32, -} - -fn run(scenario: &Scenario, sensor: Sensor, gains: &Gains, seed: u64) -> Outcome { - let mut rng = Rng(seed); - let mut state = Fallback::default(); - let mut used = scenario.start_used; - let mut resets = W0 + WINDOW_MS; - let mut window = 0u32; - let sense = |used: f64, now: i64, resets: i64| Reading { - used_permille: used.round().clamp(0.0, 1000.0) as u32, - observed_at_ms: now, - resets_at_ms: resets, - }; - let mut reading = sense(used, W0, resets); - 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 unvoted, mut at_min) = (0u32, 0u32, 0u32); - let mut side = 0i8; - let mut worst_day = 0f64; - for i in 1..=steps { - let now = W0 + i * STEP_MS; - if now >= resets { - out.final_used.push(used); - used = 0.0; - resets += WINDOW_MS; - window += 1; - } - let in_window = (now - (resets - WINDOW_MS)) as f64 / HOUR_MS; - if let Some((from, to, amount)) = scenario.burst { - if in_window >= from && in_window < to { - used += amount * STEP_MS as f64 / HOUR_MS / (to - from); - } - } - let gap = scenario.gap.is_some_and(|(from, to)| in_window >= from && in_window < to); - if sensor == Sensor::Poll && !gap && (now - W0) % POLL_MS == 0 { - reading = sense(used, now, resets); - } - let vote = scenario.vote.filter(|&(from, to, _)| in_window >= from && in_window < to).map(|v| v.2); - let shown = if gap { None } else { Some(reading) }; - let beat = match vote { - Some(beat) => { - state.rate_per_h = 3600.0 / f64::from(beat); - control(gains, &mut state, shown.as_ref(), now, TARGET, true); - beat - } - None => control(gains, &mut state, shown.as_ref(), now, TARGET, false), - }; - if vote.is_none() { - unvoted += 1; - at_min += u32::from(beat == FALLBACK_MIN_S); - jumps += u32::from(f64::from(beat.abs_diff(prev_beat)) > 0.05 * f64::from(prev_beat)); - } - prev_beat = beat; - out.last_beat = beat; - if now - last_turn >= i64::from(beat) * 1000 { - let step = scenario.cost_step.map_or(1.0, |(at, factor)| if in_window >= at { factor } else { 1.0 }); - turns += 1; - let self_run = if turns % SELF_EVERY == 0 { SELF_FACTOR } else { 1.0 }; - used += rng.lognormal(scenario.mean_cost * step) * self_run; - last_turn = now; - if sensor == Sensor::Stream && !gap { - reading = sense(used.min(1000.0), now, resets); - } - } - used = used.min(1000.0); - let error = f64::from(TARGET) * (now - (resets - WINDOW_MS)) as f64 / WINDOW_MS as f64 - used; - if window + 1 == scenario.windows { - if in_window >= 144.0 { - worst_day = worst_day.max(error.abs()); - } - if in_window >= 96.0 { - let now_side = if error > BAND { - 1 - } else if error < -BAND { - -1 - } else { - 0 - }; - if now_side != 0 { - out.band_crossings += u32::from(side != 0 && side != now_side); - side = now_side; - } - } - } - } - out.final_used.push(used); - 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(unvoted.max(1)); - out -} - -const SENSORS: [Sensor; 2] = [Sensor::Stream, Sensor::Poll]; -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 = 1.0; - -#[test] -fn sim_scenarios_meet_target() { - for scenario in scenarios() { - for sensor in SENSORS { - for seed in SEEDS { - let o = run(&scenario, sensor, &GAINS, seed); - let tag = format!("{} {sensor:?} seed {seed}: {o:?}", scenario.name); - assert!(o.jumps_per_h <= JUMPS_PER_H_BOUND, "chatter {tag}"); - if scenario.unreachable { - assert_eq!(o.last_beat, FALLBACK_MIN_S, "saturated {tag}"); - assert!(o.frac_at_min > 0.9, "saturated {tag}"); - assert!(o.final_used.iter().all(|u| *u < f64::from(TARGET) - 100.0), "{tag}"); - continue; - } - for used in &o.final_used { - assert!((used - f64::from(TARGET)).abs() <= 30.0, "final {tag}"); - } - assert!(o.worst_error_last_day <= 30.0, "error {tag}"); - assert!(o.band_crossings <= 2, "oscillation {tag}"); - } - } - } -} - -/// `cargo test -p mind-body tempo::sim::sweep -- --ignored --nocapture` -#[test] -#[ignore] -fn sweep() { - let scen = scenarios(); - let mut rows = Vec::new(); - for kp in [0.0, 0.005, 0.01, 0.02, 0.05, 0.1] { - for ki in [0.0, 0.0005, 0.001, 0.002, 0.005, 0.01] { - for kd in [0.0, 0.01, 0.05, 0.1, 0.2] { - let g = Gains { kp, ki, kd }; - let (mut final_err, mut err_day, mut jumps, mut cross) = (0f64, 0f64, 0f64, 0u32); - let mut sat_ok = true; - let mut detail = String::new(); - for s in &scen { - let (mut f, mut e, mut j) = (0f64, 0f64, 0f64); - let mut lastb = 0; - for sensor in SENSORS { - for seed in SEEDS { - let o = run(s, sensor, &g, seed); - 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 { - f = o.final_used.iter().fold(f, |m, u| m.max((u - f64::from(TARGET)).abs())); - e = e.max(o.worst_error_last_day); - cross = cross.max(o.band_crossings); - } - lastb = o.last_beat; - } - } - final_err = final_err.max(f); - err_day = err_day.max(e); - jumps = jumps.max(j); - detail.push_str(&format!( - " {:<14} final_err {f:6.1} worst_err_last_day {e:6.1} jumps/h {j:5.2} last_beat {lastb}\n", - s.name - )); - } - let rank = final_err + 0.5 * err_day + 20.0 * jumps + 15.0 * f64::from(cross); - rows.push((rank, g, final_err, err_day, jumps, cross, sat_ok, detail)); - } - } - } - 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"); - for r in &rows { - println!("{} {} {} | {:.1} | {:.1} | {:.2} | {} | {}", r.1.kp, r.1.ki, r.1.kd, r.2, r.3, r.4, r.5, r.6); - } - for r in rows.iter().take(3) { - println!("\nBEST {:?}\n{}", r.1, r.7); - } - if let Some(r) = rows.iter().find(|r| r.1 == GAINS) { - println!("\nCHOSEN {:?}\n{}", r.1, r.7); - } -} +//! The plant the fallback controller is tuned against: one seven-day plan +//! window at one-minute steps, each beat a Persona turn costing a lognormal +//! number of permille, plus the operator's own usage on the same subscription. +//! Test-only. The controller here is `super::control`, the same one the +//! heartbeat will call through `fallback_beat`; the sweep passes it candidate +//! gains, `GAINS` is committed. Rerun the sweep with +//! `cargo test -p mind-body tempo::sim::sweep -- --ignored --nocapture`. + +use super::*; + +const STEP_MS: i64 = 60_000; +const POLL_MS: i64 = 15 * 60_000; +const W0: i64 = 1_000 * 3_600_000; +const TARGET: u32 = 900; +/// Sigma of ln(turn cost). +const COST_SIGMA: f64 = 0.5; +/// Every this-many turns is a Self run, costing `SELF_FACTOR` times a Persona turn. +const SELF_EVERY: u32 = 25; +const SELF_FACTOR: f64 = 5.0; +/// Half-width, in permille, of the band the error must leave to count as a side. +const BAND: f64 = 15.0; + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64 + } + + fn lognormal(&mut self, mean: f64) -> f64 { + let (a, b) = (self.next().max(1e-12), self.next()); + let normal = (-2.0 * a.ln()).sqrt() * (std::f64::consts::TAU * b).cos(); + (mean.ln() - COST_SIGMA * COST_SIGMA / 2.0 + COST_SIGMA * normal).exp() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Sensor { + /// The reading refreshes when a turn ends. + Stream, + /// The reading refreshes every fifteen minutes. + Poll, +} + +#[derive(Clone, Debug)] +struct Scenario { + name: &'static str, + windows: u32, + start_used: f64, + /// Permille per Persona turn. + mean_cost: f64, + /// (from hour, to hour, permille spread evenly) of the operator's own usage. + burst: Option<(f64, f64, f64)>, + /// (at hour, factor) on the per-turn cost. + cost_step: Option<(f64, f64)>, + /// (from hour, to hour, beat) of a standing vote. + vote: Option<(f64, f64, u32)>, + /// (from hour, to hour) with no reading. + gap: Option<(f64, f64)>, + /// The cost is too low to reach the target even at the minimum beat. + unreachable: bool, +} + +const BASE: Scenario = Scenario { + name: "quiet", + windows: 1, + start_used: 0.0, + mean_cost: 1.3, + burst: None, + cost_step: None, + vote: None, + gap: None, + unreachable: false, +}; + +fn scenarios() -> Vec { + vec![ + BASE, + Scenario { name: "cheap-turns", mean_cost: 0.6, ..BASE }, + Scenario { name: "dear-turns", mean_cost: 3.0, ..BASE }, + Scenario { name: "burst", burst: Some((48.0, 52.0, 200.0)), ..BASE }, + Scenario { name: "cost-x3", cost_step: Some((84.0, 3.0)), ..BASE }, + Scenario { name: "vote-300s-24h", vote: Some((48.0, 72.0, 300)), ..BASE }, + Scenario { name: "rollover", windows: 2, ..BASE }, + Scenario { name: "gap-6h", gap: Some((72.0, 78.0)), ..BASE }, + Scenario { name: "start-ahead", start_used: 500.0, ..BASE }, + Scenario { name: "unreachable", mean_cost: 0.05, unreachable: true, ..BASE }, + ] +} + +#[derive(Clone, Debug, Default)] +struct Outcome { + /// Used at each window's reset. + final_used: Vec, + /// Worst |trajectory - used| over the last day of the last window. + worst_error_last_day: f64, + /// Beat moves of more than 5% between minutes, per hour, outside votes. + jumps_per_h: f64, + /// Times the error crossed from beyond +BAND to beyond -BAND (or back) + /// during the last three days. + band_crossings: u32, + frac_at_min: f64, + /// The beat on the last minute. + last_beat: u32, +} + +fn run(scenario: &Scenario, sensor: Sensor, gains: &Gains, seed: u64) -> Outcome { + let mut rng = Rng(seed); + let mut state = Fallback::default(); + let mut used = scenario.start_used; + let mut resets = W0 + WINDOW_MS; + let mut window = 0u32; + let sense = |used: f64, now: i64, resets: i64| Reading { + used_permille: used.round().clamp(0.0, 1000.0) as u32, + observed_at_ms: now, + resets_at_ms: resets, + }; + let mut reading = sense(used, W0, resets); + 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 unvoted, mut at_min) = (0u32, 0u32, 0u32); + let mut side = 0i8; + let mut worst_day = 0f64; + for i in 1..steps { + let now = W0 + i * STEP_MS; + if now >= resets { + out.final_used.push(used); + used = 0.0; + resets += WINDOW_MS; + window += 1; + } + let in_window = (now - (resets - WINDOW_MS)) as f64 / HOUR_MS; + if let Some((from, to, amount)) = scenario.burst { + if in_window >= from && in_window < to { + used += amount * STEP_MS as f64 / HOUR_MS / (to - from); + } + } + let gap = scenario.gap.is_some_and(|(from, to)| in_window >= from && in_window < to); + if sensor == Sensor::Poll && !gap && (now - W0) % POLL_MS == 0 { + reading = sense(used, now, resets); + } + let vote = scenario.vote.filter(|&(from, to, _)| in_window >= from && in_window < to).map(|v| v.2); + let shown = if gap { None } else { Some(reading) }; + let beat = match vote { + Some(beat) => { + state.rate_per_h = 3600.0 / f64::from(beat); + control(gains, &mut state, shown.as_ref(), now, TARGET, true); + beat + } + None => control(gains, &mut state, shown.as_ref(), now, TARGET, false), + }; + if vote.is_none() { + unvoted += 1; + at_min += u32::from(beat == FALLBACK_MIN_S); + jumps += u32::from(f64::from(beat.abs_diff(prev_beat)) > 0.05 * f64::from(prev_beat)); + } + prev_beat = beat; + out.last_beat = beat; + if now - last_turn >= i64::from(beat) * 1000 { + let step = scenario.cost_step.map_or(1.0, |(at, factor)| if in_window >= at { factor } else { 1.0 }); + turns += 1; + let self_run = if turns % SELF_EVERY == 0 { SELF_FACTOR } else { 1.0 }; + used += rng.lognormal(scenario.mean_cost * step) * self_run; + last_turn = now; + if sensor == Sensor::Stream && !gap { + reading = sense(used.min(1000.0), now, resets); + } + } + used = used.min(1000.0); + let error = f64::from(TARGET) * (now - (resets - WINDOW_MS)) as f64 / WINDOW_MS as f64 - used; + if window + 1 == scenario.windows { + if in_window >= 144.0 { + worst_day = worst_day.max(error.abs()); + } + if in_window >= 96.0 { + let now_side = if error > BAND { + 1 + } else if error < -BAND { + -1 + } else { + 0 + }; + if now_side != 0 { + out.band_crossings += u32::from(side != 0 && side != now_side); + side = now_side; + } + } + } + } + out.final_used.push(used); + 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(unvoted.max(1)); + out +} + +const SENSORS: [Sensor; 2] = [Sensor::Stream, Sensor::Poll]; +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 = 1.0; + +#[test] +fn sim_scenarios_meet_target() { + for scenario in scenarios() { + for sensor in SENSORS { + for seed in SEEDS { + let o = run(&scenario, sensor, &GAINS, seed); + let tag = format!("{} {sensor:?} seed {seed}: {o:?}", scenario.name); + assert!(o.jumps_per_h <= JUMPS_PER_H_BOUND, "chatter {tag}"); + if scenario.unreachable { + assert_eq!(o.last_beat, FALLBACK_MIN_S, "saturated {tag}"); + assert!(o.frac_at_min > 0.9, "saturated {tag}"); + assert!(o.final_used.iter().all(|u| *u < f64::from(TARGET) - 100.0), "{tag}"); + continue; + } + for used in &o.final_used { + assert!((used - f64::from(TARGET)).abs() <= 30.0, "final {tag}"); + } + assert!(o.worst_error_last_day <= 30.0, "error {tag}"); + assert!(o.band_crossings <= 2, "oscillation {tag}"); + } + } + } +} + +/// `cargo test -p mind-body tempo::sim::sweep -- --ignored --nocapture` +#[test] +#[ignore] +fn sweep() { + let scen = scenarios(); + let mut rows = Vec::new(); + for kp in [0.0, 0.005, 0.01, 0.02, 0.05, 0.1] { + for ki in [0.0, 0.0005, 0.001, 0.002, 0.005, 0.01] { + for kd in [0.0, 0.01, 0.05, 0.1, 0.2] { + let g = Gains { kp, ki, kd }; + let (mut final_err, mut err_day, mut jumps, mut cross) = (0f64, 0f64, 0f64, 0u32); + let mut sat_ok = true; + let mut detail = String::new(); + for s in &scen { + let (mut f, mut e, mut j) = (0f64, 0f64, 0f64); + let mut lastb = 0; + for sensor in SENSORS { + for seed in SEEDS { + let o = run(s, sensor, &g, seed); + 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 { + f = o.final_used.iter().fold(f, |m, u| m.max((u - f64::from(TARGET)).abs())); + e = e.max(o.worst_error_last_day); + cross = cross.max(o.band_crossings); + } + lastb = o.last_beat; + } + } + final_err = final_err.max(f); + err_day = err_day.max(e); + jumps = jumps.max(j); + detail.push_str(&format!( + " {:<14} final_err {f:6.1} worst_err_last_day {e:6.1} jumps/h {j:5.2} last_beat {lastb}\n", + s.name + )); + } + let rank = final_err + 0.5 * err_day + 20.0 * jumps + 15.0 * f64::from(cross); + rows.push((rank, g, final_err, err_day, jumps, cross, sat_ok, detail)); + } + } + } + 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"); + for r in &rows { + println!("{} {} {} | {:.1} | {:.1} | {:.2} | {} | {}", r.1.kp, r.1.ki, r.1.kd, r.2, r.3, r.4, r.5, r.6); + } + for r in rows.iter().take(3) { + println!("\nBEST {:?}\n{}", r.1, r.7); + } + if let Some(r) = rows.iter().find(|r| r.1 == GAINS) { + println!("\nCHOSEN {:?}\n{}", r.1, r.7); + } +} diff --git a/crates/mind-body/src/tempo/tests.rs b/crates/mind-body/src/tempo/tests.rs index 5c2f8f6..eb1188f 100644 --- a/crates/mind-body/src/tempo/tests.rs +++ b/crates/mind-body/src/tempo/tests.rs @@ -1,309 +1,311 @@ -//! The tempo rule as tables: what `tally` and `pick` answer for the cases that -//! carry the rulings. Pure, so nothing here needs a mind, a clock or a daemon. - -use super::*; - -fn mind(slug: &str, waited_s: Option) -> Standing { - Standing { - slug: slug.into(), - eligible: true, - waited_s, - last_turn_ms: waited_s.map(|waited| 1_000_000 - i64::try_from(waited).unwrap() * 1000), - self_permille: 0, - topic_permille: 0, - addressed_by_member: false, - } -} - -#[test] -fn the_score_is_the_wait_in_beats_times_ten_to_the_urgency_in_orders() { - let mut standing = mind("a", Some(1800)); - assert_eq!(score(&standing, 900), 2.0, "two beats waited, no urgency"); - assert_eq!(score(&standing, 600), 3.0); - standing.self_permille = 1000; - assert_eq!(score(&standing, 900), 20.0, "full urgency is worth one order of magnitude"); - standing.self_permille = 500; - assert!((score(&standing, 900) - 2.0 * 10f64.sqrt()).abs() < 1e-9); - assert_eq!(score(&mind("never", None), 900), f64::INFINITY); -} - -#[test] -fn no_votes_is_the_default_beat() { - assert_eq!(tally(&[]), DEFAULT_BEAT_S); - assert_eq!(tally(&[None, None]), DEFAULT_BEAT_S); - assert_eq!(DEFAULT_BEAT_S, 900); -} - -#[test] -fn the_tally_is_the_lower_median_and_one_outlier_cannot_move_it() { - let table: [(&[u32], u32); 7] = [ - (&[600], 600), - (&[300, 900], 300), - (&[900, 300, 600], 600), - (&[100, 300, 600, 900], 300), - (&[600, 600, 86_400], 600), - (&[60, 600, 600], 600), - (&[60, 60, 86_400, 86_400, 600], 600), - ]; - for (votes, want) in table { - let votes: Vec> = votes.iter().copied().map(Some).collect(); - assert_eq!(tally(&votes), want, "{votes:?}"); - } -} - -#[test] -fn silence_is_abstention_and_only_cast_votes_are_counted() { - let mut eight = vec![None; 8]; - eight[0] = Some(60); - assert_eq!(tally(&eight), 60, "one voter among eight silent Minds decides"); - assert_eq!(tally(&[None; 8]), DEFAULT_BEAT_S, "none cast is the default"); - assert_eq!(tally(&[Some(3600), None, Some(60)]), 60, "two cast: the lower median"); - assert_eq!(tally(&[Some(3600), Some(1800), None, None, Some(60)]), 1800, "abstainers do not dilute the median"); -} - -#[test] -fn the_tally_does_not_depend_on_the_order_the_votes_came_in() { - assert_eq!(tally(&[Some(900), Some(60), None, Some(600)]), tally(&[None, Some(60), Some(600), Some(900)])); -} - -#[test] -fn waiting_alone_round_robins() { - let rows = [ - mind("ann", Some(100)), - mind("bob", Some(400)), - mind("cy", Some(250)), - ]; - assert_eq!( - pick(&rows, 100), - Some(1), - "the longest waiter takes the beat" - ); - // After bob's turn he has waited least; the next longest waiter goes. - let after = [ - mind("ann", Some(200)), - mind("bob", Some(0)), - mind("cy", Some(350)), - ]; - assert_eq!(pick(&after, 100), Some(2)); -} - -#[test] -fn a_full_urgency_equals_ten_times_the_wait_at_one_order() { - assert_eq!(LOG_BOUND_ORDERS, 1.0); - let steady = mind("ann", Some(100)); - let mut urgent = mind("bob", Some(11)); - urgent.self_permille = 1000; - assert_eq!( - pick(&[steady.clone(), urgent.clone()], 100), - Some(1), - "eleven seconds at full urgency beat a hundred waiting" - ); - urgent.waited_s = Some(9); - urgent.last_turn_ms = Some(1_000_000 - 9000); - assert_eq!(pick(&[steady, urgent], 100), Some(0), "nine do not"); -} - -#[test] -fn a_half_urgency_is_the_square_root_of_ten() { - let mut half = mind("ann", Some(100)); - half.self_permille = 500; - let plain = mind("bob", Some(100)); - let ratio = score(&half, 100) / score(&plain, 100); - assert!((ratio - 10f64.sqrt()).abs() < 1e-9, "{ratio}"); -} - -#[test] -fn urgency_is_the_strongest_of_self_topic_and_a_member_addressing_her() { - let mut row = mind("ann", Some(100)); - assert_eq!(urgency_permille(&row), 0); - row.self_permille = 300; - row.topic_permille = 700; - assert_eq!(urgency_permille(&row), 700); - row.addressed_by_member = true; - assert_eq!(urgency_permille(&row), 1000); - row.addressed_by_member = false; - row.topic_permille = 0; - row.self_permille = 5000; - assert_eq!( - urgency_permille(&row), - 1000, - "an urgency past the scale is the scale" - ); -} - -#[test] -fn a_tie_goes_to_the_oldest_last_turn_then_to_the_slug() { - let mut old = mind("zed", Some(100)); - old.last_turn_ms = Some(10); - let mut new = mind("ann", Some(100)); - new.last_turn_ms = Some(20); - assert_eq!( - pick(&[new.clone(), old.clone()], 100), - Some(1), - "the older last turn wins the tie, whatever the slug" - ); - new.last_turn_ms = Some(10); - assert_eq!( - pick(&[old, new], 100), - Some(1), - "equal turns: the lower slug" - ); -} - -#[test] -fn a_mind_that_never_acted_goes_first_and_the_never_acted_tie_by_slug() { - let rows = [ - mind("ann", Some(86_400)), - mind("cy", None), - mind("bob", None), - ]; - assert_eq!(pick(&rows, 900), Some(2)); -} - -#[test] -fn a_braked_or_busy_mind_is_never_picked_however_high_she_scores() { - let mut rows = [mind("ann", Some(10)), mind("bob", Some(100_000))]; - rows[1].eligible = false; - rows[1].self_permille = 1000; - assert_eq!(pick(&rows, 100), Some(0)); - rows[0].eligible = false; - assert_eq!(pick(&rows, 100), None, "nobody eligible, nobody picked"); - assert_eq!(pick(&[], 100), None); -} - -#[test] -fn a_longer_beat_does_not_change_who_wins_among_equal_urgency() { - let rows = [mind("ann", Some(500)), mind("bob", Some(300))]; - assert_eq!(pick(&rows, 60), pick(&rows, 86_400)); -} - -// The fallback controller. `control` is the one controller; these tests give it -// explicit gains so they pin each term whatever `GAINS` the simulation chose. - -const RESETS: i64 = 1_000_000_000_000; -const MID: i64 = RESETS - WINDOW_MS / 2; -const HOUR: i64 = 3_600_000; -const T: Gains = Gains { kp: 0.02, ki: 0.002, kd: 0.02 }; - -fn read(used_permille: u32, observed_at_ms: i64) -> Reading { - Reading { used_permille, observed_at_ms, resets_at_ms: RESETS } -} - -fn near(got: u32, want: u32) { - assert!(got.abs_diff(want) <= 1, "{got} is not within one second of {want}"); -} - -#[test] -fn behind_target_shortens_the_beat() { - let at = |used| control(&T, &mut Fallback::default(), Some(&read(used, MID)), MID, 900, false); - // Halfway through the window the trajectory is 450: error +50, 0, -50 is a - // turn rate of 5, 4 and 3 an hour. - near(at(400), 720); - near(at(450), DEFAULT_BEAT_S); - near(at(500), 1200); -} - -#[test] -fn derivative_is_on_measurement() { - let g = Gains { ki: 0.0, ..T }; - let mut s = Fallback::default(); - near(control(&g, &mut s, Some(&read(410, MID)), MID, 900, false), 750); - // A new observation 10 permille higher a quarter hour on: the slope is 40 an hour. - let next = read(420, MID + 15 * 60_000); - near(control(&g, &mut s, Some(&next), MID + 15 * 60_000, 900, false), 941); - // The same reading again carries no derivative term. - near(control(&g, &mut s, Some(&next), MID + 16 * 60_000, 900, false), 778); - // A step in the target with used unchanged moves the beat by kp x error alone. - near(control(&g, &mut s, Some(&next), MID + 18 * 60_000, 1000, false), 639); -} - -#[test] -fn saturated_output_does_not_wind_up() { - let g = Gains { kp: 0.1, ki: 0.01, kd: 0.0 }; - let day5 = RESETS - 2 * 24 * HOUR; - let mut s = Fallback::default(); - let behind = read(0, day5); - for i in 0..100 { - assert_eq!(control(&g, &mut s, Some(&behind), day5 + i * 60_000, 900, false), FALLBACK_MIN_S); - } - assert_eq!(s.integral, 0.0, "error is positive and the output is at its limit"); - let near_on = read(600, day5 + 100 * 60_000); - for i in 100..110 { - control(&g, &mut s, Some(&near_on), day5 + i * 60_000, 900, false); - } - assert!(s.integral > 0.0, "unsaturated again, it integrates"); - - // Saturated at the slow limit with the error pulling the other way integrates. - let mut s = Fallback { integral: -1000.0, ..Fallback::default() }; - let reading = read(400, MID); - control(&g, &mut s, Some(&reading), MID, 900, false); - control(&g, &mut s, Some(&reading), MID + HOUR, 900, false); - assert!(s.integral > -1000.0); -} - -#[test] -fn vote_freezes_and_release_is_bumpless() { - let reading = read(300, MID); - let mut s = Fallback::default(); - for i in 0..5 { - control(&T, &mut s, Some(&reading), MID + i * 60_000, 900, false); - } - s.rate_per_h = 3600.0 / 300.0; - let (mut short, mut long) = (s.clone(), s.clone()); - assert_eq!(control(&T, &mut short, Some(&reading), MID + 6 * 60_000, 900, true), 300); - assert_eq!(control(&T, &mut long, Some(&reading), MID + 6 * 60_000 + HOUR, 900, true), 300); - assert!((short.integral - long.integral).abs() < 1e-6, "a vote integrates nothing"); - near(control(&T, &mut short, Some(&reading), MID + 7 * 60_000, 900, false), 300); -} - -#[test] -fn rollover_resets_memory() { - let mut s = Fallback::default(); - let before = RESETS - 2 * HOUR; - for i in 0..12 { - let now = before + i * 10 * 60_000; - control(&T, &mut s, Some(&read(100, before)), now, 900, false); - } - assert!(s.integral > 100.0); - let new = Reading { used_permille: 5, observed_at_ms: RESETS + HOUR, resets_at_ms: RESETS + WINDOW_MS }; - // Used fell from 100 to 5 across the reset: no derivative kick, no old integral. - near(control(&T, &mut s, Some(&new), RESETS + HOUR, 900, false), 898); - assert!(s.integral < 2.0); - assert_eq!(s.window_resets_at_ms, RESETS + WINDOW_MS); -} - -#[test] -fn no_reading_holds() { - let mut s = Fallback::default(); - assert_eq!(control(&T, &mut s, None, MID, 900, false), DEFAULT_BEAT_S, "never set"); - let live = read(400, MID); - control(&T, &mut s, Some(&live), MID, 900, false); - let held = control(&T, &mut s, Some(&live), MID + 60_000, 900, false); - assert_ne!(held, DEFAULT_BEAT_S); - let integral = s.integral; - assert_eq!(control(&T, &mut s, None, MID + 2 * 60_000, 900, false), held); - let stale = Reading { observed_at_ms: RESETS - WINDOW_MS - 1, ..live }; - assert_eq!(control(&T, &mut s, Some(&stale), MID + 3 * 60_000, 900, false), held); - let expired = Reading { resets_at_ms: MID + 4 * HOUR, observed_at_ms: MID + 4 * HOUR - WINDOW_MS, ..live }; - assert_eq!(control(&T, &mut s, Some(&expired), MID + 6 * HOUR, 900, false), held); - assert_eq!(s.integral, integral, "nothing integrated while held"); - // The six hours without a reading are not integrated when it comes back. - control(&T, &mut s, Some(&read(400, MID + 6 * HOUR + 60_000)), MID + 6 * HOUR + 60_000, 900, false); - assert!(s.integral - integral < 2.0, "{} then {}", integral, s.integral); -} - -#[test] -fn beat_is_bounded() { - let wild = Gains { kp: 1.0, ki: 0.0, kd: 0.0 }; - let late = RESETS - 12 * HOUR; - assert_eq!(control(&wild, &mut Fallback::default(), Some(&read(0, late)), late, 900, false), FALLBACK_MIN_S); - assert_eq!(control(&wild, &mut Fallback::default(), Some(&read(1000, MID)), MID, 900, false), FALLBACK_MAX_S); - for used in [0, 1, 450, 900, 1000, 5000] { - for target in [0, 900, 1000] { - for gains in [wild, T, GAINS] { - let beat = control(&gains, &mut Fallback::default(), Some(&read(used, MID)), MID, target, false); - assert!((FALLBACK_MIN_S..=FALLBACK_MAX_S).contains(&beat), "{beat}"); - } - } - } -} +//! The tempo rule as tables: what `tally` and `pick` answer for the cases that +//! carry the rulings. Pure, so nothing here needs a mind, a clock or a daemon. + +use super::*; + +fn mind(slug: &str, waited_s: Option) -> Standing { + Standing { + slug: slug.into(), + eligible: true, + waited_s, + last_turn_ms: waited_s.map(|waited| 1_000_000 - i64::try_from(waited).unwrap() * 1000), + self_permille: 0, + topic_permille: 0, + addressed_by_member: false, + } +} + +#[test] +fn the_score_is_the_wait_in_beats_times_ten_to_the_urgency_in_orders() { + let mut standing = mind("a", Some(1800)); + assert_eq!(score(&standing, 900), 2.0, "two beats waited, no urgency"); + assert_eq!(score(&standing, 600), 3.0); + standing.self_permille = 1000; + assert_eq!(score(&standing, 900), 20.0, "full urgency is worth one order of magnitude"); + standing.self_permille = 500; + assert!((score(&standing, 900) - 2.0 * 10f64.sqrt()).abs() < 1e-9); + assert_eq!(score(&mind("never", None), 900), f64::INFINITY); +} + +#[test] +fn no_votes_is_the_default_beat() { + assert_eq!(tally(&[]), DEFAULT_BEAT_S); + assert_eq!(tally(&[None, None]), DEFAULT_BEAT_S); + assert_eq!(DEFAULT_BEAT_S, 900); +} + +#[test] +fn the_tally_is_the_lower_median_and_one_outlier_cannot_move_it() { + let table: [(&[u32], u32); 7] = [ + (&[600], 600), + (&[300, 900], 300), + (&[900, 300, 600], 600), + (&[100, 300, 600, 900], 300), + (&[600, 600, 86_400], 600), + (&[60, 600, 600], 600), + (&[60, 60, 86_400, 86_400, 600], 600), + ]; + for (votes, want) in table { + let votes: Vec> = votes.iter().copied().map(Some).collect(); + assert_eq!(tally(&votes), want, "{votes:?}"); + } +} + +#[test] +fn silence_is_abstention_and_only_cast_votes_are_counted() { + let mut eight = vec![None; 8]; + eight[0] = Some(60); + assert_eq!(tally(&eight), 60, "one voter among eight silent Minds decides"); + assert_eq!(tally(&[None; 8]), DEFAULT_BEAT_S, "none cast is the default"); + assert_eq!(tally(&[Some(3600), None, Some(60)]), 60, "two cast: the lower median"); + assert_eq!(tally(&[Some(3600), Some(1800), None, None, Some(60)]), 1800, "abstainers do not dilute the median"); +} + +#[test] +fn the_tally_does_not_depend_on_the_order_the_votes_came_in() { + assert_eq!(tally(&[Some(900), Some(60), None, Some(600)]), tally(&[None, Some(60), Some(600), Some(900)])); +} + +#[test] +fn waiting_alone_round_robins() { + let rows = [ + mind("ann", Some(100)), + mind("bob", Some(400)), + mind("cy", Some(250)), + ]; + assert_eq!( + pick(&rows, 100), + Some(1), + "the longest waiter takes the beat" + ); + // After bob's turn he has waited least; the next longest waiter goes. + let after = [ + mind("ann", Some(200)), + mind("bob", Some(0)), + mind("cy", Some(350)), + ]; + assert_eq!(pick(&after, 100), Some(2)); +} + +#[test] +fn a_full_urgency_equals_ten_times_the_wait_at_one_order() { + assert_eq!(LOG_BOUND_ORDERS, 1.0); + let steady = mind("ann", Some(100)); + let mut urgent = mind("bob", Some(11)); + urgent.self_permille = 1000; + assert_eq!( + pick(&[steady.clone(), urgent.clone()], 100), + Some(1), + "eleven seconds at full urgency beat a hundred waiting" + ); + urgent.waited_s = Some(9); + urgent.last_turn_ms = Some(1_000_000 - 9000); + assert_eq!(pick(&[steady, urgent], 100), Some(0), "nine do not"); +} + +#[test] +fn a_half_urgency_is_the_square_root_of_ten() { + let mut half = mind("ann", Some(100)); + half.self_permille = 500; + let plain = mind("bob", Some(100)); + let ratio = score(&half, 100) / score(&plain, 100); + assert!((ratio - 10f64.sqrt()).abs() < 1e-9, "{ratio}"); +} + +#[test] +fn urgency_is_the_strongest_of_self_topic_and_a_member_addressing_her() { + let mut row = mind("ann", Some(100)); + assert_eq!(urgency_permille(&row), 0); + row.self_permille = 300; + row.topic_permille = 700; + assert_eq!(urgency_permille(&row), 700); + row.addressed_by_member = true; + assert_eq!(urgency_permille(&row), 1000); + row.addressed_by_member = false; + row.topic_permille = 0; + row.self_permille = 5000; + assert_eq!( + urgency_permille(&row), + 1000, + "an urgency past the scale is the scale" + ); +} + +#[test] +fn a_tie_goes_to_the_oldest_last_turn_then_to_the_slug() { + let mut old = mind("zed", Some(100)); + old.last_turn_ms = Some(10); + let mut new = mind("ann", Some(100)); + new.last_turn_ms = Some(20); + assert_eq!( + pick(&[new.clone(), old.clone()], 100), + Some(1), + "the older last turn wins the tie, whatever the slug" + ); + new.last_turn_ms = Some(10); + assert_eq!( + pick(&[old, new], 100), + Some(1), + "equal turns: the lower slug" + ); +} + +#[test] +fn a_mind_that_never_acted_goes_first_and_the_never_acted_tie_by_slug() { + let rows = [ + mind("ann", Some(86_400)), + mind("cy", None), + mind("bob", None), + ]; + assert_eq!(pick(&rows, 900), Some(2)); +} + +#[test] +fn a_braked_or_busy_mind_is_never_picked_however_high_she_scores() { + let mut rows = [mind("ann", Some(10)), mind("bob", Some(100_000))]; + rows[1].eligible = false; + rows[1].self_permille = 1000; + assert_eq!(pick(&rows, 100), Some(0)); + rows[0].eligible = false; + assert_eq!(pick(&rows, 100), None, "nobody eligible, nobody picked"); + assert_eq!(pick(&[], 100), None); +} + +#[test] +fn a_longer_beat_does_not_change_who_wins_among_equal_urgency() { + let rows = [mind("ann", Some(500)), mind("bob", Some(300))]; + assert_eq!(pick(&rows, 60), pick(&rows, 86_400)); +} + +// The fallback controller. `control` is the one controller; these tests give it +// explicit gains so they pin each term whatever `GAINS` the simulation chose. + +const RESETS: i64 = 1_000_000_000_000; +const MID: i64 = RESETS - WINDOW_MS / 2; +const HOUR: i64 = 3_600_000; +const T: Gains = Gains { kp: 0.02, ki: 0.002, kd: 0.02 }; + +fn read(used_permille: u32, observed_at_ms: i64) -> Reading { + Reading { used_permille, observed_at_ms, resets_at_ms: RESETS } +} + +fn near(got: u32, want: u32) { + assert!(got.abs_diff(want) <= 1, "{got} is not within one second of {want}"); +} + +#[test] +fn behind_target_shortens_the_beat() { + let at = |used| control(&T, &mut Fallback::default(), Some(&read(used, MID)), MID, 900, false); + // Halfway through the window the trajectory is 450: error +50, 0, -50 is a + // turn rate of 5, 4 and 3 an hour. + near(at(400), 720); + near(at(450), DEFAULT_BEAT_S); + near(at(500), 1200); +} + +#[test] +fn derivative_is_on_measurement() { + let g = Gains { ki: 0.0, ..T }; + let mut s = Fallback::default(); + near(control(&g, &mut s, Some(&read(410, MID)), MID, 900, false), 750); + // A new observation 10 permille higher a quarter hour on: the slope is 40 an hour. + let next = read(420, MID + 15 * 60_000); + near(control(&g, &mut s, Some(&next), MID + 15 * 60_000, 900, false), 941); + // The same reading again carries no derivative term. + near(control(&g, &mut s, Some(&next), MID + 16 * 60_000, 900, false), 778); + // A step in the target with used unchanged moves the beat by kp x error alone. + near(control(&g, &mut s, Some(&next), MID + 18 * 60_000, 1000, false), 639); +} + +#[test] +fn saturated_output_does_not_wind_up() { + let g = Gains { kp: 0.1, ki: 0.01, kd: 0.0 }; + let day5 = RESETS - 2 * 24 * HOUR; + let mut s = Fallback::default(); + let behind = read(0, day5); + for i in 0..100 { + assert_eq!(control(&g, &mut s, Some(&behind), day5 + i * 60_000, 900, false), FALLBACK_MIN_S); + } + assert_eq!(s.integral, 0.0, "error is positive and the output is at its limit"); + let near_on = read(600, day5 + 100 * 60_000); + for i in 100..110 { + control(&g, &mut s, Some(&near_on), day5 + i * 60_000, 900, false); + } + assert!(s.integral > 0.0, "unsaturated again, it integrates"); + + // Saturated at the slow limit with the error pulling the other way integrates. + let mut s = Fallback { integral: -1000.0, ..Fallback::default() }; + let reading = read(400, MID); + control(&g, &mut s, Some(&reading), MID, 900, false); + control(&g, &mut s, Some(&reading), MID + HOUR, 900, false); + assert!(s.integral > -1000.0); +} + +#[test] +fn vote_freezes_and_release_is_bumpless() { + let reading = read(300, MID); + let mut s = Fallback::default(); + for i in 0..5 { + control(&T, &mut s, Some(&reading), MID + i * 60_000, 900, false); + } + s.rate_per_h = 3600.0 / 300.0; + let (mut short, mut long) = (s.clone(), s.clone()); + let (t_short, t_long) = (MID + 6 * 60_000, MID + 6 * 60_000 + HOUR); + assert_eq!(control(&T, &mut short, Some(&reading), t_short, 900, true), 300); + assert_eq!(control(&T, &mut long, Some(&reading), t_long, 900, true), 300); + // However long the vote held, the first beat after it is the voted beat. + near(control(&T, &mut short, Some(&reading), t_short + 60_000, 900, false), 300); + near(control(&T, &mut long, Some(&reading), t_long + 60_000, 900, false), 300); +} + +#[test] +fn rollover_resets_memory() { + let mut s = Fallback::default(); + let before = RESETS - 2 * HOUR; + for i in 0..12 { + let now = before + i * 10 * 60_000; + control(&T, &mut s, Some(&read(100, before)), now, 900, false); + } + assert!(s.integral > 100.0); + let new = Reading { used_permille: 5, observed_at_ms: RESETS + HOUR, resets_at_ms: RESETS + WINDOW_MS }; + // Used fell from 100 to 5 across the reset: no derivative kick, no old integral. + near(control(&T, &mut s, Some(&new), RESETS + HOUR, 900, false), 898); + assert!(s.integral < 2.0); + assert_eq!(s.window_resets_at_ms, RESETS + WINDOW_MS); +} + +#[test] +fn no_reading_holds() { + let mut s = Fallback::default(); + assert_eq!(control(&T, &mut s, None, MID, 900, false), DEFAULT_BEAT_S, "never set"); + let live = read(400, MID); + control(&T, &mut s, Some(&live), MID, 900, false); + let held = control(&T, &mut s, Some(&live), MID + 60_000, 900, false); + assert_ne!(held, DEFAULT_BEAT_S); + let integral = s.integral; + assert_eq!(control(&T, &mut s, None, MID + 2 * 60_000, 900, false), held); + let stale = Reading { observed_at_ms: RESETS - WINDOW_MS - 1, ..live }; + assert_eq!(control(&T, &mut s, Some(&stale), MID + 3 * 60_000, 900, false), held); + let expired = Reading { resets_at_ms: MID + 4 * HOUR, observed_at_ms: MID + 4 * HOUR - WINDOW_MS, ..live }; + assert_eq!(control(&T, &mut s, Some(&expired), MID + 6 * HOUR, 900, false), held); + assert_eq!(s.integral, integral, "nothing integrated while held"); + // The six hours without a reading are not integrated when it comes back. + control(&T, &mut s, Some(&read(400, MID + 6 * HOUR + 60_000)), MID + 6 * HOUR + 60_000, 900, false); + assert!(s.integral - integral < 2.0, "{} then {}", integral, s.integral); +} + +#[test] +fn beat_is_bounded() { + let wild = Gains { kp: 1.0, ki: 0.0, kd: 0.0 }; + let late = RESETS - 12 * HOUR; + assert_eq!(control(&wild, &mut Fallback::default(), Some(&read(0, late)), late, 900, false), FALLBACK_MIN_S); + assert_eq!(control(&wild, &mut Fallback::default(), Some(&read(1000, MID)), MID, 900, false), FALLBACK_MAX_S); + for used in [0, 1, 450, 900, 1000, 5000] { + for target in [0, 900, 1000] { + for gains in [wild, T, GAINS] { + let beat = control(&gains, &mut Fallback::default(), Some(&read(used, MID)), MID, target, false); + assert!((FALLBACK_MIN_S..=FALLBACK_MAX_S).contains(&beat), "{beat}"); + } + } + } +}