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>
This commit is contained in:
2026-07-22 18:14:37 -04:00
co-authored by Claude Opus 4.8
parent 417dfd52c1
commit b08e582c89
5 changed files with 455 additions and 3 deletions
+40
View File
@@ -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
+201
View File
@@ -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"
]
}
]
}
}
}
+198
View File
@@ -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())
+5 -1
View File
@@ -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" \
+11 -2
View File
@@ -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)