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>
This commit is contained in:
2026-07-22 21:42:42 -04:00
co-authored by Claude Opus 4.8
parent 7f2ebdbb8b
commit 8c10c0a908
2 changed files with 116 additions and 0 deletions
+14
View File
@@ -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__":
+102
View File
@@ -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]}