La depuración estadística te da una lista ordenada de predicados, no una causa raíz. Instrumentas cada branch y verificación de nulo, ejecutas cien mil ejecuciones, y el algoritmo te entrega un marcador. buffer_idx > max_len en parser.c:144 tiene una importancia de 0,94. log_file != NULL en main.c:38 también. Uno de ellos es el bug. El otro simplemente resulta ser verdadero cada vez que el programa falla.

Una alta correlación significa que un predicado y un crash coocurren. No significa que el predicado causó el crash. Si cada crash pasa por la misma ruta de limpieza, cada predicado en esa ruta parecerá culpable. Necesitas una forma de separar al perpetrador de los espectadores.

Qué mide realmente la puntuación de correlación

La metric estándar es la importancia, introducida por Liblit et al. en Cooperative Bug Isolation. La importancia es el producto de dos números: el incremento y el soporte.

El incremento es la diferencia entre la tasa de crashes cuando un predicado es verdadero y cuando es falso. Si p != NULL es verdadero en el 99% de las ejecuciones que fallan y en el 98% de las que no fallan, el incremento es 0,01. Ese predicado es inútil incluso si se dispara en cada crash.

El soporte es la fracción de todas las ejecuciones donde el predicado es verdadero. Evita que predictores raros pero perfectos dominen la clasificación. Un predicado que solo se dispara una vez pero esa ejecución falla obtiene un incremento alto y un soporte cercano a cero.

importance(pred) = (P(crash | pred=True) - P(crash | pred=False)) * P(pred=True)

Esto filtra bien el ruido. No localiza bugs. Un predicado con alta importancia podría ser el bug, la verificación de seguridad que lo atrapa, o una operación común en cada ruta hacia el crash.

Por qué los predicados que parecen culpables a menudo son inocentes

Considera un error off-by-one en la línea 42 donde un límite de bucle usa <= en lugar de <. El crash ocurre veinte líneas después. La condición del bucle se dispara en cada iteración. Una verificación de límites en la línea 45 se dispara en cada iteración. La verificación de nulo en la línea 10 se dispara una vez al inicio. Todos tienen alto soporte y todos aparecen en ejecuciones que fallan.

El problema es la distancia temporal y causal. Un predicado que se ejecuta momentos antes del crash es más probable que esté relacionado que uno del inicio. Un predicado dentro de la misma función es más probable que esté relacionado que uno en un logger de utilidades. La depuración estadística ignora esto a menos que lo agregues de nuevo.

Cómo filtrar por alcanzabilidad y distancia

El primer refinamiento es la alcanzabilidad del flujo de control. Un predicado que es siempre verdadero en ejecuciones que fallan pero también siempre verdadero en ejecuciones que no fallan que alcanzan el mismo código no es sospechoso. Es solo una propiedad de esa ruta.

Calcula una probabilidad condicional. En lugar de preguntar “¿con qué frecuencia este predicado falla?”, pregunta “¿con qué frecuencia falla dado que el programa alcanzó este punto en el flujo de control?” Si la tasa de crashes no cambia cuando condicionas al alcanzar la función, el predicado no está agregando información.

El segundo refinamiento es la distancia espacial. Después de clasificar por importancia, reclasifica por distancia al sitio del crash. Esto es una heurística. Los bugs pueden propagarse lejos. Pero la mayoría de los bugs de corrupción de memoria y desreferenciación de nulo viven dentro de unas pocas líneas del predicado que primero se desvía del comportamiento correcto.

Aquí hay una implementación ligera que calcula importancia y filtra por distancia:

from dataclasses import dataclass
from typing import List, Set

@dataclass(frozen=True)
class Predicate:
    pid: str
    file: str
    line: int
    expr: str

@dataclass
class Run:
    crashed: bool
    crash_file: str = ""
    crash_line: int = 0
    predicates: Set[Predicate] = None


def importance(pred: Predicate, runs: List[Run]) -> float:
    total = len(runs)
    with_pred = [r for r in runs if pred in r.predicates]
    without_pred = [r for r in runs if pred not in r.predicates]

    if not with_pred or not without_pred:
        return 0.0

    crash_with = sum(1 for r in with_pred if r.crashed) / len(with_pred)
    crash_without = sum(1 for r in without_pred if r.crashed) / len(without_pred)
    support = len(with_pred) / total

    return (crash_with - crash_without) * support


def avg_distance(pred: Predicate, runs: List[Run]) -> float:
    crash_runs = [r for r in runs if r.crashed]
    if not crash_runs:
        return float('inf')
    dists = []
    for r in crash_runs:
        if pred.file != r.crash_file:
            return float('inf')
        dists.append(abs(pred.line - r.crash_line))
    return sum(dists) / len(dists)


def rank_predicates(runs: List[Run]) -> List[tuple]:
    all_preds = set()
    for r in runs:
        all_preds.update(r.predicates)

    scored = []
    for p in all_preds:
        imp = importance(p, runs)
        dist = avg_distance(p, runs)
        # Higher importance is better, lower distance is better.
        if dist == float('inf'):
            score = imp * 0.1
        else:
            score = imp / (1 + dist / 10)
        scored.append((score, imp, dist, p))

    scored.sort(key=lambda x: x[0], reverse=True)
    return scored

La función de puntuación es deliberadamente simple. En arquitecturas por capas, usa una penalización de distancia más débil. En parsers ajustados, una penalización fuerte funciona mejor.

De predicado a línea exacta: triangulación

Un predicado todavía no es una ubicación de bug. Es una expresión booleana como i <= buf->len. Para llegar a un número de línea, mira el predicado en contexto.

La pregunta clave: ¿cuál es la negación de este predicado, y la negación evitaría el crash? Si el predicado es p == NULL y el crash es una desreferenciación de nulo, la negación lo evitaría. Eso convierte al predicado en una causa directa. Si el predicado es log_level > 2 y el crash es un desbordamiento de buffer, la negación no cambia nada. El predicado es un espectador.

Puedes automatizar esto parcialmente. Para cada predicado de mayor clasificación, fuerza que sea falso en un caso de reproducción. Si el crash desaparece, has encontrado la condición controladora. El bug suele ser la asignación o comparación que hizo que el predicado fuera verdadero en primer lugar.

La clasificación estadística reduce la búsqueda de miles de predicados a un puñado. Una prueba dirigida o una breve sesión de debugger cierra el ciclo.

La compensación: cobertura versus precisión

Cuánto más predicados instrumentes, más poder estadístico tienes, pero más falsos positivos generas. Instrumentar cada acceso de memoria produce millones de predicados. La mayoría se correlacionará débilmente con el crash por azar.

La solución es la instrumentation selectiva. Comienza con verificaciones de nulo, verificaciones de límites, valores de retorno de error, y condiciones de branch en funciones del stack trace. Estos son los más probables de separar el comportamiento de falla del comportamiento de no falla.

También necesitas suficientes ejecuciones. La puntuación de importancia es una estadística de muestra. Con cien ejecuciones, el ruido domina. Con diez mil, la señal se afila. La regla general de los papers de CBI es al menos mil ejecuciones por bug. Los crashes raros necesitan volumen.

Esto no puede detectar

La depuración estadística encuentra bugs que se manifiestan como desviaciones observables de predicados. No encontrará regresiones de rendimiento, errores lógicos que se mantienen dentro de los límites, o race conditions que la instrumentation perturba.

También es fundamentalmente post-hoc. Los crashes ya han ocurrido. Estás haciendo forense sobre un conjunto de datos. El valor es reducir el espacio de búsqueda de “todo el codebase” a “quizás cincuenta líneas”.

Un ejemplo mínimo de extremo a extremo

Aquí es cómo encajan las piezas con datos simulados:

# Simulate runs for a bug at parser.c:42 (off-by-one loop bound)
runs = []

# 900 non-crashing runs
for _ in range(900):
    runs.append(Run(
        crashed=False,
        predicates={
            Predicate("p1", "parser.c", 10, "buf != NULL"),
            Predicate("p2", "parser.c", 42, "i <= buf->len"),  # the bug
            Predicate("p3", "parser.c", 45, "i < buf->len"),
        }
    ))

# 100 crashing runs: all hit the bug predicate
for _ in range(100):
    runs.append(Run(
        crashed=True,
        crash_file="parser.c",
        crash_line=62,
        predicates={
            Predicate("p1", "parser.c", 10, "buf != NULL"),
            Predicate("p2", "parser.c", 42, "i <= buf->len"),
            Predicate("p3", "parser.c", 45, "i < buf->len"),
            Predicate("p4", "main.c", 5, "argc > 1"),  # startup, irrelevant
        }
    ))

results = rank_predicates(runs)
for score, imp, dist, pred in results[:3]:
    print(f"{pred.file}:{pred.line}  {pred.expr:20s}  "
          f"score={score:.3f}  importance={imp:.3f}  avg_dist={dist:.1f}")

Ejecutar esto le da a p2 en parser.c:42 la puntuación más alta porque tiene tanto alta importancia como una distancia corta al crash. p1 tiene una importancia similar pero está más lejos. p4 es penalizado por estar en un archivo diferente.

Comienza con el crash que ya estás investigando

No necesitas un framework de grado de investigación. Elige un crash que se reproduzca frecuentemente. Agrega instrumentation a los cinco predicados más interesantes en el stack de llamadas. Ejecuta tu suite de pruebas o tráfico de producción. Calcula importancia y distancia. Si un predicado separa las ejecuciones que fallan de las que no fallan y está cerca del sitio del crash, has reducido la búsqueda.

La depuración estadística no es un reemplazo de los stack traces o los debuggers. Es un reemplazo de leer cada línea de código entre main y el segfault. La correlación te pone en el vecindario. La alcanzabilidad, la distancia y una verificación rápida de la negación te llevan a la puerta.