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.

⎇ 580988cdd9f7570a799f94f44430c89e57b755ab src/router.rs Rust · 175 líneas · 7.5 KB Historia Quién tocó qué crudo

T9b: ruteo de presupuesto por Laplaciano (BudgetRouter) 580988cdd · Sergio ·

1//! `router` — ruteo de presupuesto por el grafo (T9b, §C).
2//!
3//! Cierre de arco honesto: el Laplaciano lineal que en el MVP era un **detector
4//! con falso-positivo** (`H¹ = β₁`) reaparece aquí como un **ruteador de
5//! presupuesto legítimo**. Misma máquina, problema correcto — no detectar
6//! conflictos post-hoc, sino repartir derechos proactivamente sobre el grafo.
7//!
8//! - **Presupuesto** = una 0-cocadena `b` sobre los vértices, con `Σ b_i ≤ S`.
9//! - **Rebalanceo** = un flujo (1-cocadena) que mueve presupuesto por las aristas
10//!   **conservando el total** (`Σ b` invariante ⟹ el certificado se preserva).
11//! - **El Laplaciano rutea**: `diffuse` suaviza el presupuesto (paso de calor,
12//!   reduce la varianza); `rebalance_to` localiza el flujo mínimo que repone a
13//!   una réplica seca desde vecinos con excedente — edge-local, no consenso global.
14//!
15//! Para presupuesto **escalar** basta el Laplaciano de grafo ordinario. El
16//! Laplaciano de haces (Hansen–Ghrist) solo hace falta si los presupuestos son
17//! vectoriales (multi-recurso) — ver TODO en `diffuse` y §H de `T9.md`.
18
19use crate::escrow::Budget;
20use crate::nerve::Nerve;
21
22/// Rutea un presupuesto mutable sobre el grafo del nervio.
23pub struct BudgetRouter<'a> {
24    nerve: &'a Nerve,
25    budget: &'a mut Budget,
26}
27
28impl<'a> BudgetRouter<'a> {
29    /// Envuelve un nervio y un presupuesto para rutearlo.
30    pub fn new(nerve: &'a Nerve, budget: &'a mut Budget) -> Self {
31        Self { nerve, budget }
32    }
33
34    /// Total de presupuesto `Σ b` (la cantidad conservada; el certificado de §B).
35    pub fn total(&self) -> i64 {
36        self.budget.per_replica.iter().sum()
37    }
38
39    /// Flujo **mínimo** que repone `deficit` a la réplica `r` tomándolo de sus
40    /// vecinas con excedente. Devuelve los movimientos `(desde, hacia, monto)`.
41    /// Conserva `Σ b` (solo mueve) y solo toca aristas **adyacentes a `r`**.
42    ///
43    /// Si las vecinas directas no alcanzan, cubre lo posible (el resto quedaría
44    /// para un rebalanceo multi-salto o `diffuse` previo).
45    pub fn rebalance_to(&mut self, r: usize, deficit: i64) -> Vec<(usize, usize, i64)> {
46        let mut moves = Vec::new();
47        let mut need = deficit.max(0);
48        for j in self.nerve.neighbors(r) {
49            if need == 0 {
50                break;
51            }
52            let avail = self.budget.per_replica[j].max(0);
53            let amount = avail.min(need);
54            if amount > 0 {
55                self.budget.per_replica[j] -= amount;
56                self.budget.per_replica[r] += amount;
57                moves.push((j, r, amount));
58                need -= amount;
59            }
60        }
61        moves
62    }
63
64    /// Un paso de difusión de Laplaciano que **suaviza** el presupuesto
65    /// conservando `Σ b`. Por cada arista mueve la mitad (entera) de la diferencia
66    /// del extremo alto al bajo: un promediado por pares que nunca aumenta la
67    /// varianza y mantiene los presupuestos no negativos.
68    ///
69    /// TODO(multi-recurso): para presupuestos vectoriales, sustituir este paso
70    /// escalar por el Laplaciano de haces (§C, §H de `T9.md`).
71    pub fn diffuse(&mut self) {
72        for (a, b) in self.nerve.edges() {
73            let d = self.budget.per_replica[a] - self.budget.per_replica[b];
74            let m = d / 2; // trunca hacia cero; mueve del alto al bajo en ambos signos
75            self.budget.per_replica[a] -= m;
76            self.budget.per_replica[b] += m;
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use proptest::prelude::*;
85
86    fn suma(b: &Budget) -> i64 {
87        b.per_replica.iter().sum()
88    }
89
90    /// Σ b² es un proxy de la varianza cuando `Σ b` está fijo: menor Σ b² ⟺ menor
91    /// varianza. Lo usamos para comprobar que `diffuse` no la aumenta.
92    fn suma_de_cuadrados(b: &Budget) -> i128 {
93        b.per_replica.iter().map(|&x| (x as i128) * (x as i128)).sum()
94    }
95
96    #[test]
97    fn rebalanceo_conserva_el_total_y_repone() {
98        // Camino 0—1—2; la réplica 1 está seca y pide 8.
99        let nerve = Nerve::from_edges(3, &[(0, 1), (1, 2)]);
100        let mut budget = Budget::new(vec![5, 0, 5]);
101        let antes = suma(&budget);
102
103        let moves = BudgetRouter::new(&nerve, &mut budget).rebalance_to(1, 8);
104        assert_eq!(suma(&budget), antes, "el rebalanceo conserva Σ b");
105        assert_eq!(budget.remaining(1), 8, "la réplica seca queda repuesta");
106        // Solo movió desde vecinas de 1 (0 y 2), nunca desde una no vecina.
107        for (from, to, _) in &moves {
108            assert_eq!(*to, 1);
109            assert!(nerve.neighbors(1).contains(from), "movió desde una no vecina");
110        }
111    }
112
113    #[test]
114    fn rebalanceo_solo_toca_vecinas() {
115        // Estrella: centro 0 con hojas 1,2,3. La hoja 3 NO es vecina de 1.
116        let nerve = Nerve::from_edges(4, &[(0, 1), (0, 2), (0, 3)]);
117        let mut budget = Budget::new(vec![10, 0, 0, 0]);
118        let moves = BudgetRouter::new(&nerve, &mut budget).rebalance_to(1, 5);
119        // La única vecina de 1 es 0; el flujo viene solo de ahí.
120        for (from, _, _) in &moves {
121            assert_eq!(*from, 0, "una hoja no puede reponer a otra hoja directamente");
122        }
123        assert_eq!(budget.remaining(1), 5);
124        assert_eq!(budget.remaining(0), 5);
125    }
126
127    #[test]
128    fn diffuse_no_aumenta_varianza_y_conserva_total() {
129        let nerve = Nerve::from_edges(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
130        let mut budget = Budget::new(vec![100, 0, 0, 0]);
131        let antes_sum = suma(&budget);
132        let antes_var = suma_de_cuadrados(&budget);
133
134        BudgetRouter::new(&nerve, &mut budget).diffuse();
135        assert_eq!(suma(&budget), antes_sum, "diffuse conserva Σ b");
136        assert!(suma_de_cuadrados(&budget) <= antes_var, "diffuse no aumenta la varianza");
137        assert!(budget.per_replica.iter().all(|&b| b >= 0), "sigue no negativo");
138    }
139
140    fn nerve_y_budget() -> impl Strategy<Value = (usize, Vec<(usize, usize)>, Vec<i64>)> {
141        (2usize..=6).prop_flat_map(|n| {
142            let arista = (0..n, 0..n).prop_filter("sin lazos", |(a, b)| a != b);
143            let edges = prop::collection::vec(arista, 0..10);
144            let presupuestos = prop::collection::vec(0i64..100, n);
145            (Just(n), edges, presupuestos)
146        })
147    }
148
149    proptest! {
150        /// Conservación e idempotencia del certificado: ni `rebalance_to` ni
151        /// `diffuse` cambian `Σ b`, y `diffuse` nunca aumenta la varianza.
152        #[test]
153        fn ruteo_conserva_total((n, edges, pres) in nerve_y_budget(), objetivo in 0usize..6) {
154            let nerve = Nerve::from_edges(n, &edges);
155            let mut budget = Budget::new(pres);
156            let total = budget.per_replica.iter().sum::<i64>();
157
158            let r = objetivo % n;
159            let moves = BudgetRouter::new(&nerve, &mut budget).rebalance_to(r, 30);
160            prop_assert_eq!(budget.per_replica.iter().sum::<i64>(), total, "rebalance conserva");
161            // Localización: todo movimiento entra a r desde una vecina suya.
162            for (from, to, amount) in &moves {
163                prop_assert_eq!(*to, r);
164                prop_assert!(*amount > 0);
165                prop_assert!(nerve.neighbors(r).contains(from));
166            }
167
168            let var_antes = suma_de_cuadrados(&budget);
169            BudgetRouter::new(&nerve, &mut budget).diffuse();
170            prop_assert_eq!(budget.per_replica.iter().sum::<i64>(), total, "diffuse conserva");
171            prop_assert!(suma_de_cuadrados(&budget) <= var_antes, "diffuse no aumenta varianza");
172            prop_assert!(budget.per_replica.iter().all(|&b| b >= 0));
173        }
174    }
175}