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.
M4: adaptador soberano con API congelada y contrato honesto
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}