0025 — Enricher opt-in sobre OpenAlex (núcleo): refs→DOI + co-citación¶
- Estado: Aceptada · AS-BUILT COMPLETO (2026-06-16) (Ciclos 8a + 8b implementados → Hito 8 completo)
- Fecha: 2026-06-16
- Relacionada con: 0007 (OpenAlex backbone; el Enricher deja de ser
estructural y S2 se demota), 0004 (principio de enriquecimiento
opt-in, nunca obligatorio, keys inyectadas), 0014
(semántica del
CoCitationProjector), 0021 (set de subcomandos del CLI),metodología.md(co-citación = citantes compartidos)
Contexto¶
El Hito 8 (ROADMAP/04) materializa la costura Enricher opt-in
para resolver references_id→references_doi y habilitar la co-citación end-to-end. El DoD
original del hito lo encuadraba sobre el extra [s2] (Semantic Scholar), residuo del diseño
pre-giro previo al ADR 0007: cuando la entrada era BibTeX, el único
camino a las listas de referencias era un enricher S2 estructural. Tras el ADR 0007, OpenAlex ya
trae referencias y citantes, así que ese encuadre [s2] quedó desalineado con la arquitectura real.
El hito es grande para un solo ciclo, por lo que se parte en dos:
- 8a — costura
Enricher+ resolución refs→DOI + subcomandob2g enrich. - 8b — poblar
cited_by_id(2º nivel) y co-citación end-to-end, solo sobre semillas aceptadas y con un tope configurable.
Decisión¶
El Enricher vive en el núcleo, sobre OpenAlex, no en un extra. Es la costura opt-in del ADR
0004/0007, ahora as-built.
- Contrato
Enricher(enrichers/base.py):Protocol@runtime_checkableconenrich(corpus: Corpus) -> Corpus. Idempotente (re-correr no duplica trabajo ni efectos), config/keys inyectadas (nunca embebidas), no pierde papers ante rate limit/reintentos, sin ramas muertas. El núcleo no importaduckdb; no hay red al importar; sin red en CI (MockTransport). [s2]queda superado para el Enricher estructural (supersede el DoD pre-giro del Hito 8): el enricher de referencia es OpenAlex en el núcleo (decisión B del PO). El nombre[s2]se reserva para un futuroSemanticScholarEnricherde señal adicional (no estructural), no como camino a co-citación.enriches subcomando CLI propio (decisión C del PO):cli/commands/enrich.py,run_enrich(store_path, *, email, api_key, transport). NO transiciona elCycleState(es ortogonal al lazo del FSM, ADR 0016/0021).buildsigue puro/sin red.
Ciclo 8a (HECHO, as-built)¶
OpenAlexEnricher(enrichers/openalex.py) reúne losreferences_idúnicos del corpus, los resuelve a DOI batcheando por OR (lotes ≤ 100, filtroopenalex_id:W1|W2|...,select=id,doi) y rellenareferences_doialineado por lookup.- Registra un
EnricherRef(name="openalex_references_doi", params=...)en elManifest; idempotente = reemplaza por nombre, no duplica. - Métodos nuevos en
sources/openalex.py:fetch_dois_for(ids) -> dicty_fetch_batch_select.
Ciclo 8b (HECHO, as-built)¶
OpenAlexEnricher.enrich(corpus)hace ahora 2 pasadas: refs→DOI (8a) + co-citación (8b). La pasada de co-citación toma las semillas aceptadas (is_seed=True AND curation_status=accepted), trae sus citantes y mergea losopenalex_idde esos citantes encited_by_id(unión, idempotente). Constructor conmax_citing_per_paper(tope por semilla).- (Decisión A del PO, respetada) el 2º nivel solo puebla
cited_by_id; los citantes NO se materializan como filas del corpus (no crece el corpus). Crecer el corpus con citantes es trabajo delForager+ curación (chaining forward + accept/reject), no del Enricher. - Contrato
OpenAlexSource.fetch_citing_batch(ids, *, max_per_paper) -> dict[seed_id, list[citer_id]]: batcheo por OR (cites:W1|W2|..., lotes ≤50), pagina por cursor y atribuye página a página (cruzareferenced_worksdel citante con el set objetivo, por short-id), con presupuesto por semilla: corta la paginación cuando todas las semillas del lote alcanzaronmax_per_paper.max_citing_per_paperacota el fetch por semilla (decisión del PO: el tope acota el fetch, no solo la columna), sin starvation entre semillas del mismo lote, y mata el N+1 diferido de R5.fetch_citingsingular (Forager) no cambió. - Frontera de responsabilidades: el
Sourcehace I/O + atribución + acotamiento (devuelve ya el mapaseed → citantesacotado); elEnrichersolo une ese mapa encited_by_id. Networks.quick(facade.py) incluye la red de co-citación cuando el corpus tienecited_by_idpoblado (→ 5 redes) y la omite graceful (log) si está vacío (→ 4 redes). ElCoCitationProjectorno se modificó (decisión F).- CLI
b2g enrichexpone--max-citing INTEGER.
Co-citación: el CoCitationProjector no cambia (decisión F del PO)¶
El CoCitationProjector (ADR 0014) no se toca:
cuenta cited_by_id compartido = los citantes compartidos de la
metodología.md. La frase de los docs "citantes con sus citas" se reconcilia
documentando que el 2º nivel se materializa como cited_by_id (8b): "citantes con sus citas" ≡
cited_by_id compartido, que es lo que el projector ya consume.
Consecuencias¶
- (+) La co-citación deja de depender de un enricher estructural externo (ADR 0007 cerrado a nivel de código): el camino es OpenAlex en el núcleo, opt-in.
- (+)
enrichortogonal al lazo: se puede enriquecer en cualquier estado sin perturbar el FSM;buildpermanece puro y reproducible. - (+) Batching por OR mata el N+1 de requests del 2º nivel (mejora de performance; el retry/backoff de R5 se conserva).
- (−) El DoD del Hito 8 quedaba encuadrado en
[s2]: este ADR lo supersede; el[s2]pasa a reserva para señal adicional futura. Los docs (ROADMAP/04, ARCHITECTURE §4.2, API §3) se sincronizan. - (+) La co-citación es end-to-end (8b hecho):
b2g enrichpueblacited_by_iddesde las semillas aceptadas yNetworks.quickdevuelve 5 redes cuando haycited_by_id; si no se corrióenrich(columna vacía), omite la co-citación graceful (avisa por log) → 4 redes.
AS-BUILT COMPLETO (2026-06-16): 8a + 8b implementados → Hito 8 completo.
b2g enrich(refs→DOI + co-citación, flag--max-citing),OpenAlexEnricherde 2 pasadas,OpenAlexSource.fetch_citing_batch(batcheo OR ≤50 con presupuesto por semilla),Networks.quick→ 4/5 redes segúncited_by_id. 13 subcomandos (enrichincluido). 365 tests verdes (mypy/ruff limpios).
Nota append-only — superficie 0.10.0:
enrichdeja de ser verbo ybuildya no es estrictamente "puro/sin red" (2026-06-28, #162, ADR 0038). Esta nota no revierte la decisión de 0025 (la capacidad del Enricher sigue viva, núcleo, opt-in); solo registra dónde vive ahora y un invariante que cambia:
enrichdeja de ser subcomando propio (la decisión C de arriba — "enriches subcomando CLI propio" — queda superada como superficie, no como capacidad). El verboenrichpasa a alias deprecado (aviso a stderr, se elimina en 0.11.0) que delega en la misma lógica. La pasada refs→DOI (8a) corre automática enchain(forrajeo); la pasada co-citación /cited_by(8b) corre automática enbuildcuando hay semillas aceptadas. La implementación se unifica en el helpercli/_enrich.py::enrich_corpus(corpus, source, *, max_citing, pass_name)(pasadas"refs_doi"/"cited_by"/"both"), fuente única compartida porchain/build/el aliasenrich. Ambos comandos suman un bloque aditivodata["enrichment"]al envelope--json(refs_resolved/refs_total_uniquey/ociting_new/citing_targets, solo las claves de las pasadas ejecutadas).buildsuma además--max-citing.buildya NO es estrictamente "puro/sin red". La frase de la decisión ("buildsigue puro/sin red") deja de ser cierta:buildhace requestscited_bya OpenAlex cuando hay semillas aceptadas. Por qué (decisión del PO): la co-citación necesita las semillas aceptadas, que recién están disponibles en build-time (después de curar); correrla enbuildes lo que hace que el camino one-shot del ADR 0037 produzca la red de co-citación sin un verbo aparte. Sin semillas aceptadas,buildes no-op de red (cero requests) y mantiene su pureza de proyección (los proyectores siguen puros, ADR 0014). La pasada solo pueblacited_by_id; no crece el corpus (decisión A de arriba, intacta).