From 85b5a9bc46862fbe1b389a5470d499812ce2047d Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 22 Jul 2026 20:25:09 -0400 Subject: [PATCH] =?UTF-8?q?yupana:=20instrumentar=20duraci=C3=B3n=20de=20b?= =?UTF-8?q?uild=20en=20el=20worker=20=E2=86=92=20camino=20cr=C3=ADtico=20P?= =?UTF-8?q?ESADO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El peso que le faltaba a las ondas: sin él, critical-path = nº de pasos; con él, ETA real en segundos. INSTRUMENTACIÓN (worker): scripts/farm/build-timed.sh envuelve `hammer build` midiendo la pared y la registra en $STORE/.times/-.json — un SIDECAR del store, NUNCA dentro del artefacto. La duración es no determinista (varía por máquina/carga) ⇒ no puede tocar el ArtifactHash. Keyed por hash (CAS-safe: dos workers no se pisan) + host (varias muestras por receta). Viaja al hub con el rsync de store que ya hace la cosecha; ninguna herramienta del store lo confunde con artefacto. Sólo builds REALES (pared ≥ UMBRAL 3s) — los cache-hits no envenenan la mediana. campana-deuda.sh ahora construye vía build-timed.sh (transparente, mismo exit). CONSUMO (hub): yupana._tiempos() carga name→mediana de segundos; keystones computa el CAMINO CRÍTICO pesado = longest weighted path del subgrafo de deuda (peso = segundos de build). La cadena más larga hay que construirla en SERIE aunque haya ∞ workers ⇒ es la ETA con paralelismo infinito. Degrada con gracia: sin datos, peso=1 y el camino crítico = nº de pasos (lo que ya daban las ondas). Verificado: con muestras sintéticas da "ETA 19 min sobre 5 pasos, kio → kparts → frameworkintegration → breeze → plasma-integration"; sin datos degrada a "5 pasos (SIN datos)". store/.times está git-ignorado (metadata de máquina, no se commitea). LÍMITE honesto (en la cabecera de build-timed.sh): `hammer build` arrastra deps ⇒ la pared incluye deps no selladas. En orden topológico (drenar) las deps ya están selladas y la pared mide sobre todo ESTA receta — cota superior buena para pesar. La precisión exacta pediría instrumentar el sellado dentro de hammer-build (Rust). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/state/keystones.json | 17 ++++++- scripts/farm/build-timed.sh | 42 ++++++++++++++++ scripts/farm/campana-deuda.sh | 4 +- scripts/yupana.py | 92 +++++++++++++++++++++++++++++++++-- 4 files changed, 148 insertions(+), 7 deletions(-) create mode 100755 scripts/farm/build-timed.sh diff --git a/docs/state/keystones.json b/docs/state/keystones.json index 0003f60e..42bbb565 100644 --- a/docs/state/keystones.json +++ b/docs/state/keystones.json @@ -1,5 +1,5 @@ { - "schema": "hammer-keystones/2", + "schema": "hammer-keystones/3", "nudos": [ { "nudo": "incoming-kde/kio", @@ -562,5 +562,18 @@ "en_deuda": false } ], - "en_deuda": 33 + "en_deuda": 33, + "camino_critico": { + "con_datos": false, + "segundos": null, + "pasos": 5, + "cobertura": "0/66", + "secuencia": [ + "incoming-kde/kio", + "incoming-kde/kparts", + "incoming-kde/frameworkintegration", + "incoming-kde/qqc2-desktop-style", + "incoming-kde/filelight" + ] + } } diff --git a/scripts/farm/build-timed.sh b/scripts/farm/build-timed.sh new file mode 100755 index 00000000..6c5c57c7 --- /dev/null +++ b/scripts/farm/build-timed.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# build-timed.sh — corre `hammer build` MIDIENDO la duración de pared y la registra +# como metadata, para alimentar el camino crítico PESADO de yupana (el peso que le faltaba a las +# ondas: sin él, critical-path = nº de pasos; con él, ETA real en segundos). +# +# DÓNDE VIVE EL DATO. En `$STORE/.times/-.json` — un SIDECAR del store, NUNCA dentro del +# artefacto (`-/`). La duración es NO determinista (varía por máquina y carga) ⇒ no puede +# tocar el ArtifactHash ni la reproducibilidad. Es metadata SOBRE un build, keyed por el hash del +# artefacto (CAS-safe: dos workers no se pisan) + host (permite varias muestras por receta). Viaja al +# hub con el rsync del store que ya hace la cosecha; ninguna herramienta del store lo confunde con un +# artefacto (no matchea `-`). +# +# SÓLO BUILDS REALES. Un cache-hit devuelve al instante; un build C/KDE tarda minutos. Se registra +# sólo si la pared supera UMBRAL_TIEMPO (def 3s), así los cache-hits no envenenan la mediana con ~0. +# Limitación honesta: `hammer build` arrastra deps ⇒ la pared incluye deps NO selladas. Construyendo +# en orden topológico (drenar) las deps ya están selladas (cache-hit) y la pared mide sobre todo ESTA +# receta. No es perfecto; es una cota superior buena para pesar. La precisión exacta pediría instrumentar +# el sellado dentro de hammer-build (Rust), deuda futura. +# +# Sale con el MISMO código que `hammer build` (transparente para el llamador). +set -u +HAMMER="${HAMMER:-./target/release/hammer}" +STORE="${STORE:-./store}" +UMBRAL="${UMBRAL_TIEMPO:-3}" +f="${1:?uso: build-timed.sh }" + +h=$("$HAMMER" --store "$STORE" hash "$f" 2>/dev/null) # b3:… (dry-run, ~2ms, no necesita store) +start=$(date +%s) +"$HAMMER" --store "$STORE" build "$f" +rc=$? +end=$(date +%s) +elapsed=$((end - start)) + +if [ "$rc" -eq 0 ] && [ "$elapsed" -ge "$UMBRAL" ] && [ -n "$h" ]; then + name=$(basename "$f" .toml) + host=$(hostname 2>/dev/null || echo worker) + hh=${h#b3:} + mkdir -p "$STORE/.times" + printf '{"hash":"%s","name":"%s","seconds":%s,"host":"%s","epoch":%s}\n' \ + "$h" "$name" "$elapsed" "$host" "$end" > "$STORE/.times/${hh}-${host}.json" +fi +exit "$rc" diff --git a/scripts/farm/campana-deuda.sh b/scripts/farm/campana-deuda.sh index df2de43c..2de83c2f 100755 --- a/scripts/farm/campana-deuda.sh +++ b/scripts/farm/campana-deuda.sh @@ -81,7 +81,9 @@ for r in $DEUDA; do # heartbeat: el dead-man ya cuenta `hammer build` como trabajo, pero entre receta y receta hay # huecos (fetch/vendor) donde no hay proceso con ese nombre. Tocarlo evita una muerte espuria. touch /run/hammer-heartbeat 2>/dev/null || true - if "$HAMMER" --store "$STORE" build "$f" >>"$LOG" 2>&1; then + # build-timed.sh mide la pared y registra la duración en $STORE/.times (sólo builds reales), para + # el camino crítico pesado de yupana. Transparente: sale con el mismo código que `hammer build`. + if HAMMER="$HAMMER" STORE="$STORE" "$HAMMER_DIR/scripts/farm/build-timed.sh" "$f" >>"$LOG" 2>&1; then ok=$((ok + 1)); echo "$(ts) [$i/$total] ✓ $r" | tee -a "$LOG" else fail=$((fail + 1)); falladas="$falladas $r"; echo "$(ts) [$i/$total] ✗ $r" | tee -a "$LOG" diff --git a/scripts/yupana.py b/scripts/yupana.py index fb030764..8a4de3a6 100755 --- a/scripts/yupana.py +++ b/scripts/yupana.py @@ -104,6 +104,50 @@ 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. +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 + hasta que corran builds instrumentados; los consumidores degradan a peso=1.""" + from statistics import median + muestras = {} + for f in glob.glob(str(ROOT / "store/.times/*.json")): + try: + d = json.loads(open(f).read()) + except Exception: + continue + if d.get("name") and isinstance(d.get("seconds"), (int, float)): + muestras.setdefault(d["name"], []).append(d["seconds"]) + return {n: median(v) for n, v in muestras.items()} + + +def _camino_critico(nodos, deps_debt, peso): + """Longest weighted path en el subgrafo de DEUDA (DP sobre topológico). El peso de un nudo = sus + segundos de build (1 si no hay dato). La suma sobre la cadena más larga = ETA con paralelismo + infinito: hay que construir esa cadena en SERIE. Devuelve (segundos, secuencia).""" + memo, camino = {}, {} + def cp(n): + if n in memo: + return memo[n] + mejor, via = 0, None + for d in deps_debt.get(n, ()): + c = cp(d) + if c > mejor: + mejor, via = c, d + memo[n] = peso(n) + mejor + camino[n] = via + return memo[n] + for n in nodos: + cp(n) + if not memo: + return 0, [] + fin = max(memo, key=memo.get) + seq = [] + x = fin + while x is not None: + seq.append(x); x = camino.get(x) + return memo[fin], list(reversed(seq)) + + def _cierre_inverso(objetivo, rev): """Nodos que dependen de `objetivo`, transitivamente (semántica AND: si X me tiene en su clausura, X no se construye sin mí).""" @@ -225,7 +269,31 @@ def keystones(como_json=False): # retención `exclusivo` sí es info válida, pero no son trabajo). filas.sort(key=lambda r: (not r["en_deuda"], -r["gateo_en_deuda"], -r["gateo_total"])) accionables = [r for r in filas if r["en_deuda"]] - doc = dict(schema="hammer-keystones/2", nudos=filas[:80], en_deuda=len(accionables)) + + # CAMINO CRÍTICO PESADO: subgrafo de deuda, aristas dep entre nodos en deuda, peso = segundos de + # build. La cadena más larga = ETA con paralelismo infinito. Sin datos de tiempo, peso=1 y el + # camino crítico = nº de pasos (lo que ya daban las ondas). Con datos, es la ETA real en segundos. + tiempos = _tiempos() + idx = {q: set(m) for q, m in colas.items()} + deuda_set = {(q, n) for q in colas for n in colas[q] if estados.get(n) in DEUDA} + deps_debt = {} + for (q, n) in deuda_set: + dd = set() + for d in colas.get(q, {}).get(n, []): + r = resolver(d, q, idx) + if r in deuda_set: + dd.add(r) + deps_debt[(q, n)] = dd + def peso(nodo): + return tiempos.get(nodo[1], 1 if not tiempos else _mediana_o_uno(tiempos)) + seg, seq = _camino_critico(deuda_set, deps_debt, peso) + cubiertos = sum(1 for nodo in deuda_set if nodo[1] in tiempos) + critico = dict(con_datos=bool(tiempos), segundos=seg if tiempos else None, + pasos=len(seq), cobertura=f"{cubiertos}/{len(deuda_set)}", + secuencia=[f"{q}/{n}" for q, n in seq]) + + doc = dict(schema="hammer-keystones/3", nudos=filas[:80], en_deuda=len(accionables), + camino_critico=critico) (ROOT / "docs/state/keystones.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n") if como_json: @@ -238,12 +306,28 @@ def keystones(como_json=False): print(" (ningún nodo en deuda — todas las imágenes en ámbito están completas)") for r in accionables[:20]: print(f" {r['nudo']:30} {r['gateo_en_deuda']:11} {r['gateo_total']:11} {r['exclusivo']:9}") - print(f"\n {len(accionables)} nudos en deuda en total · → docs/state/keystones.json") - print(" nota: el dominador-OR ingenuo NO sirve (build es AND, no OR); el 'exclusivo' SÍ es") - print(" dominador, en su uso correcto (retención). Advertencia completa en yupana.py.") + print(f"\n {len(accionables)} nudos en deuda en total") + print(f"\n== CAMINO CRÍTICO (la cadena más larga; hay que construirla en SERIE aunque haya ∞ workers)") + if critico["con_datos"]: + m = critico["segundos"] // 60 + print(f" ETA (paralelismo infinito): {m} min sobre {critico['pasos']} pasos " + f"· cobertura de tiempos {critico['cobertura']}") + else: + print(f" {critico['pasos']} pasos (SIN datos de tiempo aún ⇒ peso=1; instrumentá builds en") + print(f" el worker con campana-deuda.sh y el rsync de cosecha trae store/.times).") + if seq: + muestra = " → ".join(n.split("/")[-1] for n in critico["secuencia"][:8]) + print(f" {muestra}" + (" → …" if len(seq) > 8 else "")) + print(f"\n → docs/state/keystones.json") + print(" nota: dominador-OR ingenuo NO sirve (build es AND); 'exclusivo' SÍ es dominador (retención).") return 0 +def _mediana_o_uno(tiempos): + from statistics import median + return median(tiempos.values()) if tiempos else 1 + + def _fuente(f): """(sha, url, version, build_fp) de una receta. Los tres primeros = identidad de lo que BAJA. El cuarto = HUELLA DE BUILD (link, compiler, fases) — porque dos recetas con la MISMA fuente pero