> 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: 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>
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 <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"
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