Skip to main content

khora_control/gorna/
mod.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! GORNA Arbitrator implementation.
16//!
17//! This module contains the **Goal-Oriented Resource Negotiation & Allocation**
18//! logic. The arbitrator is responsible for:
19//!
20//! 1. Polling agent health via `report_status()`.
21//! 2. Sending `NegotiationRequest` to each agent and collecting strategy options.
22//! 3. Running a global budget-fitting solver that respects total frame time.
23//! 4. Applying thermal/battery multipliers from the `AnalysisReport`.
24//! 5. Detecting and handling "death spiral" conditions.
25//! 6. Issuing `ResourceBudget` to each agent.
26
27use 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
40/// Clamp range for the empirical calibration factor applied to agent-quoted
41/// strategy costs. Bounds the correction so one pathological measurement
42/// (a hitch, a cold cache) can't swing the whole fit by orders of magnitude.
43const CALIBRATION_FACTOR_MIN: f64 = 0.25;
44const CALIBRATION_FACTOR_MAX: f64 = 4.0;
45
46/// Ordinal rank of a strategy for clamping (`Bounded` mode):
47/// `LowPower < Balanced < HighPerformance`, with `Custom` ranked above the
48/// standard tiers.
49fn 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
80/// Arbitrates resource allocation between multiple ISAs.
81///
82/// The arbitrator implements a two-pass approach:
83/// - **Pass 1 (Negotiation)**: Collects strategy options from all agents.
84/// - **Pass 2 (Fitting)**: Selects the optimal strategy combination that fits
85///   within the global frame budget, respecting priorities and VRAM constraints.
86pub struct GornaArbitrator {
87    lock_timeout: Duration,
88    /// Per-agent developer-control mode (default `Learning`). Configured by the
89    /// host; consulted at issuance so a `Manual` agent is never overridden.
90    modes: HashMap<AgentId, AdaptationMode>,
91    /// The scheduler's latest wave grouping (agents that run concurrently),
92    /// refreshed by the DCC each tick via [`set_wave_plan`](Self::set_wave_plan).
93    /// Empty means serial execution: budget fitting then sums per-agent costs.
94    wave_plan: Vec<Vec<AgentId>>,
95}
96
97/// A collected negotiation from a single agent, used during the fitting pass.
98struct AgentNegotiation {
99    agent_index: usize,
100    agent_id: AgentId,
101    priority: f32,
102    strategies: Vec<StrategyOption>,
103}
104
105/// A resolved allocation for a single agent.
106struct AgentAllocation {
107    agent_index: usize,
108    strategy: StrategyOption,
109}
110
111impl GornaArbitrator {
112    /// Creates a new arbitrator with the specified lock timeout.
113    ///
114    /// The lock timeout determines how long to wait when acquiring locks on agents
115    /// during negotiation and budget issuance. Agents that cannot be locked within
116    /// this timeout are skipped.
117    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    /// Sets the scheduler's latest wave plan (how agents are grouped for
126    /// concurrent execution). Budget fitting costs each wave by its critical
127    /// path (`max` of its members); an empty plan means serial execution, so
128    /// fitting falls back to summing per-agent costs — bit-identical to the
129    /// pre-parallel behaviour.
130    pub fn set_wave_plan(&mut self, waves: &[Vec<AgentId>]) {
131        self.wave_plan = waves.to_vec();
132    }
133
134    /// Sets the [`AdaptationMode`] for an agent — the developer-control surface.
135    /// `Manual(strategy)` pins the agent; `Learning` (default) lets GORNA negotiate.
136    pub fn set_adaptation_mode(&mut self, agent_id: AgentId, mode: AdaptationMode) {
137        self.modes.insert(agent_id, mode);
138    }
139
140    /// Returns the [`AdaptationMode`] configured for an agent (default `Learning`).
141    pub fn adaptation_mode(&self, agent_id: AgentId) -> AdaptationMode {
142        self.modes.get(&agent_id).copied().unwrap_or_default()
143    }
144    /// Performs a full GORNA arbitration round.
145    ///
146    /// # Arguments
147    /// - `context`: The current DCC situational model (phase, hardware, multiplier).
148    /// - `report`: The analysis report from the `HeuristicEngine`.
149    /// - `agents`: The registered ISA agents.
150    /// - `measured_costs`: Per-agent measured execution cost in milliseconds
151    ///   (from the DCC's empirical cost models). Used to calibrate the agents'
152    ///   self-quoted strategy estimates against reality; agents without a
153    ///   measurement keep their quotes as-is (cold start).
154    /// - `replay`: When `Some`, issue the recorded strategy per agent instead of
155    ///   the negotiated fit (deterministic replay — bypasses budget fitting and
156    ///   the per-agent `AdaptationMode`). `None` for normal live arbitration.
157    /// - `hints`: Per-agent developer [`AgentHints`] accumulated by the DCC.
158    ///   `Prioritize` biases the negotiation priority (which agents the fit
159    ///   upgrades first); `Cap` clamps the issued strategy to a time ceiling.
160    ///   Advisory only — `replay` and a `Manual` pin both override a hint.
161    ///
162    /// Returns the [`TickDecisions`] actually issued this tick (agent → strategy),
163    /// so the DCC can record them for later replay.
164    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        // ── 0. Health Check ──────────────────────────────────────────────
185        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        // ── 1. Compute effective frame budget ────────────────────────────
196        // Start from the analysis-suggested latency (accounts for phase, thermal, battery).
197        let base_latency_ms = report.suggested_latency_ms;
198        // Apply the global budget multiplier from the context.
199        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        // ── 2. Negotiation Pass ──────────────────────────────────────────
209        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            // A `Prioritize` hint overrides the default per-agent priority,
222            // steering which agents the budget fit upgrades first.
223            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            // Sort strategies by estimated time (ascending = cheapest first).
252            let mut strategies = response.strategies;
253            strategies.sort_by_key(|s| s.estimated_time);
254
255            // ── 2b. Empirical calibration ────────────────────────────────
256            // Anchor the agent's self-quoted estimates in measured reality:
257            // when the DCC has an observed cost for this agent, rescale every
258            // quoted option so the one matching the agent's *current* strategy
259            // equals the measurement. Relative ordering between options is
260            // preserved — only the absolute scale moves, so the fit reasons
261            // about real milliseconds instead of static worst-case quotes.
262            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        // ── 3. Global Budget Fitting ─────────────────────────────────────
296        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        // ── 4. Issuance Pass ─────────────────────────────────────────────
303        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                // Replay: issue the recorded strategy for this agent, bypassing
319                // the fit and the AdaptationMode (deterministic reproduction).
320                // Fall back to the fit if the recorded strategy isn't offered.
321                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                // Developer control: a `Manual` agent is pinned to its chosen
328                // strategy — GORNA reports but never overrides it. `Learning`
329                // (default) issues the negotiated fit.
330                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                        // No opportunistic upgrade: keep the current strategy unless
336                        // the fit is a downgrade (or the current one isn't offered).
337                        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            // Developer `Cap` hint: clamp the issued strategy down to the time
355            // ceiling. Skipped under replay (deterministic) and for a `Manual`
356            // pin (an explicit strategy choice outranks a budget hint).
357            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    /// Polls all agents for health status and returns the count of stalled agents.
397    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    /// Forces all agents to their lowest-cost strategy as an emergency measure.
429    /// Returns the issued decisions (all `LowPower`) for recording.
430    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    /// Maps each negotiation to a wave id from `self.wave_plan`.
457    ///
458    /// Agents named together in a plan wave share its id (they run concurrently);
459    /// any agent the plan does not mention — and every agent when the plan is
460    /// empty — gets a fresh singleton id, so its cost is summed rather than
461    /// folded into a wave `max`.
462    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    /// Runs the global budget fitting algorithm.
484    ///
485    /// Strategy: Priority-weighted greedy allocation.
486    /// 1. Sort agents by priority (highest first).
487    /// 2. Try to give each agent its most expensive strategy that fits.
488    /// 3. If the total exceeds the budget, downgrade lower-priority agents first.
489    /// 4. Respect VRAM constraints if specified.
490    ///
491    /// Time is costed along the **critical path**: agents grouped in the same
492    /// wave by `self.wave_plan` run concurrently, so the wave contributes only
493    /// its `max` member time to the frame, and the frame budget is spent against
494    /// the sum over waves. An empty plan (serial execution) makes every agent
495    /// its own wave, so this reduces exactly to the sum-of-costs fit. VRAM is
496    /// always additive (memory does not overlap), so it stays a plain sum.
497    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        // Assign each negotiation to a wave id. Agents named together in a plan
524        // wave share one; any agent absent from the plan (or when the plan is
525        // empty) becomes its own singleton wave — so its cost is summed, never
526        // hidden under a `max`.
527        let wave_of = self.assign_waves(negotiations);
528        let wave_count = wave_of.iter().copied().max().map_or(0, |m| m + 1);
529
530        // The frame's baseline time is the sum over waves of each wave's slowest
531        // member (its critical path). `wave_max[w]` also stays the invariant
532        // "current max member cost of wave w" through the upgrade loop below.
533        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                // Upgrades only raise cost, and `wave_max[w]` already covers this
574                // agent's current cost, so the frame grows only if the agent
575                // overtakes its wave's slowest member.
576                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    /// Clamps `fitted` into the `[min, max]` strategy range by picking, from the
625    /// agent's offered strategies within range, the one nearest the fit. Honours
626    /// `Bounded` mode.
627    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    /// Clamps `fitted` down to a developer `Cap` hint: if the fitted strategy's
663    /// estimated cost exceeds `hints.cap_ms`, returns the most expensive offered
664    /// strategy still within the ceiling (or the cheapest, if even that
665    /// exceeds it). No cap, or already within it → `fitted` unchanged.
666    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        // `strategies` is sorted ascending by estimated_time, so the last option
683        // within the ceiling is the richest one that honours the cap; if none
684        // fit, fall back to the cheapest (index 0) — the closest we can get.
685        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    /// Finds the negotiated [`StrategyOption`] with `id` for the agent at
697    /// `agent_index`, if that agent offered it. Used to honour `Manual` mode.
698    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    /// Returns the priority weight for an agent.
711    ///
712    /// Higher values indicate greater importance. The DCC uses these weights to
713    /// decide which agents get upgraded first when budget is available.
714    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    /// Returns `true` if the agent is considered critical
729    /// and must always receive at least its minimum strategy.
730    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    // ── Mock Agent ───────────────────────────────────────────────────
752
753    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    // ── Tests ────────────────────────────────────────────────────────
855
856    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        // The agent quotes Balanced at 8ms but actually measured 24ms (×3).
869        // Calibration rescales the options to 6/24/42ms, so within the 16.66ms
870        // budget only LowPower fits — the fit must downgrade instead of
871        // trusting the optimistic quote (which would have picked HighPerformance).
872        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        // A pathological measurement (800ms vs the 8ms quote = ×100) is clamped
888        // to ×4: options become 8/32/56ms. Even the cheapest exceeds nothing —
889        // LowPower (8ms) still fits the 16.66ms budget, but no upgrade does.
890        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        // With 16.66ms total budget and a single agent, it should get HighPerformance (14ms)
921        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        // Serial (no wave plan): 14 + 14ms > 16.66ms budget, so the fit cannot
936        // grant both agents HighPerformance — one is downgraded. This is the
937        // pre-parallel sum-of-costs behaviour, unchanged.
938        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        // Concurrent wave [Renderer, Physics]: the wave costs max(14, 14) = 14ms
958        // on the critical path, which fits the budget — so both reach
959        // HighPerformance. This is the win parallel execution unlocks.
960        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        // The same 16.66ms budget yields HighPerformance under `Learning` (test
1010        // above). `Manual` pins the developer's choice instead: LowPower.
1011        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        // MockAgent reports `Balanced` as its current strategy until a budget is
1022        // applied. With a 16.66ms budget the fit would upgrade to HighPerformance,
1023        // but `Stable` forbids opportunistic upgrades — it stays at Balanced.
1024        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        // Fit would pick HighPerformance; bounds cap it at Balanced.
1058        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        // Two agents: Renderer (priority 1.0) and Physics (priority 1.0)
1085        // Total budget: 16.66ms
1086        // Each agent offers: LowPower(2ms), Balanced(8ms), HighPerformance(14ms)
1087        // Both can't be HighPerformance (14+14=28ms > 16.66ms)
1088        // With priority-based allocation, they should get strategies that fit.
1089        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        // Both should have received budgets
1106        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        // Total cost should not exceed 16.66ms
1113        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        // The PID owns the multiplier in the live loop; here we pin it directly
1139        // to exercise the lever `arbitrate` consumes.
1140        ctx.global_budget_multiplier = 0.6;
1141
1142        let mut report = normal_report();
1143        report.suggested_latency_ms = 33.33; // Heuristic suggestion for throttling
1144
1145        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        // Effective budget: 33.33 * 0.6 = ~20ms. Agent can easily get HighPerformance (14ms).
1164        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        // Both agents should be forced to LowPower
1191        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        // Two stalled agents should trigger emergency stop
1209        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        // Both should be forced to LowPower
1226        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        // Should not panic
1245        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        // Tight budget: only 10ms total. Renderer (priority 1.0) should be
1262        // upgraded before Asset (priority 0.5).
1263        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        // With 10ms total: both minimum = 2+2=4ms, remaining=6ms.
1281        // Renderer (priority 1.0) should be upgraded first: +6ms → Balanced (8ms).
1282        // Asset (priority 0.5) stays at LowPower (2ms). Total: 8+2=10ms ≤ 10ms.
1283        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        // Ample budget would fit HighPerformance (14ms), but a 5ms Cap leaves
1301        // only LowPower (2ms) within the ceiling — Balanced (8ms) exceeds it.
1302        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        // A 10ms Cap admits Balanced (8ms) but not HighPerformance (14ms).
1324        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        // Default priorities upgrade Renderer (1.0) before Asset (0.5). A
1346        // Prioritize hint lifting Asset above Renderer flips the fit: Asset
1347        // takes the single available upgrade to Balanced, Renderer stays LowPower.
1348        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        // A Cap on a DIFFERENT agent must not touch Renderer: with ample budget
1389        // and no Renderer hint, it still reaches HighPerformance (regression-0).
1390        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        // Single agent, ample budget → HighPerformance, reported back as issued.
1428        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        // A recorded decision of LowPower must be issued verbatim, even though
1443        // the live fit (ample budget) would pick HighPerformance.
1444        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        // Ample time budget (16.66ms) would let a lone agent reach
1470        // HighPerformance (14ms). But HighPerformance costs 20MB of VRAM,
1471        // Balanced 10MB. Cap available VRAM at 15MB: the fit must stop at
1472        // Balanced — the time budget is not the binding constraint here.
1473        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}