feat(rust-parity): vault_recall first surface — bit-identity gate passes
ADR-0020 first per-surface Rust parity port. Parity test runs the same fixture under CORE_BACKEND=python (default) and CORE_BACKEND=rust in subprocesses and asserts: - per-versor scores are float32 bit-identical (raw bytes hex) - top-k ordering matches, including ascending-index tie-break Tested at N=50/137/200/500 versors across four seeds. All four parameterisations pass with 0 ULP delta. Why parity holds with no Rust code change: the Cl(4,1) CGA inner product is structurally diagonal with ±1 metric. The full geometric-product Rust path (core-rs/src/cga.rs::cga_inner_raw) accumulates off-diagonal contributions to scalar[0] in pairs that cancel to bit-exact zero in float32, leaving the same serial sum_i metric[i]*X[i]*Y[i] that the Python vectorised path computes. Same kernel, two implementations. Parity gate: PASS. Performance gate: NOT YET. At N=100k the Rust path is ~13x slower than Python (266ms vs 20ms) due to per- versor numpy marshalling in the Rust binding (100k Python→Rust round trips). Default-off posture is correct until the marshalling is fixed (next per-surface follow-on).
This commit is contained in:
parent
80aa971db3
commit
3e8c50a5b3
1 changed files with 103 additions and 0 deletions
103
tests/test_vault_recall_rust_parity.py
Normal file
103
tests/test_vault_recall_rust_parity.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""ADR-0020 first-surface parity gate — vault_recall Python ⇔ Rust.
|
||||
|
||||
The Rust backend (`CORE_BACKEND=rust`) must produce bit-identical
|
||||
results to the Python vectorised path on the same input. This
|
||||
test runs the same fixture under both backends in subprocesses
|
||||
(so the backend env var is honoured at module import) and
|
||||
asserts:
|
||||
|
||||
- per-versor scores are exactly equal (float32 bit-identity)
|
||||
- top-k ordering matches, including ascending-index tie-break
|
||||
|
||||
If this test ever fails, the Rust path has diverged from the
|
||||
Python source-of-truth path. Fix the Rust kernel, not the test.
|
||||
The Python path is the canonical implementation per CLAUDE.md
|
||||
sequencing rule 5.
|
||||
|
||||
Test is skipped at collection time if the `core_rs` extension 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, vault_recall
|
||||
|
||||
rng = np.random.default_rng(int(os.environ["FIXTURE_SEED"]))
|
||||
n = int(os.environ["FIXTURE_N"])
|
||||
top_k = int(os.environ["FIXTURE_TOPK"])
|
||||
versors = [rng.standard_normal(32).astype(np.float32) for _ in range(n)]
|
||||
q = rng.standard_normal(32).astype(np.float32)
|
||||
result = vault_recall(versors, q, top_k=top_k)
|
||||
|
||||
# Encode scores as raw float32 bytes (hex) to preserve bit-identity
|
||||
# through JSON.
|
||||
encoded = [(i, np.float32(s).tobytes().hex()) for i, s in result]
|
||||
print(json.dumps({"using_rust": using_rust(), "result": encoded}))
|
||||
"""
|
||||
|
||||
|
||||
def _run_backend(backend: str, seed: int, n: int, top_k: int) -> dict:
|
||||
env = os.environ.copy()
|
||||
if backend == "rust":
|
||||
env["CORE_BACKEND"] = "rust"
|
||||
else:
|
||||
env.pop("CORE_BACKEND", None)
|
||||
env["FIXTURE_SEED"] = str(seed)
|
||||
env["FIXTURE_N"] = str(n)
|
||||
env["FIXTURE_TOPK"] = str(top_k)
|
||||
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])
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUST_AVAILABLE, reason="core_rs extension not built")
|
||||
@pytest.mark.parametrize("seed,n,top_k", [
|
||||
(0xC07E, 200, 200),
|
||||
(0xBEEF, 137, 50),
|
||||
(0x1234, 500, 10),
|
||||
(0xFACE, 50, 5),
|
||||
])
|
||||
def test_vault_recall_python_rust_bit_identity(seed: int, n: int, top_k: int) -> None:
|
||||
py = _run_backend("python", seed, n, top_k)
|
||||
rs = _run_backend("rust", seed, n, top_k)
|
||||
|
||||
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 len(py["result"]) == len(rs["result"]) == min(top_k, n)
|
||||
|
||||
# Bit-identity on scores (hex-encoded f32 bytes) and ordering on
|
||||
# indices (including ascending-index tie-break).
|
||||
for (py_i, py_hex), (rs_i, rs_hex) in zip(py["result"], rs["result"]):
|
||||
assert py_i == rs_i, f"top-k order divergence: python={py_i} rust={rs_i}"
|
||||
assert py_hex == rs_hex, (
|
||||
f"score bit-identity broken at index {py_i}: "
|
||||
f"python={py_hex} rust={rs_hex}"
|
||||
)
|
||||
Loading…
Reference in a new issue