# Redundancia y reparto de capas ## 1. Puntuación de capacidad Cada nodo declara cuatro magnitudes medibles: VRAM, RAM, NPU (TOPS) y latencia base. La puntuación combina las tres primeras con pesos normalizados y penaliza la latencia: $$ \operatorname{score}(n) = w_v \cdot \text{VRAM} + w_r \cdot \text{RAM} + w_n \cdot \text{NPU} - w_\ell \cdot \frac{\text{latencia}}{100} $$ con $w = (2.0,\ 0.5,\ 1.5,\ 0.25)$ normalizados para sumar 1. Si el nodo está cargado se aplica además un factor $(1 - 0.5 \cdot \text{load})$, de modo que un nodo al 100 % de carga conserva la mitad de atractivo. Suelo: $\operatorname{score} \ge 10^{-3}$ para evitar divisiones por cero en `node_cost()`. ## 2. Reparto proporcional por resto mayor Dadas $N$ puntuaciones y $L$ capas: 1. Cuota real de cada nodo: $q_i = L \cdot \dfrac{s_i}{\sum_j s_j}$. 2. Asignación entera: $a_i = \lfloor q_i \rfloor$. 3. Sobran $R = L - \sum_i a_i$ capas ($0 \le R < N$). Se reparten de una en una entre los nodos con mayor **parte fraccionaria** $q_i - a_i$; a igualdad, mayor puntuación; a igualdad, menor `node_id` (determinismo total). 4. `min_layers_per_node`: si algún nodo queda con menos capas de las permitidas, se las quita al nodo más cargado que aún pueda cederlas. Si no hay donante, `ShardingError`. Propiedades garantizadas (verificadas en `tests/test_scheduler.py`): - $\sum_i a_i = L$ exactamente. - Cada capa tiene **un** propietario primario: `plan.primary_owner` cubre $0 \dots L-1$. - El reparto es determinista: mismos nodos y pesos → mismo plan. - Nodos con más puntuación reciben más capas. ## 3. Bloques contiguos y orden del pipeline Las capas se entregan en **bloques contiguos**, ordenados por puntuación descendente: ```text score alto score bajo [nodo A: 0-6][nodo B: 7-9][nodo C: 10-11] ``` Contigüidad = un solo salto de red por frontera; orden por capacidad = el nodo más potente recibe la cabeza (las capas más caras) y el más ligero cierra el pipeline. `ShardingPlan.nodes()` devuelve el orden real del pipeline (por primera capa primaria) y `next_hop(node)` el siguiente nodo. El orquestador usa ambos para encadenar los workers. ## 4. Réplica de la cabeza Motivo: las primeras capas reciben la entrada y son el punto de fallo más doloroso. Se replican en los nodos **más ligeros que no las posean**, porque son los que tienen margen de VRAM/RAM: 1. Tamaño de cabeza: $H = \max(1, \operatorname{round}(L \cdot \rho))$, con $\rho$ = `replicate_head_ratio` (0.25 por defecto) y $H \le L-1$. 2. Candidatos: nodos ordenados por puntuación **ascendente**, excluyendo a los propietarios primarios de la cabeza. 3. Número de réplicas: `replica_count` o $\max(1, \lfloor N/3 \rfloor)$. 4. Los rangos replicados se fusionan con los primarios del nodo mediante `merge_ranges()` cuando son contiguos (una réplica de 0-2 en un nodo que ya tiene 3-5 se convierte en un único fragmento 0-5). Ejemplo con 3 nodos, 24 capas y $\rho = 0.25$: ```text A (score 28): 0-11 ← propietario primario B (score 13): 12-18 C (score 9): 19-23 + réplica 0-5 redundancy_map[0] = [A, C] redundancy_map[6] = [A] coverage() = {0..5: 2, resto: 1} ``` ## 5. Invariantes del mapa de redundancia `plan.redundancy_map[layer]` es una lista de nodos **capaces** de servir esa capa, con el propietario primario siempre en primera posición: - `holders(layer)[0] == owner_of(layer)`. - `uncovered_layers() == []` en cualquier plan válido. - `coverage()[layer] > 1` solo en las capas replicadas (la cabeza, por defecto). - `layer_count(node)` cuenta primarias **y** réplicas, porque es lo que el worker puede ejecutar de verdad. ## 6. Failover paso a paso ```mermaid flowchart TD F[Nodo X cae] --> L{¿Qué capas servía X en esta ruta?} L --> R{¿Todas tienen holder vivo distinto de X?} R -->|Sí| S[Elegir el holder más barato por capa] S --> P[Empalmar en la posición de X y deduplicar] P --> OK[Ruta nueva · degraded=False] R -->|No| D{¿fallback_model permitido?} D -->|Sí| G[Redistribuir capas entre supervivientes] G --> M[Modelo ncm-tiny · degraded=True] D -->|No| E[RoutingFailure controlada] ``` Detalles de implementación (`failover()`): - Empalme **posicional**: el sustituto ocupa el hueco de X para no alterar el orden del pipeline. - Deduplicación **solo de duplicados consecutivos**: si el sustituto ya estaba en la ruta y además es dueño de un tramo posterior, debe reaparecer al final para no perder cobertura. Ese caso se resuelve con una **tabla de reenvío por capa** en el worker (`set_routing_table`): tras terminar el fragmento que acaba en `L`, consulta qué nodo atiende `L+1`. - Los holders marcados `alive=False` se descartan. - El coste de la ruta nueva se recalcula con `route_cost()`; los nodos sin telemetría usan valores por defecto (`DEFAULT_LATENCY_MS`, `DEFAULT_CAPACITY_SCORE`). ## 7. Modo degradado Cuando no hay réplica para todas las capas perdidas: 1. Se conservan los nodos vivos de la ruta (`route.nodes`). 2. El orquestador recalcula el plan sobre los supervivientes con `rebalance()`, que mantiene el número total de capas y la proporción de cabeza del plan anterior. 3. Se regeneran los fragmentos de cada superviviente (`WorkerState::set_routing()` con el plan nuevo), se reescribe la cadena y se sirve `fallback_model` (`ncm-tiny` por defecto). El plano de datos de cada nodo se reconfigura fuera de la ruta (`Describe` al arrancar), así que el cambio no cuesta una recarga en caliente. 4. `status()["state"] == "degraded"` y `status()["model"] == "ncm-tiny"`. Si no queda ningún superviviente, `status()["state"] == "failed"` y `forward()` lanza `ClusterError`. ## 8. Rebalanceo al incorporar un nodo `Cluster::start()` despliega el plan inicial; el crecimiento del clúster se hace con `ClusterConfig::new(nodes, total_layers)`: se recalcula el plan sobre todos los nodos vivos, se redespliega (los workers existentes adoptan el plan nuevo con `WorkerState::set_routing()`, el nuevo arranca) y `rewire()` vuelve a elegir ruta. Si el plan no cabe (más nodos que capas con el mínimo configurado), se lanza `ClusterError::Scheduler`. ## 9. Límites del diseño - **La réplica cubre solo la cabeza** por defecto. Para replicar tramos intermedios basta con extender `assign_layers()`: el mapa ya es `capa → [nodos]`, no está limitado a la cabeza.- **No hay consenso**: el orquestador es la única autoridad. Dos orquestadores sobre el mismo clúster producirían planes divergentes. - **Cambiar un plan cambia pesos** en esta implementación (se regeneran por semilla); en un despliegue con checkpoints reales equivaldría a recargar el fragmento, con el coste de E/S correspondiente. - **Un nodo lento no es un nodo caído**: el router lo evita por coste, pero no se declara fallo hasta que `remote_health()` falla `failure_threshold` veces.