1use crate::budget_channel::BudgetChannel;
18use crate::context::{safety_ceiling, Context};
19use crate::cost_model::CostModel;
20use crate::metrics::MetricStore;
21use crate::EngineMode;
22use crossbeam_channel::{Receiver, Sender};
23use khora_core::agent::Agent;
24use khora_core::control::gorna::ResourceBudget;
25use khora_core::control::pid::{PidConfig, PidController};
26use khora_core::telemetry::{MetricId, TelemetryEvent};
27use khora_data::ecs::layout::{LayoutAdvisor, LayoutRecommendation};
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::sync::Arc;
30use std::thread;
31use std::time::{Duration, Instant};
32
33use crate::analysis::HeuristicEngine;
34use crate::gorna::GornaArbitrator;
35use crate::registry::AgentRegistry;
36use khora_core::control::gorna::{
37 AdaptationMode, AgentHints, AgentId, DecisionTrace, EngineHint, TickDecisions,
38};
39use std::collections::HashMap;
40use std::sync::Mutex;
41
42const COST_MODEL_CAPACITY: usize = 64;
44
45const FRAME_TIME_MIN_SAMPLES: usize = 10;
48
49const PID_RENEGOTIATE_DELTA: f32 = 0.05;
55
56fn forecast_total_ms(
68 models: &HashMap<AgentId, CostModel>,
69 n: f64,
70 wave_plan: &[Vec<AgentId>],
71) -> Option<f64> {
72 let mut planned: std::collections::HashSet<AgentId> = std::collections::HashSet::new();
73 let mut total = 0.0;
74 let mut any = false;
75
76 for wave in wave_plan {
77 let mut wave_max = 0.0_f64;
78 let mut wave_has = false;
79 for id in wave {
80 planned.insert(*id);
81 if let Some(p) = models.get(id).and_then(|m| m.predict_ms(n)) {
82 wave_max = wave_max.max(p.max(0.0));
83 wave_has = true;
84 }
85 }
86 if wave_has {
87 total += wave_max;
88 any = true;
89 }
90 }
91
92 for (id, m) in models {
94 if planned.contains(id) {
95 continue;
96 }
97 if let Some(p) = m.predict_ms(n) {
98 total += p.max(0.0);
99 any = true;
100 }
101 }
102
103 any.then_some(total)
104}
105
106#[derive(Debug, Clone)]
108pub struct DccConfig {
109 pub tick_rate: u32,
111 pub telemetry_buffer_size: usize,
114 pub agent_lock_timeout_ms: u64,
117 pub memory_budget_bytes: Option<u64>,
122 pub frame_pid: PidConfig,
126}
127
128impl Default for DccConfig {
129 fn default() -> Self {
130 Self {
131 tick_rate: 20,
132 telemetry_buffer_size: 1000,
133 agent_lock_timeout_ms: 100,
134 memory_budget_bytes: None,
135 frame_pid: PidConfig::default(),
136 }
137 }
138}
139
140pub struct DccService {
144 config: DccConfig,
145 context: Arc<std::sync::RwLock<Context>>,
146 registry: Arc<std::sync::Mutex<AgentRegistry>>,
147 budget_channel: Option<BudgetChannel>,
148 running: Arc<AtomicBool>,
149 handle: Option<thread::JoinHandle<()>>,
150 event_tx: Sender<TelemetryEvent>,
151 adaptation_modes: Arc<std::sync::RwLock<HashMap<AgentId, AdaptationMode>>>,
154 hints: Arc<std::sync::RwLock<HashMap<AgentId, AgentHints>>>,
159 layout_recommendations: Arc<std::sync::RwLock<HashMap<String, LayoutRecommendation>>>,
163 decision_recording: Arc<std::sync::RwLock<bool>>,
165 recorded_trace: Arc<std::sync::RwLock<DecisionTrace>>,
167 replay: Arc<std::sync::RwLock<Option<(DecisionTrace, usize)>>>,
170}
171
172impl DccService {
173 pub fn new(config: DccConfig) -> (Self, Receiver<TelemetryEvent>) {
175 let (tx, rx) = crossbeam_channel::bounded(config.telemetry_buffer_size);
176 let service = Self {
177 config,
178 context: Arc::new(std::sync::RwLock::new(Context::default())),
179 registry: Arc::new(std::sync::Mutex::new(AgentRegistry::new())),
180 budget_channel: None,
181 running: Arc::new(AtomicBool::new(false)),
182 handle: None,
183 event_tx: tx,
184 adaptation_modes: Arc::new(std::sync::RwLock::new(HashMap::new())),
185 hints: Arc::new(std::sync::RwLock::new(HashMap::new())),
186 layout_recommendations: Arc::new(std::sync::RwLock::new(HashMap::new())),
187 decision_recording: Arc::new(std::sync::RwLock::new(false)),
188 recorded_trace: Arc::new(std::sync::RwLock::new(DecisionTrace::default())),
189 replay: Arc::new(std::sync::RwLock::new(None)),
190 };
191 (service, rx)
192 }
193
194 pub fn set_adaptation_mode(&self, agent_id: AgentId, mode: AdaptationMode) {
199 if let Ok(mut modes) = self.adaptation_modes.write() {
200 modes.insert(agent_id, mode);
201 }
202 }
203
204 pub fn adaptation_mode(&self, agent_id: AgentId) -> AdaptationMode {
206 self.adaptation_modes
207 .read()
208 .ok()
209 .and_then(|m| m.get(&agent_id).copied())
210 .unwrap_or_default()
211 }
212
213 pub fn set_hint(&self, hint: EngineHint) {
221 if let Ok(mut hints) = self.hints.write() {
222 hints.entry(hint.agent()).or_default().apply(hint);
223 }
224 }
225
226 pub fn clear_agent_hints(&self, agent_id: AgentId) {
229 if let Ok(mut hints) = self.hints.write() {
230 hints.remove(&agent_id);
231 }
232 }
233
234 pub fn hints(&self) -> HashMap<AgentId, AgentHints> {
237 self.hints.read().map(|h| h.clone()).unwrap_or_default()
238 }
239
240 pub fn layout_recommendations(&self) -> HashMap<String, LayoutRecommendation> {
245 self.layout_recommendations
246 .read()
247 .map(|m| m.clone())
248 .unwrap_or_default()
249 }
250
251 pub fn start_decision_recording(&self) {
255 if let Ok(mut rec) = self.decision_recording.write() {
256 *rec = true;
257 }
258 if let Ok(mut trace) = self.recorded_trace.write() {
259 *trace = DecisionTrace::default();
260 }
261 }
262
263 pub fn stop_decision_recording(&self) -> DecisionTrace {
265 if let Ok(mut rec) = self.decision_recording.write() {
266 *rec = false;
267 }
268 self.recorded_decisions()
269 }
270
271 pub fn recorded_decisions(&self) -> DecisionTrace {
273 self.recorded_trace
274 .read()
275 .map(|t| t.clone())
276 .unwrap_or_default()
277 }
278
279 pub fn replay_decisions(&self, trace: DecisionTrace) {
283 if let Ok(mut replay) = self.replay.write() {
284 *replay = Some((trace, 0));
285 }
286 }
287
288 pub fn stop_replay(&self) {
290 if let Ok(mut replay) = self.replay.write() {
291 *replay = None;
292 }
293 }
294
295 pub fn is_replaying(&self) -> bool {
297 self.replay
298 .read()
299 .ok()
300 .and_then(|r| r.as_ref().map(|(t, c)| *c < t.ticks.len()))
301 .unwrap_or(false)
302 }
303
304 pub fn connect_budget_channel(&mut self, channel: BudgetChannel) {
307 self.budget_channel = Some(channel);
308 }
309
310 pub fn register_agent(&self, agent: Arc<std::sync::Mutex<dyn Agent>>, priority: f32) {
315 let mut registry = self.registry.lock().unwrap_or_else(|e| e.into_inner());
316 registry.register(agent, priority);
317 }
318
319 pub fn register_agent_for_mode(
324 &self,
325 agent: Arc<std::sync::Mutex<dyn Agent>>,
326 priority: f32,
327 modes: Vec<EngineMode>,
328 ) {
329 let mut registry = self.registry.lock().unwrap_or_else(|e| e.into_inner());
330 registry.register_for_mode(agent, priority, modes);
331 }
332
333 pub fn start(&mut self, event_rx: Receiver<TelemetryEvent>) {
335 if self.running.load(Ordering::SeqCst) {
336 return;
337 }
338
339 self.running.store(true, Ordering::SeqCst);
340 let running = Arc::clone(&self.running);
341 let context = Arc::clone(&self.context);
342 let registry = Arc::clone(&self.registry);
343 let budget_channel = self.budget_channel.clone();
344 let adaptation_modes = Arc::clone(&self.adaptation_modes);
345 let hints = Arc::clone(&self.hints);
346 let layout_recommendations = Arc::clone(&self.layout_recommendations);
347 let decision_recording = Arc::clone(&self.decision_recording);
348 let recorded_trace = Arc::clone(&self.recorded_trace);
349 let replay = Arc::clone(&self.replay);
350 let tick_duration = Duration::from_secs_f32(1.0 / self.config.tick_rate as f32);
351 let agent_lock_timeout = Duration::from_millis(self.config.agent_lock_timeout_ms);
352 let memory_budget_bytes = self.config.memory_budget_bytes;
353 let frame_pid_cfg = self.config.frame_pid;
354
355 let handle = thread::spawn(move || {
356 let mut store = MetricStore::new();
357 let heuristic_engine = HeuristicEngine;
358 let mut arbitrator = GornaArbitrator::new(agent_lock_timeout);
359 let mut initial_negotiation_done = false;
360 let mut cost_models: HashMap<AgentId, CostModel> = HashMap::new();
363 let mut last_workload_n: f64 = 0.0;
364 let mut latest_wave_plan: Vec<Vec<AgentId>> = Vec::new();
369 let layout_advisor = LayoutAdvisor::default();
372 let mut frame_pid = PidController::new(frame_pid_cfg);
376 let mut last_tick: Option<Instant> = None;
377 let mut last_issued_multiplier: Option<f32> = None;
380
381 log::info!("DCC Service thread started.");
382
383 while running.load(Ordering::Relaxed) {
384 let start_time = Instant::now();
385
386 while let Ok(event) = event_rx.try_recv() {
388 match event {
389 TelemetryEvent::MetricUpdate { id, value } => {
390 if let Some(v) = value.as_f64() {
391 store.push(id, v as f32);
392 }
393 }
394 TelemetryEvent::ResourceReport(_) => {}
395 TelemetryEvent::HardwareReport(report) => {
396 let mut ctx = context.write().unwrap_or_else(|e| e.into_inner());
397 ctx.hardware.thermal = report.thermal;
398 ctx.hardware.battery = report.battery;
399 ctx.hardware.cpu_load = report.cpu_load;
400 ctx.hardware.gpu_load = report.gpu_load.unwrap_or(0.0);
401 ctx.hardware.available_vram = report.gpu_timings.as_ref().map(|_| 0);
402
403 if let Some(gpu_timings) = report.gpu_timings {
404 if let Some(frame_time_us) = gpu_timings.frame_total_duration_us() {
405 store.push(
406 khora_core::telemetry::MetricId::new(
407 "renderer",
408 "frame_time",
409 ),
410 frame_time_us as f32 / 1000.0,
411 );
412 }
413 }
414
415 log::debug!(
416 "DCC Hardware: Thermal={:?}, CPU={:.2}, GPU={:?}",
417 ctx.hardware.thermal,
418 ctx.hardware.cpu_load,
419 ctx.hardware.gpu_load
420 );
421 }
422 TelemetryEvent::PhaseChange(phase_name) => {
423 let mut ctx = context.write().unwrap_or_else(|e| e.into_inner());
424 if let Some(new_mode) = EngineMode::from_name(&phase_name) {
425 log::debug!("DCC Mode: {:?} → {:?}", ctx.mode, new_mode);
426 ctx.mode = new_mode;
427 } else {
428 log::warn!("DCC: Unknown mode '{}'", phase_name);
429 }
430 }
431 TelemetryEvent::GpuReport(report) => {
432 if let Some(frame_time_us) = report.frame_total_duration_us() {
433 store.push(
434 khora_core::telemetry::MetricId::new(
435 "renderer",
436 "gpu_frame_time",
437 ),
438 frame_time_us as f32 / 1000.0,
439 );
440 }
441 store.push(
442 khora_core::telemetry::MetricId::new("renderer", "draw_calls"),
443 report.draw_calls as f32,
444 );
445 store.push(
446 khora_core::telemetry::MetricId::new(
447 "renderer",
448 "triangles_rendered",
449 ),
450 report.triangles_rendered as f32,
451 );
452 }
453 TelemetryEvent::AgentCost { id, n, time_ms } => {
454 last_workload_n = n;
457 cost_models
458 .entry(id)
459 .or_insert_with(|| CostModel::new(COST_MODEL_CAPACITY))
460 .record(n, time_ms);
461 store.push(
462 khora_core::telemetry::MetricId::new(
463 "agent",
464 format!("{id:?}_time_ms"),
465 ),
466 time_ms as f32,
467 );
468 }
469 TelemetryEvent::WavePlan { waves } => {
470 latest_wave_plan = waves;
474 }
475 TelemetryEvent::ComponentAccess {
476 type_name,
477 size_bytes,
478 query_count,
479 rows_scanned,
480 } => {
481 let recommendation =
485 layout_advisor.recommend(size_bytes, query_count, rows_scanned);
486 if let Ok(mut recs) = layout_recommendations.write() {
487 recs.insert(type_name.clone(), recommendation);
488 }
489 store.push(
490 khora_core::telemetry::MetricId::new("ecs_access", type_name),
491 rows_scanned as f32,
492 );
493 }
494 }
495 }
496
497 let (mut report, mut ctx_copy) = {
499 let mut ctx = context.write().unwrap_or_else(|e| e.into_inner());
500 let mem_bytes = store.get_average(&MetricId::new("memory", "current_bytes"));
504 ctx.hardware.current_ram_bytes = (mem_bytes > 0.0).then_some(mem_bytes as u64);
505 ctx.hardware.memory_budget_bytes = memory_budget_bytes;
506 ctx.refresh_memory_pressure();
507 let report = heuristic_engine.analyze(&ctx, &store);
508 (report, ctx.clone())
509 };
510
511 if let Some(predicted_ms) =
518 forecast_total_ms(&cost_models, last_workload_n, &latest_wave_plan)
519 {
520 if predicted_ms > report.suggested_latency_ms as f64 {
521 let ratio = (report.suggested_latency_ms as f64 / predicted_ms)
522 .clamp(0.5, 1.0) as f32;
523 report.alerts.push(format!(
524 "Cost-model forecast {:.1}ms > budget {:.1}ms at n={:.0} — tightening to {:.1}ms",
525 predicted_ms,
526 report.suggested_latency_ms,
527 last_workload_n,
528 report.suggested_latency_ms * ratio
529 ));
530 report.suggested_latency_ms *= ratio;
531 report.needs_negotiation = true;
532 }
533 }
534
535 let dt = last_tick
543 .map(|t| start_time.duration_since(t).as_secs_f32())
544 .unwrap_or_else(|| tick_duration.as_secs_f32());
545 last_tick = Some(start_time);
546
547 let frame_time_id = MetricId::new("renderer", "frame_time");
548 let measured = store.get_average(&frame_time_id);
549 let mut multiplier = if store.get_sample_count(&frame_time_id)
550 >= FRAME_TIME_MIN_SAMPLES
551 && measured > 0.0
552 {
553 frame_pid.update(report.suggested_latency_ms, measured, dt)
554 } else {
555 frame_pid.output()
556 };
557 multiplier = multiplier.min(safety_ceiling(&ctx_copy));
558 ctx_copy.global_budget_multiplier = multiplier;
559 if let Ok(mut ctx) = context.write() {
560 ctx.global_budget_multiplier = multiplier;
561 }
562 log::debug!(
563 "DCC PID: multiplier={:.3} (setpoint={:.2}ms, measured={:.2}ms, dt={:.3}s)",
564 multiplier,
565 report.suggested_latency_ms,
566 measured,
567 dt
568 );
569
570 if let Some(last) = last_issued_multiplier {
577 if (multiplier - last).abs() > PID_RENEGOTIATE_DELTA {
578 report.needs_negotiation = true;
579 report.alerts.push(format!(
580 "PID: budget multiplier {last:.2} → {multiplier:.2} since last issuance — re-arbitrating",
581 ));
582 }
583 }
584
585 for alert in &report.alerts {
586 log::info!("DCC Analysis: {}", alert);
587 }
588
589 let replay_tick: Option<TickDecisions> = replay
593 .read()
594 .ok()
595 .and_then(|r| r.as_ref().and_then(|(t, c)| t.ticks.get(*c).cloned()));
596 let replaying = replay_tick.is_some();
597
598 if report.needs_negotiation || !initial_negotiation_done || replaying {
600 let registry_lock = registry.lock().unwrap_or_else(|e| e.into_inner());
601 if !registry_lock.is_empty() {
602 let agents: Vec<_> = registry_lock.iter().cloned().collect();
603 drop(registry_lock);
604
605 let mut agents_slice: Vec<Arc<std::sync::Mutex<dyn Agent>>> = agents;
606 if let Ok(modes) = adaptation_modes.read() {
609 for (id, mode) in modes.iter() {
610 arbitrator.set_adaptation_mode(*id, *mode);
611 }
612 }
613 let tick_hints: HashMap<AgentId, AgentHints> =
617 hints.read().map(|h| h.clone()).unwrap_or_default();
618 let measured_costs: HashMap<AgentId, f64> = cost_models
623 .iter()
624 .filter_map(|(id, m)| {
625 m.predict_ms(last_workload_n)
626 .or_else(|| m.latest_ms())
627 .map(|ms| (*id, ms))
628 })
629 .collect();
630
631 arbitrator.set_wave_plan(&latest_wave_plan);
635 let issued = arbitrator.arbitrate(
636 &ctx_copy,
637 &report,
638 &mut agents_slice,
639 &measured_costs,
640 replay_tick.as_ref(),
641 &tick_hints,
642 );
643 initial_negotiation_done = true;
644 last_issued_multiplier = Some(multiplier);
645
646 if replaying {
648 if let Ok(mut r) = replay.write() {
649 if let Some((_, cursor)) = r.as_mut() {
650 *cursor += 1;
651 }
652 }
653 } else if decision_recording.read().map(|b| *b).unwrap_or(false) {
654 if let Ok(mut trace) = recorded_trace.write() {
655 trace.ticks.push(issued);
656 }
657 }
658
659 if let Some(ref budget_channel) = budget_channel {
661 for agent_arc in &agents_slice {
662 if let Ok(agent) = agent_arc.lock() {
663 let status = agent.report_status();
666 let budget = ResourceBudget {
667 strategy_id: status.current_strategy,
668 time_limit: Duration::from_secs_f32(
669 report.suggested_latency_ms / 1000.0,
670 ),
671 memory_limit: None,
672 extra_params: std::collections::HashMap::new(),
673 };
674 budget_channel.send(agent.id(), budget);
675 }
676 }
677 }
678 }
679 }
680
681 let elapsed = start_time.elapsed();
683 if elapsed < tick_duration {
684 thread::sleep(tick_duration - elapsed);
685 }
686 }
687 log::info!("DCC Service thread stopped.");
688 });
689
690 self.handle = Some(handle);
691 }
692
693 pub fn stop(&mut self) {
695 self.running.store(false, Ordering::SeqCst);
696 if let Some(handle) = self.handle.take() {
697 let _ = handle.join();
698 }
699 }
700
701 pub fn agent_registry(&self) -> &Arc<Mutex<AgentRegistry>> {
703 &self.registry
704 }
705
706 pub fn event_sender(&self) -> Sender<TelemetryEvent> {
708 self.event_tx.clone()
709 }
710
711 pub fn get_context(&self) -> Context {
713 self.context
714 .read()
715 .unwrap_or_else(|e| e.into_inner())
716 .clone()
717 }
718
719 pub fn context_handle(&self) -> Arc<std::sync::RwLock<Context>> {
725 Arc::clone(&self.context)
726 }
727
728 pub fn initialize_agents(&self, context: &mut khora_core::EngineContext<'_>) {
733 if let Ok(registry) = self.registry.lock() {
734 registry.initialize_all(context);
735 }
736 }
737
738 pub fn execute_agents(&self, context: &mut khora_core::EngineContext<'_>) {
743 if let Ok(registry) = self.registry.lock() {
744 registry.execute_all(context);
745 }
746 }
747
748 pub fn agent_count(&self) -> usize {
750 self.registry.lock().map(|r| r.len()).unwrap_or(0)
751 }
752
753 pub fn get_agent(&self, id: AgentId) -> Option<Arc<Mutex<dyn Agent>>> {
755 self.registry.lock().ok()?.get_by_id(id)
756 }
757}
758
759impl Drop for DccService {
760 fn drop(&mut self) {
761 self.stop();
762 }
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768 use crate::EngineMode;
769 use khora_core::control::gorna::{
770 AdaptationMode, AgentId, AgentStatus, NegotiationRequest, NegotiationResponse,
771 ResourceBudget, StrategyId, StrategyOption,
772 };
773 use khora_core::telemetry::{MetricId, MetricValue};
774
775 struct StubAgent {
776 applied: Option<StrategyId>,
777 }
778
779 impl Agent for StubAgent {
780 fn id(&self) -> AgentId {
781 AgentId::Renderer
782 }
783 fn negotiate(&mut self, _: NegotiationRequest) -> NegotiationResponse {
784 NegotiationResponse {
785 strategies: vec![
786 StrategyOption {
787 id: StrategyId::LowPower,
788 estimated_time: Duration::from_millis(2),
789 estimated_vram: 1024,
790 },
791 StrategyOption {
792 id: StrategyId::Balanced,
793 estimated_time: Duration::from_millis(8),
794 estimated_vram: 1024,
795 },
796 ],
797 timing_adjustment: None,
798 }
799 }
800 fn apply_budget(&mut self, budget: ResourceBudget) {
801 self.applied = Some(budget.strategy_id);
802 }
803 fn report_status(&self) -> AgentStatus {
804 AgentStatus {
805 agent_id: AgentId::Renderer,
806 current_strategy: self.applied.unwrap_or(StrategyId::Balanced),
807 health_score: 1.0,
808 is_stalled: false,
809 message: String::new(),
810 }
811 }
812 fn execute(&mut self, _: &mut khora_core::EngineContext<'_>) {}
813 fn as_any(&self) -> &dyn std::any::Any {
814 self
815 }
816 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
817 self
818 }
819 }
820
821 #[test]
822 fn test_dcc_service_lifecycle() {
823 let (mut dcc, rx) = DccService::new(DccConfig::default());
824 dcc.start(rx);
825 assert!(dcc.running.load(Ordering::SeqCst));
826 dcc.stop();
827 assert!(!dcc.running.load(Ordering::SeqCst));
828 }
829
830 #[test]
831 fn test_dcc_phase_change_ingestion() {
832 let (mut dcc, rx) = DccService::new(DccConfig::default());
833 let tx = dcc.event_sender();
834 dcc.start(rx);
835
836 tx.send(TelemetryEvent::PhaseChange("Simulation".to_string()))
837 .unwrap();
838
839 thread::sleep(Duration::from_millis(100));
840
841 let ctx = dcc.get_context();
842 assert_eq!(ctx.mode, EngineMode::Playing);
843
844 dcc.stop();
845 }
846
847 #[test]
848 fn test_dcc_metric_ingestion_smoke() {
849 let (mut dcc, rx) = DccService::new(DccConfig::default());
850 let tx = dcc.event_sender();
851 dcc.start(rx);
852
853 let id = MetricId::new("test", "metric");
854 tx.send(TelemetryEvent::MetricUpdate {
855 id,
856 value: MetricValue::Gauge(42.0),
857 })
858 .unwrap();
859
860 thread::sleep(Duration::from_millis(50));
861 dcc.stop();
862 }
863
864 #[test]
865 fn test_forecast_total_ms_sums_agent_models() {
866 let mut models: HashMap<AgentId, CostModel> = HashMap::new();
868 assert!(forecast_total_ms(&models, 100.0, &[]).is_none());
869
870 let mut renderer = CostModel::new(16);
872 let mut physics = CostModel::new(16);
873 for n in [10.0, 20.0, 40.0] {
874 renderer.record(n, 3.0 * n);
875 physics.record(n, 2.0 * n);
876 }
877 models.insert(AgentId::Renderer, renderer);
878 models.insert(AgentId::Physics, physics);
879
880 let total = forecast_total_ms(&models, 100.0, &[]).expect("a fit is available");
882 assert!((total - 500.0).abs() < 1.0, "serial forecast = {total}");
883
884 let plan = vec![vec![AgentId::Renderer, AgentId::Physics]];
886 let concurrent = forecast_total_ms(&models, 100.0, &plan).expect("a fit is available");
887 assert!(
888 (concurrent - 300.0).abs() < 1.0,
889 "concurrent forecast = {concurrent}"
890 );
891 }
892
893 #[test]
894 fn test_dcc_ingests_agent_cost_and_component_access() {
895 let (mut dcc, rx) = DccService::new(DccConfig::default());
898 let tx = dcc.event_sender();
899 dcc.start(rx);
900
901 tx.send(TelemetryEvent::AgentCost {
902 id: AgentId::Renderer,
903 n: 1000.0,
904 time_ms: 4.2,
905 })
906 .unwrap();
907 tx.send(TelemetryEvent::ComponentAccess {
908 type_name: "Transform".to_string(),
909 size_bytes: 40,
910 query_count: 12,
911 rows_scanned: 12_000,
912 })
913 .unwrap();
914
915 thread::sleep(Duration::from_millis(50));
916 assert!(dcc.running.load(Ordering::SeqCst));
917 dcc.stop();
918 }
919
920 #[test]
921 fn test_dcc_layout_advisor_produces_recommendation() {
922 let (mut dcc, rx) = DccService::new(DccConfig::default());
925 let tx = dcc.event_sender();
926 dcc.start(rx);
927
928 tx.send(TelemetryEvent::ComponentAccess {
929 type_name: "Velocity".to_string(),
930 size_bytes: 40,
931 query_count: 10,
932 rows_scanned: 40_960, })
934 .unwrap();
935
936 thread::sleep(Duration::from_millis(80));
937 let recs = dcc.layout_recommendations();
938 dcc.stop();
939
940 assert_eq!(
941 recs.get("Velocity"),
942 Some(&LayoutRecommendation::SimdFieldSoa),
943 "advisor should recommend field-SoA for a lean, large-batch component"
944 );
945 }
946
947 #[test]
948 fn test_dcc_records_decisions() {
949 let (mut dcc, rx) = DccService::new(DccConfig {
950 tick_rate: 100,
951 ..Default::default()
952 });
953 let agent = Arc::new(std::sync::Mutex::new(StubAgent { applied: None }));
954 dcc.register_agent(agent, 1.0);
955 dcc.start_decision_recording();
956 dcc.start(rx);
957
958 thread::sleep(Duration::from_millis(150));
959 let trace = dcc.stop_decision_recording();
960 dcc.stop();
961
962 assert!(
963 !trace.ticks.is_empty(),
964 "recording should capture at least one arbitration tick"
965 );
966 assert!(
967 trace
968 .ticks
969 .iter()
970 .all(|tick| tick.iter().any(|(id, _)| *id == AgentId::Renderer)),
971 "every recorded tick should include the registered agent"
972 );
973 }
974
975 #[test]
976 fn test_dcc_replay_flag_control() {
977 let (dcc, _rx) = DccService::new(DccConfig::default());
978 assert!(!dcc.is_replaying());
979
980 let trace = DecisionTrace {
981 ticks: vec![vec![(AgentId::Renderer, StrategyId::LowPower)]],
982 };
983 dcc.replay_decisions(trace);
984 assert!(dcc.is_replaying());
985
986 dcc.stop_replay();
987 assert!(!dcc.is_replaying());
988 }
989
990 #[test]
991 fn test_dcc_initial_negotiation_fires_with_agent() {
992 let (mut dcc, rx) = DccService::new(DccConfig {
993 tick_rate: 100,
994 ..Default::default()
995 });
996 let agent = Arc::new(std::sync::Mutex::new(StubAgent { applied: None }));
997 dcc.register_agent(agent.clone(), 1.0);
998 dcc.start(rx);
999
1000 thread::sleep(Duration::from_millis(200));
1001
1002 let applied = agent.lock().unwrap().applied.is_some();
1003 dcc.stop();
1004
1005 assert!(
1006 applied,
1007 "Initial GORNA negotiation should have called apply_budget"
1008 );
1009 }
1010
1011 #[test]
1012 fn test_dcc_manual_mode_pins_strategy() {
1013 let (mut dcc, rx) = DccService::new(DccConfig {
1014 tick_rate: 100,
1015 ..Default::default()
1016 });
1017 let agent = Arc::new(std::sync::Mutex::new(StubAgent { applied: None }));
1018 dcc.register_agent(agent.clone(), 1.0);
1019 dcc.set_adaptation_mode(
1022 AgentId::Renderer,
1023 AdaptationMode::Manual(StrategyId::LowPower),
1024 );
1025 dcc.start(rx);
1026
1027 thread::sleep(Duration::from_millis(200));
1028
1029 let applied = agent.lock().unwrap().applied;
1030 dcc.stop();
1031
1032 assert_eq!(
1033 applied,
1034 Some(StrategyId::LowPower),
1035 "Manual mode set via DccService should pin the agent's strategy"
1036 );
1037 }
1038
1039 #[test]
1040 fn test_pid_drives_multiplier_down_under_overrun() {
1041 let (mut dcc, rx) = DccService::new(DccConfig {
1044 tick_rate: 100,
1045 ..Default::default()
1046 });
1047 let tx = dcc.event_sender();
1048 dcc.start(rx);
1049
1050 let frame_time_id = MetricId::new("renderer", "frame_time");
1051 for _ in 0..40 {
1053 tx.send(TelemetryEvent::MetricUpdate {
1054 id: frame_time_id.clone(),
1055 value: MetricValue::Gauge(40.0),
1056 })
1057 .unwrap();
1058 thread::sleep(Duration::from_millis(5));
1059 }
1060 thread::sleep(Duration::from_millis(100));
1061
1062 let multiplier = dcc.get_context().global_budget_multiplier;
1063 dcc.stop();
1064
1065 assert!(
1066 multiplier < 1.0,
1067 "sustained overrun should pull the multiplier below 1.0, got {multiplier}"
1068 );
1069 assert!(
1070 multiplier >= 0.3,
1071 "multiplier must respect the output floor, got {multiplier}"
1072 );
1073 }
1074
1075 struct RecoveryStubAgent {
1080 applied: Option<StrategyId>,
1081 }
1082
1083 impl Agent for RecoveryStubAgent {
1084 fn id(&self) -> AgentId {
1085 AgentId::Renderer
1086 }
1087 fn negotiate(&mut self, _: NegotiationRequest) -> NegotiationResponse {
1088 NegotiationResponse {
1089 strategies: vec![
1090 StrategyOption {
1091 id: StrategyId::LowPower,
1092 estimated_time: Duration::from_millis(2),
1093 estimated_vram: 0,
1094 },
1095 StrategyOption {
1096 id: StrategyId::Balanced,
1097 estimated_time: Duration::from_millis(14),
1098 estimated_vram: 0,
1099 },
1100 ],
1101 timing_adjustment: None,
1102 }
1103 }
1104 fn apply_budget(&mut self, budget: ResourceBudget) {
1105 self.applied = Some(budget.strategy_id);
1106 }
1107 fn report_status(&self) -> AgentStatus {
1108 AgentStatus {
1109 agent_id: AgentId::Renderer,
1110 current_strategy: self.applied.unwrap_or(StrategyId::LowPower),
1111 health_score: 1.0,
1112 is_stalled: false,
1113 message: String::new(),
1114 }
1115 }
1116 fn execute(&mut self, _: &mut khora_core::EngineContext<'_>) {}
1117 fn as_any(&self) -> &dyn std::any::Any {
1118 self
1119 }
1120 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1121 self
1122 }
1123 }
1124
1125 #[test]
1126 fn test_pid_recovery_reissues_budgets_and_upgrades() {
1127 let (mut dcc, rx) = DccService::new(DccConfig {
1133 tick_rate: 100,
1134 ..Default::default()
1135 });
1136 let agent = Arc::new(std::sync::Mutex::new(RecoveryStubAgent { applied: None }));
1137 dcc.register_agent(agent.clone(), 1.0);
1138 let tx = dcc.event_sender();
1139 dcc.start(rx);
1140
1141 let frame_time_id = MetricId::new("renderer", "frame_time");
1142
1143 let mut degraded = false;
1147 let deadline = Instant::now() + Duration::from_secs(5);
1148 while Instant::now() < deadline {
1149 tx.send(TelemetryEvent::MetricUpdate {
1150 id: frame_time_id.clone(),
1151 value: khora_core::telemetry::MetricValue::Gauge(40.0),
1152 })
1153 .unwrap();
1154 thread::sleep(Duration::from_millis(5));
1155 if agent.lock().unwrap().applied == Some(StrategyId::LowPower) {
1156 degraded = true;
1157 break;
1158 }
1159 }
1160 assert!(
1161 degraded,
1162 "sustained overrun should downgrade the agent to LowPower"
1163 );
1164
1165 let mut recovered = false;
1169 let deadline = Instant::now() + Duration::from_secs(10);
1170 while Instant::now() < deadline {
1171 tx.send(TelemetryEvent::MetricUpdate {
1172 id: frame_time_id.clone(),
1173 value: khora_core::telemetry::MetricValue::Gauge(5.0),
1174 })
1175 .unwrap();
1176 thread::sleep(Duration::from_millis(5));
1177 if agent.lock().unwrap().applied == Some(StrategyId::Balanced) {
1178 recovered = true;
1179 break;
1180 }
1181 }
1182 dcc.stop();
1183
1184 assert!(
1185 recovered,
1186 "once frame time settles, the PID drift must re-arbitrate and upgrade the agent"
1187 );
1188 }
1189
1190 #[test]
1191 fn test_critical_thermal_clamps_multiplier() {
1192 let (mut dcc, rx) = DccService::new(DccConfig {
1195 tick_rate: 100,
1196 ..Default::default()
1197 });
1198 let tx = dcc.event_sender();
1199 dcc.start(rx);
1200
1201 tx.send(TelemetryEvent::HardwareReport(
1202 khora_core::telemetry::monitoring::HardwareReport {
1203 thermal: khora_core::platform::ThermalStatus::Critical,
1204 battery: khora_core::platform::BatteryLevel::Mains,
1205 cpu_load: 0.2,
1206 gpu_load: Some(0.2),
1207 gpu_timings: None,
1208 },
1209 ))
1210 .unwrap();
1211 thread::sleep(Duration::from_millis(120));
1212
1213 let multiplier = dcc.get_context().global_budget_multiplier;
1214 dcc.stop();
1215
1216 assert!(
1217 multiplier <= 0.4 + 1e-3,
1218 "Critical thermal must clamp the multiplier to the safety ceiling, got {multiplier}"
1219 );
1220 }
1221}