Skip to main content

soma_som_core/
timing.rs

1// SPDX-License-Identifier: LGPL-3.0-only
2#![allow(missing_docs)]
3
4//! Timing configuration and enforcement (Criterion 3 — bounded completion).
5//!
6//! ## Spec traceability
7//! - Criterion 3: a complete ring traversal must complete within Δt_max.
8//! - Contracts §4.1 Requirement 6: timeout enforcement.
9//! - SAD §7.3: δ_u ≤ δ_max_u per unit; Σδ_u ≤ Δt_max per cycle.
10//!
11//! `TimingConfig` and `TimingError` are §13.1 structural primitives.
12//! `TimingError` is decoupled from the engine-tier `BoundaryError` so that
13//! soma-som-core carries no upward dependency on the engine.
14
15use std::time::{Duration, Instant};
16
17use crate::types::UnitId;
18
19/// Default per-unit timeout: 1 second (SAD §7.3).
20pub const DEFAULT_UNIT_TIMEOUT_MS: u64 = 1_000;
21
22/// Default per-cycle timeout: 10 seconds (SAD §7.3).
23pub const DEFAULT_CYCLE_TIMEOUT_MS: u64 = 10_000;
24
25/// Timing errors produced by the cycle timer.
26#[derive(Debug, Clone, thiserror::Error)]
27#[non_exhaustive]
28pub enum TimingError {
29    /// Per-unit processing time exceeded δ_u^max.
30    #[error("Unit timeout: {unit} took {elapsed_ms}ms, exceeds δ_max={max_ms}ms")]
31    UnitTimeout {
32        unit: UnitId,
33        elapsed_ms: u64,
34        max_ms: u64,
35    },
36
37    /// Total cycle time exceeded Δt_max.
38    #[error("Cycle timeout: {elapsed_ms}ms exceeds Δt_max={max_ms}ms")]
39    CycleTimeout { elapsed_ms: u64, max_ms: u64 },
40
41    /// `end_unit` called for a unit that had no `start_unit`.
42    #[error("Timing protocol violation: end_unit({unit}) called before start_unit({unit})")]
43    TimingProtocolViolation { unit: UnitId },
44}
45
46/// Timing configuration for the boundary (all values in milliseconds).
47#[derive(Debug, Clone, Copy)]
48pub struct TimingConfig {
49    /// Maximum allowed time per unit (δ_max_u).
50    pub unit_timeout_ms: u64,
51
52    /// Maximum allowed time per complete cycle (Δt_max).
53    pub cycle_timeout_ms: u64,
54}
55
56impl Default for TimingConfig {
57    fn default() -> Self {
58        Self {
59            unit_timeout_ms: DEFAULT_UNIT_TIMEOUT_MS,
60            cycle_timeout_ms: DEFAULT_CYCLE_TIMEOUT_MS,
61        }
62    }
63}
64
65/// Per-unit timing record.
66#[derive(Debug, Clone, Copy)]
67pub struct UnitTiming {
68    pub unit_id: UnitId,
69    pub start: Instant,
70    pub end: Option<Instant>,
71}
72
73impl UnitTiming {
74    pub fn elapsed(&self) -> Duration {
75        match self.end {
76            Some(end) => end.duration_since(self.start),
77            None => self.start.elapsed(),
78        }
79    }
80
81    pub fn elapsed_ms(&self) -> u64 {
82        self.elapsed().as_millis() as u64
83    }
84}
85
86/// Cycle-level timing tracker.
87///
88/// Tracks per-unit timings and enforces both per-unit and per-cycle timeouts.
89#[derive(Debug)]
90pub struct CycleTimer {
91    config: TimingConfig,
92    cycle_start: Instant,
93    unit_timings: [Option<UnitTiming>; 6],
94}
95
96impl CycleTimer {
97    pub fn new(config: TimingConfig) -> Self {
98        Self {
99            config,
100            cycle_start: Instant::now(),
101            unit_timings: [None; 6],
102        }
103    }
104
105    pub fn with_defaults() -> Self {
106        Self::new(TimingConfig::default())
107    }
108
109    #[allow(clippy::indexing_slicing)]
110    pub fn start_unit(&mut self, unit_id: UnitId) {
111        self.unit_timings[unit_id.index()] = Some(UnitTiming {
112            unit_id,
113            start: Instant::now(),
114            end: None,
115        });
116    }
117
118    #[allow(clippy::indexing_slicing)]
119    pub fn end_unit(&mut self, unit_id: UnitId) -> Result<Duration, TimingError> {
120        let timing = self.unit_timings[unit_id.index()]
121            .as_mut()
122            .ok_or(TimingError::TimingProtocolViolation { unit: unit_id })?;
123
124        timing.end = Some(Instant::now());
125        let elapsed = timing.elapsed();
126        let elapsed_ms = elapsed.as_millis() as u64;
127
128        if elapsed_ms > self.config.unit_timeout_ms {
129            return Err(TimingError::UnitTimeout {
130                unit: unit_id,
131                elapsed_ms,
132                max_ms: self.config.unit_timeout_ms,
133            });
134        }
135
136        Ok(elapsed)
137    }
138
139    pub fn check_cycle_timeout(&self) -> Result<(), TimingError> {
140        let elapsed_ms = self.cycle_elapsed_ms();
141        if elapsed_ms > self.config.cycle_timeout_ms {
142            return Err(TimingError::CycleTimeout {
143                elapsed_ms,
144                max_ms: self.config.cycle_timeout_ms,
145            });
146        }
147        Ok(())
148    }
149
150    pub fn cycle_elapsed_ms(&self) -> u64 {
151        self.cycle_start.elapsed().as_millis() as u64
152    }
153
154    pub fn cycle_elapsed(&self) -> Duration {
155        self.cycle_start.elapsed()
156    }
157
158    #[allow(clippy::indexing_slicing)]
159    pub fn unit_timing(&self, unit_id: UnitId) -> Option<&UnitTiming> {
160        self.unit_timings[unit_id.index()].as_ref()
161    }
162
163    pub fn config(&self) -> &TimingConfig {
164        &self.config
165    }
166
167    pub fn total_unit_time_ms(&self) -> u64 {
168        self.unit_timings
169            .iter()
170            .filter_map(|t| t.as_ref())
171            .filter(|t| t.end.is_some())
172            .map(|t| t.elapsed_ms())
173            .sum()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use std::thread;
181
182    #[test]
183    fn default_config_has_spec_values() {
184        let c = TimingConfig::default();
185        assert_eq!(c.unit_timeout_ms, 1_000);
186        assert_eq!(c.cycle_timeout_ms, 10_000);
187    }
188
189    #[test]
190    fn cycle_timer_starts_immediately() {
191        let timer = CycleTimer::with_defaults();
192        assert!(timer.cycle_elapsed().as_nanos() > 0);
193    }
194
195    #[test]
196    fn unit_timing_roundtrip() {
197        let mut timer = CycleTimer::with_defaults();
198        timer.start_unit(UnitId::FU);
199        thread::sleep(Duration::from_millis(5));
200        let result = timer.end_unit(UnitId::FU);
201        assert!(result.is_ok());
202        assert!(result.unwrap().as_millis() >= 4);
203    }
204
205    #[test]
206    fn unit_timeout_enforced() {
207        let config = TimingConfig { unit_timeout_ms: 1, cycle_timeout_ms: 10_000 };
208        let mut timer = CycleTimer::new(config);
209        timer.start_unit(UnitId::FU);
210        thread::sleep(Duration::from_millis(10));
211        assert!(matches!(timer.end_unit(UnitId::FU), Err(TimingError::UnitTimeout { .. })));
212    }
213
214    #[test]
215    fn cycle_timeout_enforced() {
216        let config = TimingConfig { unit_timeout_ms: 10_000, cycle_timeout_ms: 1 };
217        let timer = CycleTimer::new(config);
218        thread::sleep(Duration::from_millis(10));
219        assert!(matches!(timer.check_cycle_timeout(), Err(TimingError::CycleTimeout { .. })));
220    }
221}