# 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.