> 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…
catálogo objetivo P4: la cola de la granja pasa a ser derivada del grafo
drenar.py parte la deuda en ONDAS topológicas (una receta sólo espera por deps
que también estén en deuda; las selladas ya están en el store). Onda 1 =
construible ya; dentro de cada onda, orden por `unblocks` desc.
Da lo que una lista plana no da: el orden correcto, la PROFUNDIDAD real de la
cadena (pasos secuenciales = el reloj de pared) y qué paraleliza sin pisarse.
base 0 en deuda imagen completa
cli 0 en deuda imagen completa
escritorio-kde 77 en deuda 12 ondas onda 1 = 1 receta: qtbase
escritorio-mirada 4 en deuda 2 ondas onda 1 = 3
HALLAZGO: el escritorio KDE está serializado detrás de UNA receta. qtbase
destraba 117 nodos y es lo único de la onda 1 — hasta que no esté sellada no hay
nada que paralelizar, por más workers que se enciendan. Cambia la pregunta de
"¿cuántas faltan?" (77, poco informativo) a "¿cuál es el camino crítico?".
Dos correcciones salidas de mirar la salida:
· el grafo se elige por la `cola` declarada en targets.toml, NO por cuál tiene
más nodos: incoming-kde SOMBREA recetas canónicas, así que medir
escritorio-mirada contra el grafo KDE medía una imagen que nadie construye
(3 en deuda en vez de 4). El mismo atajo estaba en seed-graph.py --frontera;
corregido en los dos.
· regla de la fuente única implementada en deps_de(): manda la receta si existe,
la semilla sólo si el nodo es `wanted`, marcando procedencia en la salida.
Enchufado al latido: cosecha-cron regenera docs/state/drenaje.json junto al
grafo y lo commitea ⇒ cola derivada y versionada, el git diff entre ciclos
muestra qué salió de la deuda. NO lanza builds: eso cuesta € y sigue siendo
decisión explícita (farm-up + campana-deuda.sh).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/docs/plan-catalogo-objetivo.md b/docs/plan-catalogo-objetivo.md
index 3628660e..a62c1188 100644
--- a/docs/plan-catalogo-objetivo.md
+++ b/docs/plan-catalogo-objetivo.md
@@ -191,6 +191,46 @@ 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
diff --git a/docs/state/drenaje.json b/docs/state/drenaje.json
new file mode 100644
index 00000000..caba0e96
--- /dev/null
+++ b/docs/state/drenaje.json
@@ -0,0 +1,201 @@
+{
+ "schema": "hammer-drenaje/1",
+ "nota": "DERIVADO del grafo de estado por scripts/drenar.py — no editar a mano.",
+ "perfiles": {
+ "base": {
+ "grafo": "docs/state/build-state.json",
+ "nodos": 51,
+ "en_deuda": 0,
+ "ondas": 0,
+ "profundidad": 0,
+ "construibles_ya": [],
+ "olas": []
+ },
+ "cli": {
+ "grafo": "docs/state/build-state.json",
+ "nodos": 74,
+ "en_deuda": 0,
+ "ondas": 0,
+ "profundidad": 0,
+ "construibles_ya": [],
+ "olas": []
+ },
+ "escritorio-kde": {
+ "grafo": "docs/state/build-state-kde.json",
+ "nodos": 162,
+ "en_deuda": 77,
+ "ondas": 12,
+ "profundidad": 12,
+ "construibles_ya": [
+ "qtbase"
+ ],
+ "olas": [
+ {
+ "n": 1,
+ "recetas": [
+ "qtbase"
+ ]
+ },
+ {
+ "n": 2,
+ "recetas": [
+ "qttools",
+ "qtsvg",
+ "qtshadertools",
+ "qtlanguageserver",
+ "qcoro"
+ ]
+ },
+ {
+ "n": 3,
+ "recetas": [
+ "qtdeclarative",
+ "kcodecs",
+ "karchive",
+ "kitemviews",
+ "kglobalaccel",
+ "kdbusaddons",
+ "attica",
+ "kdnssd",
+ "kplotting"
+ ]
+ },
+ {
+ "n": 4,
+ "recetas": [
+ "kcoreaddons",
+ "kconfig",
+ "ki18n",
+ "kwidgetsaddons",
+ "kguiaddons",
+ "kwindowsystem",
+ "solid",
+ "sonnet",
+ "kirigami",
+ "kitemmodels",
+ "qtwayland",
+ "qt5compat",
+ "qtmultimedia",
+ "kholidays",
+ "qtpositioning",
+ "syntax-highlighting",
+ "prison"
+ ]
+ },
+ {
+ "n": 5,
+ "recetas": [
+ "kcolorscheme",
+ "kcompletion",
+ "kcrash",
+ "kservice",
+ "kbookmarks",
+ "knotifications",
+ "kpackage",
+ "kauth",
+ "plasma-activities",
+ "kstatusnotifieritem",
+ "kwayland",
+ "kidletime",
+ "krunner",
+ "layer-shell-qt",
+ "kdecoration",
+ "qtspeech",
+ "knighttime",
+ "qtlocation"
+ ]
+ },
+ {
+ "n": 6,
+ "recetas": [
+ "kconfigwidgets",
+ "kiconthemes",
+ "kjobwidgets",
+ "kdeclarative",
+ "kwallet",
+ "ktextwidgets",
+ "knewstuff",
+ "kfilemetadata",
+ "libkscreen",
+ "plasma-activities-stats",
+ "kded"
+ ]
+ },
+ {
+ "n": 7,
+ "recetas": [
+ "kxmlgui",
+ "ksvg"
+ ]
+ },
+ {
+ "n": 8,
+ "recetas": [
+ "kio"
+ ]
+ },
+ {
+ "n": 9,
+ "recetas": [
+ "kparts",
+ "kcmutils",
+ "libplasma",
+ "kglobalacceld",
+ "libksysguard"
+ ]
+ },
+ {
+ "n": 10,
+ "recetas": [
+ "frameworkintegration",
+ "ktexteditor",
+ "kscreenlocker",
+ "kwin"
+ ]
+ },
+ {
+ "n": 11,
+ "recetas": [
+ "plasma-workspace",
+ "qqc2-desktop-style",
+ "breeze"
+ ]
+ },
+ {
+ "n": 12,
+ "recetas": [
+ "plasma-integration"
+ ]
+ }
+ ]
+ },
+ "escritorio-mirada": {
+ "grafo": "docs/state/build-state.json",
+ "nodos": 31,
+ "en_deuda": 4,
+ "ondas": 2,
+ "profundidad": 2,
+ "construibles_ya": [
+ "libinput",
+ "mesa-swrast",
+ "mirada-greeter"
+ ],
+ "olas": [
+ {
+ "n": 1,
+ "recetas": [
+ "libinput",
+ "mesa-swrast",
+ "mirada-greeter"
+ ]
+ },
+ {
+ "n": 2,
+ "recetas": [
+ "mirada-compositor"
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/scripts/drenar.py b/scripts/drenar.py
new file mode 100755
index 00000000..a1897074
--- /dev/null
+++ b/scripts/drenar.py
@@ -0,0 +1,198 @@
+#!/usr/bin/env python3
+# drenar.py — el ORDEN DE MASTICADO: qué construir, en qué orden, para acercar una imagen a estar
+# completa. (P4 del plan docs/plan-catalogo-objetivo.md.)
+#
+# POR QUÉ. La cola de la granja era AUTORÍA: 55 ficheros en `tandas/` escritos a mano, y para la
+# deuda una lista que el hub medía con `saldar-deuda-static.sh` y pegaba en `campana-deuda.sh`. Pero
+# el grafo de estado ya sabe qué falta (`state`), qué lo traba (`blocked_by`) y a cuántos destraba
+# (`unblocks`). Este script convierte eso en la cola: DERIVADA, no escrita.
+#
+# ONDAS. No lista "lo que falta" en un montón: lo parte en ondas topológicas. Onda 1 = construible
+# YA (ninguna dep en deuda). Onda 2 = lo que la onda 1 destraba. Etc. 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.
+# Dentro de cada onda ordena por `unblocks` desc: primero lo que libera más.
+#
+# LA REGLA DE LA FUENTE ÚNICA. Un nodo `wanted` (sin receta) no tiene deps propias; se le miran las
+# SEMBRADAS (`seed-edges.json`, P3). En cuanto tiene receta, manda la receta — ya corregida por el
+# veredicto de harkaq. La semilla es andamio y se cae sola.
+#
+# NO LANZA NADA. Sólo imprime/escribe la cola; construir cuesta € y es decisión explícita
+# (`farm-up` + `campana-deuda.sh`). Ver el pie de la salida.
+#
+# Uso: scripts/drenar.py --perfil escritorio-kde # ondas de una imagen
+# scripts/drenar.py # todo el corpus en deuda
+# scripts/drenar.py --perfil cli --n 20 # sólo las 20 primeras construibles ya
+# scripts/drenar.py --perfil escritorio-kde --tanda tandas/kde-drenaje.txt
+import json, sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+GRAFOS = ["docs/state/build-state.json", "docs/state/build-state-kde.json"]
+SEMILLA = ROOT / "docs/state/seed-edges.json"
+EN_DEUDA = ("debt", "never", "unhashable", "wanted")
+
+
+def cargar(perfil):
+ """Elige el grafo por la COLA que el perfil declara en `targets.toml`, no por cuál tiene más
+ nodos. La diferencia importa: la cola `incoming-kde` SOMBREA recetas canónicas, así que medir
+ `escritorio-mirada` contra el grafo KDE mide una imagen que nadie construye. Cada perfil se mide
+ contra el árbol de recetas del que realmente sale."""
+ sys.path.insert(0, str(ROOT / "scripts"))
+ import targets as targets_mod
+ cola = "corpus"
+ if perfil is not None:
+ try:
+ cola = targets_mod.load().get(perfil, {}).get("cola", "corpus")
+ except Exception:
+ pass
+ for f in GRAFOS:
+ p = ROOT / f
+ if not p.exists():
+ continue
+ d = json.loads(p.read_text())
+ if cola not in (d.get("by_queue") or {}):
+ continue
+ if perfil is None or any(perfil in (r.get("perfiles") or []) for r in d["nodes"].values()):
+ return d, f
+ return None, None
+
+
+def deps_de(nombre, nodo, semilla):
+ """La regla de la fuente única: con receta manda la receta; sin receta (nodo `wanted`) se usan
+ las aristas SEMBRADAS, que son hipótesis y se marcan como tales."""
+ if nodo["state"] != "wanted":
+ return nodo.get("deps") or [], "receta"
+ aristas = (semilla.get("nodos") or {}).get(nombre)
+ if isinstance(aristas, list):
+ return [a["dep"] for a in aristas if a.get("clase") in ("conocida", "frontera")], "semilla"
+ return [], "sin-datos"
+
+
+def ondas_de(perfil):
+ """(ondas, deuda, ambito, grafo) — el cálculo puro, sin imprimir. Reutilizado por --todos."""
+ doc, cual = cargar(perfil)
+ if doc is None:
+ return None, None, None, None
+ semilla = json.loads(SEMILLA.read_text()) if SEMILLA.exists() else {}
+ nodos = doc["nodes"]
+ ambito = {n: r for n, r in nodos.items()
+ if perfil is None or perfil in (r.get("perfiles") or [])}
+ deuda = {n: r for n, r in ambito.items() if r["state"] in EN_DEUDA}
+ restante, ondas = dict(deuda), []
+ while restante:
+ onda = [n for n, r in restante.items()
+ if not any(d in restante for d in deps_de(n, r, semilla)[0])]
+ if not onda:
+ ondas.append(sorted(restante)); break
+ onda.sort(key=lambda n: (-restante[n].get("unblocks", 0), restante[n].get("cls", ""), n))
+ ondas.append(onda)
+ for n in onda:
+ del restante[n]
+ return ondas, deuda, ambito, cual
+
+
+def todos_los_perfiles():
+ """Artefacto versionado con el drenaje de CADA imagen. El `git diff` entre dos corridas es el
+ avance real: qué salió de la deuda y qué onda se vació. Lo regenera el latido de la granja."""
+ sys.path.insert(0, str(ROOT / "scripts"))
+ import targets as targets_mod
+ doc = {"schema": "hammer-drenaje/1",
+ "nota": "DERIVADO del grafo de estado por scripts/drenar.py — no editar a mano.",
+ "perfiles": {}}
+ for p in sorted(targets_mod.load()):
+ ondas, deuda, ambito, cual = ondas_de(p)
+ if ondas is None:
+ continue
+ doc["perfiles"][p] = dict(
+ grafo=cual, nodos=len(ambito), en_deuda=len(deuda), ondas=len(ondas),
+ profundidad=len(ondas), construibles_ya=ondas[0] if ondas else [],
+ olas=[{"n": i + 1, "recetas": o} for i, o in enumerate(ondas)])
+ print(f" {p:20} {len(deuda):3} en deuda · {len(ondas):2} ondas · "
+ f"onda 1 = {len(ondas[0]) if ondas else 0}")
+ salida = ROOT / "docs/state/drenaje.json"
+ salida.write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
+ print(f" → {salida.relative_to(ROOT)}")
+ return 0
+
+
+def main():
+ args = sys.argv[1:]
+ if "--todos" in args:
+ return todos_los_perfiles()
+ perfil = args[args.index("--perfil") + 1] if "--perfil" in args else None
+ tope = int(args[args.index("--n") + 1]) if "--n" in args else None
+ tanda = args[args.index("--tanda") + 1] if "--tanda" in args else None
+
+ doc, cual = cargar(perfil)
+ if doc is None:
+ print(f"no encontré un grafo con el perfil `{perfil}` "
+ f"(¿corriste scripts/build-state.py [--kde]?)", file=sys.stderr)
+ return 1
+ semilla = json.loads(SEMILLA.read_text()) if SEMILLA.exists() else {}
+ nodos = doc["nodes"]
+
+ ambito = {n: r for n, r in nodos.items()
+ if perfil is None or perfil in (r.get("perfiles") or [])}
+ deuda = {n: r for n, r in ambito.items() if r["state"] in EN_DEUDA}
+ etiqueta = f"perfil `{perfil}`" if perfil else "corpus entero"
+ print(f"== drenaje de {etiqueta} ({cual})")
+ print(f" {len(ambito)} nodos en ámbito · {len(deuda)} en deuda "
+ f"({len(ambito)-len(deuda)} ya sellados)")
+ if not deuda:
+ print(" nada que drenar: la imagen está completa.")
+ return 0
+
+ # 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).
+ restante = dict(deuda)
+ ondas, procedencia = [], {}
+ while restante:
+ onda = []
+ for n, r in restante.items():
+ ds, origen = deps_de(n, r, semilla)
+ procedencia[n] = origen
+ if not any(d in restante for d in ds):
+ onda.append(n)
+ if not onda: # ciclo entre nodos en deuda: no debería pasar (topo_ok), pero no se oculta
+ print(f"\n ⚠ CICLO entre {len(restante)} nodos en deuda: "
+ f"{' '.join(sorted(restante)[:10])}")
+ ondas.append(sorted(restante))
+ break
+ onda.sort(key=lambda n: (-restante[n].get("unblocks", 0), restante[n].get("cls", ""), n))
+ ondas.append(onda)
+ for n in onda:
+ del restante[n]
+
+ print(f"\n== {len(ondas)} ondas (profundidad real de la cadena; el reloj de pared manda acá)")
+ for i, onda in enumerate(ondas, 1):
+ muestra = onda if tope is None or i > 1 else onda[:tope]
+ cab = f" onda {i}: {len(onda):3} construibles"
+ if i == 1:
+ cab += " ← ARRANCÁ POR ACÁ (nada las bloquea)"
+ print(cab)
+ for n in muestra[:25]:
+ r = deuda[n]
+ u = r.get("unblocks", 0)
+ marca = f" [{procedencia[n]}]" if procedencia[n] != "receta" else ""
+ print(f" {n:28} {r['state']:10} destraba {u:3} {r.get('cls','')}{marca}")
+ if len(muestra) > 25:
+ print(f" … y {len(muestra)-25} más")
+
+ primera = ondas[0][:tope] if tope else ondas[0]
+ print(f"\n== cola para el worker ({len(primera)} recetas de la onda 1)")
+ print(" " + " ".join(primera))
+ if tanda:
+ p = ROOT / tanda
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(f"# DERIVADA de {cual} por scripts/drenar.py — no editar a mano.\n"
+ f"# {etiqueta}: onda 1 de {len(ondas)}, ordenada por `unblocks` desc.\n"
+ + "\n".join(primera) + "\n")
+ print(f" → {tanda}")
+ print("\n construir NO es gratis (€): esto no lanza nada. En el worker:")
+ print(f" scripts/farm/campana-deuda.sh {' '.join(primera[:6])}…")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/farm/cosecha-cron.sh b/scripts/farm/cosecha-cron.sh
index dd7a0a0a..0c987362 100755
--- a/scripts/farm/cosecha-cron.sh
+++ b/scripts/farm/cosecha-cron.sh
@@ -82,6 +82,10 @@ echo "==> regenerando grafo de estado"
if [ -x "$HAMMER" ]; then
scripts/build-state.py >/dev/null 2>&1 && echo " build-state.json ✓" || echo " ⚠ build-state.py falló"
scripts/build-state.py --kde >/dev/null 2>&1 && echo " build-state-kde.json ✓" || echo " ⚠ build-state.py --kde falló"
+ # Cola DERIVADA del grafo (P4): el orden de masticado por imagen, en ondas topológicas. No lanza
+ # ningún build — sólo deja escrito qué conviene moler primero, para que la cola deje de ser una
+ # lista escrita a mano. Barato (lee JSON, no hashea).
+ scripts/drenar.py --todos >/dev/null 2>&1 && echo " drenaje.json ✓" || echo " ⚠ drenar.py falló"
else
echo " ⚠ sin binario hammer en $HAMMER — no regenero estado"
fi
@@ -89,7 +93,7 @@ fi
# 4. Commitear SÓLO el estado firme (el avance que el humano sigue). Recetas nuevas que YO autoré se
# commitean aparte, a mano, para no meter autoría a medias en un commit de cron.
if [ "${NO_COMMIT:-}" != "1" ]; then
- git add docs/state/build-state.json docs/state/build-state-kde.json 2>/dev/null || true
+ git add docs/state/build-state.json docs/state/build-state-kde.json docs/state/drenaje.json 2>/dev/null || true
if ! git diff --cached --quiet 2>/dev/null; then
git commit -q -m "estado: cosecha granja $(ts) — avance del árbol KDE" 2>/dev/null \
&& { git push -q origin main 2>/dev/null && echo "==> estado commiteado+pusheado" \
diff --git a/scripts/seed-graph.py b/scripts/seed-graph.py
index c1315601..f2d1c931 100755
--- a/scripts/seed-graph.py
+++ b/scripts/seed-graph.py
@@ -318,16 +318,25 @@ def frontera(recs, perfil):
Cada candidato es una HIPÓTESIS a clasificar por un humano: puede ser (a) una dep opcional que
hammer no habilitó a propósito, (b) un nix-ismo que falta en la lista de descarte, o (c) un
hueco real. El sembrador no decide cuál: los ordena por cuántas recetas los piden."""
- # Los perfiles de una cola aparte (escritorio-kde) sólo existen en el grafo `--kde`.
+ # 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 un perfil del corpus contra el grafo
+ # KDE mide una imagen que nadie construye.
+ try:
+ cola = targets_mod.load().get(perfil, {}).get("cola", "corpus")
+ except Exception:
+ cola = "corpus"
en_perfil, grafo_usado = [], None
for f in ("docs/state/build-state.json", "docs/state/build-state-kde.json"):
p = ROOT / f
if not p.exists():
continue
d = json.loads(p.read_text())
+ if cola not in (d.get("by_queue") or {}):
+ continue
hit = sorted(n for n, r in d["nodes"].items() if perfil in (r.get("perfiles") or []))
- if len(hit) > len(en_perfil):
+ if hit:
en_perfil, grafo_usado = hit, f
+ break
if not en_perfil:
print(f"perfil `{perfil}` sin nodos en ningún grafo de estado "
f"(¿regeneraste con scripts/build-state.py [--kde]?)", file=sys.stderr)