1use crate::envelope::AdmissibilityEnvelope;
27use crate::platform::RobotContext;
28use crate::sign::SignTuple;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub enum ReasonCode {
40 SustainedOutwardDrift,
45
46 AbruptSlewViolation,
51
52 RecurrentBoundaryGrazing,
59
60 EnvelopeViolation,
64}
65
66impl ReasonCode {
67 #[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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub enum GrammarState {
84 #[default]
86 Admissible,
87 Boundary(ReasonCode),
89 Violation,
91}
92
93impl GrammarState {
94 #[inline]
96 #[must_use]
97 pub const fn requires_attention(&self) -> bool {
98 !matches!(self, Self::Admissible)
99 }
100
101 #[inline]
103 #[must_use]
104 pub const fn is_violation(&self) -> bool {
105 matches!(self, Self::Violation)
106 }
107
108 #[inline]
110 #[must_use]
111 pub const fn is_boundary(&self) -> bool {
112 matches!(self, Self::Boundary(_))
113 }
114
115 #[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 #[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
138pub 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 #[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 #[inline]
167 #[must_use]
168 pub fn state(&self) -> GrammarState {
169 self.committed
170 }
171
172 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 if context.is_suppressed() {
184 self.committed = GrammarState::Admissible;
185 self.pending = GrammarState::Admissible;
186 self.confirmations = 0;
187 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 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 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 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 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 let graze = SignTuple::new(0.07, 0.0, 0.0);
343 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}