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:
parent
622919019d
commit
34cc345d7e
3 changed files with 757 additions and 15 deletions
371
docs/decisions/ADR-0141-multiply-as-dilator-positive-nonzero.md
Normal file
371
docs/decisions/ADR-0141-multiply-as-dilator-positive-nonzero.md
Normal 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 5–9) 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 5–9 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.
|
||||
|
|
@ -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) = 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.
|
||||
|
||||
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
|
||||
|
|
|
|||
311
tests/test_arithmetic_multiply_as_dilator.py
Normal file
311
tests/test_arithmetic_multiply_as_dilator.py
Normal 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) = e4∧e5), value = -1.0.
|
||||
N² = +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 N² = +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)·N² = 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 1–5, 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}"
|
||||
)
|
||||
Loading…
Reference in a new issue