Skip to main content

dsfb_rf/
waveform_context.rs

1//! Waveform transition context for grammar-escalation suppression.
2//!
3//! ## Motivation (paper §18.3)
4//!
5//! > "Deliberate waveform transitions — frequency hops, modulation changes,
6//! > burst boundaries — produce residual signatures structurally
7//! > indistinguishable from interference onset without a waveform-schedule
8//! > context flag. The correct integration contract includes a
9//! > regime-context channel that suppresses grammar escalation during
10//! > flagged transition windows. This is a **near-term engineering
11//! > extension**. The `platform_context.rs` module provides the hook;
12//! > population of the transition schedule is deployment-specific."
13//!
14//! This module provides that extension: a fixed-capacity waveform schedule
15//! that marks transition windows and suppresses spurious grammar escalation
16//! during those intervals.
17//!
18//! ## Design
19//!
20//! - **`TransitionWindow`**: a half-open interval `[start_k, end_k + margin)`
21//!   during which the grammar-state escalation to `Violation` is suppressed.
22//!   The optional `suppression_margin` adds post-transition damping to absorb
23//!   residual ringing from waveform changes.
24//! - **`WaveformSchedule<N>`**: a fixed-capacity (no_alloc) collection of
25//!   transition windows. `N` is a compile-time constant.
26//! - **`suppress_escalation(k, schedule)`**: the query function the engine
27//!   calls at observation `k`. Returns `true` during any active window.
28//!
29//! ## Non-Claims
30//!
31//! The schedule must be populated from a deployment-specific source
32//! (e.g., TDMA frame schedule, FHSS channel plan, link-layer signaling).
33//! This module provides the data structure and query logic only — it does
34//! not infer transition boundaries from the IQ residual itself.
35//!
36//! ## no_std / no_alloc / zero-unsafe
37//!
38//! `WaveformSchedule<N>` uses a `[TransitionWindow; N]` array. No heap.
39//! The crate-wide `#![forbid(unsafe_code)]` applies to all code here.
40//!
41//! ## References
42//!
43//! - de Beer (2026), §18.3 (Waveform Transition Artifacts)
44//! - Rondeau et al. (2004), cognitive radio state machines (heuristic basis
45//!   for suppression window design)
46
47// ── Transition Kind ────────────────────────────────────────────────────────
48
49/// Classification of a deliberate waveform transition event.
50///
51/// Used by the heuristics bank to distinguish deliberate transitions
52/// (which should suppress grammar escalation) from structural interference
53/// (which should not).
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum TransitionKind {
56    /// Frequency-hopping spread-spectrum (FHSS) hop event.
57    /// Produces abrupt residual slew then fast recovery.
58    FrequencyHop,
59
60    /// Deliberate modulation-format change (e.g., BPSK→QPSK handshake).
61    /// Produces transient residual peak during re-training period.
62    ModulationChange,
63
64    /// Burst-mode transmission onset (preamble + sync acquisition).
65    BurstStart,
66
67    /// Burst-mode transmission termination (demodulator idle flush).
68    BurstEnd,
69
70    /// Deliberate transmitter power-level change (power ramp).
71    /// Produces monotone drift consistent with PA thermal motif.
72    PowerLevelChange,
73
74    /// Pre-planned time-slot boundary from a known TDMA/FDMA schedule.
75    ScheduledSlotBoundary,
76
77    /// Transition of unspecified or deployment-specific kind.
78    Unknown,
79}
80
81impl TransitionKind {
82    /// Human-readable label for SigMF `dsfb:transition_kind` field.
83    pub const fn label(self) -> &'static str {
84        match self {
85            TransitionKind::FrequencyHop         => "FrequencyHop",
86            TransitionKind::ModulationChange     => "ModulationChange",
87            TransitionKind::BurstStart           => "BurstStart",
88            TransitionKind::BurstEnd             => "BurstEnd",
89            TransitionKind::PowerLevelChange     => "PowerLevelChange",
90            TransitionKind::ScheduledSlotBoundary => "ScheduledSlotBoundary",
91            TransitionKind::Unknown              => "Unknown",
92        }
93    }
94
95    /// Whether this transition kind requires post-transition suppression margin.
96    ///
97    /// Frequency hops and modulation changes require extra margin because
98    /// the receiver's equalizer/PLL needs time to re-acquire lock. Burst
99    /// boundaries and power changes settle faster.
100    pub const fn requires_margin(self) -> bool {
101        matches!(self, TransitionKind::FrequencyHop | TransitionKind::ModulationChange)
102    }
103}
104
105// ── Transition Window ──────────────────────────────────────────────────────
106
107/// A single waveform transition window.
108///
109/// Suppresses grammar escalation to `Violation` during
110/// `[start_k, end_k + suppression_margin)` observation indices.
111///
112/// The margin absorbs post-transition residual ringing; set to zero
113/// for transitions with fast recovery (e.g., scheduled slot boundaries).
114#[derive(Debug, Clone, Copy, PartialEq)]
115pub struct TransitionWindow {
116    /// Observation index at which the transition begins (inclusive).
117    pub start_k: u32,
118    /// Observation index at which the nominal waveform is expected to
119    /// resume (inclusive). Suppression continues through `end_k + margin`.
120    pub end_k: u32,
121    /// Additional post-transition suppression window in observations.
122    /// For `FrequencyHop` / `ModulationChange`: typically 2–10 samples
123    /// (depending on receiver lock-time). For others: 0.
124    pub suppression_margin: u32,
125    /// Semantic classification of this transition.
126    pub kind: TransitionKind,
127}
128
129impl TransitionWindow {
130    /// First observation index where suppression is active (= `start_k`).
131    #[inline]
132    pub const fn suppression_start(&self) -> u32 {
133        self.start_k
134    }
135
136    /// Last observation index where suppression is active (inclusive).
137    #[inline]
138    pub const fn suppression_end(&self) -> u32 {
139        self.end_k.saturating_add(self.suppression_margin)
140    }
141
142    /// Returns `true` if observation `k` falls within this suppression window.
143    #[inline]
144    pub fn is_active(&self, k: u32) -> bool {
145        k >= self.suppression_start() && k <= self.suppression_end()
146    }
147
148    /// Duration in observations (end_k − start_k + 1), excluding margin.
149    #[inline]
150    pub const fn duration_k(&self) -> u32 {
151        self.end_k.saturating_sub(self.start_k) + 1
152    }
153}
154
155// ── Waveform Schedule ──────────────────────────────────────────────────────
156
157/// Fixed-capacity waveform transition schedule.
158///
159/// `N` is the maximum number of transition windows that can be registered.
160/// For typical TDMA/FHSS protocols with ≤ 64 hops per evaluation window,
161/// `N = 64` is sufficient. For burst-rich environments, use `N = 128`.
162pub struct WaveformSchedule<const N: usize> {
163    windows: [TransitionWindow; N],
164    count: usize,
165}
166
167impl<const N: usize> WaveformSchedule<N> {
168    /// Create an empty schedule.
169    pub const fn new() -> Self {
170        Self {
171            // Safety: TransitionWindow is Copy + has all-zero constructible fields.
172            // We initialize with a sentinel value (kind=Unknown, all zeros).
173            windows: [TransitionWindow {
174                start_k: 0,
175                end_k: 0,
176                suppression_margin: 0,
177                kind: TransitionKind::Unknown,
178            }; N],
179            count: 0,
180        }
181    }
182
183    /// Register a new transition window.
184    ///
185    /// Returns `true` on success; `false` if the schedule is full
186    /// (capacity `N`). Caller must handle the full-schedule case
187    /// (e.g., emit a grammar `Boundary` event noting schedule overflow).
188    pub fn add(&mut self, window: TransitionWindow) -> bool {
189        if self.count >= N { return false; }
190        self.windows[self.count] = window;
191        self.count += 1;
192        true
193    }
194
195    /// Clear all registered windows (e.g., at frame boundary).
196    pub fn clear(&mut self) {
197        self.count = 0;
198    }
199
200    /// Number of registered windows.
201    #[inline]
202    pub fn len(&self) -> usize {
203        self.count
204    }
205
206    /// `true` if no windows are registered.
207    #[inline]
208    pub fn is_empty(&self) -> bool {
209        self.count == 0
210    }
211
212    /// Returns `true` if observation `k` falls within any suppression window.
213    ///
214    /// O(N) linear scan. For typical `N ≤ 128` this is always sub-microsecond.
215    pub fn is_suppressed(&self, k: u32) -> bool {
216        self.windows[..self.count].iter().any(|w| w.is_active(k))
217    }
218
219    /// Returns the first active `TransitionWindow` containing `k`, if any.
220    pub fn active_window(&self, k: u32) -> Option<&TransitionWindow> {
221        self.windows[..self.count].iter().find(|w| w.is_active(k))
222    }
223
224    /// Count of windows whose suppression interval overlaps observation `k`.
225    ///
226    /// Values > 1 indicate overlapping transitions (e.g., simultaneous
227    /// frequency hop and power ramp), which warrants extended suppression.
228    pub fn overlap_count(&self, k: u32) -> usize {
229        self.windows[..self.count].iter().filter(|w| w.is_active(k)).count()
230    }
231
232    /// Returns `true` if the schedule is at capacity.
233    #[inline]
234    pub fn is_full(&self) -> bool {
235        self.count >= N
236    }
237
238    /// Fraction of capacity used (0.0–1.0).
239    #[inline]
240    pub fn capacity_fraction(&self) -> f32 {
241        self.count as f32 / N as f32
242    }
243}
244
245// ── Grammar Integration Hook ───────────────────────────────────────────────
246
247/// Suppression decision returned to the grammar/policy layer.
248///
249/// The grammar layer calls `suppress_escalation()` at each observation.
250/// If the result is `Suppressed`, it must not escalate to `Violation`
251/// regardless of the DSA score or structural episode state.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum SuppressionDecision {
254    /// Normal operation — grammar escalation is permitted.
255    Active,
256    /// Suppressed — grammar must not escalate to `Violation`.
257    /// Contains the `TransitionKind` of the suppressing window for diagnostics.
258    Suppressed(TransitionKind),
259}
260
261/// Query whether grammar escalation should be suppressed at observation `k`.
262///
263/// Returns `Suppressed(kind)` if `k` falls within any registered transition
264/// window; otherwise returns `Active`.
265///
266/// ## Usage in the Grammar Layer
267///
268/// ```rust,ignore
269/// use dsfb_rf::waveform_context::{suppress_escalation, SuppressionDecision};
270///
271/// let decision = suppress_escalation(k, &schedule);
272/// match decision {
273///     SuppressionDecision::Active           => { /* normal grammar logic */ }
274///     SuppressionDecision::Suppressed(kind) => {
275///         // Downgrade Violation → Boundary, log suppression reason
276///     }
277/// }
278/// ```
279pub fn suppress_escalation<const N: usize>(
280    k: u32,
281    schedule: &WaveformSchedule<N>,
282) -> SuppressionDecision {
283    match schedule.active_window(k) {
284        None      => SuppressionDecision::Active,
285        Some(win) => SuppressionDecision::Suppressed(win.kind),
286    }
287}
288
289// ── Builder helpers ────────────────────────────────────────────────────────
290
291/// Convenience constructor for a frequency-hop transition window.
292///
293/// `margin` defaults to 5 observations (typical PLL re-lock for FHSS at
294/// moderate hop rates; adjust for platform-specific lock time).
295#[inline]
296pub fn freq_hop_window(start_k: u32, end_k: u32, margin: u32) -> TransitionWindow {
297    TransitionWindow {
298        start_k,
299        end_k,
300        suppression_margin: margin,
301        kind: TransitionKind::FrequencyHop,
302    }
303}
304
305/// Convenience constructor for a burst-start transition window.
306#[inline]
307pub fn burst_start_window(start_k: u32, preamble_len: u32) -> TransitionWindow {
308    TransitionWindow {
309        start_k,
310        end_k: start_k + preamble_len,
311        suppression_margin: 0,
312        kind: TransitionKind::BurstStart,
313    }
314}
315
316/// Convenience constructor for a power-level change window.
317#[inline]
318pub fn power_change_window(start_k: u32, ramp_duration_k: u32) -> TransitionWindow {
319    TransitionWindow {
320        start_k,
321        end_k: start_k + ramp_duration_k,
322        suppression_margin: 2,
323        kind: TransitionKind::PowerLevelChange,
324    }
325}
326
327// ── Tests ──────────────────────────────────────────────────────────────────
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn new_schedule_is_empty() {
335        let s = WaveformSchedule::<8>::new();
336        assert!(s.is_empty());
337        assert_eq!(s.len(), 0);
338    }
339
340    #[test]
341    fn add_and_query_window() {
342        let mut s = WaveformSchedule::<8>::new();
343        let win = freq_hop_window(100, 105, 3);
344        assert!(s.add(win));
345        // Suppressed at k=100
346        assert_eq!(s.is_suppressed(100), true);
347        // Suppressed at k=108 (105 + margin 3 = 108)
348        assert_eq!(s.is_suppressed(108), true);
349        // Active before window
350        assert_eq!(s.is_suppressed(99), false);
351        // Active after window + margin
352        assert_eq!(s.is_suppressed(109), false);
353    }
354
355    #[test]
356    fn full_schedule_returns_false_on_add() {
357        let mut s = WaveformSchedule::<2>::new();
358        let w = freq_hop_window(0, 5, 0);
359        assert!(s.add(w));
360        assert!(s.add(w));
361        assert!(!s.add(w), "full schedule must reject add");
362        assert!(s.is_full());
363    }
364
365    #[test]
366    fn clear_resets_schedule() {
367        let mut s = WaveformSchedule::<4>::new();
368        s.add(freq_hop_window(10, 20, 2));
369        s.add(freq_hop_window(50, 60, 2));
370        assert_eq!(s.len(), 2);
371        s.clear();
372        assert!(s.is_empty());
373        assert!(!s.is_suppressed(15), "cleared schedule must not suppress");
374    }
375
376    #[test]
377    fn suppress_escalation_returns_active_when_clear() {
378        let s = WaveformSchedule::<8>::new();
379        assert_eq!(suppress_escalation(42, &s), SuppressionDecision::Active);
380    }
381
382    #[test]
383    fn suppress_escalation_returns_suppressed_in_window() {
384        let mut s = WaveformSchedule::<8>::new();
385        s.add(burst_start_window(200, 10));
386        let dec = suppress_escalation(205, &s);
387        assert_eq!(dec, SuppressionDecision::Suppressed(TransitionKind::BurstStart));
388    }
389
390    #[test]
391    fn overlap_count_detects_simultaneous_transitions() {
392        let mut s = WaveformSchedule::<8>::new();
393        s.add(freq_hop_window(100, 110, 3));
394        s.add(power_change_window(105, 8));
395        // k=107 is in both windows
396        assert_eq!(s.overlap_count(107), 2, "should detect 2 overlapping windows");
397        assert_eq!(s.overlap_count(99),  0, "before all windows");
398        assert_eq!(s.overlap_count(120), 0, "after all windows");
399    }
400
401    #[test]
402    fn transition_kind_labels() {
403        assert_eq!(TransitionKind::FrequencyHop.label(), "FrequencyHop");
404        assert_eq!(TransitionKind::ModulationChange.label(), "ModulationChange");
405        assert_eq!(TransitionKind::BurstStart.label(), "BurstStart");
406        assert_eq!(TransitionKind::ScheduledSlotBoundary.label(), "ScheduledSlotBoundary");
407        assert_eq!(TransitionKind::Unknown.label(), "Unknown");
408    }
409
410    #[test]
411    fn requires_margin_correct() {
412        assert!(TransitionKind::FrequencyHop.requires_margin());
413        assert!(TransitionKind::ModulationChange.requires_margin());
414        assert!(!TransitionKind::BurstStart.requires_margin());
415        assert!(!TransitionKind::PowerLevelChange.requires_margin());
416        assert!(!TransitionKind::ScheduledSlotBoundary.requires_margin());
417    }
418
419    #[test]
420    fn window_duration_k_correct() {
421        let w = TransitionWindow {
422            start_k: 100, end_k: 110, suppression_margin: 0,
423            kind: TransitionKind::FrequencyHop,
424        };
425        assert_eq!(w.duration_k(), 11); // 110 - 100 + 1
426        assert_eq!(w.suppression_end(), 110);
427    }
428
429    #[test]
430    fn window_margin_extends_suppression() {
431        let w = freq_hop_window(100, 110, 5);
432        assert!( w.is_active(115), "margin extends to 115");
433        assert!(!w.is_active(116), "116 is past margin");
434    }
435
436    #[test]
437    fn capacity_fraction_reports_correctly() {
438        let mut s = WaveformSchedule::<4>::new();
439        assert!((s.capacity_fraction() - 0.0).abs() < 1e-5);
440        s.add(freq_hop_window(0, 5, 0));
441        assert!((s.capacity_fraction() - 0.25).abs() < 1e-5);
442        s.add(freq_hop_window(10, 15, 0));
443        assert!((s.capacity_fraction() - 0.50).abs() < 1e-5);
444    }
445
446    #[test]
447    fn multiple_windows_distinct_ranges_no_cross_suppression() {
448        let mut s = WaveformSchedule::<8>::new();
449        s.add(freq_hop_window(10, 20, 0));
450        s.add(freq_hop_window(100, 110, 0));
451        assert!( s.is_suppressed(15));
452        assert!(!s.is_suppressed(50), "gap between windows is active");
453        assert!( s.is_suppressed(105));
454    }
455}