Fecha: 2026-06-15 Estado: Aceptado Bloque: M6 (mejora del optimizer / diagnóstico) Origen: docs/TAREAS_PENDIENTES.md §6.5 — “EXPLAIN ANALYZE compara est.match vs actual. Diagnóstico directo del bias del estimator.” Refina: ADR-0070 (P5a — estimate_selectivity), ADR-0071 (P5c — cost-based dispatch).

Contexto

Pre-M6, EXPLAIN te dice:

SCAN `t` → hash-index equality `category` (bucket lookup, ~O(1))
          [est.rows=10 cols=1 est.match=6 stats.age=fresh]

EXPLAIN ANALYZE corre la query y agrega:

actual.time: 0.523 ms wall-clock
actual.rows: 6 filas producidas

Para saber si el estimator (P5a) acertó tenés que mirar dos números y hacer la cuenta de cabeza: est.match=6 vs actual.rows=6 → ratio 1.0 → estimator OK. Para una query lenta donde sospechás que P5c eligió mal por subestimar/sobreestimar, esto es engorroso.

M6 hace la cuenta automáticamente y la anota como un step extra.

Decisión

Cuando el inner de un EXPLAIN ANALYZE es un SELECT scan-only (sin JOIN/GROUP BY/HAVING/aggregate/LIMIT/OFFSET/DISTINCT/derived/ values/window), agregar un step actual.bias que muestra el ratio y lo clasifica:

actual.bias: est.match=6 actual=6 ratio=1.00 BIAS=GOOD (sobre step `1`)

Clasificación

Banda Condición Significado
MATCH est=0 && actual=0 Caso trivial degenerate.
GOOD ratio ∈ [0.5, 2.0] Estimador dentro de 2× del real.
MILD ratio ∈ [0.25, 0.5] ∪ [2.0, 4.0] 2–4× off. Aceptable pero atento.
HIGH resto, incluido est=0 && actual>0 >4× off. Sospechar plan equivocado.

Por qué scan-only

Solo en SELECT scan-only se cumple que row_count final = filas que sobrevivieron el WHERE = est.match esperado. Con JOIN, aggregate, LIMIT, etc. el actual.rows ya no representa lo mismo que est.match del SCAN step — comparar daría un “BIAS=HIGH” falso. Preferimos omitir el bias en esos casos a engañar al lector.

Implementación

3 helpers nuevos (src/sql.rs):

Integración en exec_explain (~12 LOC nuevas en el flujo):

  1. Captura analyze_scan_only = analyze && is_scan_only_select(&inner).
  2. Si la query ANALYZE arroja Ok y analyze_scan_only, busca el primer step cuyo detail contiene est.match= y emite el step actual.bias con el ratio.

Cero impacto en queries no-ANALYZE o no-scan-only.

Consecuencias

Positivas

Negativas / deuda

Alternativas consideradas

  1. Mostrar actual=K en el mismo SCAN step (mutando su detail string en vez de un step aparte). Más compacto pero hace el SCAN step asimétrico entre EXPLAIN y EXPLAIN ANALYZE — el lector tiene que aprender dos formas. El step separado es más simple.
  2. Calcular bias también para JOIN/aggregate comparando contra alguna estimación derivada del plan. Cualquier estimación intermedia sería propensa a error sistemático; mejor honestidad que un “BIAS=GOOD” optimista falso.
  3. Tracking acumulativo del bias (e.g. tabla __est_bias_log__). Útil pero requiere bump VERSION + infra de gc. Diferible.

Tests

Tres tests nuevos (m6_* en tests/integration_test.rs):

Suite total: 816 → 819 (+3 tests integration).

Referencias