use phyz_model::State;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RolloutHasher {
h: u64,
}
impl RolloutHasher {
const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
pub fn new() -> Self {
Self {
h: Self::OFFSET_BASIS,
}
}
#[inline]
pub fn absorb_f64(&mut self, x: f64) {
self.absorb_u64(x.to_bits());
}
#[inline]
pub fn absorb_u64(&mut self, w: u64) {
for b in w.to_le_bytes() {
self.h ^= b as u64;
self.h = self.h.wrapping_mul(Self::PRIME);
}
}
pub fn absorb_slice(&mut self, xs: &[f64]) {
self.absorb_u64(xs.len() as u64);
for &x in xs {
self.absorb_f64(x);
}
}
pub fn absorb_state(&mut self, state: &State) {
self.absorb_slice(state.q.as_slice());
self.absorb_slice(state.v.as_slice());
self.absorb_f64(state.time);
}
pub fn finish(&self) -> u64 {
self.h
}
pub fn hex(&self) -> String {
format!("{:016x}", self.h)
}
}
impl Default for RolloutHasher {
fn default() -> Self {
Self::new()
}
}
pub fn hash_rollout<F>(state: &mut State, steps: usize, mut step: F) -> u64
where
F: FnMut(&mut State),
{
let mut h = RolloutHasher::new();
h.absorb_state(state);
for _ in 0..steps {
step(state);
h.absorb_state(state);
}
h.finish()
}
pub fn ulp_offset(x: f64, n: i64) -> f64 {
if n == 0 || !x.is_finite() {
return x;
}
let key = f64_to_ordered(x);
ordered_to_f64(key + n as i128)
}
pub fn ulps_between(a: f64, b: f64) -> i64 {
if !a.is_finite() || !b.is_finite() {
return i64::MAX;
}
let d = f64_to_ordered(b) - f64_to_ordered(a);
d.clamp(i64::MIN as i128, i64::MAX as i128) as i64
}
fn f64_to_ordered(x: f64) -> i128 {
let bits = x.to_bits() as i64;
let ordered = if bits < 0 { i64::MIN - bits } else { bits };
ordered as i128
}
fn ordered_to_f64(k: i128) -> f64 {
let lo = f64_to_ordered(f64::MIN);
let hi = f64_to_ordered(f64::MAX);
let k = k.clamp(lo, hi) as i64;
let bits = if k < 0 { i64::MIN - k } else { k };
f64::from_bits(bits as u64)
}
pub fn state_distance(a: &State, b: &State) -> f64 {
assert_eq!(a.q.len(), b.q.len(), "states have different nq");
assert_eq!(a.v.len(), b.v.len(), "states have different nv");
let mut sum = 0.0;
for (x, y) in
a.q.as_slice()
.iter()
.zip(b.q.as_slice())
.chain(a.v.as_slice().iter().zip(b.v.as_slice()))
{
let d = x - y;
if !d.is_finite() {
return f64::INFINITY;
}
sum += d * d;
}
sum.sqrt()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DivergenceSample {
pub step: usize,
pub time: f64,
pub distance: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Divergence {
pub ulps: i64,
pub initial: f64,
pub samples: Vec<DivergenceSample>,
pub lyapunov: Option<f64>,
pub time_to_threshold: Option<f64>,
pub threshold: f64,
}
impl Divergence {
pub fn doubling_time(&self) -> Option<f64> {
self.lyapunov
.filter(|l| *l > 0.0)
.map(|l| std::f64::consts::LN_2 / l)
}
pub fn final_distance(&self) -> f64 {
self.samples.last().map_or(0.0, |s| s.distance)
}
}
pub fn divergence<F>(
model_nq: usize,
state: &State,
q_index: usize,
ulps: i64,
steps: usize,
stride: usize,
mut step: F,
) -> Divergence
where
F: FnMut(usize, &mut State),
{
assert!(q_index < model_nq, "q_index {q_index} out of range");
assert!(stride > 0, "stride must be positive");
let mut a = state.clone();
let mut b = state.clone();
b.q[q_index] = ulp_offset(b.q[q_index], ulps);
let initial = state_distance(&a, &b);
let threshold = 1e-3;
let mut samples = Vec::with_capacity(steps / stride + 1);
let mut time_to_threshold = None;
for k in 0..steps {
step(0, &mut a);
step(1, &mut b);
if (k + 1) % stride == 0 || k + 1 == steps {
let d = state_distance(&a, &b);
if time_to_threshold.is_none() && d > threshold {
time_to_threshold = Some(a.time);
}
samples.push(DivergenceSample {
step: k + 1,
time: a.time,
distance: d,
});
if !d.is_finite() {
break;
}
}
}
Divergence {
ulps,
initial,
lyapunov: fit_lyapunov(initial, &samples),
samples,
time_to_threshold,
threshold,
}
}
fn fit_lyapunov(initial: f64, samples: &[DivergenceSample]) -> Option<f64> {
if initial <= 0.0 {
return None;
}
let band: Vec<&DivergenceSample> = samples
.iter()
.filter(|s| {
s.distance.is_finite()
&& s.distance > 8.0 * initial
&& s.distance < 1e-1
&& s.time > 0.0
})
.collect();
if band.len() < 4 {
return None;
}
let n = band.len() as f64;
let (mut st, mut sy, mut stt, mut sty) = (0.0, 0.0, 0.0, 0.0);
for s in &band {
let t = s.time;
let y = (s.distance / initial).ln();
st += t;
sy += y;
stt += t * t;
sty += t * y;
}
let denom = n * stt - st * st;
if denom.abs() < f64::MIN_POSITIVE {
return None;
}
Some((n * sty - st * sy) / denom)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ulp_offset_walks_the_representable_line() {
assert_eq!(ulp_offset(1.0, 1), 1.0 + f64::EPSILON);
assert_eq!(ulp_offset(1.0, -1), 1.0 - f64::EPSILON / 2.0);
for x in [-1e300, -1.0, -1e-300, 0.0, 1e-300, 1.0, 1e300] {
for n in [-5, -1, 1, 5] {
assert_eq!(ulp_offset(ulp_offset(x, n), -n), x, "x={x} n={n}");
assert_eq!(ulps_between(x, ulp_offset(x, n)), n, "x={x} n={n}");
}
}
assert_eq!(ulp_offset(0.0, 1), f64::from_bits(1));
assert!(ulp_offset(0.0, -1) < 0.0);
assert!(ulp_offset(f64::NAN, 3).is_nan());
assert_eq!(ulp_offset(f64::INFINITY, -1), f64::INFINITY);
}
#[test]
fn hasher_is_order_and_content_sensitive_and_reproducible() {
let of = |xs: &[f64]| {
let mut h = RolloutHasher::new();
h.absorb_slice(xs);
h.finish()
};
assert_eq!(of(&[1.0, 2.0, 3.0]), of(&[1.0, 2.0, 3.0]));
assert_ne!(of(&[1.0, 2.0, 3.0]), of(&[1.0, 3.0, 2.0]));
assert_ne!(of(&[1.0]), of(&[ulp_offset(1.0, 1)]));
assert_ne!(of(&[1.0, 2.0]), of(&[1.0, 2.0, 0.0]));
assert_ne!(of(&[0.0]), of(&[-0.0]));
}
#[test]
fn lyapunov_fit_recovers_a_known_rate() {
let d0 = 1e-16;
let lambda = 3.0;
let samples: Vec<DivergenceSample> = (1..=400)
.map(|k| {
let time = k as f64 * 0.01;
DivergenceSample {
step: k,
time,
distance: d0 * (lambda * time).exp(),
}
})
.collect();
let got = fit_lyapunov(d0, &samples).expect("should fit");
assert!((got - lambda).abs() < 1e-9, "got {got}");
let flat: Vec<DivergenceSample> = (1..=400)
.map(|k| DivergenceSample {
step: k,
time: k as f64 * 0.01,
distance: d0,
})
.collect();
assert_eq!(fit_lyapunov(d0, &flat), None);
}
}