feat(adr-0020): parity gates for cga_inner, geometric_product, versor_condition, versor_apply
ADR-0020 next-level: close the parity-gate hole on the four remaining
ungated Rust surfaces.
Gates landed (subprocess-based, raw f32/f64 byte equality):
cga_inner — 14/14 bit-identical (random + basis blades + self-norm)
geometric_product — 15/15 bit-identical (random + basis blades + scalar identity)
versor_condition — 9/9 bit-identical AFTER kernel fix
versor_apply — 8/8 intentionally skipped (see below)
Kernel fix: versor_condition_raw
The Python source-of-truth (algebra.versor.versor_unit_residual) folds
the geometric product + identity subtraction + Frobenius norm in f64.
The Rust kernel was folding in f32, drifting by 1 ULP on out-of-shell
inputs. Rewrote versor_condition_raw to promote inputs to f64, use the
existing geometric_product_f64/reverse_f64 building blocks, and cast
only the final scalar back to f32. Python is canonical per CLAUDE.md
sequencing rule 5.
Honest disable: versor_apply
The Rust versor_apply_closed diverges structurally:
(1) precision — f32 sandwich vs Python's f64 throughout
(2) closure form — Rust has a null-vector early branch + no
post-unitize condition recheck; Python is the
inverse (no null branch; recheck + seed-rotor
fallback)
Per ADR-0020 "default-off until parity passes", the Rust dispatch for
versor_apply is disabled in algebra/backend.py with a pointer to the
gate. The parity tests are skipped with explicit reason. The follow-up
f64 port is documented in the ADR's new Parity status table.
Lane registration: all four parity files added to --suite algebra.
After: algebra 124 passed, 8 skipped (was 86). All other lanes green:
smoke 54, runtime 19, cognition 57, teaching 17, packs 6. Cognition
eval 100%.
This commit is contained in:
parent
5cf7c41180
commit
70e58ce446
8 changed files with 564 additions and 13 deletions
|
|
@ -59,14 +59,17 @@ def versor_apply(V: np.ndarray, F: np.ndarray) -> np.ndarray:
|
|||
"""Apply a versor through the canonical algebra closure boundary.
|
||||
|
||||
The Python implementation is the default source of truth for runtime
|
||||
closure semantics. The Rust closure path is used only when explicitly
|
||||
requested with CORE_BACKEND=rust/core_rs.
|
||||
closure semantics.
|
||||
|
||||
Rust dispatch is **disabled** for this surface pending an f64 parity
|
||||
port. The current Rust `versor_apply_closed` computes the sandwich
|
||||
in f32 and applies a closure path whose null-vector branch and
|
||||
fallback order differ structurally from Python's
|
||||
`_close_applied_versor`. The ADR-0020 gate
|
||||
`tests/test_versor_apply_rust_parity.py` documents the divergence
|
||||
and skips under the disabled dispatch; un-skip when the f64 port
|
||||
lands. Python is canonical per CLAUDE.md sequencing rule 5.
|
||||
"""
|
||||
if _RUST:
|
||||
try:
|
||||
return np.asarray(_rs.versor_apply_with_closure(V, F), dtype=np.float64)
|
||||
except (AttributeError, Exception):
|
||||
pass
|
||||
from algebra.versor import versor_apply as _va
|
||||
return _va(V, F)
|
||||
|
||||
|
|
|
|||
|
|
@ -136,13 +136,23 @@ pub fn normalize_to_versor_raw(f: &[f32; 32]) -> Result<[f32; 32], VersorError>
|
|||
}
|
||||
|
||||
/// ||F * reverse(F) - 1||_F.
|
||||
/// Returns scalar f32. Used in tests and injection gate only.
|
||||
/// Returns scalar f32 truncation of an f64 fold.
|
||||
///
|
||||
/// The fold (geometric product, identity subtraction, Frobenius norm)
|
||||
/// is performed in f64 to match the Python source-of-truth
|
||||
/// `algebra.versor.versor_unit_residual`, which uses
|
||||
/// `dtype=np.float64` + `np.linalg.norm`. ADR-0020 parity gate
|
||||
/// `tests/test_versor_condition_rust_parity.py` asserts bit-identity
|
||||
/// of the returned f32; an all-f32 fold here drifts by 1 ULP on
|
||||
/// out-of-shell inputs. Python is canonical per CLAUDE.md
|
||||
/// sequencing rule 5.
|
||||
pub fn versor_condition_raw(f: &[f32; 32]) -> Result<f32, VersorError> {
|
||||
let rev_f = reverse_raw(f);
|
||||
let mut frv = geometric_product_raw(f, &rev_f)?;
|
||||
frv[0] -= 1.0; // subtract identity
|
||||
let norm_sq: f32 = frv.iter().map(|x| x * x).sum();
|
||||
Ok(norm_sq.sqrt())
|
||||
let f64_in: [f64; 32] = core::array::from_fn(|i| f[i] as f64);
|
||||
let rev_f = reverse_f64(&f64_in);
|
||||
let mut frv = geometric_product_f64(&f64_in, &rev_f);
|
||||
frv[0] -= 1.0;
|
||||
let norm_sq: f64 = frv.iter().map(|x| x * x).sum();
|
||||
Ok(norm_sq.sqrt() as f32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@ _TEST_SUITES: dict[str, tuple[str, ...]] = {
|
|||
"tests/test_vault_recall.py",
|
||||
"tests/test_vault_recall_vectorised.py",
|
||||
"tests/test_vault_recall_rust_parity.py",
|
||||
"tests/test_cga_inner_rust_parity.py",
|
||||
"tests/test_geometric_product_rust_parity.py",
|
||||
"tests/test_versor_condition_rust_parity.py",
|
||||
"tests/test_versor_apply_rust_parity.py",
|
||||
),
|
||||
"pulse": (
|
||||
"tests/test_pulse_integration.py",
|
||||
|
|
|
|||
|
|
@ -142,6 +142,40 @@ governs sequencing.
|
|||
validates. Any divergence is a test failure, not a feature
|
||||
request.
|
||||
|
||||
## Parity status (live)
|
||||
|
||||
| Surface | Gate file | Status | Dispatch |
|
||||
|----------------------|---------------------------------------------------------|-------------------|-------------------------|
|
||||
| `vault_recall` | `tests/test_vault_recall_rust_parity.py` | passing | enabled |
|
||||
| `cga_inner` | `tests/test_cga_inner_rust_parity.py` | passing | enabled |
|
||||
| `geometric_product` | `tests/test_geometric_product_rust_parity.py` | passing | enabled |
|
||||
| `versor_condition` | `tests/test_versor_condition_rust_parity.py` | passing (after f64 fold fix) | enabled |
|
||||
| `versor_apply` | `tests/test_versor_apply_rust_parity.py` | **skipped** | **disabled pending f64 port** |
|
||||
|
||||
### Outstanding work: `versor_apply` f64 parity port
|
||||
|
||||
The current Rust `versor_apply_closed` diverges from Python on two
|
||||
axes that the bit-identity gate exposes:
|
||||
|
||||
1. **Precision** — Rust folds the sandwich (V·F·rev(V)) in f32; Python
|
||||
in f64.
|
||||
2. **Closure structure** — Rust has a null-vector early branch and no
|
||||
post-unitize `versor_condition < 1e-6` recheck; Python is the
|
||||
inverse (no null branch; recheck + seed-rotor fallback).
|
||||
|
||||
The f64 building blocks already exist in the Rust crate
|
||||
(`geometric_product_f64`, `reverse_f64`, `unitize_closed` in f64).
|
||||
A focused parity port replaces `versor_apply_closed` with an f64-
|
||||
throughout sandwich + a closure path mirroring Python's
|
||||
`_close_applied_versor` exactly, then un-skips
|
||||
`test_versor_apply_rust_parity.py` and removes the dispatch disable
|
||||
in `algebra/backend.py::versor_apply`.
|
||||
|
||||
Until that port lands, `versor_apply` runs Python under any
|
||||
`CORE_BACKEND` setting. This is the ADR's "default-off until parity
|
||||
passes" discipline applied: the surface is honestly disabled, the
|
||||
gate is honestly skipped, and the follow-up is documented here.
|
||||
|
||||
## What this ADR does NOT decide
|
||||
|
||||
- Which Phase 5 curriculum to open *second* (Hebrew vs.
|
||||
|
|
|
|||
122
tests/test_cga_inner_rust_parity.py
Normal file
122
tests/test_cga_inner_rust_parity.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""ADR-0020 parity gate — cga_inner Python ⇔ Rust.
|
||||
|
||||
The CGA inner product is a diagonal weighted dot product on Cl(4,1)
|
||||
basis blades; both the Python `algebra.cga.cga_inner` and the Rust
|
||||
`cga_inner_raw` execute the same serial fold over 32 components.
|
||||
This test asserts bit-identity (raw f32 bytes equality) under
|
||||
`CORE_BACKEND=rust` vs the Python default, across deterministic
|
||||
seeds plus structured edge cases (basis blades, scalar, pseudoscalar).
|
||||
|
||||
If this test ever fails, the Rust kernel has diverged from the
|
||||
Python source-of-truth. Fix the Rust kernel, not the test.
|
||||
Python is canonical per CLAUDE.md sequencing rule 5.
|
||||
|
||||
Test is skipped at collection time if `core_rs` is not importable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
try:
|
||||
import core_rs # noqa: F401
|
||||
|
||||
_RUST_AVAILABLE = True
|
||||
except ImportError:
|
||||
_RUST_AVAILABLE = False
|
||||
|
||||
|
||||
SCRIPT = r"""
|
||||
import json, os, sys
|
||||
import numpy as np
|
||||
sys.path.insert(0, "__REPO__")
|
||||
from algebra.backend import using_rust, cga_inner
|
||||
|
||||
mode = os.environ["FIXTURE_MODE"]
|
||||
if mode == "random":
|
||||
rng = np.random.default_rng(int(os.environ["FIXTURE_SEED"]))
|
||||
x = rng.standard_normal(32).astype(np.float32)
|
||||
y = rng.standard_normal(32).astype(np.float32)
|
||||
elif mode == "basis":
|
||||
i = int(os.environ["FIXTURE_I"])
|
||||
j = int(os.environ["FIXTURE_J"])
|
||||
x = np.zeros(32, dtype=np.float32); x[i] = 1.0
|
||||
y = np.zeros(32, dtype=np.float32); y[j] = 1.0
|
||||
elif mode == "self":
|
||||
rng = np.random.default_rng(int(os.environ["FIXTURE_SEED"]))
|
||||
x = rng.standard_normal(32).astype(np.float32)
|
||||
y = x
|
||||
else:
|
||||
raise SystemExit(f"unknown mode {mode!r}")
|
||||
|
||||
score = cga_inner(x, y)
|
||||
print(json.dumps({
|
||||
"using_rust": using_rust(),
|
||||
"score_hex": np.float32(score).tobytes().hex(),
|
||||
}))
|
||||
"""
|
||||
|
||||
|
||||
def _run_backend(backend: str, **env_extra: str) -> dict:
|
||||
env = os.environ.copy()
|
||||
if backend == "rust":
|
||||
env["CORE_BACKEND"] = "rust"
|
||||
else:
|
||||
env.pop("CORE_BACKEND", None)
|
||||
env.update(env_extra)
|
||||
script = SCRIPT.replace("__REPO__", str(REPO))
|
||||
out = subprocess.check_output(
|
||||
[sys.executable, "-c", script],
|
||||
env=env,
|
||||
cwd=str(REPO),
|
||||
text=True,
|
||||
)
|
||||
return json.loads(out.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def _assert_bit_identity(py: dict, rs: dict) -> None:
|
||||
assert py["using_rust"] is False, "Python subprocess should not load Rust backend"
|
||||
assert rs["using_rust"] is True, "Rust subprocess should load Rust backend"
|
||||
assert py["score_hex"] == rs["score_hex"], (
|
||||
f"cga_inner bit-identity broken: python={py['score_hex']} rust={rs['score_hex']}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUST_AVAILABLE, reason="core_rs extension not built")
|
||||
@pytest.mark.parametrize("seed", [0xC07E, 0xBEEF, 0x1234, 0xFACE, 0xDEAD])
|
||||
def test_cga_inner_random_bit_identity(seed: int) -> None:
|
||||
py = _run_backend("python", FIXTURE_MODE="random", FIXTURE_SEED=str(seed))
|
||||
rs = _run_backend("rust", FIXTURE_MODE="random", FIXTURE_SEED=str(seed))
|
||||
_assert_bit_identity(py, rs)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUST_AVAILABLE, reason="core_rs extension not built")
|
||||
@pytest.mark.parametrize("i,j", [
|
||||
(0, 0), # scalar self
|
||||
(31, 31), # pseudoscalar self
|
||||
(1, 1), # e1 e1 — Cl(4,1) signature check
|
||||
(5, 5), # e+ self (positive null-cone direction)
|
||||
(1, 2), # off-diagonal must vanish to exact zero
|
||||
(3, 7), # another off-diagonal
|
||||
])
|
||||
def test_cga_inner_basis_blade_bit_identity(i: int, j: int) -> None:
|
||||
py = _run_backend("python", FIXTURE_MODE="basis", FIXTURE_I=str(i), FIXTURE_J=str(j))
|
||||
rs = _run_backend("rust", FIXTURE_MODE="basis", FIXTURE_I=str(i), FIXTURE_J=str(j))
|
||||
_assert_bit_identity(py, rs)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUST_AVAILABLE, reason="core_rs extension not built")
|
||||
@pytest.mark.parametrize("seed", [0xC07E, 0xBEEF, 0x1234])
|
||||
def test_cga_inner_self_norm_bit_identity(seed: int) -> None:
|
||||
"""cga_inner(x, x) — self-norm — must match bit-for-bit."""
|
||||
py = _run_backend("python", FIXTURE_MODE="self", FIXTURE_SEED=str(seed))
|
||||
rs = _run_backend("rust", FIXTURE_MODE="self", FIXTURE_SEED=str(seed))
|
||||
_assert_bit_identity(py, rs)
|
||||
128
tests/test_geometric_product_rust_parity.py
Normal file
128
tests/test_geometric_product_rust_parity.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""ADR-0020 parity gate — geometric_product Python ⇔ Rust.
|
||||
|
||||
The Cl(4,1) geometric product is a deterministic table lookup over
|
||||
32×32 component pairs. Both the Python `algebra.cl41.geometric_product`
|
||||
and the Rust `geometric_product_raw` execute the same f32 multiply-
|
||||
accumulate sequence in the same component order. This test asserts
|
||||
bit-identity (raw f32 bytes equality, per output component) under
|
||||
`CORE_BACKEND=rust` vs the Python default.
|
||||
|
||||
Coverage:
|
||||
- random pairs (5 seeds) — broad-spectrum drift catch
|
||||
- basis × basis (canonical structural cases) — table-correctness
|
||||
- scalar × multivector — identity check
|
||||
- pseudoscalar × pseudoscalar — sign convention
|
||||
|
||||
If this test fails, the Rust kernel has diverged from Python.
|
||||
Fix the Rust kernel, not the test.
|
||||
|
||||
Test is skipped at collection time if `core_rs` is not importable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
try:
|
||||
import core_rs # noqa: F401
|
||||
|
||||
_RUST_AVAILABLE = True
|
||||
except ImportError:
|
||||
_RUST_AVAILABLE = False
|
||||
|
||||
|
||||
SCRIPT = r"""
|
||||
import json, os, sys
|
||||
import numpy as np
|
||||
sys.path.insert(0, "__REPO__")
|
||||
from algebra.backend import using_rust, geometric_product
|
||||
|
||||
mode = os.environ["FIXTURE_MODE"]
|
||||
if mode == "random":
|
||||
rng = np.random.default_rng(int(os.environ["FIXTURE_SEED"]))
|
||||
a = rng.standard_normal(32).astype(np.float32)
|
||||
b = rng.standard_normal(32).astype(np.float32)
|
||||
elif mode == "basis":
|
||||
i = int(os.environ["FIXTURE_I"])
|
||||
j = int(os.environ["FIXTURE_J"])
|
||||
a = np.zeros(32, dtype=np.float32); a[i] = 1.0
|
||||
b = np.zeros(32, dtype=np.float32); b[j] = 1.0
|
||||
elif mode == "scalar_identity":
|
||||
rng = np.random.default_rng(int(os.environ["FIXTURE_SEED"]))
|
||||
a = np.zeros(32, dtype=np.float32); a[0] = 1.0
|
||||
b = rng.standard_normal(32).astype(np.float32)
|
||||
else:
|
||||
raise SystemExit(f"unknown mode {mode!r}")
|
||||
|
||||
out = np.asarray(geometric_product(a, b), dtype=np.float32)
|
||||
# Hex-encode each f32 component to preserve bit-identity through JSON.
|
||||
encoded = [np.float32(v).tobytes().hex() for v in out]
|
||||
print(json.dumps({"using_rust": using_rust(), "result": encoded}))
|
||||
"""
|
||||
|
||||
|
||||
def _run_backend(backend: str, **env_extra: str) -> dict:
|
||||
env = os.environ.copy()
|
||||
if backend == "rust":
|
||||
env["CORE_BACKEND"] = "rust"
|
||||
else:
|
||||
env.pop("CORE_BACKEND", None)
|
||||
env.update(env_extra)
|
||||
script = SCRIPT.replace("__REPO__", str(REPO))
|
||||
out = subprocess.check_output(
|
||||
[sys.executable, "-c", script],
|
||||
env=env,
|
||||
cwd=str(REPO),
|
||||
text=True,
|
||||
)
|
||||
return json.loads(out.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def _assert_bit_identity(py: dict, rs: dict) -> None:
|
||||
assert py["using_rust"] is False
|
||||
assert rs["using_rust"] is True
|
||||
assert len(py["result"]) == len(rs["result"]) == 32
|
||||
for k, (p, r) in enumerate(zip(py["result"], rs["result"])):
|
||||
assert p == r, f"geometric_product divergence at component {k}: python={p} rust={r}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUST_AVAILABLE, reason="core_rs extension not built")
|
||||
@pytest.mark.parametrize("seed", [0xC07E, 0xBEEF, 0x1234, 0xFACE, 0xDEAD])
|
||||
def test_geometric_product_random_bit_identity(seed: int) -> None:
|
||||
py = _run_backend("python", FIXTURE_MODE="random", FIXTURE_SEED=str(seed))
|
||||
rs = _run_backend("rust", FIXTURE_MODE="random", FIXTURE_SEED=str(seed))
|
||||
_assert_bit_identity(py, rs)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUST_AVAILABLE, reason="core_rs extension not built")
|
||||
@pytest.mark.parametrize("i,j", [
|
||||
(0, 0), # 1·1
|
||||
(0, 31), # 1·I
|
||||
(31, 31), # I·I (sign convention check)
|
||||
(1, 2), # e1·e2
|
||||
(2, 1), # e2·e1 (anticommutation)
|
||||
(5, 5), # e+ self
|
||||
(1, 1), # e1 self
|
||||
(15, 16), # arbitrary mid-grade pair
|
||||
])
|
||||
def test_geometric_product_basis_blade_bit_identity(i: int, j: int) -> None:
|
||||
py = _run_backend("python", FIXTURE_MODE="basis", FIXTURE_I=str(i), FIXTURE_J=str(j))
|
||||
rs = _run_backend("rust", FIXTURE_MODE="basis", FIXTURE_I=str(i), FIXTURE_J=str(j))
|
||||
_assert_bit_identity(py, rs)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUST_AVAILABLE, reason="core_rs extension not built")
|
||||
@pytest.mark.parametrize("seed", [0xC07E, 0xBEEF])
|
||||
def test_geometric_product_scalar_identity_bit_identity(seed: int) -> None:
|
||||
"""1 · b == b — both backends must preserve the scalar identity bit-for-bit."""
|
||||
py = _run_backend("python", FIXTURE_MODE="scalar_identity", FIXTURE_SEED=str(seed))
|
||||
rs = _run_backend("rust", FIXTURE_MODE="scalar_identity", FIXTURE_SEED=str(seed))
|
||||
_assert_bit_identity(py, rs)
|
||||
131
tests/test_versor_apply_rust_parity.py
Normal file
131
tests/test_versor_apply_rust_parity.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""ADR-0020 parity gate — versor_apply Python ⇔ Rust.
|
||||
|
||||
The sandwich product V·F·reverse(V) is the field-transition primitive
|
||||
under CLAUDE.md's non-negotiable invariant ``versor_condition(F) < 1e-6``.
|
||||
Python computes the full sandwich and closure in float64
|
||||
(`algebra.versor.versor_apply` + `_close_applied_versor`). Rust
|
||||
historically computes in f32 (`versor_apply_closed`) and the dispatch
|
||||
casts the result up to float64.
|
||||
|
||||
This test asserts bit-identity (per-component raw f64 bytes equality)
|
||||
under `CORE_BACKEND=rust` vs the Python default. If the gate fails
|
||||
the Rust path has diverged from the Python source-of-truth, and per
|
||||
ADR-0020 the Rust dispatch for this surface must be disabled until a
|
||||
parity port lands. Python is canonical per CLAUDE.md sequencing
|
||||
rule 5.
|
||||
|
||||
Coverage:
|
||||
- normalized versors applied to normalized fields (the runtime hot path)
|
||||
- identity versor applied to a random field (V=1 → V·F·rev(V) == F)
|
||||
- basis-blade rotors applied to canonical fields (structural cases)
|
||||
|
||||
Test is skipped at collection time if `core_rs` is not importable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
try:
|
||||
import core_rs # noqa: F401
|
||||
|
||||
_RUST_AVAILABLE = True
|
||||
except ImportError:
|
||||
_RUST_AVAILABLE = False
|
||||
|
||||
|
||||
SCRIPT = r"""
|
||||
import json, os, sys
|
||||
import numpy as np
|
||||
sys.path.insert(0, "__REPO__")
|
||||
from algebra.backend import using_rust, versor_apply
|
||||
from algebra.versor import normalize_to_versor
|
||||
|
||||
mode = os.environ["FIXTURE_MODE"]
|
||||
if mode == "normalized":
|
||||
rng = np.random.default_rng(int(os.environ["FIXTURE_SEED"]))
|
||||
seed_v = rng.standard_normal(32).astype(np.float64)
|
||||
seed_f = rng.standard_normal(32).astype(np.float64)
|
||||
v = normalize_to_versor(seed_v)
|
||||
f = normalize_to_versor(seed_f)
|
||||
elif mode == "identity_v":
|
||||
rng = np.random.default_rng(int(os.environ["FIXTURE_SEED"]))
|
||||
v = np.zeros(32, dtype=np.float64); v[0] = 1.0
|
||||
f = normalize_to_versor(rng.standard_normal(32).astype(np.float64))
|
||||
else:
|
||||
raise SystemExit(f"unknown mode {mode!r}")
|
||||
|
||||
out = np.asarray(versor_apply(v, f), dtype=np.float64)
|
||||
encoded = [np.float64(x).tobytes().hex() for x in out]
|
||||
print(json.dumps({"using_rust": using_rust(), "result": encoded}))
|
||||
"""
|
||||
|
||||
|
||||
def _run_backend(backend: str, **env_extra: str) -> dict:
|
||||
env = os.environ.copy()
|
||||
if backend == "rust":
|
||||
env["CORE_BACKEND"] = "rust"
|
||||
else:
|
||||
env.pop("CORE_BACKEND", None)
|
||||
env.update(env_extra)
|
||||
script = SCRIPT.replace("__REPO__", str(REPO))
|
||||
out = subprocess.check_output(
|
||||
[sys.executable, "-c", script],
|
||||
env=env,
|
||||
cwd=str(REPO),
|
||||
text=True,
|
||||
)
|
||||
return json.loads(out.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def _assert_bit_identity(py: dict, rs: dict) -> None:
|
||||
assert py["using_rust"] is False
|
||||
assert rs["using_rust"] is True
|
||||
assert len(py["result"]) == len(rs["result"]) == 32
|
||||
for k, (p, r) in enumerate(zip(py["result"], rs["result"])):
|
||||
assert p == r, f"versor_apply divergence at component {k}: python={p} rust={r}"
|
||||
|
||||
|
||||
# Rust dispatch for versor_apply is currently disabled in algebra/backend.py
|
||||
# pending an f64 parity port — see the docstring on `algebra.backend.versor_apply`.
|
||||
# The Rust kernel `versor_apply_closed` diverges from Python on two axes:
|
||||
# (1) precision: Rust folds the sandwich in f32; Python in f64
|
||||
# (2) closure structure: Rust has a null-vector early branch + no
|
||||
# post-unitize condition recheck; Python is the inverse
|
||||
# Until both axes are reconciled, the parity gate is skipped. When the
|
||||
# f64 port lands and the dispatch is re-enabled, remove this skip marker.
|
||||
_PARITY_DISABLED_REASON = (
|
||||
"Rust versor_apply dispatch disabled pending f64 parity port; "
|
||||
"see algebra/backend.py::versor_apply and ADR-0020."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason=_PARITY_DISABLED_REASON)
|
||||
@pytest.mark.parametrize("seed", [0xC07E, 0xBEEF, 0x1234, 0xFACE, 0xDEAD])
|
||||
def test_versor_apply_normalized_bit_identity(seed: int) -> None:
|
||||
"""Runtime hot path — both V and F normalized through the closure boundary."""
|
||||
py = _run_backend("python", FIXTURE_MODE="normalized", FIXTURE_SEED=str(seed))
|
||||
rs = _run_backend("rust", FIXTURE_MODE="normalized", FIXTURE_SEED=str(seed))
|
||||
_assert_bit_identity(py, rs)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason=_PARITY_DISABLED_REASON)
|
||||
@pytest.mark.parametrize("seed", [0xC07E, 0xBEEF, 0x1234])
|
||||
def test_versor_apply_identity_v_bit_identity(seed: int) -> None:
|
||||
"""V = scalar 1 → V·F·rev(V) == F. The simplest non-trivial sandwich case."""
|
||||
py = _run_backend("python", FIXTURE_MODE="identity_v", FIXTURE_SEED=str(seed))
|
||||
rs = _run_backend("rust", FIXTURE_MODE="identity_v", FIXTURE_SEED=str(seed))
|
||||
_assert_bit_identity(py, rs)
|
||||
|
||||
|
||||
# Keep _RUST_AVAILABLE referenced so static analysis sees its intended use
|
||||
# once the parity gate is re-enabled.
|
||||
_ = _RUST_AVAILABLE
|
||||
119
tests/test_versor_condition_rust_parity.py
Normal file
119
tests/test_versor_condition_rust_parity.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""ADR-0020 parity gate — versor_condition Python ⇔ Rust.
|
||||
|
||||
`versor_condition(F)` returns the field's deviation from unit-versor
|
||||
closure — the non-negotiable invariant CLAUDE.md gates on
|
||||
(< 1e-6). Both the Python `algebra.versor.versor_condition` and the
|
||||
Rust `versor_condition_raw` compose geometric_product + reverse +
|
||||
grade-0 extraction and reduce to a single float. This test asserts
|
||||
bit-identity (raw f32 bytes equality) under `CORE_BACKEND=rust` vs
|
||||
the Python default.
|
||||
|
||||
Coverage:
|
||||
- normalized versors from deterministic seeds (the runtime hot path)
|
||||
- raw fields before closure (catches divergence on out-of-shell input)
|
||||
- identity element (scalar 1.0) — must return 0.0 on both sides
|
||||
|
||||
If this gate fails, the field invariant itself is at risk under
|
||||
Rust — the Rust path must be fixed, not the test. Python is the
|
||||
canonical closure check per CLAUDE.md sequencing rule 5.
|
||||
|
||||
Test is skipped at collection time if `core_rs` is not importable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
try:
|
||||
import core_rs # noqa: F401
|
||||
|
||||
_RUST_AVAILABLE = True
|
||||
except ImportError:
|
||||
_RUST_AVAILABLE = False
|
||||
|
||||
|
||||
SCRIPT = r"""
|
||||
import json, os, sys
|
||||
import numpy as np
|
||||
sys.path.insert(0, "__REPO__")
|
||||
from algebra.backend import using_rust, versor_condition
|
||||
from algebra.versor import normalize_to_versor
|
||||
|
||||
mode = os.environ["FIXTURE_MODE"]
|
||||
if mode == "normalized":
|
||||
rng = np.random.default_rng(int(os.environ["FIXTURE_SEED"]))
|
||||
seed_v = rng.standard_normal(32).astype(np.float32)
|
||||
v = normalize_to_versor(seed_v).astype(np.float32)
|
||||
elif mode == "raw":
|
||||
rng = np.random.default_rng(int(os.environ["FIXTURE_SEED"]))
|
||||
v = rng.standard_normal(32).astype(np.float32)
|
||||
elif mode == "scalar_one":
|
||||
v = np.zeros(32, dtype=np.float32); v[0] = 1.0
|
||||
else:
|
||||
raise SystemExit(f"unknown mode {mode!r}")
|
||||
|
||||
cond = versor_condition(v)
|
||||
print(json.dumps({
|
||||
"using_rust": using_rust(),
|
||||
"cond_hex": np.float32(cond).tobytes().hex(),
|
||||
}))
|
||||
"""
|
||||
|
||||
|
||||
def _run_backend(backend: str, **env_extra: str) -> dict:
|
||||
env = os.environ.copy()
|
||||
if backend == "rust":
|
||||
env["CORE_BACKEND"] = "rust"
|
||||
else:
|
||||
env.pop("CORE_BACKEND", None)
|
||||
env.update(env_extra)
|
||||
script = SCRIPT.replace("__REPO__", str(REPO))
|
||||
out = subprocess.check_output(
|
||||
[sys.executable, "-c", script],
|
||||
env=env,
|
||||
cwd=str(REPO),
|
||||
text=True,
|
||||
)
|
||||
return json.loads(out.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def _assert_bit_identity(py: dict, rs: dict) -> None:
|
||||
assert py["using_rust"] is False
|
||||
assert rs["using_rust"] is True
|
||||
assert py["cond_hex"] == rs["cond_hex"], (
|
||||
f"versor_condition divergence: python={py['cond_hex']} rust={rs['cond_hex']}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUST_AVAILABLE, reason="core_rs extension not built")
|
||||
@pytest.mark.parametrize("seed", [0xC07E, 0xBEEF, 0x1234, 0xFACE, 0xDEAD])
|
||||
def test_versor_condition_normalized_bit_identity(seed: int) -> None:
|
||||
"""The runtime hot path — normalized versors fed into the closure check."""
|
||||
py = _run_backend("python", FIXTURE_MODE="normalized", FIXTURE_SEED=str(seed))
|
||||
rs = _run_backend("rust", FIXTURE_MODE="normalized", FIXTURE_SEED=str(seed))
|
||||
_assert_bit_identity(py, rs)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUST_AVAILABLE, reason="core_rs extension not built")
|
||||
@pytest.mark.parametrize("seed", [0xC07E, 0xBEEF, 0x1234])
|
||||
def test_versor_condition_raw_field_bit_identity(seed: int) -> None:
|
||||
"""Out-of-shell input — divergence here would mean different closure math."""
|
||||
py = _run_backend("python", FIXTURE_MODE="raw", FIXTURE_SEED=str(seed))
|
||||
rs = _run_backend("rust", FIXTURE_MODE="raw", FIXTURE_SEED=str(seed))
|
||||
_assert_bit_identity(py, rs)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUST_AVAILABLE, reason="core_rs extension not built")
|
||||
def test_versor_condition_scalar_one_bit_identity() -> None:
|
||||
"""The identity element must be at zero condition on both sides."""
|
||||
py = _run_backend("python", FIXTURE_MODE="scalar_one")
|
||||
rs = _run_backend("rust", FIXTURE_MODE="scalar_one")
|
||||
_assert_bit_identity(py, rs)
|
||||
Loading…
Reference in a new issue