fix(core): lock python and extend rust cga surface

This commit is contained in:
Shay 2026-07-02 15:20:14 -07:00
parent 8b12423dec
commit 03c87e680b
11 changed files with 133 additions and 16 deletions

View file

@ -40,7 +40,7 @@ jobs:
- name: set up uv
uses: astral-sh/setup-uv@v5
with:
python-version: '3.11'
python-version: '3.12.13'
enable-cache: true
- name: install dependencies

View file

@ -38,7 +38,7 @@ jobs:
- name: set up uv
uses: astral-sh/setup-uv@v5
with:
python-version: '3.11'
python-version: '3.12.13'
enable-cache: true
- name: install dependencies

View file

@ -36,7 +36,7 @@ jobs:
- name: set up python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.12.13'
cache: 'pip'
- name: install dependencies

View file

@ -66,7 +66,7 @@ jobs:
- name: set up uv
uses: astral-sh/setup-uv@v5
with:
python-version: '3.11'
python-version: '3.12.13'
enable-cache: true
- name: install dependencies

View file

@ -37,7 +37,7 @@ jobs:
- name: set up uv
uses: astral-sh/setup-uv@v5
with:
python-version: '3.11'
python-version: '3.12.13'
enable-cache: true
- name: install dependencies

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.12.13

View file

@ -121,6 +121,39 @@ fn cga_inner(
cga_inner_raw(x_slice, y_slice).map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Embed a Euclidean point [x, y, z] into the CGA null cone.
#[pyfunction]
fn embed_point(
py: Python<'_>,
p: numpy::PyReadonlyArray1<'_, f32>,
) -> PyResult<PyObject> {
let p_slice = read_f32_xyz(&p)?;
let result = crate::cga::embed_point_raw(p_slice);
f32_array_to_numpy(py, &result)
}
/// Re-project a multivector onto the null cone by Euclidean read-back + re-embed.
#[pyfunction]
fn null_project(
py: Python<'_>,
x: numpy::PyReadonlyArray1<'_, f32>,
) -> PyResult<PyObject> {
let x_slice = read_f32_cl41_mv(&x)?;
let result = crate::cga::null_project_raw(x_slice);
f32_array_to_numpy(py, &result)
}
/// Check whether a multivector lies on the null cone.
#[pyfunction]
fn is_null(
x: numpy::PyReadonlyArray1<'_, f32>,
tol: f32,
) -> PyResult<bool> {
let x_slice = read_f32_cl41_mv(&x)?;
crate::cga::is_null_raw(x_slice, tol)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Parallel top-k vault recall by CGA inner product (zero-copy).
///
/// Per ADR-0020 follow-on (task #35): accepts a 2D numpy
@ -298,6 +331,25 @@ fn read_f64_cl41_mv<'a>(arr: &'a numpy::PyReadonlyArray1<'a, f64>) -> PyResult<&
})
}
fn read_f32_xyz<'a>(arr: &'a numpy::PyReadonlyArray1<'a, f32>) -> PyResult<&'a [f32; 3]> {
let len = arr.len()?;
if len != 3 {
return Err(PyValueError::new_err(format!(
"expected contiguous float32 array of length 3, got length {}",
len
)));
}
let slice = arr.as_slice().map_err(|e| {
PyValueError::new_err(format!(
"input must be C-contiguous float32 (3,): {}",
e
))
})?;
slice.try_into().map_err(|_| {
PyValueError::new_err("expected contiguous float32 array of length 3")
})
}
fn extract_f32_slice(obj: &pyo3::types::PyAny) -> PyResult<[f32; 32]> {
let np = obj.py().import("numpy")?;
let arr = np.call_method1("asarray", (obj, "float32"))?;
@ -353,6 +405,9 @@ fn core_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(versor_condition, m)?)?;
m.add_function(wrap_pyfunction!(normalize_to_versor, m)?)?;
m.add_function(wrap_pyfunction!(cga_inner, m)?)?;
m.add_function(wrap_pyfunction!(embed_point, m)?)?;
m.add_function(wrap_pyfunction!(null_project, m)?)?;
m.add_function(wrap_pyfunction!(is_null, m)?)?;
m.add_function(wrap_pyfunction!(vault_recall, m)?)?;
m.add_function(wrap_pyfunction!(unitize_expmap, m)?)?;
m.add_function(wrap_pyfunction!(diffusion_step, m)?)?;

View file

@ -2,7 +2,7 @@
{"id":"FSC-BENIGN-002","kind":"single_token_admit","prime":["What does the seeker pursue?","The seeker pursues wisdom."],"prompt":"What does the seeker pursue?","expected_endpoint":"wisdom","chain_tokens":["wisdom"],"grounding_note":"Single-token region; wisdom self-score ≈ 1.95."}
{"id":"FSC-BENIGN-003","kind":"single_token_admit","prime":["What does the student ask?","The student asks a question."],"prompt":"What does the student ask?","expected_endpoint":"question","chain_tokens":["question"],"grounding_note":"Single-token region; question self-score ≈ 1.42."}
{"id":"FSC-BENIGN-004","kind":"single_token_admit","prime":["What is the building block of language?","The building block of language is the word."],"prompt":"What is the building block of language?","expected_endpoint":"word","chain_tokens":["word"],"grounding_note":"Single-token region; word self-score ≈ 5.86 (largest in corpus)."}
{"id":"FSC-BENIGN-005","kind":"single_token_admit","prime":["What does the philosopher seek?","The philosopher seeks understanding."],"prompt":"What does the philosopher seek?","expected_endpoint":"understanding","chain_tokens":["understanding"],"grounding_note":"Single-token region; understanding pack-grounded."}
{"id":"FSC-BENIGN-005","kind":"single_token_admit","prime":["What does the sage seek?","The sage seeks understanding."],"prompt":"What does the sage seek?","expected_endpoint":"understanding","chain_tokens":["understanding"],"grounding_note":"Single-token region; understanding pack-grounded."}
{"id":"FSC-BENIGN-006","kind":"single_token_admit","prime":["What does language carry?","Language carries meaning."],"prompt":"What does language carry?","expected_endpoint":"meaning","chain_tokens":["meaning"],"grounding_note":"Single-token region; meaning self-score positive."}
{"id":"FSC-BENIGN-007","kind":"single_token_admit","prime":["What organizes memory?","Identity organizes memory."],"prompt":"What organizes memory?","expected_endpoint":"identity","chain_tokens":["identity"],"grounding_note":"Single-token region; identity self-score ≈ 2.50."}
{"id":"FSC-BENIGN-008","kind":"single_token_admit","prime":["What is the source of all things?","The beginning is the source of all things."],"prompt":"What is the source of all things?","expected_endpoint":"beginning","chain_tokens":["beginning"],"grounding_note":"Single-token region; 'beginning' has comfortably positive self-cga_inner (~1.36). Replaced 'correction' (self-score -0.036 under Cl(4,1) — see Phase 5 findings)."}

View file

@ -2,7 +2,7 @@
name = "core-versor"
version = "0.1.0"
description = "Versor Engine: cognitive field system on Cl(4,1) Conformal Geometric Algebra"
requires-python = ">=3.11"
requires-python = "==3.12.13"
dependencies = [
"hypothesis>=6.152.7",

View file

@ -20,7 +20,8 @@ UI_PORT="${UI_PORT:-5173}"
API_HOST="127.0.0.1"
UI_HOST="127.0.0.1"
MIN_NODE_MAJOR=20
MIN_PY_MINOR=11 # requires-python >=3.11
REQUIRED_PYTHON_VERSION="3.12.13"
REQUIRED_PYTHON_BIN="/opt/homebrew/bin/python3.12"
# --- resolve repo root from this script's location (works from anywhere) -----
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -72,15 +73,14 @@ setup() {
bold "Setup"
if [ ! -x "$VENV/bin/python" ]; then
warn "creating Python venv (.venv) with uv"
uv venv "$VENV" >/dev/null
warn "creating Python venv (.venv) with uv (${REQUIRED_PYTHON_VERSION})"
uv venv --python "$REQUIRED_PYTHON_BIN" "$VENV" >/dev/null
fi
# Confirm the venv Python meets the minimum.
local py_minor
py_minor="$("$VENV/bin/python" -c 'import sys; print(sys.version_info[1])')"
[ "$py_minor" -ge "$MIN_PY_MINOR" ] \
|| die "venv Python is 3.${py_minor}; need >= 3.${MIN_PY_MINOR}. Recreate: rm -rf .venv && uv venv --python 3.12"
ok "Python $("$VENV/bin/python" -c 'import platform; print(platform.python_version())')"
local py_version
py_version="$("$VENV/bin/python" -c 'import platform; print(platform.python_version())')"
[ "$py_version" = "$REQUIRED_PYTHON_VERSION" ] \
|| die "venv Python is ${py_version}; need exactly ${REQUIRED_PYTHON_VERSION}. Recreate: rm -rf .venv && uv venv --python ${REQUIRED_PYTHON_BIN}"
ok "Python ${py_version}"
if [ ! -x "$VENV/bin/core" ] || ! "$VENV/bin/python" -c 'import workbench.server' >/dev/null 2>&1; then
warn "installing the CORE package into .venv (editable) — first run only, may take a minute"

View file

@ -0,0 +1,61 @@
"""ADR-0020 parity surface — Rust-exposed CGA helpers match Python exactly."""
from __future__ import annotations
import numpy as np
import pytest
from algebra.cga import embed_point as py_embed_point
from algebra.cga import is_null as py_is_null
from algebra.cga import null_project as py_null_project
try:
import core_rs
_RUST_AVAILABLE = True
except ImportError:
_RUST_AVAILABLE = False
pytestmark = pytest.mark.skipif(
not _RUST_AVAILABLE, reason="core_rs extension not built"
)
def _assert_f32_bit_identity(left: np.ndarray, right: np.ndarray) -> None:
left_f32 = np.asarray(left, dtype=np.float32)
right_f32 = np.asarray(right, dtype=np.float32)
assert left_f32.shape == right_f32.shape
assert left_f32.tobytes().hex() == right_f32.tobytes().hex()
@pytest.mark.parametrize(
"point",
(
np.array([0.0, 0.0, 0.0], dtype=np.float32),
np.array([1.0, 2.0, 3.0], dtype=np.float32),
np.array([-4.5, 0.25, 8.0], dtype=np.float32),
),
)
def test_embed_point_matches_python_bit_for_bit(point: np.ndarray) -> None:
py = py_embed_point(point)
rs = np.asarray(core_rs.embed_point(point), dtype=np.float32)
_assert_f32_bit_identity(py, rs)
@pytest.mark.parametrize("seed", (3, 7, 11))
def test_null_project_matches_python_bit_for_bit(seed: int) -> None:
rng = np.random.default_rng(seed)
drifted = py_embed_point(rng.standard_normal(3).astype(np.float32)).astype(np.float32)
drifted[0] += np.float32(0.125)
drifted[7] -= np.float32(0.5)
py = py_null_project(drifted)
rs = np.asarray(core_rs.null_project(drifted), dtype=np.float32)
_assert_f32_bit_identity(py, rs)
@pytest.mark.parametrize("seed", (5, 9, 13))
def test_is_null_matches_python(seed: int) -> None:
rng = np.random.default_rng(seed)
point = py_embed_point(rng.standard_normal(3).astype(np.float32)).astype(np.float32)
assert py_is_null(point) is bool(core_rs.is_null(point, np.float32(1e-6)))