feat(ADR-0139): arithmetic-as-versor spike — add closes exactly in Cl(4,1) (#212)
First step of the Engine A lift program (CLAUDE.md commits the project to a
single deterministic cognitive engine; Engine B / math pipeline was always
intentional scaffolding per math_solver.py:24). Proves the load-bearing
unknown: one arithmetic operation can be represented as a closed versor at
the required tolerance, with no new normalization and no weakened invariant.
Scope (frozen by ADR-0139):
- One operation: add
- Single-axis embedding: quantities on e1 axis
- No graph wiring, no pipeline integration, no GSM8K case routed
- Unit carried as caller metadata
Construction:
- embed_quantity(v, u) = embed_point([v, 0, 0]) (existing CGA primitive)
- translator(b) = 1 - 0.5 * (b*e1 * n_inf) (textbook CGA translator)
- decode_quantity(F, u) = (F[1], u) (e1 coordinate)
Measured values (all 11 fixed cases + composability):
a b vcond(T) |<R,R>| decode_err
0.0 0.0 0.000e+00 0.000e+00 0.000e+00
0.0 1.0 0.000e+00 0.000e+00 0.000e+00
1.0 0.0 0.000e+00 0.000e+00 0.000e+00
3.0 4.0 0.000e+00 0.000e+00 0.000e+00
7.0 -3.0 0.000e+00 0.000e+00 0.000e+00
0.25 0.75 0.000e+00 0.000e+00 0.000e+00
1.5 2.5 0.000e+00 0.000e+00 0.000e+00
-5.0 5.0 0.000e+00 0.000e+00 0.000e+00
-2.0 -3.0 0.000e+00 0.000e+00 0.000e+00
100.0 1.0 0.000e+00 0.000e+00 0.000e+00
1.0 100.0 0.000e+00 0.000e+00 0.000e+00
compose (2, 3, 5) → 10: |<R2,R2>| = 0.000e+00, decode_err = 0.000e+00
Every residual is exactly 0.0 in float64. The construction is algebraically
closed: T_t * reverse(T_t) = 1 - 0.25*B^2 where B = t*n_inf, and B^2 = 0
because (e14)^2 + (e15)^2 = -1 + 1 and cross-terms cancel. No machine-epsilon
drift accumulates because the relevant cancellation happens at the algebraic
level before float arithmetic.
ADR-0139 acceptance items 1-6 (one parametrized test family each):
1. Embedding well-formedness — test_family1_embedding_is_null (11 cases)
2. Translator well-formedness — test_family2_translator_unit_versor (11 cases)
3. Closure — test_family3_sandwich_preserves_null (11 cases)
4. Arithmetic correctness — test_family4_decode_matches_sum (11 cases)
5. Replay determinism — test_family5_replay_byte_identical (11 cases)
6. Composability — test_family6_two_translators_compose (1 case)
Total: 56 tests, all passing.
Lift program decision: proceeds. Follow-on ADRs (subtract, multiply, Rate,
compare, MathProblemGraph → PropositionGraph, pipeline integration, first
GSM8K case end-to-end through Engine A) are now justified by a concrete
algebraic foundation rather than design speculation.
Out of scope per ADR-0139:
- No modifications to algebra/, core/cognition/, chat/, math_solver.py,
math_verifier.py, math_realizer.py, math_candidate_parser.py
- No GSM8K runner changes
- No pack changes
- Engine B continues serving GSM8K unchanged; the 3/50 admission set is
preserved
CLI lanes intentionally not run — main has known test-rot orthogonal to
this PR. The 56 new tests are self-contained and the diff touches only
three new files.
This commit is contained in:
parent
7d0803b457
commit
589297b79a
3 changed files with 678 additions and 0 deletions
355
docs/decisions/ADR-0139-arithmetic-as-versor-spike.md
Normal file
355
docs/decisions/ADR-0139-arithmetic-as-versor-spike.md
Normal file
|
|
@ -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.
|
||||
152
generate/math_versor_arithmetic.py
Normal file
152
generate/math_versor_arithmetic.py
Normal file
|
|
@ -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
|
||||
171
tests/test_arithmetic_as_versor_add.py
Normal file
171
tests/test_arithmetic_as_versor_add.py
Normal file
|
|
@ -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}"
|
||||
)
|
||||
Loading…
Reference in a new issue