Files
hammer/scripts/seed-graph.py
T
sergioandClaude Opus 4.8 9b67268a65 triaje: los 170 candidatos de frontera clasificados, y la cadena se ejerce entera
Evidencia dura primero: NINGUNO de los 170 bloquea un build. Toda receta que
pide uno de estos ya está sellada al menos una vez ⇒ hammer construye sin
ninguno. Eso descarta empíricamente "hueco de build" para los 170 y deja sólo
la pregunta de runtime.

VEREDICTOS
  provisto  11 — no faltan: ya los da otra receta con otro nombre. nixpkgs parte
                 en varios paquetes lo que acá es uno solo. Verificado contra el
                 store: wayland-scanner→wayland, mesa-libgbm→mesa (nuestros tres
                 mesa producen libgbm.so.1), libxcb-*→xcb-util-*, poppler-qt6→
                 poppler, gmp-with-cxx→gmp, uname→coreutils.
  nix-ismo   5 — andamiaje de nixpkgs (env wrappers, helpers del stdenv, glibc
                 asomando por su libc).
  opcional 150 — software real que nixpkgs habilita y hammer no necesita, con el
                 porqué agrupado: systemd (usamos arje-zero), X11 heredado (el
                 escritorio es Wayland), Vulkan/shaders, audio/multimedia,
                 conectores de BD, tooling de docs/tests, bindings Python de Qt,
                 paquetería ajena (tenemos .swm).
  hueco      4 — gaps de RUNTIME, no de build: shared-mime-info (sin base MIME
                 no hay tipos de fichero), xwayland (ninguna app X11 corre),
                 polkit-qt-1 (sin diálogos de autorización), qqc2-breeze-style
                 (los controles QML caen a un estilo genérico).

CORRIGE UN ERROR MÍO DE P3: dije que mesa-libgbm/libglvnd eran "el muro de
GBM/EGL". Falso — libgbm ya lo produce nuestro mesa. El muro era softpipe vs
llvmpipe, no un paquete ausente.

CUARTO VEREDICTO NUEVO (`provisto`) con su propio lazo: sale a alias-triaje.txt
y seed-graph.py lo carga en MAPA ⇒ esos 11 nombres dejan de contarse como hueco
para siempre. Junto con nixismos-triaje.txt, el sembrador aprende de su triaje.

Y LA CADENA SE EJERCE ENTERA POR PRIMERA VEZ: los 4 huecos entraron como raíces
de escritorio-kde → nacieron 4 nodos `wanted` (raíces 11, sin receta 4; el grafo
sigue cerrando, --check exit 0) → seed-graph los sembró (42 aristas conocidas,
21 candidatos nuevos de frontera) → drenar.py los ordena marcándolos [semilla],
que es la regla de la fuente única a la vista. qqc2-breeze-style no cae en la
onda 1 porque sus deps SEMBRADAS la traban (kcodecs, kirigami…): el andamio
funcionando como se diseñó.

De paso: qtbase se selló mientras corría esto ⇒ la onda 1 de KDE se abrió de 1 a
13 recetas. El cuello de botella que reportó P4 ya está destrabado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:30:19 -04:00

440 lines
23 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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")
# Lazo cerrado: los candidatos que un humano marcó `nix-ismo` en el triaje (P5) vuelven acá como
# descarte, así cada corrida del sembrador sale más limpia que la anterior. Si el fichero no está,
# no pasa nada — el sembrador funciona igual, sólo con más ruido.
_TRIAJE = ROOT / "docs/state/nixismos-triaje.txt"
if _TRIAJE.exists():
NIXISMOS_EXACTOS |= {l.strip() for l in _TRIAJE.read_text().splitlines()
if l.strip() and not l.startswith("#")}
# Y los `provisto`: nombres que nixpkgs separa en un paquete propio y acá los da otra receta
# (`mesa-libgbm`→mesa). Entran al MAPA, así se resuelven como arista CONOCIDA en vez de hueco.
_ALIAS = ROOT / "docs/state/alias-triaje.txt"
if _ALIAS.exists():
for _l in _ALIAS.read_text().splitlines():
if _l.strip() and not _l.startswith("#"):
_p = _l.split()
if len(_p) == 2:
MAPA.setdefault(_p[0], _p[1])
NIXISMOS_INFIJOS = ("-hook",) # `cargo-build-hook.sh` no termina en `-hook`, lo contiene
# Toolchains que provee el LAB (el rootfs del sandbox), no una receta: ninguna receta Rust declara
# `rust` ni `cargo` en `[deps].build` — están vacías y el toolchain viene de abajo. Que nixpkgs los
# liste no es un hueco de hammer; es que las dos distros ponen la frontera en lugares distintos.
PROVISTO_POR_EL_LAB = {"cargo", "rustc", "rust"}
# Deps que hammer declara SIEMPRE y nix nunca lista porque las provee el stdenv implícito. No son
# un fallo del sembrador: son convención del sandbox de hammer (cada dep es una capa --overlay-src).
CONVENCION_HAMMER = {"make", "binutils", "linux-headers"}
# Sufijos de VARIANTE de hammer: `zlib` y `zlib-shared` son el mismo paquete con otra decisión de
# enlazado. nixpkgs no puede saber cuál eligió hammer ⇒ no es un fallo del sembrador, es una
# decisión nuestra. Se cuenta aparte para no inflar ni el acierto ni el error.
VARIANTES = ("-shared", "-static")
def normalizar(n, por_minusculas=None):
"""nix-name → (hammer-name, es_nixismo). El mapeo va ANTES del descarte (`pkg-config-wrapper`
termina en `-wrapper` pero es pkgconf, dep real). Con `por_minusculas` ({nombre.lower(): nombre
real}) resuelve además las diferencias de caja: nixpkgs dice `libx11`, hammer `libX11`."""
if n in MAPA:
return MAPA[n], False
# nixpkgs a veces deja la versión pegada al pname (`auditable-cargo-1.96.1`, `glibc-iconv-2.42`):
# se despega para poder reconocer la base. Sólo si el sufijo es puramente numérico.
base = re.sub(r"-\d[\d.]*$", "", n)
if base != n and (base in NIXISMOS_EXACTOS or base in MAPA):
return MAPA.get(base, base), base not in MAPA
if (n in NIXISMOS_EXACTOS or any(n.endswith(s) for s in NIXISMOS_SUFIJOS)
or any(s in n for s in NIXISMOS_INFIJOS)):
return n, True
# `-wrapped` / `-unwrapped` son variantes de empaquetado de nix, no paquetes distintos.
for suf in ("-unwrapped", "-wrapped"):
if n.endswith(suf):
n = n[: -len(suf)]
break
if por_minusculas:
n = por_minusculas.get(n.lower(), n)
return n, False
def raiz_variante(n):
"""`zlib-shared` → `zlib`. Para comparar sin castigar una decisión de enlazado nuestra."""
for suf in VARIANTES:
if n.endswith(suf):
return n[: -len(suf)]
return n
# --- lectura del corpus ---------------------------------------------------------------------------
def recetas():
"""{nombre: [deps de build]} sobre `recipes/` + las colas incoming-*."""
out = {}
for pat in ["recipes/*.toml", "recipes/incoming-*/*.toml"]:
for f in sorted(glob.glob(pat)):
n = os.path.basename(f)[:-5]
try:
d = tomllib.load(open(f, "rb"))
except Exception:
continue
out.setdefault(n, d.get("deps", {}).get("build", []))
return out
# --- extracción desde nixpkgs ---------------------------------------------------------------------
# Los paquetes NO viven todos en el top-level de nixpkgs: el escritorio KDE —justo el objetivo de
# este frente— cuelga de `kdePackages.` (Plasma 6) o `libsForQt5.`. Sin este fallback, 22 de 40
# nombres de la primera calibración salían "sin attr en nixpkgs" y la frontera KDE era insembrable.
# `ns` viaja en la respuesta: de dónde salió cada dep es parte de su procedencia.
EXPR = """
pkgs: let
names = [ %s ];
espacios = [ { ns = ""; set = pkgs; } ]
++ (if pkgs ? kdePackages then [ { ns = "kdePackages"; set = pkgs.kdePackages; } ] else [])
++ (if pkgs ? libsForQt5 then [ { ns = "libsForQt5"; set = pkgs.libsForQt5; } ] else []);
buscar = n: let hits = builtins.filter (e: e.set ? ${n}) espacios;
in if hits == [] then null else builtins.head hits;
uno = n: let e = buscar n; in if e == null then null else
let r = builtins.tryEval (let p = e.set.${n}; v = {
ns = e.ns;
bi = map (x: x.pname or x.name or "") (p.buildInputs or []);
nbi = map (x: x.pname or x.name or "") (p.nativeBuildInputs or []);
pbi = map (x: x.pname or x.name or "") (p.propagatedBuildInputs or []);
}; in builtins.deepSeq v v);
in if r.success then r.value else null;
in builtins.listToAttrs (map (n: { name = n; value = uno n; }) names)
"""
def nix_deps(nombres):
"""{nombre: {bi,nbi,pbi}} o None si nixpkgs no lo tiene / su eval falla. En LOTES: el costo es
el arranque del evaluador, no el paquete (6 en 0.7s con el eval cache caliente)."""
res = {}
for i in range(0, len(nombres), LOTE):
chunk = nombres[i : i + LOTE]
expr = EXPR % " ".join(json.dumps(n) for n in chunk)
cmd = ["nix", "eval", "--extra-experimental-features", "nix-command flakes"]
if NIX_ROOT:
cmd += ["--store", f"local?root={NIX_ROOT}"]
cmd += ["--json", f"{NIXPKGS}#legacyPackages.x86_64-linux", "--apply", expr]
print(f" nix eval lote {i//LOTE+1} ({len(chunk)} nombres)…", file=sys.stderr)
p = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
if p.returncode != 0:
print(f" ⚠ el lote falló: {p.stderr.strip()[:300]}", file=sys.stderr)
res.update({n: None for n in chunk})
continue
res.update(json.loads(p.stdout))
return res
def sembrar(nombres, recs):
"""nombre → [{dep, clase, origen_nix}]. Une bi+nbi+pbi: las deps REALES de varios paquetes viven
en `propagatedBuildInputs` (freetype: zlib/bzip2/brotli/libpng están ahí, no en buildInputs)."""
por_minusculas = {r.lower(): r for r in recs}
crudo = nix_deps(nombres)
out = {}
for n in nombres:
d = crudo.get(n)
if d is None:
out[n] = None # nixpkgs no lo tiene: la frontera queda sin sembrar (candidato a Alpine)
continue
vistos, aristas = set(), []
for campo in ("nbi", "bi", "pbi"):
for crudo_n in d.get(campo) or []:
dep, nixismo = normalizar(crudo_n, por_minusculas)
if dep in vistos:
continue
vistos.add(dep)
clase = ("nix-ismo" if nixismo else "lab" if dep in PROVISTO_POR_EL_LAB
else "conocida" if dep in recs else "frontera")
aristas.append(dict(dep=dep, clase=clase, origen_nix=crudo_n, campo=campo,
ns=d.get("ns") or "top-level"))
out[n] = aristas
return out
# --- modos ----------------------------------------------------------------------------------------
def sembrar_cierre(raices, recs, prof_max=4, tope=4000):
"""Siembra en ONDAS hasta punto fijo: las deps de las deps. Necesario porque las dos distros
declaran cosas distintas — hammer APLANA la clausura en `[deps].build` (cada dep es una capa
--overlay-src del sandbox: si no está declarada, no está en el árbol), mientras nix declara sólo
las DIRECTAS y deja que la propagación haga el resto. Comparar unas con otras mide un artefacto
de la comparación, no la calidad de la semilla.
Devuelve {nombre: [aristas]} de TODO lo visitado; la clausura se calcula después sobre ese grafo
(que es también lo que hace `build-state.py` con las aristas reales)."""
grafo, pendientes, prof = {}, list(dict.fromkeys(raices)), 0
while pendientes and prof < prof_max and len(grafo) < tope:
prof += 1
print(f" onda {prof}: {len(pendientes)} nombres nuevos", file=sys.stderr)
nuevo = sembrar(pendientes, recs)
grafo.update(nuevo)
siguientes = {a["dep"] for ar in nuevo.values() if ar for a in ar
if a["clase"] not in ("nix-ismo", "lab") and a["dep"] not in grafo}
pendientes = sorted(siguientes)[: max(0, tope - len(grafo))]
return grafo
def clausura_sembrada(raiz, grafo):
"""Alcanzables desde `raiz` por las aristas sembradas (sin la raíz)."""
vistos, pila = set(), [a["dep"] for a in (grafo.get(raiz) or [])
if a["clase"] not in ("nix-ismo", "lab")]
while pila:
n = pila.pop()
if n in vistos:
continue
vistos.add(n)
for a in grafo.get(n) or []:
if a["clase"] not in ("nix-ismo", "lab") and a["dep"] not in vistos:
pila.append(a["dep"])
return vistos
def calibrar(recs, n_muestra):
"""Mide la semilla contra la VERDAD que ya tenemos: las recetas escritas a mano. Es la única
forma honesta de saber si sembrar sirve, antes de sembrar nada."""
# Candidatas: recetas C/GUI con deps declaradas (las Rust/Go declaran sólo el toolchain).
cand = [n for n, deps in sorted(recs.items())
if deps and "go" not in deps and "rust" not in deps and "cargo" not in deps]
paso = max(1, len(cand) // n_muestra)
muestra = cand[::paso][:n_muestra]
print(f"== calibrando sobre {len(muestra)} recetas con deps declaradas (de {len(cand)})")
if TRANSITIVO:
grafo = sembrar_cierre(muestra, recs)
sem = {n: ([dict(dep=d, clase=("conocida" if d in recs else "frontera"), origen_nix=d,
campo="cierre", ns="") for d in sorted(clausura_sembrada(n, grafo))]
if grafo.get(n) is not None else None) for n in muestra}
else:
sem = sembrar(muestra, recs)
tot_v = tot_ok = tot_falta_real = tot_falta_conv = tot_variante = 0
sobra_con_receta, sobra_frontera, nixismos = {}, {}, {}
sin_nix, detalle = [], []
for n in muestra:
aristas = sem.get(n)
if aristas is None:
sin_nix.append(n)
continue
for a in aristas:
if a["clase"] == "nix-ismo":
nixismos[a["origen_nix"]] = nixismos.get(a["origen_nix"], 0) + 1
sembrado = {a["dep"] for a in aristas if a["clase"] not in ("nix-ismo", "lab")}
verdad = set(recs[n])
ok = verdad & sembrado
falta = verdad - sembrado
# `zlib-shared` declarada y `zlib` sembrada: acertó el paquete, no la variante. Aparte.
raices_sembradas = {raiz_variante(s) for s in sembrado}
variante = {f for f in falta if raiz_variante(f) in raices_sembradas}
falta -= variante
falta_conv = falta & CONVENCION_HAMMER
falta_real = falta - CONVENCION_HAMMER
sobra = {s for s in sembrado - verdad if raiz_variante(s) not in
{raiz_variante(v) for v in verdad}}
for s in sobra:
destino = sobra_con_receta if s in recs else sobra_frontera
destino[s] = destino.get(s, 0) + 1
tot_v += len(verdad); tot_ok += len(ok); tot_variante += len(variante)
tot_falta_real += len(falta_real); tot_falta_conv += len(falta_conv)
detalle.append((n, len(verdad), len(ok), sorted(falta_real), sorted(sobra)))
print(f"\n== VEREDICTO (verdad = las {tot_v} deps declaradas a mano en la muestra)")
if tot_v:
print(f" aciertos {tot_ok:4} ({100*tot_ok//tot_v}% de la verdad)")
print(f" faltan (reales) {tot_falta_real:4} ({100*tot_falta_real//tot_v}%) ← la semilla NO las ve")
print(f" faltan (convención hammer: {' '.join(sorted(CONVENCION_HAMMER))}) {tot_falta_conv:4}"
f" ← nix las da por implícitas en el stdenv; NO es ruido, es constante")
print(f" variante distinta {tot_variante:4} ← acertó el paquete, erró -shared/-static"
f" (decisión NUESTRA, nix no puede saberla)")
n_sobra = sum(sobra_con_receta.values()) + sum(sobra_frontera.values())
print(f" sobrantes {n_sobra:4} = {sum(sobra_con_receta.values())} con receta en hammer"
f" (deps que hammer NO necesitó) + {sum(sobra_frontera.values())} sin receta")
print(f" nix-ismos filtrados {sum(nixismos.values()):3} (andamiaje descartado antes de contar)")
if sin_nix:
print(f" sin attr en nixpkgs {len(sin_nix):3}: {' '.join(sin_nix[:12])}")
print("\n== los 15 sobrantes SIN receta más frecuentes (candidatos a MAPA o a nix-ismo)")
for k, v in sorted(sobra_frontera.items(), key=lambda kv: -kv[1])[:15]:
print(f" {v:3}× {k}")
print("\n== peores casos (más deps reales no vistas)")
for n, nv, nok, falta, sobra in sorted(detalle, key=lambda d: -len(d[3]))[:8]:
print(f" {n:22} verdad {nv:2}, acierta {nok:2}, no ve: {' '.join(falta) or '—'}")
return 0
def frontera(recs, perfil):
"""Qué dice nixpkgs que hace falta y hammer NO TIENE RECETA para construir, mirando las recetas
que YA existen en la clausura de un perfil. Es el modo útil mientras no haya nodos `wanted`:
descubre la frontera desde el corpus actual en vez de esperar a que alguien la declare.
Cada candidato es una HIPÓTESIS a clasificar por un humano: puede ser (a) una dep opcional que
hammer no habilitó a propósito, (b) un nix-ismo que falta en la lista de descarte, o (c) un
hueco real. El sembrador no decide cuál: los ordena por cuántas recetas los piden."""
# El grafo se elige por la COLA que el perfil declara, no por cuál tiene más nodos: la cola
# `incoming-kde` SOMBREA recetas canónicas, así que medir un perfil del corpus contra el grafo
# KDE mide una imagen que nadie construye.
try:
cola = targets_mod.load().get(perfil, {}).get("cola", "corpus")
except Exception:
cola = "corpus"
en_perfil, grafo_usado = [], None
for f in ("docs/state/build-state.json", "docs/state/build-state-kde.json"):
p = ROOT / f
if not p.exists():
continue
d = json.loads(p.read_text())
if cola not in (d.get("by_queue") or {}):
continue
hit = sorted(n for n, r in d["nodes"].items() if perfil in (r.get("perfiles") or []))
if hit:
en_perfil, grafo_usado = hit, f
break
if not en_perfil:
print(f"perfil `{perfil}` sin nodos en ningún grafo de estado "
f"(¿regeneraste con scripts/build-state.py [--kde]?)", file=sys.stderr)
return 1
print(f" (grafo: {grafo_usado})")
print(f"== sembrando las {len(en_perfil)} recetas de la clausura de `{perfil}`")
sem = sembrar(en_perfil, recs)
cand, sin_nix = {}, []
for n, aristas in sem.items():
if aristas is None:
sin_nix.append(n); continue
for a in aristas:
if a["clase"] == "frontera":
cand.setdefault(a["dep"], []).append(n)
print(f" sin attr en nixpkgs: {len(sin_nix)}"
+ (f" ({' '.join(sin_nix[:10])}…)" if sin_nix else ""))
orden = sorted(cand.items(), key=lambda kv: (-len(kv[1]), kv[0]))
print(f"\n== {len(cand)} candidatos a HUECO (nixpkgs los pide, hammer no tiene receta)")
for dep, quienes in orden[:40]:
print(f" {len(quienes):3}× {dep:28}{' '.join(sorted(quienes)[:5])}"
+ (" …" if len(quienes) > 5 else ""))
# Artefacto durable y versionado: el `git diff` de este fichero muestra qué huecos APARECEN
# entre dos corridas (una receta nueva puede destapar deps que nadie había pedido antes).
prev = json.loads(FRONTERA.read_text()) if FRONTERA.exists() else {"perfiles": {}}
prev.setdefault("perfiles", {})[perfil] = dict(
recetas_sembradas=len(en_perfil), sin_attr_en_nixpkgs=sin_nix,
candidatos={dep: sorted(q) for dep, q in orden})
prev["schema"] = "hammer-seed-frontera/1"
prev["advertencia"] = ("HIPÓTESIS desde metadata de nixpkgs. Cada candidato es (a) dep opcional "
"que hammer no habilitó, (b) nix-ismo por filtrar, o (c) hueco real. "
"Clasificar a mano; NO promover a receta automáticamente.")
FRONTERA.write_text(json.dumps(prev, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
print(f"\n{FRONTERA.relative_to(ROOT)} (perfil `{perfil}`)")
return 0
def sembrar_a_fichero(nombres, recs):
sem = sembrar(nombres, recs)
doc = dict(
schema="hammer-seed-edges/1",
fuente="nixpkgs (metadata; NO se construye con nix — ADR 0004)",
advertencia="HIPÓTESIS, no verdad. Sólo válido para nodos SIN receta; con receta, manda la receta.",
nodos={n: (a if a is not None else "sin-attr-en-nixpkgs") for n, a in sorted(sem.items())},
)
OUT.write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
n_ok = sum(1 for a in sem.values() if a)
conocidas = sum(1 for a in sem.values() if a for x in a if x["clase"] == "conocida")
frontera = sorted({x["dep"] for a in sem.values() if a for x in a if x["clase"] == "frontera"})
print(f"== sembrados {n_ok}/{len(nombres)} nodos → {OUT.relative_to(ROOT)}")
print(f" aristas `conocida` (hay receta): {conocidas}")
print(f" `frontera` (candidatos a nodo wanted nuevo): {len(frontera)}")
if frontera:
print(" " + " ".join(frontera[:30]) + (" …" if len(frontera) > 30 else ""))
return 0
def main():
args = sys.argv[1:]
recs = recetas()
if not args:
print(__doc__ or "uso: seed-graph.py --calibrar [N] | --perfil <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())