tawasuyu

sergio/takana

> 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…

TOML 604Rust 56Shell 36Markdown 281227 Definiciones

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>

85b5a9bc46862fbe1b389a5470d499812ce2047d · por sergio · (hace 3 meses) · padre 4f609080d · ver el árbol en este commit

Delta semántico · 6

scripts/yupana.py

4 archivos · +148 −7

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