plan: catálogo objetivo — manifiesto de perfiles + estado wanted en el grafo

El grafo de lo que EXISTE cierra perfecto (768 nodos, 0 huérfanas, topo OK).
El de lo que la distro DEBE tener no existe: vive en 2 strings de shell de
product-userland-from-repo.sh, uno de mirada-usb.sh y 55 tandas planas. De ahí
que "cuánto falta" no tenga respuesta.

Plan en 5 piezas: targets.toml (perfiles = imágenes, listando RAÍCES y dejando
que la clausura salga del grafo), estado `wanted` + membresía de perfil en
build-state.py, siembra de aristas desde metadata upstream sin construir, y el
drenaje topológico donde harkaq reemplaza la arista hipotética por la medida.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 17:13:40 -04:00
co-authored by Claude Opus 4.8
parent 1b436a717f
commit 75c33fffe5
+174
View File
@@ -0,0 +1,174 @@
# 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 (`Impuro``Hermetico` 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 `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.
```toml
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.
- **alpine** — `makedepends` 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.
### 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`).