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>
This commit is contained in:
Executable
+184
@@ -0,0 +1,184 @@
|
|||||||
|
#!/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())
|
||||||
Reference in New Issue
Block a user