# Enrutamiento consciente del coste ## 1. Objetivo Elegir, para cada petición, la secuencia de nodos que cubre las capas solicitadas con el **menor coste estimado**. El coste está en milisegundos equivalentes, así que se puede comparar directamente con la latencia que observa el usuario. ## 2. Modelo de coste ### 2.1 Coste de una etapa $$ \operatorname{cost}(n) = w_\ell \cdot \text{latencia}_n + w_c \cdot \text{load}_n \cdot 120 + w_k \cdot \frac{100}{\max(\operatorname{score}_n,\ 0.01)} $$ | Símbolo | Peso por defecto | Significado | | --- | --- | --- | | $w_\ell$ | 1.0 | La latencia medida pesa 1 a 1. | | $w_c$ | 1.0 | Un nodo al 100 % de carga añade 120 ms equivalentes. | | $w_k$ | 0.35 | Un nodo con score 1 añade 35 ms; con score 28, 1.25 ms. | ### 2.2 Coste de una ruta $$ \operatorname{coste}(R) = \sum_{n \in R} \operatorname{cost}(n) + w_h \cdot 15 \cdot (|R| - 1) + w_p \cdot 10 \cdot \frac{\text{tokens\_prompt}}{1024} $$ - Término de saltos: cada frontera entre nodos cuesta 15 ms equivalentes. - Término de prefill: un prompt largo penaliza por igual todas las rutas, pero hace que las diferencias de latencia pesen proporcionalmente menos. - Ruta vacía → $\infty$. ## 3. Camino mínimo por fronteras de fragmento Una restricción manda sobre todo lo demás: **un fragmento se ejecuta completo**. El nodo que recibe `next_layer = L` solo puede continuar si tiene un fragmento que *empieza* en `L`; entrar en mitad de un fragmento recalcularía capas que otro nodo ya ha hecho. Por eso `score_route()` resuelve un camino mínimo en un grafo acíclico: - **Vértices**: las fronteras de capa `first, first+1, ..., last+1`. - **Aristas**: cada fragmento `(L, E)` de un nodo vivo, que va de `L` a `E+1` con coste `cost(nodo) + coste_salto` (el salto solo se cobra si cambia de nodo). - **Solución**: programación dinámica en orden ascendente de capa, con desempate por número de etapas y después por `node_id` (determinismo total). Un nodo con dos fragmentos contiguos cuenta como **una sola etapa**; un nodo que reaparece más adelante (por ejemplo, sirviendo la cabeza replicada y después la cola) cuenta dos veces, porque ejecuta dos tramos. Si no existe camino completo se lanza `NoRouteAvailable` con la ventana en el mensaje. ## 4. Ejemplo numérico Tres nodos, `gpu` saturado pero potente, `mini` sano y `laptop` con réplica implícita: | Nodo | Latencia | Carga | Score | $\operatorname{cost}(n)$ | | --- | --- | --- | --- | --- | | gpu | 40 ms | 0.8 | 10 | 40 + 96 + 3.5 = **139.5** | | mini | 6 ms | 0.2 | 6 | 6 + 24 + 5.8 = **35.8** | | laptop | 9 ms | 0.0 | 3 | 9 + 0 + 11.7 = **20.7** | - Camino `gpu (0-3) → mini (4-7) → laptop (8-11)`: 139.5 + 35.8 + 20.7 + 2·15 = **226.0** - Camino `mini (0-7) → laptop (8-11)`: 35.8 + 20.7 + 15 = **71.5** ← óptimo Resultado: `("mini", "laptop")`. Es el caso que verifica `test_score_route_prefers_the_cheapest_covering_pipeline`. El DP explora `mini` para la cabeza (0-3) y, como también es dueño de 4-7, lo mantiene en la misma etapa: no se cobra un salto ficticio ni se lista dos veces. ## 5. Ventana de capas `RoutingRequest.layer_range` permite pedir un subconjunto: - `None`: la ventana es `[primera capa servida, última capa servida]` de los nodos vivos. - `(8, 11)`: solo la cola; el router puede devolver un único nodo. `prompt_tokens` no cambia la cobertura, solo el coste relativo del prefill. ## 6. Failover `failover(route, redundancy_map, failed_node, ...)`: 1. Si `failed_node` no está en la ruta → se devuelve la ruta intacta con la razón "no participa en la ruta". No es un error: el orquestador puede sondear nodos ociosos. 2. Se determinan las capas del nodo caído (de su `NodeState.served_layers` si hay telemetría; si no, de `redundancy_map`), recortadas a la ventana de la ruta. 3. Para cada capa se busca el holder vivo más barato distinto del caído. Si **todas** las capas tienen sustituto, el sustituto ocupa la posición del caído. Solo se colapsan duplicados **consecutivos**: si el sustituto ya estaba más adelante en la ruta y además es dueño de otro tramo, reaparece al final para no perder cobertura. 4. Si falta alguna capa: - `fallback_model` no nulo → ruta degradada con los supervivientes (`degraded=True`). - `fallback_model=None` → `RoutingFailure` (excepción controlada). ## 7. Telemetría `NodeState` se puede construir desde diccionarios (`NodeState.from_dict`) para que el router funcione con respuestas de `health` llegadas por red: ```json { "node_id": "mini", "latency_ms": 6.4, "current_load": 0.2, "capacity_score": 13.0, "served_layers": [[7, 9]], "alive": true } ``` `WorkerState::health()` devuelve además `served_requests`, `failed_requests`, `load` y `latency_ms`, que son útiles para diagnóstico pero no entran en el coste. El `load` del plano de datos llega por la RPC `Health` y se agrega en el nodo. ## 8. Ajuste de pesos ```rust use mesh_router::{RoutingRequest, RouteWeights, score_route}; // Prioriza latencia sobre capacidad (LAN rápida, nodos pequeños) let rapido = RouteWeights { latency: 2.0, load: 1.5, capacity: 0.1, hop: 0.05, prompt: 0.2, }; let ruta = score_route(RoutingRequest::default(), &estados, rapido, "ncm")?; println!("{} saltos, coste {:.1}", ruta.hops(), ruta.cost); ``` Recomendaciones: - **Ethernet 1 GbE**: subir `hop` a 0.3-0.5 si el pipeline tiene 3+ nodos. - **Nodos muy heterogéneos**: subir `capacity` para no cargar el mini-PC. - **Carga variable**: subir `load` para evitar el nodo caliente antes de que se sature. - **Plano de datos remoto**: el salto worker → DataPlane por gRPC no se modela como `hop`; entra en `latency` porque el nodo lo mide con su propio EWMA. ## 9. Lo que el router no hace - No reserva capacidad ni impone cuotas: dos peticiones pueden elegir el mismo nodo. - No mide la red en tiempo real: la latencia llega del EWMA del worker. - No reintenta: si el nodo elegido falla durante la petición, el orquestador recompone el pipeline para las siguientes.