Skip to content

English · Español

03 — Reproducibilidad, manifests y dvc

🇪🇸 Si el corpus no se reproduce desde una semilla, los resultados de cada fase posterior son anécdota. La cadena de SHA256s en MANIFEST.json une corpus, embeddings, checkpoint y reporte. dvc rastrea el data/processed/ sin tocar git.


El contrato de reproducibilidad

Según CLAUDE.md §0.5: cada script numérico siembra los RNGs y escribe un manifest. Para la Fase 12, el contrato es estricto:

Ejecutar python scripts/gen_corpus.py --seed 42 en cualquier máquina con las versiones fijadas produce una tripla data/processed/{train,val,test}.jsonl cuyos SHA256s coinciden exactamente con el manifest.

Este es un test de CI. Si una refactorización rompe la reproducibilidad silenciosamente, el CI falla en el siguiente push.

Sutileza: la mayoría del corpus es enumeración determinista — cada celda (verbo, tiempo, persona) produce una fila correcta fija. El determinismo aquí no depende de la semilla. La semilla solo controla:

  1. El orden en que las filas se escriben al JSONL (que afecta la asignación de id y por tanto el SHA256 del archivo).
  2. Qué subconjunto de tipos de mis-conjugación elegibles se aplica a qué celdas (el generador muestrea ~1–3 por celda elegible de la taxonomía de 6 tipos).
  3. Los IDs de las filas de mis-conjugación.

Así que cambiar la semilla cambia el SHA256, pero el conjunto de celdas cubiertas permanece igual.

Qué significa "con semilla" en la práctica

Tres fuentes de aleatoriedad en nuestro pipeline:

  1. random.Random(seed) — usado para escoger el subconjunto de tipos de mis-conjugación por celda y para barajar el orden de las filas.
  2. numpy.random.default_rng(seed) — no usado en v1 (no se necesita aleatoriedad NumPy para enumeración); reservado para añadidos posteriores.
  3. Orden de iteración de dict de Python — aleatoriedad implícita. Desde Python 3.7 los dicts están ordenados por inserción, así que esto es determinista. Confiamos en esto.

El helper seed_everything(seed) (src/utils/seeding.py, desde la Fase 0) siembra tanto random como numpy.random, además de fijar PYTHONHASHSEED (que solo surte efecto para invocaciones de subprocess, no el proceso actual — pero lo registramos por completitud).

Anti-patrón: usar random.random() (las funciones a nivel de módulo) sin pasar el RNG explícitamente. El random a nivel de módulo usa un estado global compartido; si cualquier librería importada lo llama, tu ejecución "con semilla" está contaminada.

Convención: cada helper recibe un argumento explícito rng: random.Random y usa solo rng.choice, rng.randint, etc.

Normalización NFC (y por qué)

Según la teoría 03 de la Fase 11, el proyecto NFC-normaliza todo el texto antes de cualquier hashing o tokenización. Esto no es negociable para la Fase 12 por el español:

  • ñ puede codificarse como NFC (un único codepoint U+00F1) o NFD (n + tilde combinante U+006E + U+0303). Ambos se renderizan visualmente idénticos; ambos tienen secuencias de bytes y SHA256s diferentes.
  • El sistema de archivos de macOS usa NFD por defecto; Linux usa NFC; Windows mixto. Un corpus generado en macOS y validado en Linux divergiría.

Mitigación: gen_corpus.py escribe NFC, validate_corpus.py re-NFC cada campo y asegura igualdad antes de computar el hash del manifest. Las entradas del diccionario en español embebidas en la tabla de verbos se almacenan en NFC.

El manifest

data/MANIFEST.json:

{
  "corpus_name": "lynx-cortex-verbgrammar-v1",
  "version": "1.0.0",
  "date_generated": "2026-MM-DD",
  "seed": 42,
  "versions": {
    "python": "3.11.x",
    "numpy": "X.Y.Z",
    "corpus_spec": "1.0.0"
  },
  "config": {
    "verbs": ["work", "play", "walk", "talk", "listen", "watch", "study",
              "finish", "start", "look", "want", "like",
              "be", "have", "do", "go", "come", "see", "eat", "write"],
    "tense_surfaces": ["infinitive", "present_simple", "past_simple",
                       "past_participle", "future_will", "future_going_to"],
    "persons": ["1sg", "2sg", "3sg"],
    "labels": ["correct", "mis_conjugated"],
    "mis_conjugation_types": ["missing_third_person_s",
                              "overregularization_past",
                              "wrong_aux_will_with_to",
                              "wrong_aux_going_to_missing_ing",
                              "subject_verb_disagreement",
                              "bare_participle_missing_aux"],
    "split_ratios": [0.8, 0.1, 0.1],
    "split_grain": "(verb, tense)"
  },
  "files": {
    "data/processed/train.jsonl": {
      "sha256": "abc123...",
      "lines": 364,
      "bytes": 18420
    },
    "data/processed/val.jsonl": { "...": "..." },
    "data/processed/test.jsonl": { "...": "..." }
  },
  "per_cell_counts": {
    "work:present_simple:1sg": 1,
    "work:present_simple:2sg": 1,
    "work:present_simple:3sg": 1,
    "go:past_simple:1sg": 1,
    "...": "..."
  },
  "mis_conjugation_counts_by_type": {
    "missing_third_person_s": 20,
    "overregularization_past": 16,
    "...": "..."
  },
  "total_correct": 360,
  "total_mis_conjugated": 128,
  "total_rows": 488
}

El campo versions.corpus_spec es la versión de data/corpus_spec.md. Cuando la spec cambia (se añade un tipo de mis-conjugación, se corrige un lema en español), la versión del corpus sube y los SHA256s cambian. Las fases posteriores fijadas a v1.0.0 siguen funcionando con el corpus viejo; las nuevas ejecuciones de entrenamiento usan el nuevo.

dvc para la capa de datos

Según A8, dvc se instala en la Fase 12. Usamos solo su funcionalidad de tracking de archivos:

dvc add data/processed/train.jsonl
dvc add data/processed/val.jsonl
dvc add data/processed/test.jsonl
git add data/processed/*.dvc data/.dvcignore
git commit -m "chore: track corpus with dvc"

Esto crea train.jsonl.dvc (un pequeño archivo de texto que contiene el SHA256 de los datos) que se checkea a git. Los archivos .jsonl reales se añaden a .gitignore. Los datos viven en .dvc/cache/.

Eso es todo. No definimos pipelines DVC (dvc.yaml), no hacemos push a un remoto, no usamos experimentos. La integración completa de dvc son ~5 comandos.

¿Por qué molestarse para un corpus tan pequeño (~50 KiB)? Porque:

  1. El proyecto crecerá otros artefactos (embeddings .npy, checkpoints de modelo) que no pertenecen a git. El mismo flujo de trabajo de dvc los maneja más tarde.
  2. Práctica ahora → hábito después.

Paga el coste de dvc ahora; beneficio después.

Almacenamiento remoto (diferido)

dvc puede empujar la caché a almacenamiento compatible con S3. No lo hacemos en la Fase 12. Razones:

  • La máquina de Borja tiene 62 GiB de RAM y mucho disco; la caché local está bien.
  • Empujar a un remoto añade una cuenta, costes y un paso de permisos.
  • La Fase 23 (GPU en la nube) necesitará almacenamiento remoto de todos modos; diferir al setup de esa fase.

Si Borja ejecuta dvc push sin configurar un remoto, dvc da error. Nota en el lab.

Subidas de versión

¿Cuándo sube la versión del corpus?

Cambio Subida
Arreglar un typo en una traducción al español Patch (1.0.0 → 1.0.1)
Añadir un nuevo tipo de mis-conjugación Minor (1.0.0 → 1.1.0)
Cambiar la semántica de normalize (afecta fingerprints) Major (1.0.0 → 2.0.0)
Añadir un nuevo verbo (violaría §A13) Prohibido sin enmienda-A.
Cambiar la granularidad de split de (verbo, tiempo) a (verbo, tiempo, persona) Major.
Bug fix en una plantilla del generador Patch.

Un cambio de versión major requiere que las fases posteriores re-entrenen. Un cambio minor podría requerirlo; un patch es usualmente retrocompatible. El reporte de fase registra cuál.

El check de reproducibilidad del CI

.github/workflows/ci.yml (o donde la Fase 0 configuró CI) tiene un job:

- name: Reproduce corpus
  run: |
    python scripts/gen_corpus.py --seed 42 --output /tmp/test-corpus
    diff <(jq -S '.files | to_entries | map({key, sha256: .value.sha256})' data/MANIFEST.json) \
         <(jq -S '.files | to_entries | map({key, sha256: .value.sha256})' /tmp/test-corpus/MANIFEST.json)

Si el SHA256 cambia silenciosamente, esto falla. El test se ejecuta en cada push que toca src/minicorpus/, scripts/gen_corpus.py, o data/corpus_spec.md.

Distribución más allá de Borja

Si otro learner clona el repo, ¿qué le da el corpus?

  1. git clone → tiene los punteros .dvc pero no los archivos .jsonl reales.
  2. dvc pull → sin un remoto configurado, esto falla. Su fallback: python scripts/gen_corpus.py --seed 42 regenera los mismos archivos. El check de SHA256 confirma.

Importante: el corpus es completamente regenerable desde la semilla + el codebase. No es necesario compartir "datos". El proyecto completo es un único repositorio Git más un corpus regenerable.

(Esta es una de las razones por las que elegimos enumerado en lugar de scrapeado. Un corpus scrapeado no puede regenerarse; tendrías que almacenar los bytes reales y distribuirlos.)

Lo que no hacemos

  • Workflows de dvc multi-máquina. Fuera de alcance.
  • Pipelines de dvc (dvc.yaml). Útiles cuando los datos tienen transformaciones costosas; los nuestros no.
  • Rolling continuo del corpus. El corpus es estático dentro de una versión. No lo actualizamos diariamente.
  • Acceso streaming al corpus. Todas las filas caben en RAM a nuestra escala.

Estas son consideraciones razonables para v2. v1 mantiene dvc a su mínimo de 5 comandos.

Problemas de práctica

Soluciones en solutions/03-reproducibility-and-versioning-ref.md (apertura de fase).

  1. Borja regenera el corpus en una máquina diferente y obtiene SHA256s diferentes. Tres causas raíz probables — ¿cuáles son, y cómo diagnosticas cada una? (Pista: una de ellas involucra NFC vs NFD.)
  2. La versión de Python cambia de 3.11.5 a 3.11.6. ¿Debería esto desencadenar una subida de versión del corpus? (Pista: piensa en qué fuentes de aleatoriedad son sensibles a la versión de Python.)
  3. Sugiere un test de CI que detecte "un acento en español se guardó como NFD en lugar de NFC" antes del merge.

Recapitulación de un párrafo

La reproducibilidad se hace cumplir con RNGs con semilla (rng explícito por helper), un MANIFEST.json que lleva SHA256s + versiones + conteos por celda, y un test de CI que regenera el corpus y compara los hashes. La normalización NFC de todo el texto y campos en español no es negociable para mantener los hashes estables cross-plataforma. dvc se usa en su modo mínimo (solo tracking de archivos, sin pipelines, sin remoto en v1). La versión del corpus sigue semver: patch para arreglos de typos, minor para nuevos tipos de mis-conjugación o entradas en español, major para cambios de normalización. El corpus completo es regenerable desde la semilla + el codebase — ningún dato externo necesita distribuirse.


Siguiente: lab/00-corpus-spec.md.