# Arquitectura de HECATE Documento de referencia del enjambre: qué vive en Rust, qué vive en Python, dónde está la frontera y qué garantías ofrece cada capa. HECATE es un **enjambre neuronal**: varias copias completas del modelo conviven a la vez (Pilar A), el hardware nuevo se integra sin reiniciar (Pilar B) y las IAs locales ceden su conocimiento mediante adaptadores fusionados (Pilar C). | Pilar | Documento | | --- | --- | | A · Multi-pipeline y redundancia | [`swarm-routing.md`](swarm-routing.md) | | B · Crecimiento por hardware | [`growth-protocol.md`](growth-protocol.md) | | C · Telomerasa | [`telomerase.md`](telomerase.md) | ## 1. Vista de componentes ```mermaid flowchart TD subgraph Control["Plano de control · Rust + Tokio (crates/)"] D[mesh-discovery
mDNS + NodeProfile] --> S[mesh-scheduler
plan_swarm + growth_decision] S --> RT[mesh-router
route_request + failover_in_swarm] RT --> O[mesh-orchestrator
Swarm + telomerase_round] O --> W[mesh-worker-net
PeerPool + clientes gRPC] N[hecate-node CLI] -.-> O N -.-> W end subgraph Proto["Contrato · proto/hecate.proto"] PB[["DataPlane: Describe · Health · Forward · Profile
Telomerase: Extract · Merge · Apply · Stats"]] end subgraph Data["Plano de datos · Python + PyTorch (python/hecate/)"] T[ncm_tokenizer
VQ-VAE residual] --> SVC[dataplane.service] F[ncm_transformer
NcmLayerFragment] --> SVC L[telomerase
LoRA + FedAvg] --> SVC SVC --> GRPC[dataplane.server
grpc.server + workers] end W ==>|"Forward"| PB O ==>|"Extract/Merge/Apply"| PB PB ==> GRPC GRPC -.->|"Profile al descubrir"| O ``` - **Plano de control**: no mueve activaciones por sí mismo; decide *quién* ejecuta *qué*, mantiene el pool de sockets y reenvía. Rust con Tokio, sin `unsafe`. - **Contrato**: un `.proto` genera los stubs de ambos lados. Única fuente de verdad. - **Plano de datos**: solo hace matemáticas de tensor. No conoce la topología del enjambre. ## 2. Flujo de una petición ```mermaid sequenceDiagram participant C as Cliente participant R as mesh-router participant A as worker A (copia 0) participant PA as DataPlane A participant B as worker B (copia 0) participant PB as DataPlane B C->>R: petición(model="ncm-base") R->>R: route_request → copia más barata R-->>C: ruta ncm-base#0 = [A, B] C->>A: forward(activaciones, next_layer=0) activate A A->>PA: Forward(layer_start=0, bytes) PA-->>A: ForwardResponse(layer_end=5, bytes) A->>B: forward(activaciones, next_layer=6) activate B B->>PB: Forward(layer_start=6, bytes) PB-->>B: ForwardResponse(layer_end=11, bytes) B-->>A: forward_result deactivate B A-->>C: forward_result (trace=[A,B]) deactivate A ``` El resultado se propaga **sin transformación**: el último nodo responde y los intermedios devuelven la misma cabecera añadiendo su latencia. `trace` acumula el recorrido, lo que hace trivial verificar en los tests qué nodos han participado. ## 3. Mapa de módulos ### Plano de control (Rust) | Crate | Símbolos públicos | Notas | | --- | --- | --- | | `crates/mesh-proto` | `v1::{DataPlaneClient, TelomeraseClient, ForwardRequest, NodeProfile, AdapterPayload}`, `ActivationBuffer`, `encode_activations` | Generado desde el `.proto` + validación de activaciones. | | `crates/mesh-scheduler` | `NodeProfile`, `ExtensionWeights`, `plan_swarm`, `SwarmPlan`, `Pipeline`, `growth_decision`, `GrowthAction`, `apply_growth`, `merge_expert` | Puro: sin E/S. Determinista. | | `crates/mesh-router` | `SwarmState`, `route_request`, `failover_in_swarm`, `pipeline_cost`, `degraded_route` | Elige copia por coste; failover por redundancia. | | `crates/mesh-discovery` | `DiscoveredNode`, `NodeRegistry`, `MdnsDiscovery`, `to_profile` | Anuncia capacidades **y** telomerasa. | | `crates/mesh-worker-net` | `Worker`, `PeerPool`, `DataPlane`, `Telomerase`, `WorkerClient` | Bucle de red + clientes gRPC. | | `crates/mesh-orchestrator` | `Swarm`, `SwarmConfig`, `SwarmStatus`, `telomerase_round`, `TelomeraseLink`, `MergeReport` | Ciclo de vida completo. | | `crates/hecate-node` | CLI | `serve`, `orchestrate`, `plan`, `swarm`, `swarm-sim`, `swarm-live`, `probe`, `bench`. | ### Plano de datos (Python) | Ruta | Símbolos públicos | Notas | | --- | --- | --- | | `python/hecate/ncm_transformer/layer_fragment.py` | `FragmentConfig`, `NcmLayerFragment`, `NcmBlock`, `RMSNorm` | PyTorch real: SDPA causal, SwiGLU. Parámetros congelados. | | `python/hecate/ncm_tokenizer/tokenizer_wrapper.py` | `TokenizerConfig`, `VectorQuantizer`, `NcmTokenizer` | Cuantización residual, entrenamiento EMA. | | `python/hecate/telomerase/adapter.py` | `TelomeraseAdapter`, `LoraConfig`, `LoraLinear`, `ApplyReport` | LoRA entrenable sobre el fragmento congelado. | | `python/hecate/telomerase/federated.py` | `merge_payloads`, `merge_messages`, `federated_average` | FedAvg ponderado por muestras. | | `python/hecate/telomerase/codec.py` | `encode_tensor`, `decode_tensor`, `validate_payload` | f32 LE, el mismo códec que las activaciones. | | `python/hecate/telomerase/service.py` | `TelomeraseService` | `Extract`/`Merge`/`Apply`/`Stats`. | | `python/hecate/dataplane/service.py` | `DataPlaneService`, `PlaneSettings`, `HardwareProfile` | Implementa los cuatro RPC del plano de datos. | ## 4. Protocolo de red Hay **dos** protocolos, deliberadamente distintos. ### 4.1 Salto worker → worker (TCP crudo, Rust) ```text +----------------+----------------+------------------+---------------------+ | uint32 BE | uint32 BE | JSON (UTF-8) | payload binario | | longitud total | longitud cabec. | cabecera | float32 LE | +----------------+----------------+------------------+---------------------+ 4 B 4 B variable rows*cols*4 B ``` Cabecera de petición: ```json { "op": "forward", "protocol": 1, "request_id": "abc-123", "rows": 8, "cols": 24, "next_layer": 7, "trace": ["nodo-a"] } ``` Decisiones: - **JSON para la cabecera**: legible con `tcpdump`/Wireshark y versionable (`protocol`). Cuesta unos 60 bytes y evita el *codegen* de un segundo contrato binario. - **Binario para las activaciones**: `float32` little-endian, el mismo orden que usa el contrato gRPC; no hay *byte swap* en la frontera con Rust ni con NumPy. - **Límite de 64 MiB por trama y 8 KiB de cabecera**: corta mensajes corruptos antes de reservar memoria. - **`next_layer`**: imprescindible cuando un nodo tiene varios fragmentos. ### 4.2 Worker → DataPlane y orquestador → Telomerase (gRPC/HTTP-2) - `activations` es `bytes` `f32` LE crudo, no `repeated float`: el varint de protobuf triplicaría el tamaño en la red. - `ForwardRequest.layer_start` debe ser el inicio exacto de un fragmento. - `Describe`/`Health`/`Profile` quedan fuera de la ruta crítica. - `Telomerase` se registra **en el mismo puerto** que `DataPlane`: un nodo no necesita dos servicios escuchando. - `AdapterPayload` reutiliza el códec de activaciones (f32 LE), de modo que no hay una segunda convención de extremidad que se rompa en otra arquitectura. ## 5. Concurrencia ### 5.1 Plano de control (Tokio) - Cada nodo es un `Worker` que escucha con `TcpListener` y lanza **una tarea Tokio por conexión**: no hay hilo por cliente ni bloqueo. - El `PeerPool` guarda hasta 32 conexiones ociosas por par, con `TCP_NODELAY`. - El contador de peticiones en vuelo y el EWMA viven bajo un `Mutex` pequeño; la medición no toma el lock durante el trabajo. - `Swarm` es síncrono y puro: plan y telemetría se actualizan sin await. Solo `telomerase_round` es asíncrono, y está fuera de la ruta crítica. - El `Cluster` arranca workers con `DEFAULT_BASE_PORT = 0` (puertos efímeros): dos clústeres conviven y los tests son deterministas. ### 5.2 Plano de datos (Python) - `grpc.server` con `ThreadPoolExecutor(max_workers=8)`. - Contadores protegidos por `threading.Lock`; el EWMA usa `EWMA_ALPHA = 0.3`. - `Forward` decodifica con `torch.frombuffer` y ejecuta bajo `@torch.no_grad()`. - El entrenamiento del adaptador **sí** necesita gradiente, así que recorre los bloques directamente (`_forward_trainable`) en lugar del `forward` decorado. ## 6. Despliegue ```mermaid flowchart LR subgraph Host1["portátil · worker :17010"] W1[worker Rust] --> P1[dataplane :50251 + telomerase] end subgraph Host2["mini-PC · worker :17011"] W2[worker Rust] --> P2[dataplane :50252 + telomerase] end subgraph Host3["sobremesa · worker :17012"] W3[worker Rust] --> P3[dataplane :50253] end W1 --> W2 --> W3 O[orchestrator] -.-> W1 O -.-> W2 O -.-> W3 O -.->|"Extract / Apply"| P1 O -.->|"Extract / Apply"| P2 ``` | Configuración | Cuándo | Cómo | | --- | --- | --- | | **Plano de datos compartido** | Validar el control con un único PyTorch. | `shared_data_plane: "http://host:50251"`. | | **Plano de datos por nodo** | Despliegue real, cada nodo con su GPU. | `data_plane: {"gpu": "http://...", "mini": "http://..."}`. | ## 7. Modos de fallo | Fallo | Detección | Respuesta | | --- | --- | --- | | Nodo caído con réplica | `Health` falla `failure_threshold` veces | `failover_in_swarm` reencamina a la réplica de otra copia. | | Nodo caído sin réplica completa | Igual | Empalme por capa; si falta alguna, degradación a `ncm-tiny`. | | Todos los nodos caídos | Igual | `state = failed`; la ruta devuelve `NoAliveNodes`. | | Plano de datos de un nodo caído | `describe()`/`forward()` fallan con `Transport` | El nodo se marca no saludable y el monitor lo trata como caído. | | Activaciones con dimensión errónea | `ActivationBuffer::validate` (Rust) y `decode_activations` (Python) | `INVALID_ARGUMENT` antes de tocar un tensor. | | `layer_start` que no es inicio de fragmento | `DataPlaneService.Forward` | `FAILED_PRECONDITION` con la lista de inicios disponibles. | | Nodo sin telomerasa | `Extract`/`Apply` responden `FAILED_PRECONDITION` | La ronda lo anota como fallo y sigue con los demás. | | Adaptador incompatible (modelo, rango, formas) | Validación en Python | `INVALID_ARGUMENT`; el nodo conserva su adaptador. | | Fusión con un solo payload | Regla de la ronda | No se viaja al fusionador: sería una identidad. | | Nodo saturado | `Health.load` | El router lo evita si hay alternativa más barata. | ## 8. Puntos de extensión - **Contrato**: añadir un RPC es editar el `.proto` y regenerar; `verify.sh` detecta si alguien olvida regenerar. - **Transporte**: sustituir el framing TCP por QUIC o `io_uring` sin tocar scheduler, router ni plano de datos: la frontera es `Frame` + `HopHeader`. - **Modelo**: cambiar `NcmLayerFragment` por un transformer real manteniendo `forward(activaciones) -> activaciones`; el worker no se entera. - **Crecimiento**: añadir una cuarta `GrowthAction` (por ejemplo, dividir un tramo para paralelismo de tensor) solo requiere extender `apply_growth`. - **Fusión**: sustituir FedAvg por ponderación por pérdida sin tocar el servicio gRPC. - **Descubrimiento**: cambiar mDNS por Consul; el `NodeRegistry` es la única superficie. ## 9. Qué no hace (y por qué) - No entrena el modelo base: es un plano de ejecución. La telomerasa ajusta adaptadores, que es barato y local. - No balancea con colas reales: `load` es una aproximación para el router, no un *load balancer*. - No garantiza orden entre peticiones concurrentes: cada una lleva `request_id` y su tarea. - No persiste estado en el plano de control: el plan se recalcula al arrancar y en cada rewire. - No verifica la calidad de un adaptador: solo su forma y su compatibilidad.