---
name: infer_fk_containment_duckdb
kind: function
lang: py
domain: datascience
version: "1.1.0"
purity: impure
signature: "def infer_fk_containment_duckdb(db_path: str, tables: list = None, min_inclusion: float = 0.9, max_card: int = 200000, require_name_signal: bool = True) -> dict"
description: "Infiere FOREIGN KEYs candidatas entre tablas DuckDB combinando SEÑAL DE NOMBRE y containment de valores: exige primero que el origen nombre/contenga la tabla destino (patron <X>Id -> X.<X>Id / <x>_id -> x) y que el destino sea su PK nombrada, excluyendo PKs propias y columnas de medida como origen; luego confirma con inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)| >= min_inclusion y B key-ish (distinct/count >= 0.95). El filtro de nombre va ANTES del INTERSECT: mata el 10-20x de falsos positivos de la contencion pura y acelera (menos pares). Degrada a contencion pura si el esquema no usa convencion de nombres. Poda por tipo base y push-down SQL sin traer filas a RAM. Parte del grupo eda (relaciones inter-tabla)."
tags: [eda, relations, duckdb, foreign-key, schema-inference, datascience, exploratory-data-analysis]
params:
  - name: db_path
    desc: "Ruta al archivo DuckDB. Debe existir (lectura read-only via las primitivas del grupo duckdb; no se crea)."
  - name: tables
    desc: "Lista de nombres de tabla a considerar. None (default) usa todas las del esquema main (duckdb_list_tables). Cada nombre se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo en el SQL."
  - name: min_inclusion
    desc: "Umbral minimo de inclusion (0-1) para emitir una FK candidata. inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|. Default 0.9."
  - name: max_card
    desc: "Tope de filas en la tabla destino (lado B, el caro del INTERSECT). Si count(T2) > max_card, los pares hacia T2 se saltan para no disparar un INTERSECT gigante; se acumula una nota en skipped[]. Default 200000."
  - name: require_name_signal
    desc: "Si True (default) exige señal de nombre ademas de contencion: el origen debe nombrar/contener la tabla destino y NO ser la PK de su propia tabla; el destino debe ser su PK nombrada (o `id`). Filtra los pares ANTES del INTERSECT (precision + velocidad, issues H3+H10). Degrada automaticamente a contencion pura si el esquema no usa convencion de nombres de clave (ninguna columna `...id`). Con False nunca se exige señal (comportamiento historico de contencion pura)."
output: "dict dict-no-throw. En exito {status:'ok', fk_candidates:[{from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key, name_match}, ...], tables:[str], skipped:[str], name_signal_enforced:bool} con fk_candidates ordenado por (name_match, inclusion) descendente; name_match indica si la candidata tiene señal de nombre; name_signal_enforced indica si el filtro de nombre se aplico (False si se degrado a contencion pura); cardinality es '1:1' (A casi unica en T1) o 'N:1' (A se repite, apunta a la key de T2). En error {status:'error', error:str}."
uses_functions: [duckdb_list_tables_py_infra, duckdb_table_schema_py_infra, duckdb_query_readonly_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_detecta_fk_orders_customer_id", "test_shape_resultado", "test_no_inventa_fk_columnas_no_relacionadas", "test_no_fk_entre_tipos_incompatibles", "test_min_inclusion_alto_filtra", "test_subset_explicito_de_tablas", "test_db_inexistente_devuelve_error", "test_tabla_invalida_devuelve_error", "test_name_signal_helpers", "test_conserva_fk_reales_con_nombre", "test_excluye_medida_y_pk_como_origen", "test_degrada_a_contencion_sin_pistas_de_nombre", "test_require_name_signal_false_es_historico", "test_flujo_materializado_create_table_as_no_vacia"]
test_file_path: "python/functions/datascience/infer_fk_containment_duckdb_test.py"
file_path: "python/functions/datascience/infer_fk_containment_duckdb.py"
---

## Ejemplo

```python
import sys, os, duckdb
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import infer_fk_containment_duckdb

# Base de ejemplo en /tmp: orders.customer_id -> customers.id
path = "/tmp/fk_demo.duckdb"
if os.path.exists(path):
    os.remove(path)
con = duckdb.connect(path)
con.execute("CREATE TABLE customers (id INTEGER, region VARCHAR)")
con.execute("INSERT INTO customers VALUES (1,'norte'),(2,'sur'),(3,'este'),(4,'oeste')")
con.execute("CREATE TABLE orders (order_id INTEGER, customer_id INTEGER, total DOUBLE)")
con.execute("INSERT INTO orders VALUES (10,1,99.5),(11,2,12.0),(12,1,45.25),(13,3,7.75),(14,4,60.0)")
con.close()

res = infer_fk_containment_duckdb(path, min_inclusion=0.9)
if res["status"] == "ok":
    for fk in res["fk_candidates"]:
        print(f"{fk['from_table']}.{fk['from_col']} -> "
              f"{fk['to_table']}.{fk['to_col']}  "
              f"inclusion={fk['inclusion']:.2f}  {fk['cardinality']}")
    # -> orders.customer_id -> customers.id  inclusion=1.00  N:1
else:
    print("error:", res["error"])
```

## Cuando usarla

- Cuando exploras un esquema DuckDB que no conoces y quieres descubrir el grafo de relaciones (que tabla referencia a cual) sin que la base haya declarado FKs.
- Como paso del grupo `eda` que va mas alla del perfil por tabla (`summarize_table_duckdb`): aqui se modelan las relaciones INTER-tabla.
- Antes de migrar un esquema sin constraints a otro motor (PostgreSQL, etc.) para proponer las FOREIGN KEYs que faltan.
- Para auditar integridad referencial: una inclusion < 1.0 en una FK que crees que deberia ser total indica valores huerfanos (filas de T1 cuyo valor no existe en la key de T2).

## Gotchas

- **Impura**: lee de disco via las primitivas read-only del grupo `duckdb` (no crea ni modifica la base). El `db_path` debe existir.
- **Coste O(pares podados)**: el numero de comparaciones es O(tablas^2 x columnas^2) ANTES de la poda. La poda por tipo base (solo se comparan columnas de la misma clase: ambos enteros, ambos varchar, ...) recorta drasticamente ese espacio, pero en esquemas con muchas tablas y columnas del mismo tipo puede seguir siendo costoso. Cada par evaluado dispara un `INTERSECT` en el motor.
- **`INTERSECT` puede ser caro en tablas enormes**: por eso `max_card` (default 200000) limita el lado destino. Si `count(T2) > max_card`, los pares hacia T2 se saltan y se anota en `skipped[]`. Sube `max_card` con cuidado: el INTERSECT materializa los distintos de ambos lados.
- **Señal de nombre obligatoria por defecto (`require_name_signal=True`)**: para emitir una candidata, el origen debe NOMBRAR o CONTENER la tabla destino (`AlbumId -> Album`, `customer_id -> customers`, `manager_staff_id -> staff`) y el destino debe ser su PK nombrada (o `id`). Esto mata el grueso de falsos de la contencion pura (sin el filtro, chinook daba 111 candidatas vs 9 reales; sakila 565 vs ~21). Limite: una FK con **nombre divergente** que no contiene la tabla destino (p.ej. `Customer.SupportRepId -> Employee.EmployeeId`) NO es alcanzable por nombre y se pierde; y las **self-FK** (`Employee.ReportsTo -> Employee`) nunca se infieren (la funcion exige T1 != T2). Si tu esquema usa convencion de nombres pero tiene FK con columnas que no terminan en `id`, esas tambien se pierden en modo enforce.
- **Degrada a contencion pura sin convencion de nombres**: si NINGUNA columna del esquema termina en `id`, no se exige señal y se vuelve al comportamiento historico de solo-contencion (`name_signal_enforced=False` en el retorno). Tambien puedes forzarlo con `require_name_signal=False`.
- **Containment != FK declarada**: que A este contenido en B (con B key-ish) es una FK *probable*, no una garantia. Una columna puede estar contenida por coincidencia (rangos pequenos de enteros, banderas, fechas solapadas) sin ser una relacion real. Revisa siempre las candidatas; trata `inclusion` y `cardinality` como senales, no como verdad.
- **Entero y float NO se mezclan**: la poda por tipo pone INTEGER/BIGINT/... en la clase `integer` y FLOAT/DOUBLE/DECIMAL en `float`, y solo empareja columnas de la misma clase. Una FK entera contra una columna float casi nunca es real, asi que se descarta de entrada.
- **Solo esquema `main`** cuando `tables=None`: hereda el alcance de `duckdb_list_tables` (esquema `main`).
- **Identificadores interpolados**: nombres de tabla/columna se validan contra `^[A-Za-z_][A-Za-z0-9_]*$` y se citan (COUNT DISTINCT / INTERSECT no admiten parametros posicionales para identificadores). Una tabla con nombre invalido devuelve `{status:'error'}`; una columna con nombre invalido se ignora sin abortar.
- **Direccion**: cada candidata es A -> B (A es la FK, B es la key referenciada). El par inverso (B -> A) se evalua por separado y normalmente no pasa el filtro de inclusion o el de key.

## Notas

Definicion de containment usada:

```text
inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|
```

Criterio de emision de FK candidata A (de T1) -> B (de T2):

1. T1 != T2 y `type_class(A) == type_class(B)` (poda por clase de tipo base).
2. `count(T2) <= max_card` (si no, los pares hacia T2 se saltan -> `skipped[]`).
3. `distinct(A) > 0`.
4. B es key-ish: `distinct(B) / count(T2) >= 0.95`.
5. `inclusion(A subseteq B) >= min_inclusion`.

Cardinalidad: si A es (casi) unica en T1 (`distinct(A) / count(T1) >= 0.95`) ->
`1:1`; si no -> `N:1` (A se repite y apunta a la key de T2).

Todo se calcula con push-down (`COUNT(DISTINCT)`, `INTERSECT`) — nunca se traen
filas a RAM. Los `count(*)` por tabla y los `distinct` por columna se cachean para
no recomputarlos entre pares.
```text
fk_candidate = {
  from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key,
  name_match
}
```

Antes del criterio de containment (pasos 1-5), cuando `require_name_signal=True` y
el esquema usa convencion de nombres, se aplica un filtro de SEÑAL DE NOMBRE que
recorta los pares evaluados (por eso baja tambien el coste, issue H10):

0a. El origen no puede ser la PK de su propia tabla, detectada SOLO por NOMBRE: el
    stem de la columna casa con el nombre de la tabla (`Genre.GenreId`, `film.film_id`)
    o es el generico `id`. NO se usa la PRIMARY KEY declarada — asi funciona sobre
    tablas materializadas con `CREATE TABLE AS` (sin PK), donde `Track.AlbumId`
    (stem 'album' != tabla 'track') NO es PK propia y se conserva como FK.
0b. El destino debe ser la PK nombrada de su tabla: `to_col` nombra `to_table`
    (`store_id` en store) o es el generico `id`.
0c. El origen debe nombrar la tabla destino: su stem casa con `to_table`
    (`<X>Id -> X`) o la contiene como subcadena (`manager_staff_id -> staff`).

## Capability growth log

- v1.1.0 (2026-06-29) — añade `require_name_signal` (default True): filtro de señal
  de nombre ANTES del containment. Corrige falsos positivos masivos de la
  inferencia por sola contencion (chinook 111->9, sakila 565->21) y acelera (chinook
  6.8s->0.4s, sakila 23.4s->0.9s). Issues H3 + H10 del benchmark EDA. Retrocompatible:
  degrada a contencion pura si el esquema no usa convencion de nombres de clave.
  Nuevos campos en el retorno: `name_match` por candidata y `name_signal_enforced`.
