diff --git a/docs/plan-catalogo-objetivo.md b/docs/plan-catalogo-objetivo.md index 3b56c253..3628660e 100644 --- a/docs/plan-catalogo-objetivo.md +++ b/docs/plan-catalogo-objetivo.md @@ -127,6 +127,56 @@ descarte de nix-ismos. Va a haber ruido. Su único trabajo es hacer la frontera ordenable**, no correcta. Nota de ADR: leer la *metadata* de nixpkgs no viola el ADR 0004 — lo que prohíbe es **construir** con nix, y acá no se construye nada. +#### P3 — RESULTADO MEDIDO (2026-07-22) + +`scripts/seed-graph.py`, con `--calibrar` que mide la semilla contra la verdad que ya tenemos: las +recetas escritas a mano. Tres rondas de calibración, cada corrección salida de un dato y no de una +intuición. Muestra final: 60 recetas, 686 deps declaradas. + +| modo | recall (de la verdad) | precisión (aristas sembradas que son reales) | +|---|---|---| +| **directas** | 39% * | **59%** (269 de 455) | +| transitivas | **84%** | 6% (390 de 6760) | + +\* contra la verdad *aplanada*. La comparación es injusta y el modo transitivo lo prueba: la +información **sí está** (84%), sólo hace falta aplanarla. + +**Decisión: se siembran aristas DIRECTAS; la transitividad la hace el grafo.** Las dos distros ponen +la frontera en lugares distintos — 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), nixpkgs declara sólo las +directas y propaga. Reproducir el aplanado con el cierre transitivo de nixpkgs arrastra su universo +de bootstrap: **4629 nodos fantasma** (`glibc-locales`, `autoconf`, `texinfo`, `help2man`). Como +`build-state.py` ya calcula clausura desde las aristas, aplanar en el sembrador es trabajo duplicado +que además envenena el grafo. + +Lo que la calibración enseñó, y que ninguna intuición habría dado: + +- **Cuatro clases de discrepancia, no una.** No todo desacuerdo es ruido: `make`/`binutils`/ + `linux-headers` son **convención de hammer** (nix las da por implícitas en el stdenv); `zlib` vs + `zlib-shared` es una **decisión nuestra** de enlazado que nix no puede conocer; `cargo`/`rustc` los + **provee el lab**, no una receta (las recetas Rust declaran `deps = []`); y recién lo que queda es + ruido de verdad. Contarlas juntas hacía parecer irreducible lo que era clasificable. +- **Los nombres desalinean de formas aburridas y arreglables**: mayúsculas (`libx11` vs `libX11`), + implementación (`gettext`→`gettext-tiny`, `ninja`→`samurai`), versión pegada al pname + (`glibc-iconv-2.42`), hooks del stdenv (`cargo-build-hook.sh`). Filtrarlos subió la precisión de + 45% a 59% y bajó los "sin attr en nixpkgs" de 22/40 a 6/60. +- **El escritorio KDE no vive en el top-level de nixpkgs** sino bajo `kdePackages.`. Sin ese + fallback, justo el perfil objetivo era insembrable. + +**Y un hallazgo de secuencia: hoy hay 0 nodos `wanted`**, porque `targets.toml` se pobló por +lift-and-shift de lo que ya existe. Un sembrador de nodos inexistentes no tiene a quién sembrar. De +ahí el modo que sí da valor ya: `--frontera ` siembra las recetas que YA existen en la +clausura de un perfil y reporta lo que nixpkgs pide y hammer no tiene receta para construir — +descubre la frontera desde el corpus actual en vez de esperar a que alguien la declare. Sale a +`docs/state/seed-frontera.json`, versionado, para que el `git diff` muestre qué huecos aparecen. +Primer barrido: **137 candidatos en `escritorio-kde`** (`kdoctools` ×6, `milou`, `polkit-qt-1`, +`libkscreen`, `qqc2-breeze-style`, y `mesa-libgbm`/`libglvnd`/`spirv-tools` — que son exactamente el +muro de GBM/EGL ya documentado), **51 en `cli`**, 46 en `escritorio-mirada`. + +Cada candidato es una **hipótesis a clasificar por un humano**: (a) dep opcional que hammer no +habilitó a propósito, (b) nix-ismo que falta filtrar, o (c) hueco real. El sembrador no decide cuál; +los ordena por cuántas recetas los piden. **Nada se promueve a receta automáticamente.** + ### P4 — el bucle de drenaje, y dónde harkaq cierra el lazo Regla de fuente única de verdad: diff --git a/docs/state/seed-frontera.json b/docs/state/seed-frontera.json new file mode 100644 index 00000000..8abdf02c --- /dev/null +++ b/docs/state/seed-frontera.json @@ -0,0 +1,840 @@ +{ + "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.", + "perfiles": { + "cli": { + "candidatos": { + "acl": [ + "rsync" + ], + "adns": [ + "gnupg" + ], + "audit": [ + "dbus" + ], + "autoconf": [ + "util-linux" + ], + "autoconf-archive": [ + "python3" + ], + "automake": [ + "util-linux" + ], + "bluez-headers": [ + "python3" + ], + "check": [ + "parted" + ], + "cmocka": [ + "shadow" + ], + "db": [ + "iproute2" + ], + "ed": [ + "lsof" + ], + "fuse": [ + "e2fsprogs" + ], + "gdbm": [ + "python3" + ], + "git-minimal": [ + "starship" + ], + "glibc": [ + "go" + ], + "gnutls": [ + "gnupg" + ], + "groff": [ + "sudo" + ], + "help2man": [ + "flex" + ], + "iptables": [ + "iproute2" + ], + "jemalloc": [ + "fd" + ], + "kmod": [ + "pciutils" + ], + "krb5": [ + "curl" + ], + "libapparmor": [ + "dbus" + ], + "libbpf": [ + "iproute2" + ], + "libbsd": [ + "shadow" + ], + "libcap-ng": [ + "dbus", + "util-linux" + ], + "libidn2": [ + "curl", + "iputils" + ], + "libmnl": [ + "iproute2" + ], + "libpsl": [ + "curl" + ], + "libunwind": [ + "strace" + ], + "libxcrypt": [ + "perl", + "shadow", + "util-linux" + ], + "lvm2": [ + "parted" + ], + "mpdecimal": [ + "python3" + ], + "nghttp2": [ + "curl" + ], + "nghttp3": [ + "curl" + ], + "ngtcp2": [ + "curl" + ], + "openldap": [ + "gnupg" + ], + "pandoc-cli": [ + "eza" + ], + "po4a": [ + "util-linux" + ], + "popt": [ + "rsync" + ], + "remove-references-to": [ + "jq" + ], + "systemd-minimal": [ + "dbus" + ], + "systemd-minimal-libs": [ + "dhcpcd", + "htop", + "util-linux" + ], + "tcb": [ + "shadow" + ], + "texinfo": [ + "e2fsprogs", + "git", + "gnupg" + ], + "tinyxxd": [ + "dosfstools" + ], + "tpm2-tss": [ + "gnupg" + ], + "uname": [ + "git" + ], + "xclip": [ + "gitui" + ], + "xmlto": [ + "git" + ], + "xxhash": [ + "rsync" + ] + }, + "recetas_sembradas": 74, + "sin_attr_en_nixpkgs": [ + "ca-certificates", + "libusb", + "linux-headers", + "make", + "procps-ng", + "sed" + ] + }, + "escritorio-kde": { + "candidatos": { + "SPIRV-LLVM-Translator": [ + "mesa" + ], + "acl": [ + "kio" + ], + "appstream-qt": [ + "frameworkintegration", + "plasma-workspace" + ], + "aspell": [ + "sonnet" + ], + "at-spi2-core": [ + "qtbase" + ], + "attr": [ + "kfilemetadata", + "kio" + ], + "audit": [ + "dbus", + "linux-pam" + ], + "aurorae": [ + "kwin" + ], + "autoconf": [ + "util-linux" + ], + "autoconf-archive": [ + "python3" + ], + "automake": [ + "util-linux" + ], + "avahi": [ + "kdnssd" + ], + "baloo": [ + "plasma-workspace" + ], + "bluez-headers": [ + "python3" + ], + "clang": [ + "mesa" + ], + "cups": [ + "qtbase" + ], + "db": [ + "linux-pam" + ], + "dejagnu": [ + "libffi" + ], + "directx-headers": [ + "mesa" + ], + "docbook5": [ + "linux-pam" + ], + "docutils": [ + "libdrm" + ], + "double-conversion": [ + "qtbase" + ], + "doxygen": [ + "libqalculate", + "libxkbcommon", + "wayland" + ], + "ebook-tools": [ + "kfilemetadata" + ], + "editorconfig-core-c": [ + "ktexteditor" + ], + "flatpak": [ + "plasma-workspace" + ], + "flite": [ + "qtspeech" + ], + "gdbm": [ + "python3" + ], + "glslang": [ + "mesa" + ], + "gmp-with-cxx": [ + "mpfr" + ], + "gpgmepp": [ + "kwallet" + ], + "gpsd": [ + "plasma-workspace" + ], + "graphviz": [ + "wayland" + ], + "gst-libav": [ + "qtmultimedia" + ], + "gst-plugins-bad": [ + "qtmultimedia" + ], + "gst-plugins-base": [ + "libcanberra", + "qtmultimedia" + ], + "gst-plugins-good": [ + "qtmultimedia" + ], + "gstreamer": [ + "libcanberra", + "qtmultimedia" + ], + "gtk+3": [ + "qtbase" + ], + "help2man": [ + "flex" + ], + "hunspell": [ + "sonnet" + ], + "jasper": [ + "qtsvg" + ], + "kdegraphics-mobipocket": [ + "kfilemetadata" + ], + "kdoctools": [ + "kded", + "kio", + "kpackage", + "kservice", + "kwallet", + "kwin" + ], + "kio-extras": [ + "plasma-workspace" + ], + "kuserfeedback": [ + "plasma-workspace" + ], + "libapparmor": [ + "dbus" + ], + "libappimage": [ + "kfilemetadata" + ], + "libb2": [ + "qtbase" + ], + "libcap-ng": [ + "dbus", + "util-linux" + ], + "libclc": [ + "mesa" + ], + "libdatrie": [ + "qtbase" + ], + "libdmtx": [ + "prison" + ], + "libei": [ + "kwin" + ], + "libglvnd": [ + "libepoxy", + "mesa", + "qtbase" + ], + "libimobiledevice": [ + "solid" + ], + "libkexiv2": [ + "plasma-workspace" + ], + "libmng": [ + "qtsvg" + ], + "libpciaccess": [ + "libdrm" + ], + "libpq": [ + "qtbase" + ], + "libproxy": [ + "qtbase" + ], + "libpulseaudio": [ + "libcanberra", + "qtmultimedia" + ], + "libqaccessibilityclient": [ + "kwin" + ], + "libsecret": [ + "kwallet" + ], + "libselinux": [ + "qtbase" + ], + "libsepol": [ + "qtbase" + ], + "libthai": [ + "qtbase" + ], + "libunwind": [ + "mesa", + "qtmultimedia" + ], + "libva-minimal": [ + "mesa" + ], + "libwacom": [ + "libinput" + ], + "libxcb-cursor": [ + "qtbase" + ], + "libxcb-image": [ + "qtbase", + "xcb-util-cursor" + ], + "libxcb-keysyms": [ + "mesa", + "qtbase" + ], + "libxcb-render-util": [ + "qtbase", + "xcb-util-cursor" + ], + "libxcb-util": [ + "qtbase" + ], + "libxcb-wm": [ + "qtbase" + ], + "libxcomposite": [ + "qtbase" + ], + "libxcrypt": [ + "linux-pam", + "perl", + "util-linux" + ], + "libxft": [ + "plasma-workspace" + ], + "libxrandr": [ + "mesa", + "qtmultimedia", + "vulkan-loader" + ], + "libxscrnsaver": [ + "kidletime" + ], + "libxshmfence": [ + "mesa" + ], + "libxxf86vm": [ + "mesa" + ], + "lksctp-tools": [ + "qtbase" + ], + "llvm": [ + "mesa" + ], + "lndir": [ + "qtbase" + ], + "lttng-ust": [ + "qtbase" + ], + "lua": [ + "libinput" + ], + "mariadb-connector-c": [ + "qtbase" + ], + "mariadb-connector-odbc": [ + "qtbase" + ], + "md4c": [ + "qtbase" + ], + "mesa-libgbm": [ + "kwin", + "mesa", + "qtbase" + ], + "milou": [ + "kwin", + "plasma-workspace" + ], + "mpdecimal": [ + "python3" + ], + "orc": [ + "qtmultimedia" + ], + "oxygen-icons": [ + "frameworkintegration" + ], + "packagekit-qt": [ + "frameworkintegration", + "plasma-workspace" + ], + "plasma-nano": [ + "plasma-workspace" + ], + "ply": [ + "mesa" + ], + "po4a": [ + "util-linux" + ], + "polkit-qt-1": [ + "kauth", + "plasma-workspace" + ], + "poppler-qt6": [ + "kfilemetadata" + ], + "psqlodbc": [ + "qtbase" + ], + "pycparser": [ + "mesa" + ], + "pyside6": [ + "kcoreaddons", + "kguiaddons", + "knotifications", + "kstatusnotifieritem", + "kwidgetsaddons", + "kxmlgui" + ], + "python3-3.14.6-env": [ + "kcoreaddons", + "kguiaddons", + "knotifications", + "kstatusnotifieritem", + "kwidgetsaddons", + "kxmlgui", + "libinput" + ], + "pyyaml": [ + "mesa" + ], + "qca": [ + "kwallet" + ], + "qqc2-breeze-style": [ + "kscreenlocker", + "plasma-workspace" + ], + "qtquick3d": [ + "kwin", + "qtmultimedia" + ], + "qtsensors": [ + "kwin" + ], + "qtserialport": [ + "qtpositioning" + ], + "qtvirtualkeyboard": [ + "kwin", + "plasma-workspace" + ], + "qtwebsockets": [ + "qcoro" + ], + "rhash": [ + "cmake" + ], + "rust-bindgen": [ + "mesa" + ], + "rust-cbindgen": [ + "mesa" + ], + "setxkbmap": [ + "libxkbcommon" + ], + "shared-mime-info": [ + "kcoreaddons" + ], + "shiboken6": [ + "kcoreaddons", + "kguiaddons", + "knotifications", + "kstatusnotifieritem", + "kwidgetsaddons", + "kxmlgui" + ], + "speech-dispatcher": [ + "qtspeech" + ], + "spirv-tools": [ + "kdeclarative", + "mesa", + "plasma-workspace" + ], + "sqlite-connector-odbc": [ + "qtbase" + ], + "syndication": [ + "knewstuff" + ], + "systemd": [ + "libcanberra", + "qtbase" + ], + "systemd-minimal": [ + "dbus", + "mako" + ], + "systemd-minimal-libs": [ + "libinput", + "linux-pam", + "mesa", + "qtbase", + "util-linux" + ], + "taglib": [ + "kfilemetadata" + ], + "unixodbc": [ + "qtbase" + ], + "v4l-utils": [ + "libdisplay-info" + ], + "valgrind": [ + "libdrm", + "mesa" + ], + "w3m": [ + "linux-pam" + ], + "wayland-scanner": [ + "kscreenlocker", + "libxkbcommon", + "mako", + "mesa", + "qtbase", + "wayland", + "wayland-protocols" + ], + "xkbcomp": [ + "libxkbcommon" + ], + "xmlto": [ + "wayland" + ], + "xvfb": [ + "libxkbcommon" + ], + "xwayland": [ + "kwin" + ] + }, + "recetas_sembradas": 162, + "sin_attr_en_nixpkgs": [ + "extra-cmake-modules", + "gettext-tiny", + "icu4c", + "kdecoration", + "layer-shell-qt", + "libkscreen", + "libksysguard", + "linux-headers", + "lm-sensors", + "make", + "markupsafe", + "packaging", + "perl-xml-parser", + "xcb-util", + "xcb-util-image", + "xcb-util-keysyms", + "xcb-util-renderutil", + "xcb-util-wm" + ] + }, + "escritorio-mirada": { + "candidatos": { + "SPIRV-LLVM-Translator": [ + "mesa" + ], + "audit": [ + "linux-pam" + ], + "autoconf-archive": [ + "python3" + ], + "bluez-headers": [ + "python3" + ], + "clang": [ + "mesa" + ], + "db": [ + "linux-pam" + ], + "dejagnu": [ + "libffi" + ], + "directx-headers": [ + "mesa" + ], + "docbook5": [ + "linux-pam" + ], + "docutils": [ + "libdrm" + ], + "doxygen": [ + "libxkbcommon", + "wayland" + ], + "gdbm": [ + "python3" + ], + "glslang": [ + "mesa" + ], + "graphviz": [ + "wayland" + ], + "help2man": [ + "flex" + ], + "libclc": [ + "mesa" + ], + "libglvnd": [ + "mesa" + ], + "libpciaccess": [ + "libdrm" + ], + "libunwind": [ + "mesa" + ], + "libva-minimal": [ + "mesa" + ], + "libwacom": [ + "libinput" + ], + "libxcb-keysyms": [ + "mesa" + ], + "libxcrypt": [ + "linux-pam" + ], + "libxrandr": [ + "mesa" + ], + "libxshmfence": [ + "mesa" + ], + "libxxf86vm": [ + "mesa" + ], + "llvm": [ + "mesa" + ], + "lua": [ + "libinput" + ], + "mesa-libgbm": [ + "mesa" + ], + "mpdecimal": [ + "python3" + ], + "ply": [ + "mesa" + ], + "pycparser": [ + "mesa" + ], + "python3-3.14.6-env": [ + "libinput" + ], + "pyyaml": [ + "mesa" + ], + "rust-bindgen": [ + "mesa" + ], + "rust-cbindgen": [ + "mesa" + ], + "setxkbmap": [ + "libxkbcommon" + ], + "spirv-tools": [ + "mesa" + ], + "systemd-minimal": [ + "mako" + ], + "systemd-minimal-libs": [ + "libinput", + "linux-pam", + "mesa", + "seatd" + ], + "valgrind": [ + "libdrm", + "mesa" + ], + "w3m": [ + "linux-pam" + ], + "wayland-scanner": [ + "libxkbcommon", + "mako", + "mesa", + "wayland", + "wayland-protocols" + ], + "xkbcomp": [ + "libxkbcommon" + ], + "xmlto": [ + "wayland" + ], + "xvfb": [ + "libxkbcommon" + ] + }, + "recetas_sembradas": 40, + "sin_attr_en_nixpkgs": [ + "make", + "markupsafe", + "mesa-swrast", + "mirada-compositor", + "mirada-ctl", + "mirada-greeter", + "packaging" + ] + } + }, + "schema": "hammer-seed-frontera/1" +} diff --git a/scripts/seed-graph.py b/scripts/seed-graph.py new file mode 100755 index 00000000..c1315601 --- /dev/null +++ b/scripts/seed-graph.py @@ -0,0 +1,414 @@ +#!/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") +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.""" + # Los perfiles de una cola aparte (escritorio-kde) sólo existen en el grafo `--kde`. + en_perfil, grafo_usado = [], None + for f in ("docs/state/build-state.json", "docs/state/build-state-kde.json"): + p = ROOT / f + if not p.exists(): + continue + d = json.loads(p.read_text()) + hit = sorted(n for n, r in d["nodes"].items() if perfil in (r.get("perfiles") or [])) + if len(hit) > len(en_perfil): + en_perfil, grafo_usado = hit, f + 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())