gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1383Shell 129Rust 79Markdown 732473 Definiciones

⎇ be368df07055c27d7fba652ef9f68f608d661d22 docs/plan-catalogo-objetivo.md Markdown · 294 líneas · 17.5 KB Historia crudo

takana etapa 5b: los 59 docs de diseño, runbooks y ADR 97ceb7241 · Sergio ·

1# Plan — catálogo objetivo: del caos al contador que baja
2
3**Fecha:** 2026-07-22 · **Estado:** propuesto, listo para ejecutar · **Toca:** `scripts/build-state.py`,
4`docs/state/`, `scripts/product-userland-from-repo.sh`, `scripts/mirada-usb.sh`
5
6## 1. El diagnóstico, medido (no intuido)
7
8La pregunta que lo dispara: *«¿se puede barrer primero TODOS los paquetes que va a tener la distro y
9construir el árbol de dependencias de una, en vez de descubrir a los golpes?»*
10
11Media respuesta ya está construida. `docs/state/build-state.json` **es** ese árbol: 768 nodos,
12aristas = deps de build, `topo_ok: true`, **0 deps huérfanas**, 750 selladas / 16 deuda / 2 nunca. Y
13cada nodo trae `blocked_by` (quién me traba) y `unblocks` (a cuántos destrabo) ⇒ el orden de
14masticado ya está calculado (`scripts/build-state.py:147-157`).
15
16Lo que **no** existe es el grafo de lo que la distro *debe* tener. Ese conocimiento vive disperso:
17
18| dónde | qué es | forma |
19|---|---|---|
20| `scripts/product-userland-from-repo.sh:26-28` | el userland real del producto | 2 strings de shell |
21| `scripts/mirada-usb.sh:38` | la imagen de escritorio mirada | 1 string de shell |
22| `tandas/*.txt` | 55 ficheros de campañas pasadas | listas planas, sin aristas |
23| `recipes/incoming-kde/` | 206 recetas del escritorio | una cola, no un objetivo |
24
25De ahí la sensación de caos: **el mapa cierra perfecto, pero sólo cubre el territorio ya
26conquistado.** «¿Cuánto falta?» no tiene respuesta porque no hay artefacto que enumere el destino.
27Y cada frente nuevo re-descubre su propia frontera desde cero.
28
29## 2. Qué se puede saber de antemano y qué no
30
31Honestidad sobre el límite, porque define el alcance del plan:
32
33- **Se puede saber el esqueleto.** nixpkgs/Alpine ya tienen el grafo de deps de casi todo. Se
34  extrae sin construir nada (`scripts/nix-import.sh:24-40` ya hace exactamente ese `nix eval`
35  sacando `buildInputs` + `nativeBuildInputs`).
36- **No se puede saber la verdad.** Las deps de un paquete no son propiedad del paquete: son
37  propiedad de *tu configuración de build*. mesa con qué drivers gallium, qué plataformas, qué
38  opciones meson — cambiás una flag y cambia el grafo. Lo importado es el grafo de **otra** distro
39  con **otras** decisiones. Aproximación buena, verdad no.
40- **El tramo final sólo se sabe ejecutando el build** — pero cuesta **1** construcción, no N.
41  «Compilar mesa cinco veces» era el método viejo (probar → fallar → adivinar qué faltó). harkaq ya
42  convirtió eso de *búsqueda* en *medición*: construís una vez, el veredicto por fase dice qué paths
43  tocó de verdad, los declarás. Es el bucle ya cerrado con zlib (`Impuro` → `Hermetico` en 3 fases)
44  y el que la campaña desatendida corre en la granja (49 recetas con deps declaradas por el kernel).
45
46**Conclusión:** el caos no es inevitable, pero no se elimina por adelantado — se convierte. El
47barrido previo compra el **esqueleto y el orden**; harkaq compra **una medición en vez de cinco
48adivinanzas**. Lo irreducible que queda son las deps que dependen de *tus* opciones de compilación,
49y ésas son decisiones, no descubrimientos: van en la receta, no en un bucle de prueba y error.
50
51## 3. El invariante nuevo
52
53> Hoy `orphan_deps == 0` porque el grafo sólo contiene lo que existe. El plan lo abre a propósito:
54> **la frontera deja de ser invisible y pasa a ser un número.**
55
56Ojo con el gate de CI (`build-state.py --check`, `scripts/build-state.py:201`): hoy sale 1 si hay
57huérfanas o `unhashable`. El diseño lo preserva — un nodo *deseado* está **declarado**, así que una
58arista que apunta a él **resuelve** y `orphan_deps` sigue significando lo mismo (arista colgando que
59nadie declaró ni quiere). La frontera se cuenta con un estado nuevo, `wanted`, que **no** es un
60fallo. Y `wanted` ≠ `unhashable`: el primero es «no hay receta todavía», el segundo «hay receta y
61está rota».
62
63## 4. Las piezas
64
65### P1 — `docs/state/targets.toml`: el manifiesto de objetivo
66
67Un solo fichero declarativo. Un `perfil` = una imagen enviable.
68
69```toml
70schema = "takana-targets/1"
71
72[perfil.base]
73descripcion = "userland foundational: la distro arranca y se usa"
74paquetes = ["bash", "git", "sudo", "doas", "util-linux", "..."]
75
76[perfil.cli]
77hereda = ["base"]
78paquetes = ["bat", "fd", "ripgrep", "..."]
79
80[perfil.escritorio-kde]
81hereda = ["base"]
82paquetes = ["plasma-desktop", "..."]
83```
84
85**La inversión clave:** `paquetes` lista sólo las **raíces** — lo que un usuario pide por nombre. La
86clausura sale del grafo, no de la lista. Hoy es al revés: `tandas/base-system-3-libs.txt` enumera a
87mano los miembros de la clausura («libs»). Eso es trabajo que el grafo puede hacer solo.
88
89Migración: los strings de `product-userland-from-repo.sh:26-28` pasan a `perfil.base` +
90`perfil.cli` **verbatim**, y el de `mirada-usb.sh:38` a `perfil.escritorio-mirada`. Es un
91lift-and-shift, no un rediseño. **Gate:** el `PKGS_LIST` resultante debe ser idéntico al de hoy —
92verificable con un diff de la lista expandida. Ninguna imagen que hoy funciona puede cambiar.
93
94### P2 — estado `wanted` + membresía de perfil en `build-state.py`
95
96Cambio quirúrgico (~40 líneas) sobre `load_recipes()` / `main()`:
97
981. Cargar `targets.toml`, expandir `hereda`, obtener el set de raíces por perfil.
992. Toda raíz —y toda dep de un nodo— que no tenga receta se crea como nodo
100   `{state: "wanted", deps: [], perfiles: [...]}`. Deja de ser huérfana y pasa a ser frontera.
1013. **Campo `perfiles` en TODOS los nodos**, propagado por alcanzabilidad desde las raíces. Esto
102   solo ya paga el trabajo: responde *«si rompo zlib, qué imágenes se caen»* y —más incómodo—
103   *«cuáles de las 768 recetas no las necesita ninguna imagen»* (sospecha: buena parte de los 362
104   CLIs Go son catálogo, no distro; conviene saberlo explícitamente en vez de suponerlo).
1054. `totals` gana `wanted`; bloque nuevo `by_profile`: por perfil, tamaño de clausura / sellado /
106   deuda / wanted. **Ése es el «cuánto falta», por imagen.**
107
108**Límite honesto que hay que escribir en el header del script:** la clausura por perfil necesita las
109deps de los nodos `wanted`, que no se conocen hasta P3. En la primera corrida `by_profile` es una
110**cota inferior** que crece a medida que se siembra. Mejor un número que converge que ninguno.
111
112### P3 — `scripts/seed-graph.py`: sembrar aristas desde upstream
113
114Para cada nodo `wanted` sin aristas, sacar la lista de deps de la metadata upstream **sin construir**:
115
116- **nix** — reusar tal cual el `nix eval --apply` de `nix-import.sh:24-40`, que ya devuelve
117  `build_inputs` + `native_build_inputs`. No emitir receta: sólo las aristas.
118- **alpine** — `makedepends` del APKBUILD, vía `alpine-import.sh`.
119
120Salida a un fichero **aparte**, `docs/state/seed-edges.json`, con procedencia por arista
121(`{dep, fuente: "nix"|"alpine", fecha}`). Nunca a `targets.toml`, nunca a `recipes/`.
122
123**Disciplina, en negrita porque es donde esto se puede pudrir: la semilla es una HIPÓTESIS, no la
124verdad.** Los nombres son de upstream (`pkg-config` no existe en takana, que usa zig-cc; `stdenv` y
125los hooks de autoreconf no son nada acá). Hace falta una tabla de mapeo de nombres y una lista de
126descarte de nix-ismos. Va a haber ruido. Su único trabajo es hacer la frontera **contable y
127ordenable**, no correcta. Nota de ADR: leer la *metadata* de nixpkgs no viola el ADR 0004 — lo que
128prohíbe es **construir** con nix, y acá no se construye nada.
129
130#### P3 — RESULTADO MEDIDO (2026-07-22)
131
132`scripts/seed-graph.py`, con `--calibrar` que mide la semilla contra la verdad que ya tenemos: las
133recetas escritas a mano. Tres rondas de calibración, cada corrección salida de un dato y no de una
134intuición. Muestra final: 60 recetas, 686 deps declaradas.
135
136| modo | recall (de la verdad) | precisión (aristas sembradas que son reales) |
137|---|---|---|
138| **directas** | 39% * | **59%** (269 de 455) |
139| transitivas | **84%** | 6% (390 de 6760) |
140
141\* contra la verdad *aplanada*. La comparación es injusta y el modo transitivo lo prueba: la
142información **sí está** (84%), sólo hace falta aplanarla.
143
144**Decisión: se siembran aristas DIRECTAS; la transitividad la hace el grafo.** Las dos distros ponen
145la frontera en lugares distintos — takana APLANA la clausura en `[deps].build` (cada dep es una capa
146`--overlay-src` del sandbox: si no está declarada, no está en el árbol), nixpkgs declara sólo las
147directas y propaga. Reproducir el aplanado con el cierre transitivo de nixpkgs arrastra su universo
148de bootstrap: **4629 nodos fantasma** (`glibc-locales`, `autoconf`, `texinfo`, `help2man`). Como
149`build-state.py` ya calcula clausura desde las aristas, aplanar en el sembrador es trabajo duplicado
150que además envenena el grafo.
151
152Lo que la calibración enseñó, y que ninguna intuición habría dado:
153
154- **Cuatro clases de discrepancia, no una.** No todo desacuerdo es ruido: `make`/`binutils`/
155  `linux-headers` son **convención de takana** (nix las da por implícitas en el stdenv); `zlib` vs
156  `zlib-shared` es una **decisión nuestra** de enlazado que nix no puede conocer; `cargo`/`rustc` los
157  **provee el lab**, no una receta (las recetas Rust declaran `deps = []`); y recién lo que queda es
158  ruido de verdad. Contarlas juntas hacía parecer irreducible lo que era clasificable.
159- **Los nombres desalinean de formas aburridas y arreglables**: mayúsculas (`libx11` vs `libX11`),
160  implementación (`gettext`→`gettext-tiny`, `ninja`→`samurai`), versión pegada al pname
161  (`glibc-iconv-2.42`), hooks del stdenv (`cargo-build-hook.sh`). Filtrarlos subió la precisión de
162  45% a 59% y bajó los "sin attr en nixpkgs" de 22/40 a 6/60.
163- **El escritorio KDE no vive en el top-level de nixpkgs** sino bajo `kdePackages.`. Sin ese
164  fallback, justo el perfil objetivo era insembrable.
165
166**Y un hallazgo de secuencia: hoy hay 0 nodos `wanted`**, porque `targets.toml` se pobló por
167lift-and-shift de lo que ya existe. Un sembrador de nodos inexistentes no tiene a quién sembrar. De
168ahí el modo que sí da valor ya: `--frontera <perfil>` siembra las recetas que YA existen en la
169clausura de un perfil y reporta lo que nixpkgs pide y takana no tiene receta para construir —
170descubre la frontera desde el corpus actual en vez de esperar a que alguien la declare. Sale a
171`docs/state/seed-frontera.json`, versionado, para que el `git diff` muestre qué huecos aparecen.
172Primer barrido: **137 candidatos en `escritorio-kde`** (`kdoctools` ×6, `milou`, `polkit-qt-1`,
173`libkscreen`, `qqc2-breeze-style`, y `mesa-libgbm`/`libglvnd`/`spirv-tools` — que son exactamente el
174muro de GBM/EGL ya documentado), **51 en `cli`**, 46 en `escritorio-mirada`.
175
176Cada candidato es una **hipótesis a clasificar por un humano**: (a) dep opcional que takana no
177habilitó a propósito, (b) nix-ismo que falta filtrar, o (c) hueco real. El sembrador no decide cuál;
178los ordena por cuántas recetas los piden. **Nada se promueve a receta automáticamente.**
179
180### P4 — el bucle de drenaje, y dónde harkaq cierra el lazo
181
182Regla de fuente única de verdad:
183
184> **`seed-edges.json` sólo se consulta para nodos SIN receta. En cuanto hay receta, manda la receta.**
185
186Con eso la semilla es **auto-borrable**: cada paquete construido reemplaza su arista hipotética por
187la medida (la receta, ya corregida por el veredicto de harkaq — que es literalmente lo que hace la
188campaña desatendida hoy). Al final del frente, `seed-edges.json` queda vacío. Si no se vacía, eso
189mismo es la lista de lo que falta.
190
191Orden de ataque por perfil: topológico, priorizado por `unblocks` — ya se calcula en
192`build-state.py:151-157`, sólo hay que incluir los nodos `wanted`.
193
194#### P4 — RESULTADO MEDIDO (2026-07-22)
195
196`scripts/drenar.py`. No lista "lo que falta" en un montón: lo parte en **ondas topológicas** sobre el
197subgrafo *en deuda* (una receta sólo espera por deps que también estén en deuda; las selladas ya
198están en el store y no bloquean). Onda 1 = construible ya. Dentro de cada onda, orden por `unblocks`
199desc: primero lo que libera más.
200
201Eso da tres cosas que una lista plana no da: el orden correcto, **la profundidad real de la cadena**
202—cuántos pasos secuenciales faltan, que es lo que manda en el reloj de pared— y qué se puede
203paralelizar sin pisarse.
204
205| perfil | en deuda | ondas | onda 1 |
206|---|---|---|---|
207| base | 0 | — | imagen completa |
208| cli | 0 | — | imagen completa |
209| **escritorio-kde** | **77** | **12** | **1 receta: `qtbase`** |
210| escritorio-mirada | 4 | 2 | 3 recetas |
211
212**El hallazgo: el escritorio KDE está serializado detrás de una sola receta.** `qtbase` destraba 117
213nodos y es lo único de la onda 1 — hasta que no esté sellada, ningún otro trabajo del escritorio
214puede empezar, y por más workers que se enciendan no hay nada que paralelizar. Las 12 ondas son el
215piso de pasos secuenciales. Eso cambia la pregunta de "¿cuántas recetas faltan?" (77, poco
216informativo) a "¿cuál es el camino crítico?" (qtbase → qttools/qtdeclarative → los KF6 → Plasma).
217
218Dos correcciones que salieron de mirar la salida:
219
220- **El grafo se elige por la `cola` que el perfil declara, no por cuál tiene más nodos.** La cola
221  `incoming-kde` SOMBREA recetas canónicas, así que medir `escritorio-mirada` contra el grafo KDE
222  medía una imagen que nadie construye (daba 3 en deuda en vez de 4). Cada perfil se mide contra el
223  árbol de recetas del que realmente sale. El mismo atajo estaba en `seed-graph.py --frontera`;
224  corregido en los dos.
225- **La regla de la fuente única, implementada**: `deps_de()` usa la receta si existe y las aristas
226  sembradas sólo si el nodo es `wanted`, marcando la procedencia (`[semilla]`) en la salida. Hoy no
227  se ejerce (0 nodos `wanted`), pero queda escrita donde se va a usar.
228
229**Enchufado al latido**: `cosecha-cron.sh` regenera `docs/state/drenaje.json` junto al grafo y lo
230commitea. La cola pasa a ser **derivada y versionada** — el `git diff` entre dos ciclos muestra qué
231salió de la deuda y qué onda se vació. **No lanza ningún build**: construir cuesta € y sigue siendo
232decisión explícita (`farm-up` + `campana-deuda.sh`), que es lo que el pie de la salida recuerda.
233
234### P5 — jubilar las tandas (degradarlas, no borrarlas)
235
236`tandas/` pasa a ser **archivo de campañas pasadas** (55 ficheros con historia, no se tiran). El
237trabajo nuevo sale de una vista derivada del grafo: los próximos N nodos `wanted` **construibles ya**
238(sin `blocked_by`) en orden topológico. El lazo de la granja (`vps-nunca-idle`, la cosecha cron) se
239alimenta de ahí en vez de listas escritas a mano.
240
241**Eso es lo que de verdad mata la sensación de caos: la cola del worker deja de ser autoría y pasa a
242ser derivada.**
243
244#### P5 — RESULTADO (2026-07-22)
245
246`tandas/README.md` declara el directorio **archivo de campañas pasadas** (55 ficheros, no se borran:
247su `git log` es la crónica de cada frente) y documenta de dónde sale el trabajo ahora. La vía de
248escape (`import-batch.sh -f`) sigue viva; deja de ser el camino principal.
249
250Lo que faltaba para que la cola fuera de verdad derivada era el paso humano, y estaba sin forma:
251137 candidatos de frontera clasificables sólo en la cabeza de alguien. `scripts/triaje.py` le da
252forma durable — `docs/state/frontera-triaje.toml`, **170 candidatos** unificados de los tres
253perfiles. Cada veredicto CIERRA un lazo distinto, que es lo que lo hace algo más que una lista:
254
255| veredicto | destino | efecto |
256|---|---|---|
257| `hueco` | raíz en `targets.toml` | nace un nodo `wanted` ⇒ el sembrador lo siembra y el drenaje lo ordena |
258| `opcional` | queda registrado | deja de reaparecer como pregunta |
259| `nix-ismo` | `nixismos-triaje.txt` | vuelve a `seed-graph.py` como descarte ⇒ **el sembrador aprende de su propio triaje** |
260
261Sincronizar **nunca pisa un juicio humano**: agrega candidatos nuevos y marca `ausente = true` los
262que dejaron de aparecer (que puede ser progreso —se escribió la receta— o un cambio de nixpkgs, y
263conviene verlo en el `git diff`). El campo `sugerencia` propone con evidencia mecánica (cuántas
264recetas lo piden, si el nombre tiene forma de env/wrapper de nix) pero `veredicto` arranca en
265`pendiente`: la heurística **abarata** el juicio, no lo cierra.
266
267Lazo verificado de punta a punta: marcar `python3-3.14.6-env` como `nix-ismo` → `--aplicar` lo
268escribe al descarte → `seed-graph.normalizar()` lo devuelve clasificado y deja de contarlo como
269hueco. (Probado y revertido; los 170 siguen en `pendiente` — clasificar es decisión del humano.)
270
271**Estado de la cadena**: cerrada y corriendo. `targets.toml` → `build-state.json` → `drenaje.json` →
272worker, regenerada por el latido cada 30 min. El único paso que requiere una persona es el triaje.
273
274## 5. Secuencia (commits chicos, valor en el primero)
275
276| paso | qué | riesgo | esfuerzo |
277|---|---|---|---|
278| **P1** | `targets.toml` + lift-and-shift de los 3 strings; gate = lista expandida idéntica | bajo | ~1h |
279| **P2** | `wanted` + `perfiles` + `by_profile` en `build-state.py`; regenerar y commitear el diff del JSON | bajo | ~1-2h |
280| **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 |
281| **P4** | enchufar el drenaje a la cola de la granja | medio | tras P3 |
282
283P1+P2 es el corte mínimo viable y ya entrega el primer número de «cuánto falta por imagen». P3 no se
284generaliza hasta conocer su ruido.
285
286**Gates:**
287- P2 mantiene `--check` en verde y `topo_ok: true`.
288- P3 **no escribe** en `recipes/` ni en `targets.toml`.
289- Ninguna imagen que hoy bootea cambia de contenido por P1.
290
291## 6. Coordinación
292
293Toca ficheros compartidos (`scripts/build-state.py`, `docs/state/`). Hay otro agente trabajando en
294el repo ⇒ acordar antes de P1/P2, y **nunca `git add -A`** (memoria `etapa-g-frente-go`).

plan-catalogo-objetivo.md

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óndequé esforma
scripts/product-userland-from-repo.sh:26-28el userland real del producto2 strings de shell
scripts/mirada-usb.sh:38la imagen de escritorio mirada1 string de shell
tandas/*.txt55 ficheros de campañas pasadaslistas planas, sin aristas
recipes/incoming-kde/206 recetas del escritoriouna 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:

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.

schema = "takana-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:

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 takana, 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.

modorecall (de la verdad)precisión (aristas sembradas que son reales)
directas39% *59% (269 de 455)
transitivas84%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 — takana 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:

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

perfilen deudaondasonda 1
base0—imagen completa
cli0—imagen completa
escritorio-kde77121 receta: qtbase
escritorio-mirada423 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:

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:

veredictodestinoefecto
huecoraíz en targets.tomlnace un nodo wanted ⇒ el sembrador lo siembra y el drenaje lo ordena
opcionalqueda registradodeja de reaparecer como pregunta
nix-ismonixismos-triaje.txtvuelve 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)

pasoquériesgoesfuerzo
P1targets.toml + lift-and-shift de los 3 strings; gate = lista expandida idénticabajo~1h
P2wanted + perfiles + by_profile en build-state.py; regenerar y commitear el diff del JSONbajo~1-2h
P3seed-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 generalizarmedio~medio día
P4enchufar el drenaje a la cola de la granjamediotras 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:

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