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}