Skip to main content

dsfb_robotics/
grammar.rs

1//! Grammar FSM: `Admissible | Boundary[ReasonCode] | Violation`.
2//!
3//! The grammar is the typed intermediate representation DSFB emits in
4//! place of a scalar alarm count. Operators see *why* a residual
5//! trajectory is drifting, not merely *that* a threshold was crossed.
6//!
7//! ## State assignment
8//!
9//! For each observation `k`:
10//!
11//! - **Violation**: `‖r(k)‖ > ρ_eff` (confirmed envelope exit).
12//! - **Boundary**: `‖r(k)‖ > boundary_frac × ρ_eff` with a qualifying
13//!   reason — sustained outward drift (ṙ > 0 over the drift window) or
14//!   abrupt slew (|r̈| > δ_s) — **or** recurrent boundary grazing (K
15//!   near-boundary hits in a K-long history buffer).
16//! - **Admissible**: otherwise.
17//!
18//! ## Hysteresis
19//!
20//! Two consecutive confirmations are required before a state change is
21//! committed, matching dsfb-rf's canonical FSM. This prevents
22//! single-sample transients from flipping the grammar. During a
23//! suppressed robot context (commissioning, maintenance) the FSM is
24//! force-reset to `Admissible` so violations cannot occur.
25
26use crate::envelope::AdmissibilityEnvelope;
27use crate::platform::RobotContext;
28use crate::sign::SignTuple;
29
30/// Reason code qualifying a `Boundary` grammar state.
31///
32/// Typed reason codes let an operator distinguish classes of structural
33/// behaviour without the observer making a fault-classification claim.
34/// For a robotics deployment, the reason codes map to recognisable
35/// failure *modes* (collision, friction drift, payload step, cyclic
36/// loading) without DSFB having to commit to the *cause*.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub enum ReasonCode {
40    /// Persistent positive drift (ṙ > 0) across the drift window.
41    ///
42    /// Robotics mapping: friction/gravity-comp bias accumulating,
43    /// thermal drift of joint encoders, slow payload mass change.
44    SustainedOutwardDrift,
45
46    /// Abrupt slew event (|r̈| > δ_s).
47    ///
48    /// Robotics mapping: collision onset, actuator saturation, sudden
49    /// payload step, commanded-mode transition not flagged as suppressed.
50    AbruptSlewViolation,
51
52    /// `K` recurrent near-boundary hits within the last `K`
53    /// observations.
54    ///
55    /// Robotics mapping: cyclic loading (periodic pick-and-place
56    /// rhythm, gait cycle near limit), mechanical resonance, repetitive
57    /// approach of a kinematic limit.
58    RecurrentBoundaryGrazing,
59
60    /// Confirmed envelope violation (`‖r‖ > ρ_eff`). Used as a reason
61    /// qualifier when referring to the transition rather than the
62    /// `Violation` state itself.
63    EnvelopeViolation,
64}
65
66impl ReasonCode {
67    /// Stable human-readable label for logging and JSON emission.
68    #[inline]
69    #[must_use]
70    pub const fn label(self) -> &'static str {
71        match self {
72            Self::SustainedOutwardDrift => "SustainedOutwardDrift",
73            Self::AbruptSlewViolation => "AbruptSlewViolation",
74            Self::RecurrentBoundaryGrazing => "RecurrentBoundaryGrazing",
75            Self::EnvelopeViolation => "EnvelopeViolation",
76        }
77    }
78}
79
80/// The typed grammar state.
81#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub enum GrammarState {
84    /// Residual inside envelope and not drifting outward. Nominal.
85    #[default]
86    Admissible,
87    /// Residual in the boundary band with a qualifying reason code.
88    Boundary(ReasonCode),
89    /// Residual has exited the envelope.
90    Violation,
91}
92
93impl GrammarState {
94    /// `true` for any state that warrants operator attention.
95    #[inline]
96    #[must_use]
97    pub const fn requires_attention(&self) -> bool {
98        !matches!(self, Self::Admissible)
99    }
100
101    /// `true` iff this is the `Violation` state.
102    #[inline]
103    #[must_use]
104    pub const fn is_violation(&self) -> bool {
105        matches!(self, Self::Violation)
106    }
107
108    /// `true` iff this is a `Boundary[_]` state.
109    #[inline]
110    #[must_use]
111    pub const fn is_boundary(&self) -> bool {
112        matches!(self, Self::Boundary(_))
113    }
114
115    /// Severity level: `0 = Admissible`, `1 = Boundary`, `2 = Violation`.
116    #[inline]
117    #[must_use]
118    pub const fn severity(&self) -> u8 {
119        match self {
120            Self::Admissible => 0,
121            Self::Boundary(_) => 1,
122            Self::Violation => 2,
123        }
124    }
125
126    /// Stable label used in the canonical `Episode::grammar` field.
127    #[inline]
128    #[must_use]
129    pub const fn label(&self) -> &'static str {
130        match self {
131            Self::Admissible => "Admissible",
132            Self::Boundary(_) => "Boundary",
133            Self::Violation => "Violation",
134        }
135    }
136}
137
138/// Grammar evaluator with 2-confirmation hysteresis and `K`-long
139/// boundary-grazing history.
140///
141/// All state is stack-allocated; no heap, no `unsafe`, no `std`.
142pub struct GrammarEvaluator<const K: usize> {
143    pending: GrammarState,
144    confirmations: u8,
145    committed: GrammarState,
146    boundary_hits: [bool; K],
147    hit_head: usize,
148    hit_count: usize,
149}
150
151impl<const K: usize> GrammarEvaluator<K> {
152    /// Construct an evaluator initialised to `Admissible`.
153    #[must_use]
154    pub const fn new() -> Self {
155        Self {
156            pending: GrammarState::Admissible,
157            confirmations: 0,
158            committed: GrammarState::Admissible,
159            boundary_hits: [false; K],
160            hit_head: 0,
161            hit_count: 0,
162        }
163    }
164
165    /// The currently committed grammar state.
166    #[inline]
167    #[must_use]
168    pub fn state(&self) -> GrammarState {
169        self.committed
170    }
171
172    /// Evaluate the grammar state for one observation and return the
173    /// committed state after applying hysteresis.
174    pub fn evaluate(
175        &mut self,
176        sign: &SignTuple,
177        envelope: &AdmissibilityEnvelope,
178        context: RobotContext,
179    ) -> GrammarState {
180        debug_assert!(envelope.rho >= 0.0, "envelope radius must be non-negative");
181        debug_assert!((0.0..=1.0).contains(&envelope.boundary_frac), "boundary_frac out of [0,1]");
182        // Suppressed context (commissioning / maintenance): force and hold Admissible.
183        if context.is_suppressed() {
184            self.committed = GrammarState::Admissible;
185            self.pending = GrammarState::Admissible;
186            self.confirmations = 0;
187            // Also clear grazing history so a resumption does not inherit
188            // pre-commissioning boundary hits.
189            self.boundary_hits = [false; K];
190            self.hit_head = 0;
191            self.hit_count = 0;
192            return GrammarState::Admissible;
193        }
194
195        let multiplier = context.admissibility_multiplier();
196        debug_assert!(multiplier >= 0.0, "admissibility multiplier must be non-negative");
197        let raw = self.compute_raw_state(sign, envelope, multiplier);
198
199        // Update boundary-grazing history.
200        if K > 0 {
201            let is_approach = envelope.is_boundary_approach(sign.norm, multiplier)
202                && !envelope.is_violation(sign.norm, multiplier);
203            self.boundary_hits[self.hit_head] = is_approach;
204            self.hit_head = (self.hit_head + 1) % K;
205            if self.hit_count < K {
206                self.hit_count += 1;
207            }
208        }
209
210        // 2-confirmation hysteresis.
211        if raw == self.pending {
212            if self.confirmations < 2 {
213                self.confirmations += 1;
214            }
215            if self.confirmations >= 2 {
216                self.committed = raw;
217            }
218        } else {
219            self.pending = raw;
220            self.confirmations = 1;
221        }
222
223        self.committed
224    }
225
226    fn compute_raw_state(
227        &self,
228        sign: &SignTuple,
229        envelope: &AdmissibilityEnvelope,
230        multiplier: f64,
231    ) -> GrammarState {
232        debug_assert!(envelope.rho >= 0.0);
233        debug_assert!(multiplier >= 0.0);
234        debug_assert!(self.hit_count <= K, "hit_count must never exceed K");
235        if envelope.is_violation(sign.norm, multiplier) {
236            return GrammarState::Violation;
237        }
238
239        if envelope.is_boundary_approach(sign.norm, multiplier) {
240            if sign.is_outward_drift() {
241                return GrammarState::Boundary(ReasonCode::SustainedOutwardDrift);
242            }
243            if sign.is_abrupt_slew(envelope.delta_s) {
244                return GrammarState::Boundary(ReasonCode::AbruptSlewViolation);
245            }
246        }
247
248        if K > 0 && self.hit_count >= K {
249            let grazing_hits = self.boundary_hits.iter().filter(|&&h| h).count();
250            debug_assert!(grazing_hits <= K, "grazing_hits bounded by buffer length");
251            if grazing_hits >= K {
252                return GrammarState::Boundary(ReasonCode::RecurrentBoundaryGrazing);
253            }
254        }
255
256        GrammarState::Admissible
257    }
258
259    /// Reset the evaluator.
260    pub fn reset(&mut self) {
261        *self = Self::new();
262    }
263}
264
265impl<const K: usize> Default for GrammarEvaluator<K> {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::envelope::AdmissibilityEnvelope;
275    use crate::platform::RobotContext;
276    use crate::sign::SignTuple;
277
278    fn env() -> AdmissibilityEnvelope {
279        AdmissibilityEnvelope::new(0.1)
280    }
281
282    #[test]
283    fn clean_signal_is_admissible() {
284        let mut e = GrammarEvaluator::<4>::new();
285        for _ in 0..5 {
286            let s = SignTuple::new(0.02, 0.0, 0.0);
287            assert_eq!(e.evaluate(&s, &env(), RobotContext::ArmOperating), GrammarState::Admissible);
288        }
289    }
290
291    #[test]
292    fn violation_committed_after_hysteresis() {
293        let mut e = GrammarEvaluator::<4>::new();
294        let big = SignTuple::new(0.15, 0.0, 0.0);
295        e.evaluate(&big, &env(), RobotContext::ArmOperating);
296        let s = e.evaluate(&big, &env(), RobotContext::ArmOperating);
297        assert_eq!(s, GrammarState::Violation);
298    }
299
300    #[test]
301    fn single_transient_dismissed_by_hysteresis() {
302        let mut e = GrammarEvaluator::<4>::new();
303        let big = SignTuple::new(0.15, 0.0, 0.0);
304        let small = SignTuple::new(0.02, 0.0, 0.0);
305        e.evaluate(&big, &env(), RobotContext::ArmOperating);
306        let s = e.evaluate(&small, &env(), RobotContext::ArmOperating);
307        assert_eq!(s, GrammarState::Admissible, "single transient must be dismissed");
308    }
309
310    #[test]
311    fn commissioning_suppresses_violations() {
312        let mut e = GrammarEvaluator::<4>::new();
313        let huge = SignTuple::new(1_000.0, 50.0, 5.0);
314        for _ in 0..5 {
315            assert_eq!(e.evaluate(&huge, &env(), RobotContext::ArmCommissioning), GrammarState::Admissible);
316        }
317    }
318
319    #[test]
320    fn sustained_outward_drift_is_boundary() {
321        let mut e = GrammarEvaluator::<4>::new();
322        let drift = SignTuple::new(0.07, 0.005, 0.0);
323        e.evaluate(&drift, &env(), RobotContext::ArmOperating);
324        let s = e.evaluate(&drift, &env(), RobotContext::ArmOperating);
325        assert_eq!(s, GrammarState::Boundary(ReasonCode::SustainedOutwardDrift));
326    }
327
328    #[test]
329    fn abrupt_slew_is_boundary_when_in_approach_band() {
330        let mut e = GrammarEvaluator::<4>::new();
331        // Norm 0.08 > 0.5·ρ(=0.05) and slew magnitude > δ_s (0.05).
332        let s_in = SignTuple::new(0.08, 0.0, 0.2);
333        e.evaluate(&s_in, &env(), RobotContext::ArmOperating);
334        let s = e.evaluate(&s_in, &env(), RobotContext::ArmOperating);
335        assert_eq!(s, GrammarState::Boundary(ReasonCode::AbruptSlewViolation));
336    }
337
338    #[test]
339    fn recurrent_grazing_detected_after_k_hits() {
340        let mut e = GrammarEvaluator::<3>::new();
341        // Boundary approach without outward drift → only grazing triggers Boundary.
342        let graze = SignTuple::new(0.07, 0.0, 0.0);
343        // Need ≥ K = 3 approaches in history, then confirmation.
344        for _ in 0..5 {
345            e.evaluate(&graze, &env(), RobotContext::ArmOperating);
346        }
347        assert_eq!(e.state(), GrammarState::Boundary(ReasonCode::RecurrentBoundaryGrazing));
348    }
349
350    #[test]
351    fn severity_monotone_with_state() {
352        assert!(GrammarState::Violation.severity() > GrammarState::Boundary(ReasonCode::EnvelopeViolation).severity());
353        assert!(GrammarState::Boundary(ReasonCode::SustainedOutwardDrift).severity() > GrammarState::Admissible.severity());
354    }
355
356    #[test]
357    fn labels_are_stable() {
358        assert_eq!(GrammarState::Admissible.label(), "Admissible");
359        assert_eq!(GrammarState::Boundary(ReasonCode::SustainedOutwardDrift).label(), "Boundary");
360        assert_eq!(GrammarState::Violation.label(), "Violation");
361    }
362}