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:
co-authored by
Claude Opus 4.8
parent
dbd6875b00
commit
94482d292d
+123
-8
@@ -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
@@ -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
@@ -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
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user