> 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: modelo EXTRA-GRAFO — `preflight` aprende los 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. Esos 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.
`yupana preflight <receta>` corre los patrones ANTES de construir. Primer patrón:
overlay-lowerdir, la cicatriz de kio. Habría marcado kio antes de quemar la
campaña descubriéndolo a los golpes:
kio: 52 deps ⇒ lowerdir ~5346B > 4096B (una página) ⇒ depende del merge
CALIBRADO, no adivinado: el estimador daba corto (2974B "ok") hasta usar el
prefijo que bwrap VE en runtime (/oldroot/…, ruta del worker, no la del hub).
Con eso: kio estimado 5346B vs real 5315B — 31B de error, sin fudge. El primer
predictor "obvio" mentía con confianza; la lección de siempre.
preflight --perfil escritorio-kde destapa lo sistémico: las 40 recetas KDE
profundas CUELGAN del merge (plasma-desktop: 114 deps → 11708B, casi 3× el
límite). Todo el escritorio dependía del fix 6a0dc40. El pre-flight vuelve
VISIBLE y contable una fragilidad que antes sólo se manifestaba fallando.
Registro extensible (PATRONES_ENTORNO): futuros = capacidad-de-host (rootfs
laptop≠worker, techo MSRV — acumular observaciones), skew de toolchain (zig-skew
— fingerprint por artefacto). harkaq ya MIDE R2 (evidencia negativa); esto lo
vuelve consultable + pre-vuela. Guardián: kio marcado + estimador calibrado.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 <receta> ¿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 <r> 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 <perfil> 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]}