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
+126
View File
@@ -0,0 +1,126 @@
"""La config tiene que fallar al arrancar, no a las tres horas."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from enlace.config.load import ConfigError, compose, load_config
REPO = Path(__file__).resolve().parents[1]
def test_composicion_por_capas():
cfg = load_config(REPO / "configs/runs/pretrain-2060.yaml")
assert cfg.hardware.name == "turing-2060"
assert cfg.model.name == "tiny-50m"
assert cfg.train.run_name == "pretrain-tiny-50m"
assert cfg.data.source == "shards"
def test_el_yaml_raiz_pisa_las_capas_incluidas():
cfg = load_config(REPO / "configs/runs/smoke-2060.yaml")
# El perfil de la 2060 declara micro_batch_size 8; la corrida lo sube a 64
# porque el modelo char es diminuto.
assert cfg.hardware.micro_batch_size == 64
assert cfg.hardware.dtype == "float16" # esto sí viene del perfil
def test_overrides_de_linea_de_comandos():
cfg = load_config(REPO / "configs/runs/smoke-cpu.yaml", ["train.seed=99"])
assert cfg.train.seed == 99
@pytest.mark.parametrize("perfil", ["turing-2060", "blackwell-5090", "cpu"])
def test_todos_los_perfiles_de_hardware_son_validos(perfil, tmp_path):
"""Un perfil roto solo se descubre al migrar de placa; mejor ahora."""
raiz = {
"include": {
"hardware": f"hardware/{perfil}.yaml",
"model": "model/tiny-50m.yaml",
"train": "train/pretrain.yaml",
"data": "data/corpus.yaml",
}
}
path = REPO / "configs" / "runs" / f"_tmp_{perfil}.yaml"
path.write_text(yaml.safe_dump(raiz))
try:
cfg = load_config(path)
assert cfg.hardware.name == perfil
finally:
path.unlink()
def _raiz(tmp_path: Path, **parches) -> Path:
"""Config raíz válida con parches encima, para probar validaciones."""
base = {
"include": {
"hardware": "hardware/cpu.yaml",
"model": "model/tiny-50m.yaml",
"train": "train/pretrain.yaml",
"data": "data/corpus.yaml",
}
}
base.update(parches)
path = REPO / "configs" / "runs" / "_tmp_test.yaml"
path.write_text(yaml.safe_dump(base))
return path
def _espera_error(path: Path, fragmento: str):
try:
with pytest.raises(ConfigError) as exc:
load_config(path)
assert fragmento in str(exc.value)
finally:
path.unlink()
def test_rechaza_campos_desconocidos(tmp_path):
# Un typo tiene que ser un error ruidoso, no un default silencioso.
_espera_error(_raiz(tmp_path, model={"n_layers": 12}), "n_layers")
def test_rechaza_fp16_sin_grad_scaler(tmp_path):
_espera_error(
_raiz(tmp_path, hardware={"dtype": "float16", "use_grad_scaler": False}),
"use_grad_scaler",
)
def test_rechaza_grad_scaler_sin_fp16(tmp_path):
_espera_error(
_raiz(tmp_path, hardware={"dtype": "float32", "use_grad_scaler": True}),
"use_grad_scaler",
)
def test_rechaza_dmodel_no_divisible_por_heads(tmp_path):
_espera_error(_raiz(tmp_path, model={"d_model": 500}), "no es divisible")
def test_rechaza_gqa_incoherente(tmp_path):
_espera_error(_raiz(tmp_path, model={"n_head": 8, "n_kv_head": 3}), "n_kv_head")
def test_rechaza_schedule_que_no_entra(tmp_path):
_espera_error(
_raiz(tmp_path, train={"max_steps": 100, "schedule": {"warmup_steps": 80, "decay_steps": 80}}),
"no quedaría fase estable",
)
def test_rechaza_data_source_sin_su_ruta(tmp_path):
_espera_error(_raiz(tmp_path, data={"source": "chars", "text_path": None}), "text_path")
def test_include_con_capa_desconocida(tmp_path):
path = REPO / "configs" / "runs" / "_tmp_bad.yaml"
path.write_text(yaml.safe_dump({"include": {"hardwar": "hardware/cpu.yaml"}}))
try:
with pytest.raises(ConfigError, match="capas desconocidas"):
compose(path)
finally:
path.unlink()