0015 — Corpus sobre TabularBackend; DuckDB backend por defecto¶
- Estado: Aceptada
- Fecha: 2026-06-15
- Relacionada con: 0002 (núcleo agnóstico de backend), 0006 (tabla Arrow + Pydantic), 0009 (biblioteca viva en DuckDB), 0013 (identidad/hash/merge)
- Enmienda: 0006 (el
Corpusdeja de ser un objeto-valor sobrepa.Tablecruda) y reencuadra 0009 (DuckDB pasa a ser el backend por defecto delCorpus, sigue siendo costura, ya no un "Storeopcional aparte").
Contexto¶
Terminados los Hitos 0–2, el Corpus del Hito 1 (src/bib2graph/corpus.py) tiene semántica
de valor pura: accept/reject/merge/add_paper hacen table.to_pylist() → mutar en
Python → reconstruir la tabla Arrow entera. Eso fue correcto para el núcleo testeable del
Hito 1, pero no escala: cada decisión de curación reconstruye toda la tabla en memoria, y la
biblioteca viva (ADR 0009) necesita persistir y mutar entre corridas sobre colecciones que
crecen con el tiempo. Antes de construir el Hito 3 (DuckDBStore) hay que resolver dónde
viven las mutaciones.
El diseño previo separaba dos cosas que en realidad son una: el Corpus (objeto-valor Arrow,
ADR 0006) y un Store DuckDB aparte que lo persiste (ADR 0009 §Consecuencias, ROADMAP Hito
3). Eso obliga a dos modelos de mutación (en memoria por valor, en DuckDB por SQL) y a un
trasiego constante tabla↔store. El momento de corregirlo es ahora (barato: Hito 2 recién
terminado, Hito 3 sin construir).
La tensión de fondo: queremos escala y persistencia (DuckDB, SQL UPDATE/MERGE por id
en vez de copia en memoria) sin romper el principio de núcleo puro agnóstico de backend
(ADR 0002) ni la lección de v0 (que el núcleo no dependa de un servidor para existir ni para
testearse).
Decisión¶
El Corpus se respalda en un TabularBackend (Protocol), no en una pa.Table cruda. El
núcleo NO importa duckdb directamente: depende del Protocol, recibe la implementación
inyectada.
Dos implementaciones de referencia:
InMemoryBackend— puro, sin I/O. Es el working set efímero y el backend de los tests (el núcleo se testea sin DuckDB instalado). Hereda la lógica actual decorpus.py(mutación en Python sobre listas de dicts).DuckDBBackend— la biblioteca viva (ADR 0009). Archivo.duckdbo:memory:. Mutaciones por SQLUPDATE/MERGEporid, no por copia en memoria. Es el backend por defecto delCorpuscuando hay persistencia.
Las mutaciones se delegan al backend: Corpus.accept/reject/merge/add_paper ya no
reconstruyen la tabla entera en Python; piden la operación al TabularBackend, que la cumple a
su manera (InMemory en Python; DuckDB en SQL). Las reglas de identidad/hash/merge del
ADR 0013 (D1/D2/D3) se mantienen como contrato: cada
backend debe cumplirlas (InMemory replicando la lógica Python; DuckDB expresándolas en SQL).
Los proyectores y analizadores siguen siendo funciones puras sobre pa.Table (ADR 0006,
0014): consumen corpus.to_arrow(). Solo
cambia el contenedor del Corpus, no el núcleo de análisis: to_arrow() es el puente
estable entre la biblioteca viva (cualquier backend) y el núcleo puro de proyección.
Consecuencias¶
- Escala sin servidores. DuckDB embebido muta por SQL por
id; las decisiones de curación dejan de copiar toda la tabla. La biblioteca viva crece sin penalización cuadrática. - Núcleo puro preservado (ADR 0002): el núcleo depende del Protocol
TabularBackend, no deduckdb. Los tests usanInMemoryBackendy no necesitan DuckDB — la lección de v0 (núcleo testeable sin I/O) sigue en pie. - El
Storedeja de ser una costura aparte. DuckDB pasa de "Storeque persiste un Corpus Arrow" (ADR 0009) a "backend por defecto del Corpus". La costura sigue existiendo (es el punto de extensión:ZoteroStore/Neo4jStoresiguen siendo destinos opt-in), pero el respaldo primario de la biblioteca viva es elDuckDBBackend. - El contrato del ADR 0013 sube de "implementación del Corpus" a "contrato del backend". Cada
backend debe garantizar
idestable (D1),corpus_hashorder-independent (D2) y las reglas demerge(D3). Elcorpus_hashse computa siempre sobre el contenido (víato_arrow()), nunca sobre detalles del backend. - Costo (rework del Hito 1). Hay que migrar
src/bib2graph/corpus.py. Detalle accionable para elcoderen el milestone "Hito 1.5 — Rework deCorpus" del ROADMAP; resumen: - Extraer un
TabularBackend(Protocol) ensrc/bib2graph/backends/(o módulo nuevo). Operaciones mínimas:add_paper,merge,set_curation/apply_curation,to_arrow,filter_view(seeds/candidates/accepted),corpus_hash,__len__,__eq__por hash. InMemoryBackend: mover ahí la lógica actual decorpus.py(los helpers_merge_rows,_merge_curation_status,_merge_list_field,_compute_corpus_hash,_apply_curation,_rows_to_table). ElCorpusdeja de operarself._tabledirectamente y delega enself._backend.DuckDBBackend: las mismas operaciones en SQL (UPDATE/MERGEporid); el merge campo a campo y la resolución decuration_status(D3) se expresan en SQL/UDF respetando el ADR 0013. Cae en el Hito 3 (la costura de red/persistencia), no en el rework de núcleo.Corpus.from_arrow/add_paper/merge/accept/reject/materialize/snapshot(encorpus.py) pasan a delegar en el backend;snapshot()sigue sellando el hash D2 víato_arrow().- Preservar
_compute_id(D1), la semántica de__eq__porcorpus_hash(D2) y el orden determinista demerge(D3) como contrato verificado por tests del backend. - Tests: los tests del Hito 1 (
tests/unit/) corren contraInMemoryBackendpor defecto; agregar un set de tests de contrato de backend (mismos invariantes D1/D2/D3) parametrizado para queDuckDBBackendlo cumpla cuando llegue en el Hito 3. - Riesgo: dos implementaciones del mismo contrato pueden divergir. Se mitiga con el set de tests de contrato compartido (mismos casos, ambos backends).