gioser

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 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

fix(estado): el cierre de perfil medía build∪runtime, y el §4 pregunta por la IMAGEN

La versión de esta mañana sumaba al digest las herramientas de `[solo-build]` — `llvm21`, `go`,
`python3`, `binutils`, `cmake`, `perl`—, que **no van en la imagen**: la imagen no se construye a
sí misma. Entre 17 y 35 nodos por perfil, en pares (cola,nombre):

```
perfil               build∪runtime  imagen   solo-build de más
base                           86      69                  17
cli                           153     133                  20
escritorio-cosmic             296     261                  35
escritorio-gnome              369     337                  32
escritorio-kde                445     411                  34
escritorio-mirada              41      23                  18
escritorio-sway               282     249                  33
servidor                      186     166                  20
servidor-minimo               117      99                  18
```

Con el conjunto equivocado, tocar un compilador movía el digest y decía «la imagen quedó rancia»
cuando sus bytes no cambian — un disparador de rearmado que se enciende por algo que no entra en
la imagen es el calendario que el §4 quiere evitar. Ahora la membresía sale de
`yupana.membresia_imagen()`, que es la vista que `hydrate-profile.py` ya proyecta al FHS: una sola
fuente para «qué lleva esta imagen», en vez de dos que divergen.

**Y dos correcciones a lo que escribí esta mañana, porque las dos eran mías:**

1. **El control de ayer era más débil de lo que lo presenté.** Dije que mover `bash` «prueba la
   detección y la regla del grafo por cola a la vez» porque movía 4 perfiles y dejaba quietos los
   4 escritorios. Falso: sólo perturbé UN grafo, y cada escritorio lee otro. En la realidad los
   grafos se regeneran todos del mismo store, así que un `bash` distinto movería los 8 — que es lo
   que hace la versión nueva. El control probaba mi particionado, no la detección.

2. **El campo `perfiles` NO era el problema.** Lo acusé de «mentira» citando el docstring de
   `yupana`, y medido: `yupana.membresia()` da 431 para `escritorio-kde` y el campo `perfiles` del
   grafo de su cola da **431 también** — cero diferencia. Los «445» que leí eran pares
   (cola,nombre) contra nombres: la misma membresía contada en dos unidades. Leer el grafo de la
   cola correcta ya daba la membresía correcta. Usar `yupana` sigue siendo mejor —una sola
   autoridad, sin la pregunta «¿qué grafo?»— pero no por lo que dije.

**Y un tercer caso que la vista de imagen destapa:** `escritorio-kde` declara `wanted/xwayland`,
que **no tiene receta en ningún sitio** (`yupana` lo llava con cola `wanted`; build-state lo llama
`ajeno` en `incoming-kde` — el mismo nodo con dos etiquetas). No se le puede pedir hash y no aporta
bytes: no hay nada que instalar. Entra al digest por NOMBRE con su marca —si mañana alguien le
escribe la receta, la marca pasa a hash y el digest se mueve— y el informe lo DICE («declara sin
receta: wanted/xwayland») en vez de esconderlo detrás de un «completo». Negarle el digest por eso
habría dejado al escritorio más grande eternamente incompleto por un nodo, que es volver inútil un
informe correcto — el mismo error que ya había cometido con los `ajeno`.

Los hashes se indexan por **(cola, nombre)** y no por nombre: `openssl` es una receta distinta en
`corpus` y en `incoming-kde`. Comprobado antes de confiar en el mapa: 1.288 pares, **cero hashes
contradictorios** entre los cinco grafos — y si algún día se contradicen, el informe lo grita en
vez de resolverlo a dedo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

19ed361e048a5f83905f12fae9660f55d2c66a29 · ruwaq Sergio · (12 p'unchaw ñawpaq) · tayta 0b9220d82 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 5

scripts/cierre-perfiles.py

1 qillqakuna · +145 −103

diff --git a/scripts/cierre-perfiles.py b/scripts/cierre-perfiles.py
index 8d916d14..40262b3f 100755
--- a/scripts/cierre-perfiles.py
+++ b/scripts/cierre-perfiles.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-# cierre-perfiles.py — el HASH DE LA CLAUSURA de cada perfil, sin construir nada.
+# cierre-perfiles.py — el HASH DE LA CLAUSURA DE IMAGEN de cada perfil, sin construir nada.
 #
 # ══ QUÉ CONTESTA, Y POR QUÉ FALTABA ═══════════════════════════════════════════════════════════
 # El SDD 33 §4 decide **cuándo rearmar los medios: por el CIERRE, no por el calendario.**
@@ -11,101 +11,136 @@
 #
 # Un cron semanal que rearma imágenes gasta horas de worker para producir, casi siempre, los mismos
 # bytes. El disparador correcto es que la clausura haya cambiado — pero **ese `h` no existía**: el
-# grafo sabe el hash de cada ARTEFACTO y nadie resumía el conjunto. Esto lo calcula.
+# grafo sabe el hash de cada ARTEFACTO y nadie resumía el conjunto. Esto lo calcula. No construye,
+# no descarga y no publica.
 #
-# No construye, no descarga y no publica: lee los grafos que el latido ya regenera cada 30 min.
+# ══ ⚠ DE QUÉ CONJUNTO, QUE ES LA PARTE QUE SE PUEDE ERRAR SIN ENTERARSE ═══════════════════════
+# **Del cierre de IMAGEN (`yupana.membresia_imagen`), no del de build.** La primera versión de este
+# guion (2026-10-10, mañana) usó el campo `perfiles` de `build-state.json`, y estaba mal por DOS
+# motivos independientes, los dos medidos el mismo día:
 #
-# ⚠ **No cubre la otra mitad del §4, y conviene decirlo:** la comparación «≠ el hash de la imagen
-# publicada» necesita que la imagen DECLARE de qué clausura salió, y hoy no hay imágenes publicadas
-# —el sitio no ofrece descargas— así que no hay con qué comparar. Lo que esto deja listo es el lado
-# nuestro: el `git log` de `docs/state/cierres.json` pasa a ser la señal «este perfil se movió ⇒ su
-# imagen quedó rancia», que es lo que hoy nadie puede saber sin rearmar para averiguarlo.
+#  1. **`build ∪ runtime` ≠ lo que va en la imagen.** El cierre de un perfil se calcula sobre las
+#     dos aristas porque para CONSTRUIR hacen falta las dos, pero **la imagen no se construye a sí
+#     misma**: `llvm21`, `go`, `python3`, `binutils`, `cmake`, `perl`… son `[solo-build]`. Son el
+#     **44 % de los bytes de `servidor`** (medido el 2026-09-22, ver `yupana.membresia_imagen`).
+#     Con el conjunto equivocado, tocar una herramienta de build movería el digest y diría «la
+#     imagen quedó rancia» cuando sus bytes no cambian. Un disparador de rearmado que se enciende
+#     por algo que no entra en la imagen es exactamente el calendario que el §4 quiere evitar.
+#       base 86 → 69 · cli 153 → 133 · servidor 186 → 166 · escritorio-kde 445 → 411
 #
-# ══ LA TRAMPA, QUE ES MEDIDA Y CUESTA UN CIERRE FALSO ═════════════════════════════════════════
-# **La clausura de un perfil se calcula en EL GRAFO DE SU COLA, no en el del corpus.** Medido el
-# 2026-10-10: el perfil `base` tiene **86 nodos en `build-state.json` y 78 en
-# `build-state-kde.json`**. Los 8 que "faltan" —dbus, gmp, libgcrypt, libgpg-error, libnl, mpfr,
-# openssl, util-linux— **sí están** en el grafo de KDE, pero con `perfiles=['escritorio-kde']`:
-# ahí el nombre `openssl` es la variante de `recipes/incoming-kde/`, no la del corpus, y la
-# membresía se calcula por grafo. Leer el grafo equivocado da un cierre que parece bueno y no lo es.
+#  2. **El campo `perfiles` NO es la fuente de verdad, y `yupana` lo dice en su propio docstring**
+#     («esto es lo que arregla la mentira de `perfiles`»). Se calcula por grafo, así que para los
+#     escritorios queda truncado: `escritorio-kde` son **256** en `build-state.json`, **431** en
+#     `build-state-kde.json` y **445** de verdad. Leer «el grafo de su cola» mitiga el síntoma y no
+#     lo arregla: la membresía hay que resolverla sobre el grafo entero, respetando sibling-first,
+#     y eso es justo lo que `yupana.membresia()` hace.
 #
-# `targets.py` ya lo tiene escrito («su membresía de perfil está en el grafo de SU cola») y de ahí
-# sale la `cola` de cada perfil, así que acá no se adivina: se importa.
+# ⇒ La membresía sale de `yupana`; los hashes, de los grafos, indexados por **(cola, nombre)** y no
+# por nombre a secas — porque `openssl` es una receta distinta en `corpus` y en `incoming-kde`.
+# Comprobado antes de confiar en el mapa: 1.288 pares y **cero hashes contradictorios** entre los
+# cinco grafos.
 #
-# ⇒ Corolario que hay que respetar al usar esto: **un digest sólo es comparable CONSIGO MISMO a lo
-# largo del tiempo.** Dos perfiles no se comparan entre sí, y un digest calculado desde otro grafo
-# no es el mismo número para el mismo perfil.
+# ⚠ **Lo que sigue faltando del §4: que la imagen publicada declare de qué clausura salió.** Hoy
+# ninguna imagen declara nada —no se escribe ni un `/etc/os-release`— y además no hay imágenes
+# publicadas. Lo que esto deja listo es el lado nuestro: el `git log` de `docs/state/cierres.json`
+# ES la señal «este perfil se movió ⇒ su imagen quedó rancia».
 #
 # Uso:  scripts/cierre-perfiles.py [--json docs/state/cierres.json] [--contra <fichero>]
 #       La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano.
-import argparse, hashlib, json, os, sys
+import argparse, collections, glob, hashlib, json, os, sys
 
 RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 sys.path.insert(0, os.path.join(RAIZ, 'scripts'))
-from targets import load as cargar_perfiles  # noqa: E402
+import yupana  # noqa: E402
 
 ESTADO = os.path.join(RAIZ, 'docs', 'state')
 
 
-def grafo_de(cola):
-    """El fichero de grafo que corresponde a una cola. `corpus` es el grafo base; las colas de
-    escritorio tienen el suyo, y es EL ÚNICO donde su membresía de perfil está completa."""
-    if cola in ('corpus', '', None):
-        return 'build-state.json'
-    return 'build-state-%s.json' % cola.replace('incoming-', '')
-
-
-def cierre(perfil, cola):
-    """(digest, detalle) de la clausura del perfil, o (None, detalle) si está incompleta."""
-    ruta = os.path.join(ESTADO, grafo_de(cola))
-    det = {'perfil': perfil, 'cola': cola, 'grafo': os.path.basename(ruta)}
-    if not os.path.exists(ruta):
-        det['estado'] = 'sin-grafo'
-        return None, det
-    with open(ruta, encoding='utf-8') as f:
-        g = json.load(f)
-    miembros = {n: v for n, v in g['nodes'].items() if perfil in (v.get('perfiles') or [])}
-    det['nodos'] = len(miembros)
-    if not miembros:
-        det['estado'] = 'perfil-ausente-del-grafo'
-        return None, det
-
-    # ⚠ UN CIERRE INCOMPLETO NO SE RESUME EN UN HASH. Si a un solo nodo le falta el hash —o no está
-    # sellado— el digest saldría igual, con pinta de dato firme, y diría que la imagen "cambió"
-    # cuando lo que cambió es cuánto sabemos. Es la regla 3 de CLAUDE.md llevada al conjunto: un
-    # ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.
-    # ⚠ UN NODO `ajeno` NO TIENE HASH, Y ESO ES CORRECTO, NO UN HUECO. Es un artefacto que no
-    # construimos nosotros (xwayland, rust-toolchain-bin, steam-runtime-sniper): está EN la
-    # clausura y su contenido queda FUERA de lo que este digest puede cubrir. Tratarlo como «sin
-    # hash» dejaba a `escritorio-kde` eternamente «incompleto» por un solo nodo, que es la forma de
-    # que un informe correcto se vuelva inútil. Pero tampoco se ignora: entra al digest por NOMBRE
-    # con un marcador, así que si mañana la clausura suma o pierde un ajeno, el número se mueve.
-    ajenos = sorted(n for n, v in miembros.items() if v.get('state') == 'ajeno')
-    propios = {n: v for n, v in miembros.items() if v.get('state') != 'ajeno'}
-    det['ajenos'] = ajenos
-    sin_hash = sorted(n for n, v in propios.items() if not v.get('hash'))
-    sin_sellar = sorted(n for n, v in propios.items() if v.get('state') != 'sealed')
-    if sin_hash or sin_sellar:
-        det['estado'] = 'incompleto'
-        det['sin_hash'] = sin_hash[:10]
-        det['sin_sellar'] = sin_sellar[:10]
-        det['n_sin_hash'], det['n_sin_sellar'] = len(sin_hash), len(sin_sellar)
-        return None, det
-
-    # El digest es sobre pares `nombre hash`, ORDENADOS: el orden de un dict de json no es contrato
-    # y sin ordenar el mismo cierre daría dos números distintos ([[no-determinismo-hashmap-en-build-rs]]
-    # es la misma trampa, un nivel más abajo).
-    h = hashlib.sha256()
-    for n in sorted(propios):
-        h.update(('%s %s\n' % (n, propios[n]['hash'])).encode())
-    for n in ajenos:
-        # Sin hash que poner: se marca la PRESENCIA. El contenido de un ajeno no lo garantiza este
-        # número, y el informe lo dice por nombre para que nadie crea que sí.
-        h.update(('%s ajeno\n' % n).encode())
-    # `sha256:` y no `b3:` A PROPÓSITO: un ArtifactHash del store es `b3:`, y usar el mismo prefijo
-    # para un resumen que NO direcciona nada en el store invitaría a buscarlo ahí.
-    det['estado'] = 'ok'
-    return 'sha256:' + h.hexdigest(), det
+def mapa_hashes():
+    """{(cola, nombre): (hash, estado)} desde los cinco grafos.
+
+    Por (cola, nombre) y no por nombre: `openssl` es una receta distinta en `corpus` y en
+    `incoming-kde`, y colapsarlas daría el hash de la que apareciera primero. Se comprobó que los
+    grafos no se contradicen —1.288 pares, 0 conflictos— y si algún día se contradicen hay que
+    enterarse, así que el conflicto se devuelve en vez de resolverse a dedo.
+    """
+    m, conflictos = {}, []
+    for f in sorted(glob.glob(os.path.join(ESTADO, 'build-state*.json'))):
+        if not f.endswith('.json'):
+            continue
+        with open(f, encoding='utf-8') as fh:
+            g = json.load(fh)
+        for n, v in g['nodes'].items():
+            k = (v.get('queue'), n)
+            par = (v.get('hash'), v.get('state'))
+            if k in m and m[k] != par:
+                conflictos.append((k, m[k], par, os.path.basename(f)))
+            m[k] = par
+    return m, conflictos
+
+
+def cierres():
+    por_perfil = collections.defaultdict(set)
+    for clave, perfiles in yupana.membresia_imagen().items():
+        for p in perfiles:
+            por_perfil[p].add(clave)
+    hashes, conflictos = mapa_hashes()
+
+    filas = []
+    for perfil in sorted(por_perfil):
+        miembros = sorted(por_perfil[perfil])
+        det = {'perfil': perfil, 'nodos': len(miembros), 'vista': 'imagen'}
+
+        # ── Tres clases de miembro, y las tres se nombran ────────────────────────────────────
+        # ⚠ `wanted` = **declarado por el perfil y sin receta que lo provea** (`escritorio-kde`
+        # declara `xwayland`, que no existe como receta en ningún sitio; `yupana` lo llava con cola
+        # `wanted`, build-state lo llama `ajeno` en `incoming-kde` — el mismo nodo con dos
+        # etiquetas). No se le puede pedir hash y **no aporta bytes a la imagen**: no hay nada que
+        # instalar. ⇒ Entra por NOMBRE con su marca, igual que un `ajeno`, y el día que alguien le
+        # escriba la receta la marca pasa a ser un hash y el digest se mueve — que es exactamente
+        # lo que se quiere.
+        #
+        # Negarle el digest por esto sería repetir el error de la primera versión con los `ajeno`:
+        # dejar a `escritorio-kde` —el escritorio más grande— eternamente «incompleto» por un nodo,
+        # o sea volver inútil un informe correcto.
+        wanted = sorted('%s/%s' % k for k in miembros if k[0] == 'wanted')
+        resto = [k for k in miembros if k[0] != 'wanted']
+        desconocidos = sorted('%s/%s' % k for k in resto if k not in hashes)
+        ajenos = sorted('%s/%s' % k for k in resto
+                        if k in hashes and hashes[k][1] == 'ajeno')
+        propios = [k for k in resto if k in hashes and hashes[k][1] != 'ajeno']
+        det['wanted'] = wanted
+        sin_hash = sorted('%s/%s' % k for k in propios if not hashes[k][0])
+        sin_sellar = sorted('%s/%s' % k for k in propios if hashes[k][1] != 'sealed')
+        det['ajenos'] = ajenos
+
+        if desconocidos or sin_hash or sin_sellar:
+            det['estado'] = 'incompleto'
+            det['desconocidos'] = desconocidos[:10]
+            det['sin_hash'] = sin_hash[:10]
+            det['sin_sellar'] = sin_sellar[:10]
+            det['n_desconocidos'] = len(desconocidos)
+            det['n_sin_hash'], det['n_sin_sellar'] = len(sin_hash), len(sin_sellar)
+            det['cierre'] = None
+            filas.append(det)
+            continue
+
+        # Pares `cola/nombre hash`, ORDENADOS: el orden de un dict no es contrato, y sin ordenar el
+        # mismo cierre daría dos números distintos.
+        h = hashlib.sha256()
+        for k in sorted(propios):
+            h.update(('%s/%s %s\n' % (k[0], k[1], hashes[k][0])).encode())
+        for n in ajenos + ['%s (wanted)' % w for w in wanted]:
+            # Sin hash que poner: se marca la PRESENCIA. Un `ajeno` es un artefacto que no
+            # construimos nosotros (xwayland, rust-toolchain-bin): está EN la clausura y su
+            # contenido queda FUERA de lo que este número garantiza. Si la clausura suma o pierde
+            # un ajeno, el digest se mueve; su contenido no lo cubre, y el informe lo nombra.
+            h.update(('%s ajeno\n' % n).encode())
+        # `sha256:` y no `b3:` a propósito: esto resume un conjunto y no direcciona nada en el store.
+        det['estado'] = 'ok'
+        det['cierre'] = 'sha256:' + h.hexdigest()
+        filas.append(det)
+    return filas, conflictos
 
 
 def main():
@@ -114,43 +149,50 @@ def main():
     ap.add_argument('--contra', help='comparar contra un informe anterior y decir qué se movió')
     a = ap.parse_args()
 
-    perfiles = cargar_perfiles()
-    filas = []
-    for nombre in sorted(perfiles):
-        dig, det = cierre(nombre, perfiles[nombre].get('cola', 'corpus'))
-        det['cierre'] = dig
-        filas.append(det)
-
+    filas, conflictos = cierres()
     previo = {}
     ruta_previa = a.contra or a.json
     if ruta_previa and os.path.exists(ruta_previa):
         try:
             with open(ruta_previa, encoding='utf-8') as f:
-                previo = {r['perfil']: r.get('cierre') for r in json.load(f)['perfiles']}
+                prev = json.load(f)
+            # Sólo se compara contra un informe de la MISMA vista: el digest de `build ∪ runtime`
+            # y el de la imagen son números distintos del mismo perfil, y compararlos diría «se
+            # movió» en el primer ciclo tras el cambio de vista, que es una alarma falsa.
+            if prev.get('vista') == 'imagen':
+                previo = {r['perfil']: r.get('cierre') for r in prev['perfiles']}
         except Exception:
             previo = {}
 
     ok = [f for f in filas if f['estado'] == 'ok']
-    print('== cierre de perfiles: %d de %d con clausura completa ==' % (len(ok), len(filas)))
+    print('== cierre de IMAGEN por perfil: %d de %d con clausura completa ==' % (len(ok), len(filas)))
+    if conflictos:
+        print('   ⚠ %d par(es) (cola,nombre) con HASH CONTRADICTORIO entre grafos — mirarlo:'
+              % len(conflictos))
+        for k, viejo, nuevo, f in conflictos[:5]:
+            print('     %s/%s: %s vs %s (%s)' % (k[0], k[1], viejo[0], nuevo[0], f))
     movidos = []
     for f in filas:
         marca = ''
-        if f['estado'] == 'ok' and f['perfil'] in previo and previo[f['perfil']]:
+        if f['estado'] == 'ok' and previo.get(f['perfil']):
             if previo[f['perfil']] != f['cierre']:
                 marca = '  ← SE MOVIÓ (su imagen quedó rancia)'
                 movidos.append(f['perfil'])
             else:
                 marca = '  (igual que la vez anterior)'
         if f['estado'] == 'ok':
-            print('   %-20s %5d nodos  %s%s' % (f['perfil'], f['nodos'], f['cierre'][:23] + '…', marca))
-        else:
-            # Se dice el motivo, no sólo que falta: «incompleto» sin el porqué manda a buscar a mano.
             extra = ''
-            if f['estado'] == 'incompleto':
-                extra = ' (%d sin hash, %d sin sellar; p.ej. %s)' % (
-                    f['n_sin_hash'], f['n_sin_sellar'],
-                    (f['sin_hash'] or f['sin_sellar'] or ['—'])[0])
-            print('   %-20s %5s        %s%s' % (f['perfil'], f.get('nodos', '?'), f['estado'], extra))
+            if f.get('wanted'):
+                # Se dice en el informe, no sólo en el json: un perfil que declara algo que nadie
+                # provee es un dato, y esconderlo detrás de un digest «completo» lo perdería.
+                extra = '  · declara sin receta: %s' % ', '.join(f['wanted'])
+            print('   %-20s %5d nodos  %s…%s%s'
+                  % (f['perfil'], f['nodos'], f['cierre'][:22], marca, extra))
+        else:
+            print('   %-20s %5d        incompleto (%d desconocidos, %d sin hash, %d sin sellar; '
+                  'p.ej. %s)' % (f['perfil'], f['nodos'], f['n_desconocidos'], f['n_sin_hash'],
+                                 f['n_sin_sellar'],
+                                 (f['desconocidos'] or f['sin_hash'] or f['sin_sellar'] or ['—'])[0]))
 
     if movidos:
         print('\n   %d perfil(es) con la clausura movida ⇒ rearmar su imagen, probar con los tres '
@@ -160,12 +202,12 @@ def main():
 
     if a.json:
         with open(a.json, 'w', encoding='utf-8') as f:
-            json.dump({'schema': 'takana-cierres/1', 'perfiles': filas,
-                       'completos': len(ok), 'total': len(filas),
+            json.dump({'schema': 'takana-cierres/2', 'vista': 'imagen',
+                       'perfiles': filas, 'completos': len(ok), 'total': len(filas),
+                       'conflictos_de_hash': len(conflictos),
                        'movidos_vs_anterior': movidos}, f, ensure_ascii=False, indent=1)
         print('\n   informe: %s' % a.json)
-    # Sale 0 siempre: esto INFORMA. Que una clausura se mueva es lo normal cuando se trabaja, no un
-    # fallo, y un guardián que grita cada vez que alguien sella algo se deja de leer.
+    # Sale 0 siempre: esto INFORMA. Que una clausura se mueva es lo normal cuando se trabaja.
     return 0