gioser

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 1572Shell 203Rust 109Markdown 993728 Definitions

yupana: los 2 "bugs" de duplicados eran falsos positivos MÍOS — detector afinado, 0 reales

El usuario pidió arreglar itstool y la sombra de dbus. Apliqué el reflejo
(`yupana radio` + diff completo antes de tocar) y NINGUNO era un bug: ambos eran
patrones deliberados y documentados que mi detector, demasiado superficial, leyó
mal. Si los "arreglaba" a ciegas rompía dos builds.

  · dbus: corpus/dbus (estático) e incoming-kde/dbus (dinámico) NO son redundantes
    — qtbase linkea libdbus-1.so dinámicamente para Qt6DBus. El detector comparó
    sólo versión+sha+deps, no el BUILD. Borrar la sombra rompía KDE.
  · itstool: es un STUB documentado (`[source]=carrier`). El itstool real es
    Python con libxml2-bindings ausentes en el lab; este genera un script inline y
    sólo PRESTA el tarball de gettext-tiny. Apuntarlo al itstool real rompía
    appstream (radio 5) con un source que ni compila acá.
    Los 3 *-hello son el mismo patrón carrier; prison/prison-scanner una variante
    cross-nombre (misma fuente, WITH_ZXING distinto).

FIX = el detector, no las recetas. Tres señales que le faltaban:
  1. huella de BUILD en la firma de sombra (estático≠dinámico ⇒ no redundante).
  2. CARRIER: ≤1 receta del grupo construye la fuente ⇒ el resto presta el tarball.
     Señal: ¿invoca make/meson/cmake/ninja/cargo? (comentarios strippeados — la
     prosa "invocación de meson" de un stub daba falso builder, lo cazó el guardián).
  3. variante cross-nombre: ≥2 builders con BUILD distinto = deliberado, no bug.
Resultado: 0 colisiones reales (invariante c se cumple), 4 carrier + 14 variantes
+ 12 sombras justificadas, todas benignas.

Guardián extendido: ancla que el detector agrupa por FUENTE (itstool↔gettext-tiny)
y NO cría lobos (itstool=carrier, dbus=variante deliberada).

LECCIÓN: el reflejo del radio/diff antes de tocar evitó dos borrados destructivos
guiados por un falso positivo de mi propia herramienta. Medir antes de creer, aun
a la propia yupana.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

4f609080df4626ed0ea9fedd94f57eaa3f59a208 · by sergio · (3 months ago) · parent 619a30187 · browse the tree at this commit

Semantic delta · 4

scripts/test-yupana-radio.py

  • body changed fn main

scripts/yupana.py

3 files · +160 −93

diff --git a/docs/state/duplicados.json b/docs/state/duplicados.json
index 5977fbba..e6b90af4 100644
--- a/docs/state/duplicados.json
+++ b/docs/state/duplicados.json
@@ -1,53 +1,6 @@
 {
- "schema": "hammer-duplicados/1",
- "colisiones_fuente": [
-  {
-   "sha": "29cc165e27e83d2b",
-   "nudos": [
-    "corpus/gettext-tiny",
-    "corpus/itstool"
-   ],
-   "radio": {
-    "corpus/gettext-tiny": 78,
-    "corpus/itstool": 1
-   }
-  },
-  {
-   "sha": "5eacc5550f0bdbba",
-   "nudos": [
-    "corpus/adwaita-hello",
-    "corpus/libadwaita"
-   ],
-   "radio": {
-    "corpus/adwaita-hello": 0,
-    "corpus/libadwaita": 3
-   }
-  },
-  {
-   "sha": "760903e9ae401f8b",
-   "nudos": [
-    "incoming-kde/prison",
-    "incoming-kde/prison-scanner"
-   ],
-   "radio": {
-    "incoming-kde/prison-scanner": 1,
-    "incoming-kde/prison": 3
-   }
-  },
-  {
-   "sha": "ab35d420102f3e8b",
-   "nudos": [
-    "corpus/gtksourceview",
-    "corpus/hammer-edit",
-    "corpus/sourceview-hello"
-   ],
-   "radio": {
-    "corpus/gtksourceview": 2,
-    "corpus/hammer-edit": 0,
-    "corpus/sourceview-hello": 0
-   }
-  }
- ],
+ "schema": "hammer-duplicados/2",
+ "colisiones_fuente": [],
  "variantes": [
   {
    "sha": "ab5a03176ee106d3",
@@ -93,17 +46,6 @@
     "incoming-kde/glib-shared": 10
    }
   },
-  {
-   "sha": "e1817c650ddc3261",
-   "nudos": [
-    "corpus/gtk4",
-    "corpus/gtk4-hello"
-   ],
-   "radio": {
-    "corpus/gtk4-hello": 0,
-    "corpus/gtk4": 6
-   }
-  },
   {
    "sha": "075920b826834ac4",
    "nudos": [
@@ -196,6 +138,17 @@
     "incoming-kde/zlib-shared": 34
    }
   },
+  {
+   "sha": "760903e9ae401f8b",
+   "nudos": [
+    "incoming-kde/prison",
+    "incoming-kde/prison-scanner"
+   ],
+   "radio": {
+    "incoming-kde/prison-scanner": 1,
+    "incoming-kde/prison": 3
+   }
+  },
   {
    "sha": "809afafab61593f9",
    "nudos": [
@@ -208,6 +161,67 @@
    }
   }
  ],
+ "carriers": [
+  {
+   "sha": "5eacc5550f0bdbba",
+   "nudos": [
+    "corpus/adwaita-hello",
+    "corpus/libadwaita"
+   ],
+   "radio": {
+    "corpus/adwaita-hello": 0,
+    "corpus/libadwaita": 3
+   },
+   "carriers": [
+    "corpus/adwaita-hello"
+   ]
+  },
+  {
+   "sha": "29cc165e27e83d2b",
+   "nudos": [
+    "corpus/gettext-tiny",
+    "corpus/itstool"
+   ],
+   "radio": {
+    "corpus/gettext-tiny": 78,
+    "corpus/itstool": 1
+   },
+   "carriers": [
+    "corpus/itstool"
+   ]
+  },
+  {
+   "sha": "e1817c650ddc3261",
+   "nudos": [
+    "corpus/gtk4",
+    "corpus/gtk4-hello"
+   ],
+   "radio": {
+    "corpus/gtk4-hello": 0,
+    "corpus/gtk4": 6
+   },
+   "carriers": [
+    "corpus/gtk4-hello"
+   ]
+  },
+  {
+   "sha": "ab35d420102f3e8b",
+   "nudos": [
+    "corpus/gtksourceview",
+    "corpus/hammer-edit",
+    "corpus/sourceview-hello"
+   ],
+   "radio": {
+    "corpus/gtksourceview": 2,
+    "corpus/hammer-edit": 0,
+    "corpus/sourceview-hello": 0
+   },
+   "carriers": [
+    "corpus/hammer-edit",
+    "corpus/sourceview-hello"
+   ]
+  }
+ ],
  "sombras": [
   {
    "nombre": "dbus",
@@ -215,7 +229,7 @@
     "corpus",
     "incoming-kde"
    ],
-   "redundante": true,
+   "redundante": false,
    "radio": {
     "corpus/dbus": 0,
     "incoming-kde/dbus": 114
diff --git a/scripts/test-yupana-radio.py b/scripts/test-yupana-radio.py
index 9f5df895..147b0a17 100755
--- a/scripts/test-yupana-radio.py
+++ b/scripts/test-yupana-radio.py
@@ -38,19 +38,31 @@ def main():
         if consumidores and not memb.get((q, n)) and any(cq != "corpus" for cq in colas_cons):
             pass  # puede no tener perfil declarado; no es fallo duro, sólo libdrm es el guardián
 
-    # 3) INVARIANTE (c): el detector de duplicados debe seguir viendo la fuente, no el nombre. La
-    #    identidad de un nudo es lo que BAJA (sha), no cómo se llama. Si esto se rompe (p.ej. alguien
-    #    agrupa por nombre), las colisiones de fuente —shas copiados por error— dejan de verse.
+    # 3) INVARIANTE (c): el detector debe AGRUPAR por fuente (sha), no por nombre — la identidad de un
+    #    nudo es lo que BAJA. Ancla: itstool y gettext-tiny comparten sha (itstool le presta el tarball
+    #    como carrier). Si el detector deja de ver ese grupo, está agrupando por nombre = invariante ciego.
     recs_f = yupana._recetas_con_fuente()
     por_sha = {}
     for k, r in recs_f.items():
         if r["sha"]:
-            por_sha.setdefault(r["sha"], set()).add(k[1])  # nombres por sha
-    colis = {s: ns for s, ns in por_sha.items()
-             if len({yupana._base(n) for n in ns}) > 1}  # familias distintas, misma fuente
-    if not colis:
-        fallos.append("el detector de duplicados no ve NINGUNA colisión de fuente — "
-                      "¿se está agrupando por nombre en vez de por sha? (invariante c ciego)")
+            por_sha.setdefault(r["sha"], set()).add(k)
+    sha_it = recs_f.get(("corpus", "itstool"), {}).get("sha")
+    if not sha_it or ("corpus", "gettext-tiny") not in por_sha.get(sha_it, set()):
+        fallos.append("el detector no agrupa itstool con gettext-tiny por sha — "
+                      "¿agrupa por nombre en vez de por fuente? (invariante c ciego)")
+
+    # 3b) NO crIar lobos: itstool NO es un bug — es un CARRIER (stub que presta el tarball, no
+    #     construye la fuente). Si el detector lo llama construye_fuente=True, lo reportaría como
+    #     colisión falsa (casi se "arregla" un stub apuntándolo al itstool real que no compila en el lab).
+    if recs_f.get(("corpus", "itstool"), {}).get("construye_fuente"):
+        fallos.append("itstool marcado como que CONSTRUYE su fuente — es un carrier/stub, "
+                      "el detector lo reportaría como colisión falsa")
+    # 3c) dbus: corpus (estático) vs incoming-kde (dinámico, para Qt6DBus) — misma fuente, BUILD
+    #     distinto ⇒ variante deliberada, NO sombra redundante. Casi se borra load-bearing (2026-07-22).
+    if ("corpus", "dbus") in recs_f and ("incoming-kde", "dbus") in recs_f:
+        if recs_f[("corpus", "dbus")]["build_fp"] == recs_f[("incoming-kde", "dbus")]["build_fp"]:
+            fallos.append("corpus/dbus e incoming-kde/dbus tienen build_fp IGUAL — el detector "
+                          "los marcaría redundantes y son variantes estático/dinámico deliberadas")
 
     if fallos:
         print("✗ REGRESIÓN de yupana:")
@@ -61,7 +73,8 @@ def main():
     print(f"✓ radio cruza colas: libdrm consumido por {d} recetas de "
           f"{len({q for q,_ in rev.get(('corpus','libdrm'),())})} colas, atribuido a "
           f"{sorted(memb.get(('corpus','libdrm'), set()))}")
-    print(f"✓ detector de duplicados por fuente: {len(colis)} colisiones vistas (invariante c activo)")
+    print(f"✓ detector agrupa por fuente (itstool↔gettext-tiny) y no cría lobos "
+          f"(itstool=carrier, dbus=variante)")
     return 0
 
 if __name__ == "__main__":
diff --git a/scripts/yupana.py b/scripts/yupana.py
index 931e9daf..fb030764 100755
--- a/scripts/yupana.py
+++ b/scripts/yupana.py
@@ -44,7 +44,7 @@
 # Uso:  scripts/yupana.py radio <receta> [--json]
 #       scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | objetivo
 # Como módulo:  from yupana import cargar_colas, membresia, dependientes, resolver
-import glob, json, os, subprocess, sys, tomllib
+import glob, json, os, re, subprocess, sys, tomllib
 from pathlib import Path
 
 ROOT = Path(__file__).resolve().parent.parent
@@ -245,26 +245,46 @@ def keystones(como_json=False):
 
 
 def _fuente(f):
-    """(sha, url, version) de una receta — la identidad de lo que BAJA, no de cómo se llama."""
+    """(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
+    build distinto NO son redundantes: son variantes deliberadas (corpus/dbus estático vs
+    incoming-kde/dbus dinámico para Qt6DBus). Ignorar esto hacía que el detector marcara `dbus` como
+    sombra redundante y casi borra una receta load-bearing (2026-07-22)."""
     try:
         d = tomllib.load(open(f, "rb"))
     except Exception:
-        return (None, "", None)
+        return (None, "", None, None, False)
     s = d.get("source", {})
+    b = d.get("build", {})
+    ph = b.get("phases", {}) or {}
+    build_fp = (b.get("link"), b.get("compiler"),
+                tuple(sorted((k, str(v)) for k, v in ph.items())))
+    # ¿construye la FUENTE descargada, o es un CARRIER (stub/demo que genera todo inline y sólo presta
+    # el tarball)? Señal: invoca un sistema de build. itstool/-hello no ⇒ carrier; su colisión de sha
+    # es esperada, no un error de copiado. (2026-07-22: sin esto el detector marcaba 4 falsos bugs.)
+    blob = "\n".join(str(ph.get(k, "")) for k in ("configure", "compile", "install"))
+    # Strip comentarios shell (`# …` a fin de línea) ANTES de buscar: si no, la prosa de un stub
+    # ("# parsea la invocación de meson") matchea y marca al carrier como builder (falso positivo
+    # que el guardián cazó, 2026-07-22).
+    sin_comentarios = re.sub(r"#.*", "", blob)
+    construye = any(re.search(rx, sin_comentarios) for rx in
+                    (r"\bmake\b", r"\bmeson\b", r"\bcmake\b", r"\bninja\b", r"\bcargo\b",
+                     r"\./configure", r"\bautogen", r"\bautoreconf"))
     return (s.get("sha256") or s.get("output_hash"),
             s.get("tarball") or s.get("url") or s.get("git") or "",
-            d.get("version"))
+            d.get("version"), build_fp, construye)
 
 
 def _recetas_con_fuente():
-    """{(cola, nombre): {sha, url, version, deps}} sobre todo el disco."""
+    """{(cola, nombre): {sha, url, version, build_fp, construye_fuente, deps}} sobre todo el disco."""
     out = {}
     for pat, es_incoming in (("recipes/*.toml", False), ("recipes/incoming-*/*.toml", True)):
         for f in sorted(glob.glob(str(ROOT / pat))):
             n = os.path.basename(f)[:-5]
             q = os.path.basename(os.path.dirname(f)) if es_incoming else "corpus"
-            sha, url, ver = _fuente(f)
-            out[(q, n)] = dict(sha=sha, url=url, version=ver, deps=_deps(f))
+            sha, url, ver, build_fp, construye = _fuente(f)
+            out[(q, n)] = dict(sha=sha, url=url, version=ver, build_fp=build_fp,
+                               construye_fuente=construye, deps=_deps(f))
     return out
 
 
@@ -287,15 +307,27 @@ def duplicados(como_json=False):
     for k, r in recs.items():
         if r["sha"]:
             por_sha[r["sha"]].append(k)
-    colisiones, variantes = [], []
+    colisiones, variantes, carriers = [], [], []
     for sha, ks in por_sha.items():
         nombres = {n for _, n in ks}
         if len(nombres) < 2:
             continue
-        familias = {_base(n) for _, n in ks}
         grupo = dict(sha=sha[:16], nudos=sorted(f"{q}/{n}" for q, n in ks),
                      radio={f"{q}/{n}": len(rev.get((q, n), ())) for q, n in ks})
-        (variantes if len(familias) == 1 else colisiones).append(grupo)
+        builders = [k for k in ks if recs[k]["construye_fuente"]]
+        familias = {_base(n) for _, n in ks}
+        # Clasificación por evidencia, de más benigno a más sospechoso:
+        if len(builders) <= 1:
+            # ≤1 receta construye la fuente ⇒ el resto son CARRIERS que le prestan el tarball. Benigno.
+            grupo["carriers"] = sorted(f"{q}/{n}" for (q, n) in ks
+                                       if not recs[(q, n)]["construye_fuente"])
+            carriers.append(grupo)
+        elif len(familias) == 1:
+            variantes.append(grupo)          # misma familia (zlib/zlib-shared): variante deliberada
+        elif len({recs[k]["build_fp"] for k in builders}) > 1:
+            variantes.append(grupo)          # familias distintas pero BUILD distinto: variante cross-nombre
+        else:                                # ≥2 builders, MISMO build, familias distintas ⇒ sospechoso
+            colisiones.append(grupo)
 
     # 2) sombras: mismo nombre en >1 cola
     por_nombre = defaultdict(list)
@@ -305,15 +337,18 @@ def duplicados(como_json=False):
     for n, qs in por_nombre.items():
         if len(qs) < 2:
             continue
-        firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"],
+        # Redundante SÓLO si coinciden fuente Y BUILD: mismo tarball no basta, dos builds distintos
+        # del mismo source (estático vs dinámico) son variantes deliberadas, no redundancia.
+        firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"], recs[(q, n)]["build_fp"],
                    tuple(sorted(recs[(q, n)]["deps"]))) for q in qs}
         sombras.append(dict(nombre=n, colas=sorted(qs),
-                            redundante=len(firmas) == 1,  # idénticas ⇒ la sombra no aporta nada
+                            redundante=len(firmas) == 1,  # idénticas en fuente+build ⇒ una sobra
                             radio={f"{q}/{n}": len(rev.get((q, n), ())) for q in qs}))
 
-    doc = dict(schema="hammer-duplicados/1",
+    doc = dict(schema="hammer-duplicados/2",
                colisiones_fuente=sorted(colisiones, key=lambda g: -max(g["radio"].values() or [0])),
                variantes=sorted(variantes, key=lambda g: g["nudos"]),
+               carriers=sorted(carriers, key=lambda g: g["nudos"]),
                sombras=sorted(sombras, key=lambda s: (not s["redundante"], s["nombre"])))
     (ROOT / "docs/state/duplicados.json").write_text(
         json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
@@ -321,20 +356,25 @@ def duplicados(como_json=False):
     if como_json:
         print(json.dumps(doc, indent=1, ensure_ascii=False))
         return 0
+    red = [s for s in sombras if s["redundante"]]
     print(f"== INVARIANTE (c) CANÓNICO — duplicados por evidencia de fuente\n")
-    print(f"⚠ COLISIONES DE FUENTE: {len(colisiones)}  (mismo sha, familias distintas — revisá el sha)")
+    print(f"⚠ COLISIONES DE FUENTE REALES: {len(colisiones)}  (≥2 recetas construyen el MISMO source,")
+    print(f"   mismo build, familias distintas — casi seguro un sha copiado por error)")
     for g in doc["colisiones_fuente"]:
         print(f"   sha {g['sha']}…  {g['nudos']}")
         print(f"        radio: " + "  ".join(f"{k.split('/')[-1]}={v}" for k, v in g["radio"].items()))
-    red = [s for s in sombras if s["redundante"]]
-    print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)}  (mismo nombre+versión+sha+deps en 2 colas — una sobra)")
+    if not colisiones:
+        print("   (ninguna — el invariante (c) se cumple)")
+    print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)}  (mismo nombre+fuente+BUILD en 2 colas — una sobra)")
     for s in red:
-        print(f"   {s['nombre']:24} {s['colas']}  radio " +
-              " ".join(f"{q}={v}" for (q, _), v in
-                       ((k.split('/'), v) for k, v in s["radio"].items())))
-    print(f"\n✓ variantes deliberadas (misma fuente, familia OK): {len(variantes)}  — no son problema")
-    print(f"✓ sombras justificadas (difieren en versión/deps): {len(sombras)-len(red)}")
-    print(f"\n   → docs/state/duplicados.json  ·  el radio ayuda a elegir el canónico (el de mayor radio)")
+        print(f"   {s['nombre']:24} {s['colas']}")
+    if not red:
+        print("   (ninguna)")
+    print(f"\n✓ BENIGNOS (no son problema):")
+    print(f"   {len(carriers)} grupos CARRIER (stub/demo que presta el tarball: itstool, *-hello)")
+    print(f"   {len(variantes)} variantes deliberadas (misma fuente, build/familia distintos: zlib-shared, prison-scanner)")
+    print(f"   {len(sombras)-len(red)} sombras justificadas (mismo nombre, build distinto: dbus estático/dinámico)")
+    print(f"\n   → docs/state/duplicados.json")
     return 0