0017 — Reproducibilidad por historia auditable + snapshot sellado, no por recómputo¶
- Estado: Aceptada · enmendada 2026-06-15 (identidad vs procedencia; Louvain seeded — ver "Enmienda" al final)
- Fecha: 2026-06-15
- Relacionada con: 0007 (OpenAlex backbone),
0009 (biblioteca viva; snapshot = export),
0013 (
corpus_hash, log de procedencia), 0016 (máquina de estados del lazo) - Precisa: el principio 8 del PRD §6 ("reproducibilidad por historia auditable + snapshot exportable, no por inmutabilidad"), declarando por qué el recómputo no sirve.
Contexto¶
El ADR 0009 ya estableció que la persistencia es una biblioteca viva y que el snapshot es un export derivable, con reproducibilidad por historia auditable + snapshot exportable, no por inmutabilidad. Faltaba declarar explícitamente el hecho que lo obliga: que re-ejecutar la misma ecuación contra OpenAlex NO garantiza el mismo corpus.
OpenAlex cambia en el tiempo (ADR 0007 lo nota como advertencia de reproducibilidad): se agregan obras, se corrigen referencias, cambia la cobertura. Una ecuación idéntica corrida hoy y en seis meses devuelve conjuntos distintos. Por lo tanto, la ecuación + la query ejecutada NO son un artefacto reproducible por sí solas: documentan qué se pidió, no qué se obtuvo.
Decisión¶
El artefacto reproducible es el snapshot sellado, no el recómputo de la ecuación. La reproducibilidad se sostiene en dos patas, ambas ya presentes en el modelo:
- Historia auditable — el log de procedencia append-only (
provenance, ADR 0013 D4) y elManifestregistran qué ecuación, qué query OpenAlex ejecutada, qué salto de chaining, qué filtros (conteos PRISMA) y qué decisión humana, y cuándo. Eso hace la corrida reportable y auditable (PRISMA / vom Brocke). - Snapshot sellado — la foto (
corpus.parquet+manifest.json) concorpus_hash(D2) es el artefacto que se versiona (git-lfs / DVC) y que otro investigador reproduce bit a bit, sin volver a llamar a OpenAlex.
El openalex_version del Manifest ancla la foto a la versión/fecha del snapshot de
OpenAlex usado. Declararlo es obligatorio para que el snapshot sea interpretable: dice "este
corpus corresponde a OpenAlex en tal estado". Sin ese ancla, dos snapshots de la misma ecuación
en fechas distintas no son comparables.
Reproducir = re-sellar/releer el snapshot, no re-correr la ecuación. Re-correr la ecuación es re-investigar (legítimo, pero produce un corpus nuevo, no el mismo).
Consecuencias¶
- Queda claro qué promete la herramienta: reproducir un resultado (el snapshot), no recuperar el mundo de OpenAlex en una fecha pasada. Honestidad sobre el límite de la fuente.
- El
openalex_versiondeja de ser opcional en la práctica: sigue siendo un campo delManifestcon defaultNone(D5), pero elOpenAlexSource(Hito 4) debe poblarlo al sembrar, y un snapshot conopenalex_version=Nonese reporta como menos reproducible (entra en el reporte de calidad, ADR 0018). - Refuerza el ADR 0016: el archivo vivo cambia (la idea muta, el corpus crece); el snapshot
congela un punto del lazo. El
LoopStatedel instante puede sellarse en el snapshot. - No reabre el ADR 0009: lo precisa. El snapshot ya era "export sellado"; este ADR fija que es la unidad de reproducibilidad y por qué el recómputo no la sustituye.
- Recomendación para el
coder: ningún cambio de núcleo nuevo respecto al Hito 1 (elsnapshot()ya sellacorpus_hash); el Hito 4 (OpenAlexSource) debe poblarManifest.openalex_versional sembrar, y el reporte de calidad (Hito 5+, ADR 0018) debe marcar los snapshots sin ancla de versión.
Enmienda — 2026-06-15 (identidad vs procedencia; reloj en la frontera; Louvain seeded)¶
Motivada por el red-team del AS-BUILT v0.2 (Nota 06, RAÍZ 2): el principio de este ADR es correcto, pero el código no lo cumple — el
corpus_hashactual incluye los timestamps de curación, así que dos corridas que aceptan los mismos ids producen hash distinto y el snapshot no es reproducible bit a bit. El cuerpo del ADR (arriba) queda como historia; esta enmienda precisa el contrato correcto.
Identidad (contenido) ≠ procedencia (auditoría). Son dos ejes:
- Identidad — el
corpus_hashse computa SOLO sobre contenido bibliográfico, excluyendoProvenanceEvent/timestamps. Dos corridas con el mismo contenido curado (mismos ids, mismocuration_status) tienen el mismo hash, aunque hayan ocurrido en momentos distintos. La identidad es del qué, no del cuándo. - Procedencia — log append-only FUERA de la identidad. El
provenance(ADR 0013 D4) sigue registrando cuándo/quién/por qué (para auditar PRISMA / vom Brocke), pero no entra al hash. Es modelado porProvenanceEvent(BaseModel)(ADR 0023). - El reloj se inyecta en la frontera (CLI), no en el núcleo.
accept/rejecty los filtros (apply_filter/apply_filters) reciben el instante de decisión (decided_at) como parámetro inyectado desde la frontera, en vez de llamardatetime.now(UTC)adentro. Las cuatro fronteras CLI que curan lo inyectan:cli/commands/accept.py,reject.pyyfilter.py(este último con un únicodecided_atpara todos los pasos PRISMA de la invocación). Fallback documentado (no contradicción): cuando el caller no proveedecided_at(uso como librería sin frontera CLI), el helper_apply_curation_to_rows(backends/memory.py) generadatetime.now(UTC)como conveniencia ergonómica, de modo quecorpus.accept(ids)sigue funcionando sin obligar a pasar un reloj. Esto no rompe la reproducibilidad porque eldecided_atno entra al hash (es procedencia, no identidad, punto 1): la identidad del corpus es determinista por construcción, independientemente de si el timestamp vino inyectado o del fallback. El contrato preciso es entonces "el reloj se inyecta en la frontera; el núcleo solo recurre adatetime.now()como fallback de librería, fuera de la identidad" — no "el núcleo nunca toca el reloj". - Análisis reproducible — Louvain con
random_statederivado del content-hash.detect_communities(method="louvain")se siembra con unrandom_statederivado delcorpus_hash(_louvain_seed_from_hash,facade.py:int(corpus_hash[:8], 16) % 2**31), de modo que mismo corpus → mismas comunidades entre corridas.resolutionexpuesta en Hito 9 ✅ (2026-06-17): la idea original incluía exponer el parámetroresolutionde Louvain; se difirió al Hito 9 y ya está construido —NetworkSpec.resolution: float = 1.0, propagado acommunity_louvain.best_partition(..., resolution=...), fuera delcorpus_hash(param de spec, no de contenido), así que el seed de Louvain (random_statederivado delcorpus_hash) queda intacto y la reproducibilidad se mantiene. R2 entregó la pata que importa para la reproducibilidad (elrandom_stateseeded); la exposición deresolutiones aditiva (no rompe nada construido).
Consecuencia: el snapshot vuelve a ser reproducible bit a bit (cumple la promesa original) y
la pureza de facade.py ("mismo corpus + mismo spec → mismo NetworkArtifact") deja de estar rota.
Implementado en Hito R2 (✅ 2026-06-16; ver ROADMAP): backends/memory.py
(compute_corpus_hash excluye provenance; _apply_curation_to_rows recibe decided_at con
fallback), corpus.py (accept/reject con decided_at: datetime | None), filters/prisma.py
(apply_filter/apply_filters con decided_at), cli/commands/{accept,reject,filter}.py (inyectan
datetime.now(UTC)), networks/{facade,analyzer}.py (Louvain seeded).