0031 — Preprocesamiento automático en la ingesta (normalize + dedup) y rapidfuzz al núcleo¶
- Estado: Aceptada · AS-BUILT (2026-06-18) (#88) · supersede en parte a
0026 (el dedup deja de ser "función de librería sin
subcomando" y pasa a ser automático en la ingesta) y a la enmienda
[dedup]de 0005 (el extra se elimina;rapidfuzzpasa a núcleo) - Fecha: 2026-06-18
- Decidido por: IA (Claude Opus 4.8), validado por el Product Owner proxy
(ver
registro-ia.md) - Relacionada con: 0026 (algoritmo de dedup determinista —
intacto; cambia cuándo y cómo se invoca, no el qué), 0005
(matriz de extras;
[dedup]se elimina), 0011 (thesaurus determinista; queda como el ÚNICO paso explícito del preproc), 0017 (R2 — reloj en la frontera;applied_atinyectado), 0022 (sin IA generativa; el dedup sigue siendo similitud de cadenas determinista), 0024 (upsert-concat D3 con_seq: la razón depersist_replace), 0016 (FSM;thesauruses transversal, NO transiciona)
Contexto¶
El ADR 0026 (Hito 7) construyó el dedup fuzzy determinista
(deduplicate_authors/deduplicate_keywords) como función de librería, en el extra [dedup]
(import perezoso de rapidfuzz), sin subcomando CLI: el PO decidió entonces no exponerlo como
paso del producto. Resultado en la práctica: el corpus de la biblioteca viva nunca se deduplicaba
salvo que un programa llamara a las funciones a mano. El caso real (examples/valoraciones/,
ADR 0030) y las sesiones de QA mostraron que el corpus acumulado quedaba con variantes
casi-iguales de autores y keywords que ni normalize ni el thesaurus colapsan — exactamente lo
que el dedup existe para resolver — porque nadie lo corría.
Hay además un problema estructural con la deduplicación cross-biblioteca: cada ingesta
(seed/seed_from_bib/chain/restore) mergea el lote entrante en la biblioteca acumulada con
el upsert-concat D3 (_seq, ADR 0024), que concatena listas y deduplica solo por id. Un
dedup que corriera solo sobre el lote entrante dejaría pasar variantes que ya viven en la
biblioteca; y un upsert posterior reintroduciría las variantes viejas junto a las nuevas. Para
que el corpus quede deduplicado de verdad hay que (a) deduplicar el corpus completo ya mergeado,
no el lote, y (b) reemplazar la tabla corpus en lugar de upsertear.
rapidfuzz como extra [dedup] con import perezoso solo se justificaba mientras el dedup era
opcional. Si el dedup es automático en cada ingesta, rapidfuzz es requerido siempre: un extra
opt-in para una capacidad del camino caliente es una trampa (instalación "mínima" que produce un
corpus sin deduplicar en silencio, contra la regla de 0005 "fallar fuerte, no degradar en silencio").
Decisión¶
El preprocesamiento determinista se ejecuta automáticamente en la ingesta, rapidfuzz pasa al
núcleo, y el thesaurus queda como el único paso explícito del preproc.
-
normalize+ dedup AUTOMÁTICOS en la ingesta, punto único en la frontera. Las cuatro rutas de ingesta (seed,seed_from_bib,chain,restore) aplican el helper de fronteracli/_ingest.py::normalize_and_dedupsobre el corpus completo ya mergeado (existing.merge(incoming)→normalize_and_dedup→ persistir). El orden determinista esPreprocessor().normalize→deduplicate_authors(0.92)→deduplicate_keywords(0.90)(umbrales de 0026). Al correr sobre el corpus completo, la deduplicación es cross-biblioteca (ve toda la biblioteca acumulada, no solo el lote entrante). El algoritmo de dedup de 0026 no cambia (token_sort_ratio + Union-Find + canónico más-frecuente/desempate-id, determinista e idempotente); cambia quién lo invoca y cuándo. -
rapidfuzzpasa a[project.dependencies]; el extra[dedup]se ELIMINA. El import enpreprocessors/dedup.pydeja de ser perezoso (es import de nivel de módulo). Esto supersede la enmienda[dedup] = rapidfuzz>=3,<4de 0005: ya no hay extra que instalar, niImportErroraccionable que apunte auv sync --extra dedup. Coherente con 0005 "se declara todo lo que se importa": una dependencia del camino caliente va en el núcleo. -
El thesaurus es el ÚNICO paso explícito del preproc —
b2g thesaurus --from <archivo>(18° subcomando), transversal al FSM.apply_thesaurusrequiere el mapeo curado del usuario, así que no puede ser automático. Se expone como subcomando propio que lee el corpus, aplicaPreprocessor.apply_thesaurusy persiste sin transicionar elCycleState(mismo criterio queenrich/curate/networks, ADR 0016). El orden conceptualnormalize → thesaurus → dedupde 0026 se reordena en la práctica anormalize + dedup(automáticos, en la ingesta) ythesaurus(explícito, cuando el investigador tiene el mapeo); el thesaurus sobrescribekeywords_idy reemplaza la tabla (ver punto 4). -
persist_replace(store) /overwrite_corpus(backend): reemplazo de tabla, no upsert. La ingesta automática ythesauruspersisten conDuckDBStore.persist_replace→DuckDBBackend.overwrite_corpus, que hace DELETE + INSERT de la tablacorpus(reasignando_seqdesde 0, ADR 0024) preservando las tablas hermanas (loop_state_log,referenced_but_not_fetched). Es necesario porque el upsert-concat D3 (persist) reintroduciría las variantes que el dedup acaba de colapsar (concatena listas, deduplica solo porid). Elpersist/upsert normal queda intacto para el caso legítimo "mismo paper desde dos fuentes" (D3) y para los demás llamadores;persist_replacese usa SOLO donde ya tenés el corpus completo, normalizado y deduplicado en memoria. -
normalize/apply_thesaurusaceptanapplied_atinyectado desde la frontera (R2). ElPreprocRefdelManifestsella el timestamp que la frontera CLI inyecta con un únicodatetime.now(UTC)por invocación (mismo patrón quedecided_aten curación, ADR 0017). Sinapplied_at, se usadatetime.now(UTC)(uso como librería independiente). -
build/networkssiguen PUROS (sin red, sin preproc implícito): el corpus ya entra deduplicado desde la ingesta. La pureza de los proyectores (ADR 0014) no se toca.
Diferido a la epic GUI (#34)¶
- Revisión asistida de clusters ambiguos. Cuando un cluster fuzzy es dudoso (variantes en el
borde del umbral), lo correcto es sugerir N opciones canónicas y dejar elegir al humano
(determinista, vía los scores de
rapidfuzz, sin IA generativa — coherente con ADR 0022). Eso requiere una superficie interactiva que el CLI no tiene; se difiere a la epic GUI #34. Hoy el dedup automático aplica el canónico determinista (más-frecuente/desempate-id) sin pedir confirmación.
Consecuencias¶
- (+) El corpus queda SIEMPRE normalizado y deduplicado cross-biblioteca tras cualquier ingesta, sin que el usuario tenga que recordar correr nada. Cierra el drift "el dedup existía pero nadie lo corría" de 0026.
- (+) Instalación sin trampas:
rapidfuzzen el núcleo elimina el extra opt-in que producía un corpus sin deduplicar en silencio. Coherente con 0005 ("fallar fuerte, no degradar en silencio"). - (+) Punto único de preproc en la frontera (
normalize_and_dedup): una sola ruta, testeable, con el reloj inyectado (R2). El thesaurus, que necesita input humano, queda explícito y separado. - (+)
persist_replaceresuelve el reintroducir-variantes del upsert-concat sin romper D3 para los demás llamadores ni perder las tablas hermanas. - (−) Costo O(n²) del dedup por ingesta (deuda conocida). Al deduplicar el corpus completo en cada ingesta, el costo del clustering fuzzy crece con el tamaño de la biblioteca (no solo del lote). Aceptable a la escala actual (caso real: decenas–cientos de filas); la optimización (blocking/índices, dedup incremental) es trabajo futuro si aparece un corpus grande.
- (−) Skip conocido #93:
test_run_seed_from_bib_reseed_incrementa_rondaqueda skip — un crashBibDataString/pyparsingen un reseed dentro del mismo proceso, expuesto por el auto-dedup. No afecta el CLI real (cada invocación es un proceso nuevo; el crash es del estado global debibtexparseral reentrar en el mismo proceso). Reabrible con su propio encuadre. - (−) 0026 deja de describir el comportamiento como-construido en cuanto a invocación: el dedup ya no es "función de librería sin subcomando" sino automático en la ingesta. 0026 queda como historia inmutable del algoritmo (que sigue vigente); este ADR lo marca superseded-en-parte.
AS-BUILT (2026-06-18, #88): helper
src/bib2graph/cli/_ingest.py::normalize_and_dedupcableado enseed/seed_from_bib/chain/restore;rapidfuzz>=3,<4en[project.dependencies](extra[dedup]eliminado;preprocessors/dedup.pyimportarapidfuzza nivel de módulo); 18° subcomandob2g thesaurus --from <archivo>(cli/commands/thesaurus.py, transversal, no transiciona);DuckDBStore.persist_replace/DuckDBBackend.overwrite_corpus(DELETE+INSERT, preservan tablas hermanas, reasignan_seq);Preprocessor.normalize/apply_thesaurusaceptanapplied_at. Deuda: O(n²) del dedup por ingesta (optimización futura) y skip #93.
Nota append-only — el "único paso explícito"
thesaurusse retira como verbo (2026-06-28, #164, ADR 0038). El punto 3 de la decisión arriba ("El thesaurus es el ÚNICO paso explícito del preproc —b2g thesaurus --from <archivo>, 18° subcomando") se revisa: el verbothesaurusse elimina (no queda ni como alias). El ADR 0038 ya lo anticipó (issue #149, cerrada invalid); esta nota cierra el círculo en el 0031.
- La capacidad se preserva como flag
b2g build --thesaurus <archivo>(consolidación cross-lingüe de keywords, ADR 0011 intacto):buildaplicaPreprocessor.apply_thesaurussobrekeywords_idantes de scopear y proyectar, persiste el corpus actualizado conpersist_replace(punto 4, intacto) y suma un bloque aditivodata["thesaurus"](keywords_mapped/keywords_total/aliases_loaded/applied_at).- El resto del 0031 sigue vigente:
normalize+ dedup automáticos en la ingesta (puntos 1, 2, 4, 5) no cambian;rapidfuzzsigue en el núcleo. Lo único que cae es el verbo explícito: la consolidación de keywords pasa de paso-CLI propio a flag debuild. (Sutileza vs. el 0031: ya no hay "único paso explícito" del preproc; el thesaurus es ahora un opt-in debuild, no un verbo.)