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

⎇ 74acdb9dbca5fc2399bce6f3d65a3bf865c01c22 src/adapter.rs Rust · 318 líneas · 11.9 KB Historia Quién tocó qué crudo

M4: adaptador soberano con API congelada y contrato honesto 703ae7023 · Sergio ·

1//! `adapter` — el puente a los sistemas soberanos (M4, §H de T9/T10/T11).
2//!
3//! Unifica las piezas del proyecto tras una **API congelada** con precondiciones
4//! honestas. Cablea `EscrowState` (presupuesto con procedencia, crash-safe y
5//! merge-safe, T10a), el WAL durable (T11b), el ruteo por el grafo del nervio
6//! (T9b/T10b) y el fencing por época (T11c) en una cuenta soberana usable.
7//!
8//! **Contrato del adaptador (§H):**
9//! - **Requiere** WAL durable de `spent` por dispositivo (persistir antes del ack).
10//! - **Requiere** identidad-como-conjunto-de-dispositivos (una celda por dispositivo).
11//! - **Garantiza** seguridad coordination-free del invariante bajo el supuesto de
12//!   un-escritor-durable-por-celda (certificado dinámico de T10).
13//! - **Acota y detecta** (no previene) el doble gasto por clon; expone
14//!   `CloneDetected` como señal para que la aplicación compense (ver `BOUNDARY.md`:
15//!   es una alarma, no una recuperación).
16//! - **Limita** la disponibilidad bajo partición al presupuesto local
17//!   (`AVAILABILITY.md`).
18//!
19//! Si el sustrato no puede dar durabilidad por dispositivo, `open` **rehúsa** en
20//! vez de prometer seguridad que no tiene. Esa negativa es parte del diseño.
21
22use thiserror::Error;
23
24use crate::durable::WriteAheadLog;
25use crate::escrow::{Budget, EscrowState};
26use crate::fencing::MergeOutcome;
27use crate::invariant::GlobalInvariant;
28use crate::nerve::Nerve;
29use crate::router::SpendOutcome;
30
31/// Lo que el sustrato soberano (Tawasuyu/Hammer) declara poder ofrecer.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct SubstrateCapabilities {
34    /// ¿Persiste `spent` por dispositivo antes de reconocer (WAL/fsync)?
35    pub durable_per_device: bool,
36    /// ¿La identidad es un conjunto de dispositivos, cada uno con su celda?
37    pub identity_as_devices: bool,
38}
39
40impl SubstrateCapabilities {
41    /// Un sustrato que cumple ambas precondiciones del contrato.
42    pub fn full() -> Self {
43        Self {
44            durable_per_device: true,
45            identity_as_devices: true,
46        }
47    }
48}
49
50/// El adaptador rehúsa cuando el sustrato no cumple el contrato (§H).
51#[derive(Debug, Error, PartialEq, Eq)]
52pub enum AdapterError {
53    /// Sin WAL durable por dispositivo no hay garantía: se rehúsa.
54    #[error("el sustrato no ofrece durabilidad por dispositivo: se rehúsa (sin ella, sin garantía)")]
55    NoDurability,
56    /// Sin identidad-como-conjunto-de-dispositivos no se cumple single-writer por celda.
57    #[error("el sustrato no modela la identidad como conjunto de dispositivos: se rehúsa")]
58    NoDeviceIdentity,
59    /// Reparto inválido (excede el slack o negativo).
60    #[error("reparto de presupuesto inválido para el invariante dado")]
61    InvalidSplit,
62}
63
64/// Una cuenta soberana: una identidad con sus celdas-dispositivo, su invariante,
65/// su presupuesto (con procedencia y durabilidad) y su grafo de comunicación.
66#[derive(Debug, Clone)]
67pub struct SovereignAccount {
68    inv: GlobalInvariant,
69    nerve: Nerve,
70    /// Presupuesto con procedencia (crash-safe, merge-safe): `b0` + `granted` + `spent`.
71    state: EscrowState,
72    /// WAL durable por dispositivo: `spent` no puede olvidar.
73    wal: Vec<WriteAheadLog>,
74    /// Época de fencing por dispositivo (device-local, monótona).
75    epoch: Vec<u64>,
76}
77
78impl SovereignAccount {
79    /// **Abre una cuenta, o rehúsa** si el sustrato no cumple el contrato (§H).
80    ///
81    /// `weights` reparte el `slack` del invariante entre los dispositivos
82    /// (`Σ b0 ≤ slack`, garantizado por construcción); `edges` es el grafo de
83    /// comunicación entre dispositivos de la identidad.
84    pub fn open(
85        caps: SubstrateCapabilities,
86        inv: GlobalInvariant,
87        weights: &[u64],
88        edges: &[(usize, usize)],
89    ) -> Result<Self, AdapterError> {
90        if !caps.durable_per_device {
91            return Err(AdapterError::NoDurability);
92        }
93        if !caps.identity_as_devices {
94            return Err(AdapterError::NoDeviceIdentity);
95        }
96        let b0 = Budget::split(&inv, weights).per_replica;
97        let n = b0.len();
98        if b0.iter().any(|&b| b < 0) || b0.iter().sum::<i64>() > inv.slack() {
99            return Err(AdapterError::InvalidSplit);
100        }
101        Ok(Self {
102            inv,
103            nerve: Nerve::from_edges(n, edges),
104            state: EscrowState::new(b0),
105            wal: (0..n).map(|_| WriteAheadLog::new()).collect(),
106            epoch: vec![0; n],
107        })
108    }
109
110    /// Número de dispositivos de la identidad.
111    pub fn devices(&self) -> usize {
112        self.state.replicas()
113    }
114
115    /// Presupuesto disponible del dispositivo.
116    pub fn available(&self, device: usize) -> i64 {
117        self.state.avail(device)
118    }
119
120    /// Presupuesto total disponible de la identidad (`Σ b0 − Σ spent`).
121    pub fn total_available(&self) -> i64 {
122        self.state.total_avail()
123    }
124
125    /// Valor del recurso: `inicial − Σ spent`. Certificado: siempre `≥ floor`.
126    pub fn value(&self) -> i64 {
127        self.inv.initial - self.state.spent.iter().sum::<i64>()
128    }
129
130    /// ¿Se preserva el invariante? (Debe ser cierto en toda ejecución.)
131    pub fn invariant_holds(&self) -> bool {
132        self.inv.holds(self.value())
133    }
134
135    /// Intenta mover `deficit` de presupuesto a `device` desde vecinas con
136    /// excedente. **Transaccional**: si no se cubre, no mueve nada (§D fail-safe).
137    fn route_to(&mut self, device: usize, deficit: i64) -> bool {
138        let neighbors = self.nerve.neighbors(device);
139        let surplus: i64 = neighbors.iter().map(|&j| self.state.avail(j).max(0)).sum();
140        if surplus < deficit {
141            return false; // sin ruta: no aplica nada
142        }
143        let mut need = deficit;
144        for j in neighbors {
145            if need == 0 {
146                break;
147            }
148            let give = self.state.avail(j).max(0).min(need);
149            if give > 0 {
150                self.state.grant(j, device, give); // transferencia CRDT (monótona)
151                need -= give;
152            }
153        }
154        true
155    }
156
157    /// **Gasto guardado, durable y fail-safe** (el núcleo del contrato). Gasta
158    /// local si cabe; si no, rebalancea edge-local desde vecinas; si no hay ruta
159    /// (partición), **rechaza** y reporta cuánto puede gastar localmente. Nunca
160    /// sobregira ni cuelga. El commit al WAL precede al ack (durabilidad).
161    pub fn spend(&mut self, device: usize, amount: i64) -> SpendOutcome {
162        debug_assert!(amount >= 0);
163        let avail = self.state.avail(device);
164        if amount > avail {
165            let deficit = amount - avail;
166            if !self.route_to(device, deficit) {
167                return SpendOutcome::Rejected {
168                    spendable: avail.max(0),
169                };
170            }
171        }
172        // Durabilidad: persistir ANTES de reconocer (T11b / §B).
173        self.wal[device].commit_spend(amount);
174        let ok = self.state.spend(device, amount);
175        debug_assert!(ok, "tras rebalancear, el gasto debe caber");
176        SpendOutcome::Spent
177    }
178
179    /// Recuperación tras crash del dispositivo: `spent` se restaura desde el WAL
180    /// durable (`≥ último ack`). Nunca re-gasta lo ya comprometido (T11b).
181    pub fn recover(&mut self, device: usize) {
182        self.state.spent[device] = self.wal[device].committed();
183    }
184
185    /// Arranque de un dispositivo: incrementa y persiste su época (device-local).
186    pub fn reboot(&mut self, device: usize) -> u64 {
187        self.epoch[device] += 1;
188        self.epoch[device]
189    }
190
191    /// **Sincroniza con otra vista de la cuenta** (merge). Detecta un clon si un
192    /// dispositivo aparece con dos épocas: devuelve `CloneDetected` (alarma, no
193    /// recuperación — `BOUNDARY.md`). El merge del presupuesto es CRDT (T10a).
194    pub fn sync(&mut self, other: &SovereignAccount) -> MergeOutcome {
195        let n = self.devices();
196        let mut clon = None;
197        for d in 0..n {
198            if self.epoch[d] != other.epoch[d] {
199                clon = Some(MergeOutcome::CloneDetected {
200                    device_id: d as u64,
201                    stale_epoch: self.epoch[d].min(other.epoch[d]),
202                    live_epoch: self.epoch[d].max(other.epoch[d]),
203                });
204                self.epoch[d] = self.epoch[d].max(other.epoch[d]);
205            }
206        }
207        self.state.merge(&other.state);
208        clon.unwrap_or(MergeOutcome::Ok)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn cuenta_de_100() -> SovereignAccount {
217        // Cuenta de 100, saldo ≥ 0 (slack 100), dos dispositivos [50,50], conectados.
218        SovereignAccount::open(
219            SubstrateCapabilities::full(),
220            GlobalInvariant::new(100, 0),
221            &[1, 1],
222            &[(0, 1)],
223        )
224        .expect("sustrato completo: abre")
225    }
226
227    #[test]
228    fn rehusa_sin_durabilidad() {
229        let caps = SubstrateCapabilities {
230            durable_per_device: false,
231            identity_as_devices: true,
232        };
233        let r = SovereignAccount::open(caps, GlobalInvariant::new(100, 0), &[1, 1], &[(0, 1)]);
234        assert_eq!(r.unwrap_err(), AdapterError::NoDurability);
235    }
236
237    #[test]
238    fn rehusa_sin_identidad_por_dispositivo() {
239        let caps = SubstrateCapabilities {
240            durable_per_device: true,
241            identity_as_devices: false,
242        };
243        let r = SovereignAccount::open(caps, GlobalInvariant::new(100, 0), &[1, 1], &[]);
244        assert_eq!(r.unwrap_err(), AdapterError::NoDeviceIdentity);
245    }
246
247    #[test]
248    fn gasto_local_dentro_de_presupuesto() {
249        let mut c = cuenta_de_100();
250        assert_eq!(c.spend(0, 30), SpendOutcome::Spent);
251        assert_eq!(c.available(0), 20);
252        assert_eq!(c.value(), 70);
253        assert!(c.invariant_holds());
254    }
255
256    #[test]
257    fn gasto_que_excede_lo_local_se_rebalancea() {
258        let mut c = cuenta_de_100();
259        // El dispositivo 0 (50) retira 60: 50 propios + 10 rebalanceados desde 1.
260        assert_eq!(c.spend(0, 60), SpendOutcome::Spent);
261        assert_eq!(c.available(0), 0);
262        assert_eq!(c.available(1), 40);
263        assert_eq!(c.value(), 40);
264        assert!(c.invariant_holds());
265    }
266
267    #[test]
268    fn particion_sin_ruta_es_fail_safe() {
269        // Dos dispositivos [50,50] SIN arista (particionados).
270        let mut c = SovereignAccount::open(
271            SubstrateCapabilities::full(),
272            GlobalInvariant::new(100, 0),
273            &[1, 1],
274            &[],
275        )
276        .unwrap();
277        assert_eq!(c.spend(0, 60), SpendOutcome::Rejected { spendable: 50 });
278        assert_eq!(c.value(), 100, "un rechazo no gasta nada");
279        assert_eq!(c.spend(0, 50), SpendOutcome::Spent, "sus 50 locales sí");
280        assert!(c.invariant_holds());
281    }
282
283    #[test]
284    fn durabilidad_recupera_tras_crash() {
285        let mut c = cuenta_de_100();
286        c.spend(0, 40); // ack durable
287        // "Crash": la contabilidad en memoria se corrompe a un valor viejo.
288        c.state.spent[0] = 0;
289        c.recover(0); // se restaura desde el WAL
290        assert_eq!(c.state.spent[0], 40, "spent recuperado ≥ último ack");
291        assert!(c.invariant_holds());
292    }
293
294    #[test]
295    fn clon_detectado_en_sync() {
296        let mut a = cuenta_de_100();
297        let mut b = a.clone();
298        // El dispositivo 0 de `b` rebootea a una época nueva (linaje bifurcado).
299        b.reboot(0);
300        assert!(matches!(
301            a.sync(&b),
302            MergeOutcome::CloneDetected { device_id: 0, stale_epoch: 0, live_epoch: 1 }
303        ));
304    }
305
306    #[test]
307    fn certificado_gastando_todo_el_slack() {
308        let mut c = cuenta_de_100();
309        // Ambos dispositivos gastan su celda entera: Σ spent = 100 = slack.
310        assert_eq!(c.spend(0, 50), SpendOutcome::Spent);
311        assert_eq!(c.spend(1, 50), SpendOutcome::Spent);
312        assert_eq!(c.value(), 0);
313        assert!(c.invariant_holds(), "valor 0 ≥ piso 0: el certificado aguanta");
314        // Un gasto más no cabe y no hay excedente: fail-safe.
315        assert_eq!(c.spend(0, 1), SpendOutcome::Rejected { spendable: 0 });
316        assert!(c.invariant_holds());
317    }
318}