Skip to main content

clawft_kernel/
cognitive_tick.rs

1//! Configurable cognitive tick loop -- the heartbeat of the ECC cognitive substrate.
2//!
3//! The [`CognitiveTick`] service drives the kernel's cognitive processing
4//! cycle at a configurable interval, with optional adaptive adjustment
5//! based on measured compute timings and drift detection.
6
7use std::sync::Mutex;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11
12use crate::health::HealthStatus;
13use crate::service::{ServiceType, SystemService};
14
15// ---------------------------------------------------------------------------
16// Configuration
17// ---------------------------------------------------------------------------
18
19/// Configuration for the cognitive tick loop.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct CognitiveTickConfig {
22    /// Target tick interval in milliseconds.
23    pub tick_interval_ms: u32,
24    /// Fraction of the tick interval budget available for compute (0.0..1.0).
25    pub tick_budget_ratio: f32,
26    /// Number of ticks used for initial calibration.
27    pub calibration_ticks: u32,
28    /// Whether to adaptively adjust the tick interval based on load.
29    pub adaptive_tick: bool,
30    /// Window (in seconds) over which recent timings are averaged.
31    pub adaptive_window_s: u32,
32}
33
34impl Default for CognitiveTickConfig {
35    fn default() -> Self {
36        Self {
37            tick_interval_ms: 50,
38            tick_budget_ratio: 0.3,
39            calibration_ticks: 100,
40            adaptive_tick: true,
41            adaptive_window_s: 30,
42        }
43    }
44}
45
46// ---------------------------------------------------------------------------
47// Stats (public snapshot)
48// ---------------------------------------------------------------------------
49
50/// A point-in-time snapshot of cognitive tick statistics.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct CognitiveTickStats {
53    /// Total number of ticks recorded.
54    pub tick_count: u64,
55    /// Current (possibly adapted) tick interval in milliseconds.
56    pub current_interval_ms: u32,
57    /// Running average compute time in microseconds.
58    pub avg_compute_us: u64,
59    /// Maximum observed compute time in microseconds.
60    pub max_compute_us: u64,
61    /// Number of ticks that exceeded the compute budget.
62    pub drift_count: u64,
63    /// Whether the tick loop is currently running.
64    pub running: bool,
65}
66
67// ---------------------------------------------------------------------------
68// Internal mutable state
69// ---------------------------------------------------------------------------
70
71struct CognitiveTickState {
72    tick_count: u64,
73    current_interval_ms: u32,
74    running: bool,
75    drift_count: u64,
76    recent_timings_us: Vec<u64>,
77    max_compute_us: u64,
78}
79
80// ---------------------------------------------------------------------------
81// CognitiveTick
82// ---------------------------------------------------------------------------
83
84/// The cognitive tick service.
85///
86/// Tracks per-tick compute timings, detects budget drift, and optionally
87/// adjusts the tick interval to maintain throughput under load.
88pub struct CognitiveTick {
89    config: CognitiveTickConfig,
90    state: Mutex<CognitiveTickState>,
91}
92
93impl CognitiveTick {
94    /// Create a new cognitive tick service with the given configuration.
95    pub fn new(config: CognitiveTickConfig) -> Self {
96        let interval = config.tick_interval_ms;
97        Self {
98            config,
99            state: Mutex::new(CognitiveTickState {
100                tick_count: 0,
101                current_interval_ms: interval,
102                running: false,
103                drift_count: 0,
104                recent_timings_us: Vec::new(),
105                max_compute_us: 0,
106            }),
107        }
108    }
109
110    /// Convenience constructor that creates a default config with a custom interval.
111    pub fn with_interval(interval_ms: u32) -> Self {
112        Self::new(CognitiveTickConfig {
113            tick_interval_ms: interval_ms,
114            ..CognitiveTickConfig::default()
115        })
116    }
117
118    /// Return a point-in-time snapshot of statistics.
119    pub fn stats(&self) -> CognitiveTickStats {
120        let s = self.state.lock().unwrap();
121        let avg = if s.recent_timings_us.is_empty() {
122            0
123        } else {
124            let sum: u64 = s.recent_timings_us.iter().sum();
125            sum / s.recent_timings_us.len() as u64
126        };
127        CognitiveTickStats {
128            tick_count: s.tick_count,
129            current_interval_ms: s.current_interval_ms,
130            avg_compute_us: avg,
131            max_compute_us: s.max_compute_us,
132            drift_count: s.drift_count,
133            running: s.running,
134        }
135    }
136
137    /// Record a tick with the given compute duration in microseconds.
138    ///
139    /// This method:
140    /// 1. Increments the tick counter.
141    /// 2. Maintains a sliding window of recent timings.
142    /// 3. Updates the maximum observed compute time.
143    /// 4. Detects budget drift (compute exceeding the budget).
144    /// 5. Adaptively adjusts the tick interval if enabled.
145    pub fn record_tick(&self, compute_us: u64) {
146        let mut s = self.state.lock().unwrap();
147
148        // 1. Increment tick count.
149        s.tick_count += 1;
150
151        // 2. Maintain sliding window.
152        let window_size = self.window_capacity(s.current_interval_ms);
153        s.recent_timings_us.push(compute_us);
154        if s.recent_timings_us.len() > window_size {
155            let excess = s.recent_timings_us.len() - window_size;
156            s.recent_timings_us.drain(..excess);
157        }
158
159        // 3. Update max.
160        if compute_us > s.max_compute_us {
161            s.max_compute_us = compute_us;
162        }
163
164        // 4. Drift detection.
165        let budget_us =
166            (s.current_interval_ms as f32 * 1000.0 * self.config.tick_budget_ratio) as u64;
167        if compute_us > budget_us {
168            s.drift_count += 1;
169        }
170
171        // 5. Adaptive adjustment.
172        if self.config.adaptive_tick && !s.recent_timings_us.is_empty() {
173            let avg: u64 =
174                s.recent_timings_us.iter().sum::<u64>() / s.recent_timings_us.len() as u64;
175            let upper_threshold = (budget_us as f64 * 1.1) as u64;
176            let lower_threshold = (budget_us as f64 * 0.5) as u64;
177
178            if avg > upper_threshold {
179                // Increase interval by 10%.
180                let new_interval = (s.current_interval_ms as f64 * 1.1).round() as u32;
181                s.current_interval_ms = new_interval;
182            } else if avg < lower_threshold {
183                // Decrease interval by 10%, minimum 10ms.
184                let new_interval = (s.current_interval_ms as f64 * 0.9).round() as u32;
185                s.current_interval_ms = new_interval.max(10);
186            }
187        }
188    }
189
190    /// Whether the tick loop is currently running.
191    pub fn is_running(&self) -> bool {
192        self.state.lock().unwrap().running
193    }
194
195    /// Set the running state.
196    pub fn set_running(&self, running: bool) {
197        self.state.lock().unwrap().running = running;
198    }
199
200    /// Total number of ticks recorded.
201    pub fn tick_count(&self) -> u64 {
202        self.state.lock().unwrap().tick_count
203    }
204
205    /// Current (possibly adapted) tick interval in milliseconds.
206    pub fn current_interval_ms(&self) -> u32 {
207        self.state.lock().unwrap().current_interval_ms
208    }
209
210    /// Number of ticks that exceeded the compute budget.
211    pub fn drift_count(&self) -> u64 {
212        self.state.lock().unwrap().drift_count
213    }
214
215    /// Reset all statistics to initial values (configuration is preserved).
216    pub fn reset(&self) {
217        let mut s = self.state.lock().unwrap();
218        s.tick_count = 0;
219        s.current_interval_ms = self.config.tick_interval_ms;
220        s.running = false;
221        s.drift_count = 0;
222        s.recent_timings_us.clear();
223        s.max_compute_us = 0;
224    }
225
226    // --- private helpers ---
227
228    /// Compute the maximum number of timing samples to retain.
229    fn window_capacity(&self, interval_ms: u32) -> usize {
230        if interval_ms == 0 {
231            return 1;
232        }
233        let ticks_per_window = (self.config.adaptive_window_s * 1000) / interval_ms;
234        (ticks_per_window as usize).max(1)
235    }
236}
237
238// ---------------------------------------------------------------------------
239// SystemService implementation
240// ---------------------------------------------------------------------------
241
242#[async_trait]
243impl SystemService for CognitiveTick {
244    fn name(&self) -> &str {
245        "ecc.cognitive_tick"
246    }
247
248    fn service_type(&self) -> ServiceType {
249        ServiceType::Core
250    }
251
252    async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
253        self.set_running(true);
254        Ok(())
255    }
256
257    async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
258        self.set_running(false);
259        Ok(())
260    }
261
262    async fn health_check(&self) -> HealthStatus {
263        if self.is_running() {
264            HealthStatus::Healthy
265        } else {
266            HealthStatus::Degraded("cognitive tick not running".into())
267        }
268    }
269}
270
271// ---------------------------------------------------------------------------
272// Tests
273// ---------------------------------------------------------------------------
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn default_config() {
281        let cfg = CognitiveTickConfig::default();
282        assert_eq!(cfg.tick_interval_ms, 50);
283        assert!((cfg.tick_budget_ratio - 0.3).abs() < f32::EPSILON);
284        assert_eq!(cfg.calibration_ticks, 100);
285        assert!(cfg.adaptive_tick);
286        assert_eq!(cfg.adaptive_window_s, 30);
287    }
288
289    #[test]
290    fn new_with_config() {
291        let cfg = CognitiveTickConfig {
292            tick_interval_ms: 100,
293            tick_budget_ratio: 0.5,
294            calibration_ticks: 200,
295            adaptive_tick: false,
296            adaptive_window_s: 60,
297        };
298        let ct = CognitiveTick::new(cfg.clone());
299        assert_eq!(ct.current_interval_ms(), 100);
300        assert!(!ct.is_running());
301    }
302
303    #[test]
304    fn with_interval() {
305        let ct = CognitiveTick::with_interval(75);
306        assert_eq!(ct.current_interval_ms(), 75);
307        // Other fields should be defaults.
308        assert!(ct.config.adaptive_tick);
309        assert_eq!(ct.config.calibration_ticks, 100);
310    }
311
312    #[test]
313    fn stats_initial() {
314        let ct = CognitiveTick::new(CognitiveTickConfig::default());
315        let s = ct.stats();
316        assert_eq!(s.tick_count, 0);
317        assert_eq!(s.current_interval_ms, 50);
318        assert_eq!(s.avg_compute_us, 0);
319        assert_eq!(s.max_compute_us, 0);
320        assert_eq!(s.drift_count, 0);
321        assert!(!s.running);
322    }
323
324    #[test]
325    fn record_tick_increments_count() {
326        let ct = CognitiveTick::new(CognitiveTickConfig::default());
327        ct.record_tick(100);
328        ct.record_tick(200);
329        ct.record_tick(300);
330        assert_eq!(ct.tick_count(), 3);
331    }
332
333    #[test]
334    fn record_tick_updates_max() {
335        let ct = CognitiveTick::new(CognitiveTickConfig::default());
336        ct.record_tick(100);
337        ct.record_tick(500);
338        ct.record_tick(200);
339        assert_eq!(ct.stats().max_compute_us, 500);
340    }
341
342    #[test]
343    fn is_running_default_false() {
344        let ct = CognitiveTick::new(CognitiveTickConfig::default());
345        assert!(!ct.is_running());
346    }
347
348    #[test]
349    fn set_running() {
350        let ct = CognitiveTick::new(CognitiveTickConfig::default());
351        ct.set_running(true);
352        assert!(ct.is_running());
353        ct.set_running(false);
354        assert!(!ct.is_running());
355    }
356
357    #[test]
358    fn tick_count() {
359        let ct = CognitiveTick::new(CognitiveTickConfig::default());
360        assert_eq!(ct.tick_count(), 0);
361        ct.record_tick(10);
362        assert_eq!(ct.tick_count(), 1);
363    }
364
365    #[test]
366    fn current_interval_ms() {
367        let ct = CognitiveTick::with_interval(42);
368        assert_eq!(ct.current_interval_ms(), 42);
369    }
370
371    #[test]
372    fn drift_detection() {
373        // Default: interval=50ms, budget_ratio=0.3 => budget = 50*1000*0.3 = 15000us
374        let mut cfg = CognitiveTickConfig::default();
375        cfg.adaptive_tick = false; // disable adaptive so interval stays constant
376        let ct = CognitiveTick::new(cfg);
377
378        // Under budget: no drift.
379        ct.record_tick(10_000);
380        assert_eq!(ct.drift_count(), 0);
381
382        // Exactly at budget boundary (15000): not exceeding, no drift.
383        ct.record_tick(15_000);
384        assert_eq!(ct.drift_count(), 0);
385
386        // Over budget.
387        ct.record_tick(16_000);
388        assert_eq!(ct.drift_count(), 1);
389
390        // Another over budget.
391        ct.record_tick(20_000);
392        assert_eq!(ct.drift_count(), 2);
393    }
394
395    #[test]
396    fn adaptive_increase() {
397        // Set up a config where budget is small so we can easily exceed 1.1x.
398        // interval=50ms, ratio=0.3 => budget = 15000us, upper = 16500us
399        let cfg = CognitiveTickConfig {
400            tick_interval_ms: 50,
401            tick_budget_ratio: 0.3,
402            calibration_ticks: 100,
403            adaptive_tick: true,
404            adaptive_window_s: 30,
405        };
406        let ct = CognitiveTick::new(cfg);
407
408        // Record many ticks with compute well above the upper threshold (16500us).
409        for _ in 0..20 {
410            ct.record_tick(20_000);
411        }
412
413        // Interval should have increased from 50.
414        assert!(
415            ct.current_interval_ms() > 50,
416            "expected interval > 50, got {}",
417            ct.current_interval_ms()
418        );
419    }
420
421    #[test]
422    fn adaptive_decrease() {
423        // interval=100ms, ratio=0.3 => budget = 30000us, lower = 15000us
424        let cfg = CognitiveTickConfig {
425            tick_interval_ms: 100,
426            tick_budget_ratio: 0.3,
427            calibration_ticks: 100,
428            adaptive_tick: true,
429            adaptive_window_s: 30,
430        };
431        let ct = CognitiveTick::new(cfg);
432
433        // Record many ticks with compute well below the lower threshold.
434        for _ in 0..20 {
435            ct.record_tick(1_000);
436        }
437
438        // Interval should have decreased from 100.
439        assert!(
440            ct.current_interval_ms() < 100,
441            "expected interval < 100, got {}",
442            ct.current_interval_ms()
443        );
444    }
445
446    #[test]
447    fn adaptive_min_interval() {
448        // Start with a small interval so it can shrink toward the minimum.
449        let cfg = CognitiveTickConfig {
450            tick_interval_ms: 12,
451            tick_budget_ratio: 0.3,
452            calibration_ticks: 100,
453            adaptive_tick: true,
454            adaptive_window_s: 30,
455        };
456        let ct = CognitiveTick::new(cfg);
457
458        // Record very fast ticks to push the interval down.
459        for _ in 0..200 {
460            ct.record_tick(1);
461        }
462
463        // Interval must never go below 10ms.
464        assert!(
465            ct.current_interval_ms() >= 10,
466            "expected interval >= 10, got {}",
467            ct.current_interval_ms()
468        );
469    }
470
471    #[test]
472    fn reset_clears_stats() {
473        let ct = CognitiveTick::with_interval(80);
474        ct.set_running(true);
475        ct.record_tick(5_000);
476        ct.record_tick(50_000);
477
478        // Verify non-zero state.
479        assert!(ct.tick_count() > 0);
480        assert!(ct.stats().max_compute_us > 0);
481        assert!(ct.is_running());
482
483        ct.reset();
484
485        assert_eq!(ct.tick_count(), 0);
486        assert_eq!(ct.stats().max_compute_us, 0);
487        assert_eq!(ct.stats().avg_compute_us, 0);
488        assert_eq!(ct.drift_count(), 0);
489        assert!(!ct.is_running());
490        // Interval should be reset to config value.
491        assert_eq!(ct.current_interval_ms(), 80);
492    }
493
494    #[tokio::test]
495    async fn service_name_and_type() {
496        let ct = CognitiveTick::new(CognitiveTickConfig::default());
497        assert_eq!(ct.name(), "ecc.cognitive_tick");
498        assert_eq!(ct.service_type(), ServiceType::Core);
499    }
500
501    #[tokio::test]
502    async fn service_start_stop() {
503        let ct = CognitiveTick::new(CognitiveTickConfig::default());
504        assert!(!ct.is_running());
505
506        ct.start().await.unwrap();
507        assert!(ct.is_running());
508
509        ct.stop().await.unwrap();
510        assert!(!ct.is_running());
511    }
512
513    #[tokio::test]
514    async fn health_check_reflects_running() {
515        let ct = CognitiveTick::new(CognitiveTickConfig::default());
516        assert_eq!(
517            ct.health_check().await,
518            HealthStatus::Degraded("cognitive tick not running".into())
519        );
520
521        ct.start().await.unwrap();
522        assert_eq!(ct.health_check().await, HealthStatus::Healthy);
523    }
524
525    #[test]
526    fn config_serde_roundtrip() {
527        let cfg = CognitiveTickConfig::default();
528        let json = serde_json::to_string(&cfg).unwrap();
529        let restored: CognitiveTickConfig = serde_json::from_str(&json).unwrap();
530        assert_eq!(restored.tick_interval_ms, cfg.tick_interval_ms);
531        assert!((restored.tick_budget_ratio - cfg.tick_budget_ratio).abs() < f32::EPSILON);
532    }
533
534    #[test]
535    fn stats_serde_roundtrip() {
536        let ct = CognitiveTick::new(CognitiveTickConfig::default());
537        ct.record_tick(1234);
538        let stats = ct.stats();
539        let json = serde_json::to_string(&stats).unwrap();
540        let restored: CognitiveTickStats = serde_json::from_str(&json).unwrap();
541        assert_eq!(restored.tick_count, 1);
542        assert_eq!(restored.avg_compute_us, 1234);
543    }
544}