khora_core/lane/deck.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//! `OutputDeck` — typed mutable bus for lane outputs, scoped to one tick.
16//!
17//! Symmetric counterpart of [`LaneBus`](super::LaneBus): lanes write typed
18//! outputs into the deck during the CLAD descent; the engine drains specific
19//! types at the I/O boundary (e.g. recorded GPU command buffers, draw lists).
20//!
21//! # Visibility contract
22//!
23//! - [`OutputDeck::slot`] returns a `&mut T` for lanes to push into.
24//! - [`OutputDeck::take`] is `pub(crate)` (used by the scheduler to expose
25//! typed outputs to the engine I/O layer in a controlled way).
26
27use std::any::{Any, TypeId};
28use std::collections::HashMap;
29
30/// Mutable typed deck collecting lane outputs for one tick.
31///
32/// Each typed slot is created on first access, holding `T::default()` until
33/// a lane writes into it. The engine drains specific types at the I/O
34/// boundary (submit, present) via [`OutputDeck::take`].
35pub struct OutputDeck {
36 slots: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
37}
38
39impl OutputDeck {
40 /// Creates an empty deck.
41 pub fn new() -> Self {
42 Self {
43 slots: HashMap::new(),
44 }
45 }
46
47 /// Returns a mutable reference to the slot for type `T`, creating it
48 /// with `T::default()` on first access.
49 ///
50 /// Lanes call this to accumulate outputs (e.g. push command buffers
51 /// onto a `Vec`).
52 pub fn slot<T: Default + Any + Send + Sync>(&mut self) -> &mut T {
53 self.slots
54 .entry(TypeId::of::<T>())
55 .or_insert_with(|| Box::new(T::default()))
56 .downcast_mut::<T>()
57 .expect("slot type mismatch — TypeId collision is impossible in safe Rust")
58 }
59
60 /// Removes and returns the slot for type `T`, replacing it with the
61 /// default value (so subsequent ticks start clean).
62 ///
63 /// Returns `T::default()` if no lane wrote into this slot during the
64 /// tick. Used by the engine at the I/O boundary.
65 pub fn take<T: Default + Any + Send + Sync>(&mut self) -> T {
66 self.slots
67 .remove(&TypeId::of::<T>())
68 .and_then(|b| b.downcast::<T>().ok().map(|b| *b))
69 .unwrap_or_default()
70 }
71
72 /// Reports whether a slot of the given type has been written this tick.
73 pub fn contains<T: Any + Send + Sync>(&self) -> bool {
74 self.slots.contains_key(&TypeId::of::<T>())
75 }
76
77 /// Number of slots currently holding data.
78 pub fn len(&self) -> usize {
79 self.slots.len()
80 }
81
82 /// Whether no slots have been written.
83 pub fn is_empty(&self) -> bool {
84 self.slots.is_empty()
85 }
86
87 /// Clears all slots. Called by the scheduler between ticks if a deck
88 /// instance is reused rather than reallocated.
89 pub fn clear(&mut self) {
90 self.slots.clear();
91 }
92
93 /// Moves every slot from `other` into `self`.
94 ///
95 /// Used by the parallel executor to fold a worker's private deck shard back
96 /// into the shared deck after a concurrent wave. The merged shards are
97 /// expected to write **disjoint** slot types (guaranteed by the executor's
98 /// [`AgentAccess::Isolated`](crate::agent::AgentAccess::Isolated) eligibility
99 /// rules). A colliding `TypeId` is therefore a scheduling bug: the existing
100 /// slot is kept and the collision logged, rather than silently dropping one
101 /// side's outputs.
102 pub fn merge_from(&mut self, other: OutputDeck) {
103 for (type_id, value) in other.slots {
104 if self.slots.contains_key(&type_id) {
105 log::error!(
106 "OutputDeck::merge_from: colliding slot {type_id:?} across parallel deck \
107 shards — keeping the existing entry (parallel-eligibility bug)"
108 );
109 continue;
110 }
111 self.slots.insert(type_id, value);
112 }
113 }
114}
115
116impl Default for OutputDeck {
117 fn default() -> Self {
118 Self::new()
119 }
120}
121
122impl std::fmt::Debug for OutputDeck {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("OutputDeck")
125 .field("slots", &self.slots.len())
126 .finish()
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn slot_lazily_inserts_default() {
136 let mut deck = OutputDeck::new();
137 assert!(!deck.contains::<Vec<u32>>());
138 let v = deck.slot::<Vec<u32>>();
139 v.push(1);
140 v.push(2);
141 assert_eq!(deck.slot::<Vec<u32>>(), &vec![1, 2]);
142 }
143
144 #[test]
145 fn take_removes_and_returns_value() {
146 let mut deck = OutputDeck::new();
147 deck.slot::<Vec<u32>>().extend([1, 2, 3]);
148 let taken: Vec<u32> = deck.take();
149 assert_eq!(taken, vec![1, 2, 3]);
150 assert!(!deck.contains::<Vec<u32>>());
151 }
152
153 #[test]
154 fn take_missing_returns_default() {
155 let mut deck = OutputDeck::new();
156 let taken: Vec<u32> = deck.take();
157 assert!(taken.is_empty());
158 }
159
160 #[test]
161 fn distinct_types_dont_alias() {
162 let mut deck = OutputDeck::new();
163 deck.slot::<Vec<u32>>().push(1);
164 deck.slot::<Vec<u8>>().push(2);
165 assert_eq!(deck.take::<Vec<u32>>(), vec![1]);
166 assert_eq!(deck.take::<Vec<u8>>(), vec![2]);
167 }
168
169 #[test]
170 fn merge_from_folds_disjoint_shards() {
171 let mut main = OutputDeck::new();
172 main.slot::<Vec<u32>>().push(1);
173
174 let mut shard = OutputDeck::new();
175 shard.slot::<Vec<u8>>().extend([7, 8]);
176
177 main.merge_from(shard);
178 assert_eq!(main.take::<Vec<u32>>(), vec![1]);
179 assert_eq!(main.take::<Vec<u8>>(), vec![7, 8]);
180 }
181
182 #[test]
183 fn merge_from_keeps_existing_on_collision() {
184 let mut main = OutputDeck::new();
185 main.slot::<Vec<u32>>().push(1);
186
187 let mut shard = OutputDeck::new();
188 shard.slot::<Vec<u32>>().push(99);
189
190 // Colliding slot type: the existing entry is kept (defensive).
191 main.merge_from(shard);
192 assert_eq!(main.take::<Vec<u32>>(), vec![1]);
193 }
194}