tawasuyu

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…

TOML 1534Shell 199Rust 109Markdown 993713 Definitions

⎇ 5ac10fb3bab477cf6b9788aa5e4f0bab03d53ec1 scripts/cierre-perfiles.py Python · 223 lines · 12.2 KB History Who touched what raw

fix(estado): el digest de la imagen y el de la vigía no coincidían — por el prefijo `b3:` a39110b50 · Sergio ·

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())