gioser

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.

⎇ bd89fbb2d1f130276989f6fa0a0623c113d99988 src/escrow.rs Rust · 351 lines · 14.8 KB History Who touched what raw

T10a: presupuesto con procedencia (transferencia CRDT) bd89fbb2d · Sergio ·

1//! `escrow` — evitación de coordinación por presupuestos (T9, §B).
2//!
3//! Método de escrow (O'Neil 1986) como Bounded Counter CRDT (Balegas et al.,
4//! SRDS 2015). Para un invariante global `valor ≥ K`:
5//!
6//! 1. El *slack* `S = valor − K` es lo gastable sin romper `I`.
7//! 2. Se **reparte** `S` en presupuestos por réplica `b_i` con `Σ b_i ≤ S`.
8//! 3. Mientras `r_i` tenga presupuesto, **gasta localmente sin coordinar** — y es
9//!    correcto **incluso particionada**, porque `b_i` es suyo.
10//! 4. La única coordinación es el **rebalanceo edge-local** al agotar `b_i` (T9b).
11//!
12//! **El certificado.** Si `spent_i ≤ b_i` en cada réplica, entonces
13//! `valor = inicial − Σ spent_i ≥ inicial − Σ b_i ≥ inicial − S = K`, para **toda
14//! ejecución, orden y partición**. No se descubre tarde: se garantiza por
15//! construcción. Eso es evitación real, no la detección reactiva de T8 (§A).
16//!
17//! **Honestidad (§B):** escrow no vence a CALM; *transforma* la operación
18//! ("retirar sin límite" → "retirar dentro de presupuesto") para que sea
19//! I-confluente, al costo de un rebalanceo ocasional.
20
21use crate::invariant::GlobalInvariant;
22
23/// El resultado de intentar gastar localmente.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum SpendResult {
26    /// Dentro de presupuesto: gasto local, **cero coordinación**.
27    Local,
28    /// Presupuesto agotado: hace falta rebalanceo edge-local (T9b).
29    NeedsRebalance {
30        /// Cuánto falta para cubrir el gasto pedido.
31        deficit: i64,
32    },
33}
34
35/// El presupuesto repartido: la 0-cocadena `b` sobre las réplicas.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Budget {
38    /// Presupuesto restante por réplica.
39    pub per_replica: Vec<i64>,
40}
41
42impl Budget {
43    /// Presupuesto a partir de un reparto explícito.
44    pub fn new(per_replica: Vec<i64>) -> Self {
45        Self { per_replica }
46    }
47
48    /// Reparte el `slack` en `n` presupuestos proporcionales a `weights`, con
49    /// división entera para garantizar `Σ b_i ≤ slack` (reparto siempre válido).
50    pub fn split(inv: &GlobalInvariant, weights: &[u64]) -> Self {
51        let s = inv.slack().max(0) as u128;
52        let total: u128 = weights.iter().map(|&w| w as u128).sum();
53        let per_replica = if total == 0 {
54            vec![0; weights.len()]
55        } else {
56            // ⌊w_i · S / total⌋ ⟹ la suma nunca excede S.
57            weights.iter().map(|&w| ((w as u128 * s) / total) as i64).collect()
58        };
59        Self { per_replica }
60    }
61
62    /// El certificado del reparto (§B): `Σ b_i ≤ slack` y todo `b_i ≥ 0`.
63    pub fn is_valid_split(&self, inv: &GlobalInvariant) -> bool {
64        self.per_replica.iter().all(|&b| b >= 0)
65            && self.per_replica.iter().sum::<i64>() <= inv.slack()
66    }
67
68    /// Presupuesto restante de la réplica `r`.
69    pub fn remaining(&self, r: usize) -> i64 {
70        self.per_replica[r]
71    }
72
73    /// Gasto local de `amount` (≥ 0) en la réplica `r`. Si cabe, descuenta y
74    /// devuelve `Local`; si no, **no gasta** y devuelve `NeedsRebalance` con el
75    /// déficit. Nunca declara "libre global" a ciegas.
76    pub fn try_spend(&mut self, r: usize, amount: i64) -> SpendResult {
77        debug_assert!(amount >= 0, "los gastos son no negativos");
78        if amount <= self.per_replica[r] {
79            self.per_replica[r] -= amount;
80            SpendResult::Local
81        } else {
82            SpendResult::NeedsRebalance {
83                deficit: amount - self.per_replica[r],
84            }
85        }
86    }
87
88    /// El valor del recurso implicado por unos gastos `spent_i` (para el
89    /// certificado): `inicial − Σ spent_i`.
90    pub fn value_after(inv: &GlobalInvariant, spent: &[i64]) -> i64 {
91        inv.initial - spent.iter().sum::<i64>()
92    }
93}
94
95/// **Presupuesto con procedencia** (T10, §C.1 — la vía Balegas): todo es monótono
96/// creciente, así que la fusión es un CRDT y **no hay carreras**. Sustituye al
97/// `Budget` mutable de T9 (`b_i` leído-y-escrito), que era seguro solo en
98/// snapshots congelados y secuenciales.
99///
100/// El presupuesto disponible es una **función derivada** de dos historiales que
101/// solo crecen: `granted[from][to]` (derechos cedidos) y `spent[i]` (gasto).
102///
103/// **El teorema (§C):** `Σ avail = Σ b0 − Σ spent`, invariante bajo cualquier
104/// entrelazado, porque cada `granted[j][i]` entra `+` en `i` y `−` en `j` y se
105/// cancela. Imposible fabricar presupuesto por fusión; imposible perderlo por un
106/// crash a medio propagar (un `grant` vive entero en `granted[from][to]`: al
107/// re-fusionar, o llegó completo o no llegó).
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct EscrowState {
110    /// Reparto inicial (constante); `Σ b0 ≤ slack`.
111    pub b0: Vec<i64>,
112    /// `granted[from][to]`: derechos transferidos, solo crece. Fila `from`
113    /// escrita solo por la réplica `from` (single-writer ⟹ sin conflicto).
114    pub granted: Vec<Vec<i64>>,
115    /// `spent[i]`: gasto de la réplica `i`, solo crece.
116    pub spent: Vec<i64>,
117}
118
119impl EscrowState {
120    /// Estado inicial a partir de un reparto `b0` (sin transferencias ni gasto).
121    pub fn new(b0: Vec<i64>) -> Self {
122        let n = b0.len();
123        Self {
124            b0,
125            granted: vec![vec![0; n]; n],
126            spent: vec![0; n],
127        }
128    }
129
130    /// Número de réplicas.
131    pub fn replicas(&self) -> usize {
132        self.b0.len()
133    }
134
135    /// Presupuesto disponible de `i` = inicial + recibido − cedido − gastado.
136    pub fn avail(&self, i: usize) -> i64 {
137        let recibido: i64 = (0..self.replicas()).map(|j| self.granted[j][i]).sum();
138        let cedido: i64 = self.granted[i].iter().sum();
139        self.b0[i] + recibido - cedido - self.spent[i]
140    }
141
142    /// Presupuesto total disponible. Por el teorema de §C es exactamente
143    /// `Σ b0 − Σ spent` — los `granted` se cancelan y **no cambian el total**.
144    pub fn total_avail(&self) -> i64 {
145        self.b0.iter().sum::<i64>() - self.spent.iter().sum::<i64>()
146    }
147
148    /// La otra vía de calcular el total (sumando cada `avail`), que DEBE coincidir
149    /// con `total_avail`: la prueba de la cancelación de `granted`.
150    pub fn sum_avail(&self) -> i64 {
151        (0..self.replicas()).map(|i| self.avail(i)).sum()
152    }
153
154    /// La réplica `from` cede `amount` (≥0) a `to`. Guardado por su `avail` local
155    /// (single-writer): nunca cede más de lo que tiene ⟹ imposible fabricar en el
156    /// origen. Devuelve `false` si no le alcanza.
157    pub fn grant(&mut self, from: usize, to: usize, amount: i64) -> bool {
158        debug_assert!(amount >= 0);
159        if amount <= self.avail(from) {
160            self.granted[from][to] += amount;
161            true
162        } else {
163            false
164        }
165    }
166
167    /// La réplica `i` gasta `amount` (≥0) si le queda `avail`. Monótono.
168    pub fn spend(&mut self, i: usize, amount: i64) -> bool {
169        debug_assert!(amount >= 0);
170        if amount <= self.avail(i) {
171            self.spent[i] += amount;
172            true
173        } else {
174            false
175        }
176    }
177
178    /// **Fusión CRDT**: máximo componente a componente en `granted` y `spent`
179    /// (los historiales solo crecen). Conmutativa, asociativa, idempotente. No
180    /// puede subir `total_avail`: `Σ spent` solo crece ⟹ el total solo baja.
181    pub fn merge(&mut self, other: &EscrowState) {
182        debug_assert_eq!(self.b0, other.b0, "mismo reparto inicial");
183        let n = self.replicas();
184        for i in 0..n {
185            self.spent[i] = self.spent[i].max(other.spent[i]);
186            for j in 0..n {
187                self.granted[i][j] = self.granted[i][j].max(other.granted[i][j]);
188            }
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::verdict::Verdict;
197    use proptest::prelude::*;
198
199    /// El caso 60/60 de T8, ahora bien tratado (§F): con presupuestos [50,50]
200    /// (reparto válido de S=100), una réplica **particionada** que intenta retirar
201    /// 60 recibe `NeedsRebalance`, NO `Local`. Muere el falso-verde del detector.
202    #[test]
203    fn t9_particionado_60_es_bloqueado_no_libre() {
204        let inv = GlobalInvariant::new(100, 0); // saldo ≥ 0, slack 100
205        let mut budget = Budget::new(vec![50, 50]);
206        assert!(budget.is_valid_split(&inv));
207
208        // La réplica 0, aislada, intenta retirar 60 (> su presupuesto de 50).
209        let res = budget.try_spend(0, 60);
210        assert_eq!(res, SpendResult::NeedsRebalance { deficit: 10 });
211        // El veredicto de escrow NO es "libre": pide rebalanceo (localizado).
212        assert!(matches!(Verdict::via_escrow(0, &res), Verdict::NeedsCoordination { .. }));
213        // Y no gastó: el presupuesto sigue intacto (el gasto queda pendiente).
214        assert_eq!(budget.remaining(0), 50);
215    }
216
217    /// Gasto dentro de presupuesto: local y descuenta.
218    #[test]
219    fn gasto_dentro_de_presupuesto_es_local() {
220        let mut budget = Budget::new(vec![50, 50]);
221        assert_eq!(budget.try_spend(0, 30), SpendResult::Local);
222        assert_eq!(budget.remaining(0), 20);
223        assert!(matches!(Verdict::via_escrow(0, &SpendResult::Local), Verdict::RunsFree));
224    }
225
226    fn split_valido(inv: &GlobalInvariant, raw: &[u64]) -> Budget {
227        let b = Budget::split(inv, raw);
228        assert!(b.is_valid_split(inv), "split debe ser válido por construcción");
229        b
230    }
231
232    proptest! {
233        /// **EL CERTIFICADO (§F, el corazón de T9a).** Para todo reparto válido y
234        /// TODA combinación de gastos dentro de presupuesto —incluidas particiones
235        /// arbitrarias y todas las réplicas gastando su máximo a la vez— el
236        /// invariante global `valor ≥ K` se preserva. Se cuantifica sobre todos
237        /// los estados alcanzables, no sobre un merge concreto.
238        #[test]
239        fn certificado_valor_nunca_baja_del_piso(
240            initial in 0i64..500,
241            floor in -100i64..100,
242            pesos in prop::collection::vec(0u64..50, 1..6),
243            gasto_raw in prop::collection::vec(0u64..600, 1..6),
244        ) {
245            prop_assume!(initial >= floor);              // slack ≥ 0
246            let inv = GlobalInvariant::new(initial, floor);
247            let budget = split_valido(&inv, &pesos);
248
249            // Gastos arbitrarios pero acotados por el presupuesto de cada réplica.
250            let spent: Vec<i64> = budget
251                .per_replica
252                .iter()
253                .zip(gasto_raw.iter().chain(std::iter::repeat(&0)))
254                .map(|(&b, &g)| (g as i64).min(b))       // spent_i ≤ b_i
255                .collect();
256
257            // El certificado: valor = inicial − Σ spent ≥ floor. Para cualquier
258            // subconjunto que sincronice (partición) el valor es aún mayor.
259            let valor = Budget::value_after(&inv, &spent);
260            prop_assert!(inv.holds(valor), "valor {} < piso {}", valor, inv.floor);
261        }
262    }
263
264    // ---- T10a: presupuesto con procedencia (EscrowState) ----
265
266    /// **El modo de fallo de §B, PREVENIDO en el origen.** `B` tiene 30. Cede 20 a
267    /// `A`; el segundo intento de ceder 20 a `C` **falla** (solo le quedan 10),
268    /// porque `grant` está guardado por el `avail` local. Nada de fabricar 20 por
269    /// una lectura-y-escritura descuidada. `Σ avail` se conserva en 30.
270    #[test]
271    fn grant_guardado_previene_la_fabricacion() {
272        let mut s = EscrowState::new(vec![0, 30, 0]);
273        assert!(s.grant(1, 0, 20), "B cede 20 a A");
274        assert!(!s.grant(1, 2, 20), "B ya no puede ceder otros 20 (solo le quedan 10)");
275        assert!(s.grant(1, 2, 10), "pero sí los 10 restantes");
276        assert_eq!(s.avail(0), 20);
277        assert_eq!(s.avail(1), 0);
278        assert_eq!(s.avail(2), 10);
279        assert_eq!(s.total_avail(), 30, "Σ avail conservado: cero fabricación");
280    }
281
282    /// Crash a mitad de transferencia: un `grant` aplicado en una copia y no
283    /// propagado, al re-fusionar tarde da el mismo estado. Ni fabrica ni pierde.
284    #[test]
285    fn crash_a_mitad_de_transferencia_es_seguro() {
286        let base = EscrowState::new(vec![30, 0]);
287        // Copia 1: B0 concede 20 a la réplica 1 (el "grant en tránsito").
288        let mut c1 = base.clone();
289        assert!(c1.grant(0, 1, 20));
290        // Copia 2 (crashea sin ver el grant): fusiona el resto tal cual.
291        let mut c2 = base.clone();
292        c2.merge(&base);
293        // El grant llega tarde: c2 re-fusiona con c1.
294        c2.merge(&c1);
295        assert_eq!(c2, c1, "el grant llega entero o no llega; nunca a medias");
296        assert_eq!(c2.total_avail(), 30);
297    }
298
299    fn escrow_st() -> impl Strategy<Value = EscrowState> {
300        (1usize..=4).prop_flat_map(|n| {
301            let b0 = prop::collection::vec(0i64..50, n);
302            let granted = prop::collection::vec(prop::collection::vec(0i64..30, n), n);
303            let spent = prop::collection::vec(0i64..30, n);
304            (b0, granted, spent).prop_map(|(b0, granted, spent)| EscrowState { b0, granted, spent })
305        })
306    }
307
308    /// Dos estados con el mismo reparto inicial (para poder fusionarlos).
309    fn par_de_escrow() -> impl Strategy<Value = (EscrowState, EscrowState)> {
310        escrow_st().prop_flat_map(|a| {
311            let n = a.replicas();
312            let granted = prop::collection::vec(prop::collection::vec(0i64..30, n), n);
313            let spent = prop::collection::vec(0i64..30, n);
314            let b0 = a.b0.clone();
315            (Just(a), (granted, spent)).prop_map(move |(a, (g, s))| {
316                let b = EscrowState { b0: b0.clone(), granted: g, spent: s };
317                (a, b)
318            })
319        })
320    }
321
322    proptest! {
323        /// **§F.1 — el total no se puede fabricar.** `Σ avail` (sumando cada réplica)
324        /// SIEMPRE es igual a `Σ b0 − Σ spent`: los `granted` se cancelan, pase lo
325        /// que pase con las transferencias. Este es el teorema que mata el §B.
326        #[test]
327        fn total_avail_es_invariante_a_los_granted(s in escrow_st()) {
328            prop_assert_eq!(s.sum_avail(), s.total_avail());
329            prop_assert_eq!(s.total_avail(), s.b0.iter().sum::<i64>() - s.spent.iter().sum::<i64>());
330        }
331
332        /// **§F.2 — `merge` es un CRDT:** conmutativo e idempotente.
333        #[test]
334        fn merge_conmutativo_e_idempotente((a, b) in par_de_escrow()) {
335            let mut ab = a.clone(); ab.merge(&b);
336            let mut ba = b.clone(); ba.merge(&a);
337            prop_assert_eq!(&ab, &ba, "merge conmutativo");
338            let mut abb = ab.clone(); abb.merge(&b);
339            prop_assert_eq!(&abb, &ab, "merge idempotente");
340        }
341
342        /// **§F.1 (fusión) — `merge` nunca sube el total.** `Σ spent` solo crece por
343        /// max ⟹ `total_avail` solo puede bajar. Imposible fabricar por fusión.
344        #[test]
345        fn merge_nunca_fabrica_presupuesto((a, b) in par_de_escrow()) {
346            let mut m = a.clone(); m.merge(&b);
347            prop_assert!(m.total_avail() <= a.total_avail());
348            prop_assert!(m.total_avail() <= b.total_avail());
349        }
350    }
351}