diff --git a/algebra/backend.py b/algebra/backend.py new file mode 100644 index 00000000..ef9c22c9 --- /dev/null +++ b/algebra/backend.py @@ -0,0 +1,75 @@ +""" +Backend dispatch: use Rust extension (core_rs) when available, +fall back to pure Python (algebra/cl41.py etc.) transparently. + +This module is the single switch. All algebra modules import from here +for performance-critical ops. Pure Python is always the fallback — +the system is never broken by a missing Rust build. + +Usage: + from algebra.backend import geometric_product, versor_apply, cga_inner, vault_recall +""" + +import numpy as np + +try: + import core_rs as _rs + _RUST = True +except ImportError: + _RUST = False + + +def geometric_product(A: np.ndarray, B: np.ndarray) -> np.ndarray: + if _RUST: + return np.asarray(_rs.geometric_product(A, B), dtype=np.float32) + from algebra.cl41 import geometric_product as _gp + return _gp(A, B) + + +def versor_apply(V: np.ndarray, F: np.ndarray) -> np.ndarray: + if _RUST: + return np.asarray(_rs.versor_apply(V, F), dtype=np.float32) + from algebra.versor import versor_apply as _va + return _va(V, F) + + +def versor_condition(F: np.ndarray) -> float: + if _RUST: + return float(_rs.versor_condition(F)) + from algebra.versor import versor_condition as _vc + return _vc(F) + + +def normalize_to_versor(F: np.ndarray) -> np.ndarray: + if _RUST: + return np.asarray(_rs.normalize_to_versor(F), dtype=np.float32) + from algebra.versor import normalize_to_versor as _nv + return _nv(F) + + +def cga_inner(X: np.ndarray, Y: np.ndarray) -> float: + if _RUST: + return float(_rs.cga_inner(X, Y)) + from algebra.cga import cga_inner as _ci + return _ci(X, Y) + + +def vault_recall(versors: list, query: np.ndarray, top_k: int = 5) -> list: + """ + Top-k CGA inner product recall. + Rust path: parallel Rayon scan (releases GIL, true multithreaded). + Python path: sequential list comprehension. + """ + if _RUST: + results = _rs.vault_recall(versors, query, top_k) + # results: list of (index, score) + return results + from algebra.cga import cga_inner as _ci + scores = [(i, _ci(query, v)) for i, v in enumerate(versors)] + scores.sort(key=lambda x: -x[1]) + return scores[:top_k] + + +def using_rust() -> bool: + """Returns True if the Rust extension is loaded.""" + return _RUST diff --git a/core-rs/build.rs b/core-rs/build.rs new file mode 100644 index 00000000..0c1c7b7b --- /dev/null +++ b/core-rs/build.rs @@ -0,0 +1,7 @@ +// build.rs: nothing special needed for PyO3 — maturin handles the rest. +// This file exists as an extension point for future build-time codegen +// (e.g. generating the full Cl(4,1) multiplication table as a static array +// rather than computing it at OnceLock init time). +fn main() { + println!("cargo:rerun-if-changed=src/"); +} diff --git a/core-rs/pyproject.toml b/core-rs/pyproject.toml new file mode 100644 index 00000000..48ed8161 --- /dev/null +++ b/core-rs/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = ["maturin>=1.5,<2.0"] +build-backend = "maturin" + +[tool.maturin] +features = ["extension-module"] +module-name = "core_rs" +python-source = "python" diff --git a/core-rs/src/holonomy.rs b/core-rs/src/holonomy.rs new file mode 100644 index 00000000..dafda0c5 --- /dev/null +++ b/core-rs/src/holonomy.rs @@ -0,0 +1,81 @@ +//! Holonomy encoder in Rust — the forward+reverse versor walk. +//! +//! This is in Rust because: +//! - Long prompts (100+ tokens) do 200+ geometric products in sequence +//! - Each geometric product is O(32^2) = 1024 multiply-adds +//! - Python overhead per call makes this 10-50x slower than necessary +//! - Rust collapses the entire walk into a single allocation-free loop + +use crate::cl41::{geometric_product_raw, reverse_raw}; +use crate::versor::normalize_to_versor_raw; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum HolonomyError { + #[error("Empty word list")] + Empty, + #[error("Versor error: {0}")] + Versor(String), +} + +/// Compute holonomy of a word versor sequence. +/// +/// Forward walk: F = w0 * w1 * ... * wn +/// Reverse walk: R = (1-alpha) * rev(wn) * ... * rev(w0) +/// Holonomy: H = normalize(F * R) +/// +/// weights: per-word scalars (inverse frequency). If empty, uniform 1.0. +/// alpha: blend factor [0,1]. 0.5 recommended. +pub fn holonomy_encode_raw( + words: &[[f32; 32]], + weights: &[f32], + alpha: f32, +) -> Result<[f32; 32], HolonomyError> { + if words.is_empty() { + return Err(HolonomyError::Empty); + } + + let n = words.len(); + let use_weights = !weights.is_empty() && weights.len() == n; + + // Forward accumulation + let mut scaled = words[0]; + if use_weights { + let w = weights[0]; + for x in scaled.iter_mut() { *x *= w; } + } + let mut f = normalize_to_versor_raw(&scaled) + .map_err(|e| HolonomyError::Versor(e.to_string()))?; + + for k in 1..n { + let mut wk = words[k]; + if use_weights { + let w = weights[k]; + for x in wk.iter_mut() { *x *= w; } + } + let wk_norm = normalize_to_versor_raw(&wk) + .map_err(|e| HolonomyError::Versor(e.to_string()))?; + f = geometric_product_raw(&f, &wk_norm) + .map_err(|e| HolonomyError::Versor(e.to_string()))?; + } + + // Reverse accumulation with (1-alpha) damping + let damp = 1.0 - alpha; + let mut last_rev = reverse_raw(&words[n - 1]); + for x in last_rev.iter_mut() { *x *= damp; } + let mut r = normalize_to_versor_raw(&last_rev) + .map_err(|e| HolonomyError::Versor(e.to_string()))?; + + for k in (0..n - 1).rev() { + let rev_wk = reverse_raw(&words[k]); + let rev_norm = normalize_to_versor_raw(&rev_wk) + .map_err(|e| HolonomyError::Versor(e.to_string()))?; + r = geometric_product_raw(&rev_norm, &r) + .map_err(|e| HolonomyError::Versor(e.to_string()))?; + } + + let h = geometric_product_raw(&f, &r) + .map_err(|e| HolonomyError::Versor(e.to_string()))?; + normalize_to_versor_raw(&h) + .map_err(|e| HolonomyError::Versor(e.to_string())) +} diff --git a/core-rs/src/propagate.rs b/core-rs/src/propagate.rs new file mode 100644 index 00000000..b5ba75ee --- /dev/null +++ b/core-rs/src/propagate.rs @@ -0,0 +1,48 @@ +//! Propagation loop in Rust — tight versor_apply chain. +//! +//! propagate_n steps runs N versor_apply calls in a single Rust stack frame, +//! eliminating Python dispatch overhead for each step. +//! Used by generate/stream.py when stepping more than one token at a time +//! (e.g. prefill, speculative steps, or batch generation). + +use crate::versor::versor_apply_raw; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PropagateError { + #[error("Versor error during propagation: {0}")] + Versor(String), +} + +/// Run n versor_apply steps in sequence. +/// rotors: slice of n [f32;32] versors to apply in order +/// f0: initial field state +/// Returns final field state after n steps. +pub fn propagate_n_raw( + rotors: &[[f32; 32]], + f0: &[f32; 32], +) -> Result<[f32; 32], PropagateError> { + let mut f = *f0; + for v in rotors { + f = versor_apply_raw(v, &f) + .map_err(|e| PropagateError::Versor(e.to_string()))?; + } + Ok(f) +} + +/// Parallel batch propagation: apply the same rotor V to a batch of field states. +/// Used for beam search or multi-hypothesis generation. +/// Returns new batch of field states. +pub fn propagate_batch_raw( + v: &[f32; 32], + fields: &[[f32; 32]], +) -> Result, PropagateError> { + use rayon::prelude::*; + fields + .par_iter() + .map(|f| { + versor_apply_raw(v, f) + .map_err(|e| PropagateError::Versor(e.to_string())) + }) + .collect() +} diff --git a/core-rs/tests/test_cga.rs b/core-rs/tests/test_cga.rs new file mode 100644 index 00000000..0555e3cc --- /dev/null +++ b/core-rs/tests/test_cga.rs @@ -0,0 +1,48 @@ +#[cfg(test)] +mod tests { + use crate::cga::{cga_inner_raw, embed_point_raw, is_null_raw, null_project_raw}; + + #[test] + fn test_embedded_point_is_null() { + let p = [1.0f32, 2.0, 3.0]; + let x = embed_point_raw(&p); + assert!(is_null_raw(&x, 1e-5).unwrap(), "Embedded point should be null"); + } + + #[test] + fn test_origin_is_null() { + let x = embed_point_raw(&[0.0, 0.0, 0.0]); + assert!(is_null_raw(&x, 1e-5).unwrap()); + } + + #[test] + fn test_cga_inner_symmetry() { + let x = embed_point_raw(&[1.0, 0.0, 0.0]); + let y = embed_point_raw(&[0.0, 1.0, 0.0]); + let xy = cga_inner_raw(&x, &y).unwrap(); + let yx = cga_inner_raw(&y, &x).unwrap(); + assert!((xy - yx).abs() < 1e-6, "cga_inner not symmetric"); + } + + #[test] + fn test_cga_distance_identity() { + // Points at distance 1: cga_inner = -d^2/2 = -0.5 + let x = embed_point_raw(&[0.0, 0.0, 0.0]); + let y = embed_point_raw(&[1.0, 0.0, 0.0]); + let inner = cga_inner_raw(&x, &y).unwrap(); + assert!((inner - (-0.5)).abs() < 1e-5, + "Expected -0.5 for unit-distance points, got {}", inner); + } + + #[test] + fn test_null_project_restores_null() { + let p = [1.0f32, 2.0, 3.0]; + let mut x = embed_point_raw(&p); + // Introduce drift + x[0] += 0.05; + x[7] -= 0.03; + let fixed = null_project_raw(&x); + assert!(is_null_raw(&fixed, 1e-5).unwrap(), + "null_project failed to restore null cone"); + } +} diff --git a/core-rs/tests/test_cl41.rs b/core-rs/tests/test_cl41.rs new file mode 100644 index 00000000..abaa6007 --- /dev/null +++ b/core-rs/tests/test_cl41.rs @@ -0,0 +1,69 @@ +#[cfg(test)] +mod tests { + use crate::cl41::{geometric_product_raw, reverse_raw, BLADE_MASKS, MASK_TO_IDX}; + + fn basis(i: usize) -> [f32; 32] { + let mut v = [0f32; 32]; + v[1 + i] = 1.0; // grade-1 component + v + } + + fn scalar(s: f32) -> [f32; 32] { + let mut v = [0f32; 32]; + v[0] = s; + v + } + + #[test] + fn test_e1_squared_is_plus1() { + let e1 = basis(0); + let r = geometric_product_raw(&e1, &e1).unwrap(); + // e1^2 = +1 (signature index 0 = +1) + assert!((r[0] - 1.0).abs() < 1e-6, "e1^2 should be +1, got {}", r[0]); + } + + #[test] + fn test_e5_squared_is_minus1() { + let e5 = basis(4); + let r = geometric_product_raw(&e5, &e5).unwrap(); + // e5^2 = -1 (signature index 4 = -1) + assert!((r[0] + 1.0).abs() < 1e-6, "e5^2 should be -1, got {}", r[0]); + } + + #[test] + fn test_e1_e2_anticommute() { + let e1 = basis(0); + let e2 = basis(1); + let e1e2 = geometric_product_raw(&e1, &e2).unwrap(); + let e2e1 = geometric_product_raw(&e2, &e1).unwrap(); + // e1*e2 = -e2*e1 + for i in 0..32 { + assert!((e1e2[i] + e2e1[i]).abs() < 1e-6, + "e1*e2 + e2*e1 != 0 at index {}", i); + } + } + + #[test] + fn test_scalar_identity() { + let e1 = basis(0); + let one = scalar(1.0); + let r = geometric_product_raw(&one, &e1).unwrap(); + assert!((r[1] - 1.0).abs() < 1e-6, "1*e1 should be e1"); + } + + #[test] + fn test_reverse_grade2_sign() { + // A grade-2 blade should flip sign under reverse + let mut a = [0f32; 32]; + a[6] = 1.0; // first grade-2 component + let r = reverse_raw(&a); + assert!((r[6] + 1.0).abs() < 1e-6, "reverse of grade-2 blade should negate"); + } + + #[test] + fn test_reverse_grade1_unchanged() { + let e1 = basis(0); + let r = reverse_raw(&e1); + assert!((r[1] - 1.0).abs() < 1e-6, "reverse of grade-1 blade should be unchanged"); + } +} diff --git a/core-rs/tests/test_vault.rs b/core-rs/tests/test_vault.rs new file mode 100644 index 00000000..77468611 --- /dev/null +++ b/core-rs/tests/test_vault.rs @@ -0,0 +1,50 @@ +#[cfg(test)] +mod tests { + use crate::vault::vault_recall_raw; + use crate::versor::normalize_to_versor_raw; + + fn random_versor(seed: u64) -> [f32; 32] { + let mut state = seed ^ 0xdeadbeef_cafebabe; + let mut raw = [0f32; 32]; + for x in raw.iter_mut() { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + *x = ((state >> 33) as f32) / (u32::MAX as f32) * 2.0 - 1.0; + } + normalize_to_versor_raw(&raw).expect("normalize failed") + } + + #[test] + fn test_recall_self() { + let versors: Vec<[f32; 32]> = (0..20).map(|i| random_versor(i as u64)).collect(); + for (i, query) in versors.iter().enumerate() { + let results = vault_recall_raw(&versors, query, 1).unwrap(); + assert_eq!(results[0].0, i, + "Versor {} should recall itself as top-1, got {}", i, results[0].0); + } + } + + #[test] + fn test_empty_vault() { + let query = random_versor(0); + let results = vault_recall_raw(&[], &query, 5).unwrap(); + assert!(results.is_empty()); + } + + #[test] + fn test_top_k_count() { + let versors: Vec<[f32; 32]> = (0..10).map(|i| random_versor(i as u64)).collect(); + let query = random_versor(99); + let results = vault_recall_raw(&versors, &query, 3).unwrap(); + assert_eq!(results.len(), 3); + } + + #[test] + fn test_scores_descending() { + let versors: Vec<[f32; 32]> = (0..10).map(|i| random_versor(i as u64)).collect(); + let query = random_versor(99); + let results = vault_recall_raw(&versors, &query, 5).unwrap(); + for w in results.windows(2) { + assert!(w[0].1 >= w[1].1, "Scores not descending"); + } + } +} diff --git a/core-rs/tests/test_versor.rs b/core-rs/tests/test_versor.rs new file mode 100644 index 00000000..db13261b --- /dev/null +++ b/core-rs/tests/test_versor.rs @@ -0,0 +1,59 @@ +#[cfg(test)] +mod tests { + use crate::versor::{versor_apply_raw, normalize_to_versor_raw, versor_condition_raw}; + + fn random_versor(seed: u64) -> [f32; 32] { + // Simple LCG for deterministic test data — no external deps + let mut state = seed ^ 0xdeadbeef_cafebabe; + let mut raw = [0f32; 32]; + for x in raw.iter_mut() { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + *x = ((state >> 33) as f32) / (u32::MAX as f32) * 2.0 - 1.0; + } + normalize_to_versor_raw(&raw).expect("normalize failed") + } + + #[test] + fn test_normalize_produces_versor() { + for seed in 0..20u64 { + let v = random_versor(seed); + let cond = versor_condition_raw(&v).unwrap(); + assert!(cond < 1e-5, "versor_condition after normalize = {}", cond); + } + } + + #[test] + fn test_versor_apply_preserves_manifold() { + for seed in 0..20u64 { + let v = random_versor(seed); + let f = random_versor(seed + 1000); + let result = versor_apply_raw(&v, &f).unwrap(); + let cond = versor_condition_raw(&result).unwrap(); + assert!(cond < 1e-4, + "versor_apply broke manifold: condition={:.2e} at seed={}", cond, seed); + } + } + + #[test] + fn test_identity_versor() { + let mut identity = [0f32; 32]; + identity[0] = 1.0; + let f = random_versor(42); + let result = versor_apply_raw(&identity, &f).unwrap(); + for i in 0..32 { + assert!((result[i] - f[i]).abs() < 1e-5, + "Identity apply changed component {}: {} vs {}", i, result[i], f[i]); + } + } + + #[test] + fn test_composition_closed() { + let v1 = random_versor(0); + let v2 = random_versor(1); + let f = random_versor(2); + let f2 = versor_apply_raw(&v1, &f).unwrap(); + let f3 = versor_apply_raw(&v2, &f2).unwrap(); + let cond = versor_condition_raw(&f3).unwrap(); + assert!(cond < 1e-4, "Composition broke manifold: condition={:.2e}", cond); + } +} diff --git a/docs/RUST.md b/docs/RUST.md new file mode 100644 index 00000000..b91b7b9e --- /dev/null +++ b/docs/RUST.md @@ -0,0 +1,81 @@ +# Rust Extension (core-rs) + +## Why Rust + +Three operations dominate CORE-AI's runtime: + +1. geometric_product — O(32^2) = 1024 multiply-adds per call, called 2-3x per versor_apply +2. vault_recall scan — O(N) cga_inner calls, N = all stored versors, called once per token +3. holonomy_encode — 2 * prompt_length geometric products in sequence + +None of these release the Python GIL. Rayon gives vault_recall true multithreaded +parallelism across CPU cores. The geometric product loop is cache-friendly and +compiler-autovectorized when opt-level=3 + lto=true. + +## What is in Rust + +| Module | Rust file | Why | +|---|---|---| +| Cl(4,1) product | cl41.rs | Hot inner loop, 1024 MADs, autovectorizable | +| Versor ops | versor.rs | 3x geometric_product per step, allocation-free | +| CGA inner product | cga.rs | Called every token decode and every vault recall | +| Vault top-k scan | vault.rs | Rayon parallel scan — GIL blocks Python threads | +| Holonomy encode | holonomy.rs | 200+ products for long prompts | +| Batch propagation | propagate.rs | Beam search / speculative decode | + +## What stays in Python + +| Layer | Why | +|---|---| +| VocabManifold | Word lookup, edge rotor construction — called once per token, not per step | +| SessionContext | Orchestration, not arithmetic | +| FieldState | Plain dataclass | +| PersonaMotor | Motor construction is infrequent | + +## Zero-Copy Semantics + +All f32 arrays are passed as numpy arrays from Python. +The Rust functions receive them as `[f32; 32]` stack arrays — copied once from +the numpy buffer into a stack frame, processed, and returned as a new numpy array. +No heap allocation inside any hot-path function. + +For vault_recall, the versors list is iterated via Rayon par_iter with no cloning: +each worker holds a read-only reference to its slice element. + +## Build + +Requires maturin and a Rust toolchain (stable 1.75+). + +```bash +cd core-rs +pip install maturin +maturin develop --release # installs core_rs into current venv +``` + +Or build a wheel: +```bash +maturin build --release +pip install target/wheels/*.whl +``` + +Verify Rust backend is active from Python: +```python +from algebra.backend import using_rust +print(using_rust()) # True if core_rs is installed +``` + +## Running Rust Tests + +```bash +cd core-rs +cargo test +``` + +## Type Safety Contract + +All multivectors entering the Rust layer are validated as f32 arrays of length 32 +by extract_f32_slice() in lib.rs. Type errors surface immediately as Python +ValueError with a descriptive message rather than silent memory corruption. + +All error types use thiserror — every failure path is a named enum variant, +not a string panic.