tempo: a reading older than twice the longest beat is no evidence (holds the last beat); integrate per call again

Replaces the integrate-only-on-advance step: it changed the sampling the gains were tuned against for no gain over an age horizon, and left a replayed reading steering for as long as its age stayed inside it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-01 03:11:46 +01:00
commit ae937d1430
2 changed files with 43 additions and 41 deletions

View file

@ -20,6 +20,9 @@ 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;
@ -61,15 +64,13 @@ pub const GAINS: Gains = Gains {
/// 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. `last_step_ms` is when the integral
/// last took a step.
/// `carry_per_h`, which fades after release.
#[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 last_step_ms: i64,
pub carry_per_h: f64,
pub window_resets_at_ms: i64,
pub rate_per_h: f64,
@ -78,13 +79,15 @@ pub struct Fallback {
/// 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; the integral steps only on a reading that
/// advanced, at most one longest beat each, and not against a beat limit.
/// between distinct observations; conditional integration against the beat
/// limits.
/// 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 and the controller takes the wheel back within a poll
/// period or two. A new window clears the integral and the derivative memory;
/// with no usable reading the last beat holds (`DEFAULT_BEAT_S` before any).
/// 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>,
@ -123,23 +126,23 @@ fn control(
target_permille: u32,
voted: bool,
) -> u32 {
let call_h = if state.last_now_ms > 0 {
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 {
state.last_step_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);
};
if r.resets_at_ms.abs_diff(state.window_resets_at_ms) > RESETS_JITTER_MS {
state.integral = 0.0;
state.last_used_permille = r.used_permille;
state.last_observed_at_ms = r.observed_at_ms;
state.last_step_ms = now_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;
@ -167,7 +170,6 @@ fn control(
// 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.last_step_ms = now_ms;
state.carry_per_h = if state.rate_per_h > 0.0 {
state.rate_per_h.clamp(low, high) - rate(state.integral)
} else {
@ -177,20 +179,12 @@ fn control(
}
let before = rate(state.integral) + state.carry_per_h;
let winding = (before >= high && error > 0.0) || (before <= low && error < 0.0);
if fresh {
// Only a reading that advanced is evidence; one step covers at most the
// longest beat, so a late reading does not repay the silence.
if !winding {
let step_h = (now_ms - state.last_step_ms).clamp(0, i64::from(FALLBACK_MAX_S) * 1000)
as f64
/ HOUR_MS;
state.integral += error * step_h;
}
state.last_step_ms = now_ms;
if !winding {
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 *= (-call_h / CARRY_FADE_H).exp();
state.carry_per_h *= (-dt_h / CARRY_FADE_H).exp();
beat
}

View file

@ -304,7 +304,6 @@ fn saturated_output_does_not_wind_up() {
last_used_permille: 400,
last_observed_at_ms: MID - 60_000,
last_now_ms: MID - 60_000,
last_step_ms: MID - 60_000,
window_resets_at_ms: RESETS,
..Fallback::default()
};
@ -515,20 +514,24 @@ fn warm(gains: &Gains) -> Fallback {
#[test]
fn a_frozen_reading_does_not_wind_up() {
let mut s = Fallback::default();
let frozen = read(300, MID);
for i in 0..=288 {
control(&T, &mut s, Some(&frozen), MID + i * 10 * 60_000, 900, false);
}
assert_eq!(
s.integral, 0.0,
"a reading that never advanced is no evidence"
);
// The first reading that does advance is accountable for at most the
// longest beat, not the silence before it.
let now = MID + 48 * HOUR;
let at = |s: &mut Fallback, hours: i64| {
control(&T, s, Some(&frozen), MID + hours * HOUR, 900, 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);
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);
assert!(s.integral > 100.0 && s.integral < 450.0, "{}", s.integral);
assert_ne!(s.rate_per_h, 3600.0 / f64::from(held));
}
#[test]
@ -547,11 +550,12 @@ fn one_millisecond_of_resets_jitter_is_the_same_window() {
assert!(steady.integral > 100.0);
assert!((jittery.integral - steady.integral).abs() < 1e-3);
// 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, MID + 61 * 60_000)
..read(5, now)
};
control(&T, &mut jittery, Some(&next), MID + 61 * 60_000, 900, false);
control(&T, &mut jittery, Some(&next), now, 900, false);
assert!(jittery.integral < 1.0);
}
@ -569,8 +573,12 @@ fn a_vote_beyond_the_beat_bounds_leaves_the_integral_alone() {
assert!(control(&T, &mut s, Some(&read(300, t)), t, 900, false) > 3500);
// And takes the wheel back: well behind the trajectory, two hours on, the
// beat is its own again.
let t = t + 2 * HOUR;
assert!(control(&T, &mut s, Some(&read(300, t)), t, 900, false) < 700);
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);
}
assert!(beat < 700, "{beat}");
}
}