* 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.
221 lines
8.1 KiB
Python
221 lines
8.1 KiB
Python
"""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
|