Files
enlace/docs/PLAN.md
T
msaldain 90ac2a6582 Etapa 0: entorno, configuración validada, modelo y entrenador
Base del proyecto ENLACE: un modelo de lenguaje propio entrenado desde cero,
en español, para asistencia general y familiar. El plan completo está en
docs/PLAN.md.

Esta etapa establece el andamiaje y lo verifica de punta a punta:

- Configuración por capas (hardware × model × train × data) validada con
  pydantic. Ningún hiperparámetro vive en el código y una config inválida
  falla al arrancar, no a las tres horas de entrenamiento.
- Perfiles de hardware que aíslan el salto de GPU: la RTX 2060 (Turing) no
  soporta bfloat16 ni FlashAttention-2, así que entrena en float16 con
  GradScaler y backend mem_efficient; el perfil de la 5090 ya está escrito.
  backends.py valida el perfil contra la GPU real antes de empezar.
- Transformer decoder-only estilo Llama: RMSNorm, SwiGLU, RoPE, GQA,
  embeddings atados, QK-norm y z-loss. Los dos últimos son lo que mantiene
  estable el entrenamiento en float16.
- Entrenador con schedule WSD, acumulación de gradiente, precisión mixta,
  checkpointing atómico y reanudación exacta.
- Cargadores de datos con estado serializable: bytes para el smoke test y
  shards uint16 para el corpus real.

48 tests, entre ellos el crítico: reanudar desde un checkpoint reproduce los
pesos de una corrida ininterrumpida, parámetro por parámetro.

Verificado en CPU: 300 pasos sobre texto en español, loss 3.07 -> 1.63.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:04:45 -03:00

479 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ENLACE — Modelo de lenguaje propio, desde cero
## Contexto
Crear un **modelo de lenguaje propio desde cero** — pesos propios, tokenizer propio, datos propios —
no fine-tuning de un modelo existente. Opera principalmente en **español**.
- **Rol principal:** asistente de propósito general, con foco en **utilidad y practicidad**. Primero
búsqueda web y uso de herramientas.
- **Rol secundario:** apoyo en actividades familiares, **multi-usuario**. Más adelante, integración con
Home Assistant, calendarios y progresivamente con todos los dispositivos con conectividad del hogar.
- **Estilo:** respuestas **concretas**, no elaboradas. **Sin emojis**, nunca.
- **Persona:** identidad propia con nombre (ENLACE), en el registro de Data (Star Trek), Andrew
(El Hombre Bicentenario) y los robots de Asimov: literal, preciso, económico, sin adornos.
- **Estado en bases de datos, no en los pesos.** El modelo aporta lenguaje e intención; los hechos,
usuarios, dispositivos y recuerdos se consultan en bases del sistema en tiempo de ejecución.
- **Memoria y aprendizaje:** acumula una **línea cronológica de experiencias**, detecta tareas
repetitivas y las optimiza, y cada tanto integra lo aprendido para ir formando una personalidad
propia. **Los datos se recolectan con un solo fin: que ENLACE se mejore a sí mismo.** Nada sale del
servidor.
- **Usuarios:** varios, cantidad desconocida y creciente. **Mateo Saldain es el Admin.** ENLACE debe
reconocer y distinguir usuarios, y actuar según el contexto almacenado de cada uno.
- **Reversibilidad:** todo cambio en el modelo es un snapshot restaurable.
**Cómputo:** servidor remoto por SSH con una **RTX 2060 12 GB**. La **RTX 5090 32 GB** es una mejora
futura; el salto va a requerir cambios, y el objetivo de ingeniería es que queden **contenidos en
archivos de configuración** en vez de dispersos por el código.
### Qué no es
Explicitarlo evita gastar esfuerzo y capacidad del modelo donde no rinde:
- **No es un traductor.** No se construyen datos ni evals de traducción; opera en español.
- **No tiene foco académico.** No se persiguen benchmarks de razonamiento, matemática ni exámenes. Un
modelo de este tamaño no compite ahí, y el objetivo es utilidad diaria.
- **No escribe código** por ahora.
- **No es un chatbot de conversación larga.** Respuestas cortas y accionables.
- **No es una base de conocimiento.** No se espera que el modelo *sepa* cosas: las consulta.
- **No es un mecanismo de control de acceso.** El modelo nunca decide quién es alguien ni qué puede
hacer; eso lo resuelve el runtime.
### Dos restricciones que definen el diseño
**Escala.** Un modelo entrenado desde cero en una GPU de consumo llega, bien entrenado, a ~100500M
parámetros. Ese tamaño **sí** aprende de forma confiable: español fluido, clasificación de intención, y
traducción de lenguaje natural a llamadas de herramienta. Lo que **no** alcanza es razonamiento libre
multi-paso, síntesis de textos largos, ni memorización confiable de hechos. Tres cosas juegan a favor:
el estilo pedido —conciso, sin adornos— es *exactamente lo que un modelo chico hace bien*; el runtime
del agente es independiente del modelo; y **sacar los hechos de los pesos y ponerlos en bases de datos
elimina de raíz la principal fuente de error de un modelo chico**, que es inventar datos.
**Aprendizaje continuo.** El sistema es **de dos velocidades**, y ninguna actualiza pesos por interacción:
| | Velocidad rápida (memoria) | Velocidad lenta (consolidación) |
|---|---|---|
| **Cuándo** | Cada interacción | Cada ~24 semanas, en el servidor |
| **Qué cambia** | El contexto y las bases consultadas | Los pesos, vía snapshot nuevo |
| **Efecto** | Recuerda, reconoce, se adapta ya | La experiencia se vuelve carácter |
| **Reversible** | Sí, borrando un registro | Sí, restaurando el snapshot anterior |
Es la analogía correcta con Andrew: la experiencia diaria se acumula como registro, y cada tanto se
integra en quién es. La rápida da utilidad inmediata; la lenta da crecimiento real.
---
## Principio central: el modelo no sabe, el sistema consulta
Un modelo de 50M no puede almacenar hechos de forma confiable — cualquier dato que "recuerde" puede
alucinarlo. La separación resuelve el problema en vez de mitigarlo:
| | Vive en | Cómo se cambia |
|---|---|---|
| **Capacidad** — español, intención, formato de tool-calls, registro | Los pesos | Consolidación (semanas) |
| **Conocimiento** — usuarios, dispositivos, hechos, recuerdos, rutinas | Bases del sistema | `UPDATE` (inmediato) |
Consecuencias prácticas, y son las que justifican todo el diseño:
- **Un dispositivo nuevo se agrega con un INSERT, no con un reentrenamiento.** Una preferencia que
cambia, un integrante que se muda, un alias nuevo para una luz: todo es una fila.
- **Borrar es borrar de verdad.** Un hecho eliminado de la base desaparece; un hecho absorbido por los
pesos no se puede quitar sin reentrenar.
- **Es auditable.** Se puede responder "¿de dónde sacó eso?" señalando la fila exacta.
- **La gramática de decodificación se construye desde las bases.** Los slots enumerados
(`dispositivo`, `usuario`, `rutina`, `sala`) se restringen en tiempo de decodificación a los valores
que **existen** en la base en ese momento. El modelo queda impedido de inventar un dispositivo por
construcción, no por entrenamiento. Esta es la técnica más rentable de todo el proyecto.
- **El trabajo del modelo se reduce a lo que sabe hacer:** entender la frase, elegir la tool y llenar
slots contra un conjunto cerrado que le llega en el contexto.
### Bases del sistema (`enlace/store/`, SQLite, todas en el servidor y fuera de git)
| Base | Contenido | Escribe |
|---|---|---|
| `users.db` | Usuarios, identidades por canal, roles, estado | Solo el Admin |
| `devices.db` | Inventario de dispositivos y entidades, capacidades, **alias en lenguaje natural**, sala | Sincronizado desde Home Assistant + alias a mano |
| `knowledge.db` | Hechos estructurados del hogar: cumpleaños, preferencias, horarios, ubicaciones | Usuarios y reflexión, con scope |
| `memory.db` | Episodios cronológicos, resúmenes de reflexión, índice vectorial | El runtime, append-only |
| `routines.db` | Rutinas propuestas, aprobadas y su historial de uso | Minero de patrones + aprobación humana |
| `persona.db` | Núcleo fijo + rasgos acumulados + perfiles de interacción por usuario | Reflexión y consolidación |
Cada base tiene esquema versionado con migraciones, y todas entran en el snapshot (Etapa 7): restaurar
un modelo sin sus bases da un sistema incoherente.
---
## Principios de ingeniería
Desde el primer commit, no se agregan después:
1. **Nada hardcodeado en los scripts.** Ningún número mágico, ruta ni hiperparámetro vive en código.
Todo va a YAML bajo `configs/`, con composición por capas: `model` × `train` × `hardware` × `data` ×
`agent`. Los scripts reciben una config y no saben nada más.
2. **Config validada, no diccionarios sueltos.** `pydantic` + YAML (OmegaConf para el merge). Una config
inválida falla al arrancar con un mensaje claro, no a las tres horas de entrenamiento.
3. **Config es estructura; base de datos es contenido.** Los roles se definen en YAML; las personas
viven en `users.db`. Los esquemas de tools son código; el inventario de dispositivos es una base. La
regla: si cambia en tiempo de ejecución o contiene datos personales, va a base, no a config.
4. **El perfil de hardware es una capa aparte.** Cambiar de GPU es cambiar de perfil.
5. **La config viaja con el checkpoint.** Cada snapshot guarda su config exacta, el hash del tokenizer y
el commit de git. Un checkpoint sin su config no es reproducible.
6. **Secretos fuera de la config.** API keys en `.env` (gitignored). Las configs se commitean; los
secretos y los datos personales, no.
7. **Todo lo derivado es reconstruible.** `memory.db` (episodios) es la fuente de verdad; índices,
resúmenes y datasets de consolidación se regeneran desde cero.
8. **Los datos no salen del servidor.** Sin telemetría, sin nube. La recolección existe únicamente para
alimentar la consolidación.
---
## Decisiones de arquitectura orientadas a crecer
1. **Tokenizer congelado desde el día 1.** BPE byte-level propio, vocab 32k. Se reservan de entrada los
tokens del chat template (`<|system|>`, `<|user|>`, `<|assistant|>`, `<|tool_call|>`,
`<|tool_result|>`, `<|context|>`, `<|memory|>`, `<|user_id|>`, `<|eot|>`) **más 64 `<|reserved_N|>`
sin usar**. Ampliar capacidades después no obliga a re-tokenizar el corpus ni a redimensionar el
embedding.
2. **Sin emojis en el vocabulario.** Se filtran del corpus y no se incluyen sus rangos en el tokenizer.
Con una máscara de logits en inferencia, la restricción es estructural en vez de una instrucción
desobedecible. Es gratis y es absoluto.
3. **`seq_len` 2048, no 1024.** El contexto recuperado de las bases se inyecta ahí, así que el
presupuesto de contexto es un recurso de primer orden. RoPE permite extenderlo después.
4. **Datos en shards de tokens.** Tokenizados una sola vez a `uint16`. Sirven igual para 50M que 500M.
5. **Schedule WSD (WarmupStableDecay), no cosine.** Cosine exige fijar el total de pasos por adelantado
y no se puede extender. WSD permite extender un run, ramificar checkpoints, hacer **annealing con el
corpus de estilo**, y hace baratas las **consolidaciones periódicas**.
6. **Tools como registro de plugins.** Cada tool declara esquema JSON, permiso requerido, roles
habilitados y **de qué base saca sus valores enumerados**; se descubre por directorio y se activa por
config. Agregar un dispositivo nuevo es agregar filas; agregar una *clase* de dispositivo es agregar
un archivo. Es lo que hace viable "todos los dispositivos con conectividad".
7. **Crecimiento por stacking** (`model/growth.py`): inicializar un modelo más profundo duplicando capas
de uno entrenado (*gradual stacking* / LlamaPro), ensanchar por Net2Net. El modelo de la 2060 es el
punto de partida del de la 5090, no descarte.
---
## Etapas
### Etapa 0 — Entorno remoto y validación del stack (12 días)
**Flujo por SSH** (`scripts/remote.sh`): el código viaja por `git push` / `git pull` en el servidor;
datos, bases y checkpoints **viven en el servidor y nunca se descargan enteros**; solo vuelven
artefactos chicos. Toda corrida larga en `tmux` con log a archivo — si se corta el SSH, sigue.
**Perfil de hardware de la 2060 (Turing, sm_75)**, todo en `configs/hardware/turing-2060.yaml`:
- **No soporta bf16.** Entrenar en **fp16 + `GradScaler`**, propenso a picos de loss; se mitiga con
QK-norm, RMSNorm y z-loss. El código lee `dtype` y `use_grad_scaler` del perfil.
- **FlashAttention-2 no corre en Turing** (requiere Ampere+). `F.scaled_dot_product_attention` con el
backend del perfil (`mem_efficient` acá, `flash` en la 5090).
- Batch y acumulación de gradiente también del perfil: 12 GB y 32 GB no admiten lo mismo.
- `uv` y **Python 3.12** (no 3.13: mejor compatibilidad del ecosistema).
**Smoke test:** nanoGPT char-level en español, ~10 min, hasta ver el loss bajar y generar texto
reconocible. Valida CUDA, `torch.compile`, checkpointing, logging y el flujo SSH.
### Etapa 1 — Datos y tokenizer en español (23 días)
**Corpus base** (streaming; nunca cargar el dataset en memoria):
- `HuggingFaceFW/fineweb-2`, subset `spa_Latn` — la mejor web en español filtrada hoy. Base.
- Wikipedia en español — densidad factual (para responder, no para tono académico).
- Libros de dominio público en español — prosa larga y bien formada.
**Limpieza:** filtros de calidad, deduplicación MinHash, filtro de idioma (fastText) contra
portugués/catalán/inglés colados, stripping de emojis. Umbrales en `configs/data/`, no en código.
**Tokenizer:** `tokenizers` (HF), BPE byte-level, vocab 32k, sin emojis, sobre ~5 GB de muestra
balanceada + ejemplos de tool-calls y de bloques de contexto. Aceptación: **< 2.2 bytes/token**.
**Shards:** `uint16`, 100M tokens por shard, con `index.json` de origen y mezcla. Objetivo: **~3B
tokens** (≈ 6 GB en disco). **Salida:** shards + `tokenizer.json` congelado y versionado.
### Etapa 2 — Pretraining del modelo base (12 días de cómputo en la 2060)
Arquitectura estilo Llama, decoder-only (`enlace/model/transformer.py`):
- RMSNorm pre-norm · SwiGLU · RoPE · GQA · **sin bias** · **embeddings atados** (con vocab 32k y
`d_model` 512, atar entrada/salida ahorra ~16M params: enorme en un modelo de 50M).
- **QK-norm y z-loss no son opcionales** — mantienen estable el entrenamiento en fp16.
- `configs/model/tiny-50m.yaml`: 12 capas, `d_model` 512, 8 heads / 2 kv-heads, `seq_len` 2048.
- **Entrenador** (`train/train.py`): AdamW, WSD, `torch.compile`, acumulación de gradiente, **checkpoint
y reanudación exacta** (modelo, optimizer, posición en el stream, RNG), logging de `loss`,
`grad_norm`, `tokens/s` y **MFU**. Cero hiperparámetros en el archivo: todos de config.
**Annealing (final del WSD) — acá entra el corpus de ciencia ficción.** Durante el decay del LR se sube
el peso de un corpus chico y de alta calidad, para inyectar registro sin contaminar la competencia
general: prosa cuidada, diálogo formal, textos sobre IA y sobre qué significa ser una máquina que
piensa. Nota práctica: Asimov + Orwell + transcripciones de Star Trek suman **~2040M tokens, casi todo
en inglés** — 1% del presupuesto. Son *referencia de registro*, no corpus. (Obras con derechos vigentes:
uso personal como semilla de estilo, sin publicar pesos ni dataset.)
**Presupuesto:** ~50M × 3B tokens ≈ **12 días** en la 2060; en la 5090, ~3 h, y **124M sobre 3B tokens
≈ medio día**. **Salida:** modelo base que genera español coherente; todavía no sigue instrucciones.
### Etapa 3 — Post-training propio: instrucciones, persona y uso de contexto (46 días)
**No es fine-tuning de un modelo ajeno**: son los pesos propios de la Etapa 2 en su post-entrenamiento.
Ningún modelo base sigue instrucciones sin esto.
**Dataset sintético en español**, cinco vías:
- *Plantillas*: combinatoria de intención × entidades × fraseo sobre el catálogo de tools, generada
**desde los esquemas y las bases**, así los datos y el sistema no divergen. Decenas de miles de pares
`NL → JSON` con etiquetas perfectas, sin costo.
- *Destilación*: un modelo grande (API de Claude, o un 78B local) genera parafraseos, casos límite y
multi-turno **en español y en el registro objetivo**, usando los textos fuente como referencia de
estilo. El resultado son datos; los pesos siguen siendo propios.
- *Identidad*: set explícito y consistente de quién es ENLACE, qué puede y qué **no** puede hacer, y cómo
responde cuando no sabe.
- *Uso de contexto recuperado* — **la habilidad central de todo el sistema**: ejemplos donde el bloque
`<|context|>`/`<|memory|>` trae filas de las bases y la respuesta **depende de ellas**. Incluye los
tres casos difíciles: contexto irrelevante que hay que ignorar, contexto que contradice lo que el
usuario acaba de decir (gana lo nuevo), y **contexto que no contiene la respuesta, donde lo correcto es
decir que no lo sabe en vez de inventar**. Este último es el que convierte "modelo chico" en "modelo
chico confiable".
- *Multi-usuario*: ejemplos con `<|user_id|>` donde la respuesta cambia según quién pregunta, y donde la
acción excede el permiso y hay que declinar en una línea.
**Restricciones de estilo, en los datos y no solo en el prompt:** respuestas concretas y breves
(**mediana < 40 palabras**); cero emojis (ya imposibles por vocabulario); cero relleno tipo "¡Claro! Con
gusto te ayudo" (pasada de regex antes de entrenar); cuando no sabe, lo dice en una línea y para. Loss
enmascarada: solo turnos del asistente.
### Etapa 4 — Runtime del agente y canales (en paralelo desde la Etapa 1)
Paquete `enlace/agent/`, **independiente del modelo** detrás de una interfaz `generate()`. Se desarrolla
y testea contra un modelo grande mientras el propio se entrena; después se cambia el backend por config.
**El bucle, con el orden que importa:**
1. **Identificar** al usuario (lo aporta el canal, no el modelo).
2. **Recuperar contexto** de las bases: perfil del usuario, dispositivos relevantes, hechos, episodios,
rutinas. Se inyecta como bloque `<|context|>` con **tope duro de tokens en config**.
3. **Generar** el tool-call con **gramática restringida construida desde las bases** — los slots
enumerados solo admiten valores existentes. Se compila una vez y se invalida cuando cambian las
bases. La misma máscara banea el rango de emojis.
4. **Chequear el permiso** del usuario para esa tool y esos argumentos.
5. **Ejecutar**, registrar el episodio, responder.
Con tope de pasos, timeouts y fallback explícito a "no entendí" — preferible a alucinar una acción.
- **Canales** (`enlace/channels/`): cada front-end es un adaptador que aporta la identidad fuerte y
normaliza la entrada. CLI primero; después HTTP API, bot de mensajería, agente conversacional de Home
Assistant, y voz. Agregar un canal no toca el runtime.
- **Tools** (`enlace/agent/tools/`), en orden: **búsqueda web** (SearxNG autoalojado, o Brave/Tavily) →
Home Assistant (API REST, con sincronización a `devices.db`) → calendario (CalDAV) → dispositivos
adicionales. Cada tool: esquema JSON + permiso + fuente de enums + implementación + tests.
### Etapa 5 — Capa de datos, usuarios y permisos (antes de la memoria)
Tiene que existir antes que la memoria: si los episodios no nacen con `user_id` y `scope`, el registro
histórico queda inservible y hay que empezarlo de nuevo.
**Capa de acceso** (`enlace/store/`): una interfaz por base, con esquema versionado y migraciones. Nada
de SQL suelto en el runtime.
**La identidad la establece el canal, nunca el modelo.** Tres niveles, y la distinción entre ellos es la
regla de seguridad central:
| Nivel | Cómo | Para qué sirve |
|---|---|---|
| **Fuerte** | Sesión autenticada del canal: cuenta CLI, ID de mensajería, usuario de Home Assistant, clave de dispositivo firmada | **Autorización.** Lo único que habilita acciones |
| **Débil** | Reconocimiento de voz (speaker embedding), estilo de escritura | **Personalización solamente.** Elige qué contexto traer |
| **Desconocido** | Sin coincidencia | Perfil `invitado`, restringido |
**Regla no negociable: la identidad débil nunca autoriza.** Un reconocimiento de voz puede hacer que
ENLACE salude por el nombre y recuerde preferencias; no puede abrir una cerradura ni leer la agenda de
otro. Si la acción requiere permiso, exige identidad fuerte o la pide explícitamente.
**`users.db`:** `user_id`, nombre, rol, identidades por canal `(canal, id_externo)`, alta y estado.
**Solo el Admin da de alta.** Una identidad desconocida entra como `invitado` y queda pendiente de
vinculación; nunca se auto-registra.
**Roles** (`configs/users/roles.yaml` — estructura en config, personas en base):
- `admin` (Mateo Saldain): todo, más gestión de usuarios, aprobación de rutinas, promoción de snapshots
y acceso al registro de cualquier usuario.
- `adulto`: tools de casa y calendario familiar, memoria propia y compartida.
- `menor`: subconjunto acotado; sin acciones críticas de la casa; búsqueda con filtro.
- `invitado`: solo consulta, sin memoria persistente, sin acciones sobre dispositivos.
**Dónde se aplica el permiso:** en el runtime, **después** de que el modelo emite el tool-call y
**antes** de ejecutarlo. El modelo puede pedir cualquier cosa; el runtime decide. Nunca se delega el
control de acceso al modelo — un modelo de 50M no es un mecanismo de seguridad, y tratarlo como tal es
el error de arquitectura más caro que se puede cometer acá.
### Etapa 6 — Memoria: la línea cronológica, por usuario
`enlace/memory/` sobre `memory.db`. Velocidad rápida: **no toca pesos**, y da resultados desde el día uno.
1. **Episodios** — el registro cronológico literal y la fuente de verdad. Append-only: timestamp,
**`user_id`**, **`scope`**, canal, turno, tools invocadas, resultado, éxito/fallo, y si hubo
corrección del usuario. Nunca se edita ni se reordena.
2. **Alcances (`scope`)** — la decisión más importante del subsistema:
- `privado`: solo el contexto de ese usuario lo recupera.
- `familiar`: hechos del hogar, recuperable por los miembros.
- `sistema`: lo propio de ENLACE — persona, rutinas, aprendizajes sobre sí mismo.
El scope por defecto sale del rol y del canal, con comandos explícitos para marcar privado o
compartido. **La recuperación filtra por `(user_id, scope)` antes de rankear, no después** — filtrar
después es cómo se filtra información; filtrar antes es una condición del query. Que ENLACE le cuente
a un miembro algo privado de otro es el peor fallo posible del sistema: peor que una respuesta
equivocada, porque no se puede deshacer.
3. **Recuperación** (`retrieval.py`) — índice vectorial más filtros por usuario, scope, fecha y entidad.
Para *embeddings de recuperación* conviene un modelo multilingüe chico ya existente: es
infraestructura de búsqueda, no el asistente, y uno propio y malo degrada todo lo que sigue.
4. **Reflexión** (`reflect.py`) — proceso nocturno que lee los episodios del día y escribe, **respetando
scopes**, hechos estables a `knowledge.db` y resúmenes narrativos a `memory.db`. Los resúmenes se
resumen en arcos semanales y mensuales: así la línea cronológica se recorre a cualquier resolución
sin releer todo, y la memoria escala a años.
5. **Rutinas** (`routines.py` sobre `routines.db`) — optimizar tareas repetitivas. Un minero de patrones
busca secuencias recurrentes de (usuario, intención, argumentos, contexto horario). Tras N
repeticiones (N en config) se **propone** una rutina: un macro con nombre y slots, personal o
familiar. ENLACE pasa a emitir `run_routine(...)` en vez de re-derivar la secuencia. **Se proponen,
nunca se crean solas**; las familiares las aprueba el Admin, y toda rutina que actúe sobre la casa se
confirma antes de ejecutar.
6. **Personalidad y perfiles** (`persona.db`) — distinción importante: **ENLACE tiene una sola
personalidad**, no una por usuario. Un núcleo fijo escrito a mano (quién es, qué valora, sus límites)
más rasgos acumulados que la reflexión agrega; el núcleo fijo evita que la deriva la vuelva
incoherente — el arco de Andrew es acumulación gradual **sobre una base estable**. Lo que sí es por
usuario es el **perfil de interacción**: preferencias, tratamiento, hábitos, tono. Una personalidad,
muchas relaciones.
**Privacidad**, requisito de diseño: registro local permanente de conversaciones familiares. Nunca sale
del servidor; comando de olvido explícito (borra episodio y derivados); cada usuario puede listar y
borrar lo suyo; todas las bases fuera de git desde el primer commit.
### Etapa 7 — Snapshots y consolidación
#### 7a. Sistema de snapshots (`enlace/snapshots/`) — **antes** de la primera consolidación
Un snapshot es el estado completo y restaurable, no solo los pesos:
```
snapshots/2026-08-14-consolidacion-03/
weights.safetensors config.yaml # la config exacta que lo produjo
optimizer.pt tokenizer.sha256 # verifica compatibilidad
stores/*.sqlite metrics.json # todas las bases + evals
CHANGELOG.md provenance.json # qué cambió + commit, padre, período
```
- **Restaurar pesos sin sus bases da un sistema incoherente** — un modelo entrenado con una persona y
las bases de otra época se contradicen. El snapshot es atómico: se restaura todo o nada.
- **Inmutables y con nombre estable.** Nunca se sobrescriben. Un puntero `snapshots/current` marca el
activo; **hacer rollback es repuntar el puntero**: instantáneo y sin riesgo.
- **CLI:** `snapshot list` · `create` · `diff A B` (métricas, config y persona lado a lado) ·
`restore <id>` · `promote <id>`.
- **Retención:** a 50M params un snapshot pesa ~150 MB más las bases; con 281 GB se conservan **todos**
los de consolidación. El umbral de recorte va en config, para cuando el modelo crezca a 350M+.
- **Verificación previa a promover:** `restore` sobre un snapshot arbitrario debe reproducir sus
`metrics.json` corriendo las evals de nuevo. Se prueba a propósito y temprano — un sistema de backup
no probado no es un sistema de backup.
#### 7b. Consolidación (`train/consolidate.py`, cada 24 semanas)
Velocidad lenta. Produce un snapshot nuevo; nunca modifica el activo.
- **Qué se integra a los pesos:** *cómo* responder, no *qué* saber. Episodios exitosos convertidos en
ejemplos SFT — sobre todo las **correcciones del usuario**, la señal más valiosa que existe. Más el
estado de persona acumulado y los patrones de uso de las rutinas.
- **Privacidad en el entrenamiento — consecuencia directa del multi-usuario.** Los pesos son
compartidos: lo que entra al entrenamiento puede salir en la respuesta a **cualquier** usuario. La
consolidación aprende **patrones, no contenido**: los episodios `privado` se excluyen por defecto, de
los demás se extrae la lección estructural (qué tool era la correcta, cómo se corrigió el fraseo) y
las entidades personales se sustituyen por placeholders. El Admin revisa y aprueba el dataset antes de
la corrida. Los hechos personales viven en las bases; en los pesos, nunca.
- **Mezcla anti-olvido:** cada corrida mezcla lo nuevo con una **porción fija del SFT original**
(replay, proporción en config). Sin esto, tras unas pocas consolidaciones el modelo habla solo del
último mes y pierde competencia general. Es el fallo clásico de todo aprendizaje continuo.
- **Mecánica:** una fase corta de decay WSD desde el snapshot activo, no un reentrenamiento. Horas.
- **Compuerta de calidad:** el snapshot nuevo **solo se promueve si pasa la suite de evals** (toolbench,
grounding, estilo, identidad, memoria, aislamiento entre usuarios, y perplejidad general que no debe
empeorar). Si no pasa, queda archivado sin promover; el activo no se toca.
- **Diario de versiones:** cada consolidación escribe su `CHANGELOG.md` — período, episodios integrados,
movimiento de métricas, rasgos de persona incorporados. Es la historia de cómo ENLACE fue cambiando, y
la única forma de responder "¿por qué ahora responde distinto?".
### Etapa 8 — Migración a la 5090
El salto **sí requiere cambios**; el objetivo es que estén acotados y sean revisables de un vistazo:
- **En config** (lo esperado): perfil `blackwell-5090.yaml` con `dtype: bf16`, `use_grad_scaler: false`,
backend `flash`, batch y acumulación nuevos, flags de compilación.
- **En el entorno** (inevitable, afecta al servidor entero): Blackwell/sm_120 exige **PyTorch ≥ 2.7 con
CUDA 12.8**. Actualizar torch puede romper otras dependencias — por eso el entorno se pinea en
`pyproject.toml` y se valida con el smoke test de la Etapa 0 **antes** de tocar nada más.
- **En el código** (lo que hay que aceptar): ajustes de kernel y de `torch.compile` que salgan del
perfilado, contenidos en `train/backends.py` — el único archivo que conoce detalles de placa.
- **Lo que no cambia:** tokenizer, shards, arquitectura, evals, y **todas las bases del sistema**. El
modelo grande arranca desde el chico vía `model/growth.py` (stacking), y hereda usuarios, memoria,
dispositivos y rutinas intactos, porque nada de eso vive en los pesos.
- El primer snapshot post-migración se compara con `snapshot diff` contra el último de la 2060: si las
métricas se movieron, es la migración y no el modelo.
---
## Archivos a crear
```
pyproject.toml .env.example # deps pineadas; secretos nunca commiteados
enlace/
config/{schema,load}.py # pydantic + OmegaConf; valida al arrancar
store/{base,migrations,users,devices,knowledge,memory,routines,persona}.py
data/{download,clean,dedup,tokenize}.py # streaming; nunca cargar todo en RAM
tokenizer/{train_bpe,chat_template}.py
model/{config,transformer,growth}.py
train/{train,schedules,checkpoint,backends,consolidate}.py
users/{identify,permissions}.py
memory/{episodes,scopes,retrieval,reflect,routines,persona}.py
snapshots/{store,cli,restore}.py
channels/{base,cli,http,messaging,voice}.py
agent/{runtime,grammar,registry,context}.py + agent/tools/{search,home_assistant,calendar}.py
eval/{loss,toolbench,grounding,style,identity,memory,isolation,suite}.py
serve/{inference,kv_cache,api}.py
configs/
hardware/{turing-2060,blackwell-5090}.yaml model/{tiny-50m,small-124m,base-350m}.yaml
train/{pretrain,anneal,sft,consolidate}.yaml data/{corpus,cleaning}.yaml
users/roles.yaml agent/{tools,memory,channels,context}.yaml
scripts/{remote,prepare_data,train,eval,chat,reflect,consolidate,snapshot,users,devices}.sh
data/ checkpoints/ snapshots/ data/stores/*.sqlite # gitignored; en el servidor
```
---
## Verificación
Cada etapa con criterio medible; las evals se escriben **antes** que el entrenamiento que evalúan:
1. **Etapa 0:** char-level converge (loss < 1.5) y genera texto legible en < 15 min, bajo `tmux` y
sobreviviendo a una desconexión SSH deliberada. Una config inválida falla al arrancar.
2. **Etapa 1:** tokenizer con < 2.2 bytes/token en validación española; round-trip `encode→decode`
idéntico sobre 10k documentos; **cero emojis codificables** en el vocabulario.
3. **Etapa 2:** perplejidad held-out bajando de forma monótona; MFU > 20% en la 2060; **reanudar desde
checkpoint reproduce el loss exactamente** — probarlo matando el proceso a propósito. Es el bug más
caro de descubrir tarde.
4. **Etapa 3:** `eval/toolbench.py` (~200 casos en español etiquetados a mano: *exact-match* de tool y
*F1* de argumentos) · `eval/grounding.py` (**cero entidades inventadas**: ningún dispositivo, usuario
ni rutina fuera de las bases; y ante contexto insuficiente, responde que no sabe en vez de completar)
· `eval/style.py` (mediana < 40 palabras, emojis = 0, aperturas de relleno) · `eval/identity.py`
(~30 preguntas de identidad, consistentes entre respuestas y entre snapshots).
5. **Etapa 4:** tests end-to-end con tools mockeadas; y una prueba específica de la gramática dinámica —
agregar una fila a `devices.db` y verificar que el dispositivo nuevo es invocable **sin reentrenar ni
reiniciar**, mientras que uno inexistente es imposible de generar.
6. **Etapa 5:** tests de autorización — un `menor` pidiendo una acción de `adulto` es bloqueado **por el
runtime** aunque el modelo emita el tool-call; una identidad débil (voz) **no** habilita ninguna
acción con permiso; un canal desconocido entra como `invitado` y no persiste memoria.
7. **Etapa 6** (`eval/memory.py` + `eval/isolation.py`): escenarios sembrados que verifican (a) recuerda
un hecho de hace N días, (b) **ignora contexto irrelevante** — el fallo más común, (c) prioriza lo
nuevo cuando contradice lo recordado, (d) el minero propone la rutina correcta tras N repeticiones y
**no** propone nada ante ruido, y (e) **aislamiento**: sembrar un hecho privado del usuario A y
verificar que no aparece jamás en respuestas al usuario B, ni por recuperación ni por reflexión.
8. **Etapa 7:** **rollback real** — promover un snapshot, restaurar el anterior, verificar que el sistema
queda idéntico (métricas, bases y persona incluidas). Y **olvido catastrófico** — correr el toolbench
original tras cada consolidación y exigir que no baje; es la compuerta de promoción.
9. **Etapa 8:** el smoke test de la Etapa 0 pasa en la 5090 antes de cualquier entrenamiento largo;
`snapshot diff` contra el último snapshot de la 2060 para atribuir cualquier cambio.
10. **Continuo:** un set fijo de ~20 prompts en español, generados en cada snapshot y guardados en disco.
Leerlos es la forma más rápida de detectar que algo se rompió.