--- name: extract_null_mask kind: function lang: py domain: datascience version: "1.0.0" purity: impure signature: "def extract_null_mask(query_fn, table: str, columns: list, max_rows: int = 5000) -> dict" description: "Extrae la mascara de nulos (1=falta / 0=presente) de una muestra de filas de una tabla, una lista 0/1 por columna alineada por fila, para alimentar el capitulo de calidad / patron de nulos de AutomaticEDA sin que el capitulo toque la base de datos. Recibe un lector read-only inyectado `query_fn(sql) -> dict` (mismo contrato que duckdb_query_readonly / pg_query / el `_q` de profile_table) y NO abre ninguna conexion por su cuenta. Construye UNA sola query que proyecta por cada columna `CASE WHEN \"col\" IS NULL THEN 1 ELSE 0 END` con identificadores escapados y LIMIT. Devuelve dict dict-no-throw: columns (efectivamente leidas, en orden), mask (lista int 0/1 por columna, misma longitud todas) y n. Una celda None se cuenta defensivamente como 1 (falta)." tags: [eda, nulls, missing, datascience, automatic-eda, extraction, read-only, duckdb, postgres, python] uses_functions: [] uses_types: [] returns: [] returns_optional: false error_type: "error_go_core" imports: [] params: - name: query_fn desc: "callable lector read-only del backend activo. Recibe un string SQL y devuelve un dict {'status':'ok','rows':[{col:val,...},...]} (mismo contrato que duckdb_query_readonly o el `_q` de profile_table). NO se abre ninguna conexion dentro de la funcion: toda la lectura pasa por query_fn. Si es None -> error." - name: table desc: "nombre de la tabla de la que muestrear la mascara de nulos. Se escapa con comillas dobles en la query. Vacio o None -> status error." - name: columns desc: "lista de nombres de columna a evaluar. Cada una produce una entrada en `mask` con una lista 0/1 paralela por fila (1=IS NULL, 0=presente). Cada nombre se escapa con comillas dobles. Vacia o None -> status error." - name: max_rows desc: "limite de filas a muestrear (clausula LIMIT). Default 5000. Protege frente a tablas enormes; con LIMIT obtienes el primer tramo, no un muestreo uniforme." output: "dict (nunca lanza). En exito: {'status':'ok','table':str,'columns':[str,...] (en orden),'mask':{col:[int 0/1,...],...} (1=falta/IS NULL, 0=presente; todas las listas con misma longitud = n),'n':int}. En error (sin lanzar): {'status':'error','error':str,'table':str,'columns':[],'mask':{},'n':0}. Errores: query_fn None, table vacia, columns vacia, o query_fn devuelve status!='ok' (se propaga su error)." tested: true tests: ["test_golden_mask_alineada", "test_celda_none_cuenta_como_falta", "test_columns_vacia_status_error", "test_query_fn_status_error_propaga", "test_query_fn_none_da_error_sin_reventar", "test_sql_contiene_case_y_limit"] test_file_path: "python/functions/datascience/extract_null_mask_test.py" file_path: "python/functions/datascience/extract_null_mask.py" --- ## Ejemplo ```python import sys, os sys.path.insert(0, os.path.join("python", "functions")) from datascience.extract_null_mask import extract_null_mask from infra import duckdb_query_readonly # El lector read-only se inyecta como closure (igual que el `_q` de profile_table). db = "data/clientes.duckdb" def _q(sql): return duckdb_query_readonly(db, sql) res = extract_null_mask(_q, "clientes", ["email", "telefono", "edad"]) # res == { # "status": "ok", # "table": "clientes", # "columns": ["email", "telefono", "edad"], # "mask": { # "email": [0, 0, 1, 0, ...], # fila 2 sin email # "telefono": [1, 0, 1, 0, ...], # "edad": [0, 0, 0, 1, ...], # }, # "n": 5000, # } # % de nulos por columna a partir de la muestra: pct = {c: 100 * sum(bits) / max(res["n"], 1) for c, bits in res["mask"].items()} # Se entrega al capitulo de calidad sin que este toque la BD: ctx = {"null_mask": res} ``` ## Cuando usarla Cuando el capitulo de calidad / patron de nulos de AutomaticEDA necesita saber DONDE faltan los valores (no solo cuantos) y NO debe abrir la base de datos por su cuenta: extraes aqui la mascara 0/1 por columna alineada por fila y se la pasas en `ctx['null_mask']`. Usala siempre que quieras detectar co-ocurrencia de nulos (filas que fallan en varias columnas a la vez), calcular el % de nulos sobre una muestra, o pintar un heatmap de missingness reutilizando un unico lector read-only inyectado, en vez de hacer N `COUNT(*) WHERE col IS NULL` por separado. ## Gotchas - **Impura**: lee de la base de datos a traves de `query_fn`. No abre conexiones por su cuenta — depende por completo del lector inyectado. Sigue el estilo dict-no-throw del grupo `eda`: nunca lanza; ante cualquier fallo devuelve `{"status":"error","error":...}` con `columns=[]`, `mask={}`, `n=0`. - **`error_type` en el frontmatter es `error_go_core` por convencion del registry** (toda funcion impura debe declararlo y el indexer lo exige), pero el codigo NO lanza esa excepcion: degrada al dict de error. Es metadata, no comportamiento. - **Muestra, no censo**: con `LIMIT max_rows` obtienes el primer tramo de filas que devuelva el backend, no un muestreo uniforme ni la tabla entera. El % de nulos derivado es una estimacion sobre esa muestra; para el conteo exacto usa un agregado `COUNT(*)`/`COUNT(col)` aparte. - **Alineacion por fila**: `mask[col][i]` corresponde a la misma fila `i` que `mask[otra_col][i]`. Todas las listas tienen longitud `n`, asi que puedes cruzar columnas por indice (co-ocurrencia de nulos) sin re-alinear. - **Defensa None -> 1**: el SQL ya devuelve 0/1, pero si una celda llega como `None` (CASE no aplicado, columna ausente en la fila, backend que nulifica) se cuenta como 1 (falta). Un valor inesperado no convertible a int se trata como presente (0). - **No loguear los datos crudos**: aunque `mask` es solo 0/1, los nombres de columna pueden revelar el esquema. En trazas usa `n` y el numero de columnas, no el dict completo.