Skip to main content

khora_control/
scheduler.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//! The ExecutionScheduler — hot-path orchestrator for the SAA frame loop.
16
17use crate::budget_channel::BudgetChannel;
18use crate::context::Context;
19use crate::plugin::EnginePlugin;
20use crate::registry::AgentRegistry;
21use crate::substrate;
22use crate::worker_pool::WorkerPool;
23use crossbeam_channel::Sender;
24use khora_core::agent::completion::{AgentCompletionMap, CompletionOutcome};
25use khora_core::agent::dependency::DependencyKind;
26use khora_core::agent::timing::AgentImportance;
27use khora_core::agent::{AgentAccess, AgentDependency, EngineMode, ExecutionPhase};
28use khora_core::control::gorna::{AgentFrameStatus, AgentFrameStatusMap, AgentId};
29use khora_core::graph::topological_sort;
30use khora_core::lane::{LaneBus, OutputDeck};
31use khora_core::telemetry::TelemetryEvent;
32use khora_core::{EngineContext, Runtime, WorldAccess};
33use khora_data::ecs::World;
34use std::sync::{Arc, Mutex};
35use std::time::{Duration, Instant};
36
37/// How often (in frames) the scheduler samples per-component access stats for
38/// the layout advisor. Per-frame would flood the telemetry channel for data
39/// that changes slowly; ~once a second at 60 FPS is plenty.
40const COMPONENT_ACCESS_SAMPLE_PERIOD: u64 = 60;
41
42/// Upper bound on real frame delta fed into the simulation accumulator, in
43/// seconds. A longer real gap (debugger break, asset hitch, window drag) is
44/// truncated to this value so the accumulator never demands an unbounded
45/// number of catch-up steps — the classic "spiral of death".
46const MAX_FRAME_DELTA_SECONDS: f32 = 0.25;
47
48/// Upper bound on fixed simulation sub-steps run in a single frame. When the
49/// accumulator would demand more, the excess time is dropped (the sim runs
50/// in slow-motion rather than freezing). Bounds worst-case per-frame cost.
51const MAX_SIM_STEPS: u32 = 5;
52
53/// Pure step-count arithmetic for the fixed-timestep accumulator.
54///
55/// Given the carried-over `accumulator`, the (already clamped) real frame
56/// `dt`, the simulation `fixed_delta`, and a `max_steps` ceiling, returns:
57/// - `steps` — whole fixed sub-steps to run this frame (`0..=max_steps`),
58/// - `new_accumulator` — leftover time carried to the next frame,
59/// - `alpha` — render-interpolation factor in `[0, 1)`.
60///
61/// On `max_steps` saturation the excess accumulated time is discarded so the
62/// accumulator stays bounded (slow-motion under sustained overload rather than
63/// a runaway). A non-positive `fixed_delta` is treated as a single step with
64/// no leftover (degenerate guard; callers pass a positive step).
65fn compute_sim_steps(accumulator: f32, dt: f32, fixed_delta: f32, max_steps: u32) -> SimSteps {
66    if fixed_delta <= 0.0 {
67        return SimSteps {
68            steps: 1,
69            new_accumulator: 0.0,
70            alpha: 0.0,
71        };
72    }
73
74    let mut acc = accumulator + dt;
75    let mut steps = (acc / fixed_delta).floor() as i64;
76    if steps < 0 {
77        steps = 0;
78    }
79    let mut steps = steps as u32;
80
81    if steps > max_steps {
82        // Drop the excess: consume exactly `max_steps` worth of time and
83        // discard the remainder so the accumulator cannot grow without bound.
84        acc -= max_steps as f32 * fixed_delta;
85        let dropped = (acc / fixed_delta).floor().max(0.0);
86        acc -= dropped * fixed_delta;
87        steps = max_steps;
88    } else {
89        acc -= steps as f32 * fixed_delta;
90    }
91
92    // Guard against tiny negative residue from float subtraction.
93    if acc < 0.0 {
94        acc = 0.0;
95    }
96    let alpha = (acc / fixed_delta).clamp(0.0, 1.0);
97
98    SimSteps {
99        steps,
100        new_accumulator: acc,
101        alpha,
102    }
103}
104
105/// Result of [`compute_sim_steps`].
106#[derive(Debug, Clone, Copy, PartialEq)]
107struct SimSteps {
108    steps: u32,
109    new_accumulator: f32,
110    alpha: f32,
111}
112
113type AgentSlot = (
114    Arc<Mutex<dyn khora_core::agent::Agent>>,
115    AgentImportance,
116    f32,
117    Vec<AgentDependency>,
118);
119
120/// Which subset of a phase's agents [`ExecutionScheduler::execute_agents_in_phase`]
121/// should run, used to split the fixed-update sub-loop from the once-per-frame
122/// loop without double-running any agent.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124enum AgentSelection {
125    /// Every agent in the phase (legacy path — no fixed agent present).
126    All,
127    /// Only agents declaring a `fixed_timestep` (the sub-stepped sim agents).
128    FixedOnly,
129    /// Every agent except the fixed-timestep ones (they were sub-stepped).
130    ExcludeFixed,
131}
132
133/// Whether the agent in `slot` declares a positive `fixed_timestep`.
134fn slot_is_fixed(slot: &AgentSlot) -> bool {
135    slot.0
136        .lock()
137        .ok()
138        .and_then(|a| a.execution_timing().fixed_timestep)
139        .map(|d| d > Duration::ZERO)
140        .unwrap_or(false)
141}
142
143/// The hot-path execution scheduler.
144pub struct ExecutionScheduler {
145    registry: Arc<Mutex<AgentRegistry>>,
146    budget_channel: BudgetChannel,
147    plugins: Vec<EnginePlugin>,
148    phase_order: Vec<ExecutionPhase>,
149    context: Arc<std::sync::RwLock<Context>>,
150    frame_start: Instant,
151    frame_budget: Duration,
152    /// Output deck retained across the tick boundary so the engine I/O
153    /// layer can drain typed lane outputs (recorded GPU commands, draw
154    /// lists, etc.) after the scheduler has finished.
155    last_deck: OutputDeck,
156    /// Read-only observation tunnel to the DCC: per-agent cost samples and
157    /// per-component access snapshots are pushed here (non-blocking) for the
158    /// cold-path cost model + layout advisor. `None` if telemetry is disabled.
159    telemetry: Option<Sender<TelemetryEvent>>,
160    /// Monotonic frame counter, used to throttle low-rate telemetry sampling.
161    frame_counter: u64,
162    /// Wall-clock instant of the previous `run_frame`, used to derive the
163    /// real per-frame delta. `None` on the very first frame.
164    last_frame_instant: Option<Instant>,
165    /// Carried-over simulation time (seconds) for the fixed-timestep
166    /// accumulator. Each frame the real delta is added and whole
167    /// `fixed_delta` steps are consumed; the remainder stays here.
168    sim_accumulator: f32,
169    /// When `true`, each phase runs through the parallel wave executor
170    /// ([`execute_agents_parallel`](Self::execute_agents_parallel)); when
171    /// `false` (default) agents run sequentially. Off by default: enabling it
172    /// is only sound once agents opt into
173    /// [`AgentAccess::Isolated`](khora_core::agent::AgentAccess::Isolated) and no
174    /// two `Isolated` agents in a phase write the same deck slot.
175    parallel_execution: bool,
176    /// Persistent worker pool for concurrent waves. Spawned once here and
177    /// joined on drop; runs a wave's `Isolated` agents as `'static` jobs while
178    /// the lone `SharedWorld` agent runs inline. See [`WorkerPool`].
179    pool: WorkerPool,
180}
181
182impl ExecutionScheduler {
183    /// Creates a new scheduler.
184    pub fn new(
185        registry: Arc<Mutex<AgentRegistry>>,
186        context: Arc<std::sync::RwLock<Context>>,
187        agent_ids: &[AgentId],
188    ) -> Self {
189        Self {
190            registry,
191            budget_channel: BudgetChannel::new(agent_ids),
192            plugins: Vec::new(),
193            phase_order: ExecutionPhase::DEFAULT_ORDER.to_vec(),
194            context,
195            frame_start: Instant::now(),
196            frame_budget: Duration::from_millis(16),
197            last_deck: OutputDeck::new(),
198            telemetry: None,
199            frame_counter: 0,
200            last_frame_instant: None,
201            sim_accumulator: 0.0,
202            parallel_execution: false,
203            pool: WorkerPool::new(default_pool_size()),
204        }
205    }
206
207    /// Enables or disables the parallel wave executor (default off).
208    ///
209    /// Sound only when the phase's agents correctly declare their
210    /// [`AgentAccess`](khora_core::agent::AgentAccess): eligible agents must
211    /// touch no `World` and write disjoint deck slots. With the default
212    /// (all-`Exclusive`) declarations every wave is a singleton, so this is
213    /// behaviourally identical to sequential execution.
214    pub fn set_parallel_execution(&mut self, enabled: bool) {
215        self.parallel_execution = enabled;
216    }
217
218    /// Publishes the current frame's [`TelemetryEvent::WavePlan`] to the DCC so
219    /// the cold-path cost model can budget a concurrent wave by its critical
220    /// path (`max`) rather than the sum of its members.
221    ///
222    /// No-op when telemetry is disabled or parallel execution is off — serial
223    /// execution runs every agent as its own singleton, so summing per-agent
224    /// costs (the DCC's fallback with no plan) is already exact.
225    fn emit_wave_plan(&self, mode: &EngineMode) {
226        let Some(tx) = &self.telemetry else { return };
227        if !self.parallel_execution {
228            return;
229        }
230        // Mirror the executor's grouping: for each phase, the phase's agents in
231        // `sort_agents` order, partitioned into waves. Every agent lands in
232        // exactly one wave (Exclusive agents are singletons), so cross-phase and
233        // serial work is naturally costed as a sum while concurrent members
234        // share a wave.
235        let mut waves: Vec<Vec<AgentId>> = Vec::new();
236        for &phase in &self.phase_order {
237            let agents = {
238                let registry = self.registry.lock().unwrap_or_else(|e| e.into_inner());
239                registry.collect_for_phase(phase, mode)
240            };
241            if agents.is_empty() {
242                continue;
243            }
244            let metas = build_wave_metas(&sort_agents(agents));
245            for wave in partition_waves(&metas) {
246                let ids: Vec<AgentId> = wave.iter().filter_map(|&i| metas[i].id).collect();
247                if !ids.is_empty() {
248                    waves.push(ids);
249                }
250            }
251        }
252        let _ = tx.try_send(TelemetryEvent::WavePlan { waves });
253    }
254
255    /// Connects the read-only observation tunnel to the DCC. The scheduler then
256    /// publishes per-agent cost samples and per-component access snapshots so
257    /// the cold path can fit cost models and recommend layouts. Non-blocking:
258    /// if the channel is full the sample is dropped (telemetry is best-effort).
259    pub fn set_telemetry_sender(&mut self, sender: Sender<TelemetryEvent>) {
260        self.telemetry = Some(sender);
261    }
262
263    /// Mutable access to the last frame's [`OutputDeck`] — drained by the
264    /// engine at the I/O boundary (e.g. GPU submit / present).
265    pub fn deck_mut(&mut self) -> &mut OutputDeck {
266        &mut self.last_deck
267    }
268
269    /// Returns a reference to the budget channel for the DCC to send budgets.
270    pub fn budget_channel(&self) -> &BudgetChannel {
271        &self.budget_channel
272    }
273
274    /// Registers an engine plugin.
275    pub fn register_plugin(&mut self, plugin: EnginePlugin) {
276        log::info!("Scheduler: Registered plugin '{}'", plugin.name());
277        self.plugins.push(plugin);
278    }
279
280    /// Sets the phase execution order.
281    pub fn set_phase_order(&mut self, order: &[ExecutionPhase]) {
282        self.phase_order = order.to_vec();
283    }
284
285    /// Inserts a phase after an existing phase.
286    pub fn insert_after(&mut self, existing: ExecutionPhase, new: ExecutionPhase) {
287        if let Some(pos) = self.phase_order.iter().position(|p| *p == existing) {
288            self.phase_order.insert(pos + 1, new);
289        }
290    }
291
292    /// Inserts a phase before an existing phase.
293    pub fn insert_before(&mut self, existing: ExecutionPhase, new: ExecutionPhase) {
294        if let Some(pos) = self.phase_order.iter().position(|p| *p == existing) {
295            self.phase_order.insert(pos, new);
296        }
297    }
298
299    /// Removes a phase from the order.
300    pub fn remove_phase(&mut self, phase: ExecutionPhase) {
301        self.phase_order.retain(|p| *p != phase);
302    }
303
304    /// Executes the complete frame cycle.
305    ///
306    /// This is called every frame by the engine loop.
307    ///
308    /// ## Fixed-timestep sequencing
309    ///
310    /// Rendering runs at the display's variable rate, but the simulation must
311    /// advance in fixed increments to stay frame-rate independent and
312    /// deterministic. The scheduler reconciles the two with an accumulator:
313    ///
314    /// 1. Measure the real wall-clock delta since the previous frame, clamped
315    ///    to [`MAX_FRAME_DELTA_SECONDS`] (spiral-of-death guard), and add it to
316    ///    `sim_accumulator`.
317    /// 2. The **fixed step** is the smallest `fixed_timestep` declared by any
318    ///    registered agent (a single sim clock — physics owns it via its GORNA
319    ///    strategy). If no agent declares one, the frame degrades to the legacy
320    ///    "everything once per frame" path with no behaviour change.
321    /// 3. Consume whole steps: `steps = floor(accumulator / fixed_delta)`,
322    ///    capped at [`MAX_SIM_STEPS`]; the remainder carries over and yields the
323    ///    render `interpolation_alpha`.
324    /// 4. Run the **fixed-timestep agents** (TRANSFORM-phase physics) `steps`
325    ///    times — a fixed-update sub-loop — so the provider advances N discrete
326    ///    sub-steps. Then run the regular phase loop **once**, excluding the
327    ///    agents already stepped, so OUTPUT-phase render fires a single time.
328    /// 5. Publish the fresh `Time` (delta, fixed_delta, alpha) into the runtime
329    ///    resource before the render phase reads it.
330    ///
331    /// Substrate Flows project once per frame; the GORNA completion map,
332    /// budget arbitration, and telemetry all observe each individual agent
333    /// invocation (a sub-step counts as a real run, with its own cost sample).
334    pub fn run_frame(&mut self, world: &mut World, runtime: Arc<Runtime>) {
335        // 1. Sync budgets from cold thread
336        self.budget_channel.sync();
337        self.frame_start = Instant::now();
338
339        // 1b. Real frame delta + fixed-timestep accumulator bookkeeping.
340        let now = self.frame_start;
341        let fixed_delta = self.smallest_fixed_delta();
342        let dt = match self.last_frame_instant {
343            Some(prev) => now.duration_since(prev).as_secs_f32(),
344            // First frame: advance by exactly one fixed step (no real history).
345            None => fixed_delta.unwrap_or(khora_core::time::DEFAULT_FIXED_DELTA_SECONDS),
346        }
347        .min(MAX_FRAME_DELTA_SECONDS);
348        self.last_frame_instant = Some(now);
349
350        // With no fixed-timestep agent, the simulation isn't decoupled: run
351        // everything exactly once (legacy behaviour) and report alpha 0.
352        let (sim_steps, fixed_delta_for_time) = match fixed_delta {
353            Some(fd) => {
354                let r = compute_sim_steps(self.sim_accumulator, dt, fd, MAX_SIM_STEPS);
355                self.sim_accumulator = r.new_accumulator;
356                (r, fd)
357            }
358            None => (
359                SimSteps {
360                    steps: 1,
361                    new_accumulator: 0.0,
362                    alpha: 0.0,
363                },
364                khora_core::time::DEFAULT_FIXED_DELTA_SECONDS,
365            ),
366        };
367
368        // 1c. Publish the per-frame Time resource (read by Flows + game code).
369        self.publish_time(&runtime, dt, fixed_delta_for_time, sim_steps.alpha);
370
371        // 2. Build the per-frame completion map. The scheduler tracks
372        //    completion internally — agents do not read it through the
373        //    runtime containers.
374        let agent_ids = self
375            .registry
376            .lock()
377            .unwrap_or_else(|e| e.into_inner())
378            .all_ids();
379        let completion_map = Arc::new(AgentCompletionMap::new(&agent_ids));
380
381        // 3. Read current mode
382        let mode = {
383            let ctx = self.context.read().unwrap_or_else(|e| e.into_inner());
384            ctx.mode.clone()
385        };
386
387        // 3b. Publish how agents will be grouped into concurrent waves this
388        //     frame, so the DCC can budget each wave by its critical path.
389        self.emit_wave_plan(&mode);
390
391        // 4. Build the per-frame substrate: typed input bus and output deck.
392        //    The deck is moved into the scheduler's `last_deck` slot at the
393        //    end of the frame so the engine I/O layer can drain it.
394        let mut bus = LaneBus::new();
395        let mut deck = OutputDeck::new();
396
397        // 5. Run the Substrate Pass: every registered Flow projects its View
398        //    into the bus. Flows are read-only projectors — no budget needed
399        //    (only agents compete for the frame budget).
400        substrate::run_flows(world, &mut bus, &runtime);
401
402        // Freeze the bus behind an `Arc` for the rest of the frame. It is
403        // strictly read-only from here on (the CLAD descent only reads Views),
404        // and the worker pool needs an owned `Arc<LaneBus>` to make a concurrent
405        // wave's `Isolated` jobs `'static`. Serial paths deref it to `&LaneBus`.
406        let bus = Arc::new(bus);
407
408        // 6. Fixed-update sub-loop. When the simulation is decoupled, the
409        //    fixed-timestep agents advance `steps` discrete sub-steps before
410        //    the once-per-frame phase loop. Each sub-step is a full agent
411        //    invocation, so the physics provider integrates N times while the
412        //    render pass below fires once. When there is no fixed agent
413        //    (`fixed_delta == None`), `steps` is 1 and these agents simply run
414        //    in their normal phase below — so this loop does nothing.
415        let phases: Vec<ExecutionPhase> = self.phase_order.clone();
416        if fixed_delta.is_some() {
417            for _ in 0..sim_steps.steps {
418                for &phase in &phases {
419                    self.execute_agents_in_phase(
420                        phase,
421                        world,
422                        &runtime,
423                        &mode,
424                        &completion_map,
425                        &bus,
426                        &mut deck,
427                        AgentSelection::FixedOnly,
428                    );
429                }
430            }
431        }
432
433        // Once-per-frame agents. When the sim was sub-stepped above, fixed
434        // agents are excluded here so they are not run an extra time; with no
435        // fixed agent, every agent runs through this `All` path as before.
436        let selection = if fixed_delta.is_some() {
437            AgentSelection::ExcludeFixed
438        } else {
439            AgentSelection::All
440        };
441
442        // 8. Execute each phase: plugins then agents (CLAD descent —
443        //    agents invoke their lanes themselves through `Agent::execute`).
444        for phase in phases {
445            for plugin in &mut self.plugins {
446                if plugin.wants_phase(phase) {
447                    plugin.execute(phase, world);
448                }
449            }
450
451            self.execute_agents_in_phase(
452                phase,
453                world,
454                &runtime,
455                &mode,
456                &completion_map,
457                &bus,
458                &mut deck,
459                selection,
460            );
461        }
462
463        // 9. Hand the populated deck off to the engine for the I/O boundary.
464        self.last_deck = deck;
465
466        // 10. Low-rate observation tunnel: publish per-component access snapshots
467        //    so the DCC's layout advisor can recommend layouts. Sampled every
468        //    `COMPONENT_ACCESS_SAMPLE_PERIOD` frames (the counters are cumulative
469        //    and move slowly), and best-effort (dropped if the channel is full).
470        self.frame_counter = self.frame_counter.wrapping_add(1);
471        if let Some(tx) = &self.telemetry {
472            if self
473                .frame_counter
474                .is_multiple_of(COMPONENT_ACCESS_SAMPLE_PERIOD)
475            {
476                for (type_name, size_bytes, query_count, rows_scanned) in
477                    world.component_access_snapshot()
478                {
479                    let _ = tx.try_send(TelemetryEvent::ComponentAccess {
480                        type_name,
481                        size_bytes,
482                        query_count,
483                        rows_scanned,
484                    });
485                }
486            }
487        }
488    }
489
490    /// Smallest `fixed_timestep` (in seconds) declared by any registered
491    /// agent, or `None` if no agent declares one.
492    ///
493    /// A single sim clock is assumed: when several agents declare different
494    /// fixed steps the smallest wins, so every fixed agent is stepped at least
495    /// as often as it asked for. In practice physics is the sole owner.
496    fn smallest_fixed_delta(&self) -> Option<f32> {
497        let registry = self.registry.lock().ok()?;
498        registry
499            .iter()
500            .filter_map(|agent| {
501                agent
502                    .lock()
503                    .ok()
504                    .and_then(|a| a.execution_timing().fixed_timestep)
505            })
506            .map(|d| d.as_secs_f32())
507            .filter(|d| *d > 0.0)
508            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
509    }
510
511    /// Writes the per-frame [`Time`](khora_core::time::Time) into the runtime
512    /// resource so Flows and game `update` read the real delta + alpha.
513    /// No-op if no `SharedTime` resource is registered.
514    fn publish_time(&self, runtime: &Runtime, dt: f32, fixed_delta: f32, alpha: f32) {
515        let Some(shared) = runtime.resources.get::<khora_core::time::SharedTime>() else {
516            return;
517        };
518        if let Ok(mut time) = shared.write() {
519            time.delta_seconds = dt;
520            time.fixed_delta_seconds = fixed_delta;
521            time.interpolation_alpha = alpha;
522            time.frame = time.frame.wrapping_add(1);
523        }
524    }
525
526    #[allow(clippy::too_many_arguments)]
527    fn execute_agents_in_phase(
528        &mut self,
529        phase: ExecutionPhase,
530        world: &mut World,
531        runtime: &Arc<Runtime>,
532        mode: &EngineMode,
533        completion_map: &Arc<AgentCompletionMap>,
534        bus: &Arc<LaneBus>,
535        deck: &mut OutputDeck,
536        selection: AgentSelection,
537    ) {
538        // Collect agents for this phase and mode
539        let agents = {
540            let registry = self.registry.lock().unwrap_or_else(|e| e.into_inner());
541            registry.collect_for_phase(phase, mode)
542        };
543
544        // Partition by whether the agent declares a fixed timestep, so the
545        // fixed-update sub-loop and the once-per-frame loop never double-run
546        // the same agent.
547        let agents: Vec<AgentSlot> = match selection {
548            AgentSelection::All => agents,
549            AgentSelection::FixedOnly => agents.into_iter().filter(slot_is_fixed).collect(),
550            AgentSelection::ExcludeFixed => agents
551                .into_iter()
552                .filter(|slot| !slot_is_fixed(slot))
553                .collect(),
554        };
555
556        if agents.is_empty() {
557            return;
558        }
559
560        let sorted = sort_agents(agents);
561        if self.parallel_execution {
562            self.execute_agents_parallel(sorted, world, runtime, completion_map, bus, deck);
563        } else {
564            self.execute_agents_sequential(sorted, world, runtime, completion_map, bus, deck);
565        }
566    }
567
568    /// Executes the phase's agents **sequentially, in priority order** (the
569    /// [`sort_agents`] output). GORNA budgets are therefore per-agent
570    /// *exclusive time slices of the frame*, not concurrent allocations:
571    /// measured agent costs add up (T1 + T2 + …), which the arbitrator's budget
572    /// fit models as a sum. The parallel path
573    /// ([`execute_agents_parallel`](Self::execute_agents_parallel)) instead
574    /// groups concurrency-eligible agents into waves and publishes that grouping
575    /// so the fit costs each wave by its critical path; with an all-singleton
576    /// grouping (this serial path) the two are identical.
577    fn execute_agents_sequential(
578        &self,
579        agents: Vec<AgentSlot>,
580        world: &mut World,
581        runtime: &Arc<Runtime>,
582        completion_map: &Arc<AgentCompletionMap>,
583        bus: &Arc<LaneBus>,
584        deck: &mut OutputDeck,
585    ) {
586        // Serial paths only need a shared `&LaneBus`.
587        let bus: &LaneBus = bus;
588
589        // Coarse workload size for the cost model — sampled once per phase
590        // (per-domain refinement is a later step).
591        let workload_n = world.entity_count() as f64;
592
593        // Scheduler-owned per-agent frame metrics: agents read their own slot
594        // in `report_status` so they hold no per-frame counters themselves.
595        let frame_status = runtime.resources.get::<AgentFrameStatusMap>().cloned();
596        let record_frame_time = |id: AgentId, measured_time_ms: f32| {
597            if let Some(fs) = &frame_status {
598                fs.write()
599                    .unwrap_or_else(|e| e.into_inner())
600                    .insert(id, AgentFrameStatus { measured_time_ms });
601            }
602        };
603
604        for (agent, importance, _priority, dependencies) in agents {
605            let agent_id = match agent.lock().ok().map(|a| a.id()) {
606                Some(id) => id,
607                None => continue,
608            };
609
610            // Budget escape valve: only *negotiable* (Optional) work is skipped
611            // under pressure. Critical/Important agents are non-negotiable.
612            if importance.is_negotiable() && self.is_under_budget_pressure() {
613                completion_map.mark(agent_id, CompletionOutcome::Skipped);
614                record_frame_time(agent_id, 0.0);
615                continue;
616            }
617
618            // Skip if hard dependencies were skipped or are unmarked
619            if !are_hard_dependencies_completed(&dependencies, completion_map) {
620                completion_map.mark(agent_id, CompletionOutcome::Skipped);
621                record_frame_time(agent_id, 0.0);
622                continue;
623            }
624
625            // Build EngineContext and execute (CLAD descent: the agent
626            // chooses and invokes its lane internally).
627            let mut engine_ctx = EngineContext {
628                world: WorldAccess::Exclusive(world as &mut dyn std::any::Any),
629                runtime: Arc::clone(runtime),
630                bus,
631                deck,
632            };
633
634            let started = Instant::now();
635            if let Ok(mut a) = agent.lock() {
636                a.execute(&mut engine_ctx);
637            }
638            let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
639            record_frame_time(agent_id, elapsed_ms as f32);
640            // Observation tunnel: report (n, time) so the DCC can fit this
641            // agent's cost model and forecast budget breaches. Best-effort.
642            if let Some(tx) = &self.telemetry {
643                let _ = tx.try_send(TelemetryEvent::AgentCost {
644                    id: agent_id,
645                    n: workload_n,
646                    time_ms: elapsed_ms,
647                });
648            }
649            completion_map.mark(agent_id, CompletionOutcome::Completed);
650        }
651    }
652
653    /// Parallel execution path — runs the phase's agents wave by wave.
654    ///
655    /// Agents declaring [`AgentAccess::Isolated`] (no `World` access, writes
656    /// only their own `OutputDeck`) are grouped into concurrent **waves** via
657    /// [`partition_waves`]; every other agent is a singleton wave. Waves run in
658    /// order, so a wave never contains an agent that hard-depends on another
659    /// member — Hard-dependency ordering is preserved exactly as in the
660    /// sequential path.
661    ///
662    /// In a concurrent wave the `Isolated` agents run as `'static` jobs on the
663    /// persistent [`WorkerPool`], each with a private `OutputDeck` shard and a
664    /// cloned `Arc<LaneBus>` (`WorldAccess::None`); the wave's lone `SharedWorld`
665    /// agent, if any, runs **inline on this thread** with a shared `&World` and
666    /// writes straight into the shared deck. The `World` never crosses a thread
667    /// boundary (no `unsafe`, no lifetime transmute). The pooled shards are
668    /// folded back into the shared deck in wave (index) order once collected,
669    /// keeping outputs deterministic. Singleton waves run inline with exclusive
670    /// `&mut World`, identical to
671    /// [`execute_agents_sequential`](Self::execute_agents_sequential).
672    ///
673    /// GORNA cost fitting still assumes sequential (sum-of-costs) budgets;
674    /// switching it to a critical-path model is a follow-up (see the concurrent
675    /// wave's per-agent timings recorded here).
676    fn execute_agents_parallel(
677        &self,
678        agents: Vec<AgentSlot>,
679        world: &mut World,
680        runtime: &Arc<Runtime>,
681        completion_map: &Arc<AgentCompletionMap>,
682        bus: &Arc<LaneBus>,
683        deck: &mut OutputDeck,
684    ) {
685        // Inline/singleton paths only need a shared `&LaneBus`; the pool jobs
686        // clone the `Arc` itself to become `'static`.
687        let bus_ref: &LaneBus = bus;
688
689        let workload_n = world.entity_count() as f64;
690        let frame_status = runtime.resources.get::<AgentFrameStatusMap>().cloned();
691        let record_frame_time = |id: AgentId, measured_time_ms: f32| {
692            if let Some(fs) = &frame_status {
693                fs.write()
694                    .unwrap_or_else(|e| e.into_inner())
695                    .insert(id, AgentFrameStatus { measured_time_ms });
696            }
697        };
698        let report_cost = |id: AgentId, time_ms: f64| {
699            if let Some(tx) = &self.telemetry {
700                let _ = tx.try_send(TelemetryEvent::AgentCost {
701                    id,
702                    n: workload_n,
703                    time_ms,
704                });
705            }
706        };
707
708        // Read each agent's id + access footprint once (a failed lock yields no
709        // id → an always-singleton, always-skipped slot, matching sequential).
710        let metas = build_wave_metas(&agents);
711
712        // Returns true if the agent should run (not budget-skipped, hard deps
713        // satisfied); marks + records the skip otherwise.
714        let gate = |id: AgentId, importance: AgentImportance, deps: &[AgentDependency]| -> bool {
715            if importance.is_negotiable() && self.is_under_budget_pressure() {
716                completion_map.mark(id, CompletionOutcome::Skipped);
717                record_frame_time(id, 0.0);
718                return false;
719            }
720            if !are_hard_dependencies_completed(deps, completion_map) {
721                completion_map.mark(id, CompletionOutcome::Skipped);
722                record_frame_time(id, 0.0);
723                return false;
724            }
725            true
726        };
727
728        for wave in partition_waves(&metas) {
729            // Singleton wave: run inline with exclusive &mut World (identical to
730            // the sequential path — covers every Exclusive agent).
731            if wave.len() == 1 {
732                let idx = wave[0];
733                let Some(id) = metas[idx].id else { continue };
734                let (agent, importance, _priority, deps) = &agents[idx];
735                if !gate(id, *importance, deps) {
736                    continue;
737                }
738                let mut engine_ctx = EngineContext {
739                    world: WorldAccess::Exclusive(world as &mut dyn std::any::Any),
740                    runtime: Arc::clone(runtime),
741                    bus: bus_ref,
742                    deck,
743                };
744                let started = Instant::now();
745                if let Ok(mut a) = agent.lock() {
746                    a.execute(&mut engine_ctx);
747                }
748                let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
749                record_frame_time(id, elapsed_ms as f32);
750                report_cost(id, elapsed_ms);
751                completion_map.mark(id, CompletionOutcome::Completed);
752                continue;
753            }
754
755            // Verify the wave's members write disjoint deck slots before we run
756            // them into private shards (declared via `Agent::deck_writes`). A
757            // collision is a parallel-eligibility bug: the shards would clash on
758            // merge — surface it here, naming the agents, not later on a raw TypeId.
759            check_wave_deck_disjoint(&wave, &metas);
760
761            // Concurrent wave. Gate on this thread, then split the survivors:
762            // the `Isolated` agents (world-free) run as `'static` jobs on the
763            // persistent pool, reaching their inputs through `Arc<LaneBus>` /
764            // `Arc<Runtime>`; the wave's lone `SharedWorld` agent (if any) runs
765            // inline here with a shared `&World`. The `World` therefore never
766            // crosses a thread boundary — no `unsafe`, no lifetime transmute.
767            let runnable: Vec<usize> = wave
768                .into_iter()
769                .filter(|&idx| match metas[idx].id {
770                    Some(id) => gate(id, agents[idx].1, &agents[idx].3),
771                    None => false,
772                })
773                .collect();
774            if runnable.is_empty() {
775                continue;
776            }
777
778            // Dispatch every `Isolated` agent to the pool first, so their work
779            // overlaps the inline `SharedWorld` agent below. Each sends back
780            // `(idx, id, shard, ms)`; `idx` recovers wave order for a
781            // deterministic fold.
782            let (tx, rx) = std::sync::mpsc::channel::<(usize, AgentId, OutputDeck, f64)>();
783            let mut isolated_count = 0usize;
784            for &idx in runnable
785                .iter()
786                .filter(|&&idx| metas[idx].access != AgentAccess::SharedWorld)
787            {
788                let agent = Arc::clone(&agents[idx].0);
789                let id = metas[idx].id.expect("gated runnable has an id");
790                let runtime = Arc::clone(runtime);
791                let bus = Arc::clone(bus);
792                let tx = tx.clone();
793                isolated_count += 1;
794                self.pool.submit(move || {
795                    let bus_ref: &LaneBus = &bus;
796                    let mut shard = OutputDeck::new();
797                    let started = Instant::now();
798                    {
799                        let mut ctx = EngineContext {
800                            world: WorldAccess::None,
801                            runtime,
802                            bus: bus_ref,
803                            deck: &mut shard,
804                        };
805                        if let Ok(mut a) = agent.lock() {
806                            a.execute(&mut ctx);
807                        }
808                    }
809                    let ms = started.elapsed().as_secs_f64() * 1000.0;
810                    let _ = tx.send((idx, id, shard, ms));
811                });
812            }
813            // Drop our sender so the collector below terminates once the pool
814            // jobs (the only remaining senders) have all reported.
815            drop(tx);
816
817            // Run the lone `SharedWorld` agent inline on this thread. It reads
818            // `&World` and writes its slot straight into the shared deck (its
819            // slot type is disjoint from the pooled shards, checked above).
820            for &idx in runnable
821                .iter()
822                .filter(|&&idx| metas[idx].access == AgentAccess::SharedWorld)
823            {
824                let id = metas[idx].id.expect("gated runnable has an id");
825                let mut ctx = EngineContext {
826                    world: WorldAccess::Shared(&*world as &dyn std::any::Any),
827                    runtime: Arc::clone(runtime),
828                    bus: bus_ref,
829                    deck,
830                };
831                let started = Instant::now();
832                if let Ok(mut a) = agents[idx].0.lock() {
833                    a.execute(&mut ctx);
834                }
835                let ms = started.elapsed().as_secs_f64() * 1000.0;
836                record_frame_time(id, ms as f32);
837                report_cost(id, ms);
838                completion_map.mark(id, CompletionOutcome::Completed);
839            }
840
841            // Collect the pooled `Isolated` results and fold their shards in
842            // wave (idx) order → deterministic deck contents.
843            let mut results: Vec<(usize, AgentId, OutputDeck, f64)> =
844                rx.iter().take(isolated_count).collect();
845            results.sort_by_key(|(idx, _, _, _)| *idx);
846            for (_, id, shard, ms) in results {
847                deck.merge_from(shard);
848                record_frame_time(id, ms as f32);
849                report_cost(id, ms);
850                completion_map.mark(id, CompletionOutcome::Completed);
851            }
852        }
853    }
854
855    fn is_under_budget_pressure(&self) -> bool {
856        self.frame_start.elapsed() > self.frame_budget
857    }
858}
859
860/// Orders the agents within a phase: Hard-dependency edges first (topological),
861/// then importance + priority as a tiebreaker among ready-equal nodes.
862///
863/// On a cycle the topo sort fails; we log and fall back to the
864/// importance/priority ordering — execution still happens, just not in DAG
865/// order. Validating cycles at registration time is a future improvement.
866fn sort_agents(mut agents: Vec<AgentSlot>) -> Vec<AgentSlot> {
867    // Importance + priority tiebreaker.
868    agents.sort_by(|a, b| {
869        let imp_ord = a.1.cmp(&b.1);
870        if imp_ord != std::cmp::Ordering::Equal {
871            return imp_ord;
872        }
873        b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)
874    });
875
876    // Build (id -> index) map after the importance sort so the topo result
877    // can be reassembled cheaply.
878    let id_to_index: std::collections::HashMap<AgentId, usize> = agents
879        .iter()
880        .enumerate()
881        .filter_map(|(idx, slot)| slot.0.lock().ok().map(|a| (a.id(), idx)))
882        .collect();
883
884    let nodes: Vec<AgentId> = id_to_index.keys().copied().collect();
885
886    // Build hard-dep edges (parent = dep target, child = dependent agent)
887    // — the dep target must run first.
888    let mut edges: Vec<(AgentId, AgentId)> = Vec::new();
889    for slot in &agents {
890        let agent_id = match slot.0.lock().ok().map(|a| a.id()) {
891            Some(id) => id,
892            None => continue,
893        };
894        for dep in &slot.3 {
895            if matches!(dep.kind, DependencyKind::Hard) && id_to_index.contains_key(&dep.target) {
896                edges.push((dep.target, agent_id));
897            }
898        }
899    }
900
901    match topological_sort(nodes, edges) {
902        Ok(order) => {
903            // Reassemble agents in topo order. Equal-priority nodes inherit
904            // the importance+priority ordering already applied above.
905            let mut taken: Vec<Option<AgentSlot>> = agents.into_iter().map(Some).collect();
906            order
907                .into_iter()
908                .filter_map(|id| {
909                    id_to_index
910                        .get(&id)
911                        .copied()
912                        .and_then(|idx| taken[idx].take())
913                })
914                .collect()
915        }
916        Err(_) => {
917            log::error!(
918                "Scheduler: cycle detected in Hard agent dependencies — falling back to importance/priority order"
919            );
920            agents
921        }
922    }
923}
924
925/// Per-agent metadata the parallel executor needs to group agents into waves.
926struct WaveMeta {
927    /// The agent's id, or `None` if it could not be locked (→ singleton, skipped).
928    id: Option<AgentId>,
929    /// Whether the agent is safe to run concurrently.
930    access: AgentAccess,
931    /// Ids this agent hard-depends on (must run in an earlier wave).
932    hard_dep_targets: Vec<AgentId>,
933    /// `OutputDeck` slot types this agent writes (from [`Agent::deck_writes`]),
934    /// used to verify co-wave agents write disjoint slots before dispatch.
935    deck_writes: Vec<std::any::TypeId>,
936}
937
938/// Worker-pool size: one thread per available core minus two (reserved for the
939/// main/render thread and the DCC cold thread), clamped to `[1, 16]`.
940fn default_pool_size() -> usize {
941    std::thread::available_parallelism()
942        .map(|n| n.get().saturating_sub(2))
943        .unwrap_or(1)
944        .clamp(1, 16)
945}
946
947/// Reads each agent's id, access footprint, and declared deck writes once,
948/// building the [`WaveMeta`] the wave partitioner + disjointness check need. A
949/// failed lock yields `id: None` → an always-singleton, always-skipped slot.
950fn build_wave_metas(agents: &[AgentSlot]) -> Vec<WaveMeta> {
951    agents
952        .iter()
953        .map(|(agent, _, _, deps)| {
954            let (id, access, deck_writes) = agent
955                .lock()
956                .ok()
957                .map(|a| (Some(a.id()), a.access(), a.deck_writes()))
958                .unwrap_or((None, AgentAccess::Exclusive, Vec::new()));
959            WaveMeta {
960                id,
961                access,
962                hard_dep_targets: deps
963                    .iter()
964                    .filter(|d| matches!(d.kind, DependencyKind::Hard))
965                    .map(|d| d.target)
966                    .collect(),
967                deck_writes,
968            }
969        })
970        .collect()
971}
972
973/// Groups agents (already in `sort_agents` order) into execution waves.
974///
975/// A wave is a maximal run of consecutive concurrency-eligible agents
976/// ([`AgentAccess::Isolated`] or [`AgentAccess::SharedWorld`]) in which no
977/// member hard-depends on another member and **at most one** is `SharedWorld`
978/// (that agent may write shared resources, so two of them could race). Any
979/// [`AgentAccess::Exclusive`] agent is its own singleton wave. Because the input
980/// is already topologically ordered by hard deps, and a dependent never shares
981/// a wave with its target, running the waves in order preserves the exact
982/// Hard-dependency ordering the sequential path guarantees.
983fn partition_waves(metas: &[WaveMeta]) -> Vec<Vec<usize>> {
984    let mut waves: Vec<Vec<usize>> = Vec::new();
985    for (i, meta) in metas.iter().enumerate() {
986        let joins_current = meta.access != AgentAccess::Exclusive
987            && waves.last().is_some_and(|wave| {
988                // every current member is concurrency-eligible …
989                wave.iter().all(|&j| metas[j].access != AgentAccess::Exclusive)
990                    // … no member is this agent's hard-dep target …
991                    && !meta.hard_dep_targets.iter().any(|target| {
992                        wave.iter().any(|&j| metas[j].id == Some(*target))
993                    })
994                    // … and the wave holds no other SharedWorld agent.
995                    && !(meta.access == AgentAccess::SharedWorld
996                        && wave
997                            .iter()
998                            .any(|&j| metas[j].access == AgentAccess::SharedWorld))
999            });
1000        if joins_current {
1001            waves.last_mut().expect("checked non-empty").push(i);
1002        } else {
1003            waves.push(vec![i]);
1004        }
1005    }
1006    waves
1007}
1008
1009/// Logs an error for every `OutputDeck` slot type written by more than one
1010/// agent in the same concurrent `wave`.
1011///
1012/// Co-wave agents run into private deck shards that are folded back together
1013/// afterwards, so they must write disjoint slots (guaranteed in principle by
1014/// the [`AgentAccess`] eligibility rules). Declaring writes via
1015/// [`Agent::deck_writes`](khora_core::agent::Agent::deck_writes) lets the
1016/// scheduler catch a violation here — naming the offending agents — rather than
1017/// discovering it defensively during the shard merge on a bare `TypeId`.
1018///
1019/// Returns the number of collisions detected (0 = disjoint, the expected case).
1020fn check_wave_deck_disjoint(wave: &[usize], metas: &[WaveMeta]) -> usize {
1021    let mut seen: std::collections::HashMap<std::any::TypeId, AgentId> =
1022        std::collections::HashMap::new();
1023    let mut collisions = 0;
1024    for &idx in wave {
1025        let Some(id) = metas[idx].id else { continue };
1026        for &slot in &metas[idx].deck_writes {
1027            if let Some(prev) = seen.insert(slot, id) {
1028                collisions += 1;
1029                log::error!(
1030                    "Scheduler: agents {prev:?} and {id:?} share a concurrent wave but both write \
1031                     OutputDeck slot {slot:?} — their shards will collide on merge \
1032                     (parallel-eligibility bug)"
1033                );
1034            }
1035        }
1036    }
1037    collisions
1038}
1039
1040fn are_hard_dependencies_completed(
1041    dependencies: &[AgentDependency],
1042    completion_map: &AgentCompletionMap,
1043) -> bool {
1044    for dep in dependencies {
1045        if matches!(dep.kind, DependencyKind::Hard) {
1046            // Conditions are not yet evaluated in the scheduler — preserved
1047            // from the previous behaviour. Treat conditional deps as active.
1048            match completion_map.outcome(dep.target) {
1049                Some(CompletionOutcome::Completed) => {}
1050                Some(CompletionOutcome::Skipped) | None => return false,
1051            }
1052        }
1053    }
1054    true
1055}
1056
1057#[cfg(test)]
1058mod wave_tests {
1059    use super::{check_wave_deck_disjoint, partition_waves, WaveMeta};
1060    use khora_core::agent::AgentAccess;
1061    use khora_core::control::gorna::AgentId;
1062
1063    fn meta(id: AgentId, access: AgentAccess, deps: &[AgentId]) -> WaveMeta {
1064        WaveMeta {
1065            id: Some(id),
1066            access,
1067            hard_dep_targets: deps.to_vec(),
1068            deck_writes: Vec::new(),
1069        }
1070    }
1071
1072    #[test]
1073    fn all_exclusive_are_singletons() {
1074        let metas = vec![
1075            meta(AgentId::Physics, AgentAccess::Exclusive, &[]),
1076            meta(AgentId::Renderer, AgentAccess::Exclusive, &[]),
1077        ];
1078        assert_eq!(partition_waves(&metas), vec![vec![0], vec![1]]);
1079    }
1080
1081    #[test]
1082    fn consecutive_isolated_form_one_wave() {
1083        let metas = vec![
1084            meta(AgentId::Audio, AgentAccess::Isolated, &[]),
1085            meta(AgentId::ShadowRenderer, AgentAccess::Isolated, &[]),
1086        ];
1087        assert_eq!(partition_waves(&metas), vec![vec![0, 1]]);
1088    }
1089
1090    #[test]
1091    fn exclusive_breaks_the_wave() {
1092        let metas = vec![
1093            meta(AgentId::Audio, AgentAccess::Isolated, &[]),
1094            meta(AgentId::Physics, AgentAccess::Exclusive, &[]),
1095            meta(AgentId::ShadowRenderer, AgentAccess::Isolated, &[]),
1096        ];
1097        assert_eq!(partition_waves(&metas), vec![vec![0], vec![1], vec![2]]);
1098    }
1099
1100    #[test]
1101    fn hard_dep_on_wave_member_splits_it() {
1102        // The second Isolated agent hard-depends on the first, so it must run in
1103        // a later wave — preserving the dependency ordering.
1104        let metas = vec![
1105            meta(AgentId::Audio, AgentAccess::Isolated, &[]),
1106            meta(
1107                AgentId::ShadowRenderer,
1108                AgentAccess::Isolated,
1109                &[AgentId::Audio],
1110            ),
1111        ];
1112        assert_eq!(partition_waves(&metas), vec![vec![0], vec![1]]);
1113    }
1114
1115    #[test]
1116    fn shared_world_joins_isolated_agents() {
1117        // One SharedWorld reader runs alongside any number of Isolated agents.
1118        let metas = vec![
1119            meta(AgentId::Renderer, AgentAccess::SharedWorld, &[]),
1120            meta(AgentId::Audio, AgentAccess::Isolated, &[]),
1121            meta(AgentId::ShadowRenderer, AgentAccess::Isolated, &[]),
1122        ];
1123        assert_eq!(partition_waves(&metas), vec![vec![0, 1, 2]]);
1124    }
1125
1126    #[test]
1127    fn two_shared_world_agents_split_into_separate_waves() {
1128        // Two SharedWorld agents may each write shared resources, so at most one
1129        // runs per wave.
1130        let metas = vec![
1131            meta(AgentId::Renderer, AgentAccess::SharedWorld, &[]),
1132            meta(AgentId::Overlay, AgentAccess::SharedWorld, &[]),
1133        ];
1134        assert_eq!(partition_waves(&metas), vec![vec![0], vec![1]]);
1135    }
1136
1137    #[test]
1138    fn exclusive_still_breaks_a_mixed_wave() {
1139        let metas = vec![
1140            meta(AgentId::Audio, AgentAccess::Isolated, &[]),
1141            meta(AgentId::Renderer, AgentAccess::SharedWorld, &[]),
1142            meta(AgentId::Physics, AgentAccess::Exclusive, &[]),
1143            meta(AgentId::ShadowRenderer, AgentAccess::Isolated, &[]),
1144        ];
1145        assert_eq!(partition_waves(&metas), vec![vec![0, 1], vec![2], vec![3]]);
1146    }
1147
1148    fn meta_writes(id: AgentId, access: AgentAccess, writes: &[std::any::TypeId]) -> WaveMeta {
1149        WaveMeta {
1150            id: Some(id),
1151            access,
1152            hard_dep_targets: Vec::new(),
1153            deck_writes: writes.to_vec(),
1154        }
1155    }
1156
1157    #[test]
1158    fn disjoint_deck_writes_pass_the_check() {
1159        // Two agents writing distinct slot types share a wave cleanly.
1160        let metas = vec![
1161            meta_writes(
1162                AgentId::Ui,
1163                AgentAccess::SharedWorld,
1164                &[std::any::TypeId::of::<u32>()],
1165            ),
1166            meta_writes(
1167                AgentId::Overlay,
1168                AgentAccess::Isolated,
1169                &[std::any::TypeId::of::<u64>()],
1170            ),
1171        ];
1172        assert_eq!(check_wave_deck_disjoint(&[0, 1], &metas), 0);
1173    }
1174
1175    #[test]
1176    fn colliding_deck_writes_are_detected() {
1177        // Two agents in one wave both declaring the same slot type collide.
1178        let metas = vec![
1179            meta_writes(
1180                AgentId::Ui,
1181                AgentAccess::SharedWorld,
1182                &[std::any::TypeId::of::<u32>()],
1183            ),
1184            meta_writes(
1185                AgentId::Overlay,
1186                AgentAccess::Isolated,
1187                &[std::any::TypeId::of::<u32>()],
1188            ),
1189        ];
1190        assert_eq!(check_wave_deck_disjoint(&[0, 1], &metas), 1);
1191    }
1192}
1193
1194#[cfg(test)]
1195mod concurrent_exec_tests {
1196    use super::*;
1197    use crate::context::Context;
1198    use crate::registry::AgentRegistry;
1199    use khora_core::agent::completion::{AgentCompletionMap, CompletionOutcome};
1200    use khora_core::agent::{Agent, AgentAccess, AgentImportance};
1201    use khora_core::control::gorna::{
1202        AgentId, AgentStatus, NegotiationRequest, NegotiationResponse, ResourceBudget, StrategyId,
1203    };
1204    use khora_core::lane::{LaneBus, OutputDeck};
1205    use khora_core::{EngineContext, Runtime};
1206    use khora_data::ecs::World;
1207    use std::sync::{Arc, Mutex, RwLock};
1208
1209    /// Deck slot: did the `SharedWorld` agent reach the shared `&World`?
1210    #[derive(Default)]
1211    struct SharedSaw(bool);
1212    /// Deck slot: did the `Isolated` agent run, and was it correctly world-free?
1213    #[derive(Default)]
1214    struct IsoRan {
1215        ran: bool,
1216        world_was_none: bool,
1217    }
1218
1219    enum Role {
1220        SharedWorldReader,
1221        IsolatedWriter,
1222    }
1223
1224    struct MockAgent {
1225        id: AgentId,
1226        access: AgentAccess,
1227        role: Role,
1228    }
1229
1230    impl Agent for MockAgent {
1231        fn id(&self) -> AgentId {
1232            self.id
1233        }
1234        fn negotiate(&mut self, _r: NegotiationRequest) -> NegotiationResponse {
1235            NegotiationResponse {
1236                strategies: vec![],
1237                timing_adjustment: None,
1238            }
1239        }
1240        fn apply_budget(&mut self, _b: ResourceBudget) {}
1241        fn report_status(&self) -> AgentStatus {
1242            AgentStatus {
1243                agent_id: self.id,
1244                current_strategy: StrategyId::Balanced,
1245                health_score: 1.0,
1246                is_stalled: false,
1247                message: String::new(),
1248            }
1249        }
1250        fn execute(&mut self, ctx: &mut EngineContext<'_>) {
1251            match self.role {
1252                Role::SharedWorldReader => {
1253                    let saw = ctx
1254                        .world_ref()
1255                        .and_then(|w| w.downcast_ref::<World>())
1256                        .is_some();
1257                    ctx.deck.slot::<SharedSaw>().0 = saw;
1258                }
1259                Role::IsolatedWriter => {
1260                    let world_was_none = ctx.world_ref().is_none();
1261                    let slot = ctx.deck.slot::<IsoRan>();
1262                    slot.ran = true;
1263                    slot.world_was_none = world_was_none;
1264                }
1265            }
1266        }
1267        fn access(&self) -> AgentAccess {
1268            self.access
1269        }
1270        fn as_any(&self) -> &dyn std::any::Any {
1271            self
1272        }
1273        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1274            self
1275        }
1276    }
1277
1278    /// Drives `execute_agents_parallel` directly with a `SharedWorld` reader and
1279    /// an `Isolated` writer in the same wave, proving the pool path: the reader
1280    /// runs inline with a shared `&World`, the writer runs pooled with none, both
1281    /// run, and the pooled shard folds back into the shared deck.
1282    #[test]
1283    fn concurrent_wave_runs_shared_reader_and_isolated_writer() {
1284        let scheduler = ExecutionScheduler::new(
1285            Arc::new(Mutex::new(AgentRegistry::new())),
1286            Arc::new(RwLock::new(Context::default())),
1287            &[AgentId::Renderer, AgentId::Audio],
1288        );
1289        let mut world = World::new();
1290        let runtime = Arc::new(Runtime::new());
1291        let completion = Arc::new(AgentCompletionMap::new(&[
1292            AgentId::Renderer,
1293            AgentId::Audio,
1294        ]));
1295        let bus = Arc::new(LaneBus::new());
1296        let mut deck = OutputDeck::new();
1297
1298        let agents: Vec<AgentSlot> = vec![
1299            (
1300                Arc::new(Mutex::new(MockAgent {
1301                    id: AgentId::Renderer,
1302                    access: AgentAccess::SharedWorld,
1303                    role: Role::SharedWorldReader,
1304                })),
1305                AgentImportance::Critical,
1306                1.0,
1307                vec![],
1308            ),
1309            (
1310                Arc::new(Mutex::new(MockAgent {
1311                    id: AgentId::Audio,
1312                    access: AgentAccess::Isolated,
1313                    role: Role::IsolatedWriter,
1314                })),
1315                AgentImportance::Critical,
1316                0.5,
1317                vec![],
1318            ),
1319        ];
1320
1321        scheduler.execute_agents_parallel(
1322            agents,
1323            &mut world,
1324            &runtime,
1325            &completion,
1326            &bus,
1327            &mut deck,
1328        );
1329
1330        assert!(
1331            deck.take::<SharedSaw>().0,
1332            "SharedWorld agent must reach the shared &World on its worker thread"
1333        );
1334        let iso = deck.take::<IsoRan>();
1335        assert!(iso.ran, "Isolated agent must run");
1336        assert!(
1337            iso.world_was_none,
1338            "Isolated agent must be granted no world access"
1339        );
1340        assert_eq!(
1341            completion.outcome(AgentId::Renderer),
1342            Some(CompletionOutcome::Completed)
1343        );
1344        assert_eq!(
1345            completion.outcome(AgentId::Audio),
1346            Some(CompletionOutcome::Completed)
1347        );
1348    }
1349}
1350
1351#[cfg(test)]
1352mod sim_step_tests {
1353    use super::{compute_sim_steps, MAX_SIM_STEPS};
1354
1355    const FIXED: f32 = 1.0 / 60.0;
1356
1357    #[test]
1358    fn exact_multiple_runs_whole_steps_no_remainder() {
1359        // Two full steps' worth of time, nothing carried over.
1360        let r = compute_sim_steps(0.0, 2.0 * FIXED, FIXED, MAX_SIM_STEPS);
1361        assert_eq!(r.steps, 2);
1362        assert!(r.new_accumulator.abs() < 1e-6, "no remainder expected");
1363        assert!(r.alpha.abs() < 1e-6);
1364    }
1365
1366    #[test]
1367    fn fractional_carry_advances_remainder() {
1368        // 2.5 steps → 2 steps run, half a step carried.
1369        let r = compute_sim_steps(0.0, 2.5 * FIXED, FIXED, MAX_SIM_STEPS);
1370        assert_eq!(r.steps, 2);
1371        assert!((r.new_accumulator - 0.5 * FIXED).abs() < 1e-6);
1372        assert!((r.alpha - 0.5).abs() < 1e-4);
1373    }
1374
1375    #[test]
1376    fn dt_below_fixed_delta_runs_no_step() {
1377        let r = compute_sim_steps(0.0, 0.5 * FIXED, FIXED, MAX_SIM_STEPS);
1378        assert_eq!(r.steps, 0);
1379        assert!((r.new_accumulator - 0.5 * FIXED).abs() < 1e-6);
1380        assert!((r.alpha - 0.5).abs() < 1e-4);
1381    }
1382
1383    #[test]
1384    fn accumulator_from_prior_frame_is_included() {
1385        // 0.7 carried + 0.6 this frame = 1.3 steps → 1 step, 0.3 carry.
1386        let r = compute_sim_steps(0.7 * FIXED, 0.6 * FIXED, FIXED, MAX_SIM_STEPS);
1387        assert_eq!(r.steps, 1);
1388        assert!((r.new_accumulator - 0.3 * FIXED).abs() < 1e-5);
1389    }
1390
1391    #[test]
1392    fn spiral_clamp_caps_steps_and_bounds_accumulator() {
1393        // A huge dt (100 steps' worth) must clamp to MAX_SIM_STEPS and the
1394        // accumulator must stay bounded (excess time dropped).
1395        let r = compute_sim_steps(0.0, 100.0 * FIXED, FIXED, MAX_SIM_STEPS);
1396        assert_eq!(r.steps, MAX_SIM_STEPS);
1397        assert!(
1398            r.new_accumulator < FIXED,
1399            "accumulator must be bounded below one step, got {}",
1400            r.new_accumulator
1401        );
1402        assert!((0.0..1.0).contains(&r.alpha));
1403    }
1404
1405    #[test]
1406    fn alpha_always_in_unit_interval() {
1407        for k in 0..400u32 {
1408            let dt = (k as f32) * 0.001;
1409            let r = compute_sim_steps(0.0, dt, FIXED, MAX_SIM_STEPS);
1410            assert!(
1411                (0.0..1.0).contains(&r.alpha),
1412                "alpha out of range for dt={dt}: {}",
1413                r.alpha
1414            );
1415        }
1416    }
1417
1418    #[test]
1419    fn determinism_same_total_time_same_step_count() {
1420        // Cadence A: ten frames of 1.5 fixed-steps each (144 Hz-ish bursts).
1421        let mut acc_a = 0.0;
1422        let mut steps_a = 0u32;
1423        for _ in 0..10 {
1424            let r = compute_sim_steps(acc_a, 1.5 * FIXED, FIXED, MAX_SIM_STEPS);
1425            acc_a = r.new_accumulator;
1426            steps_a += r.steps;
1427        }
1428        // Cadence B: five frames of 3.0 fixed-steps each (30 Hz). Same total
1429        // simulated time (15 fixed steps) split differently.
1430        let mut acc_b = 0.0;
1431        let mut steps_b = 0u32;
1432        for _ in 0..5 {
1433            let r = compute_sim_steps(acc_b, 3.0 * FIXED, FIXED, MAX_SIM_STEPS);
1434            acc_b = r.new_accumulator;
1435            steps_b += r.steps;
1436        }
1437        assert_eq!(
1438            steps_a, steps_b,
1439            "same total sim time must yield the same total steps regardless of frame cadence"
1440        );
1441        assert_eq!(steps_a, 15);
1442    }
1443
1444    #[test]
1445    fn degenerate_fixed_delta_runs_single_step() {
1446        let r = compute_sim_steps(0.0, 0.016, 0.0, MAX_SIM_STEPS);
1447        assert_eq!(r.steps, 1);
1448        assert_eq!(r.alpha, 0.0);
1449    }
1450}