sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
yupana: rename khipu→yupana — el registro es el khipu, la yupana es el ábaco que reckona sobre él
1#!/usr/bin/env python3
2# yupana.py — la YUPANA del build: el ábaco que RECKONA sobre el estado de la distro.
3#
4# DOS OBJETOS ANDINOS, DOS ROLES. En los Andes el khipu GUARDABA (cuerdas anudadas que registran
5# cantidades y relaciones) y la yupana CALCULABA sobre ese registro (el ábaco inca). Acá es igual y
6# la distinción es la arquitectura, no adorno:
7# · `docs/state/build-state.json` es el KHIPU — el registro firme y versionado: los nudos
8# (recetas) y las cuerdas (deps), la única fuente de verdad de QUÉ hay y QUÉ falta.
9# · este módulo es la YUPANA — el motor que reckona sobre el khipu: a QUIÉN rompo si toco algo
10# (radio), qué imágenes dependen de un nudo (perfiles), en qué orden masticar (ondas).
11# Es la BASE metodológica y la PUERTA ÚNICA; el resto de los scripts de estado son sus órganos.
12# Antes este saber vivía disperso (en la cabeza, en `grep`, en docs que envejecen) y cada medición
13# a mano mentía distinto.
14#
15# POR QUÉ EXISTE ESTE FICHERO Y NO SÓLO build-state.py. El 2026-07-22 un cambio a `libdrm`
16# (default_library=both, para gtk4) invalidó 118 recetas KDE. El radio se midió contra
17# `recipes/*.toml` y NO contra `recipes/incoming-kde/` ⇒ dio 8 en vez de 118. `build-state.py` sin
18# `--kde` cometía EXACTAMENTE el mismo error: cargaba sólo el corpus, y su campo `perfiles` llegaba
19# a AFIRMAR que tocar `libdrm` sólo afectaba a `escritorio-mirada` — una mentira con confianza. La
20# causa es estructural: las dependencias INVERSAS son un hecho del disco entero, no de la vista que
21# uno cargó. Este módulo las calcula SIEMPRE sobre TODAS las colas, y es de donde build-state saca
22# `perfiles`/`dependientes`. La regla, en una línea:
23#
24# QUÉ NODOS PUNTÚO es una decisión de vista. QUIÉN ME CONSUME es un hecho — nunca depende de la vista.
25#
26# RESOLUCIÓN SIBLING-FIRST. Una dep `libdrm` desde una receta de `incoming-kde` resuelve a
27# `incoming-kde/libdrm` si existe, y si no cae al `corpus/libdrm` (igual que el sandbox al montar las
28# capas). Por eso los nodos se identifican por PAR `(cola, nombre)`, no por nombre: `corpus/fontconfig`
29# y `incoming-kde/fontconfig` son nudos distintos y romper uno no rompe a los consumidores del otro.
30#
31# yupana es la PUERTA ÚNICA de la metodología. Sus verbos son los órganos del ciclo, en el orden en
32# que se usan (el mismo que la cadena del plan docs/plan-catalogo-objetivo.md):
33#
34# radio <receta> ¿a QUIÉN rompo si toco esto? (nativo; cruza TODAS las colas) ← ANTES de cambiar nada
35# perfiles <receta> ¿qué imágenes dependen de esto? (nativo)
36# estado regenera el grafo firme → build-state.py
37# objetivo qué debe tener cada imagen → targets.py --lista
38# frontera <perfil> qué pide upstream y no tenemos → seed-graph.py --frontera
39# triaje clasificar la frontera → triaje.py
40# drenar [--perfil] el orden de masticado, en ondas → drenar.py
41#
42# Uso: scripts/yupana.py radio <receta> [--json]
43# scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | objetivo
44# Como módulo: from yupana import cargar_colas, membresia, dependientes, resolver
45import glob, json, os, subprocess, sys, tomllib
46from pathlib import Path
47
48ROOT = Path(__file__).resolve().parent.parent
49sys.path.insert(0, str(ROOT / "scripts"))
50import targets as targets_mod
51
52
53def cargar_colas():
54 """{cola: {nombre: [deps de build]}} sobre TODAS las colas del disco. `corpus` = recipes/;
55 `incoming-<x>` = recipes/incoming-<x>/. Es el grafo completo, sin recortar por vista."""
56 colas = {}
57 for f in sorted(glob.glob(str(ROOT / "recipes/*.toml"))):
58 n = os.path.basename(f)[:-5]
59 colas.setdefault("corpus", {})[n] = _deps(f)
60 for f in sorted(glob.glob(str(ROOT / "recipes/incoming-*/*.toml"))):
61 q = os.path.basename(os.path.dirname(f))
62 colas.setdefault(q, {})[os.path.basename(f)[:-5]] = _deps(f)
63 return colas
64
65
66def _deps(f):
67 try:
68 return tomllib.load(open(f, "rb")).get("deps", {}).get("build", []) or []
69 except Exception:
70 return []
71
72
73def resolver(dep, desde_cola, indice):
74 """Sibling-first: (cola, nombre) del nudo que provee `dep` visto desde `desde_cola`, o None si
75 ninguna cola lo tiene (dep externa / nodo `wanted`). `indice` = {cola: set(nombres)}."""
76 if dep in indice.get(desde_cola, ()):
77 return (desde_cola, dep)
78 if dep in indice.get("corpus", ()):
79 return ("corpus", dep)
80 return None
81
82
83def dependientes(colas=None):
84 """{(cola,nombre): set((cola,nombre) que lo consumen)} — el mapa de dependencias INVERSAS sobre
85 el grafo entero. Este es el cálculo que, hecho sobre una sola cola, produjo el bug de libdrm."""
86 colas = colas or cargar_colas()
87 indice = {q: set(m) for q, m in colas.items()}
88 rev = {}
89 for q, miembros in colas.items():
90 for nombre, deps in miembros.items():
91 for d in deps:
92 objetivo = resolver(d, q, indice)
93 if objetivo:
94 rev.setdefault(objetivo, set()).add((q, nombre))
95 return rev
96
97
98def membresia(colas=None, perfiles=None):
99 """{(cola,nombre): set(perfiles que lo alcanzan)} sobre el grafo entero, respetando sibling-first.
100 Un nudo pertenece a una imagen si la clausura de sus raíces —resolviendo cada cuerda desde la
101 cola del consumidor— llega hasta él. Esto es lo que arregla la mentira de `perfiles`."""
102 colas = colas or cargar_colas()
103 perfiles = perfiles if perfiles is not None else targets_mod.load()
104 indice = {q: set(m) for q, m in colas.items()}
105 memb = {}
106 for pn, p in perfiles.items():
107 cola_p = p.get("cola", "corpus")
108 vistos, pila = set(), []
109 for raiz in p["raices"]:
110 nodo = resolver(raiz, cola_p, indice)
111 pila.append(nodo if nodo else ("wanted", raiz)) # raíz sin receta = nodo wanted
112 while pila:
113 nodo = pila.pop()
114 if nodo in vistos:
115 continue
116 vistos.add(nodo)
117 memb.setdefault(nodo, set()).add(pn)
118 q, nombre = nodo
119 if q == "wanted":
120 continue
121 for d in colas.get(q, {}).get(nombre, []):
122 sig = resolver(d, q, indice)
123 pila.append(sig if sig else ("wanted", d))
124 return memb
125
126
127def _estado(nodo):
128 """Estado del nudo desde el grafo firme, si está regenerado. Best-effort: el radio funciona sin él."""
129 q, nombre = nodo
130 fichero = "docs/state/build-state-kde.json" if q != "corpus" else "docs/state/build-state.json"
131 try:
132 d = json.loads((ROOT / fichero).read_text())
133 return d["nodes"].get(nombre, {}).get("state", "?")
134 except Exception:
135 return "?"
136
137
138def radio(nombre, como_json=False):
139 """¿A QUIÉN rompo si re-hasheo `recipes/**/<nombre>.toml`? La pregunta que, mal medida, costó 118
140 recetas. Cruza TODAS las colas por construcción: es imposible que 'olvide' una cola."""
141 colas = cargar_colas()
142 indice = {q: set(m) for q, m in colas.items()}
143 # ¿En qué cola vive la receta que se va a tocar? Puede estar en varias (variante sombreada).
144 ubic = [(q, nombre) for q in colas if nombre in colas[q]]
145 if not ubic:
146 print(f"no hay receta `{nombre}` en ninguna cola", file=sys.stderr)
147 return 1
148 rev = dependientes(colas)
149 memb = membresia(colas, targets_mod.load())
150
151 resultado = {}
152 for nodo in ubic:
153 directos = sorted(rev.get(nodo, set()))
154 # Cierre inverso: quién depende, transitivamente (el radio real de un re-hash en cascada).
155 vistos, pila = set(), list(rev.get(nodo, set()))
156 while pila:
157 n = pila.pop()
158 if n in vistos:
159 continue
160 vistos.add(n)
161 pila.extend(rev.get(n, set()))
162 imgs = set()
163 for n in vistos | {nodo}:
164 imgs |= memb.get(n, set())
165 # Cuántos de los transitivos YA están sellados: ésos son los que HOY están al día y el
166 # re-hash MANDA A DEUDA (los que ya están en deuda igual habría que reconstruirlos). El
167 # radio verdadero de rebuild forzado por el cambio.
168 sellados = sum(1 for n in vistos if _estado(n) == "sealed")
169 resultado[f"{nodo[0]}/{nodo[1]}"] = dict(
170 directos=len(directos), transitivos=len(vistos),
171 imagenes=sorted(imgs), sellados_que_caen=sellados,
172 dependientes_por_cola=_por_cola(vistos))
173
174 if como_json:
175 print(json.dumps(resultado, indent=1, ensure_ascii=False))
176 return 0
177 for nodo, r in resultado.items():
178 print(f"== radio de tocar {nodo}")
179 print(f" dependientes directos {r['directos']}")
180 print(f" dependientes transitivos {r['transitivos']} (el radio real de un re-hash en cascada)")
181 print(f" por cola " +
182 " ".join(f"{q}={n}" for q, n in sorted(r["dependientes_por_cola"].items())))
183 print(f" IMÁGENES afectadas {', '.join(r['imagenes']) or '(ninguna declarada)'}")
184 print(f" sellados que CAEN a deuda {r['sellados_que_caen']} (hoy al día; el re-hash los fuerza a rebuild)")
185 if len(resultado) > 1:
186 print()
187 print("\n regla: esto cruza TODAS las colas. NO midas radio con `grep recipes/*.toml`.")
188 return 0
189
190
191def _por_cola(nodos):
192 from collections import Counter
193 return dict(Counter(q for q, _ in nodos))
194
195
196# Delegación a los órganos: yupana es la puerta, cada script sigue siendo el músculo. Se delega por
197# subproceso (no import) para no acoplar sus ciclos de vida ni sus flags — cada uno evoluciona solo.
198ORGANOS = {
199 "estado": ["build-state.py"],
200 "drenar": ["drenar.py"],
201 "triaje": ["triaje.py"],
202 "frontera": ["seed-graph.py", "--frontera"],
203 "objetivo": ["targets.py", "--lista"],
204 "sembrar": ["seed-graph.py", "--perfil"],
205}
206
207
208def delegar(cmd, extra):
209 return subprocess.call([sys.executable, str(ROOT / "scripts" / ORGANOS[cmd][0])]
210 + ORGANOS[cmd][1:] + extra)
211
212
213def main():
214 args = sys.argv[1:]
215 if not args or args[0] in ("-h", "--help"):
216 print(__doc__)
217 return 0
218 cmd, extra = args[0], args[1:]
219 if cmd == "radio" and extra:
220 return radio(extra[0], como_json="--json" in extra)
221 if cmd == "perfiles" and extra:
222 memb = membresia()
223 hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]}
224 print(json.dumps(hits, indent=1, ensure_ascii=False) if hits
225 else f"`{extra[0]}` no lo alcanza ninguna imagen declarada")
226 return 0
227 if cmd in ORGANOS:
228 return delegar(cmd, extra)
229 print(f"verbo desconocido: {cmd}\n{__doc__}", file=sys.stderr)
230 return 1
231
232
233if __name__ == "__main__":
234 sys.exit(main())