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>
This commit is contained in:
2026-07-27 23:04:45 -03:00
commit 90ac2a6582
33 changed files with 2838 additions and 0 deletions
+478
View File
@@ -0,0 +1,478 @@
# 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ó.