Files
hammer/scripts/seed-graph.py
T
sergioandClaude Opus 4.8 ec28a9b394 catálogo objetivo P5: tandas a archivo, y el triaje que cierra el lazo
tandas/README.md declara los 55 ficheros ARCHIVO de campañas pasadas (no se
borran: su git log es la crónica de cada frente) y documenta de dónde sale el
trabajo ahora. import-batch.sh -f sigue vivo como vía de escape, no como camino.

Lo que faltaba para que la cola fuera derivada de verdad era el paso humano, y
estaba sin forma: 137 candidatos clasificables sólo en la cabeza de alguien.
triaje.py les da forma durable — docs/state/frontera-triaje.toml, 170 candidatos
unificados de los 3 perfiles. Cada veredicto cierra un lazo distinto:

  hueco    → raíz en targets.toml ⇒ nace un nodo `wanted`, el sembrador lo
             siembra y el drenaje lo ordena (= trabajo nuevo para el worker)
  opcional → registrado, deja de reaparecer como pregunta
  nix-ismo → vuelve a seed-graph.py como descarte ⇒ el sembrador APRENDE de su
             propio triaje y cada corrida sale más limpia

Sincronizar NUNCA pisa un juicio humano: agrega los nuevos y marca ausente=true
los que dejaron de aparecer (progreso, o cambio de nixpkgs — se ve en el diff).
`sugerencia` propone con evidencia mecánica pero `veredicto` arranca en
`pendiente`: la heurística abarata el juicio, no lo cierra.

Lazo verificado punta a punta: python3-3.14.6-env marcado nix-ismo → --aplicar
lo escribe al descarte → seed-graph.normalizar() lo devuelve clasificado y deja
de contarlo como hueco. Probado y revertido; los 170 siguen pendientes.

Cadena cerrada y corriendo: targets.toml → build-state.json → drenaje.json →
worker, regenerada por el latido cada 30min. El único paso que pide una persona
es el triaje.

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

431 lines
22 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("#")}
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())