Shared CGA substrate for the #17 conformal-Procrustes/Kabsch and #16 Cartan-Iwasawa decompositions. Adds algebra/null_point.py and hoists the two conformal null directions to frozen module constants. algebra/cga.py - Add frozen read-only f64 N_O / N_INF constants: the same vectors embed_point builds inline (origin embeds to N_O; N_INF is fixed by every Euclidean isometry), so the null-point primitives share one exact sign definition instead of re-deriving it per call site. - Fix header-docstring sign typo: n_o = 0.5*(e5 - e4), not 0.5*(e4 - e5). embed_point was already correct; only the module header disagreed. algebra/null_point.py (new) - dilator(scale), translator(a): CGA similarity constructors; both round-trip through the recoverers. - recover_dilation(V) -> (scale, D): reads V n_inf rev(V), weight-normalised so recovery is invariant to a non-unit versor weight (verified vs V -> kV). - recover_translation(V) -> (a, T): reads V n_o rev(V), projective dehomogenisation. - NullPointRecoveryError carries machine-readable reason codes. - Fail-closed symmetric similarity gate (_require_similarity): BOTH recoverers now reject non-versors (not_a_versor) and non-similarities (not_similarity, e.g. transversions). Closes an asymmetry where recover_translation silently accepted a transversion / non-versor and returned a plausible translation, violating the module's own wrong=0 contract. - Orientation-reversing (reflection / det=-1) versors are refused by recover_dilation with a distinct reason improper_versor, kept separate from degenerate_scale; recover_translation still accepts them (the origin image is well defined). conformal_procrustes strips reflections upstream, so this is a documented boundary, not a silent one. - Default tol=1e-9 documented: matches f64-exact recovery of a cleanly assembled versor (~1e-14 round-trip); noisy/SVD callers must pass a wider tol. tests/test_null_point_primitives.py (new): 33 tests - null-cone/pairing invariants, constant immutability, constructor round-trips, composed T.D.R peel, versor-weight invariance, and the full fail-closed matrix (transversion, non-versor, inversion, reflection asymmetry, non-positive scale, bad vector). Invariant protected: wrong=0 - no recovery returns a silently wrong value on a degenerate / non-versor / non-similarity input. Validation: 33/33 new pass; 88 passed / 1 xfailed across the CGA substrate + physics Procrustes consumers (dynamic_manifold, surprise, versor closure, rotor, holonomy). Hardened via a 3-lens adversarial verification (soundness / sign-convention / consumer-contract, each executing counterexample versors, every finding skeptic-verified): 2 CONFIRMED findings fixed (asymmetric validation gap; reflection reason conflation); tol-tightness resolved by documentation rather than a guard-weakening default change.
252 lines
9.6 KiB
Python
252 lines
9.6 KiB
Python
"""Pin tests for the conformal null-point primitives.
|
||
|
||
These lock the CGA null geometry that the shared #17 (Kabsch / conformal
|
||
Procrustes) and #16 (Cartan–Iwasawa) recovery helpers stand on:
|
||
|
||
* the frozen ``N_O`` / ``N_INF`` constants agree exactly with the vectors
|
||
``embed_point`` builds inline, and
|
||
* the sign convention is ``n_o = 0.5 * (e5 - e4)`` (NOT ``e4 - e5`` — the old
|
||
module-header docstring had that backwards).
|
||
|
||
The inner-product identities are exact (0.5 / 1.0 are representable), so the
|
||
tolerances are tight.
|
||
"""
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from algebra.cga import N_INF, N_O, cga_inner, embed_point, read_scalar_e1
|
||
from algebra.cl41 import basis_vector, geometric_product, reverse, scalar_part
|
||
from algebra.null_point import (
|
||
NullPointRecoveryError,
|
||
dilator,
|
||
recover_dilation,
|
||
recover_translation,
|
||
translator,
|
||
_E_DILATION,
|
||
)
|
||
from algebra.rotor import make_rotor_from_angle
|
||
|
||
|
||
def _sandwich(V, X):
|
||
V = np.asarray(V, dtype=np.float64)
|
||
X = np.asarray(X, dtype=np.float64)
|
||
return geometric_product(geometric_product(V, X), reverse(V))
|
||
|
||
|
||
def test_n_o_sign_convention_matches_basis_vectors():
|
||
"""n_o = 0.5 * (e5 - e4); basis_vector is 0-indexed so e4=bv(3), e5=bv(4)."""
|
||
n_o = 0.5 * (basis_vector(4) - basis_vector(3))
|
||
assert np.allclose(N_O, n_o), "frozen N_O disagrees with 0.5*(e5 - e4)"
|
||
|
||
|
||
def test_n_inf_matches_basis_vectors():
|
||
n_inf = basis_vector(3) + basis_vector(4) # e4 + e5
|
||
assert np.allclose(N_INF, n_inf), "frozen N_INF disagrees with e4 + e5"
|
||
|
||
|
||
def test_null_cone_invariants():
|
||
"""N_O and N_INF both lie on the null cone: X . X = 0."""
|
||
assert abs(cga_inner(N_O, N_O)) < 1e-12
|
||
assert abs(cga_inner(N_INF, N_INF)) < 1e-12
|
||
|
||
|
||
def test_no_ninf_pairing_is_minus_one():
|
||
"""N_O . N_INF = -1 exactly. Parenthesis is INSIDE abs: abs(x + 1), not abs(x) + 1."""
|
||
assert abs(cga_inner(N_O, N_INF) + 1.0) < 1e-12
|
||
|
||
|
||
def test_embed_origin_is_n_o():
|
||
"""The Euclidean origin embeds to n_o: e4 coeff = -0.5, e5 coeff = +0.5."""
|
||
x0 = embed_point(np.zeros(3), dtype=np.float64)
|
||
assert np.allclose(x0[4:6], [-0.5, 0.5])
|
||
# ...and the whole embedding equals N_O (origin has zero Euclidean part).
|
||
assert np.allclose(x0, N_O)
|
||
|
||
|
||
def test_constants_are_read_only():
|
||
"""The module constants must not be mutable in place."""
|
||
for const in (N_O, N_INF):
|
||
assert const.flags.writeable is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Recovery primitives: constructors, round-trips, composed peel, fail-closed.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_E_dilation_squares_to_one():
|
||
"""The dilation bivector E = n_o ^ n_inf lives at index 15 and E^2 = +1."""
|
||
assert _E_DILATION[15] == -1.0
|
||
assert np.count_nonzero(_E_DILATION) == 1
|
||
e_sq = geometric_product(_E_DILATION, _E_DILATION)
|
||
assert abs(scalar_part(e_sq) - 1.0) < 1e-12
|
||
assert np.linalg.norm(e_sq[1:]) < 1e-12 # pure scalar
|
||
|
||
|
||
def test_dilator_scales_euclidean_lengths():
|
||
"""dilator(s) scales the Euclidean coordinate of a point by s."""
|
||
X = embed_point(np.array([3.0, 0.0, 0.0]), dtype=np.float64)
|
||
for s in (2.0, 0.5, 4.0):
|
||
Y = _sandwich(dilator(s), X)
|
||
assert abs(read_scalar_e1(Y) - s * 3.0) < 1e-9
|
||
|
||
|
||
def test_translator_maps_origin_to_point():
|
||
"""translator(a) carries the origin exactly to embed_point(a)."""
|
||
for a in ([1.0, 0.0, 0.0], [2.0, -1.0, 0.5]):
|
||
a = np.array(a)
|
||
image = _sandwich(translator(a), N_O)
|
||
assert np.allclose(image, embed_point(a, dtype=np.float64), atol=1e-9)
|
||
|
||
|
||
@pytest.mark.parametrize("scale", [2.5, 0.4, 1.0, 7.0, 0.125])
|
||
def test_recover_dilation_round_trip(scale):
|
||
rec_scale, D = recover_dilation(dilator(scale))
|
||
assert abs(rec_scale - scale) < 1e-9
|
||
assert np.allclose(D, dilator(scale), atol=1e-12)
|
||
|
||
|
||
@pytest.mark.parametrize("a", [[1.5, -0.5, 2.0], [-3.0, 1.0, 0.0], [0.0, 0.0, 0.0]])
|
||
def test_recover_translation_round_trip(a):
|
||
a = np.array(a)
|
||
rec_a, T = recover_translation(translator(a))
|
||
assert np.allclose(rec_a, a, atol=1e-9)
|
||
assert np.allclose(T, translator(a), atol=1e-12)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"scale,a,angle",
|
||
[(2.5, [1.5, -0.5, 2.0], 0.7), (0.4, [-3.0, 1.0, 0.0], 1.9), (3.0, [0.2, 0.2, 0.2], -1.1)],
|
||
)
|
||
def test_recover_from_composed_similarity(scale, a, angle):
|
||
"""V = T . D . R : dilation and translation peel out exactly, rotation and
|
||
each other's presence notwithstanding."""
|
||
a = np.array(a)
|
||
R = make_rotor_from_angle(angle, bivector_idx=6).astype(np.float64)
|
||
V = geometric_product(geometric_product(translator(a), dilator(scale)), R)
|
||
rec_scale, _ = recover_dilation(V)
|
||
rec_a, _ = recover_translation(V)
|
||
assert abs(rec_scale - scale) < 1e-8
|
||
assert np.allclose(rec_a, a, atol=1e-8)
|
||
|
||
|
||
@pytest.mark.parametrize("k", [3.0, 0.5, 10.0])
|
||
def test_recover_dilation_is_versor_weight_invariant(k):
|
||
"""Regression: the raw n_inf coefficient scales with the versor weight; the
|
||
recovered scale must NOT. recover_dilation(k*V) == recover_dilation(V)."""
|
||
V = geometric_product(translator(np.array([1.0, 2.0, -1.0])), dilator(2.5))
|
||
base, _ = recover_dilation(V)
|
||
scaled, _ = recover_dilation(k * V)
|
||
assert abs(base - 2.5) < 1e-9
|
||
assert abs(scaled - base) < 1e-9
|
||
|
||
|
||
def test_recover_translation_is_weight_invariant():
|
||
V = geometric_product(translator(np.array([1.0, 2.0, -1.0])), dilator(2.5))
|
||
a0, _ = recover_translation(V)
|
||
a1, _ = recover_translation(3.0 * V)
|
||
assert np.allclose(a0, [1.0, 2.0, -1.0], atol=1e-9)
|
||
assert np.allclose(a1, a0, atol=1e-9)
|
||
|
||
|
||
def test_recover_dilation_refuses_transversion():
|
||
"""A transversion (special conformal) does NOT fix infinity -> not_similarity."""
|
||
b = np.zeros(32)
|
||
b[1] = 0.3
|
||
K = np.zeros(32)
|
||
K[0] = 1.0
|
||
K = K - 0.5 * geometric_product(b, N_O) # transversion = 1 - 0.5 b n_o
|
||
with pytest.raises(NullPointRecoveryError) as exc:
|
||
recover_dilation(K)
|
||
assert exc.value.reason == "not_similarity"
|
||
|
||
|
||
def test_recover_dilation_refuses_non_versor():
|
||
"""A mixed-grade multivector is not a versor -> not_a_versor."""
|
||
bad = np.zeros(32)
|
||
bad[0] = 1.0
|
||
bad[1] = 1.0
|
||
bad[6] = 1.0 # scalar + vector + bivector: V rev(V) not scalar
|
||
with pytest.raises(NullPointRecoveryError) as exc:
|
||
recover_dilation(bad)
|
||
assert exc.value.reason == "not_a_versor"
|
||
|
||
|
||
def test_recover_translation_refuses_inversion_as_not_similarity():
|
||
"""Unit-sphere inversion sigma = n_o - 0.5 n_inf swaps the null directions, so
|
||
it is not a similarity (does not fix infinity). The shared similarity gate
|
||
refuses it as not_similarity — the fundamental cause. (It also sends the origin
|
||
to infinity; origin_at_infinity remains a defensive division guard, subsumed
|
||
here for genuine inversions.)"""
|
||
sigma = N_O - 0.5 * N_INF # sigma^2 = 1, an honest inversion reflector
|
||
with pytest.raises(NullPointRecoveryError) as exc:
|
||
recover_translation(sigma)
|
||
assert exc.value.reason == "not_similarity"
|
||
|
||
|
||
def test_recover_translation_refuses_transversion():
|
||
"""Symmetric with recover_dilation: a transversion IS a versor and fixes the
|
||
origin, so without the similarity gate it silently returned a plausible
|
||
a=[0,0,0]. The gate must refuse it (it does not fix infinity) -> not_similarity."""
|
||
b = np.zeros(32)
|
||
b[1] = 0.3
|
||
K = np.zeros(32)
|
||
K[0] = 1.0
|
||
K = K - 0.5 * geometric_product(b, N_O) # transversion = 1 - 0.5 b n_o
|
||
with pytest.raises(NullPointRecoveryError) as exc:
|
||
recover_translation(K)
|
||
assert exc.value.reason == "not_similarity"
|
||
|
||
|
||
def test_recover_translation_refuses_non_versor():
|
||
"""A mixed-grade multivector is not a versor -> not_a_versor (symmetric with
|
||
recover_dilation; previously recover_translation accepted it and returned a
|
||
silent value)."""
|
||
bad = np.zeros(32)
|
||
bad[0] = 1.0
|
||
bad[1] = 1.0
|
||
bad[6] = 1.0 # scalar + vector + bivector: V rev(V) not scalar
|
||
with pytest.raises(NullPointRecoveryError) as exc:
|
||
recover_translation(bad)
|
||
assert exc.value.reason == "not_a_versor"
|
||
|
||
|
||
def _reflection_similarity(a, scale):
|
||
"""T . D . (e1-reflection): an orientation-reversing (det=-1) similarity, what a
|
||
raw Kabsch/SVD fit yields before it strips the reflection."""
|
||
return geometric_product(
|
||
geometric_product(translator(np.array(a)), dilator(scale)), basis_vector(0)
|
||
)
|
||
|
||
|
||
def test_recover_dilation_refuses_reflection_as_improper():
|
||
"""A reflection (improper rotation, det=-1) is refused as improper_versor, NOT
|
||
degenerate_scale: its scale magnitude is a clean, well-conditioned number, and
|
||
the distinct reason lets a consumer route 'strip the reflection' vs 'broken'."""
|
||
V = _reflection_similarity([1.0, 0.5, -0.3], 2.0)
|
||
with pytest.raises(NullPointRecoveryError) as exc:
|
||
recover_dilation(V)
|
||
assert exc.value.reason == "improper_versor"
|
||
|
||
|
||
def test_recover_translation_accepts_reflection():
|
||
"""Asymmetry by design: the origin image is well defined under a reflection, so
|
||
recover_translation SUCCEEDS on the very versor recover_dilation refuses."""
|
||
V = _reflection_similarity([1.0, 0.5, -0.3], 2.0)
|
||
rec_a, _ = recover_translation(V)
|
||
assert np.allclose(rec_a, [1.0, 0.5, -0.3], atol=1e-9)
|
||
|
||
|
||
def test_dilator_rejects_nonpositive_scale():
|
||
for bad in (0.0, -1.0, float("inf"), float("nan")):
|
||
with pytest.raises(NullPointRecoveryError) as exc:
|
||
dilator(bad)
|
||
assert exc.value.reason == "nonpositive_scale"
|
||
|
||
|
||
def test_translator_rejects_bad_vector():
|
||
with pytest.raises(NullPointRecoveryError):
|
||
translator(np.array([1.0, 2.0])) # wrong shape
|
||
with pytest.raises(NullPointRecoveryError):
|
||
translator(np.array([1.0, np.nan, 0.0])) # non-finite
|