khora_control/substrate/mod.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Substrate Pass — orchestrates the Data layer's self-maintenance work.
16//!
17//! Per CLAD's enriched doctrine, the Substrate Pass runs *around* the
18//! command path (`Control → Agent → Lane → Data`), not inside it. It is
19//! invoked by the Scheduler at well-defined points in the tick:
20//!
21//! - [`khora_data::ecs::TickPhase::PreSimulation`] before any agent simulates,
22//! - [`khora_data::ecs::TickPhase::PostSimulation`] before extraction,
23//! - [`khora_data::ecs::TickPhase::PreExtract`] right before `Flow`s project,
24//! - [`khora_data::ecs::TickPhase::Maintenance`] at the end of the tick.
25//!
26//! The pass discovers `DataSystemRegistration` entries via [`inventory`],
27//! orders them by `runs_after` (topological sort) with `order_hint` as a
28//! tie-breaker, and invokes them sequentially.
29
30pub mod flow_runner;
31
32pub use flow_runner::run_flows;
33
34use khora_core::graph::topological_sort;
35use khora_core::lane::OutputDeck;
36use khora_core::Runtime;
37use khora_data::ecs::{DataSystemRegistration, TickPhase, World};
38use std::collections::HashMap;
39
40/// Runs every [`DataSystemRegistration`] declared for the given phase, in a
41/// stable order: topological by `runs_after`, then by `order_hint`, then by
42/// `name` for full determinism.
43///
44/// `deck` is the per-tick typed [`OutputDeck`] the lanes wrote into during
45/// the CLAD descent. `Maintenance`-phase systems drain typed writeback
46/// slots from it (`deck.take::<MyWriteback>()`) and apply the results to
47/// ECS components. Pre-scheduler phases pass a transient empty deck.
48///
49/// On a cycle in `runs_after` the function logs an error and falls back to
50/// the `(order_hint, name)` ordering — execution still happens, just not in
51/// DAG order. Validating cycles at registration time is a future improvement.
52pub fn run_data_systems(
53 world: &mut World,
54 runtime: &Runtime,
55 deck: &mut OutputDeck,
56 phase: TickPhase,
57) {
58 let mut systems: Vec<&'static DataSystemRegistration> =
59 inventory::iter::<DataSystemRegistration>
60 .into_iter()
61 .filter(|s| s.phase == phase)
62 .collect();
63
64 if systems.is_empty() {
65 return;
66 }
67
68 sort_systems(&mut systems);
69
70 for sys in systems {
71 (sys.run)(world, runtime, deck);
72 }
73}
74
75/// Stable in-place sort over a phase's systems.
76///
77/// Topological by `runs_after` (Kahn's algorithm via
78/// [`topological_sort`]), with `(order_hint, name)` lifted as the
79/// tie-breaker among ready-equal nodes. On a cycle, falls back to
80/// `(order_hint, name)` only and logs an error.
81fn sort_systems(systems: &mut Vec<&'static DataSystemRegistration>) {
82 // Lift `(order_hint, name)` as the deterministic baseline. Kahn's
83 // ready queue is a FIFO, so this baseline ordering carries through to
84 // the topological output as the tie-breaker.
85 systems.sort_by(|a, b| {
86 a.order_hint
87 .cmp(&b.order_hint)
88 .then_with(|| a.name.cmp(b.name))
89 });
90
91 let by_name: HashMap<&'static str, &'static DataSystemRegistration> =
92 systems.iter().map(|s| (s.name, *s)).collect();
93
94 let nodes: Vec<&'static str> = systems.iter().map(|s| s.name).collect();
95 let edges: Vec<(&'static str, &'static str)> = systems
96 .iter()
97 .flat_map(|s| {
98 s.runs_after
99 .iter()
100 .filter(|dep| by_name.contains_key(*dep))
101 .map(move |dep| (*dep, s.name))
102 })
103 .collect();
104
105 match topological_sort(nodes, edges) {
106 Ok(order) => {
107 *systems = order
108 .into_iter()
109 .filter_map(|name| by_name.get(name).copied())
110 .collect();
111 }
112 Err(_) => {
113 log::error!(
114 "substrate: cycle detected in DataSystem `runs_after` for phase {:?} — \
115 falling back to (order_hint, name) order",
116 systems.first().map(|s| s.phase),
117 );
118 }
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use std::sync::Mutex;
126
127 static ORDER_LOG: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
128
129 fn record(name: &'static str) {
130 ORDER_LOG.lock().unwrap().push(name);
131 }
132
133 fn sys_a(_: &mut World, _: &Runtime, _: &mut OutputDeck) {
134 record("a");
135 }
136 fn sys_b(_: &mut World, _: &Runtime, _: &mut OutputDeck) {
137 record("b");
138 }
139 fn sys_c(_: &mut World, _: &Runtime, _: &mut OutputDeck) {
140 record("c");
141 }
142
143 inventory::submit! {
144 DataSystemRegistration {
145 name: "test_b",
146 phase: TickPhase::PreSimulation,
147 run: sys_b,
148 order_hint: 10,
149 runs_after: &[],
150 }
151 }
152 inventory::submit! {
153 DataSystemRegistration {
154 name: "test_a",
155 phase: TickPhase::PreSimulation,
156 run: sys_a,
157 order_hint: 0,
158 runs_after: &[],
159 }
160 }
161 inventory::submit! {
162 DataSystemRegistration {
163 name: "test_c",
164 phase: TickPhase::PreSimulation,
165 run: sys_c,
166 order_hint: -5,
167 runs_after: &["test_a"], // Forces c after a despite lower hint.
168 }
169 }
170
171 #[test]
172 fn topo_order_respects_runs_after() {
173 ORDER_LOG.lock().unwrap().clear();
174
175 let mut world = World::new();
176 let runtime = Runtime::new();
177 let mut deck = OutputDeck::new();
178 run_data_systems(&mut world, &runtime, &mut deck, TickPhase::PreSimulation);
179
180 let log = ORDER_LOG.lock().unwrap();
181 // Hard guarantee: c must come after a (declared `runs_after: ["test_a"]`).
182 let pos_a = log.iter().position(|n| *n == "a").unwrap();
183 let pos_c = log.iter().position(|n| *n == "c").unwrap();
184 assert!(
185 pos_a < pos_c,
186 "topo order violated: a should run before c (got {:?})",
187 *log
188 );
189
190 // Soft guarantee: among nodes ready at the same time, lower order_hint
191 // wins as a tie-breaker. a (hint=0) and b (hint=10) are both ready
192 // initially; a must come before b.
193 let pos_b = log.iter().position(|n| *n == "b").unwrap();
194 assert!(
195 pos_a < pos_b,
196 "tie-breaker violated: a should run before b (got {:?})",
197 *log
198 );
199 }
200}