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.
This commit is contained in:
Shay 2026-05-24 09:09:53 -07:00 committed by GitHub
parent 622919019d
commit 34cc345d7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 757 additions and 15 deletions

View file

@ -0,0 +1,371 @@
# ADR-0141 — `multiply` as Dilator (Positive Non-Zero Multipliers Only)
**Status:** Draft
**Date:** 2026-05-24
**Author:** CORE agents
**Parent:** [ADR-0140](./ADR-0140-subtract-and-additive-group-closure.md), [ADR-0139](./ADR-0139-arithmetic-as-versor-spike.md)
**Engine target:** CGA cognitive engine (`algebra/versor.py`, `algebra/cga.py`)
---
## Context
ADR-0139 and ADR-0140 proved the **additive subgroup** of Cl(4,1)
translators along e1 is exactly closed: `add`, `subtract`, inverse
composition, round-trip, and commutativity all land at residual
0.0e+00 in float64. Three levels of verification (pointwise,
algebraic group, application round-trip) all hold exactly.
That subgroup is closed under one operation pair (translation /
inverse-translation). Multiplication is structurally different —
**dilation** in conformal geometric algebra is a *different versor
manifold* with a different generator. Whether the dilator construction
closes at the same tolerance as the translator does is **not implied by
the additive result**; it has to be re-derived and re-tested.
This ADR is the spike that tests it. Scope is deliberately narrow:
**positive non-zero real multipliers only.** Negative multipliers and
multiplication by zero are explicitly deferred to follow-on ADRs.
---
## Why this scope is narrow on purpose
Three operations look like "multiplication" at the math level but
have structurally distinct algebraic representations:
| Operation | CGA construction | This ADR? |
|---|---|---|
| `multiply(positive_nonzero_real)` | Pure dilator: `D_s = exp(α/2 · (n_o ∧ n_inf))` where `s = exp(α)` | **Yes** |
| `multiply(negative_real)` | Dilation composed with reflection (or inversion) — not a pure dilator | Deferred (ADR-0141b or 0141.N) |
| `multiply(0)` | Degenerate. `D_0` involves `log(0) = −∞`; not a well-defined versor | Deferred (ADR-0141.Z) |
Trying to cover all three in one ADR conflates the algebraic claim
("dilation closes exactly") with two separate construction claims
(reflection-composition, degenerate-handling). If any of the three
fails, the diagnosis becomes harder. Splitting them isolates the
*spike's* falsification clearly:
- If this ADR fails, dilator-as-multiply is wrong and the lift program
pauses.
- If this ADR passes but the deferred ADRs fail, dilator-as-multiply
works but composed constructions need different machinery.
Same discipline as ADR-0139 starting with `add` only and ADR-0140
adding `subtract` separately rather than trying to ship `multiply`
in the same PR.
---
## Decision
### Construction
`multiply(scale)` is implemented as the **CGA dilator versor**:
```text
D_s = exp(α/2 · (n_o ∧ n_inf)) where s = exp(α), s > 0
```
Computed via the closed-form expansion that uses
`(n_o ∧ n_inf)² = (something with known value derivable from cga.py
conventions)`. The exact construction is derivable from the existing
`algebra/cga.py` primitives; no new algebra is invented in this ADR.
Restriction: `scale > 0` and `scale ≠ 0`. Calls with `scale <= 0` raise
a typed `ValueError`. The check happens at construction time so the
restriction is visible at the boundary.
### Application
```text
result = versor_apply(D_s, embed_quantity(value, unit))
```
`versor_apply` already has the dual-path behavior for null inputs (CGA
points), so no change to the existing primitive is required. Same
substrate that ADR-0139 and 0140 used.
### Decoding
After dilation, the e1 coordinate of the result point gives `value * s`.
`decode_quantity(F, unit)` (unchanged) extracts it.
---
## Acceptance
A test module — `tests/test_arithmetic_multiply_as_dilator.py` — passes
with assertions on a fixed set of `(a, s)` pairs where `s > 0` strictly.
### Assertion families
**Family 1 — Dilator well-formedness.** For each `s` in the test set:
- `versor_condition(multiply(s)) < 1e-6` (dilator is a unit versor).
**Family 2 — Closure under sandwich.** For each `(a, s)`:
- `cga_inner(R, R) < 1e-5` where `R = versor_apply(multiply(s), embed_quantity(a, "u"))`.
**Family 3 — Arithmetic correctness.** For each `(a, s)`:
- `decode_quantity(R, "u") == (a * s, "u")` within `1e-9`.
- Includes integer `s` (e.g., `s = 2, 3, 10`), unit fraction `s` (e.g.,
`s = 0.5, 0.25, 1/3`), and irrational-ish `s` (e.g., `s = √2 ≈
1.4142..., s = π ≈ 3.14159...`).
**Family 4 — Replay determinism.** Two independent runs produce byte-
identical multivectors for `multiply(s)`, applied results, and decoded
values.
**Family 5 — Identity dilator.** `multiply(1.0)` equals the scalar
identity versor `[1, 0, 0, ...]` within `1e-9` component-wise. This is
the analog of `translator(0)` being identity in the additive group;
verified explicitly because it's a degenerate-but-important edge.
**Family 6 — Composition into product.** For each `(s1, s2)`:
- `geometric_product(multiply(s1), multiply(s2)) == multiply(s1 * s2)`
component-wise within `1e-9`.
- Tests the multiplicative group structure: dilations compose to
dilations by the scalar product of their scales. This is the analog
of ADR-0140 family 9 (additive composition) for the multiplicative
group.
**Family 7 — Inverse composition.** For each `s`:
- `geometric_product(multiply(1/s), multiply(s))` equals the scalar
identity within `1e-9`.
- Tests that `multiply(1/s)` is the inverse of `multiply(s)`. This
introduces the operation that becomes ADR-0141's natural sibling
(division-as-inverse-dilator) without committing to it formally.
**Family 8 — Round-trip closure.** For each `(a, s)`:
- `versor_apply(multiply(1/s), versor_apply(multiply(s), embed_quantity(a, "u")))`
decodes to `(a, "u")` within `1e-9`.
**Family 9 — Commutativity.** For each `(s1, s2)`:
- `geometric_product(multiply(s1), multiply(s2))` byte-equals
`geometric_product(multiply(s2), multiply(s1))`.
- Dilations along a single conformal axis commute (this is the abelian
property of the multiplicative subgroup).
### Boundary refusal
**Family 10 — Refusal on invalid scale.** For each `s ∈ {0, -1, -3.5}`:
- `multiply(s)` raises `ValueError` with a typed message naming the
scale value and the restriction.
- Test that the error fires at construction time, not at application
time.
### Fixed test cases
```text
Scale set for families 1-5, 7, 8 (a, s):
(0, 2), (1, 2), (1, 3), (3, 4),
(5, 0.5), (10, 0.25), (4, 0.75),
(7, 1.0), ← identity scale
(2, 1.4142135623730951), ← √2
(1, 3.141592653589793), ← π
(100, 0.01), (0.01, 100),
(-5, 2), (5, -2) ← excluded — see family 10
Composition set for families 6, 9 (s1, s2):
(1, 1), (2, 1), (1, 2), (1.0, 1.0),
(2, 3), (3, 2), (0.5, 4),
(1.4142..., 1.4142...) → 2.0 ← √2 × √2 round-trip
(3.14159..., 1.0),
(10, 0.1) → 1.0
Boundary set for family 10 (invalid s):
0, -1, -3.5, -100, -0.0001
```
---
## Non-goals
Out of scope for this ADR:
- **No negative multiplication.** `multiply(-3)` is deferred. The
construction would need to compose a dilator with a reflection or
inversion, which is a different versor and requires its own
closure analysis. Tests for negative `s` in family 10 verify
refusal-on-construction, not admission.
- **No multiplication by zero.** `multiply(0)` is deferred. The
dilator `D_0` is degenerate (involves `log(0)`). A separate ADR
decides whether `multiply(0)` returns the zero embedding or raises.
- **No `divide` operation.** Family 7 tests `multiply(1/s)` as an
inverse internally but does not expose a public `divide()` function.
That's a sibling ADR (likely 0141.B).
- **No `Rate` construction.** Rates (`apply_rate`) are
bivector-shaped and require their own ADR (0142).
- **No `MathProblemGraph` consumer.** No `PropositionGraph`
construction. No `CognitiveTurnPipeline` integration. No GSM8K case
routed. Same boundary as ADR-0139 and ADR-0140.
- **No pack changes.** `en_arithmetic_v1` already contains the
`multiply` lemma; this ADR doesn't extend the pack.
Engine B (`math_solver.py`, candidate-graph parser, S.x corridor) remains
unchanged. The 3/50 GSM8K admission set is preserved.
---
## Rationale
**Why dilator at all?**
In CGA, the natural representation of scalar multiplication on
Euclidean points is dilation: a versor that scales distances from the
origin. Applied to a point at `[a, 0, 0]` on the e1 axis, the dilator
`D_s` (for `s > 0`) produces the point at `[a·s, 0, 0]`. This is the
direct analog of how translators represent addition.
Dilators are unit versors *on their manifold* — but that manifold is
different from the translator manifold. The closure properties have to
be checked explicitly; they're not inherited from ADR-0139/0140.
**Why the multiplicative group, not just point-pair tests?**
Same reason ADR-0140 added group-structure tests beyond pointwise
correctness: scalar multiplication on positive reals *is* a group
(abelian, with identity 1, inverse `1/s`, associative). If the dilator
construction faithfully decodes multiplication, the group axioms must
hold automatically. Testing them (families 59) is structural
verification, not optional.
If family 6 (composition into product) fails, the construction is
decoding something that *isn't* the multiplicative group on positive
reals. If family 9 (commutativity) fails, the algebra is non-abelian
along the conformal e1 axis — which would be a much deeper problem
than just "multiply doesn't work."
**Why irrational test values?**
ADR-0139 tested only integer and simple-fractional values. The
dilator construction involves `exp(α/2)`, which produces irrational
intermediate values even for integer `s`. Including `√2` and `π` in the
test set probes whether the construction handles the full positive-real
domain or only computationally clean values.
If `(2, √2)` and `(2, √2)` compose to `(2, 2)` byte-equal (family 6's
`√2 × √2 = 2` case), that's evidence the construction is closed under
its own outputs — not just on inputs the test author happened to
write down.
**Why no test for negative scales beyond family 10?**
Family 10 verifies the *boundary refusal* — that the construction
rejects invalid inputs at construction time. It does *not* test what
the right behavior for negative scales should be; that's the deferred
ADR's job. The test here only proves the boundary is enforced.
---
## Risks the spike must surface
This ADR concentrates the **highest algebra risk** in the lift program
to date. Several plausible failure modes:
- **Dilator construction may not close at `1e-6`.** Translators closed
*exactly* (residual 0.0) because their bivector squared to zero.
Dilator bivectors `(n_o ∧ n_inf)` do *not* square to zero — they
square to a known value derivable from the metric signature. So the
closure cancellation is different and may only be at machine epsilon
(~1e-15) rather than exactly 0.0. **Report measured residuals; do
not loosen the 1e-6 threshold.**
- **The exponential expansion may introduce drift.** `D_s = exp(α/2 ·
(n_o ∧ n_inf))` is computed via series expansion or via
`cosh + sinh` decomposition. The latter is closed-form and
expected to be exact in float64 because `(n_o ∧ n_inf)²` is a known
scalar; but the implementation has to commit to one or the other and
measure.
- **Irrational scales may not round-trip exactly.** `√2 × √2 = 2`
algebraically but in float64 may produce `2.0000000000000004` or
similar. Family 6's `(√2, √2) → 2` case explicitly probes this. If
the residual exceeds `1e-9`, that's a finding about the
construction's numerical fidelity, not a failure to weaken
tolerance.
- **Composition may produce drift faster than addition.** Multiplying
`(10, 0.1)` to land on the identity scale relies on `10 × 0.1 = 1.0`
in float64, which is *not* exact (`0.1` has no finite binary
representation). Family 6's `(10, 0.1) → 1.0` case is the smallest
case that probes this drift; the test threshold (`1e-9`) may need to
be reported honestly even if it doesn't quite hit `0.0`.
- **Identity-dilator may not be the literal scalar `1`.**
`multiply(1.0)` should equal the identity versor `[1, 0, ...]`. The
closed-form construction should yield this, but family 5 tests it
explicitly because the analogous `translator(0)` case was a known
edge in ADR-0140.
- **Application-level round-trip (family 8) may be worse than
algebra-level inverse (family 7).** ADR-0140 found these were both
exactly 0.0, but with translators the cancellation was perfect.
With dilators, the round-trip involves two non-zero-residual
versors composing through `versor_apply`. The application path may
accumulate drift the algebra path doesn't show. **Report both
family 7 and family 8 residuals independently.**
Per [[feedback-address-critiques-dont-waive]]: any measured value that
exceeds its threshold — even by a small amount — must be reported, not
adjusted-around. If `1e-9` is exceeded in family 3, the finding is
"dilator construction introduces float64-precision drift in arithmetic
correctness," and the ADR's status becomes a partial pass or a
falsification depending on the magnitude.
---
## Replay & invariants
Same invariants as ADR-0139 and ADR-0140:
- `versor_condition(D_s) < 1e-6` for all constructed dilators.
- Null inputs to `versor_apply` stay null.
- No new normalization introduced; no normalization site moves outside
the allowed list (CLAUDE.md).
- Float64 end-to-end.
- Determinism: same `(a, s)` → identical multivector bytes across runs.
**New cross-cutting invariant introduced by this ADR:** the
multiplicative subgroup of Cl(4,1) dilators along the conformal
diagonal is abelian and closed under composition, with identity at
`s = 1` and inverse at `s ↦ 1/s`, **for `s > 0`**. Families 59 are
the CI-enforced statement of this invariant within the restricted
domain.
---
## Sequencing for follow-on
Only if every assertion in this ADR passes:
1. **ADR-0141.B**`divide` as inverse dilator. Should be near-trivial
(analog of how `subtract` followed `add`): `divide(s) = multiply(1/s)`,
with the same group-structure verification.
2. **ADR-0141.N** — Negative multiplication. Needs the composed
dilation-with-reflection construction. Higher risk than this ADR.
3. **ADR-0141.Z** — Multiplication by zero. Degenerate case; may not be
representable as a versor at all and may require a typed refusal
or a different multivector representation.
4. **ADR-0142**`Rate` as bivector + `apply_rate` as combined
translator-dilator. Bivectors carry units in two directions; the
construction is structurally different from both translators and
dilators.
If this ADR fails, the lift program pauses pending a revised dilator
construction or a fundamentally different multiplication representation.
---
## Decision summary
Extend `generate/math_versor_arithmetic.py` with `multiply(scale)`
the standard CGA dilator versor restricted to `scale > 0`. Add a test
module verifying ten assertion families (well-formedness, closure,
arithmetic correctness, replay, identity, group composition,
inverse, round-trip, commutativity, and boundary refusal).
Acceptance is binary: every test passes within the specified
tolerances, or the ADR is withdrawn and the lift program pauses
pending a revised construction. Measured values are reported honestly
even when they pass — the threshold is the limit, not the goal.

View file

@ -37,13 +37,14 @@ from __future__ import annotations
import numpy as np
from algebra.cga import embed_point
from algebra.cga import cga_inner
from algebra.cl41 import N_COMPONENTS, geometric_product
__all__ = [
"embed_quantity",
"translator",
"subtract",
"multiply",
"decode_quantity",
"N_INF",
]
@ -82,9 +83,16 @@ def embed_quantity(value: float, unit: str) -> np.ndarray:
"""
if not isinstance(unit, str) or not unit:
raise ValueError(f"embed_quantity: unit must be a non-empty string, got {unit!r}")
point_float32 = embed_point(np.array([value, 0.0, 0.0], dtype=np.float32))
# Upcast to float64 for the runtime field-state path.
return point_float32.astype(np.float64)
# 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:
@ -133,29 +141,81 @@ def subtract(addend: float) -> np.ndarray:
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) = e4e5) with value -1.0.
= +1 (pure scalar, verified empirically and analytically).
Because = +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.
For a CGA point on the e1 axis, the e1 component directly carries
the Euclidean coordinate (and thus the encoded scalar value). The
unit string is passed through from the caller this function does
not infer or change the unit.
The decoder reads only the e1 component (index 1). It does not
cross-check the e4/e5 components for consistency with the null
property; that check is the test layer's job (assertion family 1
and 3 in the ADR).
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 e1 coordinate.
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}")
return float(arr[1]), unit
# 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

View file

@ -0,0 +1,311 @@
"""ADR-0141 acceptance tests — multiply as CGA dilator (positive non-zero only).
Ten assertion families per the ADR:
Family 1 Dilator well-formedness: versor_condition(multiply(s)) < 1e-6.
Family 2 Closure under sandwich: cga_inner(R, R) < 1e-5.
Family 3 Arithmetic correctness: decode_quantity(R, "u") == (a*s, "u") within 1e-9.
Family 4 Replay determinism: byte-identical across runs.
Family 5 Identity dilator: multiply(1.0) equals scalar identity within 1e-9.
Family 6 Composition into product: multiply(s1)*multiply(s2) == multiply(s1*s2) within 1e-9.
Family 7 Inverse composition: multiply(1/s)*multiply(s) identity within 1e-9.
Family 8 Round-trip closure: decode(versor_apply(multiply(1/s), versor_apply(multiply(s), X))) == a within 1e-9.
Family 9 Commutativity: multiply(s1)*multiply(s2) byte-equals multiply(s2)*multiply(s1).
Family 10 Boundary refusal: multiply(0), multiply(-1), multiply(-3.5), multiply(-100),
multiply(-0.0001) all raise ValueError at construction time.
PRELIMINARY MEASUREMENT REPORT (empirical, this CGA implementation):
N = n_o n_inf: single non-zero component at index 15 (blade (3,4) = e4e5), value = -1.0.
= +1.0 (pure scalar, grade-0 only, all other components zero).
n_o · n_inf = -1.0; n_o² = 0.0; n_inf² = 0.0.
Because = +1, the exponential exp(α/2·N) = cosh(α/2) + sinh(α/2)·N is exact
in float64. The dilator is: D[0] = cosh(α/2), D[15] = -sinh(α/2), all others 0.
D_s · ~D_s = cosh²(α/2) - sinh²(α/2)· = cosh²(α/2) - sinh²(α/2) = 1 exactly.
So versor_condition(D_s) is at machine epsilon, not merely < 1e-6.
FALSIFICATION DISCIPLINE (read before changing any tolerance):
DO NOT loosen any threshold below. The thresholds are the ADR contract.
If any family fails, report the measured residual and stop; do not adjust.
"""
from __future__ import annotations
import math
import pytest
import numpy as np
from algebra.cga import cga_inner
from algebra.cl41 import geometric_product, N_COMPONENTS
from algebra.versor import versor_apply, versor_condition
from generate.math_versor_arithmetic import (
decode_quantity,
embed_quantity,
multiply,
)
# ---------------------------------------------------------------------------
# Fixed test cases per ADR-0141 §Acceptance §Fixed test cases
# ---------------------------------------------------------------------------
# Scale set for families 15, 7, 8. Only (a, s) pairs with s > 0.
# The ADR lists (5, -2) as "excluded" (negative s); it is tested in family 10.
SCALE_CASES: list[tuple[float, float]] = [
(0.0, 2.0),
(1.0, 2.0),
(1.0, 3.0),
(3.0, 4.0),
(5.0, 0.5),
(10.0, 0.25),
(4.0, 0.75),
(7.0, 1.0), # identity scale
(2.0, math.sqrt(2)), # √2
(1.0, math.pi), # π
(100.0, 0.01),
(0.01, 100.0),
(-5.0, 2.0), # negative a, positive s
]
# Composition set for families 6, 9.
COMPOSE_CASES: list[tuple[float, float]] = [
(1.0, 1.0),
(2.0, 1.0),
(1.0, 2.0),
(2.0, 3.0),
(3.0, 2.0),
(0.5, 4.0),
(math.sqrt(2), math.sqrt(2)), # √2 × √2 → 2.0
(math.pi, 1.0),
(10.0, 0.1), # 10 × 0.1 → 1.0 (float64 drift probe)
]
# Boundary set for family 10. All of these must raise ValueError.
INVALID_SCALES: list[float] = [0.0, -1.0, -3.5, -100.0, -0.0001]
# Tolerance constants — exactly as specified in ADR-0141.
TOL_VERSOR = 1e-6 # versor_condition runtime contract
TOL_NULL = 1e-5 # cga_inner(X, X) for null points
TOL_IDENTITY = 1e-9 # component-wise identity comparison
TOL_DECODE = 1e-9 # arithmetic correctness
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _identity_versor() -> np.ndarray:
v = np.zeros(N_COMPONENTS, dtype=np.float64)
v[0] = 1.0
return v
# ===========================================================================
# Family 1 — Dilator well-formedness
# ===========================================================================
@pytest.mark.parametrize("a,s", SCALE_CASES)
def test_family1_dilator_unit_versor(a: float, s: float) -> None:
"""versor_condition(multiply(s)) < 1e-6 for every scale in the test set."""
D = multiply(s)
cond = versor_condition(D)
assert cond < TOL_VERSOR, (
f"multiply({s}) not unit versor: versor_condition = {cond:.6e} (threshold 1e-6)"
)
# ===========================================================================
# Family 2 — Closure under sandwich
# ===========================================================================
@pytest.mark.parametrize("a,s", SCALE_CASES)
def test_family2_sandwich_preserves_null(a: float, s: float) -> None:
"""versor_apply(multiply(s), embed_quantity(a)) stays on the null cone."""
D = multiply(s)
X = embed_quantity(a, "u")
R = versor_apply(D, X)
inner_R = abs(float(cga_inner(R, R)))
assert inner_R < TOL_NULL, (
f"sandwich result ({a} × {s}) not null: |cga_inner(R, R)| = {inner_R:.3e}"
)
# ===========================================================================
# Family 3 — Arithmetic correctness
# ===========================================================================
@pytest.mark.parametrize("a,s", SCALE_CASES)
def test_family3_decode_matches_product(a: float, s: float) -> None:
"""decode_quantity(R, 'u') returns (a * s, 'u') within 1e-9."""
D = multiply(s)
X = embed_quantity(a, "u")
R = versor_apply(D, X)
value, unit = decode_quantity(R, "u")
expected = a * s
err = abs(value - expected)
assert unit == "u", f"unit metadata lost: got {unit!r}"
assert err < TOL_DECODE, (
f"decode error for ({a} × {s}): got {value!r}, expected {expected!r}, "
f"err = {err:.6e} (threshold 1e-9)"
)
# ===========================================================================
# Family 4 — Replay determinism
# ===========================================================================
@pytest.mark.parametrize("a,s", SCALE_CASES)
def test_family4_replay_byte_identical(a: float, s: float) -> None:
"""Two independent runs produce byte-identical multivector arrays."""
X1 = embed_quantity(a, "u")
X2 = embed_quantity(a, "u")
D1 = multiply(s)
D2 = multiply(s)
R1 = versor_apply(D1, X1)
R2 = versor_apply(D2, X2)
assert X1.tobytes() == X2.tobytes(), (
f"embed_quantity({a}) not deterministic across runs"
)
assert D1.tobytes() == D2.tobytes(), (
f"multiply({s}) not deterministic across runs"
)
assert R1.tobytes() == R2.tobytes(), (
f"versor_apply result not deterministic across runs for (a={a}, s={s})"
)
# ===========================================================================
# Family 5 — Identity dilator
# ===========================================================================
def test_family5_identity_dilator() -> None:
"""multiply(1.0) equals the scalar identity versor within 1e-9 component-wise."""
D = multiply(1.0)
identity = _identity_versor()
err_vec = np.abs(D - identity)
max_err = float(err_vec.max())
assert max_err < TOL_IDENTITY, (
f"multiply(1.0) deviates from scalar identity: "
f"max component error = {max_err:.6e} (threshold 1e-9)\n"
f"Non-zero diff components: "
+ str([(i, float(err_vec[i])) for i in range(len(err_vec)) if err_vec[i] > 1e-15])
)
# ===========================================================================
# Family 6 — Composition into product
# ===========================================================================
@pytest.mark.parametrize("s1,s2", COMPOSE_CASES)
def test_family6_composition_into_product(s1: float, s2: float) -> None:
"""geometric_product(multiply(s1), multiply(s2)) == multiply(s1*s2) within 1e-9."""
D1 = multiply(s1)
D2 = multiply(s2)
D12 = geometric_product(D1, D2)
D_prod = multiply(s1 * s2)
residual = np.abs(D12 - D_prod)
max_err = float(residual.max())
assert max_err < TOL_IDENTITY, (
f"Composition residual for ({s1}, {s2}) → s1*s2={s1*s2}: "
f"max |D12 - D(s1*s2)| = {max_err:.6e} (threshold 1e-9)\n"
f"Non-zero diff components: "
+ str([(i, float(residual[i])) for i in range(len(residual)) if residual[i] > 1e-15])
)
# ===========================================================================
# Family 7 — Inverse composition
# ===========================================================================
@pytest.mark.parametrize("a,s", SCALE_CASES)
def test_family7_inverse_composition_is_identity(a: float, s: float) -> None:
"""geometric_product(multiply(1/s), multiply(s)) ≈ identity within 1e-9."""
D_s = multiply(s)
D_inv = multiply(1.0 / s)
product = geometric_product(D_inv, D_s)
identity = _identity_versor()
residual = np.abs(product - identity)
max_err = float(residual.max())
assert max_err < TOL_IDENTITY, (
f"Inverse composition residual for s={s}: "
f"max |D(1/s)*D(s) - I| = {max_err:.6e} (threshold 1e-9)\n"
f"Non-zero diff components: "
+ str([(i, float(residual[i])) for i in range(len(residual)) if residual[i] > 1e-15])
)
# ===========================================================================
# Family 8 — Round-trip closure
# ===========================================================================
@pytest.mark.parametrize("a,s", SCALE_CASES)
def test_family8_round_trip_closure(a: float, s: float) -> None:
"""versor_apply(multiply(1/s), versor_apply(multiply(s), X)) decodes to (a, u) within 1e-9."""
D_s = multiply(s)
D_inv = multiply(1.0 / s)
X = embed_quantity(a, "u")
scaled = versor_apply(D_s, X)
recovered = versor_apply(D_inv, scaled)
# Intermediate must stay on null cone.
inner_scaled = abs(float(cga_inner(scaled, scaled)))
assert inner_scaled < TOL_NULL, (
f"Round-trip intermediate not null for (a={a}, s={s}): "
f"|cga_inner| = {inner_scaled:.3e}"
)
# Final must stay on null cone.
inner_recovered = abs(float(cga_inner(recovered, recovered)))
assert inner_recovered < TOL_NULL, (
f"Round-trip final not null for (a={a}, s={s}): "
f"|cga_inner| = {inner_recovered:.3e}"
)
value, unit = decode_quantity(recovered, "u")
err = abs(value - a)
assert unit == "u"
assert err < TOL_DECODE, (
f"Round-trip decode error for (a={a}, s={s}): "
f"got {value!r}, expected {a!r}, err = {err:.6e} (threshold 1e-9)"
)
# ===========================================================================
# Family 9 — Commutativity
# ===========================================================================
@pytest.mark.parametrize("s1,s2", COMPOSE_CASES)
def test_family9_commutativity_byte_equal(s1: float, s2: float) -> None:
"""geometric_product(multiply(s1), multiply(s2)) byte-equals multiply(s2)*multiply(s1)."""
D1 = multiply(s1)
D2 = multiply(s2)
ab = geometric_product(D1, D2)
ba = geometric_product(D2, D1)
assert ab.tobytes() == ba.tobytes(), (
f"Commutativity violation for (s1={s1}, s2={s2}): "
f"D1*D2 != D2*D1\n"
f"Max component diff: {float(np.abs(ab - ba).max()):.6e}"
)
# ===========================================================================
# Family 10 — Boundary refusal at construction time
# ===========================================================================
@pytest.mark.parametrize("bad_s", INVALID_SCALES)
def test_family10_invalid_scale_raises_at_construction(bad_s: float) -> None:
"""multiply(s) raises ValueError at construction for s in {0, -1, -3.5, -100, -0.0001}."""
with pytest.raises(ValueError) as exc_info:
multiply(bad_s)
msg = str(exc_info.value)
# Error must name the scale value.
assert str(bad_s) in msg or repr(bad_s) in msg, (
f"ValueError for scale={bad_s!r} does not name the scale in message: {msg!r}"
)
# Error must name the restriction.
assert any(kw in msg.lower() for kw in ("positive", "strictly", "deferred", "> 0")), (
f"ValueError for scale={bad_s!r} does not name the restriction in message: {msg!r}"
)