English · Español
01 — Registry y Lineage (envueltos sobre MLflow + DVC)¶
🇪🇸 Un registry es un almacén content-addressable: la clave es el SHA del contenido canónico, no un nombre, ni el ID que asigne MLflow. Lineage es la cadena de manifiestos que va desde un checkpoint del tutor de gramática hasta los datos crudos (verbos en el corpus DVC), el código y la semilla. Juntos responden a la pregunta "¿qué exactamente sirvió esta corrección?".
Qué es un registry¶
Un model registry es un almacén clave-valor con dos propiedades:
- Content-addressable. La clave es el hash criptográfico del valor. Si cambias un byte del modelo, la clave cambia. La misma propiedad que git usa para los blobs.
- Inmutable. Una vez registrada, una entrada no se modifica jamás — solo se superpone con una entrada nueva. Borrar es una lápida, no una mutación.
Estas dos propiedades juntas dan la garantía operacional que la Fase 38 necesita: "si se loggeó un SHA al servir la respuesta R, puedo recuperar exactamente ese modelo, byte a byte, y reproducir R."
Un overlay de semver (v0.3.1, v0.3.2) es navegación — un puntero legible que apunta a uno de los SHAs. No es la identidad. Dos semvers pueden apuntar al mismo SHA (p. ej., latest y v0.3.1); el mismo semver no puede apuntar a dos SHAs.
Por qué envolvemos MLflow en lugar de reemplazarlo¶
MLflow trae un model registry. Lo usamos — pero solo como el backend de almacenamiento, no como fuente de identidad. Tres razones:
- La "model version" de MLflow es mutable. Puedes añadir nuevos tags a una versión, cambiar su descripción, transicionar su stage (
Staging → Production). Para un registry cuyo trabajo es "exactamente este modelo, byte a byte", la mutación es el enemigo. - La SHA de MLflow es sobre el layout en disco, no el contenido canónico. Guarda el mismo checkpoint dos veces y obtienes dos IDs de model-version distintos porque MLflow embebe metadata (timestamps, run IDs) en el artefacto registrado. La reproducibilidad se rompe.
- El lineage de MLflow es centrado en runs, no en contenido. Trackea "este run produjo estos artefactos" pero no impone que reejecutar el mismo código produzca el mismo SHA de artefacto. Necesitamos la garantía de contenido.
Solución: un wrapper de 150 líneas en scripts/mlops/registry.py que:
- Computa un SHA canónico sobre un bundle fijo (descrito abajo) antes de entregar el bundle a MLflow.
- Almacena esa SHA canónica como la primary key en un ligero log de auditoría
index.jsonlque es nuestro. - Usa MLflow como artifact store (
mlflow.artifacts.download_artifactspara recuperar por run_id). - Nos permite recorrer el lineage vía tags de runs de MLflow (
parent_run_id,corpus_dvc_hash,code_git_sha).
El wrapper es pequeño precisamente porque la capa de almacenamiento de MLflow está bien. La identidad es lo que poseemos.
Qué se registra (el bundle canónico)¶
Una entrada del registry para un modelo de tutor de gramática no es solo un fichero .safetensors. Es el bundle que permite a un tercero reejecutar el modelo determinísticamente:
| Fichero | Propósito |
|---|---|
model.safetensors |
Los pesos (base Mini-GPT o adaptador LoRA, según la entrada). |
tokenizer.json |
La tabla de merges BPE + vocab de la Fase 11 (entrenada sobre el corpus de verbos en inglés). |
config.json |
Arquitectura (n_layers, d_model, n_heads, ...) — Fase 17. |
eval_report.json |
Últimas puntuaciones del eval de la Fase 20: precisión de conjugación por (verbo, tiempo, persona), más las tasas de pase por bucket. |
train_manifest.json |
Puntero al run de entrenamiento: hash DVC del corpus, SHA git del código, semilla, hparams, run_id de MLflow. |
parent_sha |
La SHA canónica del modelo desde el que se hizo fine-tuning este (o null si fue entrenamiento desde cero). |
La SHA registrada es el hash de una representación canónica de este bundle. Canónica significa: claves JSON ordenadas, orden de ficheros ordenado, sin mtime en cabeceras de archivo. Un tar ingenuo no es canónico — embebe timestamps. Hasheamos el contenido, no el layout:
SHA = sha256(
sha256(model.safetensors)
‖ sha256(canonical_json(tokenizer.json))
‖ sha256(canonical_json(config.json))
‖ sha256(canonical_json(eval_report.json))
‖ sha256(canonical_json(train_manifest.json))
‖ utf8(parent_sha or "null")
)
donde canonical_json significa: claves ordenadas, sin separadores con espacio en blanco, codificación UTF-8, sin newline final.
Propiedades que esto nos da:
- Reguardar el modelo con un compresor de tarball distinto no cambia la SHA.
- Reguardar el eval report con claves reordenadas no cambia la SHA.
- Subir la versión patch en
config.jsonsí cambia la SHA, porque el contenido del fichero cambió. - Un cambio genuino del modelo (un peso distinto) cambia
sha256(model.safetensors), que cambia la SHA exterior.
Borja verificará esta propiedad en el lab 00: registrar el mismo checkpoint dos veces desde shells distintos, confirmar que ambos registros producen la misma SHA canónica aunque MLflow asigne IDs distintos de model-version por debajo.
La función de hash: elegir SHA-256¶
Usamos hashlib.sha256 de stdlib. Por qué:
- Resistencia a colisiones. Trabajo \(2^{128}\) para encontrar una colisión. Dos checkpoints no relacionados jamás colisionarán en ningún horizonte que nos importe.
- Stdlib. Sin nueva dependencia.
- Default de la industria. Git está migrando de SHA-1 a SHA-256. Safetensors usa SHA-256 internamente. PyPI usa SHA-256 para la integridad de los wheels.
No usamos SHA-1 (roto desde 2017), MD5 (roto desde 2004) ni hashes no-criptográficos (xxhash, MurmurHash — rápidos pero propensos a colisiones para entradas adversariales).
Lineage: la cadena¶
Lineage responde a: "dada una SHA de checkpoint de tutor de gramática, retrocede hasta el corpus de verbos."
La cadena la implementa train_manifest.json conteniendo un puntero parent_sha más punteros a artefactos aguas arriba (el hash DVC del corpus, la SHA del tokenizador, la SHA git del código). Un recorrido se ve así:
checkpoint: <model_sha>
entrenado desde el hash DVC del corpus: <corpus_dvc_hash> (p. ej., el corpus de la Fase 12 en el commit X)
que fue tokenizado usando: <tokenizer_sha> (merges BPE de la Fase 11)
con código en el SHA git: <code_git_sha> (el repo en el momento del entrenamiento)
partiendo del modelo padre: <parent_sha> ← recursión (Fase 28 LoRA → Fase 18 base)
con run ID de MLflow: <mlflow_run_id> (lookup de hiperparámetros)
con semilla: 42
Tres propiedades importan:
- Acíclica. El
parent_shade un modelo no puede ser él mismo ni ningún descendiente. El grafo de lineage es un DAG. El registry lo impone rechazando registros cuyoparent_shasea descendiente del candidato (chequeo de alcanzabilidad barato sobre elindex.jsonl). - Recorrible en código.
lineage(<sha>) -> LineageTreedevuelve el árbol completo de ancestros en O(profundidad). Sin llamada a base de datos en vivo — la cadena de manifests son ficheros + lookups de runs de MLflow. - Auditable desde un único SHA. Dada cualquier respuesta servida, la traza loggea la SHA del modelo. Desde la SHA toda la cadena es reproducible. Esto es el reclamo de operabilidad de
theory/00.
El rol de DVC en el lineage¶
DVC versiona el corpus de verbos. Cada commit a data/processed/train.jsonl produce un hash DVC; dvc pull <hash> reconstruye los bytes exactos del corpus. El script de entrenamiento registra el hash DVC en train_manifest.json en el momento del entrenamiento.
Esto importa operacionalmente: si el recorrido del lineage dice "entrenado sobre corpus_dvc_hash=abc123", y abc123 es un corpus donde "go" faltaba del conjunto de verbos irregulares, entonces un tutor que acepta "I goed" deja de ser un misterio — es un bug del corpus, arreglable añadiendo "go" al script generador de verbos irregulares y reejecutando el versionado DVC.
DVC también importa para eval_baseline.json (el gate de CI). El propio eval set está versionado con DVC; CI verifica que el hash DVC del eval set coincida antes de comparar tasas de pase. De lo contrario el baseline podría driftear silenciosamente y las filas de CpQU se volverían incomparables.
Semver como overlay fino¶
Una vez que tienes SHAs, el semver es contabilidad. Usamos el esquema estándar:
- MAJOR — cambio de arquitectura (añadiste una capa, cambiaste
d_model, cambiaste el rank de LoRA). - MINOR — cambio del corpus de entrenamiento (añadiste verbos nuevos, expandiste la tabla de tiempos de 5 a 6, añadiste personas en plural).
- PATCH — misma arquitectura, mismo corpus, semilla o hparams distintos.
Un semver mapea a exactamente una SHA en cualquier momento. El mapeo se almacena en MLflow como un tag sobre la model version (p. ej., tag:semver=v0.3.1) y también en nuestro fichero tags.json en la raíz del registry — redundante, pero el fichero local es la ruta rápida de lookup y el tag de MLflow es la copia autoritativa.
No permitimos punteros semver-a-semver (v0.3 → v0.3.2). Siempre semver → sha. La indirección rompe la reproducibilidad bajo compactación o migración del registry.
Lo que el lineage no es¶
- No es una herramienta de orquestación de pipeline de entrenamiento. La pipeline es el
Justfile+.github/workflows/. El lineage solo registra lo que hizo la pipeline. - No es un rastro de culpa. Si un modelo produce una respuesta mala, el lineage te dice qué lo produjo; no te dice por qué. El "por qué" está en las trazas de la Fase 34 + la metodología de postmortem de la Fase 40.
- No es un artefacto de cumplimiento legal. Algunas jurisdicciones requieren "model cards" con evaluaciones de bias, etc. El lineage es una precondición para eso pero no un sustituto.
Layout de almacenamiento¶
El artifact store de MLflow gestiona los bytes reales. Nuestro wrapper añade dos ficheros en la raíz del registry:
mlruns/ ← raíz de artefactos de MLflow (configurable)
<experiment_id>/<run_id>/artifacts/
model.safetensors
tokenizer.json
config.json
eval_report.json
train_manifest.json
data/dvc-remote/ ← remoto local de DVC (gitignored)
files/md5/<corpus_hash>...
.lynx-registry/ ← metadata de nuestro wrapper
tags.json ← mapeo semver → canonical_sha
index.jsonl ← log de auditoría append-only de registros
index.jsonl es el rastro de auditoría: cada registro anexa una línea con {canonical_sha, semver, mlflow_run_id, mlflow_model_version, timestamp, registrar, parent_sha}. Append-only porque los logs de auditoría deben ser tamper-evidentes.
Lectura vs escritura¶
- Lectura (
registry.get(<sha>)oregistry.get("v0.3.1")) devuelve unModelHandle— un puntero ligero que carga el bundle perezosamente desde MLflow. O(1) para el lookup; O(tamaño) al primer acceso a pesos. - Escritura (
registry.register(run_id, semver=...)) canonicaliza el bundle de artefactos, hashea, rechaza si la SHA ya existe con un mapeo de semver distinto, anexa aindex.jsonl, actualizatags.jsonatómicamente. O(tamaño-del-artefacto) para el hash; O(1) para la actualización del índice.
La llamada register() es idempotente sobre la SHA canónica: registrar el mismo bundle dos veces produce la misma SHA y no genera entrada duplicada. El segundo registro es un no-op con un handle de "ya registrado, aquí tienes la entrada existente". Esto importa para CI — el workflow puede reejecutarse; no debe contaminar el registry.
Qué ve el gate de CI¶
El workflow de deploy (theory/05, lab 04) llama primero a registry.register_pending(run_id), que computa la SHA canónica pero no añade un tag de semver. El candidato se queda en un namespace pending/ hasta que pasa el gate del eval; si pasa, registry.promote(canonical_sha, semver) añade el tag y emite la entrada del log de auditoría. Si falla, la entrada pendiente se recoge tras 24 horas (el artefacto MLflow sigue existiendo; solo se borra nuestro puntero .lynx-registry/pending/).
Esta división — register_pending luego promote — es el único lugar donde el registry tiene una máquina de estados no-trivial. En todo lo demás, el registro es de un solo tiro e inmutable.
Drill problems (resuélvelos antes del lab 00)¶
Soluciones en solutions/01-registry-and-lineage-ref.md — escritas en la apertura de la fase.
- Registras un checkpoint como
v0.3.0. Una semana después descubres queeval_report.jsontenía un bug — la precisión de conjugación por verbo para "go" estaba mal. Arreglas el eval y quieres adjuntar el informe corregido al mismo modelo. Dos opciones: (a) re-registrar el bundle con el informe corregido (obtiene una SHA canónica nueva), (b) adjuntar el informe nuevo como sidecar bajo la SHA existente. ¿Cuál es correcto y por qué? - La pipeline de entrenamiento se cayó tras escribir
model.safetensorspero antes queeval_report.json. El run de MLflow está a medio rellenar. ¿Debería tener éxitoregistry.register_pending()? ¿Qué implica eso para el contrato de atomicidad? - Recorrido de lineage: dada una SHA del tutor de gramática, diseña el layout del registry para que el recorrido hasta el hash DVC del corpus sea O(profundidad) lookups (no O(N) sobre todas las entradas). Esboza la estructura de ficheros y el esquema de tags de MLflow.
Si puedes responder a las tres, entiendes la mecánica del registry. El lab 00 hace concretas las abstracciones.
Recap de un párrafo¶
Un registry es content-addressable + inmutable. La clave es la SHA canónica del bundle (pesos + tokenizador + config + eval + train-manifest + parent_sha); MLflow es el backend de almacenamiento, no la autoridad de identidad. El lineage es la cadena parent_sha + train_manifest, recorrible en O(profundidad) desde cualquier respuesta servida, con el hash DVC del corpus como raíz. El semver es un overlay de conveniencia fino. Todo el wrapper es ~150 líneas de Python — sin base de datos, sin daemon, sin servicio separado.
Siguiente: theory/02-traffic-strategies.md.