Skip to content

Commit 0a7b7e5

Browse files
committed
jc/pillar-7-3d: fix repeated-eigenvalue eigvec recovery on rotated axisymmetric Σ
User-reported bug against the merged Pillar-7 probe (PR #403): > When a covariance has a repeated eigenvalue but is not diagonal (for > example an axisymmetric 3DGS splat with s = [2, 2, 1] and a nontrivial > quaternion), A - λI is rank one and all row-pair cross products for the > repeated λ are zero, so eigvec_for falls back to the same arbitrary > vector for both equal eigenvalues. These duplicate, non-orthogonal > vectors are then used by sqrt() and log_spd() for spectral > reconstruction, producing a matrix that is not the square root/log of > the input; the new 3D sandwich certification can therefore report > incorrect results for common axisymmetric covariances even though the > diagonal fast-path tests pass. ## Root cause `Spd3::eig` called `eigvec_for` three times independently — one per eigenvalue. For repeated eigenvalues (λ₁ = λ₂), both calls hit the same matrix `A − λ·I`, picked the same largest cross-product among row pairs, and returned the SAME unit vector. V was rank-deficient and `spectral_reconstruct(sqrt λ, V)` was NOT the square root of Σ. The diagonal fast-path branch (p1 < eps_diag) bypassed eigvec_for entirely and returned the canonical basis — so axis-aligned tests passed even though the rotated-axisymmetric case was broken. The same bug existed in ndarray's `splat3d::spd3::recover_eigvecs` in the initial PR 1 commit; it was fixed there during the PP-13 audit loop (ndarray commit 08f90ff) with the same duplicate-detection pass this commit applies to the jc probe. ## Fix After the three independent `eigvec_for` recoveries: 1. Detect column pairs with |cos θ| > 0.99 (≈ 8° tolerance, well above f64 roundoff). Demote the later column. 2. For each demoted column, fill via `gram_schmidt_complement_3d` against the basis built from the remaining filled eigenvectors — any orthogonal completion spans the degenerate eigenspace equally well, so `Σ = V · diag(λ) · Vᵀ` is invariant. 3. Final modified-Gram-Schmidt pass via `orthonormalize_columns` suppresses cross-orthogonality drift to ~1e-15 at f64. ## Regression tests added (4) The diagonal fast-path tests and the random-rotation `sandwich_preserves_spd` test did not exercise this code path. Four new tests target the rotated-axisymmetric class explicitly: - `rotated_axisymmetric_sqrt_squared_equals_sigma` — the user-reported property. s = [2, 2, 1] (pancake splat) under a 30° rotation about (1,1,1)/√3. sqrt(Σ)² must equal Σ. Pre-fix: ~5% error on diagonals. Post-fix: ~1e-8 (f64 epsilon for compound computation). - `rotated_axisymmetric_eigenvalues_are_correct` — eigenvalues = (4, 4, 1) regardless of rotation. Tolerance 1e-7 (acos-based Smith-1961 accumulates ~1e-8 on quat-constructed inputs). - `rotated_axisymmetric_eigvecs_orthonormal` — every column unit length AND every pair orthogonal. Pre-fix: dot(v0, v1) ≈ 1 (same vector). Post-fix: |dot| < 1e-9. - `rotated_axisymmetric_log_spd_finite_and_correct` — log_spd uses the same spectral lift; for Σ with eigenvalues (4, 4, 1), ‖log Σ‖_F² = 2·(ln 4)² ≈ 3.844. Pre-fix: log_spd corrupted by rank-deficient V. Post-fix: matches analytical value to 1e-6. ## Test count cargo test --manifest-path crates/jc/Cargo.toml --release → 64 passed (was 60: +4 regression tests, all green) ewa_sandwich_3d::tests: → 13 passed (was 9: +4 new), pillar_passes still PASS https://claude.ai/code/session_017GFLBnDy23AWBqvkbHHC41
1 parent c22b92d commit 0a7b7e5

1 file changed

Lines changed: 253 additions & 2 deletions

File tree

crates/jc/src/ewa_sandwich_3d.rs

Lines changed: 253 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,12 +156,55 @@ impl Spd3 {
156156
let lam3 = q + 2.0 * p * (phi + two_pi_3).cos();
157157
let lam2 = 3.0 * q - lam1 - lam3;
158158

159-
// Eigenvectors via cross-product of two rows of (A - λᵢ·I)
160-
let vecs = [
159+
// Eigenvectors via cross-product of two rows of (A - λᵢ·I).
160+
//
161+
// The duplicate-eigenvalue trap (user-reported bug against the
162+
// merged Pillar-7 probe): when λᵢ = λⱼ, two independent
163+
// `eigvec_for` calls hit the same matrix A - λ·I, find the same
164+
// largest cross-product among row pairs, and return the SAME
165+
// unit vector. The resulting V is rank-deficient and
166+
// `spectral_reconstruct(sqrt λ, V)` is NOT the square root of Σ.
167+
// This shows up immediately on axisymmetric 3DGS splats
168+
// (scale = [a, a, b] under any non-identity rotation) — a
169+
// common case the diagonal fast-path tests do NOT cover, since
170+
// a rotated axisymmetric matrix has p1 > eps_diag and falls
171+
// into this general branch.
172+
//
173+
// The fix: after the three independent recoveries, detect
174+
// column pairs with |cos θ| > 0.99 (≈ 8° tolerance, well above
175+
// f64 roundoff for the cross-product null-space recovery) and
176+
// demote the later column. The Gram-Schmidt-complement step
177+
// replaces each demoted column with a unit vector orthogonal
178+
// to all already-filled eigenvectors — any such vector spans
179+
// the degenerate eigenspace equally well, so
180+
// Σ = V · diag(λ) · Vᵀ is invariant under the choice. A final
181+
// modified Gram-Schmidt pass suppresses the cross-orthogonality
182+
// drift accumulated by independent cross-product recoveries.
183+
let mut vecs = [
161184
eigvec_for(self, lam1),
162185
eigvec_for(self, lam2),
163186
eigvec_for(self, lam3),
164187
];
188+
let mut filled = [true; 3];
189+
for i in 0..3 {
190+
for j in (i + 1)..3 {
191+
if !filled[i] || !filled[j] {
192+
continue;
193+
}
194+
let cos_theta = dot3(vecs[i], vecs[j]).abs();
195+
if cos_theta > 0.99 {
196+
filled[j] = false;
197+
}
198+
}
199+
}
200+
for k in 0..3 {
201+
if filled[k] {
202+
continue;
203+
}
204+
vecs[k] = gram_schmidt_complement_3d(&vecs, &filled, k);
205+
filled[k] = true;
206+
}
207+
orthonormalize_columns(&mut vecs);
165208
(lam1, lam2, lam3, vecs)
166209
}
167210

@@ -287,6 +330,83 @@ fn gram_schmidt_fallback(v: [f64; 3]) -> [f64; 3] {
287330
[best[0]/norm, best[1]/norm, best[2]/norm]
288331
}
289332

333+
/// Find a unit vector orthogonal to all currently-filled eigenvectors
334+
/// in `vecs`, skipping column `skip`. Used by `eig` when a duplicate
335+
/// eigenvector is detected and demoted — any orthogonal completion
336+
/// spans the degenerate eigenspace equally well.
337+
///
338+
/// Cases by basis size:
339+
/// 0 filled → return ê_x (canonical axis 0)
340+
/// 1 filled → return Gram-Schmidt projection of the canonical axis
341+
/// least parallel to the single basis vector
342+
/// 2 filled → return the cross product of the two basis vectors
343+
/// (in 3D this is the unique third orthogonal direction)
344+
fn gram_schmidt_complement_3d(
345+
vecs: &[[f64; 3]; 3],
346+
filled: &[bool; 3],
347+
skip: usize,
348+
) -> [f64; 3] {
349+
let mut basis: [[f64; 3]; 2] = [[0.0; 3]; 2];
350+
let mut n = 0usize;
351+
for k in 0..3 {
352+
if k != skip && filled[k] && n < 2 {
353+
basis[n] = vecs[k];
354+
n += 1;
355+
}
356+
}
357+
match n {
358+
0 => [1.0, 0.0, 0.0],
359+
1 => {
360+
// Choose the canonical axis with the smallest |component| in
361+
// basis[0] — that minimises collinearity.
362+
let b = basis[0];
363+
let (ax, ay, az) = (b[0].abs(), b[1].abs(), b[2].abs());
364+
let seed = if ax <= ay && ax <= az {
365+
[1.0, 0.0, 0.0]
366+
} else if ay <= az {
367+
[0.0, 1.0, 0.0]
368+
} else {
369+
[0.0, 0.0, 1.0]
370+
};
371+
let d = dot3(seed, b);
372+
let proj = [seed[0] - d * b[0], seed[1] - d * b[1], seed[2] - d * b[2]];
373+
normalize3(proj)
374+
}
375+
2 => normalize3(cross3(basis[0], basis[1])),
376+
_ => unreachable!("at most 2 filled eigenvectors at this point"),
377+
}
378+
}
379+
380+
#[inline]
381+
fn normalize3(v: [f64; 3]) -> [f64; 3] {
382+
let n_sq = dot3(v, v);
383+
if n_sq <= 0.0 {
384+
return [1.0, 0.0, 0.0];
385+
}
386+
let inv = 1.0 / n_sq.sqrt();
387+
[v[0] * inv, v[1] * inv, v[2] * inv]
388+
}
389+
390+
/// In-place modified Gram-Schmidt on a 3-column matrix stored as
391+
/// `[[f64; 3]; 3]` (column k = vecs[k]). Suppresses cross-orthogonality
392+
/// drift to ~1e-15 at f64 precision.
393+
fn orthonormalize_columns(vecs: &mut [[f64; 3]; 3]) {
394+
vecs[0] = normalize3(vecs[0]);
395+
let d10 = dot3(vecs[1], vecs[0]);
396+
vecs[1] = normalize3([
397+
vecs[1][0] - d10 * vecs[0][0],
398+
vecs[1][1] - d10 * vecs[0][1],
399+
vecs[1][2] - d10 * vecs[0][2],
400+
]);
401+
let d20 = dot3(vecs[2], vecs[0]);
402+
let d21 = dot3(vecs[2], vecs[1]);
403+
vecs[2] = normalize3([
404+
vecs[2][0] - d20 * vecs[0][0] - d21 * vecs[1][0],
405+
vecs[2][1] - d20 * vecs[0][1] - d21 * vecs[1][1],
406+
vecs[2][2] - d20 * vecs[0][2] - d21 * vecs[1][2],
407+
]);
408+
}
409+
290410
#[inline]
291411
fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
292412
[
@@ -607,4 +727,135 @@ mod tests {
607727
assert!(approx(sigma.a13, 0.0, 1e-12), "a13={}", sigma.a13);
608728
assert!(approx(sigma.a23, 0.0, 1e-12), "a23={}", sigma.a23);
609729
}
730+
731+
fn approx_spd3(a: &Spd3, b: &Spd3, tol: f64) -> bool {
732+
(a.a11 - b.a11).abs() <= tol
733+
&& (a.a12 - b.a12).abs() <= tol
734+
&& (a.a13 - b.a13).abs() <= tol
735+
&& (a.a22 - b.a22).abs() <= tol
736+
&& (a.a23 - b.a23).abs() <= tol
737+
&& (a.a33 - b.a33).abs() <= tol
738+
}
739+
740+
// ════════════════════════════════════════════════════════════════════════
741+
// Regression: rotated axisymmetric splat — the user-reported bug.
742+
//
743+
// For scale = [a, a, b] (any two of three scales equal), Σ has a
744+
// 2D degenerate eigenspace at λ = a². Under any non-identity
745+
// rotation the matrix has p1 > eps_diag, so eig() takes the general
746+
// branch — and prior to the fix in this commit, the three
747+
// independent `eigvec_for` calls all returned the SAME unit vector
748+
// for the repeated eigenvalue, leaving V rank-deficient and the
749+
// spectral reconstruction (sqrt, log_spd) wrong.
750+
//
751+
// The certification test: sqrt(Σ) · I · sqrt(Σ)ᵀ must equal Σ.
752+
// Pre-fix this asserts at ~5% error on common 3DGS pancake splats
753+
// (scale [2, 2, 1] + 30° rotation about (1,1,1)/√3).
754+
// ════════════════════════════════════════════════════════════════════════
755+
#[test]
756+
fn rotated_axisymmetric_sqrt_squared_equals_sigma() {
757+
// Axisymmetric scale (two equal eigenvalues), non-identity quat.
758+
let s = [2.0, 2.0, 1.0]; // pancake — 3DGS-typical
759+
// 30° rotation about (1,1,1)/√3: quat = (cos 15°, sin 15°·1/√3, ·, ·).
760+
let half = std::f64::consts::PI / 12.0; // 15°
761+
let inv_r3 = 1.0 / 3.0f64.sqrt();
762+
let q = [
763+
half.cos(),
764+
inv_r3 * half.sin(),
765+
inv_r3 * half.sin(),
766+
inv_r3 * half.sin(),
767+
];
768+
let sigma = Spd3::from_scale_quat(s, q);
769+
// sqrt(Σ)·I·sqrt(Σ)ᵀ = sqrt²(Σ) = Σ since sqrt is symmetric SPD.
770+
// Tolerance 1e-7 covers compound f64 error from
771+
// (quat→R) ∘ (R·diag·Rᵀ) ∘ eig ∘ sqrt ∘ sandwich. Pre-fix
772+
// this test fails at ~5% (5e-2) on common 3DGS pancake splats;
773+
// post-fix it passes at ~1e-8.
774+
let root = sigma.sqrt();
775+
let squared = sandwich(&root, &Spd3::I);
776+
assert!(
777+
approx_spd3(&squared, &sigma, 1e-7),
778+
"sqrt(Σ)² = {:?}, expected Σ = {:?}", squared, sigma,
779+
);
780+
}
781+
782+
#[test]
783+
fn rotated_axisymmetric_eigenvalues_are_correct() {
784+
// Same setup — verify eigenvalues are (4, 4, 1) regardless of
785+
// rotation. Tolerance 1e-7: the acos-based Smith-1961 formula
786+
// accumulates ~1e-8 error in f64 on inputs constructed via
787+
// from_scale_quat.
788+
let s = [2.0, 2.0, 1.0];
789+
let half = std::f64::consts::PI / 12.0;
790+
let inv_r3 = 1.0 / 3.0f64.sqrt();
791+
let q = [
792+
half.cos(),
793+
inv_r3 * half.sin(),
794+
inv_r3 * half.sin(),
795+
inv_r3 * half.sin(),
796+
];
797+
let sigma = Spd3::from_scale_quat(s, q);
798+
let (l1, l2, l3, _) = sigma.eig();
799+
assert!(approx(l1, 4.0, 1e-7), "l1={l1}");
800+
assert!(approx(l2, 4.0, 1e-7), "l2={l2}");
801+
assert!(approx(l3, 1.0, 1e-7), "l3={l3}");
802+
}
803+
804+
#[test]
805+
fn rotated_axisymmetric_eigvecs_orthonormal() {
806+
// The duplicate-detection fix must produce an orthonormal V
807+
// even when two eigenvalues are equal.
808+
let s = [2.0, 2.0, 1.0];
809+
let half = std::f64::consts::PI / 12.0;
810+
let inv_r3 = 1.0 / 3.0f64.sqrt();
811+
let q = [
812+
half.cos(),
813+
inv_r3 * half.sin(),
814+
inv_r3 * half.sin(),
815+
inv_r3 * half.sin(),
816+
];
817+
let sigma = Spd3::from_scale_quat(s, q);
818+
let (_, _, _, v) = sigma.eig();
819+
// Each column unit length.
820+
for (k, col) in v.iter().enumerate() {
821+
let n_sq = dot3(*col, *col);
822+
assert!(approx(n_sq, 1.0, 1e-9), "v[{k}] not unit: ‖v‖² = {n_sq}");
823+
}
824+
// All pairs orthogonal.
825+
for i in 0..3 {
826+
for j in (i + 1)..3 {
827+
let d = dot3(v[i], v[j]);
828+
assert!(
829+
d.abs() < 1e-9,
830+
"v[{i}] · v[{j}] = {d} (must be ~0 for orthonormal V)"
831+
);
832+
}
833+
}
834+
}
835+
836+
#[test]
837+
fn rotated_axisymmetric_log_spd_finite_and_correct() {
838+
// log_spd uses the same spectral lift as sqrt; verify it produces
839+
// a finite, correctly-scaled result on the degenerate case.
840+
// For Σ with eigenvalues (4, 4, 1):
841+
// ‖log Σ‖_F² = (log 4)² + (log 4)² + (log 1)² = 2·(ln 4)² ≈ 3.844
842+
let s = [2.0, 2.0, 1.0];
843+
let half = std::f64::consts::PI / 12.0;
844+
let inv_r3 = 1.0 / 3.0f64.sqrt();
845+
let q = [
846+
half.cos(),
847+
inv_r3 * half.sin(),
848+
inv_r3 * half.sin(),
849+
inv_r3 * half.sin(),
850+
];
851+
let sigma = Spd3::from_scale_quat(s, q);
852+
let log_sigma = sigma.log_spd();
853+
let fro_sq = log_sigma.frobenius_sq();
854+
assert!(fro_sq.is_finite(), "log_spd frobenius² not finite: {fro_sq}");
855+
let expected = 2.0 * (4.0f64.ln()).powi(2);
856+
assert!(
857+
(fro_sq - expected).abs() < 1e-6,
858+
"‖log Σ‖² = {fro_sq}, expected ≈ {expected} (= 2·(ln 4)²)"
859+
);
860+
}
610861
}

0 commit comments

Comments
 (0)