soma-som-core 0.1.0

Universal soma(som) structural primitives — Quad / Tree / Ring / Genesis / Fingerprint / TemporalLedger / CrossingRecord
Documentation
// SPDX-License-Identifier: LGPL-3.0-only
#![allow(missing_docs)]

//! Timing configuration and enforcement (Criterion 3 — bounded completion).
//!
//! ## Spec traceability
//! - Criterion 3: a complete ring traversal must complete within Δt_max.
//! - Contracts §4.1 Requirement 6: timeout enforcement.
//! - SAD §7.3: δ_u ≤ δ_max_u per unit; Σδ_u ≤ Δt_max per cycle.
//!
//! `TimingConfig` and `TimingError` are §13.1 structural primitives.
//! `TimingError` is decoupled from the engine-tier `BoundaryError` so that
//! soma-som-core carries no upward dependency on the engine.

use std::time::{Duration, Instant};

use crate::types::UnitId;

/// Default per-unit timeout: 1 second (SAD §7.3).
pub const DEFAULT_UNIT_TIMEOUT_MS: u64 = 1_000;

/// Default per-cycle timeout: 10 seconds (SAD §7.3).
pub const DEFAULT_CYCLE_TIMEOUT_MS: u64 = 10_000;

/// Timing errors produced by the cycle timer.
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum TimingError {
    /// Per-unit processing time exceeded δ_u^max.
    #[error("Unit timeout: {unit} took {elapsed_ms}ms, exceeds δ_max={max_ms}ms")]
    UnitTimeout {
        unit: UnitId,
        elapsed_ms: u64,
        max_ms: u64,
    },

    /// Total cycle time exceeded Δt_max.
    #[error("Cycle timeout: {elapsed_ms}ms exceeds Δt_max={max_ms}ms")]
    CycleTimeout { elapsed_ms: u64, max_ms: u64 },

    /// `end_unit` called for a unit that had no `start_unit`.
    #[error("Timing protocol violation: end_unit({unit}) called before start_unit({unit})")]
    TimingProtocolViolation { unit: UnitId },
}

/// Timing configuration for the boundary (all values in milliseconds).
#[derive(Debug, Clone, Copy)]
pub struct TimingConfig {
    /// Maximum allowed time per unit (δ_max_u).
    pub unit_timeout_ms: u64,

    /// Maximum allowed time per complete cycle (Δt_max).
    pub cycle_timeout_ms: u64,
}

impl Default for TimingConfig {
    fn default() -> Self {
        Self {
            unit_timeout_ms: DEFAULT_UNIT_TIMEOUT_MS,
            cycle_timeout_ms: DEFAULT_CYCLE_TIMEOUT_MS,
        }
    }
}

/// Per-unit timing record.
#[derive(Debug, Clone, Copy)]
pub struct UnitTiming {
    pub unit_id: UnitId,
    pub start: Instant,
    pub end: Option<Instant>,
}

impl UnitTiming {
    pub fn elapsed(&self) -> Duration {
        match self.end {
            Some(end) => end.duration_since(self.start),
            None => self.start.elapsed(),
        }
    }

    pub fn elapsed_ms(&self) -> u64 {
        self.elapsed().as_millis() as u64
    }
}

/// Cycle-level timing tracker.
///
/// Tracks per-unit timings and enforces both per-unit and per-cycle timeouts.
#[derive(Debug)]
pub struct CycleTimer {
    config: TimingConfig,
    cycle_start: Instant,
    unit_timings: [Option<UnitTiming>; 6],
}

impl CycleTimer {
    pub fn new(config: TimingConfig) -> Self {
        Self {
            config,
            cycle_start: Instant::now(),
            unit_timings: [None; 6],
        }
    }

    pub fn with_defaults() -> Self {
        Self::new(TimingConfig::default())
    }

    #[allow(clippy::indexing_slicing)]
    pub fn start_unit(&mut self, unit_id: UnitId) {
        self.unit_timings[unit_id.index()] = Some(UnitTiming {
            unit_id,
            start: Instant::now(),
            end: None,
        });
    }

    #[allow(clippy::indexing_slicing)]
    pub fn end_unit(&mut self, unit_id: UnitId) -> Result<Duration, TimingError> {
        let timing = self.unit_timings[unit_id.index()]
            .as_mut()
            .ok_or(TimingError::TimingProtocolViolation { unit: unit_id })?;

        timing.end = Some(Instant::now());
        let elapsed = timing.elapsed();
        let elapsed_ms = elapsed.as_millis() as u64;

        if elapsed_ms > self.config.unit_timeout_ms {
            return Err(TimingError::UnitTimeout {
                unit: unit_id,
                elapsed_ms,
                max_ms: self.config.unit_timeout_ms,
            });
        }

        Ok(elapsed)
    }

    pub fn check_cycle_timeout(&self) -> Result<(), TimingError> {
        let elapsed_ms = self.cycle_elapsed_ms();
        if elapsed_ms > self.config.cycle_timeout_ms {
            return Err(TimingError::CycleTimeout {
                elapsed_ms,
                max_ms: self.config.cycle_timeout_ms,
            });
        }
        Ok(())
    }

    pub fn cycle_elapsed_ms(&self) -> u64 {
        self.cycle_start.elapsed().as_millis() as u64
    }

    pub fn cycle_elapsed(&self) -> Duration {
        self.cycle_start.elapsed()
    }

    #[allow(clippy::indexing_slicing)]
    pub fn unit_timing(&self, unit_id: UnitId) -> Option<&UnitTiming> {
        self.unit_timings[unit_id.index()].as_ref()
    }

    pub fn config(&self) -> &TimingConfig {
        &self.config
    }

    pub fn total_unit_time_ms(&self) -> u64 {
        self.unit_timings
            .iter()
            .filter_map(|t| t.as_ref())
            .filter(|t| t.end.is_some())
            .map(|t| t.elapsed_ms())
            .sum()
    }
}

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

    #[test]
    fn default_config_has_spec_values() {
        let c = TimingConfig::default();
        assert_eq!(c.unit_timeout_ms, 1_000);
        assert_eq!(c.cycle_timeout_ms, 10_000);
    }

    #[test]
    fn cycle_timer_starts_immediately() {
        let timer = CycleTimer::with_defaults();
        assert!(timer.cycle_elapsed().as_nanos() > 0);
    }

    #[test]
    fn unit_timing_roundtrip() {
        let mut timer = CycleTimer::with_defaults();
        timer.start_unit(UnitId::FU);
        thread::sleep(Duration::from_millis(5));
        let result = timer.end_unit(UnitId::FU);
        assert!(result.is_ok());
        assert!(result.unwrap().as_millis() >= 4);
    }

    #[test]
    fn unit_timeout_enforced() {
        let config = TimingConfig { unit_timeout_ms: 1, cycle_timeout_ms: 10_000 };
        let mut timer = CycleTimer::new(config);
        timer.start_unit(UnitId::FU);
        thread::sleep(Duration::from_millis(10));
        assert!(matches!(timer.end_unit(UnitId::FU), Err(TimingError::UnitTimeout { .. })));
    }

    #[test]
    fn cycle_timeout_enforced() {
        let config = TimingConfig { unit_timeout_ms: 10_000, cycle_timeout_ms: 1 };
        let timer = CycleTimer::new(config);
        thread::sleep(Duration::from_millis(10));
        assert!(matches!(timer.check_cycle_timeout(), Err(TimingError::CycleTimeout { .. })));
    }
}