Skip to main content

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}