18 Commits

Author SHA1 Message Date
msaldain 7fac220338 Ignorar la config personal de Claude Code desde el repositorio
Python / calidad (push) Successful in 4s
Python / tests (push) Successful in 11s
YAML / yaml (push) Successful in 4s
Hasta ahora .claude/settings.local.json solo lo ignoraba un gitignore global de
Gigastar, definido en la configuración de git de VSCodium. Esa regla no existe
en arkax ni en un clon nuevo: ahí el archivo se habría subido sin que nadie lo
notara, y contiene configuración local que no corresponde compartir.

La regla pasa a vivir en el repositorio, que es donde tiene efecto para todas
las máquinas.

.claude/settings.json sí queda versionado, por decisión explícita: los permisos
se comparten entre las máquinas de trabajo.
2026-07-28 07:51:53 -03:00
msaldain 59b7c3acb6 Un respaldo de búsqueda sin credencial se omite en vez de romper la carga
Python / calidad (push) Successful in 3s
Python / tests (push) Successful in 11s
YAML / yaml (push) Successful in 4s
El CI, apenas empezó a ejecutarse de verdad, hizo fallar dos de sus tres
trabajos. La causa es un defecto de diseño, no del CI: la config del agente
declara a Brave como respaldo, y la validación exigía la credencial para poder
*leer* el archivo. Como la config vive en el repositorio y la credencial no, un
clon limpio quedaba sin poder cargar su propia configuración. Mis pruebas en
Gigastar no lo veían porque acá el archivo .env existe.

Ahora se distingue por rol, que es lo que corresponde:

- El primario sin credencial sigue siendo error fatal. Sin él no queda ninguna
  búsqueda en pie, y descubrirlo en la primera consulta real es tarde.
- Un respaldo sin credencial se cae de la cadena y el primario sigue andando.
  Degradarse es la respuesta correcta: tener red de seguridad es mejor que no
  tenerla, pero no tenerla es mejor que no arrancar.

Omitir no es esconder. La propiedad respaldos_omitidos deja el motivo a la
vista, y build_backend emite un aviso al construir la cadena, que es el punto
por el que pasa cualquier entrypoint que use búsqueda.

Verificado escondiendo .env y corriendo la suite y el validador como lo haría
un clon limpio: 124 tests en verde con credencial y sin ella.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 07:48:29 -03:00
msaldain 03dadc93da Aplicar ruff format a todo el código
YAML / yaml (push) Failing after 1m58s
Python / calidad (push) Successful in 6s
Python / tests (push) Failing after 1m22s
Cambio mecánico, sin efecto en el comportamiento: la suite pasa igual antes y
después. Va en un commit propio para no tapar los cambios con sentido.

Se agregan además dos flujos de verificación que corren en cada carga al
repositorio:

- YAML: yamllint para sintaxis y estilo, más la carga de cada config contra su
  esquema de pydantic. Son cosas distintas — un YAML puede ser sintácticamente
  perfecto y estar roto igual, con 'run_nombre' en vez de 'run_name'. Ese paso
  no instala torch: se verificó que la capa de configuración no lo importa, así
  que corre en segundos en vez de descargar dos gigas y medio de CUDA.
- Python: ruff check, ruff format --check y la suite completa con torch de CPU.

Las rutas ignoradas de .yamllint.yml van ancladas con barra inicial. Sin
anclar, 'data/' y 'runs/' excluían configs/data/ y configs/runs/ — siete
archivos, justo los que más importa revisar — y el linter pasaba en verde sin
haber mirado nada. Es el mismo defecto que ya había aparecido en .gitignore.
2026-07-28 07:25:51 -03:00
msaldain 4048936067 Corregir lo que reportó ruff, que nunca se había ejecutado
ruff estaba configurado en pyproject.toml desde el primer commit y jamás se
había corrido. Tenía 15 hallazgos. Dos importan más allá del estilo:

- zip() sin strict= trunca en silencio al más corto. En las comparaciones de
  lotes eso significa que un test podía pasar sin haber comparado todo. Donde
  los largos deben coincidir ahora es strict=True; donde difieren a propósito
  (pares consecutivos) queda strict=False, que documenta la intención.
- Un import sin usar delataba algo peor: BraveBackend se había escrito sin una
  sola prueba. Se agregan ocho, contra una respuesta con la forma que devuelve
  la API, incluidas la limpieza de etiquetas, el caso de límite de tasa —que
  tiene que distinguirse de 'no respondió'— y que la credencial viaje en la
  cabecera y nunca en la URL. El respaldo tiene que funcionar justo cuando el
  primario ya falló; merecía la misma cobertura.

El resto es orden de imports, collections.abc y líneas largas.
2026-07-28 07:25:51 -03:00
msaldain ba61125a3b Desactivar el buffering de salida en las corridas remotas
Sin terminal, Python retiene la salida estándar, así que el comando de registro
no mostraba nada durante minutos aunque el entrenamiento estuviera avanzando —
parecía colgado. Se comprobó comparando el archivo de registro, atrasado,
contra las métricas, que se escriben sin buffer y sí avanzaban.
2026-07-28 07:25:51 -03:00
msaldain 80691131e3 Determinismo como opción del perfil, para poder verificar la reanudación
En GPU dos corridas idénticas no dan los mismos pesos: el orden de reducción de
los kernels varía. Medido en la RTX 2060, dos corridas iguales de 20 pasos
difieren hasta 8,6e-4. Eso hacía imposible comprobar el criterio de aceptación
más importante del entrenamiento — que reanudar reproduzca la corrida — porque
cualquier diferencia se confundía con el ruido de la placa.

Con deterministic activado, la reanudación resulta exacta en los 47 parámetros:
el checkpoint captura todo el estado. Queda como opción del perfil y como una
config propia, en vez de un conjuro de shell que hay que recordar.

La opción rechaza convivir con la compilación, porque el autotune vuelve a
elegir kernels distintos entre corridas y reintroduce justo lo que se quería
eliminar. Está apagada en producción: cuesta rendimiento.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 07:14:18 -03:00
msaldain f0ea6202e6 Los checkpoints se cargan siempre en CPU
Reanudar en la 2060 fallaba con 'RNG state must be a torch.ByteTensor', y
arreglarlo en un lugar lo movía al siguiente: primero el generador de torch,
después el del cargador de datos. La causa común es que cargar el checkpoint
apuntando a la placa mueve todos los tensores del payload, y los estados de
generadores exigen estar en CPU.

En vez de seguir parcheando cada consumidor, se ataca el origen: el payload se
carga siempre en CPU y desaparece el parámetro map_location. Los pesos no
pierden nada, porque load_state_dict copia dentro de los parámetros existentes,
que ya están en el dispositivo correcto, y el optimizador reubica su estado
solo. El cargador de datos además fuerza CPU por su cuenta, por si un estado
llega desde otro lado.

Es una familia de fallos invisible desde Gigastar: sin placa, map_location es
cpu y los tensores nunca se mueven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 01:31:54 -03:00
msaldain 073209d48e Arreglar la reanudación en GPU: el estado del generador debe volver en CPU
Reanudar una corrida en la 2060 fallaba con 'RNG state must be a
torch.ByteTensor'. La causa: al cargar el checkpoint con map_location apuntando
a la placa, torch.load mueve todos los tensores del payload a la GPU, incluido
el estado de los generadores aleatorios, y set_rng_state exige un ByteTensor en
CPU.

Es un fallo que no se puede ver desde Gigastar: en CPU el map_location es cpu y
los tensores nunca se mueven. Lo encontró la verificación de reanudación exacta
corrida en el servidor, que es justamente el criterio de aceptación del plan.

Se agrega una prueba de regresión que verifica el contrato —lo que recibe torch
está en CPU y es uint8— y que sí corre sin GPU.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 01:29:48 -03:00
msaldain bfa372d9c3 La z-loss ya no duplica la matriz de logits
La verificación previa contra la 2060 real hizo fallar el pre-entrenamiento por
falta de memoria: 11,19 de 11,56 GB en uso. La causa es que la z-loss indexaba
los logits con una máscara booleana antes del logsumexp, y eso copia la matriz
completa. Con vocabulario de 32k y lotes de 16k tokens son 2 GB de más.

Ahora el logsumexp se calcula sobre todas las filas y el filtro se aplica al
vector resultante, que tiene una entrada por token en vez de una por token y
clase. El resultado numérico es idéntico.

Encontrado por la verificación previa, no por los tests: los tests corren en CPU
con modelos diminutos, donde estos 2 GB son unos pocos megabytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:57:40 -03:00
msaldain 12bdc983e6 Verificación previa: validar una config contra el hardware real
Comprometer uno o dos días de cómputo para descubrir en la hora tres que el
lote no entra en memoria, o que fp16 diverge, es el desperdicio más caro del
proyecto. Esto responde en minutos: construye el modelo y el optimizador como
lo haría el entrenamiento y los ejercita con lotes sintéticos del tamaño
configurado, así que no necesita que el corpus exista.

Mide el pico de VRAM reservada (no la asignada: es la reservada la que hace
fallar la asignación), el rendimiento real convertido a horas de reloj, y la
tasa de pasos que el GradScaler descarta por inf/NaN — la señal directa de que
fp16 está perdiendo actualizaciones.

Aparte: .vscode/ sale del repo (config de IDE por máquina) y smoke-2060 fija
run_name, que sin eso los comandos de logs y de descarga pedían nombres
distintos para la misma corrida.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:48:59 -03:00
msaldain 36040d7cc8 Brave como respaldo de DuckDuckGo, y arreglo de la guardia de bf16
Cadena de búsqueda con respaldo. DuckDuckGo sigue de primario porque no exige
credenciales: en el camino habitual ningún tercero se entera de qué busca la
familia. Pero su endpoint lite no es una API con contrato, así que cuando
limite por tasa o cambie el HTML, Brave responde.

Dos matices del encadenado:

- Cero resultados NO dispara el respaldo. Si el primario respondió bien y no
  encontró nada, esa es la respuesta correcta; encadenar gastaría cuota y
  devolvería resultados peores. Solo se avanza ante un error real.
- La cadena recuerda quién respondió: al depurar una respuesta rara, lo
  primero que hay que saber es de dónde salió.

Un respaldo sin credencial falla al arrancar y no en la primera consulta —
que es justo cuando el primario ya falló y el respaldo tiene que funcionar.

Aparte, verificando torch en la 2060 apareció un defecto real en backends.py:
torch.cuda.is_bf16_supported() usa including_emulation=True por defecto, así
que devuelve True en Turing, donde bf16 no existe en silicio. La guardia no
habría atrapado el perfil de la 5090 corriendo en la 2060: el entrenamiento
seguía, emulado y silencioso. Medido en la placa: 4,49 ms por matmul en bf16
contra 1,75 ms en fp16, unas 2,6x más lento. Ahora se consulta con
including_emulation=False, con respaldo por compute capability.

11 tests nuevos (113 en total).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:39:13 -03:00
msaldain 5b9f2a58bc Ajustes tras validar el harness contra la API real
Un lote de humo de 2 pedidos contra la Batch API confirmó el camino completo
—crear, consultar estado, recoger, normalizar— y dejó dos cosas a corregir:

- El costo estimado se redondeaba a 2 decimales, así que un trabajo chico
  informaba US$ 0.0. Pasa a 4 decimales: en una prueba de humo importa saber
  que gastaste algo, no que gastaste nada.
- cache_read_input_tokens quedó en 0. La causa es que el mínimo cacheable de
  Opus 5 son 512 tokens y el prompt de sistema de la prueba tenía 86: la marca
  cache_control no hace nada por debajo de ese umbral, y no avisa. Queda
  documentado en el código y en la config, con cómo detectarlo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:39:25 -03:00
msaldain b88264f41b Cargar .env en los entrypoints de Python
scripts/remote.sh hace `source .env`, pero los procesos de Python no heredan
eso: los SDKs leen variables de entorno, y nada cargaba el archivo. Una clave
correctamente escrita en .env simplemente no existía para el proceso, y el
síntoma —error de autenticación con el archivo bien configurado— es de los
más molestos de diagnosticar.

load_dotenv() se implementa a mano en vez de agregar python-dotenv: son veinte
líneas y evita una dependencia más en el servidor de entrenamiento.

Detalles que importan:

- No pisa variables que ya existan en el entorno. Una variable exportada en la
  shell o inyectada por el orquestador gana sobre el archivo, que es lo que se
  espera en producción.
- Ignora claves con valor vacío: una variable vacía autentica peor que una
  ausente, porque parece presente.
- Acepta la forma `export FOO=bar`, porque el mismo archivo lo consume
  `source .env` desde remote.sh.
- Devuelve nombres, nunca valores: estos archivos tienen secretos y no deben
  terminar en un log.

Además, ClienteAnthropic ahora falla con un mensaje que dice qué hacer cuando
no hay credencial, en vez de propagar el error del SDK.

10 tests nuevos (102 en total).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:32:42 -03:00
msaldain d0a05cdd97 Anclar las rutas de .gitignore al raíz del repo
Un patrón sin "/" inicial en .gitignore matchea cualquier directorio con ese
nombre en cualquier nivel. "data/" y "runs/" estaban excluyendo, además de lo
que se quería:

  enlace/data/     los cargadores de datos (ByteStream, ShardStream)
  configs/data/    corpus.yaml, smoke.yaml, distill.yaml
  configs/runs/    todos los configs ejecutables

Es decir que el repo commiteado no contenía ni la capa de datos ni un solo
config con el que arrancar un entrenamiento. Como scripts/remote.sh sincroniza
por git push/pull, el servidor habría recibido un paquete que falla al
importar, y el síntoma habría aparecido recién allá.

Se anclan las cuatro rutas con "/" inicial y se documenta el porqué en el
propio archivo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:27:40 -03:00
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
msaldain 1d2fcef4d0 Aclarar que ANTHROPIC_API_KEY es opcional
Solo se usa en la Etapa 3, y solo para la parte difícil del dataset SFT
(identidad y casos límite). El grueso sale de plantillas —cero modelo— y
de un 7-8B local en la 2060.

Se documenta además que una suscripción Pro/Max no sirve como credencial
programática: autentica claude.ai y Claude Code, no la API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:20:53 -03:00
msaldain 179062a5d4 Búsqueda web con DuckDuckGo, sin API key
Primera tool del agente. No depende del modelo, así que se puede usar y
verificar de punta a punta antes de que exista el modelo propio.

Sobre el endpoint: la API oficial de DuckDuckGo ("Instant Answer") no sirve
para esto — responde definiciones y fichas de entidades, y para una consulta
normal devuelve 200 con el cuerpo vacío. Los resultados web reales solo están
en el endpoint lite, que no es una API con contrato: el HTML puede cambiar y
hay límite de tasa. Por eso el backend está detrás de una interfaz y queda
implementado también SearxNG autoalojado, que es un cambio de una línea de
config cuando haga falta.

Decisiones:

- Presupuesto de contexto explícito: con seq_len 2048 los resultados compiten
  con la memoria recuperada y el turno del usuario, así que se devuelven cinco
  recortados en vez de diez completos.
- Las redirecciones /l/?uddg= se desenvuelven al destino real: guardarlas
  ensuciaría la memoria episódica, donde dos búsquedas a la misma página
  parecerían páginas distintas.
- Caché con TTL, que es de comportamiento y no de rendimiento: en una casa las
  mismas preguntas se repiten muchas veces por día.
- Sin resultados devuelve "Sin resultados." en vez de vacío, para que el modelo
  pueda decir que no encontró nada en lugar de inventar.

Se quitan Brave y Tavily de .env.example: ya no hay credenciales que gestionar
ni un tercero al que informarle qué busca la familia.

23 tests nuevos (71 en total), contra una respuesta real guardada como fixture:
el parser es lo que se rompe cuando cambia el markup, y tiene que fallar en la
suite y no en producción. Ningún test sale a internet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:15:13 -03:00
msaldain 90ac2a6582 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>
2026-07-27 23:04:45 -03:00