From 34cc345d7e620c3adfbcfa3732fb77547442ed1d Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 24 May 2026 09:09:53 -0700 Subject: [PATCH] feat(ADR-0141): multiply as CGA dilator versor (positive non-zero) (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- ...41-multiply-as-dilator-positive-nonzero.md | 371 ++++++++++++++++++ generate/math_versor_arithmetic.py | 90 ++++- tests/test_arithmetic_multiply_as_dilator.py | 311 +++++++++++++++ 3 files changed, 757 insertions(+), 15 deletions(-) create mode 100644 docs/decisions/ADR-0141-multiply-as-dilator-positive-nonzero.md create mode 100644 tests/test_arithmetic_multiply_as_dilator.py diff --git a/docs/decisions/ADR-0141-multiply-as-dilator-positive-nonzero.md b/docs/decisions/ADR-0141-multiply-as-dilator-positive-nonzero.md new file mode 100644 index 00000000..c3592472 --- /dev/null +++ b/docs/decisions/ADR-0141-multiply-as-dilator-positive-nonzero.md @@ -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. diff --git a/generate/math_versor_arithmetic.py b/generate/math_versor_arithmetic.py index 80aec93a..25440d0d 100644 --- a/generate/math_versor_arithmetic.py +++ b/generate/math_versor_arithmetic.py @@ -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 diff --git a/tests/test_arithmetic_multiply_as_dilator.py b/tests/test_arithmetic_multiply_as_dilator.py new file mode 100644 index 00000000..012f6aa2 --- /dev/null +++ b/tests/test_arithmetic_multiply_as_dilator.py @@ -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}" + )