Validación y Análisis · Tema 04

Depuración de simulaciones

La suite se puso roja. Ahora toca cazar el bug sin adivinar: reproducir, aislar con trazas y partir el intervalo hasta que el culpable confiese.

01 · Punto de partida

Depurar simulaciones no es depurar un CRUD

En un programa común, un bug suele mostrarse siempre igual. En una simulación, el bug se esconde detrás del tiempo y del azar: aparece en el paso 47, solo con cierta semilla, solo cuando la cola se vacía un martes simulado. Adivinar y tocar código al azar solo agrega bugs nuevos.

  • ¿Cómo reproducir el fallo a voluntad?
  • ¿Cómo saber el primer instante donde miente?
  • ¿Cómo reducir 500 líneas a 10 culpables?
  • ¿Cómo saber que el arreglo no rompió otra cosa?

02 · Definición

Qué hace especial al bug de simulación

◷

Temporal

El error nace en t* pero se ve 20 pasos después. Sin traza por paso, mirás el cadáver y no la escena del crimen.

🎲

Estocástico

Con azar sin semilla fija, cada corrida es distinta: el bug es un fantasma. Con semilla fija, es un insecto disecado.

🧬

Acumulativo

Cada estado depende del anterior: un error de 0,1 en t=2 es un error de 5 en t=50. Hay que buscar el primer desvío, no el más grande.

Depuración común frente a depuración de simulaciones.
Programa comúnSimulaciónAntídoto
Falla siempre igualFalla solo con cierta semillaFijar e imprimir la semilla
Stack trace al crashNúmeros «raros» sin crashInvariantes que gritan en t*
Un input, un outputHistoria de 100 pasosBisección temporal + traza mínima

03 · Método

El protocolo en 5 pasos

  1. 1
    Reproducir con semilla y caso mínimo.

    Fijá semilla, recortá pasos y datos hasta el ejemplo más chico que aún falla. Si no reproduce a voluntad, no depurés: estabilizá primero.

  2. 2
    Leer la traza, no el gráfico.

    Imprimí (t, antes → después, evento). El gráfico muestra el síntoma; la traza muestra el primer paso imposible.

  3. 3
    Bisecar el tiempo.

    Si falla en 12 pasos pero no en 6, el t* está en 7…12. Partí el intervalo hasta dar con el primer desvío exacto.

  4. 4
    Hipótesis de una sola causa.

    Formulá «creo que en t* la salida se aplica dos veces» y diseñá un experimento que la refute. Una hipótesis por vez.

  5. 5
    Fijar y blindar con regresión.

    Arreglá, agregá el caso mínimo a la suite del Tema 3 y corré todo. Un bug cazado sin test nuevo volverá.

semilla fija→traza→t* por bisección→test nuevo

04 · Herramientas

Trazas, prints estructurados y visores

Lo que sí ayuda

Traza mínima y comparable

Primeros N pasos con formato fijo, esperada vs. real lado a lado y marca del primer desvío. 10 líneas valen más que 3 gráficos.

Lo que estorba

Prints caóticos y gráficos solos

Miles de líneas sin tiempo, o solo la curva final. El ojo perdona un desfase de un paso; la comparación automática, no.

Heisenbug
Bug que desaparece al observarlo (prints que cambian el timing, azar distinto). Se fija con semilla y traza a archivo, no con prints invasivos.
Delta debugging
Recortar input, pasos y código hasta el mínimo que falla. Si con 3 pasos ya falla, no mires 100.
Punto de observación
Invariante impreso en cada paso (0 ≤ nivel ≤ tope). El primer «ROTO» es el t*.

05 · Representación en Python

Cazar con traza comparada y bisección

El patrón profesional: una función que genera traza, una que compara contra la esperada y una bisección que encuentra t* automáticamente.

Python en tu navegador. Hay un bug inyectado a propósito. Ejecutá, leé el t* y corregilo.

def sim_bug(nivel, entrada, salida, tope, pasos):
    h = []
    for t in range(pasos):
        nivel = nivel + entrada - salida  # BUG: falta max(0, min(tope, ...))
        h.append(nivel)
    return h

def esperada(nivel, entrada, salida, tope, pasos):
    h = []
    for t in range(pasos):
        nivel = max(0, min(tope, nivel + entrada - salida))
        h.append(nivel)
    return h

esp = esperada(2, 1, 3, 14, 6)
mal = sim_bug(2, 1, 3, 14, 6)
for t, (e, m) in enumerate(zip(esp, mal), start=1):
    marca = " <-- DIVERGE" if e != m else ""
    print(f"t={t}: esperada={e} programa={m}{marca}")

La primera línea con DIVERGE es el t*: ahí nace el bug, lo demás es arrastre.

Bisección automática del primer desvío

# Reusa esp y mal del bloque anterior
def primer_desvio(esp, mal):
    lo, hi, ans = 0, len(esp) - 1, None
    while lo <= hi:
        mid = (lo + hi) // 2
        if esp[:mid+1] == mal[:mid+1]:
            lo = mid + 1
        else:
            ans = mid + 1  # en base 1
            hi = mid - 1
    return ans

print("t* =", primer_desvio(esp, mal))
print("Arreglo: envolver con max(0, min(tope, ...)) y agregar test")

06 · Exploración

Laboratorio: la caza del t*

Elegí el bug inyectado (0 = ninguno, 1 = desfase, 2 = signo invertido, 3 = piso olvidado) y la entrada. El gráfico compara la esperada (lima) con el programa (naranja), marca el primer desvío con un círculo rojo y te da la pista. Tu trabajo: predecir el t* antes de mirar el veredicto.

EXPERIMENTO 04

Reproducir · aislar · fijar

semilla fija 42 · 12 pasos · tope 14

Los resultados numéricos aparecen debajo.
Bug activoDesfase
Primer desvío t*1
Divergencia máx.1,00
PistaOrden

Con bug de desfase, el primer desvío ya está en t=1: todo lo demás es arrastre.

Salida fija en 2, inicio en 10 y tope en 14. La bisección se hace sola: achicá «ver hasta el paso» para partir el intervalo como harías depurando (¿falla en 6? ¿en 3?).

Preguntas para explorar

  1. Con bug 1 (desfase) y entrada 3: ¿cuál es el t*? Achicá el zoom a 6, luego a 3. ¿En cuántas particiones lo aislás?
  2. Cambiá a bug 3 (piso olvidado) con entrada 1. ¿El t* aparece al inicio o cuando cruza cero? ¿Por qué la traza inicial «parece» correcta?
  3. Con bug 2 (signo invertido), ¿la divergencia crece o se mantiene? ¿Qué invariante lo delataría en un solo paso?
Ver respuestas sugeridas
  1. t* = 1: el desfase corre todo desde el primer valor. Con bisección lo aislás en ~4 particiones (12→6→3→1). Por eso se busca el primero, no el mayor.
  2. Aparece cuando cruza cero (t ≈ 5 con entrada 1 y salida 2 desde 10). Antes coincide: el bug dormido solo despierta en el borde. Moraleja: los tests deben incluir bordes.
  3. Crece linealmente (resta cuando debía sumar o viceversa). Un test de un solo paso con balance conocido lo caza al instante: no hacen falta 12 pasos.

07 · Comprensión

Confusiones frecuentes

«Cambio cosas hasta que el gráfico se ve bien»

Eso es calibrar un bug, no depurar. El gráfico puede quedar lindo por compensación (Tema 1, caso C). Solo la traza comparada da el alta.

«Con más prints se caza más rápido»

Más prints caóticos enlentecen. Un formato fijo (t, antes → después) y pocos pasos mínimos cazan más rápido que mil líneas.

«Si falla solo a veces, es el azar, no un bug»

El azar con semilla fija es determinista. «A veces» casi siempre es estado compartido entre réplicas o semilla no registrada.

«Arreglado, borro la traza»

El arreglo sin test nuevo es una promesa. Cada t* debe quedar como test mínimo en la suite del Tema 3.

08 · Práctica guiada

Ejercicios con Python

Ejercicio 1: mínimo reproducible

Reducí este fallo de 12 pasos al mínimo que aún diverge.

def esperada(n, e, s, tope, pasos):
    h = []
    for _ in range(pasos):
        n = max(0, min(tope, n + e - s))
        h.append(n)
    return h

def rota(n, e, s, tope, pasos):
    h = []
    for _ in range(pasos):
        n = n + e - s  # sin piso/techo
        h.append(n)
    return h

for pasos in [12, 6, 3, 2, 1]:
    e = esperada(2, 1, 3, 14, pasos)
    r = rota(2, 1, 3, 14, pasos)
    print(pasos, e == r, r)
Ver solución razonada

Con 1 paso ambas dan [0] (2+1−3 = 0, el piso no actúa). Con 2 pasos divergen: [0, 0] vs [0, -2]. El mínimo reproducible son 2 pasos: todo lo demás es ruido.

Ejercicio 2: leer la traza como un detective

¿En qué paso nace el bug y qué hipótesis sugiere?

traza = [(1, 10, 11), (2, 11, 12), (3, 12, 12), (4, 12, 12)]
# (t, esperada, programa)
for t, e, m in traza:
    print(t, e, m, "<-- t*" if e != m else "")
Ver solución

t* = 3: hasta t=2 coinciden. Hipótesis: el techo (tope 12) no se aplica desde t=3, o la entrada dejó de sumarse. Una sola causa, un experimento para refutarla.

Ejercicio 3: blindar con regresión

Convertí el bug del piso en un test con nombre que quede para siempre.

Ver una posible respuesta
def sim(nivel, e, s, tope, pasos):
    h = []
    for _ in range(pasos):
        nivel = max(0, min(tope, nivel + e - s))
        h.append(nivel)
    return h

def test_piso_no_negativo():
    assert sim(2, 1, 3, 14, 3) == [0, 0, 0]

test_piso_no_negativo()
print("Regresión OK: el bug del piso no volverá en silencio")

El test nombra al bug y fija el mínimo reproducible. Si alguien quita el max(0,…), la suite grita en milisegundos.

09 · Síntesis

Ideas para recordar

  • Depurar simulaciones = reproducir con semilla + traza comparada + bisección hasta t*.
  • Se busca el primer desvío, no el más grande: lo demás es arrastre acumulativo.
  • Menos es más: caso mínimo, pocos pasos, formato fijo de traza.
  • Cada bug cazado se convierte en test de regresión con nombre.
  • Solo con la suite en verde tiene sentido validar contra la realidad (Tema 5).

En el próximo tema cruzaremos el puente hacia el mundo real: la validación frente al sistema real.