chore(audit): substrate cleanup — dead spike, gitignore, deprecation, reader diagnosis

C1: delete generate/math_versor_arithmetic.py and its 3 tests (ADR-0139
add-only arithmetic spike; no runtime consumers, no pipeline wiring,
follow-on lift paused per module docstring).

C3: gitignore engine_state runtime artifacts (manifest.json,
recognizers.jsonl, discovery_candidates.jsonl). Module code
(engine_state/__init__.py) remains tracked; generated checkpoint
files should not be.

C5: document reader zero-delta root cause in train_sample/v1/README.md.
Both Phase 2 (whole-problem) and Phase 1 (question-only) reader paths are
called but inert because all 47 refusals are statement-level NO_INJECTOR
gaps, not question-sentence gaps. Reader unblocks when injector coverage
expands (C2 work). report.json use_reader flag corrected to reflect last run.

C6: add deprecation header to generate/math_parser.py pointing at
generate.math_candidate_graph.parse_and_solve as the live path.

C2/C4 briefs: docs/handoff/CLEANUP-C2-run-lane-migration.md and
docs/handoff/CLEANUP-C4-compositions-compile.md added as operator
dispatch docs for the medium-scope wiring tasks.
This commit is contained in:
Shay 2026-05-28 07:00:33 -07:00
parent fd6a544341
commit 89defef30b
10 changed files with 290 additions and 1024 deletions

7
.gitignore vendored
View file

@ -40,3 +40,10 @@ evals/gsm8k_math/holdouts/v1/cases-train*
# ADR-0172 W3 — math-contemplation proposals are generated on demand; the
# directory skeleton (.gitkeep) is committed, the generated JSONL is not.
teaching/math_proposals/proposals.jsonl
# ADR-0146 — engine_state/ is per-session mutable checkpoint state; only the
# module itself (engine_state/__init__.py) is tracked. Runtime-generated files
# are not source artifacts.
engine_state/manifest.json
engine_state/recognizers.jsonl
engine_state/discovery_candidates.jsonl

View file

@ -0,0 +1,126 @@
# C2 — Wire `run_lane` to `_score_one_candidate_graph`
**Classification**: Unfinished migration (audit finding C2)
**Risk**: Medium — must verify baseline comparison files still reproduce
**Scope**: `evals/gsm8k_math/runner.py` only
---
## Problem
`run_lane` (line 449) calls `_score_one`, the legacy regex path
(`generate/math_parser.py` → `MathProblemGraph``solve`). Only the
per-lane override in `train_sample/v1/runner.py:33` uses
`_score_one_candidate_graph`. Any caller that goes through `run_lane`
including the `public/`, `dev/`, and `holdout` lanes — is evaluated on the
legacy parser, not the candidate-graph architecture we've been measuring.
**Impact**: lift numbers on `train_sample/v1` are not comparable to what
`run_lane` would report on the other slices. The three-slice concern from the
audit is real.
---
## What `_score_one_candidate_graph` does differently
`_score_one_candidate_graph` (line 241) calls `parse_and_solve` from
`generate/math_candidate_graph`, which layers:
1. Whole-problem comprehension reader (ADR-0164 Phase 2, flag-gated)
2. Per-statement recognizer candidate graph (ME-1..ME-5 matchers)
3. Question-sentence hybrid reader (ADR-0164 Phase 1)
4. Injector dispatch (ADR-0170 + recognizer_anchor_inject.py)
5. Cartesian-product branch enumeration + admissibility gate
6. Existing solver + verifier (same as `_score_one`)
`_score_one` calls the regex parser directly, skipping steps 15.
---
## Migration plan
### Step 1 — Baseline snapshot (before changing `run_lane`)
```bash
# Capture current run_lane output on all slices that have unsealed cases
uv run python -m evals.gsm8k_math.runner --lane public > /tmp/pre_public.json
uv run python -m evals.gsm8k_math.runner --lane dev > /tmp/pre_dev.json
# Do NOT touch holdout; it is sealed.
```
### Step 2 — Wire `run_lane`
In `evals/gsm8k_math/runner.py` line 449, replace:
```python
outcomes = [_score_one(c) for c in cases]
```
with:
```python
outcomes = [_score_one_candidate_graph(c) for c in cases]
```
Add a `--legacy-parser` CLI flag that restores `_score_one` for the
comparison baseline file. The flag is only needed to reproduce
`baselines/frontier.json` and `baselines/comparison_v1.json`, both of which
were captured under the legacy path.
```python
# In run_lane signature:
def run_lane(
cases: list[dict[str, Any]],
*,
config: Any = None,
legacy_parser: bool = False,
) -> LaneReport:
score_fn = _score_one if legacy_parser else _score_one_candidate_graph
outcomes = [score_fn(c) for c in cases]
```
### Step 3 — Verify wrong == 0 on public/dev
```bash
uv run python -m evals.gsm8k_math.runner --lane public > /tmp/post_public.json
uv run python -m evals.gsm8k_math.runner --lane dev > /tmp/post_dev.json
# Assert wrong == 0 in both outputs
```
### Step 4 — Update baselines if counts differ
If `correct` counts differ (expected — the new path refuses differently),
update `baselines/frontier.json` and `baselines/comparison_v1.json` by
re-running with `--legacy-parser` so the files still reflect legacy baseline
numbers:
```bash
uv run python -m evals.gsm8k_math.runner --lane public --legacy-parser \
> evals/gsm8k_math/baselines/frontier.json
```
### Step 5 — Run smoke + packs suites
```bash
uv run core test --suite smoke -q
uv run core test --suite packs -q
```
---
## Invariant gates
- `wrong == 0` must hold on public + dev after the migration.
- `_score_one_candidate_graph` already enforces this by construction (same
verifier as `_score_one`, additive reader, refusal-preferring injector).
- The baseline snapshots captured in Step 1 give you a before/after diff to
explain any count change in a PR description.
---
## Relation to other findings
- **C4** (compositions compile): C4 should land first or simultaneously so
that `run_lane` immediately benefits from the registry being non-empty.
- **C5** (reader zero-delta): confirmed that the reader is inert until
statement-level injector gaps close. C2 does not depend on C5.

View file

@ -0,0 +1,127 @@
# C4 — Compile and commit `compositions.jsonl` for the math pack
**Classification**: Half-built producer/consumer loop (audit finding C4)
**Risk**: Low — purely additive, wrong == 0 guaranteed by registry design
**Scope**: `language_packs/data/en_core_math_v1/`, `language_packs/compile_compositions.py`
---
## Problem
`language_packs/data/en_core_math_v1/compositions/multiplicative_composition.jsonl`
was ratified via ADR-0169 and is present on disk. But
`language_packs/data/en_core_math_v1/compositions.jsonl` (the compiled
artifact that the runtime reads) **does not exist**.
The consumer chain is:
```
compositions/multiplicative_composition.jsonl ← ratified source (EXISTS)
↓ compile_compositions.py
compositions.jsonl ← compiled registry (MISSING)
↓ comprehension/composition_registry.py::load_composition_registry()
↓ recognizer_anchor_inject.py (line 157159)
InjectorEmission[] ← runtime output (always empty)
```
`load_composition_registry()` handles the missing file gracefully — it
returns an empty registry when `compositions.jsonl` is absent and
`manifest.json` does not declare `composition_checksum`. So the system is
stable; the ratified claims simply have no effect on runtime decisions.
---
## What to do
### Step 1 — Run the compile step
```bash
uv run python -c "
from pathlib import Path
from language_packs.compile_compositions import compile_pack_compositions
pack = Path('language_packs/data/en_core_math_v1')
result = compile_pack_compositions(pack)
print('written to:', result.output_path)
print('sha256:', result.sha256)
print('entries:', result.entry_count)
"
```
Verify `entry_count > 0` (at least the multiplicative_composition entries).
### Step 2 — Update manifest.json with composition_checksum
The manifest checksum enforces that the committed compiled artifact matches
what `compile_compositions.py` would produce from the source files. Once the
compiled artifact is committed, `manifest.json` should be updated to declare
`composition_checksum` so any future drift raises
`CompositionRegistryLoadError` at load time (defense in depth).
```bash
sha=$(sha256sum language_packs/data/en_core_math_v1/compositions.jsonl | cut -d' ' -f1)
# or on macOS:
sha=$(shasum -a 256 language_packs/data/en_core_math_v1/compositions.jsonl | cut -d' ' -f1)
```
Then add `"composition_checksum": "<sha>"` to `manifest.json`.
### Step 3 — Verify the registry is non-empty at runtime
```bash
uv run python -c "
from generate.comprehension.composition_registry import load_composition_registry
r = load_composition_registry()
print('empty:', r.is_empty())
print('categories:', list(r.by_category.keys()))
"
```
Expected: `empty: False`, at least `multiplicative_composition` in categories.
### Step 4 — Run packs + smoke suites
```bash
uv run core test --suite packs -q
uv run core test --suite smoke -q
```
### Step 5 — Commit
```
language_packs/data/en_core_math_v1/compositions.jsonl (new)
language_packs/data/en_core_math_v1/manifest.json (updated: composition_checksum)
```
Commit message:
```
feat(packs): compile multiplicative_composition registry for math pack
Runs compile_compositions.py to produce compositions.jsonl from the
ratified multiplicative_composition.jsonl source. Updates manifest.json
with composition_checksum. load_composition_registry() now returns a
non-empty registry; recognizer_anchor_inject.py can emit InjectorEmission
for composition-shape matches. Closes the producer/consumer gap from
ADR-0169.
```
---
## Invariant gates
- `wrong == 0` is preserved by the registry's refusal-preferring discipline:
`is_falsified` returns `()` immediately; `is_affirmed` gates every emission.
- The `WrongCompositionCategory` check at load time prevents any unsafe
category from being accepted even if the source file is mutated.
- The manifest checksum (after Step 2) provides ongoing compile-drift
detection.
---
## Relation to other findings
- **C2** (run_lane migration): C4 should land at the same time or before C2
so the candidate-graph path immediately sees a non-empty composition
registry.
- **C5** (reader zero-delta): the reader's zero-delta is unrelated to
compositions — it is a statement-level injector gap. C4 does not fix C5.

View file

@ -49,3 +49,24 @@ Each case in `cases.jsonl` preserves:
- `question`: the verbatim question string
- `answer_expression`: the verbatim answer field containing reasoning steps and number suffix
- `answer_numeric`: the integer or float parsed from the `#### N` suffix in the answer expression
## ADR-0164 Reader — Zero-Delta Diagnosis
`report.json` records `use_reader: true` (3 correct / 47 refused / 0 wrong), identical counts
to the baseline. The reader is not silent: both Phase 2 (whole-problem) and Phase 1
(question-only hybrid) are called. The zero-delta has a structural cause — all 47 refusals
are **statement-level**, not question-level:
- Phase 2 (`_try_comprehension_reader`) processes every sentence in order. It refuses on the
first statement it cannot classify (unknown rate, multiplicative aggregation, fractional
scale, etc.) and returns `None`, passing control to the regex pipeline.
- Phase 1 (`_try_reader_for_question`) is subsequently reached but only substitutes the
question's `CandidateUnknown`. It cannot recover cases whose statement sentences produced
no injector output (`NO_INJECTOR`) or no admissible candidate — those remain refused after
Phase 1's contribution.
Decision: the reader is structurally correct (`wrong == 0` preserved), inert on this sample
because the bottleneck is statement parsing / injector dispatch gaps (C2 in the cleanup wave),
not question parsing. No scope change needed; the reader unblocks when injector coverage
expands. Record this as the truth-test gate: reader lift will show only after `NO_INJECTOR`
refusals convert to admitted cases.

View file

@ -265,5 +265,5 @@
"sample_count": 50,
"sample_path": "evals/gsm8k_math/train_sample/v1/cases.jsonl",
"schema_version": 1,
"use_reader": false
"use_reader": true
}

View file

@ -1,5 +1,13 @@
"""ADR-0115 Phase 1.3 — deterministic math word-problem parser.
.. deprecated::
This module is the **legacy regex parser path**. New callers should use
:func:`generate.math_candidate_graph.parse_and_solve`, which layers the
recognizer-driven candidate-graph topology on top. This module remains as
the fallback path wired inside ``math_candidate_graph.py``; it should not
be invoked directly by pipeline or eval code.
Turns a grade-school math word problem into a :class:`MathProblemGraph`
via rule-based extraction. No LLM, no sampling, no statistical anything.
Same input string always produces the same graph; failures raise

View file

@ -1,221 +0,0 @@
"""ADR-0139 — Arithmetic-as-versor spike: `add` only.
Algebraic substrate for representing scalar arithmetic as closed versors
in Cl(4,1). This module proves the **load-bearing unknown** of the
Engine A lift program: that one arithmetic operation can be represented
as a closed unit versor satisfying ``versor_condition < 1e-6`` without
weakening any existing invariant.
Scope (frozen by ADR-0139):
- Single operation: ``add``.
- Single-axis embedding: quantities live on the e1 axis of the CGA
conformal model.
- No graph wiring (no ``MathProblemGraph`` consumer).
- No pipeline wiring (no ``CognitiveTurnPipeline`` integration).
- No GSM8K case routed.
- Unit is carried as caller metadata; not encoded in the multivector.
If acceptance assertions hold for ``add``, follow-on ADRs cover
``subtract`` (inverse translator), ``multiply`` (dilator), and the lift
to ``MathProblemGraph`` consumers. If they do not, the lift program is
paused.
Determinism: float64 end-to-end. No platform-conditional code. No
randomness.
References:
- ``algebra/cga.py:embed_point`` conformal point embedding
- ``algebra/cga.py:cga_inner`` null-cone metric
- ``algebra/versor.py:versor_apply`` sandwich product (null inputs
preserved via raw sandwich)
- ``algebra/versor.py:versor_condition`` ``|V·reverse(V) - 1|``
- ``algebra/cl41.py:geometric_product`` Cl(4,1) geometric product
"""
from __future__ import annotations
import numpy as np
from algebra.cga import cga_inner
from algebra.cl41 import N_COMPONENTS, geometric_product
__all__ = [
"embed_quantity",
"translator",
"subtract",
"multiply",
"decode_quantity",
"N_INF",
]
# Conformal point at infinity: n_inf = e4 + e5 (per algebra/cga.py
# convention). Constructed as a 32-component grade-1 multivector with
# components at indices 4 (e4) and 5 (e5) both equal to 1.0.
def _n_inf() -> np.ndarray:
v = np.zeros(N_COMPONENTS, dtype=np.float64)
v[4] = 1.0
v[5] = 1.0
return v
N_INF: np.ndarray = _n_inf()
def embed_quantity(value: float, unit: str) -> np.ndarray:
"""Embed a scalar quantity as a conformal point on the e1 axis.
The quantity ``value`` becomes a CGA null point at Euclidean
coordinates ``[value, 0, 0]``. The ``unit`` argument is not
encoded in the multivector it is carried as caller metadata and
enforced by ``decode_quantity`` returning the same unit string.
Returns a float64 32-component multivector lying on the null cone:
``cga_inner(X, X) 0``.
Args:
value: Numeric value of the quantity.
unit: Unit string (carried metadata; not encoded).
Returns:
32-component float64 multivector representing the embedded point.
"""
if not isinstance(unit, str) or not unit:
raise ValueError(f"embed_quantity: unit must be a non-empty string, got {unit!r}")
# Embed directly in float64 to avoid float32 quantization error for
# values like 0.01 that have no exact float32 representation.
# Formula: X = v*e1 + n_o + 0.5*v²*n_inf, n_o = 0.5*(e5-e4), n_inf = e4+e5.
v = float(value)
v_sq = v * v
result = np.zeros(N_COMPONENTS, dtype=np.float64)
result[1] = v # e1 component
result[4] = 0.5 * (v_sq - 1.0) # e4: n_o contribution -0.5, n_inf contribution +0.5*v²
result[5] = 0.5 * (v_sq + 1.0) # e5: n_o contribution +0.5, n_inf contribution +0.5*v²
return result
def translator(addend: float) -> np.ndarray:
"""Construct the CGA translator versor for additive shift along e1.
Standard CGA translator construction:
T_t = 1 - 0.5 * (t · n_inf)
where ``t = addend * e1`` is the Euclidean translation vector lifted
to grade-1, and ``n_inf = e4 + e5``. Since ``t`` and ``n_inf`` are
orthogonal null/non-null vectors, their geometric product is purely
a bivector and ``(t · n_inf)² = 0``, so the closed-form expression
is exact (no higher-order terms in the exponential expansion).
The construction guarantees ``T_t · reverse(T_t) = 1`` exactly in
exact arithmetic; in float64 the residual measured by
``versor_condition`` should be at machine epsilon.
Args:
addend: Scalar to add along e1.
Returns:
32-component float64 unit versor satisfying
``versor_condition(T) < 1e-6``.
"""
# t = addend * e1 — grade-1 vector with only e1 component
t = np.zeros(N_COMPONENTS, dtype=np.float64)
t[1] = float(addend)
# B = t * n_inf — geometric product (bivector since t ⊥ n_inf)
bivector = geometric_product(t, N_INF)
# T = 1 - 0.5 * B
T = np.zeros(N_COMPONENTS, dtype=np.float64)
T[0] = 1.0 # scalar part
T -= 0.5 * bivector
return T
def subtract(addend: float) -> np.ndarray:
"""Construct the CGA translator versor for subtractive shift along e1.
Delegates to ``translator(-addend)``. No new algebra.
"""
return translator(-float(addend))
def multiply(scale: float) -> np.ndarray:
"""Construct the CGA dilator versor for multiplicative scaling along e1.
Restricted to scale > 0 strictly. Calls with scale <= 0 raise
ValueError. Negative scales (require composition with reflection)
and multiplication by zero (degenerate) are deferred to follow-on ADRs.
Construction: D_s = cosh(α/2) + sinh(α/2) * (n_o n_inf)
where s = exp(α), α = ln(s).
Measured in this CGA implementation (blade indices 0-indexed):
N = n_o n_inf has a single non-zero component at index 15
(blade (3,4) = e4e5) with value -1.0.
= +1 (pure scalar, verified empirically and analytically).
Because = +1 the exponential exp(α/2 · N) = cosh(α/2) + sinh(α/2)·N
is exact in float64 no series truncation error.
The sandwich D_s · X · ~D_s applied to a null CGA point P(a) yields
a null point projectively equal to P(a·s) with n_inf normalization
factor 1/s. decode_quantity normalizes by n_inf to recover a·s.
Args:
scale: Positive real multiplier. Must satisfy scale > 0.
Returns:
32-component float64 unit versor satisfying
``versor_condition(D) < 1e-6``.
Raises:
ValueError: If scale <= 0.
"""
scale = float(scale)
if scale <= 0.0:
raise ValueError(
f"multiply: scale must be strictly positive, got {scale!r}. "
f"Negative scales and zero are deferred to follow-on ADRs."
)
alpha = np.log(scale)
half = alpha / 2.0
D = np.zeros(N_COMPONENTS, dtype=np.float64)
D[0] = np.cosh(half)
# N = n_o ∧ n_inf has component -1 at index 15 (blade (3,4), measured).
# D_s = cosh(α/2)·1 + sinh(α/2)·N → D[15] = sinh · (-1) = -sinh.
D[15] = -np.sinh(half)
return D
def decode_quantity(F: np.ndarray, unit: str) -> tuple[float, str]:
"""Decode a multivector back to a (value, unit) scalar quantity.
CGA points are projective: D_s * P * ~D_s produces a point
proportional to P(s·x) with scale factor 1/s. Normalizing by the
n_inf inner product recovers the true Euclidean coordinate regardless
of projective scale. For translator outputs (n_inf·X = -1) the
normalization is 1 and the result is identical to the previous
direct e1 read.
Args:
F: 32-component multivector to decode.
unit: Unit string to attach to the returned scalar.
Returns:
Tuple of ``(value, unit)`` where ``value`` is the normalized
e1 coordinate.
"""
if not isinstance(unit, str) or not unit:
raise ValueError(f"decode_quantity: unit must be a non-empty string, got {unit!r}")
arr = np.asarray(F, dtype=np.float64)
if arr.shape != (N_COMPONENTS,):
raise ValueError(f"decode_quantity: expected shape ({N_COMPONENTS},), got {arr.shape}")
# Normalize e1 by the n_inf inner product. For normalized conformal
# points (n_inf·X = -1) this divides by 1; for dilated points with
# scale s it divides by 1/s, recovering value * s.
n_inf_inner = float(cga_inner(N_INF, arr))
if abs(n_inf_inner) < 1e-15:
raise ValueError("decode_quantity: degenerate point (n_inf inner product is zero)")
return float(arr[1]) / (-n_inf_inner), unit

View file

@ -1,171 +0,0 @@
"""ADR-0139 acceptance tests — arithmetic-as-versor spike for `add`.
Six assertion families per the ADR:
1. Embedding well-formedness embedded quantity is on the null cone.
2. Translator well-formedness versor_condition < 1e-6.
3. Closure sandwiched result is still on the null cone.
4. Arithmetic correctness decoded value equals a + b within 1e-9.
5. Replay determinism running twice produces byte-identical arrays.
6. Composability two consecutive translators decode to a + b + c.
If any test fails, ADR-0139 is falsified; the lift program is paused.
DO NOT loosen tolerances to make tests pass.
"""
from __future__ import annotations
import pytest
import numpy as np
from algebra.cga import cga_inner
from algebra.versor import versor_apply, versor_condition
from generate.math_versor_arithmetic import (
decode_quantity,
embed_quantity,
translator,
)
# Fixed test cases per ADR-0139 acceptance.
ADD_CASES: list[tuple[float, float]] = [
(0.0, 0.0),
(0.0, 1.0),
(1.0, 0.0),
(3.0, 4.0),
(7.0, -3.0),
(0.25, 0.75),
(1.5, 2.5),
(-5.0, 5.0),
(-2.0, -3.0),
(100.0, 1.0),
(1.0, 100.0),
]
# Composability case per ADR-0139.
COMPOSE_CASE: tuple[float, float, float] = (2.0, 3.0, 5.0)
# Tolerance constants — exactly as specified in the ADR.
TOL_NULL = 1e-5 # cga_inner(X, X) for null points (f32 sandwich noise floor)
TOL_VERSOR = 1e-6 # versor_condition runtime contract
TOL_DECODE = 1e-9 # arithmetic correctness
# ----- Assertion family 1: embedding well-formedness -----
@pytest.mark.parametrize("a,b", ADD_CASES)
def test_family1_embedding_is_null(a: float, b: float) -> None:
"""embed_quantity(a, _) and embed_quantity(b, _) both lie on the null cone."""
X_a = embed_quantity(a, "u")
X_b = embed_quantity(b, "u")
inner_a = abs(float(cga_inner(X_a, X_a)))
inner_b = abs(float(cga_inner(X_b, X_b)))
assert inner_a < TOL_NULL, (
f"embed_quantity({a}) not null: |cga_inner| = {inner_a:.3e}"
)
assert inner_b < TOL_NULL, (
f"embed_quantity({b}) not null: |cga_inner| = {inner_b:.3e}"
)
# ----- Assertion family 2: translator well-formedness -----
@pytest.mark.parametrize("a,b", ADD_CASES)
def test_family2_translator_unit_versor(a: float, b: float) -> None:
"""translator(b) satisfies versor_condition < 1e-6."""
T = translator(b)
cond = versor_condition(T)
assert cond < TOL_VERSOR, (
f"translator({b}) not unit versor: versor_condition = {cond:.3e}"
)
# ----- Assertion family 3: closure -----
@pytest.mark.parametrize("a,b", ADD_CASES)
def test_family3_sandwich_preserves_null(a: float, b: float) -> None:
"""versor_apply(translator(b), embed_quantity(a, _)) is still on the null cone."""
X = embed_quantity(a, "u")
T = translator(b)
R = versor_apply(T, X)
inner_R = abs(float(cga_inner(R, R)))
assert inner_R < TOL_NULL, (
f"sandwich result ({a} + {b}) not null: |cga_inner(R, R)| = {inner_R:.3e}"
)
# ----- Assertion family 4: arithmetic correctness -----
@pytest.mark.parametrize("a,b", ADD_CASES)
def test_family4_decode_matches_sum(a: float, b: float) -> None:
"""decode_quantity(R, _) returns (a + b, _) within 1e-9."""
X = embed_quantity(a, "u")
T = translator(b)
R = versor_apply(T, X)
value, unit = decode_quantity(R, "u")
expected = a + b
err = abs(value - expected)
assert unit == "u", f"unit metadata lost: got {unit!r}"
assert err < TOL_DECODE, (
f"decode error for ({a} + {b}): got {value}, expected {expected}, err = {err:.3e}"
)
# ----- Assertion family 5: replay determinism -----
@pytest.mark.parametrize("a,b", ADD_CASES)
def test_family5_replay_byte_identical(a: float, b: float) -> None:
"""Two independent runs produce byte-identical multivector arrays."""
X1 = embed_quantity(a, "u")
X2 = embed_quantity(a, "u")
T1 = translator(b)
T2 = translator(b)
R1 = versor_apply(T1, X1)
R2 = versor_apply(T2, X2)
assert X1.tobytes() == X2.tobytes(), (
f"embed_quantity({a}) not deterministic across runs"
)
assert T1.tobytes() == T2.tobytes(), (
f"translator({b}) not deterministic across runs"
)
assert R1.tobytes() == R2.tobytes(), (
f"versor_apply result not deterministic across runs for ({a}, {b})"
)
# ----- Assertion family 6: composability -----
def test_family6_two_translators_compose() -> None:
"""T_c · T_b · X decodes to a + b + c."""
a, b, c = COMPOSE_CASE
X = embed_quantity(a, "u")
T_b = translator(b)
T_c = translator(c)
# Apply T_b first, then T_c.
R1 = versor_apply(T_b, X)
R2 = versor_apply(T_c, R1)
# Each intermediate result must remain on the null cone.
inner_R1 = abs(float(cga_inner(R1, R1)))
inner_R2 = abs(float(cga_inner(R2, R2)))
assert inner_R1 < TOL_NULL, (
f"intermediate (a + b = {a + b}) not null: |cga_inner| = {inner_R1:.3e}"
)
assert inner_R2 < TOL_NULL, (
f"final (a + b + c = {a + b + c}) not null: |cga_inner| = {inner_R2:.3e}"
)
value, unit = decode_quantity(R2, "u")
expected = a + b + c
err = abs(value - expected)
assert unit == "u"
assert err < TOL_DECODE, (
f"compose decode error: got {value}, expected {expected}, err = {err:.3e}"
)

View file

@ -1,311 +0,0 @@
"""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}"
)

View file

@ -1,320 +0,0 @@
"""ADR-0140 acceptance tests — subtract as inverse translator + additive group closure.
Nine assertion families per the ADR:
Families 1-6 (inherited from ADR-0139, applied to subtract):
1. Embedding well-formedness subtract cases lie on null cone.
2. Translator-of-negative well-formedness versor_condition(subtract(b)) < 1e-6.
3. Closure under sandwich sandwich result stays on null cone.
4. Arithmetic correctness decoded value equals a b within 1e-9.
5. Replay determinism byte-identical across runs.
6. Composability subtract(c) subtract(b) decodes to a b c.
New group-property families:
7. Inverse composition geometric_product(translator(-b), translator(b)) identity.
8. Round-trip closure versor_apply(T_{-b}, versor_apply(T_b, X)) decodes to (a, u).
9. Commutativity / composition into sum:
a) translator(a) * translator(b) translator(a+b) component-wise.
b) translator(a) * translator(b) == translator(b) * translator(a) byte-equal.
If family 7 or 9 fails, ADR-0139's algebraic claim is invalidated retroactively.
The lift program is paused see ADR-0140 §Falsification Discipline.
DO NOT loosen tolerances to make tests pass.
"""
from __future__ import annotations
import pytest
import numpy as np
from algebra.cga import cga_inner
from algebra.cl41 import geometric_product
from algebra.versor import versor_apply, versor_condition
from generate.math_versor_arithmetic import (
decode_quantity,
embed_quantity,
subtract,
translator,
)
# ---------------------------------------------------------------------------
# Fixed test cases per ADR-0140 §Acceptance
# ---------------------------------------------------------------------------
SUBTRACT_CASES: list[tuple[float, float]] = [
(0.0, 0.0),
(5.0, 0.0),
(0.0, 5.0),
(10.0, 3.0),
(3.0, 10.0),
(1.5, 0.5),
(0.25, 0.75),
(-5.0, 3.0),
(5.0, -3.0),
(-2.0, -3.0),
(100.0, 1.0),
]
GROUP_CASES: list[tuple[float, float]] = [
(0.0, 0.0),
(1.0, 0.0),
(0.0, 1.0),
(1.0, 1.0),
(-1.0, 1.0),
(3.0, 4.0),
(0.5, 0.5),
(-2.5, 2.5),
(100.0, 1.0),
(1.0, 100.0),
]
# Composability case for family 6 (subtract twice).
COMPOSE_CASE: tuple[float, float, float] = (20.0, 3.0, 5.0)
# Tolerance constants — exactly as specified in the ADR.
TOL_NULL = 1e-5 # cga_inner(X, X) for null points
TOL_VERSOR = 1e-6 # versor_condition runtime contract
TOL_DECODE = 1e-9 # arithmetic correctness / round-trip / group properties
# ---------------------------------------------------------------------------
# Identity versor (scalar 1): component 0 = 1, all others 0.
# ---------------------------------------------------------------------------
def _identity_versor() -> np.ndarray:
from algebra.cl41 import N_COMPONENTS
v = np.zeros(N_COMPONENTS, dtype=np.float64)
v[0] = 1.0
return v
# ===========================================================================
# Families 1-6: ADR-0139 assertion families applied to subtract
# ===========================================================================
# ----- Family 1: embedding well-formedness -----
@pytest.mark.parametrize("a,b", SUBTRACT_CASES)
def test_family1_embedding_is_null(a: float, b: float) -> None:
"""embed_quantity(a, _) and embed_quantity(b, _) both lie on the null cone."""
X_a = embed_quantity(a, "u")
X_b = embed_quantity(b, "u")
inner_a = abs(float(cga_inner(X_a, X_a)))
inner_b = abs(float(cga_inner(X_b, X_b)))
assert inner_a < TOL_NULL, (
f"embed_quantity({a}) not null: |cga_inner| = {inner_a:.3e}"
)
assert inner_b < TOL_NULL, (
f"embed_quantity({b}) not null: |cga_inner| = {inner_b:.3e}"
)
# ----- Family 2: translator-of-negative well-formedness -----
@pytest.mark.parametrize("a,b", SUBTRACT_CASES)
def test_family2_subtract_unit_versor(a: float, b: float) -> None:
"""subtract(b) satisfies versor_condition < 1e-6."""
S = subtract(b)
cond = versor_condition(S)
assert cond < TOL_VERSOR, (
f"subtract({b}) not unit versor: versor_condition = {cond:.3e}"
)
# ----- Family 3: closure under sandwich -----
@pytest.mark.parametrize("a,b", SUBTRACT_CASES)
def test_family3_sandwich_preserves_null(a: float, b: float) -> None:
"""versor_apply(subtract(b), embed_quantity(a, _)) stays on the null cone."""
X = embed_quantity(a, "u")
S = subtract(b)
R = versor_apply(S, X)
inner_R = abs(float(cga_inner(R, R)))
assert inner_R < TOL_NULL, (
f"sandwich result ({a} {b}) not null: |cga_inner(R, R)| = {inner_R:.3e}"
)
# ----- Family 4: arithmetic correctness -----
@pytest.mark.parametrize("a,b", SUBTRACT_CASES)
def test_family4_decode_matches_difference(a: float, b: float) -> None:
"""decode_quantity(R, _) returns (a b, _) within 1e-9."""
X = embed_quantity(a, "u")
S = subtract(b)
R = versor_apply(S, X)
value, unit = decode_quantity(R, "u")
expected = a - b
err = abs(value - expected)
assert unit == "u", f"unit metadata lost: got {unit!r}"
assert err < TOL_DECODE, (
f"decode error for ({a} {b}): got {value}, expected {expected}, err = {err:.3e}"
)
# ----- Family 5: replay determinism -----
@pytest.mark.parametrize("a,b", SUBTRACT_CASES)
def test_family5_replay_byte_identical(a: float, b: float) -> None:
"""Two independent runs produce byte-identical multivector arrays."""
X1 = embed_quantity(a, "u")
X2 = embed_quantity(a, "u")
S1 = subtract(b)
S2 = subtract(b)
R1 = versor_apply(S1, X1)
R2 = versor_apply(S2, X2)
assert X1.tobytes() == X2.tobytes(), (
f"embed_quantity({a}) not deterministic across runs"
)
assert S1.tobytes() == S2.tobytes(), (
f"subtract({b}) not deterministic across runs"
)
assert R1.tobytes() == R2.tobytes(), (
f"versor_apply result not deterministic across runs for ({a}, {b})"
)
# ----- Family 6: composability -----
def test_family6_two_subtracts_compose() -> None:
"""subtract(c) ∘ subtract(b) applied to embed_quantity(a) decodes to a b c."""
a, b, c = COMPOSE_CASE
X = embed_quantity(a, "u")
S_b = subtract(b)
S_c = subtract(c)
R1 = versor_apply(S_b, X)
R2 = versor_apply(S_c, R1)
inner_R1 = abs(float(cga_inner(R1, R1)))
inner_R2 = abs(float(cga_inner(R2, R2)))
assert inner_R1 < TOL_NULL, (
f"intermediate (a b = {a - b}) not null: |cga_inner| = {inner_R1:.3e}"
)
assert inner_R2 < TOL_NULL, (
f"final (a b c = {a - b - c}) not null: |cga_inner| = {inner_R2:.3e}"
)
value, unit = decode_quantity(R2, "u")
expected = a - b - c
err = abs(value - expected)
assert unit == "u"
assert err < TOL_DECODE, (
f"compose decode error: got {value}, expected {expected}, err = {err:.3e}"
)
# ===========================================================================
# Families 7-9: Additive group structure verification
# ===========================================================================
# ----- Family 7: inverse composition -----
#
# geometric_product(translator(-b), translator(b)) must equal the identity
# versor (component 0 = 1, all others 0) within 1e-9 component-wise.
#
# If this fails, the algebra is not decoding exact addition — it is decoding
# something that resembles addition on point-pairs but does not form a group.
# That invalidates ADR-0139 retroactively. STOP; do not loosen 1e-9.
@pytest.mark.parametrize("a,b", GROUP_CASES)
def test_family7_inverse_composition_is_identity(a: float, b: float) -> None:
"""geometric_product(translator(-b), translator(b)) ≈ identity within 1e-9."""
T_pos = translator(b)
T_neg = translator(-b)
product = geometric_product(T_neg, T_pos)
identity = _identity_versor()
residual = np.abs(product - identity)
max_residual = float(residual.max())
assert max_residual < TOL_DECODE, (
f"Inverse composition residual for b={b}: max |product - identity| = {max_residual:.6e}\n"
f"Component residuals (non-zero): "
+ str([(i, float(residual[i])) for i in range(len(residual)) if residual[i] > 1e-15])
)
# ----- Family 8: round-trip closure -----
#
# versor_apply(T_{-b}, versor_apply(T_b, embed_quantity(a))) must decode
# back to (a, "u") within 1e-9. Includes the b=0 edge case.
@pytest.mark.parametrize("a,b", GROUP_CASES)
def test_family8_round_trip_closure(a: float, b: float) -> None:
"""versor_apply(T_{{-b}}, versor_apply(T_b, X)) decodes to (a, u) within 1e-9."""
X = embed_quantity(a, "u")
T_pos = translator(b)
T_neg = translator(-b)
shifted = versor_apply(T_pos, X)
recovered = versor_apply(T_neg, shifted)
# Intermediate must stay on null cone.
inner_shifted = abs(float(cga_inner(shifted, shifted)))
assert inner_shifted < TOL_NULL, (
f"Round-trip intermediate not null for (a={a}, b={b}): "
f"|cga_inner| = {inner_shifted:.3e}"
)
# Final must stay on null cone.
inner_recovered = abs(float(cga_inner(recovered, recovered)))
assert inner_recovered < TOL_NULL, (
f"Round-trip result not null for (a={a}, b={b}): "
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}, b={b}): "
f"got {value}, expected {a}, err = {err:.3e}"
)
# ----- Family 9a: composition into sum -----
#
# geometric_product(translator(a), translator(b)) must equal translator(a+b)
# component-wise within 1e-9.
@pytest.mark.parametrize("a,b", GROUP_CASES)
def test_family9a_composition_equals_sum_translator(a: float, b: float) -> None:
"""geometric_product(translator(a), translator(b)) == translator(a+b) within 1e-9."""
T_a = translator(a)
T_b = translator(b)
T_sum = translator(a + b)
product = geometric_product(T_a, T_b)
residual = np.abs(product - T_sum)
max_residual = float(residual.max())
assert max_residual < TOL_DECODE, (
f"Sum-composition residual for (a={a}, b={b}): "
f"max |T_a*T_b - T_{{a+b}}| = {max_residual:.6e}\n"
f"Component residuals (non-zero): "
+ str([(i, float(residual[i])) for i in range(len(residual)) if residual[i] > 1e-15])
)
# ----- Family 9b: commutativity -----
#
# geometric_product(translator(a), translator(b)) must equal
# geometric_product(translator(b), translator(a)) byte-exactly.
# If this fails, the algebra decodes a non-abelian operation.
@pytest.mark.parametrize("a,b", GROUP_CASES)
def test_family9b_commutativity_byte_equal(a: float, b: float) -> None:
"""geometric_product(translator(a), translator(b)) byte-equals geometric_product(translator(b), translator(a))."""
T_a = translator(a)
T_b = translator(b)
ab = geometric_product(T_a, T_b)
ba = geometric_product(T_b, T_a)
assert ab.tobytes() == ba.tobytes(), (
f"Commutativity violation for (a={a}, b={b}): "
f"T_a*T_b != T_b*T_a\n"
f"Max component diff: {float(np.abs(ab - ba).max()):.6e}"
)