Files
hammer/docs/23-plan-rehasheo.md
T
sergioandClaude Opus 5 102aabd1ff SEGUNDA corrección: lo que medía el test era DERIVA, no no-determinismo — el corpus SÍ reproduce
El §1.bis dijo «sí hay no-determinismo» porque appstream y bison divergían. También estaba mal,
y por un fallo de diseño del propio test.

`verificar-repro.sh` compara el artefacto GUARDADO contra una reconstrucción de hoy. Pero el
guardado puede tener meses: se construyó con OTRO estado del lab. El test conflaba dos cosas:
  · NO-DETERMINISMO — mismas entradas, mismo lab, salidas distintas (rompe el invariante);
  · DERIVA — el artefacto viejo no es lo que el lab de hoy produce (el mundo se movió).

LA PRUEBA QUE LAS SEPARA es construir DOS VECES HOY, y se dio sin querer: al reejecutar el
verificador sobre recetas ya reconstruidas, TODAS pasaron a reproducir.
    anew  ✗ diverge → ✓ REPRODUCE
    gron  ✗ diverge → ✓ REPRODUCE
    age   ✗ diverge → ✓ REPRODUCE   (y en la 1ª ni instalaba los mismos ficheros: faltaba age-inspect)

⇒ EL CORPUS ES DETERMINISTA HOY. Lo que hay es deriva contra artefactos viejos.

QUÉ SIGNIFICA, sin adornos:
· El argumento de reproducibilidad para el split de debug SE CAE. El split se justifica por
  ESPACIO (~60%), que sigue siendo real y grande. Nada más.
· Pero la DERIVA es un problema por derecho propio y mayor: el store contiene artefactos que el
  lab de hoy no reproduciría, así que «nuestros artefactos son verificables por terceros» es
  falso para parte del corpus — quien reconstruya no obtendrá lo publicado. `age` es el caso
  feo: la reconstrucción ni siquiera instala los mismos ficheros.
· ⇒ La reconstrucción masiva SIGUE valiendo la pena, pero por otra razón: no para arreglar el
  determinismo sino para PONER EL STORE AL DÍA CON EL LAB y que la promesa sea cierta.

Y otro hallazgo del mismo experimento: en el hub las recetas Go SÍ reconstruyen (0 fallos). Lo
que fallaba en el worker era la RED para bajar los módulos, no las recetas. Eso cambia el
bloqueante de la etapa 4: no es «Go no reconstruye», es «el worker no tiene red para módulos».

LA LECCIÓN, que es la misma tres veces en este documento: un test hay que diseñarlo contra la
PREGUNTA, no contra lo que es fácil de comparar. Comparar con lo que hay en el store es cómodo;
comparar dos builds de hoy es lo que contesta. El script queda anotado con esto en la cabecera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:34:15 -04:00

15 KiB
Raw Blame History

SDD 23 — Plan del re-hasheo masivo (corregido: la etapa 1 desmintió mi primera conclusión)

Escrito 2026-08-08, tras la decisión del usuario de hacer «ahora, antes de seguir creciendo» el re-hasheo que llevaba tiempo pendiente.

La campaña se había planteado como dos trabajos que exigen reconstruir casi todo el corpus:

  1. -ffile-prefix-map global, para cerrar el «no-determinismo probable de 92 paquetes».
  2. Separar la información de depuración en paquetes -debug.

Los dos son reales. La primera versión de este documento decía que el primero se cancelaba «porque su premisa es falsa» — me equivoqué, y la etapa 1 lo demostró en veinte minutos. La corrección está en el §1.bis, y es el mejor argumento a favor de tener puertas.


1. Lo primero fue verificar la premisa (y me pasé de confiado — ver §1.bis)

El plan heredado decía: «92 paquetes con no-determinismo probable, todos por rutas /src en .debug_*; pendiente: -ffile-prefix-map global re-hashea ~720». Antes de comprometer días de granja, se comprobó. Tres medidas, en orden de fuerza creciente:

(a) Las rutas /src son CONSTANTES, no variables. El lab bindea el árbol de fuentes en /src —literal, siempre— así que una ruta /src/foo.c embebida en .debug_ es idéntica en cada rebuild y en cualquier máquina. No es no-determinismo: es una ruta fija que parece sospechosa.

(b) Barrido de 195 artefactos buscando rutas que sí variarían (/home/<user>/, /opt/hammer/work, /tmp/<aleatorio>): 15 las tienen (≈8%). Pero al mirarlas una a una, ninguna es nuestra:

ruta hallada qué es realmente
/home/buildozer/aports/main/musl/src/musl-1.2.6 la ruta de compilación de Alpine, horneada en el musl prebuilt que inyectamos
/tmp/apiresponse.json una cadena literal en el código fuente de gron
/home/jimmac/, /home/sam/dev/ rutas de los autores dentro de los assets SVG de adwaita

Las tres son constantes que vienen de fuera y son idénticas en cada rebuild. Buscar rutas con pinta de host no mide no-determinismo: mide qué escribieron terceros en sus fuentes. Es la misma clase de error que la regla del .a no-PIC —grep R_X86_64_32 contaba reubicaciones de .debug_*— y que el conteo de licencias con grep -l license, que contaba comentarios.

(c) La prueba que decide: reconstruir y comparar. Se apartó el artefacto de wl-clipboard, se reconstruyó con las deps cacheadas y se comparó con hammer why-differs:

   24 entradas idénticas · 0 divergen
   ✓ los dos árboles son idénticos: REPRODUCE

⇒ Conclusión que saqué entonces: «la reproducibilidad se sostiene, el -ffile-prefix-map no hace falta». Era falsa, y duró lo que tardó en correrse la etapa 1. Ver §1.bis.

La lección de método, que vale más que el ahorro: el no-determinismo se mide reconstruyendo y comparando, no buscando cadenas sospechosas. Un grep sobre binarios genera candidatos, no veredictos. Y este proyecto ya tiene la herramienta del veredicto (why-differs); lo que faltaba era usarla antes de planificar.

⚠️ Esto no prueba que las 1153 reproduzcan — sólo que UNA lo hace. Yo lo leí como si probara más de lo que probaba, que es exactamente el error contra el que este mismo documento advierte dos párrafos más arriba.


1.bis 🔴 CORRECCIÓN: sí hay no-determinismo, y el -ffile-prefix-map SÍ hace falta

La etapa 1 (verificación amplia, scripts/verificar-repro.sh) se corrió sobre una muestra por clase de build. Resultado sobre las que pudieron reconstruirse:

receta veredicto
binutils ✓ REPRODUCE
appstream DIVERGE
bison DIVERGE
wl-clipboard (§1c) ✓ REPRODUCE

Y la causa, idéntica en las dos que divergen, dicha por why-differs:

difieren [.debug_aranges, .debug_info, .debug_pubnames, .debug_pubtypes, .debug_str]
 — sólo info de depuración (el código ejecutable es idéntico)
 · .debug_str sólo en B: /src/output/meson-private

Dónde me equivoqué, exactamente: el barrido de §1b buscaba rutas del host (/home/<user>/, /tmp/<aleatorio>) y no encontró ninguna nuestra — cierto. Pero el no-determinismo no venía de una ruta del host sino de rutas internas al árbol de build (/src/output/meson-private) que varían entre corridas aunque /src sea constante. Buscar la forma equivocada de ruta y no hallarla no prueba que no haya otra. Y una sola muestra que reproduce (§1c) no es una muestra.

⇒ La lección del §1 sigue siendo válida y encima se refuerza: el veredicto es reconstruir y comparar. Sólo que esta vez el equivocado fui yo, y lo que me salvó fue haber puesto la puerta antes de la campaña en vez de después.

🎁 Y de ahí sale la mejor noticia del plan

Las dos mitades son el mismo trabajo. La divergencia vive ENTERA en secciones .debug_* y el código ejecutable es idéntico ⇒ separar el debug del artefacto principal hace que el artefacto principal reproduzca, sin necesidad de tocar -ffile-prefix-map en 720 recetas.

Queda por decidir qué se hace con el paquete -debug en sí (su contenido seguiría siendo no-determinista sin -ffile-prefix-map), pero eso es un problema mucho más chico: afecta a un artefacto secundario que nadie instala por defecto, y puede resolverse después o a la vez.


2. Lo que sí queda: separar la información de depuración

Medido sobre muestra amplia de .so/.a: el 79% del contenido binario del store son secciones .debug_* (qtdeclarative solo llega al 87%). Consecuencias, las dos que importan:

  • ~96 G de reserva de disco — el store de ~126 G serían ~30 G sin debug.
  • El espejo público baja de 126 G a ~30 G, que es la diferencia entre alojar barato y caro, y toca la decisión ya tomada de servirlo desde el Storage Box.

Y no es sólo tamaño: separar el debug es lo que permite entregar los símbolos a quien depura sin imponérselos a quien sólo usa la distro, que es lo que hacen todas las distros serias.

El precio, dicho claro

strip posterior no sirve: rompería la verificación bit-a-bit, porque el ArtifactHash es de ENTRADA (se calcula sin construir) y quedaría apuntando a un contenido que un rebuild ya no reproduce. Hay que hacerlo dentro del build, y eso cambia la fase installre-hashea las recetas afectadas. Es el coste real y no hay atajo.


3. 🧨 El hallazgo que condiciona CÓMO se hace: el entorno del lab NO está hasheado

Al buscar dónde poner una flag global apareció algo que hay que decidir antes de tocar nada.

crates/hammer-build/src/sandbox.rs fija el entorno de TODOS los builds:

CC=hammer-zig-cc (con -mcpu=baseline)   SOURCE_DATE_EPOCH=1   TZ=UTC   LC_ALL=C
AR="zig ar"   CARGO_BUILD_… codegen-units=1

Ninguna de esas entra en Recipe::hash_inputs, que es una lista blanca de source, compiler, target, link, zig_version, patches, flags, phases y deps.

Cambiar el entorno del lab cambia lo que sale del build SIN cambiar un solo hash. El store diría que todo está al día mientras los artefactos ya no corresponden a su hash. Es el fallo más peligroso posible en un sistema direccionado por contenido: no falla, miente.

Hoy no muerde porque ese entorno no cambia nunca. Pero una campaña global es precisamente el momento en que cambiaría.

Las dos formas de hacerlo, y cuál propongo

  • A — flag en el lab (una línea en sandbox.rs, aplica a todo): barata de escribir y rompe el invariante por lo de arriba. Descartada, salvo que se añada primero un lab_version a hash_inputs (ver abajo).
  • B — en la fase install de cada receta (strip --only-keep-debug + objcopy): explícita, entra al hash por diseño, re-hashea sólo lo que toca, y es auditable receta a receta. Propuesta. El coste es editar ~1100 recetas, pero es mecánico y verificable — exactamente el mismo patrón con el que hoy se poblaron 1074 licencias sin mover un hash.

Y un ticket previo, chico y de fondo: añadir a hash_inputs un lab_version que capture el entorno del sandbox, con el mismo diseño que zig_version (sólo entra si está fijado, así los hashes actuales no se mueven). Sin eso, cualquier cambio futuro del lab vuelve a ser invisible. Es barato hoy y caro el día que haga falta.


4. El plan, por etapas y con puertas

Cada etapa tiene una puerta: si no pasa, se para. Una campaña de días sin puertas es una forma elegante de romper el corpus.

# etapa puerta
0 lab_version en hash_inputs (opcional, no fijado) los 1153 hashes actuales no se mueven
1 Verificación AMPLIA de reproducibilidad: apartar y reconstruir una muestra de ~30 recetas de clases distintas (C, Rust, Go, meson, cmake) y comparar con why-differs 0 divergencias; si alguna diverge, ESO es el trabajo real y esta campaña se pospone
2 Piloto del split de debug en 5 recetas representativas los -debug salen, el binario sigue funcionando, y el rebuild reproduce bit a bit
3 Medir de verdad el ahorro y extrapolar — HECHO, ver §6 el ahorro real es ~60%, no 79%: se replanteó
4 Aplicar al corpus por tandas, con la granja cada tanda: store-gc + why-differs sobre una muestra
5 Regenerar grafos, perfiles y el espejo los perfiles siguen cerrando (escritorio-sway 121/121, KDE 162/162…)

Orden respecto de lo demás: la etapa 1 conviene ANTES que nada, porque si la reproducibilidad amplia no se sostiene, todo lo demás cambia de prioridad. Y es barata: apartar artefactos y reconstruir con deps cacheadas, como se hizo con wl-clipboard.


5. Lo que este plan NO propone

  • No tocar -ffile-prefix-map: §1 lo cerró.
  • No strippear artefactos ya sellados: rompe la evidencia, que es el invariante del proyecto.
  • No meter la flag en el lab sin lab_version: §3.
  • No empezar por el corpus entero: §4 empieza por 5 recetas, y con razón.

6. Etapa 3 — el ahorro real es ~60%, no 79%, y las cifras publicadas estaban infladas

El «79%» que este documento y los SDD 19/20 venían citando era el 79% del CONTENIDO BINARIO, medido sobre una muestra de .so/.a. Es cierto y es la cifra equivocada para planificar, porque un artefacto no es sólo binarios: trae cabeceras, datos, iconos, locales, .pc y documentación, que no encogen.

Medido con scripts/medir-debug.sh (suma los tamaños reales de las secciones .debug_* vía readelf -S y los compara con el tamaño del árbol, sin reconstruir nada):

muestra artefactos volumen debug / artefacto
pequeña 40 876 MB 27%
media (truncada a 60 ficheros/artefacto) 100 2 955 MB 38%
grande, sin truncar 250 8 874 MB 60%

La varianza es alta porque unos pocos artefactos grandes dominan el total. La muestra grande es la que manda (cubre ~7% del store), y encaja con lo único que está medido de verdad —el piloto de la etapa 2, donde se reconstruyó y se pesó:

  • bison 6 M → 3 M = 50%
  • appstream 56 M → 18 M = 68%

Los dos encierran el 60% de la muestra grande. Ésa es la validación: el modelo predice lo que el rebuild real produjo.

Las cifras corregidas

Con el store en 127 G y un ahorro de ~60%:

se venía diciendo medido
reserva de disco ~96 G ~76 G
store tras el split ~30 G ~51 G
espejo público ~30 G ~51 G

Sigue siendo el mayor ahorro disponible en el proyecto —76 G no es poco— pero no es lo prometido, y la diferencia importa para dimensionar el alojamiento del espejo, que es una decisión con factura.

La lección: una cifra medida sobre una cosa (contenido binario) se citó durante semanas como si midiera otra (tamaño de artefacto), y llegó a tres documentos. El número no era falso; la unidad sí. Cuando un número vaya a decidir un gasto, conviene volver a la medición original y comprobar QUÉ estaba midiendo.


7. 🔴 SEGUNDA CORRECCIÓN: lo que medía el test era DERIVA, no no-determinismo

El §1.bis dijo «sí hay no-determinismo» al ver que appstream y bison divergían. También estaba mal, y por un fallo de diseño del propio test.

scripts/verificar-repro.sh aparta el artefacto guardado en el store y lo compara con una reconstrucción de hoy. Pero ese artefacto guardado puede tener meses: se construyó con otro estado del lab (otro zig, otras flags, otro entorno). Así que el test conflaba dos cosas muy distintas:

  • no-determinismo — mismas entradas, mismo lab, salidas distintas. Es lo que rompe el invariante.
  • deriva — el artefacto viejo no es lo que el lab de HOY produce. No es no-determinismo: es que el mundo se movió.

La prueba que las separa: construir DOS VECES HOY. Y se hizo, sin querer al principio: al volver a correr el verificador sobre recetas ya reconstruidas, todas pasaron a REPRODUCIR.

receta 1ª corrida (viejo vs hoy) 2ª corrida (hoy vs hoy)
anew ✗ diverge REPRODUCE
gron ✗ diverge REPRODUCE
age ✗ diverge (¡y le faltaba age-inspect!) REPRODUCE

El corpus es determinista hoy. Lo que hay es deriva contra artefactos viejos.

Qué significa para la campaña, sin adornos

  • El argumento de reproducibilidad para el split de debug se cae. El split se justifica por ESPACIO (~60%, §6), que sigue siendo real y grande. Nada más.
  • Pero la deriva es un problema por derecho propio, y mayor: el store contiene artefactos que el lab de hoy no reproduciría. Mientras eso siga así, la frase «nuestros artefactos son verificables por terceros» es falsa para una parte del corpus — un tercero que reconstruya no obtendrá lo publicado. age es el caso feo: la reconstrucción ni siquiera instala los mismos ficheros (falta age-inspect), o sea que la deriva no es cosmética.
  • ⇒ La reconstrucción masiva sigue valiendo la pena, pero por una razón distinta de la que este documento decía: no para arreglar el determinismo, sino para poner el store al día con el lab y que la promesa de reproducibilidad sea cierta sobre todo el corpus.

La lección, que es la misma tres veces

Este documento se equivocó tres veces seguidas y las tres se corrigieron midiendo:

  1. «el -ffile-prefix-map no hace falta» — falso, por leer una sola muestra;
  2. «sí hay no-determinismo» — falso, por un test que confundía deriva con no-determinismo;
  3. y de paso, el «79%» que medía contenido binario y se citaba como tamaño de artefacto.

Un test hay que diseñarlo contra la pregunta, no contra lo que es fácil de comparar. Comparar con lo que hay en el store es cómodo; comparar dos builds de hoy es lo que contesta.