Skip to main content

dsfb_rf/
engine.rs

1//! Main engine: composes all pipeline stages into a single deterministic observer.
2//!
3//! ## Pipeline (paper §B, Theorem 9)
4//!
5//!   IQ Residual → Sign → Grammar → Syntax → Semantics → DSA → Policy
6//!
7//! Each stage is a deterministic function under fixed parameters.
8//! The composition is deterministic: identical ordered inputs produce
9//! identical outputs on every replay.
10//!
11//! ## Non-Intrusion Contract (paper §II, §VIII-C)
12//!
13//! The public `observe()` method accepts `&[f32]` immutable residual slices
14//! from the caller. The engine's internal mutable state is fully encapsulated.
15//! No mutable reference to any caller-owned data is ever taken.
16//! The Rust type system enforces this: `cargo geiger` reports zero unsafe.
17//!
18//! ## Generic Parameters
19//!
20//! - `W`:  window width for sign and DSA (paper default: 10)
21//! - `K`:  grammar persistence threshold (paper default: 4)
22//! - `M`:  heuristics bank capacity (default: 32)
23
24use crate::sign::{SignTuple, SignWindow};
25use crate::envelope::AdmissibilityEnvelope;
26use crate::grammar::{GrammarEvaluator, GrammarState};
27use crate::syntax::{classify, SyntaxThresholds, MotifClass};
28use crate::heuristics::{HeuristicsBank, SemanticDisposition};
29use crate::dsa::DsaWindow;
30use crate::policy::{PolicyDecision, PolicyEvaluator};
31use crate::platform::{PlatformContext, SnrFloor};
32use crate::lyapunov::{LyapunovEstimator, LyapunovResult};
33
34/// Typed non-intrusion contract for the DSFB-RF observer.
35///
36/// This struct is a compile-time, read-only declaration of the architectural
37/// guarantees this observer provides to the system it is embedded in.
38///
39/// Derived from the DSFB-Semiconductor `NonIntrusiveDsfbObserver` contract
40/// (de Beer 2026, §VIII-C) and extended for the RF context.
41///
42/// ## Guarantees
43///
44/// 1. **Observer-only write path**: `observe()` takes `&mut self` (own
45///    state only) and `&[f32]` (caller data immutable).  No mutable
46///    reference to caller-owned data is ever taken.
47///
48/// 2. **Fail-safe isolation**: if the observer panics or returns an error,
49///    it cannot alter upstream receiver behaviour.  The observer is a leaf
50///    node in the data flow graph.
51///
52/// 3. **Read-only side channel**: the observer taps the IQ residual stream
53///    that the receiver already produces.  It neither writes to the receiver's
54///    filter coefficients, detector thresholds, AGC loop state, nor any
55///    firmware register.
56///
57/// 4. **Deterministic**: identical ordered inputs produce identical outputs
58///    on every replay (Theorem 9 from the paper).  No internal PRNG,
59///    no OS clock, no hardware entropy source.
60///
61/// 5. **Non-attributing**: the observer produces grammar states and motif
62///    classes.  It does not attribute physical cause, emitter identity,
63///    or intent.
64#[derive(Debug, Clone, Copy)]
65pub struct NonIntrusiveContract {
66    /// Integration mode string.  Always `"read_only_side_channel"`.
67    pub integration_mode: &'static str,
68    /// Fail-safe isolation guarantee.
69    pub fail_safe_isolation_note: &'static str,
70    /// Write-path guarantee.
71    pub write_path_note: &'static str,
72    /// Determinism guarantee.
73    pub determinism_note: &'static str,
74    /// Attribution policy.
75    pub attribution_policy: &'static str,
76    /// Unsafe code count (enforced by `#![forbid(unsafe_code)]`).
77    pub unsafe_count: u32,
78    /// Heap allocation policy.
79    pub heap_policy: &'static str,
80}
81
82/// The canonical non-intrusion contract for dsfb-rf.
83///
84/// Include this in operator advisories, SigMF annotations, and
85/// VITA 49.2 context packets to assert the integration guarantees.
86pub const NON_INTRUSIVE_CONTRACT: NonIntrusiveContract = NonIntrusiveContract {
87    integration_mode: "read_only_side_channel",
88    fail_safe_isolation_note:
89        "observer failure cannot alter upstream receiver behaviour; \
90         observer is a leaf node with no write-back path to any upstream state",
91    write_path_note:
92        "observe() takes &[f32] (immutable caller slice); \
93         no mutable reference to caller-owned data is ever taken",
94    determinism_note:
95        "identical ordered inputs produce identical outputs on every replay; \
96         no PRNG, no OS clock, no hardware entropy source",
97    attribution_policy:
98        "grammar states and motif classes are structural observations only; \
99         no physical cause, emitter identity, or intent is attributed",
100    unsafe_count: 0,
101    heap_policy: "no_alloc in core path; heap opt-in via 'alloc' feature only",
102};
103
104/// Full deterministic trace for one observation — the audit chain.
105///
106/// Every field in this struct corresponds to a stage in the DSFB pipeline.
107/// The complete chain can be serialized to `dsfb_traceability.json` by the
108/// `output` module (requires `serde` feature).
109#[derive(Debug, Clone, Copy)]
110pub struct ObservationResult {
111    /// Observation index k.
112    pub k: u64,
113    /// Raw residual norm ‖r(k)‖.
114    pub residual_norm: f32,
115    /// Sign tuple σ(k) = (‖r‖, ṙ, r̈). Stage 1 output.
116    pub sign: SignTuple,
117    /// Grammar state after hysteresis. Stage 2 output.
118    pub grammar: GrammarState,
119    /// Motif class from syntax layer. Stage 3 output.
120    pub motif: MotifClass,
121    /// Semantic disposition from heuristics bank. Stage 4 output.
122    pub semantic: SemanticDisposition,
123    /// DSA score. Stage 5 output.
124    pub dsa_score: f32,
125    /// Final policy decision. Stage 6 output.
126    pub policy: PolicyDecision,
127    /// Lyapunov stability result: finite-time Lyapunov exponent λ(k),
128    /// stability classification, and estimated time-to-envelope-exit.
129    pub lyapunov: LyapunovResult,
130    /// Sub-threshold flag (SNR < floor → drift/slew forced to zero).
131    pub sub_threshold: bool,
132    /// Suppressed flag (waveform transition → grammar forced to Admissible).
133    pub suppressed: bool,
134}
135
136/// The DSFB RF Structural Semiotics Engine.
137///
138/// ## Type Parameters
139///
140/// - `W`: window width (sign drift + DSA accumulator). Paper Stage III: `W = 10`.
141/// - `K`: grammar persistence threshold. Paper default: `K = 4`.
142/// - `M`: heuristics bank capacity. Paper default: `M = 32`.
143///
144/// ## Memory Footprint (no_std, no_alloc)
145///
146/// All storage is stack-allocated. For `W=10, K=4, M=8`:
147/// - SignWindow<10>:        ~52 bytes
148/// - GrammarEvaluator<4>:  ~20 bytes
149/// - DsaWindow<10>:        ~212 bytes
150/// - HeuristicsBank<8>:    ~400 bytes
151/// - PolicyEvaluator:      ~8 bytes
152/// - Total:                ~700 bytes — suitable for Cortex-M4F stack
153
154// ── Decimation ────────────────────────────────────────────────────────────────
155//
156// DEFENCE: "Computational Wall" (see paper §XIX-A and AGENTS.md).
157//
158// Structural state changes (thermal drift, oscillator aging) occur at kHz or
159// Hz rates — not at GHz sample rates. The `DecimationAccumulator` down-samples
160// the residual stream before the semiotic pipeline, enabling deployment at
161// full-rate (e.g. 200 MS/s FPGA path) while the Semiotic Engine runs at a
162// decimated rate (e.g. 1 ks/s). DSFB monitors the *envelope* of the physics,
163// not the cycle of the carrier. This is not a limitation; it is the correct
164// physics.
165//
166// Implementation: accumulates `factor` norms, emits their RMS once per epoch.
167// `factor=1` (the default) means every sample passes through unchanged — no
168// performance penalty for configurations that do not need decimation.
169// `no_std`, `no_alloc`, zero `unsafe`. Stack footprint: 16 bytes.
170
171/// Streaming residual-norm decimation accumulator.
172///
173/// Collects `factor` residual-norm samples and emits a single **root-mean-square**
174/// value per epoch. This down-samples the semiotic pipeline to the physics
175/// timescale of structural change (thermal, oscillator aging) decoupled from
176/// the carrier sample rate.
177///
178/// ## Rationale (paper §XIX-A — Semiotic Decimation)
179///
180/// At 1 GSPS, a 27 ns per-sample budget is budget-limited for the full Fisher-Rao
181/// and Lyapunov machinery. Structural changes that DSFB detects (PA drift,
182/// oscillator aging, mask approach) occur at timescales > 10 ms. A decimation
183/// factor of 10 000 at 1 GSPS yields 100 kHz structural monitoring — seven
184/// decades above the physics rate, with a 27 µs per-epoch budget (10 000× more
185/// comfortable). This is architecturally identical to how a spectrum analyzer
186/// operates: full-rate ADC, decimated FFT, symbol-rate detection.
187///
188/// ## Instruction-Level Determinism
189///
190/// The accumulator is branchless (no dynamic dispatch, no heap, no loop beyond
191/// the caller's own loop). The inner hot path is exactly 6 arithmetic
192/// operations per input sample regardless of `factor`. Only the `push()`
193/// `return Some(rms)` branch fires once per `factor` samples — fully
194/// predictable by branch predictors and cycle-count manifests
195/// (paper §XIX-B, Phase II deliverable).
196///
197/// ## Usage
198///
199/// ```
200/// use dsfb_rf::engine::DecimationAccumulator;
201/// let mut d = DecimationAccumulator::new(1000);
202/// for i in 0..999 { assert!(d.push(0.05).is_none()); }
203/// let rms = d.push(0.05).unwrap(); // epoch complete
204/// assert!((rms - 0.05).abs() < 1e-5);
205/// ```
206#[derive(Debug, Clone, Copy)]
207pub struct DecimationAccumulator {
208    factor:  u32,   // Number of input samples per output epoch
209    count:   u32,   // Samples accumulated in current epoch
210    sum_sq:  f32,   // Running ‖r‖² for RMS computation
211    peak:    f32,   // Peak norm in current epoch (for diagnostics)
212}
213
214impl DecimationAccumulator {
215    /// Construct a new accumulator with the given decimation factor.
216    ///
217    /// `factor = 1` means every sample is emitted (no decimation).
218    /// `factor = k` means one RMS value is emitted per `k` input samples.
219    /// A `factor` of zero is treated as 1 (safety for const contexts).
220    pub const fn new(factor: u32) -> Self {
221        let f = if factor == 0 { 1 } else { factor };
222        Self { factor: f, count: 0, sum_sq: 0.0, peak: 0.0 }
223    }
224
225    /// Push one residual norm into the accumulator.
226    ///
227    /// Returns `Some(rms)` when a full decimation epoch is complete.
228    /// Returns `None` for all intermediate samples.
229    #[inline]
230    pub fn push(&mut self, norm: f32) -> Option<f32> {
231        let n = if norm < 0.0 { -norm } else { norm }; // abs without libm
232        self.sum_sq += n * n;
233        if n > self.peak { self.peak = n; }
234        self.count += 1;
235        if self.count >= self.factor {
236            let rms = crate::math::sqrt_f32(self.sum_sq / self.count as f32);
237            self.count  = 0;
238            self.sum_sq = 0.0;
239            self.peak   = 0.0;
240            Some(rms)
241        } else {
242            None
243        }
244    }
245
246    /// Decimation factor (samples per output epoch).
247    pub const fn factor(&self) -> u32 { self.factor }
248
249    /// Samples accumulated in the current (incomplete) epoch.
250    pub const fn count(&self) -> u32 { self.count }
251
252    /// Reset the accumulator state (does not change the factor).
253    pub fn reset(&mut self) {
254        self.count  = 0;
255        self.sum_sq = 0.0;
256        self.peak   = 0.0;
257    }
258}
259
260/// Main DSFB Structural Semiotics Engine.
261///
262/// A zero-allocation, deterministic observer that combines envelope admissibility,
263/// sign-segment grammar, DSA scoring, Lyapunov exponent estimation, heuristics,
264/// and policy evaluation into a single state machine operating on IQ residuals.
265///
266/// # Type Parameters
267/// - `W` — sliding window length for sign-segment and DSA statistics.
268/// - `K` — grammar state-machine size (number of grammar states).
269/// - `M` — heuristics bank capacity.
270///
271/// # Non-Intrusion Contract
272/// The engine is a **read-only observer**. It never modifies, delays, or discards
273/// samples from the underlying signal chain. See [`NON_INTRUSIVE_CONTRACT`].
274///
275/// # Example
276/// ```rust
277/// use dsfb_rf::engine::DsfbRfEngine;
278/// use dsfb_rf::platform::PlatformContext;
279/// let mut eng = DsfbRfEngine::<10, 4, 8>::new(0.05, 3.0);
280/// let ctx = PlatformContext::operational();
281/// let _obs = eng.observe(0.1, ctx);
282/// ```
283pub struct DsfbRfEngine<const W: usize, const K: usize, const M: usize> {
284    envelope:      AdmissibilityEnvelope,
285    sign_window:   SignWindow<W>,
286    grammar:       GrammarEvaluator<K>,
287    dsa:           DsaWindow<W>,
288    heuristics:    HeuristicsBank<M>,
289    policy_eval:   PolicyEvaluator,
290    lyapunov:      LyapunovEstimator<W>,
291    snr_floor:     SnrFloor,
292    syn_thresh:    SyntaxThresholds,
293    obs_count:     u64,
294    episode_count: u32,
295    /// Semiotic decimation accumulator.
296    ///
297    /// `observe_decimated()` uses this to down-sample the residual stream to
298    /// the physics timescale. `factor=1` (default) means every sample passes
299    /// through — the `observe()` hot path is unaffected.
300    decim: DecimationAccumulator,
301}
302
303impl<const W: usize, const K: usize, const M: usize> DsfbRfEngine<W, K, M> {
304    /// Construct engine with given envelope radius ρ and DSA threshold τ.
305    pub fn new(rho: f32, tau: f32) -> Self {
306        use crate::policy::PolicyConfig;
307        Self {
308            envelope:      AdmissibilityEnvelope::new(rho),
309            sign_window:   SignWindow::new(),
310            grammar:       GrammarEvaluator::new(),
311            dsa:           DsaWindow::new(rho * 0.5),
312            heuristics:    HeuristicsBank::default_rf(),
313            policy_eval:   PolicyEvaluator::with_config(PolicyConfig {
314                tau,
315                k: K as u8,
316                m: 1,
317                extreme_bypass: true,
318            }),
319            lyapunov:      LyapunovEstimator::new(),
320            snr_floor:     SnrFloor::default(),
321            syn_thresh:    SyntaxThresholds::default(),
322            obs_count:     0,
323            episode_count: 0,
324            decim:         DecimationAccumulator::new(1), // no decimation by default
325        }
326    }
327
328    /// Construct from a healthy-window norm slice (Stage III calibration).
329    ///
330    /// Computes ρ = μ + 3σ from `healthy_norms`.
331    /// Returns `None` if slice is empty.
332    pub fn from_calibration(healthy_norms: &[f32], tau: f32) -> Option<Self> {
333        let env = AdmissibilityEnvelope::calibrate_from_window(healthy_norms)?;
334        let mut eng = Self::new(env.rho, tau);
335        eng.dsa.calibrate_ewma_threshold(healthy_norms);
336        Some(eng)
337    }
338
339    /// Set a custom SNR floor (default: −10 dB).
340    pub fn with_snr_floor(mut self, db: f32) -> Self {
341        self.snr_floor = SnrFloor::new(db);
342        self
343    }
344
345    /// Set the semiotic decimation factor (default: 1 — no decimation).
346    ///
347    /// With `factor = D`, the full semiotic pipeline runs **once per D input
348    /// samples**.  The input window accumulates the RMS of `D` norms before
349    /// forwarding to the sign → grammar → syntax → semantics → DSA → policy
350    /// chain.
351    ///
352    /// ## When to use
353    ///
354    /// At high sample rates (≥ 1 MS/s) where structural changes of interest
355    /// (thermal drift, PA aging, mask approach) occur at kHz or Hz rates.
356    /// Decimation effectively sets the structural monitoring bandwidth to
357    /// `sample_rate / D` Hz, which is appropriate for the physics timescale.
358    ///
359    /// ## Non-intrusion guarantee is preserved
360    ///
361    /// The accumulator is entirely internal. `observe_decimated()` still takes
362    /// only `&[f32]` immutable slices from the caller. `factor=1` (default)
363    /// means `observe_decimated()` === `observe()` with zero overhead.
364    ///
365    /// ## Example
366    ///
367    /// ```
368    /// use dsfb_rf::engine::DsfbRfEngine;
369    /// // 1 GSPS receiver; monitor at 100 kHz structural rate
370    /// let eng = DsfbRfEngine::<10, 4, 8>::new(0.1, 2.0)
371    ///     .with_decimation(10_000);
372    /// assert_eq!(eng.decimation_factor(), 10_000);
373    /// ```
374    pub fn with_decimation(mut self, factor: u32) -> Self {
375        self.decim = DecimationAccumulator::new(factor);
376        self
377    }
378
379    /// Current decimation factor.
380    pub fn decimation_factor(&self) -> u32 { self.decim.factor() }
381
382    /// Process one residual norm observation.
383    ///
384    /// The full pipeline stages run in order. Returns an `ObservationResult`
385    /// containing the complete audit chain for this observation.
386    ///
387    /// ## Non-Intrusion
388    ///
389    /// `residual_norm` and `ctx` are consumed by value or immutable reference.
390    /// No caller-owned data is mutated. The engine advances only its own
391    /// internal state.
392    pub fn observe(
393        &mut self,
394        residual_norm: f32,
395        ctx: PlatformContext,
396    ) -> ObservationResult {
397        let k = self.obs_count;
398        self.obs_count += 1;
399        let sub_threshold = self.snr_floor.is_sub_threshold(ctx.snr_db);
400        let suppressed = ctx.waveform_state.is_suppressed();
401        let sign = self.sign_window.push(residual_norm, sub_threshold, self.snr_floor);
402        let effective_waveform = select_effective_waveform(ctx.waveform_state, sub_threshold);
403        let grammar = self.grammar.evaluate(&sign, &self.envelope, effective_waveform);
404        let motif = classify(&sign, grammar, self.envelope.rho, &self.syn_thresh);
405        let semantic = self.heuristics.lookup(motif, grammar);
406        let motif_fired = !matches!(motif, MotifClass::Unknown);
407        let dsa = self.dsa.push(&sign, grammar, motif_fired);
408        let lyapunov = self.lyapunov.push(residual_norm, self.envelope.rho);
409        let policy = self.policy_eval.evaluate(grammar, semantic, dsa, 1);
410        if matches!(policy, PolicyDecision::Escalate) {
411            self.episode_count = self.episode_count.saturating_add(1);
412        }
413        ObservationResult {
414            k, residual_norm, sign, grammar, motif, semantic,
415            dsa_score: dsa.0, lyapunov, policy, sub_threshold, suppressed,
416        }
417    }
418
419    /// Batch-process a slice of residual norms, returning all results.
420    ///
421    /// Convenience method for the host-side pipeline. Requires `alloc` feature
422    /// for Vec output, or use the iterator form below for bare-metal.
423    #[cfg(feature = "alloc")]
424    pub fn observe_batch(
425        &mut self,
426        norms: &[f32],
427        ctx: PlatformContext,
428    ) -> alloc::vec::Vec<ObservationResult> {
429        norms.iter().map(|&n| self.observe(n, ctx)).collect()
430    }
431
432    /// Process one residual norm through the **decimation accumulator**, then
433    /// (only when a full epoch completes) through the full semiotic pipeline.
434    ///
435    /// Returns `None` for all intermediate samples within an epoch.
436    /// Returns `Some(ObservationResult)` once per `decimation_factor()` calls.
437    ///
438    /// With `decimation_factor() == 1` (the default), this is identical to
439    /// `observe()` and returns `Some` on every call.
440    ///
441    /// ## Motivation (paper §XIX-A — Semiotic Decimation)
442    ///
443    /// DSFB monitors the *envelope* of the physics, not the *cycle* of the
444    /// carrier.  Structural state changes (thermal drift, oscillator aging,
445    /// mask approach) occur at kHz/Hz rates.  Running the full Fisher-Rao,
446    /// Lyapunov, and grammar machinery at 1 GSPS is unnecessary and violates
447    /// the sensor physics.  Decimation resolves the "Computational Wall"
448    /// criticism without sacrificing structural detection sensitivity.
449    ///
450    /// ## Non-intrusion guarantee preserved
451    ///
452    /// The `norm` argument is consumed by value; `ctx` is passed by value.
453    /// No caller-owned data is mutated.
454    ///
455    /// ## Example
456    ///
457    /// ```
458    /// use dsfb_rf::engine::DsfbRfEngine;
459    /// use dsfb_rf::platform::PlatformContext;
460    /// let mut eng = DsfbRfEngine::<10, 4, 8>::new(0.05, 2.0)
461    ///     .with_decimation(100);
462    /// let ctx = PlatformContext::with_snr(20.0);
463    /// for i in 0..99 {
464    ///     assert!(eng.observe_decimated(0.02, ctx).is_none());
465    /// }
466    /// let result = eng.observe_decimated(0.02, ctx);
467    /// assert!(result.is_some()); // 100th sample triggers epoch
468    /// ```
469    #[inline]
470    pub fn observe_decimated(
471        &mut self,
472        residual_norm: f32,
473        ctx: PlatformContext,
474    ) -> Option<ObservationResult> {
475        self.decim.push(residual_norm).map(|rms| self.observe(rms, ctx))
476    }
477
478    /// Current observation count.
479    pub fn obs_count(&self) -> u64 { self.obs_count }
480
481    /// Current escalation-episode count.
482    pub fn episode_count(&self) -> u32 { self.episode_count }
483
484    /// Current envelope radius ρ.
485    pub fn rho(&self) -> f32 { self.envelope.rho }
486
487    /// Current grammar state.
488    pub fn grammar_state(&self) -> GrammarState { self.grammar.state() }
489
490    /// Return the typed non-intrusion contract for this observer.
491    ///
492    /// Use this in operator advisories, SigMF `dsfb:contract` annotations,
493    /// and VITA 49.2 context packets to formally assert the integration
494    /// guarantees provided by this implementation.
495    ///
496    /// ## Example
497    ///
498    /// ```no_run
499    /// use dsfb_rf::engine::DsfbRfEngine;
500    /// let eng = DsfbRfEngine::<10, 4, 8>::new(0.1, 2.0);
501    /// let c = eng.contract();
502    /// assert_eq!(c.integration_mode, "read_only_side_channel");
503    /// assert_eq!(c.unsafe_count, 0);
504    /// ```
505    #[inline]
506    pub fn contract(&self) -> NonIntrusiveContract {
507        NON_INTRUSIVE_CONTRACT
508    }
509
510    /// Reset all internal state.
511    pub fn reset(&mut self) {
512        self.sign_window.reset();
513        self.grammar.reset();
514        self.dsa.reset();
515        self.lyapunov.reset();
516        self.decim.reset();
517        self.obs_count = 0;
518        self.episode_count = 0;
519    }
520}
521
522#[inline]
523fn select_effective_waveform(
524    ctx_waveform: crate::platform::WaveformState,
525    sub_threshold: bool,
526) -> crate::platform::WaveformState {
527    if sub_threshold {
528        crate::platform::WaveformState::Calibration
529    } else {
530        ctx_waveform
531    }
532}
533
534// ---------------------------------------------------------------
535// Tests
536// ---------------------------------------------------------------
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::platform::PlatformContext;
541
542    fn eng() -> DsfbRfEngine<10, 4, 8> {
543        DsfbRfEngine::new(0.10, 2.0)
544    }
545
546    fn ctx(snr: f32) -> PlatformContext { PlatformContext::with_snr(snr) }
547
548    // ── Theorem 9: Determinism ───────────────────────────────────────────
549    #[test]
550    fn determinism_identical_inputs_produce_identical_outputs() {
551        let inputs = [0.01f32, 0.02, 0.04, 0.07, 0.09, 0.08, 0.06, 0.04, 0.03, 0.02,
552                      0.03, 0.05, 0.08, 0.11, 0.10, 0.08, 0.06, 0.03, 0.02, 0.01];
553        let c = ctx(15.0);
554        let mut e1 = eng();
555        let mut e2 = eng();
556        for &n in &inputs {
557            let r1 = e1.observe(n, c);
558            let r2 = e2.observe(n, c);
559            assert_eq!(r1.policy, r2.policy,
560                "Theorem 9 violated at k={}: {:?} vs {:?}", r1.k, r1.policy, r2.policy);
561            assert_eq!(r1.grammar, r2.grammar);
562        }
563    }
564
565    // ── L8: Observer-only — no upstream mutation ─────────────────────────
566    #[test]
567    fn observe_does_not_mutate_input() {
568        let mut e = eng();
569        let original = 0.07f32;
570        let copy = original;
571        let _ = e.observe(original, ctx(15.0));
572        // original is Copy — value is unchanged
573        assert_eq!(original, copy);
574    }
575
576    // ── L10: Sub-threshold forces Admissible ─────────────────────────────
577    #[test]
578    fn sub_threshold_snr_forces_admissible() {
579        let mut e = eng();
580        // Feed large norms at sub-threshold SNR
581        for _ in 0..20 {
582            let r = e.observe(0.50, PlatformContext::with_snr(-20.0));
583            assert_eq!(r.grammar, GrammarState::Admissible,
584                "sub-threshold must force Admissible, got {:?}", r.grammar);
585            assert_eq!(r.sign.drift, 0.0);
586            assert_eq!(r.sign.slew, 0.0);
587        }
588    }
589
590    // ── XIV-C: Transition window suppression ─────────────────────────────
591    #[test]
592    fn transition_window_no_escalation() {
593        let mut e = eng();
594        let ctx_t = PlatformContext::transition();
595        for _ in 0..30 {
596            let r = e.observe(999.0, ctx_t);
597            assert!(!matches!(r.policy, PolicyDecision::Review | PolicyDecision::Escalate),
598                "transition must suppress escalation, got {:?}", r.policy);
599        }
600    }
601
602    // ── Clean signal stays Silent ─────────────────────────────────────────
603    #[test]
604    fn nominal_signal_stays_silent() {
605        let mut e = eng();
606        let c = ctx(20.0);
607        for _ in 0..30 {
608            let r = e.observe(0.02, c);
609            assert_eq!(r.policy, PolicyDecision::Silent,
610                "nominal signal at k={} must be Silent, got {:?}", r.k, r.policy);
611        }
612    }
613
614    // ── Theorem 1: Sustained drift exits envelope ─────────────────────────
615    #[test]
616    fn sustained_drift_eventually_detected() {
617        let mut e = DsfbRfEngine::<10, 4, 8>::new(0.10, 2.0);
618        let c = ctx(20.0);
619        let mut detected = false;
620        for i in 0..60u32 {
621            let norm = 0.01 + i as f32 * 0.004;
622            let r = e.observe(norm, c);
623            if matches!(r.policy, PolicyDecision::Review | PolicyDecision::Escalate) {
624                detected = true;
625                break;
626            }
627        }
628        assert!(detected,
629            "Theorem 1: sustained drift must be detected in finite observations");
630    }
631
632    // ── Calibration from healthy window ───────────────────────────────────
633    #[test]
634    fn calibration_produces_valid_engine() {
635        let healthy: [f32; 100] = core::array::from_fn(|i| 0.03 + i as f32 * 0.0002);
636        let e = DsfbRfEngine::<10, 4, 8>::from_calibration(&healthy, 2.0);
637        assert!(e.is_some());
638        let e = e.unwrap();
639        assert!(e.rho() > 0.0, "calibrated rho must be positive");
640    }
641
642    // ── Reset clears all state ────────────────────────────────────────────
643    #[test]
644    fn reset_clears_observation_count() {
645        let mut e = eng();
646        let c = ctx(15.0);
647        for _ in 0..10 { e.observe(0.05, c); }
648        assert_eq!(e.obs_count(), 10);
649        e.reset();
650        assert_eq!(e.obs_count(), 0);
651    }
652
653    // ── Bare-metal build sanity (no std, no alloc needed) ─────────────────
654    #[test]
655    fn engine_fits_in_reasonable_stack() {
656        // Verify size is manageable for MCU deployment
657        let size = core::mem::size_of::<DsfbRfEngine<10, 4, 8>>();
658        assert!(size < 4096, "engine size {} bytes exceeds 4KB stack budget", size);
659    }
660
661    // ── Non-intrusion contract assertions ─────────────────────────────────
662    #[test]
663    fn contract_mode_is_read_only_side_channel() {
664        let e = eng();
665        let c = e.contract();
666        assert_eq!(c.integration_mode, "read_only_side_channel");
667    }
668
669    #[test]
670    fn contract_unsafe_count_zero() {
671        let e = eng();
672        assert_eq!(e.contract().unsafe_count, 0);
673    }
674
675    #[test]
676    fn contract_heap_policy_no_alloc() {
677        let e = eng();
678        let policy = e.contract().heap_policy;
679        assert!(policy.contains("no_alloc"), "heap policy must assert no_alloc: {}", policy);
680    }
681
682    #[test]
683    fn non_intrusive_contract_constant_accessible() {
684        assert_eq!(NON_INTRUSIVE_CONTRACT.integration_mode, "read_only_side_channel");
685        assert_eq!(NON_INTRUSIVE_CONTRACT.unsafe_count, 0);
686    }
687
688    // ── Semiotic Decimation ───────────────────────────────────────────────
689
690    #[test]
691    fn decimation_accumulator_emits_once_per_factor() {
692        let mut d = DecimationAccumulator::new(10);
693        for i in 0..9 {
694            assert!(d.push(0.05).is_none(), "expected None at sample {i}");
695        }
696        let rms = d.push(0.05);
697        assert!(rms.is_some(), "expected Some(rms) at 10th sample");
698        let v = rms.unwrap();
699        assert!((v - 0.05).abs() < 1e-5, "rms {v} not close to 0.05");
700    }
701
702    #[test]
703    fn decimation_accumulator_factor_one_emits_every_sample() {
704        let mut d = DecimationAccumulator::new(1);
705        for i in 0..20 {
706            assert!(d.push(0.03).is_some(), "factor=1 must emit at sample {i}");
707        }
708    }
709
710    #[test]
711    fn decimation_accumulator_zero_factor_treated_as_one() {
712        let mut d = DecimationAccumulator::new(0);
713        assert_eq!(d.factor(), 1, "factor=0 must be normalised to 1");
714        assert!(d.push(0.05).is_some(), "normalised factor=1 must emit immediately");
715    }
716
717    #[test]
718    fn decimation_accumulator_rms_of_mixed_norms() {
719        let mut d = DecimationAccumulator::new(4);
720        let norms = [0.0f32, 0.0, 0.0, 4.0]; // RMS = sqrt((0+0+0+16)/4) = 2.0
721        for (i, &n) in norms.iter().enumerate() {
722            let r = d.push(n);
723            if i < 3 { assert!(r.is_none()); }
724            else { assert!((r.unwrap() - 2.0).abs() < 1e-4, "rms mismatch: {r:?}"); }
725        }
726    }
727
728    #[test]
729    fn observe_decimated_returns_none_then_some() {
730        let mut e = DsfbRfEngine::<10, 4, 8>::new(0.10, 2.0)
731            .with_decimation(5);
732        let c = ctx(20.0);
733        for _ in 0..4 {
734            assert!(e.observe_decimated(0.02, c).is_none());
735        }
736        assert!(e.observe_decimated(0.02, c).is_some());
737    }
738
739    #[test]
740    fn observe_decimated_factor_one_equiv_to_observe() {
741        let mut e1 = DsfbRfEngine::<10, 4, 8>::new(0.10, 2.0);
742        let mut e2 = DsfbRfEngine::<10, 4, 8>::new(0.10, 2.0).with_decimation(1);
743        let c = ctx(20.0);
744        for _ in 0..20 {
745            let r1 = e1.observe(0.03, c);
746            let r2 = e2.observe_decimated(0.03, c).unwrap();
747            assert_eq!(r1.policy, r2.policy,
748                "factor=1 observe_decimated must equal observe");
749        }
750    }
751
752    #[test]
753    fn decimation_theorem9_determinism_preserved() {
754        // Decimated pipeline must also satisfy Theorem 9 (determinism)
755        let inputs = [0.02f32, 0.04, 0.03, 0.05, 0.06,
756                      0.07, 0.08, 0.07, 0.05, 0.03];
757        let c = ctx(20.0);
758        let mut e1 = DsfbRfEngine::<10, 4, 8>::new(0.10, 2.0).with_decimation(5);
759        let mut e2 = DsfbRfEngine::<10, 4, 8>::new(0.10, 2.0).with_decimation(5);
760        let mut out1: [Option<crate::policy::PolicyDecision>; 10] = [None; 10];
761        let mut out2: [Option<crate::policy::PolicyDecision>; 10] = [None; 10];
762        for (i, &n) in inputs.iter().enumerate() {
763            out1[i] = e1.observe_decimated(n, c).map(|r| r.policy);
764            out2[i] = e2.observe_decimated(n, c).map(|r| r.policy);
765        }
766        assert_eq!(out1, out2, "Theorem 9 must hold for decimated pipeline");
767    }
768
769    #[test]
770    fn decimation_factor_accessible_after_builder() {
771        let e = DsfbRfEngine::<10, 4, 8>::new(0.10, 2.0).with_decimation(1000);
772        assert_eq!(e.decimation_factor(), 1000);
773    }
774
775    #[test]
776    fn reset_clears_decimation_accumulator() {
777        let mut e = DsfbRfEngine::<10, 4, 8>::new(0.10, 2.0).with_decimation(10);
778        let c = ctx(20.0);
779        for _ in 0..5 { e.observe_decimated(0.05, c); }
780        e.reset();
781        // After reset, need another full 10 samples to emit
782        for _ in 0..9 {
783            assert!(e.observe_decimated(0.05, c).is_none());
784        }
785        assert!(e.observe_decimated(0.05, c).is_some());
786    }
787}