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:
parent
fd6a544341
commit
89defef30b
10 changed files with 290 additions and 1024 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
126
docs/handoff/CLEANUP-C2-run-lane-migration.md
Normal file
126
docs/handoff/CLEANUP-C2-run-lane-migration.md
Normal 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 1–5.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
127
docs/handoff/CLEANUP-C4-compositions-compile.md
Normal file
127
docs/handoff/CLEANUP-C4-compositions-compile.md
Normal 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 157–159)
|
||||
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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) = e4∧e5) with value -1.0.
|
||||
N² = +1 (pure scalar, verified empirically and analytically).
|
||||
|
||||
Because N² = +1 the exponential exp(α/2 · N) = cosh(α/2) + sinh(α/2)·N
|
||||
is exact in float64 — no series truncation error.
|
||||
|
||||
The sandwich D_s · X · ~D_s applied to a null CGA point P(a) yields
|
||||
a null point projectively equal to P(a·s) with n_inf normalization
|
||||
factor 1/s. decode_quantity normalizes by n_inf to recover a·s.
|
||||
|
||||
Args:
|
||||
scale: Positive real multiplier. Must satisfy scale > 0.
|
||||
|
||||
Returns:
|
||||
32-component float64 unit versor satisfying
|
||||
``versor_condition(D) < 1e-6``.
|
||||
|
||||
Raises:
|
||||
ValueError: If scale <= 0.
|
||||
"""
|
||||
scale = float(scale)
|
||||
if scale <= 0.0:
|
||||
raise ValueError(
|
||||
f"multiply: scale must be strictly positive, got {scale!r}. "
|
||||
f"Negative scales and zero are deferred to follow-on ADRs."
|
||||
)
|
||||
alpha = np.log(scale)
|
||||
half = alpha / 2.0
|
||||
D = np.zeros(N_COMPONENTS, dtype=np.float64)
|
||||
D[0] = np.cosh(half)
|
||||
# N = n_o ∧ n_inf has component -1 at index 15 (blade (3,4), measured).
|
||||
# D_s = cosh(α/2)·1 + sinh(α/2)·N → D[15] = sinh · (-1) = -sinh.
|
||||
D[15] = -np.sinh(half)
|
||||
return D
|
||||
|
||||
|
||||
def decode_quantity(F: np.ndarray, unit: str) -> tuple[float, str]:
|
||||
"""Decode a multivector back to a (value, unit) scalar quantity.
|
||||
|
||||
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
|
||||
|
|
@ -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}"
|
||||
)
|
||||
|
|
@ -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) = e4∧e5), value = -1.0.
|
||||
N² = +1.0 (pure scalar, grade-0 only, all other components zero).
|
||||
n_o · n_inf = -1.0; n_o² = 0.0; n_inf² = 0.0.
|
||||
|
||||
Because N² = +1, the exponential exp(α/2·N) = cosh(α/2) + sinh(α/2)·N is exact
|
||||
in float64. The dilator is: D[0] = cosh(α/2), D[15] = -sinh(α/2), all others 0.
|
||||
|
||||
D_s · ~D_s = cosh²(α/2) - sinh²(α/2)·N² = cosh²(α/2) - sinh²(α/2) = 1 exactly.
|
||||
So versor_condition(D_s) is at machine epsilon, not merely < 1e-6.
|
||||
|
||||
FALSIFICATION DISCIPLINE (read before changing any tolerance):
|
||||
DO NOT loosen any threshold below. The thresholds are the ADR contract.
|
||||
If any family fails, report the measured residual and stop; do not adjust.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import pytest
|
||||
import numpy as np
|
||||
|
||||
from algebra.cga import cga_inner
|
||||
from algebra.cl41 import geometric_product, N_COMPONENTS
|
||||
from algebra.versor import versor_apply, versor_condition
|
||||
from generate.math_versor_arithmetic import (
|
||||
decode_quantity,
|
||||
embed_quantity,
|
||||
multiply,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixed test cases per ADR-0141 §Acceptance §Fixed test cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Scale set for families 1–5, 7, 8. Only (a, s) pairs with s > 0.
|
||||
# The ADR lists (5, -2) as "excluded" (negative s); it is tested in family 10.
|
||||
SCALE_CASES: list[tuple[float, float]] = [
|
||||
(0.0, 2.0),
|
||||
(1.0, 2.0),
|
||||
(1.0, 3.0),
|
||||
(3.0, 4.0),
|
||||
(5.0, 0.5),
|
||||
(10.0, 0.25),
|
||||
(4.0, 0.75),
|
||||
(7.0, 1.0), # identity scale
|
||||
(2.0, math.sqrt(2)), # √2
|
||||
(1.0, math.pi), # π
|
||||
(100.0, 0.01),
|
||||
(0.01, 100.0),
|
||||
(-5.0, 2.0), # negative a, positive s
|
||||
]
|
||||
|
||||
# Composition set for families 6, 9.
|
||||
COMPOSE_CASES: list[tuple[float, float]] = [
|
||||
(1.0, 1.0),
|
||||
(2.0, 1.0),
|
||||
(1.0, 2.0),
|
||||
(2.0, 3.0),
|
||||
(3.0, 2.0),
|
||||
(0.5, 4.0),
|
||||
(math.sqrt(2), math.sqrt(2)), # √2 × √2 → 2.0
|
||||
(math.pi, 1.0),
|
||||
(10.0, 0.1), # 10 × 0.1 → 1.0 (float64 drift probe)
|
||||
]
|
||||
|
||||
# Boundary set for family 10. All of these must raise ValueError.
|
||||
INVALID_SCALES: list[float] = [0.0, -1.0, -3.5, -100.0, -0.0001]
|
||||
|
||||
# Tolerance constants — exactly as specified in ADR-0141.
|
||||
TOL_VERSOR = 1e-6 # versor_condition runtime contract
|
||||
TOL_NULL = 1e-5 # cga_inner(X, X) for null points
|
||||
TOL_IDENTITY = 1e-9 # component-wise identity comparison
|
||||
TOL_DECODE = 1e-9 # arithmetic correctness
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _identity_versor() -> np.ndarray:
|
||||
v = np.zeros(N_COMPONENTS, dtype=np.float64)
|
||||
v[0] = 1.0
|
||||
return v
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Family 1 — Dilator well-formedness
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.parametrize("a,s", SCALE_CASES)
|
||||
def test_family1_dilator_unit_versor(a: float, s: float) -> None:
|
||||
"""versor_condition(multiply(s)) < 1e-6 for every scale in the test set."""
|
||||
D = multiply(s)
|
||||
cond = versor_condition(D)
|
||||
assert cond < TOL_VERSOR, (
|
||||
f"multiply({s}) not unit versor: versor_condition = {cond:.6e} (threshold 1e-6)"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Family 2 — Closure under sandwich
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.parametrize("a,s", SCALE_CASES)
|
||||
def test_family2_sandwich_preserves_null(a: float, s: float) -> None:
|
||||
"""versor_apply(multiply(s), embed_quantity(a)) stays on the null cone."""
|
||||
D = multiply(s)
|
||||
X = embed_quantity(a, "u")
|
||||
R = versor_apply(D, X)
|
||||
inner_R = abs(float(cga_inner(R, R)))
|
||||
assert inner_R < TOL_NULL, (
|
||||
f"sandwich result ({a} × {s}) not null: |cga_inner(R, R)| = {inner_R:.3e}"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Family 3 — Arithmetic correctness
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.parametrize("a,s", SCALE_CASES)
|
||||
def test_family3_decode_matches_product(a: float, s: float) -> None:
|
||||
"""decode_quantity(R, 'u') returns (a * s, 'u') within 1e-9."""
|
||||
D = multiply(s)
|
||||
X = embed_quantity(a, "u")
|
||||
R = versor_apply(D, X)
|
||||
value, unit = decode_quantity(R, "u")
|
||||
expected = a * s
|
||||
err = abs(value - expected)
|
||||
assert unit == "u", f"unit metadata lost: got {unit!r}"
|
||||
assert err < TOL_DECODE, (
|
||||
f"decode error for ({a} × {s}): got {value!r}, expected {expected!r}, "
|
||||
f"err = {err:.6e} (threshold 1e-9)"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Family 4 — Replay determinism
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.parametrize("a,s", SCALE_CASES)
|
||||
def test_family4_replay_byte_identical(a: float, s: float) -> None:
|
||||
"""Two independent runs produce byte-identical multivector arrays."""
|
||||
X1 = embed_quantity(a, "u")
|
||||
X2 = embed_quantity(a, "u")
|
||||
D1 = multiply(s)
|
||||
D2 = multiply(s)
|
||||
R1 = versor_apply(D1, X1)
|
||||
R2 = versor_apply(D2, X2)
|
||||
assert X1.tobytes() == X2.tobytes(), (
|
||||
f"embed_quantity({a}) not deterministic across runs"
|
||||
)
|
||||
assert D1.tobytes() == D2.tobytes(), (
|
||||
f"multiply({s}) not deterministic across runs"
|
||||
)
|
||||
assert R1.tobytes() == R2.tobytes(), (
|
||||
f"versor_apply result not deterministic across runs for (a={a}, s={s})"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Family 5 — Identity dilator
|
||||
# ===========================================================================
|
||||
|
||||
def test_family5_identity_dilator() -> None:
|
||||
"""multiply(1.0) equals the scalar identity versor within 1e-9 component-wise."""
|
||||
D = multiply(1.0)
|
||||
identity = _identity_versor()
|
||||
err_vec = np.abs(D - identity)
|
||||
max_err = float(err_vec.max())
|
||||
assert max_err < TOL_IDENTITY, (
|
||||
f"multiply(1.0) deviates from scalar identity: "
|
||||
f"max component error = {max_err:.6e} (threshold 1e-9)\n"
|
||||
f"Non-zero diff components: "
|
||||
+ str([(i, float(err_vec[i])) for i in range(len(err_vec)) if err_vec[i] > 1e-15])
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Family 6 — Composition into product
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.parametrize("s1,s2", COMPOSE_CASES)
|
||||
def test_family6_composition_into_product(s1: float, s2: float) -> None:
|
||||
"""geometric_product(multiply(s1), multiply(s2)) == multiply(s1*s2) within 1e-9."""
|
||||
D1 = multiply(s1)
|
||||
D2 = multiply(s2)
|
||||
D12 = geometric_product(D1, D2)
|
||||
D_prod = multiply(s1 * s2)
|
||||
|
||||
residual = np.abs(D12 - D_prod)
|
||||
max_err = float(residual.max())
|
||||
assert max_err < TOL_IDENTITY, (
|
||||
f"Composition residual for ({s1}, {s2}) → s1*s2={s1*s2}: "
|
||||
f"max |D12 - D(s1*s2)| = {max_err:.6e} (threshold 1e-9)\n"
|
||||
f"Non-zero diff components: "
|
||||
+ str([(i, float(residual[i])) for i in range(len(residual)) if residual[i] > 1e-15])
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Family 7 — Inverse composition
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.parametrize("a,s", SCALE_CASES)
|
||||
def test_family7_inverse_composition_is_identity(a: float, s: float) -> None:
|
||||
"""geometric_product(multiply(1/s), multiply(s)) ≈ identity within 1e-9."""
|
||||
D_s = multiply(s)
|
||||
D_inv = multiply(1.0 / s)
|
||||
product = geometric_product(D_inv, D_s)
|
||||
identity = _identity_versor()
|
||||
|
||||
residual = np.abs(product - identity)
|
||||
max_err = float(residual.max())
|
||||
assert max_err < TOL_IDENTITY, (
|
||||
f"Inverse composition residual for s={s}: "
|
||||
f"max |D(1/s)*D(s) - I| = {max_err:.6e} (threshold 1e-9)\n"
|
||||
f"Non-zero diff components: "
|
||||
+ str([(i, float(residual[i])) for i in range(len(residual)) if residual[i] > 1e-15])
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Family 8 — Round-trip closure
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.parametrize("a,s", SCALE_CASES)
|
||||
def test_family8_round_trip_closure(a: float, s: float) -> None:
|
||||
"""versor_apply(multiply(1/s), versor_apply(multiply(s), X)) decodes to (a, u) within 1e-9."""
|
||||
D_s = multiply(s)
|
||||
D_inv = multiply(1.0 / s)
|
||||
X = embed_quantity(a, "u")
|
||||
|
||||
scaled = versor_apply(D_s, X)
|
||||
recovered = versor_apply(D_inv, scaled)
|
||||
|
||||
# Intermediate must stay on null cone.
|
||||
inner_scaled = abs(float(cga_inner(scaled, scaled)))
|
||||
assert inner_scaled < TOL_NULL, (
|
||||
f"Round-trip intermediate not null for (a={a}, s={s}): "
|
||||
f"|cga_inner| = {inner_scaled:.3e}"
|
||||
)
|
||||
|
||||
# Final must stay on null cone.
|
||||
inner_recovered = abs(float(cga_inner(recovered, recovered)))
|
||||
assert inner_recovered < TOL_NULL, (
|
||||
f"Round-trip final not null for (a={a}, s={s}): "
|
||||
f"|cga_inner| = {inner_recovered:.3e}"
|
||||
)
|
||||
|
||||
value, unit = decode_quantity(recovered, "u")
|
||||
err = abs(value - a)
|
||||
assert unit == "u"
|
||||
assert err < TOL_DECODE, (
|
||||
f"Round-trip decode error for (a={a}, s={s}): "
|
||||
f"got {value!r}, expected {a!r}, err = {err:.6e} (threshold 1e-9)"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Family 9 — Commutativity
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.parametrize("s1,s2", COMPOSE_CASES)
|
||||
def test_family9_commutativity_byte_equal(s1: float, s2: float) -> None:
|
||||
"""geometric_product(multiply(s1), multiply(s2)) byte-equals multiply(s2)*multiply(s1)."""
|
||||
D1 = multiply(s1)
|
||||
D2 = multiply(s2)
|
||||
ab = geometric_product(D1, D2)
|
||||
ba = geometric_product(D2, D1)
|
||||
assert ab.tobytes() == ba.tobytes(), (
|
||||
f"Commutativity violation for (s1={s1}, s2={s2}): "
|
||||
f"D1*D2 != D2*D1\n"
|
||||
f"Max component diff: {float(np.abs(ab - ba).max()):.6e}"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Family 10 — Boundary refusal at construction time
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.parametrize("bad_s", INVALID_SCALES)
|
||||
def test_family10_invalid_scale_raises_at_construction(bad_s: float) -> None:
|
||||
"""multiply(s) raises ValueError at construction for s in {0, -1, -3.5, -100, -0.0001}."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
multiply(bad_s)
|
||||
msg = str(exc_info.value)
|
||||
# Error must name the scale value.
|
||||
assert str(bad_s) in msg or repr(bad_s) in msg, (
|
||||
f"ValueError for scale={bad_s!r} does not name the scale in message: {msg!r}"
|
||||
)
|
||||
# Error must name the restriction.
|
||||
assert any(kw in msg.lower() for kw in ("positive", "strictly", "deferred", "> 0")), (
|
||||
f"ValueError for scale={bad_s!r} does not name the restriction in message: {msg!r}"
|
||||
)
|
||||
|
|
@ -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}"
|
||||
)
|
||||
Loading…
Reference in a new issue