yupana: instrumentar duración de build en el worker → camino crítico PESADO

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/<hash>-<host>.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) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:25:09 -04:00
co-authored by Claude Opus 4.8
parent 4f609080df
commit 85b5a9bc46
4 changed files with 148 additions and 7 deletions
+15 -2
View File
@@ -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"
]
}
}
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
# build-timed.sh <receta.toml> — 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/<hash>-<host>.json` — un SIDECAR del store, NUNCA dentro del
# artefacto (`<hash>-<name>/`). 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 `<hash>-<name>`).
#
# 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 <receta.toml>}"
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"
+3 -1
View File
@@ -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"
+88 -4
View File
@@ -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