1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CognitiveTickConfig {
26 pub tick_interval_ms: u32,
28 pub tick_budget_ratio: f32,
30 pub calibration_ticks: u32,
32 pub adaptive_tick: bool,
34 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#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CognitiveTickStats {
57 pub tick_count: u64,
59 pub current_interval_ms: u32,
61 pub avg_compute_us: u64,
63 pub max_compute_us: u64,
65 pub drift_count: u64,
67 pub running: bool,
69}
70
71struct 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
84pub struct CognitiveTick {
93 config: CognitiveTickConfig,
94 state: Mutex<CognitiveTickState>,
95}
96
97impl CognitiveTick {
98 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 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 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 pub fn record_tick(&self, compute_us: u64) {
150 let mut s = self.state.lock().unwrap();
151
152 s.tick_count += 1;
154
155 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 if compute_us > s.max_compute_us {
165 s.max_compute_us = compute_us;
166 }
167
168 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 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 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 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 pub fn is_running(&self) -> bool {
196 self.state.lock().unwrap().running
197 }
198
199 pub fn set_running(&self, running: bool) {
201 self.state.lock().unwrap().running = running;
202 }
203
204 pub fn tick_count(&self) -> u64 {
206 self.state.lock().unwrap().tick_count
207 }
208
209 pub fn current_interval_ms(&self) -> u32 {
211 self.state.lock().unwrap().current_interval_ms
212 }
213
214 pub fn drift_count(&self) -> u64 {
216 self.state.lock().unwrap().drift_count
217 }
218
219 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 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#[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#[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#[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; let exact_every_n: u64 = 100; let train_every_n: usize = 1000; let cycle_window: usize = 20;
328 let stuck_suppress_cap: u64 = 256;
334
335 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 let mut in_stuck_phase = false;
345 let mut stuck_checks_since_log: u64 = 0;
346 let mut next_log_at: u64 = 1;
347 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 let _node_count = causal.node_count();
385 let _edge_count = causal.edge_count();
386 let _hnsw_count = hnsw.len();
387
388 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 let model = poisoned.into_inner();
397 model.predict(&features)
398 }
401 };
402
403 ticks_since_exact += 1;
405 let drift = match last_exact_coherence {
406 Some(last) => (prediction.lambda_2 - last).abs(),
407 None => f64::INFINITY, };
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 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 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 if coherence_history.len() == cycle_window {
476 coherence_history.pop_front();
477 }
478 coherence_history.push_back(exact_lambda_2);
479 }
480
481 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) => { }
525 }
526 }
527
528 match eml.lock() {
530 Ok(mut model) => {
531 model.record(features, exact_lambda_2);
532
533 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 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 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#[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 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 let cfg = CognitiveTickConfig {
682 adaptive_tick: false,
683 ..Default::default()
684 };
685 let ct = CognitiveTick::new(cfg);
686
687 ct.record_tick(10_000);
689 assert_eq!(ct.drift_count(), 0);
690
691 ct.record_tick(15_000);
693 assert_eq!(ct.drift_count(), 0);
694
695 ct.record_tick(16_000);
697 assert_eq!(ct.drift_count(), 1);
698
699 ct.record_tick(20_000);
701 assert_eq!(ct.drift_count(), 2);
702 }
703
704 #[test]
705 fn adaptive_increase() {
706 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 for _ in 0..20 {
719 ct.record_tick(20_000);
720 }
721
722 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 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 for _ in 0..20 {
744 ct.record_tick(1_000);
745 }
746
747 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 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 for _ in 0..200 {
769 ct.record_tick(1);
770 }
771
772 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 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 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}