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 Definiciones
T10b: rebalanceo sin ruta + fail-safe (Unreachable, no hang)
T10 §D/§F.4: T9 no definía el eslabón cuando no hay ruta al presupuesto
(partición). Ahora:
- router::RebalanceResult { Routed{edges} | Unreachable }. rebalance_to es
TRANSACCIONAL: si las vecinas directas cubren el déficit aplica el flujo
(Routed); si no, no mueve nada y devuelve Unreachable. Nunca a medias, nunca
bloquea.
- SpendOutcome + spend_with_rebalance: gasta local, o repone por rebalanceo, o
ante Unreachable RECHAZA con spendable = presupuesto local (fail-safe). Nunca
sobregira ni cuelga.
Tests: el 60/60 particionado con [50,50] → Unreachable → Rejected{spendable:50}
(gasta ≤50, valor≥0); con ruta se cubre por rebalanceo. Tests de T9b migrados a
la nueva firma.
AVAILABILITY.md (§F.6): el precio honesto del escrow — bajo partición la réplica
se limita a su presupuesto local (CAP: safety > availability); diffuse lo mitiga,
no lo elimina; sin promesas de liveness, solo safety. 61 tests, clippy limpio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/AVAILABILITY.md b/AVAILABILITY.md
new file mode 100644
index 0000000..1bd25ca
--- /dev/null
+++ b/AVAILABILITY.md
@@ -0,0 +1,56 @@
+# AVAILABILITY — el precio honesto del escrow (T10b, §D/§F.6)
+
+El escrow cambia el **falso-verde** del detector (T8: "particionado → libre") por
+un **límite de disponibilidad real y seguro**. Este archivo lo nombra, en vez de
+fingir disponibilidad ilimitada. Un consejero de coordinación soberano debe poder
+decir *"bajo esta partición, este nodo puede gastar hasta X y no más"*.
+
+Respaldado por `router::tests::particion_sin_ruta_es_fail_safe` y
+`gasto_con_ruta_se_cubre_por_rebalanceo`.
+
+## La regla
+
+- `try_spend`/`spend_with_rebalance` **nunca** sobregiran y **nunca** cuelgan.
+- Si el gasto excede el presupuesto local y **hay ruta** a excedente en las
+ vecinas, se cubre por rebalanceo edge-local → `Spent`.
+- Si **no hay ruta** (partición), `rebalance_to` devuelve `Unreachable` (sin mover
+ nada) y el gasto se **rechaza** con `Rejected { spendable }`: la réplica puede
+ gastar como mucho su presupuesto local, garantizando `valor ≥ K`.
+
+## El límite, en números
+
+Cuenta de 100, `saldo ≥ 0`, reparto `[50, 50]`:
+
+| Grafo | Réplica 0 pide | Resultado | Disponibilidad efectiva de 0 |
+|---|---|---|---|
+| `0—1` (conectada) | 60 | `Spent` (50 local + 10 de 1) | hasta 100 (todo el slack, si 1 cede) |
+| `∅` (particionada) | 60 | `Rejected { spendable: 50 }` | **exactamente 50** (su presupuesto local) |
+| `∅` (particionada) | 50 | `Spent` | 50 |
+
+Bajo partición, la réplica está **limitada a su presupuesto local**. No es un bug:
+es el precio correcto de no sobregirar jamás. El detector de T8 "resolvía" esto
+mintiendo (decía libre y sobregiraba); el escrow lo resuelve diciendo la verdad.
+
+## Qué mitiga `diffuse` (y qué no)
+
+`BudgetRouter::diffuse` suaviza el presupuesto **antes** de que llegue la
+partición: reparte el slack para que ninguna réplica quede con presupuesto muy por
+debajo de su demanda esperada. Reduce la probabilidad y la severidad de un
+`Rejected` — pero **no lo elimina**:
+
+- Si una réplica particionada demanda más que **todo** el slack que le tocó, ni el
+ mejor reparto la salva: no hay derechos que gastar sin coordinar.
+- `diffuse` conserva `Σ b` (no crea disponibilidad, solo la redistribuye).
+
+**Ningún mecanismo seguro puede eliminar el límite.** Es el teorema CAP en su
+forma cotidiana: bajo partición, o sacrificas consistencia (sobregiras, el bug de
+T8) o sacrificas disponibilidad (te limitas a tu presupuesto, el escrow). El
+escrow elige lo segundo, explícitamente.
+
+## Sin promesas de liveness
+
+Este documento **no** promete que un `Rejected` se convierta en `Spent` en un
+tiempo acotado: eso depende de que la partición sane y de que haya excedente que
+rutear, cosas fuera del control del mecanismo. Lo que sí se garantiza es
+**safety**: nunca un sobregiro, nunca un cuelgue. Liveness es best-effort vía
+`diffuse` proactivo y rebalanceo al reconectar.
diff --git a/src/router.rs b/src/router.rs
index c783864..0f272d0 100644
--- a/src/router.rs
+++ b/src/router.rs
@@ -19,6 +19,31 @@
use crate::escrow::Budget;
use crate::nerve::Nerve;
+/// Resultado de un intento de rebalanceo (T10b, §D).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RebalanceResult {
+ /// Se repuso el déficit; flujo edge-local usado `(desde, hacia, monto)`.
+ Routed {
+ /// Los movimientos aplicados (conservan `Σ b`).
+ edges: Vec<(usize, usize, i64)>,
+ },
+ /// Sin ruta a excedente (partición): **no se movió nada**. Fail-safe arriba.
+ Unreachable,
+}
+
+/// Resultado de un gasto guardado por el presupuesto (T10b, §D/§F.4).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SpendOutcome {
+ /// Se gastó el monto pedido (local, o tras un rebalanceo exitoso).
+ Spent,
+ /// Sin ruta al presupuesto: **rechazado, fail-safe**. La réplica puede gastar
+ /// como mucho `spendable` (su presupuesto local) sin sobregirar ni colgarse.
+ Rejected {
+ /// Lo máximo que la réplica puede gastar localmente ahora.
+ spendable: i64,
+ },
+}
+
/// Rutea un presupuesto mutable sobre el grafo del nervio.
pub struct BudgetRouter<'a> {
nerve: &'a Nerve,
@@ -36,29 +61,63 @@ impl<'a> BudgetRouter<'a> {
self.budget.per_replica.iter().sum()
}
- /// Flujo **mínimo** que repone `deficit` a la réplica `r` tomándolo de sus
- /// vecinas con excedente. Devuelve los movimientos `(desde, hacia, monto)`.
- /// Conserva `Σ b` (solo mueve) y solo toca aristas **adyacentes a `r`**.
+ /// Flujo **mínimo** que repone `deficit` a la réplica `r` desde sus vecinas
+ /// con excedente. **Transaccional** (T10b): si las vecinas directas cubren el
+ /// déficit, aplica el flujo y devuelve `Routed`; si no lo cubren (partición),
+ /// **no mueve nada** y devuelve `Unreachable` — nunca deja un estado a medias
+ /// ni bloquea. Conserva `Σ b` y solo toca aristas **adyacentes a `r`**.
///
- /// Si las vecinas directas no alcanzan, cubre lo posible (el resto quedaría
- /// para un rebalanceo multi-salto o `diffuse` previo).
- pub fn rebalance_to(&mut self, r: usize, deficit: i64) -> Vec<(usize, usize, i64)> {
+ /// Limitación honesta (§D): es edge-local a las vecinas directas. Un nodo cuyo
+ /// componente tiene excedente pero no en sus vecinas inmediatas recibe
+ /// `Unreachable`; `diffuse` lo mitiga repartiendo antes, no lo elimina.
+ pub fn rebalance_to(&mut self, r: usize, deficit: i64) -> RebalanceResult {
+ let need = deficit.max(0);
+ if need == 0 {
+ return RebalanceResult::Routed { edges: Vec::new() };
+ }
+ let neighbors = self.nerve.neighbors(r);
+ let surplus: i64 = neighbors.iter().map(|&j| self.budget.per_replica[j].max(0)).sum();
+ if surplus < need {
+ return RebalanceResult::Unreachable; // fail-safe: no aplica nada
+ }
+
let mut moves = Vec::new();
- let mut need = deficit.max(0);
- for j in self.nerve.neighbors(r) {
- if need == 0 {
+ let mut rem = need;
+ for j in neighbors {
+ if rem == 0 {
break;
}
let avail = self.budget.per_replica[j].max(0);
- let amount = avail.min(need);
+ let amount = avail.min(rem);
if amount > 0 {
self.budget.per_replica[j] -= amount;
self.budget.per_replica[r] += amount;
moves.push((j, r, amount));
- need -= amount;
+ rem -= amount;
+ }
+ }
+ RebalanceResult::Routed { edges: moves }
+ }
+
+ /// Gasto guardado por el presupuesto, con rebalanceo y **fail-safe** (§D/§F.4):
+ /// si cabe local, gasta; si no, intenta reponer desde vecinas; si no hay ruta
+ /// (`Unreachable`), **rechaza** y reporta cuánto puede gastar localmente. Nunca
+ /// sobregira ni cuelga.
+ pub fn spend_with_rebalance(&mut self, r: usize, amount: i64) -> SpendOutcome {
+ let local = self.budget.per_replica[r];
+ if amount <= local {
+ self.budget.per_replica[r] -= amount;
+ return SpendOutcome::Spent;
+ }
+ match self.rebalance_to(r, amount - local) {
+ RebalanceResult::Routed { .. } => {
+ self.budget.per_replica[r] -= amount; // el rebalanceo dejó lo justo
+ SpendOutcome::Spent
}
+ RebalanceResult::Unreachable => SpendOutcome::Rejected {
+ spendable: self.budget.per_replica[r].max(0),
+ },
}
- moves
}
/// Un paso de difusión de Laplaciano que **suaviza** el presupuesto
@@ -93,6 +152,14 @@ mod tests {
b.per_replica.iter().map(|&x| (x as i128) * (x as i128)).sum()
}
+ /// Extrae las aristas de un `Routed`, o falla si fue `Unreachable`.
+ fn routed(res: RebalanceResult) -> Vec<(usize, usize, i64)> {
+ match res {
+ RebalanceResult::Routed { edges } => edges,
+ RebalanceResult::Unreachable => panic!("debía rutear, no Unreachable"),
+ }
+ }
+
#[test]
fn rebalanceo_conserva_el_total_y_repone() {
// Camino 0—1—2; la réplica 1 está seca y pide 8.
@@ -100,7 +167,7 @@ mod tests {
let mut budget = Budget::new(vec![5, 0, 5]);
let antes = suma(&budget);
- let moves = BudgetRouter::new(&nerve, &mut budget).rebalance_to(1, 8);
+ let moves = routed(BudgetRouter::new(&nerve, &mut budget).rebalance_to(1, 8));
assert_eq!(suma(&budget), antes, "el rebalanceo conserva Σ b");
assert_eq!(budget.remaining(1), 8, "la réplica seca queda repuesta");
// Solo movió desde vecinas de 1 (0 y 2), nunca desde una no vecina.
@@ -115,7 +182,7 @@ mod tests {
// Estrella: centro 0 con hojas 1,2,3. La hoja 3 NO es vecina de 1.
let nerve = Nerve::from_edges(4, &[(0, 1), (0, 2), (0, 3)]);
let mut budget = Budget::new(vec![10, 0, 0, 0]);
- let moves = BudgetRouter::new(&nerve, &mut budget).rebalance_to(1, 5);
+ let moves = routed(BudgetRouter::new(&nerve, &mut budget).rebalance_to(1, 5));
// La única vecina de 1 es 0; el flujo viene solo de ahí.
for (from, _, _) in &moves {
assert_eq!(*from, 0, "una hoja no puede reponer a otra hoja directamente");
@@ -124,6 +191,46 @@ mod tests {
assert_eq!(budget.remaining(0), 5);
}
+ /// **§F.4 — partición sin ruta ⟹ fail-safe.** El 60/60 particionado con
+ /// `[50,50]`: la réplica 0 aislada intenta retirar 60 → `rebalance_to` da
+ /// `Unreachable` (no mueve nada), el gasto se **rechaza** y a lo sumo puede
+ /// gastar sus 50 locales. Nunca sobregira, nunca cuelga.
+ #[test]
+ fn particion_sin_ruta_es_fail_safe() {
+ let nerve = Nerve::from_edges(2, &[]); // sin aristas: partición total
+ let mut budget = Budget::new(vec![50, 50]);
+ let antes = suma(&budget);
+ let mut router = BudgetRouter::new(&nerve, &mut budget);
+
+ // El rebalanceo no tiene ruta y no mueve nada.
+ assert_eq!(router.rebalance_to(0, 10), RebalanceResult::Unreachable);
+ // El gasto de 60 se rechaza; puede gastar hasta 50.
+ assert_eq!(router.spend_with_rebalance(0, 60), SpendOutcome::Rejected { spendable: 50 });
+ assert_eq!(suma(&budget), antes, "un rechazo no toca el presupuesto");
+
+ // Gastar los 50 locales sí es seguro (valor = 100 − 50 = 50 ≥ 0).
+ assert_eq!(
+ BudgetRouter::new(&nerve, &mut budget).spend_with_rebalance(0, 50),
+ SpendOutcome::Spent
+ );
+ assert_eq!(budget.remaining(0), 0);
+ }
+
+ /// Con ruta, el gasto que excede lo local se cubre por rebalanceo y procede.
+ #[test]
+ fn gasto_con_ruta_se_cubre_por_rebalanceo() {
+ let nerve = Nerve::from_edges(2, &[(0, 1)]);
+ let mut budget = Budget::new(vec![50, 50]);
+ // La réplica 0 retira 60: 50 locales + 10 repuestos desde 1.
+ assert_eq!(
+ BudgetRouter::new(&nerve, &mut budget).spend_with_rebalance(0, 60),
+ SpendOutcome::Spent
+ );
+ assert_eq!(budget.remaining(0), 0);
+ assert_eq!(budget.remaining(1), 40);
+ assert_eq!(suma(&budget), 40, "gastó 60 de los 100: Σ b = 40");
+ }
+
#[test]
fn diffuse_no_aumenta_varianza_y_conserva_total() {
let nerve = Nerve::from_edges(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
@@ -156,13 +263,16 @@ mod tests {
let total = budget.per_replica.iter().sum::<i64>();
let r = objetivo % n;
- let moves = BudgetRouter::new(&nerve, &mut budget).rebalance_to(r, 30);
+ let res = BudgetRouter::new(&nerve, &mut budget).rebalance_to(r, 30);
+ // Conserva Σ b tanto si rutea (mueve dentro) como si es Unreachable (no mueve).
prop_assert_eq!(budget.per_replica.iter().sum::<i64>(), total, "rebalance conserva");
- // Localización: todo movimiento entra a r desde una vecina suya.
- for (from, to, amount) in &moves {
- prop_assert_eq!(*to, r);
- prop_assert!(*amount > 0);
- prop_assert!(nerve.neighbors(r).contains(from));
+ if let RebalanceResult::Routed { edges } = res {
+ // Localización: todo movimiento entra a r desde una vecina suya.
+ for (from, to, amount) in &edges {
+ prop_assert_eq!(*to, r);
+ prop_assert!(*amount > 0);
+ prop_assert!(nerve.neighbors(r).contains(from));
+ }
}
let var_antes = suma_de_cuadrados(&budget);