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}