seed-graph.py extrae deps de la metadata de nixpkgs sin construir nada (leerla
no viola el ADR 0004, que prohíbe *construir* con nix). `--calibrar` mide la
semilla contra la única verdad disponible: las recetas escritas a mano.
VEREDICTO (60 recetas, 686 deps):
directas recall 39%*, precisión 59% (269 de 455)
transitivas recall 84%, precisión 6% (390 de 6760)
* contra la verdad APLANADA — comparación injusta, y el modo transitivo
prueba que la información sí está; sólo falta aplanarla.
Decisión: sembrar aristas DIRECTAS, la transitividad la hace el grafo. hammer
aplana la clausura en [deps].build (cada dep = capa --overlay-src), nixpkgs
declara directas y propaga; reproducir el aplanado arrastra 4629 nodos fantasma
del bootstrap de nixpkgs.
Lo que enseñó la calibración (3 rondas, cada fix salido de un dato):
· 4 clases de discrepancia, no 1: convención hammer (make/binutils/linux-headers,
implícitas en el stdenv de nix), variante nuestra (zlib vs zlib-shared),
provisto por el lab (cargo/rustc — las recetas Rust declaran deps=[]), y recién
después ruido real. Contarlas juntas hacía parecer irreducible lo clasificable.
· desalineación aburrida y arreglable: caja (libx11/libX11), implementación
(gettext→gettext-tiny, ninja→samurai), versión pegada al pname, hooks del
stdenv. Precisión 45%→59%; "sin attr en nixpkgs" 22/40 → 6/60.
· el escritorio KDE cuelga de kdePackages., no del top-level: sin ese fallback
el perfil objetivo era insembrable.
Hallazgo de secuencia: hay 0 nodos `wanted` (targets.toml se pobló por
lift-and-shift de lo existente), así que el sembrador no tenía a quién sembrar.
De ahí `--frontera <perfil>`: siembra las recetas que YA existen y reporta lo que
nixpkgs pide y hammer no puede construir. 137 candidatos en escritorio-kde
(kdoctools ×6, milou, polkit-qt-1, libkscreen, y mesa-libgbm/libglvnd/spirv-tools
= el muro de GBM/EGL ya documentado), 51 en cli, 46 en escritorio-mirada.
Cada candidato es una HIPÓTESIS a clasificar a mano: dep opcional no habilitada,
nix-ismo por filtrar, o hueco real. Nada se promueve a receta automáticamente.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 KiB
Plan — catálogo objetivo: del caos al contador que baja
Fecha: 2026-07-22 · Estado: propuesto, listo para ejecutar · Toca: scripts/build-state.py,
docs/state/, scripts/product-userland-from-repo.sh, scripts/mirada-usb.sh
1. El diagnóstico, medido (no intuido)
La pregunta que lo dispara: «¿se puede barrer primero TODOS los paquetes que va a tener la distro y construir el árbol de dependencias de una, en vez de descubrir a los golpes?»
Media respuesta ya está construida. docs/state/build-state.json es ese árbol: 768 nodos,
aristas = deps de build, topo_ok: true, 0 deps huérfanas, 750 selladas / 16 deuda / 2 nunca. Y
cada nodo trae blocked_by (quién me traba) y unblocks (a cuántos destrabo) ⇒ el orden de
masticado ya está calculado (scripts/build-state.py:147-157).
Lo que no existe es el grafo de lo que la distro debe tener. Ese conocimiento vive disperso:
| dónde | qué es | forma |
|---|---|---|
scripts/product-userland-from-repo.sh:26-28 |
el userland real del producto | 2 strings de shell |
scripts/mirada-usb.sh:38 |
la imagen de escritorio mirada | 1 string de shell |
tandas/*.txt |
55 ficheros de campañas pasadas | listas planas, sin aristas |
recipes/incoming-kde/ |
206 recetas del escritorio | una cola, no un objetivo |
De ahí la sensación de caos: el mapa cierra perfecto, pero sólo cubre el territorio ya conquistado. «¿Cuánto falta?» no tiene respuesta porque no hay artefacto que enumere el destino. Y cada frente nuevo re-descubre su propia frontera desde cero.
2. Qué se puede saber de antemano y qué no
Honestidad sobre el límite, porque define el alcance del plan:
- Se puede saber el esqueleto. nixpkgs/Alpine ya tienen el grafo de deps de casi todo. Se
extrae sin construir nada (
scripts/nix-import.sh:24-40ya hace exactamente esenix evalsacandobuildInputs+nativeBuildInputs). - No se puede saber la verdad. Las deps de un paquete no son propiedad del paquete: son propiedad de tu configuración de build. mesa con qué drivers gallium, qué plataformas, qué opciones meson — cambiás una flag y cambia el grafo. Lo importado es el grafo de otra distro con otras decisiones. Aproximación buena, verdad no.
- El tramo final sólo se sabe ejecutando el build — pero cuesta 1 construcción, no N.
«Compilar mesa cinco veces» era el método viejo (probar → fallar → adivinar qué faltó). harkaq ya
convirtió eso de búsqueda en medición: construís una vez, el veredicto por fase dice qué paths
tocó de verdad, los declarás. Es el bucle ya cerrado con zlib (
Impuro→Hermeticoen 3 fases) y el que la campaña desatendida corre en la granja (49 recetas con deps declaradas por el kernel).
Conclusión: el caos no es inevitable, pero no se elimina por adelantado — se convierte. El barrido previo compra el esqueleto y el orden; harkaq compra una medición en vez de cinco adivinanzas. Lo irreducible que queda son las deps que dependen de tus opciones de compilación, y ésas son decisiones, no descubrimientos: van en la receta, no en un bucle de prueba y error.
3. El invariante nuevo
Hoy
orphan_deps == 0porque el grafo sólo contiene lo que existe. El plan lo abre a propósito: la frontera deja de ser invisible y pasa a ser un número.
Ojo con el gate de CI (build-state.py --check, scripts/build-state.py:201): hoy sale 1 si hay
huérfanas o unhashable. El diseño lo preserva — un nodo deseado está declarado, así que una
arista que apunta a él resuelve y orphan_deps sigue significando lo mismo (arista colgando que
nadie declaró ni quiere). La frontera se cuenta con un estado nuevo, wanted, que no es un
fallo. Y wanted ≠ unhashable: el primero es «no hay receta todavía», el segundo «hay receta y
está rota».
4. Las piezas
P1 — docs/state/targets.toml: el manifiesto de objetivo
Un solo fichero declarativo. Un perfil = una imagen enviable.
schema = "hammer-targets/1"
[perfil.base]
descripcion = "userland foundational: la distro arranca y se usa"
paquetes = ["bash", "git", "sudo", "doas", "util-linux", "..."]
[perfil.cli]
hereda = ["base"]
paquetes = ["bat", "fd", "ripgrep", "..."]
[perfil.escritorio-kde]
hereda = ["base"]
paquetes = ["plasma-desktop", "..."]
La inversión clave: paquetes lista sólo las raíces — lo que un usuario pide por nombre. La
clausura sale del grafo, no de la lista. Hoy es al revés: tandas/base-system-3-libs.txt enumera a
mano los miembros de la clausura («libs»). Eso es trabajo que el grafo puede hacer solo.
Migración: los strings de product-userland-from-repo.sh:26-28 pasan a perfil.base +
perfil.cli verbatim, y el de mirada-usb.sh:38 a perfil.escritorio-mirada. Es un
lift-and-shift, no un rediseño. Gate: el PKGS_LIST resultante debe ser idéntico al de hoy —
verificable con un diff de la lista expandida. Ninguna imagen que hoy funciona puede cambiar.
P2 — estado wanted + membresía de perfil en build-state.py
Cambio quirúrgico (~40 líneas) sobre load_recipes() / main():
- Cargar
targets.toml, expandirhereda, obtener el set de raíces por perfil. - Toda raíz —y toda dep de un nodo— que no tenga receta se crea como nodo
{state: "wanted", deps: [], perfiles: [...]}. Deja de ser huérfana y pasa a ser frontera. - Campo
perfilesen TODOS los nodos, propagado por alcanzabilidad desde las raíces. Esto solo ya paga el trabajo: responde «si rompo zlib, qué imágenes se caen» y —más incómodo— «cuáles de las 768 recetas no las necesita ninguna imagen» (sospecha: buena parte de los 362 CLIs Go son catálogo, no distro; conviene saberlo explícitamente en vez de suponerlo). totalsganawanted; bloque nuevoby_profile: por perfil, tamaño de clausura / sellado / deuda / wanted. Ése es el «cuánto falta», por imagen.
Límite honesto que hay que escribir en el header del script: la clausura por perfil necesita las
deps de los nodos wanted, que no se conocen hasta P3. En la primera corrida by_profile es una
cota inferior que crece a medida que se siembra. Mejor un número que converge que ninguno.
P3 — scripts/seed-graph.py: sembrar aristas desde upstream
Para cada nodo wanted sin aristas, sacar la lista de deps de la metadata upstream sin construir:
- nix — reusar tal cual el
nix eval --applydenix-import.sh:24-40, que ya devuelvebuild_inputs+native_build_inputs. No emitir receta: sólo las aristas. - alpine —
makedependsdel APKBUILD, víaalpine-import.sh.
Salida a un fichero aparte, docs/state/seed-edges.json, con procedencia por arista
({dep, fuente: "nix"|"alpine", fecha}). Nunca a targets.toml, nunca a recipes/.
Disciplina, en negrita porque es donde esto se puede pudrir: la semilla es una HIPÓTESIS, no la
verdad. Los nombres son de upstream (pkg-config no existe en hammer, que usa zig-cc; stdenv y
los hooks de autoreconf no son nada acá). Hace falta una tabla de mapeo de nombres y una lista de
descarte de nix-ismos. Va a haber ruido. Su único trabajo es hacer la frontera contable y
ordenable, no correcta. Nota de ADR: leer la metadata de nixpkgs no viola el ADR 0004 — lo que
prohíbe es construir con nix, y acá no se construye nada.
P3 — RESULTADO MEDIDO (2026-07-22)
scripts/seed-graph.py, con --calibrar que mide la semilla contra la verdad que ya tenemos: las
recetas escritas a mano. Tres rondas de calibración, cada corrección salida de un dato y no de una
intuición. Muestra final: 60 recetas, 686 deps declaradas.
| modo | recall (de la verdad) | precisión (aristas sembradas que son reales) |
|---|---|---|
| directas | 39% * | 59% (269 de 455) |
| transitivas | 84% | 6% (390 de 6760) |
* contra la verdad aplanada. La comparación es injusta y el modo transitivo lo prueba: la información sí está (84%), sólo hace falta aplanarla.
Decisión: se siembran aristas DIRECTAS; la transitividad la hace el grafo. Las dos distros ponen
la frontera en lugares distintos — hammer APLANA la clausura en [deps].build (cada dep es una capa
--overlay-src del sandbox: si no está declarada, no está en el árbol), nixpkgs declara sólo las
directas y propaga. Reproducir el aplanado con el cierre transitivo de nixpkgs arrastra su universo
de bootstrap: 4629 nodos fantasma (glibc-locales, autoconf, texinfo, help2man). Como
build-state.py ya calcula clausura desde las aristas, aplanar en el sembrador es trabajo duplicado
que además envenena el grafo.
Lo que la calibración enseñó, y que ninguna intuición habría dado:
- Cuatro clases de discrepancia, no una. No todo desacuerdo es ruido:
make/binutils/linux-headersson convención de hammer (nix las da por implícitas en el stdenv);zlibvszlib-sharedes una decisión nuestra de enlazado que nix no puede conocer;cargo/rustclos provee el lab, no una receta (las recetas Rust declarandeps = []); y recién lo que queda es ruido de verdad. Contarlas juntas hacía parecer irreducible lo que era clasificable. - Los nombres desalinean de formas aburridas y arreglables: mayúsculas (
libx11vslibX11), implementación (gettext→gettext-tiny,ninja→samurai), versión pegada al pname (glibc-iconv-2.42), hooks del stdenv (cargo-build-hook.sh). Filtrarlos subió la precisión de 45% a 59% y bajó los "sin attr en nixpkgs" de 22/40 a 6/60. - El escritorio KDE no vive en el top-level de nixpkgs sino bajo
kdePackages.. Sin ese fallback, justo el perfil objetivo era insembrable.
Y un hallazgo de secuencia: hoy hay 0 nodos wanted, porque targets.toml se pobló por
lift-and-shift de lo que ya existe. Un sembrador de nodos inexistentes no tiene a quién sembrar. De
ahí el modo que sí da valor ya: --frontera <perfil> siembra las recetas que YA existen en la
clausura de un perfil y reporta lo que nixpkgs pide y hammer no tiene receta para construir —
descubre la frontera desde el corpus actual en vez de esperar a que alguien la declare. Sale a
docs/state/seed-frontera.json, versionado, para que el git diff muestre qué huecos aparecen.
Primer barrido: 137 candidatos en escritorio-kde (kdoctools ×6, milou, polkit-qt-1,
libkscreen, qqc2-breeze-style, y mesa-libgbm/libglvnd/spirv-tools — que son exactamente el
muro de GBM/EGL ya documentado), 51 en cli, 46 en escritorio-mirada.
Cada candidato es una hipótesis a clasificar por un humano: (a) dep opcional que hammer no habilitó a propósito, (b) nix-ismo que falta filtrar, o (c) hueco real. El sembrador no decide cuál; los ordena por cuántas recetas los piden. Nada se promueve a receta automáticamente.
P4 — el bucle de drenaje, y dónde harkaq cierra el lazo
Regla de fuente única de verdad:
seed-edges.jsonsólo se consulta para nodos SIN receta. En cuanto hay receta, manda la receta.
Con eso la semilla es auto-borrable: cada paquete construido reemplaza su arista hipotética por
la medida (la receta, ya corregida por el veredicto de harkaq — que es literalmente lo que hace la
campaña desatendida hoy). Al final del frente, seed-edges.json queda vacío. Si no se vacía, eso
mismo es la lista de lo que falta.
Orden de ataque por perfil: topológico, priorizado por unblocks — ya se calcula en
build-state.py:151-157, sólo hay que incluir los nodos wanted.
P5 — jubilar las tandas (degradarlas, no borrarlas)
tandas/ pasa a ser archivo de campañas pasadas (55 ficheros con historia, no se tiran). El
trabajo nuevo sale de una vista derivada del grafo: los próximos N nodos wanted construibles ya
(sin blocked_by) en orden topológico. El lazo de la granja (vps-nunca-idle, la cosecha cron) se
alimenta de ahí en vez de listas escritas a mano.
Eso es lo que de verdad mata la sensación de caos: la cola del worker deja de ser autoría y pasa a ser derivada.
5. Secuencia (commits chicos, valor en el primero)
| paso | qué | riesgo | esfuerzo |
|---|---|---|---|
| P1 | targets.toml + lift-and-shift de los 3 strings; gate = lista expandida idéntica |
bajo | ~1h |
| P2 | wanted + perfiles + by_profile en build-state.py; regenerar y commitear el diff del JSON |
bajo | ~1-2h |
| P3 | seed-graph.py contra un solo perfil (escritorio-kde: el más duro y el de mayor valor, 206 recetas con frontera desconocida). Medir la tasa de ruido antes de generalizar |
medio | ~medio día |
| P4 | enchufar el drenaje a la cola de la granja | medio | tras P3 |
P1+P2 es el corte mínimo viable y ya entrega el primer número de «cuánto falta por imagen». P3 no se generaliza hasta conocer su ruido.
Gates:
- P2 mantiene
--checken verde ytopo_ok: true. - P3 no escribe en
recipes/ni entargets.toml. - Ninguna imagen que hoy bootea cambia de contenido por P1.
6. Coordinación
Toca ficheros compartidos (scripts/build-state.py, docs/state/). Hay otro agente trabajando en
el repo ⇒ acordar antes de P1/P2, y nunca git add -A (memoria etapa-g-frente-go).