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 Sutichaykuna

catálogo objetivo P3: sembrador de aristas desde nixpkgs, calibrado contra la verdad

seed-graph.py extrae deps de la metadata de nixpkgs sin construir nada (leerla
no viola el ADR 0004, que prohíbe *construir* con nix). `--calibrar` mide la
semilla contra la única verdad disponible: las recetas escritas a mano.

VEREDICTO (60 recetas, 686 deps):
   directas     recall 39%*, precisión 59% (269 de 455)
   transitivas  recall 84%,  precisión  6% (390 de 6760)
   * contra la verdad APLANADA — comparación injusta, y el modo transitivo
     prueba que la información sí está; sólo falta aplanarla.

Decisión: sembrar aristas DIRECTAS, la transitividad la hace el grafo. hammer
aplana la clausura en [deps].build (cada dep = capa --overlay-src), nixpkgs
declara directas y propaga; reproducir el aplanado arrastra 4629 nodos fantasma
del bootstrap de nixpkgs.

Lo que enseñó la calibración (3 rondas, cada fix salido de un dato):
· 4 clases de discrepancia, no 1: convención hammer (make/binutils/linux-headers,
  implícitas en el stdenv de nix), variante nuestra (zlib vs zlib-shared),
  provisto por el lab (cargo/rustc — las recetas Rust declaran deps=[]), y recién
  después ruido real. Contarlas juntas hacía parecer irreducible lo clasificable.
· desalineación aburrida y arreglable: caja (libx11/libX11), implementación
  (gettext→gettext-tiny, ninja→samurai), versión pegada al pname, hooks del
  stdenv. Precisión 45%→59%; "sin attr en nixpkgs" 22/40 → 6/60.
· el escritorio KDE cuelga de kdePackages., no del top-level: sin ese fallback
  el perfil objetivo era insembrable.

Hallazgo de secuencia: hay 0 nodos `wanted` (targets.toml se pobló por
lift-and-shift de lo existente), así que el sembrador no tenía a quién sembrar.
De ahí `--frontera <perfil>`: siembra las recetas que YA existen y reporta lo que
nixpkgs pide y hammer no puede construir. 137 candidatos en escritorio-kde
(kdoctools ×6, milou, polkit-qt-1, libkscreen, y mesa-libgbm/libglvnd/spirv-tools
= el muro de GBM/EGL ya documentado), 51 en cli, 46 en escritorio-mirada.

Cada candidato es una HIPÓTESIS a clasificar a mano: dep opcional no habilitada,
nix-ismo por filtrar, o hueco real. Nada se promueve a receta automáticamente.

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

0093c1f19cbb2a56bc17bc093c1e3f027eb4437a · ruwaq sergio · (3 killa ñawpaq) · tayta 2f674b982 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 11

scripts/seed-graph.py

3 qillqakuna · +1304 −0

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 <perfil>` 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 <perfil>  # qué pide nixpkgs y hammer no tiene → seed-frontera.json
+#       scripts/seed-graph.py --perfil <perfil>    # siembra los nodos `wanted` → seed-edges.json
+#       scripts/seed-graph.py <nombre...>          # 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 <p> | <nombre...>", 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 <perfil>", 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())