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 Definitions

⎇ d2514b766a207ec049b26727f541c4d32d2a89c5 src/invariant.rs Rust · 264 lines · 10.5 KB History Who touched what raw

T8c: invariante, I-confluence y la discrepancia buena (versión fuerte) d2514b766 · Sergio ·

1//! `invariant` — coordinación de verdad: I-confluence sobre el grafo (T8c, §C–§F).
2//!
3//! Sin invariante, un CRDT nunca coordina (T8a/b). La coordinación aparece cuando
4//! hay un **invariante global `I`** que la fusión no preserva: los estados válidos
5//! `V = {s : I(s)}` no son cerrados bajo join. Ejemplo canónico (Bailis et al.,
6//! VLDB 2014): **saldo ≥ 0**.
7//!
8//! Modelo: `bound` = saldo inicial; cada réplica `i` retira `withdrawals[i]`. El
9//! estado es un `GCounter` grow-only de retiros (merge = máximo por casilla). El
10//! invariante sobre un estado reconciliado `s` es `bound ≥ Σ retiros(s)`.
11//!
12//! **Veredicto honesto (§C):** reconcilia por el orden NATIVO (el merge del CRDT)
13//! y `corre libre ⟺ punto_fijo ∈ V`. Es un test de I-confluence **constructivo,
14//! sensible a la instancia y consciente de la topología**: el mismo programa da
15//! distinto veredicto según los valores concretos y según quién sincroniza con
16//! quién. (Riesgo R-T8b: `p ∈ V` es un predicado **añadido** sobre el punto fijo,
17//! no algo intrínseco al Laplaciano. La difusión calcula el *merge*; I-confluence
18//! es *merge + predicado de validez*.)
19
20use std::collections::{BTreeMap, BTreeSet};
21
22use crate::cell::GCounter;
23use crate::native;
24use crate::nerve::ReplicaId;
25use crate::oracle::OracleVerdict;
26use crate::verdict::{Knot, Verdict};
27
28/// Una instancia del programa "retiros sobre una cuenta con saldo ≥ 0".
29#[derive(Debug, Clone)]
30pub struct AccountConfig {
31    /// Nombre del caso.
32    pub name: String,
33    /// Número de réplicas.
34    pub replicas: usize,
35    /// Saldo inicial `B₀` (la cota del invariante).
36    pub bound: u64,
37    /// Retiro concurrente de cada réplica.
38    pub withdrawals: Vec<u64>,
39    /// Grafo de compartición (quién reconcilia con quién).
40    pub edges: Vec<(usize, usize)>,
41}
42
43impl AccountConfig {
44    /// Construye una instancia (con `withdrawals.len() == replicas`).
45    pub fn new(
46        name: impl Into<String>,
47        bound: u64,
48        withdrawals: Vec<u64>,
49        edges: Vec<(usize, usize)>,
50    ) -> Self {
51        Self {
52            name: name.into(),
53            replicas: withdrawals.len(),
54            bound,
55            withdrawals,
56            edges,
57        }
58    }
59
60    /// Estado local de cada réplica: un `GCounter` con su propia casilla puesta a
61    /// su retiro (grow-only; solo incrementa lo suyo).
62    fn seeds(&self) -> Vec<GCounter> {
63        (0..self.replicas)
64            .map(|i| {
65                let mut slots = vec![0u64; self.replicas];
66                slots[i] = self.withdrawals[i];
67                GCounter { slots }
68            })
69            .collect()
70    }
71
72    /// Estado reconciliado por el orden nativo (merge por componente conexa).
73    pub fn reconcile(&self) -> Vec<GCounter> {
74        native::reconcile(&self.edges, &self.seeds())
75    }
76
77    /// Componentes conexas del grafo (union-find). Camino independiente del merge.
78    fn components(&self) -> Vec<usize> {
79        let mut parent: Vec<usize> = (0..self.replicas).collect();
80        fn find(parent: &mut [usize], x: usize) -> usize {
81            let mut r = x;
82            while parent[r] != r {
83                r = parent[r];
84            }
85            r
86        }
87        for &(a, b) in &self.edges {
88            let ra = find(&mut parent, a);
89            let rb = find(&mut parent, b);
90            if ra != rb {
91                parent[ra] = rb;
92            }
93        }
94        (0..self.replicas).map(|i| find(&mut parent, i)).collect()
95    }
96
97    /// **Veredicto del haz (§C):** difunde al punto fijo y comprueba `∈ V`. Corre
98    /// libre si ninguna componente reconciliada sobregira; si alguna lo hace,
99    /// localiza las réplicas culpables (las que retiraron) y sus aristas.
100    pub fn haz_verdict(&self) -> Verdict {
101        let fixpoint = self.reconcile();
102
103        // Vértices cuyo estado reconciliado viola el invariante (sobregiro).
104        let mut malos = BTreeSet::new();
105        for (v, state) in fixpoint.iter().enumerate() {
106            if self.bound < state.value() {
107                malos.insert(v);
108            }
109        }
110
111        if malos.is_empty() {
112            return Verdict::RunsFree;
113        }
114
115        let replicas: Vec<ReplicaId> = malos
116            .iter()
117            .filter(|&&v| self.withdrawals[v] > 0)
118            .map(|&v| ReplicaId(v))
119            .collect();
120        let edges: Vec<usize> = self
121            .edges
122            .iter()
123            .enumerate()
124            .filter(|(_, &(a, b))| malos.contains(&a) || malos.contains(&b))
125            .map(|(i, _)| i)
126            .collect();
127
128        Verdict::NeedsCoordination {
129            knots: vec![Knot { replicas, edges }],
130        }
131    }
132
133    /// El veredicto del haz como `OracleVerdict` (para comparar).
134    pub fn haz_coordination(&self) -> OracleVerdict {
135        match self.haz_verdict() {
136            Verdict::RunsFree => OracleVerdict::CoordinationFree,
137            Verdict::NeedsCoordination { .. } => OracleVerdict::NeedsCoordination,
138        }
139    }
140
141    /// **CALM sintáctico por-programa:** el retiro es una operación no monótona
142    /// (decrementa el saldo), así que el check crudo condena el programa entero,
143    /// sin mirar valores → siempre `coordina`. Es la respuesta a batir (§D).
144    pub fn calm_syntactic(&self) -> OracleVerdict {
145        OracleVerdict::NeedsCoordination
146    }
147
148    /// **Oráculo de I-confluence independiente** (§D): por un camino que NO usa la
149    /// difusión — suma aritmética de los retiros por componente vía union-find — y
150    /// comprueba `Σ ≤ bound`. El haz debe igualarlo en toda instancia (corrección
151    /// y soundness §E.2).
152    pub fn i_confluence_oracle(&self) -> OracleVerdict {
153        let comp = self.components();
154        let mut sums: BTreeMap<usize, u64> = BTreeMap::new();
155        for (&c, &w) in comp.iter().zip(&self.withdrawals) {
156            *sums.entry(c).or_default() += w;
157        }
158        if sums.values().all(|&s| s <= self.bound) {
159            OracleVerdict::CoordinationFree
160        } else {
161            OracleVerdict::NeedsCoordination
162        }
163    }
164
165    /// ¿Es una **discrepancia buena** (versión fuerte, §D instancia A)? El haz
166    /// corre libre mientras el CALM sintáctico coordina — con `I` preservado.
167    pub fn es_discrepancia_buena(&self) -> bool {
168        self.haz_coordination() == OracleVerdict::CoordinationFree
169            && self.calm_syntactic() == OracleVerdict::NeedsCoordination
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use proptest::prelude::*;
177
178    /// **El premio (§D, instancia A):** dos réplicas retiran 10 cada una de una
179    /// cuenta de 100. Los valores caben (20 ≤ 100) → el haz corre libre. Pero el
180    /// retiro es no monótono → CALM sintáctico coordina. Discrepancia buena: el
181    /// haz es más fino que el check por-programa. Testigo para `STRONG_RESULT.md`.
182    #[test]
183    fn discrepancia_buena_existe() {
184        let a = AccountConfig::new("retiros-que-caben", 100, vec![10, 10], vec![(0, 1)]);
185        assert_eq!(a.haz_coordination(), OracleVerdict::CoordinationFree);
186        assert_eq!(a.calm_syntactic(), OracleVerdict::NeedsCoordination);
187        assert_eq!(a.i_confluence_oracle(), OracleVerdict::CoordinationFree);
188        assert!(a.es_discrepancia_buena());
189    }
190
191    /// Instancia B: los retiros sobregiran (60 + 60 > 100) → el haz coordina, y
192    /// coincide con I-confluence. Además localiza a las dos réplicas culpables.
193    #[test]
194    fn sobregiro_coordina_y_localiza() {
195        let a = AccountConfig::new("retiros-que-sobregiran", 100, vec![60, 60], vec![(0, 1)]);
196        assert_eq!(a.haz_coordination(), OracleVerdict::NeedsCoordination);
197        assert_eq!(a.i_confluence_oracle(), OracleVerdict::NeedsCoordination);
198        match a.haz_verdict() {
199            Verdict::NeedsCoordination { knots } => {
200                assert_eq!(knots[0].replicas, vec![ReplicaId(0), ReplicaId(1)]);
201            }
202            Verdict::RunsFree => panic!("debería coordinar"),
203        }
204    }
205
206    /// **La topología importa** (§F, el pitch de novedad): los MISMOS valores
207    /// (60 y 60 sobre 100) corren libres si las réplicas NO comparten (dos
208    /// componentes de 60) y coordinan si comparten (una de 120). I-confluence
209    /// sobre el grafo real, no sobre un par abstracto.
210    #[test]
211    fn el_mismo_valor_depende_del_grafo() {
212        let desconectado = AccountConfig::new("particion", 100, vec![60, 60], vec![]);
213        let conectado = AccountConfig::new("conjunto", 100, vec![60, 60], vec![(0, 1)]);
214        assert_eq!(desconectado.haz_coordination(), OracleVerdict::CoordinationFree);
215        assert_eq!(conectado.haz_coordination(), OracleVerdict::NeedsCoordination);
216    }
217
218    /// Barrido determinista que **caza** discrepancias buenas: hay muchas, no una
219    /// casualidad. Prueba de que la versión fuerte es robusta.
220    #[test]
221    fn la_caza_encuentra_muchas_discrepancias() {
222        let mut encontradas = 0;
223        for total in 0..=90u64 {
224            for w0 in 0..=total {
225                let a = AccountConfig::new("barrido", 100, vec![w0, total - w0], vec![(0, 1)]);
226                if a.es_discrepancia_buena() {
227                    encontradas += 1;
228                }
229            }
230        }
231        assert!(encontradas > 100, "la discrepancia buena es robusta: {encontradas}");
232    }
233
234    fn instancia() -> impl Strategy<Value = AccountConfig> {
235        (2usize..=5, 1u64..=50).prop_flat_map(|(n, bound)| {
236            let ws = prop::collection::vec(0u64..=40, n);
237            let arista = (0..n, 0..n).prop_filter("sin lazos", |(a, b)| a != b);
238            let es = prop::collection::vec(arista, 0..8);
239            (Just(bound), ws, es)
240                .prop_map(|(bound, ws, es)| AccountConfig::new("prop", bound, ws, es))
241        })
242    }
243
244    proptest! {
245        /// **Corrección (§D):** el haz iguala al oráculo de I-confluence en TODA
246        /// instancia. Dos caminos independientes (difusión de merges vs suma por
247        /// componente) que coinciden siempre.
248        #[test]
249        fn haz_iguala_i_confluence(a in instancia()) {
250            prop_assert_eq!(a.haz_coordination(), a.i_confluence_oracle());
251        }
252
253        /// **Soundness (§E.2, no se negocia):** el haz NUNCA dice "corre libre"
254        /// cuando alguna componente reconciliada viola el invariante.
255        #[test]
256        fn soundness_el_haz_nunca_miente(a in instancia()) {
257            if a.haz_coordination() == OracleVerdict::CoordinationFree {
258                for state in a.reconcile() {
259                    prop_assert!(a.bound >= state.value(), "libre ⟹ invariante preservado");
260                }
261            }
262        }
263    }
264}