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.

Rust 24Markdown 15TOML 1358 Definiciones

T9b: ruteo de presupuesto por Laplaciano (BudgetRouter)

T9 §C: el Laplaciano lineal, que en el MVP era un detector con falso-positivo
(H¹=β₁), reaparece como ruteador de presupuesto legítimo. Misma máquina,
problema correcto.

- nerve: por fin útil — Nerve::from_edges/num_replicas/edges/neighbors sobre
  el grafo petgraph (era scaffold desde M0).
- router::BudgetRouter (reusa el nervio):
  · rebalance_to(r, deficit): flujo MÍNIMO que repone a r desde vecinas con
    excedente; devuelve (desde,hacia,monto). Conserva Σb, solo aristas
    adyacentes a r.
  · diffuse(): paso de Laplaciano (promediado por pares, mitad entera de la
    diferencia) que suaviza conservando Σb y sin aumentar la varianza; deja
    los presupuestos no negativos. TODO: Laplaciano de haces si multi-recurso.

Tests (+ proptest): rebalanceo conserva Σb y solo toca vecinas del nodo seco;
diffuse conserva Σb y no aumenta Σb² (varianza). 53 tests, clippy limpio.

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

580988cdd9f7570a799f94f44430c89e57b755ab · por Sergio · (hace 3 meses) · padre 89c62aa71 · ver el árbol en este commit

Delta semántico · 17

src/lib.rs

src/nerve.rs

src/router.rs

3 archivos · +210 −1

diff --git a/src/lib.rs b/src/lib.rs
index 092401d..04898e1 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -22,6 +22,7 @@ pub mod linalg;
 pub mod native;
 pub mod nerve;
 pub mod oracle;
+pub mod router;
 pub mod sheaf;
 pub mod tarski;
 pub mod verdict;
diff --git a/src/nerve.rs b/src/nerve.rs
index e5eac74..9d5bd7a 100644
--- a/src/nerve.rs
+++ b/src/nerve.rs
@@ -2,7 +2,7 @@
 //!
 //! Vértices = réplicas, aristas = pares que comparten un dato.
 
-use petgraph::graph::UnGraph;
+use petgraph::graph::{NodeIndex, UnGraph};
 
 /// Identificador de una réplica (vértice del nervio).
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -31,4 +31,37 @@ impl Nerve {
             graph: UnGraph::new_undirected(),
         }
     }
+
+    /// Construye el nervio de `num_vertices` réplicas y una lista de aristas.
+    /// Los índices de nodo coinciden con los `ReplicaId` (0..num_vertices).
+    pub fn from_edges(num_vertices: usize, edges: &[(usize, usize)]) -> Self {
+        let mut graph = UnGraph::new_undirected();
+        let nodes: Vec<NodeIndex> = (0..num_vertices).map(|i| graph.add_node(ReplicaId(i))).collect();
+        for &(a, b) in edges {
+            graph.add_edge(nodes[a], nodes[b], SharedLink::default());
+        }
+        Self { graph }
+    }
+
+    /// Número de réplicas (vértices).
+    pub fn num_replicas(&self) -> usize {
+        self.graph.node_count()
+    }
+
+    /// Las aristas como pares `(u, v)` de índices de réplica.
+    pub fn edges(&self) -> Vec<(usize, usize)> {
+        self.graph
+            .edge_indices()
+            .filter_map(|e| self.graph.edge_endpoints(e))
+            .map(|(a, b)| (a.index(), b.index()))
+            .collect()
+    }
+
+    /// Vecinas de la réplica `v` en el nervio.
+    pub fn neighbors(&self, v: usize) -> Vec<usize> {
+        self.graph
+            .neighbors(NodeIndex::new(v))
+            .map(|n| n.index())
+            .collect()
+    }
 }
diff --git a/src/router.rs b/src/router.rs
new file mode 100644
index 0000000..c783864
--- /dev/null
+++ b/src/router.rs
@@ -0,0 +1,175 @@
+//! `router` — ruteo de presupuesto por el grafo (T9b, §C).
+//!
+//! Cierre de arco honesto: el Laplaciano lineal que en el MVP era un **detector
+//! con falso-positivo** (`H¹ = β₁`) reaparece aquí como un **ruteador de
+//! presupuesto legítimo**. Misma máquina, problema correcto — no detectar
+//! conflictos post-hoc, sino repartir derechos proactivamente sobre el grafo.
+//!
+//! - **Presupuesto** = una 0-cocadena `b` sobre los vértices, con `Σ b_i ≤ S`.
+//! - **Rebalanceo** = un flujo (1-cocadena) que mueve presupuesto por las aristas
+//!   **conservando el total** (`Σ b` invariante ⟹ el certificado se preserva).
+//! - **El Laplaciano rutea**: `diffuse` suaviza el presupuesto (paso de calor,
+//!   reduce la varianza); `rebalance_to` localiza el flujo mínimo que repone a
+//!   una réplica seca desde vecinos con excedente — edge-local, no consenso global.
+//!
+//! Para presupuesto **escalar** basta el Laplaciano de grafo ordinario. El
+//! Laplaciano de haces (Hansen–Ghrist) solo hace falta si los presupuestos son
+//! vectoriales (multi-recurso) — ver TODO en `diffuse` y §H de `T9.md`.
+
+use crate::escrow::Budget;
+use crate::nerve::Nerve;
+
+/// Rutea un presupuesto mutable sobre el grafo del nervio.
+pub struct BudgetRouter<'a> {
+    nerve: &'a Nerve,
+    budget: &'a mut Budget,
+}
+
+impl<'a> BudgetRouter<'a> {
+    /// Envuelve un nervio y un presupuesto para rutearlo.
+    pub fn new(nerve: &'a Nerve, budget: &'a mut Budget) -> Self {
+        Self { nerve, budget }
+    }
+
+    /// Total de presupuesto `Σ b` (la cantidad conservada; el certificado de §B).
+    pub fn total(&self) -> i64 {
+        self.budget.per_replica.iter().sum()
+    }
+
+    /// Flujo **mínimo** que repone `deficit` a la réplica `r` tomándolo de sus
+    /// vecinas con excedente. Devuelve los movimientos `(desde, hacia, monto)`.
+    /// Conserva `Σ b` (solo mueve) y solo toca aristas **adyacentes a `r`**.
+    ///
+    /// Si las vecinas directas no alcanzan, cubre lo posible (el resto quedaría
+    /// para un rebalanceo multi-salto o `diffuse` previo).
+    pub fn rebalance_to(&mut self, r: usize, deficit: i64) -> Vec<(usize, usize, i64)> {
+        let mut moves = Vec::new();
+        let mut need = deficit.max(0);
+        for j in self.nerve.neighbors(r) {
+            if need == 0 {
+                break;
+            }
+            let avail = self.budget.per_replica[j].max(0);
+            let amount = avail.min(need);
+            if amount > 0 {
+                self.budget.per_replica[j] -= amount;
+                self.budget.per_replica[r] += amount;
+                moves.push((j, r, amount));
+                need -= amount;
+            }
+        }
+        moves
+    }
+
+    /// Un paso de difusión de Laplaciano que **suaviza** el presupuesto
+    /// conservando `Σ b`. Por cada arista mueve la mitad (entera) de la diferencia
+    /// del extremo alto al bajo: un promediado por pares que nunca aumenta la
+    /// varianza y mantiene los presupuestos no negativos.
+    ///
+    /// TODO(multi-recurso): para presupuestos vectoriales, sustituir este paso
+    /// escalar por el Laplaciano de haces (§C, §H de `T9.md`).
+    pub fn diffuse(&mut self) {
+        for (a, b) in self.nerve.edges() {
+            let d = self.budget.per_replica[a] - self.budget.per_replica[b];
+            let m = d / 2; // trunca hacia cero; mueve del alto al bajo en ambos signos
+            self.budget.per_replica[a] -= m;
+            self.budget.per_replica[b] += m;
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use proptest::prelude::*;
+
+    fn suma(b: &Budget) -> i64 {
+        b.per_replica.iter().sum()
+    }
+
+    /// Σ b² es un proxy de la varianza cuando `Σ b` está fijo: menor Σ b² ⟺ menor
+    /// varianza. Lo usamos para comprobar que `diffuse` no la aumenta.
+    fn suma_de_cuadrados(b: &Budget) -> i128 {
+        b.per_replica.iter().map(|&x| (x as i128) * (x as i128)).sum()
+    }
+
+    #[test]
+    fn rebalanceo_conserva_el_total_y_repone() {
+        // Camino 0—1—2; la réplica 1 está seca y pide 8.
+        let nerve = Nerve::from_edges(3, &[(0, 1), (1, 2)]);
+        let mut budget = Budget::new(vec![5, 0, 5]);
+        let antes = suma(&budget);
+
+        let moves = BudgetRouter::new(&nerve, &mut budget).rebalance_to(1, 8);
+        assert_eq!(suma(&budget), antes, "el rebalanceo conserva Σ b");
+        assert_eq!(budget.remaining(1), 8, "la réplica seca queda repuesta");
+        // Solo movió desde vecinas de 1 (0 y 2), nunca desde una no vecina.
+        for (from, to, _) in &moves {
+            assert_eq!(*to, 1);
+            assert!(nerve.neighbors(1).contains(from), "movió desde una no vecina");
+        }
+    }
+
+    #[test]
+    fn rebalanceo_solo_toca_vecinas() {
+        // Estrella: centro 0 con hojas 1,2,3. La hoja 3 NO es vecina de 1.
+        let nerve = Nerve::from_edges(4, &[(0, 1), (0, 2), (0, 3)]);
+        let mut budget = Budget::new(vec![10, 0, 0, 0]);
+        let moves = BudgetRouter::new(&nerve, &mut budget).rebalance_to(1, 5);
+        // La única vecina de 1 es 0; el flujo viene solo de ahí.
+        for (from, _, _) in &moves {
+            assert_eq!(*from, 0, "una hoja no puede reponer a otra hoja directamente");
+        }
+        assert_eq!(budget.remaining(1), 5);
+        assert_eq!(budget.remaining(0), 5);
+    }
+
+    #[test]
+    fn diffuse_no_aumenta_varianza_y_conserva_total() {
+        let nerve = Nerve::from_edges(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
+        let mut budget = Budget::new(vec![100, 0, 0, 0]);
+        let antes_sum = suma(&budget);
+        let antes_var = suma_de_cuadrados(&budget);
+
+        BudgetRouter::new(&nerve, &mut budget).diffuse();
+        assert_eq!(suma(&budget), antes_sum, "diffuse conserva Σ b");
+        assert!(suma_de_cuadrados(&budget) <= antes_var, "diffuse no aumenta la varianza");
+        assert!(budget.per_replica.iter().all(|&b| b >= 0), "sigue no negativo");
+    }
+
+    fn nerve_y_budget() -> impl Strategy<Value = (usize, Vec<(usize, usize)>, Vec<i64>)> {
+        (2usize..=6).prop_flat_map(|n| {
+            let arista = (0..n, 0..n).prop_filter("sin lazos", |(a, b)| a != b);
+            let edges = prop::collection::vec(arista, 0..10);
+            let presupuestos = prop::collection::vec(0i64..100, n);
+            (Just(n), edges, presupuestos)
+        })
+    }
+
+    proptest! {
+        /// Conservación e idempotencia del certificado: ni `rebalance_to` ni
+        /// `diffuse` cambian `Σ b`, y `diffuse` nunca aumenta la varianza.
+        #[test]
+        fn ruteo_conserva_total((n, edges, pres) in nerve_y_budget(), objetivo in 0usize..6) {
+            let nerve = Nerve::from_edges(n, &edges);
+            let mut budget = Budget::new(pres);
+            let total = budget.per_replica.iter().sum::<i64>();
+
+            let r = objetivo % n;
+            let moves = BudgetRouter::new(&nerve, &mut budget).rebalance_to(r, 30);
+            prop_assert_eq!(budget.per_replica.iter().sum::<i64>(), total, "rebalance conserva");
+            // Localización: todo movimiento entra a r desde una vecina suya.
+            for (from, to, amount) in &moves {
+                prop_assert_eq!(*to, r);
+                prop_assert!(*amount > 0);
+                prop_assert!(nerve.neighbors(r).contains(from));
+            }
+
+            let var_antes = suma_de_cuadrados(&budget);
+            BudgetRouter::new(&nerve, &mut budget).diffuse();
+            prop_assert_eq!(budget.per_replica.iter().sum::<i64>(), total, "diffuse conserva");
+            prop_assert!(suma_de_cuadrados(&budget) <= var_antes, "diffuse no aumenta varianza");
+            prop_assert!(budget.per_replica.iter().all(|&b| b >= 0));
+        }
+    }
+}