90ac2a6582
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>
127 lines
3.9 KiB
Python
127 lines
3.9 KiB
Python
"""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()
|