Merge eureka-body/tempo-pid-sim

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-02 22:54:48 +01:00
commit 3177b33adf
5 changed files with 1710 additions and 7 deletions

View file

@ -5,13 +5,213 @@
//! 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 two decisions.
//! 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;
/// A reading observed longer ago than this is no evidence of where usage is now:
/// twice the longest beat, since every turn refreshes it.
const STALE_AFTER_MS: i64 = 2 * FALLBACK_MAX_S as i64 * 1000;
/// Two `resets_at` values this close name one window: the connector reports
/// whole unix seconds, and a new window starts a week on.
const RESETS_JITTER_MS: u64 = 60_000;
/// How fast, in hours, the controller takes the wheel back from a released
/// vote: the sensor's own poll period, since the vote's effect cannot show in a
/// reading sooner.
const CARRY_FADE_H: f64 = 0.25;
/// The longest stretch of time one call may integrate: the longest beat, the
/// longest wait between two calls of a running heartbeat. A longer silence (a
/// restart, an outage) is not integrated as if the swarm had run through it.
const MAX_STEP_MS: i64 = FALLBACK_MAX_S as i64 * 1000;
/// 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, and the controller turns the gap between that and its own answer into
/// `carry_per_h`, which fades after release, over the sensor's poll period
/// whatever the vote's length.
#[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 carry_per_h: f64,
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: it does not
/// integrate error in the direction of a saturated actuator. `saturated` is the
/// caller's one report that a shorter beat cannot buy more spend: the last beat
/// was the floor, or launched nothing because every eligible Mind was busy
/// (ruling `tempo-controller-knows-the-ceiling`); the slow limit needs no report,
/// since the integral's own answer at it is the controller's. One call
/// integrates at most `MAX_STEP_MS` of time.
/// While `voted` nothing integrates and the controller notes how far the voted
/// beat (held inside the beat bounds) is from its own answer, so release starts
/// at the voted beat, to within the one integration step of the first free call,
/// and the controller takes the wheel back within a poll period or two. The
/// carry fades on that period whatever the vote's length, so a vote of a minute
/// costs the extra turns of a poll period or two of fast beat. A new window clears the integral and the derivative memory;
/// with no usable reading (none, an expired window, or one observed more than
/// `STALE_AFTER_MS` ago, which a connector replaying its last answer keeps
/// producing) 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,
saturated: bool,
) -> u32 {
control(
&GAINS,
state,
reading,
now_ms,
target_permille,
voted,
saturated,
)
}
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,
saturated: bool,
) -> u32 {
let dt_h = if state.last_now_ms > 0 {
(now_ms - state.last_now_ms).clamp(0, MAX_STEP_MS) 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
&& now_ms - r.observed_at_ms <= STALE_AFTER_MS
}) else {
return held(state);
};
let rolled = r.resets_at_ms.abs_diff(state.window_resets_at_ms) > RESETS_JITTER_MS;
if rolled {
state.integral = 0.0;
state.last_used_permille = r.used_permille;
state.last_observed_at_ms = r.observed_at_ms;
}
state.window_resets_at_ms = r.resets_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;
}
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),
);
if voted {
// Nothing integrates. The carry is the gap between the voted rate (held
// inside the beat bounds, which are all the controller can answer) and
// what the controller would answer now, so release starts at the voted
// beat; it fades once the vote is gone.
state.carry_per_h = if state.rate_per_h > 0.0 {
state.rate_per_h.clamp(low, high) - rate(state.integral)
} else {
0.0
};
return held(state);
}
// The slow limit is judged on the integral's own answer: the carry is a
// transient that fades and must not decide what the integral absorbs.
let winding = (saturated && error > 0.0) || (rate(state.integral) <= low && error < 0.0);
if !winding && !rolled {
state.integral += error * dt_h;
}
let beat = beat_of(rate(state.integral) + state.carry_per_h);
state.rate_per_h = 3600.0 / f64::from(beat);
state.carry_per_h *= (-dt_h / CARRY_FADE_H).exp();
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;
@ -92,5 +292,7 @@ pub fn pick(rows: &[Standing], beat_s: u32) -> Option<usize> {
.map(|(index, _)| index)
}
#[cfg(test)]
mod sim;
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,519 @@
//! 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.
//! The swarm has a throughput ceiling: a turn needs a Mind that is not in one,
//! each turn holds its Mind for a lognormal time, and when every Mind is busy
//! the beat launches nothing and the controller is told so.
//! 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;
/// The roster the heartbeat routes (aqua, eureka, heimdall, kiko, libby, nibu,
/// void, weksa: the slugs it launched over 2026-10-30..11-01).
const MINDS: usize = 8;
/// How long a Persona turn holds its Mind, lognormal. Measured on Yggdrasil from
/// the `mind-persona@*` start and stop lines over 29 h (1284 turns): mean 15.4 s,
/// median 6.4 s, p90 27.7 s, p99 186 s; the fit below has median 7.5 s, p90 35 s.
const BUSY_MEAN_S: f64 = 15.4;
const BUSY_SIGMA: f64 = 1.2;
/// The shortest spacing between two on-beat launches is one heartbeat poll
/// (median 30 s over 4100 polls); the sim's step, 60 s, is the beat floor and is
/// longer, so the spacing never binds.
const POLL_S: i64 = 30;
const _: () = assert!(POLL_S * 1000 <= STEP_MS);
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, sigma: 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() - sigma * sigma / 2.0 + 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,
/// The vote itself spends the week: no controller can end on target, it
/// can only stop burning once the vote is gone.
overspent: bool,
/// Minds the heartbeat can launch, and the factor on how long a turn holds one.
minds: usize,
busy_scale: f64,
/// The most band crossings the controller may show here.
max_crossings: u32,
/// The ceiling is below what the target needs for most of the week: no
/// controller ends on target, and the scenario only compares the controller
/// told of the stalls with the one that is not.
stress: 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,
overspent: false,
minds: MINDS,
busy_scale: 1.0,
max_crossings: 2,
stress: 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: "vote-60s-1h",
vote: Some((48.0, 49.0, 60)),
max_crossings: 3,
..BASE
},
Scenario {
name: "vote-60s-4h",
vote: Some((48.0, 52.0, 60)),
..BASE
},
Scenario {
name: "vote-60s-24h",
vote: Some((48.0, 72.0, 60)),
overspent: true,
..BASE
},
Scenario {
name: "vote-7200s-24h",
vote: Some((48.0, 72.0, 7200)),
..BASE
},
Scenario {
name: "vote-86400s-24h",
vote: Some((48.0, 72.0, 86_400)),
..BASE
},
Scenario {
name: "cheap-then-x3",
mean_cost: 0.6,
cost_step: Some((84.0, 3.0)),
max_crossings: 3,
..BASE
},
Scenario {
name: "one-slow-mind",
mean_cost: 0.6,
cost_step: Some((84.0, 3.0)),
minds: 1,
busy_scale: 45.0,
stress: true,
..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 same at the maximum beat.
frac_at_max: f64,
/// The beat on the last minute.
last_beat: u32,
/// Minutes the beat was due and every Mind was busy.
stalls: u32,
}
fn run(scenario: &Scenario, sensor: Sensor, gains: &Gains, seed: u64) -> Outcome {
drive(scenario, sensor, gains, seed, true)
}
/// `report_stalls` false withholds the busy half of the saturation report (the
/// floor half is always given): the controller as it was before it was told.
fn drive(
scenario: &Scenario,
sensor: Sensor,
gains: &Gains,
seed: u64,
report_stalls: bool,
) -> Outcome {
let mut rng = Rng(seed);
let mut busy_rng = Rng(seed ^ 0xB05E_B05E_B05E_B05E);
let mut busy_until = vec![W0; scenario.minds];
// The heartbeat's report: a beat since the last launch found every Mind busy.
let (mut stalled, mut found_busy) = (false, false);
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 at_max, mut last_day) = (0u32, 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, false);
beat
}
None => {
let saturated = prev_beat == FALLBACK_MIN_S || (report_stalls && stalled);
control(
gains,
&mut state,
shown.as_ref(),
now,
TARGET,
false,
saturated,
)
}
};
if vote.is_none() {
if window + 1 == scenario.windows && in_window >= 144.0 {
last_day += 1;
at_min += u32::from(beat == FALLBACK_MIN_S);
at_max += u32::from(beat == FALLBACK_MAX_S);
}
jumps += u32::from(f64::from(beat.abs_diff(prev_beat)) > 0.05 * f64::from(prev_beat));
}
prev_beat = beat;
out.last_beat = beat;
if now - last_turn >= i64::from(beat) * 1000 {
if let Some(mind) = busy_until.iter().position(|&until| until <= now) {
stalled = found_busy;
found_busy = false;
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, COST_SIGMA) * self_run;
busy_until[mind] = now
+ (busy_rng.lognormal(BUSY_MEAN_S * scenario.busy_scale, BUSY_SIGMA) * 1000.0)
as i64;
last_turn = now;
if sensor == Sensor::Stream && !gap {
reading = sense(used.min(1000.0), now, resets);
}
} else {
stalled = true;
found_busy = true;
out.stalls += 1;
}
}
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.frac_at_max = f64::from(at_max) / 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.
const FINAL_TOLERANCE: f64 = 30.0;
/// The worst |error| on the last day: the vote scenarios' recovery sets it.
const LAST_DAY_ERROR_BOUND: f64 = 75.0;
#[test]
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.stress {
continue;
}
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;
}
if scenario.overspent {
assert_eq!(o.last_beat, FALLBACK_MAX_S, "backs off {tag}");
assert!(o.frac_at_max > 0.9, "backs off {tag}");
continue;
}
for used in &o.final_used {
assert!(
(used - f64::from(TARGET)).abs() <= FINAL_TOLERANCE,
"final {tag}"
);
}
assert!(
o.worst_error_last_day <= LAST_DAY_ERROR_BOUND,
"error {tag}"
);
assert!(
o.band_crossings <= scenario.max_crossings,
"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, mut x) = (0f64, 0f64, 0f64, 0u32);
let (mut lastb, mut stalls) = (0, 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.stress {
} else if s.unreachable {
sat_ok &= o.last_beat == FALLBACK_MIN_S && o.frac_at_min > 0.9;
} else if s.overspent {
sat_ok &= o.last_beat == FALLBACK_MAX_S && o.frac_at_max > 0.9;
} else {
f = o
.final_used
.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);
x = x.max(o.band_crossings);
}
lastb = o.last_beat;
stalls = stalls.max(o.stalls);
}
}
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} crossings {x} last_beat {lastb} stalled_min {stalls}\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 or overspent"
);
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);
}
}
/// Against a ceiling the target cannot be reached under, the controller told
/// that a beat found every Mind busy winds its integral less, so it overspends
/// less at the end than the one that is not told.
#[test]
fn the_busy_report_lowers_the_overshoot_against_a_ceiling() {
let scenario = scenarios().into_iter().find(|s| s.stress).unwrap();
for sensor in SENSORS {
for seed in SEEDS {
let told = drive(&scenario, sensor, &GAINS, seed, true);
let blind = drive(&scenario, sensor, &GAINS, seed, false);
let tag = format!("{sensor:?} seed {seed}: told {told:?} blind {blind:?}");
assert!(told.stalls > 0, "the ceiling binds {tag}");
assert!(told.final_used[0] <= blind.final_used[0], "final {tag}");
assert!(
told.worst_error_last_day < blind.worst_error_last_day,
"error {tag}"
);
}
}
}

View file

@ -21,7 +21,11 @@ fn the_score_is_the_wait_in_beats_times_ten_to_the_urgency_in_orders() {
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");
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);
@ -55,15 +59,34 @@ fn the_tally_is_the_lower_median_and_one_outlier_cannot_move_it() {
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");
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)]));
assert_eq!(
tally(&[Some(900), Some(60), None, Some(600)]),
tally(&[None, Some(60), Some(600), Some(900)])
);
}
#[test]
@ -176,3 +199,717 @@ 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,
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, 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,
false,
),
941,
);
// The same reading again carries no derivative term.
near(
control(
&g,
&mut s,
Some(&next),
MID + 16 * 60_000,
900,
false,
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,
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();
for i in 0..100 {
let now = day5 + i * 60_000;
assert_eq!(
control(&g, &mut s, Some(&read(0, now)), now, 900, false, true),
FALLBACK_MIN_S
);
}
assert_eq!(
s.integral, 0.0,
"error is positive and the caller reports the floor"
);
for i in 100..110 {
let now = day5 + i * 60_000;
control(&g, &mut s, Some(&read(600, now)), now, 900, false, 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,
last_used_permille: 400,
last_observed_at_ms: MID - 60_000,
last_now_ms: MID - 60_000,
window_resets_at_ms: RESETS,
..Fallback::default()
};
assert_eq!(
control(&g, &mut s, Some(&read(400, MID)), MID, 900, false, false),
FALLBACK_MAX_S
);
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,
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, false),
300
);
assert_eq!(
control(&T, &mut long, Some(&reading), t_long, 900, true, false),
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,
false,
),
300,
);
near(
control(
&T,
&mut long,
Some(&reading),
t_long + 60_000,
900,
false,
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, now)), now, 900, false, 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, 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, false),
DEFAULT_BEAT_S,
"never set"
);
let live = read(400, MID + 60_000);
control(&T, &mut s, Some(&read(400, MID)), MID, 900, false, false);
let held = control(&T, &mut s, Some(&live), MID + 60_000, 900, false, false);
assert_ne!(held, DEFAULT_BEAT_S);
let integral = s.integral;
assert!(integral > 0.0, "a fresh reading integrates");
assert_eq!(
control(&T, &mut s, None, MID + 2 * 60_000, 900, false, false),
held
);
// Observed just before the window it names began: not its reading.
let stale = Reading {
observed_at_ms: RESETS - WINDOW_MS - 60_000,
used_permille: 900,
..live
};
assert_eq!(
control(
&T,
&mut s,
Some(&stale),
RESETS - WINDOW_MS + 60_000,
900,
false,
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,
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,
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,
false,
),
FALLBACK_MIN_S
);
assert_eq!(
control(
&wild,
&mut Fallback::default(),
Some(&read(1000, MID)),
MID,
900,
false,
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,
false,
);
assert!((FALLBACK_MIN_S..=FALLBACK_MAX_S).contains(&beat), "{beat}");
}
}
}
}
#[test]
fn fallback_beat_is_the_controller_with_the_committed_gains() {
let live = read(400, MID);
let (mut a, mut b) = (Fallback::default(), Fallback::default());
let beat = fallback_beat(&mut a, Some(&live), MID, 900, false, false);
assert_eq!(
beat,
control(&GAINS, &mut b, Some(&live), MID, 900, false, false)
);
assert_ne!(beat, DEFAULT_BEAT_S, "behind the trajectory");
assert_eq!(a, b);
// On the trajectory with no memory the answer is the bias, whatever the gains.
let on = read(450, MID);
let beat = fallback_beat(&mut Fallback::default(), Some(&on), MID, 900, false, false);
assert_eq!(beat, DEFAULT_BEAT_S);
assert_eq!(
fallback_beat(&mut Fallback::default(), None, MID, 900, false, false),
DEFAULT_BEAT_S
);
}
#[test]
fn a_window_that_ends_this_instant_is_expired() {
let mut s = Fallback::default();
let live = read(400, MID);
let held = control(&T, &mut s, Some(&live), MID, 900, false, false);
let ending = Reading {
resets_at_ms: MID + 60_000,
observed_at_ms: MID,
used_permille: 0,
};
// At its reset instant the window holds the last beat instead of steering.
assert_eq!(
control(&T, &mut s, Some(&ending), MID + 60_000, 900, false, false),
held
);
}
/// A state mid-window with some integral, as after a quiet hour of fresh readings.
fn warm(gains: &Gains) -> Fallback {
let mut s = Fallback::default();
for i in 0..7 {
let now = MID + i * 10 * 60_000;
control(gains, &mut s, Some(&read(300, now)), now, 900, false, false);
}
assert!(s.integral > 0.0);
s
}
#[test]
fn a_frozen_reading_does_not_wind_up() {
let frozen = read(300, MID);
let at = |s: &mut Fallback, hours: i64| {
control(&T, s, Some(&frozen), MID + hours * HOUR, 900, false, false)
};
let mut s = Fallback::default();
at(&mut s, 0);
// Inside the horizon it is live; past it, the last beat holds and nothing
// integrates, however long the connector keeps replaying it.
at(&mut s, 1);
assert!(s.integral > 100.0 && s.integral < 200.0, "{}", s.integral);
// Observed exactly two hours ago still counts; the horizon is not shorter.
let before = s.integral;
at(&mut s, 2);
assert!(s.integral > before, "{} then {}", before, s.integral);
let (held, integral) = (at(&mut s, 3), s.integral);
assert_eq!(at(&mut s, 48), held);
assert_eq!(at(&mut s, 80), held);
assert_eq!(s.integral, integral);
// A reading that advances takes over again.
let now = MID + 82 * HOUR;
control(&T, &mut s, Some(&read(300, now)), now, 900, false, false);
assert_ne!(s.rate_per_h, 3600.0 / f64::from(held));
}
#[test]
fn one_millisecond_of_resets_jitter_is_the_same_window() {
let (mut steady, mut jittery) = (Fallback::default(), Fallback::default());
for i in 0..60 {
let now = MID + i * 60_000;
let r = read(300, now);
control(&T, &mut steady, Some(&r), now, 900, false, false);
let jitter = Reading {
resets_at_ms: RESETS + i % 2,
..r
};
control(&T, &mut jittery, Some(&jitter), now, 900, false, false);
}
assert!(steady.integral > 100.0);
assert!((jittery.integral - steady.integral).abs() < 1e-3);
// The jitter allowed is a minute, to the millisecond.
for (jitter, same) in [(60_000, true), (60_001, false)] {
let mut s = Fallback::default();
control(&T, &mut s, Some(&read(300, MID)), MID, 900, false, false);
let later = Reading {
resets_at_ms: RESETS + jitter,
..read(300, MID + HOUR)
};
control(&T, &mut s, Some(&later), MID + HOUR, 900, false, false);
assert_eq!(s.integral > 0.0, same, "{jitter} ms");
}
// A window a week on is not the same window.
let now = RESETS + HOUR;
let next = Reading {
resets_at_ms: RESETS + WINDOW_MS - 1_000,
..read(5, now)
};
control(&T, &mut jittery, Some(&next), now, 900, false, false);
assert!(jittery.integral < 1.0);
}
#[test]
fn a_vote_beyond_the_beat_bounds_leaves_the_integral_alone() {
for voted_beat in [7200.0, 86_400.0] {
let mut s = warm(&T);
let integral = s.integral;
s.rate_per_h = 3600.0 / voted_beat;
let t = MID + 3 * HOUR;
control(&T, &mut s, Some(&read(300, t)), t, 900, true, false);
assert_eq!(s.integral, integral, "a vote integrates nothing");
// The controller can answer no more than its longest beat.
let t = t + 60_000;
assert!(control(&T, &mut s, Some(&read(300, t)), t, 900, false, false) > 3500);
// And takes the wheel back: well behind the trajectory, two hours on, the
// beat is its own again.
let mut beat = 0;
for i in 1..=12 {
let t = t + i * 10 * 60_000;
beat = control(&T, &mut s, Some(&read(300, t)), t, 900, false, false);
}
assert!(beat < 700, "{beat}");
}
}
#[test]
fn a_short_vote_while_ahead_does_not_outlive_itself() {
let mut s = Fallback::default();
let at = |s: &mut Fallback, ms: i64, voted: bool| {
control(
&T,
s,
Some(&read(650, MID + ms)),
MID + ms,
900,
voted,
false,
)
};
at(&mut s, 0, false);
s.rate_per_h = 60.0;
at(&mut s, HOUR, true);
near(at(&mut s, HOUR + 60_000, false), 60);
// 200 permille ahead of the trajectory: two hours on it is back to the
// slowest beat, not the 60 s it was voted.
for i in 2..=121 {
at(&mut s, HOUR + i * 60_000, false);
}
assert_eq!(at(&mut s, HOUR + 122 * 60_000, false), FALLBACK_MAX_S);
assert_eq!(
s.integral, 0.0,
"the carry is not an output the integral absorbs"
);
}
#[test]
fn the_vote_carry_needs_no_integral_gain_and_a_set_rate() {
// ki = 0: nothing to divide by; release is still the voted beat.
let g = Gains { ki: 0.0, ..T };
let mut s = warm(&g);
s.rate_per_h = 12.0;
let t = MID + 3 * HOUR;
control(&g, &mut s, Some(&read(300, t)), t, 900, true, false);
near(
control(
&g,
&mut s,
Some(&read(300, t + 60_000)),
t + 60_000,
900,
false,
false,
),
300,
);
// A vote cast before the caller set any rate is no carry at all.
let (mut voted, mut plain) = (Fallback::default(), Fallback::default());
let r = read(300, MID);
control(&T, &mut voted, Some(&r), MID, 900, true, false);
let after = read(300, MID + 60_000);
assert_eq!(
control(
&T,
&mut voted,
Some(&after),
MID + 60_000,
900,
false,
false
),
control(
&T,
&mut plain,
Some(&after),
MID + 60_000,
900,
false,
false
)
);
}
#[test]
fn a_clock_that_steps_back_integrates_and_fades_nothing_backwards() {
let mut s = warm(&T);
let integral = s.integral;
let t = MID + 2 * HOUR;
control(&T, &mut s, Some(&read(300, t)), t, 900, false, false);
let after_forward = s.integral;
assert!(after_forward > integral);
control(
&T,
&mut s,
Some(&read(300, t + 60_000)),
t - HOUR,
900,
false,
false,
);
assert_eq!(s.integral, after_forward, "no negative step");
s.rate_per_h = 12.0;
control(
&T,
&mut s,
Some(&read(300, t + 120_000)),
t,
900,
true,
false,
);
let carry = s.carry_per_h;
control(
&T,
&mut s,
Some(&read(300, t + 180_000)),
t - HOUR,
900,
false,
false,
);
assert!(s.carry_per_h.abs() <= carry.abs(), "the carry only fades");
}
#[test]
fn a_released_vote_that_saturates_the_output_does_not_wind_the_integral() {
let mut s = Fallback::default();
let at = |s: &mut Fallback, ms: i64, voted: bool, saturated: bool| {
control(
&T,
s,
Some(&read(300, MID + ms)),
MID + ms,
900,
voted,
saturated,
)
};
at(&mut s, 0, false, false);
s.rate_per_h = 60.0;
at(&mut s, 60_000, true, false);
// Behind the trajectory, the voted rate is the fastest the controller can
// answer and the error pushes further: the carry is part of the output the
// integral must not wind against.
at(&mut s, 120_000, false, true);
assert_eq!(s.integral, 0.0);
}
/// Time spent without a call is not integrated as if the swarm had run through
/// it: one fresh reading after two days integrates one longest beat of error.
#[test]
fn a_gap_between_calls_integrates_at_most_one_longest_beat() {
let mut s = warm(&GAINS);
let before = s.integral;
let now = MID + 48 * HOUR;
let error = 900.0 * (now - (RESETS - WINDOW_MS)) as f64 / WINDOW_MS as f64 - 300.0;
let beat = control(
&GAINS,
&mut s,
Some(&read(300, now)),
now,
900,
false,
false,
);
let step = s.integral - before;
assert!(
(step - error * (MAX_STEP_MS as f64 / HOUR_MS)).abs() < 1e-6,
"{step} for error {error}"
);
assert!(
beat > 100,
"an outage did not park the controller at its floor: {beat}"
);
// A gap shorter than the cap is integrated whole.
let mut s = warm(&GAINS);
let before = s.integral;
let now = MID + 90 * 60_000;
let error = 900.0 * (now - (RESETS - WINDOW_MS)) as f64 / WINDOW_MS as f64 - 300.0;
control(
&GAINS,
&mut s,
Some(&read(300, now)),
now,
900,
false,
false,
);
assert!((s.integral - before - error * 0.5).abs() < 1e-6);
}
/// The caller's saturation report stops integration toward more spend and
/// nothing else.
#[test]
fn a_saturated_report_stops_integration_toward_more_spend_only() {
let g = Gains { kd: 0.0, ..T };
let mut s = warm(&g);
let integral = s.integral;
let t = MID + 2 * HOUR;
control(&g, &mut s, Some(&read(300, t)), t, 900, false, true);
assert_eq!(s.integral, integral, "behind and saturated: nothing to buy");
let t = t + 60_000;
control(&g, &mut s, Some(&read(300, t)), t, 900, false, false);
assert!(s.integral > integral, "not saturated, it integrates again");
let integral = s.integral;
let t = t + 60_000;
control(&g, &mut s, Some(&read(480, t)), t, 900, false, true);
assert!(
s.integral < integral,
"ahead, saturation does not hold the integral up"
);
}
/// The vote's release is the voted beat less the one integration step the first
/// free call takes before it answers.
#[test]
fn release_of_the_slowest_vote_is_within_one_integration_step() {
let mut s = warm(&T);
s.rate_per_h = 1.0;
let t = MID + 3 * HOUR;
control(&T, &mut s, Some(&read(300, t)), t, 900, true, false);
let beat = control(
&T,
&mut s,
Some(&read(300, t + 60_000)),
t + 60_000,
900,
false,
false,
);
assert!((3400..FALLBACK_MAX_S).contains(&beat), "{beat}");
}
/// A vote under the 60 s floor is held at the floor's rate: the carry is the gap
/// to that, not to the faster rate voted.
#[test]
fn a_vote_under_the_floor_carries_the_floor_not_the_vote() {
let mut s = warm(&T);
s.rate_per_h = 120.0;
let t = MID + 3 * HOUR;
control(&T, &mut s, Some(&read(300, t)), t, 900, true, false);
let own = BIAS_PER_H + T.kp * 178.0 + T.ki * s.integral;
assert!(
(s.carry_per_h + own - 60.0).abs() < 2.0,
"carry {} over own {own}",
s.carry_per_h
);
}

View file

@ -272,9 +272,16 @@ mind_body::self_run::inbox: fn render (briefs : & [Brief] , plan : & str) -> Str
mind_body::self_run::inbox: fn resolving_sentence (id : & str) -> String
mind_body::self_run::inbox: struct Brief # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[id: String, campaign: String, repo: String, item: String, ordinal: u64]
mind_body::tempo: const DEFAULT_BEAT_S : u32
mind_body::tempo: const FALLBACK_MAX_S : u32
mind_body::tempo: const FALLBACK_MIN_S : u32
mind_body::tempo: const GAINS : Gains
mind_body::tempo: const LOG_BOUND_ORDERS : f64
mind_body::tempo: fn fallback_beat (state : & mut Fallback , reading : Option < & Reading > , now_ms : i64 , target_permille : u32 , voted : bool , saturated : bool ,) -> u32
mind_body::tempo: fn pick (rows : & [Standing] , beat_s : u32) -> Option < usize >
mind_body::tempo: fn score (standing : & Standing , beat_s : u32) -> f64
mind_body::tempo: fn tally (votes : & [Option < u32 >]) -> u32
mind_body::tempo: fn urgency_permille (standing : & Standing) -> u16
mind_body::tempo: struct Fallback # [derive (Clone , Debug , Default , PartialEq)] pub-fields[integral: f64, last_used_permille: u32, last_observed_at_ms: i64, last_now_ms: i64, carry_per_h: f64, window_resets_at_ms: i64, rate_per_h: f64]
mind_body::tempo: struct Gains # [derive (Clone , Copy , Debug , PartialEq)] pub-fields[kp: f64, ki: f64, kd: f64]
mind_body::tempo: struct Reading # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub-fields[used_permille: u32, observed_at_ms: i64, resets_at_ms: i64]
mind_body::tempo: struct Standing # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[slug: String, eligible: bool, waited_s: Option < u64 >, last_turn_ms: Option < i64 >, self_permille: u16, topic_permille: u16, addressed_by_member: bool]

View file

@ -0,0 +1,238 @@
# Tempo fallback PID: plant, sweep, chosen gains (2026-11-01)
Rulings: `tempo-default-is-usage-fitter`, `tempo-fallback-pid-tuned-by-sim`,
`minds-control-burn-democratically`, `tempo-silence-is-abstention`. The
controller is `tempo::control` in `crates/mind-body/src/tempo.rs`; `fallback_beat`
is it with the committed `GAINS`. The plant is `crates/mind-body/src/tempo/sim.rs`
(test-only). Nothing is wired into the heartbeat yet.
## Control law
Actuator: the turn rate `u` in beats per hour (usage per hour is roughly linear
in `u`; the beat is not). `target(now) = target_permille x elapsed / 7 d`,
`e = target(now) - used` (permille, positive means behind).
`u = 3600/900 + kp e + ki I - kd d(used)/dt`, `beat = clamp(round(3600/u), 60, 3600)`
seconds, `u <= 0` gives 3600. Derivative on the measurement and only between
distinct observations. Conditional integration: the controller does not
integrate error in the direction of a saturated actuator. Saturation is one input
the caller gives, `saturated` (ruling `tempo-controller-knows-the-ceiling`): the
last beat was the floor, or launched nothing because every eligible Mind was busy;
with it and `e > 0` nothing integrates. The slow limit needs no report: with
`u(I) <= 1/h` (the integral's own answer, the carry excluded) and `e < 0` nothing
integrates. One call integrates at most one longest beat (3600 s) of time: a
heartbeat down for two days that finds a fresh reading integrates 1 h of error, not
48 h (Soul's probe wound 11736 permille-h and held the floor to the window end).
A new `resets_at` (more than a minute from the last: the connector reports whole
unix seconds, and a new window is a week on) clears `I` and the derivative memory
and integrates nothing on the call that finds it; no reading, an expired window, a
reading older than its window or one observed more than two longest beats (2 h)
ago holds the last beat and integrates nothing (time spent holding is not
integrated later).
Votes. While a vote decides nothing integrates. The caller sets `rate_per_h` to
the voted rate; the controller keeps `carry = clamp(voted rate, 1/h, 60/h) - its
own rate now`, so the first beat after release is the voted beat (a beat beyond the
bounds is answered at the bound), and the carry then fades with time constant
0.25 h, the sensor's poll period. Three limits, each measured and kept:
- Release is the voted beat to within one integration step, not within a second:
the first unvoted call integrates a step of error before it answers. A 3600 s
vote released 150 permille behind answers 3506 s with a one-minute step (94 s,
2.6%), and so for every longer vote; a 60 s vote moves none. The unit test holds
the slow end to 3400..3599 s.
- The carry fades on the sensor's period whatever the vote's length, so a vote in
force for one call releases at its beat and takes about 75 min to return to the
slow beat: tens of extra turns for a vote that lasted a minute, bounded by the
span of the beat bounds, and it decays.
- The carry is not part of the output the integral is judged against. Before, a
60 s vote at 160 permille ahead left the integral at about -143 where the unvoted
controller stays locked at 0; now it stays 0 (unit test
`a_short_vote_while_ahead_does_not_outlive_itself`).
Why not an integral that tracks the voted rate (the first sketch): the integral is
the slow term, `dI/dt = e`, so parking it at the voted rate keeps the vote's burn
for as long as `I` takes to unwind. After a 60 s vote it is parked at ~5600 and
unwinds at `e` per hour: a 60 s vote of 1 h or 4 h left the plan spent to 1000
within the day (last-day error 215-229 permille, Soul's probe), and clamping the
tracked value to the beat bounds does not touch it, since 60 s is itself a bound.
The carry is bounded by construction; the integral is never moved by a vote.
## Plant
One seven-day window, 60 s steps, `target = 900` permille. Each beat that elapses
launches one turn if a Mind is free (see the ceiling below): cost lognormal with mean `c` and sigma(ln) 0.5; every 25th turn is a
Self run at 5 x. Per-turn cost range used: `c` in 0.6 (cheap) .. 1.3 (base) ..
3.0 (dear) permille; the 900 s bias beat reaches 90% at about `c` = 1.34. Usage
saturates at 1000. Sensor: `Stream` refreshes the reading when a turn ends,
`Poll` every 15 minutes; readings are rounded to 1 permille; a gap shows no
reading. Three seeds per scenario and sensor.
Scenarios: quiet (c 1.3); cheap-turns (0.6); dear-turns (3.0); burst (+200 permille
over hours 48-52); cost-x3 (turn cost x3 from hour 84); votes from hour 48, then
release: 300 s for 24 h, 60 s for 1 h, 60 s for 4 h, 60 s for 24 h, 7200 s for 24 h
and 86400 s for 24 h (the longest a Mind may vote; the last two are beyond the
3600 s the controller can answer); rollover (two windows, used
resets to 0, the sensor shows the old window until its next refresh); gap-6h (no
reading from hour 72 to 78); start-ahead (500 permille used at the start);
unreachable (c 0.05: even at the 60 s minimum the target cannot be reached, the
controller must sit at 60 s over the last day); cheap-then-x3 (c 0.6, tripled from
hour 84: the winding case Soul probed); one-slow-mind (one Mind and turns 45 x
longer, a ceiling near 5 turns an hour, with cheap-then-x3's cost).
A vote of 60 s for 24 h costs about 1900 permille at c 1.3, more than the week: the
vote itself exhausts the plan, so no controller can end on the target and the
scenario asserts what the ruling asks of the fallback once the vote is gone: it
stops burning (slowest beat over the whole last day). The 1 h and 4 h holds at 60 s
(about 80 and 310 permille over) are reachable and are held to the same final and
last-day bounds as the rest.
Metrics: used at each window's reset; worst |error| over the last day; "jumps":
minutes where the beat moved by more than 5%, per hour, outside votes; "band
crossings": the error going from beyond +15 to beyond -15 permille (or back) in
the last three days. No oscillation is sustained when crossings stay at or under
2 (one overshoot).
Recorded trace: not used. Each Mind's `persona-trace.cc` on Yggdrasil holds one
`plan seven_day` line (the newest), not a series, so there is nothing to replay.
### The throughput ceiling
The heartbeat launches at most one on-beat Persona turn per poll, skips a Mind
whose Persona run is in force, and a beat that finds nobody free launches nothing
and is tried again at the next poll (`heartbeat.rs` `poll`). The sim books a launch
only when a Mind is free: each turn holds its Mind for a lognormal time, a beat
that finds all busy launches nothing, and the controller's calls carry `saturated`
from then until a launch comes on time (the report a heartbeat can make: "a beat
since the last launch found every Mind busy"; a launch that came late keeps it up
through the next beat). The step, 60 s, is the beat floor and longer than the
poll, so the spacing between launches never binds.
Calibration, read-only on Yggdrasil (journal retention is about two days of the
heartbeat and 29 h of unit lines, not five):
- `sudo journalctl -u mind-heartbeat.service --since "-5 days" -o short-iso`:
4100 `polled:` lines from 2026-10-30T02:16Z, 43 h. Gaps between consecutive
polls under 300 s: p10 25 s, median 30 s, mean 37.7 s, p90 54 s. 782 polls
launched something (472 of them two or more Minds at once: mention and
Self-ended wakes, which do not wait for the beat), 1346 `held: BrakeAbsent`.
Launched slugs: aqua 166, eureka 152, heimdall 170, kiko 150, libby 146, nibu 181,
void 173, weksa 146: eight Minds. The journal says only `no launch`, not why, so
how often every Mind was busy is not logged.
- `sudo journalctl _PID=1 --since "-5 days"` filtered to `mind-persona@`, each
`Started` paired with its `Deactivated successfully`: 1284 turns over 29.4 h,
duration min 3.3 s, p10 4.0, median 6.4, p90 27.7, p99 186, max 505, mean 15.4 s;
at most 6 turns in flight at once; starts per hour median 20, max 154 (the bursts
are the mention wakes); gaps between starts median 44 s. `mind-self@` (Self runs;
they do not hold a Persona turn's Mind): 147 over 28 h, mean 457 s, median 44 s,
p99 7299 s, one Self run per 8.7 Persona turns where the sim's cost model has one
per 25 (not recalibrated here).
- The sim's busy time is lognormal with mean 15.4 s and sigma 1.2 (median 7.5 s,
p90 35 s against the measured 6.4 and 27.7; the mean is what the ceiling is made of).
Ceiling: spacing 30 s gives at most 120 on-beat launches an hour; eight Minds each
busy 15.4 s give 8 x 3600 / 15.4 = 1870 an hour. The beat floor, 60 turns an hour,
is below both, so on the measured plant the ceiling does not bind: every real
scenario, vote-60s-24h included, runs with zero stalled minutes, and its results
are those of the unthrottled plant (a 60 s vote for 24 h still spends ~1900
permille and the controller still backs off to 3600 s after it; cheap-then-x3 ends
within 15 permille of the target, 33.8 worst last-day error).
It would bind with fewer Minds or turns tens of times longer, which is what
`one-slow-mind` stands for: one Mind and a ceiling near 5 turns an hour. The
controller does not end on target there (the target needs more than the ceiling
for 84 h and then the cost triples); the scenario compares the controller told of
the stalls with the same controller not told, six runs (3 seeds x 2 sensors); used
at the end of the week, worst last-day error:
| run | told | not told |
|---|---|---|
| Stream 11 | 941, 131 | 1000, 192 |
| Stream 23 | 929, 113 | 1000, 165 |
| Stream 47 | 999, 111 | 1000, 165 |
| Poll 11 | 941, 131 | 1000, 193 |
| Poll 23 | 930, 114 | 1000, 166 |
| Poll 47 | 1000, 113 | 1000, 165 |
The report lowers the overshoot in every run and does not remove it. It is one bit
per call and the ceiling is soft (turn durations are lognormal, so some beats still
find a Mind free); the integral keeps growing in those intervals and carries about
1500 permille-hours into the cost step. A controller that also read how much of
the requested rate was delivered would need more than the one input the ruling
gives it; whether that is wanted is the operator's.
## Sweep
`kp` in {0.01, 0.02, 0.03, 0.05, 0.1}, `ki` in {0.002, 0.005, 0.01, 0.02, 0.04},
`kd` in {0, 0.002, 0.005, 0.01, 0.02, 0.05}: 150 candidates x 17 scenarios x 2
sensors x 3 seeds. Rerun:
cargo test -p mind-body --lib tempo::sim::sweep -- --ignored --nocapture
(on Yggdrasil through `tools/stopgap/ygg-verify.sh`). Candidates are ranked by
`final error + 0.5 x last-day error + 20 x jumps/h + 15 x crossings`. Best fourteen, and the chosen row (27th by that rank):
| kp ki kd | worst final error | worst error, last day | worst jumps/h | worst band crossings | holds MIN when unreachable, MAX when overspent |
|---|---|---|---|---|---|
| 0.1 0.02 0 | 30.9 | 46.9 | 1.44 | 0 | true |
| 0.1 0.01 0 | 30.9 | 47.3 | 1.45 | 0 | true |
| 0.1 0.02 0.002 | 30.9 | 46.9 | 1.54 | 0 | true |
| 0.1 0.01 0.002 | 30.9 | 47.3 | 1.55 | 0 | true |
| 0.1 0.04 0 | 30.9 | 52.3 | 1.43 | 0 | true |
| 0.1 0.005 0 | 30.9 | 52.3 | 1.48 | 0 | true |
| 0.1 0.002 0 | 30.9 | 52.3 | 1.49 | 0 | true |
| 0.1 0.04 0.002 | 30.9 | 52.3 | 1.49 | 0 | true |
| 0.1 0.005 0.002 | 30.9 | 52.3 | 1.55 | 0 | true |
| 0.1 0.002 0.002 | 30.9 | 52.3 | 1.58 | 0 | true |
| 0.03 0.002 0 | 33.6 | 54.9 | 0.77 | 1 | true |
| 0.1 0.02 0.005 | 30.9 | 46.9 | 1.89 | 0 | true |
| 0.1 0.01 0.005 | 30.9 | 47.3 | 1.89 | 0 | true |
| 0.1 0.04 0.005 | 30.9 | 52.3 | 1.82 | 0 | true |
| 0.02 0.01 0 (chosen, 27th) | 29.6 | 46.8 | 0.37 | 3 | true |
Chosen `GAINS = { kp: 0.02, ki: 0.01, kd: 0.0 }`, per scenario (worst over sensors
and seeds; one-slow-mind is not scored, see the ceiling above):
```
quiet final_err 5.4 worst_err_last_day 14.3 jumps/h 0.03 crossings 1 last_beat 1028
cheap-turns final_err 9.3 worst_err_last_day 19.5 jumps/h 0.00 crossings 1 last_beat 392
dear-turns final_err 29.6 worst_err_last_day 33.8 jumps/h 0.37 crossings 1 last_beat 2041
burst final_err 14.1 worst_err_last_day 17.2 jumps/h 0.07 crossings 2 last_beat 876
cost-x3 final_err 7.8 worst_err_last_day 32.5 jumps/h 0.21 crossings 0 last_beat 3465
vote-300s-24h final_err 29.5 worst_err_last_day 46.8 jumps/h 0.14 crossings 1 last_beat 928
vote-60s-1h final_err 18.0 worst_err_last_day 19.9 jumps/h 0.30 crossings 3 last_beat 847
vote-60s-4h final_err 23.2 worst_err_last_day 32.5 jumps/h 0.25 crossings 1 last_beat 625
vote-60s-24h final_err 0.0 worst_err_last_day 0.0 jumps/h 0.18 crossings 0 last_beat 3600
vote-7200s-24h final_err 16.5 worst_err_last_day 24.5 jumps/h 0.08 crossings 2 last_beat 1326
vote-86400s-24h final_err 22.4 worst_err_last_day 24.6 jumps/h 0.08 crossings 2 last_beat 1212
cheap-then-x3 final_err 14.9 worst_err_last_day 33.8 jumps/h 0.06 crossings 3 last_beat 1760
rollover final_err 7.3 worst_err_last_day 10.1 jumps/h 0.04 crossings 0 last_beat 875
gap-6h final_err 5.4 worst_err_last_day 13.1 jumps/h 0.04 crossings 0 last_beat 1014
start-ahead final_err 27.6 worst_err_last_day 29.8 jumps/h 0.02 crossings 2 last_beat 1479
unreachable final_err 0.0 worst_err_last_day 0.0 jumps/h 0.00 crossings 0 last_beat 60
```
The chosen row is 27th by that rank: the rank weights accuracy over chatter, and
nothing in the grid is both within 30 permille at every reset and quiet at a
crossing bound of 2: with the sweep's own filter (worst final error at most 30,
jumps at most 0.5 an hour, at most 2 crossings on any scenario) no row of the 150
passes. `kp` 0.1 (first by rank) ends at 30.9 on dear-turns, over the 30 bound, and
moves the beat on nearly every turn (1.4 jumps/h) because each turn's usage lump is
read as error. With the crossing bound at 3, exactly three rows pass, all `kp` 0.02:
`ki` 0.01 (final 29.6, last-day 46.8, 0.37 jumps/h), 0.02 (29.6, 44.5, 0.35) and 0.04
(29.6, 54.4, 0.33). They are within noise of each other on every measured axis, and
the sweep does not separate them; `GAINS` has the smallest integral gain of the
three, which is the choice made, not a measured winner (0.02 would rank slightly
higher). `kd` helps nowhere: with 1-permille readings and about four turns an hour,
the measured slope is mostly quantisation, and every `kd` > 0 only adds jumps. The
committed controller is therefore a PI in practice; the D term stays in the law
(tested with explicit gains) so a plant with finer readings can use it.
Bounds the acceptance test holds `GAINS` to: reachable scenarios end within 30
permille of the target at every reset (the chosen row's worst is 29.6, dear-turns
and vote-300s-24h); worst last-day error at most 75 (worst 46.8); at most 0.5
jumps/h (worst 0.37); at most 2 band crossings, except the two scenarios that
carry their own bound of 3, vote-60s-1h and cheap-then-x3: each crosses three times
on some seed and sensor (vote-60s-1h on two of the six runs, cheap-then-x3 on at least
the Stream run of seed 47), a wobble just over the 15-permille band with last-day
error at most 33.8, not a sustained oscillation. Admitting those two rows is the
whole of the widening; the other scenarios stay at 2. The unreachable case sits at
60 s and the overspent vote at 3600 s over the last day.