#!/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 # Una cola NO-corpus identifica su grafo unívocamente (incoming-kde/gnome sólo viven en su # propio grafo), así que basta con que la cola coincida. La comprobación por ETIQUETA de perfil # sólo desambigua dentro de `corpus` (donde varios perfiles se solapan). Sin este atajo, un # perfil cuyas RAÍCES aún no están autoradas (0 nodos etiquetados, p.ej. escritorio-gnome # recién arrancado) no hallaba su grafo y drenar erraba "¿corriste build-state.py?" en vez de # reckonar honestamente un perfil todavía sin cierre. if cola != "corpus": return d, f 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 perfil is not None and not ambito: # Ámbito vacío = ninguna raíz del perfil está autorada ⇒ el perfil NO tiene cierre todavía # (top-down sin arrancar). Distinto de "completa": completa tendría nodos sellados en ámbito. # Puede haber recetas SUELTAS en la cola (leaves) que ninguna raíz alcanza aún. print(f" perfil SIN CIERRE todavía: 0 raíces autoradas — autorá desde las raíces " f"declaradas en targets.toml (top-down feature-minimal), no al revés.") return 0 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())