T3: backend GF(2) exacto + migración a aritmética sin epsilon
- gf2: Gf2Backend bit-packed con u64 (BitMatrix, eliminación gaussiana
sobre F2 vía XOR de filas). rank y cokernel_basis exactos, sin epsilon.
Reduce las entradas f64 mod 2 (-1 ≡ 1). Detrás del mismo trait LinAlg.
- cohomology/tests: cross-validan que f64 y GF(2) coinciden en H⁰/H¹.
- verdict: test de 'todos los ciclos obstruidos' (dos triángulos → 2 nudos).
- tests/m1.rs: M1 verificado también en exacto; localización idéntica en
ambos backends.
Base matemática: sobre GF(2) el espacio de ciclos tiene dim E−V+C = b₁,
así la cordura §7.2 y M1 dan resultados idénticos que f64 pero exactos.
19 tests, clippy limpio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
018b9715e34418b865a7dfbebb213dc72fdd3985 · por Sergio · 2026-07-01T18:32:00Z (hace 3 meses) · padre 94482d292 · ver el árbol en este commit
diff --git a/src/cohomology.rs b/src/cohomology.rs
index 7eeb353..95f76c4 100644
--- a/src/cohomology.rs
+++ b/src/cohomology.rs
@@ -62,14 +62,24 @@ pub fn compute_with<L: LinAlg>(sheaf: &Sheaf, backend: &L) -> Result<Cohomology>
#[cfg(test)]
mod tests {
use super::*;
+ use crate::gf2::Gf2Backend;
/// Prueba de cordura del haz constante (§7.2): para stalk de dimensión `d`,
/// `dim H⁰ = (#componentes)·d` y `dim H¹ = (primer nº de Betti)·d`.
///
- /// Aquí verificamos con `d = 1`, donde H⁰ = #componentes y H¹ = b₁.
+ /// Aquí verificamos con `d = 1`, donde H⁰ = #componentes y H¹ = b₁. Además
+ /// cross-validamos que el backend exacto `GF(2)` coincide con `f64` en las
+ /// dimensiones (sin epsilon): si difirieran, uno de los dos estaría mal.
fn coho_constante(nv: usize, edges: &[(usize, usize)]) -> Cohomology {
let sheaf = Sheaf::constant(nv, edges, 1);
- compute(&sheaf).expect("haz constante bien formado")
+ let f64_coho = compute(&sheaf).expect("haz constante bien formado");
+ let gf2_coho = compute_with(&sheaf, &Gf2Backend).expect("haz constante");
+ assert_eq!(
+ (f64_coho.dim_h0, f64_coho.dim_h1),
+ (gf2_coho.dim_h0, gf2_coho.dim_h1),
+ "f64 y GF(2) deben coincidir en las dimensiones cohomológicas"
+ );
+ f64_coho
}
#[test]
diff --git a/src/gf2.rs b/src/gf2.rs
new file mode 100644
index 0000000..511083a
--- /dev/null
+++ b/src/gf2.rs
@@ -0,0 +1,170 @@
+//! Aritmética **exacta** sobre 𝔽₂ (§7.1), bit-packed con `u64`.
+//!
+//! La versión "topológica pura": sin epsilon. Reinterpreta las entradas de una
+//! `Matrix` `f64` módulo 2 (las restricciones del MVP viven en `{-1,0,1}`, y
+//! `-1 ≡ 1 (mód 2)`), y hace eliminación gaussiana sobre 𝔽₂ con XOR de filas.
+
+use crate::linalg::{LinAlg, Matrix, Scalar};
+
+/// Matriz densa sobre 𝔽₂: cada fila son `words_per_row` palabras de 64 bits.
+#[derive(Debug, Clone)]
+struct BitMatrix {
+ rows: usize,
+ cols: usize,
+ words_per_row: usize,
+ /// `rows * words_per_row` palabras, fila mayor.
+ data: Vec<u64>,
+}
+
+impl BitMatrix {
+ fn new(rows: usize, cols: usize) -> Self {
+ let words_per_row = cols.div_ceil(64);
+ Self {
+ rows,
+ cols,
+ words_per_row,
+ data: vec![0; rows * words_per_row],
+ }
+ }
+
+ /// Construye la matriz binaria reduciendo cada entrada `f64` módulo 2.
+ fn from_matrix(m: &Matrix) -> Self {
+ let mut b = Self::new(m.rows, m.cols);
+ for r in 0..m.rows {
+ for c in 0..m.cols {
+ if (m.get(r, c).round() as i64).rem_euclid(2) == 1 {
+ b.set(r, c);
+ }
+ }
+ }
+ b
+ }
+
+ #[inline]
+ fn get(&self, r: usize, c: usize) -> bool {
+ let idx = r * self.words_per_row + (c >> 6);
+ (self.data[idx] >> (c & 63)) & 1 == 1
+ }
+
+ #[inline]
+ fn set(&mut self, r: usize, c: usize) {
+ let idx = r * self.words_per_row + (c >> 6);
+ self.data[idx] |= 1 << (c & 63);
+ }
+
+ fn swap_rows(&mut self, a: usize, b: usize) {
+ if a == b {
+ return;
+ }
+ for w in 0..self.words_per_row {
+ self.data.swap(a * self.words_per_row + w, b * self.words_per_row + w);
+ }
+ }
+
+ /// `fila[dst] ^= fila[src]` (suma sobre 𝔽₂).
+ fn xor_row_into(&mut self, dst: usize, src: usize) {
+ for w in 0..self.words_per_row {
+ self.data[dst * self.words_per_row + w] ^= self.data[src * self.words_per_row + w];
+ }
+ }
+
+ /// Lleva la matriz a RREF sobre 𝔽₂ in situ; devuelve las columnas pivote.
+ fn rref(&mut self) -> Vec<usize> {
+ let mut pivots = Vec::new();
+ let mut row = 0;
+ for col in 0..self.cols {
+ if row >= self.rows {
+ break;
+ }
+ // Busca una fila (>= row) con bit en esta columna.
+ let Some(sel) = (row..self.rows).find(|&r| self.get(r, col)) else {
+ continue; // columna libre
+ };
+ self.swap_rows(row, sel);
+ // Elimina la columna del resto de filas (arriba y abajo → RREF).
+ for r in 0..self.rows {
+ if r != row && self.get(r, col) {
+ self.xor_row_into(r, row);
+ }
+ }
+ pivots.push(col);
+ row += 1;
+ }
+ pivots
+ }
+
+ /// Base del espacio nulo `{x : self·x = 0}` sobre 𝔽₂, una por columna libre.
+ fn null_space(&self) -> Vec<Vec<Scalar>> {
+ let mut a = self.clone();
+ let pivots = a.rref();
+ let mut basis = Vec::new();
+ for free in 0..a.cols {
+ if pivots.contains(&free) {
+ continue;
+ }
+ let mut vec = vec![0.0; a.cols];
+ vec[free] = 1.0;
+ // Sobre 𝔽₂, el coeficiente de despeje es +rref[i][free] (−x ≡ x).
+ for (i, &pc) in pivots.iter().enumerate() {
+ if a.get(i, free) {
+ vec[pc] = 1.0;
+ }
+ }
+ basis.push(vec);
+ }
+ basis
+ }
+}
+
+/// Backend de álgebra lineal **exacto** sobre 𝔽₂ (§7.1).
+#[derive(Debug, Default, Clone, Copy)]
+pub struct Gf2Backend;
+
+impl LinAlg for Gf2Backend {
+ fn rank(&self, m: &Matrix) -> usize {
+ BitMatrix::from_matrix(m).rref().len()
+ }
+
+ fn cokernel_basis(&self, m: &Matrix) -> Vec<Vec<Scalar>> {
+ // coker(m) ≅ ker(mᵀ) sobre 𝔽₂: vectores del espacio de aristas.
+ BitMatrix::from_matrix(&m.transpose()).null_space()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn mat(rows: &[&[Scalar]]) -> Matrix {
+ let mut m = Matrix::zeros(rows.len(), rows[0].len());
+ for (i, row) in rows.iter().enumerate() {
+ for (j, &v) in row.iter().enumerate() {
+ m.set(i, j, v);
+ }
+ }
+ m
+ }
+
+ #[test]
+ fn rank_exacto_sobre_gf2() {
+ // Sobre 𝔽₂ la tercera fila = fila0 XOR fila1, luego rango 2.
+ let m = mat(&[&[1.0, 1.0, 0.0], &[0.0, 1.0, 1.0], &[1.0, 0.0, 1.0]]);
+ assert_eq!(Gf2Backend.rank(&m), 2);
+ }
+
+ #[test]
+ fn menos_uno_es_uno_mod_dos() {
+ // Fila [1,-1] y [−1,1] son idénticas mód 2 → rango 1.
+ let m = mat(&[&[1.0, -1.0], &[-1.0, 1.0]]);
+ assert_eq!(Gf2Backend.rank(&m), 1);
+ }
+
+ #[test]
+ fn cokernel_del_triangulo() {
+ // Incidencia (no signada) del triángulo sobre 𝔽₂: cokernel dim 1 = [1,1,1].
+ let delta = mat(&[&[1.0, 1.0, 0.0], &[0.0, 1.0, 1.0], &[1.0, 0.0, 1.0]]);
+ let basis = Gf2Backend.cokernel_basis(&delta);
+ assert_eq!(basis.len(), 1);
+ assert!(basis[0].iter().all(|&x| x == 1.0), "el ciclo toca las 3 aristas");
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 9a2e039..73b881e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -14,6 +14,7 @@
pub mod cell;
pub mod cohomology;
pub mod error;
+pub mod gf2;
pub mod linalg;
pub mod nerve;
pub mod oracle;
diff --git a/src/verdict.rs b/src/verdict.rs
index 8ce5e13..54676c7 100644
--- a/src/verdict.rs
+++ b/src/verdict.rs
@@ -90,3 +90,37 @@ impl fmt::Display for Verdict {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::cohomology::compute_with;
+ use crate::gf2::Gf2Backend;
+
+ #[test]
+ fn arbol_corre_libre() {
+ let sheaf = Sheaf::constant(3, &[(0, 1), (1, 2)], 1);
+ let coho = compute_with(&sheaf, &Gf2Backend).unwrap();
+ assert_eq!(Verdict::diagnose(&sheaf, &coho), Verdict::RunsFree);
+ }
+
+ #[test]
+ fn dos_triangulos_reportan_dos_nudos() {
+ // Dos triángulos que comparten la arista (1,2): b₁ = 2 → dos ciclos
+ // obstruidos, luego dos nudos (uno por generador del cokernel).
+ let edges = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3)];
+ let sheaf = Sheaf::constant(4, &edges, 1);
+ let coho = compute_with(&sheaf, &Gf2Backend).unwrap();
+ match Verdict::diagnose(&sheaf, &coho) {
+ Verdict::NeedsCoordination { knots } => {
+ assert_eq!(knots.len(), 2, "todos los ciclos obstruidos, no solo uno");
+ // Cada nudo es un ciclo real: al menos 3 aristas y 3 réplicas.
+ for k in &knots {
+ assert!(k.edges.len() >= 3);
+ assert!(k.replicas.len() >= 3);
+ }
+ }
+ Verdict::RunsFree => panic!("dos ciclos deberían obstruir"),
+ }
+ }
+}
diff --git a/tests/m1.rs b/tests/m1.rs
index 5b0b1b0..0fd7ff6 100644
--- a/tests/m1.rs
+++ b/tests/m1.rs
@@ -1,7 +1,9 @@
//! Criterio de éxito del hito M1 (§8): A da 0, B da ≠0, y la localización de B
//! apunta al ciclo correcto (entre las réplicas 1 y 2).
-use sheafsync::cohomology;
+use sheafsync::cohomology::{compute, compute_with};
+use sheafsync::gf2::Gf2Backend;
+use sheafsync::linalg::F64Backend;
use sheafsync::nerve::ReplicaId;
use sheafsync::sheaf::Sheaf;
use sheafsync::verdict::Verdict;
@@ -10,7 +12,7 @@ use sheafsync::verdict::Verdict;
fn corrida_a_corre_libre() {
// G-Counter compartido en camino acíclico (stalk = vector ℤ³).
let sheaf = Sheaf::constant(3, &[(0, 1), (1, 2)], 3);
- let coho = cohomology::compute(&sheaf).unwrap();
+ let coho = compute(&sheaf).unwrap();
assert_eq!(coho.dim_h1, 0, "el caso monótono no tiene obstrucción");
assert_eq!(Verdict::diagnose(&sheaf, &coho), Verdict::RunsFree);
}
@@ -19,7 +21,7 @@ fn corrida_a_corre_libre() {
fn corrida_b_coordina_con_nudo_localizado() {
// ResettableRegister: enlace doble irreconciliable entre réplicas 1 y 2.
let sheaf = Sheaf::constant(3, &[(0, 1), (1, 2), (1, 2)], 1);
- let coho = cohomology::compute(&sheaf).unwrap();
+ let coho = compute(&sheaf).unwrap();
assert_eq!(coho.dim_h1, 1, "el ciclo produce una obstrucción");
let verdict = Verdict::diagnose(&sheaf, &coho);
@@ -35,3 +37,21 @@ fn corrida_b_coordina_con_nudo_localizado() {
// Y son las dos aristas del enlace doble (índices 1 y 2), no la 0.
assert_eq!(knot.edges, vec![1, 2]);
}
+
+/// El veredicto de M1 no depende de la aritmética: `f64` (con epsilon) y `GF(2)`
+/// (exacta) coinciden en A y en B. Migración a exacto de T3.
+#[test]
+fn m1_coincide_en_f64_y_gf2() {
+ let sheaf_a = Sheaf::constant(3, &[(0, 1), (1, 2)], 3);
+ let sheaf_b = Sheaf::constant(3, &[(0, 1), (1, 2), (1, 2)], 1);
+
+ for (sheaf, esperado_h1) in [(&sheaf_a, 0), (&sheaf_b, 1)] {
+ let a = compute_with(sheaf, &F64Backend::new()).unwrap();
+ let b = compute_with(sheaf, &Gf2Backend).unwrap();
+ assert_eq!(a.dim_h1, esperado_h1);
+ assert_eq!(a.dim_h1, b.dim_h1);
+ assert_eq!(a.dim_h0, b.dim_h0);
+ // La localización coincide en ambos backends.
+ assert_eq!(Verdict::diagnose(sheaf, &a), Verdict::diagnose(sheaf, &b));
+ }
+}