gioser

tawasuyu/hifas

Motor en Rust para decidir, con demostración y no a ojo, cuándo un conjunto de réplicas local-first puede operar sin coordinación y cuándo no — y, cuando la respuesta es "sí, dentro de límites", proveer el mecanismo que lo garantiza.

Rust 24Markdown 15TOML 1358 Sutichaykuna

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 · ruwaq Sergio · (3 killa ñawpaq) · tayta 94482d292 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 25

src/cohomology.rs

src/gf2.rs

src/lib.rs

  • musuq mod gf2 pub

src/verdict.rs

tests/m1.rs

5 qillqakuna · +240 −5

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));
+    }
+}