diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..aa9b8f1 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,64 @@ +# Calidad del código Python, en cada carga. +# +# Existe por una razón concreta: ruff estaba configurado en pyproject.toml desde +# el primer commit y nunca se había ejecutado. Cuando por fin se corrió tenía 15 +# hallazgos, uno de ellos real —`zip()` sin `strict=` trunca en silencio al más +# corto, así que un test que compara dos listas de distinto largo pasaría sin +# comparar todo— y otro que delataba un backend sin ninguna prueba. +# +# Una regla que no se ejecuta no es una regla. +name: Python + +on: + push: + pull_request: + +jobs: + calidad: + runs-on: ubuntu-latest + steps: + - name: Descargar el repositorio + uses: actions/checkout@v4 + + - name: Preparar Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Instalar ruff + run: python -m pip install --upgrade pip "ruff>=0.6" + + - name: Estilo y errores + # Las reglas viven en pyproject.toml, no acá: un solo lugar donde + # cambiarlas, y el mismo resultado corriendo `ruff check .` en local. + run: ruff check --output-format=github . + + - name: Formato consistente + run: ruff format --check --diff . + + tests: + runs-on: ubuntu-latest + steps: + - name: Descargar el repositorio + uses: actions/checkout@v4 + + - name: Preparar Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Instalar dependencias + # torch de CPU explícitamente: la variante con CUDA pesa unos 2,5 GB y + # acá no hay placa. La suite entera corre en CPU a propósito. + run: | + python -m pip install --upgrade pip + python -m pip install torch --index-url https://download.pytorch.org/whl/cpu + python -m pip install -e ".[dev]" + + - name: Suite de tests + # Se desactiva la carga automática de plugins: en entornos con otros + # paquetes instalados (ROS, por ejemplo) pytest intenta cargar sus + # plugins y falla al recolectar por motivos ajenos al proyecto. + env: + PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1" + run: pytest -q diff --git a/.github/workflows/yaml.yml b/.github/workflows/yaml.yml new file mode 100644 index 0000000..ef6019a --- /dev/null +++ b/.github/workflows/yaml.yml @@ -0,0 +1,45 @@ +# Verificación de los YAML del repositorio, en cada carga. +# +# Dos niveles, porque atrapan cosas distintas: +# +# 1. yamllint mira la sintaxis y el estilo: indentación, duplicados, tabs. +# 2. La validación contra el esquema carga cada config con pydantic. Un YAML +# puede ser sintácticamente perfecto y estar roto igual — `n_layers` en vez +# de `n_layer`, un schedule que no entra en max_steps, float16 sin +# GradScaler. Eso solo lo ve el esquema. +# +# El segundo paso no instala torch a propósito: la capa de configuración no lo +# importa, así que el CI corre en segundos en vez de descargar 2,5 GB de CUDA. +name: YAML + +on: + push: + pull_request: + +jobs: + yaml: + runs-on: ubuntu-latest + steps: + - name: Descargar el repositorio + uses: actions/checkout@v4 + + - name: Preparar Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Instalar dependencias + # Solo lo que hace falta: yamllint para la sintaxis, pydantic y + # omegaconf para el esquema. Nada de torch. + run: | + python -m pip install --upgrade pip + python -m pip install "yamllint>=1.35" "pydantic>=2.7" "omegaconf>=2.3" + + - name: Sintaxis y estilo de los YAML + # --strict convierte las advertencias en errores: si una regla no vale + # la pena, se desactiva en .yamllint.yml, no se deja avisando para + # siempre. + run: yamllint --strict . + + - name: Las configs cargan y pasan su esquema + run: python scripts/validar_configs.py diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..7f95f69 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,45 @@ +# Reglas de estilo para los YAML del repositorio. +# +# Se parte de las reglas por defecto de yamllint y se ajusta solo lo que choca +# con las convenciones ya establecidas del proyecto. +extends: default + +rules: + # 100 caracteres, igual que ruff en pyproject.toml. Los comentarios de las + # configs explican decisiones y no entran cómodos en 80. + line-length: + max: 100 + allow-non-breakable-words: true + + # No se usa el marcador `---` al inicio de los archivos. + document-start: disable + + # `on:` en los workflows es una clave legítima, no el booleano de YAML 1.1. + truthy: + check-keys: false + + # Las listas dentro de un mapeo se escriben sin indentar de más: + # fallbacks: + # - brave + # y también en línea, `fallbacks: [brave]`. Ambas se aceptan. + indentation: + spaces: 2 + indent-sequences: consistent + + comments: + min-spaces-from-content: 1 + + # Los archivos generados por herramientas externas no siempre traen salto + # final; no vale la pena fallar el CI por eso. + new-line-at-end-of-file: enable + +# Anclados con "/" inicial a propósito: sin anclar, "data/" y "runs/" excluyen +# cualquier directorio con ese nombre en cualquier nivel — incluidos +# configs/data/ y configs/runs/, que son justamente los que más importa +# revisar. El síntoma es un linter que pasa en verde sin haber mirado nada. +ignore: | + /.venv/ + /data/ + /runs/ + /snapshots/ + /checkpoints/ diff --git a/enlace/config/schema.py b/enlace/config/schema.py index b692e7f..07d160a 100644 --- a/enlace/config/schema.py +++ b/enlace/config/schema.py @@ -235,9 +235,7 @@ class SearchConfig(_Base): """ backend: Literal["duckduckgo", "brave", "searxng"] = "duckduckgo" - fallbacks: list[Literal["duckduckgo", "brave", "searxng"]] = Field( - default_factory=list - ) + fallbacks: list[Literal["duckduckgo", "brave", "searxng"]] = Field(default_factory=list) region: str = "es-es" country: str = "uy" language: str = "es" diff --git a/enlace/data/loaders.py b/enlace/data/loaders.py index ab02ab8..e9d9388 100644 --- a/enlace/data/loaders.py +++ b/enlace/data/loaders.py @@ -56,9 +56,7 @@ class ByteStream: ) raw = np.frombuffer(path.read_bytes(), dtype=np.uint8) if len(raw) < seq_len * 4: - raise ValueError( - f"{path} tiene {len(raw)} bytes: muy poco para seq_len={seq_len}." - ) + raise ValueError(f"{path} tiene {len(raw)} bytes: muy poco para seq_len={seq_len}.") split_at = int(len(raw) * (1.0 - val_fraction)) self._data = { diff --git a/enlace/model/transformer.py b/enlace/model/transformer.py index 864c769..1a63438 100644 --- a/enlace/model/transformer.py +++ b/enlace/model/transformer.py @@ -186,9 +186,7 @@ class Transformer(nn.Module): total -= self.tok_emb.weight.numel() return total - def forward( - self, idx: Tensor, targets: Tensor | None = None - ) -> tuple[Tensor, Tensor | None]: + def forward(self, idx: Tensor, targets: Tensor | None = None) -> tuple[Tensor, Tensor | None]: """Devuelve (logits, loss). `loss` es None si no hay targets.""" _, t = idx.shape if t > self.cfg.seq_len: diff --git a/enlace/train/checkpoint.py b/enlace/train/checkpoint.py index 3236aa6..c0deb2a 100644 --- a/enlace/train/checkpoint.py +++ b/enlace/train/checkpoint.py @@ -122,9 +122,7 @@ def load( fmt = payload.get("format") if fmt != CHECKPOINT_FORMAT: - raise ValueError( - f"{path}: formato de checkpoint {fmt}, se esperaba {CHECKPOINT_FORMAT}." - ) + raise ValueError(f"{path}: formato de checkpoint {fmt}, se esperaba {CHECKPOINT_FORMAT}.") module = getattr(model, "_orig_mod", model) module.load_state_dict(payload["model"]) diff --git a/enlace/train/train.py b/enlace/train/train.py index 975168b..e40e221 100644 --- a/enlace/train/train.py +++ b/enlace/train/train.py @@ -146,9 +146,7 @@ def train(cfg: Config, resume: bool = False) -> Path: if ckpt_path is None: print(f"[enlace] --resume sin checkpoints en {run_dir}: se empieza de cero") else: - meta = checkpoint.load( - ckpt_path, model=model, optimizer=optimizer, scaler=scaler - ) + meta = checkpoint.load(ckpt_path, model=model, optimizer=optimizer, scaler=scaler) stream.load_state_dict(meta["stream"]) start_step = meta["step"] print(f"[enlace] reanudado desde {ckpt_path.name} en el paso {start_step}") @@ -157,9 +155,7 @@ def train(cfg: Config, resume: bool = False) -> Path: print("[enlace] compilando el modelo (la primera iteración tarda)...") model = torch.compile(model) # type: ignore[assignment] - tokens_per_step = ( - cfg.hardware.effective_batch_size * cfg.model.seq_len - ) + tokens_per_step = cfg.hardware.effective_batch_size * cfg.model.seq_len fpt = flops_per_token(getattr(model, "_orig_mod", model)) model.train() @@ -194,9 +190,7 @@ def train(cfg: Config, resume: bool = False) -> Path: # se aplicaría sobre gradientes inflados por el GradScaler. scaler.unscale_(optimizer) grad_norm = float( - torch.nn.utils.clip_grad_norm_( - model.parameters(), cfg.train.optimizer.grad_clip - ) + torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.train.optimizer.grad_clip) ) scaler.step(optimizer) scaler.update() diff --git a/pyproject.toml b/pyproject.toml index 9d33588..ca00bfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ distill = [ dev = [ "pytest>=8.0", "ruff>=0.6", + "yamllint>=1.35", ] [build-system] diff --git a/scripts/validar_configs.py b/scripts/validar_configs.py new file mode 100755 index 0000000..509e8b6 --- /dev/null +++ b/scripts/validar_configs.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Valida que todas las configs del repositorio carguen y pasen su esquema. + +Un YAML sintácticamente correcto puede seguir estando roto: `n_layers` en vez de +`n_layer`, un schedule que no entra en `max_steps`, float16 sin GradScaler. Eso +no lo ve un linter de sintaxis — lo ven los validadores de pydantic, que ya +existen. Esto los ejecuta sobre cada config del repositorio. + +No importa torch a propósito: así el CI valida configs en segundos en vez de +descargar los dos gigas y medio de CUDA. + + python scripts/validar_configs.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +RAIZ = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RAIZ)) + +from enlace.config.load import ( # noqa: E402 + ConfigError, + load_agent_config, + load_config, + load_distill_config, +) + + +def validar() -> int: + fallas: list[tuple[Path, str]] = [] + verificadas = 0 + + # Las configs de corrida componen las cuatro capas, así que validarlas + # cubre transitivamente hardware, modelo, entrenamiento y datos. + for path in sorted((RAIZ / "configs" / "runs").glob("*.yaml")): + try: + cfg = load_config(path) + verificadas += 1 + print( + f" OK {path.relative_to(RAIZ)} " + f"({cfg.hardware.name} / {cfg.model.name} / {cfg.train.run_name})" + ) + except ConfigError as exc: + fallas.append((path, str(exc))) + print(f" FALLA {path.relative_to(RAIZ)}") + + # Los árboles que no son de entrenamiento se cargan aparte. + for etiqueta, cargador, path in ( + ("agente", load_agent_config, RAIZ / "configs" / "agent" / "tools.yaml"), + ("destilación", load_distill_config, RAIZ / "configs" / "data" / "distill.yaml"), + ): + try: + cargador(path) + verificadas += 1 + print(f" OK {path.relative_to(RAIZ)} ({etiqueta})") + except ConfigError as exc: + fallas.append((path, str(exc))) + print(f" FALLA {path.relative_to(RAIZ)}") + + print() + if fallas: + for path, motivo in fallas: + print(f"--- {path.relative_to(RAIZ)} ---") + print(motivo) + print() + print(f"{len(fallas)} config(s) inválida(s) de {verificadas + len(fallas)}.") + return 1 + + print(f"{verificadas} configs válidas.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(validar()) diff --git a/tests/test_resume.py b/tests/test_resume.py index cfbc0b2..57c2018 100644 --- a/tests/test_resume.py +++ b/tests/test_resume.py @@ -92,9 +92,7 @@ def test_el_estado_rng_se_restaura_desde_cpu(monkeypatch): recibidos = [] original = torch.set_rng_state - monkeypatch.setattr( - torch, "set_rng_state", lambda s: (recibidos.append(s), original(s))[1] - ) + monkeypatch.setattr(torch, "set_rng_state", lambda s: (recibidos.append(s), original(s))[1]) _restore_rng(_rng_state()) assert recibidos, "no se llamó a set_rng_state"