Files
enlace/enlace/config/load.py
T
msaldain 8805b0541b Generación de datos sintéticos con la Batch API
Etapa 3 del plan. Se elige la Batch API sobre llamadas sueltas porque cuesta
la mitad y generar un dataset no es sensible a la latencia: es exactamente su
caso de uso. A cambio el trabajo es asincrónico y puede tardar horas, así que
todo el estado vive en disco y es reanudable — matar el proceso, perder la
conexión o reiniciar el servidor no pierde nada ni gasta de nuevo.

Cuatro decisiones que sostienen eso:

- El custom_id se deriva del hash del contenido del pedido. El trabajo queda
  idempotente: re-generar la misma especificación produce los mismos IDs, así
  que lo ya resuelto se reconoce y no se vuelve a pagar.
- Los resultados se indexan por custom_id, nunca por posición. La API los
  devuelve en cualquier orden, y emparejarlos por índice mezcla las respuestas
  en silencio; un dataset mal alineado es peor que uno vacío porque parece
  correcto. El cliente falso de los tests los invierte a propósito.
- Los fallos se registran en errors.jsonl con su motivo en vez de descartarse,
  y --retry-failed reenvía solo esos.
- El prompt de sistema compartido va marcado para caché: es idéntico entre
  miles de pedidos, y a ~0,1x del precio de entrada deja de contar.

Los lotes se persisten después de cada envío y no al final, para que una caída
a mitad de camino no deje lotes huérfanos sin registrar.

El SDK de anthropic se importa de forma perezosa y queda como extra opcional:
el servidor de entrenamiento no lo necesita y el paquete importa sin él.

21 tests nuevos (92 en total), todos contra un cliente falso — lo que hay que
verificar es el harness, no el SDK.

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

173 lines
6.1 KiB
Python

"""Composición y carga de configs.
Un YAML raíz declara qué capa usar de cada familia; se fusionan con OmegaConf y
el resultado se valida con pydantic. Los overrides de línea de comandos existen
para probar variantes sin editar archivos, no para esconder configuración.
python -m enlace.train.train configs/runs/smoke.yaml hardware.compile=false
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
from omegaconf import DictConfig, OmegaConf
from enlace.config.schema import AgentConfig, Config, DistillConfig
# Familias de capas que puede declarar un YAML raíz, en el orden en que se
# fusionan. El orden solo importa para los mensajes de error.
LAYERS = ("hardware", "model", "train", "data")
class ConfigError(RuntimeError):
"""Error de configuración legible, sin traceback de pydantic encima."""
def _resolve(path: str | Path, root: Path) -> Path:
p = Path(path)
full = p if p.is_absolute() else root / p
if not full.is_file():
raise ConfigError(f"no existe el archivo de config: {full}")
return full
def _load_yaml(path: Path) -> DictConfig:
cfg = OmegaConf.load(path)
if not isinstance(cfg, DictConfig):
raise ConfigError(f"{path}: el YAML raíz debe ser un mapeo, no una lista.")
return cfg
def compose(path: str | Path, overrides: list[str] | None = None) -> dict[str, Any]:
"""Compone un YAML raíz en un dict plano, sin validar todavía.
El YAML raíz tiene la forma:
include:
hardware: hardware/turing-2060.yaml
model: model/tiny-50m.yaml
train: train/pretrain.yaml
data: data/corpus.yaml
# opcional: ajustes puntuales encima de las capas incluidas
train:
run_name: mi-corrida
"""
path = Path(path).resolve()
if not path.is_file():
raise ConfigError(f"no existe el archivo de config: {path}")
raw = _load_yaml(path)
# Las rutas de `include` se resuelven contra el directorio configs/, que es
# el padre del directorio del YAML raíz (configs/runs/foo.yaml -> configs/).
configs_root = path.parent.parent if path.parent.name == "runs" else path.parent
includes = raw.pop("include", None)
merged = OmegaConf.create({})
if includes is not None:
for layer in LAYERS:
ref = includes.get(layer)
if ref is None:
continue
merged[layer] = _load_yaml(_resolve(ref, configs_root))
unknown = set(includes.keys()) - set(LAYERS)
if unknown:
raise ConfigError(
f"{path}: capas desconocidas en `include`: {sorted(unknown)}. "
f"Válidas: {list(LAYERS)}."
)
# Lo que quede en el YAML raíz pisa a las capas incluidas.
merged = OmegaConf.merge(merged, raw)
if overrides:
merged = OmegaConf.merge(merged, OmegaConf.from_dotlist(list(overrides)))
resolved = OmegaConf.to_container(merged, resolve=True)
assert isinstance(resolved, dict)
return resolved
def load_config(path: str | Path, overrides: list[str] | None = None) -> Config:
"""Compone, valida y devuelve la config. Falla temprano y con claridad."""
data = compose(path, overrides)
try:
return Config.model_validate(data)
except Exception as exc: # pydantic.ValidationError y los ValueError propios
raise ConfigError(f"config inválida ({path}):\n{exc}") from None
CONFIGS_ROOT = Path(__file__).resolve().parents[2] / "configs"
def _load_single(path: Path, overrides: list[str] | None, model_cls, etiqueta: str):
"""Carga un YAML suelto y lo valida. No compone capas.
Lo usan los árboles de config que no son de entrenamiento (agente,
destilación): no necesitan saber nada del optimizador ni del schedule.
Los valores que salen de `.env` se resuelven con `${oc.env:...}`, así que
los secretos nunca entran en un archivo commiteado.
"""
if not path.is_file():
raise ConfigError(f"no existe el archivo de config: {path}")
merged = _load_yaml(path)
if overrides:
merged = OmegaConf.merge(merged, OmegaConf.from_dotlist(list(overrides)))
data = OmegaConf.to_container(merged, resolve=True)
assert isinstance(data, dict)
# OmegaConf devuelve cadena vacía cuando la variable de entorno no está
# definida; para pydantic eso tiene que ser ausencia, no un valor vacío.
_vaciar_a_none(data)
try:
return model_cls.model_validate(data)
except Exception as exc:
raise ConfigError(f"config de {etiqueta} inválida ({path}):\n{exc}") from None
def _vaciar_a_none(data: dict) -> None:
"""Convierte las cadenas vacías de `${oc.env:VAR,""}` en ausencia, recursivo."""
for clave, valor in data.items():
if isinstance(valor, dict):
_vaciar_a_none(valor)
elif valor == "":
data[clave] = None
def load_agent_config(
path: str | Path | None = None, overrides: list[str] | None = None
) -> AgentConfig:
"""Carga la config del runtime del agente."""
path = Path(path) if path is not None else CONFIGS_ROOT / "agent" / "tools.yaml"
return _load_single(path, overrides, AgentConfig, "agente")
def load_distill_config(
path: str | Path | None = None, overrides: list[str] | None = None
) -> DistillConfig:
"""Carga la config de generación de datos sintéticos por lotes."""
path = Path(path) if path is not None else CONFIGS_ROOT / "data" / "distill.yaml"
return _load_single(path, overrides, DistillConfig, "destilación")
def load_config_from_argv(argv: list[str] | None = None) -> Config:
"""`prog config.yaml [clave.sub=valor ...]`, para los entrypoints."""
args = list(sys.argv[1:] if argv is None else argv)
if not args:
raise ConfigError("uso: <programa> <config.yaml> [clave.sub=valor ...]")
return load_config(args[0], args[1:])
def to_yaml(config: Config) -> str:
"""Serializa la config resuelta, para guardarla junto al checkpoint.
Un checkpoint sin su config no es reproducible: esto es lo que se escribe
en el snapshot.
"""
return OmegaConf.to_yaml(OmegaConf.create(config.model_dump(mode="json")))