Skip to main content

dsfb_robotics/
engine.rs

1//! `DsfbRoboticsEngine` — the streaming orchestrator that composes
2//! [`crate::sign::SignWindow`], [`crate::grammar::GrammarEvaluator`], and [`crate::envelope::AdmissibilityEnvelope`]
3//! into a `no_std` + `no_alloc` observer suitable for bare-metal MCU
4//! deployment alongside a safety-rated controller.
5//!
6//! ## Type parameters
7//!
8//! - `W` — drift-window length (samples over which ṙ is estimated).
9//! - `K` — persistence threshold for the recurrent-grazing reason
10//!   code (also the length of the internal grazing history buffer).
11//!
12//! ## Canonical observe loop
13//!
14//! ```
15//! use dsfb_robotics::engine::DsfbRoboticsEngine;
16//! use dsfb_robotics::platform::RobotContext;
17//! use dsfb_robotics::Episode;
18//!
19//! let mut eng = DsfbRoboticsEngine::<8, 4>::new(0.1);
20//! let mut out = [Episode::empty(); 32];
21//!
22//! let residual_norm: f64 = 0.045; // ‖r(k)‖ from your upstream observer
23//! let ep = eng.observe_one(residual_norm, false, RobotContext::ArmOperating, 0);
24//! // advisory output: ep.grammar, ep.decision
25//! let _ = (ep.grammar, ep.decision);
26//! let _ = &out;
27//! ```
28
29use crate::envelope::AdmissibilityEnvelope;
30use crate::episode::Episode;
31use crate::grammar::GrammarEvaluator;
32use crate::platform::RobotContext;
33use crate::policy::PolicyDecision;
34use crate::sign::SignWindow;
35
36/// Streaming DSFB engine.
37///
38/// All state is stack-allocated. No heap, no `unsafe`, no `std`.
39pub struct DsfbRoboticsEngine<const W: usize, const K: usize> {
40    envelope: AdmissibilityEnvelope,
41    sign_window: SignWindow<W>,
42    grammar: GrammarEvaluator<K>,
43}
44
45impl<const W: usize, const K: usize> DsfbRoboticsEngine<W, K> {
46    /// Create an engine from an envelope radius, using the paper
47    /// defaults for the boundary fraction and slew threshold.
48    #[must_use]
49    pub const fn new(rho: f64) -> Self {
50        Self {
51            envelope: AdmissibilityEnvelope::new(rho),
52            sign_window: SignWindow::<W>::new(),
53            grammar: GrammarEvaluator::<K>::new(),
54        }
55    }
56
57    /// Create an engine from an explicit envelope.
58    #[must_use]
59    pub const fn from_envelope(envelope: AdmissibilityEnvelope) -> Self {
60        Self {
61            envelope,
62            sign_window: SignWindow::<W>::new(),
63            grammar: GrammarEvaluator::<K>::new(),
64        }
65    }
66
67    /// Replace the envelope (e.g. after online recalibration on a
68    /// longer healthy window).
69    pub fn set_envelope(&mut self, envelope: AdmissibilityEnvelope) {
70        self.envelope = envelope;
71    }
72
73    /// Inspect the current envelope.
74    #[inline]
75    #[must_use]
76    pub fn envelope(&self) -> AdmissibilityEnvelope {
77        self.envelope
78    }
79
80    /// Observe a single residual norm and return the emitted episode.
81    ///
82    /// - `norm` — `‖r(k)‖` from the upstream observer.
83    /// - `below_floor` — `true` if the sample is below the known
84    ///   noise floor (forces drift and slew to zero for this sample).
85    /// - `context` — current robot operating regime.
86    /// - `index` — sample index within the caller's stream, passed
87    ///   through to [`Episode::index`] for traceability.
88    pub fn observe_one(
89        &mut self,
90        norm: f64,
91        below_floor: bool,
92        context: RobotContext,
93        index: usize,
94    ) -> Episode {
95        let sign = self.sign_window.push(norm, below_floor);
96        let state = self.grammar.evaluate(&sign, &self.envelope, context);
97        let decision = PolicyDecision::from_grammar(state);
98        Episode::new(index, norm * norm, sign.drift, state, decision)
99    }
100
101    /// Stream `residuals` into a caller-owned output buffer `out`,
102    /// emitting one episode per input sample.
103    ///
104    /// Returns the number of episodes written. Never writes past
105    /// `out.len()`: if `residuals.len() > out.len()` the function
106    /// stops at capacity and returns `out.len()` (fail-closed,
107    /// advisory-only semantics). Passing `context` applies uniformly
108    /// to every sample in the call — callers that need to change
109    /// context mid-stream should invoke [`Self::observe_one`] in a
110    /// loop.
111    pub fn observe(
112        &mut self,
113        residuals: &[f64],
114        out: &mut [Episode],
115        context: RobotContext,
116    ) -> usize {
117        debug_assert!(residuals.len() <= usize::MAX / 2, "residuals slice unreasonable");
118        debug_assert!(out.len() <= usize::MAX / 2, "output buffer unreasonable");
119
120        let mut written = 0_usize;
121        let n = residuals.len().min(out.len());
122        let mut i = 0_usize;
123        while i < n {
124            let r = residuals[i];
125            // Treat non-finite residuals as below-floor (missingness-aware),
126            // matching the semiconductor-crate behaviour.
127            let below_floor = !r.is_finite();
128            let norm = if r.is_finite() { crate::math::abs_f64(r) } else { 0.0 };
129            out[written] = self.observe_one(norm, below_floor, context, i);
130            written += 1;
131            i += 1;
132        }
133        written
134    }
135
136    /// Reset the streaming state (sign window + grammar hysteresis)
137    /// without touching the envelope.
138    ///
139    /// Use after a commissioning-to-operating transition to avoid
140    /// pre-commissioning noise bleeding into the operating state.
141    pub fn reset(&mut self) {
142        self.sign_window.reset();
143        self.grammar.reset();
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn streaming_stays_admissible_for_quiet_input() {
153        let mut eng = DsfbRoboticsEngine::<8, 4>::new(0.1);
154        let residuals = [0.01_f64; 32];
155        let mut out = [Episode::empty(); 32];
156        let n = eng.observe(&residuals, &mut out, RobotContext::ArmOperating);
157        assert_eq!(n, 32);
158        for e in &out[..n] {
159            assert_eq!(e.grammar, "Admissible");
160            assert_eq!(e.decision, "Silent");
161        }
162    }
163
164    #[test]
165    fn persistent_violation_produces_escalate() {
166        let mut eng = DsfbRoboticsEngine::<8, 4>::new(0.1);
167        let residuals = [0.5_f64; 32];
168        let mut out = [Episode::empty(); 32];
169        let n = eng.observe(&residuals, &mut out, RobotContext::ArmOperating);
170        assert_eq!(n, 32);
171        // Hysteresis: first sample is pending, from sample 2 onward it must be committed.
172        let escalated = out[..n].iter().filter(|e| e.decision == "Escalate").count();
173        assert!(escalated >= 30, "expected ≥30 Escalate episodes, got {}", escalated);
174    }
175
176    #[test]
177    fn commissioning_suppresses_everything() {
178        let mut eng = DsfbRoboticsEngine::<8, 4>::new(0.1);
179        let residuals = [1_000.0_f64; 32];
180        let mut out = [Episode::empty(); 32];
181        let n = eng.observe(&residuals, &mut out, RobotContext::ArmCommissioning);
182        assert_eq!(n, 32);
183        for e in &out[..n] {
184            assert_eq!(e.grammar, "Admissible");
185            assert_eq!(e.decision, "Silent");
186        }
187    }
188
189    #[test]
190    fn observe_respects_output_capacity() {
191        let mut eng = DsfbRoboticsEngine::<8, 4>::new(0.1);
192        let residuals = [0.02_f64; 32];
193        let mut small_out = [Episode::empty(); 4];
194        let n = eng.observe(&residuals, &mut small_out, RobotContext::ArmOperating);
195        assert_eq!(n, 4, "must never write past output capacity");
196    }
197
198    #[test]
199    fn observe_one_preserves_sample_index() {
200        let mut eng = DsfbRoboticsEngine::<4, 3>::new(0.1);
201        for i in 0..10 {
202            let e = eng.observe_one(0.02, false, RobotContext::ArmOperating, i);
203            assert_eq!(e.index, i);
204        }
205    }
206
207    #[test]
208    fn nonfinite_residual_treated_as_below_floor() {
209        let mut eng = DsfbRoboticsEngine::<4, 3>::new(0.1);
210        let residuals = [0.02_f64, f64::NAN, 0.02, f64::INFINITY, 0.02];
211        let mut out = [Episode::empty(); 5];
212        let n = eng.observe(&residuals, &mut out, RobotContext::ArmOperating);
213        assert_eq!(n, 5);
214        for e in &out[..n] {
215            assert_eq!(e.grammar, "Admissible");
216            assert_eq!(e.decision, "Silent");
217        }
218    }
219
220    #[test]
221    fn reset_clears_streaming_state_but_keeps_envelope() {
222        let mut eng = DsfbRoboticsEngine::<4, 3>::new(0.1);
223        let before = eng.envelope().rho;
224        for _ in 0..10 {
225            eng.observe_one(0.5, false, RobotContext::ArmOperating, 0);
226        }
227        eng.reset();
228        let after = eng.envelope().rho;
229        assert_eq!(before, after);
230        // Post-reset, the first observation re-enters pending state.
231        let e = eng.observe_one(0.01, false, RobotContext::ArmOperating, 0);
232        assert_eq!(e.grammar, "Admissible");
233    }
234}