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 815Rust 56Shell 37Markdown 291229 Definiciones

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

`yupana keystones` rankea los nudos EN DEUDA por cuánto trabajo BLOQUEADO libera
su sellado (cierre inverso transitivo restringido a la deuda). Sellá de arriba
hacia abajo y la cascada cae lo antes posible.

CORRIGE UN ERROR MÍO: dije "árbol de dominadores = keystones". Lo medí y es
FALSO. El dominador ingenuo asume alcanzabilidad OR (basta un camino), pero
construir es AND (hacen falta TODAS las deps): kcoreaddons depende de dbus/libdrm
/mesa además de qtbase ⇒ hay un camino de desbloqueo que evita qtbase y el
dominador-OR lo pierde. La métrica correcta bajo AND es el cierre inverso
transitivo. Y el crudo lo gana el toolchain (make gatea 313, inútil) ⇒ el
keystone accionable es el cierre restringido a la deuda, y sólo cuenta si el
nudo mismo está en deuda (un sellado ya está disponible, no gatea nada).

Estado actual (qtbase ya sellado, era EL keystone con 117 gateados):
   kio        36   ← sellarlo libera 36 recetas KDE bloqueadas
   kparts     19
   kcmutils   17
   ksvg       14
   libplasma  13
Ése es el camino crítico ahora.

El dominador SÍ entra, en su uso legítimo: la columna `exclusivo` = retención
estilo GC (nodos que sólo dependen de mí). kwin=9 (sus plugins privados),
libksysguard=3. Responde "si suelto esta feature, cuánto más puedo soltar".

Sin datos nuevos (no necesita duración de build). Cuando instrumentemos tiempos,
el mismo cierre se vuelve camino crítico PESADO (ETA real).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

619a30187c1a82fd71c205cb0bff05c3675a2ef9 · por sergio · (hace 3 meses) · padre 0f3a1dc39 · ver el árbol en este commit

Delta semántico · 7

scripts/yupana.py

2 archivos · +719 −0

diff --git a/docs/state/keystones.json b/docs/state/keystones.json
new file mode 100644
index 00000000..0003f60e
--- /dev/null
+++ b/docs/state/keystones.json
@@ -0,0 +1,566 @@
+{
+ "schema": "hammer-keystones/2",
+ "nudos": [
+  {
+   "nudo": "incoming-kde/kio",
+   "gateo_total": 36,
+   "gateo_en_deuda": 36,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/kparts",
+   "gateo_total": 19,
+   "gateo_en_deuda": 19,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/kcmutils",
+   "gateo_total": 17,
+   "gateo_en_deuda": 17,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/ksvg",
+   "gateo_total": 14,
+   "gateo_en_deuda": 14,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/libplasma",
+   "gateo_total": 13,
+   "gateo_en_deuda": 13,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/qtspeech",
+   "gateo_total": 6,
+   "gateo_en_deuda": 6,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/frameworkintegration",
+   "gateo_total": 5,
+   "gateo_en_deuda": 5,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/syntax-highlighting",
+   "gateo_total": 5,
+   "gateo_en_deuda": 5,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/kglobalacceld",
+   "gateo_total": 5,
+   "gateo_en_deuda": 5,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/ktexteditor",
+   "gateo_total": 4,
+   "gateo_en_deuda": 4,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/prison",
+   "gateo_total": 4,
+   "gateo_en_deuda": 4,
+   "exclusivo": 1,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/plasma-activities-stats",
+   "gateo_total": 3,
+   "gateo_en_deuda": 3,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/libksysguard",
+   "gateo_total": 3,
+   "gateo_en_deuda": 3,
+   "exclusivo": 3,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/kscreenlocker",
+   "gateo_total": 3,
+   "gateo_en_deuda": 3,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/kwin",
+   "gateo_total": 3,
+   "gateo_en_deuda": 3,
+   "exclusivo": 9,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/qtlocation",
+   "gateo_total": 3,
+   "gateo_en_deuda": 3,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/qcoro",
+   "gateo_total": 3,
+   "gateo_en_deuda": 3,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/kunitconversion",
+   "gateo_total": 3,
+   "gateo_en_deuda": 3,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/qqc2-desktop-style",
+   "gateo_total": 2,
+   "gateo_en_deuda": 2,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/threadweaver",
+   "gateo_total": 2,
+   "gateo_en_deuda": 2,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/knotifyconfig",
+   "gateo_total": 2,
+   "gateo_en_deuda": 2,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/plasma5support",
+   "gateo_total": 2,
+   "gateo_en_deuda": 2,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/plasma-workspace",
+   "gateo_total": 2,
+   "gateo_en_deuda": 2,
+   "exclusivo": 27,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/qcoro-shared",
+   "gateo_total": 2,
+   "gateo_en_deuda": 2,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/systemsettings",
+   "gateo_total": 1,
+   "gateo_en_deuda": 1,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/poppler",
+   "gateo_total": 1,
+   "gateo_en_deuda": 1,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/breeze",
+   "gateo_total": 1,
+   "gateo_en_deuda": 1,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/networkmanager-qt",
+   "gateo_total": 1,
+   "gateo_en_deuda": 1,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/modemmanager-qt",
+   "gateo_total": 1,
+   "gateo_en_deuda": 1,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/qtkeychain",
+   "gateo_total": 1,
+   "gateo_en_deuda": 1,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/pulseaudio-qt",
+   "gateo_total": 1,
+   "gateo_en_deuda": 1,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/prison-scanner",
+   "gateo_total": 1,
+   "gateo_en_deuda": 1,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "incoming-kde/kpipewire",
+   "gateo_total": 1,
+   "gateo_en_deuda": 1,
+   "exclusivo": 0,
+   "en_deuda": true
+  },
+  {
+   "nudo": "corpus/make",
+   "gateo_total": 313,
+   "gateo_en_deuda": 65,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/binutils",
+   "gateo_total": 239,
+   "gateo_en_deuda": 65,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/zlib",
+   "gateo_total": 233,
+   "gateo_en_deuda": 65,
+   "exclusivo": 1,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/pkgconf",
+   "gateo_total": 269,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/python3",
+   "gateo_total": 205,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/samurai",
+   "gateo_total": 198,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/meson",
+   "gateo_total": 186,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/expat",
+   "gateo_total": 149,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/libffi",
+   "gateo_total": 149,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/bison",
+   "gateo_total": 147,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/flex",
+   "gateo_total": 144,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/zstd",
+   "gateo_total": 135,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/wayland",
+   "gateo_total": 134,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/libdrm",
+   "gateo_total": 132,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/wayland-protocols",
+   "gateo_total": 132,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/mako",
+   "gateo_total": 131,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/markupsafe",
+   "gateo_total": 131,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/packaging",
+   "gateo_total": 131,
+   "gateo_en_deuda": 64,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/cmake",
+   "gateo_total": 145,
+   "gateo_en_deuda": 62,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/mesa",
+   "gateo_total": 128,
+   "gateo_en_deuda": 62,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/pcre2",
+   "gateo_total": 138,
+   "gateo_en_deuda": 60,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/util-macros",
+   "gateo_total": 139,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/xorgproto",
+   "gateo_total": 138,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/libXau",
+   "gateo_total": 134,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/libXdmcp",
+   "gateo_total": 134,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/xcb-proto",
+   "gateo_total": 134,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/libpthread-stubs",
+   "gateo_total": 134,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/libxcb",
+   "gateo_total": 133,
+   "gateo_en_deuda": 56,
+   "exclusivo": 1,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/xtrans",
+   "gateo_total": 129,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/libX11",
+   "gateo_total": 126,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/dbus",
+   "gateo_total": 122,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/libXfixes",
+   "gateo_total": 122,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/libXext",
+   "gateo_total": 121,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/openssl",
+   "gateo_total": 120,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/libXrender",
+   "gateo_total": 120,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/libXi",
+   "gateo_total": 120,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/xcb-util",
+   "gateo_total": 120,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/xcb-util-image",
+   "gateo_total": 119,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/xcb-util-renderutil",
+   "gateo_total": 119,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/libxkbcommon",
+   "gateo_total": 118,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/xcb-util-keysyms",
+   "gateo_total": 118,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/xcb-util-wm",
+   "gateo_total": 118,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/xcb-util-cursor",
+   "gateo_total": 118,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/qtbase",
+   "gateo_total": 117,
+   "gateo_en_deuda": 56,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "corpus/gettext-tiny",
+   "gateo_total": 85,
+   "gateo_en_deuda": 54,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/qttools",
+   "gateo_total": 103,
+   "gateo_en_deuda": 52,
+   "exclusivo": 0,
+   "en_deuda": false
+  },
+  {
+   "nudo": "incoming-kde/qtsvg",
+   "gateo_total": 100,
+   "gateo_en_deuda": 52,
+   "exclusivo": 0,
+   "en_deuda": false
+  }
+ ],
+ "en_deuda": 33
+}
diff --git a/scripts/yupana.py b/scripts/yupana.py
index 6e29ca94..931e9daf 100755
--- a/scripts/yupana.py
+++ b/scripts/yupana.py
@@ -33,6 +33,8 @@
 #
 #   radio <receta>    ¿a QUIÉN rompo si toco esto? (nativo; cruza TODAS las colas) ← ANTES de cambiar nada
 #   perfiles <receta> ¿qué imágenes dependen de esto? (nativo)
+#   duplicados        invariante (c): nudos que son el mismo paquete bajo dos nombres (por sha) (nativo)
+#   keystones         nudos en deuda que gatean el máximo trabajo bloqueado (cierre inverso, AND) (nativo)
 #   estado            regenera el grafo firme          → build-state.py
 #   objetivo          qué debe tener cada imagen        → targets.py --lista
 #   frontera <perfil> qué pide upstream y no tenemos    → seed-graph.py --frontera
@@ -93,6 +95,155 @@ def _base(n):
     return n
 
 
+# --- KEYSTONES: los nudos que gatean el trabajo -------------------------------------------------
+# ADVERTENCIA MATEMÁTICA (medida, no supuesta). El árbol de dominadores INGENUO NO sirve acá: asume
+# alcanzabilidad OR (basta un camino), pero construir es AND (hacen falta TODAS las deps). `kcoreaddons`
+# depende de `dbus`/`libdrm`/`mesa` además de `qtbase` ⇒ en el grafo de desbloqueo hay un camino que
+# evita qtbase, y el dominador-OR lo pierde. La métrica correcta del "cuánto gateo" bajo AND es el
+# CIERRE INVERSO TRANSITIVO. Pero el crudo lo gana el toolchain (make=313, inútil): el keystone
+# ACCIONABLE es el cierre inverso restringido a la DEUDA — cuánto trabajo BLOQUEADO libero. El
+# dominador SÍ tiene un uso legítimo, otro: la RETENCIÓN (lo exclusivamente mío), abajo.
+
+def _cierre_inverso(objetivo, rev):
+    """Nodos que dependen de `objetivo`, transitivamente (semántica AND: si X me tiene en su clausura,
+    X no se construye sin mí)."""
+    vis, pila = set(), [objetivo]
+    while pila:
+        x = pila.pop()
+        for c in rev.get(x, ()):
+            if c not in vis:
+                vis.add(c); pila.append(c)
+    return vis
+
+
+def _dominador_retencion(colas, perfiles):
+    """RETENCIÓN estilo GC: para cada nudo, cuántos otros son EXCLUSIVAMENTE suyos — sólo alcanzables
+    (como dependencia, desde las raíces de las imágenes) a través de él. Este SÍ es el árbol de
+    dominadores, en su uso correcto: 'si suelto este nudo, cuánto más puedo soltar'. Grafo
+    consumidor→dep, raíz virtual sobre todas las raíces de perfil; retención = tamaño del subárbol de
+    dominadores. Iterativo (Cooper-Harvey-Kennedy), suficiente para ~1000 nudos."""
+    idx = {q: set(m) for q, m in colas.items()}
+    R = ("__raiz__", "")
+    succ = {R: set()}
+    for pn, p in perfiles.items():
+        for raiz in p["raices"]:
+            nodo = resolver(raiz, p.get("cola", "corpus"), idx)
+            if nodo:
+                succ[R].add(nodo)
+    # aristas consumidor→dep
+    for q, miembros in colas.items():
+        for n in miembros:
+            nodo = (q, n)
+            succ.setdefault(nodo, set())
+            for d in miembros[n]:
+                r = resolver(d, q, idx)
+                if r:
+                    succ[nodo].add(r)
+    # sólo lo alcanzable desde R
+    orden, vis = [], set()
+    def dfs(u):
+        vis.add(u)
+        for v in succ.get(u, ()):
+            if v not in vis:
+                dfs(v)
+        orden.append(u)
+    sys.setrecursionlimit(10000)
+    dfs(R)
+    rpo = list(reversed(orden))
+    pos = {n: i for i, n in enumerate(rpo)}
+    preds = {}
+    for u in succ:
+        for v in succ[u]:
+            preds.setdefault(v, set()).add(u)
+    idom = {R: R}
+    def inter(a, b):
+        while a != b:
+            while pos[a] > pos[b]:
+                a = idom[a]
+            while pos[b] > pos[a]:
+                b = idom[b]
+        return a
+    cambio = True
+    while cambio:
+        cambio = False
+        for u in rpo:
+            if u == R:
+                continue
+            ps = [p for p in preds.get(u, ()) if p in idom]
+            if not ps:
+                continue
+            nuevo = ps[0]
+            for p in ps[1:]:
+                nuevo = inter(nuevo, p)
+            if idom.get(u) != nuevo:
+                idom[u] = nuevo; cambio = True
+    # tamaño de subárbol (retención) = cuántos nodos domina cada uno
+    hijos = {}
+    for n, d in idom.items():
+        if n != d:
+            hijos.setdefault(d, []).append(n)
+    ret = {}
+    def subtam(u):
+        t = 0
+        for h in hijos.get(u, ()):
+            t += 1 + subtam(h)
+        ret[u] = t
+        return t
+    subtam(R)
+    return ret
+
+
+def keystones(como_json=False):
+    """Los nudos que GATEAN más trabajo. Métrica accionable: cierre inverso transitivo restringido a
+    la DEUDA (cuánto bloqueado libero al sellar esto). Segunda columna: RETENCIÓN por dominadores (lo
+    exclusivamente mío). El grafo elegido es el que más deuda tiene (KDE si existe)."""
+    from collections import Counter
+    colas = cargar_colas()
+    rev = dependientes(colas)
+    perfiles = targets_mod.load()
+    # estados desde el khipu más completo
+    estados = {}
+    for f in ("docs/state/build-state-kde.json", "docs/state/build-state.json"):
+        p = ROOT / f
+        if p.exists():
+            for n, r in json.loads(p.read_text())["nodes"].items():
+                estados.setdefault(n, r.get("state", "?"))
+    DEUDA = ("debt", "never", "unhashable", "wanted")
+    ret = _dominador_retencion(colas, perfiles)
+
+    filas = []
+    for nodo in list(rev):
+        cerr = _cierre_inverso(nodo, rev)
+        en_deuda = sum(1 for (q, n) in cerr if estados.get(n) in DEUDA)
+        q, n = nodo
+        yo_deuda = estados.get(n) in DEUDA
+        filas.append(dict(nudo=f"{q}/{n}", gateo_total=len(cerr), gateo_en_deuda=en_deuda,
+                          exclusivo=ret.get(nodo, 0), en_deuda=yo_deuda))
+    # KEYSTONE ACCIONABLE: un nodo SELLADO ya está disponible ⇒ construirlo no libera nada (make gatea
+    # 65 en deuda pero está hecho). El que importa está EN DEUDA y gatea el máximo trabajo bloqueado.
+    # Orden: primero los en-deuda, por gateo-en-deuda desc. Los sellados quedan como contexto (su
+    # retención `exclusivo` sí es info válida, pero no son trabajo).
+    filas.sort(key=lambda r: (not r["en_deuda"], -r["gateo_en_deuda"], -r["gateo_total"]))
+    accionables = [r for r in filas if r["en_deuda"]]
+    doc = dict(schema="hammer-keystones/2", nudos=filas[:80], en_deuda=len(accionables))
+    (ROOT / "docs/state/keystones.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
+
+    if como_json:
+        print(json.dumps(doc, indent=1, ensure_ascii=False)); return 0
+    print("== KEYSTONES — nudos EN DEUDA que gatean el máximo trabajo bloqueado (semántica AND)")
+    print("   'gateo en deuda' = otro trabajo bloqueado que se libera al sellar ESTE. La métrica accionable:")
+    print("   sellá de arriba hacia abajo y la cascada cae lo antes posible.\n")
+    print(f"   {'nudo (en deuda)':30} {'gateo-deuda':>11} {'gateo-total':>11} {'exclusivo':>9}")
+    if not accionables:
+        print("   (ningún nodo en deuda — todas las imágenes en ámbito están completas)")
+    for r in accionables[:20]:
+        print(f"   {r['nudo']:30} {r['gateo_en_deuda']:11} {r['gateo_total']:11} {r['exclusivo']:9}")
+    print(f"\n   {len(accionables)} nudos en deuda en total  ·  → docs/state/keystones.json")
+    print("   nota: el dominador-OR ingenuo NO sirve (build es AND, no OR); el 'exclusivo' SÍ es")
+    print("         dominador, en su uso correcto (retención). Advertencia completa en yupana.py.")
+    return 0
+
+
 def _fuente(f):
     """(sha, url, version) de una receta — la identidad de lo que BAJA, no de cómo se llama."""
     try:
@@ -337,6 +488,8 @@ def main():
         return radio(extra[0], como_json="--json" in extra)
     if cmd == "duplicados":
         return duplicados(como_json="--json" in extra)
+    if cmd == "keystones":
+        return keystones(como_json="--json" in extra)
     if cmd == "perfiles" and extra:
         memb = membresia()
         hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]}