alice-physics 1.2.0

Deterministic 128-bit Fixed-Point Physics Engine
Documentation
//! ALICE-Analytics bridge: Physics simulation metrics
//!
//! Feeds per-step simulation metrics (step time, collision count,
//! energy drift) into ALICE-Analytics sketches for profiling and
//! anomaly detection.

use alice_analytics::sketch::{DDSketch256, HyperLogLog12};

/// Physics telemetry collector backed by ALICE-Analytics sketches.
pub struct PhysicsTelemetry {
    /// Step execution time in microseconds (quantile estimation).
    step_time: DDSketch256,
    /// Collision / contact count per step.
    contacts: DDSketch256,
    /// Energy drift per step (|E_n - E_{n-1}|).
    energy_drift: DDSketch256,
    /// Unique body pair collisions (cardinality estimation).
    collision_pairs: HyperLogLog12,
    /// Total simulation steps.
    total_steps: u64,
}

impl PhysicsTelemetry {
    /// Create a new physics telemetry collector.
    pub fn new() -> Self {
        Self {
            step_time: DDSketch256::new(0.01),
            contacts: DDSketch256::new(0.01),
            energy_drift: DDSketch256::new(0.01),
            collision_pairs: HyperLogLog12::new(),
            total_steps: 0,
        }
    }

    /// Record a simulation step's timing.
    pub fn record_step_time(&mut self, time_us: f64) {
        self.step_time.insert(time_us);
        self.total_steps += 1;
    }

    /// Record contact count for a step.
    pub fn record_contacts(&mut self, count: f64) {
        self.contacts.insert(count);
    }

    /// Record energy drift between consecutive steps.
    pub fn record_energy_drift(&mut self, drift: f64) {
        self.energy_drift.insert(drift.abs());
    }

    /// Record a collision pair observation (for unique pair counting).
    ///
    /// `pair_hash` should be a deterministic hash of the two body IDs
    /// (e.g. `min(a,b) << 32 | max(a,b)`).
    pub fn record_collision_pair(&mut self, pair_hash: u64) {
        self.collision_pairs.insert_hash(pair_hash);
    }

    /// Estimated p50 step time.
    pub fn step_time_p50(&self) -> f64 {
        self.step_time.quantile(0.5)
    }

    /// Estimated p99 step time.
    pub fn step_time_p99(&self) -> f64 {
        self.step_time.quantile(0.99)
    }

    /// Estimated p50 contact count.
    pub fn contacts_p50(&self) -> f64 {
        self.contacts.quantile(0.5)
    }

    /// Estimated p99 contact count.
    pub fn contacts_p99(&self) -> f64 {
        self.contacts.quantile(0.99)
    }

    /// Estimated p99 energy drift (should stay near zero for stable sims).
    pub fn energy_drift_p99(&self) -> f64 {
        self.energy_drift.quantile(0.99)
    }

    /// Estimated unique collision pairs observed.
    pub fn unique_collision_pairs(&self) -> f64 {
        self.collision_pairs.cardinality()
    }

    /// Total simulation steps recorded.
    pub fn total_steps(&self) -> u64 {
        self.total_steps
    }
}

impl Default for PhysicsTelemetry {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_physics_telemetry() {
        let mut tel = PhysicsTelemetry::new();

        for step in 0u64..200 {
            tel.record_step_time(100.0 + (step % 20) as f64);
            tel.record_contacts((step % 10) as f64);
            tel.record_energy_drift(0.001 * step as f64);

            let pair = (step % 5) << 32 | ((step + 1) % 5);
            tel.record_collision_pair(pair);
        }

        assert_eq!(tel.total_steps(), 200);
        assert!(tel.step_time_p50() > 0.0);
        assert!(tel.contacts_p50() >= 0.0);
        assert!(tel.energy_drift_p99() >= 0.0);
        assert!(tel.unique_collision_pairs() >= 1.0);
    }

    /// p99 estimators: for a known distribution the 99th percentile is bracketed
    /// by the sample values (DDSketch relative error), and it is monotone —
    /// feeding a larger tail raises it, feeding only small values keeps it low.
    #[test]
    fn step_time_and_contacts_p99_track_the_upper_tail() {
        let mut tel = PhysicsTelemetry::new();
        // 990 steps at 1.0 ms and 10 at 50 ms: p99 sits at the boundary of the
        // 50 ms tail (between 1 and 50, and >= the p50 of 1 ms)
        for _ in 0..990 {
            tel.record_step_time(1000.0);
            tel.record_contacts(10.0);
        }
        for _ in 0..10 {
            tel.record_step_time(50_000.0);
            tel.record_contacts(400.0);
        }
        let st99 = tel.step_time_p99();
        let ct99 = tel.contacts_p99();
        assert!(
            st99 >= tel.step_time_p50(),
            "p99 {st99} below p50 {}",
            tel.step_time_p50()
        );
        assert!(
            (1000.0 * 0.95..=50_000.0 * 1.05).contains(&st99), // DDSketch relative error
            "step p99 {st99} outside [1 ms, 50 ms]"
        );
        assert!(
            ct99 >= tel.contacts_p50() * 0.95,
            "contacts p99 {ct99} below p50"
        );
        assert!(
            (10.0 * 0.95..=400.0 * 1.05).contains(&ct99),
            "contacts p99 {ct99} outside [10, 400]"
        );
        // a uniform stream: p99 ≈ the (only) value within sketch error
        let mut flat = PhysicsTelemetry::new();
        for _ in 0..500 {
            flat.record_step_time(2000.0);
            flat.record_contacts(3.0);
        }
        assert!(
            (flat.step_time_p99() - 2000.0).abs() < 2000.0 * 0.05,
            "{}",
            flat.step_time_p99()
        );
        assert!(
            (flat.contacts_p99() - 3.0).abs() < 3.0 * 0.05,
            "{}",
            flat.contacts_p99()
        );
        // ordering: heavier tail → larger p99
        assert!(tel.step_time_p99() > flat.step_time_p99());
        assert!(tel.contacts_p99() > flat.contacts_p99());
    }
}