diff --git a/algebra/backend.py b/algebra/backend.py index da810fcf..2a7efb5b 100644 --- a/algebra/backend.py +++ b/algebra/backend.py @@ -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) diff --git a/core-rs/src/versor.rs b/core-rs/src/versor.rs index 09b241da..fe35a438 100644 --- a/core-rs/src/versor.rs +++ b/core-rs/src/versor.rs @@ -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 { - 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)] diff --git a/core/cli.py b/core/cli.py index 17d01369..ca251e32 100644 --- a/core/cli.py +++ b/core/cli.py @@ -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", diff --git a/docs/decisions/ADR-0020-phase5-rust-parity-sequencing.md b/docs/decisions/ADR-0020-phase5-rust-parity-sequencing.md index 74d2f181..4819897b 100644 --- a/docs/decisions/ADR-0020-phase5-rust-parity-sequencing.md +++ b/docs/decisions/ADR-0020-phase5-rust-parity-sequencing.md @@ -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. diff --git a/tests/test_cga_inner_rust_parity.py b/tests/test_cga_inner_rust_parity.py new file mode 100644 index 00000000..15dd2079 --- /dev/null +++ b/tests/test_cga_inner_rust_parity.py @@ -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) diff --git a/tests/test_geometric_product_rust_parity.py b/tests/test_geometric_product_rust_parity.py new file mode 100644 index 00000000..36ccc8e1 --- /dev/null +++ b/tests/test_geometric_product_rust_parity.py @@ -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) diff --git a/tests/test_versor_apply_rust_parity.py b/tests/test_versor_apply_rust_parity.py new file mode 100644 index 00000000..c92e949a --- /dev/null +++ b/tests/test_versor_apply_rust_parity.py @@ -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 diff --git a/tests/test_versor_condition_rust_parity.py b/tests/test_versor_condition_rust_parity.py new file mode 100644 index 00000000..b082fa45 --- /dev/null +++ b/tests/test_versor_condition_rust_parity.py @@ -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)