core/generate/math_versor_arithmetic.py
Shay 34cc345d7e
feat(ADR-0141): multiply as CGA dilator versor (positive non-zero) (#216)
* feat(ADR-0141): multiply as CGA dilator versor (positive non-zero)

Adds `multiply(scale)` to `generate/math_versor_arithmetic.py` as the
standard CGA dilator for multiplicative scaling along e1, restricted to
`scale > 0`.  All ten ADR-0141 assertion families pass.

Preliminary measurement confirmed:
  N = n_o ∧ n_inf: component -1 at index 15 (blade (3,4) = e4∧e5)
  N² = +1.0 (pure scalar) → closed-form D_s = cosh(α/2) + sinh(α/2)·N
  n_o · n_inf = -1;  n_o² = n_inf² = 0

Because N² = +1, the cosh/sinh expansion is exact in float64 and
D_s · ~D_s = cosh² − sinh² = 1 holds to machine epsilon.

The sandwich D_s·X·~D_s produces a null point with n_inf normalization
1/s.  `decode_quantity` is updated to divide by that factor, recovering
value · s.  For translator outputs (normalization = 1) the result is
identical to the previous direct e1 read; all 152 prior add/subtract
tests pass unchanged.

`embed_quantity` is updated to embed directly in float64, eliminating
float32 quantization error for values like 0.01 (float32(0.01) ≠ 0.01);
all prior test-case values were exactly representable in float32.

* docs(ADR-0141): add decision document for multiply-as-dilator spike

The ADR doc was drafted in a separate branch and not present when the
implementation worktree was created from origin/main. Adding it now so
the decision record lands on main with the implementation it specifies.

Content unchanged from the draft — same spec the implementation already
satisfies (10 assertion families, fixed test cases, falsification
discipline, deferred scope for negative / zero / divide / Rate).

No code or test changes in this commit.
2026-05-24 09:09:53 -07:00

221 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ADR-0139 — Arithmetic-as-versor spike: `add` only.
Algebraic substrate for representing scalar arithmetic as closed versors
in Cl(4,1). This module proves the **load-bearing unknown** of the
Engine A lift program: that one arithmetic operation can be represented
as a closed unit versor satisfying ``versor_condition < 1e-6`` without
weakening any existing invariant.
Scope (frozen by ADR-0139):
- Single operation: ``add``.
- Single-axis embedding: quantities live on the e1 axis of the CGA
conformal model.
- No graph wiring (no ``MathProblemGraph`` consumer).
- No pipeline wiring (no ``CognitiveTurnPipeline`` integration).
- No GSM8K case routed.
- Unit is carried as caller metadata; not encoded in the multivector.
If acceptance assertions hold for ``add``, follow-on ADRs cover
``subtract`` (inverse translator), ``multiply`` (dilator), and the lift
to ``MathProblemGraph`` consumers. If they do not, the lift program is
paused.
Determinism: float64 end-to-end. No platform-conditional code. No
randomness.
References:
- ``algebra/cga.py:embed_point`` — conformal point embedding
- ``algebra/cga.py:cga_inner`` — null-cone metric
- ``algebra/versor.py:versor_apply`` — sandwich product (null inputs
preserved via raw sandwich)
- ``algebra/versor.py:versor_condition`` — ``|V·reverse(V) - 1|``
- ``algebra/cl41.py:geometric_product`` — Cl(4,1) geometric product
"""
from __future__ import annotations
import numpy as np
from algebra.cga import cga_inner
from algebra.cl41 import N_COMPONENTS, geometric_product
__all__ = [
"embed_quantity",
"translator",
"subtract",
"multiply",
"decode_quantity",
"N_INF",
]
# Conformal point at infinity: n_inf = e4 + e5 (per algebra/cga.py
# convention). Constructed as a 32-component grade-1 multivector with
# components at indices 4 (e4) and 5 (e5) both equal to 1.0.
def _n_inf() -> np.ndarray:
v = np.zeros(N_COMPONENTS, dtype=np.float64)
v[4] = 1.0
v[5] = 1.0
return v
N_INF: np.ndarray = _n_inf()
def embed_quantity(value: float, unit: str) -> np.ndarray:
"""Embed a scalar quantity as a conformal point on the e1 axis.
The quantity ``value`` becomes a CGA null point at Euclidean
coordinates ``[value, 0, 0]``. The ``unit`` argument is not
encoded in the multivector — it is carried as caller metadata and
enforced by ``decode_quantity`` returning the same unit string.
Returns a float64 32-component multivector lying on the null cone:
``cga_inner(X, X) ≈ 0``.
Args:
value: Numeric value of the quantity.
unit: Unit string (carried metadata; not encoded).
Returns:
32-component float64 multivector representing the embedded point.
"""
if not isinstance(unit, str) or not unit:
raise ValueError(f"embed_quantity: unit must be a non-empty string, got {unit!r}")
# Embed directly in float64 to avoid float32 quantization error for
# values like 0.01 that have no exact float32 representation.
# Formula: X = v*e1 + n_o + 0.5*v²*n_inf, n_o = 0.5*(e5-e4), n_inf = e4+e5.
v = float(value)
v_sq = v * v
result = np.zeros(N_COMPONENTS, dtype=np.float64)
result[1] = v # e1 component
result[4] = 0.5 * (v_sq - 1.0) # e4: n_o contribution -0.5, n_inf contribution +0.5*v²
result[5] = 0.5 * (v_sq + 1.0) # e5: n_o contribution +0.5, n_inf contribution +0.5*v²
return result
def translator(addend: float) -> np.ndarray:
"""Construct the CGA translator versor for additive shift along e1.
Standard CGA translator construction:
T_t = 1 - 0.5 * (t · n_inf)
where ``t = addend * e1`` is the Euclidean translation vector lifted
to grade-1, and ``n_inf = e4 + e5``. Since ``t`` and ``n_inf`` are
orthogonal null/non-null vectors, their geometric product is purely
a bivector and ``(t · n_inf)² = 0``, so the closed-form expression
is exact (no higher-order terms in the exponential expansion).
The construction guarantees ``T_t · reverse(T_t) = 1`` exactly in
exact arithmetic; in float64 the residual measured by
``versor_condition`` should be at machine epsilon.
Args:
addend: Scalar to add along e1.
Returns:
32-component float64 unit versor satisfying
``versor_condition(T) < 1e-6``.
"""
# t = addend * e1 — grade-1 vector with only e1 component
t = np.zeros(N_COMPONENTS, dtype=np.float64)
t[1] = float(addend)
# B = t * n_inf — geometric product (bivector since t ⊥ n_inf)
bivector = geometric_product(t, N_INF)
# T = 1 - 0.5 * B
T = np.zeros(N_COMPONENTS, dtype=np.float64)
T[0] = 1.0 # scalar part
T -= 0.5 * bivector
return T
def subtract(addend: float) -> np.ndarray:
"""Construct the CGA translator versor for subtractive shift along e1.
Delegates to ``translator(-addend)``. No new algebra.
"""
return translator(-float(addend))
def multiply(scale: float) -> np.ndarray:
"""Construct the CGA dilator versor for multiplicative scaling along e1.
Restricted to scale > 0 strictly. Calls with scale <= 0 raise
ValueError. Negative scales (require composition with reflection)
and multiplication by zero (degenerate) are deferred to follow-on ADRs.
Construction: D_s = cosh(α/2) + sinh(α/2) * (n_o ∧ n_inf)
where s = exp(α), α = ln(s).
Measured in this CGA implementation (blade indices 0-indexed):
N = n_o ∧ n_inf has a single non-zero component at index 15
(blade (3,4) = e4∧e5) with value -1.0.
N² = +1 (pure scalar, verified empirically and analytically).
Because N² = +1 the exponential exp(α/2 · N) = cosh(α/2) + sinh(α/2)·N
is exact in float64 — no series truncation error.
The sandwich D_s · X · ~D_s applied to a null CGA point P(a) yields
a null point projectively equal to P(a·s) with n_inf normalization
factor 1/s. decode_quantity normalizes by n_inf to recover a·s.
Args:
scale: Positive real multiplier. Must satisfy scale > 0.
Returns:
32-component float64 unit versor satisfying
``versor_condition(D) < 1e-6``.
Raises:
ValueError: If scale <= 0.
"""
scale = float(scale)
if scale <= 0.0:
raise ValueError(
f"multiply: scale must be strictly positive, got {scale!r}. "
f"Negative scales and zero are deferred to follow-on ADRs."
)
alpha = np.log(scale)
half = alpha / 2.0
D = np.zeros(N_COMPONENTS, dtype=np.float64)
D[0] = np.cosh(half)
# N = n_o ∧ n_inf has component -1 at index 15 (blade (3,4), measured).
# D_s = cosh(α/2)·1 + sinh(α/2)·N → D[15] = sinh · (-1) = -sinh.
D[15] = -np.sinh(half)
return D
def decode_quantity(F: np.ndarray, unit: str) -> tuple[float, str]:
"""Decode a multivector back to a (value, unit) scalar quantity.
CGA points are projective: D_s * P * ~D_s produces a point
proportional to P(s·x) with scale factor 1/s. Normalizing by the
n_inf inner product recovers the true Euclidean coordinate regardless
of projective scale. For translator outputs (n_inf·X = -1) the
normalization is 1 and the result is identical to the previous
direct e1 read.
Args:
F: 32-component multivector to decode.
unit: Unit string to attach to the returned scalar.
Returns:
Tuple of ``(value, unit)`` where ``value`` is the normalized
e1 coordinate.
"""
if not isinstance(unit, str) or not unit:
raise ValueError(f"decode_quantity: unit must be a non-empty string, got {unit!r}")
arr = np.asarray(F, dtype=np.float64)
if arr.shape != (N_COMPONENTS,):
raise ValueError(f"decode_quantity: expected shape ({N_COMPONENTS},), got {arr.shape}")
# Normalize e1 by the n_inf inner product. For normalized conformal
# points (n_inf·X = -1) this divides by 1; for dilated points with
# scale s it divides by 1/s, recovering value * s.
n_inf_inner = float(cga_inner(N_INF, arr))
if abs(n_inf_inner) < 1e-15:
raise ValueError("decode_quantity: degenerate point (n_inf inner product is zero)")
return float(arr[1]) / (-n_inf_inner), unit