build-state.py gana --gnome (análogo a --kde): carga recipes/incoming-gnome/ y sale a build-state-gnome.json. drenar/yupana/seed-graph aprenden ese grafo (keystones, drenar --perfil escritorio-gnome, radio de nodos incoming-gnome, frontera). Hoy la cola está vacía ⇒ el grafo trae 0 nodos gnome; se poblará al aterrizar recetas y ahí drenar/keystones reckonan solos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
441 lines
23 KiB
Python
Executable File
441 lines
23 KiB
Python
Executable File
#!/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",
|
||
"docs/state/build-state-gnome.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())
|