tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ 4190c7b43e5bd323a9b166340cfa16cd324ca2e4 scripts/seed-graph.py Python · 478 lines · 25.8 KB History Who touched what raw

takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4 476168bb0 · Sergio ·

1#!/usr/bin/env python3
2# seed-graph.py — SIEMBRA las aristas de los nodos `wanted` del grafo desde la metadata de nixpkgs,
3# SIN construir nada. (P3 del plan docs/plan-catalogo-objetivo.md.)
4#
5# POR QUÉ. `build-state.py` sabe qué falta (nodos `wanted`) pero no de qué cuelga: las deps de un
6# paquete sin receta no se conocen hasta escribirla. Eso deja la clausura de cada perfil como cota
7# inferior y el orden de ataque a ciegas. nixpkgs ya tiene ese grafo; se extrae con `nix eval` (sólo
8# METADATA — leerla no viola el ADR 0004, que prohíbe *construir* con nix).
9#
10# LA SEMILLA ES UNA HIPÓTESIS, NO LA VERDAD. Son las deps de OTRA distro con OTRAS decisiones de
11# build (qué opciones, qué backends). Sirve para hacer la frontera contable y ordenable, no para
12# creerle. Por eso:
13#   · sale a `docs/state/seed-edges.json`, un fichero APARTE, con procedencia por arista;
14#   · NUNCA se escribe en `recipes/` ni en `targets.toml`;
15#   · sólo se consulta para nodos SIN receta — en cuanto hay receta, manda la receta (ya corregida
16#     por el veredicto de harkaq). La semilla se auto-borra a medida que el frente se drena.
17#
18# Cada arista sembrada se CLASIFICA, que es donde está el valor:
19#   conocida  — tras normalizar, hay receta con ese nombre. Arista utilizable ya.
20#   frontera  — no hay receta y no es un nix-ismo ⇒ candidato a nodo `wanted` nuevo.
21#   nix-ismo  — andamiaje de nixpkgs que en takana no existe (hooks, wrappers, doc tooling).
22#   lab       — toolchain que provee el rootfs del sandbox, no una receta (cargo/rustc).
23#
24# CALIBRADO, no supuesto (medición completa en el plan). Muestra de 60 recetas / 686 deps:
25# aristas directas → 59% de precisión; el cierre transitivo sube el recall a 84% pero se lleva la
26# precisión a 6% (4629 nodos fantasma del bootstrap de nixpkgs) ⇒ **se siembran DIRECTAS y la
27# transitividad la hace el grafo**, que es lo que `build-state.py` ya sabe hacer con las aristas.
28#
29# Uso:  scripts/seed-graph.py --calibrar [N] [--transitivo]  # mide el ruido contra recetas escritas
30#       scripts/seed-graph.py --frontera <perfil>  # qué pide nixpkgs y takana no tiene → seed-frontera.json
31#       scripts/seed-graph.py --perfil <perfil>    # siembra los nodos `wanted` → seed-edges.json
32#       scripts/seed-graph.py <nombre...>          # siembra nombres sueltos
33# Env:  NIXPKGS (def nixpkgs), NIX_ROOT (def ~/.nixstore; vacío = store del sistema)
34import shutil, json, os, re, subprocess, sys, glob, tomllib
35from pathlib import Path
36
37ROOT = Path(__file__).resolve().parent.parent
38os.chdir(ROOT)
39sys.path.insert(0, str(ROOT / "scripts"))
40import targets as targets_mod
41
42TRANSITIVO = "--transitivo" in sys.argv  # comparar CLAUSURA sembrada vs deps aplanadas de hammer
43NIXPKGS = os.environ.get("NIXPKGS", "nixpkgs")
44# El store de nix va al `work/` del repo, NO a `~/.nixstore` (2026-09-08). En gioser `$HOME`
45# vive en `/` (74 G, 21 libres) y el volumen tiene 126: llenar `/` no rompe una tanda, rompe
46# la MÁQUINA. Y `work/` ya es el sitio de lo pesado-y-regenerable, así que la poda lo alcanza.
47# Medido: el store de un eval son ~8 MB; lo gordo es la caché git de nixpkgs (~69 MB), que
48# nix pone en `$XDG_CACHE_HOME` y por eso NO la gobierna esta variable — en gioser está
49# mudada al volumen con un enlace (`~/.cache/nix -> /mnt/vvv/nix-cache`).
50NIX_ROOT = os.environ.get("NIX_ROOT", str(ROOT / "work/nixstore"))
51OUT = ROOT / "docs/state/seed-edges.json"
52FRONTERA = ROOT / "docs/state/seed-frontera.json"
53LOTE = 120  # nombres por proceso de `nix eval` (uno solo evalúa 6 en 0.7s; el costo es el arranque)
54
55# --- normalización nix → takana -------------------------------------------------------------------
56# Mapeo por evidencia, no por intuición: cada entrada salió de una discrepancia vista en --calibrar.
57MAPA = {
58    "ninja": "samurai",             # hammer usa samurai como backend de meson
59    "pkg-config": "pkgconf",
60    "pkg-config-wrapper": "pkgconf",
61    "libpng-apng": "libpng",        # nixpkgs sirve libpng con el parche APNG
62    "gettext": "gettext-tiny",      # hammer usa la implementación tiny
63    "gnum4": "m4",
64    "XML-Parser": "perl-xml-parser",
65    "bash-interactive": "bash",
66    "util-linux-minimal": "util-linux",
67    "cacert": "ca-certificates",
68}
69# Andamiaje de nixpkgs sin contraparte en takana: hooks del stdenv, wrappers, tooling de docs.
70NIXISMOS_EXACTOS = {
71    "", "which", "stdenv", "gtk-doc", "docbook-xml", "docbook-xsl", "docbook-xsl-nons",
72    "docbook-xsl-ns", "docbook2X", "asciidoc", "asciidoctor", "install-shell-files",
73    "fix-darwin-dylib-names", "update-autotools-gnu-config-scripts-hook",
74    "gobject-introspection-wrapped", "nuke-refs", "auditable-cargo", "glibc-iconv",
75    "glibc-locales", "cmake-minimal",
76}
77NIXISMOS_SUFIJOS = ("-hook", "Hook", "-wrapper", "-setup-hook", ".sh")
78# Lazo cerrado: los candidatos que un humano marcó `nix-ismo` en el triaje (P5) vuelven acá como
79# descarte, así cada corrida del sembrador sale más limpia que la anterior. Si el fichero no está,
80# no pasa nada — el sembrador funciona igual, sólo con más ruido.
81_TRIAJE = ROOT / "docs/state/nixismos-triaje.txt"
82if _TRIAJE.exists():
83    NIXISMOS_EXACTOS |= {l.strip() for l in _TRIAJE.read_text().splitlines()
84                         if l.strip() and not l.startswith("#")}
85# Y los `provisto`: nombres que nixpkgs separa en un paquete propio y acá los da otra receta
86# (`mesa-libgbm`→mesa). Entran al MAPA, así se resuelven como arista CONOCIDA en vez de hueco.
87_ALIAS = ROOT / "docs/state/alias-triaje.txt"
88if _ALIAS.exists():
89    for _l in _ALIAS.read_text().splitlines():
90        if _l.strip() and not _l.startswith("#"):
91            _p = _l.split()
92            if len(_p) == 2:
93                MAPA.setdefault(_p[0], _p[1])
94NIXISMOS_INFIJOS = ("-hook",)   # `cargo-build-hook.sh` no termina en `-hook`, lo contiene
95# Toolchains que provee el LAB (el rootfs del sandbox), no una receta: ninguna receta Rust declara
96# `rust` ni `cargo` en `[deps].build` — están vacías y el toolchain viene de abajo. Que nixpkgs los
97# liste no es un hueco de takana; es que las dos distros ponen la frontera en lugares distintos.
98PROVISTO_POR_EL_LAB = {"cargo", "rustc", "rust"}
99# Deps que takana declara SIEMPRE y nix nunca lista porque las provee el stdenv implícito. No son
100# un fallo del sembrador: son convención del sandbox de takana (cada dep es una capa --overlay-src).
101CONVENCION_HAMMER = {"make", "binutils", "linux-headers"}
102# Sufijos de VARIANTE de takana: `zlib` y `zlib-shared` son el mismo paquete con otra decisión de
103# enlazado. nixpkgs no puede saber cuál eligió takana ⇒ no es un fallo del sembrador, es una
104# decisión nuestra. Se cuenta aparte para no inflar ni el acierto ni el error.
105VARIANTES = ("-shared", "-static")
106
107
108def normalizar(n, por_minusculas=None):
109    """nix-name → (hammer-name, es_nixismo). El mapeo va ANTES del descarte (`pkg-config-wrapper`
110    termina en `-wrapper` pero es pkgconf, dep real). Con `por_minusculas` ({nombre.lower(): nombre
111    real}) resuelve además las diferencias de caja: nixpkgs dice `libx11`, hammer `libX11`."""
112    if n in MAPA:
113        return MAPA[n], False
114    # nixpkgs a veces deja la versión pegada al pname (`auditable-cargo-1.96.1`, `glibc-iconv-2.42`):
115    # se despega para poder reconocer la base. Sólo si el sufijo es puramente numérico.
116    base = re.sub(r"-\d[\d.]*$", "", n)
117    if base != n and (base in NIXISMOS_EXACTOS or base in MAPA):
118        return MAPA.get(base, base), base not in MAPA
119    if (n in NIXISMOS_EXACTOS or any(n.endswith(s) for s in NIXISMOS_SUFIJOS)
120            or any(s in n for s in NIXISMOS_INFIJOS)):
121        return n, True
122    # `-wrapped` / `-unwrapped` son variantes de empaquetado de nix, no paquetes distintos.
123    for suf in ("-unwrapped", "-wrapped"):
124        if n.endswith(suf):
125            n = n[: -len(suf)]
126            break
127    if por_minusculas:
128        n = por_minusculas.get(n.lower(), n)
129    return n, False
130
131
132def raiz_variante(n):
133    """`zlib-shared` → `zlib`. Para comparar sin castigar una decisión de enlazado nuestra."""
134    for suf in VARIANTES:
135        if n.endswith(suf):
136            return n[: -len(suf)]
137    return n
138
139
140# --- lectura del corpus ---------------------------------------------------------------------------
141def recetas():
142    """{nombre: [deps de build]} sobre `recipes/` + las colas incoming-*."""
143    out = {}
144    for pat in ["recipes/*.toml", "recipes/incoming-*/*.toml"]:
145        for f in sorted(glob.glob(pat)):
146            n = os.path.basename(f)[:-5]
147            try:
148                d = tomllib.load(open(f, "rb"))
149            except Exception:
150                continue
151            out.setdefault(n, d.get("deps", {}).get("build", []))
152    return out
153
154
155# --- extracción desde nixpkgs ---------------------------------------------------------------------
156# Los paquetes NO viven todos en el top-level de nixpkgs: el escritorio KDE —justo el objetivo de
157# este frente— cuelga de `kdePackages.` (Plasma 6) o `libsForQt5.`. Sin este fallback, 22 de 40
158# nombres de la primera calibración salían "sin attr en nixpkgs" y la frontera KDE era insembrable.
159# `ns` viaja en la respuesta: de dónde salió cada dep es parte de su procedencia.
160EXPR = """
161pkgs: let
162  names = [ %s ];
163  espacios = [ { ns = ""; set = pkgs; } ]
164    ++ (if pkgs ? kdePackages then [ { ns = "kdePackages"; set = pkgs.kdePackages; } ] else [])
165    ++ (if pkgs ? libsForQt5 then [ { ns = "libsForQt5"; set = pkgs.libsForQt5; } ] else []);
166  buscar = n: let hits = builtins.filter (e: e.set ? ${n}) espacios;
167              in if hits == [] then null else builtins.head hits;
168  uno = n: let e = buscar n; in if e == null then null else
169    let r = builtins.tryEval (let p = e.set.${n}; v = {
170      ns  = e.ns;
171      bi  = map (x: x.pname or x.name or "") (p.buildInputs or []);
172      nbi = map (x: x.pname or x.name or "") (p.nativeBuildInputs or []);
173      pbi = map (x: x.pname or x.name or "") (p.propagatedBuildInputs or []);
174    }; in builtins.deepSeq v v);
175    in if r.success then r.value else null;
176in builtins.listToAttrs (map (n: { name = n; value = uno n; }) names)
177"""
178
179
180def nix_deps(nombres):
181    """{nombre: {bi,nbi,pbi}} o None si nixpkgs no lo tiene / su eval falla. En LOTES: el costo es
182    el arranque del evaluador, no el paquete (6 en 0.7s con el eval cache caliente)."""
183    res = {}
184    for i in range(0, len(nombres), LOTE):
185        chunk = nombres[i : i + LOTE]
186        expr = EXPR % " ".join(json.dumps(n) for n in chunk)
187        cmd = ["nix", "eval", "--extra-experimental-features", "nix-command flakes"]
188        if NIX_ROOT:
189            cmd += ["--store", f"local?root={NIX_ROOT}"]
190        cmd += ["--json", f"{NIXPKGS}#legacyPackages.x86_64-linux", "--apply", expr]
191        print(f"   nix eval lote {i//LOTE+1} ({len(chunk)} nombres)…", file=sys.stderr)
192        p = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
193        if p.returncode != 0:
194            print(f"   ⚠ el lote falló: {p.stderr.strip()[:300]}", file=sys.stderr)
195            res.update({n: None for n in chunk})
196            continue
197        res.update(json.loads(p.stdout))
198    return res
199
200
201def sembrar(nombres, recs):
202    """nombre → [{dep, clase, origen_nix}]. Une bi+nbi+pbi: las deps REALES de varios paquetes viven
203    en `propagatedBuildInputs` (freetype: zlib/bzip2/brotli/libpng están ahí, no en buildInputs)."""
204    por_minusculas = {r.lower(): r for r in recs}
205    crudo = nix_deps(nombres)
206    out = {}
207    for n in nombres:
208        d = crudo.get(n)
209        if d is None:
210            out[n] = None  # nixpkgs no lo tiene: la frontera queda sin sembrar (candidato a Alpine)
211            continue
212        vistos, aristas = set(), []
213        for campo in ("nbi", "bi", "pbi"):
214            for crudo_n in d.get(campo) or []:
215                dep, nixismo = normalizar(crudo_n, por_minusculas)
216                if dep in vistos:
217                    continue
218                vistos.add(dep)
219                clase = ("nix-ismo" if nixismo else "lab" if dep in PROVISTO_POR_EL_LAB
220                         else "conocida" if dep in recs else "frontera")
221                aristas.append(dict(dep=dep, clase=clase, origen_nix=crudo_n, campo=campo,
222                                    ns=d.get("ns") or "top-level"))
223        out[n] = aristas
224    return out
225
226
227# --- modos ----------------------------------------------------------------------------------------
228def sembrar_cierre(raices, recs, prof_max=4, tope=4000):
229    """Siembra en ONDAS hasta punto fijo: las deps de las deps. Necesario porque las dos distros
230    declaran cosas distintas — hammer APLANA la clausura en `[deps].build` (cada dep es una capa
231    --overlay-src del sandbox: si no está declarada, no está en el árbol), mientras nix declara sólo
232    las DIRECTAS y deja que la propagación haga el resto. Comparar unas con otras mide un artefacto
233    de la comparación, no la calidad de la semilla.
234
235    Devuelve {nombre: [aristas]} de TODO lo visitado; la clausura se calcula después sobre ese grafo
236    (que es también lo que hace `build-state.py` con las aristas reales)."""
237    grafo, pendientes, prof = {}, list(dict.fromkeys(raices)), 0
238    while pendientes and prof < prof_max and len(grafo) < tope:
239        prof += 1
240        print(f"   onda {prof}: {len(pendientes)} nombres nuevos", file=sys.stderr)
241        nuevo = sembrar(pendientes, recs)
242        grafo.update(nuevo)
243        siguientes = {a["dep"] for ar in nuevo.values() if ar for a in ar
244                      if a["clase"] not in ("nix-ismo", "lab") and a["dep"] not in grafo}
245        pendientes = sorted(siguientes)[: max(0, tope - len(grafo))]
246    return grafo
247
248
249def clausura_sembrada(raiz, grafo):
250    """Alcanzables desde `raiz` por las aristas sembradas (sin la raíz)."""
251    vistos, pila = set(), [a["dep"] for a in (grafo.get(raiz) or [])
252                           if a["clase"] not in ("nix-ismo", "lab")]
253    while pila:
254        n = pila.pop()
255        if n in vistos:
256            continue
257        vistos.add(n)
258        for a in grafo.get(n) or []:
259            if a["clase"] not in ("nix-ismo", "lab") and a["dep"] not in vistos:
260                pila.append(a["dep"])
261    return vistos
262
263
264def calibrar(recs, n_muestra):
265    """Mide la semilla contra la VERDAD que ya tenemos: las recetas escritas a mano. Es la única
266    forma honesta de saber si sembrar sirve, antes de sembrar nada."""
267    # Candidatas: recetas C/GUI con deps declaradas (las Rust/Go declaran sólo el toolchain).
268    cand = [n for n, deps in sorted(recs.items())
269            if deps and "go" not in deps and "rust" not in deps and "cargo" not in deps]
270    paso = max(1, len(cand) // n_muestra)
271    muestra = cand[::paso][:n_muestra]
272    print(f"== calibrando sobre {len(muestra)} recetas con deps declaradas (de {len(cand)})")
273
274    if TRANSITIVO:
275        grafo = sembrar_cierre(muestra, recs)
276        sem = {n: ([dict(dep=d, clase=("conocida" if d in recs else "frontera"), origen_nix=d,
277                         campo="cierre", ns="") for d in sorted(clausura_sembrada(n, grafo))]
278                   if grafo.get(n) is not None else None) for n in muestra}
279    else:
280        sem = sembrar(muestra, recs)
281    tot_v = tot_ok = tot_falta_real = tot_falta_conv = tot_variante = 0
282    sobra_con_receta, sobra_frontera, nixismos = {}, {}, {}
283    sin_nix, detalle = [], []
284    for n in muestra:
285        aristas = sem.get(n)
286        if aristas is None:
287            sin_nix.append(n)
288            continue
289        for a in aristas:
290            if a["clase"] == "nix-ismo":
291                nixismos[a["origen_nix"]] = nixismos.get(a["origen_nix"], 0) + 1
292        sembrado = {a["dep"] for a in aristas if a["clase"] not in ("nix-ismo", "lab")}
293        verdad = set(recs[n])
294        ok = verdad & sembrado
295        falta = verdad - sembrado
296        # `zlib-shared` declarada y `zlib` sembrada: acertó el paquete, no la variante. Aparte.
297        raices_sembradas = {raiz_variante(s) for s in sembrado}
298        variante = {f for f in falta if raiz_variante(f) in raices_sembradas}
299        falta -= variante
300        falta_conv = falta & CONVENCION_HAMMER
301        falta_real = falta - CONVENCION_HAMMER
302        sobra = {s for s in sembrado - verdad if raiz_variante(s) not in
303                 {raiz_variante(v) for v in verdad}}
304        for s in sobra:
305            destino = sobra_con_receta if s in recs else sobra_frontera
306            destino[s] = destino.get(s, 0) + 1
307        tot_v += len(verdad); tot_ok += len(ok); tot_variante += len(variante)
308        tot_falta_real += len(falta_real); tot_falta_conv += len(falta_conv)
309        detalle.append((n, len(verdad), len(ok), sorted(falta_real), sorted(sobra)))
310
311    print(f"\n== VEREDICTO (verdad = las {tot_v} deps declaradas a mano en la muestra)")
312    if tot_v:
313        print(f"   aciertos          {tot_ok:4}  ({100*tot_ok//tot_v}% de la verdad)")
314        print(f"   faltan (reales)   {tot_falta_real:4}  ({100*tot_falta_real//tot_v}%)  ← la semilla NO las ve")
315        print(f"   faltan (convención hammer: {' '.join(sorted(CONVENCION_HAMMER))}) {tot_falta_conv:4}"
316              f"  ← nix las da por implícitas en el stdenv; NO es ruido, es constante")
317        print(f"   variante distinta {tot_variante:4}  ← acertó el paquete, erró -shared/-static"
318              f" (decisión NUESTRA, nix no puede saberla)")
319    n_sobra = sum(sobra_con_receta.values()) + sum(sobra_frontera.values())
320    print(f"   sobrantes         {n_sobra:4}  = {sum(sobra_con_receta.values())} con receta en hammer"
321          f" (deps que hammer NO necesitó) + {sum(sobra_frontera.values())} sin receta")
322    print(f"   nix-ismos filtrados {sum(nixismos.values()):3}  (andamiaje descartado antes de contar)")
323    if sin_nix:
324        print(f"   sin attr en nixpkgs {len(sin_nix):3}: {' '.join(sin_nix[:12])}")
325
326    print("\n== los 15 sobrantes SIN receta más frecuentes (candidatos a MAPA o a nix-ismo)")
327    for k, v in sorted(sobra_frontera.items(), key=lambda kv: -kv[1])[:15]:
328        print(f"   {v:3}×  {k}")
329    print("\n== peores casos (más deps reales no vistas)")
330    for n, nv, nok, falta, sobra in sorted(detalle, key=lambda d: -len(d[3]))[:8]:
331        print(f"   {n:22} verdad {nv:2}, acierta {nok:2}, no ve: {' '.join(falta) or '—'}")
332    return 0
333
334
335def frontera(recs, perfil):
336    """Qué dice nixpkgs que hace falta y hammer NO TIENE RECETA para construir, mirando las recetas
337    que YA existen en la clausura de un perfil. Es el modo útil mientras no haya nodos `wanted`:
338    descubre la frontera desde el corpus actual en vez de esperar a que alguien la declare.
339
340    Cada candidato es una HIPÓTESIS a clasificar por un humano: puede ser (a) una dep opcional que
341    hammer no habilitó a propósito, (b) un nix-ismo que falta en la lista de descarte, o (c) un
342    hueco real. El sembrador no decide cuál: los ordena por cuántas recetas los piden."""
343    # El grafo se elige por la COLA que el perfil declara, no por cuál tiene más nodos: la cola
344    # `incoming-kde` SOMBREA recetas canónicas, así que medir un perfil del corpus contra el grafo
345    # KDE mide una imagen que nadie construye.
346    try:
347        cola = targets_mod.load().get(perfil, {}).get("cola", "corpus")
348    except Exception:
349        cola = "corpus"
350    en_perfil, grafo_usado = [], None
351    # ⚠ EL FICHERO SE DERIVA DE LA COLA, no se escribe a mano (2026-09-09). Esta lista estaba fija y
352    # sólo tenía corpus/kde/gnome, así que `escritorio-sway` y `escritorio-cosmic` eran INVISIBLES
353    # para la frontera: nadie podía preguntar «¿qué le falta a sway?» y obtener respuesta. Y el
354    # error mentía sobre la causa — «¿regeneraste con build-state.py?» — con los cinco grafos
355    # frescos de hacía minutos.
356    #   Es la QUINTA vez que cae el mismo cable en este repo: GNOME (2026-07-27), COSMIC
357    # (2026-08-03) y wlr/sway (2026-08-26) en `cosecha-cron.sh`, keystones y duplicados
358    # (2026-09-08) en el mismo sitio, y ahora acá. Las cuatro anteriores se arreglaron AGREGANDO
359    # una línea, que es arreglar el caso y dejar la trampa. Derivarlo de la cola la quita: una cola
360    # nueva trae su grafo sola. `corpus` → build-state.json; `incoming-<x>` → build-state-<x>.json.
361    cand_files = ["docs/state/build-state.json"] if cola == "corpus" else [
362        f"docs/state/build-state-{cola.removeprefix('incoming-')}.json",
363        "docs/state/build-state.json"]
364    for f in cand_files:
365        p = ROOT / f
366        if not p.exists():
367            continue
368        d = json.loads(p.read_text())
369        if cola not in (d.get("by_queue") or {}):
370            continue
371        hit = sorted(n for n, r in d["nodes"].items() if perfil in (r.get("perfiles") or []))
372        if hit:
373            en_perfil, grafo_usado = hit, f
374            break
375    if not en_perfil:
376        print(f"perfil `{perfil}` sin nodos en ningún grafo de estado "
377              f"(¿regeneraste con scripts/build-state.py [--kde]?)", file=sys.stderr)
378        return 1
379    print(f"   (grafo: {grafo_usado})")
380    print(f"== sembrando las {len(en_perfil)} recetas de la clausura de `{perfil}`")
381    sem = sembrar(en_perfil, recs)
382    cand, sin_nix = {}, []
383    for n, aristas in sem.items():
384        if aristas is None:
385            sin_nix.append(n); continue
386        for a in aristas:
387            if a["clase"] == "frontera":
388                cand.setdefault(a["dep"], []).append(n)
389    print(f"   sin attr en nixpkgs: {len(sin_nix)}"
390          + (f" ({' '.join(sin_nix[:10])}…)" if sin_nix else ""))
391    orden = sorted(cand.items(), key=lambda kv: (-len(kv[1]), kv[0]))
392    print(f"\n== {len(cand)} candidatos a HUECO (nixpkgs los pide, hammer no tiene receta)")
393    for dep, quienes in orden[:40]:
394        print(f"   {len(quienes):3}×  {dep:28} ← {' '.join(sorted(quienes)[:5])}"
395              + (" …" if len(quienes) > 5 else ""))
396
397    # Artefacto durable y versionado: el `git diff` de este fichero muestra qué huecos APARECEN
398    # entre dos corridas (una receta nueva puede destapar deps que nadie había pedido antes).
399    prev = json.loads(FRONTERA.read_text()) if FRONTERA.exists() else {"perfiles": {}}
400    prev.setdefault("perfiles", {})[perfil] = dict(
401        recetas_sembradas=len(en_perfil), sin_attr_en_nixpkgs=sin_nix,
402        candidatos={dep: sorted(q) for dep, q in orden})
403    prev["schema"] = "hammer-seed-frontera/1"
404    prev["advertencia"] = ("HIPÓTESIS desde metadata de nixpkgs. Cada candidato es (a) dep opcional "
405                           "que hammer no habilitó, (b) nix-ismo por filtrar, o (c) hueco real. "
406                           "Clasificar a mano; NO promover a receta automáticamente.")
407    FRONTERA.write_text(json.dumps(prev, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
408    print(f"\n   → {FRONTERA.relative_to(ROOT)} (perfil `{perfil}`)")
409    return 0
410
411
412def sembrar_a_fichero(nombres, recs):
413    sem = sembrar(nombres, recs)
414    doc = dict(
415        schema="hammer-seed-edges/1",
416        fuente="nixpkgs (metadata; NO se construye con nix — ADR 0004)",
417        advertencia="HIPÓTESIS, no verdad. Sólo válido para nodos SIN receta; con receta, manda la receta.",
418        nodos={n: (a if a is not None else "sin-attr-en-nixpkgs") for n, a in sorted(sem.items())},
419    )
420    OUT.write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
421    n_ok = sum(1 for a in sem.values() if a)
422    conocidas = sum(1 for a in sem.values() if a for x in a if x["clase"] == "conocida")
423    frontera = sorted({x["dep"] for a in sem.values() if a for x in a if x["clase"] == "frontera"})
424    print(f"== sembrados {n_ok}/{len(nombres)} nodos → {OUT.relative_to(ROOT)}")
425    print(f"   aristas `conocida` (hay receta): {conocidas}")
426    print(f"   `frontera` (candidatos a nodo wanted nuevo): {len(frontera)}")
427    if frontera:
428        print("   " + " ".join(frontera[:30]) + (" …" if len(frontera) > 30 else ""))
429    return 0
430
431
432def main():
433    args = sys.argv[1:]
434    if not args:
435        print(__doc__ or "uso: seed-graph.py --calibrar [N] | --frontera <p> | --perfil <p> | <nombre...>",
436              file=sys.stderr)
437        return 1
438    # ⚠ PRECONDICIÓN EXPLÍCITA, y no es cosmética (2026-09-08). TODOS los verbos de este script
439    # preguntan a `nix eval` qué declara nixpkgs — incluido `--calibrar`, que yo había eximido por
440    # suposición hasta que el control lo desmintió en el acto. Sin `nix` en el PATH, la llamada moría
441    # con veinte líneas de traceback terminadas en `FileNotFoundError: 'nix'`, que es la cara de un
442    # BUG DE CÓDIGO: manda a depurar el script cuando lo único que falta es una herramienta que en
443    # esta máquina nunca se instaló. El coste no es el susto, es el desvío. Un fallo de ENTORNO tiene
444    # que decir qué falta y qué hacer; va DESPUÉS del uso, para que `seed-graph.py` a secas siga
445    # explicándose.
446    if not shutil.which("nix"):
447        print("seed-graph: falta `nix` en el PATH.\n"
448              "  Este script pregunta a nixpkgs (`nix eval`) qué declara upstream como dependencias;\n"
449              "  sin él no hay frontera que calcular. No es un fallo de las recetas ni del grafo.\n"
450              "  El hub `gioser` no lo trae: los `docs/state/seed-*.json` del repo se generaron en\n"
451              "  otra máquina (2026-08-10). Instalar nix, o correr este verbo donde ya esté.",
452              file=sys.stderr)
453        return 2
454    recs = recetas()
455    if not args:
456        print(__doc__ or "uso: seed-graph.py --calibrar [N] | --perfil <p> | <nombre...>", file=sys.stderr)
457        return 1
458    if args[0] == "--calibrar":
459        n = next((int(a) for a in args[1:] if a.isdigit()), 40)
460        return calibrar(recs, n)
461    if args[0] == "--frontera":
462        if len(args) < 2:
463            print("uso: seed-graph.py --frontera <perfil>", file=sys.stderr); return 1
464        return frontera(recs, args[1])
465    if args[0] == "--perfil":
466        perfiles = targets_mod.load()
467        p = perfiles.get(args[1])
468        if not p:
469            print(f"perfil desconocido: {args[1]}", file=sys.stderr); return 1
470        pend = [r for r in p["raices"] if r not in recs]
471        if not pend:
472            print(f"el perfil `{args[1]}` no tiene nodos sin receta: nada que sembrar."); return 0
473        return sembrar_a_fichero(pend, recs)
474    return sembrar_a_fichero(args, recs)
475
476
477if __name__ == "__main__":
478    sys.exit(main())