gioser

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…

TOML 1389Shell 137Markdown 80Rust 802602 Definitions

⎇ 619a30187c1a82fd71c205cb0bff05c3675a2ef9 scripts/yupana.py Python · 506 lines · 23.3 KB History Who touched what raw

yupana: keystones — el camino crítico real, con la matemática correcta (AND, no OR) 619a30187 · sergio ·

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#   estado            regenera el grafo firme          → build-state.py
39#   objetivo          qué debe tener cada imagen        → targets.py --lista
40#   frontera <perfil> qué pide upstream y no tenemos    → seed-graph.py --frontera
41#   triaje            clasificar la frontera            → triaje.py
42#   drenar [--perfil] el orden de masticado, en ondas   → drenar.py
43#
44# Uso:  scripts/yupana.py radio <receta> [--json]
45#       scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | objetivo
46# Como módulo:  from yupana import cargar_colas, membresia, dependientes, resolver
47import glob, json, os, subprocess, sys, tomllib
48from pathlib import Path
49
50ROOT = Path(__file__).resolve().parent.parent
51sys.path.insert(0, str(ROOT / "scripts"))
52import targets as targets_mod
53
54
55def cargar_colas():
56    """{cola: {nombre: [deps de build]}} sobre TODAS las colas del disco. `corpus` = recipes/;
57    `incoming-<x>` = recipes/incoming-<x>/. Es el grafo completo, sin recortar por vista."""
58    colas = {}
59    for f in sorted(glob.glob(str(ROOT / "recipes/*.toml"))):
60        n = os.path.basename(f)[:-5]
61        colas.setdefault("corpus", {})[n] = _deps(f)
62    for f in sorted(glob.glob(str(ROOT / "recipes/incoming-*/*.toml"))):
63        q = os.path.basename(os.path.dirname(f))
64        colas.setdefault(q, {})[os.path.basename(f)[:-5]] = _deps(f)
65    return colas
66
67
68def _deps(f):
69    try:
70        return tomllib.load(open(f, "rb")).get("deps", {}).get("build", []) or []
71    except Exception:
72        return []
73
74
75# --- INVARIANTE (c): CANÓNICO — sin duplicados accidentales entre orígenes ------------------------
76# Sufijos que en hammer marcan una VARIANTE deliberada del mismo paquete (misma fuente, otra decisión
77# de build: estático/shared, kernel por perfil, mesa por backend). Compartir sha con estos NO es un
78# duplicado a fundir — es la diversificación coherente [[etapa-g-gui-chain-boundary]]. `-hello`/`-hola`
79# son demos que vendorean la fuente de otro a propósito.
80SUFIJOS_VARIANTE = ("-shared", "-static", "-libs", "-tiny", "-dev", "-headers", "-minimal",
81                    "-generic", "-metal-dual", "-metal", "-swrast", "-llvmpipe", "-hello", "-hola")
82
83
84def _base(n):
85    """Reduce un nombre a su base de familia quitando sufijos de variante, repetido: `zlib-shared`→
86    `zlib`, `mesa-llvmpipe`→`mesa`, `linux-generic`→`linux`. NO toca el `lib` inicial a propósito
87    (`libpng`→`png` colapsaría paquetes sin relación). Para decidir si dos nudos que comparten fuente
88    son la MISMA familia (variante ok) o paquetes SIN relación (colisión real)."""
89    prev = None
90    while prev != n:
91        prev = n
92        for s in SUFIJOS_VARIANTE:
93            if n.endswith(s) and len(n) > len(s):
94                n = n[: -len(s)]
95    return n
96
97
98# --- KEYSTONES: los nudos que gatean el trabajo -------------------------------------------------
99# ADVERTENCIA MATEMÁTICA (medida, no supuesta). El árbol de dominadores INGENUO NO sirve acá: asume
100# alcanzabilidad OR (basta un camino), pero construir es AND (hacen falta TODAS las deps). `kcoreaddons`
101# depende de `dbus`/`libdrm`/`mesa` además de `qtbase` ⇒ en el grafo de desbloqueo hay un camino que
102# evita qtbase, y el dominador-OR lo pierde. La métrica correcta del "cuánto gateo" bajo AND es el
103# CIERRE INVERSO TRANSITIVO. Pero el crudo lo gana el toolchain (make=313, inútil): el keystone
104# ACCIONABLE es el cierre inverso restringido a la DEUDA — cuánto trabajo BLOQUEADO libero. El
105# dominador SÍ tiene un uso legítimo, otro: la RETENCIÓN (lo exclusivamente mío), abajo.
106
107def _cierre_inverso(objetivo, rev):
108    """Nodos que dependen de `objetivo`, transitivamente (semántica AND: si X me tiene en su clausura,
109    X no se construye sin mí)."""
110    vis, pila = set(), [objetivo]
111    while pila:
112        x = pila.pop()
113        for c in rev.get(x, ()):
114            if c not in vis:
115                vis.add(c); pila.append(c)
116    return vis
117
118
119def _dominador_retencion(colas, perfiles):
120    """RETENCIÓN estilo GC: para cada nudo, cuántos otros son EXCLUSIVAMENTE suyos — sólo alcanzables
121    (como dependencia, desde las raíces de las imágenes) a través de él. Este SÍ es el árbol de
122    dominadores, en su uso correcto: 'si suelto este nudo, cuánto más puedo soltar'. Grafo
123    consumidor→dep, raíz virtual sobre todas las raíces de perfil; retención = tamaño del subárbol de
124    dominadores. Iterativo (Cooper-Harvey-Kennedy), suficiente para ~1000 nudos."""
125    idx = {q: set(m) for q, m in colas.items()}
126    R = ("__raiz__", "")
127    succ = {R: set()}
128    for pn, p in perfiles.items():
129        for raiz in p["raices"]:
130            nodo = resolver(raiz, p.get("cola", "corpus"), idx)
131            if nodo:
132                succ[R].add(nodo)
133    # aristas consumidor→dep
134    for q, miembros in colas.items():
135        for n in miembros:
136            nodo = (q, n)
137            succ.setdefault(nodo, set())
138            for d in miembros[n]:
139                r = resolver(d, q, idx)
140                if r:
141                    succ[nodo].add(r)
142    # sólo lo alcanzable desde R
143    orden, vis = [], set()
144    def dfs(u):
145        vis.add(u)
146        for v in succ.get(u, ()):
147            if v not in vis:
148                dfs(v)
149        orden.append(u)
150    sys.setrecursionlimit(10000)
151    dfs(R)
152    rpo = list(reversed(orden))
153    pos = {n: i for i, n in enumerate(rpo)}
154    preds = {}
155    for u in succ:
156        for v in succ[u]:
157            preds.setdefault(v, set()).add(u)
158    idom = {R: R}
159    def inter(a, b):
160        while a != b:
161            while pos[a] > pos[b]:
162                a = idom[a]
163            while pos[b] > pos[a]:
164                b = idom[b]
165        return a
166    cambio = True
167    while cambio:
168        cambio = False
169        for u in rpo:
170            if u == R:
171                continue
172            ps = [p for p in preds.get(u, ()) if p in idom]
173            if not ps:
174                continue
175            nuevo = ps[0]
176            for p in ps[1:]:
177                nuevo = inter(nuevo, p)
178            if idom.get(u) != nuevo:
179                idom[u] = nuevo; cambio = True
180    # tamaño de subárbol (retención) = cuántos nodos domina cada uno
181    hijos = {}
182    for n, d in idom.items():
183        if n != d:
184            hijos.setdefault(d, []).append(n)
185    ret = {}
186    def subtam(u):
187        t = 0
188        for h in hijos.get(u, ()):
189            t += 1 + subtam(h)
190        ret[u] = t
191        return t
192    subtam(R)
193    return ret
194
195
196def keystones(como_json=False):
197    """Los nudos que GATEAN más trabajo. Métrica accionable: cierre inverso transitivo restringido a
198    la DEUDA (cuánto bloqueado libero al sellar esto). Segunda columna: RETENCIÓN por dominadores (lo
199    exclusivamente mío). El grafo elegido es el que más deuda tiene (KDE si existe)."""
200    from collections import Counter
201    colas = cargar_colas()
202    rev = dependientes(colas)
203    perfiles = targets_mod.load()
204    # estados desde el khipu más completo
205    estados = {}
206    for f in ("docs/state/build-state-kde.json", "docs/state/build-state.json"):
207        p = ROOT / f
208        if p.exists():
209            for n, r in json.loads(p.read_text())["nodes"].items():
210                estados.setdefault(n, r.get("state", "?"))
211    DEUDA = ("debt", "never", "unhashable", "wanted")
212    ret = _dominador_retencion(colas, perfiles)
213
214    filas = []
215    for nodo in list(rev):
216        cerr = _cierre_inverso(nodo, rev)
217        en_deuda = sum(1 for (q, n) in cerr if estados.get(n) in DEUDA)
218        q, n = nodo
219        yo_deuda = estados.get(n) in DEUDA
220        filas.append(dict(nudo=f"{q}/{n}", gateo_total=len(cerr), gateo_en_deuda=en_deuda,
221                          exclusivo=ret.get(nodo, 0), en_deuda=yo_deuda))
222    # KEYSTONE ACCIONABLE: un nodo SELLADO ya está disponible ⇒ construirlo no libera nada (make gatea
223    # 65 en deuda pero está hecho). El que importa está EN DEUDA y gatea el máximo trabajo bloqueado.
224    # Orden: primero los en-deuda, por gateo-en-deuda desc. Los sellados quedan como contexto (su
225    # retención `exclusivo` sí es info válida, pero no son trabajo).
226    filas.sort(key=lambda r: (not r["en_deuda"], -r["gateo_en_deuda"], -r["gateo_total"]))
227    accionables = [r for r in filas if r["en_deuda"]]
228    doc = dict(schema="hammer-keystones/2", nudos=filas[:80], en_deuda=len(accionables))
229    (ROOT / "docs/state/keystones.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
230
231    if como_json:
232        print(json.dumps(doc, indent=1, ensure_ascii=False)); return 0
233    print("== KEYSTONES — nudos EN DEUDA que gatean el máximo trabajo bloqueado (semántica AND)")
234    print("   'gateo en deuda' = otro trabajo bloqueado que se libera al sellar ESTE. La métrica accionable:")
235    print("   sellá de arriba hacia abajo y la cascada cae lo antes posible.\n")
236    print(f"   {'nudo (en deuda)':30} {'gateo-deuda':>11} {'gateo-total':>11} {'exclusivo':>9}")
237    if not accionables:
238        print("   (ningún nodo en deuda — todas las imágenes en ámbito están completas)")
239    for r in accionables[:20]:
240        print(f"   {r['nudo']:30} {r['gateo_en_deuda']:11} {r['gateo_total']:11} {r['exclusivo']:9}")
241    print(f"\n   {len(accionables)} nudos en deuda en total  ·  → docs/state/keystones.json")
242    print("   nota: el dominador-OR ingenuo NO sirve (build es AND, no OR); el 'exclusivo' SÍ es")
243    print("         dominador, en su uso correcto (retención). Advertencia completa en yupana.py.")
244    return 0
245
246
247def _fuente(f):
248    """(sha, url, version) de una receta — la identidad de lo que BAJA, no de cómo se llama."""
249    try:
250        d = tomllib.load(open(f, "rb"))
251    except Exception:
252        return (None, "", None)
253    s = d.get("source", {})
254    return (s.get("sha256") or s.get("output_hash"),
255            s.get("tarball") or s.get("url") or s.get("git") or "",
256            d.get("version"))
257
258
259def _recetas_con_fuente():
260    """{(cola, nombre): {sha, url, version, deps}} sobre todo el disco."""
261    out = {}
262    for pat, es_incoming in (("recipes/*.toml", False), ("recipes/incoming-*/*.toml", True)):
263        for f in sorted(glob.glob(str(ROOT / pat))):
264            n = os.path.basename(f)[:-5]
265            q = os.path.basename(os.path.dirname(f)) if es_incoming else "corpus"
266            sha, url, ver = _fuente(f)
267            out[(q, n)] = dict(sha=sha, url=url, version=ver, deps=_deps(f))
268    return out
269
270
271def duplicados(como_json=False):
272    """INVARIANTE (c): detecta nudos que son el MISMO paquete bajo nombres/colas distintos, por
273    EVIDENCIA de fuente (sha/url), no por nombre. Clasifica cada grupo:
274      colisión-fuente — mismo sha, nombres de familias DISTINTAS ⇒ casi seguro un sha copiado por
275                        error (itstool tenía el tarball de gettext-tiny). ACCIONABLE.
276      variante        — mismo sha, misma familia (`zlib`/`zlib-shared`, `mesa-*`) ⇒ deliberado, OK.
277      sombra          — mismo NOMBRE en >1 cola. Redundante si versión+sha+deps coinciden; si no,
278                        es una variante justificada de la cola.
279    NO funde nada solo: reporta y deja el juicio al humano (como el triaje). El radio de cada nudo
280    ayuda a elegir el canónico."""
281    recs = _recetas_con_fuente()
282    from collections import defaultdict
283    rev = dependientes()
284
285    # 1) grupos por sha (identidad de fuente)
286    por_sha = defaultdict(list)
287    for k, r in recs.items():
288        if r["sha"]:
289            por_sha[r["sha"]].append(k)
290    colisiones, variantes = [], []
291    for sha, ks in por_sha.items():
292        nombres = {n for _, n in ks}
293        if len(nombres) < 2:
294            continue
295        familias = {_base(n) for _, n in ks}
296        grupo = dict(sha=sha[:16], nudos=sorted(f"{q}/{n}" for q, n in ks),
297                     radio={f"{q}/{n}": len(rev.get((q, n), ())) for q, n in ks})
298        (variantes if len(familias) == 1 else colisiones).append(grupo)
299
300    # 2) sombras: mismo nombre en >1 cola
301    por_nombre = defaultdict(list)
302    for (q, n) in recs:
303        por_nombre[n].append(q)
304    sombras = []
305    for n, qs in por_nombre.items():
306        if len(qs) < 2:
307            continue
308        firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"],
309                   tuple(sorted(recs[(q, n)]["deps"]))) for q in qs}
310        sombras.append(dict(nombre=n, colas=sorted(qs),
311                            redundante=len(firmas) == 1,  # idénticas ⇒ la sombra no aporta nada
312                            radio={f"{q}/{n}": len(rev.get((q, n), ())) for q in qs}))
313
314    doc = dict(schema="hammer-duplicados/1",
315               colisiones_fuente=sorted(colisiones, key=lambda g: -max(g["radio"].values() or [0])),
316               variantes=sorted(variantes, key=lambda g: g["nudos"]),
317               sombras=sorted(sombras, key=lambda s: (not s["redundante"], s["nombre"])))
318    (ROOT / "docs/state/duplicados.json").write_text(
319        json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
320
321    if como_json:
322        print(json.dumps(doc, indent=1, ensure_ascii=False))
323        return 0
324    print(f"== INVARIANTE (c) CANÓNICO — duplicados por evidencia de fuente\n")
325    print(f"⚠ COLISIONES DE FUENTE: {len(colisiones)}  (mismo sha, familias distintas — revisá el sha)")
326    for g in doc["colisiones_fuente"]:
327        print(f"   sha {g['sha']}…  {g['nudos']}")
328        print(f"        radio: " + "  ".join(f"{k.split('/')[-1]}={v}" for k, v in g["radio"].items()))
329    red = [s for s in sombras if s["redundante"]]
330    print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)}  (mismo nombre+versión+sha+deps en 2 colas — una sobra)")
331    for s in red:
332        print(f"   {s['nombre']:24} {s['colas']}  radio " +
333              " ".join(f"{q}={v}" for (q, _), v in
334                       ((k.split('/'), v) for k, v in s["radio"].items())))
335    print(f"\n✓ variantes deliberadas (misma fuente, familia OK): {len(variantes)}  — no son problema")
336    print(f"✓ sombras justificadas (difieren en versión/deps): {len(sombras)-len(red)}")
337    print(f"\n   → docs/state/duplicados.json  ·  el radio ayuda a elegir el canónico (el de mayor radio)")
338    return 0
339
340
341def resolver(dep, desde_cola, indice):
342    """Sibling-first: (cola, nombre) del nudo que provee `dep` visto desde `desde_cola`, o None si
343    ninguna cola lo tiene (dep externa / nodo `wanted`). `indice` = {cola: set(nombres)}."""
344    if dep in indice.get(desde_cola, ()):
345        return (desde_cola, dep)
346    if dep in indice.get("corpus", ()):
347        return ("corpus", dep)
348    return None
349
350
351def dependientes(colas=None):
352    """{(cola,nombre): set((cola,nombre) que lo consumen)} — el mapa de dependencias INVERSAS sobre
353    el grafo entero. Este es el cálculo que, hecho sobre una sola cola, produjo el bug de libdrm."""
354    colas = colas or cargar_colas()
355    indice = {q: set(m) for q, m in colas.items()}
356    rev = {}
357    for q, miembros in colas.items():
358        for nombre, deps in miembros.items():
359            for d in deps:
360                objetivo = resolver(d, q, indice)
361                if objetivo:
362                    rev.setdefault(objetivo, set()).add((q, nombre))
363    return rev
364
365
366def membresia(colas=None, perfiles=None):
367    """{(cola,nombre): set(perfiles que lo alcanzan)} sobre el grafo entero, respetando sibling-first.
368    Un nudo pertenece a una imagen si la clausura de sus raíces —resolviendo cada cuerda desde la
369    cola del consumidor— llega hasta él. Esto es lo que arregla la mentira de `perfiles`."""
370    colas = colas or cargar_colas()
371    perfiles = perfiles if perfiles is not None else targets_mod.load()
372    indice = {q: set(m) for q, m in colas.items()}
373    memb = {}
374    for pn, p in perfiles.items():
375        cola_p = p.get("cola", "corpus")
376        vistos, pila = set(), []
377        for raiz in p["raices"]:
378            nodo = resolver(raiz, cola_p, indice)
379            pila.append(nodo if nodo else ("wanted", raiz))  # raíz sin receta = nodo wanted
380        while pila:
381            nodo = pila.pop()
382            if nodo in vistos:
383                continue
384            vistos.add(nodo)
385            memb.setdefault(nodo, set()).add(pn)
386            q, nombre = nodo
387            if q == "wanted":
388                continue
389            for d in colas.get(q, {}).get(nombre, []):
390                sig = resolver(d, q, indice)
391                pila.append(sig if sig else ("wanted", d))
392    return memb
393
394
395def _estado(nodo):
396    """Estado del nudo desde el grafo firme, si está regenerado. Best-effort: el radio funciona sin él."""
397    q, nombre = nodo
398    fichero = "docs/state/build-state-kde.json" if q != "corpus" else "docs/state/build-state.json"
399    try:
400        d = json.loads((ROOT / fichero).read_text())
401        return d["nodes"].get(nombre, {}).get("state", "?")
402    except Exception:
403        return "?"
404
405
406def radio(nombre, como_json=False):
407    """¿A QUIÉN rompo si re-hasheo `recipes/**/<nombre>.toml`? La pregunta que, mal medida, costó 118
408    recetas. Cruza TODAS las colas por construcción: es imposible que 'olvide' una cola."""
409    colas = cargar_colas()
410    indice = {q: set(m) for q, m in colas.items()}
411    # ¿En qué cola vive la receta que se va a tocar? Puede estar en varias (variante sombreada).
412    ubic = [(q, nombre) for q in colas if nombre in colas[q]]
413    if not ubic:
414        print(f"no hay receta `{nombre}` en ninguna cola", file=sys.stderr)
415        return 1
416    rev = dependientes(colas)
417    memb = membresia(colas, targets_mod.load())
418
419    resultado = {}
420    for nodo in ubic:
421        directos = sorted(rev.get(nodo, set()))
422        # Cierre inverso: quién depende, transitivamente (el radio real de un re-hash en cascada).
423        vistos, pila = set(), list(rev.get(nodo, set()))
424        while pila:
425            n = pila.pop()
426            if n in vistos:
427                continue
428            vistos.add(n)
429            pila.extend(rev.get(n, set()))
430        imgs = set()
431        for n in vistos | {nodo}:
432            imgs |= memb.get(n, set())
433        # Cuántos de los transitivos YA están sellados: ésos son los que HOY están al día y el
434        # re-hash MANDA A DEUDA (los que ya están en deuda igual habría que reconstruirlos). El
435        # radio verdadero de rebuild forzado por el cambio.
436        sellados = sum(1 for n in vistos if _estado(n) == "sealed")
437        resultado[f"{nodo[0]}/{nodo[1]}"] = dict(
438            directos=len(directos), transitivos=len(vistos),
439            imagenes=sorted(imgs), sellados_que_caen=sellados,
440            dependientes_por_cola=_por_cola(vistos))
441
442    if como_json:
443        print(json.dumps(resultado, indent=1, ensure_ascii=False))
444        return 0
445    for nodo, r in resultado.items():
446        print(f"== radio de tocar  {nodo}")
447        print(f"   dependientes directos     {r['directos']}")
448        print(f"   dependientes transitivos  {r['transitivos']}   (el radio real de un re-hash en cascada)")
449        print(f"   por cola                  " +
450              "  ".join(f"{q}={n}" for q, n in sorted(r["dependientes_por_cola"].items())))
451        print(f"   IMÁGENES afectadas        {', '.join(r['imagenes']) or '(ninguna declarada)'}")
452        print(f"   sellados que CAEN a deuda {r['sellados_que_caen']}   (hoy al día; el re-hash los fuerza a rebuild)")
453        if len(resultado) > 1:
454            print()
455    print("\n   regla: esto cruza TODAS las colas. NO midas radio con `grep recipes/*.toml`.")
456    return 0
457
458
459def _por_cola(nodos):
460    from collections import Counter
461    return dict(Counter(q for q, _ in nodos))
462
463
464# Delegación a los órganos: yupana es la puerta, cada script sigue siendo el músculo. Se delega por
465# subproceso (no import) para no acoplar sus ciclos de vida ni sus flags — cada uno evoluciona solo.
466ORGANOS = {
467    "estado":   ["build-state.py"],
468    "drenar":   ["drenar.py"],
469    "triaje":   ["triaje.py"],
470    "frontera": ["seed-graph.py", "--frontera"],
471    "objetivo": ["targets.py", "--lista"],
472    "sembrar":  ["seed-graph.py", "--perfil"],
473}
474
475
476def delegar(cmd, extra):
477    return subprocess.call([sys.executable, str(ROOT / "scripts" / ORGANOS[cmd][0])]
478                           + ORGANOS[cmd][1:] + extra)
479
480
481def main():
482    args = sys.argv[1:]
483    if not args or args[0] in ("-h", "--help"):
484        print(__doc__)
485        return 0
486    cmd, extra = args[0], args[1:]
487    if cmd == "radio" and extra:
488        return radio(extra[0], como_json="--json" in extra)
489    if cmd == "duplicados":
490        return duplicados(como_json="--json" in extra)
491    if cmd == "keystones":
492        return keystones(como_json="--json" in extra)
493    if cmd == "perfiles" and extra:
494        memb = membresia()
495        hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]}
496        print(json.dumps(hits, indent=1, ensure_ascii=False) if hits
497              else f"`{extra[0]}` no lo alcanza ninguna imagen declarada")
498        return 0
499    if cmd in ORGANOS:
500        return delegar(cmd, extra)
501    print(f"verbo desconocido: {cmd}\n{__doc__}", file=sys.stderr)
502    return 1
503
504
505if __name__ == "__main__":
506    sys.exit(main())