From a40be6201bf3ea97a9035eb0664e151d127fe3f1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:46:10 +0100 Subject: [PATCH] tempo: rustfmt the new code, LF endings as the base has them Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/tempo.rs | 479 +++++++++--------- crates/mind-body/src/tempo/sim.rs | 659 +++++++++++++----------- crates/mind-body/src/tempo/tests.rs | 749 ++++++++++++++++------------ 3 files changed, 1049 insertions(+), 838 deletions(-) diff --git a/crates/mind-body/src/tempo.rs b/crates/mind-body/src/tempo.rs index d13feaa..3e44b74 100644 --- a/crates/mind-body/src/tempo.rs +++ b/crates/mind-body/src/tempo.rs @@ -1,231 +1,248 @@ -//! The tempo rule of the shared heartbeat, pure: no clock, no mind, no I/O -//! (rulings `eureka-body:ruling:shared-heartbeat-joint-lever`, -//! `self-declared-urgency-etiquette`, `swarm-shares-log-bound`). -//! -//! The Minds jointly hold the lever to one heartbeat. `tally` turns their votes -//! into the beat; `pick` chooses who takes the turn when a beat falls. The -//! heartbeat owns when a beat falls and what it launches; this module owns only -//! the decisions: the swarm's tempo, who takes the turn, and the third, the -//! beat when no vote is cast (`fallback_beat`, rulings -//! `tempo-default-is-usage-fitter`, `tempo-fallback-pid-tuned-by-sim`). - -use std::cmp::Ordering; - -/// The beat, in seconds, when no vote is cast. -pub const DEFAULT_BEAT_S: u32 = 900; - -/// The shortest and longest beat the fallback controller may answer, in seconds. -pub const FALLBACK_MIN_S: u32 = 60; -pub const FALLBACK_MAX_S: u32 = 3600; - -const WINDOW_MS: i64 = 7 * 24 * 3_600_000; -const HOUR_MS: f64 = 3_600_000.0; -/// The turn rate, in beats per hour, that `DEFAULT_BEAT_S` stands for: the -/// controller's bias. -const BIAS_PER_H: f64 = 3600.0 / DEFAULT_BEAT_S as f64; - -/// The seven-day plan window as the connector last read it. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Reading { - pub used_permille: u32, - pub observed_at_ms: i64, - pub resets_at_ms: i64, -} - -/// PID gains. The actuator is the turn rate in beats per hour and the error is -/// in permille of the plan, so `kp` is beats/h per permille, `ki` beats/h per -/// permille-hour and `kd` beats/h per (permille/h). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Gains { - pub kp: f64, - pub ki: f64, - pub kd: f64, -} - -/// Chosen by the committed simulation (`docs/research/2026-11-01-tempo-pid-sim.md`), -/// never by a run-time search. -pub const GAINS: Gains = Gains { kp: 0.02, ki: 0.01, kd: 0.0 }; - -/// The fallback controller's memory. The heartbeat owns persisting it; it is -/// never published as truth. `rate_per_h` is the last output as a turn rate -/// (zero: none yet); while a vote decides the caller sets it to the voted beat's -/// rate, so the release is bumpless. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Fallback { - pub integral: f64, - pub last_used_permille: u32, - pub last_observed_at_ms: i64, - pub last_now_ms: i64, - pub window_resets_at_ms: i64, - pub rate_per_h: f64, -} - -/// The beat when no vote is cast: a PID on the gap between the seven-day usage -/// trajectory to `target_permille` and what was used, acting on the turn rate -/// (behind the trajectory raises it). Derivative on the measurement, only -/// between distinct observations; conditional integration against the beat -/// bounds; while `voted` nothing integrates and the integral tracks the voted -/// rate, so release is bumpless. A new window clears the memory; with no -/// usable reading the last beat holds (`DEFAULT_BEAT_S` before any). -pub fn fallback_beat( - state: &mut Fallback, - reading: Option<&Reading>, - now_ms: i64, - target_permille: u32, - voted: bool, -) -> u32 { - control(&GAINS, state, reading, now_ms, target_permille, voted) -} - -fn beat_of(rate_per_h: f64) -> u32 { - if rate_per_h > 0.0 { - ((3600.0 / rate_per_h).round().clamp(f64::from(FALLBACK_MIN_S), f64::from(FALLBACK_MAX_S))) as u32 - } else { - FALLBACK_MAX_S - } -} - -fn held(state: &Fallback) -> u32 { - if state.rate_per_h == 0.0 { - DEFAULT_BEAT_S - } else { - beat_of(state.rate_per_h) - } -} - -/// The one controller; `fallback_beat` is it with `GAINS`, the simulation is it -/// with candidates. -fn control( - gains: &Gains, - state: &mut Fallback, - reading: Option<&Reading>, - now_ms: i64, - target_permille: u32, - voted: bool, -) -> u32 { - let dt_h = if state.last_now_ms > 0 { (now_ms - state.last_now_ms).max(0) as f64 / HOUR_MS } else { 0.0 }; - state.last_now_ms = now_ms; - let Some(r) = reading.filter(|r| now_ms < r.resets_at_ms && r.observed_at_ms >= r.resets_at_ms - WINDOW_MS) else { - return held(state); - }; - if r.resets_at_ms != state.window_resets_at_ms { - state.window_resets_at_ms = r.resets_at_ms; - state.integral = 0.0; - state.last_used_permille = r.used_permille; - state.last_observed_at_ms = r.observed_at_ms; - } - let elapsed = (now_ms - (r.resets_at_ms - WINDOW_MS)).clamp(0, WINDOW_MS) as f64; - let error = f64::from(target_permille) * elapsed / WINDOW_MS as f64 - f64::from(r.used_permille); - let fresh = r.observed_at_ms > state.last_observed_at_ms; - let slope = if fresh { - (f64::from(r.used_permille) - f64::from(state.last_used_permille)) - / ((r.observed_at_ms - state.last_observed_at_ms) as f64 / HOUR_MS) - } else { - 0.0 - }; - if fresh { - state.last_used_permille = r.used_permille; - state.last_observed_at_ms = r.observed_at_ms; - } - if voted { - if gains.ki != 0.0 && state.rate_per_h > 0.0 { - state.integral = (state.rate_per_h - BIAS_PER_H - gains.kp * error) / gains.ki; - } - return held(state); - } - let rate = |integral: f64| BIAS_PER_H + gains.kp * error + gains.ki * integral - gains.kd * slope; - let (low, high) = (3600.0 / f64::from(FALLBACK_MAX_S), 3600.0 / f64::from(FALLBACK_MIN_S)); - let before = rate(state.integral); - let winding = (before >= high && error > 0.0) || (before <= low && error < 0.0); - if !winding { - state.integral += error * dt_h; - } - let beat = beat_of(rate(state.integral)); - state.rate_per_h = 3600.0 / f64::from(beat); - beat -} - -/// How many orders of magnitude a full urgency may multiply a Mind's wait by. -/// Interim, per `swarm-shares-log-bound`, until the swarm's governance sets it. -pub const LOG_BOUND_ORDERS: f64 = 1.0; - -/// The swarm's tempo (ruling `eureka-body:ruling:tempo-silence-is-abstention`): -/// the lower median of the votes actually cast, each a Mind's newest standing -/// vote in seconds per beat. Silence is abstention: a Mind with no standing vote -/// (never voted, or withdrew) or whose mind cannot be read is `None` and is not -/// counted, so the beat follows those who care to vote. With no vote cast it is -/// `DEFAULT_BEAT_S`. On one voter that voter decides; on two the lower of them. -pub fn tally(votes: &[Option]) -> u32 { - let mut cast: Vec = votes.iter().flatten().copied().collect(); - cast.sort_unstable(); - match cast.len() { - 0 => DEFAULT_BEAT_S, - n => cast[(n - 1) / 2], - } -} - -/// What the pick sees of one Mind. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Standing { - pub slug: String, - /// Her brake is released and she is not in a turn. - pub eligible: bool, - /// Seconds since her last Persona turn; none when she has never had one. - pub waited_s: Option, - /// When her last Persona turn began, in unix milliseconds; none when never. - pub last_turn_ms: Option, - /// The urgency she declared for herself and no turn has consumed, in thousandths. - pub self_permille: u16, - /// The urgency her subscriptions give her pending deliveries, in thousandths. - pub topic_permille: u16, - /// A member (not the operator) has a pending message that names or tags her. - pub addressed_by_member: bool, -} - -/// The urgency in force for a Mind, in thousandths: the strongest of what she -/// declared, what her topics say and a member addressing her (which is 1000). -pub fn urgency_permille(standing: &Standing) -> u16 { - let addressed = if standing.addressed_by_member { - 1000 - } else { - 0 - }; - standing - .self_permille - .max(standing.topic_permille) - .max(addressed) - .min(1000) -} - -/// `(waited / beat) x 10^(LOG_BOUND_ORDERS x urgency / 1000)`. A Mind that has -/// never had a turn has waited forever: infinite, so she is first, and the tie -/// rule orders the never-acted among themselves. -pub fn score(standing: &Standing, beat_s: u32) -> f64 { - let waited = standing - .waited_s - .map_or(f64::INFINITY, |waited| waited as f64); - let exponent = LOG_BOUND_ORDERS * f64::from(urgency_permille(standing)) / 1000.0; - waited / f64::from(beat_s.max(1)) * 10f64.powf(exponent) -} - -/// The winner of a beat, as an index into `rows`: the highest score among the -/// eligible; a tie goes to the oldest last turn (never is oldest), then to the -/// lowest slug. None when nobody is eligible. A braked or busy Mind is never picked. -pub fn pick(rows: &[Standing], beat_s: u32) -> Option { - let order = |a: &(usize, &Standing), b: &(usize, &Standing)| -> Ordering { - score(b.1, beat_s) - .total_cmp(&score(a.1, beat_s)) - .then_with(|| a.1.last_turn_ms.cmp(&b.1.last_turn_ms)) - .then_with(|| a.1.slug.cmp(&b.1.slug)) - }; - rows.iter() - .enumerate() - .filter(|(_, row)| row.eligible) - .min_by(order) - .map(|(index, _)| index) -} - -#[cfg(test)] -mod sim; -#[cfg(test)] -mod tests; +//! The tempo rule of the shared heartbeat, pure: no clock, no mind, no I/O +//! (rulings `eureka-body:ruling:shared-heartbeat-joint-lever`, +//! `self-declared-urgency-etiquette`, `swarm-shares-log-bound`). +//! +//! The Minds jointly hold the lever to one heartbeat. `tally` turns their votes +//! into the beat; `pick` chooses who takes the turn when a beat falls. The +//! heartbeat owns when a beat falls and what it launches; this module owns only +//! the decisions: the swarm's tempo, who takes the turn, and the third, the +//! beat when no vote is cast (`fallback_beat`, rulings +//! `tempo-default-is-usage-fitter`, `tempo-fallback-pid-tuned-by-sim`). + +use std::cmp::Ordering; + +/// The beat, in seconds, when no vote is cast. +pub const DEFAULT_BEAT_S: u32 = 900; + +/// The shortest and longest beat the fallback controller may answer, in seconds. +pub const FALLBACK_MIN_S: u32 = 60; +pub const FALLBACK_MAX_S: u32 = 3600; + +const WINDOW_MS: i64 = 7 * 24 * 3_600_000; +const HOUR_MS: f64 = 3_600_000.0; +/// The turn rate, in beats per hour, that `DEFAULT_BEAT_S` stands for: the +/// controller's bias. +const BIAS_PER_H: f64 = 3600.0 / DEFAULT_BEAT_S as f64; + +/// The seven-day plan window as the connector last read it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reading { + pub used_permille: u32, + pub observed_at_ms: i64, + pub resets_at_ms: i64, +} + +/// PID gains. The actuator is the turn rate in beats per hour and the error is +/// in permille of the plan, so `kp` is beats/h per permille, `ki` beats/h per +/// permille-hour and `kd` beats/h per (permille/h). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Gains { + pub kp: f64, + pub ki: f64, + pub kd: f64, +} + +/// Chosen by the committed simulation (`docs/research/2026-11-01-tempo-pid-sim.md`), +/// never by a run-time search. +pub const GAINS: Gains = Gains { + kp: 0.02, + ki: 0.01, + kd: 0.0, +}; + +/// The fallback controller's memory. The heartbeat owns persisting it; it is +/// never published as truth. `rate_per_h` is the last output as a turn rate +/// (zero: none yet); while a vote decides the caller sets it to the voted beat's +/// rate, so the release is bumpless. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Fallback { + pub integral: f64, + pub last_used_permille: u32, + pub last_observed_at_ms: i64, + pub last_now_ms: i64, + pub window_resets_at_ms: i64, + pub rate_per_h: f64, +} + +/// The beat when no vote is cast: a PID on the gap between the seven-day usage +/// trajectory to `target_permille` and what was used, acting on the turn rate +/// (behind the trajectory raises it). Derivative on the measurement, only +/// between distinct observations; conditional integration against the beat +/// bounds; while `voted` nothing integrates and the integral tracks the voted +/// rate, so release is bumpless. A new window clears the memory; with no +/// usable reading the last beat holds (`DEFAULT_BEAT_S` before any). +pub fn fallback_beat( + state: &mut Fallback, + reading: Option<&Reading>, + now_ms: i64, + target_permille: u32, + voted: bool, +) -> u32 { + control(&GAINS, state, reading, now_ms, target_permille, voted) +} + +fn beat_of(rate_per_h: f64) -> u32 { + if rate_per_h > 0.0 { + ((3600.0 / rate_per_h) + .round() + .clamp(f64::from(FALLBACK_MIN_S), f64::from(FALLBACK_MAX_S))) as u32 + } else { + FALLBACK_MAX_S + } +} + +fn held(state: &Fallback) -> u32 { + if state.rate_per_h == 0.0 { + DEFAULT_BEAT_S + } else { + beat_of(state.rate_per_h) + } +} + +/// The one controller; `fallback_beat` is it with `GAINS`, the simulation is it +/// with candidates. +fn control( + gains: &Gains, + state: &mut Fallback, + reading: Option<&Reading>, + now_ms: i64, + target_permille: u32, + voted: bool, +) -> u32 { + let dt_h = if state.last_now_ms > 0 { + (now_ms - state.last_now_ms).max(0) as f64 / HOUR_MS + } else { + 0.0 + }; + state.last_now_ms = now_ms; + let Some(r) = reading + .filter(|r| now_ms < r.resets_at_ms && r.observed_at_ms >= r.resets_at_ms - WINDOW_MS) + else { + return held(state); + }; + if r.resets_at_ms != state.window_resets_at_ms { + state.window_resets_at_ms = r.resets_at_ms; + state.integral = 0.0; + state.last_used_permille = r.used_permille; + state.last_observed_at_ms = r.observed_at_ms; + } + let elapsed = (now_ms - (r.resets_at_ms - WINDOW_MS)).clamp(0, WINDOW_MS) as f64; + let error = + f64::from(target_permille) * elapsed / WINDOW_MS as f64 - f64::from(r.used_permille); + let fresh = r.observed_at_ms > state.last_observed_at_ms; + let slope = if fresh { + (f64::from(r.used_permille) - f64::from(state.last_used_permille)) + / ((r.observed_at_ms - state.last_observed_at_ms) as f64 / HOUR_MS) + } else { + 0.0 + }; + if fresh { + state.last_used_permille = r.used_permille; + state.last_observed_at_ms = r.observed_at_ms; + } + if voted { + if gains.ki != 0.0 && state.rate_per_h > 0.0 { + state.integral = (state.rate_per_h - BIAS_PER_H - gains.kp * error) / gains.ki; + } + return held(state); + } + let rate = + |integral: f64| BIAS_PER_H + gains.kp * error + gains.ki * integral - gains.kd * slope; + let (low, high) = ( + 3600.0 / f64::from(FALLBACK_MAX_S), + 3600.0 / f64::from(FALLBACK_MIN_S), + ); + let before = rate(state.integral); + let winding = (before >= high && error > 0.0) || (before <= low && error < 0.0); + if !winding { + state.integral += error * dt_h; + } + let beat = beat_of(rate(state.integral)); + state.rate_per_h = 3600.0 / f64::from(beat); + beat +} + +/// How many orders of magnitude a full urgency may multiply a Mind's wait by. +/// Interim, per `swarm-shares-log-bound`, until the swarm's governance sets it. +pub const LOG_BOUND_ORDERS: f64 = 1.0; + +/// The swarm's tempo (ruling `eureka-body:ruling:tempo-silence-is-abstention`): +/// the lower median of the votes actually cast, each a Mind's newest standing +/// vote in seconds per beat. Silence is abstention: a Mind with no standing vote +/// (never voted, or withdrew) or whose mind cannot be read is `None` and is not +/// counted, so the beat follows those who care to vote. With no vote cast it is +/// `DEFAULT_BEAT_S`. On one voter that voter decides; on two the lower of them. +pub fn tally(votes: &[Option]) -> u32 { + let mut cast: Vec = votes.iter().flatten().copied().collect(); + cast.sort_unstable(); + match cast.len() { + 0 => DEFAULT_BEAT_S, + n => cast[(n - 1) / 2], + } +} + +/// What the pick sees of one Mind. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Standing { + pub slug: String, + /// Her brake is released and she is not in a turn. + pub eligible: bool, + /// Seconds since her last Persona turn; none when she has never had one. + pub waited_s: Option, + /// When her last Persona turn began, in unix milliseconds; none when never. + pub last_turn_ms: Option, + /// The urgency she declared for herself and no turn has consumed, in thousandths. + pub self_permille: u16, + /// The urgency her subscriptions give her pending deliveries, in thousandths. + pub topic_permille: u16, + /// A member (not the operator) has a pending message that names or tags her. + pub addressed_by_member: bool, +} + +/// The urgency in force for a Mind, in thousandths: the strongest of what she +/// declared, what her topics say and a member addressing her (which is 1000). +pub fn urgency_permille(standing: &Standing) -> u16 { + let addressed = if standing.addressed_by_member { + 1000 + } else { + 0 + }; + standing + .self_permille + .max(standing.topic_permille) + .max(addressed) + .min(1000) +} + +/// `(waited / beat) x 10^(LOG_BOUND_ORDERS x urgency / 1000)`. A Mind that has +/// never had a turn has waited forever: infinite, so she is first, and the tie +/// rule orders the never-acted among themselves. +pub fn score(standing: &Standing, beat_s: u32) -> f64 { + let waited = standing + .waited_s + .map_or(f64::INFINITY, |waited| waited as f64); + let exponent = LOG_BOUND_ORDERS * f64::from(urgency_permille(standing)) / 1000.0; + waited / f64::from(beat_s.max(1)) * 10f64.powf(exponent) +} + +/// The winner of a beat, as an index into `rows`: the highest score among the +/// eligible; a tie goes to the oldest last turn (never is oldest), then to the +/// lowest slug. None when nobody is eligible. A braked or busy Mind is never picked. +pub fn pick(rows: &[Standing], beat_s: u32) -> Option { + let order = |a: &(usize, &Standing), b: &(usize, &Standing)| -> Ordering { + score(b.1, beat_s) + .total_cmp(&score(a.1, beat_s)) + .then_with(|| a.1.last_turn_ms.cmp(&b.1.last_turn_ms)) + .then_with(|| a.1.slug.cmp(&b.1.slug)) + }; + rows.iter() + .enumerate() + .filter(|(_, row)| row.eligible) + .min_by(order) + .map(|(index, _)| index) +} + +#[cfg(test)] +mod sim; +#[cfg(test)] +mod tests; diff --git a/crates/mind-body/src/tempo/sim.rs b/crates/mind-body/src/tempo/sim.rs index 000cd2a..d92b1a8 100644 --- a/crates/mind-body/src/tempo/sim.rs +++ b/crates/mind-body/src/tempo/sim.rs @@ -1,296 +1,363 @@ -//! 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, - /// Share of the last day of the last window spent at the minimum beat. - 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 at_min, mut last_day) = (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 - && 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() { - if window + 1 == scenario.windows && in_window >= 144.0 { - last_day += 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(last_day.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 = 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. -const LAST_DAY_ERROR_BOUND: f64 = 75.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() <= FINAL_TOLERANCE, "final {tag}"); - } - assert!(o.worst_error_last_day <= LAST_DAY_ERROR_BOUND, "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.01, 0.02, 0.03, 0.05, 0.1] { - for ki in [0.002, 0.005, 0.01, 0.02, 0.04] { - for kd in [0.0, 0.002, 0.005, 0.01, 0.02, 0.05] { - 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(4) { - 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, + /// Share of the last day of the last window spent at the minimum beat. + 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 at_min, mut last_day) = (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 + && 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() { + if window + 1 == scenario.windows && in_window >= 144.0 { + last_day += 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(last_day.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 = 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. +const LAST_DAY_ERROR_BOUND: f64 = 75.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() <= FINAL_TOLERANCE, + "final {tag}" + ); + } + assert!( + o.worst_error_last_day <= LAST_DAY_ERROR_BOUND, + "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.01, 0.02, 0.03, 0.05, 0.1] { + for ki in [0.002, 0.005, 0.01, 0.02, 0.04] { + for kd in [0.0, 0.002, 0.005, 0.01, 0.02, 0.05] { + 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(4) { + 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 eb1188f..8222eca 100644 --- a/crates/mind-body/src/tempo/tests.rs +++ b/crates/mind-body/src/tempo/tests.rs @@ -1,311 +1,438 @@ -//! 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}"); - } - } - } -} +//! 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}"); + } + } + } +}