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 11Markdown 1TOML 187 Definitions

T2: CRDTs GCounter/ResettableRegister + hito M1 de punta a punta

- cell: impl Cell para GCounter (join por máximo, is_monotone=true) y
  ResettableRegister (reset no monótono, is_monotone=false), con tests de
  las leyes de retículo y de la no-monotonía.
- verdict: Verdict::diagnose(sheaf, coho) localiza cada generador del
  cokernel en réplicas/aristas concretas (§6.5) + Display legible.
- main: hito M1 (§8). Corrida A (G-Counter, camino acíclico) → H¹=0 →
  corre libre. Corrida B (ResettableRegister, enlace doble irreconciliable
  entre réplicas 1 y 2) → H¹≠0 → nudo localizado entre réplicas 1 y 2.
- tests/m1.rs fija el criterio de éxito: A=0, B≠0, localización correcta.

13 tests pasan, clippy limpio. Modelado de frontera (§2.3/Q1) documentado
en comentarios: H¹≠0 nace de ciclos en el grafo de compartición.

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

94482d292d072d791f2244b8571bc30c262fde8a · by Sergio · (3 months ago) · parent dbd6875b0 · browse the tree at this commit

Semantic delta · 25

src/cell.rs

src/main.rs

src/verdict.rs

4 files · +304 −22

diff --git a/src/cell.rs b/src/cell.rs
index f2d94d9..966fca9 100644
--- a/src/cell.rs
+++ b/src/cell.rs
@@ -1,7 +1,7 @@
 //! `cell` — el dato local que sabe fundirse y proyectarse (§6.1).
 //!
-//! M0: solo la *forma* del trait y los tipos. Los CRDTs concretos
-//! (`GCounter`, `ResettableRegister`) y sus semánticas llegan en T2.
+//! Dos CRDTs con semántica controlada a mano (§10): un monótono (`GCounter`) y
+//! uno no monótono (`ResettableRegister`), que son las dos anclas del hito M1.
 
 use crate::linalg::Scalar;
 
@@ -20,21 +20,136 @@ pub trait Cell: Clone + PartialEq {
 
 /// CRDT mínimo del MVP: cuenta por réplica, fusiona por máximo. Siempre monótono.
 ///
-/// M0: forma únicamente; `impl Cell` llega en T2.
-#[derive(Debug, Clone, PartialEq)]
+/// Cada réplica solo incrementa su propia casilla; fusionar = máximo casilla por
+/// casilla. Nunca hay conflicto porque nunca se resta. Embebe en `ℤⁿ` (§2.3).
+#[derive(Debug, Clone, PartialEq, Eq)]
 pub struct GCounter {
-    /// Una casilla por réplica; fusionar = máximo casilla por casilla.
+    /// Una casilla por réplica.
     pub slots: Vec<u64>,
 }
 
+impl GCounter {
+    /// G-Counter en cero para `n` réplicas.
+    pub fn zeros(n: usize) -> Self {
+        Self { slots: vec![0; n] }
+    }
+
+    /// La réplica `i` incrementa su propia casilla en `by`.
+    pub fn increment(&mut self, i: usize, by: u64) {
+        self.slots[i] += by;
+    }
+
+    /// Valor total del contador (suma de casillas).
+    pub fn value(&self) -> u64 {
+        self.slots.iter().sum()
+    }
+}
+
+impl Cell for GCounter {
+    fn to_vector(&self) -> Vec<Scalar> {
+        self.slots.iter().map(|&s| s as Scalar).collect()
+    }
+
+    fn join(&self, other: &Self) -> Self {
+        let n = self.slots.len().max(other.slots.len());
+        let slots = (0..n)
+            .map(|i| {
+                let a = self.slots.get(i).copied().unwrap_or(0);
+                let b = other.slots.get(i).copied().unwrap_or(0);
+                a.max(b)
+            })
+            .collect();
+        Self { slots }
+    }
+
+    fn is_monotone() -> bool {
+        true
+    }
+}
+
 /// CRDT no monótono para el caso "difícil": permite reset/borrado.
-/// Sirve para provocar `H¹ ≠ 0` deliberadamente (§8, corrida B).
 ///
-/// M0: forma únicamente; `impl Cell` llega en T2.
-#[derive(Debug, Clone, PartialEq)]
+/// El registro guarda un valor y un contador de generación. Un `reset`
+/// incrementa la generación y puede **bajar** el valor: eso retracta conocimiento
+/// (§glosario), luego NO es monótono. Sirve para provocar `H¹ ≠ 0` (§8, corrida B).
+#[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ResettableRegister {
     /// Valor actual del registro.
     pub value: i64,
     /// Contador de generación; un reset lo incrementa (rompe la monotonicidad).
     pub generation: u64,
 }
+
+impl ResettableRegister {
+    /// Registro inicial con `value` en la generación 0.
+    pub fn new(value: i64) -> Self {
+        Self {
+            value,
+            generation: 0,
+        }
+    }
+
+    /// Sobrescribe el valor sin cambiar de generación (operación monótona-ish).
+    pub fn set(&mut self, value: i64) {
+        self.value = value;
+    }
+
+    /// Resetea: nueva generación con un valor que puede ser menor. NO monótono.
+    pub fn reset(&mut self, value: i64) {
+        self.generation += 1;
+        self.value = value;
+    }
+}
+
+impl Cell for ResettableRegister {
+    fn to_vector(&self) -> Vec<Scalar> {
+        vec![self.generation as Scalar, self.value as Scalar]
+    }
+
+    /// Gana la generación más alta; a igual generación, el mayor valor.
+    fn join(&self, other: &Self) -> Self {
+        match self.generation.cmp(&other.generation) {
+            std::cmp::Ordering::Greater => self.clone(),
+            std::cmp::Ordering::Less => other.clone(),
+            std::cmp::Ordering::Equal => Self {
+                generation: self.generation,
+                value: self.value.max(other.value),
+            },
+        }
+    }
+
+    fn is_monotone() -> bool {
+        false
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn gcounter_join_es_maximo_y_monotono() {
+        let a = GCounter { slots: vec![3, 0, 1] };
+        let b = GCounter { slots: vec![1, 5, 1] };
+        let j = a.join(&b);
+        assert_eq!(j.slots, vec![3, 5, 1]);
+        // Idempotente y conmutativo.
+        assert_eq!(a.join(&a), a);
+        assert_eq!(a.join(&b), b.join(&a));
+        // El join nunca baja respecto a ninguno de los dos (monotonía).
+        assert!(j.value() >= a.value() && j.value() >= b.value());
+        assert!(GCounter::is_monotone());
+    }
+
+    #[test]
+    fn resettable_no_es_monotono() {
+        let mut r = ResettableRegister::new(10);
+        let alto = r.clone();
+        r.reset(0); // nueva generación, valor MENOR: retracta conocimiento.
+        let fundido = alto.join(&r);
+        // El reset (generación mayor) gana pese a tener valor menor → no monótono.
+        assert_eq!(fundido.value, 0);
+        assert_eq!(fundido.generation, 1);
+        assert!(!ResettableRegister::is_monotone());
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 14aa160..1959110 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,87 @@
-//! Binario de `sheafsync`. En M0 solo saluda; las corridas A/B del hito M1
-//! (§8) se conectan aquí en T2.
+//! Binario de `sheafsync`. Hito M1 (§8): dos corridas de signo opuesto que
+//! prueban el núcleo entero de punta a punta.
+//!
+//! - Corrida A (G-Counter, monótono): compartición acíclica → `H¹ = 0` → corre libre.
+//! - Corrida B (ResettableRegister, no monótono): el reset introduce un enlace
+//!   irreconciliable sobre el dato ya compartido por las réplicas 1 y 2 → ciclo
+//!   localizado → `H¹ ≠ 0` → coordina ahí.
+
+use sheafsync::cell::{Cell, GCounter, ResettableRegister};
+use sheafsync::cohomology::{self, Cohomology};
+use sheafsync::sheaf::Sheaf;
+use sheafsync::verdict::Verdict;
 
 fn main() {
-    println!("hello sheaf");
+    println!("sheafsync — hito M1\n");
+    corrida_a();
+    println!();
+    corrida_b();
+}
+
+/// Imprime el veredicto de un haz ya construido.
+fn veredicto(nombre: &str, sheaf: &Sheaf) -> Cohomology {
+    let coho = cohomology::compute(sheaf).expect("haz bien formado");
+    let verdict = Verdict::diagnose(sheaf, &coho);
+    println!(
+        "  cohomología: dim H⁰ = {}, dim H¹ = {}  (rank δ = {}, dim C⁰ = {}, dim C¹ = {})",
+        coho.dim_h0, coho.dim_h1, coho.rank_delta, coho.dim_c0, coho.dim_c1
+    );
+    println!("  veredicto [{nombre}]: {verdict}");
+    coho
+}
+
+/// Corrida A — G-Counter monótono sobre 3 réplicas que comparten en camino.
+///
+/// Cada réplica incrementa solo su casilla y difunde el vector a sus vecinas.
+/// Al fundir por máximo siempre convergen; la compartición es acíclica, así que
+/// no hay obstrucción posible: `H¹ = 0`.
+fn corrida_a() {
+    println!("Corrida A — G-Counter (monótono: {})", GCounter::is_monotone());
+
+    // Demostración del CRDT: tres réplicas incrementan lo suyo y se funden.
+    let mut r0 = GCounter::zeros(3);
+    let mut r1 = GCounter::zeros(3);
+    let mut r2 = GCounter::zeros(3);
+    r0.increment(0, 5);
+    r1.increment(1, 3);
+    r2.increment(2, 7);
+    let fundido = r0.join(&r1).join(&r2);
+    println!(
+        "  merge de las 3 réplicas: {:?}  (valor total = {})",
+        fundido.slots,
+        fundido.value()
+    );
+
+    // Haz: stalk = el vector contador (ℤ³), restricciones = identidad (comparten
+    // el vector completo), topología = camino 0—1—2 (acíclica).
+    let sheaf = Sheaf::constant(3, &[(0, 1), (1, 2)], 3);
+    veredicto("A", &sheaf);
+}
+
+/// Corrida B — ResettableRegister no monótono sobre 3 réplicas en ciclo.
+///
+/// Las réplicas 1 y 2 comparten un registro. El reset (no monótono) impone un
+/// segundo enlace sobre ese mismo dato que no puede reconciliarse globalmente:
+/// forma un ciclo entre 1 y 2. El haz lo detecta y lo localiza ahí.
+fn corrida_b() {
+    println!(
+        "Corrida B — ResettableRegister (monótono: {})",
+        ResettableRegister::is_monotone()
+    );
+
+    // Demostración del CRDT: un reset con valor menor gana por generación.
+    let alto = ResettableRegister::new(10);
+    let mut reseteado = alto.clone();
+    reseteado.reset(0);
+    let fundido = alto.join(&reseteado);
+    println!(
+        "  merge tras reset: value = {} (gen {}) — el reset retracta el 10 previo",
+        fundido.value, fundido.generation
+    );
+
+    // Haz: stalk = valor del registro (escalar), restricciones = identidad.
+    // Aristas: 0—1 (single) y un DOBLE enlace 1—2 (el original + el que fuerza el
+    // reset), irreconciliables → ciclo localizado entre réplicas 1 y 2.
+    let sheaf = Sheaf::constant(3, &[(0, 1), (1, 2), (1, 2)], 1);
+    veredicto("B", &sheaf);
 }
diff --git a/src/verdict.rs b/src/verdict.rs
index 7bf5b8e..8ce5e13 100644
--- a/src/verdict.rs
+++ b/src/verdict.rs
@@ -2,9 +2,15 @@
 //!
 //! `H¹ = 0 → corre libre` vs `H¹ ≠ 0 → coordina aquí`, con localización.
 
+use std::collections::BTreeSet;
+use std::fmt;
+
 use crate::cohomology::Cohomology;
-use crate::error::Result;
 use crate::nerve::ReplicaId;
+use crate::sheaf::Sheaf;
+
+/// Umbral para considerar no nula una componente del vector de obstrucción.
+const SUPPORT_EPS: f64 = 1e-9;
 
 /// Un nudo: una obstrucción localizada entre réplicas/aristas concretas.
 #[derive(Debug, Clone, PartialEq)]
@@ -22,22 +28,65 @@ pub enum Verdict {
     RunsFree,
     /// `H¹ ≠ 0`: hace falta coordinar, con los nudos localizados.
     NeedsCoordination {
-        /// Los nudos detectados.
+        /// Los nudos detectados (uno por generador del cokernel).
         knots: Vec<Knot>,
     },
 }
 
 impl Verdict {
-    /// Deriva el veredicto a partir de la cohomología calculada.
+    /// Diagnostica la configuración a partir del haz y su cohomología.
     ///
-    /// M0: cuando `dim_h1 == 0` devuelve `RunsFree`; la localización de nudos
-    /// (poblar `Knot` desde `obstruction_basis`) llega en T2/T3.
-    pub fn from_cohomology(cohomology: &Cohomology) -> Result<Verdict> {
-        if cohomology.dim_h1 == 0 {
-            Ok(Verdict::RunsFree)
-        } else {
-            // TODO(T2/T3): mapear obstruction_basis → Knot con réplicas y aristas.
-            Ok(Verdict::NeedsCoordination { knots: Vec::new() })
+    /// Si `H¹ = 0` → `RunsFree`. Si no, cada vector de la base del cokernel es un
+    /// nudo independiente: las aristas con soporte no nulo lo forman, y sus
+    /// extremos son las réplicas implicadas (§7 paso 5).
+    pub fn diagnose(sheaf: &Sheaf, coho: &Cohomology) -> Verdict {
+        if coho.dim_h1 == 0 {
+            return Verdict::RunsFree;
+        }
+
+        // Offsets de cada arista dentro de C¹ (donde vive el vector de obstrucción).
+        let mut edge_offsets = Vec::with_capacity(sheaf.edge_stalks.len());
+        let mut acc = 0;
+        for &d in &sheaf.edge_stalks {
+            edge_offsets.push(acc);
+            acc += d;
+        }
+
+        let mut knots = Vec::new();
+        for basis_vec in &coho.obstruction_basis {
+            let mut edges = Vec::new();
+            let mut replicas = BTreeSet::new();
+            for (e, (&off, &d)) in edge_offsets.iter().zip(&sheaf.edge_stalks).enumerate() {
+                let touches = basis_vec[off..off + d].iter().any(|x| x.abs() > SUPPORT_EPS);
+                if touches {
+                    edges.push(e);
+                    let (u, v) = sheaf.edge_endpoints[e];
+                    replicas.insert(u);
+                    replicas.insert(v);
+                }
+            }
+            knots.push(Knot {
+                replicas: replicas.into_iter().map(ReplicaId).collect(),
+                edges,
+            });
+        }
+
+        Verdict::NeedsCoordination { knots }
+    }
+}
+
+impl fmt::Display for Verdict {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Verdict::RunsFree => write!(f, "H¹ = 0 → corre libre (sin jefe)"),
+            Verdict::NeedsCoordination { knots } => {
+                write!(f, "H¹ ≠ 0 → coordina aquí")?;
+                for knot in knots {
+                    let reps: Vec<usize> = knot.replicas.iter().map(|r| r.0).collect();
+                    write!(f, "\n    · nudo entre réplicas {reps:?} (aristas {:?})", knot.edges)?;
+                }
+                Ok(())
+            }
         }
     }
 }
diff --git a/tests/m1.rs b/tests/m1.rs
new file mode 100644
index 0000000..5b0b1b0
--- /dev/null
+++ b/tests/m1.rs
@@ -0,0 +1,37 @@
+//! 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::nerve::ReplicaId;
+use sheafsync::sheaf::Sheaf;
+use sheafsync::verdict::Verdict;
+
+#[test]
+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();
+    assert_eq!(coho.dim_h1, 0, "el caso monótono no tiene obstrucción");
+    assert_eq!(Verdict::diagnose(&sheaf, &coho), Verdict::RunsFree);
+}
+
+#[test]
+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();
+    assert_eq!(coho.dim_h1, 1, "el ciclo produce una obstrucción");
+
+    let verdict = Verdict::diagnose(&sheaf, &coho);
+    let knots = match verdict {
+        Verdict::NeedsCoordination { knots } => knots,
+        Verdict::RunsFree => panic!("debería necesitar coordinación"),
+    };
+    assert_eq!(knots.len(), 1, "un único nudo");
+    let knot = &knots[0];
+
+    // El nudo está entre las réplicas 1 y 2, NO involucra a la réplica 0.
+    assert_eq!(knot.replicas, vec![ReplicaId(1), ReplicaId(2)]);
+    // Y son las dos aristas del enlace doble (índices 1 y 2), no la 0.
+    assert_eq!(knot.edges, vec![1, 2]);
+}