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 11Markdown 1TOML 187 Definitions

M4: adaptador soberano con API congelada y contrato honesto

Unifica todo el proyecto tras adapter::SovereignAccount, con las precondiciones
del contrato §H en el centro:
- SubstrateCapabilities + AdapterError: open() REHÚSA (NoDurability /
  NoDeviceIdentity) si el sustrato no cumple, en vez de prometer seguridad que
  no tiene. La negativa es parte del diseño.
- Compone las piezas: EscrowState (presupuesto con procedencia, T10a) + WAL
  durable por dispositivo (T11b) + ruteo edge-local por el nervio (T9b/T10b) +
  época de fencing (T11c).
- API: spend (local | rebalanceo | Rejected{spendable}, fail-safe), available,
  value/invariant_holds (certificado value≥floor), recover (desde WAL),
  reboot (época), sync (Ok | CloneDetected como ALARMA, no recuperación).

M4.md: contrato congelado en tabla (cláusula → cómo se cumple → test), la API
pública, qué compone cada garantía, y las fronteras dichas (no previene clon;
no hay disponibilidad ilimitada bajo partición; no es motor de replicación).

Nerve ahora deriva Clone. 81 tests, clippy limpio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

703ae7023578fabc109ef038063c7351fb3b9f52 · by Sergio · (3 months ago) · parent 100d09cc7 · browse the tree at this commit

Semantic delta · 26

src/lib.rs

4 files · +390 −1

diff --git a/M4.md b/M4.md
new file mode 100644
index 0000000..1b76dfe
--- /dev/null
+++ b/M4.md
@@ -0,0 +1,70 @@
+# M4 — el adaptador soberano (API congelada)
+
+El puente a los sistemas soberanos (Tawasuyu/Hammer). Unifica todo el proyecto
+tras una API estable, `adapter::SovereignAccount`, con precondiciones honestas. No
+se declara completo hasta que el contrato de abajo se sostiene bajo los tests que
+lo respaldan (`adapter::tests`).
+
+## El contrato (§H de T9/T10/T11) — escrito en piedra
+
+| Cláusula | Cómo se cumple | Respaldo |
+|---|---|---|
+| **Requiere** WAL durable de `spent` por dispositivo | `open` rehúsa con `NoDurability` si `caps.durable_per_device == false` | `rehusa_sin_durabilidad` |
+| **Requiere** identidad = conjunto de dispositivos | `open` rehúsa con `NoDeviceIdentity` si `caps.identity_as_devices == false` | `rehusa_sin_identidad_por_dispositivo` |
+| **Garantiza** seguridad coordination-free del invariante | `value() ≥ floor` en toda ejecución (Σ b0 conservado, gastos guardados) | `certificado_gastando_todo_el_slack` |
+| **Acota y detecta** (no previene) el clon | `sync` devuelve `CloneDetected`; el daño ya ocurrió, es **alarma** (`BOUNDARY.md`) | `clon_detectado_en_sync` |
+| **Limita** la disponibilidad bajo partición | `spend` devuelve `Rejected { spendable }`; nunca sobregira ni cuelga | `particion_sin_ruta_es_fail_safe` |
+| **Rehúsa** en vez de prometer de más | `open` devuelve `Err(AdapterError)`, no una cuenta insegura | ambos `rehusa_*` |
+
+## La API pública congelada
+
+```rust
+SubstrateCapabilities { durable_per_device, identity_as_devices }
+AdapterError { NoDurability, NoDeviceIdentity, InvalidSplit }
+
+SovereignAccount::open(caps, inv, weights, edges) -> Result<Self, AdapterError>
+    // rehúsa si el sustrato no cumple; reparte el slack en celdas-dispositivo.
+
+  .spend(device, amount) -> SpendOutcome        // Local | rebalanceo | Rejected{spendable}
+  .available(device) -> i64                     // presupuesto local
+  .total_available() -> i64                     // Σ b0 − Σ spent
+  .value() -> i64                               // inicial − Σ spent (≥ floor: certificado)
+  .invariant_holds() -> bool
+  .recover(device)                              // restaura spent desde el WAL tras crash
+  .reboot(device) -> u64                        // incrementa la época device-local (fencing)
+  .sync(&other) -> MergeOutcome                 // Ok | CloneDetected (alarma)
+```
+
+## Qué compone (y de dónde sale cada garantía)
+
+- **Presupuesto con procedencia** — `escrow::EscrowState` (T10a): crash-safe y
+  merge-safe; `Σ avail = Σ b0 − Σ spent` invariante a las transferencias.
+- **Durabilidad** — `durable::WriteAheadLog` (T11b): el `spend` persiste al WAL
+  **antes** del ack; `recover` restaura `≥ último ack`. Sin ella, `open` rehúsa.
+- **Ruteo edge-local** — `nerve` + transferencias `grant` (T9b/T10b): `spend`
+  rebalancea de vecinas con excedente, transaccional (o rechaza, no cuelga).
+- **Réplica-por-dispositivo** — `escrow::Identity` (T11a): la cuenta ES la
+  identidad; cada dispositivo, una celda single-writer.
+- **Fencing / clon** — época device-local + `sync` → `MergeOutcome::CloneDetected`
+  (T11c). Detalle del daño acotado a una celda en `fencing.rs`.
+
+## Lo que M4 NO promete (las fronteras, dichas)
+
+- **No previene el doble gasto por clon.** Lo acota (una celda) y lo delata.
+  `CloneDetected` es una **alarma**, no una recuperación: el dinero ya salió, la
+  compensación vive en la capa de aplicación y puede fracasar (`BOUNDARY.md`).
+- **No hay disponibilidad ilimitada bajo partición.** Cada dispositivo se limita a
+  su presupuesto local; `diffuse` (ruteo proactivo, `router.rs`) lo mitiga, no lo
+  elimina (`AVAILABILITY.md`). CAP: safety > availability.
+- **No es un motor de replicación.** No hay red real ni wire protocol: es la capa
+  de decisión coordination-free que el sustrato soberano invoca. La persistencia y
+  el transporte los provee Tawasuyu/Hammer; el adaptador **exige** que la
+  persistencia sea durable-por-dispositivo, o rehúsa.
+
+## El enunciado, una última vez
+
+**Seguridad coordination-free módulo un supuesto físico nombrado:** un escritor
+durable por celda. Bajo él, el certificado dinámico de T10 aplica sin coordinación.
+Roto (clon), el daño está acotado a una celda por clon no detectado y se detecta al
+reconectar. No "seguro pase lo que pase" — seguro salvo esta condición física, con
+el costo exacto de romperla escrito en `BOUNDARY.md` y `AVAILABILITY.md`.
diff --git a/src/adapter.rs b/src/adapter.rs
new file mode 100644
index 0000000..30527f2
--- /dev/null
+++ b/src/adapter.rs
@@ -0,0 +1,318 @@
+//! `adapter` — el puente a los sistemas soberanos (M4, §H de T9/T10/T11).
+//!
+//! Unifica las piezas del proyecto tras una **API congelada** con precondiciones
+//! honestas. Cablea `EscrowState` (presupuesto con procedencia, crash-safe y
+//! merge-safe, T10a), el WAL durable (T11b), el ruteo por el grafo del nervio
+//! (T9b/T10b) y el fencing por época (T11c) en una cuenta soberana usable.
+//!
+//! **Contrato del adaptador (§H):**
+//! - **Requiere** WAL durable de `spent` por dispositivo (persistir antes del ack).
+//! - **Requiere** identidad-como-conjunto-de-dispositivos (una celda por dispositivo).
+//! - **Garantiza** seguridad coordination-free del invariante bajo el supuesto de
+//!   un-escritor-durable-por-celda (certificado dinámico de T10).
+//! - **Acota y detecta** (no previene) el doble gasto por clon; expone
+//!   `CloneDetected` como señal para que la aplicación compense (ver `BOUNDARY.md`:
+//!   es una alarma, no una recuperación).
+//! - **Limita** la disponibilidad bajo partición al presupuesto local
+//!   (`AVAILABILITY.md`).
+//!
+//! Si el sustrato no puede dar durabilidad por dispositivo, `open` **rehúsa** en
+//! vez de prometer seguridad que no tiene. Esa negativa es parte del diseño.
+
+use thiserror::Error;
+
+use crate::durable::WriteAheadLog;
+use crate::escrow::{Budget, EscrowState};
+use crate::fencing::MergeOutcome;
+use crate::invariant::GlobalInvariant;
+use crate::nerve::Nerve;
+use crate::router::SpendOutcome;
+
+/// Lo que el sustrato soberano (Tawasuyu/Hammer) declara poder ofrecer.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SubstrateCapabilities {
+    /// ¿Persiste `spent` por dispositivo antes de reconocer (WAL/fsync)?
+    pub durable_per_device: bool,
+    /// ¿La identidad es un conjunto de dispositivos, cada uno con su celda?
+    pub identity_as_devices: bool,
+}
+
+impl SubstrateCapabilities {
+    /// Un sustrato que cumple ambas precondiciones del contrato.
+    pub fn full() -> Self {
+        Self {
+            durable_per_device: true,
+            identity_as_devices: true,
+        }
+    }
+}
+
+/// El adaptador rehúsa cuando el sustrato no cumple el contrato (§H).
+#[derive(Debug, Error, PartialEq, Eq)]
+pub enum AdapterError {
+    /// Sin WAL durable por dispositivo no hay garantía: se rehúsa.
+    #[error("el sustrato no ofrece durabilidad por dispositivo: se rehúsa (sin ella, sin garantía)")]
+    NoDurability,
+    /// Sin identidad-como-conjunto-de-dispositivos no se cumple single-writer por celda.
+    #[error("el sustrato no modela la identidad como conjunto de dispositivos: se rehúsa")]
+    NoDeviceIdentity,
+    /// Reparto inválido (excede el slack o negativo).
+    #[error("reparto de presupuesto inválido para el invariante dado")]
+    InvalidSplit,
+}
+
+/// Una cuenta soberana: una identidad con sus celdas-dispositivo, su invariante,
+/// su presupuesto (con procedencia y durabilidad) y su grafo de comunicación.
+#[derive(Debug, Clone)]
+pub struct SovereignAccount {
+    inv: GlobalInvariant,
+    nerve: Nerve,
+    /// Presupuesto con procedencia (crash-safe, merge-safe): `b0` + `granted` + `spent`.
+    state: EscrowState,
+    /// WAL durable por dispositivo: `spent` no puede olvidar.
+    wal: Vec<WriteAheadLog>,
+    /// Época de fencing por dispositivo (device-local, monótona).
+    epoch: Vec<u64>,
+}
+
+impl SovereignAccount {
+    /// **Abre una cuenta, o rehúsa** si el sustrato no cumple el contrato (§H).
+    ///
+    /// `weights` reparte el `slack` del invariante entre los dispositivos
+    /// (`Σ b0 ≤ slack`, garantizado por construcción); `edges` es el grafo de
+    /// comunicación entre dispositivos de la identidad.
+    pub fn open(
+        caps: SubstrateCapabilities,
+        inv: GlobalInvariant,
+        weights: &[u64],
+        edges: &[(usize, usize)],
+    ) -> Result<Self, AdapterError> {
+        if !caps.durable_per_device {
+            return Err(AdapterError::NoDurability);
+        }
+        if !caps.identity_as_devices {
+            return Err(AdapterError::NoDeviceIdentity);
+        }
+        let b0 = Budget::split(&inv, weights).per_replica;
+        let n = b0.len();
+        if b0.iter().any(|&b| b < 0) || b0.iter().sum::<i64>() > inv.slack() {
+            return Err(AdapterError::InvalidSplit);
+        }
+        Ok(Self {
+            inv,
+            nerve: Nerve::from_edges(n, edges),
+            state: EscrowState::new(b0),
+            wal: (0..n).map(|_| WriteAheadLog::new()).collect(),
+            epoch: vec![0; n],
+        })
+    }
+
+    /// Número de dispositivos de la identidad.
+    pub fn devices(&self) -> usize {
+        self.state.replicas()
+    }
+
+    /// Presupuesto disponible del dispositivo.
+    pub fn available(&self, device: usize) -> i64 {
+        self.state.avail(device)
+    }
+
+    /// Presupuesto total disponible de la identidad (`Σ b0 − Σ spent`).
+    pub fn total_available(&self) -> i64 {
+        self.state.total_avail()
+    }
+
+    /// Valor del recurso: `inicial − Σ spent`. Certificado: siempre `≥ floor`.
+    pub fn value(&self) -> i64 {
+        self.inv.initial - self.state.spent.iter().sum::<i64>()
+    }
+
+    /// ¿Se preserva el invariante? (Debe ser cierto en toda ejecución.)
+    pub fn invariant_holds(&self) -> bool {
+        self.inv.holds(self.value())
+    }
+
+    /// Intenta mover `deficit` de presupuesto a `device` desde vecinas con
+    /// excedente. **Transaccional**: si no se cubre, no mueve nada (§D fail-safe).
+    fn route_to(&mut self, device: usize, deficit: i64) -> bool {
+        let neighbors = self.nerve.neighbors(device);
+        let surplus: i64 = neighbors.iter().map(|&j| self.state.avail(j).max(0)).sum();
+        if surplus < deficit {
+            return false; // sin ruta: no aplica nada
+        }
+        let mut need = deficit;
+        for j in neighbors {
+            if need == 0 {
+                break;
+            }
+            let give = self.state.avail(j).max(0).min(need);
+            if give > 0 {
+                self.state.grant(j, device, give); // transferencia CRDT (monótona)
+                need -= give;
+            }
+        }
+        true
+    }
+
+    /// **Gasto guardado, durable y fail-safe** (el núcleo del contrato). Gasta
+    /// local si cabe; si no, rebalancea edge-local desde vecinas; si no hay ruta
+    /// (partición), **rechaza** y reporta cuánto puede gastar localmente. Nunca
+    /// sobregira ni cuelga. El commit al WAL precede al ack (durabilidad).
+    pub fn spend(&mut self, device: usize, amount: i64) -> SpendOutcome {
+        debug_assert!(amount >= 0);
+        let avail = self.state.avail(device);
+        if amount > avail {
+            let deficit = amount - avail;
+            if !self.route_to(device, deficit) {
+                return SpendOutcome::Rejected {
+                    spendable: avail.max(0),
+                };
+            }
+        }
+        // Durabilidad: persistir ANTES de reconocer (T11b / §B).
+        self.wal[device].commit_spend(amount);
+        let ok = self.state.spend(device, amount);
+        debug_assert!(ok, "tras rebalancear, el gasto debe caber");
+        SpendOutcome::Spent
+    }
+
+    /// Recuperación tras crash del dispositivo: `spent` se restaura desde el WAL
+    /// durable (`≥ último ack`). Nunca re-gasta lo ya comprometido (T11b).
+    pub fn recover(&mut self, device: usize) {
+        self.state.spent[device] = self.wal[device].committed();
+    }
+
+    /// Arranque de un dispositivo: incrementa y persiste su época (device-local).
+    pub fn reboot(&mut self, device: usize) -> u64 {
+        self.epoch[device] += 1;
+        self.epoch[device]
+    }
+
+    /// **Sincroniza con otra vista de la cuenta** (merge). Detecta un clon si un
+    /// dispositivo aparece con dos épocas: devuelve `CloneDetected` (alarma, no
+    /// recuperación — `BOUNDARY.md`). El merge del presupuesto es CRDT (T10a).
+    pub fn sync(&mut self, other: &SovereignAccount) -> MergeOutcome {
+        let n = self.devices();
+        let mut clon = None;
+        for d in 0..n {
+            if self.epoch[d] != other.epoch[d] {
+                clon = Some(MergeOutcome::CloneDetected {
+                    device_id: d as u64,
+                    stale_epoch: self.epoch[d].min(other.epoch[d]),
+                    live_epoch: self.epoch[d].max(other.epoch[d]),
+                });
+                self.epoch[d] = self.epoch[d].max(other.epoch[d]);
+            }
+        }
+        self.state.merge(&other.state);
+        clon.unwrap_or(MergeOutcome::Ok)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn cuenta_de_100() -> SovereignAccount {
+        // Cuenta de 100, saldo ≥ 0 (slack 100), dos dispositivos [50,50], conectados.
+        SovereignAccount::open(
+            SubstrateCapabilities::full(),
+            GlobalInvariant::new(100, 0),
+            &[1, 1],
+            &[(0, 1)],
+        )
+        .expect("sustrato completo: abre")
+    }
+
+    #[test]
+    fn rehusa_sin_durabilidad() {
+        let caps = SubstrateCapabilities {
+            durable_per_device: false,
+            identity_as_devices: true,
+        };
+        let r = SovereignAccount::open(caps, GlobalInvariant::new(100, 0), &[1, 1], &[(0, 1)]);
+        assert_eq!(r.unwrap_err(), AdapterError::NoDurability);
+    }
+
+    #[test]
+    fn rehusa_sin_identidad_por_dispositivo() {
+        let caps = SubstrateCapabilities {
+            durable_per_device: true,
+            identity_as_devices: false,
+        };
+        let r = SovereignAccount::open(caps, GlobalInvariant::new(100, 0), &[1, 1], &[]);
+        assert_eq!(r.unwrap_err(), AdapterError::NoDeviceIdentity);
+    }
+
+    #[test]
+    fn gasto_local_dentro_de_presupuesto() {
+        let mut c = cuenta_de_100();
+        assert_eq!(c.spend(0, 30), SpendOutcome::Spent);
+        assert_eq!(c.available(0), 20);
+        assert_eq!(c.value(), 70);
+        assert!(c.invariant_holds());
+    }
+
+    #[test]
+    fn gasto_que_excede_lo_local_se_rebalancea() {
+        let mut c = cuenta_de_100();
+        // El dispositivo 0 (50) retira 60: 50 propios + 10 rebalanceados desde 1.
+        assert_eq!(c.spend(0, 60), SpendOutcome::Spent);
+        assert_eq!(c.available(0), 0);
+        assert_eq!(c.available(1), 40);
+        assert_eq!(c.value(), 40);
+        assert!(c.invariant_holds());
+    }
+
+    #[test]
+    fn particion_sin_ruta_es_fail_safe() {
+        // Dos dispositivos [50,50] SIN arista (particionados).
+        let mut c = SovereignAccount::open(
+            SubstrateCapabilities::full(),
+            GlobalInvariant::new(100, 0),
+            &[1, 1],
+            &[],
+        )
+        .unwrap();
+        assert_eq!(c.spend(0, 60), SpendOutcome::Rejected { spendable: 50 });
+        assert_eq!(c.value(), 100, "un rechazo no gasta nada");
+        assert_eq!(c.spend(0, 50), SpendOutcome::Spent, "sus 50 locales sí");
+        assert!(c.invariant_holds());
+    }
+
+    #[test]
+    fn durabilidad_recupera_tras_crash() {
+        let mut c = cuenta_de_100();
+        c.spend(0, 40); // ack durable
+        // "Crash": la contabilidad en memoria se corrompe a un valor viejo.
+        c.state.spent[0] = 0;
+        c.recover(0); // se restaura desde el WAL
+        assert_eq!(c.state.spent[0], 40, "spent recuperado ≥ último ack");
+        assert!(c.invariant_holds());
+    }
+
+    #[test]
+    fn clon_detectado_en_sync() {
+        let mut a = cuenta_de_100();
+        let mut b = a.clone();
+        // El dispositivo 0 de `b` rebootea a una época nueva (linaje bifurcado).
+        b.reboot(0);
+        assert!(matches!(
+            a.sync(&b),
+            MergeOutcome::CloneDetected { device_id: 0, stale_epoch: 0, live_epoch: 1 }
+        ));
+    }
+
+    #[test]
+    fn certificado_gastando_todo_el_slack() {
+        let mut c = cuenta_de_100();
+        // Ambos dispositivos gastan su celda entera: Σ spent = 100 = slack.
+        assert_eq!(c.spend(0, 50), SpendOutcome::Spent);
+        assert_eq!(c.spend(1, 50), SpendOutcome::Spent);
+        assert_eq!(c.value(), 0);
+        assert!(c.invariant_holds(), "valor 0 ≥ piso 0: el certificado aguanta");
+        // Un gasto más no cabe y no hay excedente: fail-safe.
+        assert_eq!(c.spend(0, 1), SpendOutcome::Rejected { spendable: 0 });
+        assert!(c.invariant_holds());
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index eacc221..0039688 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -11,6 +11,7 @@
 //! Pipeline (§5):
 //! `cell` → `nerve` → `sheaf` → `cohomology` → `verdict`, validado por `oracle`.
 
+pub mod adapter;
 pub mod cell;
 pub mod cohomology;
 pub mod durable;
diff --git a/src/nerve.rs b/src/nerve.rs
index 9d5bd7a..5662bed 100644
--- a/src/nerve.rs
+++ b/src/nerve.rs
@@ -18,7 +18,7 @@ pub struct SharedLink {
 }
 
 /// El nervio: grafo no dirigido de réplicas y sus enlaces compartidos.
-#[derive(Debug, Default)]
+#[derive(Debug, Default, Clone)]
 pub struct Nerve {
     /// Grafo no dirigido: vértices = réplicas, aristas = pares que comparten datos.
     pub graph: UnGraph<ReplicaId, SharedLink>,