0006 — Tabla canónica (Arrow) + NetworkSpec declarativa + snapshot inmutable¶
- Estado: Aceptada
- Fecha: 2026-06-14
- Relacionada con: 0001, 0002, 0003, 0004, 0005
- Deroga parcialmente: nada (los ADRs 1–5 son de producto y siguen vigentes).
- Sustituye: el modelo de datos implícito en
ARCHITECTURE.md§3 (Corpus = 4 dicts + dataclasses) y la API deCorpusenAPI.md§1. - Actualización (2026-06-15): la sección "Snapshot inmutable, sin in-memory store" queda
supersedida por 0009 (biblioteca viva stateful en DuckDB;
el snapshot pasa a ser un export derivable del estado vivo). El resto de este ADR (tabla
canónica Arrow + Pydantic,
NetworkSpec, versionado/tooling) sigue vigente. - Enmienda AS-BUILT (2026-06-17, Hito 9 — capa declarativa NetworkSpec): la
NetworkSpecde este ADR ganó su carga declarativa desde YAML. Construido: el loaderload_specs(path)(networks/spec.py, re-exportado desdebib2graph.networks) con esquema raíznetworks:(lista de entradas, cada una validada conNetworkSpec(**entry)); el camporesolution: float = 1.0(resolución de Louvain, fuera delcorpus_hash);model_config = ConfigDict(extra="forbid")(campo desconocido → error accionable); el 16° subcomandob2g networks --spec <yaml>; ypyyamlpromovido a dependencia del núcleo (import perezoso). El cuerpo histórico abajo (NetworkSpeccomo hook mínimo) sigue vigente; esto solo registra el AS-BUILT. Ver API.md §10. - Enmienda (2026-06-15, 2º giro): el punto "el
Corpuses un wrapper delgado sobre la tabla" (sección A) queda enmendado por 0015: elCorpusya no envuelve unapa.Tablecruda con semántica de valor, sino unTabularBackend(Protocol) que delega las mutaciones (InMemoryBackendpuro /DuckDBBackendpor defecto). La tabla canónica Arrow sigue siendo la representación del contenido (corpus.to_arrow()es el puente a los proyectores puros); solo cambia el contenedor. El resto de A (Arrow + Pydantic, vistas derivadas, columnas de estado) sigue vigente.
Contexto¶
El diseño v1 (documentado en ARCHITECTURE.md y API.md) modelaba el Corpus
como cuatro dict indexados más dataclasses (Paper, Author, Keyword,
Institution). Esto funcionaba como contrato, pero tenía tres costos concretos
que aparecieron al planificar la implementación:
- Merge, dedup e idempotencia requerían código a mano cuatro veces y luego
reglas de reconciliación para listas anidadas (
author_ids,reference_dois). Bibliometrix (R) lo resuelve con un data frame canónico ygroupby. - Normalización de campos (parseo de nombres, canonicalización de keywords,
thesaurus) se repetía entre
Source,Enrichery[dedup]. No había un lugar único para decidir "cómo se ve un autor". - No había artefacto serializable nativo para el caso "herramienta interna repetible": el corpus no se podía guardar a disco y volver a leer sin reinventar un formato.
Una exploración más amplia (cuatro arquitecturas candidatas) está en
exploracion/arquitecturas-alternativas.md.
Las alternativas evaluadas y descartadas:
- Event sourcing / log inmutable: sobreingeniería; el mismo poder se obtiene con snapshot + manifest.
- Grafo de primera (NetworkX como contrato): revive la v0 que la lección 2
de
lecciones-v0.mdjustamente rechaza (núcleo dependiente del backend). - Solo A sin D: la tabla canónica es necesaria pero no suficiente para el caso "investigador piensa por red" y para el futuro GUI.
Decisión¶
Adoptar A + D de la exploración:
A. Tabla canónica única (Arrow + Pydantic v2)¶
- Una sola tabla Arrow (
pa.Table) con schema fijo por paper es la representación intermedia del pipeline. Validada en el wrapper público con Pydantic v2; si Pydantic se vuelve cuello de botella, se migra la capa de validación amsgspec.Structsin tocar el contrato público. - El
Corpuses un wrapper delgado sobre la tabla, no un grafo, no un set de entidades. Tienetable: pa.Table,manifest: Manifest, y métodos puros (add_paper,merge,seeds,seal,materialize). Paper,Author,Keyword,Institutiondejan de ser entidades de primera clase en el modelo. Son vistas derivadas víagroupby + explodesobre la tabla. Opcionalmente, dataclasses frozen temporales víaCorpus.materialize(...)para tests y debugging. No son parte del contrato público.is_seedyprovenanceson columnas de la tabla, no atributos de dataclass. Esto evita que el "estado de pipeline" contamine la entidad.
D. NetworkSpec declarativa (capa v0.2)¶
- En v0.1: el hook
Networks.build(corpus, spec) -> NetworkArtifactya existe como función pura. La API pública deNetworkSpecse congela en v0.2. - En v0.1:
Networks.quick(corpus)arma las 4 specs razonables con defaults sensatos. Cubre el caso "investigador académico, baja fricción". - En v0.2: se libera
NetworkSpeccomo dataclass frozen con YAML loader (b2g networks --spec redes.yaml).
Snapshot inmutable, sin in-memory store¶
- No existe un
Storein-memory como costura. La persistencia por defecto es siempre unCorpusSnapshot: una carpeta concorpus.parquet+manifest.json(hash, schema_version, fuentes, parámetros, versión de la lib). DuckDB queda como store opcional en el extra[duckdb]. - Cada corrida del pipeline deja un log estructurado (qué source, qué enricher, qué preprocess, qué specs). El log + el snapshot hacen que la corrida sea reproducible a partir de los artefactos en disco, no del estado de la sesión.
Versionado, changelog, tooling¶
- SemVer estricto (
0.y.zhasta1.0.0). Breaking changes en0.ydocumentados conBREAKING:en el CHANGELOG. - Changelog auto vía
release-pleasedesde Conventional Commits, formato Keep a Changelog. - CLI como API para LLM/agentes desde v0.1: subprocess + JSON stdout, exit codes claros, sin estado. Tool schemas JSON y/o MCP son trabajo futuro (v0.3+) si la demanda lo justifica; el CLI ya es la frontera programática.
- Tooling estándar desde Hito 0:
ruff,mypy,pytest,pre-commit,commitizen,release-please, GitHub Actions. Todo declarado enpyproject.toml+.pre-commit-config.yaml+.github/workflows/.
Estructura nueva del núcleo¶
src/bib2graph/
__init__.py
corpus.py # Corpus, Manifest, CorpusSnapshot, schema Arrow
schemas.py # modelos Pydantic v2 para validación
sources/ # BibtexSource (v0.1); RIS, CSV (futuro, no publicar)
enrichers/ # SemanticScholarEnricher (v0.1, extra [s2])
preprocessors/ # normalize, dedup (v0.1 núcleo, fuzzy en [dedup])
networks/ # Projector, Analyzer, NetworkSpec, NetworkArtifact
exporters/ # GraphML, CSV
stores/ # ParquetStore (v0.1 núcleo); DuckDBStore ([duckdb]);
# Neo4jStore ([neo4j], v0.2)
cli.py # Click, delgado. CLI = API para agentes.
tests/
unit/ # tests puros, sin red ni I/O (default)
integration/ # red / APIs externas / Neo4j; @pytest.mark.integration
Consecuencias¶
- Menos código, menos drift. Merge y dedup dejan de ser cuatro funciones paralelas. Tests sobre tablas chiquitas con resultados conocidos son inmediatos.
- Reproducibilidad casi gratis.
parquet+manifest.jsonse versionan en git-lfs o DVC. Una corrida se reproduce a partir de sus artefactos, no del estado de la sesión. - Interoperabilidad nativa. Arrow es el denominador común de pandas, polars, duckdb. La lib no compite con ese ecosistema, se apoya.
- El CLI es la frontera para agentes. Cualquier agente (LLM hoy, automatización mañana) puede orquestar la lib sin reinventar wrappers.
- Costo: reescribir
ARCHITECTURE.md§3 yAPI.md§1; reorganizarROADMAP.md. No hay código todavía, así que el costo se paga una vez. - Tensiones resueltas (antes abiertas en
ARCHITECTURE.md§9): - §9.1 representación interna → tabla Arrow + Pydantic.
- §9.4 driver Neo4j → irrelevante: Neo4j pasa de "sustrato" a "adaptador opcional" mapeando tabla → grafo. El driver se elige al implementar el adaptador.
- §9.2 Publisher/ResearchArea → atributos opcionales en la tabla, no entidades. Se promueven a entidades solo si una red los necesita.
- §9.3 RawAuthor/Author → un solo estado en una columna
author_normalized: bool; la canonicalización es un Preprocessor.
Próximos pasos (orden estricto)¶
- Reescribir
ARCHITECTURE.md§3, §6, §9 con esta decisión. - Reescribir
API.md§1 (modelo), §5–7 (proyectores, analizadores, exportadores) y agregar §10 (NetworkSpec, v0.2). - Reorganizar
ROADMAP.mdcon 9 hitos (verNotas/02-exploracion/...§5). - Crear
CHANGELOG.md(Unreleased),CONTRIBUTING.md(Conventional Commits),VERSIONING.md, plantilla de release. - Actualizar
AGENTS.mdcon comandos de release, semver, changelog y notas sobre el CLI como API para agentes. - Recién entonces:
pyproject.tomlmínimo (Hito 0) y arrancar.