0020 — Método de forrajeo: scent bibliométrico determinista, backward puro / forward red, y filtros que marcan rejected¶
- Estado: Aceptada · enmendada 2026-06-15 (scent pasa de frecuencia de enlace a estructura
bibliométrica;
explain_candidatey[llm]eliminados — ver "Enmienda" al final) - Fecha: 2026-06-15
- Decidido por: mixto — el scent como frecuencia de enlace y los filtros que marcan
rejected(no borran) son decisiones del Product Owner humano; el resto (backward puro vs forward red,keywords_idpre/post-thesaurus, preview local-only) son decisiones de la IA (Claude) validadas por el PO proxy. - Relacionada con: 0008 (wedge = forrajeo asistido por information
scent), 0011 (thesaurus multilingüe determinista),
0013 (identidad
id/provenanceappend-only), 0018 (mínimo universal vs enriquecimiento; reporte de calidad declarado) - Toca: 0009 (biblioteca viva; historia C4) — precisa que el filtrado PRISMA no destruye la biblioteca.
Contexto¶
El Hito 5 construye la inserción de IA nº1 del ADR 0008 (forrajeo
asistido) más el Preprocessor y los filtros de curación. El ADR 0008 fija el flujo
(ecuación → chaining rankeado por information scent → curación → redes) pero no define qué
es el information scent en concreto, ni cómo se computa cada dirección de chaining, ni qué le
pasa a un paper que un filtro excluye. Tres preguntas quedaban abiertas:
- ¿Qué mide el information scent? Las herramientas de citation chasing (ResearchRabbit, Inciteful, Connected Papers) usan señales diversas: co-citación, acoplamiento bibliográfico, centralidad de red. Cada una tiene un costo y una semántica distinta.
- ¿Cómo se computa el chaining backward y el forward? El schema del
Corpus(API.md §1.1) traereferences_idinline desde OpenAlex (las obras citadas), perocited_by_idqueda vacío tras el seed (diferido al chaining, decisión 4.c del Hito 4): los citantes requieren una llamada extra a la red. - ¿Qué le pasa a un paper excluido por un filtro PRISMA? La biblioteca viva del ADR 0009 es stateful y curada en el tiempo; un filtro que borra filas contradiría tanto la trazabilidad PRISMA (hay que poder reportar cuántos papers se excluyeron en cada paso) como la naturaleza acumulativa de la biblioteca.
Decisión¶
A. Scent = frecuencia de enlace con el corpus (decisión del PO)¶
El information scent de un candidato es la frecuencia de enlace de cita con el corpus existente, en ambas direcciones, no acoplamiento bibliográfico, co-citación ni centralidad de red:
- Backward: scent = nº de papers del corpus que listan al candidato en
references_id. - Forward: scent = nº de papers del corpus a los que el candidato cita (cuántos papers del corpus aparecen en las referencias del citante).
Es una función pura sobre conteos (foraging.scent), sin construir grafo, sin métricas de
red. El ranking es descendente por scent con desempate por id ascendente (estable ante
cualquier PYTHONHASHSEED).
B. Backward puro (local) vs forward red¶
- Backward chaining es puro y local: los candidatos salen de
references_id, que ya está en el corpus tras el seed. No toca la red. Los candidatos backward se materializan como stubs id-only (título placeholder[candidate:{id}],openalex_idpoblado, resto nulo): no contaminan las redes y son curables/enriquecibles después. - Forward chaining requiere red:
cited_by_idestá vacío tras el seed, así que traer los citantes exigeSource.fetch_citing(openalex_id)(GET works?filter=cites:). ElForagerexige ese método alsourcey falla ruidoso si no lo tiene; no se amplió el ProtocolSource(ADR 0018) —fetch_citinges capacidad deOpenAlexSource, no contrato universal. - El
previewopera sin red (control de crecimiento del ADR 0008): backward se estima exacto localmente; forward no es estimable sin fetch, así que el preview reportaforward_requires_fetch=Trueyby_direction["forward"]=0— el conteo real solo llega conchain(direction="forward"/"both").
depth=1 es lo único implementado; depth>1 lanza NotImplementedError (futuro v0.3+).
C. Filtros PRISMA marcan rejected, NO borran (decisión del PO)¶
Los filtros de inclusión/exclusión (año / tipo / idioma / mínimo de citas) son funciones
puras que marcan los papers excluidos con curation_status='rejected' vía corpus.reject(...)
(con provenance del filtro como decided_by), nunca borran filas. Cada filtro registra el
conteo PRISMA (count_before/count_after, contando los papers no-rejected) en un
FilterStep, y apply_filters sella Manifest.filters (reemplaza: una corrida = una
secuencia de filtros).
Esto toca el ADR 0009 (biblioteca viva, historia C4): un
paper rechazado sigue en la biblioteca, auditable, y puede re-aceptarse — el filtrado es una
decisión de curación registrada, no una pérdida de datos. Es coherente con el provenance
append-only (ADR 0013, D4).
D. keywords_id se sobrescribe desde keywords_raw (precisión del schema)¶
Preprocessor.apply_thesaurus lee keywords_raw y sobrescribe keywords_id con los
conceptos canónicos del thesaurus (en/es/pt; idempotente; ADR
0011). Esto precisa la nota del schema (API.md §1.1) que ya
decía que keywords_id son los "canónicos (post-thesaurus)": antes de aplicar el thesaurus,
keywords_id no es autoritativa; el thesaurus es lo que la puebla. normalize es conservador
(lowercase/trim/acentos en authors_id, language a ISO 639-1 primario); sin fuzzy (eso es
[dedup], Hito 7) y sin columna de periodización.
Consecuencias¶
- Costo cero / determinismo total en el scent: la frecuencia de enlace es un conteo puro, reproducible y barato (no construye grafo ni corre algoritmos de centralidad). El trade-off es que ignora estructura más rica (un candidato muy central pero poco co-citado con el corpus rankea bajo); se aceptó a favor de la simplicidad y la consciencia (el investigador entiende por qué un candidato sube: "lo citan N papers de mi corpus").
- Backward es gratis y reproducible (sin red, estimable en
preview); forward cuesta una llamada por paper y solo se cuantifica al ejecutarlo. Elpreviewhonesto (forward_requires_fetch) evita prometer una estimación que no se puede dar sin red. fetch_citingqueda como capacidad deOpenAlexSource, no del ProtocolSource: una fuente que solo da el mínimo universal (ADR 0018) no habilita forward chaining, y elForagerlo dice claro en vez de fallar oscuro. Trade-off: el forward chaining no es source-agnóstico.- La biblioteca nunca pierde un paper por filtrar: trazabilidad PRISMA completa y curación
reversible (coherente con ADR 0009/0013). Costo: las vistas y conteos deben distinguir
rejectedde ausente; elcorpus_hashincluye los rechazados (son contenido, no ruido). Manifest.filtersreemplaza, no acumula: una corrida deapply_filtersdescribe la secuencia PRISMA de esa corrida. Re-filtrar sella una secuencia nueva. (Gap conocido: si se quisiera un historial de todas las corridas de filtro, hoy no se guarda — elprovenancepor paper sí registra cada rechazo.)keywords_idpre-thesaurus no es autoritativa: los proyectores de co-ocurrencia de keywords (API.md §7) deben correr después deapply_thesauruspara leer canónicos; antes,keywords_idpuede estar vacía o cruda.explain_candidate(B4) queda como stub gateado en[llm]: la firma existe y el error es accionable, pero la integración LLM es v0.2. El forrajeo y el ranking funcionan sin él. (Superado por la enmienda de abajo:explain_candidatey[llm]se eliminan.)
Enmienda — 2026-06-15 (scent bibliométrico determinista; sin LLM)¶
Motivada por el red-team del AS-BUILT v0.2 (Nota 06, RAÍZ 1) y la decisión del PO de que el producto no usa IA generativa (ADR 0022). El cuerpo del ADR (arriba) queda como historia; esta enmienda revierte la decisión A (scent = frecuencia de enlace) y elimina el stub LLM.
- El scent pasa de "frecuencia de enlace" a estructura bibliométrica. El information scent
usa los PROYECTORES —acoplamiento / co-citación / centralidad del candidato respecto del
corpus curado— en vez del conteo aritmético de citas directas. Es lo que la
Nota 05 §4 siempre prometió ("la bibliometría ES el
information scent… mapea a los proyectores"). Sigue siendo una función pura y determinista
(mismo corpus → mismo ranking, mismo desempate por
id), sin LLM ni embeddings. El forrajeo (costura) depende del núcleo de proyección (puro); el núcleo nunca de la costura. - Se elimina
explain_candidate, el móduloforaging/explain.pyy el extra[llm](ADR 0022). El "porqué" de un candidato lo da la estructura visible (con qué del corpus se acopla/co-cita), no un modelo generativo. La historia B4 del PRD se reescribe a "explicación estructural, no de IA". - Lo que NO cambia: backward puro / forward red (decisión B), filtros que marcan
rejected(decisión C),apply_thesaurussobrescribekeywords_id(decisión D),depth=1, desempate porid. El trade-off de sesgo de selección aguas arriba del propio scent (efecto Mateo) se reconoce explícitamente: el scent prioriza, no garantiza exhaustividad — eso lo sostienen los filtros PRISMA.
Recomendación para el coder: ver ROADMAP Hito R4 (foraging/scent.py consume proyectores;
borrar foraging/explain.py; quitar [llm] de pyproject.toml).
AS-BUILT — 2026-06-16 (Hito R4: scent-vía-proyectores construido y verificado; resolución de método del arquitecto)¶
El AS-BUILT de R4 reescribió
foraging/scent.pypara consumir el primitivo públicocollect_item_to_papersdenetworks/projectors.py(el forrajeo depende del núcleo de proyección, nunca al revés), y eliminóforaging/explain.py/explain_candidatey el extra[llm](ADR 0022). 291 tests verdes, mypy strict / ruff limpios. El arquitecto (2026-06-16) resolvió tres cuestiones de método bibliométrico que el verifier dejó abiertas; las fórmulas as-built son:
Backward = fuerza de co-citación con el corpus (ratificado)¶
backward_score(X) = |{Pi ∈ corpus : X ∈ Pi.references_id}|
Numéricamente coincide con el viejo conteo, pero su semántica es estructural, no aritmética: un
candidato referenciado por N corpus-papers está co-citado N veces dentro del corpus (es la
columna de X en la matriz de co-citación que el CoCitationProjector proyecta, restringida al
corpus). Cumple el DoD ("no por conteo plano"): mide una propiedad de red (co-citación), se computa
vía el primitivo de los proyectores y es la señal de olfato correcta para el backward chaining —
cuán compartido es X como antecedente del corpus curado. Se renombra el concepto en
docstrings/docs de "frecuencia de enlace" a "fuerza de co-citación con el corpus".
Forward = fuerza de citación directa al corpus (señal primaria) + acoplamiento (secundaria)¶
El forward chaining busca citantes del corpus (fetch_citing). La señal de olfato natural y
robusta para esa dirección es cuán embebido está el citante en mi corpus = a cuántos corpus-papers
cita directamente:
forward_score(Y) = |{Pi ∈ corpus : Pi ∈ Y.references_id}| (señal primaria)
Esto es siempre > 0 para un citante real (por construcción Y cita al menos un corpus-paper, que
es lo que lo hizo aparecer en fetch_citing), y rankea por fuerza de citación directa, que es la
métrica correcta para "snowballing forward" (Wohlin). El acoplamiento bibliográfico (refs
compartidas Y ↔ corpus) queda como señal secundaria/desempate, no como medida primaria.
Estado del AS-BUILT vs esta resolución (IMPLEMENTADO en R4): el primer código entregado por R4 implementó el forward como acoplamiento puro (
|{Pi : Pi.references_id ∩ Y.references_id ≠ ∅}|), que degeneraba a 0 cuando el corpus tienereferences_idralas (estado común trasseed) y, peor, descartaba el citante directo como candidato si su acoplamiento era 0. El arquitecto resolvió revertir el forward a citación directa como medida primaria; el fix (ver "Recomendación de código" abajo) se implementó dentro de R4:compute_forward_scentcalculaforward_score(Y) = |{ref ∈ Y.references_id : ref ∈ corpus_ids}|(citación directa) y emite siempre quedirect > 0. 293 tests verdes, mypy/ruff OK. El forward ya no está incompleto para corpus de referencias ralas; R4 queda cerrado del todo.
Centralidad = diferida (mejora futura)¶
El DoD listaba "acoplamiento / co-citación / centralidad" con un "y/o": pedía señal estructural de red, no las tres a la vez. Con backward = co-citación y forward = citación-directa (+ acoplamiento secundario) el espíritu del DoD se cumple (el scent es estructura bibliométrica, no conteo plano). La centralidad de red del candidato (p. ej. su grado/intermediación en la proyección del corpus) queda como mejora futura (un hito de viz/redes): requiere construir el grafo completo por candidato, un costo que excede el olfato barato y determinista que R4 entrega. Se reconcilia el DoD honestamente: centralidad no es requisito de cierre de R4.
Lo que NO cambia¶
Backward puro / forward red (decisión B), filtros rejected (C), apply_thesaurus (D), depth=1,
desempate por id. El sesgo de selección (efecto Mateo) sigue reconocido: el scent prioriza; la
exhaustividad la sostienen los filtros PRISMA.
Recomendación de código (forward — IMPLEMENTADA en R4)¶
Archivo/símbolo: src/bib2graph/foraging/scent.py:compute_forward_scent.
Estado: ✅ implementado dentro de R4 (2026-06-16). compute_forward_scent calcula
forward_score(Y) = |{ref ∈ Y.references_id : ref ∈ corpus_ids}| y emite siempre que direct > 0.
293 tests verdes, mypy strict / ruff limpios. La descripción del fix queda como rastro de la decisión.
Problema (as-built inicial): la primera fórmula medía acoplamiento puro Y ↔ corpus y solo emitía
candidatos con acoplamiento > 0 (if coupled_corpus_papers:), descartando citantes directos legítimos y
degenerando a 0 con corpus de referencias ralas.
Fix aplicado — la señal primaria es citación directa al corpus:
- Construir
corpus_ids_present = {Col.ID y Col.OPENALEX_ID de cada corpus-paper}(ya se construye para excluir duplicados; reusarlo). - Para cada citante
Yenciting_rows(no presente en el corpus):direct = |{ref ∈ Y.references_id : ref ∈ corpus_ids_present}|(cuántos corpus-papers citaYdirectamente). Esta es la medida primaria del score. - Emitir siempre el candidato cuando
direct > 0(lo será para todo citante real traído porfetch_citing). No descartar por acoplamiento nulo. - (Opcional, secundario) mantener el acoplamiento como término de desempate fino: p. ej.
score = direct + ε · couplingconε < 1(o devolver ambos y desempatar enrank_candidates). Si se prefiere simplicidad, basta conscore = direct(acoplamiento queda como mejora futura junto a centralidad).
Corolario en forager.py: _fetch_forward (:323-327) indexa candidate_rows solo para ids con
score > 0; con el fix, todo citante directo tendrá score > 0 y entrará al ranking — el citante real deja
de perderse. No requiere cambio adicional si el score primario es direct.
Test sugerido (TDD): corpus con references_id que no se solapan entre sí, y un citante Y que
cita a 2 corpus-papers pero no comparte ninguna referencia con ellos → con la fórmula as-built
score(Y)=0 (se pierde); con el fix score(Y)=2 (rankea por citación directa). Regresión que ancla la
semántica.
Decidido por: steering arquitectónico (2026-06-16) — backward=co-citación ratificado, forward=citación-directa (revierte el acoplamiento puro del AS-BUILT), centralidad diferida. Reconciliado con el DoD del ROADMAP Hito R4.
AS-BUILT — 2026-06-16 (#21: forward chaining batcheado + cap por semilla, scope is_seed)¶
El cuerpo del ADR (decisión B) describía el forward chaining sobre
Source.fetch_citing(openalex_id)singular —una request por paper (N+1)— y no fijaba un tope de citantes por semilla ni el scope exacto sobre el que opera. Esta nota anexa el AS-BUILT verificado (gate verde, 422 tests); no reescribe el cuerpo histórico.
- Forward ya no hace N+1: reusa
OpenAlexSource.fetch_citing_batch. ElForager.chain(direction="forward"/"both")agrupa loscites:en queriescites:W1|W2|...(lotes ≤50) con presupuesto por semilla (sin starvation) + retry/backoff, en vez de una request por fila. Es el mismo primitivo que el Enricher usa en 8b (ADR 0025); el batching-por-OR diferido en R5 queda así también cubierto para el Forager.fetch_citingsingular sigue existiendo, pero el forward lo consume vía la variante batcheada. - Cap por semilla
max_citing_per_paper(default 50). Nuevo parámetro deForager.__init__, expuesto en el CLI como--max-citingenb2g chain. Acota el fetch (no solo la columna) por semilla. - Scope =
is_seed=True(todas las semillas sembradas, SIN filtrarcuration_status). El chaining corre antes de la curación (cicloSEEDED→FORAGED→…→ curación transversal; las semillas nacencandidate, Nota 09). La restricción a semillasacceptedNO es del Forager — es delEnricher(Hito 8b, post-curación): el Forager expande la frontera sobreis_seed, el Enricher poblacited_by_idsobreaccepted. Documentar el Forager forrajeando soloacceptedfue el drift que causó el bug que este AS-BUILT cierra. previewdel forward sin red estima el nº de semillas a forrajear (is_seed) sin requests, manteniendoforward_requires_fetch=True.b2g monitorusa este mismo forward batcheado.
Lo que NO cambia: backward = co-citación con el corpus (puro/local), filtros rejected (C),
apply_thesaurus (D), depth=1, desempate por id, y la frontera "solo el Forager toca la red".
Decidido por: AS-BUILT verificado del #21 (2026-06-16, rama
feat/forager-cap-batching); gate verde, 422 tests. VerAPI.md§5 y §2 (fetch_citing_batch).
AS-BUILT — 2026-06-17 (#54: el backward deja de persistir placeholders; revierte el "no contaminan" de la decisión B)¶
El cuerpo del ADR (decisión B) afirmaba que los candidatos backward se materializan como stubs id-only en el corpus (título placeholder
[candidate:{id}], resto nulo) y que "no contaminan las redes". Esa afirmación era FALSA y este AS-BUILT la corrige; no reescribe el cuerpo histórico (queda como rastro del error). Gate verde, 636 tests; verifier PASA. Ramafix/backward-chaining-no-placeholders.
El "no contaminan" era falso — los stubs SÍ contaminaron¶
Las sesiones de QA con datos reales lo destaparon (Nota 09,
Nota 13, "el bug de fondo del Forager"): los stubs
[candidate:W...] sí contaminaron. Llegaron a ser ~la mitad del corpus (filas-fantasma sin
metadata real) y, peor, entraron al corpus_hash (eran filas del corpus, no ruido externo) —
rompiendo la reproducibilidad y las redes legibles. La promesa de "curables/enriquecibles después"
nunca tuvo un materializador que la sostuviera (la deuda quedó abierta como #71).
Opción B implementada — observar sin materializar¶
El backward chaining ya NO crea filas-fantasma en el corpus. Los IDs observados (los candidatos
que el ranking backward detecta) se exponen por RankedCandidates.observed_refs: list[str] (campo
nuevo, en orden de ranking, respetando max_candidates) y se persisten en una tabla append-only
hermana, referenced_but_not_fetched (par de loop_state_log, fuera de la tabla corpus). El
ranking backward sobrevive intacto en RankedCandidates.ranking; se eliminó _build_backward_candidate_row.
- Protocol
TabularBackend(backends/base.py): tres métodos nuevos —add_referenced_refs(ref_ids, *, cycle_round)(idempotente por existencia deref_id),referenced_refs_count(),referenced_refs()—, implementados porDuckDBBackend(DDL + migración liviana + copia en snapshot/_clone;observed_atvía elnow()del backend) eInMemoryBackend. b2g chainpersiste losobserved_refsen la tabla (concycle_round) y sumaobserved_refs_countal envelope JSON.b2g statussuma el campo aditivoreferenced_not_fetched(schema="1"intacto).
corpus_hash y materialización on-demand¶
El corpus_hash NO incluye los observados (viven en la tabla hermana, no en corpus): esto
arregla la contaminación previa del hash (antes los fantasmas SÍ entraban). El materializador
on-demand —rehidratar un observado a fila real del corpus vía fetch_works_by_ids (#55) cuando el
humano lo pida— está diferido a #71 (NO construido en #54).
Coherencia con otros ADR¶
- NO toca ADR 0013: el schema del
corpusqueda intacto;referenced_but_not_fetchedes una tabla aparte, no una columna nueva. - Coherente con ADR 0017: los IDs observados son
estado (qué vio el forrajeo), no contenido bibliográfico → fuera del
corpus_hash, igual que la procedencia y los timestamps. La identidad sigue siendo solo el contenido curado.
Seguimiento — el forward tiene el MISMO footgun (#78, NO arreglado en #54)¶
El verifier confirmó que el forward chaining arrastra el mismo problema:
_build_forward_candidate_row materializa filas con título placeholder [candidate:W...] (IDs de
citantes sin metadata completa). #54 solo arregló el backward; el forward queda abierto como
issue #78. Honestidad de estado: el forward NO está limpio — sigue poniendo placeholders de
título en el corpus.
Lo que NO cambia: backward = co-citación con el corpus (puro/local), forward = citación directa,
filtros rejected (C), apply_thesaurus (D), depth=1, desempate por id, "solo el Forager toca la
red".
Decidido por: AS-BUILT verificado del #54 (2026-06-17, rama
fix/backward-chaining-no-placeholders); gate verde, 636 tests, verifier PASA. VerAPI.md§5 (observed_refs) y §4 (referenced_but_not_fetched).
AS-BUILT — 2026-06-17 (#78: el forward materializa metadata REAL; cierra el footgun de placeholder forward)¶
Seguimiento directo del AS-BUILT #54 (que reconoció el footgun forward como abierto). El cuerpo del ADR (decisión B) describía el forward como dirección que materializa filas en el corpus, y el AS-BUILT inicial las creaba con título placeholder
[candidate:W...](IDs de citantes sin metadata). Esa materialización era defectuosa y este AS-BUILT la corrige; no reescribe el cuerpo histórico (queda como rastro). Gate verde, 645 tests; verifier PASA. Ramafix/forward-chaining-materialize. No reabre la decisión de método de forrajeo (backward puro/forward red, scent, filtros): solo corrige cómo el forward materializa la metadata — por eso es enmienda al 0020, su dominio, y no un ADR nuevo.
El forward también arrastraba el footgun — causa raíz: la metadata se traía y se descartaba¶
El verifier de #54 confirmó que el forward repetía el problema del backward: _build_forward_candidate_row
materializaba filas con título placeholder [candidate:W...]. La causa raíz, sin embargo, no era
falta de datos: fetch_citing_batch ya pedía _FIELDS (metadata completa) a OpenAlex y luego
los descartaba, devolviendo solo el mapeo de atribución dict[str, list[str]]. La metadata real
(título/año/autores) ya viajaba en la misma request de red y se tiraba.
Opción A1 implementada — conservar la metadata que ya viaja (cero red extra)¶
El forward ya NO persiste placeholders; materializa filas reales (título/año/autores) sin una sola request adicional:
- Se extrajo
_fetch_citing_pages(privado, comparte paginación/atribución/presupuesto-por-semilla) que devuelve(attribution, works_map). fetch_citing_batch(ids, *, max_per_paper) -> dict[str, list[str]]queda como thin wrapper con su firma y contrato INTACTOS (descartaworks_map) → elOpenAlexEnricher(8b, §3 de API.md) no se rompe.- Método público nuevo
OpenAlexSource.fetch_citing_batch_with_works(ids, *, max_per_paper) -> (attribution, works_map), que el_fetch_forwarddel Forager consume. _work_to_rowganó paramschaining_hop: int | None = Noneysource_tag: str = "openalex"(defaults backward-compat; los callers viejos —seed/fetch_citing/fetch_works_by_ids— no cambian). El forward llama_work_to_row(work, is_seed=False, chaining_hop=1, source_tag="chaining:forward")._build_forward_candidate_rowELIMINADO.
Garantía verificada: con OpenAlexSource real, todo citante atribuido está en works_map →
nunca cae al fallback-placeholder (ese fallback solo aplica a sources sintéticos de test).
La regla común y la asimetría forward/backward (no es incoherencia)¶
La regla común que mata el footgun en ambas direcciones: el corpus nunca contiene placeholders
[candidate:W...]. Las direcciones la cumplen de forma distinta, y esa asimetría es deliberada,
no un descuido:
- Backward observa sin materializar (#54): las referencias son numerosas, no se curan
activamente y su metadata no viaja en la request local del seed (solo IDs en
references_id). Materializarlas inflaría el corpus con miles de filas-fantasma → se registran en la tabla hermanareferenced_but_not_fetchedy se rehidratan on-demand (#71). - Forward materializa (#78): los citantes son pocos (acotados por
max_citing_per_paper), se curan (el humano los acepta/rechaza), y su metadata ya viene en la misma request de red defetch_citing_batch_with_works. Materializarlos con datos reales es lo correcto y es gratis.
Por eso #71 (materializador on-demand) queda, con #78 en A, solo para backward: el forward ya no necesita rehidratar nada.
Lo que NO cambia: backward = co-citación con el corpus (puro/local), forward = citación directa,
filtros rejected (C), apply_thesaurus (D), depth=1, desempate por id, "solo el Forager toca la
red", y el contrato de fetch_citing_batch (intacto).
Decidido por: AS-BUILT verificado del #78 (2026-06-17, rama
fix/forward-chaining-materialize); gate verde, 645 tests, verifier PASA. Seguimiento de #54/#55. VerAPI.md§2 (fetch_citing_batch_with_works) y §5 (forward materializa metadata real).