--- title: "Perfilar una base" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Perfilar una base} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") # El ancho se restaura al terminar la vinneta: knitr corre cada una en su propia # sesion, asi que no se filtra, pero el CRAN Cookbook pide devolver toda opcion # que se cambie y no cuesta nada cumplirlo. .opciones_previas <- options(width = 90) ``` Por omisión, `perfilar_dbi()` mantiene separados dos resultados porque provienen de universos distintos: - `resumen_tabla` calcula agregados SQL sobre la tabla completa; - `perfil_muestra` ejecuta `perfilar()` sobre las filas traídas a memoria. El resumen SQL usa `universo = "tabla_completa"` por omisión. Para tablas grandes se puede pedir `universo = "muestra_motor"` junto con `muestra_motor = n`: las métricas se calculan sobre una relación muestreada por el motor, no sobre una tabla traída a R. La capacidad se sondea antes de medir; PostgreSQL y SQL Server pueden resolverla con `TABLESAMPLE`, y otros motores con una función pseudoaleatoria y el límite de su dialecto. Si ninguna forma es aceptada, las métricas quedan en `NA` y `resumen_tabla$cobertura` conserva el motivo. Cada registro de `resumen_tabla$sql` declara `alcance`, `universo`, `tamano_muestra`, `fraccion`, `metodo` y `error_esperado`. En el universo muestreado, `error_esperado` distingue `no_estimado` —el error podría calcularse bajo un plan probabilístico, pero no se calculó— de `no_estimable` —moda, mediana y cardinalidad observada no tienen una cota simple sin supuestos adicionales—. `no_aplica` indica que no hubo muestreo efectivo. El motivo queda en `resumen_tabla$sql$motivo`, junto con el método, el tamaño y la fracción. `n_distintos` se conserva como cardinalidad observada en la muestra, no como una estimación de la cardinalidad de la tabla completa. El metadato `resumen_tabla$meta$muestreo` conserva `tamano_muestra` por compatibilidad como tamaño efectivo solicitado a la consulta. Además publica `filas_solicitadas`, el pedido original, y `filas_obtenidas`, las filas que devolvió la lectura de `perfil_muestra`; esta última puede ser `NA` cuando el bloque no se solicitó o falló antes de leer. Si la consulta de la muestra devuelve cero filas, no hay base para medir las métricas de alcance `muestra`: quedan en `NA`, con estado `no_disponible` y un motivo que nombra la muestra vacía. Esto no permite concluir que la columna esté vacía, por lo que no se publica cero ni se dispara `sin_valores`; `n` conserva el conteo de la tabla completa. `estrategia_distintos` es explícita: `"exacta"` es el valor por omisión y emite `COUNT(DISTINCT)`; `"aproximada_motor"` usa una función nativa sólo si el motor la acepta; `"catalogo"` lee `pg_stats.n_distinct` en PostgreSQL y lo publica como estimación, con guardas de herencia y de modos muestreados; en otros motores queda `no_disponible` con su motivo; y `"omitida"` no emite la consulta. Una estrategia aproximada sin capacidad no se convierte en exacta. El resultado distingue la estrategia solicitada, la resuelta y su estado. Además, `id_consulta` identifica la consulta de datos que produjo cada métrica; `muestra_id` queda reservado para la relación materializada en el spool cliente. Dos métricas con el mismo valor vieron exactamente las mismas filas y se pueden comparar directamente, sin cruzar `lote` ni `columnas_compartidas`. Un `NA` declara que esa garantía no se puede hacer; en particular, moda, frecuencia de la moda y mediana son métricas por columna y no comparten filas con otras. `consulta_id` identifica la sentencia que produjo cada medición y permite formar el grupo de consistencia: las cotas duras sólo comparan valores con el mismo identificador. La consulta exacta de distintos trae `COUNT(columna) AS n_validos_guard` junto a `COUNT(DISTINCT columna)`; si una capacidad aproximada no puede traer ese guardián, la cota se declara no comprobable y no se atribuye una inconsistencia al motor. El catálogo de la clave primaria se consulta siempre, aunque la política de costo no necesite cardinalidad. La respuesta queda en `resumen_tabla$meta$clave`, con sus `columnas`, `fuente`, `motivo`, `garantia` y `estado`. La consulta es de metadatos y no recorre la tabla. La fuente estructural sólo gobierna la medición de cardinalidad cuando la política lo necesita; no se pierde el conocimiento de una clave garantizada si `estrategia_distintos` está omitida o no disponible. Si la fuente estructural no alcanza y la estrategia no permite medir, la cardinalidad queda desconocida y se aplica la regla explícita de la política. El resumen declara `meta$snapshot = FALSE`, porque sus agregados son sentencias separadas. Cuando `n_validos` y `n_distintos` exactos son incoherentes y vienen de grupos `consulta_id` distintos, `cobertura` agrega `alcance_distinto` y el motivo conserva ambas sentencias: es evidencia de que la tabla cambió durante la corrida, no una acusación contra el motor. Mezclarlos en una fila, aun con un campo de alcance por resultado, permitiría comparar cantidades como si pertenecieran al mismo perfil. Los dos bloques impiden esa lectura. Si sólo se necesitan los agregados del motor, `bloque_muestra = "solo_agregados"` evita traer filas a R y deja `perfil_muestra = NULL`. La cobertura lo declara como `no_solicitado`, que no es un fallo. Esto vale para los dos universos: con `muestra_motor`, los agregados se calculan sobre la relación muestreada, pero no se abre un spool ni se ven filas (`meta$bloques$filas_vistas = 0`). La lectura habitual se conserva con `bloque_muestra = "con_muestra"`, que es el valor por omisión; allí la selección del motor se materializa una sola vez y se reutiliza para todo el perfil. `max_celdas_muestra` y `max_bytes_muestra` acotan sólo el bloque `perfil_muestra`: el primero se resuelve con las columnas del esquema y el segundo con una sonda de hasta 100 filas, antes de leer el resto. Los agregados SQL conservan su alcance y la cobertura declara cuál tope redujo la muestra. ## Un ejemplo en memoria Para ejecutar el bloque se necesitan los paquetes opcionales `DBI` y `RSQLite`, ambos declarados en `Suggests`. Si falta cualquiera de ellos, knitr omite el bloque y la viñeta se construye sin error. La llamada a `DBI::dbWriteTable()` crea la base de demostración en memoria. Pertenece a la preparación del ejemplo; `perfilar_dbi()` no escribe en la base, no crea tablas temporales y sólo emite consultas de lectura. ```{r base, eval = requireNamespace("DBI", quietly = TRUE) && requireNamespace("RSQLite", quietly = TRUE)} library(lupa) datos_base <- data.frame( id = 1:12, grupo = rep(c("A", "B", "C"), each = 4), valor = c(10, 12, NA, 14, 20, 22, 24, 26, 30, 32, 34, 36), fecha = as.character(as.Date("2026-01-01") + 0:11), stringsAsFactors = FALSE ) datos_base conexion <- DBI::dbConnect(RSQLite::SQLite(), ":memory:") creada <- DBI::dbWriteTable(conexion, "entregas", datos_base) sin_orden <- perfilar_dbi( conexion, "entregas", muestra = 5 ) perfil <- perfilar_dbi( conexion, "entregas", muestra = 5, orden_muestra = "id" ) solo_agregados <- perfilar_dbi( conexion, "entregas", metricas = "validos", bloque_muestra = "solo_agregados" ) ``` La conexión y los dos perfiles ya están preparados. Los bloques siguientes consultan ese mismo estado: partir la explicación no implica recrear la base ni volver a medirla. Cuando el perfil es de una tabla en memoria, `perfilar()` proyecta el costo de las celdas antes de iniciar las etapas caras. Desde 100.000 celdas puede mostrar, sólo en una sesion interactiva, un aviso con la duracion estimada y la fuente de la referencia; en un guion no interactivo queda en silencio. La proyeccion se conserva en `meta$costo_tabla_ancha`. Para la proximidad del vocabulario, valores de mas de 10.000 caracteres quedan fuera de la distancia normalizada y la cobertura lo declara con el umbral; `Inf` es la forma explicita de recuperar el comportamiento anterior. ## Antes de conectarse: qué hace falta para cada motor `lupa` tiene dos dependencias obligatorias, `cli` y `data.table`. El paquete del motor y la biblioteca del sistema que va debajo son cosa de quien instala, y el error que aparece cuando falta alguna rara vez dice cuál es. ```{r requisitos, eval=FALSE} requisitos_motor() # el catálogo entero requisitos_motor("mariadb") # qué necesita MariaDB, y cómo conseguirlo ``` Por cada motor devuelve el paquete de R, la biblioteca del sistema con su nombre en Debian y en Fedora, **la salida sin permisos de administrador** cuando existe, el dialecto esperado y si está probado contra motor real o sólo esperado. Eso último importa y conviene leerlo con cuidado: la columna dice si **`lupa`** se probó contra ese motor, no si tu instalación funciona. Y la comprobación de la biblioteca del sistema es honesta sobre su propio límite — cuando sólo puede decir «el paquete de R no está, y si al instalarlo falla la compilación lo que falta es esto», dice eso, porque comprobar de verdad exigiría intentar compilar. La clave primaria que se lee después de esa conexión conserva dos datos distintos: `fuente` dice qué catálogo respondió y `garantia` dice si el estado consultado alcanza para confiar en el motor. Oracle sólo queda garantizado con `STATUS = ENABLED` y `VALIDATED`; PostgreSQL consulta `enforced` y la validación, y MySQL `enforced`. MariaDB, SQL Server, SQLite y DuckDB no ofrecen en esta lectura un estado comparable, así que allí una clave visible queda con garantía desconocida. No hay que leer una entrada del catálogo como prueba de los datos existentes cuando el estado no fue consultable. Una tabla sin clave declarada queda con `garantia = "no_declarada"`; una consulta fallida o un catálogo cuya visibilidad no se puede establecer queda con `garantia = "desconocida"` y su motivo. El método de impresión muestra esta línea de metadatos junto al resumen. Por ejemplo, una tabla sin clave dice «Clave primaria: no declara clave», mientras que una consulta fallida dice que no se pudo preguntar al catálogo. ## Leer el resultado sin memorizar su forma `perfilar()` devuelve un `perfil` plano y `perfilar_dbi()` un contenedor con dos componentes: el segundo puede ser `NULL` si la muestra no se pidió o no estuvo disponible. Es una diferencia real —una tabla en memoria y una tabla remota no son lo mismo— pero convertía la lectura en un acertijo: `perfil$general$filas` funciona sobre el primero y devuelve `NULL` sobre el segundo. ```{r accesores} library(lupa) en_memoria <- perfilar(datos_administrativos, analizar_dependencias = FALSE) n_filas(en_memoria) nrow(columnas(en_memoria)) nrow(hallazgos(en_memoria)) nrow(cobertura(en_memoria)) sql_perfil(en_memoria) # NULL: un perfil en memoria no emitió SQL ``` Los mismos cinco nombres sirven para las cuatro formas de salida. Y no inventan lo que no hay: un perfil DBI sin muestra leída, o con la muestra no solicitada, devuelve una tabla de hallazgos vacía **con su aviso**, no una tabla que aparente que se midió y no había nada. ## Los dos bloques posibles del resultado `names(perfil)` hace visible la separación que devuelve `perfilar_dbi()`. La tabla siguiente compara el alcance de cada bloque: las filas de origen y la cantidad de campos analíticos, sin presentarlos como un único perfil. ```{r resultado, eval = requireNamespace("DBI", quietly = TRUE) && requireNamespace("RSQLite", quietly = TRUE)} names(perfil) solo_agregados$perfil_muestra solo_agregados$resumen_tabla$cobertura[ solo_agregados$resumen_tabla$cobertura$bloque == "perfil_muestra", , drop = FALSE ] data.frame( bloque = c("resumen_tabla", "perfil_muestra"), filas_fuente = c( perfil$resumen_tabla$meta$filas, perfil$perfil_muestra$meta$filas_analizadas ), campos_analiticos = c( ncol(perfil$resumen_tabla$columnas) - 1L, ncol(perfil$perfil_muestra$columnas) - 1L ), columnas_del_resultado = c( ncol(perfil$resumen_tabla$columnas), ncol(perfil$perfil_muestra$columnas) ) ) ``` ## `resumen_tabla`: agregados sobre la tabla completa `resumen_tabla$columnas` contiene quince campos analíticos, además del nombre de la columna. Son los conteos y agregados que esta interfaz puede calcular en SQL sin traer la tabla: faltantes, distintos, moda y resúmenes cuantitativos, entre otros. La salida se calcula sobre las doce filas completas de `entregas`. ```{r resumen, eval = requireNamespace("DBI", quietly = TRUE) && requireNamespace("RSQLite", quietly = TRUE)} perfil$resumen_tabla$columnas[, c( "columna", "n", "n_faltantes", "n_distintos", "minimo", "maximo", "media" )] ``` `perfil_muestra$columnas` trae 117 campos analíticos además del nombre de la columna, pero su universo son cinco filas en este ejemplo. Llamar *perfil* al resumen SQL afirmaría una completitud que no tiene: el resumen cubre quince de esos aspectos sobre doce filas; el perfil cubre sus 117 sobre las cinco filas obtenidas. Las consultas, estados y motivos de los agregados SQL quedan en `resumen_tabla$sql` para que también se vea qué aceptó o rechazó el motor. La instrumentación queda activa por omisión y sólo agrega metadatos. Cada fila de `resumen_tabla$sql` conserva la métrica que ya representaba y suma `duracion_ms`, `cpu_ms`, `n_filas_resultado`, `bytes_resultado_r`, `consulta_id` e `id_consulta`, además de `etapa` y `nivel`. La duración incluye la ejecución y la lectura del resultado que DBI entregó a R; el tamaño es `object.size()` de ese resultado. `cpu_ms` es la suma de `user.self` y `sys.self` de `proc.time()`: cerca de cero indica espera y cerca de uno al dividirlo por `duracion_ms` indica trabajo del cliente. Las filas que no emitieron consulta dejan esos campos en `NA`, que significa «no medido», no `0`. Como una consulta puede producir varias filas (una por columna y métrica), su duración se repite en ellas: `nivel = 1` marca la primera fila de cada `consulta_id` y `nivel = 2` las repeticiones. Para sumar tiempos SQL se deben usar sólo las filas de `nivel = 1`. ```{r tiempos-sql, eval = requireNamespace("DBI", quietly = TRUE) && requireNamespace("RSQLite", quietly = TRUE)} perfil$resumen_tabla$sql[, c( "metrica", "estado", "duracion_ms", "cpu_ms", "n_filas_resultado", "bytes_resultado_r", "consulta_id", "id_consulta", "etapa", "nivel" )] perfil$resumen_tabla$tiempos ``` `resumen_tabla$tiempos` reúne en milisegundos las etapas grandes del lado R, incluidas la lectura y el perfilado de la muestra, el perfilado por columna y los análisis opcionales. La columna `nivel` dice cuáles se pueden sumar: las de `nivel = 1` son disjuntas entre sí y las de nivel mayor están contenidas en alguna de ellas. `perfilado_muestra` es inclusivo —contiene el perfilado por columna, las dependencias y los casi-duplicados—, así que sumar la columna entera da más que la corrida completa. `instrumentar = FALSE` conserva el mismo plan, la misma cantidad y el mismo orden de consultas, pero deja las duraciones, el CPU y los metadatos de tamaño en `NA` y marca las etapas como `no_medido`. `resumen_tabla$sql$memoria_trabajo` responde a la pregunta «¿qué trabajo no debo recomputar incrementalmente sobre una tabla mayor?». Sus tres valores son `creciente`, `acotado` y `NA` cuando la fila no tiene medición. ```{r memoria-trabajo, eval = requireNamespace("DBI", quietly = TRUE) && requireNamespace("RSQLite", quietly = TRUE)} sql_memoria <- perfil$resumen_tabla$sql filtro_memoria <- ( sql_memoria$memoria_trabajo %in% c("acotado", "creciente") | is.na(sql_memoria$memoria_trabajo) ) & ( (sql_memoria$columna == "id" & sql_memoria$metrica %in% c("n_validos", "n_distintos")) | (sql_memoria$columna == "grupo" & sql_memoria$metrica == "minimo") ) ejemplos_memoria <- sql_memoria[ filtro_memoria, c("columna", "metrica", "estado", "alcance", "fraccion", "metodo", "memoria_trabajo") ] ejemplos_memoria stopifnot( identical( as.character(ejemplos_memoria$memoria_trabajo), c("acotado", "creciente", NA_character_) ) ) ``` La regla es: alcance efectivo con tope real, `acotado`; si no, el método resuelto; sin medición, `NA`. ## `perfil_muestra`: el perfil completo de la muestra El perfil completo se obtiene en memoria sobre las cinco filas seleccionadas. La salida muestra algunos de sus 117 campos analíticos para que se vean, junto con los conteos, los faltantes, los distintos y el tipo inferido. ```{r muestra, eval = requireNamespace("DBI", quietly = TRUE) && requireNamespace("RSQLite", quietly = TRUE)} perfil$perfil_muestra$columnas[, c( "columna", "n", "n_faltantes", "n_distintos", "tipo_inferido" )] ``` Los hallazgos de `perfil_muestra` conservan sus unidades declaradas. En particular, las inconsistencias de mayúsculas, las equivalencias Unicode y `casi_duplicados_vocabulario` cuentan valores distintos, pero su trazabilidad enumera por fila todas las filas que contienen esos valores. En el último caso incluye las formas seleccionadas por el grupo, incluida la dominante. Por eso la traza puede tener más filas que `n_afectados`; no es una contradicción de alcance, sino el localizador del grupo que se debe revisar. El orden de la traza prioriza las formas no dominantes antes de las dominantes, para que `max_filas_hallazgo` conserve primero las filas accionables; la evidencia declara cuántas filas mostradas pertenecen a cada forma. En `patron_raro`, la evidencia agrega la proporción del patrón dominante y las filas en patrones no dominantes excluidos por superar `umbral_patron_raro`; si el dominante no alcanza `umbral_patron_dominante`, la no medición queda en `cobertura_diagnosticos`. Las columnas `integer64` tienen una condición equivalente: si `bit64` está instalado, `lupa` registra sus métodos antes de medir la secuencia; si falta, las cinco medidas públicas quedan en `NA` y `cobertura_diagnosticos` deja constancia de que `secuencia_entera` no se evaluó. En los textos publicados, los números usan notación fija con precisión completa, independiente de `scipen` y de `digits`, y conservan `OutDec`. Las sugerencias de ausencia estructural componen el código con una copia UTF-8 marcada de cada nombre. El nombre que queda en `columnas$columna` conserva los bytes y la marca que llegaron desde la tabla; la sugerencia sigue siendo código para copiar y pegar bajo `LC_CTYPE = "C"`. Algunos controladores no saben traer ciertas columnas en una lectura corriente —`TEXT` y `NTEXT` en SQL Server, `CLOB` y `BLOB` en Oracle, `bytea` en PostgreSQL— y responden con un error al pedirlas junto con el resto. Una sola columna así se llevaba puesta la muestra entera. Ahora, cuando la lectura falla, `lupa` **averigua cuáles no se pueden leer y reintenta sin ellas**: la muestra vuelve con las columnas que sí se pudieron leer, y `resumen_tabla$cobertura` gana una fila `alcance_distinto` que nombra las que quedaron afuera y conserva el motivo textual del motor. Averiguarlo, y no deducirlo, es lo que hace que esto funcione contra un controlador cualquiera. La versión anterior miraba el tipo que declara el motor y lo comparaba contra una lista de nombres. Contra el driver `{SQL Server}` eso no reconocía **ninguna** columna, porque ese driver no informa nombres sino códigos numéricos de ODBC —`"-1"` en vez de `"varchar(max)"`—, y la muestra se perdía igual, en silencio. Ahora el reintento **le pregunta al motor**: divide el conjunto de columnas y va podando los subconjuntos que sí se leen, hasta aislar las que fallan. El nombre del tipo sigue sirviendo de atajo —si se reconoce, se ahorra el descarte—, pero ya no decide. Eso cambia también lo que se puede afirmar. Cuando la omisión se resolvió por descarte, `omision_comprobada` es `TRUE` y el aviso lo dice: cada una de esas columnas falló por sí sola y el resto se leyó junto. Cuando la resolvió el atajo, es `FALSE` y el aviso mantiene el "no se comprobó que sean la causa", porque el reintento salta ante cualquier fallo y un corte de red que se recupera en el segundo intento daría el mismo camino. `sondas_descarte` publica cuántas consultas costó averiguarlo. El descarte está acotado: como mucho dos sondas por columna, y nunca más de la mitad del saldo de `max_consultas`. Si el descarte no aísla nada, no se inventa una culpable: la muestra se declara no disponible y el motivo dice cuántos subconjuntos se probaron y cómo terminó. En todos los casos el resumen por columna las cubre igual, porque los agregados se calculan en el motor; lo que falta es su perfil por fila. Para incluirlas, conviene convertirlas a texto acotado en una vista y perfilar la vista. El bloque de metadatos del muestreo —`perfil_muestra$meta$origen_dbi$muestreo`— describe lo que **de verdad** se leyó y no lo que se había planeado leer: `columnas_leidas` son las columnas que la muestra trae, `sql_muestra` es la consulta que se emitió, `metodo` y `acotado_en` describen la lectura que salió —no la que se había planeado, que el reintento puede cambiar—, y `columnas_omitidas` con `motivo_columnas_omitidas` dicen cuáles quedaron afuera y qué respondió el motor. ## El muestreo declarado `muestra = 5` declara las filas pedidas. La metadata registra además las filas obtenidas, las doce filas totales, el método y el orden. Sin `orden_muestra`, un `LIMIT 5` no tiene orden garantizado y `reproducible` es `FALSE`. Con `orden_muestra = "id"`, `lupa` verifica en la tabla completa que el orden sea único. Sólo entonces informa `reproducible = TRUE`. Indicar una columna que se repite no alcanza: el orden puede quedar declarado y aun así no determinar qué filas ocupan el límite. ```{r muestreo, eval = requireNamespace("DBI", quietly = TRUE) && requireNamespace("RSQLite", quietly = TRUE)} resumir_muestreo <- function(x, caso) { m <- x$perfil_muestra$meta$origen_dbi$muestreo data.frame( caso = caso, filas_pedidas = m$filas_solicitadas, filas_obtenidas = m$filas_obtenidas, filas_totales = m$filas_totales_fuente, metodo = m$metodo, orden = if (length(m$orden_muestra)) { paste(m$orden_muestra, collapse = ", ") } else { "no declarado" }, orden_unico_verificado = m$orden_unico_verificado, reproducible = m$reproducible, stringsAsFactors = FALSE ) } rbind( resumir_muestreo(sin_orden, "sin orden"), resumir_muestreo(perfil, "orden por id") ) data.frame( bloque = c("resumen_tabla", "perfil_muestra"), solo_lectura = c( perfil$resumen_tabla$meta$solo_lectura, perfil$perfil_muestra$meta$origen_dbi$solo_lectura ), objetos_temporales = c( perfil$resumen_tabla$meta$objetos_temporales, perfil$perfil_muestra$meta$origen_dbi$objetos_temporales ) ) invisible(DBI::dbDisconnect(conexion)) ``` La metadata no convierte una muestra en la tabla completa. Permite que quien consume el resultado compare cada bloque dentro de su propio alcance. ## Muestras y aproximaciones para tablas grandes La muestra usada por `perfil_muestra` y la relación usada por `universo = "muestra_motor"` son declaradas por separado. La primera se trae a R para ejecutar el perfil completo; la segunda permanece en SQL para que los agregados no recorran la tabla completa. La metadata de ambos bloques publica el universo y el método efectivo. ```{r modos-grandes, eval=FALSE} estimado <- perfilar_dbi( conexion, "entregas", universo = "muestra_motor", muestra_motor = 5000, muestra = 5000, metricas = c( "validos", "distintos", "moda", "basicos", "mediana", "desvio" ) ) estimado$resumen_tabla$sql[ estimado$resumen_tabla$sql$estado %in% c("estimado", "observado_muestra"), c("columna", "metrica", "estado", "universo", "tamano_muestra", "metodo", "fraccion", "error_esperado") ] aproximado <- perfilar_dbi( conexion, "entregas", muestra = 5000, estrategia_mediana = "aproximada_motor", estrategia_distintos = "aproximada_motor" ) aproximado$resumen_tabla$meta$aproximaciones ``` Las estrategias de mediana y de distintos son independientes. Para pedir una función nativa de cardinalidad hay que usar `estrategia_distintos = "aproximada_motor"`; para habilitar la aproximación de mediana se usa `estrategia_mediana = "aproximada_motor"`. La sonda de mediana prueba primero una forma nativa exacta consolidada, luego la forma exacta por columna y deja la aproximada para el final. Por eso una mediana exacta queda `calculado`, aunque se haya pedido la estrategia aproximada; sólo queda `estimado` cuando esa forma aproximada efectivamente corrió. Una consulta no emitida o sin un valor utilizable queda `no_disponible`. `plan_perfilado_dbi()` emite las sondas de capacidad y publica un **rango** de consultas en `attr(plan, "supuesto")`. No escanea datos para decidir el costo: lee el esquema, sondea capacidades y, con `politica_costo = "por_cardinalidad"`, puede usar una garantía estructural o una fuente de catálogo. Nunca lanza `COUNT(DISTINCT ...)` sólo para despejar una duda. Por eso el plan no proyecta el costo temporal de `COUNT(DISTINCT)` antes de correr: la referencia honesta es el primer lote de distintos medido durante la ejecución, y el plan no emite consultas de datos. ## La memoria del procesamiento no se estima El plan declara explícitamente que la memoria del procesamiento **no se estima**: no escala de forma predecible con las filas ni con las celdas, según lo medido. Conserva la **magnitud del trabajo** que se conoce —filas, celdas y pares de texto—, rotulada como magnitud y no como consumo de memoria. Las cifras que ayudan a decidir son datos de referencia medidos, **no una predicción para la tabla del plan, sino una corrida única y fechada (2026-08-28) contra un motor remoto**: traer la tabla costó aproximadamente 0,13 GB por millón de filas y procesar en R aproximadamente 1,0-1,5 MB por cada mil filas. La segunda cifra varió por 1,62x entre tablas de la misma magnitud; esa variación es justamente el motivo por el que no se usa para estimar. Ver todas las filas y tener todas las filas en memoria no son lo mismo. En corridas de referencia, 4,5 millones de filas entraron en 0,6 GB y tardaron 25 segundos en llegar, mientras que procesar 4,5 millones ocupó aproximadamente 7 GB y procesar 12,8 millones aproximadamente 19 GB. El problema observado está en el procesamiento en R, no en la red ni en el motor. El extremo inferior es `total`: si la fuente de cardinalidad queda desconocida, supone que la política omite la moda. El extremo superior es `total_maximo` —también reflejado en `total_lotes_rechazados` tras sumar la bisección— y deja abierto el camino que las ejecuta. Si un lote es rechazado, se agregan hasta `2n - 1` sondas por lote de `n` columnas. El costo real cae entre los dos, y el plan lo declara en las dos direcciones. `estrategia_distintos` dice cómo se obtiene o se omite `n_distintos`, y publica la estrategia solicitada, la resuelta y el estado antes y después de la corrida. `fuente_cardinalidad_costo` dice de dónde sale la proporción usada por la política de costo; es independiente y no puede convertir una petición `"aproximada_motor"` o `"catalogo"` en un agregado exacto. Cada consulta de agregados planos que trae `n_validos` incluye `COUNT(*) AS n_total_consulta` en la misma sentencia. La completitud usa ese denominador local por lote, incluso después de una bisección, y no cruza el total de otra consulta. El total del universo se mantiene separado cuando hace falta para una muestra o cuando no hay agregados planos. Después se ejecutan distintos, moda y mediana. `tamano_lote_planos` y `tamano_lote_distintos` son independientes; este último vale 2 por omisión. La medición contra el servidor de referencia mostró un `Shared Read` constante entre lotes: dos cardinalidades se amortizan en una pasada de la tabla. El derrame crece con el ancho del lote; dos mantuvo casi el mismo tiempo por columna que una y derramó menos que los lotes más anchos. El costo de `COUNT(DISTINCT ...)` se anuncia después de completar el primer lote de distintos y antes de iniciar el segundo, cuando hay una referencia medida. Con `instrumentar = TRUE`, la proyección usa la mediana de las duraciones de las consultas de ese primer lote en esta misma corrida y la multiplica por la cantidad total de lotes de distintos. El mensaje dice que es una estimación y de dónde sale; no usa `reltuples`. Con un solo lote no hay nada que proyectar ni que evitar, así que no se publica una proyección. La misma regla funciona si se pide sólo `metricas = "distintos"`. Sólo se muestra cuando supera 30 segundos y no pide confirmación, por lo que `perfilar_dbi()` no queda esperando en un guion. Los avisos de `moda` y `mediana` tienen interruptores y umbrales propios: `avisar_costo_moda`/`umbral_segundos_aviso_moda` proyectan por cardinalidad y `avisar_costo_mediana`/`umbral_segundos_aviso_mediana` proyectan por filas. Cada aviso llega antes de pagar la consulta y su proyección queda separada en `meta$costo_moda` o `meta$costo_mediana`; la fuente de cada cardinalidad se publica, incluso cuando proviene de una estimación de catálogo. `plan_perfilado_dbi()` no publica esa proyección temporal: no emite consultas de datos y, por lo tanto, no puede medir el primer lote. El plan lo declara explícitamente; la medición y la proyección quedan en `resumen_tabla$meta$costo_distintos` después de ejecutar. En PostgreSQL, la preparación puede consultar `pg_stats.n_distinct`, `pg_stats.avg_width`, `pg_class.reltuples`, `SHOW work_mem` y, desde la versión 13, `SHOW hash_mem_multiplier`. Con esos metadatos estima el tamaño del hash de agregación y avisa antes del primer `COUNT(DISTINCT)` si supera el límite efectivo. `meta$estimacion_derrame` conserva ese diagnóstico como estimación, no como medición; subir `work_mem` en la sesión puede evitar el derrame y el paquete nunca modifica ese parámetro. Si faltan estadísticas, permisos o el motor no es PostgreSQL, declara que no pudo estimar. El informe conserva el derrame real sólo cuando PostgreSQL permite atribuirlo sin ambigüedad mediante `pg_stat_statements`: `resumen_tabla$sql` publica los bloques temporales escritos y `meta$derrame` resume las consultas observadas. Si esa evidencia no está disponible, el estado lo dice y el paquete no deduce un derrame del tiempo transcurrido ni cambia `work_mem`. La medición real manda sobre `meta$estimacion_derrame`: un derrame medido se informa aunque la estimación previa no hubiera superado el límite. La decisión de pagar moda y mediana es explícita. `politica_costo = "todas"` es el valor por omisión y conserva todas las métricas solicitadas. Con `politica_costo = "por_cardinalidad"`, la corrida resuelve primero las fuentes estructurales y mide los valores válidos y distintos sólo cuando no hay una fuente exacta y la estrategia lo permite; luego omite, por columna, sólo la moda cuando `n_distintos / n_validos >= umbral_cardinalidad`. El umbral por omisión es `0.5` y gobierna sólo la moda. La mediana se conserva: el barrido medido queda plano frente a la cardinalidad y su costo lo gobierna el número de filas. Cada omisión queda declarada en `resumen_tabla$sql` y `meta$decisiones_costo` explica por separado cada decisión. Una estrategia omitida, de catálogo o aproximada sin capacidad nunca se convierte en `COUNT(DISTINCT ...)`. Ahora bien, **cuántas consultas se emiten no dice cuánto cuestan**: catorce consultas sobre dos millones de filas son mucho más trabajo que doscientas sobre mil, y quien mira el plan quiere saber si esto tarda segundos, minutos u horas. Por eso el plan estima además la magnitud, en cuentas de verdad y no en un índice inventado, y la estima en **dos mitades**, porque el reloj de una corrida no lo decide siempre el motor. La del motor son `filas_leidas`, cuántas filas habría que leer, y `ordenaciones_completas`, cuántas veces habría que ordenar la tabla entera; se resume en `magnitud_motor`. La del cliente son `columnas_texto` y `pares_texto`, cuántos pares de formas podría comparar en R el detector de vocabulario sobre la muestra; se resume en `magnitud_texto`. `magnitud` es **la mayor de las dos** —`"baja"`, `"media"`, `"alta"`, o `"desconocida"` si no se conoce el número de filas—. Contar sólo el motor daba juicios falsos con números ciertos. Una tabla del catálogo de PostGIS de 3.912 filas, con una columna de geometría guardada como texto, pedía 64.592 lecturas de fila y cero ordenaciones: magnitud `"baja"`. Y tardaba 35 segundos, **ya con el presupuesto de trabajo calibrado** —la misma tabla tardaba 243 segundos antes, y esa medición es la que aparece en `vignette("escala-y-duplicados")`—. El trabajo que quedaba estaba del otro lado, comparando formas, que no es una lectura de fila. Al imprimir el plan se ven las dos mitades, y el aviso de magnitud alta viene con las palancas concretas para acotarla —que no son las mismas de un lado que del otro—: `universo = "muestra_motor"`, recortar `metricas`, bajar `muestra` o poner `max_consultas` para el motor, y `max_trabajo_vocabulario` para lo que se hace en R. Es una estimación y lo dice en `attr(plan, "supuesto_costo")`. La del motor cuenta las filas que habría que leer **si ningún índice ayudara**, y cada ordenación completa como `log2(filas)` pasadas; un índice sobre la columna ordenada, o una tabla que entra en la memoria del motor, la bajan mucho. La del cliente cuenta pares: el conteo es exacto, pero **cuánto cuesta cada par depende del largo de los valores, que el plan no conoce sin leerlos**. Medido con `benchmark/medir_costo_texto.R`, entre 660.000 y 1.150.000 pares por segundo con valores de cuarenta caracteres —la banda cubre dos máquinas—, y entre 70.000 y 80.000 con valores de doscientos: con textos muy largos el tiempo real es varias veces el que sugiere la referencia. Los números publicados no dependen de esos supuestos, así que quien no los comparta puede rehacer la cuenta. ### Lo que el muestreo en el motor no puede darte Los agregados planos que comparten lote —`COUNT(col)`, mínimo, máximo, media, ceros, negativos y desvío— se piden en una sola consulta. `COUNT(DISTINCT ...)` y la moda conservan consultas separadas por sus planes propios. La moda intenta traer `SUM(COUNT(*)) OVER () AS n_validos_guard` junto a su frecuencia y sondea esa forma antes de emitirla; si el motor la rechaza, conserva la consulta anterior y lo declara en `resumen_tabla$meta$moda_guardian`. La mediana también conserva ese camino como respaldo; en PostgreSQL 9.3 y SQLite el conteo de la mediana queda como subconsulta escalar en la misma sentencia que ordena y recorta. Un motor que acepta `PERCENTILE_CONT(...) WITHIN GROUP` puede consolidar varias en un solo `SELECT` por lote. Con `universo = "muestra_motor"`, `TABLESAMPLE` o la fuente pseudoaleatoria resuelta se ejecuta una sola vez y se materializa en un spool externo de sesión cliente. El trailer conserva y verifica `muestra_id`, `snapshot_id`, `orden_id`, `n_filas`, `bytes` y checksum; las pasadas de resumen y diagnóstico releen esa misma relación. `id_consulta` sigue identificando la sentencia que produjo una medición, mientras `muestra_id` identifica la materialización. Un chunk que supera el presupuesto se rechaza antes de escribir y publica `spool_presupuesto_excedido` y `muestra_inestable:presupuesto_materializacion`; no se entrega una muestra híbrida. El spool no escribe en la conexión DBI ni crea objetos temporales del motor. Por eso las formas candidatas están ordenadas por **previsibilidad del tamaño** y no por costo. Primero la de cantidad fija, `TABLESAMPLE RESERVOIR (n ROWS)`; después la de nivel de fila, `TABLESAMPLE BERNOULLI (p)`; y recién al final las de bloque. Medido contra PostgreSQL 16, pidiendo el 20 % de una tabla de 5.000 filas en cuatro corridas: ``` TABLESAMPLE SYSTEM (20) 678 904 452 1384 TABLESAMPLE BERNOULLI (20) 1011 1017 981 1050 ``` `SYSTEM` elige bloques enteros, así que el tamaño de la muestra salta de un tercio al doble de lo pedido, y sobre una tabla chica puede dar cero. Con tamaños tan distintos entre consultas, dos métricas del mismo perfil dejan de ser comparables entre sí. La guarda de coherencia interna compara la frecuencia de la moda con `n_validos_guard` de su propia sentencia; si la sonda de la ventana no pasa, deja la cota como no comprobable y conserva la medición sin culpar al motor. Si necesitás que todas las métricas describan exactamente las mismas filas, el camino es `perfil_muestra`: trae una muestra a R **una sola vez** y corre sobre ella el perfil completo. Cuesta traer los datos; a cambio, todo lo que informa habla del mismo conjunto. ```{r, cierre, include=FALSE} options(.opciones_previas) ```