fix(eda): hallazgos de comportamiento del benchmark (H2,H3,H6,H7,H8,H10,H11)
Ronda 4 (verificada con re-corrida sobre los datasets afectados): - H2: stl_decompose deriva periodo de la frecuencia del indice (seattle period=365 seasonal_strength=0.84; fin del period=2 espurio) - H3+H10: infer_fk por senal de nombre (<X>Id->X.<X>Id) + excluir no-clave -> chinook 111->9 FK, todas reales, cero absurdas, 16-27x mas rapido; base intacta (flag off->111) - H6: association no computa eta2 si cardinalidad~=n (Ticket-Fare espurio fuera) - H7: id secuencial monotono excluido de correlacion y PCA/KMeans (PassengerId fuera) - H8: correlacion de series no estacionarias marcada espuria / sobre retornos - H11: distribution_type usa modos/cardinalidad/normalidad (quality->discrete) - 66 tests verdes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,119 @@ def _valid_idents(*names) -> bool:
|
||||
return all(isinstance(n, str) and _IDENT_RE.match(n) for n in names)
|
||||
|
||||
|
||||
# --- Señal de nombre (precisión de FK, issues H3 + H10) -----------------------
|
||||
# La contención de valores por si sola produce 10-20x falsos positivos: cualquier
|
||||
# clave entera pequeña (1..N) esta contenida en cualquier clave mas grande, y las
|
||||
# columnas de medida (cantidades, importes) caen dentro del rango de los ids. La
|
||||
# señal mas fuerte de una FK real es el NOMBRE de la columna: el patron canonico
|
||||
# `<X>Id -> <X>.<X>Id` (PascalCase de chinook) o `<x>_id -> <x>.<x>_id` (snake_case
|
||||
# de sakila). Filtrar candidatos por nombre ANTES del INTERSECT corrige precision
|
||||
# y rendimiento a la vez (menos pares que evaluar).
|
||||
|
||||
|
||||
def _norm(s) -> str:
|
||||
"""Normaliza un identificador: minusculas, solo [a-z0-9] (quita `_`, espacios)."""
|
||||
return re.sub(r"[^a-z0-9]", "", str(s).lower())
|
||||
|
||||
|
||||
def _singular(s: str) -> str:
|
||||
"""Singular ingles aproximado (KISS): customers->customer, cities->city.
|
||||
|
||||
Heuristica suficiente para casar columna `<x>_id` con tabla `<x>s`/`<x>`.
|
||||
"""
|
||||
if len(s) > 4 and s.endswith("ies"):
|
||||
return s[:-3] + "y"
|
||||
if len(s) > 3 and s.endswith("s") and not s.endswith("ss"):
|
||||
return s[:-1]
|
||||
return s
|
||||
|
||||
|
||||
def _ends_id(norm: str) -> bool:
|
||||
"""True si el nombre normalizado termina en `id` (incluye el propio `id`)."""
|
||||
return norm.endswith("id") and norm != ""
|
||||
|
||||
|
||||
def _id_stem(norm: str) -> str:
|
||||
"""Quita el sufijo `id` de un nombre ya normalizado: albumid->album, id->''."""
|
||||
return norm[:-2] if norm.endswith("id") else norm
|
||||
|
||||
|
||||
def _name_eq(a, b) -> bool:
|
||||
"""Igualdad de nombres tolerante a singular/plural (normaliza ambos lados)."""
|
||||
na, nb = _norm(a), _norm(b)
|
||||
if not na or not nb:
|
||||
return False
|
||||
return _singular(na) == _singular(nb)
|
||||
|
||||
|
||||
def _col_is_own_pk(col: str, table: str) -> bool:
|
||||
"""True si `col` parece la PRIMARY KEY de su propia `table` por nombre.
|
||||
|
||||
Una PK no es origen de FK en un esquema normal: `Genre.GenreId` referencia a
|
||||
su propia tabla, no a otra. Casos: `GenreId` en Genre, `film_id` en film, o el
|
||||
generico `id`. Esto impide que las PKs pequeñas (1..N), contenidas por
|
||||
construccion en cualquier clave mayor, se emitan como FK absurdas (origen).
|
||||
"""
|
||||
nc = _norm(col)
|
||||
if nc == "id":
|
||||
return True
|
||||
if _ends_id(nc) and _name_eq(_id_stem(nc), table):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _name_signal(from_col: str, to_table: str, to_col: str) -> bool:
|
||||
"""True si (from_col -> to_table.to_col) tiene señal de nombre de FK real.
|
||||
|
||||
Dos condiciones, AMBAS necesarias:
|
||||
|
||||
1. El DESTINO es la PK nombrada de su tabla: `to_col` nombra `to_table`
|
||||
(`store_id` en store, `AlbumId` en Album) o es el generico `id`. Esto ancla
|
||||
el destino a una clave real, no a una columna cualquiera de la tabla.
|
||||
|
||||
2. El ORIGEN apunta a esa tabla por su nombre: el stem de `from_col` casa con
|
||||
`to_table` (`<X>Id -> X`, `<x>_id -> x`) o lo CONTIENE como subcadena
|
||||
(`manager_staff_id -> staff`). El origen debe terminar en `id`.
|
||||
|
||||
Mata el grueso de falsos de la contencion pura: `ArtistId -> Invoice.InvoiceId`
|
||||
falla porque "artist" no nombra ni contiene "invoice"; `Quantity -> AlbumId`
|
||||
falla porque "quantity" no termina en id. Conserva las FK reales con nombre que
|
||||
casa (`Track.AlbumId -> Album.AlbumId`). Limite conocido: FK con nombre
|
||||
divergente que no contiene la tabla destino (`Customer.SupportRepId ->
|
||||
Employee.EmployeeId`) no son alcanzables por nombre.
|
||||
"""
|
||||
nfc = _norm(from_col)
|
||||
if not _ends_id(nfc):
|
||||
return False
|
||||
ntc = _norm(to_col)
|
||||
# (1) destino = PK nombrada del to_table, o `id` generico.
|
||||
to_col_ok = (_ends_id(ntc) and _name_eq(_id_stem(ntc), to_table)) or ntc == "id"
|
||||
if not to_col_ok:
|
||||
return False
|
||||
# (2) origen nombra (o contiene) la tabla destino.
|
||||
fstem = _id_stem(nfc)
|
||||
if _name_eq(fstem, to_table):
|
||||
return True
|
||||
sing_t = _singular(_norm(to_table))
|
||||
if sing_t and (sing_t in fstem or _norm(to_table) in fstem):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _schema_has_name_hints(cols_by_table: dict) -> bool:
|
||||
"""True si el esquema usa convencion de nombres de clave (alguna columna `...id`).
|
||||
|
||||
Permite degradar a contencion pura (retrocompatible) en bases con columnas
|
||||
cripticas (`c1`, `c2`) que no siguen ninguna convencion: ahi la señal de
|
||||
nombre no aplica y se vuelve al comportamiento historico.
|
||||
"""
|
||||
for cols in cols_by_table.values():
|
||||
for c in cols:
|
||||
if _ends_id(_norm(c["name"])):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _scalar(res: dict):
|
||||
"""Extrae el unico valor escalar de un resultado duckdb_query_readonly.
|
||||
|
||||
@@ -111,6 +224,7 @@ def infer_fk_containment_duckdb(
|
||||
tables: list = None,
|
||||
min_inclusion: float = 0.9,
|
||||
max_card: int = 200000,
|
||||
require_name_signal: bool = True,
|
||||
) -> dict:
|
||||
"""Infiere FOREIGN KEYs candidatas entre tablas DuckDB por containment de valores.
|
||||
|
||||
@@ -125,13 +239,23 @@ def infer_fk_containment_duckdb(
|
||||
max_card: tope de filas en la tabla destino (lado B, el caro del INTERSECT).
|
||||
Si count(T2) > max_card, el par se salta para no disparar un INTERSECT
|
||||
gigante; se acumula una nota en skipped[]. Default 200000.
|
||||
require_name_signal: si True (default) exige SEÑAL DE NOMBRE ademas de
|
||||
contencion: la columna origen debe nombrar la tabla destino (patron
|
||||
`<X>Id -> X.<X>Id` / `<x>_id -> x.<x>_id`) o referenciar su PK
|
||||
nombrada, y NO puede ser la PK de su propia tabla. Esto elimina el
|
||||
10-20x de falsos positivos de la contencion pura (issues H3+H10) y, al
|
||||
filtrar pares ANTES del INTERSECT, acelera. Degrada automaticamente a
|
||||
contencion pura si el esquema no usa convencion de nombres de clave
|
||||
(ninguna columna `...id`), por retrocompatibilidad. Con False nunca se
|
||||
exige señal (comportamiento historico).
|
||||
|
||||
Returns:
|
||||
dict dict-no-throw. En exito:
|
||||
{status:'ok',
|
||||
fk_candidates:[{from_table, from_col, to_table, to_col, inclusion,
|
||||
cardinality, to_is_key}, ...], # ordenado por inclusion desc
|
||||
tables:[str], skipped:[str]}
|
||||
cardinality, to_is_key, name_match}, ...],
|
||||
# ordenado por inclusion desc
|
||||
tables:[str], skipped:[str], name_signal_enforced:bool}
|
||||
En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
@@ -214,6 +338,11 @@ def infer_fk_containment_duckdb(
|
||||
key_cache[cache_key] = (ratio >= 0.95, ratio)
|
||||
return key_cache[cache_key]
|
||||
|
||||
# 4b) ¿Exigir señal de nombre? Se exige si el caller lo pide Y el esquema
|
||||
# usa convencion de nombres de clave. Si no hay convencion (columnas
|
||||
# cripticas), se degrada a contencion pura (retrocompatible).
|
||||
enforce_name = require_name_signal and _schema_has_name_hints(cols_by_table)
|
||||
|
||||
candidates = []
|
||||
|
||||
# 5) Pares (A en T1, B en T2) con T1 != T2 y misma clase de tipo (PODA).
|
||||
@@ -235,11 +364,24 @@ def infer_fk_containment_duckdb(
|
||||
for a in cols_by_table[t1]:
|
||||
if a["type_class"] == "other":
|
||||
continue
|
||||
# PODA por nombre: una PK nunca es ORIGEN de FK. Excluir aqui
|
||||
# (antes del bucle interno) mata pares absurdos como
|
||||
# `Genre.GenreId -> Track.TrackId` de raiz.
|
||||
if enforce_name and _col_is_own_pk(a["name"], t1):
|
||||
continue
|
||||
for b in cols_by_table[t2]:
|
||||
# PODA: solo pares con la misma clase de tipo base.
|
||||
if a["type_class"] != b["type_class"]:
|
||||
continue
|
||||
|
||||
# PODA POR NOMBRE (issues H3+H10): exigir señal de nombre
|
||||
# ANTES del INTERSECT. Recorta el grueso de pares falsos y
|
||||
# evita el coste del containment sobre ellos.
|
||||
if enforce_name and not _name_signal(
|
||||
a["name"], t2, b["name"]
|
||||
):
|
||||
continue
|
||||
|
||||
# distinct(A); si es 0, no hay containment que medir.
|
||||
d_a = distinct_count(t1, a["name"])
|
||||
if d_a == 0:
|
||||
@@ -281,16 +423,25 @@ def infer_fk_containment_duckdb(
|
||||
"inclusion": inclusion,
|
||||
"cardinality": cardinality,
|
||||
"to_is_key": True,
|
||||
"name_match": _name_signal(
|
||||
a["name"], t2, b["name"]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda c: c["inclusion"], reverse=True)
|
||||
# Orden: primero las que tienen señal de nombre (FK mas fiables), luego por
|
||||
# inclusion descendente. En modo degradado (sin señal) todas son False y el
|
||||
# orden cae a inclusion, como antes.
|
||||
candidates.sort(
|
||||
key=lambda c: (c["name_match"], c["inclusion"]), reverse=True
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"fk_candidates": candidates,
|
||||
"tables": tables,
|
||||
"skipped": skipped,
|
||||
"name_signal_enforced": enforce_name,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
Reference in New Issue
Block a user