#!/usr/bin/env python3 # seed-graph.py — SIEMBRA las aristas de los nodos `wanted` del grafo desde la metadata de nixpkgs, # SIN construir nada. (P3 del plan docs/plan-catalogo-objetivo.md.) # # POR QUÉ. `build-state.py` sabe qué falta (nodos `wanted`) pero no de qué cuelga: las deps de un # paquete sin receta no se conocen hasta escribirla. Eso deja la clausura de cada perfil como cota # inferior y el orden de ataque a ciegas. nixpkgs ya tiene ese grafo; se extrae con `nix eval` (sólo # METADATA — leerla no viola el ADR 0004, que prohíbe *construir* con nix). # # LA SEMILLA ES UNA HIPÓTESIS, NO LA VERDAD. Son las deps de OTRA distro con OTRAS decisiones de # build (qué opciones, qué backends). Sirve para hacer la frontera contable y ordenable, no para # creerle. Por eso: # · sale a `docs/state/seed-edges.json`, un fichero APARTE, con procedencia por arista; # · NUNCA se escribe en `recipes/` ni en `targets.toml`; # · sólo se consulta para nodos SIN receta — en cuanto hay receta, manda la receta (ya corregida # por el veredicto de harkaq). La semilla se auto-borra a medida que el frente se drena. # # Cada arista sembrada se CLASIFICA, que es donde está el valor: # conocida — tras normalizar, hay receta con ese nombre. Arista utilizable ya. # frontera — no hay receta y no es un nix-ismo ⇒ candidato a nodo `wanted` nuevo. # nix-ismo — andamiaje de nixpkgs que en hammer no existe (hooks, wrappers, doc tooling). # lab — toolchain que provee el rootfs del sandbox, no una receta (cargo/rustc). # # CALIBRADO, no supuesto (medición completa en el plan). Muestra de 60 recetas / 686 deps: # aristas directas → 59% de precisión; el cierre transitivo sube el recall a 84% pero se lleva la # precisión a 6% (4629 nodos fantasma del bootstrap de nixpkgs) ⇒ **se siembran DIRECTAS y la # transitividad la hace el grafo**, que es lo que `build-state.py` ya sabe hacer con las aristas. # # Uso: scripts/seed-graph.py --calibrar [N] [--transitivo] # mide el ruido contra recetas escritas # scripts/seed-graph.py --frontera # qué pide nixpkgs y hammer no tiene → seed-frontera.json # scripts/seed-graph.py --perfil # siembra los nodos `wanted` → seed-edges.json # scripts/seed-graph.py # siembra nombres sueltos # Env: NIXPKGS (def nixpkgs), NIX_ROOT (def ~/.nixstore; vacío = store del sistema) import json, os, re, subprocess, sys, glob, tomllib from pathlib import Path ROOT = Path(__file__).resolve().parent.parent os.chdir(ROOT) sys.path.insert(0, str(ROOT / "scripts")) import targets as targets_mod TRANSITIVO = "--transitivo" in sys.argv # comparar CLAUSURA sembrada vs deps aplanadas de hammer NIXPKGS = os.environ.get("NIXPKGS", "nixpkgs") NIX_ROOT = os.environ.get("NIX_ROOT", os.path.expanduser("~/.nixstore")) OUT = ROOT / "docs/state/seed-edges.json" FRONTERA = ROOT / "docs/state/seed-frontera.json" LOTE = 120 # nombres por proceso de `nix eval` (uno solo evalúa 6 en 0.7s; el costo es el arranque) # --- normalización nix → hammer ------------------------------------------------------------------- # Mapeo por evidencia, no por intuición: cada entrada salió de una discrepancia vista en --calibrar. MAPA = { "ninja": "samurai", # hammer usa samurai como backend de meson "pkg-config": "pkgconf", "pkg-config-wrapper": "pkgconf", "libpng-apng": "libpng", # nixpkgs sirve libpng con el parche APNG "gettext": "gettext-tiny", # hammer usa la implementación tiny "gnum4": "m4", "XML-Parser": "perl-xml-parser", "bash-interactive": "bash", "util-linux-minimal": "util-linux", "cacert": "ca-certificates", } # Andamiaje de nixpkgs sin contraparte en hammer: hooks del stdenv, wrappers, tooling de docs. NIXISMOS_EXACTOS = { "", "which", "stdenv", "gtk-doc", "docbook-xml", "docbook-xsl", "docbook-xsl-nons", "docbook-xsl-ns", "docbook2X", "asciidoc", "asciidoctor", "install-shell-files", "fix-darwin-dylib-names", "update-autotools-gnu-config-scripts-hook", "gobject-introspection-wrapped", "nuke-refs", "auditable-cargo", "glibc-iconv", "glibc-locales", "cmake-minimal", } NIXISMOS_SUFIJOS = ("-hook", "Hook", "-wrapper", "-setup-hook", ".sh") # Lazo cerrado: los candidatos que un humano marcó `nix-ismo` en el triaje (P5) vuelven acá como # descarte, así cada corrida del sembrador sale más limpia que la anterior. Si el fichero no está, # no pasa nada — el sembrador funciona igual, sólo con más ruido. _TRIAJE = ROOT / "docs/state/nixismos-triaje.txt" if _TRIAJE.exists(): NIXISMOS_EXACTOS |= {l.strip() for l in _TRIAJE.read_text().splitlines() if l.strip() and not l.startswith("#")} # Y los `provisto`: nombres que nixpkgs separa en un paquete propio y acá los da otra receta # (`mesa-libgbm`→mesa). Entran al MAPA, así se resuelven como arista CONOCIDA en vez de hueco. _ALIAS = ROOT / "docs/state/alias-triaje.txt" if _ALIAS.exists(): for _l in _ALIAS.read_text().splitlines(): if _l.strip() and not _l.startswith("#"): _p = _l.split() if len(_p) == 2: MAPA.setdefault(_p[0], _p[1]) NIXISMOS_INFIJOS = ("-hook",) # `cargo-build-hook.sh` no termina en `-hook`, lo contiene # Toolchains que provee el LAB (el rootfs del sandbox), no una receta: ninguna receta Rust declara # `rust` ni `cargo` en `[deps].build` — están vacías y el toolchain viene de abajo. Que nixpkgs los # liste no es un hueco de hammer; es que las dos distros ponen la frontera en lugares distintos. PROVISTO_POR_EL_LAB = {"cargo", "rustc", "rust"} # Deps que hammer declara SIEMPRE y nix nunca lista porque las provee el stdenv implícito. No son # un fallo del sembrador: son convención del sandbox de hammer (cada dep es una capa --overlay-src). CONVENCION_HAMMER = {"make", "binutils", "linux-headers"} # Sufijos de VARIANTE de hammer: `zlib` y `zlib-shared` son el mismo paquete con otra decisión de # enlazado. nixpkgs no puede saber cuál eligió hammer ⇒ no es un fallo del sembrador, es una # decisión nuestra. Se cuenta aparte para no inflar ni el acierto ni el error. VARIANTES = ("-shared", "-static") def normalizar(n, por_minusculas=None): """nix-name → (hammer-name, es_nixismo). El mapeo va ANTES del descarte (`pkg-config-wrapper` termina en `-wrapper` pero es pkgconf, dep real). Con `por_minusculas` ({nombre.lower(): nombre real}) resuelve además las diferencias de caja: nixpkgs dice `libx11`, hammer `libX11`.""" if n in MAPA: return MAPA[n], False # nixpkgs a veces deja la versión pegada al pname (`auditable-cargo-1.96.1`, `glibc-iconv-2.42`): # se despega para poder reconocer la base. Sólo si el sufijo es puramente numérico. base = re.sub(r"-\d[\d.]*$", "", n) if base != n and (base in NIXISMOS_EXACTOS or base in MAPA): return MAPA.get(base, base), base not in MAPA if (n in NIXISMOS_EXACTOS or any(n.endswith(s) for s in NIXISMOS_SUFIJOS) or any(s in n for s in NIXISMOS_INFIJOS)): return n, True # `-wrapped` / `-unwrapped` son variantes de empaquetado de nix, no paquetes distintos. for suf in ("-unwrapped", "-wrapped"): if n.endswith(suf): n = n[: -len(suf)] break if por_minusculas: n = por_minusculas.get(n.lower(), n) return n, False def raiz_variante(n): """`zlib-shared` → `zlib`. Para comparar sin castigar una decisión de enlazado nuestra.""" for suf in VARIANTES: if n.endswith(suf): return n[: -len(suf)] return n # --- lectura del corpus --------------------------------------------------------------------------- def recetas(): """{nombre: [deps de build]} sobre `recipes/` + las colas incoming-*.""" out = {} for pat in ["recipes/*.toml", "recipes/incoming-*/*.toml"]: for f in sorted(glob.glob(pat)): n = os.path.basename(f)[:-5] try: d = tomllib.load(open(f, "rb")) except Exception: continue out.setdefault(n, d.get("deps", {}).get("build", [])) return out # --- extracción desde nixpkgs --------------------------------------------------------------------- # Los paquetes NO viven todos en el top-level de nixpkgs: el escritorio KDE —justo el objetivo de # este frente— cuelga de `kdePackages.` (Plasma 6) o `libsForQt5.`. Sin este fallback, 22 de 40 # nombres de la primera calibración salían "sin attr en nixpkgs" y la frontera KDE era insembrable. # `ns` viaja en la respuesta: de dónde salió cada dep es parte de su procedencia. EXPR = """ pkgs: let names = [ %s ]; espacios = [ { ns = ""; set = pkgs; } ] ++ (if pkgs ? kdePackages then [ { ns = "kdePackages"; set = pkgs.kdePackages; } ] else []) ++ (if pkgs ? libsForQt5 then [ { ns = "libsForQt5"; set = pkgs.libsForQt5; } ] else []); buscar = n: let hits = builtins.filter (e: e.set ? ${n}) espacios; in if hits == [] then null else builtins.head hits; uno = n: let e = buscar n; in if e == null then null else let r = builtins.tryEval (let p = e.set.${n}; v = { ns = e.ns; bi = map (x: x.pname or x.name or "") (p.buildInputs or []); nbi = map (x: x.pname or x.name or "") (p.nativeBuildInputs or []); pbi = map (x: x.pname or x.name or "") (p.propagatedBuildInputs or []); }; in builtins.deepSeq v v); in if r.success then r.value else null; in builtins.listToAttrs (map (n: { name = n; value = uno n; }) names) """ def nix_deps(nombres): """{nombre: {bi,nbi,pbi}} o None si nixpkgs no lo tiene / su eval falla. En LOTES: el costo es el arranque del evaluador, no el paquete (6 en 0.7s con el eval cache caliente).""" res = {} for i in range(0, len(nombres), LOTE): chunk = nombres[i : i + LOTE] expr = EXPR % " ".join(json.dumps(n) for n in chunk) cmd = ["nix", "eval", "--extra-experimental-features", "nix-command flakes"] if NIX_ROOT: cmd += ["--store", f"local?root={NIX_ROOT}"] cmd += ["--json", f"{NIXPKGS}#legacyPackages.x86_64-linux", "--apply", expr] print(f" nix eval lote {i//LOTE+1} ({len(chunk)} nombres)…", file=sys.stderr) p = subprocess.run(cmd, capture_output=True, text=True, timeout=1800) if p.returncode != 0: print(f" ⚠ el lote falló: {p.stderr.strip()[:300]}", file=sys.stderr) res.update({n: None for n in chunk}) continue res.update(json.loads(p.stdout)) return res def sembrar(nombres, recs): """nombre → [{dep, clase, origen_nix}]. Une bi+nbi+pbi: las deps REALES de varios paquetes viven en `propagatedBuildInputs` (freetype: zlib/bzip2/brotli/libpng están ahí, no en buildInputs).""" por_minusculas = {r.lower(): r for r in recs} crudo = nix_deps(nombres) out = {} for n in nombres: d = crudo.get(n) if d is None: out[n] = None # nixpkgs no lo tiene: la frontera queda sin sembrar (candidato a Alpine) continue vistos, aristas = set(), [] for campo in ("nbi", "bi", "pbi"): for crudo_n in d.get(campo) or []: dep, nixismo = normalizar(crudo_n, por_minusculas) if dep in vistos: continue vistos.add(dep) clase = ("nix-ismo" if nixismo else "lab" if dep in PROVISTO_POR_EL_LAB else "conocida" if dep in recs else "frontera") aristas.append(dict(dep=dep, clase=clase, origen_nix=crudo_n, campo=campo, ns=d.get("ns") or "top-level")) out[n] = aristas return out # --- modos ---------------------------------------------------------------------------------------- def sembrar_cierre(raices, recs, prof_max=4, tope=4000): """Siembra en ONDAS hasta punto fijo: las deps de las deps. Necesario porque las dos distros declaran cosas distintas — hammer APLANA la clausura en `[deps].build` (cada dep es una capa --overlay-src del sandbox: si no está declarada, no está en el árbol), mientras nix declara sólo las DIRECTAS y deja que la propagación haga el resto. Comparar unas con otras mide un artefacto de la comparación, no la calidad de la semilla. Devuelve {nombre: [aristas]} de TODO lo visitado; la clausura se calcula después sobre ese grafo (que es también lo que hace `build-state.py` con las aristas reales).""" grafo, pendientes, prof = {}, list(dict.fromkeys(raices)), 0 while pendientes and prof < prof_max and len(grafo) < tope: prof += 1 print(f" onda {prof}: {len(pendientes)} nombres nuevos", file=sys.stderr) nuevo = sembrar(pendientes, recs) grafo.update(nuevo) siguientes = {a["dep"] for ar in nuevo.values() if ar for a in ar if a["clase"] not in ("nix-ismo", "lab") and a["dep"] not in grafo} pendientes = sorted(siguientes)[: max(0, tope - len(grafo))] return grafo def clausura_sembrada(raiz, grafo): """Alcanzables desde `raiz` por las aristas sembradas (sin la raíz).""" vistos, pila = set(), [a["dep"] for a in (grafo.get(raiz) or []) if a["clase"] not in ("nix-ismo", "lab")] while pila: n = pila.pop() if n in vistos: continue vistos.add(n) for a in grafo.get(n) or []: if a["clase"] not in ("nix-ismo", "lab") and a["dep"] not in vistos: pila.append(a["dep"]) return vistos def calibrar(recs, n_muestra): """Mide la semilla contra la VERDAD que ya tenemos: las recetas escritas a mano. Es la única forma honesta de saber si sembrar sirve, antes de sembrar nada.""" # Candidatas: recetas C/GUI con deps declaradas (las Rust/Go declaran sólo el toolchain). cand = [n for n, deps in sorted(recs.items()) if deps and "go" not in deps and "rust" not in deps and "cargo" not in deps] paso = max(1, len(cand) // n_muestra) muestra = cand[::paso][:n_muestra] print(f"== calibrando sobre {len(muestra)} recetas con deps declaradas (de {len(cand)})") if TRANSITIVO: grafo = sembrar_cierre(muestra, recs) sem = {n: ([dict(dep=d, clase=("conocida" if d in recs else "frontera"), origen_nix=d, campo="cierre", ns="") for d in sorted(clausura_sembrada(n, grafo))] if grafo.get(n) is not None else None) for n in muestra} else: sem = sembrar(muestra, recs) tot_v = tot_ok = tot_falta_real = tot_falta_conv = tot_variante = 0 sobra_con_receta, sobra_frontera, nixismos = {}, {}, {} sin_nix, detalle = [], [] for n in muestra: aristas = sem.get(n) if aristas is None: sin_nix.append(n) continue for a in aristas: if a["clase"] == "nix-ismo": nixismos[a["origen_nix"]] = nixismos.get(a["origen_nix"], 0) + 1 sembrado = {a["dep"] for a in aristas if a["clase"] not in ("nix-ismo", "lab")} verdad = set(recs[n]) ok = verdad & sembrado falta = verdad - sembrado # `zlib-shared` declarada y `zlib` sembrada: acertó el paquete, no la variante. Aparte. raices_sembradas = {raiz_variante(s) for s in sembrado} variante = {f for f in falta if raiz_variante(f) in raices_sembradas} falta -= variante falta_conv = falta & CONVENCION_HAMMER falta_real = falta - CONVENCION_HAMMER sobra = {s for s in sembrado - verdad if raiz_variante(s) not in {raiz_variante(v) for v in verdad}} for s in sobra: destino = sobra_con_receta if s in recs else sobra_frontera destino[s] = destino.get(s, 0) + 1 tot_v += len(verdad); tot_ok += len(ok); tot_variante += len(variante) tot_falta_real += len(falta_real); tot_falta_conv += len(falta_conv) detalle.append((n, len(verdad), len(ok), sorted(falta_real), sorted(sobra))) print(f"\n== VEREDICTO (verdad = las {tot_v} deps declaradas a mano en la muestra)") if tot_v: print(f" aciertos {tot_ok:4} ({100*tot_ok//tot_v}% de la verdad)") print(f" faltan (reales) {tot_falta_real:4} ({100*tot_falta_real//tot_v}%) ← la semilla NO las ve") print(f" faltan (convención hammer: {' '.join(sorted(CONVENCION_HAMMER))}) {tot_falta_conv:4}" f" ← nix las da por implícitas en el stdenv; NO es ruido, es constante") print(f" variante distinta {tot_variante:4} ← acertó el paquete, erró -shared/-static" f" (decisión NUESTRA, nix no puede saberla)") n_sobra = sum(sobra_con_receta.values()) + sum(sobra_frontera.values()) print(f" sobrantes {n_sobra:4} = {sum(sobra_con_receta.values())} con receta en hammer" f" (deps que hammer NO necesitó) + {sum(sobra_frontera.values())} sin receta") print(f" nix-ismos filtrados {sum(nixismos.values()):3} (andamiaje descartado antes de contar)") if sin_nix: print(f" sin attr en nixpkgs {len(sin_nix):3}: {' '.join(sin_nix[:12])}") print("\n== los 15 sobrantes SIN receta más frecuentes (candidatos a MAPA o a nix-ismo)") for k, v in sorted(sobra_frontera.items(), key=lambda kv: -kv[1])[:15]: print(f" {v:3}× {k}") print("\n== peores casos (más deps reales no vistas)") for n, nv, nok, falta, sobra in sorted(detalle, key=lambda d: -len(d[3]))[:8]: print(f" {n:22} verdad {nv:2}, acierta {nok:2}, no ve: {' '.join(falta) or '—'}") return 0 def frontera(recs, perfil): """Qué dice nixpkgs que hace falta y hammer NO TIENE RECETA para construir, mirando las recetas que YA existen en la clausura de un perfil. Es el modo útil mientras no haya nodos `wanted`: descubre la frontera desde el corpus actual en vez de esperar a que alguien la declare. Cada candidato es una HIPÓTESIS a clasificar por un humano: puede ser (a) una dep opcional que hammer no habilitó a propósito, (b) un nix-ismo que falta en la lista de descarte, o (c) un hueco real. El sembrador no decide cuál: los ordena por cuántas recetas los piden.""" # El grafo se elige por la COLA que el perfil declara, no por cuál tiene más nodos: la cola # `incoming-kde` SOMBREA recetas canónicas, así que medir un perfil del corpus contra el grafo # KDE mide una imagen que nadie construye. try: cola = targets_mod.load().get(perfil, {}).get("cola", "corpus") except Exception: cola = "corpus" en_perfil, grafo_usado = [], None for f in ("docs/state/build-state.json", "docs/state/build-state-kde.json", "docs/state/build-state-gnome.json"): p = ROOT / f if not p.exists(): continue d = json.loads(p.read_text()) if cola not in (d.get("by_queue") or {}): continue hit = sorted(n for n, r in d["nodes"].items() if perfil in (r.get("perfiles") or [])) if hit: en_perfil, grafo_usado = hit, f break if not en_perfil: print(f"perfil `{perfil}` sin nodos en ningún grafo de estado " f"(¿regeneraste con scripts/build-state.py [--kde]?)", file=sys.stderr) return 1 print(f" (grafo: {grafo_usado})") print(f"== sembrando las {len(en_perfil)} recetas de la clausura de `{perfil}`") sem = sembrar(en_perfil, recs) cand, sin_nix = {}, [] for n, aristas in sem.items(): if aristas is None: sin_nix.append(n); continue for a in aristas: if a["clase"] == "frontera": cand.setdefault(a["dep"], []).append(n) print(f" sin attr en nixpkgs: {len(sin_nix)}" + (f" ({' '.join(sin_nix[:10])}…)" if sin_nix else "")) orden = sorted(cand.items(), key=lambda kv: (-len(kv[1]), kv[0])) print(f"\n== {len(cand)} candidatos a HUECO (nixpkgs los pide, hammer no tiene receta)") for dep, quienes in orden[:40]: print(f" {len(quienes):3}× {dep:28} ← {' '.join(sorted(quienes)[:5])}" + (" …" if len(quienes) > 5 else "")) # Artefacto durable y versionado: el `git diff` de este fichero muestra qué huecos APARECEN # entre dos corridas (una receta nueva puede destapar deps que nadie había pedido antes). prev = json.loads(FRONTERA.read_text()) if FRONTERA.exists() else {"perfiles": {}} prev.setdefault("perfiles", {})[perfil] = dict( recetas_sembradas=len(en_perfil), sin_attr_en_nixpkgs=sin_nix, candidatos={dep: sorted(q) for dep, q in orden}) prev["schema"] = "hammer-seed-frontera/1" prev["advertencia"] = ("HIPÓTESIS desde metadata de nixpkgs. Cada candidato es (a) dep opcional " "que hammer no habilitó, (b) nix-ismo por filtrar, o (c) hueco real. " "Clasificar a mano; NO promover a receta automáticamente.") FRONTERA.write_text(json.dumps(prev, indent=1, ensure_ascii=False, sort_keys=True) + "\n") print(f"\n → {FRONTERA.relative_to(ROOT)} (perfil `{perfil}`)") return 0 def sembrar_a_fichero(nombres, recs): sem = sembrar(nombres, recs) doc = dict( schema="hammer-seed-edges/1", fuente="nixpkgs (metadata; NO se construye con nix — ADR 0004)", advertencia="HIPÓTESIS, no verdad. Sólo válido para nodos SIN receta; con receta, manda la receta.", nodos={n: (a if a is not None else "sin-attr-en-nixpkgs") for n, a in sorted(sem.items())}, ) OUT.write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n") n_ok = sum(1 for a in sem.values() if a) conocidas = sum(1 for a in sem.values() if a for x in a if x["clase"] == "conocida") frontera = sorted({x["dep"] for a in sem.values() if a for x in a if x["clase"] == "frontera"}) print(f"== sembrados {n_ok}/{len(nombres)} nodos → {OUT.relative_to(ROOT)}") print(f" aristas `conocida` (hay receta): {conocidas}") print(f" `frontera` (candidatos a nodo wanted nuevo): {len(frontera)}") if frontera: print(" " + " ".join(frontera[:30]) + (" …" if len(frontera) > 30 else "")) return 0 def main(): args = sys.argv[1:] recs = recetas() if not args: print(__doc__ or "uso: seed-graph.py --calibrar [N] | --perfil

| ", file=sys.stderr) return 1 if args[0] == "--calibrar": n = next((int(a) for a in args[1:] if a.isdigit()), 40) return calibrar(recs, n) if args[0] == "--frontera": if len(args) < 2: print("uso: seed-graph.py --frontera ", file=sys.stderr); return 1 return frontera(recs, args[1]) if args[0] == "--perfil": perfiles = targets_mod.load() p = perfiles.get(args[1]) if not p: print(f"perfil desconocido: {args[1]}", file=sys.stderr); return 1 pend = [r for r in p["raices"] if r not in recs] if not pend: print(f"el perfil `{args[1]}` no tiene nodos sin receta: nada que sembrar."); return 0 return sembrar_a_fichero(pend, recs) return sembrar_a_fichero(args, recs) if __name__ == "__main__": sys.exit(main())