API — superficie pública de bib2graph¶
Contratos de las costuras y del núcleo: el "producto" que ve quien la integra o la extiende. Son bocetos de interfaz (firmas + docstrings), no la implementación: el código es la fuente de verdad última y este doc describe el contrato que ese código cumple. Diseño de fondo en
ARCHITECTURE.md; método enNotas/metodología.md.El
Corpuses una tabla Arrow validada con Pydantic v2 (ADR 0006) respaldada por unTabularBackend(InMemoryBackendpuro /DuckDBBackendpor defecto; ADR 0015);Paper/Author/Keyword/Institutionson vistas derivadas, no tipos. El estado del lazo (CycleState) vive en el backend persistente (ADR 0016). El contratoSourcesepara mínimo universal vs enriquecimiento opcional (ADR 0018). El producto no usa IA generativa (ADR 0022): la asistencia del forrajeo es estructura bibliométrica determinista (information scent).La superficie pública —núcleo, costuras, capa de servicios neutral
service/, y el CLI de 10 verbos (ADR 0037/0038)— está construida; lo marcadofuturoestá declarado pero no implementado (no falsamente prometido). Las firmas de abajo se verifican contrasrc/bib2graph/.
Convenciones¶
- Tipado estático en todas las firmas públicas. Las costuras se definen como
Protocolo ABC. - Funciones puras en el núcleo (proyectores, analizadores, preprocesador): sin red, sin
estado global. El estado (biblioteca viva +
LoopState) vive en el backend persistente (DuckDBBackend), no en la sesión. - Estado de implementación:
v1vsfuturo(declarado, NO implementado — marcado como tal, no falsamente prometido; lección 5 de v0).
Convenciones del CLI agente-native (ADR 0010 / 0021)¶
El CLI b2g (paquete bib2graph.cli, entry point b2g = "bib2graph.cli:main") cumple el contrato del
ADR 0021. Cada subcomando lleva --json (envelope
estable/versionado, también activable con B2G_JSON=1, ver §Envelope JSON) y exit codes (0 éxito
· 1 uso · 2 datos · 3 dependencia · 4 red · 5 store/snapshot corrupto o bloqueado). Sin
estado entre invocaciones: el estado vive en el library.duckdb del workspace (opción global
opcional --workspace; --store fue eliminada en #75).
Superficie 0.10.0 — 10 verbos del ciclo + 3 grupos noun-verb + skill (ADR
0037/0038/0039).
La superficie mapea 1:1 el ciclo (más es menos); el conteo es verificable contra b2g --help:
- 10 verbos del ciclo:
init,seed,chain,curate(grupo),build,read(grupo),export,snapshot(grupo),status,validate. (El par EXPORT/SNAPSHOT cuenta como uno; ADR 0037.) - 3 grupos noun-verb:
read {list,stats,show,top},curate {dump,apply,accept,reject,filter},snapshot {create,restore}. Un grupo sin subcomando imprime ayuda y sale exit 0; elcommanddel envelope usa la ruta completa ("read list"). - 1 comando meta fuera del set de 10 (no es un paso del ciclo):
skill add(ADR 0039). - Aliases deprecados (vivos con aviso a stderr, retiro 0.11.0):
accept,reject,filter,inspect,monitor,networks,enrich,restore,resolve(ver §Avisos de deprecación).thesaurusNO es alias: se retiró por completo (su capacidad esbuild --thesaurus, #164).
status expone el ciclo: estado actual del FSM (SEEDED/FORAGED/FILTERED/BUILT/MONITORED, dominio
en bib2graph.cycle), transitions_available, curation_available (accept/reject siempre
disponibles, curación transversal), round (contador de ronda con reseed), conteos por
curation_status, workspace: {root, source}, networks_cache_stale: bool (+ warnings accionable
cuando la cache de networks/ quedó obsoleta — avisa, NO regenera) y referenced_not_fetched (nº de
IDs que el backward chaining observó sin materializar; §4/§5). Todos campos aditivos, schema="1"
intacto. validate chequea la consistencia del workspace (read-only).
init (ADR 0029): scaffold de un workspace.
b2g init <name> crea <name>/ con workspace.json + library.duckdb +
networks//snapshots//exports/; b2g init . inicializa el cwd (adopta un .duckdb legacy). Si
la carpeta ya es workspace → WorkspaceExistsError. NO transiciona. data = {root, name, ...}.
seed (ADR 0030): TRES modos
mutuamente excluyentes (exactamente uno; ninguno o más de uno → exit 1):
--equation '<texto>'— ecuación cruda (modo OpenAlex directo, con red).--spec equation.yaml— la misma siembra parametrizada por un YAML versionable (clave raízequation:, modeloEquationSpec, §2; paridad 1:1 flag ⇄ campo).--from-bib <archivo.bib>— siembra desde BibTeX local sin red (BibtexSource.load);is_seed=True/candidate, transiciona aSEEDED(o reseed → ronda++).data = {papers_added, total_papers, round, reseeded}(sinexecuted_query/translation_report). Faltabibtexparser([bibtex]) →DependencyErrorexit 3; archivo inexistente /.bibmal formado →DataErrorexit 2.--resolve(solo con--from-bib): tras cargar, encadena la resolución DOI→source_id(= correrb2g resolve) reusando el store abierto; sumadata["resolve"].--emailse permite con--from-bibsolo junto a--resolve(se propaga al polite pool).
Flags OpenAlex (solo con --equation/--spec): --max-results INT (default del source 200;
muestras chicas); --exclude TEXT (repetible) = negaciones quirúrgicas inyectadas dentro de la
única expresión title_and_abstract.search:((query) AND NOT "<término>") (campo no repetido), en el
translation_report; --min-year/--max-year filtran contra OpenAlex
(from_publication_date/to_publication_date como predicado separado por coma, fuera del search);
--native = query cruda (sin traducción; min/max-year no aplican). Combinar cualquier flag
OpenAlex con --from-bib → exit 1 (salvo --email junto a --resolve). No existe
seed --from-corpus (rehidratar un parquet curado es snapshot restore).
chain (paso CHAIN): expande el corpus con candidatos rankeados por information scent
(forward/backward batcheado, §5). --direction [backward|forward|both] (default both),
--depth (solo 1), --max-candidates, --max-citing (presupuesto de citantes por semilla
en forward, default 50), --email, --preview (dry-run sin red ni transición: backward exacto
desde references_id; forward exacto solo si hay cited_by_id). Transiciona a FORAGED y corre
automática la pasada refs→DOI (§Enricher absorbido): el --json suma data["enrichment"]. data =
{candidates_found, new_candidates, total_papers, direction, depth, ranking_preview, observed_refs_count,
loop_state, round, enrichment}.
--since(forrajeo incremental, absorbemonitor): trae solo citantes desde una fecha (ISOYYYY-MM-DDo atajo90d/6m/1y, parseado encli/_options.py::parse_since). Fuerza forward y transiciona aMONITORED.backward + --since→ exit 1;both + --since→ la ventana aplica solo al tramo forward. Sin corpus/estado previo →DataErrorexit 2 (sugiereb2g seed). No existe estadoCHAINED. El aliasmonitordelega aquí.
Enricher absorbido en chain/build (#162): el OpenAlexEnricher (§3) no es verbo propio. La
pasada refs→DOI corre automática en chain; la pasada co-citación (cited_by) corre automática
en build cuando hay semillas aceptadas (no-op de red sin ellas). Por eso build ya NO es
estrictamente "sin red" (ADR 0025 enmendado). Ambos suman data["enrichment"]. El alias b2g enrich
corre ambas pasadas y NO transiciona.
curate {dump,apply,accept,reject,filter} (grupo noun-verb, #155). La transición la define el
VERBO: solo curate filter→FILTERED; el resto transversal. BREAKING: la forma-flag
curate --dump/--from-csv/--all fue eliminada sin alias. Lógica fuente única en
service/curate.py.
curate dumpescribe un CSV revisable offline.--outoverride (default<workspace>/exports/curacion.csv);--scope [candidates|seeds|all](defaultcandidates:candidate AND NOT is_seed;seeds=is_seed;all= todo). Sin candidatos → error que sugiere--scope all/b2g chain. Columnas (16, orden estable):id, source_id, title, year, authors, venue, doi, keywords, cited_by_count, references_count, is_seed, openalex_url, scent_score, cluster, decision, note— editables solodecision/note.cited_by_count/references_count/scent_score/clustersalen vacías (placeholders, no fallan).data = {csv_path, papers_exported, columns}.curate apply <csv>aplica decisiones en lote (accepted→accept,rejected→reject,undecided→no-op; case-insensitive). Idempotente (decided_atinyectado en la frontera CLI, R2). CSV sinid/decisionodecisioninválida →DataErrorexit 2. IDs huérfanos →not_found_count+ aviso (no no-op silencioso).data = {accepted_count, rejected_count, skipped_count, not_found_count, total_rows}.notese ignora en apply (advisory). Lee el CSV conutf-8-sig: tolera el BOM UTF-8 que Excel-Windows agrega al guardar como UTF-8 (#238, política Excel-friendly de #214).curate accept --ids ... [--by NOMBRE]/curate reject --ids ... [--by NOMBRE]— por ID (uno-a-uno o lote). Compartenaccept_papers/reject_paperscon los verbos sueltosaccept/reject(alias deprecados).curate filter(--year-gte/--year-lte,--language,--type,--min-citations): aplica inclusión/exclusión PRISMA marcandorejected(no borra) con conteo por paso. Transiciona aFILTERED. Compartefilter_corpus(store_path, *, year_gte, year_lte, language, type_in, min_citations, decided_at)con el verbo sueltofilter.
build y export separados (ADR 0021 §B). build computa Networks.quick (4-5 redes) y escribe
a <workspace>/networks/<kind>/ (transiciona a BUILT); export --format graphml|csv relee esos
artefactos (ws.networks_dir) y los serializa (sin transición). export --out-dir override
opcional (default <workspace>/exports/).
build tiene dos modos: quick (sin --spec) y declarativo (build --spec <redes.yaml>:
load_specs con clave raíz networks: → Networks.build por red; helper único _build_from_spec_file).
Ambos transicionan a BUILT y sellan networks/.corpus_hash (decisión D1; a diferencia del alias
networks, que es transversal). Flags:
--scope [all|accepted|seeds](defaultall): filtra el corpus por curación antes de proyectar (Corpus.scoped, §1.2).accepted=is_seed+ aceptados;seeds= solo semillas. El.corpus_hashse sella con el corpus filtrado;clusters.csv/decoratereflejan ese subset. Scope con 0 papers → exit 0 +warning(no error). No confundir conNetworkSpec.scope(§10, por-red sobreis_seed).--corpus-scope [all|accepted|seeds_only]= alias deprecado (oculto en--help, vocab interno; precede a--scopesi se pasan ambos).--min-weight N(solo quick): descarta aristas con peso < N. Con--specse usa elmin_weightpor-red del YAML; pasarlo junto a--specemite warning y se ignora.--thesaurus <archivo>(#164): aplica un thesaurus multilingüe (JSON ADR 0011) sobrekeywords_idantes de scopear/proyectar, persiste conpersist_replace(§4.1) y sumadata["thesaurus"] = {keywords_mapped, keywords_total, aliases_loaded, applied_at}. Inexistente/mal formado →DataErrorexit 2.--email/--max-citing INT: parametrizan la pasadacited_by(co-citación; ver Enricher absorbido).
Artefactos por red: todas escriben network.graphml + metrics.json; clusters.csv solo las
redes de paper (bibliographic_coupling, cocitation) con comunidades (las de
autor/institución/keyword devuelven [] y omiten el archivo, por diseño). Diagnóstico de red-vacía:
build reusa predict_build_preview (la misma fuente que status, no-divergencia por-corpus) y lo
emite en data["empty_networks"] (lista de {kind, reason, fix_command}, separada de data["warnings"]
corpus-level). --json.data: networks_built, artifacts_dir, corpus_hash, scope (token CLI),
corpus_scope (vocab interno, backward-compat), networks (con clusters_csv condicional), warnings,
empty_networks, maturity (ver Apéndice), enrichment, thesaurus (si se pasó --thesaurus).
snapshot {create, restore} (grupo noun-verb, #163). Fuente única en service/snapshot.py. La
transición la define el verbo.
snapshot create(= exsnapshotplano, BREAKING sin alias): sella una foto reproducible (parquet +manifest.json, ADR 0017).--out-diroverride opcional (default<workspace>/snapshots/). NO transiciona.data = {snapshot_dir, corpus_hash, total_papers, schema_version, maturity}.snapshot restore --from-corpus <parquet>(= ex verbo planorestore): rehidrata un corpus ya curado SIN red (lee conCORPUS_SCHEMA,Corpus.from_arrow, merge+dedup+persist; cero llamadas a OpenAlex). Preserva la curación (decision/curation_status/is_seed, D3). Transiciona aFILTERED(reusa la transición permisivafilter; válida desde cualquier estado, incluido store vacío). Parquet inexistente o schema no canónico →DataErrorexit 2.data = {papers_loaded, total_papers, state, round}. El verbo sueltorestorees alias deprecado (command="restore").
read {list,stats,show,top} (grupo noun-verb, #156/#157): lectura pura del corpus (no transiciona).
Lógica en service/reads.py (§0.1).
read list— filtros AND combinables:--query TEXT(substring case-insensitive sobre el título),--status {candidate,accepted,rejected},--seeds/--candidates(poris_seed),--year INT.data = {papers: [{id, title, year, curation_status, is_seed}], count}.read stats --group-by {status,year,is_seed}(defaultstatus): conteos agrupados.data = {group_by, total, groups: [{key, count}]}.--group-byinválido → exit 1 (UsageError deChoice).read show --id <ID>: delega enget_paper(resuelve id | doi | source_id, prioridad id>doi>source_id, ADR 0036).data= la fila completa del corpus (~14 campos).--idsin match →DataErrorexit 2.read top— la salida de investigación: dos bloques sobre redes recomputadas en lectura (no requierebuild).--top N/-n(default 10),--kind(Choicesobre los 5NetworkKind, defaultbibliographic_couplingporque es robusto en el one-shot frío: no necesitachain --forward).data = {kind, top, central: [{id, title, degree_centrality, community?}], cocitation: [{source, source_title, target, target_title, weight}], reason?, fix_command?, maturity}.central= top N nodos de--kindpordegree_centrality;cocitation= SIEMPRE la red cocitation, top N aristas porweight. Honest-empty (exit 0, no error): cocitación vacía (sincited_by_id) → bloque[]+reason/fix_command(depredict_build_preview).--kindinválido → exit 1;n <= 0o red que falla genuinamente →DataErrorexit 2.
skill add [--user|--project] [--force] (comando meta, ADR
0039): instala la skill de Claude Code end-user
que enseña al agente a usar bib2graph (los 10 verbos + el one-shot init→seed→chain→build→read). La
skill viaja vendoreada en el wheel bajo src/bib2graph/skill/ (SKILL.md + reference/, fuente
commiteada vía packages = ["src/bib2graph"]): el version-lock skill==cli garantiza que la skill enseñe
los verbos que el CLI expone. skill add copia la skill al directorio del cliente: --user
(default) → ~/.claude/skills/bib2graph/, --project → .claude/skills/bib2graph/. Idempotente;
si el destino existe y difiere falla accionable y --force pisa. Funciona SIN workspace y emite
--json schema="1" sin transición de FSM. La skill es markdown sin dependencias Python (la IA está
en el Claude Code del usuario, no en el producto; ADR 0022). data = {install_path, scope, installed,
already_present, skill_md, reference_dir, how_to}.
resolve (alias deprecado): resuelve los DOIs del corpus a source_id de OpenAlex (cierra el GAP del
flujo BibTeX: sin source_id, chain da 0). Filtra doi != NULL AND source_id IS NULL, consulta
OpenAlex (OpenAlexSource.fetch_dois_to_openalex_ids vía service/resolve.py::resolve_dois) y puebla
source_id; idempotente, persiste con persist_replace. --email (polite pool). data =
{resolved, total_with_doi, already_resolved, total_papers}. NO transiciona. Red caída → NetworkError
exit 4; store bloqueado → StoreError exit 5. Encadenable en seed --from-bib --resolve.
networks --spec / inspect (alias deprecados): networks --spec <redes.yaml> construye redes desde
el YAML pero NO transiciona ni sella .corpus_hash (ad-hoc transversal) — usá build --spec
(paso BUILD pleno). inspect lo absorben read show (papers) y status (manifest/FSM).
--workspace global (OPCIONAL). Va en el grupo b2g, antes del subcomando. --store fue
ELIMINADA (#75, BREAKING): pasarla da el error estándar de Click (No such option). El modo degenerado
(.duckdb suelto) dejó de existir; un .duckdb legacy se adopta con b2g init .. Resolución
ambiente (precedencia): (1) --workspace explícito, (2) B2G_WORKSPACE (env), (3) walk-up del cwd
buscando workspace.json. Sin ninguno → error accionable que sugiere b2g init.
Transiciones automáticas del ciclo (ADR 0021 §F): seed→SEEDED (con estado previo = reseed,
ronda++), chain→FORAGED, chain --since→MONITORED, curate filter→FILTERED, build→BUILT,
snapshot restore→FILTERED. El resto (read, export, snapshot create, status, validate,
curate {dump,apply,accept,reject}, los alias enrich/networks/resolve) no transiciona. El
estado destino lo dicta bib2graph.cycle.apply_transition (fuente única; los comandos no hardcodean el
destino).
Envelope JSON común y versionado (ADR 0021 §C): en modo --json, cada subcomando emite un objeto
JSON con schema="1":
{
"schema": "1",
"ok": true,
"command": "seed",
"exit_code": 0,
"data": { },
"warnings": [],
"error": null
}
En error conocido: ok=false, data={}, error={"code": <CODE>, "message": <accionable>}. Los exit
codes se mapean por tipo de error (ADR 0021 §D): DataError→2, ImportError/DependencyError/
NotImplementedError→3, httpx.HTTPError→4, StoreLockedError/OSError→5. AttributeError no se
mapea (un bug real no se disfraza de "capacidad faltante"); la capacidad-de-source-faltante se convierte
en DependencyError con un pre-check hasattr en el comando (p. ej. chain antes del Forager).
Borde: el error de uso sale SIN envelope. Ante una opción requerida faltante, una opción desconocida
(p. ej. --store) o ningún workspace resoluble, Click aborta el parseo antes de entrar al comando:
mensaje de uso en stderr + exit 1, sin envelope. El envelope solo cubre errores dentro de la
ejecución del comando.
stdout puro en modo JSON (ENFORCED, #151). En modo JSON (por --json o B2G_JSON) stdout emite
exactamente una línea (el envelope), también en el camino de error (ok=false → envelope en stdout).
El texto humano va a stderr.
B2G_JSON — modo JSON por entorno (#151). Además de --json (post-verbo: b2g <cmd> --json), el
modo JSON se activa con B2G_JSON truthy (1/true/yes, case-insensitive) en todos los comandos.
Precedencia: --json explícito gana; no existe --no-json. Recomendación agents-first: export
B2G_JSON=1 una vez y correr el ciclo sin repetir el flag. Aditivo: envelope/exit codes/FSM no cambian.
Apéndice — bloque maturity del one-shot (#160, ADR 0037 §f). Los artefactos del camino one-shot
llevan un bloque aditivo data["maturity"] que se autodeclara borrador sin pulir (honestidad por
construcción), para que ni un agente que optimiza por exit 0 ni un humano apurado confundan un one-shot
con un resultado terminado. schema="1" intacto.
Forma estable: SIEMPRE 4 claves (orden y tipos fijos):
| clave | tipo | regla de derivación |
|---|---|---|
curated |
bool |
true si el corpus completo (pre-scope) tiene ≥1 paper con curation_status ∈ {accepted, rejected}. Independiente del scope y del FSM. |
scope |
str | null |
el token CLI (all/accepted/seeds, no el vocab interno seeds_only). En snapshot create y read top es "all". |
saturated |
bool |
false constante en one-shot (no sobre-afirmar; gancho futuro: convergencia de referenced_refs_count()). |
empty_networks |
list[str] |
solo los tokens kind de las redes vacías (reason/fix_command no se duplican: viven en data["empty_networks"]). |
Aparece siempre en build (incl. early-return de corpus vacío), snapshot create y read top;
ausente en read list/read stats/read show. Lo calcula la función pura
service.maturity.compute_maturity(corpus, *, scope, empty_network_kinds) (§0).
Avisos de deprecación (ADR 0038 P1)¶
La consolidación 0.10.0 retira solapamientos sin romper de una: los nombres viejos siguen
funcionando durante 0.10.x con un aviso de deprecación, y se eliminan en 0.11.0 (criterio por
versión, no fecha). El helper único es cli/_deprecation.py::emit_deprecation.
Formato canónico (exacto):
- Canal: stderr SIEMPRE (modo humano y modo
--json), nunca stdout — preserva el stdout puro de una línea-envelope (#151). En--json, el mismo mensaje se propaga además alwarnings[]top-level del envelope (no adata), enhebrado víabuild_envelope(..., warnings=[msg]). - No cambia el contrato: el alias delega en la misma lógica de servicio (fuente única) y conserva
su
command/envelope;schema="1", exit codes y FSM intactos.
Los 9 verbos deprecados (alias vivo con aviso → forma canónica):
| Alias deprecado | Forma canónica |
|---|---|
b2g accept |
b2g curate accept |
b2g reject |
b2g curate reject |
b2g filter |
b2g curate filter |
b2g inspect |
b2g read show (papers) / b2g status (manifest/FSM) |
b2g monitor |
b2g chain --since |
b2g networks |
b2g build --spec |
b2g enrich |
b2g chain (refs→DOI) + b2g build (co-citación) |
b2g restore |
b2g snapshot restore |
b2g resolve |
b2g seed --resolve |
Además (mismo corte 0.11.0):
- Entry-point
bib2graph→b2g(main_bib2graph_aliasemite el aviso y delega enmain). - Opción
build --corpus-scope→build --scope(deprecación de flag, oculta en--help; el vocab viejoseeds_onlysigue aceptado y tiene precedencia si se pasan ambos).
thesaurus NO está en esta lista: se retiró por completo (sin alias). Su capacidad vive como
b2g build --thesaurus <archivo> (#164, ver §build).
0. Capa de servicios service/ — contrato neutral compartido (ADR 0028)¶
src/bib2graph/service/ es la capa de servicios neutral de la que el CLI es un adaptador delgado
(ADR 0028, inversión de dependencia ports &
adapters). Aloja el contrato (envelope versionado + jerarquía de errores + mapeo error→código) y
las lecturas read-only del corpus (service/reads.py, §0.1, que consume el grupo CLI read). El
contrato externo del CLI (envelope schema="1", exit codes 0–5, ADR 0021) no cambia.
Invariante de neutralidad de transporte (estricta). service/ es agnóstica de transporte:
sin print, sin sys.exit, sin Click, sin FastAPI. Es el límite que mantiene el contrato
reutilizable por cualquier adaptador. El I/O (emit/emit_human en cli/_envelope.py,
handle_errors/_emit_error_envelope en cli/_errors.py) se queda en el adaptador CLI.
Contrato público (re-exportado desde bib2graph.service.__init__):
# service/envelope.py — envelope JSON común y versionado
ENVELOPE_SCHEMA_VERSION: str = "1" # versión del contrato del envelope (ADR 0021)
def build_envelope(
*,
command: str,
ok: bool,
data: dict[str, Any],
exit_code: int,
warnings: list[str] | None = None,
error: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Construye el envelope JSON estable del contrato (schema="1").
{schema, ok, command, exit_code, data, warnings, error}. Función pura, sin I/O."""
# service/errors.py — jerarquía de errores tipados (ADR 0021)
class B2GError(Exception):
"""Base de los errores accionables. Atributos de clase: exit_code: int, code: str;
instancia: .message. Subclases con su (exit_code, code):"""
exit_code: int = 1
code: str = "B2G_ERROR"
class UsageError(B2GError): exit_code = 1; code = "USAGE_ERROR" # uso (opción faltante/inválida)
class DataError(B2GError): exit_code = 2; code = "DATA_ERROR" # schema/ids/filtro inválido
class DependencyError(B2GError): exit_code = 3; code = "DEPENDENCY_ERROR" # ImportError / capacidad faltante
class NetworkError(B2GError): exit_code = 4; code = "NETWORK_ERROR" # httpx.HTTPError / timeout
class StoreError(B2GError): exit_code = 5; code = "STORE_ERROR" # store/snapshot bloqueado o corrupto
def code_for(exc: BaseException) -> int:
"""Mapeo PURO error→exit code (0–5, ADR 0021), sin I/O ni sys.exit:
B2GError → su .exit_code; OSError (incl. StoreLockedError) → 5; ImportError → 3;
httpx.HTTPError → 4. Excepción no mapeada → TypeError (el llamador decide).
Lo usan la capa de servicio y los adaptadores para derivar exit code / HTTP status
sin duplicar la política."""
# service/maturity.py — bloque maturity del one-shot (#160, ADR 0037 §f / 0038 P3)
def compute_maturity(
corpus: Corpus, *, scope: str | None, empty_network_kinds: list[str]
) -> dict[str, Any]:
"""Bloque maturity para el --json de build/snapshot create/read top (ver Apéndice maturity).
Función PURA, sin I/O. Devuelve EXACTAMENTE 4 claves:
{curated: bool, scope: str|None, saturated: bool, empty_networks: list[str]}.
curated = corpus tiene ≥1 paper con curation_status ∈ {accepted, rejected};
saturated = False constante (one-shot never over-claims; gancho futuro referenced_refs_count);
empty_networks = solo los kind (reason/fix_command NO se duplican)."""
Adaptadores (el contrato se re-exporta, no se duplica). cli/_envelope.py y cli/_errors.py
hacen from bib2graph.service... import ... y re-exportan los mismos objetos, así que los imports
existentes del CLI y los tests (from bib2graph.cli._envelope import build_envelope,
from bib2graph.cli._errors import B2GError, DataError, …) siguen funcionando sin cambios. El
decorador handle_errors (CLI) conserva su propia escalera try/except por tipo de error + el
sys.exit y la emisión del envelope de error; code_for es el mapeo puro disponible para cualquier
adaptador. El mapeo de code_for y el de handle_errors describen la misma política (ADR 0021 §D).
0.1 Lecturas de servicio service/reads.py — lecturas read-only del corpus¶
src/bib2graph/service/reads.py expone las lecturas read-only del corpus que consume el grupo
CLI read (list_papers, corpus_stats, get_paper, get_top — §Grupo read), re-exportadas
desde bib2graph.service.__init__. Cada una recibe un Workspace ya resuelto (la resolución
ambiente vive en el adaptador CLI, ADR 0029), abre el store read-only, y devuelve un
dict/list[dict] serializable o lanza un B2GError tipado. Sin red, sin mutación, sin
transición de ciclo; determinismo R2 (mismo corpus → misma lectura).
El módulo conserva además funciones de lectura más ricas (get_workspace, list_rounds, get_scent,
get_network, compare_rounds) que hoy ningún comando consume (su poda opcional es trabajo de
limpieza, #191), más la resolución inversa
id→DOI/URL (resolve_doi, resolve_url; #212,
aditiva, devuelven None sin lanzar). Decisiones de modelado:
ronda = snapshot sellado (no el contador loop_round), get_scent = score de acoplamiento real
+ vecinos, get_network = red de la ronda viva recomputada.
def get_workspace(ws: Workspace) -> dict[str, Any]:
"""Estado del workspace activo. Devuelve:
{name, root, created_at, bib2graph_version, source, loop_state (str|None),
round (int), total_papers (int), counts_by_status (dict[str,int]),
transitions_available (list[str]), curation_available (list[str]),
networks_cache_stale (bool)}. Raises StoreError."""
def list_rounds(ws: Workspace) -> list[dict[str, Any]]:
"""Snapshots sellados (vía Workspace.list_snapshots()) + una entrada sintética "live".
Por snapshot: {id, corpus_hash, created_at, total_papers, schema_version}.
Entrada viva: {id="live", round, loop_state, total_papers}. Raises StoreError.
Ronda = snapshot (B-G2-1 Opción A); el contador loop_round se ve en la entrada "live"."""
def get_paper(ws: Workspace, ident: str) -> dict[str, Any]:
"""Fila del corpus (CORPUS_SCHEMA) resuelta por identidad source-agnóstica
(ADR 0036): `ident` matchea contra **id | doi | source_id**, con prioridad
`id` > `doi` > `source_id` (devuelve el primer match). Devuelve:
{id, source_id, doi, title, year, abstract, is_seed, curation_status,
authors_raw, authors_id, keywords_id, references_id, cited_by_id,
provenance (list, parseada del JSON)}.
Raises DataError si `ident` no matchea ningún id/doi/source_id;
StoreError si el store falla. `read show --id` delega en esta lectura
(§Convenciones CLI · grupo `read`)."""
def get_scent(ws: Workspace, paper_id: str) -> dict[str, Any]:
"""Score de acoplamiento bibliográfico real + vecinos compartidos (B-G2-2). Devuelve:
{paper_id, score (int = nº de corpus-papers con >=1 referencia compartida),
coupling (list[{paper_id, title, weight}], ordenada por peso desc),
references (list[{paper_id, title}] resueltas al corpus),
cited_by (list[{paper_id, title}] resueltos al corpus)}.
Raises DataError si el id no existe; StoreError si el store falla."""
def get_network(ws: Workspace, kind: str) -> dict[str, Any]:
"""Red de la ronda VIVA recomputada con Networks.build + decorate (B-G2-3; función pura,
Louvain seeded por corpus_hash, R2). `kind` en NetworkKind del núcleo
(bibliographic_coupling, cocitation, author_collab, institution_collab,
keyword_cooccurrence). Devuelve:
{nodes (list[{id, label, degree_centrality, community?, year?, is_seed?, curation_status?}]),
edges (list[{source, target, weight}]),
metrics ({n_nodes, n_edges, density, num_components, avg_clustering, n_communities})}.
Raises DataError si kind es inválido o la red no se puede construir; StoreError si el store falla."""
def compare_rounds(ws: Workspace, round_a: str, round_b: str) -> dict[str, Any]:
"""EL DIFERENCIADOR (ADR 0027). Diff entre dos snapshots por Col.ID; "live" usa el corpus vivo.
Devuelve:
{round_a, round_b, added_paper_ids (ids en b no en a), removed_paper_ids (ids en a no en b),
mutated_hubs ([], DIFERIDO — requiere redes por snapshot, B-G2-3),
metrics_change (list[{metric, before, after}], hoy con n_papers; las métricas por red
solo aparecen si ambos snapshots tienen networks/<kind>/metrics.json, que hoy no se
materializa por snapshot)}.
Raises DataError si un snapshot no existe o no tiene corpus.parquet; StoreError si el store falla."""
# --- Lecturas detrás del grupo CLI `read` (#156/#157; ver §Grupo `read`) ---
def list_papers(ws: Workspace, *, query=None, status=None, is_seed=None, year=None) -> dict[str, Any]:
"""Lista mínima del corpus con filtros AND (todos opcionales). Devuelve:
{papers: [{id, title, year, curation_status, is_seed}], count: int}.
query = substring case-insensitive sobre el título; status = curation_status exacto;
is_seed True/False; year exacto. Raises StoreError. (Detrás de `read list`.)"""
def corpus_stats(ws: Workspace, *, group_by="status") -> dict[str, Any]:
"""Conteos agrupados por status (default) | year | is_seed. Devuelve:
{group_by, total, groups: [{key, count}]}. Raises DataError si group_by inválido;
StoreError si el store falla. (Detrás de `read stats`.)"""
def get_top(ws: Workspace, *, n=10, kind="bibliographic_coupling") -> dict[str, Any]:
"""Salida de investigación (#157): nodos centrales + pares de co-citación con título,
sobre redes recomputadas (NO requiere `build`; mismo camino que get_network). Devuelve:
{kind, top, central: [{id, title, degree_centrality, community?}],
cocitation: [{source, source_title, target, target_title, weight}], reason?, fix_command?,
maturity}.
`central` = top n nodos de la red `kind` por degree_centrality desc (título completo en redes
de paper; label de entidad en author/institution/keyword). `cocitation` = SIEMPRE la red
cocitation, top n aristas por weight desc.
Honest-empty: co-citación vacía (sin cited_by_id) → bloque [] + reason/fix_command
(de predict_build_preview), NO error. `maturity` (aditivo, #160, ver Apéndice `maturity`):
SIEMPRE presente, scope="all", empty_networks=["cocitation"] si la co-citación quedó vacía.
Raises DataError si kind inválido, n <= 0, o la red
falla genuinamente; StoreError si el store falla. (Detrás de `read top`.)"""
# --- Resolución inversa id→DOI/URL (#212, opción 1; sin red, sobre el corpus cargado) ---
def resolve_doi(ws: Workspace, paper_id: str) -> str | None:
"""DOI desnudo del paper con `Col.ID == paper_id`, o `None`. Devuelve `None`
(NO lanza DataError) cuando el id no existe, el paper no tiene DOI, o el DOI es
cadena vacía `""` (mismo criterio de "vacío = ausente" que networks/decorate.py).
Sin red: opera sobre el corpus ya cargado. Raises StoreError si el store falla."""
def resolve_url(ws: Workspace, paper_id: str) -> str | None:
"""URL canónica `https://doi.org/<doi>` del paper, o `None` en los mismos casos
que resolve_doi (id inexistente / sin DOI / DOI vacío). Deriva vía
`doi_to_url(resolve_doi(...))`. Sin red. Raises StoreError si el store falla.
`doi_to_url(doi: str|None) -> str|None` (bib2graph.constants) es la FUENTE ÚNICA
de la derivación DOI→URL, compartida con la decoración del atributo `url` de redes
(#209, ver §8 nota) — sin drift."""
Nota de fidelidad al núcleo. Las lecturas no inventan campos que el núcleo no sostiene:
get_paper expone authors_raw/authors_id (no objetos autor con ORCID), get_network no entrega
modularity ni un id de red persistido, y compare_rounds deja mutated_hubs=[] mientras no haya
redes por snapshot.
1. Modelo de dominio — Corpus¶
Wrapper sobre un TabularBackend (Protocol) cuyo contenido es una tabla Arrow (pa.Table)
con schema fijo por paper, validada con Pydantic v2 (ADR 0006). El Corpus delega las
mutaciones al backend (ADR 0015): los métodos
siguen devolviendo un Corpus (semántica de valor a nivel de API), pero accept/reject/
merge/add_paper no reconstruyen la tabla entera en memoria — piden la operación al backend.
InMemoryBackend— puro, sin I/O: working set efímero y backend de los tests (el núcleo se testea sin DuckDB). Es el comportamiento del Hito 1, movido al backend.DuckDBBackend— la biblioteca viva (ADR 0009): archivo.duckdbo:memory:, mutaciones por SQLUPDATE/MERGEporid. Es el backend por defecto con persistencia, y donde vive elLoopState(ADR 0016).
Las reglas de identidad/hash/merge (ADR 0013,
D1/D2/D3) son contrato que cada backend cumple (InMemory en Python, DuckDB en SQL).
corpus.to_arrow() es el puente estable a los proyectores/analizadores puros (§7–§8): solo
cambia el contenedor, no el núcleo de análisis.
Símbolos públicos del Hito 1/1.5 (from bib2graph import ...): Corpus, Manifest,
CorpusSnapshot, SchemaError (la excepción de contrato que lanzan Corpus.from_arrow() y
add_paper() al violarse el schema canónico), y —del rework del Hito 1.5— TabularBackend
(Protocol) e InMemoryBackend (ver §1.4).
1.1 Schema de la tabla (columnas canónicas)¶
| Columna | Tipo Arrow | Nullable | Notas |
|---|---|---|---|
id |
string |
no | id interno estable (hash de doi/source_id; ver §1.1 Identidad, ADR 0036) |
source_id |
string |
sí | id del motor de extracción que entregó el paper (p. ej. W... para OpenAlex). Agnóstico al motor (ADR 0036): el nombre del motor vive en provenance.source, no en la columna |
doi |
string |
sí | DOI normalizado |
title |
string |
no | título completo |
year |
int32 |
sí | año de publicación |
abstract |
string |
sí | |
source |
string |
sí | revista / venue |
language |
string |
sí | código ISO 639-1 |
publisher |
string |
sí | atributo, no entidad |
research_areas |
list[string] |
— | atributos, no entidades |
is_seed |
bool |
no | True si entró por la ecuación/semilla; False si lo trajo el chaining |
curation_status |
string |
no | candidate / accepted / rejected (biblioteca viva) |
provenance |
string |
sí | JSON: lista de eventos (log append-only). Cada evento {action, equation_id, chaining_hop, source, fetched_at, decided_by, decided_at}. Ver nota abajo (ADR 0013) |
authors_raw / authors_id |
list[string] |
— | nombres crudos / ids canónicos (ORCID si hay) |
authors_affiliations |
list[string] |
— | per-autor (de OpenAlex authorships); habilita geografía/asortatividad |
keywords_raw / keywords_id |
list[string] |
— | crudos / canónicos (post-thesaurus) |
institutions_raw / institutions_id |
list[string] |
— | crudos / ids canónicos (ROR si hay) |
references_id |
list[string] |
— | obras citadas (ids OpenAlex); vienen de OpenAlex, no de un Enricher |
references_doi |
list[string] |
— | refs resueltas a DOI (las puebla un Enricher opt-in; OpenAlex las da como URLs internas) |
cited_by_id |
list[string] |
— | citantes (ids OpenAlex); habilita forward chaining y co-citación |
El schema exacto vive en bib2graph.schemas. La validación se hace en Corpus.from_arrow() y en
cada Source.seed()/load().
Tabla lateral
external_ids(paper_id, engine, id)(ADR 0036, opción C — infra presente, sin poblar): el backend exponeexternal_ids_for(paper_id)/all_external_ids()para registrar 1↔N los IDs que cada motor asignó al mismo paper (unificados por DOI). Su consumo (cruce cross-motor) está diferido a la llegada del 2º motor (#120); hoy la identidad/dedup se resuelven solo por elidcanónico (DOI primero).
provenance es un log append-only (ADR 0013,
D4), no un objeto único: la columna string guarda un JSON que es una lista de eventos. 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 (action='accepted'/'rejected', con decided_by y
decided_at) sin borrar los previos. None/cadena vacía equivalen a "sin eventos".
id estable y determinista (ADR 0013, D1;
precedencia invertida por ADR 0036, D1'):
id = f"{prefix}:{sha256(valor)[:16]}" con precedencia doi normalizado (doi:) → source_id
(src:) → title+year (tt:). El DOI es el ancla universal e interoperable entre motores (un
paper con DOI tiene el mismo id venga de OpenAlex, de Semantic Scholar o de un .bib); source_id
es el fallback para papers sin DOI, antes de caer a title+year (frágil). El mismo paper produce el
mismo id entre corridas; es la base de la dedup en merge y en la biblioteca viva.
1.2 Corpus (wrapper)¶
class Corpus:
"""Wrapper sobre un TabularBackend + un Manifest (ADR 0015).
Lo que circula por el pipeline: Source lo siembra, el Forager lo expande,
el humano lo cura, el Preprocessor lo normaliza, el backend lo persiste (biblioteca
viva), los Projectors lo consumen vía to_arrow(). Las mutaciones se DELEGAN al
backend (InMemoryBackend puro / DuckDBBackend por defecto): la API mantiene
semántica de valor (devuelve Corpus), pero no copia la tabla entera en memoria.
"""
manifest: Manifest
@classmethod
def from_arrow(cls, table: pa.Table, *, backend: "TabularBackend | None" = None) -> "Corpus":
"""Valida con Pydantic y construye el Corpus sobre `backend` (default InMemoryBackend).
Falla ruidoso si el schema no coincide."""
def to_arrow(self) -> pa.Table:
"""Materializa el contenido del backend como pa.Table. Puente a los proyectores puros."""
def seeds(self) -> pa.Table: """Vista is_seed == True."""
def candidates(self) -> pa.Table: """Vista curation_status == 'candidate'."""
def accepted(self) -> pa.Table: """Vista curation_status == 'accepted' (la biblioteca curada)."""
def scoped(self, scope: str) -> "Corpus":
"""Vista PURA por estado de curación: devuelve un Corpus NUEVO con el subconjunto de filas
(no muta el original). Valores: `'all'` = corpus completo; `'accepted'` = `is_seed == True`
OR `curation_status == 'accepted'`; `'seeds_only'` = `is_seed == True`. Scope inválido →
`ValueError` accionable. Determinista: dos llamadas con el mismo scope dan corpora con el
mismo `corpus_hash` (subset estable). `'all'` reusa el backend; los otros materializan el
filtro en un `InMemoryBackend`. Lo usa `b2g build --scope` (vocab CLI `seeds`→`seeds_only`;
alias deprecado `--corpus-scope` usa este vocab interno) para sellar el hash del
corpus FILTRADO. Issue #56 / #159. **NO confundir con `NetworkSpec.scope`** (§10): aquel es un
eje por-red (`full`/`seeds_only`) sobre `is_seed`; `scoped()` filtra el corpus entero por
curación antes de proyectar."""
def with_manifest(self, manifest: Manifest) -> "Corpus":
"""Devuelve un Corpus nuevo con el MISMO contenido y otro Manifest (semántica de valor:
el original no muta). No toca el backend; el `corpus_hash` no cambia (el hash es sobre la
tabla, no sobre el Manifest). API canónica para que las costuras (Source/Forager/Filter)
sellen su metadata —p. ej. `OpenAlexSource.seed()` puebla `openalex_version`/`equations`—
sin reconstruir el corpus. v1 (Hito 4)."""
def add_paper(self, row: dict) -> "Corpus":
"""Valida la fila (PaperRow) y agrega el paper. Calcula `id` (D1) si no viene."""
def merge(self, other: "Corpus") -> "Corpus":
"""Combina deduplicando por `id` (idempotente). Combinación por campo: escalar no-nulo
gana (ambos no-nulos → `other`); columnas de lista = unión deduplicada (preserva `None`);
`curation_status` por decisión humana más reciente (`provenance.decided_at`), fallback
`accepted`>`rejected`>`candidate`; `provenance` = unión de eventos únicos (log).
Orden de filas: **primera aparición** (filas de `self` en orden, luego las nuevas de
`other`). Ver ADR 0013 (D3)."""
def accept(self, ids: list[str], *, by: str = "human", decided_at: datetime | None = None) -> "Corpus":
"""Marca papers como 'accepted' y AGREGA un evento al log de provenance. Devuelve Corpus nuevo.
`decided_at` se inyecta desde la frontera CLI (Hito R2, ADR 0017); `None` → el backend usa
`datetime.now(UTC)` como fallback de librería. El `decided_at` NO entra al `corpus_hash`."""
def reject(self, ids: list[str], *, by: str = "human", decided_at: datetime | None = None) -> "Corpus": ...
def materialize(self, view: Literal["author", "keyword", "institution"]) -> pa.Table: ...
def snapshot(self, path: Path) -> "CorpusSnapshot":
"""Exporta una FOTO sellada del estado actual (parquet + manifest.json) para reportar/
reproducir. CALCULA el `corpus_hash` real (D2) y lo escribe en el Manifest del snapshot.
NO es la persistencia (eso es el Store DuckDB); es un export derivable."""
def __eq__(self, other: object) -> bool:
"""Igualdad canónica vía `corpus_hash` (D2): mismo contenido semántico, insensible al
orden de filas y al orden interno de las columnas de lista; no compara el Manifest.
Robusta ante cualquier `PYTHONHASHSEED`. Ver ADR 0013."""
Notas de contrato (ADR 0013):
__eq__es porcorpus_hash, no porpa.Table.equals: dosCorpuscon el mismo contenido en distinto orden de filas (o de listas) son iguales. Elcorpus_hashexcluyeprovenance/timestamps (identidad = contenido bibliográfico; la procedencia audita, no identifica) pero incluyecuration_status(contenido curado), nunca campos volátiles del Manifest (D2).mergeemite filas en orden determinista (primera aparición): habilita diffs y snapshots reproducibles. Es idempotente:c.merge(c) == c.
Backend y estado del lazo (ADR 0015 / 0016):
- Las mutaciones se delegan al
TabularBackend. D1/D2/D3 son contrato que cada backend cumple (InMemory en Python, DuckDB por SQL). Elcorpus_hash(D2) se computa siempre sobreto_arrow(). - El
CycleState(SEEDED → FORAGED → FILTERED → BUILT → MONITORED, transiciones permisivas) vive en el backend persistente (DuckDBBackend), no en elCorpusefímero (tablaloop_state_logappend-only; estado actual = última fila), expuesto víaloop_state()/set_loop_state()(§4) yb2g status.
1.3 Manifest y CorpusSnapshot¶
class Manifest(BaseModel):
"""Metadatos del Corpus. Se serializa a manifest.json junto al parquet del snapshot."""
# Obligatorios (sin default) — D5
schema_version: str
corpus_hash: str
lib_version: str
created_at: datetime
# Con default — D5
openalex_version: str | None = None # versión/fecha del snapshot de OpenAlex usado
equations: list[EquationRef] = [] # ecuaciones + query OpenAlex ejecutada + reporte de traducción
chaining: ChainingParams | None = None # profundidad, topes, dirección
preprocessors: list[PreprocRef] = [] # normalize + thesaurus aplicados
filters: list[FilterStep] = [] # criterios incl/excl con conteos (flujo PRISMA)
enrichers: list[EnricherRef] = [] # opcional (resolución de refs / 2º nivel)
class CorpusSnapshot:
"""Carpeta con corpus.parquet + manifest.json: EXPORT sellado del estado vivo en un instante.
Reproducible y versionable (git-lfs / DVC). No es la biblioteca viva, es su foto."""
path: Path
manifest: Manifest
@property
def corpus(self) -> Corpus: ...
Notas de contrato (ADR 0013; D5/D6):
corpus_hashse calcula al sellar: el Manifest en memoria llevacorpus_hash=""(placeholder); el hash real (D2) se computa ensnapshot()y vive enCorpusSnapshot.manifest.- Obligatorios (D5):
schema_version,corpus_hash,lib_version,created_at; el resto con default. Siimportlib.metadatano resuelve la versión instalada,lib_version = "unknown"(no"0.0.0"inventado — honesto sobre la reproducibilidad). schema_version(D6): se escribe y round-tripea; el rechazo por incompatibilidad + migraciones sobre el store vivo es futuro.
1.4 TabularBackend (Protocol) e InMemoryBackend¶
El contenedor del Corpus es un TabularBackend (Protocol @runtime_checkable); el Corpus
delega en él (ADR 0015). El núcleo depende solo
del Protocol (no de duckdb). Las mutaciones tienen semántica de valor: cada operación
devuelve una instancia nueva del backend; la original no muta. id ya viene calculado por
Corpus.add_paper (D1 se valida antes de delegar). Las reglas D1/D2/D3 (ADR
0013) son contrato de este Protocol: cada
implementación las cumple a su manera (InMemory en Python, DuckDB en SQL).
@runtime_checkable
class TabularBackend(Protocol):
"""Respalda el contenido del Corpus. Cumple D1/D2/D3 (ADR 0013).
Implementaciones: InMemoryBackend (puro, tests) / DuckDBBackend (biblioteca viva, Hito 3)."""
def to_arrow(self) -> pa.Table: ...
# Contenido completo como tabla Arrow canónica. Puente a los proyectores puros.
def add_paper(self, row: dict) -> "TabularBackend": ...
# `id` ya calculado y fila ya validada por Corpus.add_paper. Devuelve backend nuevo.
def merge(self, other_table: pa.Table) -> "TabularBackend": ...
# Fusión D3: orden por primera aparición (filas de self, luego nuevas), dedup por `id`.
def apply_curation(self, ids: list[str], *, action: str, by: str,
decided_at: str | None = None) -> "TabularBackend": ...
# accept/reject: AGREGA un evento al log `provenance` (action/decided_by/decided_at).
# `decided_at` (ISO8601 UTC) inyectado desde la frontera (Hito R2, ADR 0017);
# `None` → fallback `datetime.now(UTC)` (uso como librería). NO entra al corpus_hash.
def filter_view(self, view: Literal["seeds", "candidates", "accepted"]) -> pa.Table: ...
# Vista filtrada (is_seed / curation_status == 'candidate' | 'accepted').
def corpus_hash(self) -> str: ... # D2, order-independent, sobre el contenido
def __len__(self) -> int: ...
def __eq__(self, other: object) -> bool: ... # igualdad canónica por corpus_hash (D2)
# AS-BUILT #54 (2026-06-17): tabla hermana `referenced_but_not_fetched` (append-only, par de
# loop_state_log) — los IDs que el backward chaining OBSERVA sin materializar en el corpus (§5).
# FUERA de la tabla `corpus` y del corpus_hash (son estado, no contenido; ADR 0017).
def add_referenced_refs(self, ref_ids: list[str], *, cycle_round: int) -> "TabularBackend": ...
# Registra IDs observados (idempotente por existencia de `ref_id`; observed_at = now() del backend).
def referenced_refs_count(self) -> int: ... # nº de IDs observados distintos
def referenced_refs(self) -> pa.Table: ... # los IDs observados (ref_id, cycle_round, observed_at)
| Implementación | Estado | Notas |
|---|---|---|
InMemoryBackend |
v1 | Núcleo puro, sin I/O. Working set efímero y backend de los tests (el núcleo se testea sin DuckDB). Semántica de valor; hereda la lógica del Hito 1 (mutación en Python sobre listas de dicts, table-rebuild). No persiste. |
DuckDBBackend |
v1, por defecto | La biblioteca viva (ADR 0009/0015): construido (Hito 3). Mutación por SQL puro (INSERT … ON CONFLICT (id) DO UPDATE + merge D3 en SQL/UDF), persiste entre corridas (.duckdb o :memory:), aloja el LoopState (ADR 0016). Pasa la suite de contrato de backend (D1/D2/D3). Carga perezosa (PEP 562): no se importa con import bib2graph. Ver §4. |
TabularBackend e InMemoryBackend son símbolos públicos v1 (from bib2graph import
TabularBackend, InMemoryBackend). El contrato D1/D2/D3 se verifica con una suite parametrizada
por backend (tests/unit/test_backends.py), ahora parametrizada también con DuckDBBackend
(Hito 3, construido): el backend SQL cumple los mismos invariantes que el InMemory.
2. Costura Source — sembrar un corpus¶
El contrato Source es agnóstico de la forma de OpenAlex (ADR
0018): separa lo que todo corpus necesita para
existir de lo que algunas fuentes pueden o no entregar.
- Mínimo universal (obligatorio para toda
Source):id,title,year,authors_raw,keywords_raw. Habilita ya las redes de co-autoría y co-ocurrencia de keywords. - Enriquecimiento opcional (la
Sourcepuede omitirlo; el schema admite nulos):references_id/references_doi,cited_by_id,authors_affiliations(per-autor),institutions_id. Habilita acoplamiento, co-citación, redes de instituciones y asortatividad geográfica.
Una Source que solo provee el mínimo es ciudadana legítima (habilita fuentes
latinoamericanas — SciELO, Redalyc, La Referencia — sin obligarlas a entregar lo que no tienen);
los proyectores de enriquecimiento producen redes parciales sobre esos papers y lo reportan
(no fallan). (El contrato se declara en v0.1; las fuentes nuevas e impl son posteriores.)
class Source(Protocol):
"""Convierte una entrada externa en un Corpus. Acceso a campos DEFENSIVO (sin KeyError).
Debe entregar el MÍNIMO UNIVERSAL (id, title, year, authors_raw, keywords_raw); el
enriquecimiento (refs/citantes/afiliaciones/instituciones) es OPCIONAL (ADR 0018)."""
def seed(self, query: str, *, exclude: list[str] | None = None) -> "SeedResult":
"""Siembra desde una ecuación de búsqueda. Devuelve el Corpus + la query ejecutada
y el reporte de traducción (qué mapeó, qué se aproximó, qué se descartó).
`exclude` (negaciones quirúrgicas, opcional): cada término se inyecta DENTRO de la
única expresión `title_and_abstract.search:((query) AND NOT "<término>")` (el campo
NO se repite) y se REPORTA en el
translation_report (query visible, ejercicio consciente). Las comillas internas del
término se sanean. Ignorado con `native=True` (query cruda). Una Source que no siembra
por ecuación (p. ej. BibtexSource) lanza NotImplementedError."""
def load(self, path: str) -> Corpus:
"""Siembra desde un archivo (export/pearls). is_seed=True."""
class SeedResult(BaseModel):
corpus: Corpus
executed_query: str # la query OpenAlex EXACTA ejecutada (consciencia, ADR 0007)
translation_report: list[str] # mapeos limpios / aproximados / descartados (p. ej. NEAR no soportado) + negaciones aplicadas (exclude, #30)
Capa declarativa de la ecuación — EquationSpec + load_equation_spec (Ciclo 9a, ADR
0030; src/bib2graph/sources/equation.py).
Empaqueta los parámetros de b2g seed en un YAML versionable (el artefacto "qué se busca"), análogo
a NetworkSpec/load_specs del Hito 9. Clave raíz equation: (objeto, no lista — una
ecuación por archivo). El modo b2g seed --spec equation.yaml (§convenciones CLI) carga la spec y la
mapea a run_seed; equivale a --equation + flags.
class EquationSpec(BaseModel):
"""Configuración declarativa de una ecuación de búsqueda (ADR 0030).
model_config = ConfigDict(extra="forbid"): campo desconocido en el YAML → error accionable."""
query: str # requerido (no vacío) — la ecuación de búsqueda
exclude: list[str] = [] # #30 — AND NOT "…" DENTRO de la search:((query) AND NOT "…")
max_results: int | None = None # #14 — tope (None → default del source, 200)
native: bool = False # passthrough crudo a OpenAlex (sin traducción)
min_year: int | None = None # filtra: from_publication_date contra OpenAlex
max_year: int | None = None # filtra: to_publication_date contra OpenAlex
def load_equation_spec(path: str | Path) -> EquationSpec:
"""Carga/valida la EquationSpec desde un YAML (clave raíz `equation:`).
Errores accionables (mismo patrón que `load_specs`): YAML malformado → ValueError;
clave raíz ausente → ValueError; campo desconocido/tipo incorrecto → ValueError
citando archivo + campo. Importación perezosa de PyYAML."""
| Implementación | Estado | Notas |
|---|---|---|
OpenAlexSource |
v1 | Referencia/backbone, sobre httpx. Entrega mínimo + enriquecimiento (refs inline + afiliaciones per-autor + instituciones; cited_by_id lo puebla el chaining/Enricher, no el seed). Traducción passthrough: envuelve la ecuación en title_and_abstract.search:(...) y reporta los límites WoS (NEAR/comodín/tags) sin traducirlos. Flag native=True (query cruda). Negaciones (exclude): cada AND NOT "<término>" se inyecta dentro de la única expresión search:((query) AND NOT "<término>") (campo no repetido; el filtro de año queda como predicado separado por coma, fuera del search) y se reporta en el translation_report; ignorado con native. Credenciales inyectadas (arg → OPENALEX_API_KEY → ~/.openalex/credentials → polite pool; ADR 0012). Cursor paging con tope max_results (default 200). Puebla Manifest.openalex_version (ADR 0017). transport inyectable (tests sin red). Un 429 (rate limit del pool anónimo) en seed() → NetworkError (exit 4) con mensaje accionable: declarar --email mueve la petición al polite pool (remedio primario); api_key opcional (ADR 0012, #210). |
BibtexSource |
v1, secundaria | Sembrar desde pearls vía load(). Extra [bibtex] (import perezoso de bibtexparser); acceso defensivo (campos faltantes sin KeyError). Mínimo universal. seed() lanza NotImplementedError. .bib con error grave → ValueError; sin entradas válidas → UserWarning (no no-op silencioso). Carga bulk con from_arrow. |
ScieloSource / RedalycSource / LaReferenciaSource |
futuro | Fuentes regionales, mínimo universal. Declaradas, no implementadas (ADR 0018). |
RisSource / CsvSource |
futuro | No implementados. |
Capacidades de OpenAlexSource fuera del Protocol Source (específicas del backbone; las consumen
el Forager y el Enricher):
fetch_citing(openalex_id) -> list[dict](singular, forward chaining):GET works?filter=cites:, con retry/backoff ante 429/5xx. Al agotar los reintentos con 429 →NetworkError(exit 4) accionable (polite pool/--email; ADR 0012, #210); los 5xx agotados conservanhttpx.HTTPStatusError. Asimetría deliberada: solo el 429 tiene remedio del lado del usuario.fetch_citing_batch(ids, *, max_per_paper, since=None) -> dict[seed_id, list[citer_id]]: trae los citantes batcheando por OR (cites:W1|W2|..., lotes ≤50), pagina por cursor y atribuye página a página con presupuesto por semilla (corta cuando todas alcanzanmax_per_paper; sin starvation).sincefiltra a los publicados desde esa fecha (from_publication_date; lo usachain --since). Lo consume el Enricher para poblarcited_by_id.fetch_citing_batch_with_works(ids, *, max_per_paper, since=None) -> tuple[dict[...], dict[citer_id, work]]: variante que conserva la metadata (works_map) que la misma request ya trae (cero red extra). La consume el Forager para materializar filas reales en el forward (no placeholders).fetch_dois_for(ids) -> dict: resuelvereferences_id→DOI batcheando por OR (≤100,select=id,doi).fetch_works_by_ids(ids) -> Corpus: materializa works desde sus IDs OpenAlex (batcheo OR ≤100). Devuelve unCorpusconis_seed=False,candidate,provenance[action="fetched_by_id"]; IDs inexistentes se omiten sin error; orden determinista; lista vacía →Corpusvacío sin tocar la red. Es el primitivo que materializaría lo observado por el backward chaining. Centraliza el mapeo JSON→Arrow vía_work_to_row(parametrizado poris_seed/action/chaining_hop/source_tag).
Reporte de cobertura/calidad (concepto declarado, concreto futuro; ADR 0018): por seed/source,
mide % de refs resueltas, % con DOI, distribución idioma/región y completitud del enriquecimiento;
alimenta el juicio de cuándo cambiar de Source. Se declara como contrato (función pura sobre pa.Table),
sin cablearse vacío.
2.1 Convención examples/ — corpus de ejemplo commiteado¶
examples/ es la única excepción al .gitignore de datos de usuario (ADR
0030): un corpus real, curado y reducido
(CC0/OpenAlex) commiteado al árbol como caso real reproducible sin red. Reglas:
- Un ejemplo = una carpeta de propósito único (
examples/<nombre>/), autocontenida, con:corpus.parquet(curado y congelado, schemaCORPUS_SCHEMA; parquet/CSV, NUNCA.duckdb),equation.yaml(ecuación de procedencia,EquationSpec),curacion.csv(decisiones de curación congeladas queb2g curate applyconsume — receta determinista) yREADME.md(la procedencia: la receta CLI, no un script). - Cómo se restaura:
b2g snapshot restore --from-corpus examples/<nombre>/corpus.parquetrehidrata el corpus sin red, preserva la curación y transiciona aFILTERED; luegobuildcorre localmente. .gitignore:!examples/trackea el ejemplo;examples/**/*.duckdbprotege de que un store vivo se cuele.- Ejemplos existentes:
examples/valoraciones/: ~80 filas (70candidate+ 10accepted), armado 100% por CLI (seed --spec equation.yaml→curate apply curacion.csv→build --max-citing 25→snapshot create). Co-citación presente (rala) + las otras 4 redes sustanciales. Verificado por el gate R2 (tests/unit/test_example_r2_gate.py:corpus_hash+ comunidades Louvain estables).examples/bibtex/: unsample.bibchico (10 entradas, con variedad deliberada de campos faltantes para ejercitar el parser defensivo) +README.mdcon la receta 100% CLI (b2g init→b2g seed --from-bib examples/bibtex/sample.bib→b2g build). Demuestra el segundo camino de seed (BibTeX local, sin red). El.bibqueda trackeado por la excepción!examples/ya existente.
3. Costura Enricher — señal extra (opt-in, ya NO estructural)¶
Con OpenAlex como backbone, refs y citantes ya vienen en el corpus (ADR 0007). El Enricher
queda opt-in para resolver references a DOI y el segundo nivel de fetch (poblar cited_by_id
≡ citantes compartidos) que habilita la co-citación end-to-end. Vive en el núcleo sobre OpenAlex
(ADR 0025), no en [s2] (reservado para un
futuro SemanticScholarEnricher). No se invoca por verbo propio (#162): la pasada refs→DOI corre
automática en chain y la de co-citación en build (helper único cli/_enrich.py::enrich_corpus); el
verbo b2g enrich sobrevive como alias deprecado.
@runtime_checkable
class Enricher(Protocol):
"""Config (API keys) INYECTADA, nunca embebida. Sin ramas muertas. Rate limit/reintentos
sin perder papers. Idempotente. NO transiciona el CycleState (ortogonal al lazo, ADR 0025)."""
def enrich(self, corpus: Corpus) -> Corpus: ...
| Implementación | Estado | Aporta |
|---|---|---|
OpenAlexEnricher |
v1, opt-in | enrich(corpus) hace 2 pasadas. refs→DOI: resuelve los references_id únicos batcheando por OR (≤100, select=id,doi), rellena references_doi y registra un EnricherRef idempotente en el Manifest. co-citación: para las semillas aceptadas trae sus citantes vía OpenAlexSource.fetch_citing_batch (§2) y mergea sus openalex_id en cited_by_id (unión idempotente); no materializa citantes como filas (no crece el corpus). Constructor con max_citing_per_paper (tope por semilla). Frontera: el Source hace I/O + atribución; el Enricher solo une. |
SemanticScholarEnricher |
futuro | señal de citas adicional (reserva del [s2], no estructural) |
CrossRefEnricher / ScopusEnricher |
futuro | No implementados. |
4. Costura Store / backend de persistencia (biblioteca viva)¶
La persistencia por defecto es el DuckDBBackend del Corpus (ADR
0015): no un Store que persiste un Corpus Arrow
aparte, sino el backend por defecto del Corpus (mutaciones por SQL). El Store sigue siendo la
costura/punto de extensión para destinos externos opt-in (Zotero, Neo4j). El CycleState (ADR 0016)
vive en el backend persistente.
El contrato TabularBackend (Protocol) y su firma completa viven en §1.4 (to_arrow, add_paper,
merge, apply_curation, filter_view, corpus_hash, __len__, __eq__, y la tabla hermana
append-only referenced_but_not_fetched vía add_referenced_refs/referenced_refs_count/
referenced_refs, fuera del corpus_hash; §1.4 + §5). El Store de abajo es la costura de
persistencia/intercambio externa, distinta del backend del Corpus.
class Store(Protocol):
"""Costura de persistencia/intercambio externa. El respaldo por defecto del Corpus es el
DuckDBBackend; esta costura cubre destinos opt-in (Zotero, Neo4j) y export (Parquet)."""
def persist(self, corpus: Corpus) -> None:
"""Funde el corpus en la biblioteca viva (merge idempotente + log de procedencia)."""
def load(self) -> Corpus:
"""Devuelve el corpus ACUMULADO (estado entre corridas)."""
| Implementación | Estado | Notas |
|---|---|---|
DuckDBBackend |
v1, por defecto | Biblioteca viva (ADR 0009/0015): backend del Corpus, stateful, acumula entre corridas, mutación por SQL puro (INSERT … ON CONFLICT (id) DO UPDATE + merge D3 en SQL/UDF), log de procedencia/curación + LoopState, query SQL. Es núcleo, no extra. :memory: o archivo. (El DuckDBStore es su fachada de costura.) |
InMemoryBackend |
v1 | Backend puro (tests + working set efímero). Sin I/O. No persiste. |
ParquetStore |
futuro (no implementado) | Formato de export/intercambio del snapshot. Hoy lo cubre Corpus.snapshot() (parquet + manifest.json); un Store de export dedicado solo se construye si hace falta (lección 5: no se publica vacío). |
ZoteroStore |
futuro (V1.1, [zotero]) |
Sincroniza la biblioteca con una colección Zotero. Costura, no el corazón. |
Neo4jStore |
futuro (post-V1, [neo4j]) |
Adaptador tabla→grafo para Cypher. Ya no es sustrato (ADR 0002). |
Concurrencia (ADR 0019): DuckDB es single-writer. V1 asume 1 archivo
.duckdb= 1 escritor (lecturas concurrentes OK). Si el archivo está bloqueado por otro escritor,DuckDBBackend/DuckDBStorelanzanStoreLockedError(subclase deOSError); el CLI (Hito 6) lo mapea al exit code5. Multi-escritor concurrente es post-v1.0.
4.1 DuckDBStore — fachada de costura + extensiones del backend¶
DuckDBStore(path) (en bib2graph.stores.duckdb, re-exportado perezosamente como
bib2graph.DuckDBStore) implementa el Protocol Store (persist/load) delegando en un
DuckDBBackend sobre el archivo. load() devuelve un Corpus respaldado por ese backend (las
mutaciones subsiguientes tocan el archivo en disco).
class DuckDBStore:
def __init__(self, path: str | Path) -> None: ... # abre/crea el .duckdb; StoreLockedError si bloqueado
def persist(self, corpus: Corpus) -> None: ... # merge idempotente por id (upsert-concat D3) en la biblioteca viva
def persist_replace(self, corpus: Corpus) -> None: ...# DELETE+INSERT de la tabla `corpus`: el estado en disco
# queda EXACTAMENTE el corpus dado; preserva las tablas
# hermanas (loop_state_log, referenced_but_not_fetched)
def load(self) -> Corpus: ... # corpus acumulado, respaldado por el DuckDBBackend
@property
def backend(self) -> "DuckDBBackend": ... # acceso al backend para las extensiones de abajo
persist_replacevspersist(#88, ADR 0031). La ingesta automática (seed/seed_from_bib/chain/restore) y la pasadabuild --thesaurus(#164) persisten conpersist_replace(→DuckDBBackend.overwrite_corpus, DELETE+INSERT reasignando_seqdesde 0, ADR 0024), porque ya tienen el corpus completo, normalizado y deduplicado en memoria y el upsert-concat D3 (persist) reintroduciría las variantes que el dedup cross-biblioteca acaba de colapsar.persist/upsert queda intacto para el resto de los llamadores (caso "mismo paper desde dos fuentes", D3). Ambos preservan las tablas hermanas.
Procedencia del Manifest persistida entre cargas. Los bloques de procedencia del Manifest
(§1.3) que no son contenido del corpus se guardan en tablas hermanas del .duckdb y DuckDBStore.load()
los reconstruye al rehidratar, para que sobrevivan a un ciclo persist/load:
manifest.filters⇄ tablafilter_log— víaDuckDBBackend.persist_filter_steps()/load_filter_steps()(#126).manifest.enrichers⇄ tablaenricher_log— víaDuckDBBackend.persist_enricher_refs()/load_enricher_refs()(#141), mismo patrón quefilters: la pasada de enriquecimiento (chainrefs→DOI,buildco-citación) sella susEnricherRefyload()los recompone, así el snapshot reporta qué enriquecimiento se aplicó sin re-correrlo.
Extensiones del DuckDBBackend, FUERA del Protocol Store/TabularBackend (se acceden vía
store.backend.…): son específicas de DuckDB y no parte del contrato genérico:
class DuckDBBackend:
# ... cumple TabularBackend (§1.4) ...
def loop_state(self) -> "CycleState | None": ... # estado actual del ciclo (None si no hubo transiciones)
def loop_round(self) -> int: ... # contador de ronda (0 sin estado; 1 primera; 2+ re-sembrados)
def set_loop_state(self, state: "CycleState", *, cycle_round: int | None = None) -> None: ...
# registra una transición + ronda (log append-only, permisiva)
def query(self, sql: str) -> pa.Table: ... # consulta SQL de SOLO lectura sobre el corpus
El ciclo es un concepto de dominio puro (bib2graph.cycle); el backend solo lo persiste:
# bib2graph/cycle.py — dominio puro, sin DuckDB (ADR 0016 enmendado, R3)
class CycleState(StrEnum):
SEEDED = "SEEDED"; FORAGED = "FORAGED"; FILTERED = "FILTERED"; BUILT = "BUILT"; MONITORED = "MONITORED"
def apply_transition(state: CycleState | None, action: str, round: int) -> tuple[CycleState, int]: ...
# reseed → (SEEDED, round+1); seed/chain/filter/build/monitor → estado de cadena, misma ronda
def available_transitions(state: CycleState | None) -> list[str]: ... # transiciones de ciclo desde el estado
CURATION_ACTIONS: list[str] = ["accept", "reject"] # transversal: siempre disponible, no transiciona
El estado + la ronda se persisten en loop_state_log (append-only; estado actual = última fila;
columna round); las transiciones son permisivas (ADR 0016: no se bloquea ningún salto). reseed
es de primera clase (loop-back a SEEDED + ronda++, acumula sobre lo curado); seed.py lo cablea
cuando hay estado previo. Fuente única de verdad: chain/filter/build derivan su destino de
apply_transition, no de un literal. MONITORED es alcanzable vía b2g chain --since
(#158, forrajeo incremental; el alias deprecado b2g monitor delega), que dispara
apply_transition(state, "monitor", round) (paso 8 del ciclo).
El comando b2g status consume loop_state()/loop_round()/available_transitions() y expone
curation_available/round (ver §convenciones CLI).
Carga perezosa (PEP 562):
DuckDBBackendyDuckDBStorese exponen vía__getattr__enbib2graph/__init__.py, de modo queimport bib2graphNO importaduckdb(el núcleo permanece puro y testeable sin DuckDB). Solobib2graph.DuckDBBackend/bib2graph.DuckDBStorecargan el módulo bajo demanda.CycleStateyStoreLockedErrorse importan desdebib2graph.backends.duckdb(obib2graph.stores.duckdb);bib2graph.cycle(CycleState/apply_transition/available_transitions/CURATION_ACTIONS) es núcleo puro, sin DuckDB.
5. Núcleo — Forrajeo / chaining (asistencia algorítmica, SIN IA)¶
El information scent es estructura bibliométrica de cita con el corpus (ADR
0020). Es una función pura sobre el
primitivo collect_item_to_papers (índice {ref → corpus-papers que lo citan}):
- Backward (puro, local): scent = fuerza de co-citación con el corpus = nº de corpus-papers
que listan al candidato en
references_id. No toca la red (las refs ya vienen en el corpus). - Forward (requiere red): scent = fuerza de citación directa al corpus
(
forward_score(Y) = |{ref ∈ Y.references_id : ref ∈ corpus_ids}|, emite condirect > 0) — señal primaria robusta. Exige traer los citantes víasource.fetch_citing(...). - Centralidad del candidato: diferida (viz).
El ranking es descendente por scent con desempate por id ascendente (estable ante cualquier
PYTHONHASHSEED).
Direction = Literal["backward", "forward", "both"] # bib2graph.foraging.Direction
class Forager:
"""Orquesta el chaining sobre un Source, rankeando candidatos por *information scent*
bibliométrico (co-citación backward / citación directa forward, ADR 0008/0020/0022).
El scent consume el primitivo de proyectores. Solo el Forager toca la red; el núcleo
de scent es puro."""
def __init__(self, source: Source, *, depth: int = 1, max_candidates: int | None = None,
max_citing_per_paper: int = 50) -> None:
"""depth=1 por defecto; depth>1 lanza NotImplementedError (futuro v0.3+).
max_candidates = tope configurable del ranking (None = sin límite).
max_citing_per_paper = tope de citantes POR SEMILLA en el forward batcheado (default 50;
acota el fetch vía fetch_citing_batch; CLI `--max-citing`). AS-BUILT #21 (2026-06-16)."""
def preview(self, corpus: Corpus, *, direction: Direction = "both") -> "GrowthPreview":
"""'Esta expansión sumaría ~N papers' SIN traerlos. Opera SOLO localmente, SIN red.
Backward: estimación EXACTA local desde references_id. Forward: NO estimable sin red
(cited_by_id está vacío tras el seed) → estima el nº de SEMILLAS a forrajear (is_seed,
SIN filtrar curation_status) con by_direction['forward']=0 y forward_requires_fetch=True;
el conteo de citantes reales solo llega con chain(). NO muta el corpus."""
def chain(self, corpus: Corpus, *, direction: Direction = "both",
since: date | None = None) -> "RankedCandidates":
"""Computa candidatos (curation_status='candidate', is_seed=False) rankeados por scent.
Devuelve SOLO los candidatos nuevos (no mergeados): el humano hace
corpus.merge(ranked.corpus). NO muta el corpus de entrada. Sella Manifest.chaining.
`since` (#158, forrajeo incremental): propaga a fetch_citing_batch(since=) →
from_publication_date en OpenAlex; solo afecta el tramo forward. Lo usa `b2g chain --since`
(transición a MONITORED)."""
class GrowthPreview(BaseModel):
estimated_new: int # total estimable localmente (forward=0 si requiere fetch)
by_direction: dict[str, int] # {'backward': N, 'forward': 0 si requiere fetch}
direction: Direction
forward_requires_fetch: bool = False # True si se pidió forward/both → forward desconocido sin red
class RankedCandidates(BaseModel):
corpus: Corpus # SOLO los candidatos nuevos (no mergeado con el corpus semilla).
# Forward (#78): materializa filas con metadata REAL (título/año/
# autores), NO placeholders — vía fetch_citing_batch_with_works.
# Backward (#54): NO materializa filas — observa, ver observed_refs.
ranking: list[tuple[str, float]] # (id, information_scent), desc scent / asc id
observed_refs: list[str] = [] # AS-BUILT #54 (2026-06-17): IDs observados por el backward SIN
# materializarlos en .corpus (orden de ranking, respeta
# max_candidates). El backward observa; el forward materializa.
# b2g chain los persiste en `referenced_but_not_fetched` (§4),
# fuera del corpus_hash. Materializar = diferido a #71.
# RETIRADO (ADR 0022): `explain_candidate` y el extra `[llm]` se ELIMINAN del producto.
# El producto no usa IA generativa. El "porqué" de un candidato lo explica la ESTRUCTURA
# VISIBLE (con qué del corpus se acopla/co-cita), no un LLM. Ver ROADMAP Hito R4.
# (En el AS-BUILT v0.2 existía como stub gateado en [llm]; la remediación lo borra.)
Notas de contrato (ADR 0020):
- Forward chaining requiere
source.fetch_citing_batch(ids, *, max_per_paper)(§2, capacidad deOpenAlexSource, no del ProtocolSource— una source de solo-mínimo no habilita forward). El comandochainhace un pre-checkhasattry lanzaDependencyError(exit 3) si el source no lo soporta (unAttributeErrorgenuino no se disfraza de "source sin forward"). - Forward batcheado + cap por semilla:
fetch_citing_batchbatchea por OR (≤50) con presupuesto por semilla (max_citing_per_paper, default 50 — CLI--max-citing), sin N+1. El alcance del forward esis_seed=True(todas las semillas, sin filtrarcuration_status): el chaining precede a la curación. La restricción aacceptedes del Enricher (co-citación, §3), no del Forager. - Backward observa sin contaminar: no crea filas-fantasma en el corpus; los IDs observados salen por
RankedCandidates.observed_refsyb2g chainlos persiste en la tabla hermanareferenced_but_not_fetched(§4), fuera delcorpus_hash. Forward sí materializa filas con metadata real (víafetch_citing_batch_with_works, §2; cero red extra). Asimetría deliberada. previewychainno mutan el corpus de entrada (semántica de valor).fetch_citing(singular, con retry/backoff ante 429/5xx) sigue disponible; el forward lo consume vía la variante batcheada.
6. Núcleo — Preprocessor + filtros PRISMA¶
class Preprocessor:
"""Determinístico e idempotente. La parte fuzzy vive en §11 (ahora núcleo, no extra). Registra un
PreprocRef en el Manifest por cada operación aplicada. `applied_at` se inyecta desde la frontera
(R2): un único datetime.now(UTC) por invocación, igual que `decided_at` en curación."""
def normalize(self, corpus: Corpus, *, applied_at: datetime | None = None) -> Corpus:
"""Normalización CONSERVADORA (decisión b=A): authors_id (lowercase + quitar acentos +
colapso de espacios) y language (subtag ISO 639-1 primario). SIN fuzzy (eso es el dedup,
§11), SIN columna de periodización. Idempotente. NO muta el corpus de entrada. Corre
AUTOMÁTICAMENTE en la ingesta (helper `normalize_and_dedup`, ADR 0031)."""
def apply_thesaurus(self, corpus: Corpus, thesaurus: dict | Path, *,
applied_at: datetime | None = None) -> Corpus:
"""Lee keywords_raw y SOBRESCRIBE keywords_id con los conceptos canónicos del thesaurus
multilingüe CURADO (en/es/pt), dict canónico→aliases en JSON o Path a ese JSON.
Determinista e idempotente (ADR 0011). SIN fallback semántico/LLM (ADR 0011 enmendado /
0022): lo que no matchea queda fuera, sin inventar conceptos con un modelo. Paso EXPLÍCITO
(flag `b2g build --thesaurus`, #164), NO automático: requiere el mapeo del usuario (ADR 0031)."""
Filtros de inclusión/exclusión (funciones puras, flujo PRISMA; ADR 0020):
class FilterCriterion(BaseModel):
field: Literal["year", "type", "language", "min_citations"]
op: Literal["gte", "lte", "in", "not_in", "eq"]
value: int | str | list[str]
# year: gte/lte · type: in/not_in (sobre research_areas) · language: eq/in/not_in
# min_citations: gte (sobre len(cited_by_id))
def apply_filter(corpus: Corpus, criterion: FilterCriterion) -> tuple[Corpus, FilterStep]: ...
def apply_filters(corpus: Corpus, criteria: list[FilterCriterion]) -> tuple[Corpus, list[FilterStep]]:
"""Encadena los criterios en orden y SELLA Manifest.filters con todos los pasos
(reemplaza: una corrida = una secuencia PRISMA). Devuelve (corpus_final, [FilterStep, ...])."""
Notas de contrato (ADR 0020):
- Los filtros MARCAN
rejected, NO borran: un paper excluido queda en la tabla concuration_status='rejected'víacorpus.reject(...)(con el criterio enprovenance), nunca se borra. La exclusión es curación reversible y auditable (biblioteca viva, ADR 0009/0013). - Conteo PRISMA por paso: cada
FilterStepllevacount_before/count_aftersobre los papers no-rejected (candidate + accepted). keywords_ides post-thesaurus: los proyectores de co-ocurrencia de keywords (§7) deben correr después deapply_thesaurus.- Campo/operador desconocido LANZA
ValueErroraccionable (lista los válidos); no es no-op silencioso (endurece el flujo PRISMA, sin exclusiones perdidas). - Símbolos públicos (
from bib2graph import ...):Forager,GrowthPreview,RankedCandidates,Preprocessor,FilterCriterion,apply_filters.apply_filter(singular) desdebib2graph.filters.
7. Núcleo — Projector (funciones puras)¶
class Projector(Protocol):
def project(self, table: pa.Table, *, min_weight: int = 1,
scope: Literal["full", "seeds_only"] = "full") -> nx.Graph: ...
| Proyector | Estado | Insumo | Scope por defecto | Requiere Enricher |
|---|---|---|---|---|
BibliographicCouplingProjector |
v1 | references_id |
full (corpus completo) |
No (refs ya en corpus) |
AuthorCollaborationProjector |
v1 | authors_id |
full |
No |
InstitutionCollaborationProjector |
v1 | institutions_id |
full |
No |
KeywordCoOccurrenceProjector |
v1 | keywords_id (post-thesaurus) |
full |
No |
CoCitationProjector |
v1 | cited_by_id + citas de citantes |
seeds_only |
Sí (2º nivel de fetch) |
El acoplamiento (barato, mira adelante) es ciudadano de primera y opera sobre el corpus completo (crítica #2). La co-citación es la más cara (segundo nivel de fetch).
Notas de contrato (Hito 2, ADR 0014):
- Peso = conteo crudo de ítems compartidos (D1);
min_weight(default 1) descarta aristas conweight < min_weight. Sin normalización (Salton/Jaccard) en v1. - Tipo de nodo (D2): co-autoría / instituciones / co-word → la entidad es el nodo
(
authors_id/institutions_id/keywords_id); acoplamiento / co-citación → el paper (id) es el nodo. - Co-citación: el
CoCitationProjectorcuentacited_by_idcompartido = los citantes compartidos de la metodología (la frase "citantes con sus citas" ≡cited_by_idcompartido). Proyecta con scopeseeds_only. La co-citación es end-to-end: la pasadacited_byque corre automática enbuild(cuando hay aceptadas) pueblacited_by_idcon el 2º nivel de fetch delOpenAlexEnricher(ADR 0007/0025), yNetworks.quickla incluye cuando esa columna está poblada (§10). - Los proyectores siguen PUROS — NO setean atributos de nodo (ADR 0014): producen
un
nx.Graphcon ids crudos como nodos (doi:…,I185261750, un ORCID), sinlabel. La legibilidad (label + atributos) la inyecta la capadecorate(§7.1), que es la frontera entre la proyección pura y el export. Esta separación es deliberada (ADR 0014).
7.1 Frontera — decorate (label legible + atributos de nodo)¶
bib2graph.networks.decorate es la capa de frontera entre los proyectores puros (§7) y los
exportadores (§9). Los proyectores devuelven grafos con ids crudos como nodos y sin atributos;
decorate transforma esos ids en labels legibles e inyecta atributos de
curación/comunidad/centralidad, para que las redes no salgan ilegibles en Gephi/VOSviewer/Cytoscape.
LABEL_MAX_CHARS: int = 60 # tope del label de paper; título largo → truncado + "..."
def decorate_graph(graph: nx.Graph, table: pa.Table, kind: str, *,
communities: dict[Any, int] | None = None) -> None:
"""Inyecta label + atributos en los nodos del grafo IN-PLACE (no copia; el llamador/
exporter copia si necesita preservar el original). No muta el corpus ni la tabla.
Determinista; no importa duckdb (núcleo puro)."""
def decorate(artifact: NetworkArtifact, table: pa.Table) -> None:
"""Atajo sobre decorate_graph: extrae kind y communities del NetworkArtifact.
Es el punto de integración en facade.py (_build_artifact)."""
networks/__init__.py re-exporta decorate/decorate_graph.
Atributos de nodo inyectados:
| Atributo | Kinds | Origen |
|---|---|---|
label |
todos | string legible (mapeo por kind, abajo) |
degree_centrality |
todos | float, vía nx.degree_centrality |
year |
paper (coupling/cocitation) | int (ausente si None en el corpus) |
doi |
paper (coupling/cocitation) | string desde Col.DOI (DOI desnudo/normalizado, p. ej. 10.1234/abc); ausente si el paper no tiene DOI (mismo criterio que year) |
url |
paper (coupling/cocitation) | string derivada https://doi.org/<doi>; solo presente si hay DOI (no es columna del corpus, ver nota abajo) |
is_seed |
paper | bool |
curation_status |
paper | string |
community |
todos | int, solo si se provee artifact.communities |
doi/url aplican solo a paper-kinds (bibliographic_coupling y cocitation); los nodos de
autor/institución/keyword no los reciben. url es derivada (https://doi.org/<doi>), no una
columna del corpus: el DOI es la única identidad de primera clase (ADR 0036) y la URL es una expansión
trivial determinista a la hora de decorar. La derivación vive en doi_to_url(doi: str|None) -> str|None
(bib2graph.constants), fuente única compartida con resolve_url (§0.1, #212) — sin drift.
Ausencia condicional como year: sin DOI truthy, el nodo no
recibe ni doi ni url. Los exporters CSV/GraphML (§9) los propagan automáticamente cuando están
presentes (son genéricos y omiten None) — sin cambios en exporters.
Mapeo de label por NetworkKind:
| Kind | Nodo | label |
|---|---|---|
bibliographic_coupling / cocitation |
paper (id) |
"título (año)", truncado a LABEL_MAX_CHARS (60) + "..."; fallback al id crudo si no hay título |
author_collab |
authors_id |
authors_raw correlativo al authors_id (fallback al id) |
institution_collab |
institutions_id |
institutions_raw correlativo (fallback al id) |
keyword_cooccurrence |
keywords_id |
la keyword (ya legible) |
| (kind desconocido) | — | fallback al id crudo (extensible, no falla) |
Cableado: decorate se aplica en facade.py:_build_artifact, de modo que Networks.quick /
Networks.build (§10) ya devuelven artefactos decorados y b2g build/export salen con label
legible sin pasos extra. Los proyectores (§7) NO se tocan (siguen puros, ADR 0014): la decoración
es la única capa que sabe de labels.
7.2 Núcleo — cluster_table (resumen de comunidades)¶
bib2graph.networks.cluster_table es una función pura que cruza los nodos de una red con el
corpus para producir una fila de resumen por comunidad (quién/qué/cuándo cae en cada comunidad),
base del clusters.csv que escribe b2g build. Con --scope, build le pasa el corpus filtrado,
así que sus conteos cuadran con los nodos del grafo (sin drift).
def cluster_table(table: pa.Table, artifact: NetworkArtifact) -> list[dict[str, Any]]:
"""Una fila por comunidad de `artifact.communities`. Función pura (sin red, sin duckdb).
Cruza nodo→fila por Col.ID (id canónico), NUNCA por source_id. Devuelve [] si el kind
no es de paper o si no hay comunidades. Orden determinista por `cluster` ascendente."""
networks/__init__.py re-exporta cluster_table. Solo aplica a redes de paper
(bibliographic_coupling/cocitation); para autor/institución/keyword devuelve [] (no crash), por
eso clusters.csv se emite únicamente para esas dos redes.
Columnas de cada fila (orden estable):
| Columna | Tipo | Origen |
|---|---|---|
cluster |
int |
id de comunidad |
size |
int |
nº de nodos en la comunidad (incluye nodos sin match en el corpus) |
seed_count |
int |
nodos con is_seed=True |
candidate_count |
int |
nodos con curation_status='candidate' |
accepted_count |
int |
nodos con curation_status='accepted' |
year_min / year_max |
int \| None |
rango de año (None si ningún nodo tiene año) |
year_mean |
float \| None |
media de año redondeada a 1 decimal (None si no hay años) |
top_authors |
list[str] |
hasta 5 autores más frecuentes, de authors_raw |
top_keywords |
list[str] |
hasta 5 keywords más frecuentes, de keywords_id (post-thesaurus) |
Notas de contrato:
- Cruce por
Col.ID, nosource_id: el nodo del grafo es unCol.ID(doi:…/src:…); indexar porsource_iddaría 0 cruces. Un nodo sin match en el corpus suma alsizepero no aporta año/autores/keywords. - Determinista (ADR 0017): el top de autores/keywords se ordena por
(-frecuencia, nombre asc), reproducible independiente del método de clustering y dePYTHONHASHSEED. - Pura: sin red ni
duckdb. Combina concommunity_composition(§8, % por categoría del atributo).
8. Núcleo — Analyzer (funciones puras)¶
def network_metrics(g: nx.Graph) -> dict:
"""Densidad, nº de componentes, clustering promedio."""
def centrality(g: nx.Graph) -> dict:
"""Centralidad de grado e intermediación por nodo."""
def detect_communities(g: nx.Graph, method: str = "louvain", *,
random_state: int | None = None) -> dict:
"""method ∈ {'louvain', 'label_prop', 'greedy_modularity'}. Louvain requiere
`python-louvain` (DECLARADO); si falta, FALLA explícito (lección 7).
`random_state` (Hito R2, ADR 0017): semilla determinista de Louvain. `facade.py` la
deriva del `corpus_hash` de contenido (`_louvain_seed_from_hash`) → comunidades
reproducibles entre corridas. `None` = Louvain sin semilla. (`resolution`: Hito 9.)"""
def assortativity(g: nx.Graph, *, attribute: str | None = None,
by_degree: bool = True, proxy: str | None = None) -> dict:
"""Asortatividad por un ATRIBUTO categórico configurable (p. ej. 'region') y/o por grado.
`attribute` y sus categorías son config del USUARIO (no hardcodear; crítica #5).
`proxy` documenta si el atributo es un proxy (p. ej. 'affiliation_per_paper'): se reporta
en el output como disclaimer ('fácil pero consciente'). Validado en el sandbox IED."""
def community_composition(g: nx.Graph, communities: dict, attribute: str) -> dict:
"""% de cada categoría del atributo dentro de cada comunidad.
(Composición bibliográfica de las comunidades de una red de paper → `cluster_table`, §7.2.)"""
def cocitation_quality_report(corpus: Corpus, g: nx.Graph, *,
thresholds: "QualityThresholds | None" = None) -> dict:
"""Informe de calidad (metodología §4). Umbrales CONFIGURABLES (no fijos del estudio de
semiconductores; crítica #5). Defaults sensatos si thresholds is None."""
class QualityThresholds(BaseModel):
min_volume: int = 200
min_doi_refs_pct: float = 0.90
min_countries: int = 5
min_recurrent_authors: int = 10
Notas de contrato (Hito 2, ADR 0014):
assortativityconproxyañade una claveproxy_disclaimeral dict de salida (D4): el atributo es un proxy del campo real, no el campo real ("fácil pero consciente").cocitation_quality_reportdevuelve{criterio: {valor, umbral, pasa, ...}}+overall_pass(sin score ponderado; D6). El criteriomin_countriesusainstitutions_idcomo proxy de países (cuenta ids de institución únicos) y lo marca con un disclaimer en su entrada; el lookup ROR→país real llega en el Hito 8.
9. Núcleo — Exporter¶
class Exporter(Protocol):
def export(self, g: nx.Graph, results: dict, out_dir: str) -> None: ...
class GraphMLExporter: ... # v1 — para Gephi / VOSviewer / Cytoscape
class CsvExporter: ... # v1 — nodos.csv + aristas.csv para pandas
Notas de contrato (Hito 2, ADR 0014, D5):
CsvExporterescribearistas.csv(source,target,weight) ynodos.csv(id,label+ atributos de nodo + métricas deresults—degree/betweenness/community— unidas por id). Orden de filas determinista. Ellabel(yyear/doi/url/is_seed/curation_status/community) lo inyecta la capadecorate(§7.1) antes del export, no el exporter;doi/urlsalen solo en paper-kinds y solo cuando el paper tiene DOI.GraphMLExporterescribe esos atributos como node attributes, omite los atributos con valorNone(Gephi /nx.write_graphmlno los admiten) y no muta el grafo original (opera sobre una copia).clusters.csv(AS-BUILT #31): además denetwork.graphml+metrics.json,b2g buildescribe<networks_dir>/<kind>/clusters.csvcuando la red es de paper y tiene comunidades (cluster_tableno vacío, §7.2). Una fila por comunidad; las columnas de lista (top_authors/top_keywords) se serializan con separador|. No lo emite unExporter—lo arma el comandobuilda partir decluster_table—; las redes sin comunidades o no-paper no generan el archivo. Solo lo generanbibliographic_couplingycocitation:author_collab,institution_collabykeyword_cooccurrenceemitennetwork.graphml+metrics.jsonpero noclusters.csv, por diseño (sus nodos no son papers; ver §7.2).
10. Capa declarativa — NetworkSpec¶
class NetworkSpec(BaseModel):
model_config = ConfigDict(extra="forbid") # Hito 9: campo desconocido en el YAML → error
# accionable (no se ignora en silencio)
kind: NetworkKind # R5: enum de constants.py (fuente única, ADR 0023);
# antes era un Literal[...] duplicado (eliminado)
min_weight: int = 1
min_year: int | None = None
max_year: int | None = None
scope: Literal["full", "seeds_only"] = "full"
clustering: Literal["louvain", "label_prop", "greedy_modularity"] | None = "louvain"
resolution: float = 1.0 # Hito 9: resolución de Louvain (python-louvain best_partition).
# Default 1.0 = comportamiento anterior. Ignorado en label_prop/
# greedy_modularity (sin error). FUERA del corpus_hash (param de spec,
# no de contenido — como min_weight/scope; el seed de Louvain sigue
# siendo función pura del corpus_hash, R2).
assortativity_attribute: str | None = None # p. ej. "region"
layout: Literal["spring", "kamada_kawai", "circular"] | None = None
keyword_filter: list[str] | None = None # Issue #113: sub-red temática. Filtra el corpus ANTES
# de proyectar a los papers cuyo keywords_raw matchee
# (ANY, substring, case-insensitive) algún término.
# None/[] = sin filtro. Param de spec, FUERA del
# corpus_hash (como min_weight/scope).
def load_specs(path: str | Path) -> list[NetworkSpec]:
"""Carga y valida una lista de NetworkSpec desde YAML (Hito 9). Re-exportada desde
bib2graph.networks. Clave raíz `networks:` = lista; cada entrada se valida con
NetworkSpec(**entry) (no se redefine el schema). Errores accionables (ValueError):
YAML malformado, falta de raíz `networks:`, entrada no-dict, y ValidationError citando
archivo + `red #<idx>` (0-based) + campo."""
class NetworkArtifact:
graph: nx.Graph
metrics: dict
communities: dict | None
assortativity: dict | None
layout: dict | None
spec: NetworkSpec
class Networks:
@staticmethod
def build(corpus: Corpus, spec: NetworkSpec) -> NetworkArtifact: ...
@staticmethod
def quick(corpus: Corpus) -> list[NetworkArtifact]:
"""Arma las specs razonables y devuelve sus artefactos (caso 'investigador, baja
fricción'). Devuelve **4 o 5 redes**: coupling (full), co-autoría, institución, co-word
siempre; la **co-citación** se incluye (→5) cuando el corpus tiene `cited_by_id` poblado
(tras la pasada `cited_by` de `build`) y se **omite graceful** (log) si está vacío (→4).
Los artefactos vienen **decorados** (label legible + atributos de nodo, §7.1)."""
Modo quick cubre baja fricción; modo spec (YAML) cubre el pipeline declarativo versionable, vía
load_specs(redes.yaml) + Networks.build por red (subcomando build --spec, §convenciones CLI).
Notas de contrato (ADR 0014):
Networks.quickarma 4 o 5 redes: couplingfull, co-autoría, institución y co-word siempre (4); suma la co-citación (→5) cuando el corpus tienecited_by_idpoblado (2º nivel de fetch del Enricher, ADR 0025), y la omite avisando por log (→4) si esa columna está vacía.- Artefactos decorados:
Networks.build/quickdevuelven artefactos conlabellegible + atributos de nodo (víadecorate, §7.1); los proyectores (§7) siguen puros (ADR 0014). El símbolo público re-exportado desdebib2graphesNetworkArtifact(NetworkSpecse importa desdebib2graph.networks). resolution(Louvain) ekeyword_filter(issue #113, sub-red temática: filtra el corpus a los papers cuyokeywords_rawmatchee ANY substring case-insensitive antes de proyectar) son params de spec, fuera delcorpus_hash(comomin_weight/scope): el seed de Louvain sigue siendo función pura delcorpus_hash(R2).load_specs(clave raíznetworks:= lista; cada entrada se valida conNetworkSpec(**entry)) da errores accionables (ValueError): YAML malformado, falta de raíz, entrada no-dict, oValidationErrorcitando archivo +red #<idx>+ campo.
Campos válidos de cada entrada del YAML (kind obligatorio, resto con default; extra="forbid" →
campo desconocido se rechaza con ValueError; name: NO es un campo — anotá con comentario #):
| Campo | Valores / tipo | Default |
|---|---|---|
kind |
bibliographic_coupling · cocitation · author_collab · institution_collab · keyword_cooccurrence |
(obligatorio) |
min_weight |
int |
1 |
min_year / max_year |
int |
null |
scope |
full · seeds_only |
full |
clustering |
louvain · label_prop · greedy_modularity · null |
louvain |
resolution |
float (solo Louvain) |
1.0 |
assortativity_attribute |
str (p. ej. region) |
null |
layout |
spring · kamada_kawai · circular · null |
null |
keyword_filter |
list[str] (ANY substring sobre keywords_raw) |
null |
networks:
- kind: bibliographic_coupling
min_weight: 2
resolution: 1.5
- kind: keyword_cooccurrence
keyword_filter: ["complex", "ecolog"]
11. Deduplicación fuzzy — AUTOMÁTICA en la ingesta (rapidfuzz núcleo)¶
Dedup fuzzy determinista con rapidfuzz (núcleo desde #88): el complemento aproximado de la
normalización conservadora del Preprocessor (§6). Las funciones siguen exportadas desde
bib2graph.preprocessors, pero se invocan automáticamente desde el helper canónico
preprocessors.pipeline::normalize_and_dedup, no a mano. Operan sobre la columna _id
(authors_id/keywords_id), nunca sobre _raw.
# Helper canónico — punto único de la ingesta (preprocessors/pipeline.py;
# re-exportado por compat desde cli/_ingest.py → el import viejo
# `from bib2graph.cli._ingest import normalize_and_dedup` sigue vivo, no es breaking)
def normalize_and_dedup(corpus: Corpus, *, applied_at: datetime | None = None) -> Corpus:
"""normalize → deduplicate_authors(0.92) → deduplicate_keywords(0.90), en ese orden, sobre el
corpus COMPLETO YA MERGEADO (existing + incoming) ⇒ dedup CROSS-BIBLIOTECA. NO aplica thesaurus
(eso es el flag explícito `b2g build --thesaurus`, #164). `applied_at` se inyecta desde la frontera (R2)."""
# Funciones de librería (ADR 0026, intactas; ahora invocadas por el helper, no a mano)
def deduplicate_authors(corpus: Corpus, *, threshold: float = 0.92) -> Corpus:
"""Colapsa variantes de `authors_id` por similitud de nombres (fuzzy DETERMINISTA). Lo trivial
ya lo hizo el Preprocessor (§6); esto es el complemento aproximado."""
def deduplicate_keywords(corpus: Corpus, *, threshold: float = 0.90) -> Corpus:
"""Colapsa variantes de `keywords_id` fuera del thesaurus por similitud de cadenas."""
Notas de contrato (ADR 0026 / 0031):
- Automático en la ingesta, cross-biblioteca: las cuatro rutas
(
seed/seed_from_bib/chain/restore) hacenexisting.merge(incoming)→normalize_and_dedup(corpus_completo)→store.persist_replace(...). Corre sobre el corpus completo (no el lote) para deduplicar contra toda la biblioteca acumulada; se persiste conpersist_replace(§4.1) porque el upsert-concat D3 reintroduciría las variantes colapsadas.buildsigue puro (el corpus ya entra deduplicado). thresholdpor-campo (autores0.92/ keywords0.90):rapidfuzz.fuzz.token_sort_ratio(0–100) contrathreshold * 100. Umbrales fijos como constantes públicasTHRESHOLD_AUTHORS/THRESHOLD_KEYWORDSdebib2graph.preprocessors(fuente única enpreprocessors.pipeline, issue #175): el umbral compartido por la ingesta y elrestorees uno solo, sin copias que diverjan.- Determinista e idempotente: los pares ≥ umbral forman componentes conexas vía Union-Find; el
canónico del cluster es la variante más frecuente (desempate por
idascendente); se preserva el orden de primera aparición y nunca se toca_raw. Mismo corpus + threshold + versión derapidfuzz→ mismo resultado (verificado cross-PYTHONHASHSEED); converge en una pasada. NO usa IA (similitud de cadenas, no semántica/LLM; ADR 0022). Registra unPreprocRefen elManifest({library, rapidfuzz_version, scorer, threshold, n_clusters_collapsed}). rapidfuzzen el núcleo:rapidfuzz>=3,<4en[project.dependencies](ya no hay extra[dedup]).- Campos en V1: autores + keywords. Instituciones diferidas;
splink(record-linkage probabilístico) diferido a post-V1 (ADR 0026). Deuda conocida: el dedup por ingesta es O(n²) sobre el corpus completo (optimización futura). La revisión asistida de clusters ambiguos (sugerir N canónicos determinista vía scores, sin IA → el humano elige) requiere superficie interactiva y no está; hoy el dedup aplica el canónico determinista sin confirmar.
12. Ejemplo de uso (ecuación → biblioteca viva → redes)¶
12.1 Por CLI agente-native (el camino canónico)¶
Se inicia el workspace una vez y, trabajando dentro de su carpeta, los comandos se resuelven por
ambiente. Con B2G_JSON=1, una línea JSON por comando (un agente corre el ciclo sin repetir --json):
b2g init ied # crea ./ied/ (workspace.json + library.duckdb + …)
cd ied # a partir de acá el workspace se resuelve por cwd
export B2G_JSON=1
b2g seed --equation '"unequal ecological exchange"' --max-results 50 \
--exclude "blockchain" --email tu@correo.org # --exclude (repetible): negaciones en el translation_report
b2g chain --direction both --max-candidates 300 # → FORAGED (+ pasada refs→DOI automática)
b2g curate dump # vuelca candidatos a exports/curacion.csv (revisar offline)
b2g curate apply curacion.csv # aplica accepted/rejected en lote
b2g build --max-citing 50 --email tu@correo.org # → BUILT; co-citación (cited_by) sobre las aceptadas
b2g read top --kind bibliographic_coupling # salida de investigación (nodos centrales + co-citación)
b2g export --format graphml # serializa networks/ a exports/
b2g snapshot create # foto reproducible (parquet + manifest.json)
b2g status # CycleState + round + curation_available + workspace
Migración de un .duckdb legacy: corré b2g init . en su carpeta para adoptarlo como workspace.
El modo declarativo se invoca con b2g build --spec redes.yaml (carga load_specs → Networks.build
por red, escribe networks/<kind>/, transiciona a BUILT y sella .corpus_hash).
12.2 Como librería Python¶
El mismo dominio sin CLI (el núcleo es puro y testeable; el forrajeo y el store hacen I/O):
from pathlib import Path
from bib2graph import (
OpenAlexSource, Forager, Preprocessor, DuckDBStore, Networks, GraphMLExporter,
FilterCriterion, apply_filters,
)
# 1) Sembrar (query ejecutada + reporte de traducción visibles)
seed = OpenAlexSource(email="tu@correo.org").seed(
'"unequal ecological exchange" OR "intercambio ecológico desigual"')
print(seed.executed_query, "\n".join(seed.translation_report))
# 2) Forrajear (depth=1; backward observa sin materializar, forward materializa filas reales)
forager = Forager(OpenAlexSource(email="tu@correo.org"), depth=1, max_candidates=300)
ranked = forager.chain(seed.corpus)
# 3) Curar + normalizar + thesaurus determinista
corpus = seed.corpus.merge(ranked.corpus).accept(ids=[...]).reject(ids=[...])
corpus = Preprocessor().normalize(corpus)
corpus = Preprocessor().apply_thesaurus(corpus, Path("thesaurus_ied.json"))
# 4) Filtrar (PRISMA: marca rejected, no borra) + persistir + snapshot + redes
corpus, steps = apply_filters(corpus, [
FilterCriterion(field="year", op="gte", value=2010),
FilterCriterion(field="language", op="in", value=["en", "es", "pt"]),
])
store = DuckDBStore(Path("ied/library.duckdb")); store.persist(corpus)
snap = store.load().snapshot(Path("ied/snapshots/ied"))
for art in Networks.quick(snap.corpus):
GraphMLExporter().export(art.graph, art.metrics, out_dir=Path(f"ied/networks/{art.spec.kind}"))
DuckDBStore se importa desde bib2graph (re-export perezoso vía PEP 562, §4.1): import bib2graph
no arrastra duckdb.