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…
⎇ a1bcfa9b1e7267a7ca8de79544875106c5c1d8f1
scripts/drenar.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
drenaje: medía 5 de 7 imágenes y las otras 2 desaparecían en silencio
1#!/usr/bin/env python3
2# drenar.py — el ORDEN DE MASTICADO: qué construir, en qué orden, para acercar una imagen a estar
3# completa. (P4 del plan docs/plan-catalogo-objetivo.md.)
4#
5# POR QUÉ. La cola de la granja era AUTORÍA: 55 ficheros en `tandas/` escritos a mano, y para la
6# deuda una lista que el hub medía con `saldar-deuda-static.sh` y pegaba en `campana-deuda.sh`. Pero
7# el grafo de estado ya sabe qué falta (`state`), qué lo traba (`blocked_by`) y a cuántos destraba
8# (`unblocks`). Este script convierte eso en la cola: DERIVADA, no escrita.
9#
10# ONDAS. No lista "lo que falta" en un montón: lo parte en ondas topológicas. Onda 1 = construible
11# YA (ninguna dep en deuda). Onda 2 = lo que la onda 1 destraba. Etc. Eso da tres cosas que una
12# lista plana no da: el orden correcto, la PROFUNDIDAD real de la cadena (cuántos pasos secuenciales
13# faltan, que es lo que manda en el reloj de pared), y qué se puede paralelizar sin pisarse.
14# Dentro de cada onda ordena por `unblocks` desc: primero lo que libera más.
15#
16# LA REGLA DE LA FUENTE ÚNICA. Un nodo `wanted` (sin receta) no tiene deps propias; se le miran las
17# SEMBRADAS (`seed-edges.json`, P3). En cuanto tiene receta, manda la receta — ya corregida por el
18# veredicto de harkaq. La semilla es andamio y se cae sola.
19#
20# NO LANZA NADA. Sólo imprime/escribe la cola; construir cuesta € y es decisión explícita
21# (`farm-up` + `campana-deuda.sh`). Ver el pie de la salida.
22#
23# Uso: scripts/drenar.py --perfil escritorio-kde # ondas de una imagen
24# scripts/drenar.py # todo el corpus en deuda
25# scripts/drenar.py --perfil cli --n 20 # sólo las 20 primeras construibles ya
26# scripts/drenar.py --perfil escritorio-kde --tanda tandas/kde-drenaje.txt
27import json, sys
28from pathlib import Path
29
30ROOT = Path(__file__).resolve().parent.parent
31# UNA LÍNEA POR GRAFO, Y SE OLVIDAN (2026-09-04). Esta lista nació con corpus+kde+gnome y se quedó
32# ahí mientras se abrían dos escritorios más: `escritorio-cosmic` (cola `incoming-cosmic`) y
33# `escritorio-sway` (cola `incoming-wlr`) declaraban una cola que ningún grafo de la lista contenía,
34# así que `cargar()` devolvía None y `--todos` los saltaba EN SILENCIO — 2 de las 7 imágenes fuera
35# del drenaje sin que nada lo dijera. Es el mismo olvido que ya costó 17 días de
36# build-state-wlr.json congelado (ver cosecha-cron.sh). Al abrir una cola nueva, se agrega ACÁ.
37GRAFOS = ["docs/state/build-state.json", "docs/state/build-state-kde.json",
38 "docs/state/build-state-gnome.json", "docs/state/build-state-cosmic.json",
39 "docs/state/build-state-wlr.json"]
40SEMILLA = ROOT / "docs/state/seed-edges.json"
41EN_DEUDA = ("debt", "never", "unhashable", "wanted")
42
43
44def cargar(perfil):
45 """Elige el grafo por la COLA que el perfil declara en `targets.toml`, no por cuál tiene más
46 nodos. La diferencia importa: la cola `incoming-kde` SOMBREA recetas canónicas, así que medir
47 `escritorio-mirada` contra el grafo KDE mide una imagen que nadie construye. Cada perfil se mide
48 contra el árbol de recetas del que realmente sale."""
49 sys.path.insert(0, str(ROOT / "scripts"))
50 import targets as targets_mod
51 cola = "corpus"
52 if perfil is not None:
53 try:
54 cola = targets_mod.load().get(perfil, {}).get("cola", "corpus")
55 except Exception:
56 pass
57 for f in GRAFOS:
58 p = ROOT / f
59 if not p.exists():
60 continue
61 d = json.loads(p.read_text())
62 if cola not in (d.get("by_queue") or {}):
63 continue
64 # Una cola NO-corpus identifica su grafo unívocamente (incoming-kde/gnome sólo viven en su
65 # propio grafo), así que basta con que la cola coincida. La comprobación por ETIQUETA de perfil
66 # sólo desambigua dentro de `corpus` (donde varios perfiles se solapan). Sin este atajo, un
67 # perfil cuyas RAÍCES aún no están autoradas (0 nodos etiquetados, p.ej. escritorio-gnome
68 # recién arrancado) no hallaba su grafo y drenar erraba "¿corriste build-state.py?" en vez de
69 # reckonar honestamente un perfil todavía sin cierre.
70 if cola != "corpus":
71 return d, f
72 if perfil is None or any(perfil in (r.get("perfiles") or []) for r in d["nodes"].values()):
73 return d, f
74 return None, None
75
76
77def deps_de(nombre, nodo, semilla):
78 """La regla de la fuente única: con receta manda la receta; sin receta (nodo `wanted`) se usan
79 las aristas SEMBRADAS, que son hipótesis y se marcan como tales."""
80 if nodo["state"] != "wanted":
81 return nodo.get("deps") or [], "receta"
82 aristas = (semilla.get("nodos") or {}).get(nombre)
83 if isinstance(aristas, list):
84 return [a["dep"] for a in aristas if a.get("clase") in ("conocida", "frontera")], "semilla"
85 return [], "sin-datos"
86
87
88def ondas_de(perfil):
89 """(ondas, deuda, ambito, grafo) — el cálculo puro, sin imprimir. Reutilizado por --todos."""
90 doc, cual = cargar(perfil)
91 if doc is None:
92 return None, None, None, None
93 semilla = json.loads(SEMILLA.read_text()) if SEMILLA.exists() else {}
94 nodos = doc["nodes"]
95 ambito = {n: r for n, r in nodos.items()
96 if perfil is None or perfil in (r.get("perfiles") or [])}
97 deuda = {n: r for n, r in ambito.items() if r["state"] in EN_DEUDA}
98 restante, ondas = dict(deuda), []
99 while restante:
100 onda = [n for n, r in restante.items()
101 if not any(d in restante for d in deps_de(n, r, semilla)[0])]
102 if not onda:
103 ondas.append(sorted(restante)); break
104 onda.sort(key=lambda n: (-restante[n].get("unblocks", 0), restante[n].get("cls", ""), n))
105 ondas.append(onda)
106 for n in onda:
107 del restante[n]
108 return ondas, deuda, ambito, cual
109
110
111def todos_los_perfiles():
112 """Artefacto versionado con el drenaje de CADA imagen. El `git diff` entre dos corridas es el
113 avance real: qué salió de la deuda y qué onda se vació. Lo regenera el latido de la granja."""
114 sys.path.insert(0, str(ROOT / "scripts"))
115 import targets as targets_mod
116 doc = {"schema": "hammer-drenaje/1",
117 "nota": "DERIVADO del grafo de estado por scripts/drenar.py — no editar a mano.",
118 "perfiles": {}}
119 sin_medir = []
120 for p in sorted(targets_mod.load()):
121 ondas, deuda, ambito, cual = ondas_de(p)
122 # UN PERFIL QUE NO SE PUEDE MEDIR NO SE SALTA EN SILENCIO. Antes esto era un `continue` pelado
123 # y por eso dos imágenes enteras faltaban del artefacto sin dejar rastro: el JSON listaba 5
124 # perfiles, targets.toml declaraba 7, y los dos números no se cruzaban en ningún lado. Ahora
125 # queda escrito EN el artefacto (`sin_medir`) y gritado por stdout — regla 3 del repo: un
126 # ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.
127 if ondas is None:
128 sin_medir.append(p)
129 print(f" ⚠ {p:20} SIN MEDIR: ningún grafo de GRAFOS contiene su cola "
130 f"(¿falta build-state.py --<cola> o la línea en GRAFOS?)")
131 continue
132 doc["perfiles"][p] = dict(
133 grafo=cual, nodos=len(ambito), en_deuda=len(deuda), ondas=len(ondas),
134 profundidad=len(ondas), construibles_ya=ondas[0] if ondas else [],
135 olas=[{"n": i + 1, "recetas": o} for i, o in enumerate(ondas)])
136 print(f" {p:20} {len(deuda):3} en deuda · {len(ondas):2} ondas · "
137 f"onda 1 = {len(ondas[0]) if ondas else 0}")
138 doc["sin_medir"] = sin_medir
139 salida = ROOT / "docs/state/drenaje.json"
140 salida.write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
141 print(f" → {salida.relative_to(ROOT)}")
142 if sin_medir:
143 print(f" ⚠ {len(sin_medir)} de {len(targets_mod.load())} imágenes SIN MEDIR: "
144 f"{' '.join(sin_medir)}")
145 return 1
146 return 0
147
148
149def main():
150 args = sys.argv[1:]
151 if "--todos" in args:
152 return todos_los_perfiles()
153 perfil = args[args.index("--perfil") + 1] if "--perfil" in args else None
154 tope = int(args[args.index("--n") + 1]) if "--n" in args else None
155 tanda = args[args.index("--tanda") + 1] if "--tanda" in args else None
156
157 doc, cual = cargar(perfil)
158 if doc is None:
159 print(f"no encontré un grafo con el perfil `{perfil}` "
160 f"(¿corriste scripts/build-state.py [--kde]?)", file=sys.stderr)
161 return 1
162 semilla = json.loads(SEMILLA.read_text()) if SEMILLA.exists() else {}
163 nodos = doc["nodes"]
164
165 ambito = {n: r for n, r in nodos.items()
166 if perfil is None or perfil in (r.get("perfiles") or [])}
167 deuda = {n: r for n, r in ambito.items() if r["state"] in EN_DEUDA}
168 etiqueta = f"perfil `{perfil}`" if perfil else "corpus entero"
169 print(f"== drenaje de {etiqueta} ({cual})")
170 print(f" {len(ambito)} nodos en ámbito · {len(deuda)} en deuda "
171 f"({len(ambito)-len(deuda)} ya sellados)")
172 if perfil is not None and not ambito:
173 # Ámbito vacío = ninguna raíz del perfil está autorada ⇒ el perfil NO tiene cierre todavía
174 # (top-down sin arrancar). Distinto de "completa": completa tendría nodos sellados en ámbito.
175 # Puede haber recetas SUELTAS en la cola (leaves) que ninguna raíz alcanza aún.
176 print(f" perfil SIN CIERRE todavía: 0 raíces autoradas — autorá desde las raíces "
177 f"declaradas en targets.toml (top-down feature-minimal), no al revés.")
178 return 0
179 if not deuda:
180 print(" nada que drenar: la imagen está completa.")
181 return 0
182
183 # Ondas topológicas sobre el subgrafo EN DEUDA: una receta sólo espera por deps que también
184 # estén en deuda (las selladas ya están en el store y no bloquean).
185 restante = dict(deuda)
186 ondas, procedencia = [], {}
187 while restante:
188 onda = []
189 for n, r in restante.items():
190 ds, origen = deps_de(n, r, semilla)
191 procedencia[n] = origen
192 if not any(d in restante for d in ds):
193 onda.append(n)
194 if not onda: # ciclo entre nodos en deuda: no debería pasar (topo_ok), pero no se oculta
195 print(f"\n ⚠ CICLO entre {len(restante)} nodos en deuda: "
196 f"{' '.join(sorted(restante)[:10])}")
197 ondas.append(sorted(restante))
198 break
199 onda.sort(key=lambda n: (-restante[n].get("unblocks", 0), restante[n].get("cls", ""), n))
200 ondas.append(onda)
201 for n in onda:
202 del restante[n]
203
204 print(f"\n== {len(ondas)} ondas (profundidad real de la cadena; el reloj de pared manda acá)")
205 for i, onda in enumerate(ondas, 1):
206 muestra = onda if tope is None or i > 1 else onda[:tope]
207 cab = f" onda {i}: {len(onda):3} construibles"
208 if i == 1:
209 cab += " ← ARRANCÁ POR ACÁ (nada las bloquea)"
210 print(cab)
211 for n in muestra[:25]:
212 r = deuda[n]
213 u = r.get("unblocks", 0)
214 marca = f" [{procedencia[n]}]" if procedencia[n] != "receta" else ""
215 print(f" {n:28} {r['state']:10} destraba {u:3} {r.get('cls','')}{marca}")
216 if len(muestra) > 25:
217 print(f" … y {len(muestra)-25} más")
218
219 primera = ondas[0][:tope] if tope else ondas[0]
220 print(f"\n== cola para el worker ({len(primera)} recetas de la onda 1)")
221 print(" " + " ".join(primera))
222 if tanda:
223 p = ROOT / tanda
224 p.parent.mkdir(parents=True, exist_ok=True)
225 p.write_text(f"# DERIVADA de {cual} por scripts/drenar.py — no editar a mano.\n"
226 f"# {etiqueta}: onda 1 de {len(ondas)}, ordenada por `unblocks` desc.\n"
227 + "\n".join(primera) + "\n")
228 print(f" → {tanda}")
229 print("\n construir NO es gratis (€): esto no lanza nada. En el worker:")
230 print(f" scripts/farm/campana-deuda.sh {' '.join(primera[:6])}…")
231 return 0
232
233
234if __name__ == "__main__":
235 sys.exit(main())