core/algebra/versor.py
Shay 694754ab46 feat(algebra): null-preserving versor_apply path + un-skip 2 invariant tests
Closes the two skipped null-preservation tests and the architectural
gap behind them.  In CGA, null vectors represent Euclidean points;
under a conformal transformation a point must map to a point —
applying a versor sandwich to a null vector must preserve null
property.  The previous implementation forced everything onto the
unit-versor shell, which is correct for field-state propagation but
wrong for geometric point input.

Implementation
- algebra/versor.py: new `_input_is_null(F)` checks `cga_inner(F,F) ≈ 0`;
  `versor_apply` routes null inputs around `_close_applied_versor`
  and returns the raw sandwich V·F·rev(V), which algebraically
  preserves null property.  Non-null inputs unchanged.
- core-rs/src/versor.rs: `versor_apply_closed_f64` gains the same
  null-check branch via `input_is_null_f64`.  ADR-0020 parity
  preserved (8/8 versor_apply bit-identity tests still pass).

Test changes
- tests/test_architectural_invariants.py::TestINV06NullConePreservation::
  test_versor_apply_preserves_null_property — un-skipped, passes.
- tests/test_rust_backend.py::test_rust_versor_apply_preserves_null_vectors
  — un-skipped, passes.
- tests/test_versor_closure.py::test_versor_apply_closes_null_like_field_
  results_for_runtime_contract — renamed to
  test_versor_apply_preserves_null_property_for_null_inputs and
  rewritten to assert the now-correct semantics (null in → null out).
  The old contract over-specified closure for null inputs and
  contradicted the architectural invariant; that's what kept the
  invariant test skipped.

Stale gap docs updated
- inference_closure / cross_domain_transfer / multi_step_reasoning
  gaps.md now lead with a resolution block: lanes pass at 100% on
  both splits after the typed operators (transitive_walk,
  multi_relation_walk, path_recall in generate/operators.py) +
  pipeline wiring (_maybe_transitive_walk + _fold_walk_into_surface)
  landed.  The historic findings are preserved below for traceability.
- compositionality gaps.md: partial resolution — recall up from
  6.25% to 68.75%; overall_pass True; residual ~30% miss requires
  a relation-aware `compose_relations` operator (v2 follow-on).

Lane health unchanged: algebra 132, smoke 55, runtime 19, teaching 17,
packs 6, cognition 103.  Cognition eval 100%.  Four formerly-"blocked"
reasoning lanes confirmed 100% / overall_pass=True end-to-end.
2026-05-16 21:40:37 -07:00

180 lines
6.7 KiB
Python

from __future__ import annotations
import numpy as np
from .cl41 import geometric_product, reverse
__all__ = [
"unitize_versor",
"versor_apply",
"versor_condition",
"versor_unit_residual",
]
_CONSTRUCTION_RESIDUE_TOLERANCE = 1e-2
_RUNTIME_CLOSURE_TOLERANCE = 1e-6
_NEAR_ZERO_TOLERANCE = 1e-12
_DENSE_SEED_MIN_COMPONENTS = 8
_SEED_BIVECTORS = (6, 7, 8, 10, 11, 13)
_RUNTIME_FIELD_DTYPE = np.dtype(np.float64)
def _array_dtype(v: np.ndarray) -> np.dtype:
arr = np.asarray(v)
return arr.dtype if arr.dtype in (np.dtype(np.float32), np.dtype(np.float64)) else np.dtype(np.float32)
def _diagnostic_message(prefix: str, *, input_norm: float, scalar_sq: float, residue_norm: float) -> str:
return f"{prefix}: input_norm={input_norm:.6e}, scalar_sq={scalar_sq:.6e}, residue_norm={residue_norm:.6e}"
def _unitize_closed(v: np.ndarray, dtype: np.dtype) -> np.ndarray:
dtype = _array_dtype(v)
v = np.asarray(v, dtype=np.float64)
input_norm = float(np.linalg.norm(v))
if input_norm < _NEAR_ZERO_TOLERANCE:
raise ValueError(_diagnostic_message("unitize_versor: near_zero", input_norm=input_norm, scalar_sq=0.0, residue_norm=0.0))
vv = geometric_product(v, reverse(v)).astype(np.float64)
scalar_sq = float(vv[0])
residue = vv.copy()
residue[0] = 0
residue_norm = float(np.linalg.norm(residue))
if residue_norm >= _CONSTRUCTION_RESIDUE_TOLERANCE:
raise ValueError(_diagnostic_message("unitize_versor: bad_residue", input_norm=input_norm, scalar_sq=scalar_sq, residue_norm=residue_norm))
if scalar_sq <= 0.0:
raise ValueError(_diagnostic_message("unitize_versor: bad_scalar", input_norm=input_norm, scalar_sq=scalar_sq, residue_norm=residue_norm))
return (v * (1.0 / np.sqrt(scalar_sq))).astype(dtype)
def _seed_to_rotor(v: np.ndarray, dtype: np.dtype) -> np.ndarray:
seed = np.asarray(v, dtype=np.float64).ravel()
if seed.shape != (32,):
raise ValueError("unitize_versor expects a 32-component multivector.")
rotor = np.zeros(32, dtype=np.float64)
rotor[0] = 1.0
scale = float(np.linalg.norm(seed)) or 1.0
for step, blade in enumerate(_SEED_BIVECTORS):
source = seed[(blade + step) % 32] / scale
theta = 0.5 * np.tanh(source)
factor = np.zeros(32, dtype=np.float64)
factor[0] = np.cos(theta)
factor[blade] = np.sin(theta)
rotor = geometric_product(rotor, factor)
return _unitize_closed(rotor, dtype)
def unitize_versor(v: np.ndarray) -> np.ndarray:
dtype = _array_dtype(v)
arr = np.asarray(v, dtype=np.float64)
try:
return _unitize_closed(arr, dtype)
except ValueError as exc:
if "bad_residue" not in str(exc):
raise
support = int(np.count_nonzero(np.abs(arr) > _NEAR_ZERO_TOLERANCE))
if support < _DENSE_SEED_MIN_COMPONENTS:
raise
return _seed_to_rotor(arr, dtype)
def normalize_to_versor(v: np.ndarray) -> np.ndarray:
dtype = _array_dtype(v)
try:
return unitize_versor(v)
except ValueError as exc:
if "bad_residue" not in str(exc):
raise
return _seed_to_rotor(v, dtype)
def construction_seed_versor(v: np.ndarray) -> np.ndarray:
"""Map a raw construction seed into the closed versor manifold."""
return _seed_to_rotor(v, _array_dtype(v))
def _runtime_closed(v: np.ndarray) -> np.ndarray:
"""Return a field that satisfies the strict runtime closure invariant."""
candidate = unitize_versor(np.asarray(v, dtype=_RUNTIME_FIELD_DTYPE)).astype(_RUNTIME_FIELD_DTYPE)
if versor_condition(candidate) < _RUNTIME_CLOSURE_TOLERANCE:
return candidate
return _seed_to_rotor(v, _RUNTIME_FIELD_DTYPE).astype(_RUNTIME_FIELD_DTYPE)
def _close_applied_versor(v: np.ndarray) -> np.ndarray:
"""Close runtime field sandwich results at float64 precision.
``unitize_versor`` is intentionally permissive for construction-time inputs
whose non-scalar residue is still below the construction tolerance. Runtime
field propagation is stricter: if the result still fails the public
``versor_condition < 1e-6`` contract after unitization, project it through
the deterministic construction map instead of leaking small drift.
"""
arr = np.asarray(v, dtype=_RUNTIME_FIELD_DTYPE)
try:
return _runtime_closed(arr)
except ValueError:
return _seed_to_rotor(arr, _RUNTIME_FIELD_DTYPE).astype(_RUNTIME_FIELD_DTYPE)
_NULL_INNER_TOL: float = 1e-5 # f32 sandwich noise floor for null inputs
def _input_is_null(F: np.ndarray) -> bool:
"""True if F is a null vector in the CGA inner product (self-inner ≈ 0).
Used to route null inputs around the unit-versor closure path so the
sandwich V·F·rev(V) preserves the null property (Euclidean points
map to Euclidean points under conformal transformations).
"""
from algebra.cga import cga_inner
return abs(float(cga_inner(F, F))) < _NULL_INNER_TOL
def versor_apply(V: np.ndarray, F: np.ndarray) -> np.ndarray:
"""Apply a versor V to a multivector F via the sandwich V·F·rev(V).
Two regimes:
- **Non-null F** (the runtime field-state path): the result is
closed back onto the unit-versor manifold via
`_close_applied_versor` so the invariant
`versor_condition(F) < 1e-6` is preserved.
- **Null F** (CGA point input): the raw sandwich preserves the
null property algebraically. Closure would force the result
onto the unit-versor shell, breaking the null invariant
(Euclidean points should map to Euclidean points under
conformal transformations). We detect null inputs by
``cga_inner(F, F) ≈ 0`` and return the raw sandwich.
This dual-path replaces the previously-skipped tests
`test_versor_apply_preserves_null_property` and the Rust parity
sibling `test_rust_versor_apply_preserves_null_vectors`.
"""
V = np.asarray(V, dtype=_RUNTIME_FIELD_DTYPE)
F = np.asarray(F, dtype=_RUNTIME_FIELD_DTYPE)
applied = geometric_product(geometric_product(V, F), reverse(V)).astype(_RUNTIME_FIELD_DTYPE)
if _input_is_null(F):
return applied # null inputs: keep raw sandwich, do not unitise
return _close_applied_versor(applied)
def versor_unit_residual(v: np.ndarray, *, allow_negative: bool = False) -> float:
v = np.asarray(v, dtype=np.float64)
vv = geometric_product(v, reverse(v)).astype(np.float64)
plus = vv.copy()
plus[0] -= 1.0
plus_residual = float(np.linalg.norm(plus))
if not allow_negative:
return plus_residual
minus = vv.copy()
minus[0] += 1.0
return min(plus_residual, float(np.linalg.norm(minus)))
def versor_condition(v: np.ndarray) -> float:
return versor_unit_residual(v, allow_negative=False)