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…
⎇ c16db9a212fe4949bdce5374de96ed1ceffa47da
scripts/cierre-perfiles.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
fix(estado): el digest de la imagen y el de la vigía no coincidían — por el prefijo `b3:`
1#!/usr/bin/env python3
2# cierre-perfiles.py — el HASH DE LA CLAUSURA DE IMAGEN de cada perfil, sin construir nada.
3#
4# ══ QUÉ CONTESTA, Y POR QUÉ FALTABA ═══════════════════════════════════════════════════════════
5# El SDD 33 §4 decide **cuándo rearmar los medios: por el CIERRE, no por el calendario.**
6#
7# por cada perfil publicado:
8# h = hash de la clausura del perfil
9# si h ≠ el hash de la imagen publicada:
10# rearmar, probar con los tres arneses, publicar
11#
12# Un cron semanal que rearma imágenes gasta horas de worker para producir, casi siempre, los mismos
13# bytes. El disparador correcto es que la clausura haya cambiado — pero **ese `h` no existía**: el
14# grafo sabe el hash de cada ARTEFACTO y nadie resumía el conjunto. Esto lo calcula. No construye,
15# no descarga y no publica.
16#
17# ══ ⚠ DE QUÉ CONJUNTO, QUE ES LA PARTE QUE SE PUEDE ERRAR SIN ENTERARSE ═══════════════════════
18# **Del cierre de IMAGEN (`yupana.membresia_imagen`), no del de build.** La primera versión de este
19# guion (2026-10-10, mañana) usó el campo `perfiles` de `build-state.json`, y estaba mal por DOS
20# motivos independientes, los dos medidos el mismo día:
21#
22# 1. **`build ∪ runtime` ≠ lo que va en la imagen.** El cierre de un perfil se calcula sobre las
23# dos aristas porque para CONSTRUIR hacen falta las dos, pero **la imagen no se construye a sí
24# misma**: `llvm21`, `go`, `python3`, `binutils`, `cmake`, `perl`… son `[solo-build]`. Son el
25# **44 % de los bytes de `servidor`** (medido el 2026-09-22, ver `yupana.membresia_imagen`).
26# Con el conjunto equivocado, tocar una herramienta de build movería el digest y diría «la
27# imagen quedó rancia» cuando sus bytes no cambian. Un disparador de rearmado que se enciende
28# por algo que no entra en la imagen es exactamente el calendario que el §4 quiere evitar.
29# base 86 → 69 · cli 153 → 133 · servidor 186 → 166 · escritorio-kde 445 → 411
30#
31# 2. **El campo `perfiles` NO es la fuente de verdad, y `yupana` lo dice en su propio docstring**
32# («esto es lo que arregla la mentira de `perfiles`»). Se calcula por grafo, así que para los
33# escritorios queda truncado: `escritorio-kde` son **256** en `build-state.json`, **431** en
34# `build-state-kde.json` y **445** de verdad. Leer «el grafo de su cola» mitiga el síntoma y no
35# lo arregla: la membresía hay que resolverla sobre el grafo entero, respetando sibling-first,
36# y eso es justo lo que `yupana.membresia()` hace.
37#
38# ⇒ La membresía sale de `yupana`; los hashes, de los grafos, indexados por **(cola, nombre)** y no
39# por nombre a secas — porque `openssl` es una receta distinta en `corpus` y en `incoming-kde`.
40# Comprobado antes de confiar en el mapa: 1.288 pares y **cero hashes contradictorios** entre los
41# cinco grafos.
42#
43# ⚠ **Lo que sigue faltando del §4: que la imagen publicada declare de qué clausura salió.** Hoy
44# ninguna imagen declara nada —no se escribe ni un `/etc/os-release`— y además no hay imágenes
45# publicadas. Lo que esto deja listo es el lado nuestro: el `git log` de `docs/state/cierres.json`
46# ES la señal «este perfil se movió ⇒ su imagen quedó rancia».
47#
48# Uso: scripts/cierre-perfiles.py [--json docs/state/cierres.json] [--contra <fichero>]
49# La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano.
50import argparse, collections, glob, hashlib, json, os, sys
51
52RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
53sys.path.insert(0, os.path.join(RAIZ, 'scripts'))
54import yupana # noqa: E402
55
56ESTADO = os.path.join(RAIZ, 'docs', 'state')
57
58
59def mapa_hashes():
60 """{(cola, nombre): (hash, estado)} desde los cinco grafos.
61
62 Por (cola, nombre) y no por nombre: `openssl` es una receta distinta en `corpus` y en
63 `incoming-kde`, y colapsarlas daría el hash de la que apareciera primero. Se comprobó que los
64 grafos no se contradicen —1.288 pares, 0 conflictos— y si algún día se contradicen hay que
65 enterarse, así que el conflicto se devuelve en vez de resolverse a dedo.
66 """
67 m, conflictos = {}, []
68 for f in sorted(glob.glob(os.path.join(ESTADO, 'build-state*.json'))):
69 if not f.endswith('.json'):
70 continue
71 with open(f, encoding='utf-8') as fh:
72 g = json.load(fh)
73 for n, v in g['nodes'].items():
74 k = (v.get('queue'), n)
75 par = (v.get('hash'), v.get('state'))
76 if k in m and m[k] != par:
77 conflictos.append((k, m[k], par, os.path.basename(f)))
78 m[k] = par
79 return m, conflictos
80
81
82# ══ LA FUNCIÓN COMPARTIDA, Y POR QUÉ ES TAN CORTA ═════════════════════════════════════════════
83# `hydrate-profile.py` la importa para escribir el digest DENTRO de la imagen que acaba de armar.
84# Si hubiera dos implementaciones, el número de la imagen y el que esta vigía predice divergirían
85# sin que nada lo dijera — y entonces la comparación del §4 («h ≠ el de la imagen publicada»)
86# diría «rearmá» por una diferencia de código y no de contenido. Por eso es UNA función, de tres
87# líneas, en vez de dos «equivalentes».
88def digest_de(items):
89 """sha256 sobre `cola/nombre hash`, ORDENADO. `items` = iterable de (cola, nombre, hash).
90
91 Ordenado porque el orden de un dict no es contrato y sin ordenar el mismo cierre daría dos
92 números. Por (cola, nombre) y no por nombre porque `openssl` es una receta distinta en `corpus`
93 y en `incoming-kde`. Y `sha256:` y no `b3:` porque esto resume un conjunto y no direcciona nada
94 en el store: reusar el prefijo del ArtifactHash invitaría a buscarlo ahí.
95 """
96 # ⚠ EL PREFIJO `b3:` SE NORMALIZA ACÁ, Y ES UN BUG MEDIDO, NO UNA PRECAUCIÓN. La vigía lee el
97 # hash del grafo, que lo guarda como `b3:7373…`; `hydrate-profile.py` lo saca de `takana hash`
98 # y le quita el prefijo. **Mismo hash, cadena distinta, digest distinto** — los dos números
99 # salieron diferentes la primera vez que se hidrató de verdad para compararlos, con los cinco
100 # hashes IDÉNTICOS uno a uno. Que la función normalice es lo único que impide que un llamador
101 # futuro vuelva a pisarlo: una función compartida que exige un formato convenido de palabra ya
102 # son dos implementaciones otra vez.
103 h = hashlib.sha256()
104 for cola, nombre, hh in sorted((c, n, (x or '').replace('b3:', '')) for c, n, x in items):
105 h.update(('%s/%s b3:%s\n' % (cola, nombre, hh)).encode())
106 return 'sha256:' + h.hexdigest()
107
108
109def cierres():
110 por_perfil = collections.defaultdict(set)
111 for clave, perfiles in yupana.membresia_imagen().items():
112 for p in perfiles:
113 por_perfil[p].add(clave)
114 hashes, conflictos = mapa_hashes()
115
116 filas = []
117 for perfil in sorted(por_perfil):
118 miembros = sorted(por_perfil[perfil])
119 det = {'perfil': perfil, 'nodos': len(miembros), 'vista': 'imagen'}
120
121 # ── `wanted` FUERA DEL DIGEST, y esto es alineación con quien manda ────────────────────
122 # `wanted` = declarado por el perfil y sin receta que lo provea (`escritorio-kde` declara
123 # `xwayland`). **`hydrate-profile.py` los excluye explícitamente** (`n[0] != "wanted"`), así
124 # que no van en la imagen — y un digest que los incluyera NUNCA coincidiría con el que la
125 # imagen declara, que es justo para lo que existe este número. Se informan al lado, no
126 # adentro: la información no se pierde y la comparación no se rompe.
127 #
128 # Medido el 2026-10-10: en los diez perfiles, el ÚNICO miembro no construible de cualquier
129 # cierre de imagen es `wanted/xwayland`. Ni un `ajeno`, ni un desconocido. Por eso cualquier
130 # otro caso que no se pueda hashear se trata como clausura INCOMPLETA y sin digest — que es
131 # también lo que `hydrate` hace: aborta sin `--allow-missing`.
132 wanted = sorted('%s/%s' % k for k in miembros if k[0] == 'wanted')
133 resto = [k for k in miembros if k[0] != 'wanted']
134 det['wanted'] = wanted
135 det['nodos_en_digest'] = len(resto)
136
137 impedidos = sorted('%s/%s (%s)' % (k[0], k[1], 'sin entrada en ningún grafo' if k not in hashes
138 else 'sin hash' if not hashes[k][0]
139 else 'estado=%s' % hashes[k][1])
140 for k in resto
141 if k not in hashes or not hashes[k][0] or hashes[k][1] != 'sealed')
142 if impedidos:
143 det['estado'] = 'incompleto'
144 det['impedidos'] = impedidos[:10]
145 det['n_impedidos'] = len(impedidos)
146 det['cierre'] = None
147 filas.append(det)
148 continue
149
150 det['estado'] = 'ok'
151 det['cierre'] = digest_de((k[0], k[1], hashes[k][0]) for k in resto)
152 filas.append(det)
153 return filas, conflictos
154
155
156def main():
157 ap = argparse.ArgumentParser()
158 ap.add_argument('--json', help='escribir el informe en este fichero')
159 ap.add_argument('--contra', help='comparar contra un informe anterior y decir qué se movió')
160 a = ap.parse_args()
161
162 filas, conflictos = cierres()
163 previo = {}
164 ruta_previa = a.contra or a.json
165 if ruta_previa and os.path.exists(ruta_previa):
166 try:
167 with open(ruta_previa, encoding='utf-8') as f:
168 prev = json.load(f)
169 # Sólo se compara contra un informe de la MISMA vista: el digest de `build ∪ runtime`
170 # y el de la imagen son números distintos del mismo perfil, y compararlos diría «se
171 # movió» en el primer ciclo tras el cambio de vista, que es una alarma falsa.
172 if prev.get('vista') == 'imagen':
173 previo = {r['perfil']: r.get('cierre') for r in prev['perfiles']}
174 except Exception:
175 previo = {}
176
177 ok = [f for f in filas if f['estado'] == 'ok']
178 print('== cierre de IMAGEN por perfil: %d de %d con clausura completa ==' % (len(ok), len(filas)))
179 if conflictos:
180 print(' ⚠ %d par(es) (cola,nombre) con HASH CONTRADICTORIO entre grafos — mirarlo:'
181 % len(conflictos))
182 for k, viejo, nuevo, f in conflictos[:5]:
183 print(' %s/%s: %s vs %s (%s)' % (k[0], k[1], viejo[0], nuevo[0], f))
184 movidos = []
185 for f in filas:
186 marca = ''
187 if f['estado'] == 'ok' and previo.get(f['perfil']):
188 if previo[f['perfil']] != f['cierre']:
189 marca = ' ← SE MOVIÓ (su imagen quedó rancia)'
190 movidos.append(f['perfil'])
191 else:
192 marca = ' (igual que la vez anterior)'
193 if f['estado'] == 'ok':
194 extra = ''
195 if f.get('wanted'):
196 # Se dice en el informe, no sólo en el json: un perfil que declara algo que nadie
197 # provee es un dato, y esconderlo detrás de un digest «completo» lo perdería.
198 extra = ' · declara sin receta: %s' % ', '.join(f['wanted'])
199 print(' %-20s %5d nodos %s…%s%s'
200 % (f['perfil'], f['nodos_en_digest'], f['cierre'][:22], marca, extra))
201 else:
202 print(' %-20s %5d incompleto: %d nodo(s) sin hashear; p.ej. %s'
203 % (f['perfil'], f['nodos'], f['n_impedidos'], f['impedidos'][0]))
204
205 if movidos:
206 print('\n %d perfil(es) con la clausura movida ⇒ rearmar su imagen, probar con los tres '
207 'arneses y publicar (SDD 33 §4):' % len(movidos))
208 for m in movidos:
209 print(' %s' % m)
210
211 if a.json:
212 with open(a.json, 'w', encoding='utf-8') as f:
213 json.dump({'schema': 'takana-cierres/2', 'vista': 'imagen',
214 'perfiles': filas, 'completos': len(ok), 'total': len(filas),
215 'conflictos_de_hash': len(conflictos),
216 'movidos_vs_anterior': movidos}, f, ensure_ascii=False, indent=1)
217 print('\n informe: %s' % a.json)
218 # Sale 0 siempre: esto INFORMA. Que una clausura se mueva es lo normal cuando se trabaja.
219 return 0
220
221
222if __name__ == '__main__':
223 sys.exit(main())