Skip to main content

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}