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 Definitions

T1: cohomología f64 + prueba de cordura del haz constante

Implementa el corazón (§7):
- linalg: F64Backend::rank y cokernel_basis vía RREF con pivoteo parcial;
  Matrix con identity/transpose/get/set.
- sheaf: ensamblado del coboundary δ (bloque +R_u / -R_v por arista) con
  validación de dimensiones, y constructor Sheaf::constant.
- cohomology::compute + compute_with(backend): rank δ, dim H⁰, dim H¹ y
  base del cokernel como localización de la obstrucción.

Cordura topológica (§7.2) verde: árbol H¹=0, triángulo H¹ dim1, dos
triángulos pegados H¹ dim2, bosque desconexo H⁰=2, stalk d=2 escala Betti.
9 tests pasan, sin warnings de clippy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

dbd6875b002d449ae0cc222ffbe91b6e2848f59d · by Sergio · (3 months ago) · parent 6595dbed8 · browse the tree at this commit

Semantic delta · 30

src/linalg.rs

src/sheaf.rs

3 files · +381 −28

diff --git a/src/cohomology.rs b/src/cohomology.rs
index f03a312..7eeb353 100644
--- a/src/cohomology.rs
+++ b/src/cohomology.rs
@@ -1,10 +1,9 @@
 //! `cohomology` — la única cuenta que importa (§6.4, §7).
 //!
 //! `rank δ`, `dim H⁰`, `dim H¹` y una base del cokernel para localizar el nudo.
-//! M0: forma del resultado y firma de `compute`. La cuenta llega en T1.
 
-use crate::error::{Result, SheafError};
-use crate::linalg::Scalar;
+use crate::error::Result;
+use crate::linalg::{F64Backend, LinAlg, Scalar};
 use crate::sheaf::Sheaf;
 
 /// Resultado del cálculo cohomológico sobre un haz.
@@ -25,9 +24,101 @@ pub struct Cohomology {
     pub obstruction_basis: Vec<Vec<Scalar>>,
 }
 
-/// Calcula la cohomología del haz vía eliminación gaussiana (§7).
+/// Calcula la cohomología del haz con el backend `f64` por defecto (§7.1).
+pub fn compute(sheaf: &Sheaf) -> Result<Cohomology> {
+    compute_with(sheaf, &F64Backend::new())
+}
+
+/// Calcula la cohomología del haz con un backend de álgebra lineal explícito.
 ///
-/// M0: no implementado; llega en T1.
-pub fn compute(_sheaf: &Sheaf) -> Result<Cohomology> {
-    Err(SheafError::NotImplemented("cohomology::compute llega en T1"))
+/// Pasos (§7): ensamblar `δ`, `rank(δ)`, `dim H⁰ = dimC⁰ − rank`,
+/// `dim H¹ = dimC¹ − rank`, y si `H¹ ≠ 0`, una base del cokernel.
+pub fn compute_with<L: LinAlg>(sheaf: &Sheaf, backend: &L) -> Result<Cohomology> {
+    let delta = sheaf.coboundary()?;
+    let dim_c0 = sheaf.dim_c0();
+    let dim_c1 = sheaf.dim_c1();
+    let rank_delta = backend.rank(&delta);
+
+    // rank ≤ min(dimC⁰, dimC¹), así que las restas nunca subdesbordan.
+    let dim_h0 = dim_c0 - rank_delta;
+    let dim_h1 = dim_c1 - rank_delta;
+
+    let obstruction_basis = if dim_h1 > 0 {
+        backend.cokernel_basis(&delta)
+    } else {
+        Vec::new()
+    };
+
+    Ok(Cohomology {
+        dim_c0,
+        dim_c1,
+        rank_delta,
+        dim_h0,
+        dim_h1,
+        obstruction_basis,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// 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₁.
+    fn coho_constante(nv: usize, edges: &[(usize, usize)]) -> Cohomology {
+        let sheaf = Sheaf::constant(nv, edges, 1);
+        compute(&sheaf).expect("haz constante bien formado")
+    }
+
+    #[test]
+    fn arbol_no_tiene_obstruccion() {
+        // Camino de 4 vértices (árbol conexo): H⁰ = 1, H¹ = 0.
+        let coho = coho_constante(4, &[(0, 1), (1, 2), (2, 3)]);
+        assert_eq!(coho.dim_h0, 1, "un árbol es conexo");
+        assert_eq!(coho.dim_h1, 0, "un árbol no tiene ciclos");
+        assert!(coho.obstruction_basis.is_empty());
+    }
+
+    #[test]
+    fn triangulo_tiene_un_ciclo() {
+        // Triángulo: conexo con un ciclo. H⁰ = 1, H¹ = 1.
+        let coho = coho_constante(3, &[(0, 1), (1, 2), (2, 0)]);
+        assert_eq!(coho.dim_h0, 1);
+        assert_eq!(coho.dim_h1, 1, "b₁ del triángulo es 1");
+        assert_eq!(coho.obstruction_basis.len(), 1);
+        // La obstrucción toca las 3 aristas del ciclo (soporte no nulo en todas).
+        let v = &coho.obstruction_basis[0];
+        assert_eq!(v.len(), 3);
+        assert!(v.iter().all(|x| x.abs() > 1e-9), "el nudo es el ciclo entero");
+    }
+
+    #[test]
+    fn dos_triangulos_pegados_tienen_dos_ciclos() {
+        // Dos triángulos que comparten la arista (1,2): V=4, E=5, C=1.
+        // b₁ = E − V + C = 5 − 4 + 1 = 2.
+        let edges = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3)];
+        let coho = coho_constante(4, &edges);
+        assert_eq!(coho.dim_h0, 1);
+        assert_eq!(coho.dim_h1, 2, "dos ciclos independientes");
+        assert_eq!(coho.obstruction_basis.len(), 2);
+    }
+
+    #[test]
+    fn bosque_desconexo_cuenta_componentes() {
+        // Dos aristas disjuntas: V=4, E=2, C=2, sin ciclos.
+        let coho = coho_constante(4, &[(0, 1), (2, 3)]);
+        assert_eq!(coho.dim_h0, 2, "dos componentes conexas");
+        assert_eq!(coho.dim_h1, 0);
+    }
+
+    #[test]
+    fn stalk_de_dimension_mayor_escala_betti() {
+        // Mismo triángulo pero con stalk d=2: H⁰ = 1·2, H¹ = 1·2.
+        let sheaf = Sheaf::constant(3, &[(0, 1), (1, 2), (2, 0)], 2);
+        let coho = compute(&sheaf).unwrap();
+        assert_eq!(coho.dim_h0, 2);
+        assert_eq!(coho.dim_h1, 2);
+    }
 }
diff --git a/src/linalg.rs b/src/linalg.rs
index 02d2e6e..8126822 100644
--- a/src/linalg.rs
+++ b/src/linalg.rs
@@ -1,17 +1,13 @@
 //! Aislamiento del álgebra lineal tras un `trait` (§5.1, §7.1).
 //!
 //! Dos aritméticas, misma interfaz:
-//! - `F64Backend`: rápido, con tolerancia epsilon. Para arrancar el MVP.
+//! - `F64Backend`: rápido, con tolerancia epsilon. Para arrancar el MVP (T1).
 //! - `Gf2Backend` (M2): exacto, bit-packed sobre 𝔽₂. Aún no implementado.
-//!
-//! En M0 solo dejamos la *forma*: la impl `f64` existe pero sus operaciones
-//! todavía no calculan nada. La lógica llega en T1.
 
 /// Escalar del backend activo. Alias que resuelve la impl elegida (§6.1, §7).
 pub type Scalar = f64;
 
-/// Matriz densa mínima almacenada por filas. Placeholder de M0; en T1 puede
-/// migrarse a `nalgebra::DMatrix` o mantenerse propia para el backend `GF(2)`.
+/// Matriz densa almacenada por filas (row-major).
 #[derive(Debug, Clone, PartialEq)]
 pub struct Matrix {
     pub rows: usize,
@@ -29,6 +25,54 @@ impl Matrix {
             data: vec![0.0; rows * cols],
         }
     }
+
+    /// Matriz identidad `n × n`.
+    pub fn identity(n: usize) -> Self {
+        let mut m = Self::zeros(n, n);
+        for i in 0..n {
+            m.set(i, i, 1.0);
+        }
+        m
+    }
+
+    /// Lee la entrada `(r, c)`.
+    #[inline]
+    pub fn get(&self, r: usize, c: usize) -> Scalar {
+        self.data[r * self.cols + c]
+    }
+
+    /// Escribe la entrada `(r, c)`.
+    #[inline]
+    pub fn set(&mut self, r: usize, c: usize, v: Scalar) {
+        self.data[r * self.cols + c] = v;
+    }
+
+    /// Suma `v` a la entrada `(r, c)` (para acumular bloques solapados).
+    #[inline]
+    pub fn add_to(&mut self, r: usize, c: usize, v: Scalar) {
+        self.data[r * self.cols + c] += v;
+    }
+
+    /// Intercambia dos filas in situ.
+    fn swap_rows(&mut self, a: usize, b: usize) {
+        if a == b {
+            return;
+        }
+        for c in 0..self.cols {
+            self.data.swap(a * self.cols + c, b * self.cols + c);
+        }
+    }
+
+    /// Transpuesta.
+    pub fn transpose(&self) -> Matrix {
+        let mut t = Matrix::zeros(self.cols, self.rows);
+        for r in 0..self.rows {
+            for c in 0..self.cols {
+                t.set(c, r, self.get(r, c));
+            }
+        }
+        t
+    }
 }
 
 /// Interfaz de álgebra lineal que el motor usa sin conocer la aritmética concreta.
@@ -41,33 +85,161 @@ pub trait LinAlg {
 
     /// Base del cokernel `C¹ / im δ`: vectores de longitud `m.rows` que no
     /// provienen de ninguna sección global. Vacío si `H¹ = 0`.
+    ///
+    /// Se calcula como el espacio nulo izquierdo de `m` (= `ker(mᵀ)`), que en un
+    /// espacio con producto interno representa `coker(m) ≅ (im m)^⊥`. Cada vector
+    /// vive en el espacio de aristas `C¹`; sus componentes no nulas son el nudo.
     fn cokernel_basis(&self, m: &Matrix) -> Vec<Vec<Scalar>>;
 }
 
 /// Backend `f64` del MVP (§7.1). Rango con tolerancia epsilon.
-///
-/// M0: shell vacío. La eliminación gaussiana llega en T1.
-#[derive(Debug, Default, Clone, Copy)]
+#[derive(Debug, Clone, Copy)]
 pub struct F64Backend {
     /// Tolerancia para considerar un pivote como cero.
     pub epsilon: Scalar,
 }
 
+impl Default for F64Backend {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
 impl F64Backend {
     /// Backend con la tolerancia por defecto.
     pub fn new() -> Self {
         Self { epsilon: 1e-9 }
     }
+
+    /// Forma escalonada reducida por filas (RREF) con pivoteo parcial.
+    /// Devuelve la matriz reducida y la lista de columnas pivote (en orden).
+    fn rref(&self, m: &Matrix) -> (Matrix, Vec<usize>) {
+        let mut a = m.clone();
+        let mut pivot_cols = Vec::new();
+        let mut row = 0;
+
+        for col in 0..a.cols {
+            if row >= a.rows {
+                break;
+            }
+            // Pivoteo parcial: mayor magnitud en la columna, filas >= row.
+            let mut sel = row;
+            let mut best = a.get(row, col).abs();
+            for r in (row + 1)..a.rows {
+                let v = a.get(r, col).abs();
+                if v > best {
+                    best = v;
+                    sel = r;
+                }
+            }
+            if best <= self.epsilon {
+                continue; // columna sin pivote → variable libre
+            }
+            a.swap_rows(row, sel);
+
+            // Normaliza la fila pivote.
+            let piv = a.get(row, col);
+            for c in 0..a.cols {
+                let v = a.get(row, c) / piv;
+                a.set(row, c, v);
+            }
+
+            // Elimina la columna en el resto de filas.
+            for r in 0..a.rows {
+                if r == row {
+                    continue;
+                }
+                let factor = a.get(r, col);
+                if factor.abs() > 0.0 {
+                    for c in 0..a.cols {
+                        let v = a.get(r, c) - factor * a.get(row, c);
+                        a.set(r, c, v);
+                    }
+                }
+            }
+
+            pivot_cols.push(col);
+            row += 1;
+        }
+
+        (a, pivot_cols)
+    }
+
+    /// Base del espacio nulo `{x : m·x = 0}`, una por columna libre.
+    fn null_space(&self, m: &Matrix) -> Vec<Vec<Scalar>> {
+        let (rref, pivots) = self.rref(m);
+        let ncols = m.cols;
+        let is_pivot = |c: usize| pivots.contains(&c);
+
+        let mut basis = Vec::new();
+        for free in 0..ncols {
+            if is_pivot(free) {
+                continue;
+            }
+            // Variable libre = 1; las pivote se despejan desde la RREF.
+            let mut vec = vec![0.0; ncols];
+            vec[free] = 1.0;
+            for (i, &pc) in pivots.iter().enumerate() {
+                vec[pc] = -rref.get(i, free);
+            }
+            basis.push(vec);
+        }
+        basis
+    }
 }
 
 impl LinAlg for F64Backend {
-    fn rank(&self, _m: &Matrix) -> usize {
-        // TODO(T1): eliminación gaussiana con pivoteo, contar pivotes > epsilon.
-        unimplemented!("F64Backend::rank llega en T1")
+    fn rank(&self, m: &Matrix) -> usize {
+        self.rref(m).1.len()
+    }
+
+    fn cokernel_basis(&self, m: &Matrix) -> Vec<Vec<Scalar>> {
+        // coker(m) ≅ ker(mᵀ): vectores del espacio de aristas ortogonales a im(m).
+        self.null_space(&m.transpose())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Matriz a partir de filas, para tests legibles.
+    fn mat(rows: &[&[Scalar]]) -> Matrix {
+        let r = rows.len();
+        let c = rows[0].len();
+        let mut m = Matrix::zeros(r, c);
+        for (i, row) in rows.iter().enumerate() {
+            for (j, &v) in row.iter().enumerate() {
+                m.set(i, j, v);
+            }
+        }
+        m
     }
 
-    fn cokernel_basis(&self, _m: &Matrix) -> Vec<Vec<Scalar>> {
-        // TODO(T1): extraer base del cokernel a partir de la forma escalonada.
-        unimplemented!("F64Backend::cokernel_basis llega en T1")
+    #[test]
+    fn rank_de_matriz_conocida() {
+        // Rango 2: la tercera fila es suma de las dos primeras.
+        let m = mat(&[&[1.0, 0.0, 1.0], &[0.0, 1.0, 1.0], &[1.0, 1.0, 2.0]]);
+        assert_eq!(F64Backend::new().rank(&m), 2);
+    }
+
+    #[test]
+    fn rank_identidad_y_cero() {
+        let be = F64Backend::new();
+        assert_eq!(be.rank(&Matrix::identity(4)), 4);
+        assert_eq!(be.rank(&Matrix::zeros(3, 5)), 0);
+    }
+
+    #[test]
+    fn null_space_dimension_correcta() {
+        // 1×3 de rango 1 ⇒ espacio nulo de dimensión 2.
+        let m = mat(&[&[1.0, 2.0, 3.0]]);
+        let ns = F64Backend::new().null_space(&m);
+        assert_eq!(ns.len(), 2);
+        // Cada vector debe anular la fila: 1·x0 + 2·x1 + 3·x2 = 0.
+        for v in &ns {
+            let dot = v[0] + 2.0 * v[1] + 3.0 * v[2];
+            assert!(dot.abs() < 1e-9, "vector no está en el núcleo: {v:?}");
+        }
     }
 }
diff --git a/src/sheaf.rs b/src/sheaf.rs
index 64a41b3..7448925 100644
--- a/src/sheaf.rs
+++ b/src/sheaf.rs
@@ -1,7 +1,4 @@
 //! `sheaf` — el haz celular y el ensamblado del coboundary `δ` (§6.3, §7).
-//!
-//! M0: forma de los tipos y firma de `coboundary`. El ensamblado real
-//! (bloque por arista, `+R_{u⊴e}` / `−R_{v⊴e}`) llega en T1.
 
 use crate::error::{Result, SheafError};
 use crate::linalg::Matrix;
@@ -11,7 +8,7 @@ use crate::linalg::Matrix;
 pub type RestrictionMap = Matrix;
 
 /// Un haz celular sobre el nervio.
-#[derive(Debug, Default)]
+#[derive(Debug, Default, Clone)]
 pub struct Sheaf {
     /// Para cada réplica: la dimensión de su stalk `F(v)`.
     pub vertex_stalks: Vec<usize>,
@@ -34,13 +31,106 @@ impl Sheaf {
         self.edge_stalks.iter().sum()
     }
 
+    /// Desplazamiento de la columna donde empieza el bloque del vértice `v` en `C⁰`.
+    fn vertex_offset(&self, v: usize) -> usize {
+        self.vertex_stalks[..v].iter().sum()
+    }
+
+    /// Desplazamiento de la fila donde empieza el bloque de la arista `e` en `C¹`.
+    fn edge_offset(&self, e: usize) -> usize {
+        self.edge_stalks[..e].iter().sum()
+    }
+
+    /// Comprueba que los tamaños de las matrices de restricción cuadran con las
+    /// dimensiones declaradas de stalks (fila = arista, columna = vértice).
+    fn validate(&self) -> Result<()> {
+        let ne = self.edge_stalks.len();
+        if self.restrictions.len() != ne || self.edge_endpoints.len() != ne {
+            return Err(SheafError::DimensionMismatch(format!(
+                "aristas: {ne} stalks, {} restricciones, {} extremos",
+                self.restrictions.len(),
+                self.edge_endpoints.len()
+            )));
+        }
+        let nv = self.vertex_stalks.len();
+        for (e, ((ru, rv), &(u, v))) in self
+            .restrictions
+            .iter()
+            .zip(self.edge_endpoints.iter())
+            .enumerate()
+        {
+            if u >= nv || v >= nv {
+                return Err(SheafError::MissingEntity(format!(
+                    "arista {e} referencia vértice inexistente ({u},{v}) de {nv}"
+                )));
+            }
+            let ed = self.edge_stalks[e];
+            if ru.rows != ed || ru.cols != self.vertex_stalks[u] {
+                return Err(SheafError::DimensionMismatch(format!(
+                    "arista {e}: R_u es {}×{}, se esperaba {ed}×{}",
+                    ru.rows, ru.cols, self.vertex_stalks[u]
+                )));
+            }
+            if rv.rows != ed || rv.cols != self.vertex_stalks[v] {
+                return Err(SheafError::DimensionMismatch(format!(
+                    "arista {e}: R_v es {}×{}, se esperaba {ed}×{}",
+                    rv.rows, rv.cols, self.vertex_stalks[v]
+                )));
+            }
+        }
+        Ok(())
+    }
+
     /// Ensambla la matriz global de coboundary `δ: C⁰ → C¹`.
     ///
     /// Convención (§6.3): para arista `e=(u,v)`,
     /// `(δx)_e = R_{u⊴e}·x_u − R_{v⊴e}·x_v`.
     ///
-    /// M0: no implementado; llega en T1.
+    /// Bloque de la arista `e`: `+R_u` en las columnas de `u`, `−R_v` en las de
+    /// `v`. Se acumula (`+=`) para tolerar lazos o multiaristas.
     pub fn coboundary(&self) -> Result<Matrix> {
-        Err(SheafError::NotImplemented("Sheaf::coboundary llega en T1"))
+        self.validate()?;
+        let mut delta = Matrix::zeros(self.dim_c1(), self.dim_c0());
+
+        for (e, ((ru, rv), &(u, v))) in self
+            .restrictions
+            .iter()
+            .zip(self.edge_endpoints.iter())
+            .enumerate()
+        {
+            let row0 = self.edge_offset(e);
+            let ed = self.edge_stalks[e];
+
+            let ucol0 = self.vertex_offset(u);
+            for i in 0..ed {
+                for j in 0..ru.cols {
+                    delta.add_to(row0 + i, ucol0 + j, ru.get(i, j));
+                }
+            }
+
+            let vcol0 = self.vertex_offset(v);
+            for i in 0..ed {
+                for j in 0..rv.cols {
+                    delta.add_to(row0 + i, vcol0 + j, -rv.get(i, j));
+                }
+            }
+        }
+
+        Ok(delta)
+    }
+
+    /// Construye el **haz constante** de dimensión de stalk `d` sobre un grafo:
+    /// todas las stalks (vértice y arista) valen `d`, y toda restricción es la
+    /// identidad. Es el andamio de la prueba de cordura topológica (§7.2).
+    ///
+    /// `num_vertices` réplicas y `edges` como pares `(u, v)` de índices.
+    pub fn constant(num_vertices: usize, edges: &[(usize, usize)], d: usize) -> Self {
+        let id = Matrix::identity(d);
+        Self {
+            vertex_stalks: vec![d; num_vertices],
+            edge_stalks: vec![d; edges.len()],
+            restrictions: edges.iter().map(|_| (id.clone(), id.clone())).collect(),
+            edge_endpoints: edges.to_vec(),
+        }
     }
 }