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…
⎇ d33fc94fc59e581842d785cb3fff35b73f4b0abe
scripts/yupana.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
gnome: enciende la CAPA GRAFO del yupana-gnome
1#!/usr/bin/env python3
2# yupana.py — la YUPANA del build: el ábaco que RECKONA sobre el estado de la distro.
3#
4# DOS OBJETOS ANDINOS, DOS ROLES. En los Andes el khipu GUARDABA (cuerdas anudadas que registran
5# cantidades y relaciones) y la yupana CALCULABA sobre ese registro (el ábaco inca). Acá es igual y
6# la distinción es la arquitectura, no adorno:
7# · `docs/state/build-state.json` es el KHIPU — el registro firme y versionado: los nudos
8# (recetas) y las cuerdas (deps), la única fuente de verdad de QUÉ hay y QUÉ falta.
9# · este módulo es la YUPANA — el motor que reckona sobre el khipu: a QUIÉN rompo si toco algo
10# (radio), qué imágenes dependen de un nudo (perfiles), en qué orden masticar (ondas).
11# Es la BASE metodológica y la PUERTA ÚNICA; el resto de los scripts de estado son sus órganos.
12# Antes este saber vivía disperso (en la cabeza, en `grep`, en docs que envejecen) y cada medición
13# a mano mentía distinto.
14#
15# POR QUÉ EXISTE ESTE FICHERO Y NO SÓLO build-state.py. El 2026-07-22 un cambio a `libdrm`
16# (default_library=both, para gtk4) invalidó 118 recetas KDE. El radio se midió contra
17# `recipes/*.toml` y NO contra `recipes/incoming-kde/` ⇒ dio 8 en vez de 118. `build-state.py` sin
18# `--kde` cometía EXACTAMENTE el mismo error: cargaba sólo el corpus, y su campo `perfiles` llegaba
19# a AFIRMAR que tocar `libdrm` sólo afectaba a `escritorio-mirada` — una mentira con confianza. La
20# causa es estructural: las dependencias INVERSAS son un hecho del disco entero, no de la vista que
21# uno cargó. Este módulo las calcula SIEMPRE sobre TODAS las colas, y es de donde build-state saca
22# `perfiles`/`dependientes`. La regla, en una línea:
23#
24# QUÉ NODOS PUNTÚO es una decisión de vista. QUIÉN ME CONSUME es un hecho — nunca depende de la vista.
25#
26# RESOLUCIÓN SIBLING-FIRST. Una dep `libdrm` desde una receta de `incoming-kde` resuelve a
27# `incoming-kde/libdrm` si existe, y si no cae al `corpus/libdrm` (igual que el sandbox al montar las
28# capas). Por eso los nodos se identifican por PAR `(cola, nombre)`, no por nombre: `corpus/fontconfig`
29# y `incoming-kde/fontconfig` son nudos distintos y romper uno no rompe a los consumidores del otro.
30#
31# yupana es la PUERTA ÚNICA de la metodología. Sus verbos son los órganos del ciclo, en el orden en
32# que se usan (el mismo que la cadena del plan docs/plan-catalogo-objetivo.md):
33#
34# radio <receta> ¿a QUIÉN rompo si toco esto? (nativo; cruza TODAS las colas) ← ANTES de cambiar nada
35# perfiles <receta> ¿qué imágenes dependen de esto? (nativo)
36# duplicados invariante (c): nudos que son el mismo paquete bajo dos nombres (por sha) (nativo)
37# keystones nudos en deuda que gatean el máximo trabajo bloqueado (cierre inverso, AND) (nativo)
38# preflight <r> modelo EXTRA-GRAFO: ¿choca con un límite del entorno que el grafo no ve? (nativo)
39# estado regenera el grafo firme → build-state.py
40# objetivo qué debe tener cada imagen → targets.py --lista
41# frontera <perfil> qué pide upstream y no tenemos → seed-graph.py --frontera
42# triaje clasificar la frontera → triaje.py
43# drenar [--perfil] el orden de masticado, en ondas → drenar.py
44#
45# Uso: scripts/yupana.py radio <receta> [--json]
46# scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | objetivo
47# Como módulo: from yupana import cargar_colas, membresia, dependientes, resolver
48import glob, json, os, re, subprocess, sys, tomllib
49from pathlib import Path
50
51ROOT = Path(__file__).resolve().parent.parent
52sys.path.insert(0, str(ROOT / "scripts"))
53import targets as targets_mod
54
55
56def cargar_colas():
57 """{cola: {nombre: [deps de build]}} sobre TODAS las colas del disco. `corpus` = recipes/;
58 `incoming-<x>` = recipes/incoming-<x>/. Es el grafo completo, sin recortar por vista."""
59 colas = {}
60 for f in sorted(glob.glob(str(ROOT / "recipes/*.toml"))):
61 n = os.path.basename(f)[:-5]
62 colas.setdefault("corpus", {})[n] = _deps(f)
63 for f in sorted(glob.glob(str(ROOT / "recipes/incoming-*/*.toml"))):
64 q = os.path.basename(os.path.dirname(f))
65 colas.setdefault(q, {})[os.path.basename(f)[:-5]] = _deps(f)
66 return colas
67
68
69def _deps(f):
70 try:
71 return tomllib.load(open(f, "rb")).get("deps", {}).get("build", []) or []
72 except Exception:
73 return []
74
75
76# --- INVARIANTE (c): CANÓNICO — sin duplicados accidentales entre orígenes ------------------------
77# Sufijos que en hammer marcan una VARIANTE deliberada del mismo paquete (misma fuente, otra decisión
78# de build: estático/shared, kernel por perfil, mesa por backend). Compartir sha con estos NO es un
79# duplicado a fundir — es la diversificación coherente [[etapa-g-gui-chain-boundary]]. `-hello`/`-hola`
80# son demos que vendorean la fuente de otro a propósito.
81SUFIJOS_VARIANTE = ("-shared", "-static", "-libs", "-tiny", "-dev", "-headers", "-minimal",
82 "-generic", "-metal-dual", "-metal", "-swrast", "-llvmpipe", "-hello", "-hola")
83
84
85def _base(n):
86 """Reduce un nombre a su base de familia quitando sufijos de variante, repetido: `zlib-shared`→
87 `zlib`, `mesa-llvmpipe`→`mesa`, `linux-generic`→`linux`. NO toca el `lib` inicial a propósito
88 (`libpng`→`png` colapsaría paquetes sin relación). Para decidir si dos nudos que comparten fuente
89 son la MISMA familia (variante ok) o paquetes SIN relación (colisión real)."""
90 prev = None
91 while prev != n:
92 prev = n
93 for s in SUFIJOS_VARIANTE:
94 if n.endswith(s) and len(n) > len(s):
95 n = n[: -len(s)]
96 return n
97
98
99# --- KEYSTONES: los nudos que gatean el trabajo -------------------------------------------------
100# ADVERTENCIA MATEMÁTICA (medida, no supuesta). El árbol de dominadores INGENUO NO sirve acá: asume
101# alcanzabilidad OR (basta un camino), pero construir es AND (hacen falta TODAS las deps). `kcoreaddons`
102# depende de `dbus`/`libdrm`/`mesa` además de `qtbase` ⇒ en el grafo de desbloqueo hay un camino que
103# evita qtbase, y el dominador-OR lo pierde. La métrica correcta del "cuánto gateo" bajo AND es el
104# CIERRE INVERSO TRANSITIVO. Pero el crudo lo gana el toolchain (make=313, inútil): el keystone
105# ACCIONABLE es el cierre inverso restringido a la DEUDA — cuánto trabajo BLOQUEADO libero. El
106# dominador SÍ tiene un uso legítimo, otro: la RETENCIÓN (lo exclusivamente mío), abajo.
107
108# --- MODELO EXTRA-GRAFO: patrones de hueco de ENTORNO -------------------------------------------
109# El grafo modela deps (R1: Receta×Receta). El entorno es OTRA capa: R2 (Receta×Capacidad, qué exige
110# un build) y R3 (Host×Capacidad, qué ofrece la máquina). Construible = clausura(R1) ∧ R2⊆R3. Estos
111# huecos viven FUERA del grafo (infra del sandbox, toolchain, layout de fs) y una matemática de grafos
112# sola no los ve — pero SÍ se aprenden, como patrones con predictor, calibrados contra fallos REALES.
113# Cada patrón: (nombre, deps) → veredicto|None. `preflight` los corre ANTES de construir.
114#
115# HOY: 1 patrón (overlay-lowerdir), la cicatriz de kio (2026-07-22). Futuros: capacidad-de-host
116# (rootfs laptop≠worker, techo MSRV — acumular observaciones (receta,host,lo-que-faltó)); skew de
117# toolchain (zig-skew — fingerprint del toolchain por artefacto). harkaq ya MIDE R2 (evidencia
118# negativa: qué path del entorno tocó un build); esto lo vuelve consultable + pre-vuela.
119#
120# CALIBRADO, no adivinado: el estimador daba corto hasta usar el prefijo que bwrap VE (`/oldroot/...`,
121# la ruta runtime del worker, no la del hub). Con eso: kio estimado 5346B vs real 5315B (31B de error).
122# El primer predictor "obvio" (prefijo del hub) mentía con confianza — la lección de siempre.
123PAGINA_OVERLAY = 4096 # tope del string de opciones de un mount overlayfs = PAGE_SIZE
124# Prefijo que bwrap ve en runtime (bajo /oldroot, ruta del worker). Ajustable por host.
125_PREF_SANDBOX = os.environ.get("PREF_STORE_SANDBOX", "/oldroot/opt/hammer/store/")
126_OVERHEAD_OVERLAY = len("upperdir=/tmp-overlay-upper-0,workdir=/tmp-overlay-work-0,") \
127 + len("/oldroot/opt/hammer/.dev-fs/alpine") + len(",userxattr")
128
129
130def _lowerdir_estimado(deps):
131 """Bytes del string `lowerdir=` que bwrap arma apilando `deps` como capas overlay directas. Es la
132 cantidad de GRAFO (nº y nombre de deps) que cruza el umbral de ENTORNO (una página)."""
133 return _OVERHEAD_OVERLAY + sum(len(_PREF_SANDBOX) + 64 + 1 + len(n) + 1 for n in deps)
134
135
136def patron_overlay(nombre, deps):
137 """¿El apilado directo de las deps desborda el mount de overlay? Si sí, la receta SÓLO monta
138 gracias al merge de deps (cp -al a store/.dmerge, 1 sola capa). El pre-flight hace VISIBLE esa
139 dependencia de una mitigación —y su precondición— que antes era invisible hasta que fallaba."""
140 est = _lowerdir_estimado(deps)
141 if est <= PAGINA_OVERLAY:
142 return None # entra directo, sin riesgo
143 return dict(
144 patron="overlay-lowerdir",
145 detalle=f"{len(deps)} deps ⇒ lowerdir ~{est}B > {PAGINA_OVERLAY}B (una página)",
146 riesgo="depende-del-merge",
147 nota=("apilado directo DESBORDA ⇒ sólo monta vía el merge de deps (cp -al a store/.dmerge, "
148 "1 capa). Precondición: .dmerge mismo-fs que el store — OK por construcción (vive DENTRO "
149 "del store, fix 6a0dc40). Si el store cambiara de layout y el merge fallara, esta receta "
150 "NO montaría, silenciosamente."))
151
152
153PATRONES_ENTORNO = [patron_overlay]
154
155
156def preflight(objetivo):
157 """Corre los patrones de entorno sobre una receta o (con --perfil) sobre todas las de una imagen,
158 ANTES de construir. Responde la pregunta que kio contestó tarde y cara: ¿este build va a chocar
159 con un límite del entorno que el grafo no ve?"""
160 colas = cargar_colas()
161 idx = {q: set(m) for q, m in colas.items()}
162 if objetivo.startswith("perfil:"):
163 pn = objetivo.split(":", 1)[1]
164 p = targets_mod.load().get(pn)
165 if not p:
166 print(f"perfil desconocido: {pn}", file=sys.stderr); return 1
167 cola = p.get("cola", "corpus")
168 nombres = [(cola if n in idx.get(cola, ()) else "corpus", n)
169 for n in colas.get(cola, {})] if cola != "corpus" else \
170 [("corpus", n) for n in colas["corpus"]]
171 # sólo los del ámbito: todas las recetas de la cola del perfil
172 objetivos = [(q, n) for q in ({cola} | {"corpus"}) for n in colas.get(q, {})]
173 else:
174 ubic = [(q, objetivo) for q in colas if objetivo in colas[q]]
175 if not ubic:
176 print(f"no hay receta `{objetivo}` en ninguna cola", file=sys.stderr); return 1
177 objetivos = ubic
178
179 hits = []
180 for (q, n) in objetivos:
181 deps = colas[q][n]
182 for patron in PATRONES_ENTORNO:
183 v = patron(n, deps)
184 if v:
185 hits.append((f"{q}/{n}", v))
186 hits.sort(key=lambda h: -_lowerdir_estimado(colas[h[0].split("/")[0]][h[0].split("/", 1)[1]]))
187
188 if not objetivo.startswith("perfil:"):
189 q, n = objetivos[0]
190 print(f"== preflight de {q}/{n} ({len(colas[q][n])} deps)")
191 if not hits:
192 print(" ✓ sin riesgo de entorno conocido (entra en el mount overlay directo).")
193 for _, v in hits:
194 print(f" ⚠ {v['patron']}: {v['detalle']}")
195 print(f" {v['nota']}")
196 return 0
197 print(f"== preflight del perfil (patrones: {', '.join(p.__name__ for p in PATRONES_ENTORNO)})")
198 print(f" {len(hits)} recetas dependen del merge para montar (apilado directo desbordaría):")
199 for nombre, v in hits[:25]:
200 print(f" ⚠ {nombre:28} {v['detalle']}")
201 print(f"\n son construibles HOY (el merge compacta), pero su montaje CUELGA de que store/.dmerge")
202 print(f" sea mismo-fs que el store. Si un host rompe eso, fallan silenciosas — pre-vuela antes.")
203 return 0
204
205
206def _tiempos():
207 """nombre → mediana de segundos de build, desde `store/.times/*.json` (sidecar no determinista,
208 lo escribe `scripts/farm/build-timed.sh` en el worker y llega con el rsync de cosecha). Vacío
209 hasta que corran builds instrumentados; los consumidores degradan a peso=1."""
210 from statistics import median
211 muestras = {}
212 for f in glob.glob(str(ROOT / "store/.times/*.json")):
213 try:
214 d = json.loads(open(f).read())
215 except Exception:
216 continue
217 if d.get("name") and isinstance(d.get("seconds"), (int, float)):
218 muestras.setdefault(d["name"], []).append(d["seconds"])
219 return {n: median(v) for n, v in muestras.items()}
220
221
222def _camino_critico(nodos, deps_debt, peso):
223 """Longest weighted path en el subgrafo de DEUDA (DP sobre topológico). El peso de un nudo = sus
224 segundos de build (1 si no hay dato). La suma sobre la cadena más larga = ETA con paralelismo
225 infinito: hay que construir esa cadena en SERIE. Devuelve (segundos, secuencia)."""
226 memo, camino = {}, {}
227 def cp(n):
228 if n in memo:
229 return memo[n]
230 mejor, via = 0, None
231 for d in deps_debt.get(n, ()):
232 c = cp(d)
233 if c > mejor:
234 mejor, via = c, d
235 memo[n] = peso(n) + mejor
236 camino[n] = via
237 return memo[n]
238 for n in nodos:
239 cp(n)
240 if not memo:
241 return 0, []
242 fin = max(memo, key=memo.get)
243 seq = []
244 x = fin
245 while x is not None:
246 seq.append(x); x = camino.get(x)
247 return memo[fin], list(reversed(seq))
248
249
250def _cierre_inverso(objetivo, rev):
251 """Nodos que dependen de `objetivo`, transitivamente (semántica AND: si X me tiene en su clausura,
252 X no se construye sin mí)."""
253 vis, pila = set(), [objetivo]
254 while pila:
255 x = pila.pop()
256 for c in rev.get(x, ()):
257 if c not in vis:
258 vis.add(c); pila.append(c)
259 return vis
260
261
262def _dominador_retencion(colas, perfiles):
263 """RETENCIÓN estilo GC: para cada nudo, cuántos otros son EXCLUSIVAMENTE suyos — sólo alcanzables
264 (como dependencia, desde las raíces de las imágenes) a través de él. Este SÍ es el árbol de
265 dominadores, en su uso correcto: 'si suelto este nudo, cuánto más puedo soltar'. Grafo
266 consumidor→dep, raíz virtual sobre todas las raíces de perfil; retención = tamaño del subárbol de
267 dominadores. Iterativo (Cooper-Harvey-Kennedy), suficiente para ~1000 nudos."""
268 idx = {q: set(m) for q, m in colas.items()}
269 R = ("__raiz__", "")
270 succ = {R: set()}
271 for pn, p in perfiles.items():
272 for raiz in p["raices"]:
273 nodo = resolver(raiz, p.get("cola", "corpus"), idx)
274 if nodo:
275 succ[R].add(nodo)
276 # aristas consumidor→dep
277 for q, miembros in colas.items():
278 for n in miembros:
279 nodo = (q, n)
280 succ.setdefault(nodo, set())
281 for d in miembros[n]:
282 r = resolver(d, q, idx)
283 if r:
284 succ[nodo].add(r)
285 # sólo lo alcanzable desde R
286 orden, vis = [], set()
287 def dfs(u):
288 vis.add(u)
289 for v in succ.get(u, ()):
290 if v not in vis:
291 dfs(v)
292 orden.append(u)
293 sys.setrecursionlimit(10000)
294 dfs(R)
295 rpo = list(reversed(orden))
296 pos = {n: i for i, n in enumerate(rpo)}
297 preds = {}
298 for u in succ:
299 for v in succ[u]:
300 preds.setdefault(v, set()).add(u)
301 idom = {R: R}
302 def inter(a, b):
303 while a != b:
304 while pos[a] > pos[b]:
305 a = idom[a]
306 while pos[b] > pos[a]:
307 b = idom[b]
308 return a
309 cambio = True
310 while cambio:
311 cambio = False
312 for u in rpo:
313 if u == R:
314 continue
315 ps = [p for p in preds.get(u, ()) if p in idom]
316 if not ps:
317 continue
318 nuevo = ps[0]
319 for p in ps[1:]:
320 nuevo = inter(nuevo, p)
321 if idom.get(u) != nuevo:
322 idom[u] = nuevo; cambio = True
323 # tamaño de subárbol (retención) = cuántos nodos domina cada uno
324 hijos = {}
325 for n, d in idom.items():
326 if n != d:
327 hijos.setdefault(d, []).append(n)
328 ret = {}
329 def subtam(u):
330 t = 0
331 for h in hijos.get(u, ()):
332 t += 1 + subtam(h)
333 ret[u] = t
334 return t
335 subtam(R)
336 return ret
337
338
339def keystones(como_json=False):
340 """Los nudos que GATEAN más trabajo. Métrica accionable: cierre inverso transitivo restringido a
341 la DEUDA (cuánto bloqueado libero al sellar esto). Segunda columna: RETENCIÓN por dominadores (lo
342 exclusivamente mío). El grafo elegido es el que más deuda tiene (KDE si existe)."""
343 from collections import Counter
344 colas = cargar_colas()
345 rev = dependientes(colas)
346 perfiles = targets_mod.load()
347 # estados desde el khipu más completo
348 estados = {}
349 for f in ("docs/state/build-state-kde.json", "docs/state/build-state-gnome.json",
350 "docs/state/build-state.json"):
351 p = ROOT / f
352 if p.exists():
353 for n, r in json.loads(p.read_text())["nodes"].items():
354 estados.setdefault(n, r.get("state", "?"))
355 DEUDA = ("debt", "never", "unhashable", "wanted")
356 ret = _dominador_retencion(colas, perfiles)
357
358 filas = []
359 for nodo in list(rev):
360 cerr = _cierre_inverso(nodo, rev)
361 en_deuda = sum(1 for (q, n) in cerr if estados.get(n) in DEUDA)
362 q, n = nodo
363 yo_deuda = estados.get(n) in DEUDA
364 filas.append(dict(nudo=f"{q}/{n}", gateo_total=len(cerr), gateo_en_deuda=en_deuda,
365 exclusivo=ret.get(nodo, 0), en_deuda=yo_deuda))
366 # KEYSTONE ACCIONABLE: un nodo SELLADO ya está disponible ⇒ construirlo no libera nada (make gatea
367 # 65 en deuda pero está hecho). El que importa está EN DEUDA y gatea el máximo trabajo bloqueado.
368 # Orden: primero los en-deuda, por gateo-en-deuda desc. Los sellados quedan como contexto (su
369 # retención `exclusivo` sí es info válida, pero no son trabajo).
370 filas.sort(key=lambda r: (not r["en_deuda"], -r["gateo_en_deuda"], -r["gateo_total"]))
371 accionables = [r for r in filas if r["en_deuda"]]
372
373 # CAMINO CRÍTICO PESADO: subgrafo de deuda, aristas dep entre nodos en deuda, peso = segundos de
374 # build. La cadena más larga = ETA con paralelismo infinito. Sin datos de tiempo, peso=1 y el
375 # camino crítico = nº de pasos (lo que ya daban las ondas). Con datos, es la ETA real en segundos.
376 tiempos = _tiempos()
377 idx = {q: set(m) for q, m in colas.items()}
378 deuda_set = {(q, n) for q in colas for n in colas[q] if estados.get(n) in DEUDA}
379 deps_debt = {}
380 for (q, n) in deuda_set:
381 dd = set()
382 for d in colas.get(q, {}).get(n, []):
383 r = resolver(d, q, idx)
384 if r in deuda_set:
385 dd.add(r)
386 deps_debt[(q, n)] = dd
387 def peso(nodo):
388 return tiempos.get(nodo[1], 1 if not tiempos else _mediana_o_uno(tiempos))
389 seg, seq = _camino_critico(deuda_set, deps_debt, peso)
390 cubiertos = sum(1 for nodo in deuda_set if nodo[1] in tiempos)
391 critico = dict(con_datos=bool(tiempos), segundos=seg if tiempos else None,
392 pasos=len(seq), cobertura=f"{cubiertos}/{len(deuda_set)}",
393 secuencia=[f"{q}/{n}" for q, n in seq])
394
395 doc = dict(schema="hammer-keystones/3", nudos=filas[:80], en_deuda=len(accionables),
396 camino_critico=critico)
397 (ROOT / "docs/state/keystones.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
398
399 if como_json:
400 print(json.dumps(doc, indent=1, ensure_ascii=False)); return 0
401 print("== KEYSTONES — nudos EN DEUDA que gatean el máximo trabajo bloqueado (semántica AND)")
402 print(" 'gateo en deuda' = otro trabajo bloqueado que se libera al sellar ESTE. La métrica accionable:")
403 print(" sellá de arriba hacia abajo y la cascada cae lo antes posible.\n")
404 print(f" {'nudo (en deuda)':30} {'gateo-deuda':>11} {'gateo-total':>11} {'exclusivo':>9}")
405 if not accionables:
406 print(" (ningún nodo en deuda — todas las imágenes en ámbito están completas)")
407 for r in accionables[:20]:
408 print(f" {r['nudo']:30} {r['gateo_en_deuda']:11} {r['gateo_total']:11} {r['exclusivo']:9}")
409 print(f"\n {len(accionables)} nudos en deuda en total")
410 print(f"\n== CAMINO CRÍTICO (la cadena más larga; hay que construirla en SERIE aunque haya ∞ workers)")
411 if critico["con_datos"]:
412 m = critico["segundos"] // 60
413 print(f" ETA (paralelismo infinito): {m} min sobre {critico['pasos']} pasos "
414 f"· cobertura de tiempos {critico['cobertura']}")
415 else:
416 print(f" {critico['pasos']} pasos (SIN datos de tiempo aún ⇒ peso=1; instrumentá builds en")
417 print(f" el worker con campana-deuda.sh y el rsync de cosecha trae store/.times).")
418 if seq:
419 muestra = " → ".join(n.split("/")[-1] for n in critico["secuencia"][:8])
420 print(f" {muestra}" + (" → …" if len(seq) > 8 else ""))
421 print(f"\n → docs/state/keystones.json")
422 print(" nota: dominador-OR ingenuo NO sirve (build es AND); 'exclusivo' SÍ es dominador (retención).")
423 return 0
424
425
426def _mediana_o_uno(tiempos):
427 from statistics import median
428 return median(tiempos.values()) if tiempos else 1
429
430
431def _fuente(f):
432 """(sha, url, version, build_fp) de una receta. Los tres primeros = identidad de lo que BAJA. El
433 cuarto = HUELLA DE BUILD (link, compiler, fases) — porque dos recetas con la MISMA fuente pero
434 build distinto NO son redundantes: son variantes deliberadas (corpus/dbus estático vs
435 incoming-kde/dbus dinámico para Qt6DBus). Ignorar esto hacía que el detector marcara `dbus` como
436 sombra redundante y casi borra una receta load-bearing (2026-07-22)."""
437 try:
438 d = tomllib.load(open(f, "rb"))
439 except Exception:
440 return (None, "", None, None, False)
441 s = d.get("source", {})
442 b = d.get("build", {})
443 ph = b.get("phases", {}) or {}
444 build_fp = (b.get("link"), b.get("compiler"),
445 tuple(sorted((k, str(v)) for k, v in ph.items())))
446 # ¿construye la FUENTE descargada, o es un CARRIER (stub/demo que genera todo inline y sólo presta
447 # el tarball)? Señal: invoca un sistema de build. itstool/-hello no ⇒ carrier; su colisión de sha
448 # es esperada, no un error de copiado. (2026-07-22: sin esto el detector marcaba 4 falsos bugs.)
449 blob = "\n".join(str(ph.get(k, "")) for k in ("configure", "compile", "install"))
450 # Strip comentarios shell (`# …` a fin de línea) ANTES de buscar: si no, la prosa de un stub
451 # ("# parsea la invocación de meson") matchea y marca al carrier como builder (falso positivo
452 # que el guardián cazó, 2026-07-22).
453 sin_comentarios = re.sub(r"#.*", "", blob)
454 construye = any(re.search(rx, sin_comentarios) for rx in
455 (r"\bmake\b", r"\bmeson\b", r"\bcmake\b", r"\bninja\b", r"\bcargo\b",
456 r"\./configure", r"\bautogen", r"\bautoreconf"))
457 return (s.get("sha256") or s.get("output_hash"),
458 s.get("tarball") or s.get("url") or s.get("git") or "",
459 d.get("version"), build_fp, construye)
460
461
462def _recetas_con_fuente():
463 """{(cola, nombre): {sha, url, version, build_fp, construye_fuente, deps}} sobre todo el disco."""
464 out = {}
465 for pat, es_incoming in (("recipes/*.toml", False), ("recipes/incoming-*/*.toml", True)):
466 for f in sorted(glob.glob(str(ROOT / pat))):
467 n = os.path.basename(f)[:-5]
468 q = os.path.basename(os.path.dirname(f)) if es_incoming else "corpus"
469 sha, url, ver, build_fp, construye = _fuente(f)
470 out[(q, n)] = dict(sha=sha, url=url, version=ver, build_fp=build_fp,
471 construye_fuente=construye, deps=_deps(f))
472 return out
473
474
475def duplicados(como_json=False):
476 """INVARIANTE (c): detecta nudos que son el MISMO paquete bajo nombres/colas distintos, por
477 EVIDENCIA de fuente (sha/url), no por nombre. Clasifica cada grupo:
478 colisión-fuente — mismo sha, nombres de familias DISTINTAS ⇒ casi seguro un sha copiado por
479 error (itstool tenía el tarball de gettext-tiny). ACCIONABLE.
480 variante — mismo sha, misma familia (`zlib`/`zlib-shared`, `mesa-*`) ⇒ deliberado, OK.
481 sombra — mismo NOMBRE en >1 cola. Redundante si versión+sha+deps coinciden; si no,
482 es una variante justificada de la cola.
483 NO funde nada solo: reporta y deja el juicio al humano (como el triaje). El radio de cada nudo
484 ayuda a elegir el canónico."""
485 recs = _recetas_con_fuente()
486 from collections import defaultdict
487 rev = dependientes()
488
489 # 1) grupos por sha (identidad de fuente)
490 por_sha = defaultdict(list)
491 for k, r in recs.items():
492 if r["sha"]:
493 por_sha[r["sha"]].append(k)
494 colisiones, variantes, carriers = [], [], []
495 for sha, ks in por_sha.items():
496 nombres = {n for _, n in ks}
497 if len(nombres) < 2:
498 continue
499 grupo = dict(sha=sha[:16], nudos=sorted(f"{q}/{n}" for q, n in ks),
500 radio={f"{q}/{n}": len(rev.get((q, n), ())) for q, n in ks})
501 builders = [k for k in ks if recs[k]["construye_fuente"]]
502 familias = {_base(n) for _, n in ks}
503 # Clasificación por evidencia, de más benigno a más sospechoso:
504 if len(builders) <= 1:
505 # ≤1 receta construye la fuente ⇒ el resto son CARRIERS que le prestan el tarball. Benigno.
506 grupo["carriers"] = sorted(f"{q}/{n}" for (q, n) in ks
507 if not recs[(q, n)]["construye_fuente"])
508 carriers.append(grupo)
509 elif len(familias) == 1:
510 variantes.append(grupo) # misma familia (zlib/zlib-shared): variante deliberada
511 elif len({recs[k]["build_fp"] for k in builders}) > 1:
512 variantes.append(grupo) # familias distintas pero BUILD distinto: variante cross-nombre
513 else: # ≥2 builders, MISMO build, familias distintas ⇒ sospechoso
514 colisiones.append(grupo)
515
516 # 2) sombras: mismo nombre en >1 cola
517 por_nombre = defaultdict(list)
518 for (q, n) in recs:
519 por_nombre[n].append(q)
520 sombras = []
521 for n, qs in por_nombre.items():
522 if len(qs) < 2:
523 continue
524 # Redundante SÓLO si coinciden fuente Y BUILD: mismo tarball no basta, dos builds distintos
525 # del mismo source (estático vs dinámico) son variantes deliberadas, no redundancia.
526 firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"], recs[(q, n)]["build_fp"],
527 tuple(sorted(recs[(q, n)]["deps"]))) for q in qs}
528 sombras.append(dict(nombre=n, colas=sorted(qs),
529 redundante=len(firmas) == 1, # idénticas en fuente+build ⇒ una sobra
530 radio={f"{q}/{n}": len(rev.get((q, n), ())) for q in qs}))
531
532 doc = dict(schema="hammer-duplicados/2",
533 colisiones_fuente=sorted(colisiones, key=lambda g: -max(g["radio"].values() or [0])),
534 variantes=sorted(variantes, key=lambda g: g["nudos"]),
535 carriers=sorted(carriers, key=lambda g: g["nudos"]),
536 sombras=sorted(sombras, key=lambda s: (not s["redundante"], s["nombre"])))
537 (ROOT / "docs/state/duplicados.json").write_text(
538 json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
539
540 if como_json:
541 print(json.dumps(doc, indent=1, ensure_ascii=False))
542 return 0
543 red = [s for s in sombras if s["redundante"]]
544 print(f"== INVARIANTE (c) CANÓNICO — duplicados por evidencia de fuente\n")
545 print(f"⚠ COLISIONES DE FUENTE REALES: {len(colisiones)} (≥2 recetas construyen el MISMO source,")
546 print(f" mismo build, familias distintas — casi seguro un sha copiado por error)")
547 for g in doc["colisiones_fuente"]:
548 print(f" sha {g['sha']}… {g['nudos']}")
549 print(f" radio: " + " ".join(f"{k.split('/')[-1]}={v}" for k, v in g["radio"].items()))
550 if not colisiones:
551 print(" (ninguna — el invariante (c) se cumple)")
552 print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)} (mismo nombre+fuente+BUILD en 2 colas — una sobra)")
553 for s in red:
554 print(f" {s['nombre']:24} {s['colas']}")
555 if not red:
556 print(" (ninguna)")
557 print(f"\n✓ BENIGNOS (no son problema):")
558 print(f" {len(carriers)} grupos CARRIER (stub/demo que presta el tarball: itstool, *-hello)")
559 print(f" {len(variantes)} variantes deliberadas (misma fuente, build/familia distintos: zlib-shared, prison-scanner)")
560 print(f" {len(sombras)-len(red)} sombras justificadas (mismo nombre, build distinto: dbus estático/dinámico)")
561 print(f"\n → docs/state/duplicados.json")
562 return 0
563
564
565def resolver(dep, desde_cola, indice):
566 """Sibling-first: (cola, nombre) del nudo que provee `dep` visto desde `desde_cola`, o None si
567 ninguna cola lo tiene (dep externa / nodo `wanted`). `indice` = {cola: set(nombres)}."""
568 if dep in indice.get(desde_cola, ()):
569 return (desde_cola, dep)
570 if dep in indice.get("corpus", ()):
571 return ("corpus", dep)
572 return None
573
574
575def dependientes(colas=None):
576 """{(cola,nombre): set((cola,nombre) que lo consumen)} — el mapa de dependencias INVERSAS sobre
577 el grafo entero. Este es el cálculo que, hecho sobre una sola cola, produjo el bug de libdrm."""
578 colas = colas or cargar_colas()
579 indice = {q: set(m) for q, m in colas.items()}
580 rev = {}
581 for q, miembros in colas.items():
582 for nombre, deps in miembros.items():
583 for d in deps:
584 objetivo = resolver(d, q, indice)
585 if objetivo:
586 rev.setdefault(objetivo, set()).add((q, nombre))
587 return rev
588
589
590def membresia(colas=None, perfiles=None):
591 """{(cola,nombre): set(perfiles que lo alcanzan)} sobre el grafo entero, respetando sibling-first.
592 Un nudo pertenece a una imagen si la clausura de sus raíces —resolviendo cada cuerda desde la
593 cola del consumidor— llega hasta él. Esto es lo que arregla la mentira de `perfiles`."""
594 colas = colas or cargar_colas()
595 perfiles = perfiles if perfiles is not None else targets_mod.load()
596 indice = {q: set(m) for q, m in colas.items()}
597 memb = {}
598 for pn, p in perfiles.items():
599 cola_p = p.get("cola", "corpus")
600 vistos, pila = set(), []
601 for raiz in p["raices"]:
602 nodo = resolver(raiz, cola_p, indice)
603 pila.append(nodo if nodo else ("wanted", raiz)) # raíz sin receta = nodo wanted
604 while pila:
605 nodo = pila.pop()
606 if nodo in vistos:
607 continue
608 vistos.add(nodo)
609 memb.setdefault(nodo, set()).add(pn)
610 q, nombre = nodo
611 if q == "wanted":
612 continue
613 for d in colas.get(q, {}).get(nombre, []):
614 sig = resolver(d, q, indice)
615 pila.append(sig if sig else ("wanted", d))
616 return memb
617
618
619def _estado(nodo):
620 """Estado del nudo desde el grafo firme, si está regenerado. Best-effort: el radio funciona sin él."""
621 q, nombre = nodo
622 fichero = ("docs/state/build-state-gnome.json" if q == "incoming-gnome"
623 else "docs/state/build-state-kde.json" if q != "corpus"
624 else "docs/state/build-state.json")
625 try:
626 d = json.loads((ROOT / fichero).read_text())
627 return d["nodes"].get(nombre, {}).get("state", "?")
628 except Exception:
629 return "?"
630
631
632def radio(nombre, como_json=False):
633 """¿A QUIÉN rompo si re-hasheo `recipes/**/<nombre>.toml`? La pregunta que, mal medida, costó 118
634 recetas. Cruza TODAS las colas por construcción: es imposible que 'olvide' una cola."""
635 colas = cargar_colas()
636 indice = {q: set(m) for q, m in colas.items()}
637 # ¿En qué cola vive la receta que se va a tocar? Puede estar en varias (variante sombreada).
638 ubic = [(q, nombre) for q in colas if nombre in colas[q]]
639 if not ubic:
640 print(f"no hay receta `{nombre}` en ninguna cola", file=sys.stderr)
641 return 1
642 rev = dependientes(colas)
643 memb = membresia(colas, targets_mod.load())
644
645 resultado = {}
646 for nodo in ubic:
647 directos = sorted(rev.get(nodo, set()))
648 # Cierre inverso: quién depende, transitivamente (el radio real de un re-hash en cascada).
649 vistos, pila = set(), list(rev.get(nodo, set()))
650 while pila:
651 n = pila.pop()
652 if n in vistos:
653 continue
654 vistos.add(n)
655 pila.extend(rev.get(n, set()))
656 imgs = set()
657 for n in vistos | {nodo}:
658 imgs |= memb.get(n, set())
659 # Cuántos de los transitivos YA están sellados: ésos son los que HOY están al día y el
660 # re-hash MANDA A DEUDA (los que ya están en deuda igual habría que reconstruirlos). El
661 # radio verdadero de rebuild forzado por el cambio.
662 sellados = sum(1 for n in vistos if _estado(n) == "sealed")
663 resultado[f"{nodo[0]}/{nodo[1]}"] = dict(
664 directos=len(directos), transitivos=len(vistos),
665 imagenes=sorted(imgs), sellados_que_caen=sellados,
666 dependientes_por_cola=_por_cola(vistos))
667
668 if como_json:
669 print(json.dumps(resultado, indent=1, ensure_ascii=False))
670 return 0
671 for nodo, r in resultado.items():
672 print(f"== radio de tocar {nodo}")
673 print(f" dependientes directos {r['directos']}")
674 print(f" dependientes transitivos {r['transitivos']} (el radio real de un re-hash en cascada)")
675 print(f" por cola " +
676 " ".join(f"{q}={n}" for q, n in sorted(r["dependientes_por_cola"].items())))
677 print(f" IMÁGENES afectadas {', '.join(r['imagenes']) or '(ninguna declarada)'}")
678 print(f" sellados que CAEN a deuda {r['sellados_que_caen']} (hoy al día; el re-hash los fuerza a rebuild)")
679 if len(resultado) > 1:
680 print()
681 print("\n regla: esto cruza TODAS las colas. NO midas radio con `grep recipes/*.toml`.")
682 return 0
683
684
685def _por_cola(nodos):
686 from collections import Counter
687 return dict(Counter(q for q, _ in nodos))
688
689
690# Delegación a los órganos: yupana es la puerta, cada script sigue siendo el músculo. Se delega por
691# subproceso (no import) para no acoplar sus ciclos de vida ni sus flags — cada uno evoluciona solo.
692ORGANOS = {
693 "estado": ["build-state.py"],
694 "drenar": ["drenar.py"],
695 "triaje": ["triaje.py"],
696 "frontera": ["seed-graph.py", "--frontera"],
697 "objetivo": ["targets.py", "--lista"],
698 "sembrar": ["seed-graph.py", "--perfil"],
699}
700
701
702def delegar(cmd, extra):
703 return subprocess.call([sys.executable, str(ROOT / "scripts" / ORGANOS[cmd][0])]
704 + ORGANOS[cmd][1:] + extra)
705
706
707def main():
708 args = sys.argv[1:]
709 if not args or args[0] in ("-h", "--help"):
710 print(__doc__)
711 return 0
712 cmd, extra = args[0], args[1:]
713 if cmd == "radio" and extra:
714 return radio(extra[0], como_json="--json" in extra)
715 if cmd == "duplicados":
716 return duplicados(como_json="--json" in extra)
717 if cmd == "keystones":
718 return keystones(como_json="--json" in extra)
719 if cmd == "preflight" and extra:
720 obj = f"perfil:{extra[extra.index('--perfil') + 1]}" if "--perfil" in extra else extra[0]
721 return preflight(obj)
722 if cmd == "perfiles" and extra:
723 memb = membresia()
724 hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]}
725 print(json.dumps(hits, indent=1, ensure_ascii=False) if hits
726 else f"`{extra[0]}` no lo alcanza ninguna imagen declarada")
727 return 0
728 if cmd in ORGANOS:
729 return delegar(cmd, extra)
730 print(f"verbo desconocido: {cmd}\n{__doc__}", file=sys.stderr)
731 return 1
732
733
734if __name__ == "__main__":
735 sys.exit(main())