feat(eda): capítulo RELACIONES para AutomaticEDA

Añade el capítulo `relaciones` al motor AutomaticEDA: analiza las
relaciones de clave de la tabla/base y se coloca tras `correlacion`,
antes de `modelos`, en CHAPTER_ORDER.

Capas que renderiza (solo las que aplican; None si no hay nada que decir):
- Claves declaradas: PK/FK/UNIQUE reales del esquema DuckDB, vía la nueva
  función `detect_declared_keys_duckdb` (lee `duckdb_constraints()`).
- Candidatos a clave primaria: los `key_candidates` del TableProfile.
- FK candidatas inter-tabla: reusa `infer_fk_containment_duckdb`
  (containment + señal de nombre) y `build_join_graph` (roles de nodos +
  diagrama Mermaid pegable). Solo si la fuente DuckDB tiene varias tablas.
- FK candidatas intra-tabla: heurística nombre + cardinalidad, vía la nueva
  función pura `suggest_intratable_fk_candidates`, marcada como sugerencia.

Engancha al glosario clicable los términos PK, FK, containment/inclusión y
cardinalidad (contrato §11.1) y usa Group (keep-together) para el grafo.

Funciones nuevas del registry (grupo `eda`):
- detect_declared_keys_duckdb (impure, datascience) + test.
- suggest_intratable_fk_candidates (pure, datascience) + test.

Tests: relaciones_test.py (golden intra + inter, edges, no-cut render) +
los tests de ambas funciones. Suite automatic_eda + render_automatic_eda
verde (89 passed). Golden end-to-end con el pipeline render_automatic_eda
verificado sobre titanic (intra) y una BD customers/orders (inter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 18:15:15 +02:00
parent c6d9bc26da
commit 68f4ddabce
10 changed files with 1629 additions and 0 deletions
@@ -0,0 +1,91 @@
---
name: suggest_intratable_fk_candidates
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def suggest_intratable_fk_candidates(profile: dict, max_candidates: int = 20) -> list"
description: "Sobre el TableProfile de UNA tabla (el dict de profile_table), sugiere por heuristica de nombre + cardinalidad que columnas PARECEN una clave foranea hacia otra tabla, cuando no hay relaciones inter-tabla que medir (una sola tabla). Es una SUGERENCIA, no una afirmacion: el ref_table_guess es el stem del nombre (customer_id -> customer) y NO confirma containment. Pura: solo lee el dict, sin I/O; nunca lanza (devuelve [])."
tags: [eda, datascience, relationships, foreign-key, fk, heuristic, schema, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: profile
desc: "TableProfile (dict que produce profile_table / summarize_table_*). Se leen de forma defensiva `columns` (lista de ColumnProfile con name/inferred_type/physical_type/distinct_count/unique_pct/flags), `n_rows` (int) y `key_candidates` (lista de nombres de columna ya candidatos a PK, que se excluyen). Si no es dict o no trae columns -> []."
- name: max_candidates
desc: "Tope de sugerencias devueltas (default 20). Las columnas candidatas se ordenan por distinct_count descendente (mas informativas primero) antes de cortar a este maximo."
output: "list (posiblemente vacia) de dicts, uno por columna sugerida, con claves: `column` (nombre), `ref_table_guess` (tabla conjeturada por el stem del nombre, p.ej. customer_id -> 'customer'), `reason` (frase humana que deja claro que es heuristica sin confirmar containment), `distinct_count` (int|None), `unique_pct` (float|None, fraccion 0-1 tal como viene del profile), `inferred_type` (str), `physical_type` (str). Nunca lanza."
tested: true
tests: ["test_golden_customer_id_detectado_otras_no", "test_camelcase_albumid_detectado", "test_constante_status_id_no_aparece", "test_profile_vacio_y_none_devuelven_lista_vacia", "test_category_id_casi_unico_parece_pk_no_aparece", "test_ref_table_guess_multitoken_y_orden_por_distinct", "test_max_candidates_corta_la_lista", "test_id_generico_solo_nunca_es_fk"]
test_file_path: "python/functions/datascience/suggest_intratable_fk_candidates_test.py"
file_path: "python/functions/datascience/suggest_intratable_fk_candidates.py"
---
## Ejemplo
```python
from datascience import suggest_intratable_fk_candidates
# TableProfile de UNA tabla (tipo titanic): customer_id es FK N:1; id es la PK;
# amount es una medida float; name es categorica sin sufijo de id.
profile = {
"n_rows": 891,
"key_candidates": ["id"],
"columns": [
{"name": "id", "inferred_type": "numeric", "physical_type": "BIGINT",
"distinct_count": 891, "unique_pct": 1.0, "flags": ["possible_id"]},
{"name": "customer_id", "inferred_type": "numeric", "physical_type": "BIGINT",
"distinct_count": 137, "unique_pct": 0.15, "flags": []},
{"name": "amount", "inferred_type": "numeric", "physical_type": "DOUBLE",
"distinct_count": 400, "unique_pct": 0.45, "flags": []},
{"name": "name", "inferred_type": "categorical", "physical_type": "VARCHAR",
"distinct_count": 700, "unique_pct": 0.78, "flags": []},
],
}
out = suggest_intratable_fk_candidates(profile)
[c["column"] for c in out] # -> ["customer_id"]
out[0]["ref_table_guess"] # -> "customer"
out[0]["reason"]
# -> "el nombre termina en '_id' y es N:1 (137 valores distintos < 891 filas):
# parece (heuristica por nombre, sin confirmar containment) una referencia a
# una tabla «customer»"
```
## Cuando usarla
Cuando el EDA tiene SOLO UNA tabla y, por tanto, no se puede inferir una FK
inter-tabla por containment (no hay otra tabla cuyos valores contener). Es el plan B
del capitulo RELACIONES de AutomaticEDA: en vez de medir solapamiento de valores
entre tablas (lo correcto cuando hay varias, ver `infer_fk_containment_duckdb` /
`build_join_graph`), conjetura por el NOMBRE de la columna (`<algo>_id`) y por su
CARDINALIDAD N:1 que columnas parecen apuntar a una entidad externa. Usala para
enriquecer el reporte con "estas columnas parecen referencias a otras tablas" sin
prometer que esa tabla exista. NO la uses si tienes varias tablas: ahi mide
containment de verdad.
## Gotchas
- Es **heuristica**, no una verdad: produce **falsos positivos** (una columna
`period_id` que en realidad es un codigo libre, no una FK) y **falsos negativos**
(una FK que no se llama `*_id`, p.ej. `parent`, `owner`, `sku`). No la trates como
una afirmacion de esquema.
- `ref_table_guess` es una **conjetura por el nombre** (el stem sin el sufijo id):
`customer_id` -> `customer`, `AlbumId` -> `album`, `manager_staff_id` ->
`manager_staff`. Puede no coincidir con el nombre real de la tabla (plurales,
prefijos, alias). Es una pista, no un join garantizado.
- **NO confirma containment**: no comprueba que los valores de la columna existan en
ninguna otra tabla (no puede — solo recibe el perfil de una tabla). Para confirmar
una FK real con varias tablas usa `infer_fk_containment_duckdb`.
- Excluye deliberadamente: el `id`/`Id`/`ID` generico a secas (suele ser la PK
propia, no una referencia), las columnas constantes, las que parecen unicas
(`unique_pct >= 0.99`, mas PK que FK) y los tipos no-clave (float/decimal son
medidas; date/time/timestamp y boolean no son claves). En camelCase, `paid`,
`valid`, `grid` (con `id` en minuscula y sin separador) NO se confunden con FK.
- `unique_pct` se interpreta como **fraccion 0-1** (tal como la emite el profile), no
como porcentaje 0-100.