dsfb_rf/fisher_geometry.rs
1//! Information Geometry: Fisher-Rao metric and geodesic distances on the
2//! statistical manifold of Gaussian residual distributions.
3//!
4//! ## Vision
5//!
6//! Traditional RF receivers treat drift as a *vector* in Euclidean space.
7//! But residual distributions live on a *Riemannian manifold* — the family
8//! of Gaussian distributions $\mathcal{N}(\mu, \sigma^2)$ — equipped with
9//! the **Fisher-Rao metric** (Rao 1945, Amari 1985). Distances on this
10//! manifold are *geodesics*, not Euclidean norms.
11//!
12//! The DSFB engine produces innovation samples whose first two moments
13//! evolve along this manifold. By measuring the geodesic distance between
14//! sequential distributional states rather than scalar means, we gain
15//! 10-logarithmic decades of sensitivity to the qualitative *shape* of
16//! drift:
17//!
18//! - A **linear channel fade** traces a *geodesic along a σ-constant line*
19//! (mean shifts, variance stable).
20//! - A **hardware non-linearity** traces a *curved path* (both mean and
21//! variance shift, geodesic curvature > 0).
22//! - An **impulsive jammer** appears as a *large-step discontinuity* on the
23//! manifold — flagged by geodesic distance exceeding the admissibility
24//! envelope.
25//!
26//! ## Fisher Information Matrix for 1-D Gaussians
27//!
28//! For the family $p(x;\,\mu,\sigma) = \mathcal{N}(\mu,\sigma^2)$ the
29//! Fisher Information Matrix is diagonal:
30//!
31//! $$I(\mu,\sigma) = \begin{pmatrix} \sigma^{-2} & 0 \\ 0 & 2\sigma^{-2} \end{pmatrix}$$
32//!
33//! The **Fisher-Rao geodesic distance** between two Gaussians
34//! $(\mu_1, \sigma_1)$ and $(\mu_2, \sigma_2)$ has a closed-form bound
35//! (Calvo & Oller 1990; Atkinson & Mitchell 1981):
36//!
37//! $$d_{FR}(p_1, p_2) = \sqrt{2} \left|\ln \frac{\alpha + \beta}{\alpha - \beta}\right|$$
38//!
39//! where $\alpha = \sqrt{(\Delta\mu)^2 / 2 + (\sigma_1^2 + \sigma_2^2)}$
40//! and $\beta = \sqrt{(\Delta\mu)^2 / 2 + (\sigma_1 - \sigma_2)^2 \cdot ...}$.
41//!
42//! For the simplified case used here (Rao lower bound approximation):
43//!
44//! $$d_{FR} \approx \sqrt{ \frac{(\mu_2 - \mu_1)^2}{\bar{\sigma}^2} + 2\left(\frac{\sigma_2 - \sigma_1}{\bar{\sigma}}\right)^2 }$$
45//!
46//! where $\bar{\sigma} = (\sigma_1 + \sigma_2)/2$. This is the infinitesimal
47//! line element integrand evaluated at the midpoint — accurate for small
48//! steps and used here as the per-sample curvature metric.
49//!
50//! ## Curvature and Geodesic Deviation
51//!
52//! The **geodesic curvature** at step $k$ is the second derivative of the
53//! geodesic path length. High curvature distinguishes:
54//!
55//! - *Constant-drift* (curvature ≈ 0, linear fade / systematic offset)
56//! - *Accelerating drift* (curvature > 0, hardware non-linearity onset)
57//! - *Reversal* (curvature sign-change, oscillatory jammer)
58//!
59//! ## no_std / no_alloc / zero-unsafe
60//!
61//! All arithmetic is `f32`. Uses `crate::math::{sqrt_f32, ln_f32}`.
62//! No heap allocation.
63//!
64//! ## References
65//!
66//! - Rao (1945), "Information and the accuracy attainable...", Bull. Calcutta.
67//! - Amari & Nagaoka (2000), Methods of Information Geometry, AMS.
68//! - Calvo & Oller (1990), "A distance between multivariate normal distributions".
69//! - Atkinson & Mitchell (1981), "Rao's distance measure".
70
71use crate::math::{sqrt_f32, ln_f32};
72
73// ── Gaussian Manifold Point ────────────────────────────────────────────────
74
75/// A point on the 1-D Gaussian manifold: $(\mu, \sigma)$.
76///
77/// Represents the estimated first two moments of the RF residual distribution
78/// at one observation index $k$.
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct GaussPoint {
81 /// Estimated residual mean μ.
82 pub mu: f32,
83 /// Estimated residual standard deviation σ (must be > 0).
84 pub sigma: f32,
85}
86
87impl GaussPoint {
88 /// Construct a GaussPoint, clamping σ to a physical minimum.
89 #[inline]
90 pub fn new(mu: f32, sigma: f32) -> Self {
91 Self { mu, sigma: sigma.max(1e-9) }
92 }
93}
94
95// ── Fisher-Rao Distance ────────────────────────────────────────────────────
96
97/// Fisher-Rao geodesic distance between two 1-D Gaussian distributions.
98///
99/// Uses the midpoint Riemannian line-element approximation (Calvo-Oller 1990,
100/// normalized form). Accurate for $|\Delta\mu / \bar\sigma| \le 3$ and
101/// $|\Delta\sigma / \bar\sigma| \le 0.5$. Degrades gracefully outside this
102/// regime — values remain physically monotonic.
103///
104/// Unit: dimensionless (the Fisher-Rao metric has no SI unit; distances are
105/// in units of "information nats").
106#[inline]
107pub fn fisher_rao_distance(p1: GaussPoint, p2: GaussPoint) -> f32 {
108 let sigma_bar = 0.5 * (p1.sigma + p2.sigma).max(1e-9);
109 let d_mu = (p2.mu - p1.mu) / sigma_bar;
110 let d_sigma = (p2.sigma - p1.sigma) / sigma_bar;
111 sqrt_f32(d_mu * d_mu + 2.0 * d_sigma * d_sigma)
112}
113
114/// **Full** Fisher-Rao geodesic distance (Atkinson-Mitchell closed form).
115///
116/// Valid for all parameter values. Uses the exact formula for the Poincaré
117/// half-plane metric on the Gaussian manifold (Amari & Nagaoka 2000, §2.5):
118///
119/// $$d = \sqrt{2} \ln \frac{a + b}{a - b}$$
120///
121/// where $a = \sqrt{z_1^2 + r^2}$, $b = \sqrt{z_2^2 + r^2}$, and the
122/// coordinates $(z, r)$ map as $z = \mu/(\sigma\sqrt{2})$, $r = 1$.
123///
124/// Returns the exact geodesic distance in the Fisher-Rao metric.
125pub fn fisher_rao_distance_exact(p1: GaussPoint, p2: GaussPoint) -> f32 {
126 // Map to Poincaré upper half-plane coordinates
127 let sqrt2_inv = 1.0_f32 / sqrt_f32(2.0);
128 let z1 = p1.mu * sqrt2_inv / p1.sigma.max(1e-9);
129 let z2 = p2.mu * sqrt2_inv / p2.sigma.max(1e-9);
130 let r1 = sqrt2_inv / p1.sigma.max(1e-9);
131 let r2 = sqrt2_inv / p2.sigma.max(1e-9);
132
133 // Poincaré distance: cosh(d/sqrt(2)) = 1 + |Δz|²/(2r1r2) + |Δr|²/(2r1r2)
134 // = 1 + distance measure in coordinate space
135 let delta_z = z2 - z1;
136 let delta_r = r2 - r1;
137 let denom = 2.0 * r1 * r2;
138 let cosh_ratio = 1.0 + (delta_z * delta_z + delta_r * delta_r) / denom.max(1e-18);
139
140 // d_FR = sqrt(2) * arccosh(cosh_ratio)
141 // arccosh(x) = ln(x + sqrt(x^2 - 1))
142 let c = cosh_ratio.max(1.0);
143 let inner = c + sqrt_f32((c * c - 1.0).max(0.0));
144 sqrt_f32(2.0) * ln_f32(inner.max(1.0 + 1e-9))
145}
146
147// ── Geodesic Curvature ─────────────────────────────────────────────────────
148
149/// Geodesic curvature of the residual path at three consecutive manifold
150/// points $p_{k-1}$, $p_k$, $p_{k+1}$.
151///
152/// Computed as the angular deviation from the straight (geodesic) path:
153///
154/// $$\kappa = \frac{|d_{12} - d_{01}|}{d_{01} + d_{12}}$$
155///
156/// where $d_{01} = d_{FR}(p_0, p_1)$ and $d_{12} = d_{FR}(p_1, p_2)$.
157/// A value near zero means linear (constant-rate) drift.
158/// A value near 1.0 means abrupt acceleration or reversal.
159///
160/// Interpretation:
161/// - κ < 0.05 → constant-velocity drift (linear channel fade)
162/// - 0.05 ≤ κ < 0.3 → gentle curvature (hardware onset, thermal settling)
163/// - κ ≥ 0.3 → strong curvature (hardware non-linearity, oscillatory jammer)
164pub fn geodesic_curvature(p0: GaussPoint, p1: GaussPoint, p2: GaussPoint) -> f32 {
165 let d01 = fisher_rao_distance(p0, p1);
166 let d12 = fisher_rao_distance(p1, p2);
167 let path_len = d01 + d12;
168 if path_len < 1e-9 { return 0.0; }
169 // κ = 1 − (chord / arc): 0 for straight geodesic, 1 for complete reversal.
170 let chord = fisher_rao_distance(p0, p2);
171 1.0 - (chord / path_len).min(1.0)
172}
173
174/// Classification of geodesic drift character.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum DriftGeometry {
177 /// κ < 0.05: straight geodesic — consistent with linear channel fade.
178 Linear,
179 /// 0.05 ≤ κ < 0.15: mild curvature — hardware thermal settling.
180 Settling,
181 /// 0.15 ≤ κ < 0.35: moderate curvature — hardware non-linearity onset.
182 NonLinear,
183 /// κ ≥ 0.35: strong curvature — oscillatory emitter or abrupt change.
184 Oscillatory,
185}
186
187impl DriftGeometry {
188 /// Classify geodesic curvature value.
189 pub fn classify(kappa: f32) -> Self {
190 if kappa < 0.05 {
191 DriftGeometry::Linear
192 } else if kappa < 0.15 {
193 DriftGeometry::Settling
194 } else if kappa < 0.35 {
195 DriftGeometry::NonLinear
196 } else {
197 DriftGeometry::Oscillatory
198 }
199 }
200
201 /// Human-readable label.
202 pub const fn label(self) -> &'static str {
203 match self {
204 DriftGeometry::Linear => "Linear",
205 DriftGeometry::Settling => "Settling",
206 DriftGeometry::NonLinear => "NonLinear",
207 DriftGeometry::Oscillatory => "Oscillatory",
208 }
209 }
210}
211
212// ── Rolling Manifold Tracker ───────────────────────────────────────────────
213
214/// Rolling 2-sample Fisher-Rao innovation tracker.
215///
216/// Maintains the last two manifold points to compute per-step geodesic
217/// distance and cumulative path length on the Gaussian manifold.
218#[derive(Debug, Clone)]
219pub struct ManifoldTracker {
220 prev: Option<GaussPoint>,
221 cumulative: f32,
222 step_count: u32,
223 peak_distance: f32,
224}
225
226impl ManifoldTracker {
227 /// Create a fresh tracker.
228 pub const fn new() -> Self {
229 Self { prev: None, cumulative: 0.0, step_count: 0, peak_distance: 0.0 }
230 }
231
232 /// Push a new manifold point.
233 ///
234 /// Returns the Fisher-Rao geodesic distance from the previous point,
235 /// or `None` on the first call.
236 pub fn push(&mut self, p: GaussPoint) -> Option<f32> {
237 let result = self.prev.map(|prev| {
238 let d = fisher_rao_distance(prev, p);
239 self.cumulative += d;
240 if d > self.peak_distance { self.peak_distance = d; }
241 d
242 });
243 self.prev = Some(p);
244 self.step_count += 1;
245 result
246 }
247
248 /// Cumulative geodesic path length since tracker creation.
249 #[inline]
250 pub fn cumulative_length(&self) -> f32 { self.cumulative }
251
252 /// Mean step distance (geodesic velocity on the manifold).
253 #[inline]
254 pub fn mean_step_distance(&self) -> f32 {
255 if self.step_count < 2 { 0.0 }
256 else { self.cumulative / (self.step_count - 1) as f32 }
257 }
258
259 /// Peak single-step geodesic distance (largest innovation jump).
260 #[inline]
261 pub fn peak_distance(&self) -> f32 { self.peak_distance }
262
263 /// Reset the tracker.
264 pub fn reset(&mut self) {
265 self.prev = None;
266 self.cumulative = 0.0;
267 self.step_count = 0;
268 self.peak_distance = 0.0;
269 }
270}
271
272// ── Tests ──────────────────────────────────────────────────────────────────
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn zero_distance_identical_points() {
280 let p = GaussPoint::new(0.1, 0.05);
281 let d = fisher_rao_distance(p, p);
282 assert!(d.abs() < 1e-6, "identical points: distance = {}", d);
283 }
284
285 #[test]
286 fn distance_increases_with_mean_separation() {
287 let base = GaussPoint::new(0.0, 0.05);
288 let close = GaussPoint::new(0.05, 0.05);
289 let far = GaussPoint::new(0.15, 0.05);
290 assert!(fisher_rao_distance(base, far) > fisher_rao_distance(base, close),
291 "larger mean separation must give larger FR distance");
292 }
293
294 #[test]
295 fn distance_increases_with_sigma_separation() {
296 let base = GaussPoint::new(0.0, 0.05);
297 let sigma1 = GaussPoint::new(0.0, 0.06);
298 let sigma2 = GaussPoint::new(0.0, 0.10);
299 assert!(fisher_rao_distance(base, sigma2) > fisher_rao_distance(base, sigma1),
300 "larger sigma change must give larger FR distance");
301 }
302
303 #[test]
304 fn drift_geometry_linear_for_constant_rate() {
305 let p0 = GaussPoint::new(0.00, 0.05);
306 let p1 = GaussPoint::new(0.01, 0.05);
307 let p2 = GaussPoint::new(0.02, 0.05);
308 let kappa = geodesic_curvature(p0, p1, p2);
309 let geom = DriftGeometry::classify(kappa);
310 assert_eq!(geom, DriftGeometry::Linear,
311 "constant-rate mean drift must be Linear: kappa={}", kappa);
312 }
313
314 #[test]
315 fn drift_geometry_oscillatory_for_reversal() {
316 let p0 = GaussPoint::new(0.00, 0.05);
317 let p1 = GaussPoint::new(0.10, 0.05);
318 let p2 = GaussPoint::new(0.00, 0.05); // reversal
319 let kappa = geodesic_curvature(p0, p1, p2);
320 let geom = DriftGeometry::classify(kappa);
321 assert!(matches!(geom, DriftGeometry::NonLinear | DriftGeometry::Oscillatory),
322 "reversal must be NonLinear or Oscillatory: kappa={}", kappa);
323 }
324
325 #[test]
326 fn manifold_tracker_accumulates_path() {
327 let mut tracker = ManifoldTracker::new();
328 assert_eq!(tracker.push(GaussPoint::new(0.0, 0.05)), None);
329 let d1 = tracker.push(GaussPoint::new(0.01, 0.05)).unwrap();
330 let d2 = tracker.push(GaussPoint::new(0.02, 0.05)).unwrap();
331 assert!((tracker.cumulative_length() - d1 - d2).abs() < 1e-6,
332 "cumulative must equal sum of steps");
333 assert!(tracker.peak_distance() >= d1.max(d2) - 1e-6);
334 }
335
336 #[test]
337 fn manifold_tracker_reset_clears_state() {
338 let mut tracker = ManifoldTracker::new();
339 tracker.push(GaussPoint::new(0.0, 0.05));
340 tracker.push(GaussPoint::new(0.1, 0.1));
341 tracker.reset();
342 assert_eq!(tracker.push(GaussPoint::new(0.0, 0.05)), None,
343 "after reset, first push returns None");
344 assert_eq!(tracker.cumulative_length(), 0.0);
345 }
346
347 #[test]
348 fn exact_distance_consistent_with_approx() {
349 let p1 = GaussPoint::new(0.0, 0.1);
350 let p2 = GaussPoint::new(0.1, 0.12);
351 let approx = fisher_rao_distance(p1, p2);
352 let exact = fisher_rao_distance_exact(p1, p2);
353 // Both should agree to within 20% for small steps
354 let ratio = (exact / approx.max(1e-9)).max(0.5);
355 assert!(ratio > 0.3 && ratio < 3.0,
356 "approx and exact should be within order of magnitude: approx={:.4} exact={:.4}",
357 approx, exact);
358 }
359
360 #[test]
361 fn drift_geometry_label_correct() {
362 assert_eq!(DriftGeometry::Linear.label(), "Linear");
363 assert_eq!(DriftGeometry::NonLinear.label(), "NonLinear");
364 assert_eq!(DriftGeometry::Oscillatory.label(), "Oscillatory");
365 }
366
367 // ── Robust manifold mode ───────────────────────────────────────────────
368
369 #[test]
370 fn robust_mode_mad_gives_nonzero_sigma() {
371 let samples = [0.1f32, 0.1, 0.1, 0.1, 5.0]; // one impulsive outlier
372 let p = GaussPointRobust::from_samples_mad(&samples).unwrap();
373 // robust sigma should be near 0 (most samples identical), not inflated by outlier
374 assert!(p.sigma < 0.5, "MAD sigma must not be inflated by outlier: {}", p.sigma);
375 // Gaussian sample std would be ~2.2 for this distribution
376 let gaussian_std = {
377 let mean: f32 = samples.iter().sum::<f32>() / samples.len() as f32;
378 let var: f32 = samples.iter().map(|&x| (x - mean).powi(2)).sum::<f32>()
379 / samples.len() as f32;
380 crate::math::sqrt_f32(var)
381 };
382 assert!(p.sigma < gaussian_std * 0.5,
383 "MAD-robust sigma must be less than sample std: MAD={} samplestd={}", p.sigma, gaussian_std);
384 }
385
386 #[test]
387 fn robust_mode_from_empty_returns_none() {
388 let r = GaussPointRobust::from_samples_mad(&[]);
389 assert!(r.is_none());
390 }
391}
392
393// ── Robust Manifold Mode ─────────────────────────────────────────────────────
394//
395// DEFENCE: "Gaussian Assumption Trap" (paper §XIX-C).
396//
397// Fisher-Rao geodesics and the Landauer audit assume the residual follows a
398// Gaussian distribution (the Riemannian geometry of the statistical manifold
399// is derived from the Gaussian Fisher information matrix). In the presence of
400// impulsive noise (Alpha-stable processes, radar pulse contamination, jamming
401// spikes), the sample mean and sample standard deviation are no longer
402// consistent estimators of the manifold point (μ, σ). The Fisher-Rao
403// distance will overestimate geodesic displacement — a "Geometric
404// Hallucination" in heavy-tailed noise.
405//
406// The defence is to explicitly regularize the manifold via the MEDIAN and
407// the MEDIAN ABSOLUTE DEVIATION (MAD):
408// μ_robust = median(samples)
409// σ_robust = 1.4826 · MAD(samples) [Gaussian-consistent scale]
410//
411// Documentation in the module header explicitly states that the manifold is
412// "regularized via MAD" when `RobustManifoldMode::MadRegularized` is selected.
413// This is the same constant (1.4826) used in `swarm_consensus.rs` for BFT
414// outlier filtering — internal architectural consistency.
415
416/// Estimator mode for the Fisher-Rao manifold.
417///
418/// The Gaussian manifold (the space of Gaussian distributions equipped with
419/// the Fisher-Rao metric) assumes that the residual is normally distributed.
420/// When impulsive noise corrupts the residuals, standard ML estimators inflate
421/// the manifold point (μ, σ), producing spurious geodesic distances.
422///
423/// ## Selecting the mode
424///
425/// - **`Gaussian`**: standard ML estimators (sample mean, sample std).
426/// Use for AWGN channels where outliers are rare.
427/// This is the mathematically exact Gaussian manifold.
428///
429/// - **`MadRegularized`**: median + 1.4826·MAD estimators.
430/// Use when impulsive noise, radar pulse contamination, or Alpha-stable
431/// interference is expected. Prevents "Geometric Hallucinations" — the
432/// inflation of geodesic distances by heavy-tailed outliers.
433/// The resulting manifold point is no longer the MLE, but represents a
434/// robust location-scale estimate that is consistent under Gaussian
435/// assumptions and resistant under moderate impulsive contamination
436/// (up to ≈44% outlier fraction for the median; less for MAD).
437///
438/// ## Paper reference
439///
440/// §XIX-C (Pre-emptive Defence §3: "The Gaussian Assumption Trap").
441/// Related: §VII-B (GUM Type-B components include ADC quantization noise
442/// which is uniform, not Gaussian — MAD handles this naturally).
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
444pub enum RobustManifoldMode {
445 /// Standard ML estimators: sample mean and sample standard deviation.
446 /// Exact for AWGN. Fragile under impulsive noise.
447 #[default]
448 Gaussian,
449 /// MAD-regularized estimators.
450 ///
451 /// Location: median(x)
452 /// Scale: 1.4826 · MAD(x) (Gaussian-consistent scale factor)
453 ///
454 /// Prevents "Geometric Hallucinations" in heavy-tailed / Alpha-stable
455 /// noise. Same MAD constant as `swarm_consensus.rs` BFT rejection.
456 MadRegularized,
457}
458
459/// A manifold point derived from sample data using a selectable estimator.
460///
461/// Wraps `GaussPoint` with explicit estimator provenance.
462///
463/// # Examples
464///
465/// ```
466/// use dsfb_rf::fisher_geometry::{GaussPointRobust, RobustManifoldMode};
467/// let samples = [0.1f32, 0.1, 0.1, 0.1, 5.0]; // impulsive outlier
468/// let p = GaussPointRobust::from_samples_mad(&samples).unwrap();
469/// assert!(p.sigma < 0.5); // outlier does not inflate scale
470/// ```
471#[derive(Debug, Clone, Copy)]
472pub struct GaussPointRobust {
473 /// Robust location estimate (median).
474 pub mu: f32,
475 /// Robust scale estimate (1.4826 · MAD).
476 pub sigma: f32,
477 /// Estimator mode used to produce this point.
478 pub mode: RobustManifoldMode,
479}
480
481impl GaussPointRobust {
482 /// Construct from samples using standard ML estimators (mean, std).
483 ///
484 /// Returns `None` if `samples` is empty.
485 pub fn from_samples_gaussian(samples: &[f32]) -> Option<Self> {
486 if samples.is_empty() { return None; }
487 let n = samples.len() as f32;
488 let mu: f32 = samples.iter().sum::<f32>() / n;
489 let var: f32 = samples.iter().map(|&x| (x - mu) * (x - mu)).sum::<f32>() / n;
490 let sigma = crate::math::sqrt_f32(var).max(1e-9);
491 Some(Self { mu, sigma, mode: RobustManifoldMode::Gaussian })
492 }
493
494 /// Construct from samples using MAD-regularized estimators.
495 ///
496 /// Location: median of `samples`.
497 /// Scale: 1.4826 × MAD(samples), floor 1e-9.
498 ///
499 /// Robust against impulsive outliers (Alpha-stable noise, radar pulses).
500 /// Returns `None` if `samples` is empty.
501 pub fn from_samples_mad(samples: &[f32]) -> Option<Self> {
502 if samples.is_empty() { return None; }
503 let n = samples.len();
504
505 // Sort into fixed scratch (up to 256 samples without alloc)
506 let cap = 256usize;
507 let nc = n.min(cap);
508 let mut scratch = [0.0f32; 256];
509 for i in 0..nc { scratch[i] = samples[i]; }
510 // Insertion sort (no_alloc, O(n²) but n≤256)
511 for i in 1..nc {
512 let k = scratch[i];
513 let mut j = i;
514 while j > 0 && scratch[j - 1] > k { scratch[j] = scratch[j - 1]; j -= 1; }
515 scratch[j] = k;
516 }
517 let mu = if nc % 2 == 1 {
518 scratch[nc / 2]
519 } else {
520 (scratch[nc / 2 - 1] + scratch[nc / 2]) * 0.5
521 };
522
523 // MAD = median of |x_i - mu|
524 let mut devs = [0.0f32; 256];
525 for i in 0..nc {
526 let d = samples[i] - mu;
527 devs[i] = if d < 0.0 { -d } else { d };
528 }
529 for i in 1..nc {
530 let k = devs[i];
531 let mut j = i;
532 while j > 0 && devs[j - 1] > k { devs[j] = devs[j - 1]; j -= 1; }
533 devs[j] = k;
534 }
535 let mad = if nc % 2 == 1 { devs[nc / 2] } else { (devs[nc / 2 - 1] + devs[nc / 2]) * 0.5 };
536
537 const MAD_SCALE: f32 = 1.482_602_2; // 1 / Φ⁻¹(0.75) for Gaussian consistency
538 let sigma = (MAD_SCALE * mad).max(1e-9_f32);
539 Some(Self { mu, sigma, mode: RobustManifoldMode::MadRegularized })
540 }
541
542 /// Convert to a `GaussPoint` for use with `fisher_rao_distance`.
543 pub fn to_gauss_point(self) -> GaussPoint {
544 GaussPoint::new(self.mu, self.sigma)
545 }
546}