CONTRACT-01 landed its crate, so the local stand-in module goes away and the domain scalars, method payloads, their validation, the canonical digests and the trace format all come from the contract. `src/types.rs` is a facade over that crate plus the few things a coordinator needs that are not part of the type contract: a session-side DomainError, the synthetic composition's schema and event-id derivations, and the coordinator-local ControllerIntent, PortBinding and AgentOutcome that never cross the bus. Consequences worth naming: - Payloads are read and written through DomainType::from_json / to_json instead of serde derives, so a misspelled required field fails where the contract says it should. serde, sha2 and ryu-js leave this crate's dependencies with them. - The step-v1 section 8 trace is the contract's TransitionTrace, with behaviour and operational metadata already separated; the dispatch-order comparison now runs over the contract's own behaviour encoding. - Two things the migration found. IN_PROGRESS is raised strictly before any mutation, so its certainty is "none", not "unknown"; the local module had it wrong. WorldObservation::validate_against checks the views a result carries but does not require every declared view to be present, so requiring them is made explicit in the coordinator's phase C check, where step-v1 puts it. - The lost-Advance-result injection now waits for the worker to report the operation before abandoning the call, so the case it injects really is a loss after dispatch rather than a cancellation before it. Gates: cargo test -p fly-session (61 tests, both transports), cargo clippy --all-targets clean, and the runnable example produces the same behaviour trace over both transports.
223 lines
7.9 KiB
Rust
223 lines
7.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,
|
|
})
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|