build-state.py gana --gnome (análogo a --kde): carga recipes/incoming-gnome/ y sale a build-state-gnome.json. drenar/yupana/seed-graph aprenden ese grafo (keystones, drenar --perfil escritorio-gnome, radio de nodos incoming-gnome, frontera). Hoy la cola está vacía ⇒ el grafo trae 0 nodos gnome; se poblará al aterrizar recetas y ahí drenar/keystones reckonan solos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
9.1 KiB
Python
Executable File
200 lines
9.1 KiB
Python
Executable File
#!/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",
|
|
"docs/state/build-state-gnome.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())
|