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.

T11c: corridas de testigo — CloneDetected + §G.4 rollback con dientes

Completa lo que faltaba de §G.4 (antes solo estaban los tests positivos):
- fencing: witness_clone_detected_impreso imprime el testigo (dos épocas 1/2,
  total_claimed=100 de una celda de 50, reconciled=50 [linaje vivo], gasto
  aislado=50 [clon obsoleto], overspend=50<=budget ⟹ daño acotado a UNA celda).
- durable: valor_real_tras_rollback(con_durabilidad) — tally independiente del
  desembolso real. CON durabilidad: recover atrapa el rollback, re-gasto
  bloqueado, valor real=0 (VERDE). SIN durabilidad: doble gasto, valor real=-50
  (ROJO). g4_el_certificado_se_pone_rojo_bajo_rollback (#[should_panic]) prueba
  que la aserción del certificado FALLA ruidosamente sin la capa de durabilidad
  — el diente de §G.1/§G.4.

69 tests, clippy limpio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

8b7400422db4d4e1eac32c08f8a9091665f7a877 · por Sergio · (hace 3 meses) · padre a8a39a67f · ver el árbol en este commit

Delta semántico · 5

src/fencing.rs

2 archivos · +84 −0

diff --git a/src/durable.rs b/src/durable.rs
index 8deee2f..b5b1b2b 100644
--- a/src/durable.rs
+++ b/src/durable.rs
@@ -132,6 +132,63 @@ mod tests {
         assert!(d.try_restore_snapshot(80), "80 ≥ 70: no retrocede, aceptado");
     }
 
+    /// §G.4 — valor REAL del recurso tras un crash + rollback, con y sin la capa
+    /// de durabilidad. `real` es un tally independiente de lo que de verdad se
+    /// desembolsó; el certificado exige `real ≥ piso`.
+    ///
+    /// Escenario: celda con presupuesto 50 (cuenta 50, piso 0). Se gasta 50
+    /// (agota la celda), un crash revierte el estado en memoria a 0 (rollback
+    /// CRUDO, bypass del WAL), y se re-gasta.
+    fn valor_real_tras_rollback(con_durabilidad: bool) -> i64 {
+        let (initial, budget) = (50i64, 50i64);
+        let mut real = 0;
+        let mut cell = DurableSpent::new();
+
+        if budget - cell.value() >= 50 {
+            cell.spend(50); // ack durable del primer gasto
+            real += 50;
+        }
+        // Crash + rollback crudo: la memoria vuelve a un estado viejo (spent = 0).
+        cell.live = 0;
+        if con_durabilidad {
+            cell.recover(); // la durabilidad restaura spent = 50 desde el WAL
+        }
+        // Re-gasto, guardado por el `spent` visible en este momento.
+        if budget - cell.value() >= 50 {
+            cell.spend(50);
+            real += 50;
+        }
+        initial - real
+    }
+
+    /// §G.4 (verde) — con durabilidad, el rollback se atrapa (recover restaura el
+    /// WAL), el re-gasto se bloquea, y el certificado aguanta: valor real ≥ 0.
+    #[test]
+    fn g4_con_durabilidad_el_certificado_queda_verde() {
+        let v = valor_real_tras_rollback(true);
+        println!("§G.4 CON durabilidad: valor real = {v}  (>= 0 ⟹ VERDE)");
+        assert!(v >= 0, "con durabilidad no hay doble gasto");
+    }
+
+    /// §G.4 (el diente) — SIN durabilidad, el rollback no atrapado provoca doble
+    /// gasto: valor real = -50 < 0. El invariante se rompe de verdad.
+    #[test]
+    fn g4_sin_durabilidad_el_rollback_sobregira() {
+        let v = valor_real_tras_rollback(false);
+        println!("§G.4 SIN durabilidad: valor real = {v}  (< 0 ⟹ certificado ROJO)");
+        assert!(v < 0, "el rollback no atrapado DEBE sobregirar");
+    }
+
+    /// §G.4 (literal) — la aserción del certificado dinámico, ejecutada sobre el
+    /// rollback no atrapado, **falla ruidosamente**. Esto prueba que la capa de
+    /// durabilidad (§G.1) tiene dientes: sin ella, el certificado se pone rojo.
+    #[test]
+    #[should_panic(expected = "certificado en ROJO")]
+    fn g4_el_certificado_se_pone_rojo_bajo_rollback() {
+        let v = valor_real_tras_rollback(false);
+        assert!(v >= 0, "certificado en ROJO: valor real {v} < piso 0 por doble gasto");
+    }
+
     proptest! {
         /// §G.1 (con dientes) — sobre cualquier secuencia de gastos y crashes, el
         /// valor recuperado nunca cae por debajo del último ack comprometido.
diff --git a/src/fencing.rs b/src/fencing.rs
index fd7c321..b2ff1c2 100644
--- a/src/fencing.rs
+++ b/src/fencing.rs
@@ -155,6 +155,33 @@ mod tests {
         assert_eq!(a.reconciled_spent(), 50);
     }
 
+    /// Testigo impreso de `CloneDetected`: las dos épocas, el gasto aislado y el
+    /// daño acotado a una celda. (`cargo test -- --nocapture` para verlo.)
+    #[test]
+    fn witness_clone_detected_impreso() {
+        let mut a = FencedCell::new(7, 50);
+        a.epoch = 1;
+        a.spend(50); // clon A gasta su celda entera
+        let mut b = FencedCell::new(7, 50);
+        b.epoch = 2;
+        b.spend(50); // clon B (rebooteado a época 2) gasta la misma celda
+
+        let outcome = a.merge(&b);
+        println!("TESTIGO CloneDetected:");
+        println!("  outcome            = {outcome:?}");
+        println!("  épocas presentes   = {:?}", a.spent.keys().collect::<Vec<_>>());
+        println!("  total_claimed      = {} (de una celda de {})", a.total_claimed(), a.budget);
+        println!("  reconciled (vivo)  = {} (época mayor; lo único propagado)", a.reconciled_spent());
+        println!("  gasto aislado      = {} (época menor, el clon obsoleto)", a.spent[&1]);
+        println!("  overspend (daño)   = {} <= budget {} ⟹ acotado a UNA celda", a.overspend(), a.budget);
+
+        assert_eq!(
+            outcome,
+            MergeOutcome::CloneDetected { device_id: 7, stale_epoch: 1, live_epoch: 2 }
+        );
+        assert_eq!(a.overspend(), 50);
+    }
+
     /// §G.4 — fuente monótona (una sola época): merge limpio, comportamiento max.
     #[test]
     fn fuente_monotona_una_epoca_es_ok() {