Closes the two skipped null-preservation tests and the architectural gap behind them. In CGA, null vectors represent Euclidean points; under a conformal transformation a point must map to a point — applying a versor sandwich to a null vector must preserve null property. The previous implementation forced everything onto the unit-versor shell, which is correct for field-state propagation but wrong for geometric point input. Implementation - algebra/versor.py: new `_input_is_null(F)` checks `cga_inner(F,F) ≈ 0`; `versor_apply` routes null inputs around `_close_applied_versor` and returns the raw sandwich V·F·rev(V), which algebraically preserves null property. Non-null inputs unchanged. - core-rs/src/versor.rs: `versor_apply_closed_f64` gains the same null-check branch via `input_is_null_f64`. ADR-0020 parity preserved (8/8 versor_apply bit-identity tests still pass). Test changes - tests/test_architectural_invariants.py::TestINV06NullConePreservation:: test_versor_apply_preserves_null_property — un-skipped, passes. - tests/test_rust_backend.py::test_rust_versor_apply_preserves_null_vectors — un-skipped, passes. - tests/test_versor_closure.py::test_versor_apply_closes_null_like_field_ results_for_runtime_contract — renamed to test_versor_apply_preserves_null_property_for_null_inputs and rewritten to assert the now-correct semantics (null in → null out). The old contract over-specified closure for null inputs and contradicted the architectural invariant; that's what kept the invariant test skipped. Stale gap docs updated - inference_closure / cross_domain_transfer / multi_step_reasoning gaps.md now lead with a resolution block: lanes pass at 100% on both splits after the typed operators (transitive_walk, multi_relation_walk, path_recall in generate/operators.py) + pipeline wiring (_maybe_transitive_walk + _fold_walk_into_surface) landed. The historic findings are preserved below for traceability. - compositionality gaps.md: partial resolution — recall up from 6.25% to 68.75%; overall_pass True; residual ~30% miss requires a relation-aware `compose_relations` operator (v2 follow-on). Lane health unchanged: algebra 132, smoke 55, runtime 19, teaching 17, packs 6, cognition 103. Cognition eval 100%. Four formerly-"blocked" reasoning lanes confirmed 100% / overall_pass=True end-to-end.
287 lines
9.4 KiB
Rust
287 lines
9.4 KiB
Rust
//! Versor operations: the three primitives.
|
|
//!
|
|
//! versor_apply V*F*reverse(V) — the only allowed field transition
|
|
//! normalize_to_versor F/sqrt(|F*rev(F)|) — called once at injection gate
|
|
//! versor_condition ||F*rev(F)-1||_F — used in tests and gate only
|
|
|
|
use crate::cl41::{geometric_product_f64, geometric_product_raw, reverse_f64, reverse_raw, Cl41Error};
|
|
use thiserror::Error;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum VersorError {
|
|
#[error("Cl41 error: {0}")]
|
|
Cl41(#[from] Cl41Error),
|
|
#[error("Cannot normalize: norm^2 too small ({0})")]
|
|
NullVersor(f32),
|
|
}
|
|
|
|
const NEAR_ZERO_TOL: f64 = 1e-12;
|
|
const NULL_SCALAR_TOL: f64 = 1e-9;
|
|
const CONSTRUCTION_RESIDUE_TOL: f64 = 1e-2;
|
|
const SEED_BIVECTORS: [usize; 6] = [6, 7, 8, 10, 11, 13];
|
|
|
|
fn is_null_vector(v: &[f32; 32]) -> bool {
|
|
use crate::cga::cga_inner_raw;
|
|
// Generous tolerance: the f32 sandwich product introduces ~1e-6 error
|
|
// on null vectors; 1e-5 correctly classifies them without false positives
|
|
// on actual versors (which have cga_inner >> 0.1).
|
|
match cga_inner_raw(v, v) {
|
|
Ok(inner) => (inner as f64).abs() < 1e-5,
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
fn unitize_closed(v: &[f64; 32]) -> Result<[f64; 32], ()> {
|
|
let input_norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
|
if input_norm < NEAR_ZERO_TOL {
|
|
return Err(());
|
|
}
|
|
|
|
let rev = reverse_f64(v);
|
|
let vv = geometric_product_f64(v, &rev);
|
|
|
|
let scalar_sq = vv[0];
|
|
let residue_norm: f64 = vv[1..].iter().map(|x| x * x).sum::<f64>().sqrt();
|
|
|
|
if residue_norm >= CONSTRUCTION_RESIDUE_TOL {
|
|
return Err(());
|
|
}
|
|
if scalar_sq <= 0.0 {
|
|
return Err(());
|
|
}
|
|
|
|
let inv = 1.0 / scalar_sq.sqrt();
|
|
let mut result = *v;
|
|
for x in result.iter_mut() { *x *= inv; }
|
|
Ok(result)
|
|
}
|
|
|
|
fn seed_to_rotor(v: &[f64; 32]) -> Result<[f64; 32], ()> {
|
|
let scale: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
|
let scale = if scale == 0.0 { 1.0 } else { scale };
|
|
|
|
let mut rotor = [0f64; 32];
|
|
rotor[0] = 1.0;
|
|
|
|
for (step, &blade) in SEED_BIVECTORS.iter().enumerate() {
|
|
let source = v[(blade + step) % 32] / scale;
|
|
let theta = 0.5 * source.tanh();
|
|
let mut factor = [0f64; 32];
|
|
factor[0] = theta.cos();
|
|
factor[blade] = theta.sin();
|
|
|
|
rotor = geometric_product_f64(&rotor, &factor);
|
|
}
|
|
|
|
unitize_closed(&rotor)
|
|
}
|
|
|
|
fn close_applied_versor(v: &[f32; 32]) -> [f32; 32] {
|
|
if is_null_vector(v) {
|
|
return crate::cga::null_project_raw(v);
|
|
}
|
|
|
|
let v_f64: [f64; 32] = {
|
|
let mut arr = [0f64; 32];
|
|
for i in 0..32 { arr[i] = v[i] as f64; }
|
|
arr
|
|
};
|
|
|
|
if let Ok(closed) = unitize_closed(&v_f64) {
|
|
let mut result = [0f32; 32];
|
|
for i in 0..32 { result[i] = closed[i] as f32; }
|
|
return result;
|
|
}
|
|
|
|
if let Ok(seeded) = seed_to_rotor(&v_f64) {
|
|
let mut result = [0f32; 32];
|
|
for i in 0..32 { result[i] = seeded[i] as f32; }
|
|
return result;
|
|
}
|
|
|
|
*v
|
|
}
|
|
|
|
/// Sandwich product V * F * reverse(V) with closure semantics.
|
|
/// Preserves null vectors as null vectors. Applies unit-versor closure
|
|
/// with construction seed fallback for non-null results.
|
|
pub fn versor_apply_closed(v: &[f32; 32], f: &[f32; 32]) -> Result<[f32; 32], VersorError> {
|
|
let rev_v = reverse_raw(v);
|
|
let vf = geometric_product_raw(v, f)?;
|
|
let vfrv = geometric_product_raw(&vf, &rev_v)?;
|
|
Ok(close_applied_versor(&vfrv))
|
|
}
|
|
|
|
/// `versor_apply` f64 path — bit-identity port of Python
|
|
/// `algebra.versor.versor_apply` + `_close_applied_versor`.
|
|
///
|
|
/// Performs the full sandwich V·F·rev(V) and closure in f64. The
|
|
/// closure mirrors Python exactly: no null-vector early branch
|
|
/// (Python doesn't have one), and after `unitize_closed` succeeds the
|
|
/// candidate is gated through `versor_condition < 1e-6` before being
|
|
/// accepted — otherwise the deterministic `seed_to_rotor`
|
|
/// construction map is used. ADR-0020 parity gate
|
|
/// `tests/test_versor_apply_rust_parity.py`.
|
|
pub fn versor_apply_closed_f64(
|
|
v: &[f64; 32],
|
|
f: &[f64; 32],
|
|
) -> Result<[f64; 32], VersorError> {
|
|
let rev_v = reverse_f64(v);
|
|
let vf = geometric_product_f64(v, f);
|
|
let vfrv = geometric_product_f64(&vf, &rev_v);
|
|
// Null inputs (CGA points) skip closure to preserve null property.
|
|
// Matches `algebra.versor.versor_apply` _input_is_null branch.
|
|
if input_is_null_f64(f) {
|
|
return Ok(vfrv);
|
|
}
|
|
Ok(close_applied_versor_f64(&vfrv))
|
|
}
|
|
|
|
fn input_is_null_f64(f: &[f64; 32]) -> bool {
|
|
// cga_inner(f, f) ≈ 0 to the f32-sandwich noise floor.
|
|
// Symmetric formula: 0.5 * (scalar(f*f) + scalar(f*f)) = scalar(f*f).
|
|
let f_sq = geometric_product_f64(f, f);
|
|
f_sq[0].abs() < 1e-5
|
|
}
|
|
|
|
const RUNTIME_CLOSURE_TOL: f64 = 1e-6;
|
|
const DENSE_SEED_MIN_COMPONENTS: usize = 8;
|
|
|
|
fn versor_condition_f64(v: &[f64; 32]) -> f64 {
|
|
let rev = reverse_f64(v);
|
|
let mut frv = geometric_product_f64(v, &rev);
|
|
frv[0] -= 1.0;
|
|
frv.iter().map(|x| x * x).sum::<f64>().sqrt()
|
|
}
|
|
|
|
/// Mirrors Python `unitize_versor`: try `unitize_closed`; on
|
|
/// bad_residue, if dense enough fall back to `seed_to_rotor`; else
|
|
/// propagate the error.
|
|
fn unitize_versor_f64(v: &[f64; 32]) -> Result<[f64; 32], ()> {
|
|
match unitize_closed(v) {
|
|
Ok(closed) => Ok(closed),
|
|
Err(()) => {
|
|
// Python distinguishes bad_residue (eligible for seed fallback)
|
|
// from bad_scalar / near_zero (not eligible). We can't
|
|
// distinguish the error variants under the current
|
|
// `unitize_closed` signature; mirror Python's policy by gating
|
|
// the fallback on the dense-support heuristic, which is the
|
|
// condition Python also requires before invoking the rotor seed.
|
|
let support = v
|
|
.iter()
|
|
.filter(|x| x.abs() > NEAR_ZERO_TOL)
|
|
.count();
|
|
if support < DENSE_SEED_MIN_COMPONENTS {
|
|
Err(())
|
|
} else {
|
|
seed_to_rotor(v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mirrors Python `_close_applied_versor`:
|
|
/// try _runtime_closed(v) -> if condition < 1e-6 return; else seed_to_rotor
|
|
/// on any ValueError -> seed_to_rotor (with passthrough as last resort
|
|
/// if seed_to_rotor itself fails)
|
|
fn close_applied_versor_f64(v: &[f64; 32]) -> [f64; 32] {
|
|
if let Ok(candidate) = unitize_versor_f64(v) {
|
|
if versor_condition_f64(&candidate) < RUNTIME_CLOSURE_TOL {
|
|
return candidate;
|
|
}
|
|
}
|
|
if let Ok(seeded) = seed_to_rotor(v) {
|
|
return seeded;
|
|
}
|
|
*v
|
|
}
|
|
|
|
/// Raw sandwich product V * F * reverse(V) without closure.
|
|
pub fn versor_apply_raw(v: &[f32; 32], f: &[f32; 32]) -> Result<[f32; 32], VersorError> {
|
|
let rev_v = reverse_raw(v);
|
|
let vf = geometric_product_raw(v, f)?;
|
|
let vfrv = geometric_product_raw(&vf, &rev_v)?;
|
|
Ok(vfrv)
|
|
}
|
|
|
|
/// Project F onto versor manifold: F / sqrt(|scalar_part(F*rev(F))|).
|
|
/// Called ONCE at ingest/gate. Never mid-propagation.
|
|
pub fn normalize_to_versor_raw(f: &[f32; 32]) -> Result<[f32; 32], VersorError> {
|
|
let rev_f = reverse_raw(f);
|
|
let frv = geometric_product_raw(f, &rev_f)?;
|
|
let n2 = frv[0]; // grade-0 = scalar part
|
|
if n2.abs() < 1e-12 {
|
|
return Err(VersorError::NullVersor(n2));
|
|
}
|
|
let inv_norm = 1.0 / n2.abs().sqrt();
|
|
let mut result = *f;
|
|
for x in result.iter_mut() { *x *= inv_norm; }
|
|
Ok(result)
|
|
}
|
|
|
|
/// ||F * reverse(F) - 1||_F.
|
|
/// 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 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)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn identity_versor() -> [f32; 32] {
|
|
let mut v = [0f32; 32];
|
|
v[0] = 1.0;
|
|
v
|
|
}
|
|
|
|
fn simple_reflector() -> [f32; 32] {
|
|
let mut v = [0f32; 32];
|
|
v[1] = 1.0;
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn closed_identity_is_identity() {
|
|
let id = identity_versor();
|
|
let f = simple_reflector();
|
|
let result = versor_apply_closed(&id, &f).unwrap();
|
|
for i in 0..32 {
|
|
assert!((result[i] - f[i]).abs() < 1e-5, "component {} diverged", i);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn closed_preserves_versor_condition() {
|
|
let v = simple_reflector();
|
|
let f = identity_versor();
|
|
let result = versor_apply_closed(&v, &f).unwrap();
|
|
let cond = versor_condition_raw(&result).unwrap();
|
|
assert!(cond < 1e-4, "condition {} too large", cond);
|
|
}
|
|
|
|
#[test]
|
|
fn closed_matches_raw_for_identity() {
|
|
let id = identity_versor();
|
|
let f = simple_reflector();
|
|
let raw = versor_apply_raw(&id, &f).unwrap();
|
|
let closed = versor_apply_closed(&id, &f).unwrap();
|
|
for i in 0..32 {
|
|
assert!((raw[i] - closed[i]).abs() < 1e-5);
|
|
}
|
|
}
|
|
}
|