Files
hammer/scripts/yupana.py
T
sergioandClaude Opus 4.8 8c10c0a908 yupana: modelo EXTRA-GRAFO — preflight aprende los 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. Esos 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.

`yupana preflight <receta>` corre los patrones ANTES de construir. Primer patrón:
overlay-lowerdir, la cicatriz de kio. Habría marcado kio antes de quemar la
campaña descubriéndolo a los golpes:

  kio: 52 deps ⇒ lowerdir ~5346B > 4096B (una página) ⇒ depende del merge

CALIBRADO, no adivinado: el estimador daba corto (2974B "ok") hasta usar el
prefijo que bwrap VE en runtime (/oldroot/…, ruta del worker, no la del hub).
Con eso: kio estimado 5346B vs real 5315B — 31B de error, sin fudge. El primer
predictor "obvio" mentía con confianza; la lección de siempre.

preflight --perfil escritorio-kde destapa lo sistémico: las 40 recetas KDE
profundas CUELGAN del merge (plasma-desktop: 114 deps → 11708B, casi 3× el
límite). Todo el escritorio dependía del fix 6a0dc40. El pre-flight vuelve
VISIBLE y contable una fragilidad que antes sólo se manifestaba fallando.

Registro extensible (PATRONES_ENTORNO): futuros = capacidad-de-host (rootfs
laptop≠worker, techo MSRV — acumular observaciones), skew de toolchain (zig-skew
— fingerprint por artefacto). harkaq ya MIDE R2 (evidencia negativa); esto lo
vuelve consultable + pre-vuela. Guardián: kio marcado + estimador calibrado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:42:42 -04:00

733 lines
35 KiB
Python
Executable File
Raw 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
#
# 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():
"""{cola: {nombre: [deps de build]}} sobre TODAS las colas del disco. `corpus` = recipes/;
`incoming-<x>` = recipes/incoming-<x>/. Es el grafo completo, sin recortar por vista."""
colas = {}
for f in sorted(glob.glob(str(ROOT / "recipes/*.toml"))):
n = os.path.basename(f)[:-5]
colas.setdefault("corpus", {})[n] = _deps(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]] = _deps(f)
return colas
def _deps(f):
try:
return 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 hammer 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/hammer/store/")
_OVERHEAD_OVERLAY = len("upperdir=/tmp-overlay-upper-0,workdir=/tmp-overlay-work-0,") \
+ len("/oldroot/opt/hammer/.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 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"]]
# 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(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."""
q, nombre = nodo
fichero = "docs/state/build-state-kde.json" if q != "corpus" else "docs/state/build-state.json"
try:
d = json.loads((ROOT / fichero).read_text())
return d["nodes"].get(nombre, {}).get("state", "?")
except Exception:
return "?"
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
rev = dependientes(colas)
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()))
imgs = set()
for n in vistos | {nodo}:
imgs |= memb.get(n, set())
# 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,
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']}")
print(f" dependientes transitivos {r['transitivos']} (el radio real de un re-hash en cascada)")
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())