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 11Markdown 1TOML 187 Sutichaykuna
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>
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() {