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>
295 lines
18 KiB
Markdown
295 lines
18 KiB
Markdown
# 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.
|
||
|
||
#### 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 (`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.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.toml` → `build-state.json` → `drenaje.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`).
|