Files
takana/docs/plan-catalogo-objetivo.md
T
sergioandClaude Opus 4.8 0093c1f19c catálogo objetivo P3: sembrador de aristas desde nixpkgs, calibrado contra la verdad
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>
2026-07-22 18:01:08 -04:00

13 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.

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 --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).