0013 — Identidad estable de papers, hash de corpus order-independent y reglas de merge¶
- Estado: Aceptada
- Fecha: 2026-06-15
- Decidido por: IA (Claude Opus 4.8), validado por el Product Owner proxy
(ver
registro-ia.md) - Relacionada con: 0006 (tabla Arrow + Pydantic), 0007 (OpenAlex backbone), 0009 (biblioteca viva stateful en DuckDB)
- Cierra la tensión abierta de: 0009 §Consecuencias ("hay que manejar identidad estable de papers entre corridas").
- Reencuadrado (2026-06-15, 2º giro) por 0015: las reglas
D1 (
idestable), D2 (corpus_hashorder-independent) y D3 (mergecampo a campo) siguen vigentes como contrato, pero suben de "implementación delCorpus" a "contrato delTabularBackend": cada backend debe cumplirlas a su manera (InMemoryBackenden Python,DuckDBBackenden SQLUPDATE/MERGEporid). Elcorpus_hash(D2) se computa siempre sobre el contenido (corpus.to_arrow()), nunca sobre detalles del backend. D4 (provenancecomo log append-only), D5/D6 (Manifest) no cambian. - Precedencia de D1 invertida (2026-06-22, AS-BUILT 0.8) por
0036: la precedencia de la fuente del
valordelid(D1) deja de seropenalex_id-first y pasa adoi:>source_id(src:) >tt:: el DOI es el ancla universal yopenalex_idse renombra asource_idgenérico (motor de extracción intercambiable, tabla lateralexternal_ids). El cuerpo de D1 (más abajo) describe la precedencia original (oa:/openalex_id-first) y queda como historia; el AS-BUILT es_compute_idencorpus.py(doi>source_id>title|year). D2/D3/D4 no cambian.
Contexto¶
El ADR 0009 movió el corpus a una biblioteca viva que acumula entre corridas (aceptar/
rechazar candidatos, crecer, curar) y dejó explícitamente abierto un costo: manejar la
identidad estable de papers entre corridas y la reproducibilidad por historia auditable +
snapshot exportable. El Hito 1 (núcleo de la tabla canónica Corpus) tuvo que resolver, para
poder implementar merge idempotente, snapshot() con hash reproducible y una igualdad de
Corpus insensible al orden, cuatro decisiones acopladas que cambian el contrato:
- Cómo se computa el
idde un paper de forma determinista y estable entre corridas (necesario para deduplicar al fundir en la biblioteca viva). - Cómo se computa el
corpus_hashpara que dos corridas con el mismo contenido en distinto orden produzcan el mismo hash (reproducibilidad del snapshot). - Cómo se fusionan dos filas con el mismo
idsin perder la decisión humana ni la procedencia (merge idempotente sobre la biblioteca viva). - Qué forma toma
provenance. El contrato previo deAPI.md§1.1 lo describía como un objeto único{equation_id, chaining_hop, source, fetched_at, decided_by, decided_at}, pero la biblioteca viva exige un log append-only: un paper puede ser fetcheado, luego aceptado, luego re-fetcheado en otra corrida, y cada evento debe sobrevivir.
Decisión¶
D1 — id estable y determinista¶
id = f"{prefix}:{sha256(valor).hexdigest()[:16]}", con precedencia de la fuente del valor:
openalex_idpresente →prefix = "oa",valor = openalex_id.- si no,
doipresente →prefix = "doi",valor = doinormalizado (minúsculas, sin prefijohttps://doi.org/nihttp://doi.org/). - si no →
prefix = "tt",valor = f"{title.lower().strip()}|{year}".
Es determinista y estable entre corridas: el mismo paper produce el mismo id siempre, lo
que habilita dedup en merge y en la biblioteca viva.
D2 — corpus_hash order-independent¶
El hash es insensible al orden de filas y de elementos dentro de las columnas list[string]:
se ordenan las filas por id, se ordenan los elementos de cada columna de lista, se serializa con
json.dumps(sort_keys=True) y se aplica sha256. Hashea solo el contenido de la tabla,
nunca campos volátiles del Manifest (created_at, lib_version, etc.). Es la definición
autoritativa de "mismo contenido".
Precisado por ADR 0017 (enmienda 2026-06-15, Hito R2 ✅ 2026-06-16): "contenido" = contenido bibliográfico +
curation_status, pero excluyeprovenance(log de auditoría con timestamps). El texto original de este D2 incluíaprovenanceen el hash, lo que rompía la reproducibilidad bit a bit (dos corridas que aceptaban los mismos ids daban hashes distintos por los timestamps de curación). R2 corrigió: la identidad es del qué (contenido), no del cuándo (procedencia). Elprovenancesigue siendo D4 (log append-only) fuera de la identidad.
D3 — merge idempotente, combinación por campo¶
Dedup por id. Al fundir dos filas con el mismo id:
- Escalares: el no-nulo gana; si ambos no-nulos, gana el de
other. - Columnas de lista: unión deduplicada y ordenada. Si ambos lados son
None, se preservaNone(no se normaliza a[]) para mantener la idempotenciac.merge(c) == c. curation_status: gana la decisión humana más reciente segúnprovenance.decided_at; con fallback de precedenciaaccepted>rejected>candidatecuando no hay decisión humana o empatan los timestamps.provenance: unión de eventos únicos (ver D4).
El orden de filas del resultado es determinista por primera aparición: primero las filas de
self en su orden original, luego las filas nuevas de other (las que no estaban en self) en
el orden en que aparecen en other. merge es idempotente.
AS-BUILT — Cleanup pre-v0.3 (2026-06-16): en
DuckDBBackend, el orden de primera aparición (D3) ya NO se materializa interpolando ids crudos en el SQL. El AS-BUILT construía... WHERE id IN ('<id1>', ...) ORDER BY CASE id WHEN '<id>' THEN <pos> ... ENDcon f-strings sobre los ids (seguro entonces porque los ids son hashes hex, pero frágil —SQL construido con datos—; footgun catalogado en la Nota 06,backends/duckdb.py:417,423). El cleanup lo reemplazó por: leer todas las filas (SELECT * FROM corpus), ordenarlas en Python por el orden de aparición precomputado (existing_ids + new_ids_in_order) y reinsertar. Mismo orden determinista D3 (regresión verde), sin SQL parametrizado por ids. La alternativa de un CTE conVALUES(pasar el orden como tabla de parámetros) quedó descartada: el ordenamiento en Python es más simple para el tamaño de corpus objetivo y no acopla el orden a un dialecto SQL.AS-BUILT — actualizado por ADR 0024 (2026-06-16): tanto el SQL interpolado como el "leer todo / ordenar en Python / reinsertar" descritos arriba reescribían la tabla entera por cada
merge. El ADR 0024 los reemplaza por una columna interna_seq BIGINT(no parte deCORPUS_SCHEMA): el orden de primera aparición se persiste y las lecturas usanSELECT * EXCLUDE (_seq) FROM corpus ORDER BY _seq.mergedeja de reescribir la tabla; D1/D2 quedan intactos.
D4 — provenance como log append-only¶
provenance es una columna string cuyo JSON es una lista de eventos (log append-only), no
un objeto único. Cada evento tiene la forma:
{
"action": "fetched | accepted | rejected",
"equation_id": "string | null",
"chaining_hop": "int | null",
"source": "string | null",
"fetched_at": "ISO8601 | null",
"decided_by": "string | null",
"decided_at": "ISO8601 | null"
}
accept/reject agregan un evento (con action='accepted'/'rejected', decided_by,
decided_at) sin borrar los previos. Esto ajusta el contrato de API.md §1.1, que describía
provenance como objeto único.
Igualdad de Corpus vía corpus_hash¶
Corpus.__eq__ se define como igualdad canónica vía corpus_hash (D2): dos Corpus son
iguales sii tienen el mismo contenido semántico, independientemente del orden de filas y del
orden interno de las columnas de lista, y robusta ante cualquier PYTHONHASHSEED. No compara el
Manifest ni el orden de pa.Table.
Consecuencias¶
- Se cierra la tensión abierta del ADR 0009: la identidad estable de papers entre corridas queda resuelta por D1; la reproducibilidad del snapshot por D2.
mergesobre la biblioteca viva es seguro: idempotente, no pierde decisiones humanas ni procedencia, y produce un orden de filas determinista (auditable / diffeable).- El log de procedencia es auditable: cada fetch y cada decisión humana quedan como eventos inmutables; habilita PRISMA / vom Brocke (reproducibilidad por historia, ADR 0009).
- Costo de contrato:
API.md§1.1 cambia (provenance= lista de eventos) y §1.2 documenta la semántica de__eq__y el orden demerge. Elidportitle+year(tt:) es frágil ante variaciones de título/año; es el último recurso cuando faltanopenalex_idydoi. schema_versionno participa del hash ni de la identidad y, en este hito, no tiene lógica de rechazo por incompatibilidad (verregistro-ia.md, Hito 1, D6); queda para un hito posterior cuando haya migraciones sobre el store vivo.