tempo: rustfmt the new code, LF endings as the base has them
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
94b707fab1
commit
a40be6201b
3 changed files with 1049 additions and 838 deletions
|
|
@ -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>]) -> u32 {
|
||||
let mut cast: Vec<u32> = 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<u64>,
|
||||
/// When her last Persona turn began, in unix milliseconds; none when never.
|
||||
pub last_turn_ms: Option<i64>,
|
||||
/// 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<usize> {
|
||||
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>]) -> u32 {
|
||||
let mut cast: Vec<u32> = 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<u64>,
|
||||
/// When her last Persona turn began, in unix milliseconds; none when never.
|
||||
pub last_turn_ms: Option<i64>,
|
||||
/// 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<usize> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<Scenario> {
|
||||
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<f64>,
|
||||
/// 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<Scenario> {
|
||||
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<f64>,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u64>) -> 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<Option<u32>> = 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<u64>) -> 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<Option<u32>> = 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue