Metadata-Version: 2.5
Name: cotejo
Version: 0.7.0
Summary: Motor genérico de detección de duplicados y búsqueda por similitud, con heurísticas definidas por el usuario
Project-URL: Repository, https://github.com/gabinoruizramirez/cotejo
Project-URL: Documentation, https://github.com/gabinoruizramirez/cotejo#readme
Project-URL: Changelog, https://github.com/gabinoruizramirez/cotejo/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/gabinoruizramirez/cotejo/issues
Author-email: Gabino Ruiz Ramírez <gabinoruizramirez@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: data-quality,deduplication,duplicados,entity-resolution,fuzzy-matching,record-linkage
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Environment :: Win32 (MS Windows)
Classifier: Environment :: X11 Applications :: Qt
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: End Users/Desktop
Classifier: Intended Audience :: Information Technology
Classifier: Natural Language :: Spanish
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Database
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Topic :: Utilities
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: pydantic>=2.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: build
Requires-Dist: build; extra == 'build'
Requires-Dist: pillow; extra == 'build'
Requires-Dist: pyinstaller>=6.0; extra == 'build'
Requires-Dist: twine; extra == 'build'
Provides-Extra: cli
Requires-Dist: rich>=13.0; extra == 'cli'
Provides-Extra: dev
Requires-Dist: openpyxl>=3.1; extra == 'dev'
Requires-Dist: pyside6>=6.5; extra == 'dev'
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: rapidfuzz>=3.0; extra == 'dev'
Requires-Dist: rich>=13.0; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Provides-Extra: excel
Requires-Dist: openpyxl>=3.1; extra == 'excel'
Provides-Extra: gui
Requires-Dist: pyside6>=6.5; extra == 'gui'
Provides-Extra: speed
Requires-Dist: rapidfuzz>=3.0; extra == 'speed'
Description-Content-Type: text/markdown

# Cotejo

**Detección de duplicados y búsqueda por similitud sobre datos tabulares, con las heurísticas que tú defines.**

La mayoría de las herramientas de *fuzzy matching* saben decirte cuánto se parecen dos textos.
El problema real casi nunca es ese. El problema es que **dos registros muy parecidos suelen ser
cosas distintas**: el mismo jugo en presentación de 355 ml y de 600 ml, el mismo tornillo en dos
longitudes, la misma refacción con un número de pieza diferente.

Cotejo trata ese conocimiento como configuración de primera clase. Tú declaras qué columnas
comparar, con qué papel y bajo qué reglas; el motor te explica cada veredicto en lugar de
entregarte un número sin origen.

> **Estado:** las cinco fases del plan están completas — motor, aplicación de escritorio,
> catálogos de prueba con la respuesta conocida, documentación y aprendizaje del uso — y sobre
> ellas están los tres modos de trabajo: limpiar un catálogo, cruzar dos y buscar dentro de uno.
> El plan está en [`docs/arquitectura.md`](docs/arquitectura.md), y lo que cambió en cada
> versión, en [`CHANGELOG.md`](CHANGELOG.md).

---

## De dónde viene

De un sistema real en Excel y VBA, de unas cinco mil líneas, que detectaba materiales duplicados
en el catálogo de una planta automotriz: unos 270 000 registros y una decena de heurísticas
afinadas a base de revisar falsos positivos uno por uno.

Ese sistema funcionaba, pero tenía las reglas del dominio cableadas en el código y los nombres de
las columnas fijos. Cotejo conserva las heurísticas —que son la parte difícil— y las convierte en
configuración. El caso automotriz pasa a ser [un perfil de ejemplo](src/cotejo/profiles/industrial_catalog.yaml),
no el programa.

---

## Requisitos

**Python 3.10 o superior. Nada más.**

Lo único que hace falta instalar aparte son dos bibliotecas pequeñas, y `pip` se encarga:
`pydantic` valida los perfiles y `pyyaml` los lee.

Todo lo demás ya viene dentro de Python:

| Necesidad | De dónde sale |
|---|---|
| Guardar el proyecto y las decisiones | `sqlite3` — **incluido en Python**, no se instala |
| Leer y escribir CSV | `csv` — incluido |
| Normalizar acentos | `unicodedata` — incluido |

Los extras son opcionales y **ninguno cambia los resultados**:

| Extra | Para qué | Instalar |
|---|---|---|
| `rich` | Consola con formato | `pip install "cotejo[cli]"` |
| `rapidfuzz` | Distancias unas cien veces más rápidas | `pip install "cotejo[speed]"` |
| `openpyxl` | Leer archivos de Excel | `pip install "cotejo[excel]"` |
| `PySide6` | La aplicación de escritorio | `pip install "cotejo[gui]"` |

### Instalación

```bash
pip install "cotejo[gui]"    # con interfaz gráfica
pip install cotejo           # solo el motor y la consola
cotejo-gui                   # abre la ventana
```

O desde el repositorio, para tocar el código:

```bash
git clone https://github.com/gabinoruizramirez/cotejo
cd cotejo
pip install -e ".[dev]"
```

**¿Sin Python?** Cada [publicación][releases] trae un `Cotejo.exe` para Windows: se descarga, se
hace doble clic y se abre. No instala nada ni pide permisos de administrador. Los tres caminos,
con sus pegas, están en [`docs/instalar.md`](docs/instalar.md).

[releases]: https://github.com/gabinoruizramirez/cotejo/releases

### Comprobar que todo está

```bash
cotejo doctor
```

```
  ok    Python                             3.13.1 en Windows
  ok    sqlite3 (guardar el proyecto)      incluido en Python · motor 3.45.1
  ok    csv (leer y escribir CSV)          incluido en Python
  ok    unicodedata (normalizar acentos)   incluido en Python
  ok    pydantic (validar perfiles)        2.13.3
  ok    yaml (leer perfiles)               6.0.3
  ok    PySide6 (interfaz gráfica)         Qt 6.11.2

  Todo lo necesario está. Puedes ejecutar el proyecto.
```

Si algo falta, el comando dice **qué** falta y **cómo** instalarlo, distinguiendo lo obligatorio
de lo opcional.

---

## La aplicación

```bash
cotejo-gui                 # o bien:  cotejo gui datos.csv
```

Seis pasos en el orden real del trabajo. Los que todavía no tienen sentido aparecen
desactivados, para que la ventana enseñe el camino en vez de exigir que uno ya lo conozca.

### Abrir un archivo y ya tener un perfil

![Mapeo de campos](docs/imagenes/mapeo.png)

Al cargar el archivo, Cotejo mira cada columna y propone qué papel juega — **y escribe por qué**.
No hay formulario en blanco: hay una propuesta con la que se puede discutir. Corregir un papel
es cambiar un desplegable.

### Ver qué le pasa a los datos, mientras se decide

![Normalización con vista previa](docs/imagenes/normalizacion.png)

Cada regla se activa, se desactiva y se reordena, y el resultado sobre un valor real del archivo
se actualiza al instante. El asterisco marca las reglas que cambiaron algo; las demás también se
aplicaron y se muestran igual, para que quede claro que nada ocurre a espaldas de nadie.

### Ejecutar y ver qué descartó cada regla

![Ejecución](docs/imagenes/ejecucion.png)

El recuento por veto es la herramienta de calibración: si uno descarta casi todo, está mal
configurado para estos datos. Sin ese número, ajustar un perfil es adivinar.

### Revisar, con la explicación en castellano

![Revisión](docs/imagenes/revision.png)

Cada grupo con su original arriba y sus posibles duplicados debajo. Al seleccionar uno, el panel
responde tres preguntas en orden: **en qué coinciden**, **en qué se diferencian** y **por qué** el
sistema concluyó lo que concluyó. Sin umbrales, sin nombres de comparadores, sin puntos.

Dos detalles que importan más de lo que parece. Si dos valores están escritos distinto pero el
motor los trata como el mismo —`REF-1001` y `REF 1001`— aparecen como coincidencia y no como
diferencia: lo contrario sería contradecir a la propia herramienta. Y solo se muestran los campos
que deciden; las existencias, las fechas y las tiendas nunca participaron en el juicio y solo
harían ruido.

Quien esté afinando el perfil marca **Ver detalle técnico** y recupera la versión completa, con
cada comparador, su umbral y sus puntos.

### El laboratorio de reglas

![Laboratorio de reglas](docs/imagenes/laboratorio.png)

Dos claves y la comparación explicada. Sirve para entender el sistema, para depurar un perfil y
para responder la pregunta que siempre llega: *¿por qué dice eso?*

**La ventana no conoce ninguna regla.** Le pregunta al catálogo qué existe, qué parámetros admite
cada cosa y de qué tipo son, y arma los formularios con esa respuesta. Una regla propia escrita en
un archivo suelto aparece en la interfaz, con su documentación y sus campos, sin tocar una línea
de código de interfaz.

---

## El cambio central: roles de campo

El motor no conoce nombres de columnas de ningún dominio. Conoce **roles**, y tú asignas cada
columna de tu archivo a uno:

| Rol | Para qué | Cómo se compara |
|---|---|---|
| `key` | Identificador de la fila *(obligatorio)* | No se compara |
| `code` | SKU, número de parte, ISBN, RFC | Canonicalización + distancia de edición |
| `text` | Descripción en lenguaje natural | Tokens y similitud de conjuntos |
| `attribute` | Color, talla, voltaje, marca | Si coincide confirma; si difiere puede vetar |
| `measure` | 355 ML, 40x20 MM, M8x40 | Valor y unidad normalizados, con tolerancia |
| `quantity` | Existencia, precio | Informativo o desempate |
| `date` | Fecha de alta | Elige el registro original del grupo |
| `partition` | Tienda, centro, país | Restringe qué se compara con qué |
| `display` | Notas, enlaces | Solo se muestra |

Solo `key` es obligatorio. Un archivo que solo tenga descripciones funciona; uno que solo tenga
códigos también. Puede haber varias columnas del mismo rol: dos códigos, tres descripciones.

---

## Un perfil mínimo

```yaml
meta:
  name: mi_catalogo

fields:
  - { column: id,          role: key }
  - { column: referencia,  role: code }
  - { column: descripcion, role: text }
  - { column: presentacion, role: measure }
```

Con eso basta para empezar: cada rol trae una normalización y unos extractores razonables por
omisión. Cuando quieras control fino, lo declaras:

```yaml
fields:
  - column: descripcion
    role: text
    normalize:
      - trim
      - uppercase
      - strip_accents
      - { rule: expand_abbreviations, params: { mapping: { "P/": "PARA ", "TORN": "TORNILLO" } } }
      - contextual_dot
      - collapse_spaces
```

---

## La función que hace distinto a este proyecto: `explain`

Una normalización agresiva es útil y peligrosa a la vez. En lugar de describirla en prosa,
Cotejo la **demuestra sobre tu dato**:

```bash
cotejo explain industrial_catalog -c descripcion -v "TORN. CAB. P/CILINDRO NR.39 39-D-1301 135°"
```

```
columna: descripcion   rol: text

normalización
  entrada  "TORN. CAB. P/CILINDRO NR.39 39-D-1301 135°"
    01 trim  ->  (sin cambio)
    02 strip_invisible  ->  (sin cambio)
    03 unify_dashes  ->  (sin cambio)
    04 unify_quotes  ->  (sin cambio)
    05 uppercase  ->  (sin cambio)
    06 strip_accents  ->  (sin cambio)
  * 07 expand_abbreviations  ->  "TORNILLO. CABEZA. PARA CILINDRO NR.39 39-D-1301 135°"
  * 08 contextual_dot  ->  "TORNILLO  CABEZA  PARA CILINDRO NR 39 39-D-1301 135°"
  * 09 merge_alphanumeric_segments  ->  "TORNILLO  CABEZA  PARA CILINDRO NR 39 39D1301 135°"
  * 10 strip_degree_sign  ->  "TORNILLO  CABEZA  PARA CILINDRO NR 39 39D1301 135 "
  * 11 collapse_spaces  ->  "TORNILLO CABEZA PARA CILINDRO NR 39 39D1301 135"
  salida   "TORNILLO CABEZA PARA CILINDRO NR 39 39D1301 135"

rasgos extraídos
  tokens          [TORNILLO, CABEZA, CILINDRO, 39D1301, 135]
  numeric_tokens  [39, 39D1301, 135]
  head_word       [TORNILLO]
```

El asterisco marca las reglas que cambiaron algo. Las demás también se aplicaron: se muestran
para que quede claro que nada ocurre a tus espaldas.

---

## Detectar duplicados

```bash
cotejo run retail_inventory examples/abarrotes.csv
```

```
  registros            7
  bloqueo              10 pares candidatos de 21 posibles (52.38% evitados)
  pares evaluados      10
  descartados por veto
      measure_differs                    8
  pares reportados     2
      duplicado_seguro                   2
  agrupamiento         2 grupos · 0 familias descartadas · 0 solitarios descartados
```

De siete artículos, dos parejas son el mismo producto capturado dos veces y ocho pares se
descartaron por diferencia de presentación. El sistema dice **cuántos** descartó y **por qué
regla**, que es lo que permite calibrar el perfil en lugar de adivinar.

Y de cualquier par se puede pedir la cuenta completa:

```bash
cotejo pair retail_inventory examples/abarrotes.csv 1 3
```

```
1 y 3: no son el mismo

Coinciden en
    referencia   REF-1001
    marca        Del Valle

Se diferencian en
    descripcion    Jugo del Valle Naranja   frente a   Jugo del Valle Naranja 600
    presentacion   355 ml   frente a   600 ml

Por qué se descartó
    · La medida de «presentacion» no es la misma: 355 ML contra 600 ML.
```

Misma referencia, misma marca, casi la misma descripción — y aun así no son el mismo artículo.
Ese es exactamente el caso que una herramienta de similitud textual resuelve mal.

Con `-t` se obtiene el detalle técnico: cada comparador, su umbral y los puntos que aportó.

---

## Tres preguntas, un mismo motor

Detectar duplicados es una de las tres cosas que se le piden a un sistema como este. Las otras dos
son cruzar dos archivos y buscar dentro de un catálogo, y en Cotejo no son otro programa: son el
mismo perfil, las mismas reglas y los mismos vetos, con otro modo.

| Pregunta | Comando | Respuesta |
|---|---|---|
| ¿Qué filas de este archivo son la misma cosa? | `cotejo run` | Grupos: un original y sus duplicados |
| ¿A qué corresponde cada fila de este archivo en aquel otro? | `cotejo link` | Por cada fila, sus candidatos del otro archivo |
| ¿Esto que voy a dar de alta ya existe? | `cotejo search` | Por cada consulta, lo que se encontró — **o nada** |

```bash
cotejo search retail_inventory examples/lista_proveedor.csv examples/abarrotes.csv --show 5
```

```
consulta P-02  "REF-1001"  "Jugo del Valle Naranja 600"  "Del Valle"
  1. 100%  3  "REF-1001"  "Jugo del Valle Naranja 600"  "Del Valle"
        duplicado_seguro · same_canonical_code, reference_similarity, description_similarity, same_brand

consulta P-04  "REF-2010"  "Cafe soluble Legal 200 g"  "Legal"
     sin coincidencias en el catálogo
```

El artículo del proveedor de 600 ml encontró **solo** el de 600 ml del catálogo, aunque el de
355 ml comparte referencia, marca y casi la descripción: el veto `measure_differs` trabaja igual
al buscar que al limpiar. Y el café no está — que es justo lo que se fue a averiguar, así que
aparece en pantalla y en el informe con su propio renglón.

Los detalles, en [`docs/buscar-en-otro-catalogo.md`](docs/buscar-en-otro-catalogo.md).

---

## Los vetos

Un veto descarta un par sin importar cuánto puntaje haya acumulado. Aquí es donde vive el
conocimiento del dominio, y por eso son configuración y no código.

| Veto | Descarta cuando | Ejemplo |
|---|---|---|
| `exclusive_numeric_token` | Cada lado tiene un número que el otro no | `397374` contra `397373` |
| `segment_numeric_divergence` | Misma estructura, un tramo numérico distinto | `N230-026-04` contra `N230-026-13` |
| `sequential_suffix` | Solo cambia un dígito final | `T1` contra `T10` |
| `attribute_differs` | Un atributo existe en ambos y no coincide | dos marcas distintas |
| `measure_differs` | Ambos declaran medida y no es la misma | `355 ML` contra `600 ML` |
| `unit_differs` | Misma cifra, otra unidad | `500 G` contra `500 KG` |
| `blocked_words` | Alguno trae una palabra que invalida | *SEGÚN DIBUJO* |
| `protected_tokens` | Un token del vocabulario propio no coincide | paquete de 6 contra de 8 |
| `partition_mismatch` | Los registros no deben cruzarse | dos tiendas distintas |
| `regex_veto` | Lo que tu dominio necesite | `JR` contra `SR` |

Todos siguen el mismo criterio de prudencia: **solo actúan si ambos registros traen el dato**.
Si a uno le falta, no hay divergencia que declarar, y descartar por falta de información
escondería duplicados reales.

Y cuando un veto se equivoca, un **rescate** lo neutraliza:

```yaml
rescues:
  - when_signals: [same_canonical_code]
    neutralizes: [exclusive_numeric_token]
    reason: >
      Los códigos canónicos coinciden: la divergencia numérica viene de que cada
      registro capturó el mismo código con separadores distintos.
```

---

## Comandos

```bash
cotejo rules                                  # catálogo de reglas disponibles
cotejo profiles                               # perfiles incluidos
cotejo validate mi_perfil.yaml                # valida y resume un perfil
cotejo explain PERFIL -c COLUMNA -v "VALOR"   # normalización paso a paso
cotejo preview PERFIL datos.csv -n 5          # cómo queda cada registro
cotejo prepare PERFIL datos.csv -o salida.csv # normaliza y extrae rasgos
cotejo run     PERFIL datos.csv               # detecta duplicados de punta a punta
cotejo link    PERFIL propio.csv otro.csv     # cruza dos archivos
cotejo search  PERFIL consultas.csv base.csv  # ¿ya existe esto en el catálogo?
cotejo pair    PERFIL datos.csv A B           # por qué dos registros son o no el mismo
cotejo gui     [datos.csv]                    # abre la aplicación de escritorio
cotejo sample  DOMINIO -n 2000                # genera un catálogo con la respuesta conocida
cotejo evaluate PERFIL datos.csv              # mide contra esa respuesta
cotejo project datos.cotejo                   # qué se ha revisado y qué conviene ajustar
cotejo doctor                                 # comprueba que el entorno esté completo
```

`run` produce tres archivos: los pares con su explicación desglosada (auditoría), los grupos con
el original arriba y los duplicados debajo con su porcentaje (revisión), y un resumen de la
ejecución. `link` y `search` producen los mismos, cambiando los grupos por una hoja de
coincidencias con una fila por consulta —**incluidas las que no encontraron nada**.

---

## ¿Y funciona? Se puede medir

La pregunta incómoda de cualquier herramienta de este tipo es cómo saber si acierta. Cotejo trae
generadores de catálogos sintéticos que **siembran la respuesta**: duplicados con las
deformaciones de captura que aparecen en datos reales, y trampas que se les parecen muchísimo y
no son el mismo elemento.

```bash
cotejo sample retail -n 2000 -o prueba.csv
cotejo evaluate retail_inventory prueba.csv
```

```
  duplicados sembrados      172
    encontrados             172  (100%)
    perdidos                0
  trampas sembradas         170
    cayó en                 0  (0%)
  precisión                 100%
  parejas agrupadas         172
    de ellas, no sembradas  0
```

Las dos cifras hay que leerlas juntas. **Recuperación** dice cuántos duplicados encontró;
**trampas** dice en cuántas parejas se equivocó. Un sistema que dice que sí a todo tiene
recuperación perfecta y cae en todas las trampas — por eso el generador siembra las dos cosas.

Sobre 2 000 registros de cada dominio, con los perfiles incluidos:

| Perfil | Recuperación | Trampas caídas | Precisión |
|---|---|---|---|
| `retail_inventory` | 100 % | 0 % | 100 % |
| `industrial_catalog` | 100 % | 0 % | 100 % |
| `crm_contacts` | 100 % | 0 % | 100 % |
| `bibliography` | 100 % | 0 % | 100 % |

> Con honestidad: estas cifras dicen que el sistema entiende las deformaciones que el generador
> produce, y generador y motor comparten supuestos. Un 100 % aquí no es un 100 % sobre un
> catálogo real. Lo que sí garantiza es que un cambio en las reglas no rompa en silencio algo que
> antes funcionaba — y eso, en un sistema de heurísticas acumuladas, es exactamente el riesgo.

La medición sobre datos reales es otra: la pantalla de revisión calcula el porcentaje de aciertos
a partir de lo que una persona confirma o rechaza.

Veinte mil registros tardan unos diez segundos en una laptop común, y el bloqueo evita el 99.9 %
de las comparaciones posibles.

---

## Aprende de lo que revisas

Cada vez que confirmas o rechazas un par dejas un dato etiquetado, y eso se guarda en el proyecto
—un archivo `.cotejo` que aparece junto a tus datos, sin que haya que acordarse de guardar nada.

![Mejora](docs/imagenes/mejora.png)

Con unas cuantas decisiones el sistema puede responder dos preguntas que antes solo tenían
respuesta por intuición.

**¿Dónde conviene poner el corte?** Con los puntajes y los veredictos a la vista se calcula qué
precisión y qué cobertura daría cada umbral, en lugar de moverlo a ojo.

**¿Qué regla se equivoca?** Un veto que descarta pares que después confirmas está mal calibrado
para estos datos. Una señal que se activa sobre todo en los pares que rechazas está sumando
puntos donde no hay evidencia.

Para que un veto mal calibrado se pueda descubrir, la pantalla de revisión deja ver **lo que cada
veto descartó**. Sin eso, un veto demasiado agresivo trabaja en silencio y nadie se entera nunca.

Nada se aplica solo: son observaciones sobre lo que ya decidiste, y el botón lo pulsas tú.

Desde la consola, lo mismo:

```bash
cotejo project datos.cotejo
```

### Y el trabajo hecho no se repite

El mismo archivo guarda los registros ya normalizados. Al volver a abrir el catálogo mañana, si
ni los datos ni la normalización cambiaron, ese trabajo no se rehace:

| Sobre 20 000 filas | Sin caché | Con caché |
|---|---|---|
| Preparar los registros | 2.6 s | 0.6 s |

La huella que decide si sigue valiendo se calcula sobre el contenido del archivo y sobre **la
parte del perfil que interviene en preparar registros**. Mover un umbral —que es lo que uno hace
todo el rato al calibrar— no invalida nada; cambiar una regla de normalización, sí. Si algo no
cuadra, la caché sencillamente no se encuentra y se recalcula: nunca se contesta con algo viejo.

```bash
cotejo project datos.cotejo --forget-cache    # por si quieres empezar de cero
```

---

## Perfiles incluidos

Cuatro dominios muy distintos resueltos con el mismo motor. Esa es la prueba de que la
arquitectura es genérica de verdad y no solo de nombre.

| Perfil | Dominio | Qué demuestra | Datos |
|---|---|---|---|
| [`industrial_catalog`](src/cotejo/profiles/industrial_catalog.yaml) | Refacciones de planta | Códigos compuestos, identificadores fuertes, dimensiones, números de pieza | [`industrial.csv`](examples/industrial.csv) |
| [`retail_inventory`](src/cotejo/profiles/retail_inventory.yaml) | Abarrotes | La referencia se repite entre presentaciones: el tamaño es el discriminador | [`retail.csv`](examples/retail.csv) |
| [`crm_contacts`](src/cotejo/profiles/crm_contacts.yaml) | Clientes y proveedores | Normalización conservadora, la eñe se conserva, bloqueo fonético | [`contacts.csv`](examples/contacts.csv) |
| [`bibliography`](src/cotejo/profiles/bibliography.yaml) | Libros | ISBN canónico y título ponderado por rareza de término | [`bibliography.csv`](examples/bibliography.csv) |

Cada archivo de ejemplo viene con su `_verdad.csv` al lado, así que cualquiera puede reproducir
las cifras de arriba.

---

## Reglas propias, sin bifurcar el paquete

El conocimiento específico de un dominio vive en tu proyecto, no en el nuestro. Escribe un
archivo suelto:

```python
from cotejo import veto, VetoResult

@veto("talla_diferente")
def talla_diferente(left, right, column="talla"):
    """Descarta el par cuando ambos registros traen talla y no coinciden."""
    a, b = left.norm(column), right.norm(column)
    if a and b and a != b:
        return VetoResult(True, f"talla {a} distinta de {b}")
    return VetoResult(False)
```

Y decláralo en el perfil:

```yaml
plugins: [reglas_propias.py]
vetoes:
  - { rule: talla_diferente, params: { column: talla } }
```

La regla queda en el mismo catálogo que las incluidas, con su documentación y sus parámetros.
Ejemplo completo en [`examples/`](examples/).

---

## Uso como librería

```python
from cotejo import Engine, load_profile, read_table, render_pair

profile = load_profile("mi_perfil.yaml")
result = Engine(profile).run(read_table("catalogo.csv"))

print("\n".join(result.stats.lines()))

for group in result.groups:
    print(group.canonical, "->", group.members)

for pair in result.pairs[:5]:
    print(render_pair(pair, result.by_key))
```

El motor informa su avance llamando a una función que se le pasa, sin saber nada de la interfaz
que la implemente:

```python
Engine(profile, progress=lambda fase, hechos, total: print(fase, hechos, total))
```

---

## Documentación

- [`docs/recorrido.md`](docs/recorrido.md) — **un caso completo de principio a fin**, con salidas reales
- [`docs/instalar.md`](docs/instalar.md) — las tres formas de instalarlo, incluida la que no necesita Python
- [`docs/publicar.md`](docs/publicar.md) — cómo se publica una versión (PyPI, ejecutable, GitHub)
- [`docs/buscar-en-otro-catalogo.md`](docs/buscar-en-otro-catalogo.md) — cruzar dos archivos y buscar dentro de un catálogo
- [`docs/escribir-un-perfil.md`](docs/escribir-un-perfil.md) — cada pieza de un perfil y los errores que cuestan caro
- [`docs/reglas-propias.md`](docs/reglas-propias.md) — cómo añadir tus reglas sin bifurcar el paquete
- [`docs/arquitectura.md`](docs/arquitectura.md) — diseño completo, decisiones y hoja de ruta
- [`docs/reglas.md`](docs/reglas.md) — catálogo de reglas, **generado** del código y de los tests

La segunda no se escribe a mano: `tools/build_docs.py` la deriva de los docstrings y de los casos
golden, y la integración continua falla si queda desactualizada. Cada ejemplo que aparece ahí está
verificado por la suite.

---

## Pruebas

```bash
pytest -q
```

La interfaz se prueba entera sin monitor: Qt dibuja en un búfer en memoria, así que construir la
ventana, cargar datos, ejecutar el motor y comprobar el resultado funciona igual en una laptop
que en integración continua.

Los [casos golden](tests/golden/) son el corazón de la suite: cada uno documenta **por qué** su
resultado es el correcto. Hay tres conjuntos —normalización, extracción y vetos— y la suite
comprueba que ningún veto se quede sin al menos un caso que **no** deba dispararlo, porque un
veto probado solo cuando acierta no está realmente probado.

```yaml
- rule: merge_alphanumeric_segments
  cases:
    - { input: "39-D-1301",   expected: "39D1301",     why: "todos los tramos son código: es un identificador partido" }
    - { input: "30-MM",       expected: "30-MM",       why: "MM son dos letras sin dígito: es una unidad, no código" }
    - { input: "MOTOR-BOMBA", expected: "MOTOR-BOMBA", why: "palabra compuesta real: no se toca" }
```

Cuando un caso falla, el mensaje muestra esa razón: quien lo repare sabe qué se estaba protegiendo.

---

## Hoja de ruta

| Fase | Contenido | Estado |
|---|---|---|
| 1 | Núcleo, catálogo de reglas, perfiles, normalización con `explain`, extractores | **listo** |
| 2 | Bloqueo, comparadores, vetos, puntaje, agrupamiento, CLI de punta a punta | **listo** |
| 3 | Interfaz gráfica (PySide6) con laboratorio de reglas | **listo** |
| 4 | Catálogos de prueba con la respuesta conocida, medición y documentación | **listo** |
| 5 | Revisión persistida, métricas acumuladas y ajuste asistido de umbrales | **listo** |
| 6 | Los tres modos: limpiar un catálogo, cruzar dos archivos y buscar dentro de uno | **listo** |
| 7 | Caché de registros normalizados, ejecutable de Windows y publicación en PyPI | **listo** |

---

## Licencia

MIT — ver [LICENSE](LICENSE).
