O statistical debugging lhe dá uma lista ordenada de predicados, não uma causa raiz. Você instrumenta cada branch e null check, executa cem mil execuções, e o algoritmo lhe entrega um placar. buffer_idx > max_len em parser.c:144 tem uma importance de 0.94. log_file != NULL em main.c:38 também tem. Um deles é o bug. O outro simplesmente acontece de ser verdadeiro sempre que o programa lock.
Uma alta correlação significa que um predicado e um crash coocorrem. Não significa que o predicado causou o crash. Se cada crash passa pelo mesmo caminho de limpeza, cada predicado nesse caminho vai parecer culpado. Você precisa de uma forma de separar o perpetrador dos espectadores.
O que o escore de correlação realmente mede
A metric padrão é importance, introduzida por Liblit et al. em Cooperative Bug Isolation. Importance é o produto de dois números: increase e support.
Increase é a diferença entre a taxa de crash quando um predicado é verdadeiro e quando é falso. Se p != NULL é verdadeiro em 99% das execuções que travam e 98% das que não travam, o increase é 0.01. Esse predicado é inútil mesmo que dispare em cada crash.
Support é a fração de todas as execuções em que o predicado é verdadeiro. Ele impede que preditores raros, mas perfeitos, dominem o ranking. Um predicado que só dispara uma vez, mas essa execução lock, obtém increase alto e support próximo de zero.
importance(pred) = (P(crash | pred=True) - P(crash | pred=False)) * P(pred=True)
Isso filtra bem o ruído. Não localiza bugs. Um predicado com importance alta pode ser o bug, a verificação de segurança que o pega, ou uma operação comum em cada caminho até o crash.
Por que predicados com aparência de culpados frequentemente são inocentes
Considere um erro off-by-one na linha 42 em que um limite de loop usa <= em vez de <. O crash acontece vinte linhas depois. A condição do loop dispara a cada iteração. Uma verificação de limites na linha 45 dispara a cada iteração. O null check na linha 10 dispara uma vez na inicialização. Todos eles têm support alto e todos aparecem em execuções que travam.
O problema é a distância temporal e causal. Um predicado que executa momentos antes do crash é mais provável de estar relacionado do que um da inicialização. Um predicado dentro da mesma função é mais provável de estar relacionado do que um em um utilitário de logging. O statistical debugging ignora isso a menos que você o adicione de volta.
Como filtrar por reachability e distância
O primeiro refinamento é control-flow reachability. Um predicado que é sempre verdadeiro em execuções que travam, mas também sempre verdadeiro em execuções que não travam e que alcançam o mesmo código, não é suspeito. É apenas uma propriedade desse caminho.
Calcule uma probabilidade condicional. Em vez de perguntar “com que frequência esse predicado lock?”, pergunte “com que frequência ele lock dado que o programa alcançou esse ponto no control flow?” Se a taxa de crash não mudar quando você condicionar no alcance da função, o predicado não está adicionando informação.
O segundo refinamento é distância espacial. Após classificar por importance, reclassifique por distância até o local do crash. Isso é uma heurística. Bugs podem se propagar longe. Mas a maioria dos bugs de corrupção de memória e null dereference vive a poucas linhas do predicado que primeiro diverge do comportamento correto.
Aqui está uma implementação leve que calcula importance e filtra por distância:
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
A função de pontuação é deliberadamente simples. Em arquiteturas em camadas, use uma penalidade de distância mais fraca. Em parsers compactos, uma penalidade forte funciona melhor.
De predicado a linha exata: triangulação
Um predicado ainda não é uma localização de bug. É uma expressão Booleana como i <= buf->len. Para chegar a um número de linha, olhe o predicado no contexto.
A pergunta-chave: qual é a negação desse predicado, e a negação evitaria o crash? Se o predicado é p == NULL e o crash é um null dereference, a negação o evitaria. Isso faz do predicado uma causa direta. Se o predicado é log_level > 2 e o crash é um buffer overflow, a negação não muda nada. O predicado é um mero espectador.
Você pode automatizar isso parcialmente. Para cada predicado bem classificado, force-o falso em um caso de reprodução. Se o crash desaparecer, você encontrou a condição de controle. O bug geralmente é a atribuição ou comparação que fez o predicado ser verdadeiro em primeiro lugar.
O ranking estatístico estreita a busca de milhares de predicados para um punhado. Um teste direcionado ou uma sessão curta de debugger fecha o ciclo.
A compensação: cobertura versus precisão
Quanto mais predicados você instrumenta, mais poder estatístico tem, mas mais falsos positivos gera. Instrumentar cada acesso de memória produz milhões de predicados. A maioria se correlacionará fracamente com o crash ao acaso.
A correção é instrumentação seletiva. Comece com null checks, bounds checks, valores de retorno de erro, e condições de branch em funções do stack trace. Esses são os mais propensos a separar comportamento de travamento de não-travamento.
Você também precisa de execuções suficientes. O escore de importance é uma estatística de amostra. Com cem execuções, o ruído domina. Com dez mil, o sinal se afia. A regra prática dos artigos do CBI é de pelo menos mil execuções por bug. Crashes raros precisam de volume.
O que isso não pega
O statistical debugging encontra bugs que se manifestam como desvios de predicados observáveis. Ele não vai encontrar regressões de performance, erros de lógica que permanecem dentro dos limites, ou race conditions que a instrumentação perturba.
Ele também é fundamentalmente post-hoc. Os crashes já aconteceram. Você está fazendo forense em um conjunto de dados. O valor está em reduzir o espaço de busca de “o codebase inteiro” para “talvez cinquenta linhas.”
Um exemplo mínimo end-to-end
Veja como as peças se encaixam com dados 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}")
Executar isso dá a p2 em parser.c:42 o escore mais alto porque tem tanto importance alta quanto uma distância curta até o crash. p1 tem importance similar, mas está mais distante. p4 é penalizado por estar em um arquivo diferente.
Comece com o crash que você já está investigando
Você não precisa de um framework de nível de pesquisa. Escolha um crash que se reproduz frequentemente. Adicione instrumentação aos cinco predicados mais interessantes na call stack. Execute sua test suite ou tráfego de produção. Calcule importance e distance. Se um predicado separa execuções que travam de execuções que não travam e está próximo ao local do crash, você estreitou a busca.
O statistical debugging não é um substituto para stack traces ou debuggers. Ele é um substituto para ler cada linha de código entre main e o segfault. A correlação te coloca na vizinhança. Reachability, distance e uma verificação rápida da negação te levam até a porta.