From ae937d14304cd582cb814949ccb9e2e09c7a15ba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 03:11:46 +0100 Subject: [PATCH] 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 --- crates/mind-body/src/tempo.rs | 42 +++++++++++++---------------- crates/mind-body/src/tempo/tests.rs | 42 +++++++++++++++++------------ 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/crates/mind-body/src/tempo.rs b/crates/mind-body/src/tempo.rs index 455227f..3aabe5b 100644 --- a/crates/mind-body/src/tempo.rs +++ b/crates/mind-body/src/tempo.rs @@ -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 } diff --git a/crates/mind-body/src/tempo/tests.rs b/crates/mind-body/src/tempo/tests.rs index d3f8c78..868d2b3 100644 --- a/crates/mind-body/src/tempo/tests.rs +++ b/crates/mind-body/src/tempo/tests.rs @@ -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}"); } }