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:
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+42
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user