Files
SergioandClaude Opus 5 fca1de2e55 yupana: radio mezclaba las aristas de build con las de runtime y sobreestimaba el coste
`radio gcc-libs` contestaba «9 dependientes, 9 sellados caen a deuda», y con ese número avisé de una
cascada de horas —firefox con PGO dos veces, waterfox, atuq—. La respuesta correcta es CERO: los
cinco declaran gcc-libs en `deps.runtime`, y **`runtime` no entra en `hash_inputs`**, así que
re-sellarla no re-hashea a nadie. Se vio al mirar el store después del cambio: los cinco seguían al
día.

El grafo de `_deps` une build+runtime a propósito —eso es el CIERRE de una imagen, y esa unión
arregló en su día que firefox arrancara sin libstdc++—, pero `radio` responde otra pregunta: «¿a
quién obligo a reconstruir?». Ahora usa dos grafos: la cascada sale del de BUILD, y las imágenes del
de la unión, que es lo correcto para cada uno. Los que dependen sólo por runtime se siguen
informando, nombrados, diciendo que NO se reconstruyen.

Controles: gcc-libs ⇒ 0 de cascada y 9 sólo-runtime; zlib ⇒ 126 directos y 391 transitivos (sigue
viendo las de verdad); zsh ⇒ 0. Y `test-yupana-radio.py` pasa: libdrm sigue cruzando las 5 colas.

Un número que sobreestima frena arreglos correctos, que es justo lo que casi pasa acá.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 01:48:35 +00:00

823 lines
41 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# yupana.py — la YUPANA del build: el ábaco que RECKONA sobre el estado de la distro.
#
# DOS OBJETOS ANDINOS, DOS ROLES. En los Andes el khipu GUARDABA (cuerdas anudadas que registran
# cantidades y relaciones) y la yupana CALCULABA sobre ese registro (el ábaco inca). Acá es igual y
# la distinción es la arquitectura, no adorno:
# · `docs/state/build-state.json` es el KHIPU — el registro firme y versionado: los nudos
# (recetas) y las cuerdas (deps), la única fuente de verdad de QUÉ hay y QUÉ falta.
# · este módulo es la YUPANA — el motor que reckona sobre el khipu: a QUIÉN rompo si toco algo
# (radio), qué imágenes dependen de un nudo (perfiles), en qué orden masticar (ondas).
# Es la BASE metodológica y la PUERTA ÚNICA; el resto de los scripts de estado son sus órganos.
# Antes este saber vivía disperso (en la cabeza, en `grep`, en docs que envejecen) y cada medición
# a mano mentía distinto.
#
# POR QUÉ EXISTE ESTE FICHERO Y NO SÓLO build-state.py. El 2026-07-22 un cambio a `libdrm`
# (default_library=both, para gtk4) invalidó 118 recetas KDE. El radio se midió contra
# `recipes/*.toml` y NO contra `recipes/incoming-kde/` ⇒ dio 8 en vez de 118. `build-state.py` sin
# `--kde` cometía EXACTAMENTE el mismo error: cargaba sólo el corpus, y su campo `perfiles` llegaba
# a AFIRMAR que tocar `libdrm` sólo afectaba a `escritorio-mirada` — una mentira con confianza. La
# causa es estructural: las dependencias INVERSAS son un hecho del disco entero, no de la vista que
# uno cargó. Este módulo las calcula SIEMPRE sobre TODAS las colas, y es de donde build-state saca
# `perfiles`/`dependientes`. La regla, en una línea:
#
# QUÉ NODOS PUNTÚO es una decisión de vista. QUIÉN ME CONSUME es un hecho — nunca depende de la vista.
#
# RESOLUCIÓN SIBLING-FIRST. Una dep `libdrm` desde una receta de `incoming-kde` resuelve a
# `incoming-kde/libdrm` si existe, y si no cae al `corpus/libdrm` (igual que el sandbox al montar las
# capas). Por eso los nodos se identifican por PAR `(cola, nombre)`, no por nombre: `corpus/fontconfig`
# y `incoming-kde/fontconfig` son nudos distintos y romper uno no rompe a los consumidores del otro.
#
# yupana es la PUERTA ÚNICA de la metodología. Sus verbos son los órganos del ciclo, en el orden en
# que se usan (el mismo que la cadena del plan docs/plan-catalogo-objetivo.md):
#
# 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)
# preflight <r> modelo EXTRA-GRAFO: ¿choca con un límite del entorno que el grafo no ve? (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
# triaje clasificar la frontera → triaje.py
# drenar [--perfil] el orden de masticado, en ondas → drenar.py
#
# La ayuda operativa vive en el DOCSTRING de abajo y no en estos comentarios, por una razón medida:
# `main()` hace `print(__doc__)` para `--help` y para un verbo desconocido, y mientras todo el
# encabezado fue `#` eso imprimía literalmente `None`. La puerta única de la metodología no sabía
# explicarse, y el error de verbo desconocido tampoco decía cuáles son los verbos. El diseño y el
# porqué se quedan arriba como comentario; lo que un humano necesita para USARLO va en el docstring.
"""yupana — el ábaco del build: reckona sobre el khipu (docs/state/build-state.json).
Verbos, en el orden del ciclo:
radio <receta> [--json] ¿a QUIÉN rompo si toco esto? cruza TODAS las colas ← ANTES de cambiar nada
perfiles <receta> ¿qué imágenes dependen de esto?
duplicados [--json] el mismo paquete bajo dos nombres (por sha)
keystones [--json] nudos en deuda que gatean el máximo trabajo bloqueado
preflight <receta> ¿choca con un límite del entorno que el grafo no ve?
estado [--kde|--gnome|--cosmic|--wlr] regenera el grafo firme
objetivo qué debe tener cada imagen
frontera <perfil> qué pide upstream y no tenemos
triaje clasificar la frontera
sembrar <perfil> sembrar las recetas que la frontera reveló
drenar [--todos|--perfil <p>] el orden de masticado, en ondas
Uso: scripts/yupana.py radio <receta> [--json]
scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | objetivo
Como módulo: from yupana import cargar_colas, membresia, dependientes, resolver
"""
import glob, json, os, re, subprocess, sys, tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
import targets as targets_mod
def cargar_colas(solo_build=False):
"""{cola: {nombre: [deps]}} sobre TODAS las colas del disco. `corpus` = recipes/;
`incoming-<x>` = recipes/incoming-<x>/. Es el grafo completo, sin recortar por vista.
`solo_build=True` devuelve **únicamente las aristas de build**. La distinción no es cosmética y
la pide `radio`: `deps.runtime` **NO entra en el ArtifactHash**, así que un cambio en una receta
NO re-hashea a quien la consume sólo en runtime. Con la unión, `radio gcc-libs` contestaba
«9 sellados caen a deuda» —firefox con PGO dos veces, horas de granja— y la respuesta correcta
era CERO: los cinco la declaran en `runtime`. Medido el 2026-09-18."""
lee = _deps_build if solo_build else _deps
colas = {}
for f in sorted(glob.glob(str(ROOT / "recipes/*.toml"))):
n = os.path.basename(f)[:-5]
colas.setdefault("corpus", {})[n] = lee(f)
for f in sorted(glob.glob(str(ROOT / "recipes/incoming-*/*.toml"))):
q = os.path.basename(os.path.dirname(f))
colas.setdefault(q, {})[os.path.basename(f)[:-5]] = lee(f)
return colas
def _deps(f):
try:
d = tomllib.load(open(f, "rb")).get("deps", {})
# ⚠ BUILD **+ RUNTIME** (2026-09-06). Leía sólo `build`, y por eso una dep de EJECUCIÓN no
# existía para NINGUNA herramienta que use yupana —el vigía de sonames, la membresía de
# perfiles, el grafo de estado—. Medido: `firefox` declaraba `runtime = ["gcc-libs"]` y el
# cierre de las cuatro imágenes seguía sin `libstdc++.so.6`, así que el navegador no
# arrancaba y el vigía lo seguía reportando como hueco después de haberlo arreglado.
# `deps.runtime` está en el esquema de takana desde siempre; lo que faltaba era que ALGUIEN
# lo leyera. Un campo que nadie lee es un campo que miente.
# La unión es la definición de cierre: para CORRER hacen falta las dos.
return sorted(set(d.get("build", []) or []) | set(d.get("runtime", []) or []))
except Exception:
return []
def _deps_build(f):
"""Sólo las aristas de BUILD: las únicas que propagan un re-hash (`runtime` está fuera de
`hash_inputs`, igual que `license`). Para el cierre de una imagen hace falta la unión —eso es
`_deps`—; para «¿a quién obligo a reconstruir?» hace falta esto."""
try:
return sorted(set(tomllib.load(open(f, "rb")).get("deps", {}).get("build", []) or []))
except Exception:
return []
# --- INVARIANTE (c): CANÓNICO — sin duplicados accidentales entre orígenes ------------------------
# Sufijos que en takana marcan una VARIANTE deliberada del mismo paquete (misma fuente, otra decisión
# de build: estático/shared, kernel por perfil, mesa por backend). Compartir sha con estos NO es un
# duplicado a fundir — es la diversificación coherente [[etapa-g-gui-chain-boundary]]. `-hello`/`-hola`
# son demos que vendorean la fuente de otro a propósito.
SUFIJOS_VARIANTE = ("-shared", "-static", "-libs", "-tiny", "-dev", "-headers", "-minimal",
"-generic", "-metal-dual", "-metal", "-swrast", "-llvmpipe", "-hello", "-hola")
def _base(n):
"""Reduce un nombre a su base de familia quitando sufijos de variante, repetido: `zlib-shared`→
`zlib`, `mesa-llvmpipe`→`mesa`, `linux-generic`→`linux`. NO toca el `lib` inicial a propósito
(`libpng`→`png` colapsaría paquetes sin relación). Para decidir si dos nudos que comparten fuente
son la MISMA familia (variante ok) o paquetes SIN relación (colisión real)."""
prev = None
while prev != n:
prev = n
for s in SUFIJOS_VARIANTE:
if n.endswith(s) and len(n) > len(s):
n = n[: -len(s)]
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.
# --- MODELO EXTRA-GRAFO: patrones de hueco de ENTORNO -------------------------------------------
# El grafo modela deps (R1: Receta×Receta). El entorno es OTRA capa: R2 (Receta×Capacidad, qué exige
# un build) y R3 (Host×Capacidad, qué ofrece la máquina). Construible = clausura(R1) ∧ R2⊆R3. Estos
# huecos viven FUERA del grafo (infra del sandbox, toolchain, layout de fs) y una matemática de grafos
# sola no los ve — pero SÍ se aprenden, como patrones con predictor, calibrados contra fallos REALES.
# Cada patrón: (nombre, deps) → veredicto|None. `preflight` los corre ANTES de construir.
#
# HOY: 1 patrón (overlay-lowerdir), la cicatriz de kio (2026-07-22). Futuros: capacidad-de-host
# (rootfs laptop≠worker, techo MSRV — acumular observaciones (receta,host,lo-que-faltó)); skew de
# toolchain (zig-skew — fingerprint del toolchain por artefacto). harkaq ya MIDE R2 (evidencia
# negativa: qué path del entorno tocó un build); esto lo vuelve consultable + pre-vuela.
#
# CALIBRADO, no adivinado: el estimador daba corto hasta usar el prefijo que bwrap VE (`/oldroot/...`,
# la ruta runtime del worker, no la del hub). Con eso: kio estimado 5346B vs real 5315B (31B de error).
# El primer predictor "obvio" (prefijo del hub) mentía con confianza — la lección de siempre.
PAGINA_OVERLAY = 4096 # tope del string de opciones de un mount overlayfs = PAGE_SIZE
# Prefijo que bwrap ve en runtime (bajo /oldroot, ruta del worker). Ajustable por host.
_PREF_SANDBOX = os.environ.get("PREF_STORE_SANDBOX", "/oldroot/opt/takana/store/")
_OVERHEAD_OVERLAY = len("upperdir=/tmp-overlay-upper-0,workdir=/tmp-overlay-work-0,") \
+ len("/oldroot/opt/takana/.dev-fs/alpine") + len(",userxattr")
def _lowerdir_estimado(deps):
"""Bytes del string `lowerdir=` que bwrap arma apilando `deps` como capas overlay directas. Es la
cantidad de GRAFO (nº y nombre de deps) que cruza el umbral de ENTORNO (una página)."""
return _OVERHEAD_OVERLAY + sum(len(_PREF_SANDBOX) + 64 + 1 + len(n) + 1 for n in deps)
def patron_overlay(nombre, deps):
"""¿El apilado directo de las deps desborda el mount de overlay? Si sí, la receta SÓLO monta
gracias al merge de deps (cp -al a store/.dmerge, 1 sola capa). El pre-flight hace VISIBLE esa
dependencia de una mitigación —y su precondición— que antes era invisible hasta que fallaba."""
est = _lowerdir_estimado(deps)
if est <= PAGINA_OVERLAY:
return None # entra directo, sin riesgo
return dict(
patron="overlay-lowerdir",
detalle=f"{len(deps)} deps ⇒ lowerdir ~{est}B > {PAGINA_OVERLAY}B (una página)",
riesgo="depende-del-merge",
nota=("apilado directo DESBORDA ⇒ sólo monta vía el merge de deps (cp -al a store/.dmerge, "
"1 capa). Precondición: .dmerge mismo-fs que el store — OK por construcción (vive DENTRO "
"del store, fix 6a0dc40). Si el store cambiara de layout y el merge fallara, esta receta "
"NO montaría, silenciosamente."))
PATRONES_ENTORNO = [patron_overlay]
def preflight(objetivo):
"""Corre los patrones de entorno sobre una receta o (con --perfil) sobre todas las de una imagen,
ANTES de construir. Responde la pregunta que kio contestó tarde y cara: ¿este build va a chocar
con un límite del entorno que el grafo no ve?"""
colas = cargar_colas()
idx = {q: set(m) for q, m in colas.items()}
if objetivo.startswith("perfil:"):
pn = objetivo.split(":", 1)[1]
p = targets_mod.load().get(pn)
if not p:
print(f"perfil desconocido: {pn}", file=sys.stderr); return 1
cola = p.get("cola", "corpus")
nombres = [(cola if n in idx.get(cola, ()) else "corpus", n)
for n in colas.get(cola, {})] if cola != "corpus" else \
[("corpus", n) for n in colas["corpus"]]
# sólo los del ámbito: todas las recetas de la cola del perfil
objetivos = [(q, n) for q in ({cola} | {"corpus"}) for n in colas.get(q, {})]
else:
ubic = [(q, objetivo) for q in colas if objetivo in colas[q]]
if not ubic:
print(f"no hay receta `{objetivo}` en ninguna cola", file=sys.stderr); return 1
objetivos = ubic
hits = []
for (q, n) in objetivos:
deps = colas[q][n]
for patron in PATRONES_ENTORNO:
v = patron(n, deps)
if v:
hits.append((f"{q}/{n}", v))
hits.sort(key=lambda h: -_lowerdir_estimado(colas[h[0].split("/")[0]][h[0].split("/", 1)[1]]))
if not objetivo.startswith("perfil:"):
q, n = objetivos[0]
print(f"== preflight de {q}/{n} ({len(colas[q][n])} deps)")
if not hits:
print(" ✓ sin riesgo de entorno conocido (entra en el mount overlay directo).")
for _, v in hits:
print(f" ⚠ {v['patron']}: {v['detalle']}")
print(f" {v['nota']}")
return 0
print(f"== preflight del perfil (patrones: {', '.join(p.__name__ for p in PATRONES_ENTORNO)})")
print(f" {len(hits)} recetas dependen del merge para montar (apilado directo desbordaría):")
for nombre, v in hits[:25]:
print(f" ⚠ {nombre:28} {v['detalle']}")
print(f"\n son construibles HOY (el merge compacta), pero su montaje CUELGA de que store/.dmerge")
print(f" sea mismo-fs que el store. Si un host rompe eso, fallan silenciosas — pre-vuela antes.")
return 0
def _tiempos():
"""nombre → mediana de segundos de build, desde `store/.times/*.json` (sidecar no determinista,
lo escribe `scripts/farm/build-timed.sh` en el worker y llega con el rsync de cosecha). Vacío
hasta que corran builds instrumentados; los consumidores degradan a peso=1."""
from statistics import median
muestras = {}
for f in glob.glob(str(ROOT / "store/.times/*.json")):
try:
d = json.loads(open(f).read())
except Exception:
continue
if d.get("name") and isinstance(d.get("seconds"), (int, float)):
muestras.setdefault(d["name"], []).append(d["seconds"])
return {n: median(v) for n, v in muestras.items()}
def _camino_critico(nodos, deps_debt, peso):
"""Longest weighted path en el subgrafo de DEUDA (DP sobre topológico). El peso de un nudo = sus
segundos de build (1 si no hay dato). La suma sobre la cadena más larga = ETA con paralelismo
infinito: hay que construir esa cadena en SERIE. Devuelve (segundos, secuencia)."""
memo, camino = {}, {}
def cp(n):
if n in memo:
return memo[n]
mejor, via = 0, None
for d in deps_debt.get(n, ()):
c = cp(d)
if c > mejor:
mejor, via = c, d
memo[n] = peso(n) + mejor
camino[n] = via
return memo[n]
for n in nodos:
cp(n)
if not memo:
return 0, []
fin = max(memo, key=memo.get)
seq = []
x = fin
while x is not None:
seq.append(x); x = camino.get(x)
return memo[fin], list(reversed(seq))
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 _khipus():
"""TODOS los grafos de estado del disco, descubiertos y no escritos a mano.
La lista estaba codificada como `(kde, gnome, corpus)` en dos sitios y **omitía cosmic y wlr**,
que son dos de los cuatro escritorios. Es la misma forma del fallo que motivó este fichero (ver
la cabecera): medir contra un subconjunto de las colas y que el número salga chico sin que nada
avise. Un grafo nuevo entra solo; ninguno se puede olvidar.
"""
return sorted(glob.glob(str(ROOT / "docs/state/build-state*.json")))
def _estados_por_cola():
"""`{(cola, nombre): estado}` sobre todos los khipus.
Va por (COLA, nombre) y no por nombre: el mismo nombre vive en varias colas con recetas y hashes
DISTINTOS —`gdk-pixbuf` está en corpus, en incoming-kde y en incoming-wlr— así que un índice por
nombre devuelve el estado de otra receta con toda confianza. Eso es peor que no saber.
"""
estados = {}
for f in _khipus():
try:
nodos = json.loads(Path(f).read_text())["nodes"]
except Exception:
continue
for n, r in nodos.items():
q = r.get("queue")
if q:
estados.setdefault((q, n), r.get("state", "?"))
return estados
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 TODOS los khipus, indexados por (cola, nombre)
estados = _estados_por_cola()
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 nd in cerr if estados.get(nd) in DEUDA)
q, n = nodo
yo_deuda = estados.get(nodo) 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"]]
# CAMINO CRÍTICO PESADO: subgrafo de deuda, aristas dep entre nodos en deuda, peso = segundos de
# build. La cadena más larga = ETA con paralelismo infinito. Sin datos de tiempo, peso=1 y el
# camino crítico = nº de pasos (lo que ya daban las ondas). Con datos, es la ETA real en segundos.
tiempos = _tiempos()
idx = {q: set(m) for q, m in colas.items()}
deuda_set = {(q, n) for q in colas for n in colas[q] if estados.get((q, n)) in DEUDA}
deps_debt = {}
for (q, n) in deuda_set:
dd = set()
for d in colas.get(q, {}).get(n, []):
r = resolver(d, q, idx)
if r in deuda_set:
dd.add(r)
deps_debt[(q, n)] = dd
def peso(nodo):
return tiempos.get(nodo[1], 1 if not tiempos else _mediana_o_uno(tiempos))
seg, seq = _camino_critico(deuda_set, deps_debt, peso)
cubiertos = sum(1 for nodo in deuda_set if nodo[1] in tiempos)
critico = dict(con_datos=bool(tiempos), segundos=seg if tiempos else None,
pasos=len(seq), cobertura=f"{cubiertos}/{len(deuda_set)}",
secuencia=[f"{q}/{n}" for q, n in seq])
doc = dict(schema="hammer-keystones/3", nudos=filas[:80], en_deuda=len(accionables),
camino_critico=critico)
(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")
print(f"\n== CAMINO CRÍTICO (la cadena más larga; hay que construirla en SERIE aunque haya ∞ workers)")
if critico["con_datos"]:
m = critico["segundos"] // 60
print(f" ETA (paralelismo infinito): {m} min sobre {critico['pasos']} pasos "
f"· cobertura de tiempos {critico['cobertura']}")
else:
print(f" {critico['pasos']} pasos (SIN datos de tiempo aún ⇒ peso=1; instrumentá builds en")
print(f" el worker con campana-deuda.sh y el rsync de cosecha trae store/.times).")
if seq:
muestra = " → ".join(n.split("/")[-1] for n in critico["secuencia"][:8])
print(f" {muestra}" + (" → …" if len(seq) > 8 else ""))
print(f"\n → docs/state/keystones.json")
print(" nota: dominador-OR ingenuo NO sirve (build es AND); 'exclusivo' SÍ es dominador (retención).")
return 0
def _mediana_o_uno(tiempos):
from statistics import median
return median(tiempos.values()) if tiempos else 1
def _fuente(f):
"""(sha, url, version, build_fp) de una receta. Los tres primeros = identidad de lo que BAJA. El
cuarto = HUELLA DE BUILD (link, compiler, fases) — porque dos recetas con la MISMA fuente pero
build distinto NO son redundantes: son variantes deliberadas (corpus/dbus estático vs
incoming-kde/dbus dinámico para Qt6DBus). Ignorar esto hacía que el detector marcara `dbus` como
sombra redundante y casi borra una receta load-bearing (2026-07-22)."""
try:
d = tomllib.load(open(f, "rb"))
except Exception:
return (None, "", None, None, False)
s = d.get("source", {})
b = d.get("build", {})
ph = b.get("phases", {}) or {}
build_fp = (b.get("link"), b.get("compiler"),
tuple(sorted((k, str(v)) for k, v in ph.items())))
# ¿construye la FUENTE descargada, o es un CARRIER (stub/demo que genera todo inline y sólo presta
# el tarball)? Señal: invoca un sistema de build. itstool/-hello no ⇒ carrier; su colisión de sha
# es esperada, no un error de copiado. (2026-07-22: sin esto el detector marcaba 4 falsos bugs.)
blob = "\n".join(str(ph.get(k, "")) for k in ("configure", "compile", "install"))
# Strip comentarios shell (`# …` a fin de línea) ANTES de buscar: si no, la prosa de un stub
# ("# parsea la invocación de meson") matchea y marca al carrier como builder (falso positivo
# que el guardián cazó, 2026-07-22).
sin_comentarios = re.sub(r"#.*", "", blob)
construye = any(re.search(rx, sin_comentarios) for rx in
(r"\bmake\b", r"\bmeson\b", r"\bcmake\b", r"\bninja\b", r"\bcargo\b",
r"\./configure", r"\bautogen", r"\bautoreconf"))
return (s.get("sha256") or s.get("output_hash"),
s.get("tarball") or s.get("url") or s.get("git") or "",
d.get("version"), build_fp, construye)
def _recetas_con_fuente():
"""{(cola, nombre): {sha, url, version, build_fp, construye_fuente, deps}} sobre todo el disco."""
out = {}
for pat, es_incoming in (("recipes/*.toml", False), ("recipes/incoming-*/*.toml", True)):
for f in sorted(glob.glob(str(ROOT / pat))):
n = os.path.basename(f)[:-5]
q = os.path.basename(os.path.dirname(f)) if es_incoming else "corpus"
sha, url, ver, build_fp, construye = _fuente(f)
out[(q, n)] = dict(sha=sha, url=url, version=ver, build_fp=build_fp,
construye_fuente=construye, deps=_deps(f))
return out
def duplicados(como_json=False):
"""INVARIANTE (c): detecta nudos que son el MISMO paquete bajo nombres/colas distintos, por
EVIDENCIA de fuente (sha/url), no por nombre. Clasifica cada grupo:
colisión-fuente — mismo sha, nombres de familias DISTINTAS ⇒ casi seguro un sha copiado por
error (itstool tenía el tarball de gettext-tiny). ACCIONABLE.
variante — mismo sha, misma familia (`zlib`/`zlib-shared`, `mesa-*`) ⇒ deliberado, OK.
sombra — mismo NOMBRE en >1 cola. Redundante si versión+sha+deps coinciden; si no,
es una variante justificada de la cola.
NO funde nada solo: reporta y deja el juicio al humano (como el triaje). El radio de cada nudo
ayuda a elegir el canónico."""
recs = _recetas_con_fuente()
from collections import defaultdict
rev = dependientes()
# 1) grupos por sha (identidad de fuente)
por_sha = defaultdict(list)
for k, r in recs.items():
if r["sha"]:
por_sha[r["sha"]].append(k)
colisiones, variantes, carriers = [], [], []
for sha, ks in por_sha.items():
nombres = {n for _, n in ks}
if len(nombres) < 2:
continue
grupo = dict(sha=sha[:16], nudos=sorted(f"{q}/{n}" for q, n in ks),
radio={f"{q}/{n}": len(rev.get((q, n), ())) for q, n in ks})
builders = [k for k in ks if recs[k]["construye_fuente"]]
familias = {_base(n) for _, n in ks}
# Clasificación por evidencia, de más benigno a más sospechoso:
if len(builders) <= 1:
# ≤1 receta construye la fuente ⇒ el resto son CARRIERS que le prestan el tarball. Benigno.
grupo["carriers"] = sorted(f"{q}/{n}" for (q, n) in ks
if not recs[(q, n)]["construye_fuente"])
carriers.append(grupo)
elif len(familias) == 1:
variantes.append(grupo) # misma familia (zlib/zlib-shared): variante deliberada
elif len({recs[k]["build_fp"] for k in builders}) > 1:
variantes.append(grupo) # familias distintas pero BUILD distinto: variante cross-nombre
else: # ≥2 builders, MISMO build, familias distintas ⇒ sospechoso
colisiones.append(grupo)
# 2) sombras: mismo nombre en >1 cola
por_nombre = defaultdict(list)
for (q, n) in recs:
por_nombre[n].append(q)
sombras = []
for n, qs in por_nombre.items():
if len(qs) < 2:
continue
# Redundante SÓLO si coinciden fuente Y BUILD: mismo tarball no basta, dos builds distintos
# del mismo source (estático vs dinámico) son variantes deliberadas, no redundancia.
firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"], recs[(q, n)]["build_fp"],
tuple(sorted(recs[(q, n)]["deps"]))) for q in qs}
sombras.append(dict(nombre=n, colas=sorted(qs),
redundante=len(firmas) == 1, # idénticas en fuente+build ⇒ una sobra
radio={f"{q}/{n}": len(rev.get((q, n), ())) for q in qs}))
doc = dict(schema="hammer-duplicados/2",
colisiones_fuente=sorted(colisiones, key=lambda g: -max(g["radio"].values() or [0])),
variantes=sorted(variantes, key=lambda g: g["nudos"]),
carriers=sorted(carriers, key=lambda g: g["nudos"]),
sombras=sorted(sombras, key=lambda s: (not s["redundante"], s["nombre"])))
(ROOT / "docs/state/duplicados.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
red = [s for s in sombras if s["redundante"]]
print(f"== INVARIANTE (c) CANÓNICO — duplicados por evidencia de fuente\n")
print(f"⚠ COLISIONES DE FUENTE REALES: {len(colisiones)} (≥2 recetas construyen el MISMO source,")
print(f" mismo build, familias distintas — casi seguro un sha copiado por error)")
for g in doc["colisiones_fuente"]:
print(f" sha {g['sha']}{g['nudos']}")
print(f" radio: " + " ".join(f"{k.split('/')[-1]}={v}" for k, v in g["radio"].items()))
if not colisiones:
print(" (ninguna — el invariante (c) se cumple)")
print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)} (mismo nombre+fuente+BUILD en 2 colas — una sobra)")
for s in red:
print(f" {s['nombre']:24} {s['colas']}")
if not red:
print(" (ninguna)")
print(f"\n✓ BENIGNOS (no son problema):")
print(f" {len(carriers)} grupos CARRIER (stub/demo que presta el tarball: itstool, *-hello)")
print(f" {len(variantes)} variantes deliberadas (misma fuente, build/familia distintos: zlib-shared, prison-scanner)")
print(f" {len(sombras)-len(red)} sombras justificadas (mismo nombre, build distinto: dbus estático/dinámico)")
print(f"\n → docs/state/duplicados.json")
return 0
def resolver(dep, desde_cola, indice):
"""Sibling-first: (cola, nombre) del nudo que provee `dep` visto desde `desde_cola`, o None si
ninguna cola lo tiene (dep externa / nodo `wanted`). `indice` = {cola: set(nombres)}."""
if dep in indice.get(desde_cola, ()):
return (desde_cola, dep)
if dep in indice.get("corpus", ()):
return ("corpus", dep)
return None
def dependientes(colas=None):
"""{(cola,nombre): set((cola,nombre) que lo consumen)} — el mapa de dependencias INVERSAS sobre
el grafo entero. Este es el cálculo que, hecho sobre una sola cola, produjo el bug de libdrm."""
colas = colas or cargar_colas()
indice = {q: set(m) for q, m in colas.items()}
rev = {}
for q, miembros in colas.items():
for nombre, deps in miembros.items():
for d in deps:
objetivo = resolver(d, q, indice)
if objetivo:
rev.setdefault(objetivo, set()).add((q, nombre))
return rev
def membresia(colas=None, perfiles=None):
"""{(cola,nombre): set(perfiles que lo alcanzan)} sobre el grafo entero, respetando sibling-first.
Un nudo pertenece a una imagen si la clausura de sus raíces —resolviendo cada cuerda desde la
cola del consumidor— llega hasta él. Esto es lo que arregla la mentira de `perfiles`."""
colas = colas or cargar_colas()
perfiles = perfiles if perfiles is not None else targets_mod.load()
indice = {q: set(m) for q, m in colas.items()}
memb = {}
for pn, p in perfiles.items():
cola_p = p.get("cola", "corpus")
vistos, pila = set(), []
for raiz in p["raices"]:
nodo = resolver(raiz, cola_p, indice)
pila.append(nodo if nodo else ("wanted", raiz)) # raíz sin receta = nodo wanted
while pila:
nodo = pila.pop()
if nodo in vistos:
continue
vistos.add(nodo)
memb.setdefault(nodo, set()).add(pn)
q, nombre = nodo
if q == "wanted":
continue
for d in colas.get(q, {}).get(nombre, []):
sig = resolver(d, q, indice)
pila.append(sig if sig else ("wanted", d))
return memb
def _estado(nodo):
"""Estado del nudo desde el grafo firme, si está regenerado. Best-effort: el radio funciona sin él."""
return _estados_por_cola().get(nodo, "?")
def radio(nombre, como_json=False):
"""¿A QUIÉN rompo si re-hasheo `recipes/**/<nombre>.toml`? La pregunta que, mal medida, costó 118
recetas. Cruza TODAS las colas por construcción: es imposible que 'olvide' una cola."""
colas = cargar_colas()
indice = {q: set(m) for q, m in colas.items()}
# ¿En qué cola vive la receta que se va a tocar? Puede estar en varias (variante sombreada).
ubic = [(q, nombre) for q in colas if nombre in colas[q]]
if not ubic:
print(f"no hay receta `{nombre}` en ninguna cola", file=sys.stderr)
return 1
# ⚠ DOS GRAFOS, Y LA DIFERENCIA ES EL NÚMERO QUE LA GENTE USA PARA DECIDIR (2026-09-18).
# El de BUILD es el que propaga un re-hash; el de la unión (build+runtime) es el del cierre de
# una imagen. `radio` mezclaba los dos y contestaba «9 sellados caen a deuda» para `gcc-libs`,
# cuando la respuesta correcta es CERO: sus cinco consumidores la declaran en `runtime`, que no
# entra en `hash_inputs`. Ese número de más casi cuesta reconstruir firefox con PGO dos veces.
rev_b = dependientes(cargar_colas(solo_build=True)) # cascada de rebuild
rev_u = dependientes(colas) # cierre / imágenes
rev = rev_b
memb = membresia(colas, targets_mod.load())
resultado = {}
for nodo in ubic:
directos = sorted(rev.get(nodo, set()))
# Cierre inverso: quién depende, transitivamente (el radio real de un re-hash en cascada).
vistos, pila = set(), list(rev.get(nodo, set()))
while pila:
n = pila.pop()
if n in vistos:
continue
vistos.add(n)
pila.extend(rev.get(n, set()))
# Las imágenes salen del grafo de la UNIÓN: para que una imagen ARRANQUE hacen falta también
# las deps de runtime, aunque no fuercen rebuild.
vistos_u, pila_u = set(), list(rev_u.get(nodo, set()))
while pila_u:
n = pila_u.pop()
if n in vistos_u:
continue
vistos_u.add(n)
pila_u.extend(rev_u.get(n, set()))
imgs = set()
for n in vistos_u | {nodo}:
imgs |= memb.get(n, set())
solo_runtime = sorted(vistos_u - vistos)
# Cuántos de los transitivos YA están sellados: ésos son los que HOY están al día y el
# re-hash MANDA A DEUDA (los que ya están en deuda igual habría que reconstruirlos). El
# radio verdadero de rebuild forzado por el cambio.
sellados = sum(1 for n in vistos if _estado(n) == "sealed")
resultado[f"{nodo[0]}/{nodo[1]}"] = dict(
directos=len(directos), transitivos=len(vistos),
imagenes=sorted(imgs), sellados_que_caen=sellados,
solo_runtime=len(solo_runtime),
solo_runtime_nombres=[f"{q}/{n}" for q, n in solo_runtime][:12],
dependientes_por_cola=_por_cola(vistos))
if como_json:
print(json.dumps(resultado, indent=1, ensure_ascii=False))
return 0
for nodo, r in resultado.items():
print(f"== radio de tocar {nodo}")
print(f" dependientes directos {r['directos']} (por BUILD: los que un re-hash arrastra)")
print(f" dependientes transitivos {r['transitivos']} (el radio real de un re-hash en cascada)")
if r["solo_runtime"]:
print(f" sólo por RUNTIME {r['solo_runtime']} NO se reconstruyen "
f"(`runtime` está fuera de hash_inputs): " + ", ".join(r["solo_runtime_nombres"]))
print(f" por cola " +
" ".join(f"{q}={n}" for q, n in sorted(r["dependientes_por_cola"].items())))
print(f" IMÁGENES afectadas {', '.join(r['imagenes']) or '(ninguna declarada)'}")
print(f" sellados que CAEN a deuda {r['sellados_que_caen']} (hoy al día; el re-hash los fuerza a rebuild)")
if len(resultado) > 1:
print()
print("\n regla: esto cruza TODAS las colas. NO midas radio con `grep recipes/*.toml`.")
return 0
def _por_cola(nodos):
from collections import Counter
return dict(Counter(q for q, _ in nodos))
# Delegación a los órganos: yupana es la puerta, cada script sigue siendo el músculo. Se delega por
# subproceso (no import) para no acoplar sus ciclos de vida ni sus flags — cada uno evoluciona solo.
ORGANOS = {
"estado": ["build-state.py"],
"drenar": ["drenar.py"],
"triaje": ["triaje.py"],
"frontera": ["seed-graph.py", "--frontera"],
"objetivo": ["targets.py", "--lista"],
"sembrar": ["seed-graph.py", "--perfil"],
}
def delegar(cmd, extra):
return subprocess.call([sys.executable, str(ROOT / "scripts" / ORGANOS[cmd][0])]
+ ORGANOS[cmd][1:] + extra)
def main():
args = sys.argv[1:]
if not args or args[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd, extra = args[0], args[1:]
if cmd == "radio" and extra:
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 == "preflight" and extra:
obj = f"perfil:{extra[extra.index('--perfil') + 1]}" if "--perfil" in extra else extra[0]
return preflight(obj)
if cmd == "perfiles" and extra:
memb = membresia()
hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]}
print(json.dumps(hits, indent=1, ensure_ascii=False) if hits
else f"`{extra[0]}` no lo alcanza ninguna imagen declarada")
return 0
if cmd in ORGANOS:
return delegar(cmd, extra)
print(f"verbo desconocido: {cmd}\n{__doc__}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())