flybrain/services/flysim/crates/fly-session/src/clock.rs
dev 6655a1b1c6 session: coherent all-participant checkpoint and recovery
STATE-01 over the FLYSESS1 envelope CONTRACT-01 specified.

fly-session gains a `state` module: the durable store with its generations, its
rotation and the commit order of checkpoint-envelope-v1 section 5, where the store
manifest rename is the durable commit point; a compatibility block whose comparison
names the identity that differs rather than one opaque digest; and a bounded writer
that owns its payload handles until the bytes are committed or the job fails.

The writer's queue slot is taken before the first State.Capture, so a saturated
writer refuses a capture rather than queueing it without bound, and the refusal is a
BUSY the stepping session survives. Capture and durability are two events: a capture
completes when an immutable capture exists, and only the store manifest rename moves
the durable mark. A lost save reply is an outcome, and the resolution asks the store
about the same checkpoint instead of saving again.

Both worker roles implement State.Capture, State.StageRestore and
State.ActivateRestore, with once-only restore tokens bound to checkpoint, scope,
payload and incarnation. A restore selects a complete compatible generation, imports
every payload as a fresh artifact, stages the group, validates the coordinator's own
ledgers, and only then activates; a failure anywhere leaves the fence closed and
records every participant that staged as one that must be replaced. The fence lifts
at Failed -> Restoring(k) -> Paused(k) and nowhere else.

The task and the action executor gain the capture/validate_restore/install_restore
interfaces workers-v1 section 4 lists, and the ledger can re-derive the event
identities it issued under another epoch, which is what lets a resumed run's
behaviour trace be compared with an uninterrupted one.

media: check_required_audio now takes the observation's provenance instead of
exempting boundary 0. A chunk is the audio of an interval, and the observation
ActivateRestore installs covers none.

checkpoint-envelope-v1 section 3 gains a dated amendment adding `environment` to the
manifest, the holder of the world's own payload, which the table named for every
other participant; `helperState`, which that table already listed, joins the
required-field set in Rust and TypeScript. The fixture was regenerated by the
existing example; the schema set and contractDigest are unchanged.

state-media-v1 section 5 gains a dated amendment for three readings this slice
enforces: the State RPCs' compatibilityDigest is the participant's, not the
manifest's composition-level block; a restored observation carries no audio chunk;
and a participant that staged into an abandoned install must be replaced.
2026-09-22 17:43:43 +00:00

247 lines
8.9 KiB
Rust

//! Time and pacing: `step-v1` section 5.
//!
//! The accumulator is the contract crate's exact rational arithmetic, never rounded
//! nanoseconds. A 60 Hz world with a 1 ms model tick advances 16, 17, 17 ticks over its first
//! three steps and comes back to a remainder of exactly zero; rounding to microseconds does
//! not.
use crate::types::{DomainError, DomainType, ErrorCode, MutationCertainty, RationalNs};
/// One agent's tick accumulator: its tick duration, its remainder and its executed count.
#[derive(Clone, Debug)]
pub struct TickAccumulator {
tick_duration: RationalNs,
remainder: RationalNs,
executed_ticks: u64,
warmup_offset: u64,
}
impl TickAccumulator {
/// A fresh accumulator. The tick duration must be positive.
pub fn new(tick_duration: RationalNs) -> Result<TickAccumulator, String> {
tick_duration.validate().map_err(|e| e.0)?;
tick_duration.require_positive("tick duration").map_err(|e| e.0)?;
Ok(TickAccumulator {
tick_duration,
remainder: RationalNs::ZERO,
executed_ticks: 0,
warmup_offset: 0,
})
}
/// The exact accumulator a capture recorded.
///
/// The remainder is restored, never rounded or reset: a resumed agent that started its
/// first interval from zero would drift away from the run it is supposed to continue.
pub fn restored(
tick_duration: RationalNs,
remainder: RationalNs,
executed_ticks: u64,
warmup_offset: u64,
) -> Result<TickAccumulator, String> {
let mut accumulator = TickAccumulator::new(tick_duration)?;
remainder.validate().map_err(|e| e.0)?;
if remainder >= tick_duration {
return Err("a captured remainder is not below one model tick".to_owned());
}
if warmup_offset > executed_ticks {
return Err("a captured warm-up offset exceeds the executed tick count".to_owned());
}
accumulator.remainder = remainder;
accumulator.executed_ticks = executed_ticks;
accumulator.warmup_offset = warmup_offset;
Ok(accumulator)
}
pub fn tick_duration(&self) -> RationalNs {
self.tick_duration
}
/// The persisted remainder: always >= 0 and < one model tick.
pub fn remainder(&self) -> RationalNs {
self.remainder
}
/// Every tick this accumulator has executed, warm-up included.
pub fn executed_ticks(&self) -> u64 {
self.executed_ticks
}
/// The warm-up ticks executed before the first gameplay transition.
pub fn warmup_offset(&self) -> u64 {
self.warmup_offset
}
/// Accounts for `ticks` of warm-up. Warm-up does not consume an environment interval, so
/// it never touches the remainder.
pub fn warm_up(&mut self, ticks: u64) -> Result<(), String> {
self.warmup_offset = self
.warmup_offset
.checked_add(ticks)
.ok_or_else(|| "warm-up tick count overflows".to_owned())?;
self.executed_ticks = self
.executed_ticks
.checked_add(ticks)
.ok_or_else(|| "executed tick count overflows".to_owned())?;
Ok(())
}
/// Adds one environment interval and returns the whole ticks it covers.
///
/// ```text
/// accumulator += environment step duration
/// ticks = floor(accumulator / model tick duration)
/// accumulator -= ticks * model tick duration
/// ```
pub fn advance(&mut self, interval: &RationalNs) -> Result<u64, String> {
interval.validate().map_err(|e| e.0)?;
interval.require_positive("environment interval").map_err(|e| e.0)?;
let accumulated = self.remainder.checked_add(interval).map_err(|e| e.0)?;
let (ticks, remainder) =
accumulated.divide_floor(&self.tick_duration).map_err(|e| e.0)?;
debug_assert!(
remainder < self.tick_duration,
"the remainder must stay below one model tick"
);
self.remainder = remainder;
self.executed_ticks = self
.executed_ticks
.checked_add(ticks)
.ok_or_else(|| "executed tick count overflows".to_owned())?;
Ok(ticks)
}
pub fn brain_ticks(&self) -> u64 {
self.executed_ticks
}
}
/// The coordinator's pacing authority: absolute deadlines after committed boundaries.
///
/// Only one pacing authority may be active, so this is the coordinator's and the backend does
/// not throttle as well. When behind, it omits the sleep and reports the lag; it never skips a
/// world step or drops a neural tick.
#[derive(Clone, Debug)]
pub struct Pacing {
step_duration: RationalNs,
next_deadline: Option<std::time::Instant>,
lag: std::time::Duration,
lagged_steps: u64,
}
impl Pacing {
pub fn new(step_duration: RationalNs) -> Pacing {
Pacing {
step_duration,
next_deadline: None,
lag: std::time::Duration::ZERO,
lagged_steps: 0,
}
}
/// The wall-clock period of one step, rounded for sleeping only. Simulation time stays
/// rational; this value is never fed back into the accumulator.
fn period(&self) -> std::time::Duration {
let ns = u128::from(self.step_duration.numerator)
/ u128::from(self.step_duration.denominator).max(1);
std::time::Duration::from_nanos(u64::try_from(ns).unwrap_or(u64::MAX))
}
/// Waits until this step's deadline. Returns the lag if the deadline had already passed.
pub async fn wait(&mut self) -> Option<std::time::Duration> {
let now = std::time::Instant::now();
let deadline = self.next_deadline.unwrap_or(now);
let outcome = if deadline > now {
tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
None
} else {
let behind = now.duration_since(deadline);
if !behind.is_zero() {
self.lag += behind;
self.lagged_steps += 1;
}
Some(behind)
};
self.next_deadline = Some(deadline.max(now) + self.period());
outcome
}
pub fn total_lag(&self) -> std::time::Duration {
self.lag
}
pub fn lagged_steps(&self) -> u64 {
self.lagged_steps
}
}
/// Converts a whole tick count to the legacy f64 millisecond clock, refusing a run that has
/// left the exactly representable range.
pub fn ticks_to_legacy_millis(ticks: u64, tick_duration: &RationalNs) -> Result<f64, DomainError> {
// 2^53 is the last integer f64 represents exactly; beyond it a millisecond clock starts
// skipping representable ticks, so the run is refused rather than silently rounded.
const EXACT_F64_INTEGERS: u64 = 1 << 53;
if ticks >= EXACT_F64_INTEGERS {
return Err(DomainError::new(
ErrorCode::InvalidArgument,
"tick count exceeds the range the legacy millisecond clock represents exactly",
MutationCertainty::None,
));
}
let per_tick_ms =
tick_duration.numerator as f64 / (tick_duration.denominator as f64 * 1_000_000.0);
Ok(ticks as f64 * per_tick_ms)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{hz, millis};
#[test]
fn a_60_hz_world_with_a_1_ms_tick_runs_16_17_17() {
let step = hz(60).unwrap();
let mut acc = TickAccumulator::new(millis(1).unwrap()).unwrap();
let ticks: Vec<u64> = (0..3).map(|_| acc.advance(&step).unwrap()).collect();
assert_eq!(ticks, vec![16, 17, 17]);
assert_eq!(ticks.iter().sum::<u64>(), 50);
assert!(acc.remainder().is_zero());
}
#[test]
fn the_remainder_stays_below_one_tick_and_never_goes_negative() {
let step = hz(60).unwrap();
let tick = millis(1).unwrap();
let mut acc = TickAccumulator::new(tick).unwrap();
for _ in 0..600 {
acc.advance(&step).unwrap();
assert!(acc.remainder() < tick);
}
// 600 steps of 1/60 s is exactly 10 s, which is 10,000 whole milliseconds.
assert_eq!(acc.executed_ticks(), 10_000);
assert!(acc.remainder().is_zero());
}
#[test]
fn warm_up_ticks_do_not_touch_the_remainder() {
let mut acc = TickAccumulator::new(millis(1).unwrap()).unwrap();
acc.warm_up(25).unwrap();
assert_eq!(acc.executed_ticks(), 25);
assert_eq!(acc.warmup_offset(), 25);
assert!(acc.remainder().is_zero());
assert_eq!(acc.advance(&hz(60).unwrap()).unwrap(), 16);
}
#[test]
fn a_zero_interval_is_refused_rather_than_silently_producing_no_ticks() {
let mut acc = TickAccumulator::new(millis(1).unwrap()).unwrap();
assert!(acc.advance(&RationalNs::ZERO).is_err());
}
#[test]
fn the_legacy_millisecond_clock_refuses_a_run_beyond_its_exact_range() {
let tick = millis(1).unwrap();
assert_eq!(ticks_to_legacy_millis(50, &tick).unwrap(), 50.0);
assert!(ticks_to_legacy_millis(1 << 53, &tick).is_err());
}
}