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