---
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.
