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>
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));
+ }
+ }
+}