diff --git a/scripts/test-yupana-radio.py b/scripts/test-yupana-radio.py index 147b0a17..23f97274 100755 --- a/scripts/test-yupana-radio.py +++ b/scripts/test-yupana-radio.py @@ -64,6 +64,18 @@ def main(): fallos.append("corpus/dbus e incoming-kde/dbus tienen build_fp IGUAL — el detector " "los marcaría redundantes y son variantes estático/dinámico deliberadas") + # 4) MODELO EXTRA-GRAFO: el patrón de overlay debe seguir marcando kio (la cicatriz) y su + # estimación seguir calibrada contra el fallo real (5315B). Si el estimador se descalibra + # (p.ej. alguien cambia el prefijo del hub por el del sandbox), el pre-flight miente. + if ("incoming-kde", "kio") in {(q, n) for q in colas for n in colas[q]}: + deps_kio = colas["incoming-kde"]["kio"] + est = yupana._lowerdir_estimado(deps_kio) + if yupana.patron_overlay("kio", deps_kio) is None: + fallos.append("el patrón de overlay NO marca a kio — el pre-flight dejó de ver el hueco") + elif abs(est - 5315) > 600: # el fallo real midió 5315B; margen por variación de nombres + fallos.append(f"el estimador de lowerdir se descalibró: kio={est}B vs real 5315B " + f"(¿prefijo equivocado? el del hub da corto)") + if fallos: print("✗ REGRESIÓN de yupana:") for f in fallos: @@ -75,6 +87,8 @@ def main(): f"{sorted(memb.get(('corpus','libdrm'), set()))}") print(f"✓ detector agrupa por fuente (itstool↔gettext-tiny) y no cría lobos " f"(itstool=carrier, dbus=variante)") + print(f"✓ modelo extra-grafo: preflight marca kio (lowerdir ~{yupana._lowerdir_estimado(colas['incoming-kde']['kio'])}B " + f"vs real 5315B, calibrado)") return 0 if __name__ == "__main__": diff --git a/scripts/yupana.py b/scripts/yupana.py index 8a4de3a6..2f359389 100755 --- a/scripts/yupana.py +++ b/scripts/yupana.py @@ -35,6 +35,7 @@ # perfiles ¿qué imágenes dependen de esto? (nativo) # duplicados invariante (c): nudos que son el mismo paquete bajo dos nombres (por sha) (nativo) # keystones nudos en deuda que gatean el máximo trabajo bloqueado (cierre inverso, AND) (nativo) +# preflight modelo EXTRA-GRAFO: ¿choca con un límite del entorno que el grafo no ve? (nativo) # estado regenera el grafo firme → build-state.py # objetivo qué debe tener cada imagen → targets.py --lista # frontera qué pide upstream y no tenemos → seed-graph.py --frontera @@ -104,6 +105,104 @@ def _base(n): # ACCIONABLE es el cierre inverso restringido a la DEUDA — cuánto trabajo BLOQUEADO libero. El # dominador SÍ tiene un uso legítimo, otro: la RETENCIÓN (lo exclusivamente mío), abajo. +# --- MODELO EXTRA-GRAFO: patrones de hueco de ENTORNO ------------------------------------------- +# El grafo modela deps (R1: Receta×Receta). El entorno es OTRA capa: R2 (Receta×Capacidad, qué exige +# un build) y R3 (Host×Capacidad, qué ofrece la máquina). Construible = clausura(R1) ∧ R2⊆R3. Estos +# huecos viven FUERA del grafo (infra del sandbox, toolchain, layout de fs) y una matemática de grafos +# sola no los ve — pero SÍ se aprenden, como patrones con predictor, calibrados contra fallos REALES. +# Cada patrón: (nombre, deps) → veredicto|None. `preflight` los corre ANTES de construir. +# +# HOY: 1 patrón (overlay-lowerdir), la cicatriz de kio (2026-07-22). Futuros: capacidad-de-host +# (rootfs laptop≠worker, techo MSRV — acumular observaciones (receta,host,lo-que-faltó)); skew de +# toolchain (zig-skew — fingerprint del toolchain por artefacto). harkaq ya MIDE R2 (evidencia +# negativa: qué path del entorno tocó un build); esto lo vuelve consultable + pre-vuela. +# +# CALIBRADO, no adivinado: el estimador daba corto hasta usar el prefijo que bwrap VE (`/oldroot/...`, +# la ruta runtime del worker, no la del hub). Con eso: kio estimado 5346B vs real 5315B (31B de error). +# El primer predictor "obvio" (prefijo del hub) mentía con confianza — la lección de siempre. +PAGINA_OVERLAY = 4096 # tope del string de opciones de un mount overlayfs = PAGE_SIZE +# Prefijo que bwrap ve en runtime (bajo /oldroot, ruta del worker). Ajustable por host. +_PREF_SANDBOX = os.environ.get("PREF_STORE_SANDBOX", "/oldroot/opt/hammer/store/") +_OVERHEAD_OVERLAY = len("upperdir=/tmp-overlay-upper-0,workdir=/tmp-overlay-work-0,") \ + + len("/oldroot/opt/hammer/.dev-fs/alpine") + len(",userxattr") + + +def _lowerdir_estimado(deps): + """Bytes del string `lowerdir=` que bwrap arma apilando `deps` como capas overlay directas. Es la + cantidad de GRAFO (nº y nombre de deps) que cruza el umbral de ENTORNO (una página).""" + return _OVERHEAD_OVERLAY + sum(len(_PREF_SANDBOX) + 64 + 1 + len(n) + 1 for n in deps) + + +def patron_overlay(nombre, deps): + """¿El apilado directo de las deps desborda el mount de overlay? Si sí, la receta SÓLO monta + gracias al merge de deps (cp -al a store/.dmerge, 1 sola capa). El pre-flight hace VISIBLE esa + dependencia de una mitigación —y su precondición— que antes era invisible hasta que fallaba.""" + est = _lowerdir_estimado(deps) + if est <= PAGINA_OVERLAY: + return None # entra directo, sin riesgo + return dict( + patron="overlay-lowerdir", + detalle=f"{len(deps)} deps ⇒ lowerdir ~{est}B > {PAGINA_OVERLAY}B (una página)", + riesgo="depende-del-merge", + nota=("apilado directo DESBORDA ⇒ sólo monta vía el merge de deps (cp -al a store/.dmerge, " + "1 capa). Precondición: .dmerge mismo-fs que el store — OK por construcción (vive DENTRO " + "del store, fix 6a0dc40). Si el store cambiara de layout y el merge fallara, esta receta " + "NO montaría, silenciosamente.")) + + +PATRONES_ENTORNO = [patron_overlay] + + +def preflight(objetivo): + """Corre los patrones de entorno sobre una receta o (con --perfil) sobre todas las de una imagen, + ANTES de construir. Responde la pregunta que kio contestó tarde y cara: ¿este build va a chocar + con un límite del entorno que el grafo no ve?""" + colas = cargar_colas() + idx = {q: set(m) for q, m in colas.items()} + if objetivo.startswith("perfil:"): + pn = objetivo.split(":", 1)[1] + p = targets_mod.load().get(pn) + if not p: + print(f"perfil desconocido: {pn}", file=sys.stderr); return 1 + cola = p.get("cola", "corpus") + nombres = [(cola if n in idx.get(cola, ()) else "corpus", n) + for n in colas.get(cola, {})] if cola != "corpus" else \ + [("corpus", n) for n in colas["corpus"]] + # sólo los del ámbito: todas las recetas de la cola del perfil + objetivos = [(q, n) for q in ({cola} | {"corpus"}) for n in colas.get(q, {})] + else: + ubic = [(q, objetivo) for q in colas if objetivo in colas[q]] + if not ubic: + print(f"no hay receta `{objetivo}` en ninguna cola", file=sys.stderr); return 1 + objetivos = ubic + + hits = [] + for (q, n) in objetivos: + deps = colas[q][n] + for patron in PATRONES_ENTORNO: + v = patron(n, deps) + if v: + hits.append((f"{q}/{n}", v)) + hits.sort(key=lambda h: -_lowerdir_estimado(colas[h[0].split("/")[0]][h[0].split("/", 1)[1]])) + + if not objetivo.startswith("perfil:"): + q, n = objetivos[0] + print(f"== preflight de {q}/{n} ({len(colas[q][n])} deps)") + if not hits: + print(" ✓ sin riesgo de entorno conocido (entra en el mount overlay directo).") + for _, v in hits: + print(f" ⚠ {v['patron']}: {v['detalle']}") + print(f" {v['nota']}") + return 0 + print(f"== preflight del perfil (patrones: {', '.join(p.__name__ for p in PATRONES_ENTORNO)})") + print(f" {len(hits)} recetas dependen del merge para montar (apilado directo desbordaría):") + for nombre, v in hits[:25]: + print(f" ⚠ {nombre:28} {v['detalle']}") + print(f"\n son construibles HOY (el merge compacta), pero su montaje CUELGA de que store/.dmerge") + print(f" sea mismo-fs que el store. Si un host rompe eso, fallan silenciosas — pre-vuela antes.") + return 0 + + def _tiempos(): """nombre → mediana de segundos de build, desde `store/.times/*.json` (sidecar no determinista, lo escribe `scripts/farm/build-timed.sh` en el worker y llega con el rsync de cosecha). Vacío @@ -614,6 +713,9 @@ def main(): return duplicados(como_json="--json" in extra) if cmd == "keystones": return keystones(como_json="--json" in extra) + if cmd == "preflight" and extra: + obj = f"perfil:{extra[extra.index('--perfil') + 1]}" if "--perfil" in extra else extra[0] + return preflight(obj) if cmd == "perfiles" and extra: memb = membresia() hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]}