dsfb_rf/regime.rs
1//! Regime-switched admissibility envelopes.
2//!
3//! ## Theoretical basis
4//!
5//! A fixed-radius admissibility envelope ρ = const works well under
6//! Wide-Sense Stationarity (WSS) — that is, while the nominal signal
7//! regime is stable. RF receivers routinely encounter **regime transitions**:
8//!
9//! - Preamble → data payload (burst-mode receivers)
10//! - Acquisition → tracking (PLL lock transients, AGC settle)
11//! - Idle → active (TDMA slot, radar duty cycle)
12//! - Interference on → off (opportunistic spectrum sharing)
13//!
14//! The DSFB-Semiotics-Engine envelope module (de Beer 2026, §IV) models
15//! five distinct envelope modes beyond the fixed baseline:
16//!
17//! | Mode | Physical RF scenario |
18//! |---|---|
19//! | Fixed | In-lock steady-state; nominal thermal-noise floor |
20//! | Widening | Acquisition phase; PLL pull-in; AGC transient |
21//! | Tightening | Post-fault recovery; channel-condition improvement |
22//! | RegimeSwitched | Burst-mode: preamble vs payload; TDMA boundary |
23//! | Aggregate | Worst-case across simultaneously active contexts |
24//!
25//! In addition, the semiotics-engine defines a **grammar trust scalar**
26//! derived from the current boundary margin. This is distinct from the
27//! HRET channel-trust (which is residual-magnitude-based); the grammar trust
28//! scalar is **geometry-based**, measuring how far inside the envelope the
29//! current observation lies.
30//!
31//! ## Design
32//!
33//! - `no_std`, `no_alloc`, zero `unsafe`
34//! - All state is stack-allocated `f32` scalars
35//! - Widening / Tightening use EMA rate rather than open-loop ramp, so they
36//! are bounded and deterministic under any input sequence
37
38use crate::envelope::AdmissibilityEnvelope;
39
40/// Envelope operating mode.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub enum EnvelopeMode {
44 /// Fixed radius ρ = const. Standard in-lock steady-state.
45 Fixed,
46 /// Widening: EMA-smoothed expansion toward ρ_max during acquisition /
47 /// AGC transients. Rate controlled by `widen_alpha` (EMA coefficient).
48 Widening,
49 /// Tightening: EMA-smoothed contraction toward ρ_base after a fault
50 /// clears or channel conditions improve.
51 Tightening,
52 /// Regime-switched: the radius snaps between two pre-set levels depending
53 /// on the active RF regime. Maps naturally to burst-mode (preamble vs.
54 /// payload) and TDMA boundary crossings.
55 RegimeSwitched,
56 /// Aggregate: takes the maximum of all `other_rho` values provided.
57 /// Used when multiple envelope constraints are simultaneously active
58 /// (e.g., regulatory mask + link-budget margin + observed-noise floor).
59 Aggregate,
60}
61
62/// Regime labels for `RegimeSwitched` mode.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub enum RfRegime {
66 /// Burst preamble / synchronisation header — tolerates wider residuals.
67 Preamble,
68 /// Data payload — tighter envelope once lock is achieved.
69 Payload,
70 /// PLL acquisition / AGC settle — widest envelope.
71 Acquisition,
72 /// Steady-state in-lock — tightest envelope.
73 InLock,
74}
75
76/// Parameters for a regime-switched envelope.
77#[derive(Debug, Clone, Copy)]
78pub struct RegimeEnvelopeParams {
79 /// Base (tight) envelope radius ρ_base.
80 pub rho_base: f32,
81 /// Maximum (wide) envelope radius ρ_max used during widening mode or
82 /// the "wide" regime in `RegimeSwitched`.
83 pub rho_max: f32,
84 /// EMA smoothing coefficient for widening (0 < α_widen < 1).
85 /// Larger → faster widening. Typical: 0.10.
86 pub widen_alpha: f32,
87 /// EMA smoothing coefficient for tightening (0 < α_tight < 1).
88 /// Larger → faster tightening. Typical: 0.05.
89 pub tighten_alpha: f32,
90 /// Boundary band fraction (semiotics-engine §IV: 4% of ρ).
91 ///
92 /// A sample within boundary_band of ρ_eff is classified as
93 /// "boundary approach" for the grammar trust scalar.
94 pub boundary_band_frac: f32,
95 /// Slew threshold for abrupt slew detection as a fraction of ρ_eff.
96 ///
97 /// semiotics-engine default: 8% of ρ.
98 pub slew_threshold_frac: f32,
99}
100
101impl RegimeEnvelopeParams {
102 /// Sensible defaults for a standard SDR receiver.
103 pub const fn default_sdr(rho_base: f32) -> Self {
104 Self {
105 rho_base,
106 rho_max: rho_base * 3.0,
107 widen_alpha: 0.10,
108 tighten_alpha: 0.05,
109 boundary_band_frac: 0.04, // 4 % per semiotics-engine §IV
110 slew_threshold_frac: 0.08, // 8 % per semiotics-engine §IV
111 }
112 }
113}
114
115/// Grammar-level trust scalar derived from envelope geometry.
116///
117/// This is a **deterministic, bounded scalar in [0, 1]** that downweights
118/// a grammar contribution based on how close the residual norm is to the
119/// envelope boundary.
120///
121/// Definition (semiotics-engine `trust_scalar_for()`):
122///
123/// ```text
124/// margin = (ρ_eff − ‖r‖) / ρ_eff (normalised inward distance)
125/// T = clamp(margin / boundary_band_frac, 0, 1)
126/// ```
127///
128/// Interpretation:
129/// - T = 1.0 → residual deep inside envelope; grammar evidence fully trusted
130/// - T = 0.0 → residual on or outside envelope boundary; grammar evidence suppressed
131/// - Intermediate → proportional attenuation by proximity
132///
133/// This is distinct from HRET channel trust, which is magnitude-EMA-based.
134/// Grammar trust is a **per-sample geometric score** with no memory.
135#[derive(Debug, Clone, Copy, PartialEq)]
136pub struct GrammarTrustScalar {
137 /// Trust value T ∈ [0, 1].
138 pub value: f32,
139 /// Normalised inward margin = (ρ − ‖r‖) / ρ.
140 pub margin: f32,
141}
142
143impl GrammarTrustScalar {
144 /// Compute the grammar trust scalar for a given norm, effective radius, and band width.
145 ///
146 /// `band_frac` is the boundary_band_frac (default 0.04).
147 pub fn compute(norm: f32, rho_eff: f32, band_frac: f32) -> Self {
148 if rho_eff <= 1e-30 {
149 return Self { value: 0.0, margin: 0.0 };
150 }
151 let margin = (rho_eff - norm) / rho_eff;
152 // T = margin / band_frac, clamped to [0, 1]
153 let value = if band_frac < 1e-12 {
154 if margin >= 0.0 { 1.0 } else { 0.0 }
155 } else {
156 let raw = margin / band_frac;
157 raw.max(0.0).min(1.0)
158 };
159 Self { value, margin }
160 }
161
162 /// Returns true if the trust scalar indicates full confidence.
163 #[inline]
164 pub fn is_fully_trusted(&self) -> bool { self.value >= 1.0 - 1e-6 }
165
166 /// Returns true if grammar evidence is fully suppressed.
167 #[inline]
168 pub fn is_suppressed(&self) -> bool { self.value <= 1e-6 }
169}
170
171/// Regime-sensitive admissibility envelope with dynamic radius tracking.
172///
173/// Wraps `AdmissibilityEnvelope` and adds:
174/// 1. Mode-dependent radius updates (widening / tightening EMA)
175/// 2. Regime switching (snap between ρ_base and ρ_max)
176/// 3. Grammar trust scalar computation
177/// 4. Aggregate-mode maximum over multiple constraints
178///
179/// ## Stack footprint: ~48 bytes (all f32 + enum tags)
180pub struct RegimeEnvelope {
181 /// Current effective radius ρ_eff (updated per observation).
182 rho_eff: f32,
183 /// Operating mode.
184 mode: EnvelopeMode,
185 /// Parameters.
186 params: RegimeEnvelopeParams,
187 /// Consecutive boundary-approach count for RecurrentBoundaryGrazing.
188 /// Resets on each non-boundary observation.
189 consecutive_boundary: u8,
190 /// Whether an abrupt slew was detected on the last observation.
191 last_slew: bool,
192}
193
194impl RegimeEnvelope {
195 /// Construct with given parameters, starting in Fixed mode at ρ_base.
196 pub const fn new(params: RegimeEnvelopeParams) -> Self {
197 Self {
198 rho_eff: params.rho_base,
199 mode: EnvelopeMode::Fixed,
200 params,
201 consecutive_boundary: 0,
202 last_slew: false,
203 }
204 }
205
206 /// Construct directly from a base AdmissibilityEnvelope.
207 pub fn from_envelope(env: &AdmissibilityEnvelope) -> Self {
208 let params = RegimeEnvelopeParams::default_sdr(env.rho);
209 Self::new(params)
210 }
211
212 /// Set a different operating mode.
213 pub fn set_mode(&mut self, mode: EnvelopeMode) {
214 self.mode = mode;
215 }
216
217 /// Current effective envelope radius ρ_eff.
218 #[inline]
219 pub fn rho_eff(&self) -> f32 { self.rho_eff }
220
221 /// Current mode.
222 #[inline]
223 pub fn mode(&self) -> EnvelopeMode { self.mode }
224
225 /// Update the envelope for one observation of residual norm.
226 ///
227 /// Adjusts ρ_eff according to the current mode, then computes and
228 /// returns the grammar trust scalar.
229 ///
230 /// `other_rho` is only used in `Aggregate` mode (max over all provided
231 /// values); pass an empty slice for other modes.
232 pub fn update(
233 &mut self,
234 norm: f32,
235 regime: RfRegime,
236 other_rho: &[f32],
237 ) -> EnvelopeUpdateResult {
238 self.rho_eff = self.compute_rho_eff(regime, other_rho);
239
240 let band = self.params.boundary_band_frac * self.rho_eff;
241 let in_boundary_band = norm > (self.rho_eff - band).max(0.0) && norm <= self.rho_eff;
242 let above_envelope = norm > self.rho_eff;
243 if in_boundary_band {
244 self.consecutive_boundary = self.consecutive_boundary.saturating_add(1);
245 } else {
246 self.consecutive_boundary = 0;
247 }
248 let recurrent_boundary_grazing = self.consecutive_boundary >= 2;
249
250 let trust = GrammarTrustScalar::compute(norm, self.rho_eff, self.params.boundary_band_frac);
251 EnvelopeUpdateResult {
252 rho_eff: self.rho_eff,
253 mode: self.mode,
254 grammar_trust: trust,
255 in_boundary_band,
256 above_envelope,
257 recurrent_boundary_grazing,
258 }
259 }
260
261 fn compute_rho_eff(&self, regime: RfRegime, other_rho: &[f32]) -> f32 {
262 match self.mode {
263 EnvelopeMode::Fixed => self.params.rho_base,
264 EnvelopeMode::Widening => {
265 let a = self.params.widen_alpha;
266 let r = a * self.params.rho_max + (1.0 - a) * self.rho_eff;
267 r.max(self.params.rho_base).min(self.params.rho_max)
268 }
269 EnvelopeMode::Tightening => {
270 let a = self.params.tighten_alpha;
271 let r = a * self.params.rho_base + (1.0 - a) * self.rho_eff;
272 r.max(self.params.rho_base).min(self.params.rho_max)
273 }
274 EnvelopeMode::RegimeSwitched => match regime {
275 RfRegime::Preamble | RfRegime::Acquisition => self.params.rho_max,
276 RfRegime::Payload | RfRegime::InLock => self.params.rho_base,
277 },
278 EnvelopeMode::Aggregate => {
279 let mut max_rho = self.params.rho_base;
280 for &r in other_rho {
281 if r > max_rho { max_rho = r; }
282 }
283 max_rho
284 }
285 }
286 }
287
288 /// Update with explicit delta_norm for slew detection.
289 ///
290 /// Returns `(EnvelopeUpdateResult, abrupt_slew)`.
291 pub fn update_with_slew(
292 &mut self,
293 norm: f32,
294 regime: RfRegime,
295 other_rho: &[f32],
296 delta_norm: f32,
297 ) -> (EnvelopeUpdateResult, bool) {
298 let result = self.update(norm, regime, other_rho);
299 let slew_threshold = self.params.slew_threshold_frac * self.rho_eff;
300 let abrupt_slew = delta_norm.abs() > slew_threshold;
301 self.last_slew = abrupt_slew;
302 (result, abrupt_slew)
303 }
304
305 /// Reset to initial state (Fixed mode, ρ_base).
306 pub fn reset(&mut self) {
307 self.rho_eff = self.params.rho_base;
308 self.mode = EnvelopeMode::Fixed;
309 self.consecutive_boundary = 0;
310 self.last_slew = false;
311 }
312}
313
314/// Result of one `RegimeEnvelope::update()` call.
315#[derive(Debug, Clone, Copy)]
316pub struct EnvelopeUpdateResult {
317 /// Current effective envelope radius after mode update.
318 pub rho_eff: f32,
319 /// Mode that produced this result.
320 pub mode: EnvelopeMode,
321 /// Grammar trust scalar T ∈ [0, 1].
322 pub grammar_trust: GrammarTrustScalar,
323 /// True if the residual norm falls within the boundary band.
324 ///
325 /// Boundary band = (ρ_eff − 4%·ρ_eff, ρ_eff].
326 pub in_boundary_band: bool,
327 /// True if the residual norm is above ρ_eff (envelope violation).
328 pub above_envelope: bool,
329 /// True if ≥ 2 consecutive samples were in the boundary band.
330 ///
331 /// Corroborates `ReasonCode::RecurrentBoundaryGrazing` in the grammar layer.
332 pub recurrent_boundary_grazing: bool,
333}
334
335// ---------------------------------------------------------------
336// Tests
337// ---------------------------------------------------------------
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 fn params() -> RegimeEnvelopeParams {
343 RegimeEnvelopeParams {
344 rho_base: 0.10,
345 rho_max: 0.30,
346 widen_alpha: 0.20,
347 tighten_alpha: 0.10,
348 boundary_band_frac: 0.04,
349 slew_threshold_frac: 0.08,
350 }
351 }
352
353 #[test]
354 fn fixed_mode_constant_rho() {
355 let mut env = RegimeEnvelope::new(params());
356 for _ in 0..50 {
357 let r = env.update(0.05, RfRegime::InLock, &[]);
358 assert!((r.rho_eff - 0.10).abs() < 1e-6);
359 }
360 }
361
362 #[test]
363 fn widening_mode_expands() {
364 let mut env = RegimeEnvelope::new(params());
365 env.set_mode(EnvelopeMode::Widening);
366 let mut rho_prev = env.rho_eff();
367 for _ in 0..30 {
368 let r = env.update(0.05, RfRegime::Acquisition, &[]);
369 assert!(r.rho_eff >= rho_prev - 1e-9, "rho must not decrease in widening mode");
370 rho_prev = r.rho_eff;
371 }
372 assert!(rho_prev > 0.10, "rho should have grown above rho_base");
373 }
374
375 #[test]
376 fn tightening_mode_contracts() {
377 let mut env = RegimeEnvelope::new(params());
378 env.rho_eff = 0.29; // start near max
379 env.set_mode(EnvelopeMode::Tightening);
380 let mut rho_prev = env.rho_eff();
381 for _ in 0..40 {
382 let r = env.update(0.05, RfRegime::InLock, &[]);
383 assert!(r.rho_eff <= rho_prev + 1e-6, "rho must not increase in tightening mode");
384 rho_prev = r.rho_eff;
385 }
386 assert!(rho_prev < 0.29, "rho should have contracted");
387 }
388
389 #[test]
390 fn regime_switched_snaps() {
391 let mut env = RegimeEnvelope::new(params());
392 env.set_mode(EnvelopeMode::RegimeSwitched);
393
394 let r_acq = env.update(0.05, RfRegime::Acquisition, &[]);
395 assert!((r_acq.rho_eff - 0.30).abs() < 1e-6);
396
397 let r_lock = env.update(0.05, RfRegime::InLock, &[]);
398 assert!((r_lock.rho_eff - 0.10).abs() < 1e-6);
399 }
400
401 #[test]
402 fn aggregate_mode_takes_max() {
403 let mut env = RegimeEnvelope::new(params());
404 env.set_mode(EnvelopeMode::Aggregate);
405 let r = env.update(0.05, RfRegime::InLock, &[0.15, 0.25, 0.20]);
406 assert!((r.rho_eff - 0.25).abs() < 1e-6);
407 }
408
409 #[test]
410 fn grammar_trust_full_inside() {
411 let p = params(); // boundary_band_frac = 0.04
412 let mut env = RegimeEnvelope::new(p);
413 // norm = 0.00 is deep inside → trust = 1
414 let r = env.update(0.0, RfRegime::InLock, &[]);
415 assert!(r.grammar_trust.is_fully_trusted());
416 }
417
418 #[test]
419 fn grammar_trust_zero_at_boundary() {
420 let p = params();
421 let mut env = RegimeEnvelope::new(p);
422 // norm = rho_eff = 0.10 → margin = 0 → trust = 0
423 let r = env.update(0.10, RfRegime::InLock, &[]);
424 assert!(r.grammar_trust.is_suppressed(), "trust={}", r.grammar_trust.value);
425 }
426
427 #[test]
428 fn recurrent_boundary_grazing_after_two() {
429 let mut env = RegimeEnvelope::new(params());
430 // norm in boundary band: (0.096, 0.100]
431 let r1 = env.update(0.098, RfRegime::InLock, &[]);
432 assert!(!r1.recurrent_boundary_grazing, "only 1 sample — not recurring yet");
433 let r2 = env.update(0.097, RfRegime::InLock, &[]);
434 assert!(r2.recurrent_boundary_grazing, "2 consecutive should trigger grazing");
435 }
436
437 #[test]
438 fn abrupt_slew_detection() {
439 let mut env = RegimeEnvelope::new(params());
440 // slew_threshold_frac = 0.08, rho_base = 0.10 → threshold = 0.008
441 let (_, slew) = env.update_with_slew(0.05, RfRegime::InLock, &[], 0.001);
442 assert!(!slew, "0.001 < 0.008: no slew");
443 let (_, slew) = env.update_with_slew(0.05, RfRegime::InLock, &[], 0.02);
444 assert!(slew, "0.02 > 0.008: abrupt slew detected");
445 }
446}