tawasuyu

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 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>

1be92f63c6a85b3755fd8e7a8abdc97f604ceb84 · por Sergio · (hace 3 meses) · padre bd89fbb2d · ver el árbol en este commit

Delta semántico · 9

src/router.rs

2 archivos · +186 −20

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);