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>
This commit is contained in:
Sergio
2026-07-01 18:21:34 +00:00
co-authored by Claude Opus 4.8
parent dbd6875b00
commit 94482d292d
4 changed files with 304 additions and 22 deletions
+123 -8
View File
@@ -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());
}
}
+84 -3
View File
@@ -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);
}
+60 -11
View File
@@ -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(())
}
}
}
}
+37
View File
@@ -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]);
}