Skip to main content

wyrd/runtime_impl/
runtime_state.rs

1//! Opaque, durable continuation checkpoints for [`Runtime`](super::Runtime).
2//!
3//! Capture a snapshot after loom settles and the host has applied the outbox,
4//! before the next [`Runtime::begin_frame`]. Checkpoints store mutable knot
5//! state and host-fed senses only — outbox effects are never replayed by
6//! [`Runtime::restore`].
7
8use crate::foundation::Signal;
9use crate::foundation::{KnotId, NumericPath, PortSlot, Seed};
10use crate::runtime_impl::bind::{ResolvedKnot, Runtime};
11use crate::runtime_impl::error::{BindRestoreError, RestoreError};
12use std::boxed::Box;
13use std::collections::BTreeSet;
14use std::string::String;
15use std::vec::Vec;
16
17/// RON checkpoint codec error.
18#[cfg(feature = "serde-ron")]
19#[derive(Debug)]
20pub enum RuntimeStateRonCodecError {
21    /// RON parsing failed.
22    Parse(ron::error::SpannedError),
23    /// RON serialization failed.
24    Serialize(ron::Error),
25}
26
27#[cfg(feature = "serde-ron")]
28impl core::fmt::Display for RuntimeStateRonCodecError {
29    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
30        match self {
31            Self::Parse(error) => write!(f, "runtime checkpoint RON parse error: {error}"),
32            Self::Serialize(error) => {
33                write!(f, "runtime checkpoint RON serialization error: {error}")
34            }
35        }
36    }
37}
38#[cfg(feature = "serde-ron")]
39impl std::error::Error for RuntimeStateRonCodecError {}
40
41/// JSON checkpoint codec error.
42#[cfg(feature = "serde-json")]
43#[derive(Debug)]
44pub enum RuntimeStateJsonCodecError {
45    /// JSON parsing failed.
46    Parse(serde_json::Error),
47    /// JSON serialization failed.
48    Serialize(serde_json::Error),
49}
50#[cfg(feature = "serde-json")]
51impl core::fmt::Display for RuntimeStateJsonCodecError {
52    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
53        match self {
54            Self::Parse(error) => write!(f, "runtime checkpoint JSON parse error: {error}"),
55            Self::Serialize(error) => {
56                write!(f, "runtime checkpoint JSON serialization error: {error}")
57            }
58        }
59    }
60}
61#[cfg(feature = "serde-json")]
62impl std::error::Error for RuntimeStateJsonCodecError {}
63
64/// Serialize a checkpoint as pretty RON.
65#[cfg(feature = "serde-ron")]
66pub fn runtime_state_to_ron(state: &RuntimeState) -> Result<String, RuntimeStateRonCodecError> {
67    ron::ser::to_string_pretty(state, ron::ser::PrettyConfig::default())
68        .map_err(RuntimeStateRonCodecError::Serialize)
69}
70/// Deserialize a checkpoint from RON. Bind it with [`Runtime::bind_restored`] to validate topology and state.
71#[cfg(feature = "serde-ron")]
72pub fn runtime_state_from_ron(text: &str) -> Result<RuntimeState, RuntimeStateRonCodecError> {
73    ron::from_str(text).map_err(RuntimeStateRonCodecError::Parse)
74}
75/// Serialize a checkpoint as JSON.
76#[cfg(feature = "serde-json")]
77pub fn runtime_state_to_json(state: &RuntimeState) -> Result<String, RuntimeStateJsonCodecError> {
78    serde_json::to_string_pretty(state).map_err(RuntimeStateJsonCodecError::Serialize)
79}
80/// Deserialize a checkpoint from JSON. Bind it with [`Runtime::bind_restored`] to validate topology and state.
81#[cfg(feature = "serde-json")]
82pub fn runtime_state_from_json(text: &str) -> Result<RuntimeState, RuntimeStateJsonCodecError> {
83    serde_json::from_str(text).map_err(RuntimeStateJsonCodecError::Parse)
84}
85
86/// Current in-memory snapshot format version.
87pub const RUNTIME_STATE_FORMAT_VERSION: u32 = 2;
88
89/// Opaque cloneable continuation state produced by [`Runtime::snapshot`].
90///
91/// The Rust layout is private, but its Serde representation is a versioned
92/// save contract. It contains no outbox effects: host-applied signals and
93/// commands are never replayed by a restore.
94#[derive(Clone, Debug)]
95pub struct RuntimeState {
96    version: u32,
97    numeric_path: NumericPath,
98    fingerprint: u64,
99    data: RuntimeStateData,
100}
101
102#[derive(Clone, Debug)]
103struct RuntimeStateData {
104    sense_values: Vec<Signal>,
105    port_vals: Vec<Signal>,
106    prev_in: Vec<Signal>,
107    prev_dec: Vec<Signal>,
108    counter: Vec<i32>,
109    flag: Vec<bool>,
110    timer_left: Vec<u16>,
111    on_start_done: Vec<bool>,
112    delay_buf: Vec<Signal>,
113    delay_head: Vec<u16>,
114    tick: u64,
115    phase: u8,
116    rng: u64,
117}
118
119impl RuntimeState {
120    /// Snapshot format version carried by this state.
121    pub const fn format_version(&self) -> u32 {
122        self.version
123    }
124
125    /// Deterministic fingerprint of the immutable runtime this state requires.
126    pub const fn fingerprint(&self) -> u64 {
127        self.fingerprint
128    }
129
130    /// Numeric representation selected when this checkpoint was captured.
131    pub const fn numeric_path(&self) -> NumericPath {
132        self.numeric_path
133    }
134}
135
136/// Authorable initial values addressed by stable knot names.
137#[derive(Clone, Debug, Default)]
138#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
139pub struct RuntimePreset {
140    entries: Vec<RuntimePresetEntry>,
141}
142
143/// One named semantic initial-state value in a [`RuntimePreset`].
144#[derive(Clone, Debug)]
145#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
146pub enum RuntimePresetEntry {
147    /// Set a Flag latch before the first frame.
148    Flag {
149        /// Authored knot name.
150        knot: String,
151        /// Initial latch state.
152        value: bool,
153    },
154    /// Set a Counter before the first frame.
155    Counter {
156        /// Authored knot name.
157        knot: String,
158        /// Initial whole-number count.
159        value: i32,
160    },
161    /// Set a held SignalIn value before the first frame.
162    Sense {
163        /// Authored SignalIn knot name.
164        knot: String,
165        /// Domain-checked held value.
166        value: Signal,
167    },
168}
169
170impl RuntimePreset {
171    /// Create an empty semantic preset.
172    pub const fn new() -> Self {
173        Self {
174            entries: Vec::new(),
175        }
176    }
177    /// Add one semantic entry. Duplicate names are rejected by [`Runtime::bind_with_preset`].
178    pub fn push(&mut self, entry: RuntimePresetEntry) {
179        self.entries.push(entry);
180    }
181    /// Read the authored entries without exposing mutable runtime storage.
182    pub fn entries(&self) -> &[RuntimePresetEntry] {
183        &self.entries
184    }
185}
186
187/// Owned, authored-name state entry for checkpoint auditing.
188#[derive(Clone, Debug, PartialEq)]
189pub enum RuntimeStateEntry {
190    /// A held SignalIn value.
191    Sense {
192        /// Authored SignalIn knot name.
193        knot: String,
194        /// Domain-checked held value.
195        value: Signal,
196    },
197    /// A Flag latch.
198    Flag {
199        /// Authored Flag knot name.
200        knot: String,
201        /// Current latch state.
202        value: bool,
203    },
204    /// A Counter or Random cached sample backing state.
205    Counter {
206        /// Authored Counter or Random knot name.
207        knot: String,
208        /// Whole-number backing value.
209        value: i32,
210    },
211    /// Timer remaining loom ticks.
212    Timer {
213        /// Authored Timer knot name.
214        knot: String,
215        /// Remaining loom ticks before expiry.
216        remaining: u16,
217    },
218    /// Delay-ring metadata and contents in logical output order.
219    Delay {
220        /// Authored Delay knot name.
221        knot: String,
222        /// Immutable ring length fixed at bind.
223        len: u16,
224        /// Current ring head index.
225        head: u16,
226        /// Ring contents in logical output order.
227        values: Vec<Signal>,
228    },
229    /// Edge detector history.
230    Edge {
231        /// Authored edge-detector knot name.
232        knot: String,
233        /// Previous input sample used for rising/falling detection.
234        previous: Signal,
235    },
236    /// OnStart completion state.
237    OnStart {
238        /// Authored OnStart knot name.
239        knot: String,
240        /// Whether the one-shot pulse has already fired.
241        completed: bool,
242    },
243    /// Random cached sample; the stream position remains opaque.
244    Random {
245        /// Authored Random knot name.
246        knot: String,
247        /// Last emitted random sample.
248        last_sample: Signal,
249        /// Whether the xorshift stream position is intentionally omitted.
250        stream_is_opaque: bool,
251    },
252}
253
254/// Read-only semantic checkpoint report.
255#[derive(Clone, Debug, PartialEq)]
256pub struct RuntimeStateReport {
257    /// Checkpoint format version.
258    pub version: u32,
259    /// Numeric signal path.
260    pub numeric_path: NumericPath,
261    /// Immutable runtime fingerprint.
262    pub fingerprint: u64,
263    /// Last frame tick.
264    pub tick: u64,
265    /// Named state entries in lexical knot-name order.
266    pub entries: Vec<RuntimeStateEntry>,
267}
268
269#[cfg(feature = "serde")]
270#[derive(serde::Serialize, serde::Deserialize)]
271#[serde(deny_unknown_fields)]
272struct RuntimeStateWire {
273    version: u32,
274    numeric_path: NumericPath,
275    fingerprint: u64,
276    sense_values: Vec<u32>,
277    port_vals: Vec<u32>,
278    prev_in: Vec<u32>,
279    prev_dec: Vec<u32>,
280    counter: Vec<i32>,
281    flag: Vec<bool>,
282    timer_left: Vec<u16>,
283    on_start_done: Vec<bool>,
284    delay_buf: Vec<u32>,
285    delay_head: Vec<u16>,
286    tick: u64,
287    phase: u8,
288    rng: u64,
289}
290
291#[cfg(feature = "serde")]
292fn signal_to_wire(value: Signal) -> u32 {
293    #[cfg(feature = "signal-f32")]
294    {
295        value.to_bits()
296    }
297    #[cfg(feature = "signal-i32")]
298    {
299        value as u32
300    }
301}
302
303#[cfg(feature = "serde")]
304fn signal_from_wire(value: u32) -> Signal {
305    #[cfg(feature = "signal-f32")]
306    {
307        f32::from_bits(value)
308    }
309    #[cfg(feature = "signal-i32")]
310    {
311        value as i32
312    }
313}
314
315#[cfg(feature = "serde")]
316impl serde::Serialize for RuntimeState {
317    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
318    where
319        S: serde::Serializer,
320    {
321        RuntimeStateWire {
322            version: self.version,
323            numeric_path: self.numeric_path,
324            fingerprint: self.fingerprint,
325            sense_values: self
326                .data
327                .sense_values
328                .iter()
329                .copied()
330                .map(signal_to_wire)
331                .collect(),
332            port_vals: self
333                .data
334                .port_vals
335                .iter()
336                .copied()
337                .map(signal_to_wire)
338                .collect(),
339            prev_in: self
340                .data
341                .prev_in
342                .iter()
343                .copied()
344                .map(signal_to_wire)
345                .collect(),
346            prev_dec: self
347                .data
348                .prev_dec
349                .iter()
350                .copied()
351                .map(signal_to_wire)
352                .collect(),
353            counter: self.data.counter.clone(),
354            flag: self.data.flag.clone(),
355            timer_left: self.data.timer_left.clone(),
356            on_start_done: self.data.on_start_done.clone(),
357            delay_buf: self
358                .data
359                .delay_buf
360                .iter()
361                .copied()
362                .map(signal_to_wire)
363                .collect(),
364            delay_head: self.data.delay_head.clone(),
365            tick: self.data.tick,
366            phase: self.data.phase,
367            rng: self.data.rng,
368        }
369        .serialize(serializer)
370    }
371}
372
373#[cfg(feature = "serde")]
374impl<'de> serde::Deserialize<'de> for RuntimeState {
375    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
376    where
377        D: serde::Deserializer<'de>,
378    {
379        let wire = RuntimeStateWire::deserialize(deserializer)?;
380        Ok(Self {
381            version: wire.version,
382            numeric_path: wire.numeric_path,
383            fingerprint: wire.fingerprint,
384            data: RuntimeStateData {
385                sense_values: wire
386                    .sense_values
387                    .into_iter()
388                    .map(signal_from_wire)
389                    .collect(),
390                port_vals: wire.port_vals.into_iter().map(signal_from_wire).collect(),
391                prev_in: wire.prev_in.into_iter().map(signal_from_wire).collect(),
392                prev_dec: wire.prev_dec.into_iter().map(signal_from_wire).collect(),
393                counter: wire.counter,
394                flag: wire.flag,
395                timer_left: wire.timer_left,
396                on_start_done: wire.on_start_done,
397                delay_buf: wire.delay_buf.into_iter().map(signal_from_wire).collect(),
398                delay_head: wire.delay_head,
399                tick: wire.tick,
400                phase: wire.phase,
401                rng: wire.rng,
402            },
403        })
404    }
405}
406
407impl Runtime {
408    /// Bind a runtime then apply every named preset entry atomically.
409    pub fn bind_with_preset(
410        weave: crate::authoring::Weave,
411        opts: crate::runtime_impl::bind::BindOpts,
412        preset: &RuntimePreset,
413    ) -> Result<Self, crate::runtime_impl::error::PresetError> {
414        use crate::runtime_impl::error::PresetError;
415        let mut runtime =
416            Self::bind(weave, opts).map_err(|error| PresetError::Bind(Box::new(error)))?;
417        let mut names = BTreeSet::new();
418        for entry in &preset.entries {
419            let name = match entry {
420                RuntimePresetEntry::Flag { knot, .. }
421                | RuntimePresetEntry::Counter { knot, .. }
422                | RuntimePresetEntry::Sense { knot, .. } => knot,
423            };
424            if !names.insert(name) {
425                return Err(PresetError::Duplicate { knot: name.clone() });
426            }
427            let Some(id) = runtime.name_to_id.get(name).copied() else {
428                return Err(PresetError::Missing { knot: name.clone() });
429            };
430            match entry {
431                RuntimePresetEntry::Flag { .. }
432                    if matches!(
433                        runtime.knots[usize::from(id)].kind,
434                        crate::foundation::KnotKind::Flag { .. }
435                    ) => {}
436                RuntimePresetEntry::Counter { .. }
437                    if matches!(
438                        runtime.knots[usize::from(id)].kind,
439                        crate::foundation::KnotKind::Counter
440                    ) => {}
441                RuntimePresetEntry::Sense { value, .. } => {
442                    match runtime.knots[usize::from(id)].kind {
443                        crate::foundation::KnotKind::SignalIn { domain }
444                            if crate::runtime_impl::outbox::domain_value_is_valid(
445                                domain, *value,
446                            ) => {}
447                        crate::foundation::KnotKind::SignalIn { domain } => {
448                            return Err(PresetError::InvalidSignal {
449                                knot: name.clone(),
450                                domain,
451                            })
452                        }
453                        _ => {
454                            return Err(PresetError::WrongKind {
455                                knot: name.clone(),
456                                expected: "SignalIn",
457                            })
458                        }
459                    }
460                }
461                RuntimePresetEntry::Flag { .. } => {
462                    return Err(PresetError::WrongKind {
463                        knot: name.clone(),
464                        expected: "Flag",
465                    })
466                }
467                RuntimePresetEntry::Counter { .. } => {
468                    return Err(PresetError::WrongKind {
469                        knot: name.clone(),
470                        expected: "Counter",
471                    })
472                }
473            }
474        }
475        for entry in &preset.entries {
476            let name = match entry {
477                RuntimePresetEntry::Flag { knot, .. }
478                | RuntimePresetEntry::Counter { knot, .. }
479                | RuntimePresetEntry::Sense { knot, .. } => knot,
480            };
481            let id = runtime.name_to_id[name];
482            let index = usize::from(id);
483            match entry {
484                RuntimePresetEntry::Flag { value, .. } => runtime.flag[index] = *value,
485                RuntimePresetEntry::Counter { value, .. } => runtime.counter[index] = *value,
486                RuntimePresetEntry::Sense { value, .. } => runtime.sense_values[index] = *value,
487            }
488        }
489        Ok(runtime)
490    }
491
492    /// Inspect the current runtime through stable authored names.
493    pub fn inspect_state(&self) -> RuntimeStateReport {
494        self.inspect_data(
495            Self::state_format_version(),
496            NumericPath::compiled(),
497            self.runtime_fingerprint(),
498            &RuntimeStateData {
499                sense_values: self.sense_values.clone(),
500                port_vals: self.port_vals.clone(),
501                prev_in: self.prev_in.clone(),
502                prev_dec: self.prev_dec.clone(),
503                counter: self.counter.clone(),
504                flag: self.flag.clone(),
505                timer_left: self.timer_left.clone(),
506                on_start_done: self.on_start_done.clone(),
507                delay_buf: self.delay_buf.clone(),
508                delay_head: self.delay_head.clone(),
509                tick: self.tick,
510                phase: self.phase,
511                rng: self.rng,
512            },
513        )
514    }
515
516    /// Validate and inspect a checkpoint without mutating this runtime.
517    pub fn inspect_checkpoint(
518        &self,
519        state: &RuntimeState,
520    ) -> Result<RuntimeStateReport, RestoreError> {
521        self.validate_checkpoint(state)?;
522        Ok(self.inspect_data(
523            state.version,
524            state.numeric_path,
525            state.fingerprint,
526            &state.data,
527        ))
528    }
529    /// Bind a completely fresh runtime and atomically restore a checkpoint.
530    ///
531    /// This is the recommended disk-load path. The returned runtime has an
532    /// empty outbox: effects from the checkpoint frame were already applied by
533    /// the host before it was saved.
534    pub fn bind_restored(
535        weave: crate::authoring::Weave,
536        opts: crate::runtime_impl::bind::BindOpts,
537        state: &RuntimeState,
538    ) -> Result<Self, BindRestoreError> {
539        let mut runtime =
540            Self::bind(weave, opts).map_err(|error| BindRestoreError::Bind(Box::new(error)))?;
541        runtime.restore(state).map_err(BindRestoreError::Restore)?;
542        Ok(runtime)
543    }
544
545    /// Snapshot every mutable value needed for deterministic continuation.
546    ///
547    /// Capture only after `loom` and host apply, before the next
548    /// [`Runtime::begin_frame`]. Outbox effects are deliberately excluded;
549    /// restore resumes stateful memory with an empty outbox.
550    ///
551    /// # Examples
552    ///
553    /// ```
554    /// use wyrd::{weave, BindOpts, HostTime, KnotKind, Runtime, SignalDomain, ONE, ZERO};
555    ///
556    /// let weave = weave! {
557    ///     id: "snapshot.docs";
558    ///     knots {
559    ///         input = KnotKind::signal_in(SignalDomain::Bool);
560    ///         output = KnotKind::signal_out("snapshot.output", SignalDomain::Bool);
561    ///     }
562    ///     threads { input.out -> output.in; }
563    /// }?;
564    /// let mut runtime = Runtime::bind(weave.clone(), BindOpts::default())?;
565    /// let input = runtime.required_sense("input")?;
566    ///
567    /// runtime.begin_frame(HostTime { tick: 1 });
568    /// runtime.port_writer().set_sense(input, ONE)?;
569    /// runtime.loom();
570    /// let state = runtime.snapshot();
571    ///
572    /// runtime.begin_frame(HostTime { tick: 2 });
573    /// runtime.port_writer().set_sense(input, ZERO)?;
574    /// runtime.loom();
575    /// assert_eq!(runtime.outbox().signals()[0].value, ZERO);
576    ///
577    /// let restored = Runtime::bind_restored(weave, BindOpts::default(), &state)?;
578    /// assert!(restored.outbox().signals().is_empty());
579    /// # Ok::<(), Box<dyn std::error::Error>>(())
580    /// ```
581    pub fn snapshot(&self) -> RuntimeState {
582        let mut state = RuntimeState {
583            version: RUNTIME_STATE_FORMAT_VERSION,
584            numeric_path: NumericPath::compiled(),
585            fingerprint: self.runtime_fingerprint(),
586            data: RuntimeStateData {
587                sense_values: Vec::new(),
588                port_vals: Vec::new(),
589                prev_in: Vec::new(),
590                prev_dec: Vec::new(),
591                counter: Vec::new(),
592                flag: Vec::new(),
593                timer_left: Vec::new(),
594                on_start_done: Vec::new(),
595                delay_buf: Vec::new(),
596                delay_head: Vec::new(),
597                tick: 0,
598                phase: 0,
599                rng: 0,
600            },
601        };
602        self.snapshot_into(&mut state);
603        state
604    }
605
606    /// Refresh an existing snapshot while reusing its owned buffer capacity.
607    ///
608    /// Every field is overwritten, so `state` may come from an incompatible
609    /// runtime. Reuse is best-effort: buffers allocate when their retained
610    /// capacity is too small and may retain a previous high-water capacity.
611    pub fn snapshot_into(&self, state: &mut RuntimeState) {
612        state.version = RUNTIME_STATE_FORMAT_VERSION;
613        state.numeric_path = NumericPath::compiled();
614        state.fingerprint = self.runtime_fingerprint();
615        state.data.sense_values.clone_from(&self.sense_values);
616        state.data.port_vals.clone_from(&self.port_vals);
617        state.data.prev_in.clone_from(&self.prev_in);
618        state.data.prev_dec.clone_from(&self.prev_dec);
619        state.data.counter.clone_from(&self.counter);
620        state.data.flag.clone_from(&self.flag);
621        state.data.timer_left.clone_from(&self.timer_left);
622        state.data.on_start_done.clone_from(&self.on_start_done);
623        state.data.delay_buf.clone_from(&self.delay_buf);
624        state.data.delay_head.clone_from(&self.delay_head);
625        state.data.tick = self.tick;
626        state.data.phase = self.phase;
627        state.data.rng = self.rng;
628    }
629
630    /// Restore a compatible snapshot without changing runtime-local handles.
631    ///
632    /// Every compatibility and shape check runs before any mutable runtime
633    /// field is assigned, so a rejected restore leaves the runtime unchanged.
634    ///
635    /// # Errors
636    ///
637    /// Returns [`RestoreError`] when the format version, executable
638    /// fingerprint, buffer shape, or saved outbox handles are incompatible.
639    pub fn restore(&mut self, state: &RuntimeState) -> Result<(), RestoreError> {
640        self.validate_checkpoint(state)?;
641        self.sense_values.clone_from(&state.data.sense_values);
642        self.port_vals.clone_from(&state.data.port_vals);
643        self.prev_in.clone_from(&state.data.prev_in);
644        self.prev_dec.clone_from(&state.data.prev_dec);
645        self.counter.clone_from(&state.data.counter);
646        self.flag.clone_from(&state.data.flag);
647        self.timer_left.clone_from(&state.data.timer_left);
648        self.on_start_done.clone_from(&state.data.on_start_done);
649        self.delay_buf.clone_from(&state.data.delay_buf);
650        self.delay_head.clone_from(&state.data.delay_head);
651        // Effects are deliberately ephemeral. A disk load starts with an
652        // empty outbox so a host never applies a prior frame twice.
653        self.out_signals.clear();
654        self.out_emits.clear();
655        self.dropped_emits = 0;
656        self.tick = state.data.tick;
657        self.phase = state.data.phase;
658        self.rng = state.data.rng;
659        Ok(())
660    }
661
662    fn validate_checkpoint(&self, state: &RuntimeState) -> Result<(), RestoreError> {
663        if state.version != RUNTIME_STATE_FORMAT_VERSION {
664            return Err(RestoreError::UnsupportedVersion {
665                found: state.version,
666                supported: RUNTIME_STATE_FORMAT_VERSION,
667            });
668        }
669        if state.numeric_path != NumericPath::compiled() {
670            return Err(RestoreError::NumericPathMismatch {
671                expected: NumericPath::compiled(),
672                found: state.numeric_path,
673            });
674        }
675        if state.data.phase != 2 {
676            return Err(RestoreError::InvalidPhase {
677                found: state.data.phase,
678            });
679        }
680        let expected = self.runtime_fingerprint();
681        if state.fingerprint != expected {
682            return Err(RestoreError::FingerprintMismatch {
683                expected,
684                found: state.fingerprint,
685            });
686        }
687
688        self.validate_snapshot_shapes(&state.data)?;
689
690        for (field, values) in [
691            ("port_vals", &state.data.port_vals),
692            ("prev_in", &state.data.prev_in),
693            ("prev_dec", &state.data.prev_dec),
694            ("delay_buf", &state.data.delay_buf),
695        ] {
696            for (knot, value) in values.iter().enumerate() {
697                #[cfg(feature = "signal-f32")]
698                if !value.is_finite() {
699                    return Err(RestoreError::InvalidSignal {
700                        field,
701                        knot,
702                        domain: crate::foundation::SignalDomain::Level,
703                    });
704                }
705                #[cfg(feature = "signal-i32")]
706                let _ = (field, knot, value);
707            }
708        }
709
710        Ok(())
711    }
712
713    /// Format version emitted by [`Self::snapshot`].
714    pub const fn state_format_version() -> u32 {
715        RUNTIME_STATE_FORMAT_VERSION
716    }
717
718    /// Deterministic immutable compatibility fingerprint for this runtime.
719    pub const fn runtime_fingerprint(&self) -> u64 {
720        self.state_fingerprint
721    }
722
723    fn validate_snapshot_shapes(&self, data: &RuntimeStateData) -> Result<(), RestoreError> {
724        check_len(
725            "sense_values",
726            self.sense_values.len(),
727            data.sense_values.len(),
728        )?;
729        check_len("port_vals", self.port_vals.len(), data.port_vals.len())?;
730        check_len("prev_in", self.prev_in.len(), data.prev_in.len())?;
731        check_len("prev_dec", self.prev_dec.len(), data.prev_dec.len())?;
732        check_len("counter", self.counter.len(), data.counter.len())?;
733        check_len("flag", self.flag.len(), data.flag.len())?;
734        check_len("timer_left", self.timer_left.len(), data.timer_left.len())?;
735        check_len(
736            "on_start_done",
737            self.on_start_done.len(),
738            data.on_start_done.len(),
739        )?;
740        check_len("delay_buf", self.delay_buf.len(), data.delay_buf.len())?;
741        check_len("delay_head", self.delay_head.len(), data.delay_head.len())?;
742        if data.rng == 0 {
743            return Err(RestoreError::InvalidRng);
744        }
745        for (knot, resolved) in self.knots.iter().enumerate() {
746            if let crate::foundation::KnotKind::Timer { ticks, .. } = resolved.kind {
747                let remaining = data.timer_left[knot];
748                if remaining > ticks {
749                    return Err(RestoreError::InvalidTimer {
750                        knot,
751                        remaining,
752                        max: ticks,
753                    });
754                }
755            }
756            if let crate::foundation::KnotKind::Delay { ticks } = resolved.kind {
757                let head = data.delay_head[knot];
758                if ticks == 0 {
759                    if head != 0 {
760                        return Err(RestoreError::InvalidDelayHead {
761                            knot,
762                            head,
763                            len: ticks,
764                        });
765                    }
766                } else if head >= ticks {
767                    return Err(RestoreError::InvalidDelayHead {
768                        knot,
769                        head,
770                        len: ticks,
771                    });
772                }
773            }
774            if let crate::foundation::KnotKind::SignalIn { domain } = resolved.kind {
775                if !crate::runtime_impl::outbox::domain_value_is_valid(
776                    domain,
777                    data.sense_values[knot],
778                ) {
779                    return Err(RestoreError::InvalidSignal {
780                        field: "sense_values",
781                        knot,
782                        domain,
783                    });
784                }
785            }
786            if let crate::foundation::KnotKind::Random { domain, .. } = resolved.kind {
787                #[cfg(feature = "signal-f32")]
788                if !f32::from_bits(data.counter[knot] as u32).is_finite() {
789                    return Err(RestoreError::InvalidSignal {
790                        field: "counter.random_sample",
791                        knot,
792                        domain,
793                    });
794                }
795                #[cfg(feature = "signal-i32")]
796                let _ = domain;
797            }
798        }
799        Ok(())
800    }
801
802    fn inspect_data(
803        &self,
804        version: u32,
805        numeric_path: NumericPath,
806        fingerprint: u64,
807        data: &RuntimeStateData,
808    ) -> RuntimeStateReport {
809        let mut entries = Vec::new();
810        for (name, id) in &self.name_to_id {
811            let i = usize::from(*id);
812            match self.knots[i].kind {
813                crate::foundation::KnotKind::SignalIn { .. } => {
814                    entries.push(RuntimeStateEntry::Sense {
815                        knot: name.clone(),
816                        value: data.sense_values[i],
817                    })
818                }
819                crate::foundation::KnotKind::Flag { .. } => entries.push(RuntimeStateEntry::Flag {
820                    knot: name.clone(),
821                    value: data.flag[i],
822                }),
823                crate::foundation::KnotKind::Counter => entries.push(RuntimeStateEntry::Counter {
824                    knot: name.clone(),
825                    value: data.counter[i],
826                }),
827                crate::foundation::KnotKind::Timer { .. } => {
828                    entries.push(RuntimeStateEntry::Timer {
829                        knot: name.clone(),
830                        remaining: data.timer_left[i],
831                    })
832                }
833                crate::foundation::KnotKind::Delay { ticks } => {
834                    let offset = usize::from(self.delay_off[i]);
835                    let len = usize::from(ticks);
836                    let head = usize::from(data.delay_head[i]);
837                    let values = if len == 0 {
838                        Vec::new()
839                    } else {
840                        (0..len)
841                            .map(|n| data.delay_buf[offset + ((head + n) % len)])
842                            .collect()
843                    };
844                    entries.push(RuntimeStateEntry::Delay {
845                        knot: name.clone(),
846                        len: ticks,
847                        head: data.delay_head[i],
848                        values,
849                    });
850                }
851                crate::foundation::KnotKind::OnStart => entries.push(RuntimeStateEntry::OnStart {
852                    knot: name.clone(),
853                    completed: data.on_start_done[i],
854                }),
855                crate::foundation::KnotKind::Random { .. } => {
856                    #[cfg(feature = "signal-f32")]
857                    let last_sample = f32::from_bits(data.counter[i] as u32);
858                    #[cfg(feature = "signal-i32")]
859                    let last_sample = data.counter[i];
860                    entries.push(RuntimeStateEntry::Random {
861                        knot: name.clone(),
862                        last_sample,
863                        stream_is_opaque: true,
864                    });
865                }
866                crate::foundation::KnotKind::RisingFromZero
867                | crate::foundation::KnotKind::FallingToZero
868                | crate::foundation::KnotKind::Change => entries.push(RuntimeStateEntry::Edge {
869                    knot: name.clone(),
870                    previous: data.prev_in[i],
871                }),
872                _ => {}
873            }
874        }
875        RuntimeStateReport {
876            version,
877            numeric_path,
878            fingerprint,
879            tick: data.tick,
880            entries,
881        }
882    }
883}
884
885pub(crate) fn runtime_fingerprint_for(
886    knots: &[ResolvedKnot],
887    threads: &[(KnotId, PortSlot, KnotId, PortSlot)],
888    path_names: &[String],
889    cmd_names: &[String],
890    max_emits_per_tick: u16,
891    seed_mix: u64,
892    _bind_seed: Option<Seed>,
893) -> u64 {
894    let mut hash = Fnv1a::new();
895    hash.bytes(b"wyrd-runtime-state-v1");
896    hash.usize(knots.len());
897    for knot in knots {
898        fingerprint_knot(&mut hash, &knot.kind);
899    }
900    hash.usize(threads.len());
901    for &(from, from_slot, to, to_slot) in threads {
902        hash.u16(from.get());
903        hash.u8(from_slot.get());
904        hash.u16(to.get());
905        hash.u8(to_slot.get());
906    }
907    hash.usize(path_names.len());
908    for path in path_names {
909        hash.bytes(path.as_bytes());
910        hash.u8(0xff);
911    }
912    hash.usize(cmd_names.len());
913    for command in cmd_names {
914        hash.bytes(command.as_bytes());
915        hash.u8(0xff);
916    }
917    #[cfg(feature = "signal-f32")]
918    hash.u8(1);
919    #[cfg(feature = "signal-i32")]
920    hash.u8(2);
921    hash.u16(max_emits_per_tick);
922    hash.u64(seed_mix);
923    hash.finish()
924}
925
926fn check_len(field: &'static str, expected: usize, found: usize) -> Result<(), RestoreError> {
927    if expected == found {
928        Ok(())
929    } else {
930        Err(RestoreError::ShapeMismatch {
931            field,
932            expected,
933            found,
934        })
935    }
936}
937
938struct Fnv1a(u64);
939
940impl Fnv1a {
941    const fn new() -> Self {
942        Self(0xcbf2_9ce4_8422_2325)
943    }
944
945    fn bytes(&mut self, bytes: &[u8]) {
946        for byte in bytes {
947            self.u8(*byte);
948        }
949    }
950
951    fn u8(&mut self, value: u8) {
952        self.0 ^= u64::from(value);
953        self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
954    }
955
956    fn u16(&mut self, value: u16) {
957        self.bytes(&value.to_le_bytes());
958    }
959
960    fn u64(&mut self, value: u64) {
961        self.bytes(&value.to_le_bytes());
962    }
963
964    fn usize(&mut self, value: usize) {
965        self.u64(value as u64);
966    }
967
968    const fn finish(&self) -> u64 {
969        self.0
970    }
971}
972
973fn fingerprint_knot(hash: &mut Fnv1a, kind: &crate::foundation::KnotKind) {
974    use crate::foundation::KnotKind;
975    match kind {
976        KnotKind::Constant { domain, value } => {
977            hash.u8(0);
978            domain_hash(hash, *domain);
979            signal_hash(hash, *value);
980        }
981        KnotKind::SignalIn { domain } => {
982            hash.u8(1);
983            domain_hash(hash, *domain);
984        }
985        KnotKind::OnStart => hash.u8(2),
986        KnotKind::Not => hash.u8(3),
987        KnotKind::And { arity } => {
988            hash.u8(4);
989            hash.u8(*arity);
990        }
991        KnotKind::Or { arity } => {
992            hash.u8(5);
993            hash.u8(*arity);
994        }
995        KnotKind::Compare {
996            domain,
997            op,
998            rhs_const,
999        } => {
1000            hash.u8(6);
1001            domain_hash(hash, *domain);
1002            hash.u8(compare_hash(*op));
1003            option_signal_hash(hash, *rhs_const);
1004        }
1005        KnotKind::RisingFromZero => hash.u8(7),
1006        KnotKind::Flag {
1007            priority,
1008            enable_toggle,
1009        } => {
1010            hash.u8(8);
1011            hash.u8(flag_priority_hash(*priority));
1012            hash.u8(u8::from(*enable_toggle));
1013        }
1014        KnotKind::Counter => hash.u8(9),
1015        KnotKind::Timer { mode, ticks } => {
1016            hash.u8(10);
1017            hash.u8(timer_mode_hash(*mode));
1018            hash.u16(*ticks);
1019        }
1020        KnotKind::Delay { ticks } => {
1021            hash.u8(11);
1022            hash.u16(*ticks);
1023        }
1024        KnotKind::Calc { domain, op } => {
1025            hash.u8(12);
1026            domain_hash(hash, *domain);
1027            hash.u8(calc_hash(*op));
1028        }
1029        KnotKind::Map {
1030            domain,
1031            in_min,
1032            in_max,
1033            out_min,
1034            out_max,
1035        } => {
1036            hash.u8(13);
1037            domain_hash(hash, *domain);
1038            signal_hash(hash, *in_min);
1039            signal_hash(hash, *in_max);
1040            signal_hash(hash, *out_min);
1041            signal_hash(hash, *out_max);
1042        }
1043        KnotKind::Abs { domain } => {
1044            hash.u8(14);
1045            domain_hash(hash, *domain);
1046        }
1047        KnotKind::Neg { domain } => {
1048            hash.u8(15);
1049            domain_hash(hash, *domain);
1050        }
1051        KnotKind::Select => hash.u8(16),
1052        KnotKind::Digitize {
1053            domain,
1054            steps,
1055            in_min,
1056            in_max,
1057            out_min,
1058            out_max,
1059        } => {
1060            hash.u8(17);
1061            domain_hash(hash, *domain);
1062            hash.u16(*steps);
1063            signal_hash(hash, *in_min);
1064            signal_hash(hash, *in_max);
1065            signal_hash(hash, *out_min);
1066            signal_hash(hash, *out_max);
1067        }
1068        KnotKind::Threshold {
1069            domain,
1070            high,
1071            low,
1072            use_hysteresis,
1073        } => {
1074            hash.u8(18);
1075            domain_hash(hash, *domain);
1076            signal_hash(hash, *high);
1077            signal_hash(hash, *low);
1078            hash.u8(u8::from(*use_hysteresis));
1079        }
1080        KnotKind::Random {
1081            domain,
1082            require_gate,
1083        } => {
1084            hash.u8(19);
1085            domain_hash(hash, *domain);
1086            hash.u8(u8::from(*require_gate));
1087        }
1088        KnotKind::Sqrt { domain } => {
1089            hash.u8(20);
1090            domain_hash(hash, *domain);
1091        }
1092        KnotKind::Xor => hash.u8(21),
1093        KnotKind::FallingToZero => hash.u8(22),
1094        KnotKind::Change => hash.u8(23),
1095        KnotKind::Clamp { domain, min, max } => {
1096            hash.u8(24);
1097            domain_hash(hash, *domain);
1098            signal_hash(hash, *min);
1099            signal_hash(hash, *max);
1100        }
1101        KnotKind::Convert { from, to } => {
1102            hash.u8(25);
1103            domain_hash(hash, *from);
1104            domain_hash(hash, *to);
1105        }
1106        KnotKind::SignalOut { path, domain } => {
1107            hash.u8(26);
1108            hash.bytes(path.as_bytes());
1109            hash.u8(0);
1110            domain_hash(hash, *domain);
1111        }
1112        KnotKind::EmitCommand { name } => {
1113            hash.u8(27);
1114            hash.bytes(name.as_bytes());
1115            hash.u8(0);
1116        }
1117    }
1118}
1119
1120fn domain_hash(hash: &mut Fnv1a, domain: crate::foundation::SignalDomain) {
1121    hash.u8(match domain {
1122        crate::foundation::SignalDomain::Bool => 0,
1123        crate::foundation::SignalDomain::Level => 1,
1124        crate::foundation::SignalDomain::Count => 2,
1125    });
1126}
1127fn compare_hash(op: crate::foundation::CompareOp) -> u8 {
1128    match op {
1129        crate::foundation::CompareOp::Eq => 0,
1130        crate::foundation::CompareOp::Ne => 1,
1131        crate::foundation::CompareOp::Lt => 2,
1132        crate::foundation::CompareOp::Lte => 3,
1133        crate::foundation::CompareOp::Gt => 4,
1134        crate::foundation::CompareOp::Gte => 5,
1135    }
1136}
1137fn flag_priority_hash(priority: crate::foundation::FlagPriority) -> u8 {
1138    match priority {
1139        crate::foundation::FlagPriority::ResetWins => 0,
1140        crate::foundation::FlagPriority::SetWins => 1,
1141    }
1142}
1143fn timer_mode_hash(mode: crate::foundation::TimerMode) -> u8 {
1144    match mode {
1145        crate::foundation::TimerMode::FedCountdown => 0,
1146        crate::foundation::TimerMode::PulseHold => 1,
1147    }
1148}
1149fn calc_hash(op: crate::foundation::CalcOp) -> u8 {
1150    match op {
1151        crate::foundation::CalcOp::Add => 0,
1152        crate::foundation::CalcOp::Sub => 1,
1153        crate::foundation::CalcOp::Mul => 2,
1154        crate::foundation::CalcOp::Div => 3,
1155    }
1156}
1157fn option_signal_hash(hash: &mut Fnv1a, value: Option<Signal>) {
1158    match value {
1159        Some(value) => {
1160            hash.u8(1);
1161            signal_hash(hash, value);
1162        }
1163        None => hash.u8(0),
1164    }
1165}
1166fn signal_hash(hash: &mut Fnv1a, value: Signal) {
1167    #[cfg(feature = "signal-f32")]
1168    hash.u64(u64::from(value.to_bits()));
1169    #[cfg(feature = "signal-i32")]
1170    hash.bytes(&value.to_le_bytes());
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175    use super::*;
1176    use crate::foundation::{HostTime, KnotKind, SignalDomain, ONE};
1177    use crate::{BindOpts, Weave};
1178
1179    fn outbox_runtime() -> Runtime {
1180        let mut builder = Weave::builder("snapshot-shapes").unwrap();
1181        let source = builder
1182            .knot("source", KnotKind::constant(ONE, SignalDomain::Bool))
1183            .unwrap();
1184        let output = builder
1185            .knot(
1186                "output",
1187                KnotKind::signal_out("snapshot.value", SignalDomain::Bool),
1188            )
1189            .unwrap();
1190        let emit = builder
1191            .knot("emit", KnotKind::emit_command("snapshot.command"))
1192            .unwrap();
1193        for (target, port) in [(&output, "in"), (&emit, "trigger")] {
1194            let from = builder.output(&source, "out").unwrap();
1195            let to = builder.input(target, port).unwrap();
1196            builder.connect(from, to).unwrap();
1197        }
1198        let mut runtime = Runtime::bind(
1199            builder.build().unwrap(),
1200            BindOpts {
1201                max_emits_per_tick: 1,
1202                ..BindOpts::default()
1203            },
1204        )
1205        .unwrap();
1206        runtime.begin_frame(HostTime { tick: 0 });
1207        runtime.loom();
1208        runtime
1209    }
1210
1211    #[test]
1212    fn restore_rejects_version_shapes_and_invalid_runtime_state() {
1213        let mut runtime = outbox_runtime();
1214        assert_eq!(
1215            Runtime::state_format_version(),
1216            RUNTIME_STATE_FORMAT_VERSION
1217        );
1218
1219        let mut state = runtime.snapshot();
1220        state.version = 0;
1221        assert!(matches!(
1222            runtime.restore(&state),
1223            Err(RestoreError::UnsupportedVersion { .. })
1224        ));
1225
1226        let mut state = runtime.snapshot();
1227        state.data.sense_values.pop();
1228        assert!(matches!(
1229            runtime.restore(&state),
1230            Err(RestoreError::ShapeMismatch {
1231                field: "sense_values",
1232                ..
1233            })
1234        ));
1235
1236        let mut state = runtime.snapshot();
1237        state.data.on_start_done.pop();
1238        assert!(matches!(
1239            runtime.restore(&state),
1240            Err(RestoreError::ShapeMismatch {
1241                field: "on_start_done",
1242                ..
1243            })
1244        ));
1245
1246        let mut state = runtime.snapshot();
1247        state.data.rng = 0;
1248        assert!(matches!(
1249            runtime.restore(&state),
1250            Err(RestoreError::InvalidRng)
1251        ));
1252    }
1253
1254    #[test]
1255    fn restore_clears_ephemeral_outbox_effects() {
1256        let source = outbox_runtime();
1257        let state = source.snapshot();
1258        let mut destination = outbox_runtime();
1259        destination.restore(&state).unwrap();
1260        assert!(destination.outbox().signals().is_empty());
1261        assert!(destination.outbox().emits().is_empty());
1262    }
1263}