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:
+22
-10
@@ -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)
|
||||
|
||||
Executable
+230
@@ -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())
|
||||
Reference in New Issue
Block a user