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}