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, único 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 (B2GError UsageError) · 2 datos (DataError; también errores de parseo de
Click tras la enmienda 2026-10-11 #283, re-canalizados como envelope en modo JSON) · 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 — 10 verbos del ciclo + skill + schema = 12 registros exactos (ADR
0037/0038/0039/0045).
La superficie mapea 1:1 el ciclo (más es menos); el conteo es verificable contra b2g --help (12
comandos de nivel superior, ni uno más):
- 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"). - 2 comandos meta fuera del set de 10 (no son pasos del ciclo; ni FSM ni workspace):
skill(grupo{add,providers}; ADR 0039, ADR 0046 #193) yschema(ADR 0045 #260, ver §schema). Un gruposkillsin subcomando imprime ayuda y sale exit 0 (como los demás grupos).
Retirado en 0.12.0 (BREAKING, #207, ADR 0038 P1 ejecutado): los 9 verbos planos deprecados
—accept, reject, filter, inspect, monitor, networks, enrich, restore, resolve— ya
no se registran (invocarlos da el error estándar de Click No such command). Su lógica vive en las
formas canónicas del ciclo (ver tabla en §Formas canónicas de los verbos retirados). También se
retiraron el entry-point legado bib2graph (queda solo b2g), el flag build --corpus-scope
(queda build --scope, default all) y el módulo cli/_deprecation.py. thesaurus nunca fue
alias: se retiró por completo (su capacidad es build --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} (el bloque hoy es universal en los comandos del
ciclo, ADR 0045 #259 — ver §Envelope), networks_cache_stale: bool (+ warnings accionable
cuando la cache de networks/ quedó obsoleta — avisa, NO regenera), referenced_not_fetched (nº de
IDs que el backward chaining observó sin materializar; §4/§5) y equations (lista de las
ecuaciones registradas en el store, leída de backend.load_equations(); QA 0.14.0 #2, ADR 0050 D1):
cada entrada es {equation_id, raw_query, engine, created_at}; siempre presente ([] si no hay
ecuaciones, sin romper). En modo humano se imprime la sección "Ecuaciones registradas (N):". Así
la procedencia deja de ser invisible (antes solo se veía en la metadata del .arrow exportado) sin
agregar un verbo (es estado, no una acción — ADR 0037). 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). Los términos se combinan en AND dentro de la única expresióntitle_and_abstract.search:(...): más términos ⇒ menos resultados (el--helpdel flag lo explicita, ADR 0049 #287, para que un agente que obtiene 0 papers sepa aflojar la ecuación en vez de agregarle términos).--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(service/resolve.py::resolve_dois) 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).
--preview(dry-run, ADR 0049 #287): traduce la ecuación y muestra la query de OpenAlex (title_and_abstract.search:(...)) SIN fetchear ni tocar el corpus — la forma barata de ver a qué se traduce una ecuación antes de gastar red o cuota. Solo válido con--equation/--spec(con--from-bib→UsageErrorexit 1: no hay traducción que previsualizar). Corta antes de resolver workspace → funciona sin workspace (no requiereb2g init; no transiciona la FSM). En--jsonemite el envelope estándar condata.preview = true,data.executed_query(la query exacta) ydata.translation_report(mapeos limpios/aproximados/descartados + negaciones); no traepapers_added/total_papers(nada se materializó). Es el análogo dechain --previewpara el paso SEED.
Credenciales de OpenAlex (seed/chain/build; ADR 0012).
Dos credenciales, ambas inyectadas, ninguna obligatoria:
- API key — env
OPENALEX_API_KEY(no hay flag--api-keyen los verbos vivos: es un secreto, entra solo por entorno para no aparecer en el envelope--json). Precedencia: argumentoapi_key=(solo Python) > envOPENALEX_API_KEY> ausencia ⇒ polite pool. Con key se manda como headerAuthorization: Bearer <key>. Sube el rate limit (relevante con el modelo de créditos de OpenAlex 2026, #124). Sin key elSourceno rompe: corre en polite pool con menor límite (sin degradación de resultados, solo de velocidad). --email(polite pool) — no es secreto (identificador de cortesía); viaja comomailtoen la query y mueve las peticiones al polite pool (límite más generoso que el anónimo).
Ante 429/5xx, seed reintenta con backoff exponencial (3 intentos, el mismo camino que el
forrajeo forward; ADR 0049 #287): antes seed fallaba a la primera con un 429; ahora sólo aflora el
error al agotar los reintentos. Sin cambio de contrato: mismos exit codes al agotar y mismo
mensaje accionable (ADR 0012). Un 429 agotado tras
retry/backoff aflora como NetworkError (exit 4) con mensaje accionable (declarar --email /
configurar la key) y error.subcode = "RATE_LIMITED" en el envelope --json (grieta 3a, ADR
0045 #258); análogamente un 504 agotado da
error.subcode = "UPSTREAM_TIMEOUT". Ver error.subcode en §Envelope.
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, budget_used, budget_stopped}.
- Forrajeo acotado — guardarraíles anti-footgun (#309, MVP aditivo). El forrajeo forward es
multiplicativo (cada semilla dispara sus propias llamadas) y su costo es invisible hasta que ya se
gastó: un
chainsin acotar sobre cientos de semillas puede agotar la cuota de OpenAlex (429 account-wide, recuperación de horas). Estos flags acotan y presupuestan, todos aditivos — los defaults NO cambian (sin ninguno: todas las semillas, comportamiento previo). El flip a un default seguro/acotado (--exhaustivecomo opt-in) es la Discussion #310. - Unidad escopada (de qué papers-origen se forrajea), mutuamente excluyentes entre sí:
--ids(repetible; IDs explícitos de papers-origen, valida contra el corpus),--top N(las N semillas más "centrales"),--scope [all|accepted|seeds](reusaCorpus.scoped, vocab CLIseeds→seeds_only). Combinar dos →UsageErrorexit 1. --top Nusa el nº dereferences_idcomo PROXY de centralidad (degradación documentada): ordena las semillas porlen(references_id)desc (desempateidasc), no por la centralidad real del acoplamiento bibliográfico sobre el grafo construido. Es puro y no requiere unbuildprevio ni artefactos en disco. Para la centralidad real, correrb2g buildy pasar los IDs más centrales delmetrics.jsonvía--ids/--scope(cli/commands/chain.py::_top_n_seed_ids).--previewrefleja el scoping (#309): la estimación de fanout se acota al subconjunto resuelto; sumadata["origin_count"](cuántos papers-origen participan del fanout estimado).-
--budget Ntopa las llamadas HTTP del forward chaining; al alcanzarlo el forrajeo para limpio, sin reintentar (nunca reintenta en loop sobre 429, complemento de #306). El resultado queda parcial pero consistente y marcado condata["budget_stopped"] = true+data["budget_used"](llamadas realizadas).budget_usedno cuenta las llamadas de la pasada refs→DOI posterior (acotada, fuera de alcance del budget). Sin--budget: sin tope (budget_used=0,budget_stopped=false). VerCallBudgeten §5. -
chain forward/bothpueblacited_by_idde las semillas alcanzadas (ADR 0048, #270): el forrajeo hacia adelante ya trae los citantes; con esta decisión completa además la columnacited_by_idde esas semillas (unión idempotente víaCorpus.merge), dejando listo el insumo delCoCitationProjector. Así el lazo naturalseed → chain forward → curate accept → buildproduce la red de co-citación sinenrichni flag que descubrir.chain backwardpuro no tocacited_by_id. candidates_foundes el total de candidatos rankeados que ve--preview(backward observados-
forward materializados, recortado por
--max-candidates), NO el número de filas materializadas en el corpus (#269). En chaining puramente backward los IDs observados no se materializan como filas (opción B, #54): viven enobserved_refs/el ranking, así quecandidates_foundpuede excedertotal_papers(el corpus no crece con backward). No confundir connew_candidates(filas nuevas respecto del corpus previo). -
--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 forrajeo incremental que antes hacíab2g monitorvive acá; ese verbo suelto se retiró en 0.12.0.)
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 ex verbo suelto
b2g enrich, que corría ambas pasadas sin transicionar, se retiró en 0.12.0: usá b2g chain para la
pasada refs→DOI y b2g build para la de co-citación.)
buildya no es el único ni el primer poblador decited_by_id(ADR 0048, #270): desde quechain forward/bothpueblacited_by_idde las semillas alcanzadas, el insumo de la co-citación suele llegar ya poblado albuild. La pasada 8b debuildsigue existiendo (la frase de arriba sigue siendo cierta) y se solapa transitoriamente con lo que dejóchain; el solapamiento es inocuo porque la unión sobrecited_by_ides idempotente (no duplica ni corrompe). Diferencia de alcance:chain forwardpueblacited_by_idde las semillas al traer los citantes (independiente de la curación); la pasada 8b debuildestá atada aaccepted.
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 ...] [SELECTOR] [--by NOMBRE]/curate reject [--ids ...] [SELECTOR] [--by NOMBRE]— acepta/rechaza por identificador y/o por selector declarativo (curación masiva, #308).--idsya NO es obligatorio. Cada--idsacepta las tres formas: id interno, DOI crudo osource_id(ADR 0049 #287), con la misma prioridad queread show: id > doi > source_id (ADR 0036). La resolución vive en el helper compartidoservice/_identity.py::resolve_idents, reusado porcuratey porread.get_paper(fuente única de identidad). Si algún identificador no resuelve, el error lista los no resueltos y aclara que se aceptan las tres formas.- SELECTOR declarativo (#308):
--query(substring de título, case-insensitive, mismo criterio queread list --query),--year-gte/--year-lte,--language(repetible),--type(repetible),--min-citations— misma semántica quecurate filter. El conjunto afectado es la UNIÓN de--idsresueltos y lo que matchea el selector; se combinan libremente (no hace falta que se solapen). Sin--idsni selector →DataErrorexit 2 (mensaje que lista los criterios). Los ids resueltos por selector nunca fallan por "no encontrado" (se derivan del corpus mismo). data = {accepted_count|rejected_count, ids, selector_matched_count}.ids= todos los ids afectados (unión, ordenados);selector_matched_count= cuántos vinieron del selector (0si no se usó). Usanaccept_papers/reject_papersdeservice/curate.py(fuente única de curación).curate acceptpor selector PUEDE revertir unrejected→accepted—no es una inconsistencia. Esto es distinto decurate filter, que nunca toca unaccepted(ADR 0044: la inclusión manual gana). La asimetría es deliberada:filteres exclusión PRISMA automática (respeta la decisión manual previa);accept/rejectson la decisión manual explícita y por eso sobrescriben el estado anterior, incluso vía selector. Uncurate accept --query "..."que matchea un paper antes rechazado lo re-acepta a propósito.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)deservice/curate.py(fuente única). La inclusión manual gana (ADR 0044): el filtro omite los papersaccepted(nunca los rechaza, aunque no cumplan el criterio), igual que ya omite a losrejected; solo marcarejecteda los no-aceptados (candidatey demás) que no pasan.
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/, = ws.exports_dir).
export --format arrow|bibtex — serializa el CORPUS (ADR
0050 D4, #293, extendido por ADR
0052 #321 para embeber la red tipada cuando hay
b2g build previo). export gana dos formatos nuevos al --format existente (graphml/csv):
NO es un verbo nuevo (respeta la poda de 10 verbos; export/snapshot = un verbo) y no
transiciona la FSM. A diferencia de graphml/csv (que serializan redes de build releyendo
networks/), estos serializan el corpus del store vivo:
--format arrow→ escribecorpus.arrow= Feather / Arrow IPC (no parquet), víaArrowExporter(exporters/arrow.py). Si hayb2g buildprevio del kind pedido, la tabla se extiende con la red tipada (columnas struct anidadas + metadatanetwork_type/…; ADR 0052 D1) — ver §1.8 Red tipada en el Arrow del corpus. Si no, la tabla se exporta sin columnas de red (modo degradado honesto, ADR 0052 D4). La metadata de ecuación +equation_hash(§1.6) viaja dentro del Feather → archivo autoverificable que el consumidor programático sube y valida. Es un artefacto distinto desnapshot create(parquet +manifest.json, reproducibilidad interna): no se solapan.--network-type <kind>(defaultbibliographic_coupling): selecciona qué red tipada se embebe (reusa elNetworkKindStrEnum deconstants.py, fuente única, ADR 0023; ADR 0052 D2). Solo aplica a--format arrow(no abibtex/graphml/csv). Una red por Arrow (multi-red en un mismo Arrow queda diferida a 0.16.0+). Kinds no-papers (author_collab/institution_collab/keyword_cooccurrence): los nodos no son papers, no se pueden anclar en el struct anidado → la tabla sale sin columnas de red y se emite unwarningaccionable ("network_type=<kind> not supported in Arrow export; use --format graphml for this kind").cocitationcon corpus sincited_by_idpoblado →DataError(exit 2, accionable: "run 'b2g chain forward' / 'b2g enrich' first").--no-network(enmienda post-0.15.0 al ADR 0052 D4): exporta el Arrow sin red aunque haya unb2g buildprevio — mismo resultado que el modo degradado (sin columnas ni metadata de red, campos D5 ennull/0) pero sin warning (es un pedido explícito). Excluyente con--network-typeexplícito (USAGE_ERROR, exit 1). Desde Python, el equivalente es serializarcorpus.to_arrow()sin pasar porarrow_with_network.--format bibtex→ escribecorpus.bibparseable, víaBibtexExporter(exporters/bibtex.py; extra[bibtex]— import perezoso debibtexparser, falta → exit 3). Defaults del MVP (ADR 0050 D4, congelados): entry-type inferido con@articledefault (heurística mínima:booktitle/sinjournal→@inproceedings); citekey = elidinterno del paper (doi:…/src:…/tt:…, estable y sin colisiones), el DOI va como campo (doi = {…}+url = https://doi.org/<doi>); campos mínimos universalestitle,author(and-joined),year,doi,journal(= venue/source),url—keywords/abstractfuera del MVP; scope = el corpus scopeado por--scope, sin filtrar por "metadata suficiente" (las entradas incompletas se emiten con los campos que haya, no se descartan).
--scope [all|accepted|seeds] (default all; Choice): filtra el corpus antes de serializar,
reusando Corpus.scoped(scope) (vocab CLI seeds→seeds_only; §1.2). accepted EXCLUYE a los
rejected aunque sean semillas (ADR 0051, #307):
un paper rechazado por curate reject/filter NO sale en --scope accepted —coincide con
.exclude(curation_status='rejected') sobre el Arrow. Solo aplica a los formatos de
CORPUS (arrow/bibtex). Con graphml/csv se IGNORA (esas redes ya vienen scopeadas por b2g
build --scope); si se pasa --scope explícito con un formato de red, export emite un warning
accionable (no error). --scope es propio de export — es distinto del build --scope de §build.
Envelope --json (aditivo, schema="1" intacto): data = {format, out_dir, files_written,
workspace} más una clave según la familia — rows_exported (nº de filas del corpus)
para arrow/bibtex, networks_exported (nº de redes) para graphml/csv. Son claves
distintas, NO unificadas: el consumidor sabe por cuál mira qué familia exportó. Para --format
arrow se suman 7 campos aditivos del bloque de red (ADR 0052 D5) — network_type,
network_nodes, network_edges, network_communities, network_modularity, network_type_algo,
network_resolution — todos null/0 cuando no hay red embebida (modo degradado o kind no-paper);
ver §1.8. Los avisos (p. ej. --scope ignorado con graphml/csv, equation_hash omitido, modo
degradado, kind no-paper) van en el warnings top-level del envelope, NO en data (canal único,
§Envelope). --out-dir override (default ws.exports_dir).
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). Flags:
--scope [all|accepted|seeds](defaultall): filtra el corpus por curación antes de proyectar (Corpus.scoped, §1.2).accepted=is_seed+ aceptados menos losrejected(ADR 0051, #307: rechazar una semilla la saca deaccepted);seeds= solo semillas (incluye rechazadas). 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). Es la única forma: el flag--corpus-scope(y su vocab internoseeds_only) se retiró en 0.12.0 (#207, BREAKING).--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). Hint de co-citación → chain forward (ADR 0049 #287): si hay semillas pero ninguna
aceptada y cited_by_id está vacío, build agrega un warning que apunta a b2g chain forward
para poblar cited_by_id (camino de ADR 0048) sin aceptar en masa —en vez de forzar aceptar todos
los candidatos sólo para computar la red—. Es sólo descubribilidad: el gate del enricher no
cambia (la pasada 8b de co-citación sigue atada a accepted, ADR 0025). --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) y reconstruye la tablaequations(D1, ADR 0050) desde elmanifest.jsonhermano del parquet si existe —graceful: sin manifest hermano (oequationsausente) → 0 ecuaciones, sin error—, cerrando el round-trip de procedencia consnapshot create. 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 sueltob2g restorese retiró en 0.12.0; su capacidad esb2g snapshot 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, vía el helper compartidoservice/_identity.py::resolve_idents—misma resolución quecurate accept/reject, ADR 0049).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 [--provider claude-code|opencode] [--user|--project] [--force] (comando meta, ADR
0039, enmendado por ADR
0046 #193): instala la skill 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 elegido por
--provider (default claude-code; el provider es un dato, no una rama de control, ADR 0046) y
scope --user (default) o --project:
--provider |
--user (default) |
--project |
|---|---|---|
claude-code (default) |
~/.claude/skills/bib2graph/ |
.claude/skills/bib2graph/ |
opencode |
~/.config/opencode/skills/bib2graph/ |
.opencode/skills/bib2graph/ |
La transformación del contenido es identidad (mismo SKILL.md para todo provider; OpenCode ya lee
.claude/skills/ de todos modos — ADR 0046, fase 1). 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 / OpenCode
del usuario, no en el producto; ADR 0022). data = {install_path, scope, provider, installed,
already_present, skill_md, reference_dir, how_to}.
skill providers [--json] (comando meta, ADR 0046 #193): enumera los providers soportados por
skill add, para que un agente descubra por introspección qué clientes puede targetear sin leer el
código ni la doc. Sigue el patrón de skill/schema: no transiciona la FSM, no resuelve workspace, no
lee el store ni hace red. data = {providers: [{name, default, description, project_root, user_root}],
default_provider} (con default_provider = "claude-code"; las rutas van relativas, tal como se resuelven
contra cwd/home).
schema (comando meta, ADR 0045 #260):
introspección del contrato por el mismo canal de invocación, para que un agente en frío conozca la
forma del envelope y los exit codes sin salir a docs/API.md en prosa. Sigue el patrón de skill: no
transiciona la FSM, no resuelve workspace, no lee el store ni hace red — determinista y estático (mismo
resultado en cualquier invocación). data = {contract_version, envelope_schema, exit_codes,
surface_summary}: contract_version = ENVELOPE_SCHEMA_VERSION (hoy "1"); envelope_schema
= JSON-schema Draft-07 simplificado del envelope {schema, ok, command, exit_code, data, warnings,
error} (incluye error.subcode como propiedad opcional); exit_codes = los 6 (0–5) con name y
meaning (el 4 menciona subcode); surface_summary = descripción textual de la superficie
("10 verbos del ciclo + skill + schema"). Ver cli/commands/schema.py::build_schema_data().
b2g schema --json # envelope con el contrato legible por máquina
b2g schema # resumen humano: versión, exit codes y superficie (stderr)
Resolución DOI→source_id (seed --from-bib --resolve, ADR 0035): 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). Suma
data["resolve"] = {resolved, total_with_doi, already_resolved, total_papers}. NO transiciona. Red
caída → NetworkError exit 4; store bloqueado → StoreError exit 5. (El ex verbo suelto b2g resolve
se retiró en 0.12.0: su ruta única es seed --from-bib --resolve.)
Formas canónicas de los verbos retirados¶
Los 9 verbos planos que existieron como alias deprecados durante 0.10.x se retiraron en 0.12.0
(#207, ADR 0038 P1, BREAKING). Invocarlos hoy da el error estándar de Click (No such command). Su
lógica no se perdió: vive en la superficie del ciclo (fuente única en service/).
| Verbo retirado (0.12.0) | 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 --spec |
b2g build --spec (paso BUILD pleno: transiciona y sella .corpus_hash) |
b2g enrich |
b2g chain (refs→DOI) + b2g build (co-citación) |
b2g restore |
b2g snapshot restore |
b2g resolve |
b2g seed --from-bib --resolve |
Además se retiraron el entry-point legado bib2graph (queda solo b2g) y el flag
build --corpus-scope (queda build --scope). thesaurus nunca fue alias: se retiró por completo
en su momento; su capacidad es build --thesaurus (#164).
--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}, skill, schema) 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>}.
Canal único de warnings (ADR 0021 §C, enmienda QA 0.14.0 #3). Los avisos no fatales van
SIEMPRE en el campo warnings top-level del envelope y NUNCA duplicados dentro de data:
el consumidor mira un solo lugar. Aunque un servicio interno (run_export/run_build) devuelva un
data["warnings"] propio (lo conserva para sus tests), la superficie CLI lo extrae (pop, no
get) antes de emitir, así el texto sobrevive en un único lugar. Excepción por diseño:
data["empty_networks"] de build no es un warning sino un diagnóstico estructurado por-red
({kind, reason, fix_command}) que vive en data, separado del canal warnings (§build).
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).
error.subcode — desambiguación de red (ADR 0045 #258).
El objeto error puede llevar un campo opcional subcode, poblado solo para
code="NETWORK_ERROR" (exit 4). Valores: RATE_LIMITED (HTTP 429, transitorio → reintentar con
backoff) y UPSTREAM_TIMEOUT (HTTP 504 o timeout → no reintentable sin cambiar la petición). Cuando
no aplica, el campo no aparece (no se emite subcode: null). Es aditivo: code sigue
NETWORK_ERROR y exit_code sigue 4, sin cambios en el mapeo. El source lo puebla al agotar reintentos
(sources/openalex.py en 429/504); si una httpx.HTTPStatusError/httpx.TimeoutException cruda llega a
handle_errors sin traducir, el borde CLI lo deriva del response.status_code (429/504) o asume
UPSTREAM_TIMEOUT para timeouts (cli/_errors.py::_subcode_for_http_error; service/errors.py::subcode_for_status).
Mensaje accionable del RATE_LIMITED (#306). Un 429 no es un problema de conexión y
"reintentá" es el consejo contrario al correcto ante un rate-limit account-wide (recuperación de
horas): un agente que lo sigue al pie de la letra reintenta en loop y quema más cuota. Por eso el
message de un 429 NO usa el genérico "Error de red… Verificá tu conexión… reintentá"; nombra el
error como rate-limit / cuota, incluye el Retry-After si el upstream lo declaró, y sugiere
esperar el reset o reducir el alcance del forrajeo (chain --top N/--ids, ver §5 y #309). El
subcode RATE_LIMITED sigue siendo la señal para ramificar sin parsear texto; el mensaje solo deja de
misdiagnosticar el caso humano. Aplica tanto al 429 traducido por OpenAlexSource
(sources/openalex.py::_build_rate_limit_message) como al 429 crudo que llegue al borde CLI
(cli/_errors.py::_rate_limit_fallback_message).
data.workspace universal + warning de walk-up (ADR 0045 #259). Todos los comandos del ciclo que
resuelven un workspace ecoan data["workspace"] = {"root": <str|null>, "source": <str>} (antes solo lo
hacía status). source ∈ {flag, env, cwd, init}, según la precedencia de resolución (ADR 0029;
la precedencia no cambió). Cuando source == "cwd" (resolución implícita por walk-up del cwd, sin
--workspace/B2G_WORKSPACE) se anexa a warnings el mensaje literal (constante
WORKSPACE_WALKUP_WARNING, cli/_store.py):
Los comandos meta que corren sin workspace (init, skill, schema) no ecoan data.workspace
ni emiten este warning.
Borde: el error de uso de Click se re-canaliza al envelope en modo JSON (enmienda 2026-10-11, #283).
Ante una opción requerida faltante, una opción desconocida (p. ej. --store) o un comando inexistente
(NoSuchCommand), Click aborta el parseo antes de entrar al cuerpo del subcomando, así que el
decorador @handle_errors (que cubre el camino de errores de dominio) nunca los ve:
- Modo JSON (
--jsonflag local o envB2G_JSONtruthy): stdout recibe una línea envelope conschema="1",ok=false,error.code='USAGE_ERROR',error.message=<click message>,exit_code=2, ycommandbest-effort (parseado del mensaje paraNoSuchCommand, deexc.ctx.command.namepara el resto;""si no se puede determinar).stderrqueda vacío. - Modo humano (sin flag ni env): comportamiento previo intacto — texto a
stderr+ exit 2.
--json en argv antes del parseo (fix post-0.15.0). Click aborta en el primer token inválido y
nunca alcanza el --json del subcomando (p. ej. b2g monitor --json, b2g export --json --format
wrongformat). Por eso main() busca --json en el argv crudo (hasta el separador --) además de
B2G_JSON; ambos caminos dan el mismo envelope. Hasta 0.15.0 solo funcionaba B2G_JSON=1 (bug B1
reportado por la validación de Atalaya). --json puesto antes del verbo (b2g --json monitor) lo
rechaza el grupo raíz: también sale como envelope, con command="b2g".
El interceptor vive en cli/_click_error_handler.py (helper puro +
emit_click_usage_error_envelope) y se cablea en main(). El envelope tiene la misma forma que
cualquier error:
{
"schema": "1",
"ok": false,
"command": "monitor",
"exit_code": 2,
"data": {},
"warnings": [],
"error": {"code": "USAGE_ERROR", "message": "No such command 'monitor'."}
}
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).
Verbos deprecados retirados (histórico, ADR 0038 P1)¶
Durante 0.10.x, la consolidación del 0037 dejó vivos —con aviso de deprecación a stderr— los 9 verbos
planos que solapaban con la superficie del ciclo. Ese periodo terminó: en 0.12.0 (#207, BREAKING) los
9 aliases, el entry-point legado bib2graph, el flag build --corpus-scope y el módulo
cli/_deprecation.py se retiraron por completo. Ya no hay aliases vivos ni avisos de deprecación:
invocar un verbo retirado da el error estándar de Click (No such command).
El mapeo de cada verbo retirado a su forma canónica vive en §Formas canónicas de los verbos
retirados (arriba). thesaurus nunca fue alias: se retiró por completo; su capacidad es
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
# ADR 0045 #258: __init__(message, *, subcode=None) — subcode aditivo (RATE_LIMITED / UPSTREAM_TIMEOUT),
# propagado a error.subcode del envelope solo para NETWORK_ERROR (ver §Envelope).
class StoreError(B2GError): exit_code = 5; code = "STORE_ERROR" # store/snapshot bloqueado o corrupto
# Subcodes de red (ADR 0045 #258) + mapeo de status HTTP → subcode
RATE_LIMITED = "RATE_LIMITED"; UPSTREAM_TIMEOUT = "UPSTREAM_TIMEOUT"
def subcode_for_status(status_code: int) -> str | None:
"""429 → RATE_LIMITED, 504 → UPSTREAM_TIMEOUT, otro → None. Función pura."""
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, source_kind}. Ver nota abajo (ADR 0013 D4, 0050 D2) |
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",
"source_kind": "seed_equation | chaining_forward | chaining_backward | 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".
source_kind (ADR 0050 D2, 0.15.0)
desambigua el origen semántico del evento sin sobrecargar equation_id:
"seed_equation"— el paper entró por una ecuación de búsqueda (equation_id="eq-…"FK a la tablaequations, §1.5;chaining_hop=null)."chaining_forward"— citante traído por forward chaining (equation_id=null;chaining_hop=1+)."chaining_backward"— referencia forrajeada por backward chaining (equation_id=null;chaining_hop=1+).null— eventos que no aplican (loaddesde BibTeX,fetched_by_id, curaciónaccepted/rejected).
chaining_hop no se renombra (constraint del consumidor programático, §1.7). equation_id deja de
contener el prefijo chaining: en escritura nueva: el campo pasa a ser FK pura a equations.equation_id
o null. Corpus viejos con equation_id="chaining:…" se migran on-read vía
parse_provenance_migrated (bib2graph.schemas, #320): el prefijo se traduce a source_kind y se
confirma chaining_hop; el equation_id queda null en la vista migrada. La serialización que sale al
parquet/Feather sigue siendo el JSON canónico del evento (Pydantic v2, orden determinista).
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.
ADR 0050 D3: puebla la METADATA del schema Arrow con `equation_hash`/`equation_hash_algo`/
`equation_expression`/`equation_id` cuando hay exactamente 1 ecuación en la tabla `equations`
(§1.5/§1.6). Con 0 o >1 ecuaciones la metadata se omite (silenciosa acá; el warning lo emite
el punto de consumo). El schema de columnas + el dominio de `curation_status` son contrato con
consumidores externos (el consumidor programático, §1.7)."""
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') AND curation_status != 'rejected'`
(ADR 0051, #307: una semilla RECHAZADA queda EXCLUIDA de `accepted` aunque sea semilla —
rechazar refleja la curación PRISMA); `'seeds_only'` = `is_seed == True` (**NO** excluye
rejected: una semilla rechazada SÍ aparece — scope estructural "qué entró", numerador PRISMA;
decisión explícita del ADR 0051, no omisión). 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`
internamente) para sellar el hash del
corpus FILTRADO. Issue #56 / #159 / #307. **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
# EquationRef vive en `bib2graph.corpus` (NO en `schemas`).
# ADR 0050 D1: extendida con engine/params/created_at;
# se PERSISTE en la tabla `equations` del store vivo (§1.5)
# y `DuckDBStore.load()` la RECONSTRUYE de vuelta (D1
# completo, QA 0.14.0 #1): el snapshot sella la tabla
# fielmente, no `[]`.
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) -> list[str]: ... # los ref_id observados, en orden de inserción
# ADR 0050 D1: tabla hermana `equations` (objeto ecuación de 1ª clase; FUERA del corpus_hash,
# como referenced_but_not_fetched). Firmas y schema en §1.5.
def persist_equation(self, equation_id: str, *, engine: str, raw_query: str,
params_json: str, label: str | None = None,
created_at: str | None = None) -> None: ... # upsert idempotente por equation_id
def load_equations(self) -> list[dict[str, object]]: ... # filas persistidas, orden de inserción
| 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.
1.5 Objeto/tabla equations — la ecuación como objeto de 1ª clase (ADR 0050 D1, v1)¶
La ecuación de búsqueda se persiste como objeto de 1ª clase en el store vivo (ADR
0050 D1, 0.14.0), no solo como el
equation_id string suelto dentro del JSON de provenance. Es una tabla hermana del backend
(análoga a loop_state_log/referenced_but_not_fetched): SQL en DuckDBBackend, estructura
equivalente en InMemoryBackend. Schema de la tabla equations (PK equation_id):
| Campo | Tipo | Nullable | Notas |
|---|---|---|---|
equation_id |
string (PK) |
no | formato eq-<YYYYMMDDTHHMMSS> (seed); mismo que hoy en provenance. Estable. |
engine |
string |
no | motor que ejecutó la búsqueda (openalex; futuro s2/crossref). |
raw_query |
string |
no | la ecuación cruda tal como la escribió el usuario (EquationSpec.query / --equation), antes de traducir a filtros OpenAlex. Es lo que el consumidor programático confirma y hashea (§1.6). |
params_json |
string (JSON) |
no (default '{}') |
superconjunto de EquationRef + los flags de seed: {exclude, max_results, native, min_year, max_year, executed_query, translation_report}. |
label |
string \| null |
sí | etiqueta humana opcional. |
created_at |
string (ISO8601 UTC) |
no | sello de creación de la ecuación. |
provenance.equation_ides una FK / denormalización aequations.equation_id. La tablaequationses la fuente de verdad; elequation_idstring enprovenancees cache/denormalización. Retrocompat total: los consumidores que solo leenprovenance.equation_idno rompen (el string sigue ahí). En escritura nueva ya no contiene el prefijochaining:— el overload se disuelve víasource_kind(ADR 0050 D2, §1.1ProvenanceEvent). Corpus viejos conchaining:*se migran on-read (parse_provenance_migrated, §1.1) — la escritura nueva va limpia.- Se persiste al sembrar.
b2g seed --equation/--specescribe una fila enequationsen la biblioteca viva (store.backend.persist_equation(...), idempotente porequation_idPK:INSERT OR REPLACEen DuckDB, upsert por posición en InMemory). Elraw_queryque se persiste es la ecuación cruda;params_jsonserializaEquationRef.params. - El chaining NO crea filas en
equations(un citante forrajeado no vino de una ecuación; queda conequation_id=null+source_kind="chaining_forward"/"chaining_backward"). - Round-trip completo (D1, QA 0.14.0 #1).
DuckDBStore.load()reconstruyemanifest.equationsdesde esta tabla al rehidratar (mismo patrón quefilters/enrichers, ver §4 "Procedencia del Manifest persistida entre cargas"). Por eso elmanifest.jsonque sellasnapshot createrefleja fielmente la tablaequations, no un[]vacío: la persistencia y la carga cierran el ciclo, la procedencia sobrevive entre sesiones. (b2g statusla expone directo deload_equations(), sin pasar por el manifest reconstruido; ver §Convenciones CLI ·status.)
EquationRef (bib2graph.corpus, NO schemas) es la vista Python de esta fila, reusada en el
Manifest del snapshot (§1.3). ADR 0050 D1 la extiende (aditivo, retrocompat):
class EquationRef(BaseModel):
"""Referencia a una ecuación ejecutada. Vive en bib2graph.corpus (ADR 0030 + 0050 D1).
Se sella en Manifest.equations (snapshot) Y se persiste en la tabla `equations` (store vivo)."""
equation_id: str # PK, `eq-<YYYYMMDDTHHMMSS>` (seed)
query: str # query EJECUTADA (traducida) — campo histórico
translation_report: list[str] = Field(default_factory=list)
engine: str | None = None # ADR 0050: motor (`openalex`; futuro s2/crossref)
params: dict[str, object] = Field(default_factory=dict) # ADR 0050: raw_query, exclude, max_results,
# native, min_year, max_year, executed_query
created_at: str | None = None # ADR 0050: sello ISO8601 UTC
query= query traducida (histórico); la ecuación CRUDA vive enparams["raw_query"](= la columnaraw_queryde la tabla y lo que se hashea, §1.6). No se confunden:querymantiene el uso histórico del campo;raw_queryes lo que el usuario escribió.
Métodos del backend (aditivos al Protocol TabularBackend, §1.4):
def persist_equation(self, equation_id: str, *, engine: str, raw_query: str,
params_json: str, label: str | None = None,
created_at: str | None = None) -> None: ...
# Upsert idempotente por equation_id (PK). created_at=None → datetime.now(UTC).
def load_equations(self) -> list[dict[str, object]]: ...
# Filas persistidas (equation_id, engine, raw_query, params_json, label, created_at),
# en orden de inserción. Copia defensiva.
D2 (source_kind) implementado en 0.15.0 (#320). El ProvenanceEvent ya lleva source_kind
(§1.1) aditivo al lado de chaining_hop, que no se toca (constraint del consumidor programático,
§1.7). Escritura nueva: equation_id=null + source_kind="chaining_forward"/"chaining_backward"
para citantes; equation_id="eq-…" + source_kind="seed_equation" para semillas. Corpus pre-D2 con
equation_id="chaining:…" se migran on-read vía parse_provenance_migrated (sin reescribir stores;
el parser se mantiene en runtime, no es breaking). corpus_hash intacto (R2 excluye provenance).
1.6 Metadata del schema Arrow — equation_hash (ADR 0050 D3, v1) — contrato con el consumidor programático¶
to_arrow() (§1.2) puebla la metadata del schema Arrow (pa.schema(...).with_metadata({...}),
bytes→bytes) con la ligadura ecuación↔corpus, cuando hay exactamente una ecuación en la tabla
equations (§1.5). Claves (bytes UTF-8):
| Clave | Valor |
|---|---|
equation_hash |
SHA-256 (hex, minúsculas) de la ecuación cruda normalizada (ver abajo). |
equation_hash_algo |
"sha256" (constante; explícito para versionar el algoritmo del digest). |
equation_expression |
la ecuación cruda (raw_query), para trazabilidad. |
equation_id |
la FK (eq-…), para cruzar con la tabla equations (§1.5). |
Normalización canónica del hash — strip() puro, CONGELADO (contrato de verificación del consumidor). Se
hashea la ecuación cruda (raw_query = lo que el usuario escribió, antes de traducir a filtros
OpenAlex):
Es decir: solo str.strip() (elimina espacios al inicio/final) → codificar en UTF-8 →
SHA-256 → hexdigest en minúsculas. NO colapsa espacios internos, NO cambia mayúsculas,
NO normaliza comillas ni Unicode (NFC/NFKC). Esta definición es exactamente la del contrato de
verificación del consumidor y se congela: una vez que el consumidor programático empiece a verificar,
cambiar la normalización rompería la verificación (por eso equation_hash_algo deja versionar el
algoritmo para evoluciones controladas).
- Regla 1-ecuación → presente / 0-o-N → omitida (congelada, ADR 0050 D3). Exactamente una
ecuación en
equations→ la metadata (equation_hash/equation_hash_algo/equation_expression/equation_id) se puebla. Cero o múltiples ecuaciones → la metadata se omite por completo ybuild_equation_metadatadevuelve unwarningaccionable. Nunca se inventa un hash para 0 o1 ecuaciones (fallar suave y honesto, no mentir un hash que el consumidor programático no podría verificar). El helper puro
build_equation_metadata(equations) -> tuple[dict[bytes, bytes], str | None](bib2graph.backends.memory) implementa la regla;to_arrow()lo aplica silenciosamente (elwarninglo emite el punto de consumo, p. ej.b2g export --format arrow). - Contrato a FUTURO, no bloqueante. La verificación por hash aún no está implementada del lado del consumidor (su contrato de verificación en estado Propuesta). bib2graph emite el hash bien (strip exacto) para dejar la ligadura lista para cuando el consumidor programático la active; su ausencia no frena nada.
El schema de columnas de
to_arrow()(los 19 nombres+tipos que el consumidor programático lee) y el dominio decuration_status(candidate | accepted | rejected) son CONTRATO con consumidores externos — ver §1.7. La metadata de ecuación (D1/D3) es aditiva: no toca esas columnas.
1.7 Contrato con consumidores externos — schema del Arrow congelado¶
Un consumidor programático carga corpus reales subiendo el Arrow que produce to_arrow()
(Feather de b2g export --format arrow, §CLI). Un usuario programático que integra el Arrow de bib2graph
reportó que su ingest impone un constraint DURO sobre el schema (ADR 0050 §Constraints del consumidor
programático): estos dos elementos son contrato con consumidores externos y no se cambian sin coordinar
cross-repo:
- El schema de columnas del Arrow — los 19 nombres+tipos load-bearing que el consumidor programático
lee (
INGESTED_COLUMNS):id,doi,source_id,references_id,references_doi,title,year,abstract,source,curation_status,is_seed,provenance,authors_raw,authors_id,authors_affiliations,keywords_raw,keywords_id,institutions_raw,institutions_id.source_id/references_iddeben seguir siendo ids OpenAlex del mismo namespace (W…): el consumidor programático joineareferences_idde un paper contrasource_idde otro para armar el grafo de citas. Un rename/retipo los vuelve invisibles sin error (rompe en silencio). - El dominio de
curation_status— exactamentecandidate | accepted | rejected. El ingest del consumidor programático hace.exclude(rejected); renombrar un estado filtra mal los papers rechazados.
Regla: aditivo es seguro; renombrar/retipar/estrechar NO. Agregar columnas nuevas a
CORPUS_SCHEMA o metadata nueva al schema (como equation_hash, D3) es 100% seguro — el consumidor
programático ignora todo lo que no está en INGESTED_COLUMNS y toda metadata que no consume. Renombrar,
retipar o estrechar el dominio sobre lo que el consumidor programático lee rompe la carga (varios
rompen en silencio).
Esto lo blinda el test guardarraíl tests/unit/test_arrow_schema_contract.py (#296, ya en dev): una
copia congelada de los 19 nombres+tipos + el dominio de curation_status, que falla si alguien
renombra/retipa una columna load-bearing o cambia el dominio. Agregar columnas nuevas lo deja pasar
(verificado). Referencia normativa: ADR 0050 §Constraints del consumidor programático.
1.8 Red tipada en el Arrow del corpus (ADR 0052, v1, #321)¶
b2g export --format arrow extiende el contrato del Feather con la red tipada del último
b2g build sobre el corpus scopeado, en el mismo archivo autoverificable (sin multi-tabla ni hacks
de Arrow IPC estándar). El cierre asociativo papers.id ↔ network_edges[].neighbor_id es exacto por
construcción. Es aditivo sobre §1.6/§1.7: no toca los 19 nombres+tipos load-bearing del
consumidor programático, ni el dominio de curation_status, ni el corpus_hash: compute_corpus_hash excluye las 5 columnas de red
(constants.ARROW_NETWORK_COLUMNS), así que el mismo corpus exportado con y sin red da el mismo
corpus_hash (ADR 0052 §Constraints; corregido post-0.15.0 — en 0.15.0 el hash de la tabla con red
difería).
5 columnas nuevas en la tabla papers¶
Pobladas si y solo si hay NetworkArtifact disponible del b2g build para el --network-type
pedido. Ausentes (no null/no vacías — el Arrow no las trae; el schema no las declara) si el
corpus no tiene red computada (modo degradado, abajo).
| Columna | Tipo Arrow | Nullable | Semántica |
|---|---|---|---|
community_id |
int64 |
no | id de la comunidad del paper en la red. Negativo (-1) = outlier/no asignado (paper sin aristas o sin match en el grafo). |
degree_weighted |
int64 |
no | suma de los pesos de las aristas del paper. Aislados = 0. |
intra_community_pagerank |
float64 |
no | PageRank del paper dentro del subgrafo de su comunidad, ponderado por weight (ADR 0052 D3). Robusto al tamaño: una comunidad de 5 nodos y una de 500 dan PageRanks comparables. 0.0 para papers sin match en el grafo. |
network_edges |
list<struct<neighbor_id: string, weight: int64>> |
no | aristas salientes del paper. neighbor_id = Col.ID canónico del vecino (doi:… / src:… / tt:… — la misma regla de identidad del corpus, NO source_id crudo). weight = peso de la arista. Lista vacía si el nodo está aislado. |
network_communities |
list<struct<community_id: int64, size: int64, modularity: float64>> |
no | partición global de la red, idéntica para todos los papers del Arrow (no es por-nodo). Lista de un solo elemento si la partición tiene 1 comunidad; vacía si detect_communities no corrió. modularity es la de Newman de la partición entera (misma por entrada, por consistencia de tipo). |
Cruce estable: el neighbor_id de network_edges es el Col.ID (doi:… cuando hay DOI,
source_id:… cuando no, tt:… cuando tampoco — la misma regla que el resto del corpus). El
consumidor joinea con papers.id por igualdad exacta de string. No se exportan aristas con
source_id crudo: el source_id ya está como columna de papers (§1.7, constraint duro del
consumidor programático). Esta es la decisión más contraintuitiva del ADR 0052 pero la única que
mantiene la coherencia con la identidad DOI-ancla del corpus (ADR 0036).
4 claves nuevas en la metadata del schema Arrow (aditivas sobre §1.6)¶
arrow_with_network extiende la metadata con (solo si hay NetworkArtifact):
| Clave | Valor | Cuándo |
|---|---|---|
network_type |
"bibliographic_coupling" | "cocitation" |
Una red por Arrow. Ausente en modo degradado. |
network_type_algo |
"louvain" | "label_prop" | "greedy_modularity" |
Solo presente si network_type está presente. Refleja el clustering del NetworkSpec del b2g build que produjo la red. |
network_resolution |
float como string | Solo presente si network_type está presente. Refleja la resolution del NetworkSpec. |
network_scope |
"full" | "seeds_only" |
Solo presente si network_type está presente. Refleja el scope del NetworkSpec. |
Conviven con equation_hash / equation_hash_algo / equation_expression / equation_id de §1.6.
La metadata es inmutable una vez escrita; el consumidor la lee y, si network_type está ausente,
sabe que está en modo degradado (abajo).
Una red por Arrow, configurable (b2g export --network-type, ADR 0052 D2)¶
- Default:
bibliographic_coupling(la más robusta en one-shot, la primera delNetworks.quick). - Override: nuevo flag
--network-typeque reusa elNetworkKindStrEnum existente enbib2graph.constants(fuente única, ADR 0023) — no se introduce literal nuevo. - Kinds no-papers (
author_collab/institution_collab/keyword_cooccurrence): los nodos de esas redes no son papers; el struct anidadonetwork_edges/network_communitiesno aplica.arrow_with_networkrechaza esos kinds conValueErrory la CLI los cubre con unwarningaccionable y exporta igual la tabla pelada ("network_type=<kind> not supported in Arrow export; use --format graphml for this kind"). Para esos kinds, usar--format graphml. PAPER_NETWORK_KINDS(bib2graph.constants, ADR 0023) es la fuente única que decide qué kinds se pueden embeber:frozenset({NetworkKind.BIBLIOGRAPHIC_COUPLING, NetworkKind.COCITATION}).cocitationrequierecited_by_idpoblado (ADR 0052 D2). Si se pide--network-type cocitationpero ningún paper del corpus tienecited_by_idno-vacío,b2g exportemiteDataError(exit 2) accionable ("run 'b2g chain forward' / 'b2g enrich' first") — no exporta un Arrow con red vacía.- Multi-red en un mismo Arrow queda diferida a 0.16.0+ (el struct puede explotar de tamaño si hay 5 redes con miles de aristas cada una; se decide bajo demanda).
Modo degradado honesto (ADR 0052 D4)¶
b2g export --format arrow sobre un corpus sin b2g build previo (o build que falló, o restaurado
de un snapshot anterior al build): el Arrow se exporta igual, pero las columnas de red están
ausentes (no null/no vacías — el Arrow no las trae, el schema no las declara), network_type
ausente en metadata. El envelope --json lleva un warning top-level:
{
"warnings": [
"no network computed; run 'b2g build' first to attach a typed network to the export"
]
}
Exit code = 0 (export exitoso, modo degradado documentado). El consumidor lee la ausencia de
network_type en la metadata del schema como "esto es un corpus pelado" y obra en consecuencia.
Por qué no exportamos structs vacíos con null/[]: ocuparía espacio y mentiría sobre la presencia
de una red que no existe. La forma del Arrow cambia con y sin red; el consumidor debe validar la
presencia/ausencia de las columnas de red (es la disciplina que el constraint "rompen-en-silencio" del
§1.7 ya impone).
Envelope --json aditivo para export --format arrow (ADR 0052 D5)¶
schema="1" intacto. Se agregan al dict data los siguientes 7 campos siempre presentes en
--format arrow (los otros formatos de export no los llevan), todos null/0 cuando no hay red
embebida:
| Campo | Tipo | Semántica |
|---|---|---|
network_type |
string | null |
Refleja la metadata del Arrow. null en modo degradado o kind no-paper. |
network_nodes |
int |
Nº de nodos en la red (papers con al menos una arista). 0 en modo degradado. |
network_edges |
int |
Nº de aristas en la red. 0 en modo degradado. |
network_communities |
int |
Nº de comunidades detectadas. 0 en modo degradado. |
network_modularity |
float | null |
Modularidad de Newman de la partición (nx.community.modularity). null en modo degradado. |
network_type_algo |
string | null |
"louvain" | "label_prop" | "greedy_modularity". null en modo degradado. |
network_resolution |
float | null |
Resolución del NetworkSpec. null en modo degradado. |
Son informativos (el consumidor puede sacarlos del Arrow leyendo la metadata y las columnas, pero
tenerlos en el envelope le evita parsear el schema). El bloque maturity existente se mantiene intacto
(§Envelope, #160).
Función núcleo y dependencias¶
networks/intra_community_pagerank.py::intra_community_pagerank(graph, communities, *, weight="weight") -> dict[node, float]— función pura (núcleo; sin I/O, sin red, sin duckdb). Computa PageRank restringido al subgrafo de cada comunidad. Costo total O(|V| + |E|) en subgrafos disjuntos. Para comunidades de 1 nodo aislado, PageRank =1.0por convención denx.pagerank.exporters/arrow.py::arrow_with_network(table, artifact) -> pa.Table— función pura: siartifact is None, devuelvetableintacta (modo degradado); si no, exigekind ∈ PAPER_NETWORK_KINDS, adhiere las 5 columnas (connullable=Falseenforced vía cast del schema) y extiende la metadata con las 4 claves de red. No toca I/O, no mutatable, no afectacorpus_hash.- Dependencia nueva:
scipy>=1.17.1al núcleo ([project.dependencies],pyproject.toml).nx.pageranklo requiere. No es opt-in ni[extra]: vive en el núcleo porque la red tipada es núcleo puro.
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: la api_key se resuelve arg → env OPENALEX_API_KEY → ausencia ⇒ polite pool (ADR 0012); con key viaja en el header Authorization: Bearer <key>. El email (arg email=/--email) viaja como mailto en la query (polite pool). Sin key el source no rompe: corre en polite pool, solo con menor límite. 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
ex verbo suelto b2g enrich se retiró en 0.12.0.
@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.manifest.equations⇄ tablaequations(§1.5) — víaDuckDBBackend.persist_equation()/load_equations()(QA 0.14.0 #1, ADR 0050 D1), mismo patrón:seed --equation/--specsella la fila yload()reconstruyemanifest.equationsal rehidratar. Así elmanifest.jsondel snapshot refleja fielmente la tabla lateralequations(D1 completo): antes era un bug —la tabla se poblaba peroload()no la leía de vuelta, y unsnapshot createposterior sellabaequations: []pese a haber ecuaciones registradas, perdiendo la procedencia—.load_equations()nunca lanza por tabla ausente (stores pre-D1 devuelven[], retrocompat sin excepciones).
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 ex verbo b2g monitor se retiró en 0.12.0), 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, origin_ids: set[str] | None = None,
call_budget: "CallBudget | None" = None) -> 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).
origin_ids (#309, CLI `chain --ids`/`--top`/`--scope`): si NO es None, acota los papers-origen
del forrajeo a las filas cuyo `id` o `source_id` está en el set (matchea ambos). None (default)
= todas las semillas (comportamiento previo, sin cambios).
call_budget (#309, CLI `chain --budget N`): `CallBudget` compartido por referencia con el
`source` para topar las llamadas HTTP del forward; al agotarse, el chaining PARA limpio (sin
reintentar) y `RankedCandidates.budget_stopped` queda True con lo materializado hasta ese punto.
None = sin tope."""
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.
budget_used: int = 0 # #309: llamadas HTTP realizadas cuando `chain --budget N` está
# activo. 0 si no se pasó call_budget al Forager.
budget_stopped: bool = False # #309: True si el chaining se detuvo temprano por agotar el
# call_budget (parada limpia, sin reintentar) → resultado PARCIAL
# pero consistente. b2g chain avisa y sugiere re-correr acotado.
class CallBudget: # bib2graph.foraging.CallBudget (#309, CLI `chain --budget N`)
"""Tope mutable de llamadas HTTP a OpenAlex, COMPARTIDO POR REFERENCIA entre Forager y
OpenAlexSource (mismo objeto, sin acoplarlos por herencia). Guardarraíl anti-footgun: al llegar al
tope, quien consulta `exhausted` DEBE parar limpio, SIN reintentar —ni el retry/backoff ante 429/5xx
ni una página más de paginación por cursor. `limit=None` → sin tope (default)."""
limit: int | None # tope de llamadas (None = sin tope)
used: int # llamadas realizadas hasta el momento
@property
def exhausted(self) -> bool: ... # True si used >= limit (limit no-None)
def record_call(self) -> None: ... # incrementa `used`
# 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.- Forrajeo acotado y presupuestado (#309):
origin_ids(unidad escopada, CLIchain --ids/--top/--scope) ycall_budget(CallBudget, CLIchain --budget N) son aditivos: sin ellos, elForagerse comporta como antes (todas las semillas, sin tope). Al agotar elcall_budgetel chaining para limpio sin reintentar y expone lo parcial víaRankedCandidates.budget_used/budget_stopped. Ver §CLIchainpara los defaults y la Discussion #310 (flip a default acotado).
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). - La inclusión manual gana — el filtro OMITE
accepted(ADR 0044): un paperaccepteda mano queda intacto aunque no cumpla el criterio; el filtro nunca lo mueveaccepted→rejected(lo excluye del conjunto a rechazar, igual que ya excluye a losrejected). Solo actúa sobre no-aceptados (candidatey demás). Precisa el scope de 0020 §C, sin revertirlo. Unacceptedsale del corpus solo por decisión humana explícita (curate reject --ids ...). - Conteo PRISMA por paso: cada
FilterStepllevacount_before/count_aftersobre los papers no-rejected (candidate + accepted). Losacceptednunca abandonan ese conjunto por efecto de un filtro (ADR 0044). 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.CallBudget(#309) se exporta desdebib2graph.foraging(junto aForager/RankedCandidates/GrowthPreview), no del top-level.
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:cited_by_idse puebla con el 2º nivel de fetch — desdechain forward/both(ADR 0048/#270, al traer los citantes de las semillas alcanzadas) y/o desde la pasadacited_bydebuild(cuando hay aceptadas; ADR 0007/0025), unión idempotente entre ambas —, 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 DOI-first: https://doi.org/<doi> si hay DOI; fallback https://openalex.org/<source_id> si no hay DOI pero sí source_id de OpenAlex (#203); ausente si no hay ninguno de los dos (no es columna del corpus, ver nota abajo) |
venue |
paper (coupling/cocitation) | string desde Col.SOURCE (revista / fuente); ausente si el paper no tiene fuente (#203) |
authors |
paper (coupling/cocitation) | string: nombres de Col.AUTHORS_RAW unidos con "\|"; ausente si no hay autores (#203) |
keywords |
paper (coupling/cocitation) | string: keywords de Col.KEYWORDS_ID unidas con "\|"; ausente si no hay keywords (#203) |
cited_by_count |
paper (coupling/cocitation) | int derivado len(Col.CITED_BY_ID) (nº de citantes conocidos en el corpus); ausente si la lista es vacía/None. Puede subestimar el conteo real si el corpus no pasó por la pasada cited_by de build (#203) |
is_seed |
paper | bool |
curation_status |
paper | string |
community |
todos | int, solo si se provee artifact.communities |
doi/url/venue/authors/keywords/cited_by_count aplican solo a paper-kinds
(bibliographic_coupling y cocitation); los nodos de autor/institución/keyword no los reciben.
url es derivada (no es columna del corpus) y DOI-first con fallback a OpenAlex: el DOI es la
única identidad de primera clase (ADR 0036), pero cuando el paper no tiene DOI la URL cae al recurso de
OpenAlex (https://openalex.org/<source_id>) para no perder el enlace navegable (#203). La derivación
vive en resolve_paper_url(doi: str|None, source_id: str|None) -> str|None (bib2graph.constants),
que compone doi_to_url y openalex_id_to_url (fuente única de cada regla, compartida con
resolve_url §0.1, #212 — sin drift). Ausencia condicional como year: sin DOI ni source_id, el
nodo no recibe url; venue/authors/keywords/cited_by_count se omiten cuando su valor es vacío/
None. Los exporters CSV/GraphML (§9) propagan todos estos atributos 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¶
Dos familias de exportador: de red (serializan un nx.Graph de build) y de corpus (serializan
la tabla Arrow del corpus). Los consume b2g export --format … (§CLI, ADR 0050 D4).
# --- Exportadores de RED (build → networks/) ---
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
# --- Exportadores de CORPUS (ADR 0050 D4, v1) — serializan Corpus.scoped(scope).to_arrow() ---
class ArrowExporter: # v1 — `corpus.arrow` (Feather / Arrow IPC)
def export(self, table: pa.Table, out_path: str | Path) -> Path: ...
# Serializa la tabla TAL CUAL con pyarrow.feather.write_feather: NO toca schema ni metadata.
# La metadata de ecuación + equation_hash (§1.6) ya viene en el schema de to_arrow() y viaja
# dentro del Feather (archivo autoverificable para el consumidor programático).
class BibtexExporter: # v1 — `corpus.bib` (BibTeX). Extra [bibtex] (bibtexparser, import perezoso).
def export(self, table: pa.Table, out_path: str | Path) -> Path: ...
# entry-type inferido (@article default); citekey = id interno; DOI como campo; campos mínimos
# universales title/author/year/doi/journal/url (ADR 0050 D4; ver §CLI export). ImportError si
# falta bibtexparser → exit 3.
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. Ambos CSV se escriben con BOM UTF-8 (encoding="utf-8-sig") para que Excel-Windows autodetecte UTF-8 y no rompa tildes (#214). Ellabel(yyear/doi/url/venue/authors/keywords/cited_by_count/is_seed/curation_status/community) lo inyecta la capadecorate(§7.1) antes del export, no el exporter;doi/url/venue/authors/keywords/cited_by_countsalen solo en paper-kinds y solo cuando su valor está presente.urles DOI-first con fallback a OpenAlex (#203, §7.1);authors/keywordsvan con separador"|".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 export --format arrow --scope accepted # corpus.arrow (Feather autoverificable, p/ consumidor programático)
b2g export --format bibtex # corpus.bib (BibTeX, entry-type inferido)
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.