diff --git a/docs/decisions/ADR-0139-arithmetic-as-versor-spike.md b/docs/decisions/ADR-0139-arithmetic-as-versor-spike.md new file mode 100644 index 00000000..d959fcc2 --- /dev/null +++ b/docs/decisions/ADR-0139-arithmetic-as-versor-spike.md @@ -0,0 +1,355 @@ +# ADR-0139 — Arithmetic-as-Versor Spike: `add` Only + +**Status:** Draft +**Date:** 2026-05-24 +**Author:** CORE agents +**Parent / supersedes context:** [ADR-0114a](./ADR-0114a-math-capability-substrate.md), +[ADR-0115](./ADR-0115-math-problem-parser-and-graph.md), +[ADR-0116](./ADR-0116-deterministic-solver.md) +**Engine target:** CGA cognitive engine (`algebra/versor.py`, `algebra/cga.py`, +`core/cognition/pipeline.py`) + +--- + +## Context + +CLAUDE.md commits the project to a single deterministic cognitive engine: + +```text +listen → comprehend → recall → think → articulate → learn → replay +``` + +built on CGA Cl(4,1) versor algebra, exact recall, PropositionGraph, +ArticulationTarget, deterministic realizer, and trace-hash invariance. + +Between ADR-0114a and the present, a second engine grew alongside the first: + +| | Engine A (CGA cognitive engine) | Engine B (math pipeline) | +|---|---|---| +| Substrate | versor multivectors in Cl(4,1) | frozen Python dataclasses | +| Graph | `PropositionGraph` (from `graph_planner.py`) | `MathProblemGraph` (from `math_problem_graph.py`) | +| State propagation | `versor_apply(V, F)` — sandwich product | pure-Python arithmetic in `math_solver.py` | +| Closure invariant | `versor_condition(F) < 1e-6` | `assert`s on dataclass fields | +| Trace contract | `core/cognition/trace.py` | `SolutionTrace.canonical_bytes()` | +| Used by | `chat/runtime.py`, cognition eval | `evals/gsm8k_math/runner.py` | + +Engine B was always intentional scaffolding — `math_solver.py:24` states +*"the 'expert' tier (ADR-0120) is not in scope here; ADR-0116 is the Phase 2 +substrate the eventual capability claim will rest on."* + +The GSM8K corridor (ADR-0123 / 0131 / 0136 / 0138) has been extending the +parser side of Engine B without ever testing the lift to Engine A. Every PR +through that corridor reports `cognition eval byte-identical` — the symptom +that Engine A is not being invoked by math work, even though math is the +nominal capability claim the engine should eventually demonstrate. + +This ADR begins the lift. It does not finish it. It does not even cover one +GSM8K case end-to-end through Engine A. It does one thing: prove that one +arithmetic operation can be represented as a closed versor in Cl(4,1) +without weakening any existing invariant. + +--- + +## Decision + +Run a one-operation algebraic spike: **`add` only**, **algebra only**, **no +graph or pipeline wiring**. + +### Embedding choice + +A `Quantity(value: int|float, unit: str)` is embedded as a single conformal +point on the e1 axis: + +```text +embed_quantity(value, unit) = embed_point([value, 0, 0]) +``` + +Existing primitive: `algebra/cga.py:embed_point`. + +This choice: + +- Places quantities on the CGA null cone (`cga_inner(X, X) ≈ 0`). +- Uses only the existing CGA point-embedding primitive — no new algebra + invented in this ADR. +- Treats the `unit` field as carried metadata, not as a multivector + coordinate. Unit handling is propositional, not algebraic. +- Lets the standard CGA translator versor represent additive operations. + +Open question (deferred): whether multi-unit problems require multiple axes +(e1 for unit A, e2 for unit B) or whether each unit gets its own embedding +context. ADR-0139 covers single-unit `add` only and does not commit either +way. + +### Operation choice + +`add(addend: int|float) → versor` is constructed as the standard CGA translator +along e1 by `addend`: + +```text +T_a = 1 - 0.5 * a * e1 * n_inf +``` + +(exact sign/normalization to be derived against the existing `cga.py` / +`cl41.py` conventions during implementation; the construction must produce a +unit versor satisfying `versor_condition(T_a) < 1e-6` at runtime.) + +This is well-known CGA — translators are the canonical versor representation +of Euclidean translations. Adding `b` to a quantity is geometrically +translating its point on e1 by `b`. + +### Application + +```text +result = versor_apply(T_addend, embed_quantity(value, unit)) +``` + +`versor_apply` already has the correct dual-path behavior for this +embedding: null inputs (CGA points) get the raw sandwich path +(`algebra/versor.py:160-162`) so the null property is preserved through +the operation. No change to `versor_apply` is required. + +### Decoding + +A `decode_quantity(F, unit) → (value, unit)` extracts the e1 coordinate of +the result point. This is the inverse of `embed_point` restricted to the +e1 axis. + +--- + +## Acceptance + +A single test module — `tests/test_arithmetic_as_versor_add.py` — passes +with these assertions on a small fixed set of `(a, b)` pairs covering +integer, fractional, negative, and zero cases: + +1. **Embedding well-formedness.** For each input `value`: + - `cga_inner(embed_quantity(value, "u"), embed_quantity(value, "u")) ≈ 0` + (null cone preserved). + +2. **Translator well-formedness.** For each addend `b`: + - `versor_condition(translator(b)) < 1e-6`. + +3. **Closure.** For each `(a, b)`: + - Let `R = versor_apply(translator(b), embed_quantity(a, "u"))`. + - `cga_inner(R, R) ≈ 0` (result remains on null cone). + +4. **Arithmetic correctness.** For each `(a, b)`: + - `decode_quantity(R, "u") == (a + b, "u")` byte-equal at the tolerance + chosen by the embedding (decimal value match within `1e-9` for the + fixed-point test cases listed below). + +5. **Replay determinism.** Running the test twice produces byte-identical + multivector arrays (no nondeterministic float ordering, no platform + drift). + +6. **Composability (in-ADR scope).** `versor_apply(translator(c), + versor_apply(translator(b), embed_quantity(a, "u")))` decodes to + `(a + b + c, "u")` — proves two consecutive translations compose + correctly. This is the smallest two-step program the engine path + could run. + +### Fixed test cases + +```text +(0, 0), (0, 1), (1, 0), +(3, 4), (7, -3), +(0.25, 0.75), (1.5, 2.5), +(-5, 5), (-2, -3), +(100, 1), (1, 100), +``` + +Plus the composability case `(2, 3, 5) → 10`. + +--- + +## Non-goals + +Explicit out-of-scope for this ADR: + +- No `subtract`, `multiply`, `divide`, `transfer`, `apply_rate`, + `compare_additive`, `compare_multiplicative` operations. Each gets its + own follow-on ADR once `add` is proven. +- No `MathProblemGraph` consumer. The new functions take typed inputs + directly. They do not import from `math_problem_graph.py`. +- No `PropositionGraph` construction. Engine A's graph layer is not + touched. +- No `CognitiveTurnPipeline` integration. The pipeline file is not + imported. +- No `chat/runtime.py` invocation path. The chat surface is not touched. +- No GSM8K case routed through this code. The runner is not modified. +- No deprecation of Engine B. `math_solver.py`, `math_verifier.py`, + `math_realizer.py`, and the S.x corridor parsers remain in place, + unmodified, scoring GSM8K as they do today. The 3/50 admission set is + preserved. +- No pack changes. `en_arithmetic_v1` is not touched. Pack-binding for the + versor path is a separate concern. + +The ADR succeeds if `add` works algebraically. It does not claim that +the math pipeline has been lifted. It only proves the lift is feasible +for one operation. + +--- + +## Rationale + +Two design choices are load-bearing and should be defended explicitly: + +**Why a spike instead of a phased plan?** + +The arithmetic-as-versor algebra is the single load-bearing unknown for +the entire lift program. Every follow-on ADR — subtract, multiply, +compare, graph integration, pipeline integration, GSM8K routing — +assumes that arithmetic can be represented as closed versors at the +required tolerance. If `add` doesn't work cleanly, every downstream ADR +is built on sand. The spike forces that assumption to be tested in code, +not in design documents. + +**Why `add` instead of `multiply` or `compare`?** + +Translators are the most canonical CGA versor. The construction +`T_t = 1 - 0.5 * t * n_inf` is textbook. If anything in the CGA +substrate is going to behave well, translators will. + +Multiplication is dilation in CGA — also a known versor, but it requires +the `n_o ∧ n_inf` blade and exponentiation. Riskier first step. + +Comparisons (`compare_additive`, `compare_multiplicative`) are relational +predicates, not transformations. They may not be versor-shaped at all — +they might land at the proposition layer instead. Trying to make them +versor-shaped first would entangle two unknowns. + +So `add` is the smallest, cleanest, most-textbook starting point. + +**Why no graph or pipeline wiring?** + +Engine A's graph and pipeline layers already exist and work. The risk +isn't whether `versor_apply` integrates with `graph_from_intent` — that's +plumbing. The risk is whether arithmetic can be represented as versors +at all. Wiring before the algebra is proven would create the appearance +of progress without removing the load-bearing unknown. + +--- + +## Open questions for follow-on ADRs + +The following must be answered, but not by this ADR: + +1. **Multi-axis embedding.** Does a two-unit problem (`5 apples + 3 + oranges` style — even though that's not valid arithmetic, mixed-unit + intermediate states do appear in word problems) need orthogonal axes + (e1 for apples, e2 for oranges)? Or does each unit context get its + own embedding session? + +2. **Multiplication as dilation.** The dilator + `D_s = cosh(α/2) + sinh(α/2)·(n_o ∧ n_inf)` where `s = exp(α)` + represents scaling. Does it close at `versor_condition < 1e-6` for + the value ranges GSM8K actually requires? At what precision? + +3. **Comparison as proposition vs versor.** Is `compare_additive("more + by 5", x)` a versor operation, a proposition node, or both? Strongest + guess: proposition. But this needs an ADR. + +4. **`Rate` as bivector.** A `Rate(2.0, "dollars", "apple")` is + inherently two-axis. It is probably a grade-2 object connecting two + Euclidean axes. Does the existing CGA substrate support this cleanly? + +5. **PropositionGraph construction from MathProblemGraph.** Once `add` + and `subtract` are proven as versors, an ADR is needed that + constructs a `PropositionGraph` from a `MathProblemGraph` so the + engine pipeline can articulate the answer through the existing + realizer. + +6. **Trace-hash story.** Engine A's `compute_trace_hash` and Engine B's + `SolutionTrace.canonical_bytes()` need to converge. Probably the + versor sequence becomes the trace, with the existing hash function + applied. Defer to the integration ADR. + +7. **Refusal floor.** The versor path must preserve `wrong == 0`. When + the algebra cannot represent a needed operation, the engine must + refuse, not approximate. Mechanism TBD by the integration ADR. + +--- + +## Risks + +- **The translator construction may not close at `1e-6`.** The + construction-residue tolerance in `algebra/versor.py:13` is `1e-2` and + the runtime closure tolerance is `1e-6`. If `translator(b)` lands + between those, `_close_applied_versor` will project it through + `_seed_to_rotor`, which may not preserve the exact translation. The + spike must verify this empirically; if it fails, the embedding or the + construction has to be reconsidered before the ADR can ship. + +- **Float32 truncation.** `algebra/cl41.py` uses float32 for + multivectors. Large additions (e.g. `100 + 1`) may not decode back to + exactly `101.0` after the sandwich. The test cases above include + values that probe this. If float32 doesn't carry the required + precision, the embedding may need to use the float64 path that + `algebra/versor.py:18` already defines for runtime fields. + +- **Decoding may not be exact for arbitrary float values.** The e1 + component of an embedded point is the raw value, but the e4/e5 + coefficients carry `0.5 * (value^2 ± 1)`. Round-tripping requires the + e1 coordinate alone — the e4/e5 components are dependent. If the + sandwich introduces error in e1 vs e4/e5 differently, decoding from + e1 alone may not equal the input. This is the most likely failure + mode and the spike's primary falsification target. + +- **The user-facing capability gauge does not move in this ADR.** GSM8K + admissions stay at 3/50. The cognition eval stays byte-identical. The + only signal this ADR produces is a test file that does or does not + pass. That is intentional but easy to misread as "no progress." + +--- + +## Replay & invariants + +The spike is governed by the same invariants as the rest of CORE: + +- `versor_condition(F) < 1e-6` for all unit versors constructed + (translators in this ADR). +- Null inputs to `versor_apply` stay null. Verified by `cga_inner(R, R) + ≈ 0` on every result. +- No normalization is introduced outside the allowed sites + (`ingest/gate.py`, `language_packs/compiler.py`, + `algebra/versor.py`). The new functions live in a new module — + proposed path `generate/math_versor_arithmetic.py` — and call only + existing primitives. They do not add any new normalization. +- Determinism: float64 path used end-to-end where precision matters; + no platform-conditional code; no randomness. + +--- + +## Work sequencing for follow-on + +Only if this ADR's tests pass: + +1. ADR-0140: `subtract` as inverse translator. (Trivial follow-on; should + pass nearly for free.) +2. ADR-0141: `multiply` as dilator. (Risk concentrates here.) +3. ADR-0142: `Rate` as bivector and `apply_rate` as combined + translator-dilator. (Open question 4.) +4. ADR-0143: `compare_*` at the proposition layer, not versor layer. + (Open question 3.) +5. ADR-0144: `PropositionGraph` from `MathProblemGraph`. (Open + question 5.) +6. ADR-0145: One GSM8K case (gsm8k-0014) routed end-to-end through + Engine A. First moment the capability gauge is honestly attached to + the engine. + +If this ADR's tests fail, the lift program is paused and the failure +mode is documented. Engine B continues serving GSM8K. A revised +embedding strategy is required before any follow-on ADR. + +--- + +## Decision summary + +Add one new module (`generate/math_versor_arithmetic.py` — name +provisional) with three functions: `embed_quantity`, `translator`, +`decode_quantity`. Add one test module verifying `add` works as a closed +versor at the required tolerance. Change nothing else. Ship as a single +PR small enough to audit in one sitting. + +Acceptance is binary: every test in the new module passes, or the ADR is +withdrawn and the lift program is paused pending a new embedding choice. diff --git a/generate/math_versor_arithmetic.py b/generate/math_versor_arithmetic.py new file mode 100644 index 00000000..7b62acdb --- /dev/null +++ b/generate/math_versor_arithmetic.py @@ -0,0 +1,152 @@ +"""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 embed_point +from algebra.cl41 import N_COMPONENTS, geometric_product + +__all__ = [ + "embed_quantity", + "translator", + "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}") + 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) + + +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 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). + + 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. + """ + 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 diff --git a/tests/test_arithmetic_as_versor_add.py b/tests/test_arithmetic_as_versor_add.py new file mode 100644 index 00000000..84a6cf5e --- /dev/null +++ b/tests/test_arithmetic_as_versor_add.py @@ -0,0 +1,171 @@ +"""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}" + )