dsfb_rf/fixedpoint.rs
1//! Fixed-point ingress path for FPGA soft-core and bare-metal deployment.
2//!
3//! ## Context
4//!
5//! While `dsfb-rf` is purely `f32`-based in the observer hot path, a number
6//! of deployment targets **lack a hardware FPU**:
7//!
8//! - RISC-V RV32I without the `F` extension (e.g., `riscv32imac-unknown-none-elf`)
9//! - Cortex-M0/M0+ (ARMv6-M — no FPU)
10//! - FPGA soft-cores (MicroBlaze without DSP48, PicoRV32)
11//! - Custom ASICs / VLSI pipelines in e.g. C-UAS / EW front-ends
12//!
13//! In these contexts, the ADC lane residual arrives as a raw integer sample
14//! and converting to `f32` before the observer introduces software FPU
15//! overhead. The `Q16.16` fixed-point format provides:
16//!
17//! - **16 integer bits**: supports residual norms up to 65535 (far beyond any
18//! normalised IQ residual)
19//! - **16 fractional bits**: resolution of 2⁻¹⁶ ≈ 1.526 × 10⁻⁵ —
20//! comfortably below the 14-bit ADC quantisation step on typical SDRs
21//! - **32-bit arithmetic**: fits in a single 32-bit ALU word; multiply in
22//! `i64` then shift avoids overflow
23//!
24//! ## Format conventions
25//!
26//! An `i32` in Q16.16 format represents the real number `x = raw / 2^16`.
27//!
28//! ```text
29//! quantize(x) = round(x × 2^16) [f64 → Q16.16 i32]
30//! dequantize(raw) = raw as f64 / 2^16 [Q16.16 i32 → f64]
31//! to_f32(raw) = raw as f32 / 65536.0 [Q16.16 i32 → f32 for observer]
32//! ```
33//!
34//! Saturation on overflow prevents undefined behaviour and limits anomaly
35//! injection from wildly out-of-range inputs.
36//!
37//! ## DSFB-Semiotics-Engine source
38//!
39//! The `quantize_q16_16` / `dequantize_q16_16` pair mirrors the reference
40//! implementation in `dsfb-semiotics-engine/math/fixed_point.rs` (de Beer 2026).
41//! The mode label `"fixed_q16_16"` is preserved for provenance traceability
42//! in SigMF annotations.
43//!
44//! ## Design
45//!
46//! - `no_std`, `no_alloc`, zero `unsafe`
47//! - All functions are `#[inline]` for zero-cost abstraction
48//! - No `libm` dependency — arithmetic is pure integer + bit-shift
49
50/// Quantise a `f64` value into Q16.16 fixed-point format (`i32`).
51///
52/// ## Formula
53///
54/// ```text
55/// q = round(x × 2^16) = round(x × 65536.0)
56/// ```
57///
58/// Saturates at `i32::MIN` / `i32::MAX` on overflow.
59///
60/// ## Accuracy
61///
62/// Representable range: −32768.0 … +32767.999985 (≈ ±32768).
63/// Resolution: 1/65536 ≈ 1.526 × 10⁻⁵.
64///
65/// For normalised IQ residual norms in [0, 1] the quantisation error is
66/// bounded by 0.5 × 2⁻¹⁶ ≈ 7.6 × 10⁻⁶, well below 14-bit ADC LSB.
67#[inline]
68pub fn quantize_q16_16(x: f64) -> i32 {
69 const SCALE: f64 = 65536.0; // 2^16
70 let scaled = x * SCALE;
71 let rounded = if scaled >= 0.0 {
72 scaled + 0.5
73 } else {
74 scaled - 0.5
75 };
76 // Saturating cast to i32
77 if rounded >= i32::MAX as f64 {
78 i32::MAX
79 } else if rounded <= i32::MIN as f64 {
80 i32::MIN
81 } else {
82 rounded as i32
83 }
84}
85
86/// Dequantise a Q16.16 `i32` back to `f64`.
87///
88/// ## Formula
89///
90/// ```text
91/// x = raw / 2^16 = raw as f64 / 65536.0
92/// ```
93///
94/// Round-trip accuracy: |dequantize(quantize(x)) − x| ≤ 2⁻¹⁷ ≈ 7.6 × 10⁻⁶.
95#[inline]
96pub fn dequantize_q16_16(raw: i32) -> f64 {
97 raw as f64 / 65536.0
98}
99
100/// Convert a Q16.16 `i32` directly to an `f32` for the observer hot path.
101///
102/// Equivalent to `dequantize_q16_16(raw) as f32` but avoids the intermediate
103/// `f64` on platforms where `f32` is the native float type.
104#[inline]
105pub fn q16_16_to_f32(raw: i32) -> f32 {
106 raw as f32 / 65536.0_f32
107}
108
109/// Quantise an `f32` into Q16.16.
110///
111/// Convenience wrapper over `quantize_q16_16` for callers already working
112/// in `f32` (avoids widening to `f64` on soft-float platforms).
113#[inline]
114pub fn quantize_f32(x: f32) -> i32 {
115 quantize_q16_16(x as f64)
116}
117
118/// Multiply two Q16.16 values and return a Q16.16 result.
119///
120/// Internally uses `i64` to prevent overflow during the multiply.
121///
122/// ## Formula
123///
124/// ```text
125/// result = (a as i64 * b as i64) >> 16
126/// ```
127///
128/// Saturates on overflow (result > i32::MAX or < i32::MIN).
129#[inline]
130pub fn mul_q16_16(a: i32, b: i32) -> i32 {
131 let prod = a as i64 * b as i64;
132 let shifted = prod >> 16;
133 if shifted > i32::MAX as i64 {
134 i32::MAX
135 } else if shifted < i32::MIN as i64 {
136 i32::MIN
137 } else {
138 shifted as i32
139 }
140}
141
142/// Add two Q16.16 values with saturation.
143#[inline]
144pub fn add_q16_16(a: i32, b: i32) -> i32 {
145 a.saturating_add(b)
146}
147
148/// Provenance mode label for SigMF annotation.
149///
150/// Include this literal in any SigMF `dsfb:quantization_mode` annotation
151/// field to identify the Q16.16 fixed-point ingress path.
152pub const MODE_LABEL: &str = "fixed_q16_16";
153
154/// Fractional bits in the Q16.16 format.
155pub const FRAC_BITS: u32 = 16;
156
157/// Scale factor: 2^FRAC_BITS.
158pub const SCALE: i32 = 1 << FRAC_BITS; // 65536
159
160/// Maximum representable value in Q16.16 as `f64`.
161pub const MAX_VALUE: f64 = i32::MAX as f64 / 65536.0;
162
163/// Minimum representable value in Q16.16 as `f64`.
164pub const MIN_VALUE: f64 = i32::MIN as f64 / 65536.0;
165
166/// Resolution (1 LSB) of the Q16.16 format in real units.
167pub const RESOLUTION: f64 = 1.0 / 65536.0;
168
169// ---------------------------------------------------------------
170// Tests
171// ---------------------------------------------------------------
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn round_trip_zero() {
178 let raw = quantize_q16_16(0.0);
179 let back = dequantize_q16_16(raw);
180 assert_eq!(raw, 0);
181 assert!((back - 0.0).abs() < 1e-10);
182 }
183
184 #[test]
185 fn round_trip_one() {
186 let raw = quantize_q16_16(1.0);
187 assert_eq!(raw, 65536);
188 let back = dequantize_q16_16(raw);
189 assert!((back - 1.0).abs() < 1e-10, "back={}", back);
190 }
191
192 #[test]
193 fn round_trip_negative() {
194 let raw = quantize_q16_16(-1.0);
195 assert_eq!(raw, -65536);
196 let back = dequantize_q16_16(raw);
197 assert!((back - (-1.0)).abs() < 1e-10);
198 }
199
200 #[test]
201 fn round_trip_fractional() {
202 let x = 0.12345_f64;
203 let raw = quantize_q16_16(x);
204 let back = dequantize_q16_16(raw);
205 assert!(
206 (back - x).abs() < RESOLUTION + 1e-14,
207 "round-trip error {} (expected < {})", (back - x).abs(), RESOLUTION
208 );
209 }
210
211 #[test]
212 fn saturation_on_overflow() {
213 let raw = quantize_q16_16(1_000_000.0);
214 assert_eq!(raw, i32::MAX, "must saturate at i32::MAX");
215 let raw_neg = quantize_q16_16(-1_000_000.0);
216 assert_eq!(raw_neg, i32::MIN, "must saturate at i32::MIN");
217 }
218
219 #[test]
220 fn multiply_integers() {
221 // 2.0 × 3.0 = 6.0 in Q16.16
222 let a = quantize_q16_16(2.0);
223 let b = quantize_q16_16(3.0);
224 let c = mul_q16_16(a, b);
225 let back = dequantize_q16_16(c);
226 assert!((back - 6.0).abs() < 1e-4, "2×3={}", back);
227 }
228
229 #[test]
230 fn multiply_fractions() {
231 // 0.5 × 0.5 = 0.25
232 let a = quantize_q16_16(0.5);
233 let b = quantize_q16_16(0.5);
234 let c = mul_q16_16(a, b);
235 let back = dequantize_q16_16(c);
236 assert!((back - 0.25).abs() < 1e-4, "0.5×0.5={}", back);
237 }
238
239 #[test]
240 fn add_saturates() {
241 let big = i32::MAX / 2 + 10;
242 let sum = add_q16_16(big, big);
243 assert_eq!(sum, i32::MAX, "overflow must saturate");
244 }
245
246 #[test]
247 fn f32_conversion_matches() {
248 let x = 0.0456_f32;
249 let raw = quantize_f32(x);
250 let back = q16_16_to_f32(raw);
251 assert!((back - x).abs() < 2.0 * RESOLUTION as f32, "f32 round-trip error");
252 }
253
254 #[test]
255 fn constants_consistent() {
256 assert_eq!(SCALE, 65536);
257 assert_eq!(FRAC_BITS, 16);
258 assert!((MAX_VALUE - (i32::MAX as f64 / 65536.0)).abs() < 1e-10);
259 assert!((MIN_VALUE - (i32::MIN as f64 / 65536.0)).abs() < 1e-10);
260 assert!((RESOLUTION - 1.0 / 65536.0).abs() < 1e-20);
261 }
262
263 #[test]
264 fn mode_label_is_canonical() {
265 assert_eq!(MODE_LABEL, "fixed_q16_16");
266 }
267
268 // ── Periodic resync ────────────────────────────────────────────────────
269
270 #[test]
271 fn periodic_resync_triggers_at_period() {
272 let cfg = PeriodicResyncConfig { period: 1000, max_drift_ulps: 100 };
273 assert!(cfg.should_resync(999));
274 assert!(!cfg.should_resync(998));
275 }
276
277 #[test]
278 fn apply_resync_clamps_to_envelope() {
279 // Q16.16 value that has drifted slightly above the envelope rho
280 let rho_q = quantize_f32(0.10);
281 let drifted = rho_q + 200; // small ulp drift
282 let (clamped, corrected) = apply_periodic_resync(drifted, rho_q, 100);
283 assert!(corrected, "value outside tolerance should be corrected");
284 assert!(clamped <= rho_q, "clamped value must not exceed rho_q");
285 }
286
287 #[test]
288 fn apply_resync_within_tolerance_unchanged() {
289 let rho_q = quantize_f32(0.10);
290 let close = rho_q + 50; // within 100 ulp tolerance
291 let (val, corrected) = apply_periodic_resync(close, rho_q, 100);
292 assert!(!corrected, "within-tolerance value must not be corrected");
293 assert_eq!(val, close, "within-tolerance value must be unchanged");
294 }
295}
296
297// ── Periodic State Resynchronisation ────────────────────────────────────────
298//
299// DEFENCE: "Fixed-Point Precision Loss" (paper §XIX-F).
300//
301// Over billions of fixed-point accumulator steps, rounding errors perform a
302// random walk. The Allan deviation of the rounding noise has a floor slope of
303// −0.5 (white noise) in log-log, meaning cumulative drift grows as √N .
304// At N = 1×10⁹ samples in Q16.16, worst-case cumulative error ≈ √(10⁹) × 2⁻¹⁶
305// ≈ 0.49 — well into the signal band.
306//
307// Defence: every `period` samples, compare the Q16.16 accumulator to the
308// current admissibility envelope and re-zero if drift exceeds `max_drift_ulps`
309// (Unit in the Last Place at fractional bit 16 = 1/65536 ≈ 1.5 × 10⁻⁵).
310// This bounds the random walk to a finite error budget without changing the
311// mathematical invariant of the pipeline — a necessary periodic perturbation
312// correction equivalent to re-painting the "zero" of a measurement instrument.
313//
314// The `test_long_duration_stability` test in `tests/long_duration_stability.rs`
315// validates this defence over 1 000 000 observations (≈ 15 seconds at 64 kSPS).
316
317/// Configuration for periodic fixed-point accumulator resynchronisation.
318///
319/// ## Usage
320///
321/// At every observation step, call `should_resync(obs_count % period)`.
322/// When `true`, call `apply_periodic_resync()` on each Q16.16 accumulator
323/// that carries a running sum over an extended session.
324///
325/// ## Default
326///
327/// `period = 65536` (one full Q16.16 fractional cycle, ≈ 1 s at 64 kSPS).
328/// `max_drift_ulps = 32` (½ LSB of a 16-bit ADC ≈ 7.6 × 10⁻⁶).
329///
330/// # Examples
331///
332/// ```
333/// use dsfb_rf::fixedpoint::{PeriodicResyncConfig, apply_periodic_resync,
334/// quantize_f32};
335/// let cfg = PeriodicResyncConfig { period: 1000, max_drift_ulps: 50 };
336/// let rho_q = quantize_f32(0.10);
337/// let drifted = rho_q + 80;
338/// if cfg.should_resync(999) {
339/// let (v, _) = apply_periodic_resync(drifted, rho_q, cfg.max_drift_ulps);
340/// let _ = v; // re-zeroed value
341/// }
342/// ```
343#[derive(Debug, Clone, Copy)]
344pub struct PeriodicResyncConfig {
345 /// Number of observations between resync opportunities.
346 pub period: u32,
347 /// Maximum allowed accumulator drift in Q16.16 ULPs before correction.
348 /// 1 ULP = 1/65536 ≈ 1.53 × 10⁻⁵ in f32 terms.
349 pub max_drift_ulps: i32,
350}
351
352impl PeriodicResyncConfig {
353 /// Default configuration: period 65536, tolerance 32 ULPs.
354 pub const DEFAULT: Self = Self { period: 65536, max_drift_ulps: 32 };
355
356 /// Returns `true` when `obs_mod_period == period - 1` (epoch boundary).
357 ///
358 /// Call as: `cfg.should_resync(obs_count % cfg.period)`.
359 #[inline]
360 pub fn should_resync(&self, obs_mod_period: u32) -> bool {
361 obs_mod_period == self.period.saturating_sub(1)
362 }
363}
364
365/// Apply a periodic resynchronisation correction to one Q16.16 accumulator.
366///
367/// If `|accumulator - reference_q| > max_drift_ulps`, the accumulator is
368/// **clamped to `reference_q`** and the second return value is `true` (correction applied).
369/// Otherwise the accumulator is returned unchanged (`false`).
370///
371/// ## Arguments
372///
373/// * `accumulator` — current Q16.16 accumulator value.
374/// * `reference_q` — the current admissibility envelope as Q16.16 (ρ_Q).
375/// * `max_drift_ulps` — ULP tolerance before triggering correction.
376///
377/// ## Returns
378///
379/// `(corrected_value, was_corrected)`
380///
381/// # Examples
382///
383/// ```
384/// use dsfb_rf::fixedpoint::{apply_periodic_resync, quantize_f32};
385/// let rho_q = quantize_f32(0.10);
386/// let (v, fixed) = apply_periodic_resync(rho_q + 200, rho_q, 100);
387/// assert!(fixed && v == rho_q);
388/// ```
389#[inline]
390pub fn apply_periodic_resync(
391 accumulator: i32,
392 reference_q: i32,
393 max_drift_ulps: i32,
394) -> (i32, bool) {
395 let drift = accumulator.wrapping_sub(reference_q);
396 let abs_drift = drift.unsigned_abs() as i32;
397 if abs_drift > max_drift_ulps {
398 (reference_q, true)
399 } else {
400 (accumulator, false)
401 }
402}