Files
hammer/scripts/why-differs.py
T
sergioandClaude Opus 4.8 e9b6283722 cierres §2: why-differs — explica POR QUÉ dos artefactos no reproducen
El #2 de SDD 17, y encadena con el #1: cuando  da DIVERGENCIA, esto
dice qué difiere y por qué. Hoy debuggear una no-reproducción es artesanal; esto
la vuelve mecánica y legible por máquina (lo que el bucle agéntico necesita).

Algoritmo (cruce con  de tawasuyu): árbol = grafo blob+árbol hasheado ⇒
dos subárboles idénticos colapsan al mismo hash ⇒ descender SÓLO donde difiere.
O(diferencia), no O(tamaño).

Clasifica la causa, que es lo que decide la acción: solo-en-A/B (no-determinismo
de estructura) · tamaño · build-id · timestamp · path-embebido · codegen ·
contenido.

Probado: control (A vs A) → 'árboles IDÉNTICOS' ✓. Caso real (bzip2 gcc vs zig):
de 21 ficheros sólo difieren los 4 binarios → causa .

DOS VECES tuve que arreglar el diagnóstico por confundir SÍNTOMA con causa:
  1. Chequeaba build-id PRIMERO ⇒ reportaba 'build-id' cuando la causa era el
     codegen. El build-id es un HASH DEL CONTENIDO: difiere siempre que difiera
     el código.
  2. El umbral era '<1% del binario' — sonaba bien y clasificaba 1196 bytes como
     build-id. Un build-id son 20 bytes (sha1). Atado al TAMAÑO REAL (<=64).
Un diagnóstico plausible no es un diagnóstico correcto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:33:51 -04:00

185 lines
8.1 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""why-differs — explica POR QUÉ dos artefactos no reproducen el mismo hash (SDD 17 §2).
why-differs.py <artefacto-A> <artefacto-B> # dirs del store
why-differs.py b3:86a33b76… b3:9cbe76e3… --store ./store
Cuando el consenso de reconstrucción (§1, `scripts/consenso.sh`) da DIVERGENCIA, esto dice qué
difiere: qué fichero, y dentro del fichero, qué clase de diferencia (tamaño, timestamp embebido,
orden de símbolos, sección ELF). Hoy debuggear una no-reproducción es artesanal; esto la vuelve
mecánica y **legible por máquina** — que es lo que el bucle agéntico necesita para actuar.
Algoritmo (el cruce con `format` de tawasuyu): un árbol es un grafo blob+árbol hasheado; **dos
subárboles idénticos colapsan al mismo hash ⇒ el diff estructural es descender SÓLO donde
difiere**. No se compara byte a byte todo: se poda por hash y se baja únicamente por la rama
divergente. Es O(diferencia), no O(tamaño).
NO reemplaza a diffoscope en profundidad; clasifica la causa, que es lo que decide la acción:
- `solo-en-A`/`solo-en-B` → el build produjo/omitió ficheros ⇒ no-determinismo de *estructura*.
- `tamaño` → contenido genuinamente distinto (¿flags? ¿versión de dep?).
- `timestamp`/`build-id`/`path-embebido` → el clásico: algo del entorno se coló en el binario.
- `orden` → mismos bytes, otro orden (codegen paralelo, orden de link) — la causa nº1 histórica
en hammer (`codegen-units`, `CARGO_BUILD_JOBS`; ver SDD 09 §2).
"""
import hashlib
import os
import re
import subprocess
import sys
def b3(path):
"""BLAKE3 si está, si no SHA256. Sólo se usa para COMPARAR A vs B, no para direccionar."""
h = hashlib.blake2b(digest_size=32) if not hasattr(hashlib, "blake3") else hashlib.blake3()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 16), b""):
h.update(chunk)
return h.hexdigest()
def arbol(raiz):
"""path-relativo → (hash, tamaño). Ignora `.hammer/` (metadata del artefacto, no contenido)."""
out = {}
for dp, _, fs in os.walk(raiz):
if "/.hammer" in dp:
continue
for f in fs:
p = os.path.join(dp, f)
if os.path.islink(p):
out["/" + os.path.relpath(p, raiz)] = ("symlink:" + os.readlink(p), 0)
continue
try:
out["/" + os.path.relpath(p, raiz)] = (b3(p), os.path.getsize(p))
except OSError:
pass
return out
def es_elf(p):
try:
with open(p, "rb") as f:
return f.read(4) == b"\x7fELF"
except OSError:
return False
def clasificar(a, b, rel):
"""Por qué difieren dos ficheros del mismo path. Devuelve (causa, detalle)."""
sa, sb = os.path.getsize(a), os.path.getsize(b)
if sa != sb:
return "tamaño", f"{sa} vs {sb} bytes (Δ{sb - sa:+})"
# Mismo tamaño ⇒ el clásico: algo del entorno se coló. Buscar la firma.
with open(a, "rb") as f:
da = f.read()
with open(b, "rb") as f:
db = f.read()
difs = [i for i in range(len(da)) if da[i] != db[i]]
frac = len(difs) / max(len(da), 1)
if es_elf(a) and es_elf(b):
# OJO con el orden: el build-id es un HASH DEL CONTENIDO, así que difiere siempre que
# difiera el código. Reportarlo primero sería confundir el SÍNTOMA con la causa (pasó al
# probar: gcc-vs-zig salía "build-id" cuando la causa real era el codegen). Sólo es LA
# causa si la divergencia está ACOTADA a sus bytes — típicamente <1% del binario.
try:
ba = subprocess.run(["readelf", "-n", a], capture_output=True, text=True).stdout
bb = subprocess.run(["readelf", "-n", b], capture_output=True, text=True).stdout
ida = re.search(r"Build ID: ([0-9a-f]+)", ba)
idb = re.search(r"Build ID: ([0-9a-f]+)", bb)
distinto_id = bool(ida and idb and ida.group(1) != idb.group(1))
# Umbral atado a la REALIDAD, no a un % arbitrario: un build-id son 20 bytes (sha1)
# o 16 (md5), + padding. Si difieren 1196 bytes, el build-id es una fracción — hay
# codegen debajo. (Un 1% "sonaba bien" y clasificaba 1196 bytes como build-id: el
# mismo error de confundir síntoma con causa, con otro disfraz.)
if distinto_id and len(difs) <= 64:
return "build-id", (f"{ida.group(1)[:16]}… vs {idb.group(1)[:16]}… "
f"({len(difs)} bytes difieren, cabe en un build-id ⇒ es LA causa)")
if distinto_id:
# El build-id difiere PERO también el código: la causa está aguas arriba.
return "codegen", (f"{len(difs)} bytes ({frac*100:.1f}%) — el build-id también "
f"difiere, pero es consecuencia, no causa (¿otro compilador/"
f"flags?)")
except FileNotFoundError:
pass
# ¿Qué secciones difieren? Es lo que dice si es codegen (.text) o metadata (.comment).
secs = []
try:
for f_, tag in ((a, "A"), (b, "B")):
pass
sa_ = subprocess.run(["readelf", "-S", "-W", a], capture_output=True, text=True).stdout
sb_ = subprocess.run(["readelf", "-S", "-W", b], capture_output=True, text=True).stdout
if sa_ != sb_:
secs.append("la tabla de secciones difiere")
except FileNotFoundError:
pass
if secs:
return "elf-secciones", "; ".join(secs)
# Texto: buscar rutas o fechas embebidas — el canal impuro más común.
muestra = da[max(0, difs[0] - 40): difs[0] + 40] if difs else b""
try:
txt = muestra.decode("utf-8", "replace")
except Exception:
txt = repr(muestra)
if re.search(r"/(tmp|home|build|src)/", txt):
return "path-embebido", f"…{txt.strip()}…"
if re.search(r"(19|20)\d\d[-/]\d\d|\d\d:\d\d:\d\d", txt):
return "timestamp", f"…{txt.strip()}…"
if frac < 0.01:
return "pocos-bytes", f"{len(difs)} bytes difieren de {len(da)} ({frac*100:.3f}%) — 1er offset 0x{difs[0]:x}"
return "contenido", f"{len(difs)} bytes difieren de {len(da)} ({frac*100:.1f}%)"
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if len(args) < 2:
print(__doc__.split("\n")[2].strip(), file=sys.stderr)
return 2
A, B = args[0], args[1]
if "--store" in sys.argv:
store = sys.argv[sys.argv.index("--store") + 1]
def resolver(h):
h = h.replace("b3:", "")
for d in os.listdir(store):
if d.startswith(h):
return os.path.join(store, d)
raise SystemExit(f"no encuentro {h} en {store}")
A, B = resolver(A), resolver(B)
ta, tb = arbol(A), arbol(B)
solo_a = sorted(set(ta) - set(tb))
solo_b = sorted(set(tb) - set(ta))
comunes = sorted(set(ta) & set(tb))
difieren = [p for p in comunes if ta[p][0] != tb[p][0]]
print(f"══ why-differs")
print(f" A: {A}")
print(f" B: {B}")
print(f" {len(comunes)} ficheros comunes · {len(difieren)} difieren · "
f"{len(solo_a)} sólo-en-A · {len(solo_b)} sólo-en-B")
if not difieren and not solo_a and not solo_b:
print(" ⇒ los árboles son IDÉNTICOS (la diferencia de hash está en la metadata del "
"artefacto, no en su contenido)")
return 0
for p in solo_a:
print(f" sólo-en-A {p}")
for p in solo_b:
print(f" + sólo-en-B {p}")
# El corazón: descender SÓLO donde difiere (poda por hash), y clasificar la causa.
causas = {}
for p in difieren:
c, det = clasificar(os.path.join(A, p.lstrip("/")), os.path.join(B, p.lstrip("/")), p)
causas.setdefault(c, []).append((p, det))
print("\n ── por qué difieren (causa → ficheros):")
for c in sorted(causas, key=lambda k: -len(causas[k])):
print(f" [{c}] {len(causas[c])} fichero(s)")
for p, det in causas[c][:4]:
print(f" {p}\n {det}")
return 1
if __name__ == "__main__":
sys.exit(main())