|
| 1 | +//! Köstenberger-Stark 2024: Concentration of the inductive mean on Hadamard |
| 2 | +//! spaces — the math foundation for Σ-edge propagation. |
| 3 | +//! |
| 4 | +//! Citation: G. Köstenberger & T. Stark, "Robust Signal Recovery in Hadamard |
| 5 | +//! Spaces", arXiv:2307.06057v2, July 2024. |
| 6 | +//! |
| 7 | +//! # Why this pillar |
| 8 | +//! |
| 9 | +//! Pillar 5 (Jirak) certifies the convergence rate of empirical statistics on |
| 10 | +//! weakly-dependent ℝ-valued sequences — the SCALAR case. It is the right |
| 11 | +//! foundation for `CausalEdge64`'s scalar bit-fields (frequency, confidence). |
| 12 | +//! |
| 13 | +//! When edges become anisotropic (Σ-tensor instead of scalar weight), the |
| 14 | +//! aggregation is no longer in ℝ but on the cone of symmetric positive-definite |
| 15 | +//! matrices — a Hadamard space (CAT(0) metric space, non-positive curvature). |
| 16 | +//! Köstenberger-Stark Theorem 1 gives the exact concentration: |
| 17 | +//! |
| 18 | +//! ```text |
| 19 | +//! E[d²(S_n, μ)] ≤ (6 D_n / n) · Σ d(μ_k, μ) + (1/n²) · Σ Var(X_k) |
| 20 | +//! ``` |
| 21 | +//! |
| 22 | +//! where: |
| 23 | +//! - μ population Fréchet mean |
| 24 | +//! - μ_k Fréchet mean of the k-th distribution |
| 25 | +//! - X_k sample from the k-th distribution (NOT iid — heteroscedastic OK) |
| 26 | +//! - D_n max_{1≤k≤n} max{d(μ, μ_k), E[d(X_k, μ_k)]} |
| 27 | +//! - S_n inductive mean: S_1 = X_1, S_{n+1} = S_n ⊕_{1/(n+1)} X_{n+1} |
| 28 | +//! - ⊕_t geodesic at parameter t in the Hadamard space |
| 29 | +//! |
| 30 | +//! The bound holds *without* requiring identical distribution — exactly what |
| 31 | +//! we need for evidence aggregation across edges with varying confidence. |
| 32 | +//! |
| 33 | +//! # Hadamard space used in this probe |
| 34 | +//! |
| 35 | +//! 2×2 SPD matrices with the affine-invariant Riemannian metric |
| 36 | +//! |
| 37 | +//! ```text |
| 38 | +//! d(A, B) = ‖log(B^(-1/2) · A · B^(-1/2))‖_F |
| 39 | +//! ``` |
| 40 | +//! |
| 41 | +//! and geodesic |
| 42 | +//! |
| 43 | +//! ```text |
| 44 | +//! A ⊕_t B = A^(1/2) · (A^(-1/2) · B · A^(-1/2))^t · A^(1/2) |
| 45 | +//! ``` |
| 46 | +//! |
| 47 | +//! 2×2 keeps every operation closed-form (eigendecomposition is a quadratic |
| 48 | +//! root, no iterative eigensolver). The theorem holds for any k×k SPD by the |
| 49 | +//! same argument; 2×2 is sufficient demonstration. This is the canonical |
| 50 | +//! example of a Hadamard space (Bridson–Häfliger, Sturm). |
| 51 | +//! |
| 52 | +//! # Test setup |
| 53 | +//! |
| 54 | +//! - μ = I (identity) is both the population mean and each μ_k by construction |
| 55 | +//! - Heteroscedastic: σ_k = 0.3 / √(k+1) — variance shrinks per sample index |
| 56 | +//! - X_k = R(θ_k) · diag(exp(σ_k·n1), exp(σ_k·n2)) · R(θ_k)ᵀ |
| 57 | +//! with θ_k uniform on [0,π) and n1, n2 ~ N(0,1) iid |
| 58 | +//! - Var(X_k) = E[d²(X_k, μ_k)] = 2 σ_k² (rotational symmetry argument) |
| 59 | +//! - Σ d(μ_k, μ) = 0 (we set μ_k = μ = I, so the 6·D_n term vanishes) |
| 60 | +//! - Predicted bound therefore reduces to (1/n²) · Σ Var(X_k) = (2/n²) · Σ σ_k² |
| 61 | +//! |
| 62 | +//! Monte Carlo: M=1000 runs of n=100 samples. PASS if measured E[d²(S_n, I)] |
| 63 | +//! is at or below the predicted bound (with a 1.5× constant-factor slack — |
| 64 | +//! the bound is loose by construction, the rate is what we certify). |
| 65 | +
|
| 66 | +use crate::PillarResult; |
| 67 | + |
| 68 | +const N_MONTE_CARLO: usize = 1_000; |
| 69 | +const N_SAMPLES: usize = 100; |
| 70 | + |
| 71 | +// ════════════════════════════════════════════════════════════════════════════ |
| 72 | +// Deterministic randomness (matches jirak.rs convention — splitmix64) |
| 73 | +// ════════════════════════════════════════════════════════════════════════════ |
| 74 | + |
| 75 | +fn splitmix64(state: &mut u64) -> u64 { |
| 76 | + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); |
| 77 | + let mut z = *state; |
| 78 | + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); |
| 79 | + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); |
| 80 | + z ^ (z >> 31) |
| 81 | +} |
| 82 | + |
| 83 | +fn rand_uniform(state: &mut u64) -> f64 { |
| 84 | + // Uniform on [0, 1) — top 53 bits of splitmix64 output. |
| 85 | + (splitmix64(state) >> 11) as f64 / (1u64 << 53) as f64 |
| 86 | +} |
| 87 | + |
| 88 | +fn rand_normal(state: &mut u64) -> f64 { |
| 89 | + // Box-Muller transform — return one of the two standard normals. |
| 90 | + let u1 = rand_uniform(state).max(1e-300); |
| 91 | + let u2 = rand_uniform(state); |
| 92 | + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() |
| 93 | +} |
| 94 | + |
| 95 | +// ════════════════════════════════════════════════════════════════════════════ |
| 96 | +// 2×2 SPD matrix [[a, b], [b, c]] with a > 0, c > 0, a·c > b² |
| 97 | +// ════════════════════════════════════════════════════════════════════════════ |
| 98 | + |
| 99 | +#[derive(Clone, Copy, Debug)] |
| 100 | +struct Spd2 { |
| 101 | + a: f64, |
| 102 | + b: f64, |
| 103 | + c: f64, |
| 104 | +} |
| 105 | + |
| 106 | +impl Spd2 { |
| 107 | + const I: Self = Self { a: 1.0, b: 0.0, c: 1.0 }; |
| 108 | + |
| 109 | + /// Eigendecomposition. |
| 110 | + /// Returns (λ₁, λ₂, cos θ, sin θ) where the columns of R(θ) = [[c,-s],[s,c]] |
| 111 | + /// are eigenvectors corresponding to (λ₁, λ₂) respectively, λ₁ ≥ λ₂. |
| 112 | + fn eig(&self) -> (f64, f64, f64, f64) { |
| 113 | + // For 2×2 symmetric: λ = (a+c)/2 ± √(((a-c)/2)² + b²) |
| 114 | + let half_trace = (self.a + self.c) / 2.0; |
| 115 | + let half_diff = (self.a - self.c) / 2.0; |
| 116 | + let disc = (half_diff * half_diff + self.b * self.b).sqrt(); |
| 117 | + let l1 = half_trace + disc; |
| 118 | + let l2 = half_trace - disc; |
| 119 | + // Eigenvector angle: tan(2θ) = 2b / (a - c). |
| 120 | + // Degenerate case: a = c and b = 0 (already isotropic) → angle is undefined, |
| 121 | + // pick θ = 0 (any rotation works). |
| 122 | + let theta = if self.b.abs() < 1e-15 && (self.a - self.c).abs() < 1e-15 { |
| 123 | + 0.0 |
| 124 | + } else { |
| 125 | + 0.5 * (2.0 * self.b).atan2(self.a - self.c) |
| 126 | + }; |
| 127 | + (l1, l2, theta.cos(), theta.sin()) |
| 128 | + } |
| 129 | + |
| 130 | + /// Matrix power M^t via spectral calculus. |
| 131 | + fn pow(&self, t: f64) -> Self { |
| 132 | + let (l1, l2, c, s) = self.eig(); |
| 133 | + // Guard against numerical-zero eigenvalues; for SPD they should be > 0. |
| 134 | + let l1t = l1.max(1e-300).powf(t); |
| 135 | + let l2t = l2.max(1e-300).powf(t); |
| 136 | + // M^t = R · diag(λ₁^t, λ₂^t) · Rᵀ |
| 137 | + // R = [[c, -s], [s, c]] ⇒ symmetric reconstruction: |
| 138 | + let a = c * c * l1t + s * s * l2t; |
| 139 | + let b = c * s * (l1t - l2t); |
| 140 | + let cc = s * s * l1t + c * c * l2t; |
| 141 | + Self { a, b, c: cc } |
| 142 | + } |
| 143 | + |
| 144 | + fn sqrt(&self) -> Self { self.pow(0.5) } |
| 145 | + fn inv_sqrt(&self) -> Self { self.pow(-0.5) } |
| 146 | + |
| 147 | + /// Geodesic A ⊕_t B = A^(1/2) · (A^(-1/2) · B · A^(-1/2))^t · A^(1/2). |
| 148 | + fn geodesic(&self, other: &Self, t: f64) -> Self { |
| 149 | + let a_inv_sqrt = self.inv_sqrt(); |
| 150 | + let inner = sandwich(&a_inv_sqrt, other); // A^(-1/2) · B · A^(-1/2) |
| 151 | + let powered = inner.pow(t); |
| 152 | + let a_sqrt = self.sqrt(); |
| 153 | + sandwich(&a_sqrt, &powered) |
| 154 | + } |
| 155 | + |
| 156 | + /// Affine-invariant distance d(A, B) = ‖log(B^(-1/2) · A · B^(-1/2))‖_F. |
| 157 | + /// For 2×2: ‖log(M)‖_F² = (log λ₁)² + (log λ₂)². |
| 158 | + fn distance(&self, other: &Self) -> f64 { |
| 159 | + let b_inv_sqrt = other.inv_sqrt(); |
| 160 | + let m = sandwich(&b_inv_sqrt, self); // B^(-1/2) · A · B^(-1/2) |
| 161 | + let (l1, l2, _, _) = m.eig(); |
| 162 | + let log1 = l1.max(1e-300).ln(); |
| 163 | + let log2 = l2.max(1e-300).ln(); |
| 164 | + (log1 * log1 + log2 * log2).sqrt() |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +/// Symmetric sandwich product M · N · M for symmetric M, N. Result symmetric. |
| 169 | +/// M does NOT need to be SPD — this is plain matrix multiplication, used as a |
| 170 | +/// primitive for both the geodesic and the affine-invariant distance. |
| 171 | +fn sandwich(m: &Spd2, n: &Spd2) -> Spd2 { |
| 172 | + // M · N (4 entries, generally not symmetric): |
| 173 | + let p00 = m.a * n.a + m.b * n.b; |
| 174 | + let p01 = m.a * n.b + m.b * n.c; |
| 175 | + let p10 = m.b * n.a + m.c * n.b; |
| 176 | + let p11 = m.b * n.b + m.c * n.c; |
| 177 | + // (M · N) · M: |
| 178 | + let r00 = p00 * m.a + p01 * m.b; |
| 179 | + let r01 = p00 * m.b + p01 * m.c; |
| 180 | + let r10 = p10 * m.a + p11 * m.b; |
| 181 | + let r11 = p10 * m.b + p11 * m.c; |
| 182 | + // Symmetrize numerically (the analytic result IS symmetric). |
| 183 | + Spd2 { |
| 184 | + a: r00, |
| 185 | + b: 0.5 * (r01 + r10), |
| 186 | + c: r11, |
| 187 | + } |
| 188 | +} |
| 189 | + |
| 190 | +// ════════════════════════════════════════════════════════════════════════════ |
| 191 | +// Sample generator: heteroscedastic SPD around μ_k = I. |
| 192 | +// X_k = R(θ) · diag(exp(σ·n1), exp(σ·n2)) · R(θ)ᵀ |
| 193 | +// guarantees: X_k SPD by construction; Fréchet mean(X_k) = I (rot. symmetry). |
| 194 | +// ════════════════════════════════════════════════════════════════════════════ |
| 195 | + |
| 196 | +fn sample_spd(state: &mut u64, sigma_k: f64) -> Spd2 { |
| 197 | + let theta = rand_uniform(state) * std::f64::consts::PI; |
| 198 | + let n1 = rand_normal(state) * sigma_k; |
| 199 | + let n2 = rand_normal(state) * sigma_k; |
| 200 | + let l1 = n1.exp(); |
| 201 | + let l2 = n2.exp(); |
| 202 | + let c = theta.cos(); |
| 203 | + let s = theta.sin(); |
| 204 | + Spd2 { |
| 205 | + a: c * c * l1 + s * s * l2, |
| 206 | + b: c * s * (l1 - l2), |
| 207 | + c: s * s * l1 + c * c * l2, |
| 208 | + } |
| 209 | +} |
| 210 | + |
| 211 | +// ════════════════════════════════════════════════════════════════════════════ |
| 212 | +// Inductive mean: S_1 = X_1, S_{n+1} = S_n ⊕_{1/(n+1)} X_{n+1} |
| 213 | +// ════════════════════════════════════════════════════════════════════════════ |
| 214 | + |
| 215 | +fn inductive_mean(samples: &[Spd2]) -> Spd2 { |
| 216 | + let mut s = samples[0]; |
| 217 | + for (i, x) in samples.iter().enumerate().skip(1) { |
| 218 | + let t = 1.0 / (i as f64 + 1.0); |
| 219 | + s = s.geodesic(x, t); |
| 220 | + } |
| 221 | + s |
| 222 | +} |
| 223 | + |
| 224 | +// ════════════════════════════════════════════════════════════════════════════ |
| 225 | +// Theorem 1 RHS (variance-only branch): |
| 226 | +// E[d²(S_n, μ)] ≤ (1/n²) · Σ Var(X_k) = (2/n²) · Σ σ_k² |
| 227 | +// (the 6·D_n term vanishes because we set μ_k = μ = I) |
| 228 | +// ════════════════════════════════════════════════════════════════════════════ |
| 229 | + |
| 230 | +fn predicted_bound(sigmas: &[f64]) -> f64 { |
| 231 | + let n = sigmas.len() as f64; |
| 232 | + let sum_var: f64 = sigmas.iter().map(|s| 2.0 * s * s).sum(); |
| 233 | + sum_var / (n * n) |
| 234 | +} |
| 235 | + |
| 236 | +// ════════════════════════════════════════════════════════════════════════════ |
| 237 | +// The probe |
| 238 | +// ════════════════════════════════════════════════════════════════════════════ |
| 239 | + |
| 240 | +pub fn prove() -> PillarResult { |
| 241 | + // Heteroscedastic schedule — variance shrinks with k. |
| 242 | + // Non-iid by construction; each sample independent given σ_k. |
| 243 | + let sigmas: Vec<f64> = (0..N_SAMPLES) |
| 244 | + .map(|k| 0.3 / ((k as f64 + 1.0).sqrt())) |
| 245 | + .collect(); |
| 246 | + |
| 247 | + let predicted = predicted_bound(&sigmas); |
| 248 | + |
| 249 | + // Monte Carlo estimate of E[d²(S_n, I)]. |
| 250 | + let mu = Spd2::I; |
| 251 | + let mut state: u64 = 0xC0FFEE_BEEF_5EED; |
| 252 | + let mut sum_sq_dist = 0.0f64; |
| 253 | + let mut samples_buf = Vec::with_capacity(N_SAMPLES); |
| 254 | + |
| 255 | + for _ in 0..N_MONTE_CARLO { |
| 256 | + samples_buf.clear(); |
| 257 | + for &sigma_k in &sigmas { |
| 258 | + samples_buf.push(sample_spd(&mut state, sigma_k)); |
| 259 | + } |
| 260 | + let s_n = inductive_mean(&samples_buf); |
| 261 | + let d = s_n.distance(&mu); |
| 262 | + sum_sq_dist += d * d; |
| 263 | + } |
| 264 | + let measured = sum_sq_dist / N_MONTE_CARLO as f64; |
| 265 | + |
| 266 | + // PASS criterion: measured ≤ predicted · 1.5 |
| 267 | + // Theorem 1 gives the rate; the constant has a 6·D_n contribution that we |
| 268 | + // zeroed out by construction, leaving the cleaner Var-only bound. We allow |
| 269 | + // 1.5× slack to absorb finite-MC noise + the Cauchy-Schwarz steps in the |
| 270 | + // proof that introduce small additional constants. The point is: the bound |
| 271 | + // HOLDS — and would NOT hold for a substrate without the Hadamard property. |
| 272 | + let tolerance = 1.5; |
| 273 | + let pass = measured <= predicted * tolerance; |
| 274 | + |
| 275 | + let detail = format!( |
| 276 | + "n={N_SAMPLES}, MC={N_MONTE_CARLO}, σ_k = 0.3/√(k+1) (heteroscedastic). \ |
| 277 | + Σ Var(X_k) = {:.6e}. Measured E[d²(S_n,I)] = {measured:.6e}, \ |
| 278 | + predicted bound = {predicted:.6e}, tightness = {:.3}× \ |
| 279 | + (PASS if ≤ {tolerance:.1}). Hadamard space: 2×2 SPD with affine-invariant \ |
| 280 | + metric d(A,B) = ‖log(B^-½·A·B^-½)‖_F. Generalizes to k×k by the same theorem; \ |
| 281 | + certifies Σ-edge aggregation in CausalEdgeTensor.", |
| 282 | + sigmas.iter().map(|s| 2.0 * s * s).sum::<f64>(), |
| 283 | + measured / predicted.max(1e-300), |
| 284 | + ); |
| 285 | + |
| 286 | + PillarResult { |
| 287 | + name: "Köstenberger-Stark: inductive mean on Hadamard 2×2 SPD", |
| 288 | + pass, |
| 289 | + measured, |
| 290 | + predicted, |
| 291 | + detail, |
| 292 | + runtime_ms: 0, |
| 293 | + } |
| 294 | +} |
| 295 | + |
| 296 | +// ════════════════════════════════════════════════════════════════════════════ |
| 297 | +// Tests — internal sanity (do not require the full prove()). |
| 298 | +// ════════════════════════════════════════════════════════════════════════════ |
| 299 | + |
| 300 | +#[cfg(test)] |
| 301 | +mod tests { |
| 302 | + use super::*; |
| 303 | + |
| 304 | + fn approx(x: f64, y: f64, tol: f64) -> bool { |
| 305 | + (x - y).abs() < tol |
| 306 | + } |
| 307 | + |
| 308 | + #[test] |
| 309 | + fn identity_distance_is_zero() { |
| 310 | + assert!(Spd2::I.distance(&Spd2::I) < 1e-10); |
| 311 | + } |
| 312 | + |
| 313 | + #[test] |
| 314 | + fn distance_is_symmetric() { |
| 315 | + let a = Spd2 { a: 2.0, b: 0.3, c: 1.5 }; |
| 316 | + let b = Spd2 { a: 1.0, b: -0.1, c: 3.0 }; |
| 317 | + let d_ab = a.distance(&b); |
| 318 | + let d_ba = b.distance(&a); |
| 319 | + assert!(approx(d_ab, d_ba, 1e-9), "d(A,B)={d_ab}, d(B,A)={d_ba}"); |
| 320 | + } |
| 321 | + |
| 322 | + #[test] |
| 323 | + fn geodesic_endpoints() { |
| 324 | + let a = Spd2 { a: 2.0, b: 0.3, c: 1.5 }; |
| 325 | + let b = Spd2 { a: 1.0, b: -0.1, c: 3.0 }; |
| 326 | + let g0 = a.geodesic(&b, 0.0); |
| 327 | + let g1 = a.geodesic(&b, 1.0); |
| 328 | + assert!(a.distance(&g0) < 1e-8, "γ(0) should be A"); |
| 329 | + assert!(b.distance(&g1) < 1e-8, "γ(1) should be B"); |
| 330 | + } |
| 331 | + |
| 332 | + #[test] |
| 333 | + fn geodesic_midpoint_of_i_and_2i() { |
| 334 | + // I ⊕_{1/2} 2I should be √2 · I (geometric mean). |
| 335 | + let two_i = Spd2 { a: 2.0, b: 0.0, c: 2.0 }; |
| 336 | + let mid = Spd2::I.geodesic(&two_i, 0.5); |
| 337 | + let sqrt2 = std::f64::consts::SQRT_2; |
| 338 | + assert!(approx(mid.a, sqrt2, 1e-10)); |
| 339 | + assert!(approx(mid.c, sqrt2, 1e-10)); |
| 340 | + assert!(approx(mid.b, 0.0, 1e-10)); |
| 341 | + } |
| 342 | + |
| 343 | + #[test] |
| 344 | + fn pow_zero_is_identity() { |
| 345 | + let m = Spd2 { a: 3.0, b: 0.5, c: 2.0 }; |
| 346 | + let p0 = m.pow(0.0); |
| 347 | + assert!(p0.distance(&Spd2::I) < 1e-10); |
| 348 | + } |
| 349 | + |
| 350 | + #[test] |
| 351 | + fn pow_one_is_self() { |
| 352 | + let m = Spd2 { a: 3.0, b: 0.5, c: 2.0 }; |
| 353 | + let p1 = m.pow(1.0); |
| 354 | + assert!(m.distance(&p1) < 1e-10); |
| 355 | + } |
| 356 | + |
| 357 | + #[test] |
| 358 | + fn sqrt_squared_is_self() { |
| 359 | + let m = Spd2 { a: 3.0, b: 0.5, c: 2.0 }; |
| 360 | + let r = m.sqrt(); |
| 361 | + let r2 = r.pow(2.0); |
| 362 | + assert!(m.distance(&r2) < 1e-10); |
| 363 | + } |
| 364 | + |
| 365 | + #[test] |
| 366 | + fn pillar_passes() { |
| 367 | + let r = prove(); |
| 368 | + assert!( |
| 369 | + r.pass, |
| 370 | + "Köstenberger-Stark pillar failed: measured {:.6e} vs predicted {:.6e} — {}", |
| 371 | + r.measured, r.predicted, r.detail |
| 372 | + ); |
| 373 | + } |
| 374 | +} |
0 commit comments