Skip to main content

khora_control/
service.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//! Central service for the Dynamic Context Core.
16
17use crate::budget_channel::BudgetChannel;
18use crate::context::{safety_ceiling, Context};
19use crate::cost_model::CostModel;
20use crate::metrics::MetricStore;
21use crate::EngineMode;
22use crossbeam_channel::{Receiver, Sender};
23use khora_core::agent::Agent;
24use khora_core::control::gorna::ResourceBudget;
25use khora_core::control::pid::{PidConfig, PidController};
26use khora_core::telemetry::{MetricId, TelemetryEvent};
27use khora_data::ecs::layout::{LayoutAdvisor, LayoutRecommendation};
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::sync::Arc;
30use std::thread;
31use std::time::{Duration, Instant};
32
33use crate::analysis::HeuristicEngine;
34use crate::gorna::GornaArbitrator;
35use crate::registry::AgentRegistry;
36use khora_core::control::gorna::{
37    AdaptationMode, AgentHints, AgentId, DecisionTrace, EngineHint, TickDecisions,
38};
39use std::collections::HashMap;
40use std::sync::Mutex;
41
42/// Number of recent `(n, time)` samples each per-agent cost model retains.
43const COST_MODEL_CAPACITY: usize = 64;
44
45/// Minimum frame-time samples before the PID acts on the measurement. Below this
46/// the controller holds its current output rather than reacting to thin data.
47const FRAME_TIME_MIN_SAMPLES: usize = 10;
48
49/// How far the PID's budget multiplier must drift from its value at the last
50/// budget issuance before the DCC re-arbitrates on its own. The pressure
51/// heuristics already force negotiation on the way *down*; this is what lets
52/// agents recover (upgrade) once measured frame time settles back under the
53/// setpoint, instead of staying pinned at a degraded strategy.
54const PID_RENEGOTIATE_DELTA: f32 = 0.05;
55
56/// Forecasts the frame's empirical cost (`c·f(n)`) at workload `n`, grouping
57/// agents into concurrent waves: a wave costs its **critical path** (the `max`
58/// of its members), and the frame is the sum over waves.
59///
60/// With an empty `wave_plan` — serial execution, or before the hot path has
61/// published one — every agent is its own singleton wave, so this reduces
62/// exactly to the plain per-agent sum. A model for an agent absent from the
63/// plan is likewise summed as its own singleton (conservative).
64///
65/// Returns `None` until at least one agent has enough distinct-`n` samples to
66/// fit a model — before that the DCC has nothing to anticipate with.
67fn forecast_total_ms(
68    models: &HashMap<AgentId, CostModel>,
69    n: f64,
70    wave_plan: &[Vec<AgentId>],
71) -> Option<f64> {
72    let mut planned: std::collections::HashSet<AgentId> = std::collections::HashSet::new();
73    let mut total = 0.0;
74    let mut any = false;
75
76    for wave in wave_plan {
77        let mut wave_max = 0.0_f64;
78        let mut wave_has = false;
79        for id in wave {
80            planned.insert(*id);
81            if let Some(p) = models.get(id).and_then(|m| m.predict_ms(n)) {
82                wave_max = wave_max.max(p.max(0.0));
83                wave_has = true;
84            }
85        }
86        if wave_has {
87            total += wave_max;
88            any = true;
89        }
90    }
91
92    // Any model not covered by the plan is its own singleton wave.
93    for (id, m) in models {
94        if planned.contains(id) {
95            continue;
96        }
97        if let Some(p) = m.predict_ms(n) {
98            total += p.max(0.0);
99            any = true;
100        }
101    }
102
103    any.then_some(total)
104}
105
106/// Configuration for the DCC Service.
107#[derive(Debug, Clone)]
108pub struct DccConfig {
109    /// Frequency of the analysis loop in Hz.
110    pub tick_rate: u32,
111    /// Maximum number of telemetry events to buffer.
112    /// If the buffer is full, new events are dropped.
113    pub telemetry_buffer_size: usize,
114    /// Timeout for acquiring locks on agents during negotiation.
115    /// If an agent lock cannot be acquired within this time, the agent is skipped.
116    pub agent_lock_timeout_ms: u64,
117    /// Optional system-RAM budget in bytes. When set, the DCC derives
118    /// [`Context::memory_pressure`] from the tracking allocator's live usage and
119    /// degrades frame budgets as the ceiling approaches. `None` disables the
120    /// signal — chiefly useful on memory-constrained targets.
121    pub memory_budget_bytes: Option<u64>,
122    /// Tuning for the frame-time PID that drives [`Context::global_budget_multiplier`].
123    /// The defaults are conservative gains for the ~20 Hz cold path; expose this
124    /// to retune per target without touching the loop.
125    pub frame_pid: PidConfig,
126}
127
128impl Default for DccConfig {
129    fn default() -> Self {
130        Self {
131            tick_rate: 20,
132            telemetry_buffer_size: 1000,
133            agent_lock_timeout_ms: 100,
134            memory_budget_bytes: None,
135            frame_pid: PidConfig::default(),
136        }
137    }
138}
139
140/// The Dynamic Context Core service.
141///
142/// Manages the cold-path analysis loop, GORNA arbitration, and agent coordination.
143pub struct DccService {
144    config: DccConfig,
145    context: Arc<std::sync::RwLock<Context>>,
146    registry: Arc<std::sync::Mutex<AgentRegistry>>,
147    budget_channel: Option<BudgetChannel>,
148    running: Arc<AtomicBool>,
149    handle: Option<thread::JoinHandle<()>>,
150    event_tx: Sender<TelemetryEvent>,
151    /// Per-agent developer-control modes, shared with the cold-path arbitrator.
152    /// Written from any thread (host/editor), read each tick by the DCC loop.
153    adaptation_modes: Arc<std::sync::RwLock<HashMap<AgentId, AdaptationMode>>>,
154    /// Per-agent developer hints (`Cap`, `Prioritize`) that bias arbitration
155    /// without changing game semantics. Written from any thread (host/editor)
156    /// via [`set_hint`](Self::set_hint), read each tick by the DCC loop and fed
157    /// into the arbitrator. The same developer-control axis as `adaptation_modes`.
158    hints: Arc<std::sync::RwLock<HashMap<AgentId, AgentHints>>>,
159    /// Latest read-only layout recommendations per component, derived by the
160    /// DCC from access telemetry via the Data-layer advisor. Glass-box only:
161    /// the DCC *advises*, it never repacks — Data owns its layout (CLAD).
162    layout_recommendations: Arc<std::sync::RwLock<HashMap<String, LayoutRecommendation>>>,
163    /// Whether the DCC is recording GORNA decisions for deterministic replay.
164    decision_recording: Arc<std::sync::RwLock<bool>>,
165    /// The accumulating recorded decision trace (one entry per arbitration tick).
166    recorded_trace: Arc<std::sync::RwLock<DecisionTrace>>,
167    /// When `Some((trace, cursor))`, arbitration replays the trace tick by tick
168    /// instead of negotiating live.
169    replay: Arc<std::sync::RwLock<Option<(DecisionTrace, usize)>>>,
170}
171
172impl DccService {
173    /// Creates a new DCC service.
174    pub fn new(config: DccConfig) -> (Self, Receiver<TelemetryEvent>) {
175        let (tx, rx) = crossbeam_channel::bounded(config.telemetry_buffer_size);
176        let service = Self {
177            config,
178            context: Arc::new(std::sync::RwLock::new(Context::default())),
179            registry: Arc::new(std::sync::Mutex::new(AgentRegistry::new())),
180            budget_channel: None,
181            running: Arc::new(AtomicBool::new(false)),
182            handle: None,
183            event_tx: tx,
184            adaptation_modes: Arc::new(std::sync::RwLock::new(HashMap::new())),
185            hints: Arc::new(std::sync::RwLock::new(HashMap::new())),
186            layout_recommendations: Arc::new(std::sync::RwLock::new(HashMap::new())),
187            decision_recording: Arc::new(std::sync::RwLock::new(false)),
188            recorded_trace: Arc::new(std::sync::RwLock::new(DecisionTrace::default())),
189            replay: Arc::new(std::sync::RwLock::new(None)),
190        };
191        (service, rx)
192    }
193
194    /// Sets the [`AdaptationMode`] for an agent — the developer-control surface
195    /// over the adaptive core. Thread-safe; takes effect on the next arbitration
196    /// tick. `Manual(strategy)` pins an agent, `Stable` blocks opportunistic
197    /// upgrades, `Bounded` clamps the range, `Learning` (default) negotiates freely.
198    pub fn set_adaptation_mode(&self, agent_id: AgentId, mode: AdaptationMode) {
199        if let Ok(mut modes) = self.adaptation_modes.write() {
200            modes.insert(agent_id, mode);
201        }
202    }
203
204    /// Returns the [`AdaptationMode`] configured for an agent (default `Learning`).
205    pub fn adaptation_mode(&self, agent_id: AgentId) -> AdaptationMode {
206        self.adaptation_modes
207            .read()
208            .ok()
209            .and_then(|m| m.get(&agent_id).copied())
210            .unwrap_or_default()
211    }
212
213    /// Applies a developer [`EngineHint`] biasing GORNA arbitration — the same
214    /// control axis as [`set_adaptation_mode`](Self::set_adaptation_mode).
215    /// `Cap` bounds an agent's per-frame time budget; `Prioritize` biases its
216    /// negotiation weight. Thread-safe; takes effect on the next arbitration
217    /// tick. Hints persist and accumulate per agent (latest value wins per
218    /// kind) until cleared with [`clear_agent_hints`](Self::clear_agent_hints).
219    /// Advisory only: a `Manual` pin and the death-spiral safety stop still win.
220    pub fn set_hint(&self, hint: EngineHint) {
221        if let Ok(mut hints) = self.hints.write() {
222            hints.entry(hint.agent()).or_default().apply(hint);
223        }
224    }
225
226    /// Clears all developer hints for an agent, restoring engine defaults.
227    /// Thread-safe; takes effect on the next arbitration tick.
228    pub fn clear_agent_hints(&self, agent_id: AgentId) {
229        if let Ok(mut hints) = self.hints.write() {
230            hints.remove(&agent_id);
231        }
232    }
233
234    /// Read-only snapshot of the accumulated per-agent hints (glass-box; safe
235    /// any time), e.g. for the editor's Control-Plane panel.
236    pub fn hints(&self) -> HashMap<AgentId, AgentHints> {
237        self.hints.read().map(|h| h.clone()).unwrap_or_default()
238    }
239
240    /// Read-only snapshot of the per-component layout recommendations the DCC's
241    /// advisor has derived from access telemetry — for the glass-box surface
242    /// (e.g. the editor's Control-Plane panel). Advisory only: the DCC observes
243    /// the Data layer and recommends, it never repacks.
244    pub fn layout_recommendations(&self) -> HashMap<String, LayoutRecommendation> {
245        self.layout_recommendations
246            .read()
247            .map(|m| m.clone())
248            .unwrap_or_default()
249    }
250
251    /// Starts recording GORNA's per-tick decisions into a fresh trace. The
252    /// recording can be replayed later for bit-for-bit reproduction (QA,
253    /// network lockstep, bug repro). Thread-safe; takes effect next tick.
254    pub fn start_decision_recording(&self) {
255        if let Ok(mut rec) = self.decision_recording.write() {
256            *rec = true;
257        }
258        if let Ok(mut trace) = self.recorded_trace.write() {
259            *trace = DecisionTrace::default();
260        }
261    }
262
263    /// Stops recording and returns the captured [`DecisionTrace`].
264    pub fn stop_decision_recording(&self) -> DecisionTrace {
265        if let Ok(mut rec) = self.decision_recording.write() {
266            *rec = false;
267        }
268        self.recorded_decisions()
269    }
270
271    /// A snapshot of the decisions recorded so far (glass-box; safe any time).
272    pub fn recorded_decisions(&self) -> DecisionTrace {
273        self.recorded_trace
274            .read()
275            .map(|t| t.clone())
276            .unwrap_or_default()
277    }
278
279    /// Begins replaying `trace`: each subsequent arbitration tick issues the
280    /// recorded strategies in order (bypassing live fit + `AdaptationMode`)
281    /// until the trace is exhausted, then normal arbitration resumes.
282    pub fn replay_decisions(&self, trace: DecisionTrace) {
283        if let Ok(mut replay) = self.replay.write() {
284            *replay = Some((trace, 0));
285        }
286    }
287
288    /// Stops any in-progress replay, returning to live arbitration.
289    pub fn stop_replay(&self) {
290        if let Ok(mut replay) = self.replay.write() {
291            *replay = None;
292        }
293    }
294
295    /// Whether a replay is currently in progress (trace not yet exhausted).
296    pub fn is_replaying(&self) -> bool {
297        self.replay
298            .read()
299            .ok()
300            .and_then(|r| r.as_ref().map(|(t, c)| *c < t.ticks.len()))
301            .unwrap_or(false)
302    }
303
304    /// Connects the DCC to the Scheduler's budget channel.
305    /// After GORNA arbitration, budgets are sent through this channel.
306    pub fn connect_budget_channel(&mut self, channel: BudgetChannel) {
307        self.budget_channel = Some(channel);
308    }
309
310    /// Registers an agent with a priority value.
311    ///
312    /// Higher priority values mean the agent is updated first in each frame.
313    /// The agent is active in all engine modes.
314    pub fn register_agent(&self, agent: Arc<std::sync::Mutex<dyn Agent>>, priority: f32) {
315        let mut registry = self.registry.lock().unwrap_or_else(|e| e.into_inner());
316        registry.register(agent, priority);
317    }
318
319    /// Registers an agent with a priority value, active only in the specified modes.
320    ///
321    /// Higher priority values mean the agent is updated first in each frame.
322    /// If `modes` is empty, the agent is active in all modes.
323    pub fn register_agent_for_mode(
324        &self,
325        agent: Arc<std::sync::Mutex<dyn Agent>>,
326        priority: f32,
327        modes: Vec<EngineMode>,
328    ) {
329        let mut registry = self.registry.lock().unwrap_or_else(|e| e.into_inner());
330        registry.register_for_mode(agent, priority, modes);
331    }
332
333    /// Starts the DCC background thread.
334    pub fn start(&mut self, event_rx: Receiver<TelemetryEvent>) {
335        if self.running.load(Ordering::SeqCst) {
336            return;
337        }
338
339        self.running.store(true, Ordering::SeqCst);
340        let running = Arc::clone(&self.running);
341        let context = Arc::clone(&self.context);
342        let registry = Arc::clone(&self.registry);
343        let budget_channel = self.budget_channel.clone();
344        let adaptation_modes = Arc::clone(&self.adaptation_modes);
345        let hints = Arc::clone(&self.hints);
346        let layout_recommendations = Arc::clone(&self.layout_recommendations);
347        let decision_recording = Arc::clone(&self.decision_recording);
348        let recorded_trace = Arc::clone(&self.recorded_trace);
349        let replay = Arc::clone(&self.replay);
350        let tick_duration = Duration::from_secs_f32(1.0 / self.config.tick_rate as f32);
351        let agent_lock_timeout = Duration::from_millis(self.config.agent_lock_timeout_ms);
352        let memory_budget_bytes = self.config.memory_budget_bytes;
353        let frame_pid_cfg = self.config.frame_pid;
354
355        let handle = thread::spawn(move || {
356            let mut store = MetricStore::new();
357            let heuristic_engine = HeuristicEngine;
358            let mut arbitrator = GornaArbitrator::new(agent_lock_timeout);
359            let mut initial_negotiation_done = false;
360            // Per-agent empirical cost models (`c·f(n)`), fed from `AgentCost`
361            // samples and used to forecast budget breaches before they happen.
362            let mut cost_models: HashMap<AgentId, CostModel> = HashMap::new();
363            let mut last_workload_n: f64 = 0.0;
364            // Latest wave plan from the hot path: how agents are grouped for
365            // concurrent execution. Empty until the scheduler publishes one (and
366            // stays empty under serial execution), in which case cost fitting
367            // falls back to summing per-agent costs.
368            let mut latest_wave_plan: Vec<Vec<AgentId>> = Vec::new();
369            // Read-only layout advisor: turns per-component access telemetry into
370            // a recommendation for the glass-box. Stateless (a tuned heuristic).
371            let layout_advisor = LayoutAdvisor::default();
372            // Frame-time PID: regulates the global budget multiplier so measured
373            // frame time tracks the heuristic-suggested latency. Persists across
374            // ticks; `last_tick` gives the real `dt` between updates.
375            let mut frame_pid = PidController::new(frame_pid_cfg);
376            let mut last_tick: Option<Instant> = None;
377            // Multiplier in effect when budgets were last issued — drift beyond
378            // PID_RENEGOTIATE_DELTA re-arbitrates (closes the recovery path).
379            let mut last_issued_multiplier: Option<f32> = None;
380
381            log::info!("DCC Service thread started.");
382
383            while running.load(Ordering::Relaxed) {
384                let start_time = Instant::now();
385
386                // 1. Ingest all pending events
387                while let Ok(event) = event_rx.try_recv() {
388                    match event {
389                        TelemetryEvent::MetricUpdate { id, value } => {
390                            if let Some(v) = value.as_f64() {
391                                store.push(id, v as f32);
392                            }
393                        }
394                        TelemetryEvent::ResourceReport(_) => {}
395                        TelemetryEvent::HardwareReport(report) => {
396                            let mut ctx = context.write().unwrap_or_else(|e| e.into_inner());
397                            ctx.hardware.thermal = report.thermal;
398                            ctx.hardware.battery = report.battery;
399                            ctx.hardware.cpu_load = report.cpu_load;
400                            ctx.hardware.gpu_load = report.gpu_load.unwrap_or(0.0);
401                            ctx.hardware.available_vram = report.gpu_timings.as_ref().map(|_| 0);
402
403                            if let Some(gpu_timings) = report.gpu_timings {
404                                if let Some(frame_time_us) = gpu_timings.frame_total_duration_us() {
405                                    store.push(
406                                        khora_core::telemetry::MetricId::new(
407                                            "renderer",
408                                            "frame_time",
409                                        ),
410                                        frame_time_us as f32 / 1000.0,
411                                    );
412                                }
413                            }
414
415                            log::debug!(
416                                "DCC Hardware: Thermal={:?}, CPU={:.2}, GPU={:?}",
417                                ctx.hardware.thermal,
418                                ctx.hardware.cpu_load,
419                                ctx.hardware.gpu_load
420                            );
421                        }
422                        TelemetryEvent::PhaseChange(phase_name) => {
423                            let mut ctx = context.write().unwrap_or_else(|e| e.into_inner());
424                            if let Some(new_mode) = EngineMode::from_name(&phase_name) {
425                                log::debug!("DCC Mode: {:?} → {:?}", ctx.mode, new_mode);
426                                ctx.mode = new_mode;
427                            } else {
428                                log::warn!("DCC: Unknown mode '{}'", phase_name);
429                            }
430                        }
431                        TelemetryEvent::GpuReport(report) => {
432                            if let Some(frame_time_us) = report.frame_total_duration_us() {
433                                store.push(
434                                    khora_core::telemetry::MetricId::new(
435                                        "renderer",
436                                        "gpu_frame_time",
437                                    ),
438                                    frame_time_us as f32 / 1000.0,
439                                );
440                            }
441                            store.push(
442                                khora_core::telemetry::MetricId::new("renderer", "draw_calls"),
443                                report.draw_calls as f32,
444                            );
445                            store.push(
446                                khora_core::telemetry::MetricId::new(
447                                    "renderer",
448                                    "triangles_rendered",
449                                ),
450                                report.triangles_rendered as f32,
451                            );
452                        }
453                        TelemetryEvent::AgentCost { id, n, time_ms } => {
454                            // Feed the per-agent empirical cost model and surface
455                            // the latest time as a glass-box metric.
456                            last_workload_n = n;
457                            cost_models
458                                .entry(id)
459                                .or_insert_with(|| CostModel::new(COST_MODEL_CAPACITY))
460                                .record(n, time_ms);
461                            store.push(
462                                khora_core::telemetry::MetricId::new(
463                                    "agent",
464                                    format!("{id:?}_time_ms"),
465                                ),
466                                time_ms as f32,
467                            );
468                        }
469                        TelemetryEvent::WavePlan { waves } => {
470                            // The hot path republishes this each frame; keep the
471                            // latest so arbitration costs concurrent waves by
472                            // their critical path.
473                            latest_wave_plan = waves;
474                        }
475                        TelemetryEvent::ComponentAccess {
476                            type_name,
477                            size_bytes,
478                            query_count,
479                            rows_scanned,
480                        } => {
481                            // Advise (read-only) which layout this component would
482                            // benefit from, and surface rows-scanned for the
483                            // glass-box. The DCC never repacks — Data owns layout.
484                            let recommendation =
485                                layout_advisor.recommend(size_bytes, query_count, rows_scanned);
486                            if let Ok(mut recs) = layout_recommendations.write() {
487                                recs.insert(type_name.clone(), recommendation);
488                            }
489                            store.push(
490                                khora_core::telemetry::MetricId::new("ecs_access", type_name),
491                                rows_scanned as f32,
492                            );
493                        }
494                    }
495                }
496
497                // 2. Perform Analysis & Arbitration
498                let (mut report, mut ctx_copy) = {
499                    let mut ctx = context.write().unwrap_or_else(|e| e.into_inner());
500                    // Fold the latest tracking-allocator telemetry into the
501                    // context so memory pressure influences the budget alongside
502                    // thermal/battery (the allocator's data drives a decision).
503                    let mem_bytes = store.get_average(&MetricId::new("memory", "current_bytes"));
504                    ctx.hardware.current_ram_bytes = (mem_bytes > 0.0).then_some(mem_bytes as u64);
505                    ctx.hardware.memory_budget_bytes = memory_budget_bytes;
506                    ctx.refresh_memory_pressure();
507                    let report = heuristic_engine.analyze(&ctx, &store);
508                    (report, ctx.clone())
509                };
510
511                // 2b. Anticipatory budgeting: the empirical per-agent cost models
512                //     forecast the combined frame cost at the current workload. If
513                //     that exceeds the budget, negotiate now and tighten the target
514                //     so GORNA downgrades *before* the frame actually overruns —
515                //     turning the reactive loop predictive (model proposes,
516                //     measurement disposes).
517                if let Some(predicted_ms) =
518                    forecast_total_ms(&cost_models, last_workload_n, &latest_wave_plan)
519                {
520                    if predicted_ms > report.suggested_latency_ms as f64 {
521                        let ratio = (report.suggested_latency_ms as f64 / predicted_ms)
522                            .clamp(0.5, 1.0) as f32;
523                        report.alerts.push(format!(
524                            "Cost-model forecast {:.1}ms > budget {:.1}ms at n={:.0} — tightening to {:.1}ms",
525                            predicted_ms,
526                            report.suggested_latency_ms,
527                            last_workload_n,
528                            report.suggested_latency_ms * ratio
529                        ));
530                        report.suggested_latency_ms *= ratio;
531                        report.needs_negotiation = true;
532                    }
533                }
534
535                // 2b-bis. Frame-time PID: regulate the global budget multiplier so
536                //     the measured frame time tracks the (now-finalized) setpoint
537                //     `report.suggested_latency_ms`. The setpoint already carries
538                //     the thermal/battery/phase modulation; the loop converges the
539                //     multiplier smoothly instead of stepping it. A hard safety
540                //     ceiling (Critical thermal/battery, near-budget memory) caps
541                //     it immediately, independent of loop convergence.
542                let dt = last_tick
543                    .map(|t| start_time.duration_since(t).as_secs_f32())
544                    .unwrap_or_else(|| tick_duration.as_secs_f32());
545                last_tick = Some(start_time);
546
547                let frame_time_id = MetricId::new("renderer", "frame_time");
548                let measured = store.get_average(&frame_time_id);
549                let mut multiplier = if store.get_sample_count(&frame_time_id)
550                    >= FRAME_TIME_MIN_SAMPLES
551                    && measured > 0.0
552                {
553                    frame_pid.update(report.suggested_latency_ms, measured, dt)
554                } else {
555                    frame_pid.output()
556                };
557                multiplier = multiplier.min(safety_ceiling(&ctx_copy));
558                ctx_copy.global_budget_multiplier = multiplier;
559                if let Ok(mut ctx) = context.write() {
560                    ctx.global_budget_multiplier = multiplier;
561                }
562                log::debug!(
563                    "DCC PID: multiplier={:.3} (setpoint={:.2}ms, measured={:.2}ms, dt={:.3}s)",
564                    multiplier,
565                    report.suggested_latency_ms,
566                    measured,
567                    dt
568                );
569
570                // 2b-ter. Re-arbitrate when the PID has moved the effective budget
571                //     significantly since the last issuance — in both directions.
572                //     Pressure heuristics drive downgrades; this drives recovery:
573                //     once measured frame time settles under the setpoint, the
574                //     multiplier climbs back and budgets are re-issued so agents
575                //     can upgrade instead of staying degraded forever.
576                if let Some(last) = last_issued_multiplier {
577                    if (multiplier - last).abs() > PID_RENEGOTIATE_DELTA {
578                        report.needs_negotiation = true;
579                        report.alerts.push(format!(
580                            "PID: budget multiplier {last:.2} → {multiplier:.2} since last issuance — re-arbitrating",
581                        ));
582                    }
583                }
584
585                for alert in &report.alerts {
586                    log::info!("DCC Analysis: {}", alert);
587                }
588
589                // 2c. Replay: while a trace is loaded, drive arbitration every
590                //     tick from the recorded decisions (in order) until it is
591                //     exhausted, then fall back to live negotiation.
592                let replay_tick: Option<TickDecisions> = replay
593                    .read()
594                    .ok()
595                    .and_then(|r| r.as_ref().and_then(|(t, c)| t.ticks.get(*c).cloned()));
596                let replaying = replay_tick.is_some();
597
598                // 3. GORNA Negotiation
599                if report.needs_negotiation || !initial_negotiation_done || replaying {
600                    let registry_lock = registry.lock().unwrap_or_else(|e| e.into_inner());
601                    if !registry_lock.is_empty() {
602                        let agents: Vec<_> = registry_lock.iter().cloned().collect();
603                        drop(registry_lock);
604
605                        let mut agents_slice: Vec<Arc<std::sync::Mutex<dyn Agent>>> = agents;
606                        // Sync developer-control modes into the arbitrator before
607                        // it issues budgets (host may have changed them).
608                        if let Ok(modes) = adaptation_modes.read() {
609                            for (id, mode) in modes.iter() {
610                                arbitrator.set_adaptation_mode(*id, *mode);
611                            }
612                        }
613                        // Snapshot the developer hints for this tick (Cap /
614                        // Prioritize biases). Empty map = no hints = default
615                        // behaviour, so this is bit-identical when unused.
616                        let tick_hints: HashMap<AgentId, AgentHints> =
617                            hints.read().map(|h| h.clone()).unwrap_or_default();
618                        // Per-agent measured costs anchor the agents' self-quoted
619                        // estimates in reality during fitting: prefer the model's
620                        // forecast at the current workload, fall back to the
621                        // latest raw observation when no fit is available yet.
622                        let measured_costs: HashMap<AgentId, f64> = cost_models
623                            .iter()
624                            .filter_map(|(id, m)| {
625                                m.predict_ms(last_workload_n)
626                                    .or_else(|| m.latest_ms())
627                                    .map(|ms| (*id, ms))
628                            })
629                            .collect();
630
631                        // Give the arbitrator the current wave grouping so its
632                        // budget fit costs concurrent waves by their critical
633                        // path (empty plan = serial = sum-of-costs).
634                        arbitrator.set_wave_plan(&latest_wave_plan);
635                        let issued = arbitrator.arbitrate(
636                            &ctx_copy,
637                            &report,
638                            &mut agents_slice,
639                            &measured_costs,
640                            replay_tick.as_ref(),
641                            &tick_hints,
642                        );
643                        initial_negotiation_done = true;
644                        last_issued_multiplier = Some(multiplier);
645
646                        // Advance the replay cursor, or record this tick's decisions.
647                        if replaying {
648                            if let Ok(mut r) = replay.write() {
649                                if let Some((_, cursor)) = r.as_mut() {
650                                    *cursor += 1;
651                                }
652                            }
653                        } else if decision_recording.read().map(|b| *b).unwrap_or(false) {
654                            if let Ok(mut trace) = recorded_trace.write() {
655                                trace.ticks.push(issued);
656                            }
657                        }
658
659                        // Send budgets through the budget channel to the Scheduler.
660                        if let Some(ref budget_channel) = budget_channel {
661                            for agent_arc in &agents_slice {
662                                if let Ok(agent) = agent_arc.lock() {
663                                    // The agent's current budget was set by apply_budget() during arbitration.
664                                    // We need to reconstruct the budget from the agent's status.
665                                    let status = agent.report_status();
666                                    let budget = ResourceBudget {
667                                        strategy_id: status.current_strategy,
668                                        time_limit: Duration::from_secs_f32(
669                                            report.suggested_latency_ms / 1000.0,
670                                        ),
671                                        memory_limit: None,
672                                        extra_params: std::collections::HashMap::new(),
673                                    };
674                                    budget_channel.send(agent.id(), budget);
675                                }
676                            }
677                        }
678                    }
679                }
680
681                // 4. Sleep until next tick
682                let elapsed = start_time.elapsed();
683                if elapsed < tick_duration {
684                    thread::sleep(tick_duration - elapsed);
685                }
686            }
687            log::info!("DCC Service thread stopped.");
688        });
689
690        self.handle = Some(handle);
691    }
692
693    /// Stops the DCC background thread.
694    pub fn stop(&mut self) {
695        self.running.store(false, Ordering::SeqCst);
696        if let Some(handle) = self.handle.take() {
697            let _ = handle.join();
698        }
699    }
700
701    /// Returns the agent registry for use by the Scheduler.
702    pub fn agent_registry(&self) -> &Arc<Mutex<AgentRegistry>> {
703        &self.registry
704    }
705
706    /// Returns a sender handle to submit events to the DCC.
707    pub fn event_sender(&self) -> Sender<TelemetryEvent> {
708        self.event_tx.clone()
709    }
710
711    /// Returns the current context.
712    pub fn get_context(&self) -> Context {
713        self.context
714            .read()
715            .unwrap_or_else(|e| e.into_inner())
716            .clone()
717    }
718
719    /// Returns a shared handle to the live context.
720    ///
721    /// Used by observers (e.g. the editor's Control Plane workspace) that
722    /// want to read up-to-date hardware state, mode, and budget multiplier
723    /// each frame without going through `get_context()`'s clone.
724    pub fn context_handle(&self) -> Arc<std::sync::RwLock<Context>> {
725        Arc::clone(&self.context)
726    }
727
728    /// Initializes all registered agents once after registration.
729    ///
730    /// Should be called once after all agents are registered, giving them
731    /// access to engine services for caching and lane setup.
732    pub fn initialize_agents(&self, context: &mut khora_core::EngineContext<'_>) {
733        if let Ok(registry) = self.registry.lock() {
734            registry.initialize_all(context);
735        }
736    }
737
738    /// Executes all registered agents in priority order.
739    ///
740    /// Called each frame. Each agent selects the appropriate lanes and
741    /// dispatches their execution.
742    pub fn execute_agents(&self, context: &mut khora_core::EngineContext<'_>) {
743        if let Ok(registry) = self.registry.lock() {
744            registry.execute_all(context);
745        }
746    }
747
748    /// Returns the number of registered agents.
749    pub fn agent_count(&self) -> usize {
750        self.registry.lock().map(|r| r.len()).unwrap_or(0)
751    }
752
753    /// Returns a reference to the agent with the given ID, if registered.
754    pub fn get_agent(&self, id: AgentId) -> Option<Arc<Mutex<dyn Agent>>> {
755        self.registry.lock().ok()?.get_by_id(id)
756    }
757}
758
759impl Drop for DccService {
760    fn drop(&mut self) {
761        self.stop();
762    }
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use crate::EngineMode;
769    use khora_core::control::gorna::{
770        AdaptationMode, AgentId, AgentStatus, NegotiationRequest, NegotiationResponse,
771        ResourceBudget, StrategyId, StrategyOption,
772    };
773    use khora_core::telemetry::{MetricId, MetricValue};
774
775    struct StubAgent {
776        applied: Option<StrategyId>,
777    }
778
779    impl Agent for StubAgent {
780        fn id(&self) -> AgentId {
781            AgentId::Renderer
782        }
783        fn negotiate(&mut self, _: NegotiationRequest) -> NegotiationResponse {
784            NegotiationResponse {
785                strategies: vec![
786                    StrategyOption {
787                        id: StrategyId::LowPower,
788                        estimated_time: Duration::from_millis(2),
789                        estimated_vram: 1024,
790                    },
791                    StrategyOption {
792                        id: StrategyId::Balanced,
793                        estimated_time: Duration::from_millis(8),
794                        estimated_vram: 1024,
795                    },
796                ],
797                timing_adjustment: None,
798            }
799        }
800        fn apply_budget(&mut self, budget: ResourceBudget) {
801            self.applied = Some(budget.strategy_id);
802        }
803        fn report_status(&self) -> AgentStatus {
804            AgentStatus {
805                agent_id: AgentId::Renderer,
806                current_strategy: self.applied.unwrap_or(StrategyId::Balanced),
807                health_score: 1.0,
808                is_stalled: false,
809                message: String::new(),
810            }
811        }
812        fn execute(&mut self, _: &mut khora_core::EngineContext<'_>) {}
813        fn as_any(&self) -> &dyn std::any::Any {
814            self
815        }
816        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
817            self
818        }
819    }
820
821    #[test]
822    fn test_dcc_service_lifecycle() {
823        let (mut dcc, rx) = DccService::new(DccConfig::default());
824        dcc.start(rx);
825        assert!(dcc.running.load(Ordering::SeqCst));
826        dcc.stop();
827        assert!(!dcc.running.load(Ordering::SeqCst));
828    }
829
830    #[test]
831    fn test_dcc_phase_change_ingestion() {
832        let (mut dcc, rx) = DccService::new(DccConfig::default());
833        let tx = dcc.event_sender();
834        dcc.start(rx);
835
836        tx.send(TelemetryEvent::PhaseChange("Simulation".to_string()))
837            .unwrap();
838
839        thread::sleep(Duration::from_millis(100));
840
841        let ctx = dcc.get_context();
842        assert_eq!(ctx.mode, EngineMode::Playing);
843
844        dcc.stop();
845    }
846
847    #[test]
848    fn test_dcc_metric_ingestion_smoke() {
849        let (mut dcc, rx) = DccService::new(DccConfig::default());
850        let tx = dcc.event_sender();
851        dcc.start(rx);
852
853        let id = MetricId::new("test", "metric");
854        tx.send(TelemetryEvent::MetricUpdate {
855            id,
856            value: MetricValue::Gauge(42.0),
857        })
858        .unwrap();
859
860        thread::sleep(Duration::from_millis(50));
861        dcc.stop();
862    }
863
864    #[test]
865    fn test_forecast_total_ms_sums_agent_models() {
866        // No models yet → nothing to anticipate.
867        let mut models: HashMap<AgentId, CostModel> = HashMap::new();
868        assert!(forecast_total_ms(&models, 100.0, &[]).is_none());
869
870        // Renderer: linear 3·n; Physics: linear 2·n.
871        let mut renderer = CostModel::new(16);
872        let mut physics = CostModel::new(16);
873        for n in [10.0, 20.0, 40.0] {
874            renderer.record(n, 3.0 * n);
875            physics.record(n, 2.0 * n);
876        }
877        models.insert(AgentId::Renderer, renderer);
878        models.insert(AgentId::Physics, physics);
879
880        // Serial (empty plan): 300 + 200 = 500ms (within fit tolerance).
881        let total = forecast_total_ms(&models, 100.0, &[]).expect("a fit is available");
882        assert!((total - 500.0).abs() < 1.0, "serial forecast = {total}");
883
884        // Concurrent wave [Renderer, Physics]: critical path = max(300, 200).
885        let plan = vec![vec![AgentId::Renderer, AgentId::Physics]];
886        let concurrent = forecast_total_ms(&models, 100.0, &plan).expect("a fit is available");
887        assert!(
888            (concurrent - 300.0).abs() < 1.0,
889            "concurrent forecast = {concurrent}"
890        );
891    }
892
893    #[test]
894    fn test_dcc_ingests_agent_cost_and_component_access() {
895        // Smoke test for the observation-tunnel variants: the DCC must ingest
896        // AgentCost + ComponentAccess without panicking and keep running.
897        let (mut dcc, rx) = DccService::new(DccConfig::default());
898        let tx = dcc.event_sender();
899        dcc.start(rx);
900
901        tx.send(TelemetryEvent::AgentCost {
902            id: AgentId::Renderer,
903            n: 1000.0,
904            time_ms: 4.2,
905        })
906        .unwrap();
907        tx.send(TelemetryEvent::ComponentAccess {
908            type_name: "Transform".to_string(),
909            size_bytes: 40,
910            query_count: 12,
911            rows_scanned: 12_000,
912        })
913        .unwrap();
914
915        thread::sleep(Duration::from_millis(50));
916        assert!(dcc.running.load(Ordering::SeqCst));
917        dcc.stop();
918    }
919
920    #[test]
921    fn test_dcc_layout_advisor_produces_recommendation() {
922        // A lean component swept in large batches → the advisor recommends the
923        // field-SoA/SIMD layout, surfaced read-only via `layout_recommendations`.
924        let (mut dcc, rx) = DccService::new(DccConfig::default());
925        let tx = dcc.event_sender();
926        dcc.start(rx);
927
928        tx.send(TelemetryEvent::ComponentAccess {
929            type_name: "Velocity".to_string(),
930            size_bytes: 40,
931            query_count: 10,
932            rows_scanned: 40_960, // avg 4096 rows/query ≥ large-batch threshold
933        })
934        .unwrap();
935
936        thread::sleep(Duration::from_millis(80));
937        let recs = dcc.layout_recommendations();
938        dcc.stop();
939
940        assert_eq!(
941            recs.get("Velocity"),
942            Some(&LayoutRecommendation::SimdFieldSoa),
943            "advisor should recommend field-SoA for a lean, large-batch component"
944        );
945    }
946
947    #[test]
948    fn test_dcc_records_decisions() {
949        let (mut dcc, rx) = DccService::new(DccConfig {
950            tick_rate: 100,
951            ..Default::default()
952        });
953        let agent = Arc::new(std::sync::Mutex::new(StubAgent { applied: None }));
954        dcc.register_agent(agent, 1.0);
955        dcc.start_decision_recording();
956        dcc.start(rx);
957
958        thread::sleep(Duration::from_millis(150));
959        let trace = dcc.stop_decision_recording();
960        dcc.stop();
961
962        assert!(
963            !trace.ticks.is_empty(),
964            "recording should capture at least one arbitration tick"
965        );
966        assert!(
967            trace
968                .ticks
969                .iter()
970                .all(|tick| tick.iter().any(|(id, _)| *id == AgentId::Renderer)),
971            "every recorded tick should include the registered agent"
972        );
973    }
974
975    #[test]
976    fn test_dcc_replay_flag_control() {
977        let (dcc, _rx) = DccService::new(DccConfig::default());
978        assert!(!dcc.is_replaying());
979
980        let trace = DecisionTrace {
981            ticks: vec![vec![(AgentId::Renderer, StrategyId::LowPower)]],
982        };
983        dcc.replay_decisions(trace);
984        assert!(dcc.is_replaying());
985
986        dcc.stop_replay();
987        assert!(!dcc.is_replaying());
988    }
989
990    #[test]
991    fn test_dcc_initial_negotiation_fires_with_agent() {
992        let (mut dcc, rx) = DccService::new(DccConfig {
993            tick_rate: 100,
994            ..Default::default()
995        });
996        let agent = Arc::new(std::sync::Mutex::new(StubAgent { applied: None }));
997        dcc.register_agent(agent.clone(), 1.0);
998        dcc.start(rx);
999
1000        thread::sleep(Duration::from_millis(200));
1001
1002        let applied = agent.lock().unwrap().applied.is_some();
1003        dcc.stop();
1004
1005        assert!(
1006            applied,
1007            "Initial GORNA negotiation should have called apply_budget"
1008        );
1009    }
1010
1011    #[test]
1012    fn test_dcc_manual_mode_pins_strategy() {
1013        let (mut dcc, rx) = DccService::new(DccConfig {
1014            tick_rate: 100,
1015            ..Default::default()
1016        });
1017        let agent = Arc::new(std::sync::Mutex::new(StubAgent { applied: None }));
1018        dcc.register_agent(agent.clone(), 1.0);
1019        // Developer pins the agent to LowPower. With ample budget, `Learning`
1020        // would otherwise pick Balanced (the most expensive offered strategy).
1021        dcc.set_adaptation_mode(
1022            AgentId::Renderer,
1023            AdaptationMode::Manual(StrategyId::LowPower),
1024        );
1025        dcc.start(rx);
1026
1027        thread::sleep(Duration::from_millis(200));
1028
1029        let applied = agent.lock().unwrap().applied;
1030        dcc.stop();
1031
1032        assert_eq!(
1033            applied,
1034            Some(StrategyId::LowPower),
1035            "Manual mode set via DccService should pin the agent's strategy"
1036        );
1037    }
1038
1039    #[test]
1040    fn test_pid_drives_multiplier_down_under_overrun() {
1041        // A sustained frame time well above the 16.66ms target must make the PID
1042        // loop pull the global budget multiplier below 1.0 over several ticks.
1043        let (mut dcc, rx) = DccService::new(DccConfig {
1044            tick_rate: 100,
1045            ..Default::default()
1046        });
1047        let tx = dcc.event_sender();
1048        dcc.start(rx);
1049
1050        let frame_time_id = MetricId::new("renderer", "frame_time");
1051        // Feed a steady stream of 40ms frames (≫ the 16.66ms setpoint).
1052        for _ in 0..40 {
1053            tx.send(TelemetryEvent::MetricUpdate {
1054                id: frame_time_id.clone(),
1055                value: MetricValue::Gauge(40.0),
1056            })
1057            .unwrap();
1058            thread::sleep(Duration::from_millis(5));
1059        }
1060        thread::sleep(Duration::from_millis(100));
1061
1062        let multiplier = dcc.get_context().global_budget_multiplier;
1063        dcc.stop();
1064
1065        assert!(
1066            multiplier < 1.0,
1067            "sustained overrun should pull the multiplier below 1.0, got {multiplier}"
1068        );
1069        assert!(
1070            multiplier >= 0.3,
1071            "multiplier must respect the output floor, got {multiplier}"
1072        );
1073    }
1074
1075    /// Stub whose two strategies straddle the frame budget: Balanced (14ms)
1076    /// fits the 16.66ms target only when the budget multiplier is near 1.0,
1077    /// so a degraded multiplier forces LowPower and a recovered one allows
1078    /// the upgrade back — making the recovery path observable.
1079    struct RecoveryStubAgent {
1080        applied: Option<StrategyId>,
1081    }
1082
1083    impl Agent for RecoveryStubAgent {
1084        fn id(&self) -> AgentId {
1085            AgentId::Renderer
1086        }
1087        fn negotiate(&mut self, _: NegotiationRequest) -> NegotiationResponse {
1088            NegotiationResponse {
1089                strategies: vec![
1090                    StrategyOption {
1091                        id: StrategyId::LowPower,
1092                        estimated_time: Duration::from_millis(2),
1093                        estimated_vram: 0,
1094                    },
1095                    StrategyOption {
1096                        id: StrategyId::Balanced,
1097                        estimated_time: Duration::from_millis(14),
1098                        estimated_vram: 0,
1099                    },
1100                ],
1101                timing_adjustment: None,
1102            }
1103        }
1104        fn apply_budget(&mut self, budget: ResourceBudget) {
1105            self.applied = Some(budget.strategy_id);
1106        }
1107        fn report_status(&self) -> AgentStatus {
1108            AgentStatus {
1109                agent_id: AgentId::Renderer,
1110                current_strategy: self.applied.unwrap_or(StrategyId::LowPower),
1111                health_score: 1.0,
1112                is_stalled: false,
1113                message: String::new(),
1114            }
1115        }
1116        fn execute(&mut self, _: &mut khora_core::EngineContext<'_>) {}
1117        fn as_any(&self) -> &dyn std::any::Any {
1118            self
1119        }
1120        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1121            self
1122        }
1123    }
1124
1125    #[test]
1126    fn test_pid_recovery_reissues_budgets_and_upgrades() {
1127        // Once measured frame time settles back under the setpoint, the PID
1128        // multiplier climbs and its drift past PID_RENEGOTIATE_DELTA must
1129        // re-arbitrate so the agent is upgraded — without this trigger the
1130        // agent would stay pinned at the degraded strategy forever (no
1131        // pressure heuristic fires when everything is healthy).
1132        let (mut dcc, rx) = DccService::new(DccConfig {
1133            tick_rate: 100,
1134            ..Default::default()
1135        });
1136        let agent = Arc::new(std::sync::Mutex::new(RecoveryStubAgent { applied: None }));
1137        dcc.register_agent(agent.clone(), 1.0);
1138        let tx = dcc.event_sender();
1139        dcc.start(rx);
1140
1141        let frame_time_id = MetricId::new("renderer", "frame_time");
1142
1143        // Phase 1 — sustained overrun (40ms ≫ 16.66ms): the PID pulls the
1144        // multiplier down until Balanced (14ms) no longer fits and the agent
1145        // is downgraded to LowPower. Poll instead of a fixed sleep.
1146        let mut degraded = false;
1147        let deadline = Instant::now() + Duration::from_secs(5);
1148        while Instant::now() < deadline {
1149            tx.send(TelemetryEvent::MetricUpdate {
1150                id: frame_time_id.clone(),
1151                value: khora_core::telemetry::MetricValue::Gauge(40.0),
1152            })
1153            .unwrap();
1154            thread::sleep(Duration::from_millis(5));
1155            if agent.lock().unwrap().applied == Some(StrategyId::LowPower) {
1156                degraded = true;
1157                break;
1158            }
1159        }
1160        assert!(
1161            degraded,
1162            "sustained overrun should downgrade the agent to LowPower"
1163        );
1164
1165        // Phase 2 — recovery (5ms ≪ 16.66ms): the averages drop below every
1166        // pressure threshold, so only the PID-drift trigger can re-issue
1167        // budgets. The multiplier climbs back and the agent must be upgraded.
1168        let mut recovered = false;
1169        let deadline = Instant::now() + Duration::from_secs(10);
1170        while Instant::now() < deadline {
1171            tx.send(TelemetryEvent::MetricUpdate {
1172                id: frame_time_id.clone(),
1173                value: khora_core::telemetry::MetricValue::Gauge(5.0),
1174            })
1175            .unwrap();
1176            thread::sleep(Duration::from_millis(5));
1177            if agent.lock().unwrap().applied == Some(StrategyId::Balanced) {
1178                recovered = true;
1179                break;
1180            }
1181        }
1182        dcc.stop();
1183
1184        assert!(
1185            recovered,
1186            "once frame time settles, the PID drift must re-arbitrate and upgrade the agent"
1187        );
1188    }
1189
1190    #[test]
1191    fn test_critical_thermal_clamps_multiplier() {
1192        // A Critical thermal report must clamp the multiplier to the hard safety
1193        // ceiling (0.4) immediately, regardless of the PID's current position.
1194        let (mut dcc, rx) = DccService::new(DccConfig {
1195            tick_rate: 100,
1196            ..Default::default()
1197        });
1198        let tx = dcc.event_sender();
1199        dcc.start(rx);
1200
1201        tx.send(TelemetryEvent::HardwareReport(
1202            khora_core::telemetry::monitoring::HardwareReport {
1203                thermal: khora_core::platform::ThermalStatus::Critical,
1204                battery: khora_core::platform::BatteryLevel::Mains,
1205                cpu_load: 0.2,
1206                gpu_load: Some(0.2),
1207                gpu_timings: None,
1208            },
1209        ))
1210        .unwrap();
1211        thread::sleep(Duration::from_millis(120));
1212
1213        let multiplier = dcc.get_context().global_budget_multiplier;
1214        dcc.stop();
1215
1216        assert!(
1217            multiplier <= 0.4 + 1e-3,
1218            "Critical thermal must clamp the multiplier to the safety ceiling, got {multiplier}"
1219        );
1220    }
1221}