//! `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; /// Resultado de un intento de rebalanceo (T10b, §D). #[derive(Debug, Clone, PartialEq, Eq)] pub enum RebalanceResult { /// Se repuso el déficit; flujo edge-local usado `(desde, hacia, monto)`. Routed { /// Los movimientos aplicados (conservan `Σ b`). edges: Vec<(usize, usize, i64)>, }, /// Sin ruta a excedente (partición): **no se movió nada**. Fail-safe arriba. Unreachable, } /// Resultado de un gasto guardado por el presupuesto (T10b, §D/§F.4). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpendOutcome { /// Se gastó el monto pedido (local, o tras un rebalanceo exitoso). Spent, /// Sin ruta al presupuesto: **rechazado, fail-safe**. La réplica puede gastar /// como mucho `spendable` (su presupuesto local) sin sobregirar ni colgarse. Rejected { /// Lo máximo que la réplica puede gastar localmente ahora. spendable: i64, }, } /// 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` desde sus vecinas /// con excedente. **Transaccional** (T10b): si las vecinas directas cubren el /// déficit, aplica el flujo y devuelve `Routed`; si no lo cubren (partición), /// **no mueve nada** y devuelve `Unreachable` — nunca deja un estado a medias /// ni bloquea. Conserva `Σ b` y solo toca aristas **adyacentes a `r`**. /// /// Limitación honesta (§D): es edge-local a las vecinas directas. Un nodo cuyo /// componente tiene excedente pero no en sus vecinas inmediatas recibe /// `Unreachable`; `diffuse` lo mitiga repartiendo antes, no lo elimina. pub fn rebalance_to(&mut self, r: usize, deficit: i64) -> RebalanceResult { let need = deficit.max(0); if need == 0 { return RebalanceResult::Routed { edges: Vec::new() }; } let neighbors = self.nerve.neighbors(r); let surplus: i64 = neighbors.iter().map(|&j| self.budget.per_replica[j].max(0)).sum(); if surplus < need { return RebalanceResult::Unreachable; // fail-safe: no aplica nada } let mut moves = Vec::new(); let mut rem = need; for j in neighbors { if rem == 0 { break; } let avail = self.budget.per_replica[j].max(0); let amount = avail.min(rem); if amount > 0 { self.budget.per_replica[j] -= amount; self.budget.per_replica[r] += amount; moves.push((j, r, amount)); rem -= amount; } } RebalanceResult::Routed { edges: moves } } /// Gasto guardado por el presupuesto, con rebalanceo y **fail-safe** (§D/§F.4): /// si cabe local, gasta; si no, intenta reponer desde vecinas; si no hay ruta /// (`Unreachable`), **rechaza** y reporta cuánto puede gastar localmente. Nunca /// sobregira ni cuelga. pub fn spend_with_rebalance(&mut self, r: usize, amount: i64) -> SpendOutcome { let local = self.budget.per_replica[r]; if amount <= local { self.budget.per_replica[r] -= amount; return SpendOutcome::Spent; } match self.rebalance_to(r, amount - local) { RebalanceResult::Routed { .. } => { self.budget.per_replica[r] -= amount; // el rebalanceo dejó lo justo SpendOutcome::Spent } RebalanceResult::Unreachable => SpendOutcome::Rejected { spendable: self.budget.per_replica[r].max(0), }, } } /// 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() } /// Extrae las aristas de un `Routed`, o falla si fue `Unreachable`. fn routed(res: RebalanceResult) -> Vec<(usize, usize, i64)> { match res { RebalanceResult::Routed { edges } => edges, RebalanceResult::Unreachable => panic!("debía rutear, no Unreachable"), } } #[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 = routed(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 = routed(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); } /// **§F.4 — partición sin ruta ⟹ fail-safe.** El 60/60 particionado con /// `[50,50]`: la réplica 0 aislada intenta retirar 60 → `rebalance_to` da /// `Unreachable` (no mueve nada), el gasto se **rechaza** y a lo sumo puede /// gastar sus 50 locales. Nunca sobregira, nunca cuelga. #[test] fn particion_sin_ruta_es_fail_safe() { let nerve = Nerve::from_edges(2, &[]); // sin aristas: partición total let mut budget = Budget::new(vec![50, 50]); let antes = suma(&budget); let mut router = BudgetRouter::new(&nerve, &mut budget); // El rebalanceo no tiene ruta y no mueve nada. assert_eq!(router.rebalance_to(0, 10), RebalanceResult::Unreachable); // El gasto de 60 se rechaza; puede gastar hasta 50. assert_eq!(router.spend_with_rebalance(0, 60), SpendOutcome::Rejected { spendable: 50 }); assert_eq!(suma(&budget), antes, "un rechazo no toca el presupuesto"); // Gastar los 50 locales sí es seguro (valor = 100 − 50 = 50 ≥ 0). assert_eq!( BudgetRouter::new(&nerve, &mut budget).spend_with_rebalance(0, 50), SpendOutcome::Spent ); assert_eq!(budget.remaining(0), 0); } /// Con ruta, el gasto que excede lo local se cubre por rebalanceo y procede. #[test] fn gasto_con_ruta_se_cubre_por_rebalanceo() { let nerve = Nerve::from_edges(2, &[(0, 1)]); let mut budget = Budget::new(vec![50, 50]); // La réplica 0 retira 60: 50 locales + 10 repuestos desde 1. assert_eq!( BudgetRouter::new(&nerve, &mut budget).spend_with_rebalance(0, 60), SpendOutcome::Spent ); assert_eq!(budget.remaining(0), 0); assert_eq!(budget.remaining(1), 40); assert_eq!(suma(&budget), 40, "gastó 60 de los 100: Σ b = 40"); } #[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, Vec)> { (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::(); let r = objetivo % n; let res = BudgetRouter::new(&nerve, &mut budget).rebalance_to(r, 30); // Conserva Σ b tanto si rutea (mueve dentro) como si es Unreachable (no mueve). prop_assert_eq!(budget.per_replica.iter().sum::(), total, "rebalance conserva"); if let RebalanceResult::Routed { edges } = res { // Localización: todo movimiento entra a r desde una vecina suya. for (from, to, amount) in &edges { 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::(), 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)); } } }