1use crate::analysis::AnalysisReport;
28use crate::context::Context;
29use khora_core::agent::Agent;
30use khora_core::control::gorna::{
31 AdaptationMode, AgentHints, AgentId, NegotiationRequest, ResourceBudget, ResourceConstraints,
32 StrategyId, StrategyOption, TickDecisions,
33};
34use std::collections::HashMap;
35use std::sync::{Arc, Mutex};
36use std::time::{Duration, Instant};
37
38const MAX_STALLED_AGENTS: usize = 2;
39
40const CALIBRATION_FACTOR_MIN: f64 = 0.25;
44const CALIBRATION_FACTOR_MAX: f64 = 4.0;
45
46fn strategy_rank(id: StrategyId) -> u8 {
50 match id {
51 StrategyId::LowPower => 0,
52 StrategyId::Balanced => 1,
53 StrategyId::HighPerformance => 2,
54 StrategyId::Custom(_) => 3,
55 }
56}
57
58fn try_lock_agent_with_timeout<T: ?Sized>(
59 mutex: &Mutex<T>,
60 timeout: Duration,
61) -> Option<std::sync::MutexGuard<'_, T>> {
62 let start = Instant::now();
63 loop {
64 match mutex.try_lock() {
65 Ok(guard) => return Some(guard),
66 Err(std::sync::TryLockError::WouldBlock) => {
67 if start.elapsed() >= timeout {
68 return None;
69 }
70 std::thread::yield_now();
71 }
72 Err(std::sync::TryLockError::Poisoned(err)) => {
73 log::error!("Agent mutex poisoned: {}", err);
74 return None;
75 }
76 }
77 }
78}
79
80pub struct GornaArbitrator {
87 lock_timeout: Duration,
88 modes: HashMap<AgentId, AdaptationMode>,
91 wave_plan: Vec<Vec<AgentId>>,
95}
96
97struct AgentNegotiation {
99 agent_index: usize,
100 agent_id: AgentId,
101 priority: f32,
102 strategies: Vec<StrategyOption>,
103}
104
105struct AgentAllocation {
107 agent_index: usize,
108 strategy: StrategyOption,
109}
110
111impl GornaArbitrator {
112 pub fn new(lock_timeout: Duration) -> Self {
118 Self {
119 lock_timeout,
120 modes: HashMap::new(),
121 wave_plan: Vec::new(),
122 }
123 }
124
125 pub fn set_wave_plan(&mut self, waves: &[Vec<AgentId>]) {
131 self.wave_plan = waves.to_vec();
132 }
133
134 pub fn set_adaptation_mode(&mut self, agent_id: AgentId, mode: AdaptationMode) {
137 self.modes.insert(agent_id, mode);
138 }
139
140 pub fn adaptation_mode(&self, agent_id: AgentId) -> AdaptationMode {
142 self.modes.get(&agent_id).copied().unwrap_or_default()
143 }
144 pub fn arbitrate(
165 &self,
166 context: &Context,
167 report: &AnalysisReport,
168 agents: &mut [Arc<Mutex<dyn Agent>>],
169 measured_costs: &HashMap<AgentId, f64>,
170 replay: Option<&TickDecisions>,
171 hints: &HashMap<AgentId, AgentHints>,
172 ) -> TickDecisions {
173 if agents.is_empty() {
174 return TickDecisions::new();
175 }
176
177 log::debug!(
178 "GORNA: Starting arbitration for {} agents. Phase={:?}, Multiplier={:.2}",
179 agents.len(),
180 context.mode,
181 context.global_budget_multiplier
182 );
183
184 let stalled_count = self.check_agent_health(agents);
186 if stalled_count >= MAX_STALLED_AGENTS || report.death_spiral_detected {
187 log::error!(
188 "GORNA: Death spiral detected ({} stalled agents). \
189 Forcing emergency LowPower on all agents.",
190 stalled_count
191 );
192 return self.emergency_stop(agents);
193 }
194
195 let base_latency_ms = report.suggested_latency_ms;
198 let effective_budget_ms = base_latency_ms * context.global_budget_multiplier;
200
201 log::debug!(
202 "GORNA: Effective frame budget: {:.2}ms (base={:.2}ms × multiplier={:.2})",
203 effective_budget_ms,
204 base_latency_ms,
205 context.global_budget_multiplier
206 );
207
208 let mut negotiations: Vec<AgentNegotiation> = Vec::with_capacity(agents.len());
210
211 for (i, agent_mutex) in agents.iter().enumerate() {
212 let Some(mut agent) = try_lock_agent_with_timeout(agent_mutex, self.lock_timeout)
213 else {
214 log::warn!(
215 "GORNA: Failed to lock agent {} for negotiation (timeout). Skipping.",
216 i
217 );
218 continue;
219 };
220 let agent_id = agent.id();
221 let priority = hints
224 .get(&agent_id)
225 .and_then(|h| h.priority)
226 .unwrap_or_else(|| self.get_agent_priority(agent_id));
227 let timing = agent.execution_timing();
228 let current_strategy = agent.report_status().current_strategy;
229
230 let request = NegotiationRequest {
231 target_latency: Duration::from_secs_f64(effective_budget_ms as f64 / 1000.0),
232 priority_weight: priority,
233 constraints: ResourceConstraints {
234 must_run: self.is_critical_agent(agent_id),
235 ..Default::default()
236 },
237 current_mode: context.mode.clone(),
238 agent_timing: timing,
239 };
240
241 let response = agent.negotiate(request);
242
243 if response.strategies.is_empty() {
244 log::warn!(
245 "GORNA: Agent {:?} returned no strategies. Skipping.",
246 agent_id
247 );
248 continue;
249 }
250
251 let mut strategies = response.strategies;
253 strategies.sort_by_key(|s| s.estimated_time);
254
255 if let Some(&measured_ms) = measured_costs.get(&agent_id) {
263 let quoted_ms = strategies
264 .iter()
265 .find(|s| s.id == current_strategy)
266 .map(|s| s.estimated_time.as_secs_f64() * 1000.0)
267 .filter(|ms| *ms > f64::EPSILON);
268 if let Some(quoted_ms) = quoted_ms {
269 let factor = (measured_ms / quoted_ms)
270 .clamp(CALIBRATION_FACTOR_MIN, CALIBRATION_FACTOR_MAX);
271 for s in &mut strategies {
272 s.estimated_time =
273 Duration::from_secs_f64(s.estimated_time.as_secs_f64() * factor);
274 }
275 log::debug!(
276 "GORNA: Calibrated {:?} estimates ×{:.2} \
277 (measured {:.2}ms vs quoted {:.2}ms at {:?})",
278 agent_id,
279 factor,
280 measured_ms,
281 quoted_ms,
282 current_strategy
283 );
284 }
285 }
286
287 negotiations.push(AgentNegotiation {
288 agent_index: i,
289 agent_id,
290 priority,
291 strategies,
292 });
293 }
294
295 let max_vram = context
297 .hardware
298 .available_vram
299 .or(context.hardware.total_vram);
300 let allocations = self.fit_budgets(&negotiations, effective_budget_ms, max_vram);
301
302 let mut issued: TickDecisions = Vec::with_capacity(allocations.len());
304 for alloc in &allocations {
305 let Some(mut agent) =
306 try_lock_agent_with_timeout(&agents[alloc.agent_index], self.lock_timeout)
307 else {
308 log::warn!(
309 "GORNA: Failed to lock agent for budget issuance (index {}). Skipping.",
310 alloc.agent_index
311 );
312 continue;
313 };
314
315 let agent_id = agent.id();
316
317 let strategy = if let Some(recorded) = replay {
318 recorded
322 .iter()
323 .find(|(id, _)| *id == agent_id)
324 .and_then(|(_, sid)| self.strategy_for(&negotiations, alloc.agent_index, *sid))
325 .unwrap_or_else(|| alloc.strategy.clone())
326 } else {
327 match self.adaptation_mode(agent_id) {
331 AdaptationMode::Manual(pinned) => self
332 .strategy_for(&negotiations, alloc.agent_index, pinned)
333 .unwrap_or_else(|| alloc.strategy.clone()),
334 AdaptationMode::Stable => {
335 let current = agent.report_status().current_strategy;
338 match self.strategy_for(&negotiations, alloc.agent_index, current) {
339 Some(cur) if alloc.strategy.estimated_time > cur.estimated_time => cur,
340 _ => alloc.strategy.clone(),
341 }
342 }
343 AdaptationMode::Bounded { min, max } => self.clamp_strategy(
344 &negotiations,
345 alloc.agent_index,
346 &alloc.strategy,
347 min,
348 max,
349 ),
350 AdaptationMode::Learning => alloc.strategy.clone(),
351 }
352 };
353
354 let strategy = if replay.is_none()
358 && !matches!(self.adaptation_mode(agent_id), AdaptationMode::Manual(_))
359 {
360 self.apply_cap(
361 &negotiations,
362 alloc.agent_index,
363 strategy,
364 hints.get(&agent_id),
365 )
366 } else {
367 strategy
368 };
369
370 let budget = ResourceBudget {
371 strategy_id: strategy.id,
372 time_limit: strategy.estimated_time,
373 memory_limit: Some(strategy.estimated_vram),
374 extra_params: std::collections::HashMap::new(),
375 };
376
377 log::info!(
378 "GORNA: Issuing budget to {:?} — strategy={:?}, time={:.2}ms, vram={}KB",
379 agent_id,
380 budget.strategy_id,
381 budget.time_limit.as_secs_f64() * 1000.0,
382 strategy.estimated_vram / 1024
383 );
384
385 agent.apply_budget(budget);
386 issued.push((agent_id, strategy.id));
387 }
388
389 log::debug!(
390 "GORNA: Arbitration complete. {} budgets issued.",
391 issued.len()
392 );
393 issued
394 }
395
396 fn check_agent_health(&self, agents: &[Arc<Mutex<dyn Agent>>]) -> usize {
398 let mut stalled = 0;
399 for (i, agent_mutex) in agents.iter().enumerate() {
400 let Some(agent) = try_lock_agent_with_timeout(agent_mutex, self.lock_timeout) else {
401 log::warn!(
402 "GORNA: Failed to lock agent {} for health check (timeout).",
403 i
404 );
405 continue;
406 };
407 let status = agent.report_status();
408 if status.is_stalled {
409 log::warn!(
410 "GORNA: Agent {:?} is STALLED. Health={:.2}, Message: {}",
411 status.agent_id,
412 status.health_score,
413 status.message
414 );
415 stalled += 1;
416 } else if status.health_score < 0.5 {
417 log::warn!(
418 "GORNA: Agent {:?} health degraded ({:.2}). Message: {}",
419 status.agent_id,
420 status.health_score,
421 status.message
422 );
423 }
424 }
425 stalled
426 }
427
428 fn emergency_stop(&self, agents: &mut [Arc<Mutex<dyn Agent>>]) -> TickDecisions {
431 let mut issued = TickDecisions::with_capacity(agents.len());
432 for (i, agent_mutex) in agents.iter_mut().enumerate() {
433 let Some(mut agent) = try_lock_agent_with_timeout(agent_mutex, self.lock_timeout)
434 else {
435 log::warn!(
436 "GORNA: Failed to lock agent {} for emergency stop (timeout).",
437 i
438 );
439 continue;
440 };
441
442 let budget = ResourceBudget {
443 strategy_id: StrategyId::LowPower,
444 time_limit: Duration::from_millis(2),
445 memory_limit: None,
446 extra_params: std::collections::HashMap::new(),
447 };
448
449 log::warn!("GORNA: Emergency LowPower issued to {:?}.", agent.id());
450 agent.apply_budget(budget);
451 issued.push((agent.id(), StrategyId::LowPower));
452 }
453 issued
454 }
455
456 fn assign_waves(&self, negotiations: &[AgentNegotiation]) -> Vec<usize> {
463 let mut wave_of_id: HashMap<AgentId, usize> = HashMap::new();
464 for (w, wave) in self.wave_plan.iter().enumerate() {
465 for id in wave {
466 wave_of_id.entry(*id).or_insert(w);
467 }
468 }
469 let mut next_singleton = self.wave_plan.len();
470 negotiations
471 .iter()
472 .map(|n| match wave_of_id.get(&n.agent_id) {
473 Some(&w) => w,
474 None => {
475 let w = next_singleton;
476 next_singleton += 1;
477 w
478 }
479 })
480 .collect()
481 }
482
483 fn fit_budgets(
498 &self,
499 negotiations: &[AgentNegotiation],
500 total_budget_ms: f32,
501 max_vram_bytes: Option<u64>,
502 ) -> Vec<AgentAllocation> {
503 if negotiations.is_empty() {
504 return Vec::new();
505 }
506
507 let mut sorted_indices: Vec<usize> = (0..negotiations.len()).collect();
508 sorted_indices.sort_by(|&a, &b| {
509 negotiations[b]
510 .priority
511 .partial_cmp(&negotiations[a].priority)
512 .unwrap_or(std::cmp::Ordering::Equal)
513 });
514
515 let mut allocations: Vec<AgentAllocation> = negotiations
516 .iter()
517 .map(|n| AgentAllocation {
518 agent_index: n.agent_index,
519 strategy: n.strategies[0].clone(),
520 })
521 .collect();
522
523 let wave_of = self.assign_waves(negotiations);
528 let wave_count = wave_of.iter().copied().max().map_or(0, |m| m + 1);
529
530 let cost_ms = |a: &AgentAllocation| a.strategy.estimated_time.as_secs_f32() * 1000.0;
534 let mut wave_max = vec![0.0_f32; wave_count];
535 for (i, a) in allocations.iter().enumerate() {
536 wave_max[wave_of[i]] = wave_max[wave_of[i]].max(cost_ms(a));
537 }
538 let total_min_ms: f32 = wave_max.iter().sum();
539
540 let total_min_vram: u64 = allocations.iter().map(|a| a.strategy.estimated_vram).sum();
541
542 if total_min_ms > total_budget_ms {
543 log::warn!(
544 "GORNA: Even minimum strategies ({:.2}ms critical path) exceed budget ({:.2}ms). \
545 All agents at LowPower.",
546 total_min_ms,
547 total_budget_ms
548 );
549 return allocations;
550 }
551
552 if let Some(max_vram) = max_vram_bytes {
553 if total_min_vram > max_vram {
554 log::warn!(
555 "GORNA: Even minimum strategies VRAM ({:.2}MB) exceeds budget ({:.2}MB).",
556 total_min_vram as f64 / (1024.0 * 1024.0),
557 max_vram as f64 / (1024.0 * 1024.0)
558 );
559 }
560 }
561
562 let mut remaining_ms = total_budget_ms - total_min_ms;
563 let mut current_vram = total_min_vram;
564
565 for &idx in &sorted_indices {
566 let negotiation = &negotiations[idx];
567 let w = wave_of[idx];
568 let current_vram_cost = allocations[idx].strategy.estimated_vram;
569
570 let mut best_upgrade: Option<&StrategyOption> = None;
571 for strategy in negotiation.strategies.iter().rev() {
572 let cost = strategy.estimated_time.as_secs_f32() * 1000.0;
573 let delta_ms = (cost - wave_max[w]).max(0.0);
577 let delta_vram = strategy.estimated_vram.saturating_sub(current_vram_cost);
578
579 let time_fits = delta_ms <= remaining_ms;
580 let vram_fits = max_vram_bytes
581 .map(|max| current_vram + delta_vram <= max)
582 .unwrap_or(true);
583
584 if time_fits && vram_fits {
585 best_upgrade = Some(strategy);
586 break;
587 }
588 }
589
590 if let Some(upgrade) = best_upgrade {
591 let new_cost = upgrade.estimated_time.as_secs_f32() * 1000.0;
592 let delta_vram = upgrade.estimated_vram.saturating_sub(current_vram_cost);
593 let delta_frame = (new_cost - wave_max[w]).max(0.0);
594
595 remaining_ms -= delta_frame;
596 wave_max[w] = wave_max[w].max(new_cost);
597 current_vram += delta_vram;
598 allocations[idx].strategy = upgrade.clone();
599
600 log::trace!(
601 "GORNA: Upgraded {:?} to {:.2}ms (wave {} max={:.2}ms, remaining={:.2}ms, vram={:.2}MB)",
602 negotiation.agent_id,
603 new_cost,
604 w,
605 wave_max[w],
606 remaining_ms,
607 current_vram as f64 / (1024.0 * 1024.0)
608 );
609 }
610 }
611
612 if let Some(max_vram) = max_vram_bytes {
613 let total_vram: u64 = allocations.iter().map(|a| a.strategy.estimated_vram).sum();
614 log::debug!(
615 "GORNA: Total VRAM allocated: {:.2}MB / {:.2}MB",
616 total_vram as f64 / (1024.0 * 1024.0),
617 max_vram as f64 / (1024.0 * 1024.0)
618 );
619 }
620
621 allocations
622 }
623
624 fn clamp_strategy(
628 &self,
629 negotiations: &[AgentNegotiation],
630 agent_index: usize,
631 fitted: &StrategyOption,
632 min: StrategyId,
633 max: StrategyId,
634 ) -> StrategyOption {
635 let (lo, hi) = (strategy_rank(min), strategy_rank(max));
636 let fr = strategy_rank(fitted.id);
637 if fr >= lo && fr <= hi {
638 return fitted.clone();
639 }
640 let Some(n) = negotiations.iter().find(|n| n.agent_index == agent_index) else {
641 return fitted.clone();
642 };
643 let mut best: Option<&StrategyOption> = None;
644 for s in &n.strategies {
645 let sr = strategy_rank(s.id);
646 if sr < lo || sr > hi {
647 continue;
648 }
649 let closer = match best {
650 None => true,
651 Some(b) => {
652 (sr as i32 - fr as i32).abs() < (strategy_rank(b.id) as i32 - fr as i32).abs()
653 }
654 };
655 if closer {
656 best = Some(s);
657 }
658 }
659 best.cloned().unwrap_or_else(|| fitted.clone())
660 }
661
662 fn apply_cap(
667 &self,
668 negotiations: &[AgentNegotiation],
669 agent_index: usize,
670 fitted: StrategyOption,
671 hints: Option<&AgentHints>,
672 ) -> StrategyOption {
673 let Some(max_ms) = hints.and_then(|h| h.cap_ms) else {
674 return fitted;
675 };
676 if fitted.estimated_time.as_secs_f32() * 1000.0 <= max_ms {
677 return fitted;
678 }
679 let Some(n) = negotiations.iter().find(|n| n.agent_index == agent_index) else {
680 return fitted;
681 };
682 let mut chosen = &n.strategies[0];
686 for s in &n.strategies {
687 if s.estimated_time.as_secs_f32() * 1000.0 <= max_ms {
688 chosen = s;
689 } else {
690 break;
691 }
692 }
693 chosen.clone()
694 }
695
696 fn strategy_for(
699 &self,
700 negotiations: &[AgentNegotiation],
701 agent_index: usize,
702 id: StrategyId,
703 ) -> Option<StrategyOption> {
704 negotiations
705 .iter()
706 .find(|n| n.agent_index == agent_index)
707 .and_then(|n| n.strategies.iter().find(|s| s.id == id).cloned())
708 }
709
710 fn get_agent_priority(&self, id: AgentId) -> f32 {
715 match id {
716 AgentId::Renderer => 1.0,
717 AgentId::ShadowRenderer => 1.0,
718 AgentId::Physics => 1.0,
719 AgentId::Ecs => 0.8,
720 AgentId::Ui => 0.7,
721 AgentId::Audio => 0.6,
722 AgentId::Asset => 0.5,
723 AgentId::Overlay => 0.4,
724 AgentId::Skybox => 0.4,
725 }
726 }
727
728 fn is_critical_agent(&self, id: AgentId) -> bool {
731 matches!(
732 id,
733 AgentId::Renderer | AgentId::Physics | AgentId::Ecs | AgentId::Ui
734 )
735 }
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use crate::analysis::AnalysisReport;
742 use crate::context::Context;
743 use crate::EngineMode;
744 use khora_core::agent::Agent;
745 use khora_core::control::gorna::{
746 AdaptationMode, AgentHints, AgentId, AgentStatus, NegotiationRequest, NegotiationResponse,
747 ResourceBudget, StrategyId, StrategyOption, TickDecisions,
748 };
749 use khora_core::EngineContext;
750
751 struct MockAgent {
754 id: AgentId,
755 applied_budget: Option<ResourceBudget>,
756 is_stalled: bool,
757 health: f32,
758 }
759
760 impl MockAgent {
761 fn new(id: AgentId) -> Self {
762 Self {
763 id,
764 applied_budget: None,
765 is_stalled: false,
766 health: 1.0,
767 }
768 }
769
770 fn stalled(id: AgentId) -> Self {
771 Self {
772 id,
773 applied_budget: None,
774 is_stalled: true,
775 health: 0.0,
776 }
777 }
778 }
779
780 impl Agent for MockAgent {
781 fn id(&self) -> AgentId {
782 self.id
783 }
784
785 fn negotiate(&mut self, _request: NegotiationRequest) -> NegotiationResponse {
786 NegotiationResponse {
787 strategies: vec![
788 StrategyOption {
789 id: StrategyId::LowPower,
790 estimated_time: Duration::from_millis(2),
791 estimated_vram: 1024,
792 },
793 StrategyOption {
794 id: StrategyId::Balanced,
795 estimated_time: Duration::from_millis(8),
796 estimated_vram: 10 * 1024 * 1024,
797 },
798 StrategyOption {
799 id: StrategyId::HighPerformance,
800 estimated_time: Duration::from_millis(14),
801 estimated_vram: 20 * 1024 * 1024,
802 },
803 ],
804 timing_adjustment: None,
805 }
806 }
807
808 fn apply_budget(&mut self, budget: ResourceBudget) {
809 self.applied_budget = Some(budget);
810 }
811
812 fn report_status(&self) -> AgentStatus {
813 AgentStatus {
814 agent_id: self.id,
815 current_strategy: self
816 .applied_budget
817 .as_ref()
818 .map(|b| b.strategy_id)
819 .unwrap_or(StrategyId::Balanced),
820 health_score: self.health,
821 is_stalled: self.is_stalled,
822 message: String::new(),
823 }
824 }
825
826 fn execute(&mut self, _context: &mut EngineContext<'_>) {}
827
828 fn as_any(&self) -> &dyn std::any::Any {
829 self
830 }
831
832 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
833 self
834 }
835 }
836
837 fn normal_report() -> AnalysisReport {
838 AnalysisReport {
839 needs_negotiation: true,
840 suggested_latency_ms: 16.66,
841 death_spiral_detected: false,
842 alerts: Vec::new(),
843 }
844 }
845
846 fn simulation_ctx() -> Context {
847 Context {
848 mode: EngineMode::Playing,
849 global_budget_multiplier: 1.0,
850 ..Default::default()
851 }
852 }
853
854 fn create_arbitrator() -> GornaArbitrator {
857 GornaArbitrator::new(Duration::from_millis(100))
858 }
859
860 #[test]
861 fn test_measured_costs_calibrate_fit_downward() {
862 let arbitrator = create_arbitrator();
863 let ctx = simulation_ctx();
864 let report = normal_report();
865 let agent = MockAgent::new(AgentId::Renderer);
866 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![Arc::new(Mutex::new(agent))];
867
868 let measured: HashMap<AgentId, f64> = [(AgentId::Renderer, 24.0)].into();
873 let issued =
874 arbitrator.arbitrate(&ctx, &report, &mut agents, &measured, None, &HashMap::new());
875
876 assert_eq!(issued, vec![(AgentId::Renderer, StrategyId::LowPower)]);
877 }
878
879 #[test]
880 fn test_calibration_factor_is_clamped() {
881 let arbitrator = create_arbitrator();
882 let ctx = simulation_ctx();
883 let report = normal_report();
884 let agent = MockAgent::new(AgentId::Renderer);
885 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![Arc::new(Mutex::new(agent))];
886
887 let measured: HashMap<AgentId, f64> = [(AgentId::Renderer, 800.0)].into();
891 let issued =
892 arbitrator.arbitrate(&ctx, &report, &mut agents, &measured, None, &HashMap::new());
893
894 assert_eq!(issued, vec![(AgentId::Renderer, StrategyId::LowPower)]);
895 }
896
897 #[test]
898 fn test_arbitrate_single_agent_gets_best_strategy() {
899 let arbitrator = create_arbitrator();
900 let ctx = simulation_ctx();
901 let report = normal_report();
902 let agent = MockAgent::new(AgentId::Renderer);
903 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![Arc::new(Mutex::new(agent))];
904
905 arbitrator.arbitrate(
906 &ctx,
907 &report,
908 &mut agents,
909 &HashMap::new(),
910 None,
911 &HashMap::new(),
912 );
913
914 let lock = agents[0].lock().unwrap();
915 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
916 let budget = mock
917 .applied_budget
918 .as_ref()
919 .expect("Budget should be applied");
920 assert_eq!(budget.strategy_id, StrategyId::HighPerformance);
922 }
923
924 #[test]
925 fn test_fit_budgets_costs_concurrent_wave_by_critical_path() {
926 let ctx = simulation_ctx();
927 let report = normal_report();
928 let two_agents = || -> Vec<Arc<Mutex<dyn Agent>>> {
929 vec![
930 Arc::new(Mutex::new(MockAgent::new(AgentId::Renderer))),
931 Arc::new(Mutex::new(MockAgent::new(AgentId::Physics))),
932 ]
933 };
934
935 let serial = create_arbitrator();
939 let mut agents = two_agents();
940 let issued = serial.arbitrate(
941 &ctx,
942 &report,
943 &mut agents,
944 &HashMap::new(),
945 None,
946 &HashMap::new(),
947 );
948 let hp_serial = issued
949 .iter()
950 .filter(|(_, s)| *s == StrategyId::HighPerformance)
951 .count();
952 assert!(
953 hp_serial < 2,
954 "serial fit must not grant both HighPerformance: {issued:?}"
955 );
956
957 let mut concurrent = create_arbitrator();
961 concurrent.set_wave_plan(&[vec![AgentId::Renderer, AgentId::Physics]]);
962 let mut agents = two_agents();
963 let issued = concurrent.arbitrate(
964 &ctx,
965 &report,
966 &mut agents,
967 &HashMap::new(),
968 None,
969 &HashMap::new(),
970 );
971 let hp_concurrent = issued
972 .iter()
973 .filter(|(_, s)| *s == StrategyId::HighPerformance)
974 .count();
975 assert_eq!(
976 hp_concurrent, 2,
977 "concurrent wave must grant both HighPerformance: {issued:?}"
978 );
979 }
980
981 #[test]
982 fn test_manual_mode_pins_strategy_against_budget() {
983 let mut arbitrator = create_arbitrator();
984 arbitrator.set_adaptation_mode(
985 AgentId::Renderer,
986 AdaptationMode::Manual(StrategyId::LowPower),
987 );
988
989 let ctx = simulation_ctx();
990 let report = normal_report();
991 let agent = MockAgent::new(AgentId::Renderer);
992 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![Arc::new(Mutex::new(agent))];
993
994 arbitrator.arbitrate(
995 &ctx,
996 &report,
997 &mut agents,
998 &HashMap::new(),
999 None,
1000 &HashMap::new(),
1001 );
1002
1003 let lock = agents[0].lock().unwrap();
1004 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
1005 let budget = mock
1006 .applied_budget
1007 .as_ref()
1008 .expect("Budget should be applied");
1009 assert_eq!(budget.strategy_id, StrategyId::LowPower);
1012 }
1013
1014 #[test]
1015 fn test_stable_mode_blocks_opportunistic_upgrade() {
1016 let mut arbitrator = create_arbitrator();
1017 arbitrator.set_adaptation_mode(AgentId::Renderer, AdaptationMode::Stable);
1018
1019 let ctx = simulation_ctx();
1020 let report = normal_report();
1021 let agent = MockAgent::new(AgentId::Renderer);
1025 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![Arc::new(Mutex::new(agent))];
1026
1027 arbitrator.arbitrate(
1028 &ctx,
1029 &report,
1030 &mut agents,
1031 &HashMap::new(),
1032 None,
1033 &HashMap::new(),
1034 );
1035
1036 let lock = agents[0].lock().unwrap();
1037 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
1038 assert_eq!(
1039 mock.applied_budget.as_ref().unwrap().strategy_id,
1040 StrategyId::Balanced
1041 );
1042 }
1043
1044 #[test]
1045 fn test_bounded_mode_clamps_to_max() {
1046 let mut arbitrator = create_arbitrator();
1047 arbitrator.set_adaptation_mode(
1048 AgentId::Renderer,
1049 AdaptationMode::Bounded {
1050 min: StrategyId::LowPower,
1051 max: StrategyId::Balanced,
1052 },
1053 );
1054
1055 let ctx = simulation_ctx();
1056 let report = normal_report();
1057 let agent = MockAgent::new(AgentId::Renderer);
1059 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![Arc::new(Mutex::new(agent))];
1060
1061 arbitrator.arbitrate(
1062 &ctx,
1063 &report,
1064 &mut agents,
1065 &HashMap::new(),
1066 None,
1067 &HashMap::new(),
1068 );
1069
1070 let lock = agents[0].lock().unwrap();
1071 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
1072 assert_eq!(
1073 mock.applied_budget.as_ref().unwrap().strategy_id,
1074 StrategyId::Balanced
1075 );
1076 }
1077
1078 #[test]
1079 fn test_arbitrate_respects_global_budget() {
1080 let arbitrator = create_arbitrator();
1081 let ctx = simulation_ctx();
1082 let report = normal_report();
1083
1084 let renderer = MockAgent::new(AgentId::Renderer);
1090 let physics = MockAgent::new(AgentId::Physics);
1091 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![
1092 Arc::new(Mutex::new(renderer)),
1093 Arc::new(Mutex::new(physics)),
1094 ];
1095
1096 arbitrator.arbitrate(
1097 &ctx,
1098 &report,
1099 &mut agents,
1100 &HashMap::new(),
1101 None,
1102 &HashMap::new(),
1103 );
1104
1105 for agent_mutex in &agents {
1107 let lock = agent_mutex.lock().unwrap();
1108 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
1109 assert!(mock.applied_budget.is_some());
1110 }
1111
1112 let total_cost_ms: f64 = agents
1114 .iter()
1115 .map(|a| {
1116 let lock = a.lock().unwrap();
1117 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
1118 mock.applied_budget
1119 .as_ref()
1120 .unwrap()
1121 .time_limit
1122 .as_secs_f64()
1123 * 1000.0
1124 })
1125 .sum();
1126 assert!(
1127 total_cost_ms <= 16.66 + 0.1,
1128 "Total cost {:.2}ms exceeds budget 16.66ms",
1129 total_cost_ms
1130 );
1131 }
1132
1133 #[test]
1134 fn test_arbitrate_thermal_reduces_budget() {
1135 let arbitrator = create_arbitrator();
1136 let mut ctx = simulation_ctx();
1137 ctx.hardware.thermal = khora_core::platform::ThermalStatus::Throttling;
1138 ctx.global_budget_multiplier = 0.6;
1141
1142 let mut report = normal_report();
1143 report.suggested_latency_ms = 33.33; let agent = MockAgent::new(AgentId::Renderer);
1146 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![Arc::new(Mutex::new(agent))];
1147
1148 arbitrator.arbitrate(
1149 &ctx,
1150 &report,
1151 &mut agents,
1152 &HashMap::new(),
1153 None,
1154 &HashMap::new(),
1155 );
1156
1157 let lock = agents[0].lock().unwrap();
1158 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
1159 let budget = mock
1160 .applied_budget
1161 .as_ref()
1162 .expect("Budget should be applied");
1163 assert_eq!(budget.strategy_id, StrategyId::HighPerformance);
1165 }
1166
1167 #[test]
1168 fn test_emergency_stop_on_death_spiral() {
1169 let arbitrator = create_arbitrator();
1170 let ctx = simulation_ctx();
1171 let mut report = normal_report();
1172 report.death_spiral_detected = true;
1173
1174 let renderer = MockAgent::new(AgentId::Renderer);
1175 let physics = MockAgent::new(AgentId::Physics);
1176 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![
1177 Arc::new(Mutex::new(renderer)),
1178 Arc::new(Mutex::new(physics)),
1179 ];
1180
1181 arbitrator.arbitrate(
1182 &ctx,
1183 &report,
1184 &mut agents,
1185 &HashMap::new(),
1186 None,
1187 &HashMap::new(),
1188 );
1189
1190 for agent_mutex in &agents {
1192 let lock = agent_mutex.lock().unwrap();
1193 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
1194 let budget = mock
1195 .applied_budget
1196 .as_ref()
1197 .expect("Budget should be applied");
1198 assert_eq!(budget.strategy_id, StrategyId::LowPower);
1199 }
1200 }
1201
1202 #[test]
1203 fn test_emergency_stop_on_stalled_agents() {
1204 let arbitrator = create_arbitrator();
1205 let ctx = simulation_ctx();
1206 let report = normal_report();
1207
1208 let stalled1 = MockAgent::stalled(AgentId::Renderer);
1210 let stalled2 = MockAgent::stalled(AgentId::Physics);
1211 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![
1212 Arc::new(Mutex::new(stalled1)),
1213 Arc::new(Mutex::new(stalled2)),
1214 ];
1215
1216 arbitrator.arbitrate(
1217 &ctx,
1218 &report,
1219 &mut agents,
1220 &HashMap::new(),
1221 None,
1222 &HashMap::new(),
1223 );
1224
1225 for agent_mutex in &agents {
1227 let lock = agent_mutex.lock().unwrap();
1228 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
1229 let budget = mock
1230 .applied_budget
1231 .as_ref()
1232 .expect("Budget should be applied");
1233 assert_eq!(budget.strategy_id, StrategyId::LowPower);
1234 }
1235 }
1236
1237 #[test]
1238 fn test_arbitrate_empty_agents() {
1239 let arbitrator = create_arbitrator();
1240 let ctx = simulation_ctx();
1241 let report = normal_report();
1242 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![];
1243
1244 arbitrator.arbitrate(
1246 &ctx,
1247 &report,
1248 &mut agents,
1249 &HashMap::new(),
1250 None,
1251 &HashMap::new(),
1252 );
1253 }
1254
1255 #[test]
1256 fn test_priority_order_renderer_before_asset_in_simulation() {
1257 let arbitrator = create_arbitrator();
1258 let ctx = simulation_ctx();
1259 let report = normal_report();
1260
1261 let mut tight_report = report;
1264 tight_report.suggested_latency_ms = 10.0;
1265
1266 let renderer = MockAgent::new(AgentId::Renderer);
1267 let asset = MockAgent::new(AgentId::Asset);
1268 let mut agents: Vec<Arc<Mutex<dyn Agent>>> =
1269 vec![Arc::new(Mutex::new(renderer)), Arc::new(Mutex::new(asset))];
1270
1271 arbitrator.arbitrate(
1272 &ctx,
1273 &tight_report,
1274 &mut agents,
1275 &HashMap::new(),
1276 None,
1277 &HashMap::new(),
1278 );
1279
1280 let renderer_lock = agents[0].lock().unwrap();
1284 let renderer_mock =
1285 unsafe { &*((&*renderer_lock as *const dyn Agent) as *const MockAgent) };
1286 assert_eq!(
1287 renderer_mock.applied_budget.as_ref().unwrap().strategy_id,
1288 StrategyId::Balanced
1289 );
1290 }
1291
1292 #[test]
1293 fn test_hint_cap_clamps_issued_strategy() {
1294 let arbitrator = create_arbitrator();
1295 let ctx = simulation_ctx();
1296 let report = normal_report();
1297 let mut agents: Vec<Arc<Mutex<dyn Agent>>> =
1298 vec![Arc::new(Mutex::new(MockAgent::new(AgentId::Renderer)))];
1299
1300 let hints: HashMap<AgentId, AgentHints> = [(
1303 AgentId::Renderer,
1304 AgentHints {
1305 cap_ms: Some(5.0),
1306 priority: None,
1307 },
1308 )]
1309 .into();
1310 let issued =
1311 arbitrator.arbitrate(&ctx, &report, &mut agents, &HashMap::new(), None, &hints);
1312 assert_eq!(issued, vec![(AgentId::Renderer, StrategyId::LowPower)]);
1313 }
1314
1315 #[test]
1316 fn test_hint_cap_allows_richest_within_ceiling() {
1317 let arbitrator = create_arbitrator();
1318 let ctx = simulation_ctx();
1319 let report = normal_report();
1320 let mut agents: Vec<Arc<Mutex<dyn Agent>>> =
1321 vec![Arc::new(Mutex::new(MockAgent::new(AgentId::Renderer)))];
1322
1323 let hints: HashMap<AgentId, AgentHints> = [(
1325 AgentId::Renderer,
1326 AgentHints {
1327 cap_ms: Some(10.0),
1328 priority: None,
1329 },
1330 )]
1331 .into();
1332 let issued =
1333 arbitrator.arbitrate(&ctx, &report, &mut agents, &HashMap::new(), None, &hints);
1334 assert_eq!(issued, vec![(AgentId::Renderer, StrategyId::Balanced)]);
1335 }
1336
1337 #[test]
1338 fn test_hint_prioritize_reorders_budget_fit() {
1339 let arbitrator = create_arbitrator();
1340 let ctx = simulation_ctx();
1341 let report = normal_report();
1342 let mut tight_report = report;
1343 tight_report.suggested_latency_ms = 10.0;
1344
1345 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![
1349 Arc::new(Mutex::new(MockAgent::new(AgentId::Renderer))),
1350 Arc::new(Mutex::new(MockAgent::new(AgentId::Asset))),
1351 ];
1352 let hints: HashMap<AgentId, AgentHints> = [(
1353 AgentId::Asset,
1354 AgentHints {
1355 cap_ms: None,
1356 priority: Some(2.0),
1357 },
1358 )]
1359 .into();
1360
1361 arbitrator.arbitrate(
1362 &ctx,
1363 &tight_report,
1364 &mut agents,
1365 &HashMap::new(),
1366 None,
1367 &hints,
1368 );
1369
1370 let renderer = agents[0].lock().unwrap();
1371 let renderer_mock = unsafe { &*((&*renderer as *const dyn Agent) as *const MockAgent) };
1372 let asset = agents[1].lock().unwrap();
1373 let asset_mock = unsafe { &*((&*asset as *const dyn Agent) as *const MockAgent) };
1374 assert_eq!(
1375 asset_mock.applied_budget.as_ref().unwrap().strategy_id,
1376 StrategyId::Balanced,
1377 "the prioritized agent takes the upgrade"
1378 );
1379 assert_eq!(
1380 renderer_mock.applied_budget.as_ref().unwrap().strategy_id,
1381 StrategyId::LowPower,
1382 "the deprioritized agent is downgraded"
1383 );
1384 }
1385
1386 #[test]
1387 fn test_hint_targets_only_its_agent() {
1388 let arbitrator = create_arbitrator();
1391 let ctx = simulation_ctx();
1392 let report = normal_report();
1393 let mut agents: Vec<Arc<Mutex<dyn Agent>>> =
1394 vec![Arc::new(Mutex::new(MockAgent::new(AgentId::Renderer)))];
1395 let hints: HashMap<AgentId, AgentHints> = [(
1396 AgentId::Audio,
1397 AgentHints {
1398 cap_ms: Some(1.0),
1399 priority: None,
1400 },
1401 )]
1402 .into();
1403 let issued =
1404 arbitrator.arbitrate(&ctx, &report, &mut agents, &HashMap::new(), None, &hints);
1405 assert_eq!(
1406 issued,
1407 vec![(AgentId::Renderer, StrategyId::HighPerformance)]
1408 );
1409 }
1410
1411 #[test]
1412 fn test_arbitrate_returns_issued_decisions() {
1413 let arbitrator = create_arbitrator();
1414 let ctx = simulation_ctx();
1415 let report = normal_report();
1416 let mut agents: Vec<Arc<Mutex<dyn Agent>>> =
1417 vec![Arc::new(Mutex::new(MockAgent::new(AgentId::Renderer)))];
1418
1419 let issued = arbitrator.arbitrate(
1420 &ctx,
1421 &report,
1422 &mut agents,
1423 &HashMap::new(),
1424 None,
1425 &HashMap::new(),
1426 );
1427 assert_eq!(
1429 issued,
1430 vec![(AgentId::Renderer, StrategyId::HighPerformance)]
1431 );
1432 }
1433
1434 #[test]
1435 fn test_replay_overrides_fit_with_recorded_decision() {
1436 let arbitrator = create_arbitrator();
1437 let ctx = simulation_ctx();
1438 let report = normal_report();
1439 let mut agents: Vec<Arc<Mutex<dyn Agent>>> =
1440 vec![Arc::new(Mutex::new(MockAgent::new(AgentId::Renderer)))];
1441
1442 let recorded: TickDecisions = vec![(AgentId::Renderer, StrategyId::LowPower)];
1445 let replayed = arbitrator.arbitrate(
1446 &ctx,
1447 &report,
1448 &mut agents,
1449 &HashMap::new(),
1450 Some(&recorded),
1451 &HashMap::new(),
1452 );
1453 assert_eq!(replayed, vec![(AgentId::Renderer, StrategyId::LowPower)]);
1454
1455 let lock = agents[0].lock().unwrap();
1456 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
1457 assert_eq!(
1458 mock.applied_budget.as_ref().unwrap().strategy_id,
1459 StrategyId::LowPower
1460 );
1461 }
1462
1463 #[test]
1464 fn test_vram_budget_caps_upgrade() {
1465 let arbitrator = create_arbitrator();
1466 let mut ctx = simulation_ctx();
1467 let report = normal_report();
1468
1469 ctx.hardware.available_vram = Some(15 * 1024 * 1024);
1474
1475 let agent = MockAgent::new(AgentId::Renderer);
1476 let mut agents: Vec<Arc<Mutex<dyn Agent>>> = vec![Arc::new(Mutex::new(agent))];
1477
1478 let issued = arbitrator.arbitrate(
1479 &ctx,
1480 &report,
1481 &mut agents,
1482 &HashMap::new(),
1483 None,
1484 &HashMap::new(),
1485 );
1486
1487 assert_eq!(
1488 issued,
1489 vec![(AgentId::Renderer, StrategyId::Balanced)],
1490 "VRAM ceiling must block the HighPerformance upgrade"
1491 );
1492 let lock = agents[0].lock().unwrap();
1493 let mock = unsafe { &*((&*lock as *const dyn Agent) as *const MockAgent) };
1494 assert_eq!(
1495 mock.applied_budget.as_ref().unwrap().strategy_id,
1496 StrategyId::Balanced
1497 );
1498 }
1499
1500 #[test]
1501 fn test_agent_priorities() {
1502 let arbitrator = create_arbitrator();
1503 assert!(arbitrator.get_agent_priority(AgentId::Renderer) >= 0.9);
1504 assert!(arbitrator.get_agent_priority(AgentId::Physics) >= 0.9);
1505 assert!(arbitrator.get_agent_priority(AgentId::Ui) >= 0.5);
1506 assert!(arbitrator.get_agent_priority(AgentId::Audio) >= 0.5);
1507 }
1508
1509 #[test]
1510 fn test_critical_agents() {
1511 let arbitrator = create_arbitrator();
1512 assert!(arbitrator.is_critical_agent(AgentId::Renderer));
1513 assert!(arbitrator.is_critical_agent(AgentId::Physics));
1514 assert!(arbitrator.is_critical_agent(AgentId::Ecs));
1515 assert!(arbitrator.is_critical_agent(AgentId::Ui));
1516 assert!(!arbitrator.is_critical_agent(AgentId::Audio));
1517 }
1518}