Files
hammer/docs/plan-catalogo-objetivo.md
T
sergioandClaude Opus 4.8 ec28a9b394 catálogo objetivo P5: tandas a archivo, y el triaje que cierra el lazo
tandas/README.md declara los 55 ficheros ARCHIVO de campañas pasadas (no se
borran: su git log es la crónica de cada frente) y documenta de dónde sale el
trabajo ahora. import-batch.sh -f sigue vivo como vía de escape, no como camino.

Lo que faltaba para que la cola fuera derivada de verdad era el paso humano, y
estaba sin forma: 137 candidatos clasificables sólo en la cabeza de alguien.
triaje.py les da forma durable — docs/state/frontera-triaje.toml, 170 candidatos
unificados de los 3 perfiles. Cada veredicto cierra un lazo distinto:

  hueco    → raíz en targets.toml ⇒ nace un nodo `wanted`, el sembrador lo
             siembra y el drenaje lo ordena (= trabajo nuevo para el worker)
  opcional → registrado, deja de reaparecer como pregunta
  nix-ismo → vuelve a seed-graph.py como descarte ⇒ el sembrador APRENDE de su
             propio triaje y cada corrida sale más limpia

Sincronizar NUNCA pisa un juicio humano: agrega los nuevos y marca ausente=true
los que dejaron de aparecer (progreso, o cambio de nixpkgs — se ve en el diff).
`sugerencia` propone con evidencia mecánica pero `veredicto` arranca en
`pendiente`: la heurística abarata el juicio, no lo cierra.

Lazo verificado punta a punta: python3-3.14.6-env marcado nix-ismo → --aplicar
lo escribe al descarte → seed-graph.normalizar() lo devuelve clasificado y deja
de contarlo como hueco. Probado y revertido; los 170 siguen pendientes.

Cadena cerrada y corriendo: targets.toml → build-state.json → drenaje.json →
worker, regenerada por el latido cada 30min. El único paso que pide una persona
es el triaje.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:20:30 -04:00

18 KiB
Raw Blame History

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-40 ya hace exactamente ese nix eval sacando buildInputs + 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 (ImpuroHermetico en 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 == 0 porque 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 wantedunhashable: 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():

  1. Cargar targets.toml, expandir hereda, obtener el set de raíces por perfil.
  2. 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.
  3. Campo perfiles en 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).
  4. totals gana wanted; bloque nuevo by_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 --apply de nix-import.sh:24-40, que ya devuelve build_inputs + native_build_inputs. No emitir receta: sólo las aristas.
  • alpinemakedepends del APKBUILD, vía alpine-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-headers son convención de hammer (nix las da por implícitas en el stdenv); zlib vs zlib-shared es una decisión nuestra de enlazado que nix no puede conocer; cargo/rustc los provee el lab, no una receta (las recetas Rust declaran deps = []); 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 (libx11 vs libX11), implementación (gettextgettext-tiny, ninjasamurai), 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.json só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.

P4 — RESULTADO MEDIDO (2026-07-22)

scripts/drenar.py. No lista "lo que falta" en un montón: lo parte en ondas topológicas sobre el subgrafo en deuda (una receta sólo espera por deps que también estén en deuda; las selladas ya están en el store y no bloquean). Onda 1 = construible ya. Dentro de cada onda, orden por unblocks desc: primero lo que libera más.

Eso da tres cosas que una lista plana no da: el orden correcto, la profundidad real de la cadena —cuántos pasos secuenciales faltan, que es lo que manda en el reloj de pared— y qué se puede paralelizar sin pisarse.

perfil en deuda ondas onda 1
base 0 imagen completa
cli 0 imagen completa
escritorio-kde 77 12 1 receta: qtbase
escritorio-mirada 4 2 3 recetas

El hallazgo: el escritorio KDE está serializado detrás de una sola receta. qtbase destraba 117 nodos y es lo único de la onda 1 — hasta que no esté sellada, ningún otro trabajo del escritorio puede empezar, y por más workers que se enciendan no hay nada que paralelizar. Las 12 ondas son el piso de pasos secuenciales. Eso cambia la pregunta de "¿cuántas recetas faltan?" (77, poco informativo) a "¿cuál es el camino crítico?" (qtbase → qttools/qtdeclarative → los KF6 → Plasma).

Dos correcciones que salieron de mirar la salida:

  • El grafo se elige por la cola que el perfil declara, no por cuál tiene más nodos. La cola incoming-kde SOMBREA recetas canónicas, así que medir escritorio-mirada contra el grafo KDE medía una imagen que nadie construye (daba 3 en deuda en vez de 4). Cada perfil se mide contra el árbol de recetas del que realmente sale. El mismo atajo estaba en seed-graph.py --frontera; corregido en los dos.
  • La regla de la fuente única, implementada: deps_de() usa la receta si existe y las aristas sembradas sólo si el nodo es wanted, marcando la procedencia ([semilla]) en la salida. Hoy no se ejerce (0 nodos wanted), pero queda escrita donde se va a usar.

Enchufado al latido: cosecha-cron.sh regenera docs/state/drenaje.json junto al grafo y lo commitea. La cola pasa a ser derivada y versionada — el git diff entre dos ciclos muestra qué salió de la deuda y qué onda se vació. No lanza ningún build: construir cuesta € y sigue siendo decisión explícita (farm-up + campana-deuda.sh), que es lo que el pie de la salida recuerda.

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.

P5 — RESULTADO (2026-07-22)

tandas/README.md declara el directorio archivo de campañas pasadas (55 ficheros, no se borran: su git log es la crónica de cada frente) y documenta de dónde sale el trabajo ahora. La vía de escape (import-batch.sh -f) sigue viva; deja de ser el camino principal.

Lo que faltaba para que la cola fuera de verdad derivada era el paso humano, y estaba sin forma: 137 candidatos de frontera clasificables sólo en la cabeza de alguien. scripts/triaje.py le da forma durable — docs/state/frontera-triaje.toml, 170 candidatos unificados de los tres perfiles. Cada veredicto CIERRA un lazo distinto, que es lo que lo hace algo más que una lista:

veredicto destino efecto
hueco raíz en targets.toml nace un nodo wanted ⇒ el sembrador lo siembra y el drenaje lo ordena
opcional queda registrado deja de reaparecer como pregunta
nix-ismo nixismos-triaje.txt vuelve a seed-graph.py como descarte ⇒ el sembrador aprende de su propio triaje

Sincronizar nunca pisa un juicio humano: agrega candidatos nuevos y marca ausente = true los que dejaron de aparecer (que puede ser progreso —se escribió la receta— o un cambio de nixpkgs, y conviene verlo en el git diff). El campo sugerencia propone con evidencia mecánica (cuántas recetas lo piden, si el nombre tiene forma de env/wrapper de nix) pero veredicto arranca en pendiente: la heurística abarata el juicio, no lo cierra.

Lazo verificado de punta a punta: marcar python3-3.14.6-env como nix-ismo--aplicar lo escribe al descarte → seed-graph.normalizar() lo devuelve clasificado y deja de contarlo como hueco. (Probado y revertido; los 170 siguen en pendiente — clasificar es decisión del humano.)

Estado de la cadena: cerrada y corriendo. targets.tomlbuild-state.jsondrenaje.json → worker, regenerada por el latido cada 30 min. El único paso que requiere una persona es el triaje.

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 --check en verde y topo_ok: true.
  • P3 no escribe en recipes/ ni en targets.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).