The bug: ingest.gate.inject raised RuntimeError("Injection produced
non-versor field") on a class of ordinary English token combinations
(declarative-with-quantity + transfer phrase + "How many" question).
Both observed condition values (1.02e-06, 2.12e-06) cleared
unitize_versor's `bad_residue` heuristic but landed just above the
gate's 1e-6 downstream check, crashing the engine on textbook word
problems like:
"Tom has 5 apples. He gives 2 to Sarah. How many does Tom have?"
Root cause: normalize_to_versor accepted the unitized candidate
without checking that it strictly satisfied the gate's
versor_condition < _RUNTIME_CLOSURE_TOLERANCE (1e-6) contract.
unitize_versor's internal tolerance is permissive for construction-
time inputs; the gate's downstream tolerance is stricter. When the
two diverged on certain token mixes, the candidate slipped through
and the gate's assert fired.
Fix: mirror the strict-closure pattern from _runtime_closed /
_close_applied_versor. If unitize_versor succeeds but the result
still fails the public versor_condition < _RUNTIME_CLOSURE_TOLERANCE
contract, project through the deterministic construction map
(_seed_to_rotor) instead of returning the drifted candidate.
Per CLAUDE.md: threshold stays at 1e-6 (Non-Negotiable Field
Invariant). Construction boundary is where drift is repaired.
The fix lives at the SINGLE allowed normalization site
(ingest/gate.py's only entry point into the algebra) without
loosening any invariant.
Tests added (11):
- versor_condition strictly satisfied on a range of seeded random
inputs (property test)
- 20-iteration synthetic-marginal probe exercises the construction-
fallback path
- The three issue-#300 bisected crash repros run end-to-end through
`core chat` and complete without raising the RuntimeError
- Threshold constant pinned (failing the test if anyone lowers
_RUNTIME_CLOSURE_TOLERANCE)
Validation:
- All 11 new tests pass
- 37 existing versor / ingest tests pass (test_versor_closure +
test_versor_*_rust_parity + test_core_ingest + test_unknown_token_ingest)
- Three pre-existing main failures (architectural_invariants
INV02 / INV21 / INV24) are unchanged by this PR — verified by
running them against origin/main directly before and after the
fix
- The three crashing prompts now produce clean grounded surfaces
through `core chat`
Closes issue #300.
203 lines
7.7 KiB
Python
203 lines
7.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:
|
|
"""Encode v as a closed versor for the ingest-gate boundary.
|
|
|
|
Issue #300: certain ordinary English token combinations
|
|
(declarative-with-quantity + transfer + "How many" question)
|
|
produced a residue that ``unitize_versor`` resolved without raising
|
|
but whose ``versor_condition`` cleared the `bad_residue` heuristic
|
|
while still landing just above the gate's 1e-6 threshold
|
|
(observed: 1.02e-06 -- 2.12e-06). The gate's downstream check
|
|
then crashed.
|
|
|
|
Mirror the strict-closure pattern in ``_runtime_closed`` /
|
|
``_close_applied_versor``: if unitization succeeded but the
|
|
result still fails the public ``versor_condition <
|
|
_RUNTIME_CLOSURE_TOLERANCE`` contract, project through the
|
|
deterministic construction map instead of returning the drifted
|
|
candidate. Threshold stays at 1e-6 (CLAUDE.md non-negotiable);
|
|
the construction-boundary is where the drift is repaired, not
|
|
the gate.
|
|
"""
|
|
dtype = _array_dtype(v)
|
|
try:
|
|
candidate = unitize_versor(v)
|
|
except ValueError as exc:
|
|
if "bad_residue" not in str(exc):
|
|
raise
|
|
return _seed_to_rotor(v, dtype)
|
|
|
|
if versor_condition(candidate) >= _RUNTIME_CLOSURE_TOLERANCE:
|
|
return _seed_to_rotor(v, dtype)
|
|
return candidate
|
|
|
|
|
|
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)
|