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//!
7//! The [`run_democritus_loop`] function implements the DEMOCRITUS two-tier
8//! coherence cycle: O(1) EML prediction on every tick, falling back to
9//! exact O(k*m) Lanczos spectral analysis when drift exceeds a threshold.
10
11use std::sync::Mutex;
12
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15
16use crate::health::HealthStatus;
17use crate::service::{ServiceType, SystemService};
18
19// ---------------------------------------------------------------------------
20// Configuration
21// ---------------------------------------------------------------------------
22
23/// Configuration for the cognitive tick loop.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CognitiveTickConfig {
26    /// Target tick interval in milliseconds.
27    pub tick_interval_ms: u32,
28    /// Fraction of the tick interval budget available for compute (0.0..1.0).
29    pub tick_budget_ratio: f32,
30    /// Number of ticks used for initial calibration.
31    pub calibration_ticks: u32,
32    /// Whether to adaptively adjust the tick interval based on load.
33    pub adaptive_tick: bool,
34    /// Window (in seconds) over which recent timings are averaged.
35    pub adaptive_window_s: u32,
36}
37
38impl Default for CognitiveTickConfig {
39    fn default() -> Self {
40        Self {
41            tick_interval_ms: 50,
42            tick_budget_ratio: 0.3,
43            calibration_ticks: 100,
44            adaptive_tick: true,
45            adaptive_window_s: 30,
46        }
47    }
48}
49
50// ---------------------------------------------------------------------------
51// Stats (public snapshot)
52// ---------------------------------------------------------------------------
53
54/// A point-in-time snapshot of cognitive tick statistics.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CognitiveTickStats {
57    /// Total number of ticks recorded.
58    pub tick_count: u64,
59    /// Current (possibly adapted) tick interval in milliseconds.
60    pub current_interval_ms: u32,
61    /// Running average compute time in microseconds.
62    pub avg_compute_us: u64,
63    /// Maximum observed compute time in microseconds.
64    pub max_compute_us: u64,
65    /// Number of ticks that exceeded the compute budget.
66    pub drift_count: u64,
67    /// Whether the tick loop is currently running.
68    pub running: bool,
69}
70
71// ---------------------------------------------------------------------------
72// Internal mutable state
73// ---------------------------------------------------------------------------
74
75struct CognitiveTickState {
76    tick_count: u64,
77    current_interval_ms: u32,
78    running: bool,
79    drift_count: u64,
80    recent_timings_us: Vec<u64>,
81    max_compute_us: u64,
82}
83
84// ---------------------------------------------------------------------------
85// CognitiveTick
86// ---------------------------------------------------------------------------
87
88/// The cognitive tick service.
89///
90/// Tracks per-tick compute timings, detects budget drift, and optionally
91/// adjusts the tick interval to maintain throughput under load.
92pub struct CognitiveTick {
93    config: CognitiveTickConfig,
94    state: Mutex<CognitiveTickState>,
95}
96
97impl CognitiveTick {
98    /// Create a new cognitive tick service with the given configuration.
99    pub fn new(config: CognitiveTickConfig) -> Self {
100        let interval = config.tick_interval_ms;
101        Self {
102            config,
103            state: Mutex::new(CognitiveTickState {
104                tick_count: 0,
105                current_interval_ms: interval,
106                running: false,
107                drift_count: 0,
108                recent_timings_us: Vec::new(),
109                max_compute_us: 0,
110            }),
111        }
112    }
113
114    /// Convenience constructor that creates a default config with a custom interval.
115    pub fn with_interval(interval_ms: u32) -> Self {
116        Self::new(CognitiveTickConfig {
117            tick_interval_ms: interval_ms,
118            ..CognitiveTickConfig::default()
119        })
120    }
121
122    /// Return a point-in-time snapshot of statistics.
123    pub fn stats(&self) -> CognitiveTickStats {
124        let s = self.state.lock().unwrap();
125        let avg = if s.recent_timings_us.is_empty() {
126            0
127        } else {
128            let sum: u64 = s.recent_timings_us.iter().sum();
129            sum / s.recent_timings_us.len() as u64
130        };
131        CognitiveTickStats {
132            tick_count: s.tick_count,
133            current_interval_ms: s.current_interval_ms,
134            avg_compute_us: avg,
135            max_compute_us: s.max_compute_us,
136            drift_count: s.drift_count,
137            running: s.running,
138        }
139    }
140
141    /// Record a tick with the given compute duration in microseconds.
142    ///
143    /// This method:
144    /// 1. Increments the tick counter.
145    /// 2. Maintains a sliding window of recent timings.
146    /// 3. Updates the maximum observed compute time.
147    /// 4. Detects budget drift (compute exceeding the budget).
148    /// 5. Adaptively adjusts the tick interval if enabled.
149    pub fn record_tick(&self, compute_us: u64) {
150        let mut s = self.state.lock().unwrap();
151
152        // 1. Increment tick count.
153        s.tick_count += 1;
154
155        // 2. Maintain sliding window.
156        let window_size = self.window_capacity(s.current_interval_ms);
157        s.recent_timings_us.push(compute_us);
158        if s.recent_timings_us.len() > window_size {
159            let excess = s.recent_timings_us.len() - window_size;
160            s.recent_timings_us.drain(..excess);
161        }
162
163        // 3. Update max.
164        if compute_us > s.max_compute_us {
165            s.max_compute_us = compute_us;
166        }
167
168        // 4. Drift detection.
169        let budget_us =
170            (s.current_interval_ms as f32 * 1000.0 * self.config.tick_budget_ratio) as u64;
171        if compute_us > budget_us {
172            s.drift_count += 1;
173        }
174
175        // 5. Adaptive adjustment.
176        if self.config.adaptive_tick && !s.recent_timings_us.is_empty() {
177            let avg: u64 =
178                s.recent_timings_us.iter().sum::<u64>() / s.recent_timings_us.len() as u64;
179            let upper_threshold = (budget_us as f64 * 1.1) as u64;
180            let lower_threshold = (budget_us as f64 * 0.5) as u64;
181
182            if avg > upper_threshold {
183                // Increase interval by 10%.
184                let new_interval = (s.current_interval_ms as f64 * 1.1).round() as u32;
185                s.current_interval_ms = new_interval;
186            } else if avg < lower_threshold {
187                // Decrease interval by 10%, minimum 10ms.
188                let new_interval = (s.current_interval_ms as f64 * 0.9).round() as u32;
189                s.current_interval_ms = new_interval.max(10);
190            }
191        }
192    }
193
194    /// Whether the tick loop is currently running.
195    pub fn is_running(&self) -> bool {
196        self.state.lock().unwrap().running
197    }
198
199    /// Set the running state.
200    pub fn set_running(&self, running: bool) {
201        self.state.lock().unwrap().running = running;
202    }
203
204    /// Total number of ticks recorded.
205    pub fn tick_count(&self) -> u64 {
206        self.state.lock().unwrap().tick_count
207    }
208
209    /// Current (possibly adapted) tick interval in milliseconds.
210    pub fn current_interval_ms(&self) -> u32 {
211        self.state.lock().unwrap().current_interval_ms
212    }
213
214    /// Number of ticks that exceeded the compute budget.
215    pub fn drift_count(&self) -> u64 {
216        self.state.lock().unwrap().drift_count
217    }
218
219    /// Reset all statistics to initial values (configuration is preserved).
220    pub fn reset(&self) {
221        let mut s = self.state.lock().unwrap();
222        s.tick_count = 0;
223        s.current_interval_ms = self.config.tick_interval_ms;
224        s.running = false;
225        s.drift_count = 0;
226        s.recent_timings_us.clear();
227        s.max_compute_us = 0;
228    }
229
230    // --- private helpers ---
231
232    /// Compute the maximum number of timing samples to retain.
233    fn window_capacity(&self, interval_ms: u32) -> usize {
234        if interval_ms == 0 {
235            return 1;
236        }
237        let ticks_per_window = (self.config.adaptive_window_s * 1000) / interval_ms;
238        (ticks_per_window as usize).max(1)
239    }
240}
241
242// ---------------------------------------------------------------------------
243// SystemService implementation
244// ---------------------------------------------------------------------------
245
246#[async_trait]
247impl SystemService for CognitiveTick {
248    fn name(&self) -> &str {
249        "ecc.cognitive_tick"
250    }
251
252    fn service_type(&self) -> ServiceType {
253        ServiceType::Core
254    }
255
256    async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
257        self.set_running(true);
258        Ok(())
259    }
260
261    async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
262        self.set_running(false);
263        Ok(())
264    }
265
266    async fn health_check(&self) -> HealthStatus {
267        if self.is_running() {
268            HealthStatus::Healthy
269        } else {
270            HealthStatus::Degraded("cognitive tick not running".into())
271        }
272    }
273}
274
275// ---------------------------------------------------------------------------
276// DEMOCRITUS two-tier coherence loop
277// ---------------------------------------------------------------------------
278
279/// Run the DEMOCRITUS two-tier coherence loop.
280///
281/// This is spawned as a background tokio task during kernel boot. On every
282/// cognitive tick it performs:
283///
284/// 1. **SENSE** -- gather cheap graph metadata (node/edge counts).
285/// 2. **THINK (fast)** -- O(1) EML coherence prediction.
286/// 3. **DETECT DRIFT** -- compare prediction against last exact value.
287/// 4. **THINK (exact)** -- O(k*m) Lanczos spectral analysis (only when drift
288///    exceeds threshold, or periodically to maintain calibration).
289/// 5. **LOG** -- drain EML events and append to ExoChain.
290/// 6. **COMMIT** -- record timing in the adaptive tick system.
291///
292/// The EML model is retrained periodically from accumulated exact samples so
293/// the fast path becomes more accurate over time.
294#[cfg(feature = "ecc")]
295pub async fn run_democritus_loop(
296    tick: std::sync::Arc<CognitiveTick>,
297    causal: std::sync::Arc<crate::causal::CausalGraph>,
298    hnsw: std::sync::Arc<crate::hnsw_service::HnswService>,
299    eml: std::sync::Arc<Mutex<crate::eml_coherence::EmlCoherenceModel>>,
300) {
301    run_democritus_loop_with_chain(tick, causal, hnsw, eml, None).await;
302}
303
304/// Run the DEMOCRITUS loop with optional ExoChain logging.
305///
306/// When `chain_manager` is `Some`, EML lifecycle events (training,
307/// drift detection) are appended to the ExoChain audit trail.
308#[cfg(feature = "ecc")]
309pub async fn run_democritus_loop_with_chain(
310    tick: std::sync::Arc<CognitiveTick>,
311    causal: std::sync::Arc<crate::causal::CausalGraph>,
312    hnsw: std::sync::Arc<crate::hnsw_service::HnswService>,
313    eml: std::sync::Arc<Mutex<crate::eml_coherence::EmlCoherenceModel>>,
314    chain_manager: Option<std::sync::Arc<crate::chain::ChainManager>>,
315) {
316    use crate::causal_predict::{ConversationState, detect_conversation_cycle};
317    use crate::eml_coherence::GraphFeatures;
318    use std::collections::VecDeque;
319    use std::time::Instant;
320
321    let drift_threshold = 0.05; // trigger exact when fast prediction drifts >5%
322    let exact_every_n: u64 = 100; // force exact every 100 ticks regardless
323    let train_every_n: usize = 1000; // retrain model every 1000 exact samples
324    // Cycle-detector window. `coherence_history` stays capped at this
325    // size (Finding #3): `detect_conversation_cycle` only reads the
326    // tail anyway, so an unbounded Vec was pure leak.
327    let cycle_window: usize = 20;
328    // Steady-state suppression for the "stuck" warning (Finding #2).
329    // Once we enter a Stuck/Oscillating phase we log once, then
330    // exponentially back off: log on check 1, 2, 4, 8, 16, … up to
331    // `stuck_suppress_cap` consecutive checks before logging again.
332    // Entering/leaving the phase always logs (edge-triggered).
333    let stuck_suppress_cap: u64 = 256;
334
335    // `Option<f64>` sentinel instead of `last == 0.0` (Finding #1).
336    // The old sentinel stayed true forever on an empty causal graph
337    // because `spectral_analysis` returned lambda_2 = 0.0 every time,
338    // so `needs_exact` was always true and Lanczos ran every tick.
339    let mut last_exact_coherence: Option<f64> = None;
340    let mut ticks_since_exact: u64 = 0;
341    let mut coherence_history: VecDeque<f64> = VecDeque::with_capacity(cycle_window);
342    let mut exact_tick_count: u64 = 0;
343    // Edge-trigger + suppression state for the stuck-warning.
344    let mut in_stuck_phase = false;
345    let mut stuck_checks_since_log: u64 = 0;
346    let mut next_log_at: u64 = 1;
347    // Edge-trigger for the idle-graph notice. While the causal graph
348    // has fewer than 2 nodes the spectral path is structurally
349    // incapable of producing a non-zero `lambda_2` (see
350    // `CausalGraph::spectral_analysis_rff` early-return), so any
351    // cycle-detector verdict on that flat history is meaningless.
352    // We log the transition once on entry and once on exit instead
353    // of letting the cycle-detector emit `Stuck { net_change: 0.0 }`
354    // forever (release-gate audit
355    // `.planning/reviews/0.7.0-release-gate/02-kernel-governance.md`
356    // line 510, `17-research-streams.md` line 183).
357    let idle_node_threshold: u64 = 2;
358    let mut in_idle_phase = false;
359
360    tick.set_running(true);
361    tracing::info!(
362        "DEMOCRITUS loop started (drift_threshold={drift_threshold}, \
363         exact_every_n={exact_every_n}, train_every_n={train_every_n})"
364    );
365
366    loop {
367        let interval_ms = tick.current_interval_ms();
368        if interval_ms == 0 {
369            tracing::warn!("DEMOCRITUS loop: tick interval is 0, exiting");
370            break;
371        }
372
373        tokio::time::sleep(tokio::time::Duration::from_millis(interval_ms as u64)).await;
374
375        if !tick.is_running() {
376            tracing::info!("DEMOCRITUS loop: tick stopped, exiting");
377            break;
378        }
379
380        let start = Instant::now();
381
382        // ── SENSE ──────────────────────────────────────────────────────
383        // Gather graph state (cheap metadata, no locking on DashMap reads).
384        let _node_count = causal.node_count();
385        let _edge_count = causal.edge_count();
386        let _hnsw_count = hnsw.len();
387
388        // ── THINK (fast path) ──────────────────────────────────────────
389        // O(1) EML coherence prediction from graph features.
390        let features = GraphFeatures::from_causal_graph(&causal);
391        let prediction = match eml.lock() {
392            Ok(model) => model.predict(&features),
393            Err(poisoned) => {
394                tracing::error!("DEMOCRITUS loop: EML model lock poisoned, exiting");
395                // Clear the poison so other consumers can proceed.
396                let model = poisoned.into_inner();
397                model.predict(&features)
398                // Cannot continue safely after a poisoned lock in a loop;
399                // the next iteration would hit the same poison. Break out.
400            }
401        };
402
403        // ── DETECT DRIFT ───────────────────────────────────────────────
404        ticks_since_exact += 1;
405        let drift = match last_exact_coherence {
406            Some(last) => (prediction.lambda_2 - last).abs(),
407            None => f64::INFINITY, // first tick — force exact once
408        };
409        let needs_exact = last_exact_coherence.is_none()
410            || drift > drift_threshold
411            || ticks_since_exact >= exact_every_n;
412
413        if needs_exact {
414            // ── THINK (exact path) ─────────────────────────────────────
415            // On steady state this runs once every `exact_every_n`
416            // ticks (or when EML drift exceeds threshold). Path is
417            // size-dispatched (WEFT-361 / KG-004): Lanczos for n < 10K,
418            // RFF for larger graphs. Ground-truth Lanczos remains
419            // available via `spectral_analysis`. (Finding #4)
420            let (_method, spectral) = causal.spectral_analysis_auto();
421            let exact_lambda_2 = spectral.lambda_2;
422
423            let is_first = last_exact_coherence.is_none();
424            if is_first {
425                tracing::info!(
426                    lambda_2 = exact_lambda_2,
427                    "DEMOCRITUS: first exact coherence computed"
428                );
429            } else if drift > drift_threshold {
430                tracing::debug!(
431                    predicted = prediction.lambda_2,
432                    exact = exact_lambda_2,
433                    drift = drift,
434                    "DEMOCRITUS: drift detected, ran exact spectral analysis"
435                );
436            }
437
438            last_exact_coherence = Some(exact_lambda_2);
439            ticks_since_exact = 0;
440            exact_tick_count += 1;
441
442            // Idle-graph gate. If the causal graph is too small to
443            // produce a meaningful spectral signal, skip cycle
444            // detection: pushing 0.0 samples into history is what
445            // forces the detector into `Stuck { net_change: 0.0 }`
446            // for the lifetime of an empty daemon. We also clear
447            // any accumulated history so that when the graph wakes
448            // up the detector starts from real measurements rather
449            // than a buffer full of zero sentinels.
450            let graph_is_idle = causal.node_count() < idle_node_threshold;
451            if graph_is_idle {
452                if !in_idle_phase {
453                    tracing::info!(
454                        node_count = causal.node_count(),
455                        "DEMOCRITUS: causal graph idle (n<2), suspending cycle detection"
456                    );
457                    in_idle_phase = true;
458                    coherence_history.clear();
459                    in_stuck_phase = false;
460                    stuck_checks_since_log = 0;
461                    next_log_at = 1;
462                }
463            } else {
464                if in_idle_phase {
465                    tracing::info!(
466                        node_count = causal.node_count(),
467                        "DEMOCRITUS: causal graph active, resuming cycle detection"
468                    );
469                    in_idle_phase = false;
470                }
471
472                // Track bounded coherence history for cycle detection
473                // (Finding #3). Drop the oldest sample if we've hit the
474                // window cap so the buffer can't grow without bound.
475                if coherence_history.len() == cycle_window {
476                    coherence_history.pop_front();
477                }
478                coherence_history.push_back(exact_lambda_2);
479            }
480
481            // Run the cycle detector once per `cycle_window` exact
482            // measurements, and rate-limit the warning on steady-state
483            // stuck/oscillating phases with an exponential-backoff
484            // suppression counter (Finding #2). Entering and leaving
485            // the stuck phase are always logged (edge-triggered); the
486            // in-phase warnings drop off geometrically so the log
487            // doesn't drown.
488            if !graph_is_idle
489                && coherence_history.len() >= cycle_window
490                && exact_tick_count.is_multiple_of(cycle_window as u64)
491            {
492                let history_slice: Vec<f64> = coherence_history.iter().copied().collect();
493                let state = detect_conversation_cycle(&history_slice, cycle_window, 0.01);
494                let is_stuck = matches!(
495                    state,
496                    ConversationState::Stuck { .. } | ConversationState::Oscillating { .. }
497                );
498
499                match (in_stuck_phase, is_stuck) {
500                    (false, true) => {
501                        tracing::warn!("DEMOCRITUS: conversation entered stuck phase: {:?}", state);
502                        in_stuck_phase = true;
503                        stuck_checks_since_log = 0;
504                        next_log_at = 1;
505                    }
506                    (true, true) => {
507                        stuck_checks_since_log += 1;
508                        if stuck_checks_since_log >= next_log_at {
509                            tracing::warn!(
510                                "DEMOCRITUS: still stuck after {} checks: {:?}",
511                                stuck_checks_since_log,
512                                state
513                            );
514                            stuck_checks_since_log = 0;
515                            next_log_at = (next_log_at.saturating_mul(2)).min(stuck_suppress_cap);
516                        }
517                    }
518                    (true, false) => {
519                        tracing::info!("DEMOCRITUS: conversation left stuck phase: {:?}", state);
520                        in_stuck_phase = false;
521                        stuck_checks_since_log = 0;
522                        next_log_at = 1;
523                    }
524                    (false, false) => { /* healthy steady state */ }
525                }
526            }
527
528            // Record training data for the EML model.
529            match eml.lock() {
530                Ok(mut model) => {
531                    model.record(features, exact_lambda_2);
532
533                    // Retrain periodically once we have enough samples.
534                    let sample_count = model.training_sample_count();
535                    if sample_count >= 50 && sample_count % train_every_n == 0 {
536                        let converged = model.train();
537                        tracing::info!(
538                            sample_count = sample_count,
539                            converged = converged,
540                            "DEMOCRITUS: EML model retrained"
541                        );
542                    }
543                }
544                Err(_) => {
545                    tracing::error!(
546                        "DEMOCRITUS loop: EML model lock poisoned during record, exiting"
547                    );
548                    break;
549                }
550            }
551        }
552
553        // ── LOG (ExoChain) ─────────────────────────────────────────────
554        // Drain EML lifecycle events and append to the chain.
555        if let Some(ref cm) = chain_manager
556            && let Ok(mut model) = eml.lock()
557        {
558            for event in model.drain_events() {
559                cm.append(
560                    "eml",
561                    event.event_type(),
562                    Some(serde_json::to_value(&event).unwrap_or_default()),
563                );
564            }
565        }
566
567        // ── COMMIT ─────────────────────────────────────────────────────
568        // Record timing in the adaptive tick system.
569        let compute_us = start.elapsed().as_micros() as u64;
570        tick.record_tick(compute_us);
571    }
572
573    tick.set_running(false);
574    tracing::info!("DEMOCRITUS loop exited");
575}
576
577// ---------------------------------------------------------------------------
578// Tests
579// ---------------------------------------------------------------------------
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn default_config() {
587        let cfg = CognitiveTickConfig::default();
588        assert_eq!(cfg.tick_interval_ms, 50);
589        assert!((cfg.tick_budget_ratio - 0.3).abs() < f32::EPSILON);
590        assert_eq!(cfg.calibration_ticks, 100);
591        assert!(cfg.adaptive_tick);
592        assert_eq!(cfg.adaptive_window_s, 30);
593    }
594
595    #[test]
596    fn new_with_config() {
597        let cfg = CognitiveTickConfig {
598            tick_interval_ms: 100,
599            tick_budget_ratio: 0.5,
600            calibration_ticks: 200,
601            adaptive_tick: false,
602            adaptive_window_s: 60,
603        };
604        let ct = CognitiveTick::new(cfg.clone());
605        assert_eq!(ct.current_interval_ms(), 100);
606        assert!(!ct.is_running());
607    }
608
609    #[test]
610    fn with_interval() {
611        let ct = CognitiveTick::with_interval(75);
612        assert_eq!(ct.current_interval_ms(), 75);
613        // Other fields should be defaults.
614        assert!(ct.config.adaptive_tick);
615        assert_eq!(ct.config.calibration_ticks, 100);
616    }
617
618    #[test]
619    fn stats_initial() {
620        let ct = CognitiveTick::new(CognitiveTickConfig::default());
621        let s = ct.stats();
622        assert_eq!(s.tick_count, 0);
623        assert_eq!(s.current_interval_ms, 50);
624        assert_eq!(s.avg_compute_us, 0);
625        assert_eq!(s.max_compute_us, 0);
626        assert_eq!(s.drift_count, 0);
627        assert!(!s.running);
628    }
629
630    #[test]
631    fn record_tick_increments_count() {
632        let ct = CognitiveTick::new(CognitiveTickConfig::default());
633        ct.record_tick(100);
634        ct.record_tick(200);
635        ct.record_tick(300);
636        assert_eq!(ct.tick_count(), 3);
637    }
638
639    #[test]
640    fn record_tick_updates_max() {
641        let ct = CognitiveTick::new(CognitiveTickConfig::default());
642        ct.record_tick(100);
643        ct.record_tick(500);
644        ct.record_tick(200);
645        assert_eq!(ct.stats().max_compute_us, 500);
646    }
647
648    #[test]
649    fn is_running_default_false() {
650        let ct = CognitiveTick::new(CognitiveTickConfig::default());
651        assert!(!ct.is_running());
652    }
653
654    #[test]
655    fn set_running() {
656        let ct = CognitiveTick::new(CognitiveTickConfig::default());
657        ct.set_running(true);
658        assert!(ct.is_running());
659        ct.set_running(false);
660        assert!(!ct.is_running());
661    }
662
663    #[test]
664    fn tick_count() {
665        let ct = CognitiveTick::new(CognitiveTickConfig::default());
666        assert_eq!(ct.tick_count(), 0);
667        ct.record_tick(10);
668        assert_eq!(ct.tick_count(), 1);
669    }
670
671    #[test]
672    fn current_interval_ms() {
673        let ct = CognitiveTick::with_interval(42);
674        assert_eq!(ct.current_interval_ms(), 42);
675    }
676
677    #[test]
678    fn drift_detection() {
679        // Default: interval=50ms, budget_ratio=0.3 => budget = 50*1000*0.3 = 15000us
680        // disable adaptive so interval stays constant
681        let cfg = CognitiveTickConfig {
682            adaptive_tick: false,
683            ..Default::default()
684        };
685        let ct = CognitiveTick::new(cfg);
686
687        // Under budget: no drift.
688        ct.record_tick(10_000);
689        assert_eq!(ct.drift_count(), 0);
690
691        // Exactly at budget boundary (15000): not exceeding, no drift.
692        ct.record_tick(15_000);
693        assert_eq!(ct.drift_count(), 0);
694
695        // Over budget.
696        ct.record_tick(16_000);
697        assert_eq!(ct.drift_count(), 1);
698
699        // Another over budget.
700        ct.record_tick(20_000);
701        assert_eq!(ct.drift_count(), 2);
702    }
703
704    #[test]
705    fn adaptive_increase() {
706        // Set up a config where budget is small so we can easily exceed 1.1x.
707        // interval=50ms, ratio=0.3 => budget = 15000us, upper = 16500us
708        let cfg = CognitiveTickConfig {
709            tick_interval_ms: 50,
710            tick_budget_ratio: 0.3,
711            calibration_ticks: 100,
712            adaptive_tick: true,
713            adaptive_window_s: 30,
714        };
715        let ct = CognitiveTick::new(cfg);
716
717        // Record many ticks with compute well above the upper threshold (16500us).
718        for _ in 0..20 {
719            ct.record_tick(20_000);
720        }
721
722        // Interval should have increased from 50.
723        assert!(
724            ct.current_interval_ms() > 50,
725            "expected interval > 50, got {}",
726            ct.current_interval_ms()
727        );
728    }
729
730    #[test]
731    fn adaptive_decrease() {
732        // interval=100ms, ratio=0.3 => budget = 30000us, lower = 15000us
733        let cfg = CognitiveTickConfig {
734            tick_interval_ms: 100,
735            tick_budget_ratio: 0.3,
736            calibration_ticks: 100,
737            adaptive_tick: true,
738            adaptive_window_s: 30,
739        };
740        let ct = CognitiveTick::new(cfg);
741
742        // Record many ticks with compute well below the lower threshold.
743        for _ in 0..20 {
744            ct.record_tick(1_000);
745        }
746
747        // Interval should have decreased from 100.
748        assert!(
749            ct.current_interval_ms() < 100,
750            "expected interval < 100, got {}",
751            ct.current_interval_ms()
752        );
753    }
754
755    #[test]
756    fn adaptive_min_interval() {
757        // Start with a small interval so it can shrink toward the minimum.
758        let cfg = CognitiveTickConfig {
759            tick_interval_ms: 12,
760            tick_budget_ratio: 0.3,
761            calibration_ticks: 100,
762            adaptive_tick: true,
763            adaptive_window_s: 30,
764        };
765        let ct = CognitiveTick::new(cfg);
766
767        // Record very fast ticks to push the interval down.
768        for _ in 0..200 {
769            ct.record_tick(1);
770        }
771
772        // Interval must never go below 10ms.
773        assert!(
774            ct.current_interval_ms() >= 10,
775            "expected interval >= 10, got {}",
776            ct.current_interval_ms()
777        );
778    }
779
780    #[test]
781    fn reset_clears_stats() {
782        let ct = CognitiveTick::with_interval(80);
783        ct.set_running(true);
784        ct.record_tick(5_000);
785        ct.record_tick(50_000);
786
787        // Verify non-zero state.
788        assert!(ct.tick_count() > 0);
789        assert!(ct.stats().max_compute_us > 0);
790        assert!(ct.is_running());
791
792        ct.reset();
793
794        assert_eq!(ct.tick_count(), 0);
795        assert_eq!(ct.stats().max_compute_us, 0);
796        assert_eq!(ct.stats().avg_compute_us, 0);
797        assert_eq!(ct.drift_count(), 0);
798        assert!(!ct.is_running());
799        // Interval should be reset to config value.
800        assert_eq!(ct.current_interval_ms(), 80);
801    }
802
803    #[tokio::test]
804    async fn service_name_and_type() {
805        let ct = CognitiveTick::new(CognitiveTickConfig::default());
806        assert_eq!(ct.name(), "ecc.cognitive_tick");
807        assert_eq!(ct.service_type(), ServiceType::Core);
808    }
809
810    #[tokio::test]
811    async fn service_start_stop() {
812        let ct = CognitiveTick::new(CognitiveTickConfig::default());
813        assert!(!ct.is_running());
814
815        ct.start().await.unwrap();
816        assert!(ct.is_running());
817
818        ct.stop().await.unwrap();
819        assert!(!ct.is_running());
820    }
821
822    #[tokio::test]
823    async fn health_check_reflects_running() {
824        let ct = CognitiveTick::new(CognitiveTickConfig::default());
825        assert_eq!(
826            ct.health_check().await,
827            HealthStatus::Degraded("cognitive tick not running".into())
828        );
829
830        ct.start().await.unwrap();
831        assert_eq!(ct.health_check().await, HealthStatus::Healthy);
832    }
833
834    #[test]
835    fn config_serde_roundtrip() {
836        let cfg = CognitiveTickConfig::default();
837        let json = serde_json::to_string(&cfg).unwrap();
838        let restored: CognitiveTickConfig = serde_json::from_str(&json).unwrap();
839        assert_eq!(restored.tick_interval_ms, cfg.tick_interval_ms);
840        assert!((restored.tick_budget_ratio - cfg.tick_budget_ratio).abs() < f32::EPSILON);
841    }
842
843    #[test]
844    fn stats_serde_roundtrip() {
845        let ct = CognitiveTick::new(CognitiveTickConfig::default());
846        ct.record_tick(1234);
847        let stats = ct.stats();
848        let json = serde_json::to_string(&stats).unwrap();
849        let restored: CognitiveTickStats = serde_json::from_str(&json).unwrap();
850        assert_eq!(restored.tick_count, 1);
851        assert_eq!(restored.avg_compute_us, 1234);
852    }
853}