diff --git a/Cargo.lock b/Cargo.lock index 361ce58a10..bf50ebf70f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9077,6 +9077,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ruvector-cohomology" +version = "0.1.0" +dependencies = [ + "ruvector-mincut 2.2.3", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", +] + [[package]] name = "ruvector-collections" version = "2.2.3" diff --git a/Cargo.toml b/Cargo.toml index c2f4d345c8..6c2a2bb212 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,7 @@ members = [ "crates/ruvllm-cli", "crates/ruvllm-wasm", "crates/prime-radiant", + "crates/ruvector-cohomology", "crates/ruvector-delta-core", "crates/ruvector-delta-wasm", "crates/ruvector-delta-index", diff --git a/crates/prime-radiant/src/cohomology/obstruction.rs b/crates/prime-radiant/src/cohomology/obstruction.rs index 1df64a6073..bab77aadcb 100644 --- a/crates/prime-radiant/src/cohomology/obstruction.rs +++ b/crates/prime-radiant/src/cohomology/obstruction.rs @@ -1,8 +1,20 @@ //! Obstruction Detection //! //! Obstructions are cohomological objects that indicate global inconsistency. -//! A non-trivial obstruction means that local data cannot be patched together -//! into a global section. +//! +//! Precisely (ADR-272): for a *linear* sheaf the zero section is always a +//! global section, so a nontrivial `H^1` does **not** mean "no global +//! section exists". The correct statements on a graph are: +//! +//! - `H^0(G, F) = ker delta` is the space of global sections; +//! - `H^1(G, F) = C^1 / im delta` measures **edge data** that cannot be +//! written as the coboundary of any vertex assignment. +//! +//! A non-trivial obstruction therefore means the *observed edge data* +//! (affine constraints) cannot be explained by any global vertex assignment +//! — an irreducible contradiction in the observations, not the absence of +//! global sections. The affine syndrome engine built on this correction +//! lives in the `ruvector-cohomology` crate. use super::cocycle::{Cocycle, SheafCoboundary}; use super::laplacian::{HarmonicRepresentative, LaplacianConfig, SheafLaplacian}; diff --git a/crates/ruvector-cohomology/Cargo.toml b/crates/ruvector-cohomology/Cargo.toml new file mode 100644 index 0000000000..728014e112 --- /dev/null +++ b/crates/ruvector-cohomology/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "ruvector-cohomology" +version = "0.1.0" +edition = "2021" +rust-version = "1.77" +license = "MIT OR Apache-2.0" +authors = ["RuVector Team "] +description = "Dynamic cohomological error correction: affine sheaf syndrome, sparse semantic repair, and mincut containment over dynamic cellular sheaves" +repository = "https://github.com/ruvnet/ruvector" +homepage = "https://ruv.io/ruvector" +documentation = "https://docs.rs/ruvector-cohomology" +keywords = ["sheaf", "cohomology", "error-correction", "graph", "consistency"] +categories = ["algorithms", "science", "mathematics"] + +[lib] +crate-type = ["rlib"] + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } +sha2 = "0.10" + +# Optional coupling to the dynamic mincut engine for global quarantine +# boundaries. The crate always ships a deterministic internal s-t max-flow +# for seeded containment; this feature adds the subpolynomial global cut. +ruvector-mincut = { version = "2.0", path = "../ruvector-mincut", default-features = false, features = ["exact"], optional = true } + +[features] +default = [] +mincut = ["dep:ruvector-mincut"] + +[dev-dependencies] +serde_json = { workspace = true } + +[[bench]] +name = "batch" +harness = false + +[[bench]] +name = "dynamic_edits" +harness = false + +[[bench]] +name = "planted_cycles" +harness = false + +[[bench]] +name = "repair" +harness = false + +[[bench]] +name = "mincut_coupling" +harness = false diff --git a/crates/ruvector-cohomology/benches/batch.rs b/crates/ruvector-cohomology/benches/batch.rs new file mode 100644 index 0000000000..bc02ed4d7e --- /dev/null +++ b/crates/ruvector-cohomology/benches/batch.rs @@ -0,0 +1,19 @@ +//! Batch syndrome benchmark: assembly + full solve at increasing scale. + +mod common; + +use common::{build_ring_engine, time_ms}; +use ruvector_cohomology::dynamic::DynamicCohomology; + +fn main() { + for &(n, dim, chords) in &[(100u64, 4usize, 50u64), (1_000, 4, 500), (5_000, 8, 2_500)] { + let mut engine = build_ring_engine(n, dim, chords, 42); + let (result, ms) = time_ms(|| engine.flush().expect("flush")); + println!( + "batch: n={n} dim={dim} edges={} energy={:.3e} flush={:.2}ms", + n + chords, + result.energy, + ms + ); + } +} diff --git a/crates/ruvector-cohomology/benches/common/mod.rs b/crates/ruvector-cohomology/benches/common/mod.rs new file mode 100644 index 0000000000..6b4f5e5b06 --- /dev/null +++ b/crates/ruvector-cohomology/benches/common/mod.rs @@ -0,0 +1,78 @@ +//! Shared bench helpers: deterministic graph generators and timing. +#![allow(dead_code)] + +use ruvector_cohomology::dynamic::{CohomologyEdit, DynamicCohomology, DynamicSheafEngine, EngineConfig}; +use ruvector_cohomology::operator::RestrictionMap; +use std::time::Instant; + +/// Deterministic pseudo-random stream for reproducible benchmarks. +pub struct Rng(pub u64); + +impl Rng { + pub fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^ (z >> 31) + } + + pub fn unit(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } +} + +/// Build a ring of `n` vertices with stalk dimension `dim`, identity maps, +/// coherent observations, plus `extra_chords` random chords. +pub fn build_ring_engine(n: u64, dim: usize, extra_chords: u64, seed: u64) -> DynamicSheafEngine { + let mut engine = DynamicSheafEngine::new(EngineConfig::default()); + let mut rng = Rng(seed); + for i in 0..n { + assert!(engine + .apply_edit(CohomologyEdit::InsertVertex { id: i, dim }) + .accepted); + } + let add_edge = |engine: &mut DynamicSheafEngine, a: u64, b: u64| { + let receipt = engine.apply_edit(CohomologyEdit::InsertEdge { + a, + b, + rho_a: RestrictionMap::Identity { dim }, + rho_b: RestrictionMap::Identity { dim }, + weight: 1.0, + observation: vec![0.0; dim], + }); + receipt.accepted + }; + for i in 0..n { + add_edge(&mut engine, i, (i + 1) % n); + } + let mut added = 0; + while added < extra_chords { + let a = rng.next_u64() % n; + let b = rng.next_u64() % n; + if a != b && add_edge(&mut engine, a, b) { + added += 1; + } + } + engine +} + +/// Time a closure, returning (result, elapsed ms). +pub fn time_ms(f: impl FnOnce() -> T) -> (T, f64) { + let start = Instant::now(); + let out = f(); + (out, start.elapsed().as_secs_f64() * 1e3) +} + +/// Report a labeled percentile summary of per-op latencies (ms). +pub fn report(label: &str, mut samples: Vec) { + samples.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let pct = |p: f64| samples[((samples.len() - 1) as f64 * p) as usize]; + println!( + "{label}: n={} median={:.4}ms p99={:.4}ms max={:.4}ms", + samples.len(), + pct(0.5), + pct(0.99), + samples[samples.len() - 1] + ); +} diff --git a/crates/ruvector-cohomology/benches/dynamic_edits.rs b/crates/ruvector-cohomology/benches/dynamic_edits.rs new file mode 100644 index 0000000000..960d4353ab --- /dev/null +++ b/crates/ruvector-cohomology/benches/dynamic_edits.rs @@ -0,0 +1,34 @@ +//! Dynamic edit latency benchmark: median/p99 local edit and estimate cost. + +mod common; + +use common::{build_ring_engine, report, time_ms, Rng}; +use ruvector_cohomology::dynamic::{CohomologyEdit, DynamicCohomology}; + +fn main() { + let n = 10_000u64; + let dim = 8usize; + let mut engine = build_ring_engine(n, dim, 5_000, 7); + engine.flush().expect("initial flush"); + + let mut rng = Rng(99); + let mut edit_samples = Vec::new(); + let mut estimate_samples = Vec::new(); + for _ in 0..10_000 { + let a = rng.next_u64() % n; + let b = (a + 1) % n; + let value: Vec = (0..dim).map(|_| rng.unit() * 0.01).collect(); + let (receipt, ms) = time_ms(|| { + engine.apply_edit(CohomologyEdit::UpdateObservation { a, b, value: value.clone() }) + }); + assert!(receipt.accepted); + edit_samples.push(ms); + let (_, ems) = time_ms(|| engine.estimate()); + estimate_samples.push(ems); + } + report("local edit (UpdateObservation)", edit_samples); + report("estimate()", estimate_samples); + + let (result, ms) = time_ms(|| engine.flush().expect("flush")); + println!("exact flush after 10k edits: {:.2}ms energy={:.3e}", ms, result.energy); +} diff --git a/crates/ruvector-cohomology/benches/mincut_coupling.rs b/crates/ruvector-cohomology/benches/mincut_coupling.rs new file mode 100644 index 0000000000..d3b2273691 --- /dev/null +++ b/crates/ruvector-cohomology/benches/mincut_coupling.rs @@ -0,0 +1,42 @@ +//! Combined syndrome → tension → quarantine → repair benchmark. + +mod common; + +use common::{build_ring_engine, time_ms}; +use ruvector_cohomology::dynamic::{CohomologyEdit, DynamicCohomology}; +use ruvector_cohomology::mincut_bridge::{run_containment, ContainmentPolicy, TensionWeights}; +use ruvector_cohomology::repair::RepairPolicy; + +fn main() { + for policy in [ContainmentPolicy::IsolateFirst, ContainmentPolicy::RepairFirst] { + let mut engine = build_ring_engine(200, 4, 100, 3); + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { + a: 10, + b: 11, + value: vec![3.0; 4], + }) + .accepted); + let (receipt, ms) = time_ms(|| { + run_containment( + &mut engine, + &TensionWeights::default(), + &RepairPolicy { lambda: 0.05, ..RepairPolicy::default() }, + policy, + 0.5, + 1e-9, + ) + .expect("containment") + }); + println!( + "containment {:?}: pre={:.3e} post={:.3e} cut_edges={} isolated={} verified={} {:.2}ms", + policy, + receipt.pre_energy, + receipt.post_energy, + receipt.boundary.cut_edges.len(), + receipt.boundary.isolated_vertices.len(), + receipt.verified, + ms + ); + } +} diff --git a/crates/ruvector-cohomology/benches/planted_cycles.rs b/crates/ruvector-cohomology/benches/planted_cycles.rs new file mode 100644 index 0000000000..a91027c225 --- /dev/null +++ b/crates/ruvector-cohomology/benches/planted_cycles.rs @@ -0,0 +1,50 @@ +//! Planted-contradiction localization benchmark: precision/recall of the +//! ranked syndrome support against known planted inconsistent edges. + +mod common; + +use common::{build_ring_engine, Rng}; +use ruvector_cohomology::dynamic::{CohomologyEdit, DynamicCohomology}; +use ruvector_cohomology::EdgeKey; + +fn main() { + let n = 500u64; + let dim = 4usize; + let planted = 5usize; + // Localization precision depends on cycle redundancy: on a bare ring the + // syndrome is a constant harmonic 1-form (no localization is possible); + // with ~2 chords per vertex the residual concentrates on planted edges. + let mut engine = build_ring_engine(n, dim, 1000, 11); + let mut rng = Rng(23); + + // Plant contradictions on ring edges (guaranteed to lie on cycles). + let mut planted_edges = Vec::new(); + while planted_edges.len() < planted { + let a = rng.next_u64() % n; + let b = (a + 1) % n; + let key = EdgeKey::new(a, b).unwrap(); + if planted_edges.contains(&key) { + continue; + } + let value: Vec = (0..dim).map(|_| 1.0 + rng.unit()).collect(); + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { a, b, value }) + .accepted); + planted_edges.push(key); + } + + let result = engine.flush().expect("flush"); + let predicted: Vec = result + .ranked_support + .iter() + .take(planted) + .map(|c| c.edge) + .collect(); + let hits = predicted.iter().filter(|e| planted_edges.contains(e)).count(); + let precision = hits as f64 / predicted.len() as f64; + let recall = hits as f64 / planted_edges.len() as f64; + println!( + "planted_cycles: n={n} planted={planted} energy={:.3e} precision={precision:.2} recall={recall:.2}", + result.energy + ); +} diff --git a/crates/ruvector-cohomology/benches/repair.rs b/crates/ruvector-cohomology/benches/repair.rs new file mode 100644 index 0000000000..eabefd9764 --- /dev/null +++ b/crates/ruvector-cohomology/benches/repair.rs @@ -0,0 +1,31 @@ +//! Group-sparse repair benchmark: latency and post-repair energy. + +mod common; + +use common::{build_ring_engine, time_ms}; +use ruvector_cohomology::dynamic::{CohomologyEdit, DynamicCohomology}; +use ruvector_cohomology::repair::RepairPolicy; + +fn main() { + for &(n, dim) in &[(50u64, 4usize), (200, 4), (500, 8)] { + let mut engine = build_ring_engine(n, dim, n / 2, 5); + // One planted contradiction. + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { + a: 0, + b: 1, + value: vec![2.0; dim], + }) + .accepted); + let policy = RepairPolicy { lambda: 0.05, ..RepairPolicy::default() }; + let (plan, ms) = time_ms(|| engine.propose_repair(policy).expect("repair")); + println!( + "repair: n={n} dim={dim} support={} pre={:.3e} post={:.3e} obj={:.3e} {:.2}ms", + plan.corrections.len(), + plan.pre_energy, + plan.post_energy, + plan.objective, + ms + ); + } +} diff --git a/crates/ruvector-cohomology/src/affine.rs b/crates/ruvector-cohomology/src/affine.rs new file mode 100644 index 0000000000..02fea1a906 --- /dev/null +++ b/crates/ruvector-cohomology/src/affine.rs @@ -0,0 +1,180 @@ +//! Affine sheaf system: observations `b`, weights `W`, and gauge. +//! +//! Adds the affine layer missing from the pure-topology sheaf model +//! (issue #669, implementation gaps 5–6): observed edge relationships and +//! confidence weights, hashed deterministically for witnesses. + +use crate::block_sparse::BlockCoboundary; +use crate::{CohomologyError, EdgeKey, GaugePolicy}; +use sha2::{Digest, Sha256}; + +/// A field over `C¹`: one value block per edge, laid out to match the +/// canonical edge order of a [`BlockCoboundary`]. +#[derive(Debug, Clone, PartialEq)] +pub struct EdgeField { + /// Concatenated per-edge blocks. + pub data: Vec, +} + +impl EdgeField { + /// Zero field matching an operator layout. + pub fn zeros(op: &BlockCoboundary) -> Self { + Self { data: vec![0.0; op.dim_c1()] } + } + + /// Weighted energy `‖f‖²_W = Σ_e w_e ‖f_e‖²`. + pub fn weighted_energy(&self, op: &BlockCoboundary, weights: &EdgeWeights) -> f64 { + let mut acc = 0.0; + for (i, e) in op.edge_blocks().iter().enumerate() { + let mut block = 0.0; + for v in &self.data[e.offset..e.offset + e.dim] { + block += v * v; + } + acc += weights.values[i] * block; + } + acc + } + + /// Deterministic hash over the exact bit patterns in canonical order. + pub fn canonical_hash(&self) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"ruvector-cohomology/edge-field/v1"); + h.update((self.data.len() as u64).to_le_bytes()); + for v in &self.data { + h.update(v.to_bits().to_le_bytes()); + } + h.finalize().into() + } +} + +/// Per-edge positive scalar confidence/trust/severity weights, laid out in +/// canonical edge order. +#[derive(Debug, Clone, PartialEq)] +pub struct EdgeWeights { + /// One weight per edge. + pub values: Vec, +} + +impl EdgeWeights { + /// Unit weights matching an operator. + pub fn ones(op: &BlockCoboundary) -> Self { + Self { values: vec![1.0; op.edge_count()] } + } + + /// Validate positivity and finiteness. + pub fn validate(&self, max_value: f64) -> Result<(), CohomologyError> { + for (i, w) in self.values.iter().enumerate() { + if !w.is_finite() || *w <= 0.0 || *w > max_value { + return Err(CohomologyError::ValidationFailed(format!( + "edge weight #{i} = {w} must be finite, positive, and ≤ {max_value}" + ))); + } + } + Ok(()) + } + + /// Deterministic hash over the exact bit patterns in canonical order. + pub fn canonical_hash(&self) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"ruvector-cohomology/edge-weights/v1"); + h.update((self.values.len() as u64).to_le_bytes()); + for v in &self.values { + h.update(v.to_bits().to_le_bytes()); + } + h.finalize().into() + } +} + +/// The full affine sheaf system (issue #669 core type). +pub struct AffineSheafSystem { + /// Epoch of the topology (vertices, edges, restriction maps). + pub topology_epoch: u64, + /// Epoch of the observation/weight data. + pub observation_epoch: u64, + /// The block coboundary operator `δ`. + pub operator: BlockCoboundary, + /// Observed edge relationships `b ∈ C¹`. + pub observations: EdgeField, + /// Confidence weights `W` (per-edge scalars). + pub weights: EdgeWeights, + /// Gauge fixing policy for the least-squares representative. + pub gauge: GaugePolicy, +} + +impl AffineSheafSystem { + /// Assemble and validate a system. + pub fn new( + operator: BlockCoboundary, + observations: EdgeField, + weights: EdgeWeights, + gauge: GaugePolicy, + ) -> Result { + if observations.data.len() != operator.dim_c1() { + return Err(CohomologyError::DimensionMismatch { + expected: operator.dim_c1(), + actual: observations.data.len(), + context: "observations vs C¹ layout".into(), + }); + } + if weights.values.len() != operator.edge_count() { + return Err(CohomologyError::DimensionMismatch { + expected: operator.edge_count(), + actual: weights.values.len(), + context: "weights vs edge count".into(), + }); + } + if observations.data.iter().any(|v| !v.is_finite()) { + return Err(CohomologyError::ValidationFailed( + "observations contain non-finite values".into(), + )); + } + weights.validate(f64::MAX)?; + Ok(Self { + topology_epoch: 0, + observation_epoch: 0, + operator, + observations, + weights, + gauge, + }) + } + + /// Set the observation block for one edge. + pub fn set_observation(&mut self, key: &EdgeKey, value: &[f64]) -> Result<(), CohomologyError> { + let (off, dim) = self + .operator + .edge_slice(key) + .ok_or(CohomologyError::UnknownEdge(key.u, key.v))?; + if value.len() != dim { + return Err(CohomologyError::DimensionMismatch { + expected: dim, + actual: value.len(), + context: format!("observation on edge ({}, {})", key.u, key.v), + }); + } + if value.iter().any(|v| !v.is_finite()) { + return Err(CohomologyError::ValidationFailed( + "observation contains non-finite values".into(), + )); + } + self.observations.data[off..off + dim].copy_from_slice(value); + self.observation_epoch += 1; + Ok(()) + } + + /// Set the weight for one edge. + pub fn set_weight(&mut self, key: &EdgeKey, weight: f64) -> Result<(), CohomologyError> { + let pos = self + .operator + .edge_position(key) + .ok_or(CohomologyError::UnknownEdge(key.u, key.v))?; + if !weight.is_finite() || weight <= 0.0 { + return Err(CohomologyError::ValidationFailed(format!( + "weight {weight} must be finite and positive" + ))); + } + self.weights.values[pos] = weight; + self.observation_epoch += 1; + Ok(()) + } +} diff --git a/crates/ruvector-cohomology/src/block_sparse.rs b/crates/ruvector-cohomology/src/block_sparse.rs new file mode 100644 index 0000000000..37d76ee02c --- /dev/null +++ b/crates/ruvector-cohomology/src/block_sparse.rs @@ -0,0 +1,313 @@ +//! Block-sparse coboundary operator with deterministic indexing. +//! +//! Assembles the true weighted block sheaf operator from **both** endpoint +//! restriction maps (issue #669, implementation gaps 1–2): matrix-free +//! `apply`/`apply_transpose`, weighted Laplacian application, and dense +//! assembly for the reference oracle. + +use crate::operator::{DenseMatrix, LinearRestriction, RestrictionMap}; +use crate::{CohomologyError, EdgeKey, ValidationLimits, VertexId}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// One edge block of the coboundary operator. +#[derive(Clone)] +pub struct EdgeBlock { + /// Canonical edge key (`u < v`, oriented `u → v`). + pub key: EdgeKey, + /// Edge stalk dimension. + pub dim: usize, + /// Offset of this edge block inside `C¹`. + pub offset: usize, + /// Restriction from the tail stalk `F(u)` into `F(e)`. + pub rho_u: Arc, + /// Restriction from the head stalk `F(v)` into `F(e)`. + pub rho_v: Arc, +} + +/// Deterministic block-sparse coboundary `δ: C⁰ → C¹`. +/// +/// Vertex blocks are laid out in ascending `VertexId` order; edge blocks in +/// ascending `EdgeKey` order. Orientation is always tail (lower id) → head: +/// `(δx)_e = ρ_v x_v − ρ_u x_u`. +#[derive(Clone)] +pub struct BlockCoboundary { + vertices: Vec<(VertexId, usize, usize)>, // (id, dim, offset) + vertex_pos: BTreeMap, + edges: Vec, + edge_pos: BTreeMap, + n0: usize, + n1: usize, +} + +impl BlockCoboundary { + /// Total dimension of `C⁰` (sum of vertex stalk dimensions). + pub fn dim_c0(&self) -> usize { + self.n0 + } + + /// Total dimension of `C¹` (sum of edge stalk dimensions). + pub fn dim_c1(&self) -> usize { + self.n1 + } + + /// Number of vertices. + pub fn vertex_count(&self) -> usize { + self.vertices.len() + } + + /// Number of edges. + pub fn edge_count(&self) -> usize { + self.edges.len() + } + + /// Sorted vertex layout: `(id, stalk_dim, offset in C⁰)`. + pub fn vertex_layout(&self) -> &[(VertexId, usize, usize)] { + &self.vertices + } + + /// Sorted edge blocks. + pub fn edge_blocks(&self) -> &[EdgeBlock] { + &self.edges + } + + /// Position of a vertex in the sorted layout. + pub fn vertex_position(&self, v: VertexId) -> Option { + self.vertex_pos.get(&v).copied() + } + + /// Position of an edge in the sorted layout. + pub fn edge_position(&self, key: &EdgeKey) -> Option { + self.edge_pos.get(key).copied() + } + + /// Offset and dimension of a vertex block in `C⁰`. + pub fn vertex_slice(&self, v: VertexId) -> Option<(usize, usize)> { + self.vertex_pos.get(&v).map(|&p| { + let (_, dim, off) = self.vertices[p]; + (off, dim) + }) + } + + /// Offset and dimension of an edge block in `C¹`. + pub fn edge_slice(&self, key: &EdgeKey) -> Option<(usize, usize)> { + self.edge_pos.get(key).map(|&p| { + let b = &self.edges[p]; + (b.offset, b.dim) + }) + } + + /// `y = δ x`, matrix-free. + pub fn apply(&self, x: &[f64], y: &mut [f64]) { + debug_assert_eq!(x.len(), self.n0); + debug_assert_eq!(y.len(), self.n1); + let mut tmp = Vec::new(); + for e in &self.edges { + let (uo, ud) = self.vertex_slice(e.key.u).expect("indexed vertex"); + let (vo, vd) = self.vertex_slice(e.key.v).expect("indexed vertex"); + let ye = &mut y[e.offset..e.offset + e.dim]; + e.rho_v.apply(&x[vo..vo + vd], ye); + tmp.resize(e.dim, 0.0); + e.rho_u.apply(&x[uo..uo + ud], &mut tmp); + for (yi, ti) in ye.iter_mut().zip(tmp.iter()) { + *yi -= ti; + } + } + } + + /// `x = δᵀ y`, matrix-free. + pub fn apply_transpose(&self, y: &[f64], x: &mut [f64]) { + debug_assert_eq!(y.len(), self.n1); + debug_assert_eq!(x.len(), self.n0); + x.fill(0.0); + let mut tmp = Vec::new(); + for e in &self.edges { + let (uo, ud) = self.vertex_slice(e.key.u).expect("indexed vertex"); + let (vo, vd) = self.vertex_slice(e.key.v).expect("indexed vertex"); + let ye = &y[e.offset..e.offset + e.dim]; + tmp.resize(vd.max(ud), 0.0); + e.rho_v.apply_transpose(ye, &mut tmp[..vd]); + for (xi, ti) in x[vo..vo + vd].iter_mut().zip(tmp.iter()) { + *xi += ti; + } + e.rho_u.apply_transpose(ye, &mut tmp[..ud]); + for (xi, ti) in x[uo..uo + ud].iter_mut().zip(tmp.iter()) { + *xi -= ti; + } + } + } + + /// `out = L x = δᵀ W δ x` with per-edge scalar weights, matrix-free. + pub fn apply_weighted_laplacian(&self, weights: &[f64], x: &[f64], out: &mut [f64]) { + debug_assert_eq!(weights.len(), self.edges.len()); + let mut mid = vec![0.0; self.n1]; + self.apply(x, &mut mid); + for (i, e) in self.edges.iter().enumerate() { + let w = weights[i]; + for m in &mut mid[e.offset..e.offset + e.dim] { + *m *= w; + } + } + self.apply_transpose(&mid, out); + } + + /// Assemble `W^{1/2} δ` densely (reference oracle path only). + pub fn assemble_scaled_dense(&self, weights: &[f64]) -> DenseMatrix { + let mut a = DenseMatrix::zeros(self.n1, self.n0); + for (i, e) in self.edges.iter().enumerate() { + let sw = weights[i].sqrt(); + let (uo, _) = self.vertex_slice(e.key.u).expect("indexed vertex"); + let (vo, _) = self.vertex_slice(e.key.v).expect("indexed vertex"); + let du = e.rho_u.to_dense(); + let dv = e.rho_v.to_dense(); + for r in 0..e.dim { + for c in 0..dv.cols { + a.set(e.offset + r, vo + c, sw * dv.get(r, c)); + } + for c in 0..du.cols { + let cur = a.get(e.offset + r, uo + c); + a.set(e.offset + r, uo + c, cur - sw * du.get(r, c)); + } + } + } + a + } + + /// Deterministic SHA-256 hash of the full operator structure: sorted + /// vertex layout, sorted edge keys, orientation convention, and the + /// canonical hash of every restriction map. + pub fn canonical_hash(&self) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"ruvector-cohomology/operator/v1"); + h.update((self.vertices.len() as u64).to_le_bytes()); + for (id, dim, _) in &self.vertices { + h.update(id.to_le_bytes()); + h.update((*dim as u64).to_le_bytes()); + } + h.update((self.edges.len() as u64).to_le_bytes()); + for e in &self.edges { + h.update(e.key.u.to_le_bytes()); + h.update(e.key.v.to_le_bytes()); + h.update((e.dim as u64).to_le_bytes()); + h.update(e.rho_u.canonical_hash()); + h.update(e.rho_v.canonical_hash()); + } + h.finalize().into() + } +} + +/// Builder enforcing boundary validation and deterministic layout. +pub struct CoboundaryBuilder { + limits: ValidationLimits, + vertex_dims: BTreeMap, + edges: BTreeMap, Arc)>, +} + +impl CoboundaryBuilder { + /// New builder with default validation limits. + pub fn new() -> Self { + Self::with_limits(ValidationLimits::default()) + } + + /// New builder with explicit validation limits. + pub fn with_limits(limits: ValidationLimits) -> Self { + Self { limits, vertex_dims: BTreeMap::new(), edges: BTreeMap::new() } + } + + /// Declare a vertex stalk. + pub fn add_vertex(&mut self, id: VertexId, dim: usize) -> Result<&mut Self, CohomologyError> { + if dim == 0 || dim > self.limits.max_dim { + return Err(CohomologyError::ValidationFailed(format!( + "vertex {id} stalk dim {dim} outside (0, {}]", + self.limits.max_dim + ))); + } + if self.vertex_dims.insert(id, dim).is_some() { + return Err(CohomologyError::DuplicateVertex(id)); + } + Ok(self) + } + + /// Declare an edge with both endpoint restriction maps. `rho_a` restricts + /// the stalk of `a`, `rho_b` the stalk of `b`; they are stored against the + /// canonical (tail, head) orientation internally. + pub fn add_edge( + &mut self, + a: VertexId, + b: VertexId, + rho_a: RestrictionMap, + rho_b: RestrictionMap, + ) -> Result<&mut Self, CohomologyError> { + let key = EdgeKey::new(a, b)?; + let (rho_u, rho_v) = if a < b { (rho_a, rho_b) } else { (rho_b, rho_a) }; + rho_u.validate(&self.limits)?; + rho_v.validate(&self.limits)?; + let du = *self + .vertex_dims + .get(&key.u) + .ok_or(CohomologyError::UnknownVertex(key.u))?; + let dv = *self + .vertex_dims + .get(&key.v) + .ok_or(CohomologyError::UnknownVertex(key.v))?; + if rho_u.input_dim() != du { + return Err(CohomologyError::DimensionMismatch { + expected: du, + actual: rho_u.input_dim(), + context: format!("rho input dim at tail vertex {}", key.u), + }); + } + if rho_v.input_dim() != dv { + return Err(CohomologyError::DimensionMismatch { + expected: dv, + actual: rho_v.input_dim(), + context: format!("rho input dim at head vertex {}", key.v), + }); + } + if rho_u.output_dim() != rho_v.output_dim() { + return Err(CohomologyError::DimensionMismatch { + expected: rho_u.output_dim(), + actual: rho_v.output_dim(), + context: format!("edge stalk dim on ({}, {})", key.u, key.v), + }); + } + let dim = rho_u.output_dim(); + if self + .edges + .insert(key, (dim, Arc::new(rho_u), Arc::new(rho_v))) + .is_some() + { + return Err(CohomologyError::DuplicateEdge(key.u, key.v)); + } + Ok(self) + } + + /// Finalize into a deterministic block coboundary. + pub fn build(self) -> BlockCoboundary { + let mut vertices = Vec::with_capacity(self.vertex_dims.len()); + let mut vertex_pos = BTreeMap::new(); + let mut off = 0usize; + for (pos, (id, dim)) in self.vertex_dims.into_iter().enumerate() { + vertices.push((id, dim, off)); + vertex_pos.insert(id, pos); + off += dim; + } + let n0 = off; + let mut edges = Vec::with_capacity(self.edges.len()); + let mut edge_pos = BTreeMap::new(); + let mut eoff = 0usize; + for (pos, (key, (dim, rho_u, rho_v))) in self.edges.into_iter().enumerate() { + edges.push(EdgeBlock { key, dim, offset: eoff, rho_u, rho_v }); + edge_pos.insert(key, pos); + eoff += dim; + } + BlockCoboundary { vertices, vertex_pos, edges, edge_pos, n0, n1: eoff } + } +} + +impl Default for CoboundaryBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ruvector-cohomology/src/dynamic/edits.rs b/crates/ruvector-cohomology/src/dynamic/edits.rs new file mode 100644 index 0000000000..1a5a5add6d --- /dev/null +++ b/crates/ruvector-cohomology/src/dynamic/edits.rs @@ -0,0 +1,180 @@ +//! Edit classes, receipts, and estimates for the dynamic engine. + +use crate::operator::{DenseMatrix, RestrictionMap}; +use crate::{Endpoint, VertexId}; +use sha2::{Digest, Sha256}; + +/// An edit to the dynamic sheaf (issue #669 edit classes 1–8). +#[derive(Debug, Clone)] +pub enum CohomologyEdit { + /// Insert a vertex with the given stalk dimension. + InsertVertex { + /// Vertex id. + id: VertexId, + /// Stalk dimension. + dim: usize, + }, + /// Remove a vertex and all incident edges. + RemoveVertex { + /// Vertex id. + id: VertexId, + }, + /// Insert an edge with both restriction maps, weight, and observation. + InsertEdge { + /// One endpoint. + a: VertexId, + /// Other endpoint. + b: VertexId, + /// Restriction from the stalk of `a`. + rho_a: RestrictionMap, + /// Restriction from the stalk of `b`. + rho_b: RestrictionMap, + /// Confidence weight (positive). + weight: f64, + /// Observed edge relationship `b_e` (edge stalk dimension). + observation: Vec, + }, + /// Remove an edge. + RemoveEdge { + /// One endpoint. + a: VertexId, + /// Other endpoint. + b: VertexId, + }, + /// Replace one endpoint restriction map of an edge. + UpdateRestriction { + /// One endpoint. + a: VertexId, + /// Other endpoint. + b: VertexId, + /// Which endpoint (in canonical tail/head orientation). + endpoint: Endpoint, + /// The new map. + map: RestrictionMap, + }, + /// Replace the observation on an edge. + UpdateObservation { + /// One endpoint. + a: VertexId, + /// Other endpoint. + b: VertexId, + /// New observation block. + value: Vec, + }, + /// Replace the weight on an edge. + UpdateWeight { + /// One endpoint. + a: VertexId, + /// Other endpoint. + b: VertexId, + /// New positive weight. + weight: f64, + }, + /// Orthogonal frame update on a vertex: incident restriction maps are + /// transported exactly (`ρ ← ρ Rᵀ`, `R = Q_new Q_oldᵀ`) so the syndrome + /// is invariant. + UpdateFrame { + /// The vertex changing frame. + vertex: VertexId, + /// Old orthonormal frame. + q_old: DenseMatrix, + /// New orthonormal frame. + q_new: DenseMatrix, + }, +} + +/// Receipt for an applied (or rejected) edit. +#[derive(Debug, Clone)] +pub struct EditReceipt { + /// Whether the edit was accepted. + pub accepted: bool, + /// Topology epoch after the edit. + pub topology_epoch: u64, + /// Observation epoch after the edit. + pub observation_epoch: u64, + /// Cells marked dirty by this edit. + pub dirty_cells: Vec, + /// Deterministic hash of the edit content. + pub edit_hash: [u8; 32], + /// Rejection reason, if not accepted. + pub error: Option, +} + +/// Cheap estimate available without a global solve. +#[derive(Debug, Clone)] +pub struct SyndromeEstimate { + /// Sum of cached syndrome energy over currently-clean cells. + pub clean_energy: f64, + /// Number of dirty cells awaiting synchronization. + pub dirty_cell_count: usize, + /// `true` when nothing is dirty and the cache matches both epochs — the + /// estimate then equals the exact flushed energy. + pub is_exact: bool, + /// Topology epoch. + pub topology_epoch: u64, + /// Observation epoch. + pub observation_epoch: u64, +} + +/// Deterministic SHA-256 hash of an edit's content. +pub fn edit_hash(edit: &CohomologyEdit) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"ruvector-cohomology/edit/v1"); + match edit { + CohomologyEdit::InsertVertex { id, dim } => { + h.update([0u8]); + h.update(id.to_le_bytes()); + h.update((*dim as u64).to_le_bytes()); + } + CohomologyEdit::RemoveVertex { id } => { + h.update([1u8]); + h.update(id.to_le_bytes()); + } + CohomologyEdit::InsertEdge { a, b, rho_a, rho_b, weight, observation } => { + use crate::operator::LinearRestriction; + h.update([2u8]); + h.update(a.to_le_bytes()); + h.update(b.to_le_bytes()); + h.update(rho_a.canonical_hash()); + h.update(rho_b.canonical_hash()); + h.update(weight.to_bits().to_le_bytes()); + for v in observation { + h.update(v.to_bits().to_le_bytes()); + } + } + CohomologyEdit::RemoveEdge { a, b } => { + h.update([3u8]); + h.update(a.to_le_bytes()); + h.update(b.to_le_bytes()); + } + CohomologyEdit::UpdateRestriction { a, b, endpoint, map } => { + use crate::operator::LinearRestriction; + h.update([4u8]); + h.update(a.to_le_bytes()); + h.update(b.to_le_bytes()); + h.update([matches!(endpoint, Endpoint::Head) as u8]); + h.update(map.canonical_hash()); + } + CohomologyEdit::UpdateObservation { a, b, value } => { + h.update([5u8]); + h.update(a.to_le_bytes()); + h.update(b.to_le_bytes()); + for v in value { + h.update(v.to_bits().to_le_bytes()); + } + } + CohomologyEdit::UpdateWeight { a, b, weight } => { + h.update([6u8]); + h.update(a.to_le_bytes()); + h.update(b.to_le_bytes()); + h.update(weight.to_bits().to_le_bytes()); + } + CohomologyEdit::UpdateFrame { vertex, q_old, q_new } => { + h.update([7u8]); + h.update(vertex.to_le_bytes()); + q_old.hash_into(&mut h); + q_new.hash_into(&mut h); + } + } + h.finalize().into() +} diff --git a/crates/ruvector-cohomology/src/dynamic/mod.rs b/crates/ruvector-cohomology/src/dynamic/mod.rs new file mode 100644 index 0000000000..719f6b5f57 --- /dev/null +++ b/crates/ruvector-cohomology/src/dynamic/mod.rs @@ -0,0 +1,517 @@ +//! Dynamic maintenance engine (issue #669, Phase 4). +//! +//! Supports every edit class (vertex/edge insertion and deletion, +//! restriction/observation/weight updates, orthogonal frame updates) with +//! lazy cell-level dirty tracking, cheap [`SyndromeEstimate`]s between +//! synchronizations, and an exact [`DynamicCohomology::flush`] whose result +//! is equivalent to fresh batch assembly within solver tolerance. + +mod edits; + +pub use edits::{CohomologyEdit, EditReceipt, SyndromeEstimate}; + +use crate::affine::{AffineSheafSystem, EdgeField, EdgeWeights}; +use crate::block_sparse::CoboundaryBuilder; +use crate::operator::{LinearRestriction, RestrictionMap}; +use crate::partition::CellPartition; +use crate::repair::{propose_repair, EdgeCost, RepairPlan, RepairPolicy}; +use crate::syndrome::{compute_syndrome_warm, SyndromeConfig, SyndromeResult}; +use crate::transport::{frame_change, transport_restriction}; +use crate::witness::quantize; +use crate::{CohomologyError, EdgeKey, Endpoint, GaugePolicy, ValidationLimits, VertexId}; +use std::collections::{BTreeMap, BTreeSet}; + +/// The dynamic cohomology interface (issue #669 core interface). +pub trait DynamicCohomology { + /// Apply one edit, marking incident cells dirty. + fn apply_edit(&mut self, edit: CohomologyEdit) -> EditReceipt; + /// Cheap estimate from cached cell contributions. + fn estimate(&self) -> SyndromeEstimate; + /// Exact synchronization: equivalent to fresh batch assembly within + /// solver tolerance. + fn flush(&mut self) -> Result; + /// Propose a group-sparse repair for the current contradiction. + fn propose_repair(&mut self, policy: RepairPolicy) -> Result; +} + +/// Engine configuration. +#[derive(Debug, Clone)] +pub struct EngineConfig { + /// Boundary validation limits. + pub limits: ValidationLimits, + /// Syndrome/solver configuration. + pub syndrome: SyndromeConfig, + /// Bounded cell capacity (vertices per cell). + pub cell_capacity: usize, + /// Rebuild the partition after this many structural edits (bounds + /// fragmentation drift under long edit streams). + pub rebuild_interval: u64, +} + +impl Default for EngineConfig { + fn default() -> Self { + Self { + limits: ValidationLimits::default(), + syndrome: SyndromeConfig::default(), + cell_capacity: 64, + rebuild_interval: 4096, + } + } +} + +#[derive(Clone)] +struct EdgeState { + rho_u: RestrictionMap, + rho_v: RestrictionMap, + weight: f64, + observation: Vec, + cost: EdgeCost, + protected: bool, +} + +struct FlushCache { + topology_epoch: u64, + observation_epoch: u64, + x_star: Vec, + energy: f64, + /// Per-cell energy attribution (edge energy attributed to the tail cell). + cell_energy: Vec, +} + +/// Dynamic sheaf engine with bounded-cell dirty tracking. +pub struct DynamicSheafEngine { + config: EngineConfig, + vertices: BTreeMap, + edges: BTreeMap, + partition: CellPartition, + dirty: BTreeSet, + topology_epoch: u64, + observation_epoch: u64, + structural_edits: u64, + cache: Option, +} + +impl DynamicSheafEngine { + /// New empty engine. + pub fn new(config: EngineConfig) -> Self { + let capacity = config.cell_capacity; + Self { + config, + vertices: BTreeMap::new(), + edges: BTreeMap::new(), + partition: CellPartition::build(std::iter::empty(), capacity), + dirty: BTreeSet::new(), + topology_epoch: 0, + observation_epoch: 0, + structural_edits: 0, + cache: None, + } + } + + /// Current topology epoch. + pub fn topology_epoch(&self) -> u64 { + self.topology_epoch + } + + /// Current observation epoch. + pub fn observation_epoch(&self) -> u64 { + self.observation_epoch + } + + /// Number of vertices. + pub fn vertex_count(&self) -> usize { + self.vertices.len() + } + + /// Number of edges. + pub fn edge_count(&self) -> usize { + self.edges.len() + } + + /// Set the repair cost of an edge (outside the edit stream: costs are + /// policy metadata, not sheaf data). + pub fn set_edge_cost(&mut self, key: &EdgeKey, cost: EdgeCost) -> Result<(), CohomologyError> { + self.edges + .get_mut(key) + .map(|e| e.cost = cost) + .ok_or(CohomologyError::UnknownEdge(key.u, key.v)) + } + + /// Mark an edge protected (immutable to repair without authorization). + pub fn set_protected(&mut self, key: &EdgeKey, protected: bool) -> Result<(), CohomologyError> { + self.edges + .get_mut(key) + .map(|e| e.protected = protected) + .ok_or(CohomologyError::UnknownEdge(key.u, key.v)) + } + + /// Per-edge costs in canonical order (for the repair engine). + pub fn edge_costs(&self) -> Vec { + self.edges.values().map(|e| e.cost).collect() + } + + /// Per-edge protected flags in canonical order. + pub fn protected_mask(&self) -> Vec { + self.edges.values().map(|e| e.protected).collect() + } + + /// Assemble the current state into a batch [`AffineSheafSystem`] + /// (deterministic: BTreeMap order is canonical order). + pub fn build_system(&self) -> Result { + let mut builder = CoboundaryBuilder::with_limits(self.config.limits.clone()); + for (&id, &dim) in &self.vertices { + builder.add_vertex(id, dim)?; + } + for (key, state) in &self.edges { + builder.add_edge(key.u, key.v, state.rho_u.clone(), state.rho_v.clone())?; + } + let op = builder.build(); + let mut observations = vec![0.0; op.dim_c1()]; + let mut weights = Vec::with_capacity(self.edges.len()); + for (key, state) in &self.edges { + let (off, dim) = op.edge_slice(key).expect("edge indexed"); + observations[off..off + dim].copy_from_slice(&state.observation); + weights.push(state.weight); + } + let mut system = AffineSheafSystem::new( + op, + EdgeField { data: observations }, + EdgeWeights { values: weights }, + GaugePolicy::MinimumNorm, + )?; + system.topology_epoch = self.topology_epoch; + system.observation_epoch = self.observation_epoch; + Ok(system) + } + + fn mark_dirty_vertex(&mut self, v: VertexId, out: &mut Vec) { + if let Some(c) = self.partition.cell_of(v) { + if self.dirty.insert(c) || !out.contains(&c) { + out.push(c); + } + } + } + + fn try_apply(&mut self, edit: &CohomologyEdit, dirty: &mut Vec) -> Result<(), CohomologyError> { + match edit { + CohomologyEdit::InsertVertex { id, dim } => { + if self.vertices.contains_key(id) { + return Err(CohomologyError::DuplicateVertex(*id)); + } + if *dim == 0 || *dim > self.config.limits.max_dim { + return Err(CohomologyError::ValidationFailed(format!( + "vertex stalk dim {dim} outside (0, {}]", + self.config.limits.max_dim + ))); + } + self.vertices.insert(*id, *dim); + let cell = self.partition.insert_vertex(*id); + self.dirty.insert(cell); + dirty.push(cell); + self.topology_epoch += 1; + self.structural_edits += 1; + } + CohomologyEdit::RemoveVertex { id } => { + if !self.vertices.contains_key(id) { + return Err(CohomologyError::UnknownVertex(*id)); + } + let incident: Vec = self + .edges + .keys() + .filter(|k| k.u == *id || k.v == *id) + .copied() + .collect(); + for key in incident { + self.mark_dirty_vertex(key.u, dirty); + self.mark_dirty_vertex(key.v, dirty); + self.edges.remove(&key); + } + self.mark_dirty_vertex(*id, dirty); + self.vertices.remove(id); + self.partition.remove_vertex(*id); + self.topology_epoch += 1; + self.structural_edits += 1; + } + CohomologyEdit::InsertEdge { a, b, rho_a, rho_b, weight, observation } => { + let key = EdgeKey::new(*a, *b)?; + if self.edges.contains_key(&key) { + return Err(CohomologyError::DuplicateEdge(key.u, key.v)); + } + let da = *self.vertices.get(a).ok_or(CohomologyError::UnknownVertex(*a))?; + let db = *self.vertices.get(b).ok_or(CohomologyError::UnknownVertex(*b))?; + rho_a.validate(&self.config.limits)?; + rho_b.validate(&self.config.limits)?; + if rho_a.input_dim() != da || rho_b.input_dim() != db + || rho_a.output_dim() != rho_b.output_dim() + { + return Err(CohomologyError::DimensionMismatch { + expected: da, + actual: rho_a.input_dim(), + context: format!("edge ({a}, {b}) restriction dims"), + }); + } + if observation.len() != rho_a.output_dim() { + return Err(CohomologyError::DimensionMismatch { + expected: rho_a.output_dim(), + actual: observation.len(), + context: format!("edge ({a}, {b}) observation"), + }); + } + if !weight.is_finite() || *weight <= 0.0 || observation.iter().any(|v| !v.is_finite()) + { + return Err(CohomologyError::ValidationFailed( + "edge weight must be positive/finite and observation finite".into(), + )); + } + let (rho_u, rho_v) = if a < b { + (rho_a.clone(), rho_b.clone()) + } else { + (rho_b.clone(), rho_a.clone()) + }; + self.edges.insert( + key, + EdgeState { + rho_u, + rho_v, + weight: *weight, + observation: observation.clone(), + cost: EdgeCost::default(), + protected: false, + }, + ); + self.mark_dirty_vertex(key.u, dirty); + self.mark_dirty_vertex(key.v, dirty); + self.topology_epoch += 1; + self.structural_edits += 1; + } + CohomologyEdit::RemoveEdge { a, b } => { + let key = EdgeKey::new(*a, *b)?; + if self.edges.remove(&key).is_none() { + return Err(CohomologyError::UnknownEdge(key.u, key.v)); + } + self.mark_dirty_vertex(key.u, dirty); + self.mark_dirty_vertex(key.v, dirty); + self.topology_epoch += 1; + self.structural_edits += 1; + } + CohomologyEdit::UpdateRestriction { a, b, endpoint, map } => { + let key = EdgeKey::new(*a, *b)?; + map.validate(&self.config.limits)?; + let vertex_dim = |v: VertexId, s: &Self| { + s.vertices.get(&v).copied().ok_or(CohomologyError::UnknownVertex(v)) + }; + let (du, dv) = (vertex_dim(key.u, self)?, vertex_dim(key.v, self)?); + let state = self + .edges + .get_mut(&key) + .ok_or(CohomologyError::UnknownEdge(key.u, key.v))?; + let (expected_in, slot_dim) = match endpoint { + Endpoint::Tail => (du, state.rho_u.output_dim()), + Endpoint::Head => (dv, state.rho_v.output_dim()), + }; + if map.input_dim() != expected_in || map.output_dim() != slot_dim { + return Err(CohomologyError::DimensionMismatch { + expected: expected_in, + actual: map.input_dim(), + context: format!("restriction update on ({}, {})", key.u, key.v), + }); + } + match endpoint { + Endpoint::Tail => state.rho_u = map.clone(), + Endpoint::Head => state.rho_v = map.clone(), + } + self.mark_dirty_vertex(key.u, dirty); + self.mark_dirty_vertex(key.v, dirty); + self.topology_epoch += 1; + } + CohomologyEdit::UpdateObservation { a, b, value } => { + let key = EdgeKey::new(*a, *b)?; + let state = self + .edges + .get_mut(&key) + .ok_or(CohomologyError::UnknownEdge(key.u, key.v))?; + if value.len() != state.observation.len() { + return Err(CohomologyError::DimensionMismatch { + expected: state.observation.len(), + actual: value.len(), + context: format!("observation update on ({}, {})", key.u, key.v), + }); + } + if value.iter().any(|v| !v.is_finite()) { + return Err(CohomologyError::ValidationFailed( + "observation contains non-finite values".into(), + )); + } + state.observation = value.clone(); + self.mark_dirty_vertex(key.u, dirty); + self.mark_dirty_vertex(key.v, dirty); + self.observation_epoch += 1; + } + CohomologyEdit::UpdateWeight { a, b, weight } => { + let key = EdgeKey::new(*a, *b)?; + if !weight.is_finite() || *weight <= 0.0 { + return Err(CohomologyError::ValidationFailed(format!( + "weight {weight} must be finite and positive" + ))); + } + let state = self + .edges + .get_mut(&key) + .ok_or(CohomologyError::UnknownEdge(key.u, key.v))?; + state.weight = *weight; + self.mark_dirty_vertex(key.u, dirty); + self.mark_dirty_vertex(key.v, dirty); + self.observation_epoch += 1; + } + CohomologyEdit::UpdateFrame { vertex, q_old, q_new } => { + let dim = *self + .vertices + .get(vertex) + .ok_or(CohomologyError::UnknownVertex(*vertex))?; + if q_old.rows != dim { + return Err(CohomologyError::DimensionMismatch { + expected: dim, + actual: q_old.rows, + context: format!("frame update on vertex {vertex}"), + }); + } + let r = frame_change(q_old, q_new, &self.config.limits)?; + let incident: Vec = self + .edges + .keys() + .filter(|k| k.u == *vertex || k.v == *vertex) + .copied() + .collect(); + // Validate all transports before mutating any (atomicity). + let mut updates = Vec::with_capacity(incident.len()); + for key in &incident { + let state = self.edges.get(key).expect("incident edge"); + let new_map = if key.u == *vertex { + transport_restriction(&state.rho_u, &r)? + } else { + transport_restriction(&state.rho_v, &r)? + }; + updates.push((*key, new_map)); + } + for (key, new_map) in updates { + let state = self.edges.get_mut(&key).expect("incident edge"); + if key.u == *vertex { + state.rho_u = new_map; + } else { + state.rho_v = new_map; + } + self.mark_dirty_vertex(key.u, dirty); + self.mark_dirty_vertex(key.v, dirty); + } + self.mark_dirty_vertex(*vertex, dirty); + self.topology_epoch += 1; + } + } + Ok(()) + } +} + +impl DynamicCohomology for DynamicSheafEngine { + fn apply_edit(&mut self, edit: CohomologyEdit) -> EditReceipt { + let edit_hash = edits::edit_hash(&edit); + let mut dirty = Vec::new(); + let result = self.try_apply(&edit, &mut dirty); + dirty.sort_unstable(); + dirty.dedup(); + EditReceipt { + accepted: result.is_ok(), + topology_epoch: self.topology_epoch, + observation_epoch: self.observation_epoch, + dirty_cells: dirty, + edit_hash, + error: result.err().map(|e| e.to_string()), + } + } + + fn estimate(&self) -> SyndromeEstimate { + let (clean_energy, is_exact) = match &self.cache { + Some(cache) => { + let fresh = cache.topology_epoch == self.topology_epoch + && cache.observation_epoch == self.observation_epoch + && self.dirty.is_empty(); + if fresh { + (cache.energy, true) + } else { + let clean: f64 = cache + .cell_energy + .iter() + .enumerate() + .filter(|(c, _)| !self.dirty.contains(c)) + .map(|(_, e)| e) + .sum(); + (clean, false) + } + } + None => (0.0, self.edges.is_empty() && self.dirty.is_empty()), + }; + SyndromeEstimate { + clean_energy, + dirty_cell_count: self.dirty.len(), + is_exact, + topology_epoch: self.topology_epoch, + observation_epoch: self.observation_epoch, + } + } + + fn flush(&mut self) -> Result { + // Bound partition fragmentation drift under long edit streams. + if self.structural_edits >= self.config.rebuild_interval { + self.partition = CellPartition::build( + self.vertices.keys().copied(), + self.config.cell_capacity, + ); + self.structural_edits = 0; + self.cache = None; // cell attribution layout changed + } + let system = self.build_system()?; + let warm = self + .cache + .as_ref() + .filter(|c| c.topology_epoch == self.topology_epoch) + .map(|c| c.x_star.as_slice()); + let result = compute_syndrome_warm(&system, &self.config.syndrome, warm)?; + + // Attribute per-edge syndrome energy to the tail vertex's cell. + let mut cell_energy = vec![0.0; self.partition.cell_count()]; + for contribution in &result.ranked_support { + if let Some(cell) = self.partition.cell_of(contribution.edge.u) { + cell_energy[cell] += contribution.energy; + } + } + self.cache = Some(FlushCache { + topology_epoch: self.topology_epoch, + observation_epoch: self.observation_epoch, + x_star: result.x_star.clone(), + energy: result.energy, + cell_energy, + }); + self.dirty.clear(); + // Numerical drift guard: quantized cached energy must round-trip. + let scale = self.config.syndrome.quantization_scale; + debug_assert_eq!( + quantize(result.energy, scale), + quantize(self.cache.as_ref().expect("cache just set").energy, scale) + ); + Ok(result) + } + + fn propose_repair(&mut self, policy: RepairPolicy) -> Result { + let syndrome = self.flush()?; + let system = self.build_system()?; + let costs = self.edge_costs(); + let protected = self.protected_mask(); + propose_repair( + &system, + &syndrome, + Some(&costs), + Some(&protected), + &policy, + &self.config.syndrome, + ) + } +} diff --git a/crates/ruvector-cohomology/src/harmonic.rs b/crates/ruvector-cohomology/src/harmonic.rs new file mode 100644 index 0000000000..0593c6b603 --- /dev/null +++ b/crates/ruvector-cohomology/src/harmonic.rs @@ -0,0 +1,189 @@ +//! Harmonic (contradiction) subspace extraction and canonicalization. +//! +//! The harmonic space is `(im δ)^{⊥_W} ⊂ C¹`, expressed in +//! `W^{1/2}`-scaled coordinates as `ker(Aᵀ)` for `A = W^{1/2}δ`. Its +//! coordinates give the compact contradiction witness. Degenerate subspaces +//! are canonicalized deterministically with QR against a hashed probe basis +//! (issue #669, §7 canonicalization rules). + +use crate::block_sparse::BlockCoboundary; +use crate::operator::DenseMatrix; +use crate::solvers::lobpcg::smallest_eigenpairs; +use crate::solvers::sparse_qr::PivotedQr; +use crate::solvers::{CommittedSeed, ResidualCertificate, SolverConfig}; + +/// Canonical orthonormal basis of the harmonic subspace, in scaled +/// (`W^{1/2}`) edge coordinates. +pub struct HarmonicBasis { + /// `dim_c1 × harmonic_dim` orthonormal columns. + pub basis: DenseMatrix, + /// Numerical rank of `W^{1/2}δ`. + pub rank: usize, +} + +impl HarmonicBasis { + /// Exact dense-oracle path: rank-revealing QR of `A = W^{1/2}δ`, null + /// space of `Aᵀ`, canonicalized against a probe derived from + /// `operator_hash` so the basis is independent of factorization order. + pub fn dense( + op: &BlockCoboundary, + weights: &[f64], + rank_tol_rel: f64, + ) -> Self { + let a = op.assemble_scaled_dense(weights); + let qr = PivotedQr::factor(&a, rank_tol_rel); + let rank = qr.rank(); + let raw = qr.null_space_of_transpose(); + let basis = canonicalize_subspace(&raw, op.canonical_hash()); + Self { basis, rank } + } + + /// Coordinates of a scaled edge field in this basis: `Bᵀ (W^{1/2} s)`. + pub fn coordinates(&self, scaled_field: &[f64]) -> Vec { + let mut coords = vec![0.0; self.basis.cols]; + for c in 0..self.basis.cols { + let mut acc = 0.0; + for r in 0..self.basis.rows { + acc += self.basis.get(r, c) * scaled_field[r]; + } + coords[c] = acc; + } + coords + } +} + +/// Canonicalize a subspace given *any* orthonormal basis of it: project a +/// deterministic hashed probe onto the subspace and re-orthonormalize with +/// modified Gram–Schmidt, then fix signs by the first component of +/// significant magnitude. The result depends only on the subspace and the +/// probe seed, not on the incoming basis or factorization order. +pub fn canonicalize_subspace(raw: &DenseMatrix, seed_hash: [u8; 32]) -> DenseMatrix { + let m = raw.rows; + let h = raw.cols; + if h == 0 { + return raw.clone(); + } + let mut seed_bytes = [0u8; 8]; + seed_bytes.copy_from_slice(&seed_hash[..8]); + let mut rng = CommittedSeed(u64::from_le_bytes(seed_bytes)); + + // Probe columns projected into the subspace: C = B (Bᵀ P). + let mut cols: Vec> = Vec::with_capacity(h); + for _ in 0..h { + let probe: Vec = (0..m).map(|_| rng.next_signed_unit()).collect(); + // coeff = Bᵀ p + let mut coeff = vec![0.0; h]; + for c in 0..h { + let mut acc = 0.0; + for r in 0..m { + acc += raw.get(r, c) * probe[r]; + } + coeff[c] = acc; + } + // col = B coeff + let mut col = vec![0.0; m]; + for c in 0..h { + let cc = coeff[c]; + if cc != 0.0 { + for (r, cv) in col.iter_mut().enumerate() { + *cv += raw.get(r, c) * cc; + } + } + } + cols.push(col); + } + + // Modified Gram–Schmidt; a probe that degenerates is replaced by the + // corresponding raw basis column to keep the dimension intact. + let mut out: Vec> = Vec::with_capacity(h); + let mut fallback = 0usize; + for mut col in cols { + loop { + for prev in &out { + let d: f64 = prev.iter().zip(col.iter()).map(|(a, b)| a * b).sum(); + for (c, p) in col.iter_mut().zip(prev.iter()) { + *c -= d * p; + } + } + let n: f64 = col.iter().map(|v| v * v).sum::().sqrt(); + if n > 1e-10 { + for c in col.iter_mut() { + *c /= n; + } + break; + } + // Degenerate probe: fall back to the next raw column. + if fallback >= h { + break; + } + col = (0..m).map(|r| raw.get(r, fallback)).collect(); + fallback += 1; + } + out.push(col); + } + + // Sign canonicalization: first component with |v| > tol made positive. + let mut basis = DenseMatrix::zeros(m, h); + for (c, col) in out.iter().enumerate() { + let tol = 1e-12; + let sign = col + .iter() + .find(|v| v.abs() > tol) + .map(|v| if *v < 0.0 { -1.0 } else { 1.0 }) + .unwrap_or(1.0); + for (r, v) in col.iter().enumerate() { + basis.set(r, c, sign * v); + } + } + basis +} + +/// Spectral summary of the sheaf Laplacian obtained with LOBPCG — never +/// dominant power iteration (issue #669, implementation gap 3). +#[derive(Debug, Clone)] +pub struct SpectralSummary { + /// Smallest computed eigenvalues, ascending. + pub smallest_eigenvalues: Vec, + /// Estimated nullity (eigenvalues below `nullity_tol`). + pub nullity_estimate: usize, + /// Smallest eigenvalue above the nullity tolerance, if any (spectral gap). + pub spectral_gap: Option, + /// Residual certificate from the eigensolver. + pub certificate: ResidualCertificate, +} + +/// Compute the spectral summary of `L = δᵀWδ` for `H⁰` analysis: the +/// nullity of `L` is `dim H⁰ = dim ker δ` (the space of global sections). +pub fn spectral_summary( + op: &BlockCoboundary, + weights: &[f64], + k: usize, + seed: u64, + nullity_tol: f64, + cfg: &SolverConfig, +) -> SpectralSummary { + let n = op.dim_c0(); + let result = smallest_eigenpairs( + |x, y| op.apply_weighted_laplacian(weights, x, y), + n, + k.min(n), + seed, + cfg, + ); + let nullity = result + .eigenvalues + .iter() + .filter(|&&ev| ev.abs() < nullity_tol) + .count(); + let gap = result + .eigenvalues + .iter() + .find(|&&ev| ev.abs() >= nullity_tol) + .copied(); + SpectralSummary { + smallest_eigenvalues: result.eigenvalues, + nullity_estimate: nullity, + spectral_gap: gap, + certificate: result.certificate, + } +} diff --git a/crates/ruvector-cohomology/src/lib.rs b/crates/ruvector-cohomology/src/lib.rs new file mode 100644 index 0000000000..cf700fdce1 --- /dev/null +++ b/crates/ruvector-cohomology/src/lib.rs @@ -0,0 +1,218 @@ +//! # Dynamic Cohomological Error Correction +//! +//! Semantic syndrome extraction, sparse repair, and mincut containment over +//! dynamic cellular sheaves (RFC: ruvnet/RuVector#669). +//! +//! ## Mathematical model +//! +//! A cellular sheaf on a graph `G = (V, E)` assigns a vector space (stalk) to +//! every vertex and edge, and restriction maps `ρ_{v→e}: F(v) → F(e)`. For an +//! oriented edge `e = (u, v)` the coboundary operator is +//! +//! ```text +//! (δx)_e = ρ_{v→e} x_v − ρ_{u→e} x_u +//! ``` +//! +//! The correct cohomological semantics on a graph are: +//! +//! - `H⁰(G; F) = ker δ` — the space of **global sections**. For a linear +//! sheaf the zero section is always global, so `H⁰` is never "empty"; +//! nontrivial `H⁰` means nonzero globally consistent assignments exist. +//! - `H¹(G; F) = C¹ / im δ` — edge data that cannot be written as the +//! coboundary of any vertex assignment. Nontrivial `H¹` does **not** mean +//! "no global section exists"; it means some edge observations are +//! unexplainable by every vertex assignment. +//! +//! Production consistency is therefore an **affine** problem. Given observed +//! edge relationships `b ∈ C¹` and confidence weights `W`, solve +//! +//! ```text +//! x* = argmin_x ‖W^{1/2}(δx − b)‖² +//! ``` +//! +//! The irreducible contradiction is the weighted **syndrome** +//! +//! ```text +//! s = b − δ (δᵀWδ)† δᵀW b = b − δx* +//! ``` +//! +//! `‖s‖_W = 0` means a globally coherent explanation exists; `‖s‖_W > 0` +//! certifies an irreducible contradiction whose support localizes the +//! responsible constraints. +//! +//! ## Crate layout +//! +//! - [`operator`]: serializable linear restriction operators (no closures) +//! - [`block_sparse`]: deterministic block-sparse coboundary and sheaf Laplacian +//! - [`affine`]: observations, weights, gauge — the affine sheaf system +//! - [`syndrome`]: weighted least-squares syndrome engine with certificates +//! - [`harmonic`]: harmonic (contradiction) subspace and canonicalization +//! - [`repair`]: group-sparse semantic repair with cost and protection policy +//! - [`dynamic`]: bounded-cell dynamic maintenance with exact `flush()` +//! - [`partition`]: deterministic bounded cell partition +//! - [`witness`]: canonical, quantized, SHA-256-hashed contradiction witnesses +//! - [`transport`]: orthogonal frame transport and cycle-consistency guards +//! - [`mincut_bridge`]: syndrome→tension conversion and quarantine boundaries +//! - [`solvers`]: CG, MINRES, LOBPCG, rank-revealing QR, group-sparse ADMM +//! +//! ## Determinism +//! +//! All indexing is derived from sorted identifiers, all solvers are +//! deterministic (fixed iteration order, committed seeds), and witnesses store +//! explicitly quantized values, so repeated runs — and any parallel driver +//! that merges results by canonical key order — produce identical witness +//! hashes. + +// Dense numeric kernels use index loops over multiple coupled buffers where +// iterator chains obscure the linear algebra; the bounds are asserted. +#![allow(clippy::needless_range_loop)] + +pub mod affine; +pub mod block_sparse; +pub mod dynamic; +pub mod harmonic; +pub mod mincut_bridge; +pub mod operator; +pub mod partition; +pub mod repair; +pub mod solvers; +pub mod syndrome; +pub mod transport; +pub mod witness; + +use serde::{Deserialize, Serialize}; + +/// Vertex identifier. Deterministic layouts sort by this id. +pub type VertexId = u64; + +/// Canonical undirected edge key. Orientation is always from the lower id +/// (`u`, tail) to the higher id (`v`, head): `(δx)_e = ρ_v x_v − ρ_u x_u`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct EdgeKey { + /// Tail vertex (lower id). + pub u: VertexId, + /// Head vertex (higher id). + pub v: VertexId, +} + +impl EdgeKey { + /// Create a canonical edge key. Returns an error for self-loops. + pub fn new(a: VertexId, b: VertexId) -> Result { + if a == b { + return Err(CohomologyError::InvalidValue(format!( + "self-loop on vertex {a} is not a valid 1-cell" + ))); + } + Ok(if a < b { Self { u: a, v: b } } else { Self { u: b, v: a } }) + } + + /// Endpoint of the edge (tail = lower id). + pub fn endpoints(&self) -> (VertexId, VertexId) { + (self.u, self.v) + } +} + +/// Which endpoint of an edge a restriction map belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Endpoint { + /// The tail (lower vertex id). + Tail, + /// The head (higher vertex id). + Head, +} + +/// Gauge policy fixing the representative of the least-squares solution. +/// +/// The syndrome `s = b − δx*` is gauge-invariant; the gauge only pins `x*` +/// inside `x* + ker δ` so that witnesses are reproducible. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum GaugePolicy { + /// Minimum-norm (Moore–Penrose) solution. CG started from the zero + /// vector converges to this representative because every Krylov iterate + /// stays in `range(L)`. + #[default] + MinimumNorm, +} + +/// Validation limits enforced at every system boundary before allocation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationLimits { + /// Maximum stalk dimension accepted for a vertex or edge. + pub max_dim: usize, + /// Maximum accepted operator norm bound for a restriction map. + pub max_operator_norm: f64, + /// Tolerance for orthogonality validation of frames and orthogonal maps. + pub orthogonality_tol: f64, + /// Maximum accepted absolute value for weights and observations. + pub max_value: f64, +} + +impl Default for ValidationLimits { + fn default() -> Self { + Self { + max_dim: 4096, + max_operator_norm: 1e6, + orthogonality_tol: 1e-8, + max_value: 1e12, + } + } +} + +/// Errors produced by the cohomology engine. +#[derive(Debug, thiserror::Error)] +pub enum CohomologyError { + /// A dimension did not match the declared stalk layout. + #[error("dimension mismatch in {context}: expected {expected}, got {actual}")] + DimensionMismatch { + /// Expected dimension. + expected: usize, + /// Actual dimension. + actual: usize, + /// Human-readable location of the mismatch. + context: String, + }, + /// Referenced vertex does not exist. + #[error("unknown vertex {0}")] + UnknownVertex(VertexId), + /// Referenced edge does not exist. + #[error("unknown edge ({0}, {1})")] + UnknownEdge(VertexId, VertexId), + /// Vertex already exists. + #[error("duplicate vertex {0}")] + DuplicateVertex(VertexId), + /// Edge already exists. + #[error("duplicate edge ({0}, {1})")] + DuplicateEdge(VertexId, VertexId), + /// A value failed boundary validation. + #[error("invalid value: {0}")] + InvalidValue(String), + /// A structural validation failed (norm, orthogonality, sparsity, size). + #[error("validation failed: {0}")] + ValidationFailed(String), + /// A solver failed to reach its tolerance. + #[error("solver failure: {0}")] + SolverFailure(String), + /// Modification of a protected edge without authorization. + #[error("edge ({0}, {1}) is protected and the policy does not authorize modification")] + ProtectedEdge(VertexId, VertexId), + /// Operation requires a flushed (synchronized) state. + #[error("operation requires flush(): {0}")] + NotFlushed(String), +} + +pub use affine::{AffineSheafSystem, EdgeField, EdgeWeights}; +pub use block_sparse::{BlockCoboundary, CoboundaryBuilder}; +pub use dynamic::{ + CohomologyEdit, DynamicCohomology, DynamicSheafEngine, EditReceipt, EngineConfig, + SyndromeEstimate, +}; +pub use harmonic::{HarmonicBasis, SpectralSummary}; +pub use mincut_bridge::{ + ContainmentPolicy, InterventionReceipt, QuarantineBoundary, TensionGraph, TensionWeights, +}; +pub use operator::{DenseMatrix, LinearRestriction, RestrictionMap}; +pub use partition::CellPartition; +pub use repair::{EdgeCost, RepairPlan, RepairPolicy}; +pub use solvers::{ResidualCertificate, SolverConfig}; +pub use syndrome::{compute_syndrome, compute_syndrome_dense, EdgeContribution, SyndromeResult}; +pub use witness::CohomologyWitness; diff --git a/crates/ruvector-cohomology/src/mincut_bridge.rs b/crates/ruvector-cohomology/src/mincut_bridge.rs new file mode 100644 index 0000000000..11ba73771a --- /dev/null +++ b/crates/ruvector-cohomology/src/mincut_bridge.rs @@ -0,0 +1,449 @@ +//! Dynamic mincut coupling (issue #669, Phase 5). +//! +//! Cohomology diagnoses contradiction; mincut chooses a low-cost isolation +//! boundary. This module converts syndrome support into per-edge tension, +//! computes the cheapest quarantine boundary separating contaminated seeds +//! from the healthy region (deterministic internal max-flow; optionally the +//! `ruvector-mincut` global engine behind the `mincut` feature), runs sparse +//! repair, verifies the post-intervention syndrome, and signs a combined +//! intervention receipt. + +use crate::dynamic::{DynamicCohomology, DynamicSheafEngine}; +use crate::repair::{RepairPlan, RepairPolicy}; +use crate::syndrome::SyndromeResult; +use crate::witness::quantize; +use crate::{CohomologyError, EdgeKey, VertexId}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +/// Mixing weights for contradiction tension +/// `τ_e = α‖s_e‖ + β·leverage + γ·uncertainty + ζ·policy_severity`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct TensionWeights { + /// α — syndrome magnitude weight. + pub alpha: f64, + /// β — statistical leverage weight. + pub beta: f64, + /// γ — uncertainty / drift weight. + pub gamma: f64, + /// ζ — policy severity weight. + pub zeta: f64, +} + +impl Default for TensionWeights { + fn default() -> Self { + Self { alpha: 1.0, beta: 1.0, gamma: 0.5, zeta: 1.0 } + } +} + +/// One edge of the tension/quarantine graph. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TensionEdge { + /// The underlying sheaf edge. + pub edge: EdgeKey, + /// Contradiction tension τ_e (how implicated the edge is). + pub tension: f64, + /// Quarantine cost k_e (how expensive cutting the edge is). + pub quarantine_cost: f64, +} + +/// Tension graph derived from a syndrome. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TensionGraph { + /// Edges in canonical order. + pub edges: Vec, +} + +impl TensionGraph { + /// Derive per-edge tension from a syndrome result. + /// + /// `uncertainty` and `severity` are optional per-edge signals in + /// canonical order (map drift and policy severity); quarantine cost + /// defaults to `1 + tension`-independent unit cost unless provided. + pub fn from_syndrome( + syndrome: &SyndromeResult, + weights: &TensionWeights, + uncertainty: Option<&[f64]>, + severity: Option<&[f64]>, + quarantine_costs: Option<&[f64]>, + ) -> Self { + // ranked_support is sorted by energy; rebuild canonical (key) order. + let mut by_key: Vec<&crate::syndrome::EdgeContribution> = + syndrome.ranked_support.iter().collect(); + by_key.sort_by_key(|c| c.edge); + let edges = by_key + .iter() + .enumerate() + .map(|(i, c)| { + let magnitude = c.energy.sqrt(); + let leverage = c.fraction; + let u = uncertainty.map(|s| s[i]).unwrap_or(0.0); + let p = severity.map(|s| s[i]).unwrap_or(0.0); + let tension = weights.alpha * magnitude + + weights.beta * leverage + + weights.gamma * u + + weights.zeta * p; + let quarantine_cost = quarantine_costs.map(|s| s[i]).unwrap_or(1.0); + TensionEdge { edge: c.edge, tension, quarantine_cost } + }) + .collect(); + Self { edges } + } + + /// Vertices whose incident tension exceeds `threshold` — the + /// contamination seed set, canonically sorted. + pub fn contaminated_seeds(&self, threshold: f64) -> Vec { + let mut incident: BTreeMap = BTreeMap::new(); + for e in &self.edges { + *incident.entry(e.edge.u).or_default() += e.tension; + *incident.entry(e.edge.v).or_default() += e.tension; + } + incident + .into_iter() + .filter(|(_, t)| *t > threshold) + .map(|(v, _)| v) + .collect() + } +} + +/// A quarantine boundary: the cut and the isolated region. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuarantineBoundary { + /// Cut edges, canonically sorted. + pub cut_edges: Vec, + /// Vertices inside the quarantined (contaminated-side) region, sorted. + pub isolated_vertices: Vec, + /// Total quarantine cost of the cut. + pub total_cost: f64, +} + +/// Deterministic Dinic max-flow / min-cut on the quarantine-cost graph, +/// separating `seeds` (contaminated) from all vertices *not* reachable +/// through them. Every non-seed vertex incident to zero-tension structure +/// acts as sink side via a virtual super-sink over `healthy` vertices. +pub fn quarantine_boundary( + graph: &TensionGraph, + seeds: &[VertexId], + healthy: &[VertexId], +) -> Result { + if seeds.is_empty() { + return Ok(QuarantineBoundary { + cut_edges: Vec::new(), + isolated_vertices: Vec::new(), + total_cost: 0.0, + }); + } + if healthy.iter().any(|h| seeds.contains(h)) { + return Err(CohomologyError::InvalidValue( + "healthy set intersects contaminated seeds".into(), + )); + } + + // Deterministic vertex indexing. + let mut ids: Vec = graph + .edges + .iter() + .flat_map(|e| [e.edge.u, e.edge.v]) + .chain(seeds.iter().copied()) + .chain(healthy.iter().copied()) + .collect(); + ids.sort_unstable(); + ids.dedup(); + let index: BTreeMap = ids.iter().enumerate().map(|(i, &v)| (v, i)).collect(); + let n = ids.len() + 2; + let source = ids.len(); + let sink = ids.len() + 1; + + // Dinic structures. + struct Arc { + to: usize, + cap: f64, + rev: usize, + } + let mut adj: Vec> = (0..n).map(|_| Vec::new()).collect(); + let add_edge = |adj: &mut Vec>, a: usize, b: usize, cap: f64| { + let ra = adj[b].len(); + let rb = adj[a].len(); + adj[a].push(Arc { to: b, cap, rev: ra }); + adj[b].push(Arc { to: a, cap, rev: rb }); + }; + + const INF: f64 = 1e30; + for e in &graph.edges { + let a = index[&e.edge.u]; + let b = index[&e.edge.v]; + // Undirected edge with capacity = quarantine cost, in both arcs. + add_edge(&mut adj, a, b, e.quarantine_cost.max(0.0)); + add_edge(&mut adj, b, a, e.quarantine_cost.max(0.0)); + } + for s in seeds { + add_edge(&mut adj, source, index[s], INF); + } + for h in healthy { + add_edge(&mut adj, index[h], sink, INF); + } + + // Dinic: BFS levels + DFS blocking flow, deterministic adjacency order. + let mut total_flow = 0.0; + loop { + let mut level = vec![usize::MAX; n]; + level[source] = 0; + let mut queue = std::collections::VecDeque::from([source]); + while let Some(u) = queue.pop_front() { + for arc in &adj[u] { + if arc.cap > 1e-12 && level[arc.to] == usize::MAX { + level[arc.to] = level[u] + 1; + queue.push_back(arc.to); + } + } + } + if level[sink] == usize::MAX { + break; + } + let mut iter = vec![0usize; n]; + loop { + // Iterative DFS for one augmenting path. + let mut path: Vec<(usize, usize)> = Vec::new(); + let mut u = source; + let found = loop { + if u == sink { + break true; + } + let mut advanced = false; + while iter[u] < adj[u].len() { + let i = iter[u]; + let (to, cap) = (adj[u][i].to, adj[u][i].cap); + if cap > 1e-12 && level[to] == level[u] + 1 { + path.push((u, i)); + u = to; + advanced = true; + break; + } + iter[u] += 1; + } + if !advanced { + if let Some((prev, _)) = path.pop() { + iter[prev] += 1; + u = prev; + } else { + break false; + } + } + }; + if !found { + break; + } + let bottleneck = path + .iter() + .map(|&(u, i)| adj[u][i].cap) + .fold(INF, f64::min); + for &(u, i) in &path { + let (to, rev) = (adj[u][i].to, adj[u][i].rev); + adj[u][i].cap -= bottleneck; + adj[to][rev].cap += bottleneck; + } + total_flow += bottleneck; + } + } + + // Min cut: vertices reachable from source in the residual graph are the + // contaminated side. + let mut reachable = vec![false; n]; + reachable[source] = true; + let mut stack = vec![source]; + while let Some(u) = stack.pop() { + for arc in &adj[u] { + if arc.cap > 1e-12 && !reachable[arc.to] { + reachable[arc.to] = true; + stack.push(arc.to); + } + } + } + + let isolated_vertices: Vec = ids + .iter() + .enumerate() + .filter(|(i, _)| reachable[*i]) + .map(|(_, &v)| v) + .collect(); + let mut cut_edges: Vec = graph + .edges + .iter() + .filter(|e| reachable[index[&e.edge.u]] != reachable[index[&e.edge.v]]) + .map(|e| e.edge) + .collect(); + cut_edges.sort_unstable(); + Ok(QuarantineBoundary { + cut_edges, + isolated_vertices, + total_cost: total_flow, + }) +} + +/// Containment ordering policy (issue #669, Phase 5.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ContainmentPolicy { + /// Cut the quarantine boundary first, then repair inside the region. + IsolateFirst, + /// Repair globally first; isolate only what repair cannot fix. + RepairFirst, +} + +/// Signed receipt for a combined diagnose → isolate → repair → verify loop. +#[derive(Debug, Clone)] +pub struct InterventionReceipt { + /// Witness hash of the pre-intervention syndrome. + pub pre_witness_hash: [u8; 32], + /// Syndrome energy before intervention. + pub pre_energy: f64, + /// The quarantine boundary that was computed (may be empty). + pub boundary: QuarantineBoundary, + /// The repair that was proposed/applied. + pub repair: Option, + /// Syndrome energy after the intervention. + pub post_energy: f64, + /// Whether the post-intervention energy meets the threshold. + pub verified: bool, + /// Policy used. + pub policy: ContainmentPolicy, + /// SHA-256 over the receipt's canonical content. + pub receipt_hash: [u8; 32], +} + +impl InterventionReceipt { + fn seal(mut self, quantization_scale: f64) -> Self { + let mut h = Sha256::new(); + h.update(b"ruvector-cohomology/intervention/v1"); + h.update(self.pre_witness_hash); + h.update(quantize(self.pre_energy, quantization_scale).to_le_bytes()); + h.update((self.boundary.cut_edges.len() as u64).to_le_bytes()); + for e in &self.boundary.cut_edges { + h.update(e.u.to_le_bytes()); + h.update(e.v.to_le_bytes()); + } + for v in &self.boundary.isolated_vertices { + h.update(v.to_le_bytes()); + } + h.update(quantize(self.boundary.total_cost, quantization_scale).to_le_bytes()); + match &self.repair { + None => h.update([0u8]), + Some(r) => { + h.update([1u8]); + for (k, _) in &r.corrections { + h.update(k.u.to_le_bytes()); + h.update(k.v.to_le_bytes()); + } + h.update(quantize(r.objective, quantization_scale).to_le_bytes()); + } + } + h.update(quantize(self.post_energy, quantization_scale).to_le_bytes()); + h.update([self.verified as u8]); + h.update([match self.policy { + ContainmentPolicy::IsolateFirst => 0u8, + ContainmentPolicy::RepairFirst => 1u8, + }]); + self.receipt_hash = h.finalize().into(); + self + } +} + +/// Run the full containment control loop on a dynamic engine: +/// detect → localize → isolate (policy-dependent) → repair → verify → sign. +/// +/// `seed_threshold` selects contamination seeds by incident tension; +/// `energy_threshold` is the configured acceptable residual syndrome energy. +pub fn run_containment( + engine: &mut DynamicSheafEngine, + tension_weights: &TensionWeights, + repair_policy: &RepairPolicy, + policy: ContainmentPolicy, + seed_threshold: f64, + energy_threshold: f64, +) -> Result { + let syndrome = engine.flush()?; + let pre_energy = syndrome.energy; + let pre_witness_hash = syndrome.witness.hash; + let quant = syndrome.witness.quantization_scale; + + if pre_energy <= energy_threshold { + // Already coherent: empty intervention, verified. + return Ok(InterventionReceipt { + pre_witness_hash, + pre_energy, + boundary: QuarantineBoundary { + cut_edges: Vec::new(), + isolated_vertices: Vec::new(), + total_cost: 0.0, + }, + repair: None, + post_energy: pre_energy, + verified: true, + policy, + receipt_hash: [0u8; 32], + } + .seal(quant)); + } + + let graph = TensionGraph::from_syndrome(&syndrome, tension_weights, None, None, None); + let seeds = graph.contaminated_seeds(seed_threshold); + let healthy: Vec = { + let mut incident: BTreeMap = BTreeMap::new(); + for e in &graph.edges { + *incident.entry(e.edge.u).or_default() += e.tension; + *incident.entry(e.edge.v).or_default() += e.tension; + } + incident + .into_iter() + .filter(|(v, t)| *t <= seed_threshold * 0.1 && !seeds.contains(v)) + .map(|(v, _)| v) + .collect() + }; + + let boundary = match policy { + ContainmentPolicy::IsolateFirst => quarantine_boundary(&graph, &seeds, &healthy)?, + ContainmentPolicy::RepairFirst => QuarantineBoundary { + cut_edges: Vec::new(), + isolated_vertices: Vec::new(), + total_cost: 0.0, + }, + }; + + let repair = engine.propose_repair(repair_policy.clone())?; + let post_energy = repair.post_energy; + let verified = post_energy <= energy_threshold; + + Ok(InterventionReceipt { + pre_witness_hash, + pre_energy, + boundary, + repair: Some(repair), + post_energy, + verified, + policy, + receipt_hash: [0u8; 32], + } + .seal(quant)) +} + +/// Convert a tension graph into `(u, v, tension)` triples for the +/// `ruvector-mincut` dynamic engine (global lowest-tension boundary). +#[cfg(feature = "mincut")] +pub fn global_tension_cut( + graph: &TensionGraph, +) -> Result<(f64, Vec, Vec), CohomologyError> { + use ruvector_mincut::MinCutBuilder; + let edges: Vec<(u64, u64, f64)> = graph + .edges + .iter() + .map(|e| (e.edge.u, e.edge.v, e.tension.max(f64::MIN_POSITIVE))) + .collect(); + let mincut = MinCutBuilder::new() + .exact() + .with_edges(edges) + .build() + .map_err(|e| CohomologyError::SolverFailure(format!("mincut build: {e}")))?; + let value = mincut.min_cut_value(); + let (s, t) = mincut.partition(); + Ok((value, s, t)) +} diff --git a/crates/ruvector-cohomology/src/operator.rs b/crates/ruvector-cohomology/src/operator.rs new file mode 100644 index 0000000000..b5a5cb1971 --- /dev/null +++ b/crates/ruvector-cohomology/src/operator.rs @@ -0,0 +1,437 @@ +//! Serializable linear restriction operators. +//! +//! Restriction maps are explicit, hashable data — never closures — so they +//! can be serialized, transposed, norm-checked, and canonically hashed +//! (issue #669, implementation gap 4). + +use crate::{CohomologyError, ValidationLimits}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Row-major dense matrix used for small blocks (restriction maps, frames, +/// reference-oracle assembly). Deterministic by construction. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DenseMatrix { + /// Number of rows. + pub rows: usize, + /// Number of columns. + pub cols: usize, + /// Row-major entries, `data[r * cols + c]`. + pub data: Vec, +} + +impl DenseMatrix { + /// Zero matrix. + pub fn zeros(rows: usize, cols: usize) -> Self { + Self { rows, cols, data: vec![0.0; rows * cols] } + } + + /// Identity matrix. + pub fn identity(n: usize) -> Self { + let mut m = Self::zeros(n, n); + for i in 0..n { + m.data[i * n + i] = 1.0; + } + m + } + + /// Build from row-major data. + pub fn from_rows(rows: usize, cols: usize, data: Vec) -> Result { + if data.len() != rows * cols { + return Err(CohomologyError::DimensionMismatch { + expected: rows * cols, + actual: data.len(), + context: "DenseMatrix::from_rows".into(), + }); + } + Ok(Self { rows, cols, data }) + } + + /// Entry accessor. + #[inline] + pub fn get(&self, r: usize, c: usize) -> f64 { + self.data[r * self.cols + c] + } + + /// Entry mutator. + #[inline] + pub fn set(&mut self, r: usize, c: usize, v: f64) { + self.data[r * self.cols + c] = v; + } + + /// `y = A x`. + pub fn matvec(&self, x: &[f64], y: &mut [f64]) { + debug_assert_eq!(x.len(), self.cols); + debug_assert_eq!(y.len(), self.rows); + for r in 0..self.rows { + let row = &self.data[r * self.cols..(r + 1) * self.cols]; + let mut acc = 0.0; + for (a, b) in row.iter().zip(x.iter()) { + acc += a * b; + } + y[r] = acc; + } + } + + /// `x = Aᵀ y`. + pub fn matvec_transpose(&self, y: &[f64], x: &mut [f64]) { + debug_assert_eq!(y.len(), self.rows); + debug_assert_eq!(x.len(), self.cols); + x.fill(0.0); + for r in 0..self.rows { + let row = &self.data[r * self.cols..(r + 1) * self.cols]; + let yr = y[r]; + for (xc, a) in x.iter_mut().zip(row.iter()) { + *xc += a * yr; + } + } + } + + /// `C = A · B`. + pub fn matmul(&self, other: &DenseMatrix) -> Result { + if self.cols != other.rows { + return Err(CohomologyError::DimensionMismatch { + expected: self.cols, + actual: other.rows, + context: "DenseMatrix::matmul".into(), + }); + } + let mut out = DenseMatrix::zeros(self.rows, other.cols); + for r in 0..self.rows { + for k in 0..self.cols { + let a = self.get(r, k); + if a == 0.0 { + continue; + } + for c in 0..other.cols { + out.data[r * other.cols + c] += a * other.get(k, c); + } + } + } + Ok(out) + } + + /// Transposed copy. + pub fn transpose(&self) -> DenseMatrix { + let mut out = DenseMatrix::zeros(self.cols, self.rows); + for r in 0..self.rows { + for c in 0..self.cols { + out.set(c, r, self.get(r, c)); + } + } + out + } + + /// Frobenius norm (an upper bound on the spectral norm). + pub fn frobenius_norm(&self) -> f64 { + self.data.iter().map(|v| v * v).sum::().sqrt() + } + + /// Maximum deviation of `AᵀA` from the identity (orthonormal-column check). + pub fn orthogonality_defect(&self) -> f64 { + let mut defect: f64 = 0.0; + for i in 0..self.cols { + for j in i..self.cols { + let mut dot = 0.0; + for r in 0..self.rows { + dot += self.get(r, i) * self.get(r, j); + } + let target = if i == j { 1.0 } else { 0.0 }; + defect = defect.max((dot - target).abs()); + } + } + defect + } + + /// Feed the exact bit pattern of the matrix into a hasher. + pub fn hash_into(&self, hasher: &mut Sha256) { + hasher.update((self.rows as u64).to_le_bytes()); + hasher.update((self.cols as u64).to_le_bytes()); + for v in &self.data { + hasher.update(v.to_bits().to_le_bytes()); + } + } + + /// All entries finite? + pub fn is_finite(&self) -> bool { + self.data.iter().all(|v| v.is_finite()) + } +} + +/// A linear restriction map with explicit dimensions, transpose application, +/// an operator norm bound, and a canonical hash (issue #669 core interface). +pub trait LinearRestriction: Send + Sync { + /// Dimension of the vertex stalk this map consumes. + fn input_dim(&self) -> usize; + /// Dimension of the edge stalk this map produces. + fn output_dim(&self) -> usize; + /// `y = ρ x`. + fn apply(&self, x: &[f64], y: &mut [f64]); + /// `x = ρᵀ y`. + fn apply_transpose(&self, y: &[f64], x: &mut [f64]); + /// A sound upper bound on the spectral norm ‖ρ‖₂. + fn operator_norm_bound(&self) -> f64; + /// Deterministic SHA-256 hash of the exact operator content. + fn canonical_hash(&self) -> [u8; 32]; +} + +/// Constrained, serializable restriction map families (issue #669 §6: ship +/// identity, projection, orthogonal, and contractive operators first; +/// learned maps stay advisory). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum RestrictionMap { + /// Identity on a `dim`-dimensional stalk. + Identity { + /// Stalk dimension. + dim: usize, + }, + /// Scalar multiple of the identity (contractive when `|factor| ≤ 1`). + Scale { + /// Stalk dimension. + dim: usize, + /// Scale factor. + factor: f64, + }, + /// Coordinate projection selecting `coords` (strictly increasing) from an + /// `input_dim`-dimensional stalk. + Projection { + /// Input stalk dimension. + input_dim: usize, + /// Selected coordinates, strictly increasing. + coords: Vec, + }, + /// Matrix with orthonormal rows or columns (validated). + Orthogonal { + /// The orthogonal block. + matrix: DenseMatrix, + }, + /// General dense map. Accepted only when its norm bound passes + /// validation; intended for vetted contractive or learned-map-advisory + /// use. + Dense { + /// The dense block. + matrix: DenseMatrix, + }, +} + +impl RestrictionMap { + /// Validate the map against boundary limits before it is admitted. + pub fn validate(&self, limits: &ValidationLimits) -> Result<(), CohomologyError> { + let check_dims = |i: usize, o: usize| -> Result<(), CohomologyError> { + if i == 0 || o == 0 || i > limits.max_dim || o > limits.max_dim { + return Err(CohomologyError::ValidationFailed(format!( + "restriction dims ({o}×{i}) outside (0, {}]", + limits.max_dim + ))); + } + Ok(()) + }; + match self { + Self::Identity { dim } => check_dims(*dim, *dim), + Self::Scale { dim, factor } => { + check_dims(*dim, *dim)?; + if !factor.is_finite() || factor.abs() > limits.max_operator_norm { + return Err(CohomologyError::ValidationFailed(format!( + "scale factor {factor} not finite or exceeds norm limit" + ))); + } + Ok(()) + } + Self::Projection { input_dim, coords } => { + check_dims(*input_dim, coords.len())?; + let increasing = coords.windows(2).all(|w| w[0] < w[1]); + let in_range = coords.iter().all(|&c| c < *input_dim); + if !increasing || !in_range { + return Err(CohomologyError::ValidationFailed( + "projection coords must be strictly increasing and in range".into(), + )); + } + Ok(()) + } + Self::Orthogonal { matrix } => { + check_dims(matrix.cols, matrix.rows)?; + if !matrix.is_finite() { + return Err(CohomologyError::ValidationFailed( + "orthogonal map has non-finite entries".into(), + )); + } + let defect = if matrix.rows >= matrix.cols { + matrix.orthogonality_defect() + } else { + matrix.transpose().orthogonality_defect() + }; + if defect > limits.orthogonality_tol { + return Err(CohomologyError::ValidationFailed(format!( + "orthogonality defect {defect:.3e} exceeds tolerance {:.3e}", + limits.orthogonality_tol + ))); + } + Ok(()) + } + Self::Dense { matrix } => { + check_dims(matrix.cols, matrix.rows)?; + if !matrix.is_finite() { + return Err(CohomologyError::ValidationFailed( + "dense map has non-finite entries".into(), + )); + } + if matrix.frobenius_norm() > limits.max_operator_norm { + return Err(CohomologyError::ValidationFailed( + "dense map norm bound exceeds limit".into(), + )); + } + Ok(()) + } + } + } + + /// Write the dense representation into `out` (used by the reference + /// oracle). `out` must be `output_dim × input_dim`. + pub fn to_dense(&self) -> DenseMatrix { + match self { + Self::Identity { dim } => DenseMatrix::identity(*dim), + Self::Scale { dim, factor } => { + let mut m = DenseMatrix::zeros(*dim, *dim); + for i in 0..*dim { + m.set(i, i, *factor); + } + m + } + Self::Projection { input_dim, coords } => { + let mut m = DenseMatrix::zeros(coords.len(), *input_dim); + for (r, &c) in coords.iter().enumerate() { + m.set(r, c, 1.0); + } + m + } + Self::Orthogonal { matrix } | Self::Dense { matrix } => matrix.clone(), + } + } + + /// Right-compose with an orthogonal frame change: returns `ρ · Rᵀ`. + /// + /// Used by [`crate::transport`] when a vertex re-expresses its stalk in a + /// new orthonormal frame `R = Q_new Q_oldᵀ`: stored states transform as + /// `x_new = R x_old`, so restriction maps must become `ρ Rᵀ` to leave + /// every coboundary — and therefore the syndrome — unchanged. + pub fn compose_transpose_frame( + &self, + r_frame: &DenseMatrix, + ) -> Result { + if r_frame.rows != self.input_dim() || r_frame.cols != self.input_dim() { + return Err(CohomologyError::DimensionMismatch { + expected: self.input_dim(), + actual: r_frame.rows, + context: "compose_transpose_frame".into(), + }); + } + let composed = self.to_dense().matmul(&r_frame.transpose())?; + // Composition with an orthogonal map preserves orthogonality of + // orthogonal/identity families; everything else stays Dense. + match self { + Self::Identity { .. } | Self::Orthogonal { .. } => { + Ok(RestrictionMap::Orthogonal { matrix: composed }) + } + _ => Ok(RestrictionMap::Dense { matrix: composed }), + } + } +} + +impl LinearRestriction for RestrictionMap { + fn input_dim(&self) -> usize { + match self { + Self::Identity { dim } | Self::Scale { dim, .. } => *dim, + Self::Projection { input_dim, .. } => *input_dim, + Self::Orthogonal { matrix } | Self::Dense { matrix } => matrix.cols, + } + } + + fn output_dim(&self) -> usize { + match self { + Self::Identity { dim } | Self::Scale { dim, .. } => *dim, + Self::Projection { coords, .. } => coords.len(), + Self::Orthogonal { matrix } | Self::Dense { matrix } => matrix.rows, + } + } + + fn apply(&self, x: &[f64], y: &mut [f64]) { + debug_assert_eq!(x.len(), self.input_dim()); + debug_assert_eq!(y.len(), self.output_dim()); + match self { + Self::Identity { .. } => y.copy_from_slice(x), + Self::Scale { factor, .. } => { + for (yi, xi) in y.iter_mut().zip(x.iter()) { + *yi = factor * xi; + } + } + Self::Projection { coords, .. } => { + for (yi, &c) in y.iter_mut().zip(coords.iter()) { + *yi = x[c]; + } + } + Self::Orthogonal { matrix } | Self::Dense { matrix } => matrix.matvec(x, y), + } + } + + fn apply_transpose(&self, y: &[f64], x: &mut [f64]) { + debug_assert_eq!(y.len(), self.output_dim()); + debug_assert_eq!(x.len(), self.input_dim()); + match self { + Self::Identity { .. } => x.copy_from_slice(y), + Self::Scale { factor, .. } => { + for (xi, yi) in x.iter_mut().zip(y.iter()) { + *xi = factor * yi; + } + } + Self::Projection { coords, .. } => { + x.fill(0.0); + for (yi, &c) in y.iter().zip(coords.iter()) { + x[c] = *yi; + } + } + Self::Orthogonal { matrix } | Self::Dense { matrix } => { + matrix.matvec_transpose(y, x) + } + } + } + + fn operator_norm_bound(&self) -> f64 { + match self { + Self::Identity { .. } | Self::Projection { .. } | Self::Orthogonal { .. } => 1.0, + Self::Scale { factor, .. } => factor.abs(), + Self::Dense { matrix } => matrix.frobenius_norm(), + } + } + + fn canonical_hash(&self) -> [u8; 32] { + let mut h = Sha256::new(); + match self { + Self::Identity { dim } => { + h.update([0u8]); + h.update((*dim as u64).to_le_bytes()); + } + Self::Scale { dim, factor } => { + h.update([1u8]); + h.update((*dim as u64).to_le_bytes()); + h.update(factor.to_bits().to_le_bytes()); + } + Self::Projection { input_dim, coords } => { + h.update([2u8]); + h.update((*input_dim as u64).to_le_bytes()); + h.update((coords.len() as u64).to_le_bytes()); + for c in coords { + h.update((*c as u64).to_le_bytes()); + } + } + Self::Orthogonal { matrix } => { + h.update([3u8]); + matrix.hash_into(&mut h); + } + Self::Dense { matrix } => { + h.update([4u8]); + matrix.hash_into(&mut h); + } + } + h.finalize().into() + } +} diff --git a/crates/ruvector-cohomology/src/partition.rs b/crates/ruvector-cohomology/src/partition.rs new file mode 100644 index 0000000000..e841d11a25 --- /dev/null +++ b/crates/ruvector-cohomology/src/partition.rs @@ -0,0 +1,100 @@ +//! Deterministic bounded cell partition (issue #669, Phase 4). +//! +//! The dynamic engine partitions the complex into cells of bounded vertex +//! count. Edits mark only incident cells dirty; global synchronization is +//! deferred to `flush()`. The partition is a function of sorted vertex ids +//! and the insertion history, both deterministic. + +use crate::{EdgeKey, VertexId}; +use std::collections::BTreeMap; + +/// Bounded cell partition over the vertex set. +#[derive(Debug, Clone)] +pub struct CellPartition { + cells: Vec>, + vertex_to_cell: BTreeMap, + capacity: usize, +} + +impl CellPartition { + /// Build from sorted vertex ids, chunking into cells of at most + /// `capacity` vertices. + pub fn build(sorted_vertices: impl IntoIterator, capacity: usize) -> Self { + let capacity = capacity.max(1); + let mut cells: Vec> = Vec::new(); + let mut vertex_to_cell = BTreeMap::new(); + for v in sorted_vertices { + if cells.last().map(|c| c.len() >= capacity).unwrap_or(true) { + cells.push(Vec::with_capacity(capacity)); + } + let idx = cells.len() - 1; + cells.last_mut().expect("cell exists").push(v); + vertex_to_cell.insert(v, idx); + } + Self { cells, vertex_to_cell, capacity } + } + + /// Number of cells. + pub fn cell_count(&self) -> usize { + self.cells.len() + } + + /// Configured capacity bound. + pub fn capacity(&self) -> usize { + self.capacity + } + + /// Cell of a vertex. + pub fn cell_of(&self, v: VertexId) -> Option { + self.vertex_to_cell.get(&v).copied() + } + + /// Cells touched by an edge (deduplicated, sorted). + pub fn edge_cells(&self, key: &EdgeKey) -> Vec { + let mut cells: Vec = [self.cell_of(key.u), self.cell_of(key.v)] + .into_iter() + .flatten() + .collect(); + cells.sort_unstable(); + cells.dedup(); + cells + } + + /// Whether an edge crosses a cell boundary (interface edge). + pub fn is_interface(&self, key: &EdgeKey) -> bool { + match (self.cell_of(key.u), self.cell_of(key.v)) { + (Some(a), Some(b)) => a != b, + _ => false, + } + } + + /// Insert a vertex: appended to the last cell with room, otherwise a new + /// cell. Returns the cell index. Deterministic in the edit sequence. + pub fn insert_vertex(&mut self, v: VertexId) -> usize { + if let Some(idx) = self.vertex_to_cell.get(&v) { + return *idx; + } + let idx = match self.cells.last() { + Some(c) if c.len() < self.capacity => self.cells.len() - 1, + _ => { + self.cells.push(Vec::with_capacity(self.capacity)); + self.cells.len() - 1 + } + }; + self.cells[idx].push(v); + self.vertex_to_cell.insert(v, idx); + idx + } + + /// Remove a vertex. Returns its former cell. + pub fn remove_vertex(&mut self, v: VertexId) -> Option { + let idx = self.vertex_to_cell.remove(&v)?; + self.cells[idx].retain(|&x| x != v); + Some(idx) + } + + /// Vertices per cell (for inspection/testing). + pub fn cells(&self) -> &[Vec] { + &self.cells + } +} diff --git a/crates/ruvector-cohomology/src/repair.rs b/crates/ruvector-cohomology/src/repair.rs new file mode 100644 index 0000000000..536d6fc1a2 --- /dev/null +++ b/crates/ruvector-cohomology/src/repair.rs @@ -0,0 +1,346 @@ +//! Group-sparse semantic repair (issue #669, Phase 3). +//! +//! Computes a correction field `q` with a group-sparse penalty so that +//! `b − q` admits a globally coherent explanation, at minimum weighted +//! repair cost. Protected edges are immutable unless the policy explicitly +//! authorizes modification. + +use crate::affine::{AffineSheafSystem, EdgeField, EdgeWeights}; +use crate::solvers::admm::{solve_group_lasso, AdmmConfig, GroupLassoProblem}; +use crate::solvers::SolverConfig; +use crate::syndrome::{compute_syndrome, SyndromeConfig, SyndromeResult}; +use crate::witness::{quantize, RepairSummary}; +use crate::{CohomologyError, EdgeKey, GaugePolicy}; +use serde::{Deserialize, Serialize}; + +/// Per-edge repair cost components (issue #669: business impact, security +/// severity, latency/disruption, reversibility). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)] +pub struct EdgeCost { + /// Business impact of altering this constraint. + pub business: f64, + /// Security severity. + pub security: f64, + /// Latency / disruption cost. + pub latency: f64, + /// Cost of reversing the change later. + pub reversal: f64, +} + +/// Mixing weights for the combined cost +/// `c_e = α·business + β·security + γ·latency + η·reversal`. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct CostWeights { + /// α — business weight. + pub alpha: f64, + /// β — security weight. + pub beta: f64, + /// γ — latency weight. + pub gamma: f64, + /// η — reversal weight. + pub eta: f64, +} + +impl Default for CostWeights { + fn default() -> Self { + Self { alpha: 1.0, beta: 1.0, gamma: 1.0, eta: 1.0 } + } +} + +impl EdgeCost { + /// Combined scalar cost under the given mixing weights, floored at zero. + pub fn combined(&self, w: &CostWeights) -> f64 { + (w.alpha * self.business + w.beta * self.security + w.gamma * self.latency + + w.eta * self.reversal) + .max(0.0) + } +} + +/// Repair policy (issue #669 core interface). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepairPolicy { + /// Group sparsity strength λ. + pub lambda: f64, + /// ADMM penalty ρ. + pub rho: f64, + /// Cost mixing weights. + pub cost_weights: CostWeights, + /// Fallback cost for edges without an explicit [`EdgeCost`]. + pub default_cost: f64, + /// Outer ADMM iteration cap. + pub max_iter: usize, + /// ADMM residual tolerance. + pub tol: f64, + /// Explicit authorization to modify protected edges. Without it, + /// protected edges are hard-constrained to zero correction. + pub authorize_protected: bool, + /// Debias the correction after support selection: refit an unpenalized + /// least-squares correction restricted to the ADMM support, so the + /// group-lasso shrinkage bias does not leave residual contradiction. + pub debias: bool, +} + +impl Default for RepairPolicy { + fn default() -> Self { + Self { + lambda: 0.1, + rho: 1.0, + cost_weights: CostWeights::default(), + default_cost: 1.0, + max_iter: 400, + tol: 1e-9, + authorize_protected: false, + debias: true, + } + } +} + +/// A proposed (not yet applied) repair. +#[derive(Debug, Clone)] +pub struct RepairPlan { + /// Nonzero correction blocks in canonical edge order. + pub corrections: Vec<(EdgeKey, Vec)>, + /// Repair objective `½‖W^{1/2}(b − q − δx)‖² + λΣc_e‖q_e‖`. + pub objective: f64, + /// Syndrome energy before repair. + pub pre_energy: f64, + /// Syndrome energy after applying the correction. + pub post_energy: f64, + /// ADMM iterations consumed. + pub iterations: usize, + /// Whether ADMM converged to tolerance. + pub converged: bool, + /// Canonical repair summary (embedded in witnesses). + pub summary: RepairSummary, +} + +/// Propose a group-sparse repair for the system's current contradiction. +/// +/// `costs` and `protected` are per-edge in canonical order; pass `None` for +/// uniform defaults. +pub fn propose_repair( + system: &AffineSheafSystem, + syndrome: &SyndromeResult, + costs: Option<&[EdgeCost]>, + protected: Option<&[bool]>, + policy: &RepairPolicy, + syn_cfg: &SyndromeConfig, +) -> Result { + let op = &system.operator; + let edge_count = op.edge_count(); + if let Some(c) = costs { + if c.len() != edge_count { + return Err(CohomologyError::DimensionMismatch { + expected: edge_count, + actual: c.len(), + context: "repair costs".into(), + }); + } + } + if let Some(p) = protected { + if p.len() != edge_count { + return Err(CohomologyError::DimensionMismatch { + expected: edge_count, + actual: p.len(), + context: "protected mask".into(), + }); + } + } + + // An all-zero cost would zero the group penalty and degenerate the + // decoder into plain least squares (every edge "repaired" for free), so + // unset costs fall back to the policy default. + let combined_costs: Vec = (0..edge_count) + .map(|i| { + let c = costs + .map(|c| c[i].combined(&policy.cost_weights)) + .unwrap_or(policy.default_cost); + if c > 0.0 { c } else { policy.default_cost } + }) + .collect(); + let effective_protected: Vec = (0..edge_count) + .map(|i| !policy.authorize_protected && protected.map(|p| p[i]).unwrap_or(false)) + .collect(); + + let edge_blocks: Vec<(usize, usize)> = + op.edge_blocks().iter().map(|e| (e.offset, e.dim)).collect(); + let apply_delta = |x: &[f64], y: &mut [f64]| op.apply(x, y); + let apply_delta_t = |y: &[f64], x: &mut [f64]| op.apply_transpose(y, x); + let problem = GroupLassoProblem { + apply_delta: &apply_delta, + apply_delta_t: &apply_delta_t, + n0: op.dim_c0(), + n1: op.dim_c1(), + edge_blocks: &edge_blocks, + weights: &system.weights.values, + costs: &combined_costs, + protected: &effective_protected, + b: &system.observations.data, + }; + let admm_cfg = AdmmConfig { + lambda: policy.lambda, + rho: policy.rho, + max_iter: policy.max_iter, + tol: policy.tol, + inner: SolverConfig { tol: syn_cfg.solver.tol, max_iter: syn_cfg.solver.max_iter }, + }; + let result = solve_group_lasso(&problem, &admm_cfg); + let mut q = result.q; + let mut objective = result.objective; + + if policy.debias { + // Refit an unpenalized correction restricted to the selected + // support: alternate q_e ← b_e − (δx)_e on support edges with the + // least-squares x for b − q, until the correction stabilizes. This + // removes the group-lasso shrinkage bias so the chosen support + // cancels its contradiction completely. + let support: Vec = op + .edge_blocks() + .iter() + .enumerate() + .map(|(i, e)| { + !effective_protected[i] + && q[e.offset..e.offset + e.dim].iter().any(|v| *v != 0.0) + }) + .collect(); + if support.iter().any(|s| *s) { + let apply_l = |v: &[f64], out: &mut [f64]| { + let mut mid = vec![0.0; op.dim_c1()]; + op.apply(v, &mut mid); + for (i, e) in op.edge_blocks().iter().enumerate() { + let w = system.weights.values[i]; + for m in &mut mid[e.offset..e.offset + e.dim] { + *m *= w; + } + } + op.apply_transpose(&mid, out); + }; + let mut x = result.x.clone(); + let mut dx = vec![0.0; op.dim_c1()]; + for _ in 0..25 { + // x-step: least squares on b − q. + let mut weighted = vec![0.0; op.dim_c1()]; + for (wv, (bi, qi)) in weighted + .iter_mut() + .zip(system.observations.data.iter().zip(q.iter())) + { + *wv = bi - qi; + } + for (i, e) in op.edge_blocks().iter().enumerate() { + let w = system.weights.values[i]; + for wv in &mut weighted[e.offset..e.offset + e.dim] { + *wv *= w; + } + } + let mut rhs = vec![0.0; op.dim_c0()]; + op.apply_transpose(&weighted, &mut rhs); + let (x_new, _) = crate::solvers::cg::solve_psd( + apply_l, + &rhs, + Some(&x), + &admm_cfg.inner, + ); + x = x_new; + op.apply(&x, &mut dx); + // q-step: exact residual absorption on the support. + let mut delta: f64 = 0.0; + for (i, e) in op.edge_blocks().iter().enumerate() { + if !support[i] { + continue; + } + for j in e.offset..e.offset + e.dim { + let target = system.observations.data[j] - dx[j]; + delta = delta.max((q[j] - target).abs()); + q[j] = target; + } + } + if delta < policy.tol { + break; + } + } + objective = crate::solvers::admm::objective(&problem, policy.lambda, &x, &q); + } + } + + // Extract nonzero corrections in canonical order. + let mut corrections = Vec::new(); + for e in op.edge_blocks() { + let block = &q[e.offset..e.offset + e.dim]; + if block.iter().any(|v| *v != 0.0) { + corrections.push((e.key, block.to_vec())); + } + } + + // Post-repair syndrome energy on the corrected observations. + let mut corrected = system.observations.data.clone(); + for (c, qi) in corrected.iter_mut().zip(q.iter()) { + *c -= qi; + } + let corrected_system = AffineSheafSystem { + topology_epoch: system.topology_epoch, + observation_epoch: system.observation_epoch, + operator: op.clone(), + observations: EdgeField { data: corrected }, + weights: EdgeWeights { values: system.weights.values.clone() }, + gauge: GaugePolicy::MinimumNorm, + }; + let post = compute_syndrome(&corrected_system, syn_cfg)?; + + let summary = RepairSummary { + support: corrections.iter().map(|(k, _)| *k).collect(), + objective_q: quantize(objective, syn_cfg.quantization_scale), + pre_energy_q: quantize(syndrome.energy, syn_cfg.quantization_scale), + post_energy_q: quantize(post.energy, syn_cfg.quantization_scale), + }; + + Ok(RepairPlan { + corrections, + objective, + pre_energy: syndrome.energy, + post_energy: post.energy, + iterations: result.iterations, + converged: result.converged, + summary, + }) +} + +/// Apply a repair plan to the system's observations (`b ← b − q`). +/// +/// Fails on any protected edge unless the policy authorizes modification — +/// diagnosis is deliberately separate from authorization to act. +pub fn apply_repair( + system: &mut AffineSheafSystem, + plan: &RepairPlan, + protected: Option<&[bool]>, + policy: &RepairPolicy, +) -> Result<(), CohomologyError> { + if let Some(p) = protected { + if !policy.authorize_protected { + for (key, _) in &plan.corrections { + if let Some(pos) = system.operator.edge_position(key) { + if p[pos] { + return Err(CohomologyError::ProtectedEdge(key.u, key.v)); + } + } + } + } + } + for (key, q) in &plan.corrections { + let (off, dim) = system + .operator + .edge_slice(key) + .ok_or(CohomologyError::UnknownEdge(key.u, key.v))?; + if q.len() != dim { + return Err(CohomologyError::DimensionMismatch { + expected: dim, + actual: q.len(), + context: format!("repair block on edge ({}, {})", key.u, key.v), + }); + } + for (b, qi) in system.observations.data[off..off + dim].iter_mut().zip(q.iter()) { + *b -= qi; + } + } + system.observation_epoch += 1; + Ok(()) +} diff --git a/crates/ruvector-cohomology/src/solvers/admm.rs b/crates/ruvector-cohomology/src/solvers/admm.rs new file mode 100644 index 0000000000..63e6f6e016 --- /dev/null +++ b/crates/ruvector-cohomology/src/solvers/admm.rs @@ -0,0 +1,242 @@ +//! Group-sparse ADMM for semantic repair (issue #669, Phase 3). +//! +//! Solves +//! +//! ```text +//! min_{x,q} ½‖W^{1/2}(b − q − δx)‖² + λ Σ_e c_e ‖q_e‖₂ +//! ``` +//! +//! subject to `q_e = 0` on protected edges, via ADMM with splitting `z = q`. +//! The group ℓ₂ penalty removes or adjusts whole edge constraints, never +//! isolated scalar coordinates. + +use super::cg::solve_psd; +use super::{norm2, SolverConfig}; + +/// The group-lasso repair problem, matrix-free over `δ`. +pub struct GroupLassoProblem<'a> { + /// `y = δ x`. + pub apply_delta: &'a dyn Fn(&[f64], &mut [f64]), + /// `x = δᵀ y`. + pub apply_delta_t: &'a dyn Fn(&[f64], &mut [f64]), + /// Dimension of `C⁰`. + pub n0: usize, + /// Dimension of `C¹`. + pub n1: usize, + /// Per-edge `(offset, dim)` blocks inside `C¹`, in canonical order. + pub edge_blocks: &'a [(usize, usize)], + /// Per-edge positive weights `w_e`. + pub weights: &'a [f64], + /// Per-edge repair costs `c_e ≥ 0`. + pub costs: &'a [f64], + /// Protected edges: correction forced to zero. + pub protected: &'a [bool], + /// Observations `b ∈ C¹`. + pub b: &'a [f64], +} + +/// ADMM configuration. +#[derive(Debug, Clone)] +pub struct AdmmConfig { + /// Group sparsity strength λ. + pub lambda: f64, + /// ADMM penalty ρ. + pub rho: f64, + /// Outer iteration cap. + pub max_iter: usize, + /// Primal/dual residual tolerance (absolute, scaled by √n1). + pub tol: f64, + /// Inner CG configuration for the x-step. + pub inner: SolverConfig, +} + +impl Default for AdmmConfig { + fn default() -> Self { + Self { + lambda: 1.0, + rho: 1.0, + max_iter: 300, + tol: 1e-8, + inner: SolverConfig { tol: 1e-10, max_iter: 2000 }, + } + } +} + +/// ADMM output with residual certificates. +#[derive(Debug, Clone)] +pub struct AdmmResult { + /// Sparse group correction `q` (exactly zero off-support). + pub q: Vec, + /// Final least-squares explanation `x` for the corrected observations. + pub x: Vec, + /// Objective value at `(x, q)`. + pub objective: f64, + /// Final primal residual ‖q − z‖. + pub primal_residual: f64, + /// Final dual residual ρ‖z − z_prev‖. + pub dual_residual: f64, + /// Outer iterations consumed. + pub iterations: usize, + /// Whether both residuals met the tolerance. + pub converged: bool, +} + +/// Evaluate the repair objective at `(x, q)`. +pub fn objective(p: &GroupLassoProblem, lambda: f64, x: &[f64], q: &[f64]) -> f64 { + let mut dx = vec![0.0; p.n1]; + (p.apply_delta)(x, &mut dx); + let mut fit = 0.0; + let mut penalty = 0.0; + for (e, &(off, dim)) in p.edge_blocks.iter().enumerate() { + let mut block = 0.0; + let mut qn = 0.0; + for i in off..off + dim { + let r = p.b[i] - q[i] - dx[i]; + block += r * r; + qn += q[i] * q[i]; + } + fit += p.weights[e] * block; + penalty += p.costs[e] * qn.sqrt(); + } + 0.5 * fit + lambda * penalty +} + +/// Solve the group-lasso repair problem with ADMM. +pub fn solve_group_lasso(p: &GroupLassoProblem, cfg: &AdmmConfig) -> AdmmResult { + let n0 = p.n0; + let n1 = p.n1; + let mut x = vec![0.0; n0]; + let mut q = vec![0.0; n1]; + let mut z = vec![0.0; n1]; + let mut u = vec![0.0; n1]; + + let apply_l = |v: &[f64], out: &mut [f64]| { + let mut mid = vec![0.0; n1]; + (p.apply_delta)(v, &mut mid); + for (e, &(off, dim)) in p.edge_blocks.iter().enumerate() { + let w = p.weights[e]; + for m in &mut mid[off..off + dim] { + *m *= w; + } + } + (p.apply_delta_t)(&mid, out); + }; + + let mut dx = vec![0.0; n1]; + let mut rhs = vec![0.0; n0]; + let mut weighted = vec![0.0; n1]; + let scale = (n1 as f64).sqrt(); + let mut primal = f64::INFINITY; + let mut dual = f64::INFINITY; + let mut iterations = 0; + + for _ in 0..cfg.max_iter { + iterations += 1; + + // (x, q)-step: two block-coordinate sweeps of the joint quadratic. + for _ in 0..2 { + // x given q: L x = δᵀ W (b − q). + for i in 0..n1 { + weighted[i] = p.b[i] - q[i]; + } + for (e, &(off, dim)) in p.edge_blocks.iter().enumerate() { + let w = p.weights[e]; + for wv in &mut weighted[off..off + dim] { + *wv *= w; + } + } + (p.apply_delta_t)(&weighted, &mut rhs); + let (x_new, _cert) = solve_psd(apply_l, &rhs, Some(&x), &cfg.inner); + x = x_new; + (p.apply_delta)(&x, &mut dx); + + // q given x: per-edge closed form of the ρ-augmented quadratic. + for (e, &(off, dim)) in p.edge_blocks.iter().enumerate() { + if p.protected[e] { + for i in off..off + dim { + q[i] = 0.0; + } + continue; + } + let w = p.weights[e]; + let denom = w + cfg.rho; + for i in off..off + dim { + q[i] = (w * (p.b[i] - dx[i]) + cfg.rho * (z[i] - u[i])) / denom; + } + } + } + + // z-step: block soft threshold of q + u with λ c_e / ρ. + let z_prev = z.clone(); + for (e, &(off, dim)) in p.edge_blocks.iter().enumerate() { + if p.protected[e] { + for i in off..off + dim { + z[i] = 0.0; + } + continue; + } + let mut nrm = 0.0; + for i in off..off + dim { + let v = q[i] + u[i]; + nrm += v * v; + } + let nrm = nrm.sqrt(); + let threshold = cfg.lambda * p.costs[e] / cfg.rho; + let shrink = if nrm > threshold { 1.0 - threshold / nrm } else { 0.0 }; + for i in off..off + dim { + z[i] = shrink * (q[i] + u[i]); + } + } + + // Dual update. + for i in 0..n1 { + u[i] += q[i] - z[i]; + } + + primal = { + let mut s = 0.0; + for i in 0..n1 { + s += (q[i] - z[i]).powi(2); + } + s.sqrt() + }; + dual = { + let mut s = 0.0; + for i in 0..n1 { + s += (z[i] - z_prev[i]).powi(2); + } + cfg.rho * s.sqrt() + }; + if primal <= cfg.tol * scale && dual <= cfg.tol * scale { + break; + } + } + + // Adopt the exactly-sparse z as the correction, then refit x once. + q.copy_from_slice(&z); + for i in 0..n1 { + weighted[i] = p.b[i] - q[i]; + } + for (e, &(off, dim)) in p.edge_blocks.iter().enumerate() { + let w = p.weights[e]; + for wv in &mut weighted[off..off + dim] { + *wv *= w; + } + } + (p.apply_delta_t)(&weighted, &mut rhs); + let (x_final, _) = solve_psd(apply_l, &rhs, Some(&x), &cfg.inner); + x = x_final; + + let obj = objective(p, cfg.lambda, &x, &q); + let converged = primal <= cfg.tol * scale && dual <= cfg.tol * scale; + let _ = norm2(&dx); + AdmmResult { + q, + x, + objective: obj, + primal_residual: primal, + dual_residual: dual, + iterations, + converged, + } +} diff --git a/crates/ruvector-cohomology/src/solvers/cg.rs b/crates/ruvector-cohomology/src/solvers/cg.rs new file mode 100644 index 0000000000..9699e03696 --- /dev/null +++ b/crates/ruvector-cohomology/src/solvers/cg.rs @@ -0,0 +1,93 @@ +//! Matrix-free conjugate gradient for gauged PSD systems. +//! +//! Solving `L x = δᵀ W b` with `L = δᵀ W δ` from `x₀ = 0` keeps every Krylov +//! iterate inside `range(L)`, so CG converges to the **minimum-norm** +//! (Moore–Penrose) solution without an explicit gauge constraint — this is +//! the [`crate::GaugePolicy::MinimumNorm`] gauge. + +use super::{axpy, dot, norm2, ResidualCertificate, SolverConfig}; + +/// Solve `A x = b` for symmetric positive semidefinite `apply_a`, returning +/// the solution and a residual certificate. +/// +/// `warm_start`, when provided and consistent, seeds the iteration (used by +/// the dynamic engine between flushes on an unchanged topology). +pub fn solve_psd( + apply_a: impl Fn(&[f64], &mut [f64]), + b: &[f64], + warm_start: Option<&[f64]>, + cfg: &SolverConfig, +) -> (Vec, ResidualCertificate) { + let n = b.len(); + let rhs_norm = norm2(b); + let mut x = match warm_start { + Some(w) if w.len() == n => w.to_vec(), + _ => vec![0.0; n], + }; + let mut r = vec![0.0; n]; + if x.iter().any(|&v| v != 0.0) { + apply_a(&x, &mut r); + for (ri, bi) in r.iter_mut().zip(b.iter()) { + *ri = bi - *ri; + } + } else { + r.copy_from_slice(b); + } + + let threshold = cfg.tol * rhs_norm.max(f64::MIN_POSITIVE); + let mut rr = dot(&r, &r); + if rr.sqrt() <= threshold || rhs_norm == 0.0 { + let res = rr.sqrt(); + return ( + x, + ResidualCertificate { + residual_norm: res, + rhs_norm, + iterations: 0, + converged: true, + tolerance: cfg.tol, + }, + ); + } + + let mut p = r.clone(); + let mut ap = vec![0.0; n]; + let mut iterations = 0; + for _ in 0..cfg.max_iter.min(4 * n.max(1)) { + iterations += 1; + apply_a(&p, &mut ap); + let pap = dot(&p, &ap); + if pap <= 0.0 || !pap.is_finite() { + // Numerically at the boundary of the semidefinite cone (p in the + // nullspace direction): the current iterate is the best gauged + // answer this Krylov space offers. + break; + } + let alpha = rr / pap; + axpy(alpha, &p, &mut x); + axpy(-alpha, &ap, &mut r); + let rr_new = dot(&r, &r); + if rr_new.sqrt() <= threshold { + rr = rr_new; + break; + } + let beta = rr_new / rr; + rr = rr_new; + for (pi, ri) in p.iter_mut().zip(r.iter()) { + *pi = ri + beta * *pi; + } + } + + let residual_norm = rr.sqrt(); + let converged = residual_norm <= threshold; + ( + x, + ResidualCertificate { + residual_norm, + rhs_norm, + iterations, + converged, + tolerance: cfg.tol, + }, + ) +} diff --git a/crates/ruvector-cohomology/src/solvers/lobpcg.rs b/crates/ruvector-cohomology/src/solvers/lobpcg.rs new file mode 100644 index 0000000000..1ec4f0098f --- /dev/null +++ b/crates/ruvector-cohomology/src/solvers/lobpcg.rs @@ -0,0 +1,266 @@ +//! LOBPCG for the smallest eigenpairs of a PSD operator. +//! +//! Issue #669 solver policy, path 3: nullity and spectral-gap estimation for +//! the sheaf Laplacian must use a smallest-eigenpair method — dominant +//! power iteration is explicitly forbidden. The Rayleigh–Ritz step uses a +//! deterministic cyclic Jacobi symmetric eigensolver. + +use super::{dot, norm2, CommittedSeed, ResidualCertificate, SolverConfig}; +use crate::operator::DenseMatrix; + +/// Result of a smallest-eigenpair computation. +#[derive(Debug, Clone)] +pub struct SmallestEigenpairs { + /// Eigenvalues in ascending order. + pub eigenvalues: Vec, + /// Eigenvectors as columns (n × k), matching `eigenvalues`. + pub eigenvectors: DenseMatrix, + /// Residual certificate: `residual_norm` is the max over the block of + /// ‖A v − λ v‖. + pub certificate: ResidualCertificate, +} + +/// Cyclic Jacobi eigensolver for a small dense symmetric matrix. +/// Returns eigenvalues ascending and eigenvectors as columns, with signs +/// canonicalized (first component of largest magnitude made positive). +pub fn jacobi_eigh(a: &DenseMatrix) -> (Vec, DenseMatrix) { + let n = a.rows; + debug_assert_eq!(a.rows, a.cols); + let mut m = a.clone(); + let mut v = DenseMatrix::identity(n); + let max_sweeps = 64; + for _ in 0..max_sweeps { + let mut off: f64 = 0.0; + for i in 0..n { + for j in (i + 1)..n { + off += m.get(i, j).powi(2); + } + } + if off.sqrt() < 1e-14 * (1.0 + m.frobenius_norm()) { + break; + } + for p in 0..n { + for q in (p + 1)..n { + let apq = m.get(p, q); + if apq.abs() < 1e-300 { + continue; + } + let app = m.get(p, p); + let aqq = m.get(q, q); + let theta = (aqq - app) / (2.0 * apq); + let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt()); + let c = 1.0 / (t * t + 1.0).sqrt(); + let s = t * c; + for k in 0..n { + let mkp = m.get(k, p); + let mkq = m.get(k, q); + m.set(k, p, c * mkp - s * mkq); + m.set(k, q, s * mkp + c * mkq); + } + for k in 0..n { + let mpk = m.get(p, k); + let mqk = m.get(q, k); + m.set(p, k, c * mpk - s * mqk); + m.set(q, k, s * mpk + c * mqk); + } + for k in 0..n { + let vkp = v.get(k, p); + let vkq = v.get(k, q); + v.set(k, p, c * vkp - s * vkq); + v.set(k, q, s * vkp + c * vkq); + } + } + } + } + // Sort ascending by eigenvalue, stable for determinism. + let mut order: Vec = (0..n).collect(); + order.sort_by(|&i, &j| m.get(i, i).partial_cmp(&m.get(j, j)).unwrap().then(i.cmp(&j))); + let mut evals = Vec::with_capacity(n); + let mut evecs = DenseMatrix::zeros(n, n); + for (c, &i) in order.iter().enumerate() { + evals.push(m.get(i, i)); + // Sign canonicalization: largest-magnitude component positive. + let mut best = 0usize; + let mut best_abs = 0.0f64; + for r in 0..n { + let a = v.get(r, i).abs(); + if a > best_abs { + best_abs = a; + best = r; + } + } + let sign = if v.get(best, i) < 0.0 { -1.0 } else { 1.0 }; + for r in 0..n { + evecs.set(r, c, sign * v.get(r, i)); + } + } + (evals, evecs) +} + +/// Orthonormalize the columns of `basis` in place with modified +/// Gram–Schmidt, dropping columns below `drop_tol`. Returns kept count. +fn orthonormalize(basis: &mut Vec>, drop_tol: f64) -> usize { + let mut kept = 0usize; + for i in 0..basis.len() { + let (done, rest) = basis.split_at_mut(i); + let col = &mut rest[0]; + for prev in done[..kept].iter() { + let d = dot(prev, col); + for (c, p) in col.iter_mut().zip(prev.iter()) { + *c -= d * p; + } + } + let n = norm2(col); + if n > drop_tol { + for c in col.iter_mut() { + *c /= n; + } + if kept != i { + basis.swap(kept, i); + } + kept += 1; + } + } + basis.truncate(kept); + kept +} + +/// Compute the `k` smallest eigenpairs of a symmetric PSD operator via +/// LOBPCG with a committed seed for the starting block. +pub fn smallest_eigenpairs( + apply_a: impl Fn(&[f64], &mut [f64]), + n: usize, + k: usize, + seed: u64, + cfg: &SolverConfig, +) -> SmallestEigenpairs { + let k = k.min(n).max(1); + let mut rng = CommittedSeed(seed); + let mut x: Vec> = (0..k) + .map(|_| (0..n).map(|_| rng.next_signed_unit()).collect()) + .collect(); + orthonormalize(&mut x, 1e-12); + while x.len() < k { + // Degenerate probe (tiny n): pad with unit vectors. + let mut e = vec![0.0; n]; + e[x.len() % n] = 1.0; + x.push(e); + orthonormalize(&mut x, 1e-12); + } + + let mut p: Vec> = Vec::new(); + let mut evals = vec![0.0; k]; + let mut max_res = f64::INFINITY; + let mut iterations = 0; + let scale_estimate = { + // Cheap deterministic norm estimate for relative tolerance. + let probe: Vec = (0..n).map(|_| rng.next_signed_unit()).collect(); + let nn = norm2(&probe).max(f64::MIN_POSITIVE); + let mut out = vec![0.0; n]; + apply_a(&probe, &mut out); + (norm2(&out) / nn).max(1.0) + }; + + for _ in 0..cfg.max_iter.min(500) { + iterations += 1; + // Residuals R = A X − X Θ with Rayleigh quotients Θ. + let ax: Vec> = x + .iter() + .map(|xi| { + let mut out = vec![0.0; n]; + apply_a(xi, &mut out); + out + }) + .collect(); + let mut residuals: Vec> = Vec::with_capacity(k); + max_res = 0.0; + for (i, (xi, axi)) in x.iter().zip(ax.iter()).enumerate() { + let theta = dot(xi, axi); + evals[i] = theta; + let mut r = axi.clone(); + for (rj, xj) in r.iter_mut().zip(xi.iter()) { + *rj -= theta * xj; + } + max_res = max_res.max(norm2(&r)); + residuals.push(r); + } + if max_res <= cfg.tol * scale_estimate { + break; + } + + // Subspace S = [X, R, P], orthonormalized. + let mut s: Vec> = Vec::with_capacity(3 * k); + s.extend(x.iter().cloned()); + s.extend(residuals); + s.extend(p.iter().cloned()); + let kept = orthonormalize(&mut s, 1e-10); + if kept == 0 { + break; + } + + // Rayleigh–Ritz on the small subspace. + let a_s: Vec> = s + .iter() + .map(|si| { + let mut out = vec![0.0; n]; + apply_a(si, &mut out); + out + }) + .collect(); + let mut g = DenseMatrix::zeros(kept, kept); + for i in 0..kept { + for j in i..kept { + let v = dot(&s[i], &a_s[j]); + g.set(i, j, v); + g.set(j, i, v); + } + } + let (_, vecs) = jacobi_eigh(&g); + + // New X from the k smallest Ritz vectors; P = new − old components. + let mut x_new: Vec> = Vec::with_capacity(k); + for c in 0..k.min(kept) { + let mut xi = vec![0.0; n]; + for (row, sv) in s.iter().enumerate() { + let coeff = vecs.get(row, c); + if coeff != 0.0 { + super::axpy(coeff, sv, &mut xi); + } + } + x_new.push(xi); + } + p = x_new + .iter() + .zip(x.iter()) + .map(|(new, old)| { + let mut d = new.clone(); + for (di, oi) in d.iter_mut().zip(old.iter()) { + *di -= oi; + } + d + }) + .collect(); + orthonormalize(&mut p, 1e-10); + x = x_new; + orthonormalize(&mut x, 1e-12); + } + + let mut eigenvectors = DenseMatrix::zeros(n, x.len()); + for (c, xi) in x.iter().enumerate() { + for (r, v) in xi.iter().enumerate() { + eigenvectors.set(r, c, *v); + } + } + let converged = max_res <= cfg.tol * scale_estimate; + SmallestEigenpairs { + eigenvalues: evals[..x.len().min(k)].to_vec(), + eigenvectors, + certificate: ResidualCertificate { + residual_norm: max_res, + rhs_norm: scale_estimate, + iterations, + converged, + tolerance: cfg.tol, + }, + } +} diff --git a/crates/ruvector-cohomology/src/solvers/minres.rs b/crates/ruvector-cohomology/src/solvers/minres.rs new file mode 100644 index 0000000000..555c4591cd --- /dev/null +++ b/crates/ruvector-cohomology/src/solvers/minres.rs @@ -0,0 +1,115 @@ +//! MINRES for symmetric (possibly indefinite) systems. +//! +//! Used for saddle-point / indefinite gauge formulations where CG's positive +//! definiteness assumption fails (issue #669 solver policy, path 2). + +use super::{norm2, ResidualCertificate, SolverConfig}; + +/// Solve `A x = b` with symmetric `apply_a` via MINRES (Paige–Saunders). +pub fn solve_symmetric( + apply_a: impl Fn(&[f64], &mut [f64]), + b: &[f64], + cfg: &SolverConfig, +) -> (Vec, ResidualCertificate) { + let n = b.len(); + let rhs_norm = norm2(b); + let mut x = vec![0.0; n]; + if rhs_norm == 0.0 { + return ( + x, + ResidualCertificate { + residual_norm: 0.0, + rhs_norm, + iterations: 0, + converged: true, + tolerance: cfg.tol, + }, + ); + } + + // Lanczos vectors. + let mut v_prev = vec![0.0; n]; + let mut v = b.to_vec(); + let mut beta = rhs_norm; + for vi in v.iter_mut() { + *vi /= beta; + } + + // Givens rotation state. + let (mut c_prev, mut s_prev) = (1.0f64, 0.0f64); + let (mut c, mut s) = (1.0f64, 0.0f64); + let mut w_prev = vec![0.0; n]; + let mut w = vec![0.0; n]; + let mut eta = beta; + + let mut av = vec![0.0; n]; + let mut residual_norm = rhs_norm; + let mut iterations = 0; + let threshold = cfg.tol * rhs_norm; + + for _ in 0..cfg.max_iter.min(4 * n.max(1)) { + iterations += 1; + apply_a(&v, &mut av); + let alpha = super::dot(&v, &av); + // Lanczos three-term recurrence. + let mut v_next = av.clone(); + for i in 0..n { + v_next[i] -= alpha * v[i] + beta * v_prev[i]; + } + let beta_next = norm2(&v_next); + if beta_next > 0.0 { + for vi in v_next.iter_mut() { + *vi /= beta_next; + } + } + + // Apply previous rotations to the new tridiagonal column. + let delta = c * alpha - c_prev * s * beta; + let gamma_bar = s * alpha + c_prev * c * beta; + let epsilon = s_prev * beta; + + // New rotation to annihilate beta_next. + let gamma = (delta * delta + beta_next * beta_next).sqrt(); + let gamma = if gamma == 0.0 { f64::MIN_POSITIVE } else { gamma }; + let c_next = delta / gamma; + let s_next = beta_next / gamma; + + // Update solution direction. + let mut w_next = vec![0.0; n]; + for i in 0..n { + w_next[i] = (v[i] - gamma_bar * w[i] - epsilon * w_prev[i]) / gamma; + } + for i in 0..n { + x[i] += c_next * eta * w_next[i]; + } + residual_norm = (s_next * eta).abs(); + eta *= -s_next; + + if residual_norm <= threshold { + break; + } + + v_prev = std::mem::replace(&mut v, v_next); + w_prev = std::mem::replace(&mut w, w_next); + c_prev = c; + s_prev = s; + c = c_next; + s = s_next; + beta = beta_next; + if beta == 0.0 { + break; + } + } + + let converged = residual_norm <= threshold; + ( + x, + ResidualCertificate { + residual_norm, + rhs_norm, + iterations, + converged, + tolerance: cfg.tol, + }, + ) +} diff --git a/crates/ruvector-cohomology/src/solvers/mod.rs b/crates/ruvector-cohomology/src/solvers/mod.rs new file mode 100644 index 0000000000..0c134c3fbf --- /dev/null +++ b/crates/ruvector-cohomology/src/solvers/mod.rs @@ -0,0 +1,103 @@ +//! Deterministic solver suite (issue #669 solver policy). +//! +//! Dominant-eigenvalue power iteration is **never** used to infer nullity or +//! small eigenvalues. Available paths: +//! +//! - [`cg`]: matrix-free conjugate gradient for the gauged PSD normal +//! equations (min-norm solution from a zero start). +//! - [`minres`]: MINRES for symmetric indefinite / saddle-point systems. +//! - [`lobpcg`]: LOBPCG for the smallest eigenpairs (nullity, spectral gap). +//! - [`sparse_qr`]: column-pivoted rank-revealing Householder QR — the exact +//! reference path for small and medium systems. +//! - [`admm`]: group-sparse ADMM for semantic repair. +//! +//! Every approximate result carries a [`ResidualCertificate`]. + +pub mod admm; +pub mod cg; +pub mod lobpcg; +pub mod minres; +pub mod sparse_qr; + +use serde::{Deserialize, Serialize}; + +/// Configuration shared by the iterative solvers. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SolverConfig { + /// Relative residual tolerance. + pub tol: f64, + /// Iteration cap. + pub max_iter: usize, +} + +impl Default for SolverConfig { + fn default() -> Self { + Self { tol: 1e-12, max_iter: 10_000 } + } +} + +/// Certificate attached to every approximate solve (issue #669 requirement: +/// "residual certificates for every approximate result"). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResidualCertificate { + /// Final residual norm ‖Ax − b‖ (or method-specific analogue). + pub residual_norm: f64, + /// Norm of the right-hand side, for relative interpretation. + pub rhs_norm: f64, + /// Iterations consumed. + pub iterations: usize, + /// Whether the requested tolerance was met. + pub converged: bool, + /// The tolerance that was requested. + pub tolerance: f64, +} + +/// Committed deterministic pseudo-random stream (SplitMix64). Used for +/// eigensolver probes so that "randomized" paths are reproducible; the seed +/// is part of the witness-relevant solver configuration. +#[derive(Debug, Clone)] +pub struct CommittedSeed(pub u64); + +impl CommittedSeed { + /// Next raw value. + pub fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^ (z >> 31) + } + + /// Uniform value in `(-1, 1)`, deterministic. + pub fn next_signed_unit(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 + } +} + +/// Dense dot product. +#[inline] +pub fn dot(a: &[f64], b: &[f64]) -> f64 { + a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() +} + +/// Euclidean norm. +#[inline] +pub fn norm2(a: &[f64]) -> f64 { + dot(a, a).sqrt() +} + +/// `y += alpha * x`. +#[inline] +pub fn axpy(alpha: f64, x: &[f64], y: &mut [f64]) { + for (yi, xi) in y.iter_mut().zip(x.iter()) { + *yi += alpha * xi; + } +} + +/// `x *= alpha`. +#[inline] +pub fn scale(alpha: f64, x: &mut [f64]) { + for xi in x.iter_mut() { + *xi *= alpha; + } +} diff --git a/crates/ruvector-cohomology/src/solvers/sparse_qr.rs b/crates/ruvector-cohomology/src/solvers/sparse_qr.rs new file mode 100644 index 0000000000..ee7b794810 --- /dev/null +++ b/crates/ruvector-cohomology/src/solvers/sparse_qr.rs @@ -0,0 +1,182 @@ +//! Column-pivoted (rank-revealing) Householder QR. +//! +//! The exact reference path (issue #669 solver policy, path 4): used by the +//! dense oracle to compute exact least-squares residuals, numerical rank, +//! and an orthonormal basis of `ker(Aᵀ)` (the harmonic/contradiction space). + +use crate::operator::DenseMatrix; + +/// Column-pivoted Householder QR factorization `A P = Q R`. +pub struct PivotedQr { + m: usize, + n: usize, + /// Householder vectors below the diagonal, `R` on and above. + qr: DenseMatrix, + /// Householder coefficients. + tau: Vec, + /// Column permutation: factored column `j` is original column `perm[j]`. + perm: Vec, + rank: usize, +} + +impl PivotedQr { + /// Factor `A` with Businger–Golub column pivoting. `rank_tol_rel` is the + /// relative diagonal threshold for the numerical rank decision. + pub fn factor(a: &DenseMatrix, rank_tol_rel: f64) -> Self { + let m = a.rows; + let n = a.cols; + let mut qr = a.clone(); + let kmax = m.min(n); + let mut tau = vec![0.0; kmax]; + let mut perm: Vec = (0..n).collect(); + + // Squared column norms for pivoting. + let mut col_norms: Vec = (0..n) + .map(|j| (0..m).map(|i| qr.get(i, j).powi(2)).sum()) + .collect(); + let mut rank = 0; + let mut first_diag = 0.0f64; + + for k in 0..kmax { + // Pivot: largest remaining column norm; ties broken by lowest + // index for determinism. + let (pivot, &pnorm) = col_norms[k..] + .iter() + .enumerate() + .fold((0usize, &f64::MIN), |best, (i, v)| if *v > *best.1 { (i, v) } else { best }); + let pivot = k + pivot; + if pivot != k { + for i in 0..m { + let t = qr.get(i, k); + qr.set(i, k, qr.get(i, pivot)); + qr.set(i, pivot, t); + } + perm.swap(k, pivot); + col_norms.swap(k, pivot); + } + let _ = pnorm; + + // Householder reflector on column k, rows k..m. + let mut alpha = 0.0; + for i in k..m { + alpha += qr.get(i, k).powi(2); + } + let alpha = alpha.sqrt(); + let akk = qr.get(k, k); + if k == 0 { + first_diag = alpha; + } + if alpha <= rank_tol_rel * first_diag.max(f64::MIN_POSITIVE) { + break; // Remaining block numerically zero: rank found. + } + rank += 1; + let sign = if akk >= 0.0 { 1.0 } else { -1.0 }; + let v0 = akk + sign * alpha; + // Normalize the reflector so v[k] = 1 implicitly. + for i in (k + 1)..m { + qr.set(i, k, qr.get(i, k) / v0); + } + let mut vtv = 1.0; + for i in (k + 1)..m { + vtv += qr.get(i, k).powi(2); + } + tau[k] = 2.0 / vtv; + qr.set(k, k, -sign * alpha); + + // Apply reflector to the trailing columns. + for j in (k + 1)..n { + let mut dot = qr.get(k, j); + for i in (k + 1)..m { + dot += qr.get(i, k) * qr.get(i, j); + } + let t = tau[k] * dot; + qr.set(k, j, qr.get(k, j) - t); + for i in (k + 1)..m { + qr.set(i, j, qr.get(i, j) - t * qr.get(i, k)); + } + // Downdate the column norm. + col_norms[j] = (col_norms[j] - qr.get(k, j).powi(2)).max(0.0); + } + } + + Self { m, n, qr, tau, perm, rank } + } + + /// Numerical rank. + pub fn rank(&self) -> usize { + self.rank + } + + /// `y ← Qᵀ y` (length m). + pub fn apply_qt(&self, y: &mut [f64]) { + for k in 0..self.rank { + let mut dot = y[k]; + for i in (k + 1)..self.m { + dot += self.qr.get(i, k) * y[i]; + } + let t = self.tau[k] * dot; + y[k] -= t; + for i in (k + 1)..self.m { + y[i] -= t * self.qr.get(i, k); + } + } + } + + /// `y ← Q y` (length m). + pub fn apply_q(&self, y: &mut [f64]) { + for k in (0..self.rank).rev() { + let mut dot = y[k]; + for i in (k + 1)..self.m { + dot += self.qr.get(i, k) * y[i]; + } + let t = self.tau[k] * dot; + y[k] -= t; + for i in (k + 1)..self.m { + y[i] -= t * self.qr.get(i, k); + } + } + } + + /// Exact least-squares solution of `min ‖Ax − b‖₂` (basic solution: free + /// pivoted variables set to zero). The residual is exact regardless of + /// which least-squares representative is returned. + pub fn solve_least_squares(&self, b: &[f64]) -> Vec { + debug_assert_eq!(b.len(), self.m); + let mut qtb = b.to_vec(); + self.apply_qt(&mut qtb); + // Back-substitute R[0..rank, 0..rank] z = (Qᵀb)[0..rank]. + let r = self.rank; + let mut z = vec![0.0; r]; + for i in (0..r).rev() { + let mut acc = qtb[i]; + for j in (i + 1)..r { + acc -= self.qr.get(i, j) * z[j]; + } + z[i] = acc / self.qr.get(i, i); + } + let mut x = vec![0.0; self.n]; + for (j, &zj) in z.iter().enumerate() { + x[self.perm[j]] = zj; + } + x + } + + /// Orthonormal basis of `ker(Aᵀ)` — the columns `Q e_i` for + /// `i ∈ [rank, m)`. In syndrome coordinates (`A = W^{1/2}δ`) this is the + /// harmonic/contradiction subspace `(im δ)^{⊥_W}` expressed in + /// `W^{1/2}`-scaled coordinates. + pub fn null_space_of_transpose(&self) -> DenseMatrix { + let cols = self.m - self.rank; + let mut basis = DenseMatrix::zeros(self.m, cols); + let mut e = vec![0.0; self.m]; + for (c, i) in (self.rank..self.m).enumerate() { + e.fill(0.0); + e[i] = 1.0; + self.apply_q(&mut e); + for r in 0..self.m { + basis.set(r, c, e[r]); + } + } + basis + } +} diff --git a/crates/ruvector-cohomology/src/syndrome.rs b/crates/ruvector-cohomology/src/syndrome.rs new file mode 100644 index 0000000000..6b872696dd --- /dev/null +++ b/crates/ruvector-cohomology/src/syndrome.rs @@ -0,0 +1,290 @@ +//! Affine syndrome engine (issue #669, Phase 2). +//! +//! Solves the weighted least-squares problem +//! `x* = argmin ‖W^{1/2}(δx − b)‖²`, extracts the canonical syndrome +//! `s = b − δx*` (the weighted projection of `b` onto `(im δ)^{⊥_W}`), +//! ranks edge contributions, and produces a sealed canonical witness. + +use crate::affine::{AffineSheafSystem, EdgeField}; +use crate::harmonic::HarmonicBasis; +use crate::solvers::cg::solve_psd; +use crate::solvers::sparse_qr::PivotedQr; +use crate::solvers::{ResidualCertificate, SolverConfig}; +use crate::witness::{ + quantize, CohomologyWitness, HarmonicMeta, SupportEntry, DEFAULT_QUANTIZATION_SCALE, +}; +use crate::{CohomologyError, EdgeKey}; + +/// Configuration for syndrome extraction. +#[derive(Debug, Clone)] +pub struct SyndromeConfig { + /// Linear solver configuration. + pub solver: SolverConfig, + /// Witness quantization scale. + pub quantization_scale: f64, + /// Compute the canonical dense harmonic basis when `dim C¹` is at most + /// this threshold (the basis is O(dim²) memory). + pub dense_harmonic_threshold: usize, + /// Number of ranked support entries stored in the witness. + pub top_k_support: usize, + /// Relative rank tolerance for the rank-revealing QR. + pub rank_tol_rel: f64, +} + +impl Default for SyndromeConfig { + fn default() -> Self { + Self { + solver: SolverConfig::default(), + quantization_scale: DEFAULT_QUANTIZATION_SCALE, + dense_harmonic_threshold: 256, + top_k_support: 32, + rank_tol_rel: 1e-10, + } + } +} + +/// Contribution of one edge to the syndrome energy. +#[derive(Debug, Clone)] +pub struct EdgeContribution { + /// The edge. + pub edge: EdgeKey, + /// `w_e ‖s_e‖²`. + pub energy: f64, + /// Fraction of the total syndrome energy (statistical leverage proxy). + pub fraction: f64, +} + +/// Full syndrome result (issue #669 core type). +pub struct SyndromeResult { + /// Total syndrome energy `‖s‖²_W`. + pub energy: f64, + /// The syndrome `s = b − δx*` as an edge field. + pub residual: EdgeField, + /// Gauged least-squares explanation `x*`. + pub x_star: Vec, + /// Coordinates of `W^{1/2}s` in the canonical harmonic basis (dense + /// path; empty when the system exceeds the dense threshold). + pub harmonic_coordinates: Vec, + /// Edge contributions, highest energy first (ties by edge key). + pub ranked_support: Vec, + /// Sealed canonical witness. + pub witness: CohomologyWitness, + /// Residual certificate of the linear solve. + pub certificate: ResidualCertificate, +} + +fn rank_support(system: &AffineSheafSystem, residual: &[f64]) -> (f64, Vec) { + let op = &system.operator; + let mut total = 0.0; + let mut entries: Vec = op + .edge_blocks() + .iter() + .enumerate() + .map(|(i, e)| { + let mut block = 0.0; + for v in &residual[e.offset..e.offset + e.dim] { + block += v * v; + } + let energy = system.weights.values[i] * block; + total += energy; + EdgeContribution { edge: e.key, energy, fraction: 0.0 } + }) + .collect(); + for e in &mut entries { + e.fraction = if total > 0.0 { e.energy / total } else { 0.0 }; + } + entries.sort_by(|a, b| { + b.energy + .partial_cmp(&a.energy) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.edge.cmp(&b.edge)) + }); + (total, entries) +} + +#[allow(clippy::too_many_arguments)] +fn build_result( + system: &AffineSheafSystem, + x_star: Vec, + certificate: ResidualCertificate, + solver_id: &str, + rank_hint: Option, + cfg: &SyndromeConfig, +) -> SyndromeResult { + let op = &system.operator; + let mut dx = vec![0.0; op.dim_c1()]; + op.apply(&x_star, &mut dx); + let mut residual = system.observations.data.clone(); + for (r, d) in residual.iter_mut().zip(dx.iter()) { + *r -= d; + } + let (energy, ranked_support) = rank_support(system, &residual); + + // Canonical harmonic coordinates on the dense path. + let (harmonic_coordinates, harmonic_meta) = if op.dim_c1() <= cfg.dense_harmonic_threshold + && op.dim_c1() > 0 + { + let basis = HarmonicBasis::dense(op, &system.weights.values, cfg.rank_tol_rel); + let mut scaled = residual.clone(); + for (i, e) in op.edge_blocks().iter().enumerate() { + let sw = system.weights.values[i].sqrt(); + for v in &mut scaled[e.offset..e.offset + e.dim] { + *v *= sw; + } + } + let coords = basis.coordinates(&scaled); + let meta = HarmonicMeta { + rank: basis.rank as u64, + harmonic_dim: basis.basis.cols as u64, + coordinates_q: coords + .iter() + .map(|c| quantize(*c, cfg.quantization_scale)) + .collect(), + canonicalized: true, + }; + (coords, meta) + } else { + ( + Vec::new(), + HarmonicMeta { + rank: rank_hint.unwrap_or(0) as u64, + ..HarmonicMeta::default() + }, + ) + }; + + // Witness support is ordered on *quantized* energies (ties by edge key) + // so solver-level float noise can never change the canonical ordering. + let mut support: Vec = ranked_support + .iter() + .map(|c| SupportEntry { + edge: c.edge, + energy_q: quantize(c.energy, cfg.quantization_scale), + }) + .collect(); + support.sort_by(|a, b| b.energy_q.cmp(&a.energy_q).then(a.edge.cmp(&b.edge))); + support.truncate(cfg.top_k_support); + + let witness = CohomologyWitness { + topology_epoch: system.topology_epoch, + observation_epoch: system.observation_epoch, + vertex_count: op.vertex_count() as u64, + edge_count: op.edge_count() as u64, + operator_hash: op.canonical_hash(), + observation_hash: system.observations.canonical_hash(), + weight_hash: system.weights.canonical_hash(), + gauge: system.gauge, + solver_id: solver_id.to_string(), + solver_tol: cfg.solver.tol, + quantization_scale: cfg.quantization_scale, + energy_q: quantize(energy, cfg.quantization_scale), + support, + harmonic: harmonic_meta, + repair: None, + hash: [0u8; 32], + } + .seal(); + + SyndromeResult { + energy, + residual: EdgeField { data: residual }, + x_star, + harmonic_coordinates, + ranked_support, + witness, + certificate, + } +} + +/// Compute the syndrome with the production path: matrix-free CG on the +/// gauged normal equations `δᵀWδ x = δᵀW b` (minimum-norm gauge). +pub fn compute_syndrome( + system: &AffineSheafSystem, + cfg: &SyndromeConfig, +) -> Result { + compute_syndrome_warm(system, cfg, None) +} + +/// [`compute_syndrome`] with an optional warm start for the dynamic engine. +pub fn compute_syndrome_warm( + system: &AffineSheafSystem, + cfg: &SyndromeConfig, + warm_start: Option<&[f64]>, +) -> Result { + let op = &system.operator; + let weights = &system.weights.values; + + // rhs = δᵀ W b. + let mut weighted = system.observations.data.clone(); + for (i, e) in op.edge_blocks().iter().enumerate() { + let w = weights[i]; + for v in &mut weighted[e.offset..e.offset + e.dim] { + *v *= w; + } + } + let mut rhs = vec![0.0; op.dim_c0()]; + op.apply_transpose(&weighted, &mut rhs); + + let (x_star, certificate) = solve_psd( + |x, y| op.apply_weighted_laplacian(weights, x, y), + &rhs, + warm_start, + &cfg.solver, + ); + if !certificate.converged && certificate.residual_norm > cfg.solver.tol.sqrt() { + return Err(CohomologyError::SolverFailure(format!( + "CG did not converge: residual {:.3e} after {} iterations", + certificate.residual_norm, certificate.iterations + ))); + } + Ok(build_result( + system, + x_star, + certificate, + "cg/normal-equations", + None, + cfg, + )) +} + +/// Compute the syndrome with the exact dense reference oracle +/// (rank-revealing QR of `W^{1/2}δ`; issue #669, Phase 0). +pub fn compute_syndrome_dense( + system: &AffineSheafSystem, + cfg: &SyndromeConfig, +) -> Result { + let op = &system.operator; + let a = op.assemble_scaled_dense(&system.weights.values); + let mut b_scaled = system.observations.data.clone(); + for (i, e) in op.edge_blocks().iter().enumerate() { + let sw = system.weights.values[i].sqrt(); + for v in &mut b_scaled[e.offset..e.offset + e.dim] { + *v *= sw; + } + } + let qr = PivotedQr::factor(&a, cfg.rank_tol_rel); + let x_star = qr.solve_least_squares(&b_scaled); + + // Exact residual certificate. + let mut ax = vec![0.0; op.dim_c1()]; + a.matvec(&x_star, &mut ax); + let mut res = 0.0; + for (axi, bi) in ax.iter().zip(b_scaled.iter()) { + res += (bi - axi).powi(2); + } + let certificate = ResidualCertificate { + residual_norm: res.sqrt(), + rhs_norm: b_scaled.iter().map(|v| v * v).sum::().sqrt(), + iterations: 1, + converged: true, + tolerance: 0.0, + }; + Ok(build_result( + system, + x_star, + certificate, + "qr/dense-oracle", + Some(qr.rank()), + cfg, + )) +} diff --git a/crates/ruvector-cohomology/src/transport.rs b/crates/ruvector-cohomology/src/transport.rs new file mode 100644 index 0000000000..78cc8de595 --- /dev/null +++ b/crates/ruvector-cohomology/src/transport.rs @@ -0,0 +1,138 @@ +//! Orthogonal transport and latent-drift guards (issue #669, §6). +//! +//! The main false-positive risk of the whole engine is treating coordinate +//! drift as semantic contradiction. Orthogonal frame changes must transport +//! stored state exactly (`x_new = Q_new Q_oldᵀ x_old`) and restriction maps +//! must be re-expressed (`ρ ← ρ Rᵀ`) so every coboundary — and therefore +//! the syndrome — is invariant. Map updates are guarded by cycle-consistency +//! and coherent-control tests before acceptance. + +use crate::affine::AffineSheafSystem; +use crate::operator::{DenseMatrix, LinearRestriction, RestrictionMap}; +use crate::syndrome::{compute_syndrome, SyndromeConfig}; +use crate::{CohomologyError, ValidationLimits}; + +/// Validate `q` as a square orthogonal matrix of the given dimension. +fn validate_orthogonal( + q: &DenseMatrix, + dim: usize, + tol: f64, + name: &str, +) -> Result<(), CohomologyError> { + if q.rows != dim || q.cols != dim { + return Err(CohomologyError::DimensionMismatch { + expected: dim, + actual: q.rows, + context: format!("frame {name}"), + }); + } + if !q.is_finite() { + return Err(CohomologyError::ValidationFailed(format!( + "frame {name} has non-finite entries" + ))); + } + let defect = q.orthogonality_defect(); + if defect > tol { + return Err(CohomologyError::ValidationFailed(format!( + "frame {name} orthogonality defect {defect:.3e} exceeds {tol:.3e}" + ))); + } + Ok(()) +} + +/// Compute the frame-change matrix `R = Q_new Q_oldᵀ` after validating both +/// frames as orthogonal. +pub fn frame_change( + q_old: &DenseMatrix, + q_new: &DenseMatrix, + limits: &ValidationLimits, +) -> Result { + let dim = q_old.rows; + validate_orthogonal(q_old, dim, limits.orthogonality_tol, "Q_old")?; + validate_orthogonal(q_new, dim, limits.orthogonality_tol, "Q_new")?; + q_new.matmul(&q_old.transpose()) +} + +/// Transport a stored stalk state through a frame change: +/// `x_new = R x_old`. +pub fn transport_state(r_frame: &DenseMatrix, x_old: &[f64]) -> Result, CohomologyError> { + if x_old.len() != r_frame.cols { + return Err(CohomologyError::DimensionMismatch { + expected: r_frame.cols, + actual: x_old.len(), + context: "transport_state".into(), + }); + } + let mut out = vec![0.0; r_frame.rows]; + r_frame.matvec(x_old, &mut out); + Ok(out) +} + +/// Re-express a restriction map after its source vertex changed frame: +/// `ρ' = ρ Rᵀ`, so that `ρ'(x_new) = ρ(x_old)` exactly. +pub fn transport_restriction( + rho: &RestrictionMap, + r_frame: &DenseMatrix, +) -> Result { + rho.compose_transpose_frame(r_frame) +} + +/// Cycle-consistency defect of a closed path of square maps: the Frobenius +/// distance of their ordered product from the identity. A legitimate +/// transport family must keep this near zero on held-out cycles. +pub fn cycle_consistency_defect(maps: &[&RestrictionMap]) -> Result { + let first = maps + .first() + .ok_or_else(|| CohomologyError::InvalidValue("empty cycle".into()))?; + let dim = first.input_dim(); + let mut product = DenseMatrix::identity(dim); + for m in maps { + if m.input_dim() != product.rows || m.output_dim() != m.input_dim() { + return Err(CohomologyError::DimensionMismatch { + expected: product.rows, + actual: m.input_dim(), + context: "cycle_consistency_defect: maps must be square and chained".into(), + }); + } + product = m.to_dense().matmul(&product)?; + } + let mut defect = 0.0; + for r in 0..dim { + for c in 0..dim { + let target = if r == c { 1.0 } else { 0.0 }; + defect += (product.get(r, c) - target).powi(2); + } + } + Ok(defect.sqrt()) +} + +/// Report from the map-update guard. +#[derive(Debug, Clone)] +pub struct GuardReport { + /// Syndrome energy of the control system before the update. + pub energy_before: f64, + /// Syndrome energy of the control system after the update. + pub energy_after: f64, + /// Whether the update stays within the allowed increase. + pub accepted: bool, +} + +/// Negative-control guard for restriction-map updates: on a **coherent** +/// control system, an admissible transport update must not create syndrome +/// energy beyond `max_increase`. Learned maps stay advisory until they pass +/// this and cycle-consistency checks (issue #669 principal uncertainty). +pub fn guard_map_update( + control_before: &AffineSheafSystem, + control_after: &AffineSheafSystem, + max_increase: f64, + cfg: &SyndromeConfig, +) -> Result { + let before = compute_syndrome(control_before, cfg)?; + let after = compute_syndrome(control_after, cfg)?; + let accepted = after.energy <= before.energy + max_increase; + Ok(GuardReport { + energy_before: before.energy, + energy_after: after.energy, + accepted, + }) +} diff --git a/crates/ruvector-cohomology/src/witness.rs b/crates/ruvector-cohomology/src/witness.rs new file mode 100644 index 0000000000..e02f6f819a --- /dev/null +++ b/crates/ruvector-cohomology/src/witness.rs @@ -0,0 +1,155 @@ +//! Canonical contradiction witnesses (issue #669, §7). +//! +//! A witness is reproducible across machines and execution orders: every +//! field is either an exact hash, a sorted identifier list, or an explicitly +//! quantized value. Solver floating-point output never enters the hash +//! directly — only its quantization does. + +use crate::{EdgeKey, GaugePolicy}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Default quantization scale for witness values (values are rounded to +/// integer multiples of this scale before hashing). +pub const DEFAULT_QUANTIZATION_SCALE: f64 = 1e-9; + +/// Quantize a value onto the witness lattice. +pub fn quantize(value: f64, scale: f64) -> i64 { + let q = (value / scale).round(); + if q >= i64::MAX as f64 { + i64::MAX + } else if q <= i64::MIN as f64 { + i64::MIN + } else { + q as i64 + } +} + +/// One ranked support entry: an edge and its quantized syndrome energy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SupportEntry { + /// The edge. + pub edge: EdgeKey, + /// Quantized contribution `w_e ‖s_e‖²`. + pub energy_q: i64, +} + +/// Canonical harmonic metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct HarmonicMeta { + /// Numerical rank of `W^{1/2}δ`. + pub rank: u64, + /// Dimension of the harmonic (contradiction) subspace that was + /// canonicalized, when the dense basis path ran. + pub harmonic_dim: u64, + /// Quantized harmonic coordinates of the syndrome (dense path only). + pub coordinates_q: Vec, + /// Whether the basis was canonicalized against the hashed probe. + pub canonicalized: bool, +} + +/// Summary of a proposed repair embedded in a witness. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepairSummary { + /// Edges carrying nonzero correction, canonically sorted. + pub support: Vec, + /// Quantized repair objective. + pub objective_q: i64, + /// Quantized syndrome energy before repair. + pub pre_energy_q: i64, + /// Quantized syndrome energy after repair. + pub post_energy_q: i64, +} + +/// Canonical, signed contradiction witness. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CohomologyWitness { + /// Topology epoch at computation time. + pub topology_epoch: u64, + /// Observation epoch at computation time. + pub observation_epoch: u64, + /// Number of vertices. + pub vertex_count: u64, + /// Number of edges. + pub edge_count: u64, + /// Hash of the full operator (layout, orientation, restriction maps). + pub operator_hash: [u8; 32], + /// Hash of the observation field. + pub observation_hash: [u8; 32], + /// Hash of the weights. + pub weight_hash: [u8; 32], + /// Gauge policy used. + pub gauge: GaugePolicy, + /// Solver identifier (e.g. `"cg/normal-equations"`). + pub solver_id: String, + /// Solver tolerance. + pub solver_tol: f64, + /// Quantization scale for all quantized values below. + pub quantization_scale: f64, + /// Quantized syndrome energy `‖s‖²_W`. + pub energy_q: i64, + /// Ranked support, highest contribution first (ties by edge key). + pub support: Vec, + /// Harmonic canonicalization metadata. + pub harmonic: HarmonicMeta, + /// Optional embedded repair summary. + pub repair: Option, + /// SHA-256 over every canonical field above. + pub hash: [u8; 32], +} + +impl CohomologyWitness { + /// Compute the canonical hash from the current field values and stamp it. + pub fn seal(mut self) -> Self { + let mut h = Sha256::new(); + h.update(b"ruvector-cohomology/witness/v1"); + h.update(self.topology_epoch.to_le_bytes()); + h.update(self.observation_epoch.to_le_bytes()); + h.update(self.vertex_count.to_le_bytes()); + h.update(self.edge_count.to_le_bytes()); + h.update(self.operator_hash); + h.update(self.observation_hash); + h.update(self.weight_hash); + h.update([match self.gauge { + GaugePolicy::MinimumNorm => 0u8, + }]); + h.update(self.solver_id.as_bytes()); + h.update(self.solver_tol.to_bits().to_le_bytes()); + h.update(self.quantization_scale.to_bits().to_le_bytes()); + h.update(self.energy_q.to_le_bytes()); + h.update((self.support.len() as u64).to_le_bytes()); + for s in &self.support { + h.update(s.edge.u.to_le_bytes()); + h.update(s.edge.v.to_le_bytes()); + h.update(s.energy_q.to_le_bytes()); + } + h.update(self.harmonic.rank.to_le_bytes()); + h.update(self.harmonic.harmonic_dim.to_le_bytes()); + h.update((self.harmonic.coordinates_q.len() as u64).to_le_bytes()); + for c in &self.harmonic.coordinates_q { + h.update(c.to_le_bytes()); + } + h.update([self.harmonic.canonicalized as u8]); + match &self.repair { + None => h.update([0u8]), + Some(r) => { + h.update([1u8]); + h.update((r.support.len() as u64).to_le_bytes()); + for e in &r.support { + h.update(e.u.to_le_bytes()); + h.update(e.v.to_le_bytes()); + } + h.update(r.objective_q.to_le_bytes()); + h.update(r.pre_energy_q.to_le_bytes()); + h.update(r.post_energy_q.to_le_bytes()); + } + } + self.hash = h.finalize().into(); + self + } + + /// Hex encoding of the witness hash. + pub fn hash_hex(&self) -> String { + self.hash.iter().map(|b| format!("{b:02x}")).collect() + } +} diff --git a/crates/ruvector-cohomology/tests/adversarial.rs b/crates/ruvector-cohomology/tests/adversarial.rs new file mode 100644 index 0000000000..9ecdf63270 --- /dev/null +++ b/crates/ruvector-cohomology/tests/adversarial.rs @@ -0,0 +1,274 @@ +//! Adversarial and boundary-validation tests (issue #669 security +//! requirements), plus Byzantine containment through the mincut bridge. + +mod common; + +use common::ring_engine; +use ruvector_cohomology::block_sparse::CoboundaryBuilder; +use ruvector_cohomology::dynamic::{CohomologyEdit, DynamicCohomology, DynamicSheafEngine, EngineConfig}; +use ruvector_cohomology::mincut_bridge::{ + quarantine_boundary, run_containment, ContainmentPolicy, TensionGraph, TensionWeights, +}; +use ruvector_cohomology::operator::{DenseMatrix, RestrictionMap}; +use ruvector_cohomology::repair::{apply_repair, propose_repair, RepairPolicy}; +use ruvector_cohomology::syndrome::{compute_syndrome, SyndromeConfig}; +use ruvector_cohomology::{CohomologyError, EdgeKey, ValidationLimits}; + +#[test] +fn non_finite_and_invalid_inputs_are_rejected() { + let mut engine = ring_engine(4, 2); + // Non-finite observation. + let r = engine.apply_edit(CohomologyEdit::UpdateObservation { + a: 0, + b: 1, + value: vec![f64::NAN, 0.0], + }); + assert!(!r.accepted); + // Zero/negative/NaN weights. + for w in [0.0, -1.0, f64::INFINITY, f64::NAN] { + let r = engine.apply_edit(CohomologyEdit::UpdateWeight { a: 0, b: 1, weight: w }); + assert!(!r.accepted, "weight {w} accepted"); + } + // Dimension mismatch. + let r = engine.apply_edit(CohomologyEdit::UpdateObservation { a: 0, b: 1, value: vec![1.0] }); + assert!(!r.accepted); + // Self-loop. + let r = engine.apply_edit(CohomologyEdit::InsertEdge { + a: 2, + b: 2, + rho_a: RestrictionMap::Identity { dim: 2 }, + rho_b: RestrictionMap::Identity { dim: 2 }, + weight: 1.0, + observation: vec![0.0, 0.0], + }); + assert!(!r.accepted); + // Duplicates. + assert!(!engine.apply_edit(CohomologyEdit::InsertVertex { id: 0, dim: 2 }).accepted); + let r = engine.apply_edit(CohomologyEdit::InsertEdge { + a: 0, + b: 1, + rho_a: RestrictionMap::Identity { dim: 2 }, + rho_b: RestrictionMap::Identity { dim: 2 }, + weight: 1.0, + observation: vec![0.0, 0.0], + }); + assert!(!r.accepted); + // The engine still flushes cleanly after every rejection. + assert!(engine.flush().unwrap().energy < 1e-16); +} + +#[test] +fn malicious_restriction_maps_fail_validation() { + let limits = ValidationLimits::default(); + // Fake "orthogonal" map. + let mut skewed = DenseMatrix::identity(3); + skewed.set(0, 1, 0.5); + assert!(RestrictionMap::Orthogonal { matrix: skewed }.validate(&limits).is_err()); + // Non-finite dense map. + let mut inf = DenseMatrix::zeros(2, 2); + inf.set(0, 0, f64::INFINITY); + assert!(RestrictionMap::Dense { matrix: inf }.validate(&limits).is_err()); + // Norm bomb (numerical denial of service). + let mut bomb = DenseMatrix::zeros(2, 2); + bomb.set(0, 0, 1e12); + assert!(RestrictionMap::Dense { matrix: bomb }.validate(&limits).is_err()); + // Dimension bomb (pre-allocation bound). + assert!(RestrictionMap::Identity { dim: usize::MAX / 2 }.validate(&limits).is_err()); + // Out-of-order projection coords. + assert!(RestrictionMap::Projection { input_dim: 4, coords: vec![2, 1] } + .validate(&limits) + .is_err()); + // A near-singular but bounded dense map is admitted (validation bounds + // resources, not conditioning) and the solver still certifies. + let mut near_singular = DenseMatrix::identity(2); + near_singular.set(1, 1, 1e-12); + assert!(RestrictionMap::Dense { matrix: near_singular.clone() }.validate(&limits).is_ok()); + let mut builder = CoboundaryBuilder::new(); + builder.add_vertex(0, 2).unwrap(); + builder.add_vertex(1, 2).unwrap(); + builder + .add_edge(0, 1, RestrictionMap::Dense { matrix: near_singular }, + RestrictionMap::Identity { dim: 2 }) + .unwrap(); + let op = builder.build(); + let system = ruvector_cohomology::AffineSheafSystem::new( + op, + ruvector_cohomology::EdgeField { data: vec![1.0, 1.0] }, + ruvector_cohomology::EdgeWeights { values: vec![1.0] }, + ruvector_cohomology::GaugePolicy::MinimumNorm, + ) + .unwrap(); + let result = compute_syndrome(&system, &SyndromeConfig::default()).unwrap(); + assert!(result.energy.is_finite()); + assert!(result.certificate.residual_norm.is_finite()); +} + +#[test] +fn protected_edges_are_immutable_without_authorization() { + let mut engine = ring_engine(6, 1); + let planted = EdgeKey::new(2, 3).unwrap(); + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { a: 2, b: 3, value: vec![4.0] }) + .accepted); + engine.set_protected(&planted, true).unwrap(); + + // Without authorization the repair must leave the protected edge alone. + let plan = engine + .propose_repair(RepairPolicy { lambda: 0.05, ..RepairPolicy::default() }) + .unwrap(); + assert!( + plan.corrections.iter().all(|(k, _)| *k != planted), + "protected edge was repaired" + ); + + // With explicit authorization the same edge becomes repairable. + let authorized = engine + .propose_repair(RepairPolicy { + lambda: 0.05, + authorize_protected: true, + ..RepairPolicy::default() + }) + .unwrap(); + assert!(authorized.corrections.iter().any(|(k, _)| *k == planted)); + + // Applying an authorized plan without authorization is refused. + let mut system = engine.build_system().unwrap(); + let protected = engine.protected_mask(); + let err = apply_repair( + &mut system, + &authorized, + Some(&protected), + &RepairPolicy::default(), + ) + .unwrap_err(); + assert!(matches!(err, CohomologyError::ProtectedEdge(2, 3))); +} + +#[test] +fn byzantine_cluster_is_quarantined() { + // Two communities joined by two bridge edges; the small community holds + // mutually contradictory observations (a Byzantine cluster). The + // tension mincut must cut the cheap bridge, not the healthy community. + let dim = 1; + let mut engine = DynamicSheafEngine::new(EngineConfig::default()); + for i in 0..10u64 { + assert!(engine.apply_edit(CohomologyEdit::InsertVertex { id: i, dim }).accepted); + } + let add = |engine: &mut DynamicSheafEngine, a: u64, b: u64, obs: f64| { + let r = engine.apply_edit(CohomologyEdit::InsertEdge { + a, + b, + rho_a: RestrictionMap::Identity { dim }, + rho_b: RestrictionMap::Identity { dim }, + weight: 1.0, + observation: vec![obs], + }); + assert!(r.accepted, "{:?}", r.error); + }; + // Healthy community: vertices 0..6, coherent ring (all zeros). + for i in 0..6u64 { + add(&mut engine, i, (i + 1) % 6, 0.0); + } + // Byzantine community: vertices 6..10, contradictory cycle. + add(&mut engine, 6, 7, 5.0); + add(&mut engine, 7, 8, 5.0); + add(&mut engine, 8, 9, 5.0); + add(&mut engine, 6, 9, 0.0); // closes an inconsistent cycle + // Bridges. + add(&mut engine, 0, 6, 0.0); + add(&mut engine, 3, 8, 0.0); + + let syndrome = engine.flush().unwrap(); + assert!(syndrome.energy > 1.0); + + let graph = TensionGraph::from_syndrome(&syndrome, &TensionWeights::default(), None, None, None); + // Byzantine vertices carry ~7–9 incident tension, healthy ones ≤ ~1.1 + // (a little syndrome legitimately spreads along the bridge cycle). + let seeds = graph.contaminated_seeds(2.0); + assert!(!seeds.is_empty()); + assert!(seeds.iter().all(|v| *v >= 6), "healthy vertex flagged: {seeds:?}"); + + let healthy: Vec = (0..6).collect(); + let boundary = quarantine_boundary(&graph, &seeds, &healthy).unwrap(); + // The cut must separate along the two bridges. + assert_eq!( + boundary.cut_edges, + vec![EdgeKey::new(0, 6).unwrap(), EdgeKey::new(3, 8).unwrap()], + "unexpected boundary: {:?}", + boundary.cut_edges + ); + assert!(boundary.isolated_vertices.iter().all(|v| *v >= 6)); +} + +#[test] +fn containment_loop_verifies_post_repair_energy() { + let mut engine = ring_engine(12, 2); + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { a: 5, b: 6, value: vec![3.0, 3.0] }) + .accepted); + for policy in [ContainmentPolicy::IsolateFirst, ContainmentPolicy::RepairFirst] { + let mut e = ring_engine(12, 2); + assert!(e + .apply_edit(CohomologyEdit::UpdateObservation { a: 5, b: 6, value: vec![3.0, 3.0] }) + .accepted); + let receipt = run_containment( + &mut e, + &TensionWeights::default(), + &RepairPolicy { lambda: 0.01, ..RepairPolicy::default() }, + policy, + 0.5, + 1e-6, + ) + .unwrap(); + assert!(receipt.pre_energy > 1.0); + assert!(receipt.verified, "{policy:?}: post energy {}", receipt.post_energy); + assert!(receipt.post_energy < 1e-6); + assert_ne!(receipt.receipt_hash, [0u8; 32]); + } +} + +#[test] +fn repair_prefers_cheap_edges_under_cost_policy() { + // Same contradiction, but the planted edge is expensive to modify: the + // decoder should route the correction through cheaper cycle edges. + let mut engine = ring_engine(4, 1); + let expensive = EdgeKey::new(0, 1).unwrap(); + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { a: 0, b: 1, value: vec![2.0] }) + .accepted); + engine + .set_edge_cost(&expensive, ruvector_cohomology::repair::EdgeCost { + business: 100.0, + ..Default::default() + }) + .unwrap(); + + let syndrome = engine.flush().unwrap(); + let system = engine.build_system().unwrap(); + let plan = propose_repair( + &system, + &syndrome, + Some(&engine.edge_costs()), + Some(&engine.protected_mask()), + &RepairPolicy { lambda: 0.05, ..RepairPolicy::default() }, + &SyndromeConfig::default(), + ) + .unwrap(); + // Correction magnitude on the expensive edge should be (near) zero. + let expensive_mag: f64 = plan + .corrections + .iter() + .filter(|(k, _)| *k == expensive) + .map(|(_, q)| q.iter().map(|v| v * v).sum::().sqrt()) + .sum(); + let cheap_mag: f64 = plan + .corrections + .iter() + .filter(|(k, _)| *k != expensive) + .map(|(_, q)| q.iter().map(|v| v * v).sum::().sqrt()) + .sum(); + assert!( + cheap_mag > expensive_mag * 10.0, + "cheap {cheap_mag} vs expensive {expensive_mag}" + ); +} diff --git a/crates/ruvector-cohomology/tests/common/mod.rs b/crates/ruvector-cohomology/tests/common/mod.rs new file mode 100644 index 0000000000..0bf0850e72 --- /dev/null +++ b/crates/ruvector-cohomology/tests/common/mod.rs @@ -0,0 +1,95 @@ +//! Shared deterministic test helpers. +#![allow(dead_code)] + +use ruvector_cohomology::block_sparse::CoboundaryBuilder; +use ruvector_cohomology::dynamic::{CohomologyEdit, DynamicCohomology, DynamicSheafEngine, EngineConfig}; +use ruvector_cohomology::operator::{DenseMatrix, RestrictionMap}; +use ruvector_cohomology::{AffineSheafSystem, EdgeField, EdgeWeights, GaugePolicy}; + +/// Deterministic SplitMix64 stream. +pub struct Rng(pub u64); + +impl Rng { + pub fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^ (z >> 31) + } + + /// Uniform in (-1, 1). + pub fn signed_unit(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 + } +} + +/// Deterministic random orthogonal matrix from a product of Givens rotations. +pub fn random_orthogonal(dim: usize, rng: &mut Rng) -> DenseMatrix { + let mut q = DenseMatrix::identity(dim); + for i in 0..dim { + for j in (i + 1)..dim { + let theta = rng.signed_unit() * std::f64::consts::PI; + let (c, s) = (theta.cos(), theta.sin()); + for r in 0..dim { + let qi = q.get(r, i); + let qj = q.get(r, j); + q.set(r, i, c * qi - s * qj); + q.set(r, j, s * qi + c * qj); + } + } + } + q +} + +/// Identity-sheaf system on the given edges with observations `b`. +pub fn identity_system( + n: u64, + dim: usize, + edges: &[(u64, u64)], + mut observations: impl FnMut(usize) -> Vec, + weights: Option>, +) -> AffineSheafSystem { + let mut builder = CoboundaryBuilder::new(); + for v in 0..n { + builder.add_vertex(v, dim).unwrap(); + } + for &(a, b) in edges { + builder + .add_edge( + a, + b, + RestrictionMap::Identity { dim }, + RestrictionMap::Identity { dim }, + ) + .unwrap(); + } + let op = builder.build(); + let mut data = vec![0.0; op.dim_c1()]; + for (i, e) in op.edge_blocks().iter().enumerate() { + let block = observations(i); + data[e.offset..e.offset + e.dim].copy_from_slice(&block); + } + let w = EdgeWeights { values: weights.unwrap_or_else(|| vec![1.0; op.edge_count()]) }; + AffineSheafSystem::new(op, EdgeField { data }, w, GaugePolicy::MinimumNorm).unwrap() +} + +/// Ring engine with identity maps and coherent (zero) observations. +pub fn ring_engine(n: u64, dim: usize) -> DynamicSheafEngine { + let mut engine = DynamicSheafEngine::new(EngineConfig::default()); + for i in 0..n { + assert!(engine.apply_edit(CohomologyEdit::InsertVertex { id: i, dim }).accepted); + } + for i in 0..n { + let receipt = engine.apply_edit(CohomologyEdit::InsertEdge { + a: i, + b: (i + 1) % n, + rho_a: RestrictionMap::Identity { dim }, + rho_b: RestrictionMap::Identity { dim }, + weight: 1.0, + observation: vec![0.0; dim], + }); + assert!(receipt.accepted, "{:?}", receipt.error); + } + engine +} diff --git a/crates/ruvector-cohomology/tests/determinism.rs b/crates/ruvector-cohomology/tests/determinism.rs new file mode 100644 index 0000000000..01607d8af0 --- /dev/null +++ b/crates/ruvector-cohomology/tests/determinism.rs @@ -0,0 +1,100 @@ +//! Determinism gate (issue #669): repeated runs produce identical witness +//! hashes; construction/edit order does not change canonical results. + +mod common; + +use common::{identity_system, ring_engine}; +use ruvector_cohomology::dynamic::{CohomologyEdit, DynamicCohomology}; +use ruvector_cohomology::operator::{LinearRestriction, RestrictionMap}; +use ruvector_cohomology::syndrome::{compute_syndrome, SyndromeConfig}; + +#[test] +fn hundred_repeated_runs_same_witness_hash() { + let build = || { + identity_system(6, 2, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (0, 5), (1, 4)], |i| { + vec![i as f64 * 0.7 - 1.0, (i as f64).sin()] + }, Some(vec![1.0, 0.5, 2.0, 1.5, 0.25, 3.0, 1.0])) + }; + let reference = compute_syndrome(&build(), &SyndromeConfig::default()) + .unwrap() + .witness + .hash; + for run in 0..100 { + let hash = compute_syndrome(&build(), &SyndromeConfig::default()) + .unwrap() + .witness + .hash; + assert_eq!(hash, reference, "run {run} diverged"); + } +} + +#[test] +fn edge_insertion_order_does_not_change_witness() { + // The canonical layout is sorted, so permuted edit order must produce + // the same flushed witness hash (this is what makes parallel drivers + // that merge by canonical key order safe). + let dim = 2; + let edges: Vec<(u64, u64)> = vec![(0, 1), (1, 2), (2, 3), (0, 3), (1, 3)]; + let build = |order: &[usize]| { + let mut engine = ring_engine(4, dim); // ring edges (0,1),(1,2),(2,3),(0,3) + // Remove ring edges, reinsert everything in the permuted order. + for &(a, b) in &edges[..4] { + assert!(engine.apply_edit(CohomologyEdit::RemoveEdge { a, b }).accepted); + } + for &i in order { + let (a, b) = edges[i]; + let receipt = engine.apply_edit(CohomologyEdit::InsertEdge { + a, + b, + rho_a: RestrictionMap::Identity { dim }, + rho_b: RestrictionMap::Identity { dim }, + weight: 1.0 + i as f64, + observation: vec![i as f64, -(i as f64)], + }); + assert!(receipt.accepted, "{:?}", receipt.error); + } + engine.flush().unwrap().witness.hash + }; + let forward = build(&[0, 1, 2, 3, 4]); + let backward = build(&[4, 3, 2, 1, 0]); + let shuffled = build(&[2, 0, 4, 1, 3]); + assert_eq!(forward, backward); + assert_eq!(forward, shuffled); +} + +#[test] +fn restriction_hashes_are_content_addressed() { + let a = RestrictionMap::Identity { dim: 4 }; + let b = RestrictionMap::Identity { dim: 4 }; + let c = RestrictionMap::Identity { dim: 5 }; + assert_eq!(a.canonical_hash(), b.canonical_hash()); + assert_ne!(a.canonical_hash(), c.canonical_hash()); + + let s1 = RestrictionMap::Scale { dim: 4, factor: 1.0 }; + // Same action as identity but different canonical family: distinct hash. + assert_ne!(a.canonical_hash(), s1.canonical_hash()); +} + +#[test] +fn edit_receipts_hash_edit_content() { + let mut engine = ring_engine(4, 1); + let r1 = engine.apply_edit(CohomologyEdit::UpdateObservation { a: 0, b: 1, value: vec![1.0] }); + let mut engine2 = ring_engine(4, 1); + let r2 = engine2.apply_edit(CohomologyEdit::UpdateObservation { a: 0, b: 1, value: vec![1.0] }); + let r3 = engine2.apply_edit(CohomologyEdit::UpdateObservation { a: 0, b: 1, value: vec![2.0] }); + assert_eq!(r1.edit_hash, r2.edit_hash); + assert_ne!(r1.edit_hash, r3.edit_hash); +} + +#[test] +fn witness_support_ordering_is_canonical_under_ties() { + // Symmetric contradiction: equal energies on all cycle edges — support + // must be ordered by edge key on ties, deterministically. + let system = identity_system(3, 1, &[(0, 1), (1, 2), (0, 2)], |i| match i { + 0 => vec![1.0], + _ => vec![0.0], + }, None); + let result = compute_syndrome(&system, &SyndromeConfig::default()).unwrap(); + let keys: Vec<(u64, u64)> = result.witness.support.iter().map(|s| (s.edge.u, s.edge.v)).collect(); + assert_eq!(keys, vec![(0, 1), (0, 2), (1, 2)]); +} diff --git a/crates/ruvector-cohomology/tests/drift.rs b/crates/ruvector-cohomology/tests/drift.rs new file mode 100644 index 0000000000..12a82023c1 --- /dev/null +++ b/crates/ruvector-cohomology/tests/drift.rs @@ -0,0 +1,181 @@ +//! Latent-drift controls (issue #669, §6): orthogonal frame changes are +//! coordinate drift, not semantic contradiction — transported systems must +//! not manufacture syndrome energy. + +mod common; + +use common::{random_orthogonal, ring_engine, Rng}; +use ruvector_cohomology::dynamic::{CohomologyEdit, DynamicCohomology}; +use ruvector_cohomology::operator::{DenseMatrix, RestrictionMap}; +use ruvector_cohomology::syndrome::SyndromeConfig; +use ruvector_cohomology::transport::{cycle_consistency_defect, frame_change, guard_map_update, transport_state}; +use ruvector_cohomology::ValidationLimits; + +#[test] +fn frame_updates_leave_syndrome_invariant() { + // A coherent-but-nonzero system stays coherent under arbitrary + // orthogonal frame drift on any vertex (false contradiction rate: zero). + let dim = 3; + let mut engine = ring_engine(8, dim); + let mut rng = Rng(2026); + // Nonzero coherent observations: b = δx for a random x. + let x: Vec> = (0..8).map(|_| (0..dim).map(|_| rng.signed_unit()).collect()).collect(); + for i in 0..8u64 { + let j = (i + 1) % 8; + let (u, v) = if i < j { (i, j) } else { (j, i) }; + let value: Vec = (0..dim).map(|d| x[v as usize][d] - x[u as usize][d]).collect(); + assert!(engine.apply_edit(CohomologyEdit::UpdateObservation { a: i, b: j, value }).accepted); + } + let before = engine.flush().unwrap(); + assert!(before.energy < 1e-16); + + // Drift several vertices through random orthogonal frames. + for vertex in [1u64, 4, 6] { + let q_new = random_orthogonal(dim, &mut rng); + let receipt = engine.apply_edit(CohomologyEdit::UpdateFrame { + vertex, + q_old: DenseMatrix::identity(dim), + q_new, + }); + assert!(receipt.accepted, "{:?}", receipt.error); + } + let after = engine.flush().unwrap(); + assert!( + after.energy < 1e-16, + "coordinate drift manufactured contradiction: {}", + after.energy + ); +} + +#[test] +fn contradiction_energy_is_invariant_under_drift() { + // A genuine contradiction keeps exactly the same energy after frame + // transport: drift neither hides nor inflates it. + let dim = 2; + let mut engine = ring_engine(6, dim); + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { a: 2, b: 3, value: vec![4.0, -1.0] }) + .accepted); + let before = engine.flush().unwrap(); + assert!(before.energy > 1.0); + + let mut rng = Rng(555); + for vertex in 0..6u64 { + let q_new = random_orthogonal(dim, &mut rng); + assert!(engine + .apply_edit(CohomologyEdit::UpdateFrame { + vertex, + q_old: DenseMatrix::identity(dim), + q_new, + }) + .accepted); + } + let after = engine.flush().unwrap(); + assert!( + (after.energy - before.energy).abs() < 1e-9, + "energy drifted: {} -> {}", + before.energy, + after.energy + ); +} + +#[test] +fn transport_state_round_trips() { + let mut rng = Rng(31); + let dim = 4; + let q_old = random_orthogonal(dim, &mut rng); + let q_new = random_orthogonal(dim, &mut rng); + let limits = ValidationLimits::default(); + let r = frame_change(&q_old, &q_new, &limits).unwrap(); + let r_back = frame_change(&q_new, &q_old, &limits).unwrap(); + let x: Vec = (0..dim).map(|_| rng.signed_unit()).collect(); + let forward = transport_state(&r, &x).unwrap(); + let back = transport_state(&r_back, &forward).unwrap(); + for (a, b) in x.iter().zip(back.iter()) { + assert!((a - b).abs() < 1e-12); + } +} + +#[test] +fn cycle_consistency_flags_bad_transport() { + let dim = 3; + let mut rng = Rng(8); + let q = random_orthogonal(dim, &mut rng); + let good_cycle = [ + RestrictionMap::Orthogonal { matrix: q.clone() }, + RestrictionMap::Orthogonal { matrix: q.transpose() }, + ]; + let defect = cycle_consistency_defect(&good_cycle.iter().collect::>()).unwrap(); + assert!(defect < 1e-12, "consistent cycle flagged: {defect}"); + + let bad_cycle = [ + RestrictionMap::Orthogonal { matrix: q.clone() }, + RestrictionMap::Scale { dim, factor: 0.5 }, + ]; + let defect = cycle_consistency_defect(&bad_cycle.iter().collect::>()).unwrap(); + assert!(defect > 0.5, "inconsistent cycle passed: {defect}"); +} + +#[test] +fn map_update_guard_rejects_contradiction_manufacturing_maps() { + // Negative control on a coherent system with two independent cycles + // (ring + chord — on a single cycle *any* modified holonomy keeps the + // coboundary full-rank, so nothing can look contradictory there). + // A non-transport scaling map on a shared edge manufactures syndrome + // energy and must be rejected; a legitimate orthogonal transport passes. + let dim = 2; + let mut rng = Rng(99); + let x: Vec> = (0..5).map(|_| (0..dim).map(|_| rng.signed_unit() * 3.0).collect()).collect(); + let build_control = |x: &[Vec]| { + let mut engine = ring_engine(5, dim); + // Chord (0, 2) creates the second cycle; coherent observation. + let receipt = engine.apply_edit(CohomologyEdit::InsertEdge { + a: 0, + b: 2, + rho_a: RestrictionMap::Identity { dim }, + rho_b: RestrictionMap::Identity { dim }, + weight: 1.0, + observation: (0..dim).map(|d| x[2][d] - x[0][d]).collect(), + }); + assert!(receipt.accepted, "{:?}", receipt.error); + for i in 0..5u64 { + let j = (i + 1) % 5; + let (u, v) = if i < j { (i, j) } else { (j, i) }; + let value: Vec = (0..dim).map(|d| x[v as usize][d] - x[u as usize][d]).collect(); + assert!(engine.apply_edit(CohomologyEdit::UpdateObservation { a: i, b: j, value }).accepted); + } + engine + }; + let control_before = build_control(&x).build_system().unwrap(); + + // Bad update: two *inconsistently* re-scaled restriction maps (a single + // scaled edge is always absorbed by re-gauging its vertex to zero, so it + // cannot manufacture contradiction — two incompatible ones can). + let mut bad_engine = build_control(&x); + for (a, b, factor) in [(0u64, 1u64, 0.3), (2, 3, 0.7)] { + assert!(bad_engine + .apply_edit(CohomologyEdit::UpdateRestriction { + a, + b, + endpoint: ruvector_cohomology::Endpoint::Tail, + map: RestrictionMap::Scale { dim, factor }, + }) + .accepted); + } + let control_after = bad_engine.build_system().unwrap(); + let report = guard_map_update(&control_before, &control_after, 1e-9, &SyndromeConfig::default()).unwrap(); + assert!(!report.accepted, "bad map passed the guard: {report:?}"); + + // Good update: orthogonal frame transport. + let mut good_engine = build_control(&x); + assert!(good_engine + .apply_edit(CohomologyEdit::UpdateFrame { + vertex: 0, + q_old: DenseMatrix::identity(dim), + q_new: random_orthogonal(dim, &mut rng), + }) + .accepted); + let control_after = good_engine.build_system().unwrap(); + let report = guard_map_update(&control_before, &control_after, 1e-9, &SyndromeConfig::default()).unwrap(); + assert!(report.accepted, "legitimate transport rejected: {report:?}"); +} diff --git a/crates/ruvector-cohomology/tests/exact_small.rs b/crates/ruvector-cohomology/tests/exact_small.rs new file mode 100644 index 0000000000..2037aae72c --- /dev/null +++ b/crates/ruvector-cohomology/tests/exact_small.rs @@ -0,0 +1,170 @@ +//! Exact small-fixture tests against the dense reference oracle and +//! closed-form values (issue #669, Phase 0 + correctness acceptance gate). + +mod common; + +use common::identity_system; +use ruvector_cohomology::block_sparse::CoboundaryBuilder; +use ruvector_cohomology::harmonic::spectral_summary; +use ruvector_cohomology::operator::RestrictionMap; +use ruvector_cohomology::solvers::SolverConfig; +use ruvector_cohomology::syndrome::{compute_syndrome, compute_syndrome_dense, SyndromeConfig}; +use ruvector_cohomology::{AffineSheafSystem, EdgeField, EdgeWeights, GaugePolicy}; + +const ORACLE_TOL: f64 = 1e-10; + +#[test] +fn coherent_triangle_has_zero_syndrome() { + // b = δx for x = (0, 1, 3) on scalar stalks. + let x = [0.0, 1.0, 3.0]; + let edges = [(0u64, 1u64), (1, 2), (0, 2)]; + let system = identity_system( + 3, + 1, + &edges, + |i| match i { + 0 => vec![x[1] - x[0]], + 1 => vec![x[2] - x[0]], // canonical order sorts (0,2) before (1,2) + _ => vec![x[2] - x[1]], + }, + None, + ); + let cfg = SyndromeConfig::default(); + let result = compute_syndrome(&system, &cfg).unwrap(); + let oracle = compute_syndrome_dense(&system, &cfg).unwrap(); + assert!(result.energy < ORACLE_TOL, "energy = {}", result.energy); + assert!(oracle.energy < ORACLE_TOL); + assert!((result.energy - oracle.energy).abs() < ORACLE_TOL); +} + +#[test] +fn inconsistent_triangle_matches_closed_form() { + // Scalar triangle, unit weights, b = (1, 0, 0) on canonical edges + // (0,1), (0,2), (1,2). The harmonic generator is (1, -1, 1)/√3 + // (b ∈ im δ iff b01 − b02 + b12 = 0), so energy = (b01 − b02 + b12)²/3. + let system = identity_system(3, 1, &[(0, 1), (1, 2), (0, 2)], |i| match i { + 0 => vec![1.0], + _ => vec![0.0], + }, None); + let cfg = SyndromeConfig::default(); + let result = compute_syndrome(&system, &cfg).unwrap(); + let oracle = compute_syndrome_dense(&system, &cfg).unwrap(); + let expected = 1.0 / 3.0; + assert!((result.energy - expected).abs() < ORACLE_TOL, "energy = {}", result.energy); + assert!((oracle.energy - expected).abs() < ORACLE_TOL); + // Production path agrees with the oracle elementwise. + for (a, b) in result.residual.data.iter().zip(oracle.residual.data.iter()) { + assert!((a - b).abs() < ORACLE_TOL); + } + // All three cycle edges carry equal syndrome energy. + for c in &result.ranked_support { + assert!((c.energy - expected / 3.0).abs() < ORACLE_TOL); + } +} + +#[test] +fn tree_is_always_coherent() { + // A path graph has no cycles: every observation is explainable. + let system = identity_system(5, 3, &[(0, 1), (1, 2), (2, 3), (3, 4)], |i| { + vec![i as f64 + 1.0, -(i as f64), 0.5] + }, None); + let cfg = SyndromeConfig::default(); + let result = compute_syndrome(&system, &cfg).unwrap(); + assert!(result.energy < ORACLE_TOL, "energy = {}", result.energy); +} + +#[test] +fn weighted_syndrome_matches_oracle() { + let weights = vec![0.5, 2.0, 3.5]; + let system = identity_system(3, 2, &[(0, 1), (1, 2), (0, 2)], |i| { + vec![i as f64, 1.0 - i as f64] + }, Some(weights)); + let cfg = SyndromeConfig::default(); + let result = compute_syndrome(&system, &cfg).unwrap(); + let oracle = compute_syndrome_dense(&system, &cfg).unwrap(); + assert!((result.energy - oracle.energy).abs() < ORACLE_TOL); + for (a, b) in result.residual.data.iter().zip(oracle.residual.data.iter()) { + assert!((a - b).abs() < ORACLE_TOL); + } + // The syndrome is W-orthogonal to im δ: δᵀ W s = 0. + let op = &system.operator; + let mut weighted = result.residual.data.clone(); + for (i, e) in op.edge_blocks().iter().enumerate() { + for v in &mut weighted[e.offset..e.offset + e.dim] { + *v *= system.weights.values[i]; + } + } + let mut back = vec![0.0; op.dim_c0()]; + op.apply_transpose(&weighted, &mut back); + for v in back { + assert!(v.abs() < ORACLE_TOL, "δᵀWs component = {v}"); + } +} + +#[test] +fn scale_maps_match_oracle() { + // Non-identity restrictions: x-values must be scaled before comparison. + let mut builder = CoboundaryBuilder::new(); + for v in 0..3u64 { + builder.add_vertex(v, 2).unwrap(); + } + builder + .add_edge(0, 1, RestrictionMap::Scale { dim: 2, factor: 2.0 }, + RestrictionMap::Identity { dim: 2 }) + .unwrap(); + builder + .add_edge(1, 2, RestrictionMap::Identity { dim: 2 }, + RestrictionMap::Scale { dim: 2, factor: 0.5 }) + .unwrap(); + builder + .add_edge(0, 2, RestrictionMap::Identity { dim: 2 }, + RestrictionMap::Identity { dim: 2 }) + .unwrap(); + let op = builder.build(); + let n1 = op.dim_c1(); + let system = AffineSheafSystem::new( + op, + EdgeField { data: (0..n1).map(|i| (i as f64) * 0.3 - 0.7).collect() }, + EdgeWeights { values: vec![1.0, 1.3, 0.8] }, + GaugePolicy::MinimumNorm, + ) + .unwrap(); + let cfg = SyndromeConfig::default(); + let result = compute_syndrome(&system, &cfg).unwrap(); + let oracle = compute_syndrome_dense(&system, &cfg).unwrap(); + assert!((result.energy - oracle.energy).abs() < ORACLE_TOL); +} + +#[test] +fn h0_nullity_of_connected_identity_sheaf() { + // For the identity sheaf on a connected graph, H⁰ = constants: the + // Laplacian nullity equals the stalk dimension. Computed with LOBPCG, + // never dominant power iteration. + let dim = 3; + let system = identity_system(4, dim, &[(0, 1), (1, 2), (2, 3), (0, 3)], |_| vec![0.0; dim], None); + let summary = spectral_summary( + &system.operator, + &system.weights.values, + dim + 2, + 0xC0FFEE, + 1e-8, + &SolverConfig { tol: 1e-10, max_iter: 400 }, + ); + assert_eq!(summary.nullity_estimate, dim, "eigenvalues: {:?}", summary.smallest_eigenvalues); + let gap = summary.spectral_gap.expect("nonzero eigenvalue expected"); + assert!(gap > 0.1, "spectral gap = {gap}"); +} + +#[test] +fn oracle_and_production_witness_hashes_agree_on_small_graphs() { + // Below the dense-harmonic threshold both paths quantize to identical + // witnesses except for the solver id — compare canonical content. + let system = identity_system(3, 1, &[(0, 1), (1, 2), (0, 2)], |i| vec![i as f64], None); + let cfg = SyndromeConfig::default(); + let a = compute_syndrome(&system, &cfg).unwrap().witness; + let b = compute_syndrome_dense(&system, &cfg).unwrap().witness; + assert_eq!(a.energy_q, b.energy_q); + assert_eq!(a.support, b.support); + assert_eq!(a.harmonic.coordinates_q, b.harmonic.coordinates_q); + assert_eq!(a.operator_hash, b.operator_hash); +} diff --git a/crates/ruvector-cohomology/tests/property.rs b/crates/ruvector-cohomology/tests/property.rs new file mode 100644 index 0000000000..9995bf37c7 --- /dev/null +++ b/crates/ruvector-cohomology/tests/property.rs @@ -0,0 +1,194 @@ +//! Property tests on deterministic pseudo-random systems: syndrome +//! invariants, zero false contradictions on coherent constructions, dynamic +//! flush vs. batch equivalence, and repair localization. + +mod common; + +use common::{identity_system, ring_engine, Rng}; +use ruvector_cohomology::dynamic::{CohomologyEdit, DynamicCohomology}; +use ruvector_cohomology::repair::RepairPolicy; +use ruvector_cohomology::syndrome::{compute_syndrome, SyndromeConfig}; +use ruvector_cohomology::EdgeKey; + +/// Deterministic random connected graph: a ring plus chords. +fn random_edges(n: u64, chords: u64, rng: &mut Rng) -> Vec<(u64, u64)> { + let mut edges: Vec<(u64, u64)> = (0..n).map(|i| (i, (i + 1) % n)).collect(); + let mut added = 0; + while added < chords { + let a = rng.next_u64() % n; + let b = rng.next_u64() % n; + let key = if a < b { (a, b) } else { (b, a) }; + if a != b && !edges.contains(&key) && !edges.contains(&(key.1, key.0)) { + edges.push(key); + added += 1; + } + } + edges +} + +#[test] +fn coherent_systems_have_zero_certificates() { + // Acceptance gate: zero false contradiction certificates on generated + // coherent systems (b = δx by construction). + for seed in 0..10u64 { + let mut rng = Rng(seed.wrapping_mul(0x1234_5678).wrapping_add(1)); + let n = 8 + seed % 8; + let dim = 1 + (seed % 3) as usize; + let edges = random_edges(n, n / 2, &mut rng); + let x: Vec> = (0..n) + .map(|_| (0..dim).map(|_| rng.signed_unit() * 5.0).collect()) + .collect(); + // Compute canonical edge order first, then b_e = x_v − x_u. + let mut sorted = edges.clone(); + sorted.sort_by_key(|&(a, b)| if a < b { (a, b) } else { (b, a) }); + let system = identity_system(n, dim, &edges, |i| { + let (a, b) = sorted[i]; + let (u, v) = if a < b { (a, b) } else { (b, a) }; + (0..dim).map(|d| x[v as usize][d] - x[u as usize][d]).collect() + }, None); + let result = compute_syndrome(&system, &SyndromeConfig::default()).unwrap(); + assert!( + result.energy < 1e-16, + "false contradiction: seed {seed}, energy {}", + result.energy + ); + } +} + +#[test] +fn syndrome_is_w_orthogonal_to_image() { + for seed in 0..5u64 { + let mut rng = Rng(seed + 77); + let n = 10; + let dim = 2; + let edges = random_edges(n, 6, &mut rng); + let weights: Vec = (0..edges.len()).map(|_| 0.1 + rng.signed_unit().abs() * 3.0).collect(); + let mut obs_rng = Rng(seed + 1000); + let system = identity_system(n, dim, &edges, |_| { + let mut r = Rng(obs_rng.next_u64()); + (0..dim).map(|_| r.signed_unit() * 2.0).collect() + }, Some(weights)); + let result = compute_syndrome(&system, &SyndromeConfig::default()).unwrap(); + + let op = &system.operator; + let mut weighted = result.residual.data.clone(); + for (i, e) in op.edge_blocks().iter().enumerate() { + for v in &mut weighted[e.offset..e.offset + e.dim] { + *v *= system.weights.values[i]; + } + } + let mut back = vec![0.0; op.dim_c0()]; + op.apply_transpose(&weighted, &mut back); + let defect = back.iter().map(|v| v * v).sum::().sqrt(); + assert!(defect < 1e-8, "seed {seed}: ‖δᵀWs‖ = {defect}"); + } +} + +#[test] +fn dynamic_flush_matches_batch() { + // Acceptance gate: dynamic flush() energy agrees with fresh batch + // assembly within 1e-8, and witnesses are identical. + let mut engine = ring_engine(20, 3); + let mut rng = Rng(4242); + for _ in 0..30 { + let a = rng.next_u64() % 20; + let b = (a + 1) % 20; + let value: Vec = (0..3).map(|_| rng.signed_unit()).collect(); + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { a, b, value }) + .accepted); + } + let flushed = engine.flush().unwrap(); + let batch_system = engine.build_system().unwrap(); + let batch = compute_syndrome(&batch_system, &SyndromeConfig::default()).unwrap(); + assert!( + (flushed.energy - batch.energy).abs() < 1e-8, + "flush {} vs batch {}", + flushed.energy, + batch.energy + ); + assert_eq!(flushed.witness.hash, batch.witness.hash); +} + +#[test] +fn estimate_is_exact_after_flush_and_marks_dirty_after_edit() { + let mut engine = ring_engine(12, 2); + let flushed = engine.flush().unwrap(); + let est = engine.estimate(); + assert!(est.is_exact); + assert!((est.clean_energy - flushed.energy).abs() < 1e-12); + + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { a: 0, b: 1, value: vec![1.0, 0.0] }) + .accepted); + let est = engine.estimate(); + assert!(!est.is_exact); + assert!(est.dirty_cell_count >= 1); +} + +#[test] +fn repair_localizes_planted_contradiction() { + // Acceptance gate: planted contradiction localization + repair drives + // syndrome energy down; the planted edge is in the repair support. + // + // On a bare ring the syndrome is a harmonic 1-form and distributes + // uniformly over the cycle — no observation can localize which edge is + // wrong. Localization requires trust asymmetry: the planted edge is the + // least-trusted one (lowest weight), so the weighted projection + // concentrates the residual there. + let mut engine = ring_engine(16, 2); + let planted = EdgeKey::new(3, 4).unwrap(); + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { a: 3, b: 4, value: vec![5.0, -5.0] }) + .accepted); + assert!(engine + .apply_edit(CohomologyEdit::UpdateWeight { a: 3, b: 4, weight: 0.1 }) + .accepted); + + let syndrome = engine.flush().unwrap(); + assert!(syndrome.energy > 1.0); + // Localization: planted edge ranks first. + assert_eq!(syndrome.ranked_support[0].edge, planted); + + let plan = engine + .propose_repair(RepairPolicy { lambda: 0.05, ..RepairPolicy::default() }) + .unwrap(); + assert!(plan.corrections.iter().any(|(k, _)| *k == planted), "support: {:?}", + plan.corrections.iter().map(|(k, _)| *k).collect::>()); + assert!(plan.post_energy < syndrome.energy * 0.01, + "post {} vs pre {}", plan.post_energy, syndrome.energy); +} + +#[test] +fn repair_objective_near_exhaustive_optimum_on_triangle() { + // Tractable fixture: scalar triangle with one inconsistent edge. The + // group-lasso optimum corrects the planted edge; compare against a + // brute-force scan over single-edge corrections. + let mut engine = ring_engine(3, 1); + assert!(engine + .apply_edit(CohomologyEdit::UpdateObservation { a: 0, b: 1, value: vec![3.0] }) + .accepted); + let lambda = 0.05; + let plan = engine + .propose_repair(RepairPolicy { lambda, ..RepairPolicy::default() }) + .unwrap(); + + // Exhaustive: correcting edge e by magnitude m leaves defect (3 − m), + // residual energy (3−m)²/3 · ... — instead of the closed form, verify + // numerically that no single-edge correction of the planted edge with a + // scanned magnitude beats ADMM by more than 10%. + let mut best = f64::INFINITY; + for step in 0..=300 { + let m = step as f64 * 0.01; + let defect: f64 = 3.0 - m; + let residual_energy = defect * defect / 3.0; + let obj = 0.5 * residual_energy + lambda * m; + best = best.min(obj); + } + assert!( + plan.objective <= best * 1.10 + 1e-9, + "ADMM objective {} vs exhaustive {}", + plan.objective, + best + ); +} diff --git a/docs/adr/ADR-272-dynamic-cohomological-error-correction.md b/docs/adr/ADR-272-dynamic-cohomological-error-correction.md new file mode 100644 index 0000000000..7859e06136 --- /dev/null +++ b/docs/adr/ADR-272-dynamic-cohomological-error-correction.md @@ -0,0 +1,120 @@ +# ADR-272: Dynamic Cohomological Error Correction — `ruvector-cohomology` + +- **Status**: Implemented (Phases 0–5 of issue #669) +- **Date**: 2026-07-12 +- **Issue**: [#669 — RFC: Dynamic Cohomological Error Correction](https://github.com/ruvnet/RuVector/issues/669) +- **Research spec**: `docs/research/dynamic-cohomological-error-correction.md` (branch `research/dynamic-cohomological-error-correction`) +- **Relates to**: `prime-radiant` (cellular sheaves), `ruvector-mincut` (dynamic cuts) + +--- + +## Context + +RuVector needs a mathematically correct **affine syndrome and repair engine** over +dynamic cellular sheaves: given heterogeneous local observations connected by +transformation rules, decide whether a globally coherent explanation exists, +localize the irreducible contradiction, emit a deterministic signed witness, and +compute the least costly repair or quarantine boundary. + +`prime-radiant` supplies sheaf structures and `ruvector-mincut` supplies dynamic +cuts, but the existing cohomology path had gaps (issue #669): closure-based +restriction maps (unhashable, untransposable), a dense-only Laplacian assembled +from identity differences rather than both endpoint restrictions, dominant-power +iteration used to infer nullity, no affine observations `b`, no canonical +witnesses, no sparse repair, and no exact dynamic synchronization. + +## Mathematical correction + +The previous ADR-level statement "nontrivial `H¹` means the sheaf admits no +global section" is **wrong for linear sheaves** — the zero section is always a +global section. The corrected semantics (now also reflected in +`prime-radiant/src/cohomology/obstruction.rs`): + +- `H⁰(G;F) = ker δ` — the space of **global sections**. +- `H¹(G;F) = C¹ / im δ` — **edge data unexplainable by any vertex assignment**. + +Production consistency is an affine problem. For observations `b ∈ C¹` and +confidence weights `W`: + +``` +x* = argmin_x ‖W^{1/2}(δx − b)‖² (weighted least squares) +s = b − δ(δᵀWδ)†δᵀW b = b − δx* (canonical syndrome) +``` + +`‖s‖_W = 0` ⇔ a globally coherent explanation exists; `‖s‖_W > 0` certifies an +irreducible contradiction whose weighted support localizes the responsible +constraints. The syndrome is gauge-invariant; only the representative `x*` needs +a gauge (minimum-norm, via CG from a zero start). + +## Decision + +Ship a new crate `crates/ruvector-cohomology` (pure Rust; deps: `serde`, +`sha2`, `thiserror`; optional `mincut` feature → `ruvector-mincut`): + +1. **Explicit operators** (`operator.rs`): `LinearRestriction` trait + + serializable `RestrictionMap` families (identity, scale, projection, + orthogonal, bounded dense) with `apply`/`apply_transpose`, sound norm + bounds, canonical SHA-256 hashes, and boundary validation (dimension, + norm, orthogonality, finiteness) before allocation. +2. **Block-sparse coboundary** (`block_sparse.rs`): deterministic sorted + indexing and orientation (tail = lower id), matrix-free `δ`, `δᵀ`, + `δᵀWδ`, dense assembly only for the reference oracle. +3. **Affine syndrome** (`affine.rs`, `syndrome.rs`): observations + weights + + gauge; production path = matrix-free CG on the normal equations with a + residual certificate; reference path = column-pivoted rank-revealing + Householder QR (exact); ranked support ordered on **quantized** energies. +4. **Harmonic canonicalization** (`harmonic.rs`): harmonic basis via full QR + null space, canonicalized deterministically with QR against a hashed probe + (degenerate subspaces included); LOBPCG (never dominant power iteration) + for nullity/spectral-gap estimates. +5. **Group-sparse repair** (`repair.rs`, `solvers/admm.rs`): group-lasso ADMM + over per-edge cost `c_e = α·business + β·security + γ·latency + η·reversal`, + protected edges hard-zero unless explicitly authorized, optional debias + refit on the selected support so the repair cancels its contradiction + exactly. Diagnosis is separate from authorization to act. +6. **Dynamic maintenance** (`dynamic.rs`, `partition.rs`): bounded cell + partition, all eight edit classes, dirty-cell tracking, cheap + `estimate()`, exact `flush()` (equivalent to batch assembly; verified to + 1e-8 in tests, hash-identical witnesses), periodic partition rebuild to + bound fragmentation drift. +7. **Orthogonal transport** (`transport.rs`): frame updates transport state + exactly (`x_new = Q_new Q_oldᵀ x_old`, `ρ ← ρ Rᵀ`) leaving the syndrome + invariant; cycle-consistency defect and a coherent-negative-control guard + gate restriction-map updates. +8. **Mincut coupling** (`mincut_bridge.rs`): syndrome → per-edge tension + (`τ_e = α‖s_e‖ + β·leverage + γ·uncertainty + ζ·severity`), quarantine + boundary via deterministic internal Dinic max-flow between contaminated + seeds and the healthy region (optional global cut via `ruvector-mincut`), + isolate-first vs repair-first policies, signed intervention receipts with + post-intervention verification. +9. **Canonical witnesses** (`witness.rs`): epochs, sorted identifiers, + operator/observation/weight hashes, gauge + solver config, quantized + energies/coordinates/support, repair summary, SHA-256 seal. Solver float + noise never enters the hash — only quantized values do. + +## Consequences + +- Contradiction detection, localization, repair, and containment are one + deterministic operator usable by Ruflo agent memory, policy consistency, + embedding migration, telemetry reconciliation, and Byzantine isolation + (Phase 6 integrations build on this crate). +- `prime-radiant` keeps its existing types; this crate adapts rather than + replaces them. Replacement of the legacy spectrum path can proceed once + parity gates pass (per the RFC's migration rule). +- Known scope notes: per-edge weights are scalar (block-diagonal `W` per edge + stalk is a straightforward extension); statistical leverage is currently + the energy-fraction proxy; the 100k-vertex performance gate needs the + reference target hardware — the bench suite (`benches/`) covers the edit + latency, flush, localization, repair, and containment dimensions. + +## Verified properties (test suite) + +- Exact dense-oracle agreement ≤ 1e-10; closed-form cycle syndrome match. +- Zero false contradiction certificates on coherent constructions, including + under random orthogonal frame drift (energy invariant to 1e-9). +- `flush()` ≡ batch (energy and witness hash) after mixed edit streams. +- 100 repeated runs and permuted insertion orders → identical witness hash. +- Group-lasso repair objective within 10% of exhaustive optimum on tractable + fixtures; protected edges immutable without authorization. +- Byzantine cluster quarantined along its cheap bridge boundary; combined + containment loop verifies post-repair energy under threshold.