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 import numpy as np
from algebra.cga import embed_point from algebra.cga import cga_inner
from algebra.cl41 import N_COMPONENTS, geometric_product from algebra.cl41 import N_COMPONENTS, geometric_product
__all__ = [ __all__ = [
"embed_quantity", "embed_quantity",
"translator", "translator",
"subtract", "subtract",
"multiply",
"decode_quantity", "decode_quantity",
"N_INF", "N_INF",
] ]
@ -82,9 +83,16 @@ def embed_quantity(value: float, unit: str) -> np.ndarray:
""" """
if not isinstance(unit, str) or not unit: if not isinstance(unit, str) or not unit:
raise ValueError(f"embed_quantity: unit must be a non-empty string, got {unit!r}") 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)) # Embed directly in float64 to avoid float32 quantization error for
# Upcast to float64 for the runtime field-state path. # values like 0.01 that have no exact float32 representation.
return point_float32.astype(np.float64) # 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: def translator(addend: float) -> np.ndarray:
@ -133,29 +141,81 @@ def subtract(addend: float) -> np.ndarray:
return translator(-float(addend)) 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]: def decode_quantity(F: np.ndarray, unit: str) -> tuple[float, str]:
"""Decode a multivector back to a (value, unit) scalar quantity. """Decode a multivector back to a (value, unit) scalar quantity.
For a CGA point on the e1 axis, the e1 component directly carries CGA points are projective: D_s * P * ~D_s produces a point
the Euclidean coordinate (and thus the encoded scalar value). The proportional to P(s·x) with scale factor 1/s. Normalizing by the
unit string is passed through from the caller this function does n_inf inner product recovers the true Euclidean coordinate regardless
not infer or change the unit. of projective scale. For translator outputs (n_inf·X = -1) the
normalization is 1 and the result is identical to the previous
The decoder reads only the e1 component (index 1). It does not direct e1 read.
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).
Args: Args:
F: 32-component multivector to decode. F: 32-component multivector to decode.
unit: Unit string to attach to the returned scalar. unit: Unit string to attach to the returned scalar.
Returns: 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: if not isinstance(unit, str) or not unit:
raise ValueError(f"decode_quantity: unit must be a non-empty string, got {unit!r}") raise ValueError(f"decode_quantity: unit must be a non-empty string, got {unit!r}")
arr = np.asarray(F, dtype=np.float64) arr = np.asarray(F, dtype=np.float64)
if arr.shape != (N_COMPONENTS,): if arr.shape != (N_COMPONENTS,):
raise ValueError(f"decode_quantity: expected shape ({N_COMPONENTS},), got {arr.shape}") 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}"
)