khipu: el motor de dependencias inversas que cruza TODAS las colas (y arregla la mentira de perfiles)

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/ ⇒ 8 en vez de 118. Y build-state.py sin --kde cometía el
MISMO error: cargaba sólo el corpus, y su campo `perfiles` AFIRMABA que tocar
libdrm sólo afectaba a escritorio-mirada. Una mentira con confianza.

Causa estructural: las dependencias INVERSAS son un hecho del disco entero, no
de la vista que uno cargó. khipu.py las calcula SIEMPRE sobre todas las colas,
con resolución sibling-first (el nodo se identifica por (cola, nombre), porque
corpus/fontconfig y incoming-kde/fontconfig son nudos distintos).

  QUÉ NODOS PUNTÚO es decisión de vista. QUIÉN ME CONSUME es un hecho.

khipu es la PUERTA ÚNICA de la metodología: radio/perfiles nativos (cruzan todo
el repo) + delega estado/drenar/triaje/frontera/objetivo a sus órganos.

  khipu radio libdrm → 126 directos, 132 transitivos, [escritorio-kde,
                        escritorio-mirada], 11 sellados que caen. El número
                        honesto que habría frenado el cambio.

build-state.py ahora saca `perfiles` y el nuevo `dependientes_total` de khipu
(grafo entero), no de los nodos que cargó. Verificado: el grafo por defecto ya
atribuye libdrm a escritorio-kde; sin_perfil bajó 674→666 (8 recetas del corpus
que sólo KDE consume, ahora bien atribuidas). --check sigue en 0, grafo cierra.

Repo-wide por diseño, no KDE-céntrico: zlib toca las 4 imágenes (233 transitivos),
make 313.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 18:43:40 -04:00
co-authored by Claude Opus 4.8
parent ea5b27dba4
commit 8da6ef6122
2 changed files with 252 additions and 10 deletions
+22 -10
View File
@@ -40,6 +40,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import targets as targets_mod
import khipu # el motor de dependencias INVERSAS: cruza TODAS las colas (ver por qué en su cabecera)
ROOT = Path(__file__).resolve().parent.parent
os.chdir(ROOT)
@@ -220,18 +221,29 @@ def main():
r["unblocks"] = sum(1 for m, rm in recs.items()
if rm["state"] in DEBT_STATES and n in rm.get("deps", []))
# MEMBRESÍA DE PERFIL: qué imágenes alcanzan cada nodo. `perfiles: []` = no lo necesita ninguna
# imagen declarada (catálogo, no distro) — el dato incómodo que hasta ahora nadie medía.
for r in recs.values():
r["perfiles"] = []
# MEMBRESÍA DE PERFIL y DEPENDIENTES INVERSOS: los dos se calculan sobre el GRAFO ENTERO (todas
# las colas del disco), NO sobre los nodos que esta vista cargó. Es la corrección del bug de
# libdrm: en la corrida del corpus, `perfiles` llegaba a decir `['escritorio-mirada']` sobre un
# paquete que sostiene KDE entero, porque no veía las recetas de incoming-kde. khipu las ve
# siempre y resuelve sibling-first ⇒ el nodo se identifica por (cola, nombre). Ver khipu.py.
todas = khipu.cargar_colas()
memb = khipu.membresia(todas, cargar_perfiles({q for q in todas})) # TODOS los perfiles, no sólo los de esta vista
rev = khipu.dependientes(todas)
for n, r in recs.items():
clave = (r.get("queue", "corpus"), n)
r["perfiles"] = sorted(memb.get(clave, ()))
# Dependientes inversos DEL GRAFO ENTERO: cuántas recetas (de cualquier cola) me consumen. Es
# el radio real de un re-hash. `unblocks` (abajo) es local a la deuda de esta vista; este no.
r["dependientes_total"] = len(rev.get(clave, ()))
sin_perfil = sum(1 for r in recs.values() if not r["perfiles"])
# `by_profile` (el "cuánto falta" por imagen) SÍ es local: sólo cuenta estados de nodos que esta
# vista cargó ⇒ se limita a los perfiles cuya cola está cargada (los demás darían conteos falsos).
by_profile = {}
for pn, p in sorted(perfiles.items()):
cl = clausura(p["raices"], recs)
for n in sorted(cl):
recs[n]["perfiles"].append(pn)
by_profile[pn] = dict(raices=len(p["raices"]), clausura=len(cl),
**Counter(recs[n]["state"] for n in cl))
sin_perfil = sum(1 for r in recs.values() if not r["perfiles"])
miembros = [n for n, r in recs.items() if pn in r["perfiles"]]
by_profile[pn] = dict(raices=len(p["raices"]), clausura=len(miembros),
**Counter(recs[n]["state"] for n in miembros))
by_state = Counter(r["state"] for r in recs.values())
by_cls = Counter(r["cls"] for r in recs.values() if "cls" in r)
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
# khipu.py — el KHIPU del build: el registro vivo del estado de la distro y el motor de consultas
# sobre su grafo. Es la BASE metodológica; el resto de los scripts de estado son sus órganos.
#
# QUÉ ES UN KHIPU. El dispositivo andino de contabilidad: cuerdas anudadas que registran cantidades
# y relaciones. Este es el khipu del build — cada receta es un nudo, cada dep una cuerda, y el
# conjunto es la única fuente de verdad sobre QUÉ hay, QUÉ falta, QUIÉN depende de quién y A QUIÉN
# rompo si toco algo. Antes ese 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.
#
# khipu 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)
# 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/khipu.py radio <receta> [--json]
# scripts/khipu.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | objetivo
# Como módulo: from khipu import cargar_colas, membresia, dependientes, resolver
import glob, json, os, 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 []
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: khipu 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 == "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())