Skip to main content

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}