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 · 2 datos · 3 dependencia · 4 red · 5 store/snapshot corrupto o bloqueado).
Sin estado entre invocaciones: el estado vive en el library.duckdb del workspace (opción global
opcional --workspace; --store fue eliminada en #75).
Superficie — 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) y referenced_not_fetched (nº de
IDs que el backward chaining observó sin materializar; §4/§5). Todos campos aditivos, schema="1"
intacto. validate chequea la consistencia del workspace (read-only).
init (ADR 0029): scaffold de un workspace.
b2g init <name> crea <name>/ con workspace.json + library.duckdb +
networks//snapshots//exports/; b2g init . inicializa el cwd (adopta un .duckdb legacy). Si
la carpeta ya es workspace → WorkspaceExistsError. NO transiciona. data = {root, name, ...}.
seed (ADR 0030): TRES modos
mutuamente excluyentes (exactamente uno; ninguno o más de uno → exit 1):
--equation '<texto>'— ecuación cruda (modo OpenAlex directo, con red). 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}.
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 ... [--by NOMBRE]/curate reject --ids ... [--by NOMBRE]— por identificador (uno-a-uno o lote). 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. Usanaccept_papers/reject_papersdeservice/curate.py(fuente única de curación).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/).
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;seeds= solo semillas. El.corpus_hashse sella con el corpus filtrado;clusters.csv/decoratereflejan ese subset. Scope con 0 papers → exit 0 +warning(no error). No confundir conNetworkSpec.scope(§10, por-red sobreis_seed). 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). 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>}. 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).
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 sale SIN envelope. Ante una opción requerida faltante, una opción desconocida
(p. ej. --store) o ningún workspace resoluble, Click aborta el parseo antes de entrar al comando:
mensaje de uso en stderr + exit 1, sin envelope. El envelope solo cubre errores dentro de la
ejecución del comando.
stdout puro en modo JSON (ENFORCED, #151). En modo JSON (por --json o B2G_JSON) stdout emite
exactamente una línea (el envelope), también en el camino de error (ok=false → envelope en stdout).
El texto humano va a stderr.
B2G_JSON — modo JSON por entorno (#151). Además de --json (post-verbo: b2g <cmd> --json), el
modo JSON se activa con B2G_JSON truthy (1/true/yes, case-insensitive) en todos los comandos.
Precedencia: --json explícito gana; no existe --no-json. Recomendación agents-first: export
B2G_JSON=1 una vez y correr el ciclo sin repetir el flag. Aditivo: envelope/exit codes/FSM no cambian.
Apéndice — bloque maturity del one-shot (#160, ADR 0037 §f). Los artefactos del camino one-shot
llevan un bloque aditivo data["maturity"] que se autodeclara borrador sin pulir (honestidad por
construcción), para que ni un agente que optimiza por exit 0 ni un humano apurado confundan un one-shot
con un resultado terminado. schema="1" intacto.
Forma estable: SIEMPRE 4 claves (orden y tipos fijos):
| clave | tipo | regla de derivación |
|---|---|---|
curated |
bool |
true si el corpus completo (pre-scope) tiene ≥1 paper con curation_status ∈ {accepted, rejected}. Independiente del scope y del FSM. |
scope |
str | null |
el token CLI (all/accepted/seeds, no el vocab interno seeds_only). En snapshot create y read top es "all". |
saturated |
bool |
false constante en one-shot (no sobre-afirmar; gancho futuro: convergencia de referenced_refs_count()). |
empty_networks |
list[str] |
solo los tokens kind de las redes vacías (reason/fix_command no se duplican: viven en data["empty_networks"]). |
Aparece siempre en build (incl. early-return de corpus vacío), snapshot create y read top;
ausente en read list/read stats/read show. Lo calcula la función pura
service.maturity.compute_maturity(corpus, *, scope, empty_network_kinds) (§0).
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}. Ver nota abajo (ADR 0013) |
authors_raw / authors_id |
list[string] |
— | nombres crudos / ids canónicos (ORCID si hay) |
authors_affiliations |
list[string] |
— | per-autor (de OpenAlex authorships); habilita geografía/asortatividad |
keywords_raw / keywords_id |
list[string] |
— | crudos / canónicos (post-thesaurus) |
institutions_raw / institutions_id |
list[string] |
— | crudos / ids canónicos (ROR si hay) |
references_id |
list[string] |
— | obras citadas (ids OpenAlex); vienen de OpenAlex, no de un Enricher |
references_doi |
list[string] |
— | refs resueltas a DOI (las puebla un Enricher opt-in; OpenAlex las da como URLs internas) |
cited_by_id |
list[string] |
— | citantes (ids OpenAlex); habilita forward chaining y co-citación |
El schema exacto vive en bib2graph.schemas. La validación se hace en Corpus.from_arrow() y en
cada Source.seed()/load().
Tabla lateral
external_ids(paper_id, engine, id)(ADR 0036, opción C — infra presente, sin poblar): el backend exponeexternal_ids_for(paper_id)/all_external_ids()para registrar 1↔N los IDs que cada motor asignó al mismo paper (unificados por DOI). Su consumo (cruce cross-motor) está diferido a la llegada del 2º motor (#120); hoy la identidad/dedup se resuelven solo por elidcanónico (DOI primero).
provenance es un log append-only (ADR 0013,
D4), no un objeto único: la columna string guarda un JSON que es una lista de eventos. Cada
evento tiene la forma:
{
"action": "fetched | accepted | rejected",
"equation_id": "string | null",
"chaining_hop": "int | null",
"source": "string | null",
"fetched_at": "ISO8601 | null",
"decided_by": "string | null",
"decided_at": "ISO8601 | null"
}
accept()/reject() agregan un evento (action='accepted'/'rejected', con decided_by y
decided_at) sin borrar los previos. None/cadena vacía equivalen a "sin eventos".
id estable y determinista (ADR 0013, D1;
precedencia invertida por ADR 0036, D1'):
id = f"{prefix}:{sha256(valor)[:16]}" con precedencia doi normalizado (doi:) → source_id
(src:) → title+year (tt:). El DOI es el ancla universal e interoperable entre motores (un
paper con DOI tiene el mismo id venga de OpenAlex, de Semantic Scholar o de un .bib); source_id
es el fallback para papers sin DOI, antes de caer a title+year (frágil). El mismo paper produce el
mismo id entre corridas; es la base de la dedup en merge y en la biblioteca viva.
1.2 Corpus (wrapper)¶
class Corpus:
"""Wrapper sobre un TabularBackend + un Manifest (ADR 0015).
Lo que circula por el pipeline: Source lo siembra, el Forager lo expande,
el humano lo cura, el Preprocessor lo normaliza, el backend lo persiste (biblioteca
viva), los Projectors lo consumen vía to_arrow(). Las mutaciones se DELEGAN al
backend (InMemoryBackend puro / DuckDBBackend por defecto): la API mantiene
semántica de valor (devuelve Corpus), pero no copia la tabla entera en memoria.
"""
manifest: Manifest
@classmethod
def from_arrow(cls, table: pa.Table, *, backend: "TabularBackend | None" = None) -> "Corpus":
"""Valida con Pydantic y construye el Corpus sobre `backend` (default InMemoryBackend).
Falla ruidoso si el schema no coincide."""
def to_arrow(self) -> pa.Table:
"""Materializa el contenido del backend como pa.Table. Puente a los proyectores puros."""
def seeds(self) -> pa.Table: """Vista is_seed == True."""
def candidates(self) -> pa.Table: """Vista curation_status == 'candidate'."""
def accepted(self) -> pa.Table: """Vista curation_status == 'accepted' (la biblioteca curada)."""
def scoped(self, scope: str) -> "Corpus":
"""Vista PURA por estado de curación: devuelve un Corpus NUEVO con el subconjunto de filas
(no muta el original). Valores: `'all'` = corpus completo; `'accepted'` = `is_seed == True`
OR `curation_status == 'accepted'`; `'seeds_only'` = `is_seed == True`. Scope inválido →
`ValueError` accionable. Determinista: dos llamadas con el mismo scope dan corpora con el
mismo `corpus_hash` (subset estable). `'all'` reusa el backend; los otros materializan el
filtro en un `InMemoryBackend`. Lo usa `b2g build --scope` (vocab CLI `seeds`→`seeds_only`
internamente) para sellar el hash del
corpus FILTRADO. Issue #56 / #159. **NO confundir con `NetworkSpec.scope`** (§10): aquel es un
eje por-red (`full`/`seeds_only`) sobre `is_seed`; `scoped()` filtra el corpus entero por
curación antes de proyectar."""
def with_manifest(self, manifest: Manifest) -> "Corpus":
"""Devuelve un Corpus nuevo con el MISMO contenido y otro Manifest (semántica de valor:
el original no muta). No toca el backend; el `corpus_hash` no cambia (el hash es sobre la
tabla, no sobre el Manifest). API canónica para que las costuras (Source/Forager/Filter)
sellen su metadata —p. ej. `OpenAlexSource.seed()` puebla `openalex_version`/`equations`—
sin reconstruir el corpus. v1 (Hito 4)."""
def add_paper(self, row: dict) -> "Corpus":
"""Valida la fila (PaperRow) y agrega el paper. Calcula `id` (D1) si no viene."""
def merge(self, other: "Corpus") -> "Corpus":
"""Combina deduplicando por `id` (idempotente). Combinación por campo: escalar no-nulo
gana (ambos no-nulos → `other`); columnas de lista = unión deduplicada (preserva `None`);
`curation_status` por decisión humana más reciente (`provenance.decided_at`), fallback
`accepted`>`rejected`>`candidate`; `provenance` = unión de eventos únicos (log).
Orden de filas: **primera aparición** (filas de `self` en orden, luego las nuevas de
`other`). Ver ADR 0013 (D3)."""
def accept(self, ids: list[str], *, by: str = "human", decided_at: datetime | None = None) -> "Corpus":
"""Marca papers como 'accepted' y AGREGA un evento al log de provenance. Devuelve Corpus nuevo.
`decided_at` se inyecta desde la frontera CLI (Hito R2, ADR 0017); `None` → el backend usa
`datetime.now(UTC)` como fallback de librería. El `decided_at` NO entra al `corpus_hash`."""
def reject(self, ids: list[str], *, by: str = "human", decided_at: datetime | None = None) -> "Corpus": ...
def materialize(self, view: Literal["author", "keyword", "institution"]) -> pa.Table: ...
def snapshot(self, path: Path) -> "CorpusSnapshot":
"""Exporta una FOTO sellada del estado actual (parquet + manifest.json) para reportar/
reproducir. CALCULA el `corpus_hash` real (D2) y lo escribe en el Manifest del snapshot.
NO es la persistencia (eso es el Store DuckDB); es un export derivable."""
def __eq__(self, other: object) -> bool:
"""Igualdad canónica vía `corpus_hash` (D2): mismo contenido semántico, insensible al
orden de filas y al orden interno de las columnas de lista; no compara el Manifest.
Robusta ante cualquier `PYTHONHASHSEED`. Ver ADR 0013."""
Notas de contrato (ADR 0013):
__eq__es porcorpus_hash, no porpa.Table.equals: dosCorpuscon el mismo contenido en distinto orden de filas (o de listas) son iguales. Elcorpus_hashexcluyeprovenance/timestamps (identidad = contenido bibliográfico; la procedencia audita, no identifica) pero incluyecuration_status(contenido curado), nunca campos volátiles del Manifest (D2).mergeemite filas en orden determinista (primera aparición): habilita diffs y snapshots reproducibles. Es idempotente:c.merge(c) == c.
Backend y estado del lazo (ADR 0015 / 0016):
- Las mutaciones se delegan al
TabularBackend. D1/D2/D3 son contrato que cada backend cumple (InMemory en Python, DuckDB por SQL). Elcorpus_hash(D2) se computa siempre sobreto_arrow(). - El
CycleState(SEEDED → FORAGED → FILTERED → BUILT → MONITORED, transiciones permisivas) vive en el backend persistente (DuckDBBackend), no en elCorpusefímero (tablaloop_state_logappend-only; estado actual = última fila), expuesto víaloop_state()/set_loop_state()(§4) yb2g status.
1.3 Manifest y CorpusSnapshot¶
class Manifest(BaseModel):
"""Metadatos del Corpus. Se serializa a manifest.json junto al parquet del snapshot."""
# Obligatorios (sin default) — D5
schema_version: str
corpus_hash: str
lib_version: str
created_at: datetime
# Con default — D5
openalex_version: str | None = None # versión/fecha del snapshot de OpenAlex usado
equations: list[EquationRef] = [] # ecuaciones + query OpenAlex ejecutada + reporte de traducción
chaining: ChainingParams | None = None # profundidad, topes, dirección
preprocessors: list[PreprocRef] = [] # normalize + thesaurus aplicados
filters: list[FilterStep] = [] # criterios incl/excl con conteos (flujo PRISMA)
enrichers: list[EnricherRef] = [] # opcional (resolución de refs / 2º nivel)
class CorpusSnapshot:
"""Carpeta con corpus.parquet + manifest.json: EXPORT sellado del estado vivo en un instante.
Reproducible y versionable (git-lfs / DVC). No es la biblioteca viva, es su foto."""
path: Path
manifest: Manifest
@property
def corpus(self) -> Corpus: ...
Notas de contrato (ADR 0013; D5/D6):
corpus_hashse calcula al sellar: el Manifest en memoria llevacorpus_hash=""(placeholder); el hash real (D2) se computa ensnapshot()y vive enCorpusSnapshot.manifest.- Obligatorios (D5):
schema_version,corpus_hash,lib_version,created_at; el resto con default. Siimportlib.metadatano resuelve la versión instalada,lib_version = "unknown"(no"0.0.0"inventado — honesto sobre la reproducibilidad). schema_version(D6): se escribe y round-tripea; el rechazo por incompatibilidad + migraciones sobre el store vivo es futuro.
1.4 TabularBackend (Protocol) e InMemoryBackend¶
El contenedor del Corpus es un TabularBackend (Protocol @runtime_checkable); el Corpus
delega en él (ADR 0015). El núcleo depende solo
del Protocol (no de duckdb). Las mutaciones tienen semántica de valor: cada operación
devuelve una instancia nueva del backend; la original no muta. id ya viene calculado por
Corpus.add_paper (D1 se valida antes de delegar). Las reglas D1/D2/D3 (ADR
0013) son contrato de este Protocol: cada
implementación las cumple a su manera (InMemory en Python, DuckDB en SQL).
@runtime_checkable
class TabularBackend(Protocol):
"""Respalda el contenido del Corpus. Cumple D1/D2/D3 (ADR 0013).
Implementaciones: InMemoryBackend (puro, tests) / DuckDBBackend (biblioteca viva, Hito 3)."""
def to_arrow(self) -> pa.Table: ...
# Contenido completo como tabla Arrow canónica. Puente a los proyectores puros.
def add_paper(self, row: dict) -> "TabularBackend": ...
# `id` ya calculado y fila ya validada por Corpus.add_paper. Devuelve backend nuevo.
def merge(self, other_table: pa.Table) -> "TabularBackend": ...
# Fusión D3: orden por primera aparición (filas de self, luego nuevas), dedup por `id`.
def apply_curation(self, ids: list[str], *, action: str, by: str,
decided_at: str | None = None) -> "TabularBackend": ...
# accept/reject: AGREGA un evento al log `provenance` (action/decided_by/decided_at).
# `decided_at` (ISO8601 UTC) inyectado desde la frontera (Hito R2, ADR 0017);
# `None` → fallback `datetime.now(UTC)` (uso como librería). NO entra al corpus_hash.
def filter_view(self, view: Literal["seeds", "candidates", "accepted"]) -> pa.Table: ...
# Vista filtrada (is_seed / curation_status == 'candidate' | 'accepted').
def corpus_hash(self) -> str: ... # D2, order-independent, sobre el contenido
def __len__(self) -> int: ...
def __eq__(self, other: object) -> bool: ... # igualdad canónica por corpus_hash (D2)
# AS-BUILT #54 (2026-06-17): tabla hermana `referenced_but_not_fetched` (append-only, par de
# loop_state_log) — los IDs que el backward chaining OBSERVA sin materializar en el corpus (§5).
# FUERA de la tabla `corpus` y del corpus_hash (son estado, no contenido; ADR 0017).
def add_referenced_refs(self, ref_ids: list[str], *, cycle_round: int) -> "TabularBackend": ...
# Registra IDs observados (idempotente por existencia de `ref_id`; observed_at = now() del backend).
def referenced_refs_count(self) -> int: ... # nº de IDs observados distintos
def referenced_refs(self) -> pa.Table: ... # los IDs observados (ref_id, cycle_round, observed_at)
| Implementación | Estado | Notas |
|---|---|---|
InMemoryBackend |
v1 | Núcleo puro, sin I/O. Working set efímero y backend de los tests (el núcleo se testea sin DuckDB). Semántica de valor; hereda la lógica del Hito 1 (mutación en Python sobre listas de dicts, table-rebuild). No persiste. |
DuckDBBackend |
v1, por defecto | La biblioteca viva (ADR 0009/0015): construido (Hito 3). Mutación por SQL puro (INSERT … ON CONFLICT (id) DO UPDATE + merge D3 en SQL/UDF), persiste entre corridas (.duckdb o :memory:), aloja el LoopState (ADR 0016). Pasa la suite de contrato de backend (D1/D2/D3). Carga perezosa (PEP 562): no se importa con import bib2graph. Ver §4. |
TabularBackend e InMemoryBackend son símbolos públicos v1 (from bib2graph import
TabularBackend, InMemoryBackend). El contrato D1/D2/D3 se verifica con una suite parametrizada
por backend (tests/unit/test_backends.py), ahora parametrizada también con DuckDBBackend
(Hito 3, construido): el backend SQL cumple los mismos invariantes que el InMemory.
2. Costura Source — sembrar un corpus¶
El contrato Source es agnóstico de la forma de OpenAlex (ADR
0018): separa lo que todo corpus necesita para
existir de lo que algunas fuentes pueden o no entregar.
- Mínimo universal (obligatorio para toda
Source):id,title,year,authors_raw,keywords_raw. Habilita ya las redes de co-autoría y co-ocurrencia de keywords. - Enriquecimiento opcional (la
Sourcepuede omitirlo; el schema admite nulos):references_id/references_doi,cited_by_id,authors_affiliations(per-autor),institutions_id. Habilita acoplamiento, co-citación, redes de instituciones y asortatividad geográfica.
Una Source que solo provee el mínimo es ciudadana legítima (habilita fuentes
latinoamericanas — SciELO, Redalyc, La Referencia — sin obligarlas a entregar lo que no tienen);
los proyectores de enriquecimiento producen redes parciales sobre esos papers y lo reportan
(no fallan). (El contrato se declara en v0.1; las fuentes nuevas e impl son posteriores.)
class Source(Protocol):
"""Convierte una entrada externa en un Corpus. Acceso a campos DEFENSIVO (sin KeyError).
Debe entregar el MÍNIMO UNIVERSAL (id, title, year, authors_raw, keywords_raw); el
enriquecimiento (refs/citantes/afiliaciones/instituciones) es OPCIONAL (ADR 0018)."""
def seed(self, query: str, *, exclude: list[str] | None = None) -> "SeedResult":
"""Siembra desde una ecuación de búsqueda. Devuelve el Corpus + la query ejecutada
y el reporte de traducción (qué mapeó, qué se aproximó, qué se descartó).
`exclude` (negaciones quirúrgicas, opcional): cada término se inyecta DENTRO de la
única expresión `title_and_abstract.search:((query) AND NOT "<término>")` (el campo
NO se repite) y se REPORTA en el
translation_report (query visible, ejercicio consciente). Las comillas internas del
término se sanean. Ignorado con `native=True` (query cruda). Una Source que no siembra
por ecuación (p. ej. BibtexSource) lanza NotImplementedError."""
def load(self, path: str) -> Corpus:
"""Siembra desde un archivo (export/pearls). is_seed=True."""
class SeedResult(BaseModel):
corpus: Corpus
executed_query: str # la query OpenAlex EXACTA ejecutada (consciencia, ADR 0007)
translation_report: list[str] # mapeos limpios / aproximados / descartados (p. ej. NEAR no soportado) + negaciones aplicadas (exclude, #30)
Capa declarativa de la ecuación — EquationSpec + load_equation_spec (Ciclo 9a, ADR
0030; src/bib2graph/sources/equation.py).
Empaqueta los parámetros de b2g seed en un YAML versionable (el artefacto "qué se busca"), análogo
a NetworkSpec/load_specs del Hito 9. Clave raíz equation: (objeto, no lista — una
ecuación por archivo). El modo b2g seed --spec equation.yaml (§convenciones CLI) carga la spec y la
mapea a run_seed; equivale a --equation + flags.
class EquationSpec(BaseModel):
"""Configuración declarativa de una ecuación de búsqueda (ADR 0030).
model_config = ConfigDict(extra="forbid"): campo desconocido en el YAML → error accionable."""
query: str # requerido (no vacío) — la ecuación de búsqueda
exclude: list[str] = [] # #30 — AND NOT "…" DENTRO de la search:((query) AND NOT "…")
max_results: int | None = None # #14 — tope (None → default del source, 200)
native: bool = False # passthrough crudo a OpenAlex (sin traducción)
min_year: int | None = None # filtra: from_publication_date contra OpenAlex
max_year: int | None = None # filtra: to_publication_date contra OpenAlex
def load_equation_spec(path: str | Path) -> EquationSpec:
"""Carga/valida la EquationSpec desde un YAML (clave raíz `equation:`).
Errores accionables (mismo patrón que `load_specs`): YAML malformado → ValueError;
clave raíz ausente → ValueError; campo desconocido/tipo incorrecto → ValueError
citando archivo + campo. Importación perezosa de PyYAML."""
| Implementación | Estado | Notas |
|---|---|---|
OpenAlexSource |
v1 | Referencia/backbone, sobre httpx. Entrega mínimo + enriquecimiento (refs inline + afiliaciones per-autor + instituciones; cited_by_id lo puebla el chaining/Enricher, no el seed). Traducción passthrough: envuelve la ecuación en title_and_abstract.search:(...) y reporta los límites WoS (NEAR/comodín/tags) sin traducirlos. Flag native=True (query cruda). Negaciones (exclude): cada AND NOT "<término>" se inyecta dentro de la única expresión search:((query) AND NOT "<término>") (campo no repetido; el filtro de año queda como predicado separado por coma, fuera del search) y se reporta en el translation_report; ignorado con native. Credenciales inyectadas: 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.
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) -> None:
"""depth=1 por defecto; depth>1 lanza NotImplementedError (futuro v0.3+).
max_candidates = tope configurable del ranking (None = sin límite).
max_citing_per_paper = tope de citantes POR SEMILLA en el forward batcheado (default 50;
acota el fetch vía fetch_citing_batch; CLI `--max-citing`). AS-BUILT #21 (2026-06-16)."""
def preview(self, corpus: Corpus, *, direction: Direction = "both") -> "GrowthPreview":
"""'Esta expansión sumaría ~N papers' SIN traerlos. Opera SOLO localmente, SIN red.
Backward: estimación EXACTA local desde references_id. Forward: NO estimable sin red
(cited_by_id está vacío tras el seed) → estima el nº de SEMILLAS a forrajear (is_seed,
SIN filtrar curation_status) con by_direction['forward']=0 y forward_requires_fetch=True;
el conteo de citantes reales solo llega con chain(). NO muta el corpus."""
def chain(self, corpus: Corpus, *, direction: Direction = "both",
since: date | None = None) -> "RankedCandidates":
"""Computa candidatos (curation_status='candidate', is_seed=False) rankeados por scent.
Devuelve SOLO los candidatos nuevos (no mergeados): el humano hace
corpus.merge(ranked.corpus). NO muta el corpus de entrada. Sella Manifest.chaining.
`since` (#158, forrajeo incremental): propaga a fetch_citing_batch(since=) →
from_publication_date en OpenAlex; solo afecta el tramo forward. Lo usa `b2g chain --since`
(transición a MONITORED)."""
class GrowthPreview(BaseModel):
estimated_new: int # total estimable localmente (forward=0 si requiere fetch)
by_direction: dict[str, int] # {'backward': N, 'forward': 0 si requiere fetch}
direction: Direction
forward_requires_fetch: bool = False # True si se pidió forward/both → forward desconocido sin red
class RankedCandidates(BaseModel):
corpus: Corpus # SOLO los candidatos nuevos (no mergeado con el corpus semilla).
# Forward (#78): materializa filas con metadata REAL (título/año/
# autores), NO placeholders — vía fetch_citing_batch_with_works.
# Backward (#54): NO materializa filas — observa, ver observed_refs.
ranking: list[tuple[str, float]] # (id, information_scent), desc scent / asc id
observed_refs: list[str] = [] # AS-BUILT #54 (2026-06-17): IDs observados por el backward SIN
# materializarlos en .corpus (orden de ranking, respeta
# max_candidates). El backward observa; el forward materializa.
# b2g chain los persiste en `referenced_but_not_fetched` (§4),
# fuera del corpus_hash. Materializar = diferido a #71.
# RETIRADO (ADR 0022): `explain_candidate` y el extra `[llm]` se ELIMINAN del producto.
# El producto no usa IA generativa. El "porqué" de un candidato lo explica la ESTRUCTURA
# VISIBLE (con qué del corpus se acopla/co-cita), no un LLM. Ver ROADMAP Hito R4.
# (En el AS-BUILT v0.2 existía como stub gateado en [llm]; la remediación lo borra.)
Notas de contrato (ADR 0020):
- Forward chaining requiere
source.fetch_citing_batch(ids, *, max_per_paper)(§2, capacidad deOpenAlexSource, no del ProtocolSource— una source de solo-mínimo no habilita forward). El comandochainhace un pre-checkhasattry lanzaDependencyError(exit 3) si el source no lo soporta (unAttributeErrorgenuino no se disfraza de "source sin forward"). - Forward batcheado + cap por semilla:
fetch_citing_batchbatchea por OR (≤50) con presupuesto por semilla (max_citing_per_paper, default 50 — CLI--max-citing), sin N+1. El alcance del forward esis_seed=True(todas las semillas, sin filtrarcuration_status): el chaining precede a la curación. La restricción aacceptedes del Enricher (co-citación, §3), no del Forager. - Backward observa sin contaminar: no crea filas-fantasma en el corpus; los IDs observados salen por
RankedCandidates.observed_refsyb2g chainlos persiste en la tabla hermanareferenced_but_not_fetched(§4), fuera delcorpus_hash. Forward sí materializa filas con metadata real (víafetch_citing_batch_with_works, §2; cero red extra). Asimetría deliberada. previewychainno mutan el corpus de entrada (semántica de valor).fetch_citing(singular, con retry/backoff ante 429/5xx) sigue disponible; el forward lo consume vía la variante batcheada.
6. Núcleo — Preprocessor + filtros PRISMA¶
class Preprocessor:
"""Determinístico e idempotente. La parte fuzzy vive en §11 (ahora núcleo, no extra). Registra un
PreprocRef en el Manifest por cada operación aplicada. `applied_at` se inyecta desde la frontera
(R2): un único datetime.now(UTC) por invocación, igual que `decided_at` en curación."""
def normalize(self, corpus: Corpus, *, applied_at: datetime | None = None) -> Corpus:
"""Normalización CONSERVADORA (decisión b=A): authors_id (lowercase + quitar acentos +
colapso de espacios) y language (subtag ISO 639-1 primario). SIN fuzzy (eso es el dedup,
§11), SIN columna de periodización. Idempotente. NO muta el corpus de entrada. Corre
AUTOMÁTICAMENTE en la ingesta (helper `normalize_and_dedup`, ADR 0031)."""
def apply_thesaurus(self, corpus: Corpus, thesaurus: dict | Path, *,
applied_at: datetime | None = None) -> Corpus:
"""Lee keywords_raw y SOBRESCRIBE keywords_id con los conceptos canónicos del thesaurus
multilingüe CURADO (en/es/pt), dict canónico→aliases en JSON o Path a ese JSON.
Determinista e idempotente (ADR 0011). SIN fallback semántico/LLM (ADR 0011 enmendado /
0022): lo que no matchea queda fuera, sin inventar conceptos con un modelo. Paso EXPLÍCITO
(flag `b2g build --thesaurus`, #164), NO automático: requiere el mapeo del usuario (ADR 0031)."""
Filtros de inclusión/exclusión (funciones puras, flujo PRISMA; ADR 0020):
class FilterCriterion(BaseModel):
field: Literal["year", "type", "language", "min_citations"]
op: Literal["gte", "lte", "in", "not_in", "eq"]
value: int | str | list[str]
# year: gte/lte · type: in/not_in (sobre research_areas) · language: eq/in/not_in
# min_citations: gte (sobre len(cited_by_id))
def apply_filter(corpus: Corpus, criterion: FilterCriterion) -> tuple[Corpus, FilterStep]: ...
def apply_filters(corpus: Corpus, criteria: list[FilterCriterion]) -> tuple[Corpus, list[FilterStep]]:
"""Encadena los criterios en orden y SELLA Manifest.filters con todos los pasos
(reemplaza: una corrida = una secuencia PRISMA). Devuelve (corpus_final, [FilterStep, ...])."""
Notas de contrato (ADR 0020):
- Los filtros MARCAN
rejected, NO borran: un paper excluido queda en la tabla concuration_status='rejected'víacorpus.reject(...)(con el criterio enprovenance), nunca se borra. La exclusión es curación reversible y auditable (biblioteca viva, ADR 0009/0013). - 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.
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¶
class Exporter(Protocol):
def export(self, g: nx.Graph, results: dict, out_dir: str) -> None: ...
class GraphMLExporter: ... # v1 — para Gephi / VOSviewer / Cytoscape
class CsvExporter: ... # v1 — nodos.csv + aristas.csv para pandas
Notas de contrato (Hito 2, ADR 0014, D5):
CsvExporterescribearistas.csv(source,target,weight) ynodos.csv(id,label+ atributos de nodo + métricas deresults—degree/betweenness/community— unidas por id). Orden de filas determinista. 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 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.