wyrd/runtime_impl/host.rs
1//! Host-facing tick: begin_frame → sample → loom → apply.
2//!
3//! Dense ids only on the hot path (D-id-space / D-hostpath). Loom never sees
4//! engine types; apply maps the outbox into [`HostCommand`]s or host-local effects.
5//! Resolve `SenseId` / `HostPathId` once after bind — not each sample.
6
7use std::vec::Vec;
8
9use crate::foundation::{HostTime, Signal};
10
11use crate::runtime_impl::bind::Runtime;
12use crate::runtime_impl::error::HandleError;
13use crate::runtime_impl::handles::{CmdId, HostPathId, SenseId};
14use crate::runtime_impl::outbox::{Outbox, PortWriter};
15
16/// Dense command emitted for host apply (from SignalOut / EmitCommand).
17///
18/// `SetLevel` is host pedagogy language for any SignalOut sample (full Signal).
19#[derive(Copy, Clone, Debug, PartialEq)]
20#[non_exhaustive]
21pub enum HostCommand {
22 /// Apply one `SignalOut` sample to a host-owned path.
23 SetLevel {
24 /// Dense host path id for the signal sink.
25 path: HostPathId,
26 /// Level sampled from the graph this frame.
27 value: Signal,
28 },
29 /// Fire one capped `EmitCommand` with its optional payload.
30 Emit {
31 /// Dense command id interned at bind.
32 cmd: CmdId,
33 /// Payload port value when the emit knot sampled one.
34 payload: Signal,
35 },
36}
37
38/// Append outbox contents as dense host commands (signals then emits).
39///
40/// Prefer iterating [`Outbox::signals`] / [`Outbox::emits`] on the hottest
41/// paths; use this helper when a command list is convenient (tests, scripted
42/// hosts). Reuse `out` across ticks to avoid fresh allocation.
43pub fn append_commands(outbox: Outbox<'_>, out: &mut Vec<HostCommand>) {
44 out.reserve(outbox.signals().len() + outbox.emits().len());
45 for s in outbox.signals() {
46 out.push(HostCommand::SetLevel {
47 path: s.path,
48 value: s.value,
49 });
50 }
51 for e in outbox.emits() {
52 out.push(HostCommand::Emit {
53 cmd: e.cmd,
54 payload: e.payload,
55 });
56 }
57}
58
59/// Map a loom outbox into a new command vec (allocates). See [`append_commands`].
60pub fn outbox_to_commands(outbox: Outbox<'_>) -> Vec<HostCommand> {
61 let mut cmds = Vec::new();
62 append_commands(outbox, &mut cmds);
63 cmds
64}
65
66/// Engine-neutral host: sample senses into a [`PortWriter`], apply outbox after loom.
67///
68/// Implement on a concrete type; do not use `dyn Host` on the hot path.
69/// Hold dense [`SenseId`]s on the host; do not call `Runtime::sense_id` every tick.
70pub trait Host {
71 /// Discrete frame time passed to [`Runtime::begin_frame`].
72 fn time(&self) -> HostTime;
73 /// Write sense ports for this tick (dense `set_sense` only).
74 fn sample_into(&mut self, ports: &mut PortWriter<'_>) -> Result<(), HandleError>;
75 /// Consume this frame's outbox after loom (signals, capped emits).
76 fn apply(&mut self, outbox: Outbox<'_>);
77}
78
79/// One host tick: begin_frame → sample → loom → apply.
80pub fn tick_once(host: &mut impl Host, rt: &mut Runtime) -> Result<(), HandleError> {
81 rt.begin_frame(host.time());
82 {
83 let mut w = rt.port_writer();
84 host.sample_into(&mut w)?;
85 }
86 rt.loom();
87 host.apply(rt.outbox());
88 Ok(())
89}
90
91/// No-op host (benches, placeholder worlds).
92#[derive(Clone, Debug, Default)]
93pub struct NullHost {
94 /// Monotonic tick counter returned by [`Host::time`] and advanced in [`Host::apply`].
95 pub tick: u64,
96}
97
98impl Host for NullHost {
99 fn time(&self) -> HostTime {
100 HostTime { tick: self.tick }
101 }
102 fn sample_into(&mut self, _ports: &mut PortWriter<'_>) -> Result<(), HandleError> {
103 Ok(())
104 }
105 fn apply(&mut self, _outbox: Outbox<'_>) {
106 self.tick = self.tick.wrapping_add(1);
107 }
108}
109
110/// Scripted senses + recorded apply commands for deterministic replay tests.
111///
112/// Hold dense [`SenseId`]s resolved after bind. Each frame is a **write list**
113/// (not a full port snapshot) applied in `sample_into` when
114/// `HostTime.tick == frame_index`. Missing sense keys **hold last** value
115/// (runtime does not clear senses each frame). Ticks past `frames.len()`
116/// write nothing (last values remain). Prefer setting every sense every frame
117/// in tests so scripts stay explicit.
118///
119/// After each tick, `commands` grows with that frame's outbox mapping.
120#[derive(Clone, Debug, Default)]
121pub struct ScriptedHost {
122 /// Current frame index for [`Host::time`] (incremented in [`Host::apply`]).
123 pub tick: u64,
124 /// Write list for sample when `tick == i` (before `apply` increments tick).
125 pub frames: Vec<Vec<(SenseId, Signal)>>,
126 /// Flattened HostCommand history (append-only across ticks).
127 pub commands: Vec<HostCommand>,
128 /// Per-tick command counts (for slicing `commands`).
129 pub commands_per_tick: Vec<usize>,
130}
131
132impl ScriptedHost {
133 /// Create an empty scripted host with no frames or recorded commands.
134 pub fn new() -> Self {
135 Self::default()
136 }
137
138 /// Push one frame of dense sense writes (order preserved).
139 pub fn push_frame(&mut self, senses: impl IntoIterator<Item = (SenseId, Signal)>) {
140 self.frames.push(senses.into_iter().collect());
141 }
142
143 /// Commands applied on the last completed tick (after `tick_once`).
144 pub fn last_commands(&self) -> &[HostCommand] {
145 let Some(&n) = self.commands_per_tick.last() else {
146 return &[];
147 };
148 let start = self.commands.len().saturating_sub(n);
149 &self.commands[start..]
150 }
151}
152
153impl Host for ScriptedHost {
154 fn time(&self) -> HostTime {
155 HostTime { tick: self.tick }
156 }
157
158 fn sample_into(&mut self, ports: &mut PortWriter<'_>) -> Result<(), HandleError> {
159 let i = self.tick as usize;
160 let Some(frame) = self.frames.get(i) else {
161 return Ok(());
162 };
163 for &(id, v) in frame {
164 ports.set_sense(id, v)?;
165 }
166 Ok(())
167 }
168
169 fn apply(&mut self, outbox: Outbox<'_>) {
170 let before = self.commands.len();
171 append_commands(outbox, &mut self.commands);
172 self.commands_per_tick
173 .push(self.commands.len().saturating_sub(before));
174 self.tick = self.tick.wrapping_add(1);
175 }
176}