> 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…
kernel: modo reversa y catálogo de bundles — y las cuatro recetas apagan dos símbolos que ya no existen
Paso 1 del §8 del SDD 22 (va después de la clausura porque necesitaba el grafo). Sigue sin
compilar ni escribir nada.
hammer kernel probe — lee /proc/config.gz (o /boot/config-<release>) y muestra el kernel
que YA CORRE por el lente de los bundles: cuánto de cada uno rige, qué capacidad carga
esta máquina y no usa, y dónde el hardware CONTRADICE a un bundle aplicado. Corrido en
gioser: 10.551 símbolos, 20 dispositivos PCI, 38 drivers bindeados, 15 bundles, 7 con
capacidad que este hardware no usa.
hammer kernel bundles [--check] — el catálogo con las clausuras resueltas contra un árbol
concreto, y el control de frescura.
Piezas nuevas en hammer-core/src/kernel/:
catalog.rs bundles N1 y perillas N2. El campo `side` NO es decorativo: la mitad de N2
son variables de receta, no símbolos; mueven el hash igual pero se aplican en
otra fase y fallan distinto. Una perilla side=recipe sin recipe_field es
error de carga, porque es un diff que la UI no podría explicar.
hw.rs huella DMI+PCI+flags de CPU. El USB se LEE y se REPORTA pero NO se hashea: un
pendrive no puede cambiar la clase de hardware bajo la que se cachea un
kernel. Tampoco entra el serial: la huella agrupa máquinas, no las identifica.
Tres tests fijan esas tres propiedades.
reverse.rs el análisis. Y una tercera salida que no estaba pedida: cada fuga `select`
que entra a un bundle y NO está declarada en el catálogo es un símbolo que
upstream agregó y nadie revisó ⇒ la mitad barata de la curación del delta
(§3 del handoff) sale de comparar grafo con catálogo, sin IA.
docs/state/kernel-bundles.toml — 15 bundles N1 y 8 perillas N2, cada fuga resuelta a mano
una vez: `close_leaks` (se apaga también al que la provoca) o `accept_leaks` (se deja
abierta a sabiendas, con el motivo escrito). Ejemplo de por qué hacían falta las dos:
"sin-audio" NO cierra — DRM_I915/NOUVEAU/AMD_DC hacen select del códec HDMI, y cerrarlo
sería quedarse sin GPU. Se acepta y queda por escrito.
LO QUE DESTAPÓ EL CONTROL DE FRESCURA: las CUATRO recetas de kernel (linux, linux-metal,
linux-metal-dual, linux-generic) apagan `THUNDERBOLT` y `REISERFS_FS`, y 6.16.12 NO TIENE
NINGUNO DE LOS DOS. Thunderbolt se llama USB4 desde que upstream lo fundió con USB4;
reiserfs fue retirado. Los dos `-d` son no-ops silenciosos: el driver USB4 sigue entrando
por el defconfig mientras la receta dice que está apagado. NO las toco — cambiarlo mueve
el ArtifactHash de los cuatro kernels y es una decisión, no una limpieza.
Y el propio probe destapó un desajuste que ahora avisa: el config vivo de gioser es de la
serie 7.1 y el catálogo se revisó contra la 6.16 ⇒ las clausuras son aproximadas. Se dice
en vez de callarlo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/crates/hammer-cli/src/kernel_cmd.rs b/crates/hammer-cli/src/kernel_cmd.rs
index 054a652d..b1b2254b 100644
--- a/crates/hammer-cli/src/kernel_cmd.rs
+++ b/crates/hammer-cli/src/kernel_cmd.rs
@@ -14,6 +14,13 @@ use serde::Serialize;
/// Dónde busca el árbol de Kconfig si no se pasa `--kconfig`.
const KCONFIG_ENV: &str = "HAMMER_KCONFIG_ROOT";
+/// Ídem para el catálogo de bundles.
+const CATALOG_ENV: &str = "HAMMER_KERNEL_BUNDLES";
+/// Sitios donde buscarlo sin que nadie diga nada: el del repo y el del sistema instalado.
+const CATALOG_DEFAULTS: &[&str] = &[
+ "docs/state/kernel-bundles.toml",
+ "/usr/share/hammer/kernel-bundles.toml",
+];
#[derive(Subcommand)]
pub enum KernelCmd {
@@ -43,6 +50,40 @@ pub enum KernelCmd {
#[arg(long, default_value_t = 20)]
limit: usize,
},
+ /// **Modo reversa**: lee el kernel que ya corre y lo muestra por el lente de los bundles.
+ ///
+ /// No compila ni escribe nada. Contesta tres cosas: cuánto de cada bundle ya rige, qué
+ /// capacidad carga este kernel que esta máquina no usa, y dónde el hardware CONTRADICE a un
+ /// bundle aplicado.
+ Probe {
+ /// `.config` a mirar. Por defecto `/proc/config.gz` y si no `/boot/config-<uname -r>`.
+ #[arg(long)]
+ config: Option<PathBuf>,
+ #[arg(long)]
+ kconfig: Option<PathBuf>,
+ #[arg(long)]
+ catalog: Option<PathBuf>,
+ /// No leer el hardware (útil para analizar el config de OTRA máquina).
+ #[arg(long)]
+ no_hardware: bool,
+ #[arg(long)]
+ json: bool,
+ },
+ /// Lista el catálogo con sus clausuras ya resueltas contra un árbol de Kconfig concreto.
+ ///
+ /// Es también el control de frescura: `--check` sale distinto de cero si algún bundle nombra
+ /// símbolos que ya no existen o dejó fugas `select` sin declarar.
+ Bundles {
+ #[arg(long)]
+ kconfig: Option<PathBuf>,
+ #[arg(long)]
+ catalog: Option<PathBuf>,
+ /// Falla si el catálogo envejeció respecto de este árbol.
+ #[arg(long)]
+ check: bool,
+ #[arg(long)]
+ json: bool,
+ },
/// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
Stats {
#[arg(long)]
@@ -56,6 +97,25 @@ pub enum KernelCmd {
pub fn run(cmd: KernelCmd) -> Result<()> {
match cmd {
KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
+ KernelCmd::Probe {
+ config,
+ kconfig,
+ catalog,
+ no_hardware,
+ json,
+ } => probe(
+ config.as_deref(),
+ &kconfig_root(kconfig)?,
+ &catalog_path(catalog)?,
+ !no_hardware,
+ json,
+ ),
+ KernelCmd::Bundles {
+ kconfig,
+ catalog,
+ check,
+ json,
+ } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
KernelCmd::Closure {
symbols,
kconfig,
@@ -87,6 +147,45 @@ fn kconfig_root(flag: Option<PathBuf>) -> Result<PathBuf> {
}
}
+/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
+fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
+ if let Some(p) = flag {
+ return Ok(p);
+ }
+ if let Some(v) = std::env::var_os(CATALOG_ENV) {
+ return Ok(PathBuf::from(v));
+ }
+ for c in CATALOG_DEFAULTS {
+ let p = PathBuf::from(c);
+ if p.is_file() {
+ return Ok(p);
+ }
+ }
+ anyhow::bail!(
+ "no encontré el catálogo de bundles: pasá --catalog <path> o exportá {CATALOG_ENV}"
+ )
+}
+
+/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
+/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
+fn running_config_path() -> Result<PathBuf> {
+ let gz = PathBuf::from("/proc/config.gz");
+ if gz.is_file() {
+ return Ok(gz);
+ }
+ if let Ok(out) = std::process::Command::new("uname").arg("-r").output() {
+ let rel = String::from_utf8_lossy(&out.stdout).trim().to_string();
+ let p = PathBuf::from(format!("/boot/config-{rel}"));
+ if p.is_file() {
+ return Ok(p);
+ }
+ }
+ anyhow::bail!(
+ "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-<release>).\n\
+ Pasá --config <path> para mirar el de otra máquina."
+ )
+}
+
fn load_tree(root: &Path) -> Result<KconfigTree> {
if !root.join("Kconfig").is_file() {
anyhow::bail!(
@@ -340,6 +439,342 @@ fn closure(
Ok(())
}
+// ---------------------------------------------------------------------------------------------
+// probe — el modo reversa (#9)
+// ---------------------------------------------------------------------------------------------
+
+fn probe(
+ config: Option<&Path>,
+ kroot: &Path,
+ catalog: &Path,
+ with_hw: bool,
+ json: bool,
+) -> Result<()> {
+ use hammer_core::kernel::reverse::{BundleState, HwSignal};
+
+ let cfg_path = match config {
+ Some(p) => p.to_path_buf(),
+ None => running_config_path()?,
+ };
+ let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?);
+ if cfg.is_empty() {
+ anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display());
+ }
+ let tree = load_tree(kroot)?;
+ let cat = hammer_core::kernel::Catalog::load(catalog)?;
+ let hw = with_hw.then(hammer_core::kernel::Hardware::probe_local);
+ let rep = hammer_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref());
+
+ if json {
+ println!("{}", serde_json::to_string_pretty(&rep)?);
+ return Ok(());
+ }
+
+ println!("config {}", cfg_path.display());
+ if let Some(k) = &rep.kernel {
+ println!("kernel {k}");
+ }
+ // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma
+ // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre
+ // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo.
+ if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) {
+ if let (Some(a), Some(b)) = (serie(banner), serie(rev)) {
+ if a != b {
+ println!(
+ " ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \
+ la {b}: las clausuras son aproximadas"
+ );
+ }
+ }
+ }
+ println!("símbolos {} declarados", rep.config_symbols);
+ if let Some(h) = &hw {
+ println!("huella {}", rep.fingerprint);
+ if let Some(m) = &h.cpu_model {
+ println!(" {m}");
+ }
+ let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")]
+ .into_iter()
+ .flatten()
+ .cloned()
+ .collect::<Vec<_>>()
+ .join(" ");
+ if !maquina.is_empty() {
+ println!(" {maquina}");
+ }
+ println!(
+ " {} dispositivos PCI · {} drivers bindeados",
+ h.pci.len(),
+ h.bound_drivers.len()
+ );
+ for g in &h.gaps {
+ println!(" ⚠ {g}");
+ }
+ }
+ println!();
+
+ for b in &rep.bundles {
+ let on = b.on_builtin + b.on_module;
+ let estado = match b.state {
+ BundleState::Aplicado => "APLICADO ",
+ BundleState::Parcial => "parcial ",
+ BundleState::Sin => "sin ",
+ };
+ let hwtxt = match &b.hardware {
+ HwSignal::SinSeñal => String::new(),
+ HwSignal::Ausente => " · hardware ausente".to_string(),
+ HwSignal::Presente { evidencia } => {
+ format!(" · ⚠ HARDWARE PRESENTE ({})", evidencia.len())
+ }
+ };
+ println!(
+ "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}",
+ b.id, b.closure_len
+ );
+ if let HwSignal::Presente { evidencia } = &b.hardware {
+ for e in evidencia.iter().take(4) {
+ println!(" {e}");
+ }
+ }
+ if !b.unknown_symbols.is_empty() {
+ println!(
+ " ⚠ símbolos que este kernel no tiene: {}",
+ b.unknown_symbols.join(", ")
+ );
+ }
+ if !b.undeclared_leaks.is_empty() {
+ println!(
+ " ⚠ {} fuga(s) `select` sin declarar en el catálogo",
+ b.undeclared_leaks.len()
+ );
+ }
+ }
+
+ println!();
+ println!(
+ "resumen {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa",
+ rep.bundles.len(),
+ rep.bundles
+ .iter()
+ .filter(|b| b.state == BundleState::Aplicado)
+ .count(),
+ rep.applicable_savings
+ );
+ if rep.contradictions > 0 {
+ println!(
+ " ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente",
+ rep.contradictions
+ );
+ }
+ println!(" (detectar sirve para contradecir, nunca para podar solo)");
+ Ok(())
+}
+
+/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`,
+/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna.
+fn serie(texto: &str) -> Option<String> {
+ for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
+ let mut it = tok.split('.');
+ if let (Some(a), Some(b)) = (it.next(), it.next()) {
+ if !a.is_empty() && !b.is_empty() {
+ return Some(format!("{a}.{b}"));
+ }
+ }
+ }
+ None
+}
+
+// ---------------------------------------------------------------------------------------------
+// bundles — catálogo + control de frescura
+// ---------------------------------------------------------------------------------------------
+
+#[derive(Serialize)]
+struct BundleView {
+ id: String,
+ title: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ help: Option<String>,
+ disable: Vec<String>,
+ close_leaks: Vec<String>,
+ closure_len: usize,
+ visible: usize,
+ unknown_symbols: Vec<String>,
+ undeclared_leaks: Vec<hammer_core::kernel::SelectLeak>,
+ accepted_leaks: usize,
+}
+
+#[derive(Serialize)]
+struct BundlesReport {
+ catalog: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ reviewed_against: Option<String>,
+ bundles: Vec<BundleView>,
+ knobs: Vec<KnobView>,
+ stale: bool,
+}
+
+#[derive(Serialize)]
+struct KnobView {
+ id: String,
+ title: String,
+ side: hammer_core::kernel::Side,
+ enable: Vec<String>,
+ disable: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ recipe_field: Option<String>,
+ unknown_symbols: Vec<String>,
+}
+
+fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> {
+ let tree = load_tree(kroot)?;
+ let cat = hammer_core::kernel::Catalog::load(catalog)?;
+
+ let mut views = Vec::new();
+ for b in &cat.bundles {
+ let mut roots = b.disable.clone();
+ roots.extend(b.close_leaks.iter().cloned());
+ let unknown: Vec<String> = roots
+ .iter()
+ .chain(b.enable.iter())
+ .filter(|s| !tree.symbols.contains_key(*s))
+ .cloned()
+ .collect();
+ let cl = tree.closure_off(&roots);
+ let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
+ let undeclared: Vec<_> = tree
+ .select_leaks(&cl)
+ .into_iter()
+ .filter(|l| !aceptadas.contains(l.target.as_str()))
+ .collect();
+ views.push(BundleView {
+ id: b.id.clone(),
+ title: b.title.clone(),
+ help: b.help.clone(),
+ disable: b.disable.clone(),
+ close_leaks: b.close_leaks.clone(),
+ closure_len: cl.len(),
+ visible: cl
+ .iter()
+ .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible()))
+ .count(),
+ unknown_symbols: unknown,
+ undeclared_leaks: undeclared,
+ accepted_leaks: b.accept_leaks.len(),
+ });
+ }
+
+ let knobs: Vec<KnobView> = cat
+ .knobs
+ .iter()
+ .map(|k| KnobView {
+ id: k.id.clone(),
+ title: k.title.clone(),
+ side: k.side,
+ enable: k.enable.clone(),
+ disable: k.disable.clone(),
+ recipe_field: k.recipe_field.clone(),
+ unknown_symbols: k
+ .enable
+ .iter()
+ .chain(k.disable.iter())
+ .filter(|s| !tree.symbols.contains_key(*s))
+ .cloned()
+ .collect(),
+ })
+ .collect();
+
+ let stale = views
+ .iter()
+ .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty())
+ || knobs.iter().any(|k| !k.unknown_symbols.is_empty());
+
+ let rep = BundlesReport {
+ catalog: catalog.display().to_string(),
+ reviewed_against: cat.reviewed_against.clone(),
+ bundles: views,
+ knobs,
+ stale,
+ };
+
+ if json {
+ println!("{}", serde_json::to_string_pretty(&rep)?);
+ } else {
+ println!("catálogo {}", rep.catalog);
+ if let Some(r) = &rep.reviewed_against {
+ println!("revisado contra {r}");
+ }
+ println!("árbol {}", kroot.display());
+ println!();
+ println!("NIVEL 1 — aserciones del dueño de la máquina");
+ for v in &rep.bundles {
+ println!(
+ " {:<26} {:>5} símbolos ({} visibles) ← {}",
+ v.id,
+ v.closure_len,
+ v.visible,
+ v.disable.join(" ")
+ );
+ println!(" {}", v.title);
+ if !v.close_leaks.is_empty() {
+ println!(" fugas cerradas: {}", v.close_leaks.join(", "));
+ }
+ if v.accepted_leaks > 0 {
+ println!(" fugas aceptadas a sabiendas: {}", v.accepted_leaks);
+ }
+ if !v.unknown_symbols.is_empty() {
+ println!(
+ " ⚠ NO EXISTEN en este árbol: {}",
+ v.unknown_symbols.join(", ")
+ );
+ }
+ for l in v.undeclared_leaks.iter().take(6) {
+ println!(" ⚠ fuga sin declarar: {} ← {}", l.target, l.selector);
+ }
+ if v.undeclared_leaks.len() > 6 {
+ println!(" ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6);
+ }
+ }
+ println!();
+ println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)");
+ for k in &rep.knobs {
+ let lado = match k.side {
+ hammer_core::kernel::Side::Kconfig => "kconfig",
+ hammer_core::kernel::Side::Recipe => "receta ",
+ };
+ let que = match k.side {
+ hammer_core::kernel::Side::Kconfig => {
+ let mut v: Vec<String> =
+ k.enable.iter().map(|s| format!("+{s}")).collect();
+ v.extend(k.disable.iter().map(|s| format!("-{s}")));
+ v.join(" ")
+ }
+ hammer_core::kernel::Side::Recipe => {
+ k.recipe_field.clone().unwrap_or_default()
+ }
+ };
+ println!(" [{lado}] {:<26} {que}", k.id);
+ println!(" {}", k.title);
+ if !k.unknown_symbols.is_empty() {
+ println!(
+ " ⚠ NO EXISTEN en este árbol: {}",
+ k.unknown_symbols.join(", ")
+ );
+ }
+ }
+ println!();
+ if rep.stale {
+ println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)");
+ } else {
+ println!("✓ el catálogo está al día con este árbol");
+ }
+ }
+
+ if check && rep.stale {
+ anyhow::bail!("catálogo desactualizado");
+ }
+ Ok(())
+}
+
/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
///
/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
@@ -362,3 +797,15 @@ pub fn read_maybe_gz(p: &Path) -> Result<String> {
}
std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display()))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn serie_de_las_dos_formas_que_se_cruzan() {
+ assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1"));
+ assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16"));
+ assert_eq!(serie("sin versión"), None);
+ }
+}
diff --git a/crates/hammer-core/src/kernel/catalog.rs b/crates/hammer-core/src/kernel/catalog.rs
new file mode 100644
index 00000000..524e390b
--- /dev/null
+++ b/crates/hammer-core/src/kernel/catalog.rs
@@ -0,0 +1,254 @@
+//! El catálogo de bundles (N1) y perillas (N2).
+//!
+//! ## Por qué un bundle no es una lista de símbolos
+//! Escrito como lista, «no necesito wifi» son ~400 `CONFIG_*` que envejecen en cada release y
+//! exigen un curador humano para siempre (handoff §2.1). Escrito como **predicado sobre el grafo**
+//! —una raíz y su clausura— son dos líneas y se auto-cura: el driver que entra en 6.17 cae dentro
+//! sin que nadie lo toque.
+//!
+//! Lo que la medición del SDD 22 §9 agregó a esa idea: **la clausura sola no cierra**. `select`
+//! fuerza símbolos ignorando sus `depends on`, así que un bundle honesto declara además qué hace
+//! con cada fuga — cerrarla (`close_leaks`) o aceptarla a sabiendas (`accept_leaks`). Esa revisión
+//! se hace **una vez por bundle**, no una vez por release.
+//!
+//! ## Por qué las perillas N2 llevan un campo `side`
+//! «La mitad de N2 no es Kconfig sino variables de receta» (handoff §5). Con el hecho del §1 del
+//! SDD 22 en la mano —el config ES la identidad del artefacto— eso es más grave de lo que parece:
+//! las dos mitades mueven el hash igual, pero se aplican en fases distintas y fallan distinto. Una
+//! UI que no sepa de qué lado cae cada perilla prometerá diffs que no puede explicar.
+
+use std::collections::BTreeMap;
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+/// Formato del fichero de catálogo. Se versiona para poder migrarlo sin adivinar.
+pub const CATALOG_VERSION: u32 = 1;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Catalog {
+ pub version: u32,
+ /// Kernel contra el que se revisaron las fugas. Cambiar de versión no invalida el catálogo,
+ /// pero sí obliga a re-mirar las fugas (`hammer kernel closure`).
+ #[serde(default)]
+ pub reviewed_against: Option<String>,
+ #[serde(default, rename = "bundle")]
+ pub bundles: Vec<Bundle>,
+ #[serde(default, rename = "knob")]
+ pub knobs: Vec<Knob>,
+}
+
+/// Un bundle de nivel 1: una aserción del dueño de la máquina («no necesito wifi»).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Bundle {
+ /// Identificador estable, en kebab-case. Es lo que la UI y `plan` se pasan.
+ pub id: String,
+ /// Título en castellano, para el humano.
+ pub title: String,
+ #[serde(default)]
+ pub help: Option<String>,
+ /// **El predicado**: apagar estas raíces y, con ellas, todo lo que dependa duro de ellas.
+ #[serde(default)]
+ pub disable: Vec<String>,
+ /// Encender estos símbolos (bundles «positivos», raros en N1 pero necesarios en N2).
+ #[serde(default)]
+ pub enable: Vec<String>,
+ /// Fugas `select` que se cierran también, revisadas a mano. Cada entrada es un símbolo de
+ /// FUERA de la clausura que reencendería algo de dentro.
+ #[serde(default)]
+ pub close_leaks: Vec<String>,
+ /// Fugas que se dejan abiertas a sabiendas, con su motivo. Clave = símbolo destino.
+ #[serde(default)]
+ pub accept_leaks: BTreeMap<String, String>,
+ /// Detección de hardware, **en un solo sentido**: sirve para CONTRADECIR al usuario
+ /// («marcaste "no necesito wifi" y tenés un AX211 activo ahora mismo»), nunca para podar solo.
+ /// Un kernel podado por autodetección es cómo se fabrica un ladrillo (handoff §6).
+ #[serde(default)]
+ pub contradicted_by: Contradiction,
+ #[serde(default)]
+ pub notes: Option<String>,
+}
+
+/// Señales de hardware que contradicen a un bundle si están presentes AHORA.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Contradiction {
+ /// Clases PCI, como los 6 dígitos hex de `/sys/bus/pci/devices/*/class`, o un prefijo
+ /// (`"0280"` = controlador de red inalámbrica).
+ #[serde(default)]
+ pub pci_class: Vec<String>,
+ /// Drivers con dispositivo bindeado ahora mismo (`/sys/bus/*/devices/*/driver`).
+ #[serde(default)]
+ pub driver: Vec<String>,
+ /// Flags de CPU de `/proc/cpuinfo`.
+ #[serde(default)]
+ pub cpu_flag: Vec<String>,
+}
+
+impl Contradiction {
+ pub fn is_empty(&self) -> bool {
+ self.pci_class.is_empty() && self.driver.is_empty() && self.cpu_flag.is_empty()
+ }
+}
+
+/// De qué lado del build cae una perilla de nivel 2.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Side {
+ /// Símbolos de Kconfig: se aplican en la fase `configure`.
+ Kconfig,
+ /// Variables de la receta (LTO, `-march`, `zig_version`): otra fase, otro modo de fallo.
+ /// Mueven el `ArtifactHash` igual que los símbolos.
+ Recipe,
+}
+
+/// Una perilla de nivel 2: política y rendimiento.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Knob {
+ pub id: String,
+ pub title: String,
+ #[serde(default)]
+ pub help: Option<String>,
+ /// La mitad de N2 no es Kconfig. Este campo obliga a decirlo.
+ pub side: Side,
+ #[serde(default)]
+ pub enable: Vec<String>,
+ #[serde(default)]
+ pub disable: Vec<String>,
+ /// Sólo con `side = "recipe"`: qué campo de la receta toca, en texto, para que la UI pueda
+ /// explicar el diff aunque `plan` todavía no sepa aplicarlo.
+ #[serde(default)]
+ pub recipe_field: Option<String>,
+ #[serde(default)]
+ pub notes: Option<String>,
+}
+
+impl Catalog {
+ pub fn load(path: &Path) -> crate::Result<Catalog> {
+ let text = std::fs::read_to_string(path)?;
+ let cat: Catalog = toml::from_str(&text)
+ .map_err(|e| crate::Error::Recipe(format!("{}: {e}", path.display())))?;
+ cat.validate(path)?;
+ Ok(cat)
+ }
+
+ fn validate(&self, path: &Path) -> crate::Result<()> {
+ let p = path.display();
+ if self.version != CATALOG_VERSION {
+ return Err(crate::Error::Recipe(format!(
+ "{p}: version = {}, esperaba {CATALOG_VERSION}",
+ self.version
+ )));
+ }
+ let mut ids = std::collections::BTreeSet::new();
+ for b in &self.bundles {
+ if !ids.insert(("bundle", b.id.as_str())) {
+ return Err(crate::Error::Recipe(format!("{p}: bundle duplicado «{}»", b.id)));
+ }
+ if b.disable.is_empty() && b.enable.is_empty() {
+ return Err(crate::Error::Recipe(format!(
+ "{p}: el bundle «{}» no apaga ni enciende nada",
+ b.id
+ )));
+ }
+ }
+ for k in &self.knobs {
+ if !ids.insert(("knob", k.id.as_str())) {
+ return Err(crate::Error::Recipe(format!("{p}: perilla duplicada «{}»", k.id)));
+ }
+ // Una perilla de receta que no dice qué campo toca es exactamente el diff que la UI no
+ // va a poder explicar.
+ if k.side == Side::Recipe && k.recipe_field.is_none() {
+ return Err(crate::Error::Recipe(format!(
+ "{p}: la perilla «{}» es side=recipe y no declara recipe_field",
+ k.id
+ )));
+ }
+ if k.side == Side::Kconfig && k.enable.is_empty() && k.disable.is_empty() {
+ return Err(crate::Error::Recipe(format!(
+ "{p}: la perilla «{}» es side=kconfig y no toca ningún símbolo",
+ k.id
+ )));
+ }
+ }
+ Ok(())
+ }
+
+ pub fn bundle(&self, id: &str) -> Option<&Bundle> {
+ self.bundles.iter().find(|b| b.id == id)
+ }
+
+ pub fn knob(&self, id: &str) -> Option<&Knob> {
+ self.knobs.iter().find(|k| k.id == id)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn escribir(s: &str) -> (tempfile::TempDir, std::path::PathBuf) {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("bundles.toml");
+ std::fs::write(&p, s).unwrap();
+ (d, p)
+ }
+
+ #[test]
+ fn carga_y_resuelve_por_id() {
+ let (_d, p) = escribir(
+ r#"
+version = 1
+[[bundle]]
+id = "sin-wifi"
+title = "No necesito wifi"
+disable = ["WIRELESS"]
+close_leaks = ["WLAN"]
+[bundle.accept_leaks]
+MAC80211_LEDS = "lo deja encendido IWLEGACY, que igual no se compila"
+[bundle.contradicted_by]
+pci_class = ["0280"]
+"#,
+ );
+ let c = Catalog::load(&p).unwrap();
+ let b = c.bundle("sin-wifi").unwrap();
+ assert_eq!(b.disable, vec!["WIRELESS"]);
+ assert_eq!(b.close_leaks, vec!["WLAN"]);
+ assert_eq!(b.accept_leaks.len(), 1);
+ assert_eq!(b.contradicted_by.pci_class, vec!["0280"]);
+ }
+
+ #[test]
+ fn perilla_de_receta_sin_campo_es_error() {
+ let (_d, p) = escribir(
+ r#"
+version = 1
+[[knob]]
+id = "lto"
+title = "LTO"
+side = "recipe"
+"#,
+ );
+ // Sin `recipe_field` la UI prometería un diff que no puede explicar (handoff §5).
+ let e = Catalog::load(&p).unwrap_err().to_string();
+ assert!(e.contains("recipe_field"), "{e}");
+ }
+
+ #[test]
+ fn bundle_vacio_es_error() {
+ let (_d, p) = escribir(
+ r#"
+version = 1
+[[bundle]]
+id = "nada"
+title = "Nada"
+"#,
+ );
+ assert!(Catalog::load(&p).is_err());
+ }
+
+ #[test]
+ fn version_ajena_es_error() {
+ let (_d, p) = escribir("version = 99\n");
+ assert!(Catalog::load(&p).is_err());
+ }
+}
diff --git a/crates/hammer-core/src/kernel/hw.rs b/crates/hammer-core/src/kernel/hw.rs
new file mode 100644
index 00000000..fa65c322
--- /dev/null
+++ b/crates/hammer-core/src/kernel/hw.rs
@@ -0,0 +1,350 @@
+//! Huella de hardware: DMI + PCI + flags de CPU.
+//!
+//! Es la pieza de §2.2 del handoff — «un config que **booteó** en esta huella es un hecho
+//! atestable». Con el §1 del SDD 22 en la mano vale más de lo que el handoff suponía: como el
+//! config ES la identidad del artefacto, atestar por huella no necesita mecanismo nuevo; es un
+//! `ArtifactHash` más una firma.
+//!
+//! ## Qué entra en la huella y qué no
+//! Entran **DMI, PCI y los flags de CPU**: describen la máquina, no lo que hay enchufado hoy. El
+//! USB se **lee y se reporta pero no se hashea** — un pendrive no puede cambiar la clase de
+//! hardware bajo la que se cachea un kernel. Si entrara, la huella sería distinta cada vez y el
+//! CDN cachearía por máquina en vez de por clase, que es justo lo que §2.2 quiere evitar.
+//!
+//! ## Y para qué sirve leerla
+//! **Sólo para contradecir** (handoff §6). Detectar sirve para decirle al usuario «marcaste "no
+//! necesito wifi" y tenés un AX211 activo ahora mismo», nunca para podar el config solo: no se
+//! puede detectar el dock que se enchufa el mes que viene ni el fs del USB de rescate.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use crate::hash::ArtifactHash;
+
+/// Campos de `/sys/class/dmi/id` que se leen. Deliberadamente **no** se lee `product_uuid` ni
+/// `product_serial`: identifican al equipo concreto (y son root-only), y la huella tiene que
+/// agrupar máquinas iguales, no distinguirlas.
+const DMI_FIELDS: &[&str] = &[
+ "sys_vendor",
+ "product_name",
+ "product_version",
+ "board_vendor",
+ "board_name",
+ "bios_vendor",
+ "bios_version",
+ "chassis_type",
+];
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PciDevice {
+ /// `0000:00:1f.3`
+ pub slot: String,
+ /// `0x8086`
+ pub vendor: String,
+ pub device: String,
+ /// Clase de 6 dígitos hex: los 2 primeros son la clase base, los 2 siguientes la subclase.
+ pub class: String,
+ /// Driver bindeado ahora mismo, si hay.
+ pub driver: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct UsbDevice {
+ pub id: String,
+ pub vendor: String,
+ pub product: String,
+ pub driver: Option<String>,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Hardware {
+ pub dmi: BTreeMap<String, String>,
+ pub pci: Vec<PciDevice>,
+ /// Leído y reportado, pero **fuera** de la huella (ver doc del módulo).
+ pub usb: Vec<UsbDevice>,
+ pub cpu_model: Option<String>,
+ pub cpu_flags: BTreeSet<String>,
+ /// Todos los drivers con al menos un dispositivo bindeado, de cualquier bus.
+ pub bound_drivers: BTreeSet<String>,
+ /// Qué no se pudo leer. Una huella incompleta es un dato, no una excepción: en una VM sin DMI
+ /// o con `/sys` restringido la huella sigue siendo útil, pero quien la lea tiene que saberlo.
+ pub gaps: Vec<String>,
+}
+
+impl Hardware {
+ /// Lee la máquina real. `sys` y `proc` se inyectan para poder testear con un árbol de juguete.
+ pub fn probe(sys: &Path, proc_dir: &Path) -> Hardware {
+ let mut hw = Hardware::default();
+ hw.read_dmi(sys);
+ hw.read_pci(sys);
+ hw.read_usb(sys);
+ hw.read_cpu(proc_dir);
+ hw.read_bound_drivers(sys);
+ hw
+ }
+
+ /// El caso normal: la máquina donde corre esto.
+ pub fn probe_local() -> Hardware {
+ Hardware::probe(Path::new("/sys"), Path::new("/proc"))
+ }
+
+ fn read_dmi(&mut self, sys: &Path) {
+ let dir = sys.join("class/dmi/id");
+ if !dir.is_dir() {
+ self.gaps.push("sin DMI (¿VM o /sys restringido?)".into());
+ return;
+ }
+ for f in DMI_FIELDS {
+ if let Ok(v) = std::fs::read_to_string(dir.join(f)) {
+ let v = v.trim();
+ if !v.is_empty() {
+ self.dmi.insert((*f).to_string(), v.to_string());
+ }
+ }
+ }
+ if self.dmi.is_empty() {
+ self.gaps.push("DMI presente pero ilegible".into());
+ }
+ }
+
+ fn read_pci(&mut self, sys: &Path) {
+ let dir = sys.join("bus/pci/devices");
+ let Ok(entries) = std::fs::read_dir(&dir) else {
+ self.gaps.push("sin bus PCI".into());
+ return;
+ };
+ let mut devs = Vec::new();
+ for e in entries.flatten() {
+ let p = e.path();
+ let slot = e.file_name().to_string_lossy().into_owned();
+ let g = |f: &str| read_trim(&p.join(f)).unwrap_or_default();
+ devs.push(PciDevice {
+ slot,
+ vendor: strip_0x(&g("vendor")),
+ device: strip_0x(&g("device")),
+ class: strip_0x(&g("class")),
+ driver: link_basename(&p.join("driver")),
+ });
+ }
+ devs.sort_by(|a, b| a.slot.cmp(&b.slot));
+ self.pci = devs;
+ }
+
+ fn read_usb(&mut self, sys: &Path) {
+ let dir = sys.join("bus/usb/devices");
+ let Ok(entries) = std::fs::read_dir(&dir) else {
+ return;
+ };
+ let mut devs = Vec::new();
+ for e in entries.flatten() {
+ let p = e.path();
+ // Las interfaces (`1-1:1.0`) no son dispositivos; sólo los nodos con idVendor.
+ let Some(vendor) = read_trim(&p.join("idVendor")) else {
+ continue;
+ };
+ devs.push(UsbDevice {
+ id: e.file_name().to_string_lossy().into_owned(),
+ vendor,
+ product: read_trim(&p.join("idProduct")).unwrap_or_default(),
+ driver: link_basename(&p.join("driver")),
+ });
+ }
+ devs.sort_by(|a, b| a.id.cmp(&b.id));
+ self.usb = devs;
+ }
+
+ fn read_cpu(&mut self, proc_dir: &Path) {
+ let Ok(text) = std::fs::read_to_string(proc_dir.join("cpuinfo")) else {
+ self.gaps.push("sin /proc/cpuinfo".into());
+ return;
+ };
+ for line in text.lines() {
+ let Some((k, v)) = line.split_once(':') else {
+ continue;
+ };
+ let (k, v) = (k.trim(), v.trim());
+ match k {
+ "model name" if self.cpu_model.is_none() => {
+ self.cpu_model = Some(v.to_string());
+ }
+ "flags" if self.cpu_flags.is_empty() => {
+ self.cpu_flags = v.split_whitespace().map(|s| s.to_string()).collect();
+ }
+ _ => {}
+ }
+ }
+ }
+
+ /// Un driver «en uso» es uno con al menos un dispositivo bindeado. Es el dato que necesita el
+ /// gate de no-regresión (#6): lo que hoy funciona tiene que seguir teniendo driver mañana.
+ fn read_bound_drivers(&mut self, sys: &Path) {
+ let Ok(buses) = std::fs::read_dir(sys.join("bus")) else {
+ self.gaps.push("sin /sys/bus".into());
+ return;
+ };
+ for bus in buses.flatten() {
+ let Ok(devs) = std::fs::read_dir(bus.path().join("devices")) else {
+ continue;
+ };
+ for d in devs.flatten() {
+ if let Some(drv) = link_basename(&d.path().join("driver")) {
+ self.bound_drivers.insert(drv);
+ }
+ }
+ }
+ }
+
+ /// Los bytes canónicos que se hashean. Se exponen para poder auditar una huella sin confiar en
+ /// que el hash se calculó sobre lo que uno cree.
+ pub fn fingerprint_material(&self) -> String {
+ let mut s = String::new();
+ for (k, v) in &self.dmi {
+ s.push_str(&format!("dmi\t{k}\t{v}\n"));
+ }
+ // Sin el slot: mover una tarjeta de ranura no cambia la clase de hardware.
+ let mut pci: Vec<String> = self
+ .pci
+ .iter()
+ .map(|d| format!("pci\t{}\t{}\t{}\n", d.vendor, d.device, d.class))
+ .collect();
+ pci.sort();
+ pci.dedup();
+ s.extend(pci);
+ for f in &self.cpu_flags {
+ s.push_str(&format!("cpuflag\t{f}\n"));
+ }
+ s
+ }
+
+ /// La huella. BLAKE3 sobre [`Hardware::fingerprint_material`], con el mismo tipo que cualquier
+ /// otro identificador por contenido de hammer.
+ pub fn fingerprint(&self) -> ArtifactHash {
+ ArtifactHash::of_bytes(self.fingerprint_material().as_bytes())
+ }
+
+ /// ¿Hay algún dispositivo PCI cuya clase empiece por `prefix`? (`"0280"` = red inalámbrica.)
+ pub fn has_pci_class(&self, prefix: &str) -> bool {
+ self.pci.iter().any(|d| d.class.starts_with(prefix))
+ }
+
+ pub fn has_driver(&self, name: &str) -> bool {
+ self.bound_drivers.contains(name)
+ }
+
+ pub fn has_cpu_flag(&self, flag: &str) -> bool {
+ self.cpu_flags.contains(flag)
+ }
+}
+
+fn read_trim(p: &Path) -> Option<String> {
+ let s = std::fs::read_to_string(p).ok()?;
+ let t = s.trim();
+ if t.is_empty() {
+ None
+ } else {
+ Some(t.to_string())
+ }
+}
+
+fn strip_0x(s: &str) -> String {
+ s.trim().trim_start_matches("0x").to_string()
+}
+
+fn link_basename(p: &Path) -> Option<String> {
+ let target: PathBuf = std::fs::read_link(p).ok()?;
+ Some(target.file_name()?.to_string_lossy().into_owned())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn arbol_de_juguete() -> tempfile::TempDir {
+ let d = tempfile::tempdir().unwrap();
+ let sys = d.path().join("sys");
+ std::fs::create_dir_all(sys.join("class/dmi/id")).unwrap();
+ std::fs::write(sys.join("class/dmi/id/sys_vendor"), "LENOVO\n").unwrap();
+ std::fs::write(sys.join("class/dmi/id/product_name"), "20XW\n").unwrap();
+ // Un serial NO debe entrar aunque exista: la huella agrupa, no identifica.
+ std::fs::write(sys.join("class/dmi/id/product_serial"), "PF3ABCDE\n").unwrap();
+
+ let dev = sys.join("bus/pci/devices/0000:00:14.3");
+ std::fs::create_dir_all(&dev).unwrap();
+ std::fs::write(dev.join("vendor"), "0x8086\n").unwrap();
+ std::fs::write(dev.join("device"), "0xa0f0\n").unwrap();
+ std::fs::write(dev.join("class"), "0x028000\n").unwrap();
+ let drv = sys.join("bus/pci/drivers/iwlwifi");
+ std::fs::create_dir_all(&drv).unwrap();
+ std::os::unix::fs::symlink(&drv, dev.join("driver")).unwrap();
+
+ let proc_dir = d.path().join("proc");
+ std::fs::create_dir_all(&proc_dir).unwrap();
+ std::fs::write(
+ proc_dir.join("cpuinfo"),
+ "model name\t: Intel(R) Core(TM) i7-1165G7\nflags\t\t: fpu vme avx2 aes\n",
+ )
+ .unwrap();
+ d
+ }
+
+ #[test]
+ fn lee_dmi_pci_cpu_y_driver_bindeado() {
+ let d = arbol_de_juguete();
+ let hw = Hardware::probe(&d.path().join("sys"), &d.path().join("proc"));
+ assert_eq!(hw.dmi.get("sys_vendor").map(|s| s.as_str()), Some("LENOVO"));
+ assert_eq!(hw.pci.len(), 1);
+ assert_eq!(hw.pci[0].class, "028000");
+ assert_eq!(hw.pci[0].driver.as_deref(), Some("iwlwifi"));
+ assert!(hw.has_pci_class("0280"));
+ assert!(hw.has_driver("iwlwifi"));
+ assert!(hw.has_cpu_flag("avx2"));
+ assert_eq!(hw.cpu_model.as_deref(), Some("Intel(R) Core(TM) i7-1165G7"));
+ }
+
+ #[test]
+ fn el_serial_no_entra_en_la_huella() {
+ let d = arbol_de_juguete();
+ let hw = Hardware::probe(&d.path().join("sys"), &d.path().join("proc"));
+ let m = hw.fingerprint_material();
+ assert!(!m.contains("PF3ABCDE"), "la huella agrupa máquinas, no las identifica:\n{m}");
+ assert!(m.contains("LENOVO"));
+ }
+
+ #[test]
+ fn mover_la_tarjeta_de_ranura_no_cambia_la_huella() {
+ let d = arbol_de_juguete();
+ let sys = d.path().join("sys");
+ let hw1 = Hardware::probe(&sys, &d.path().join("proc"));
+ // Mismo dispositivo, otra ranura.
+ let viejo = sys.join("bus/pci/devices/0000:00:14.3");
+ let nuevo = sys.join("bus/pci/devices/0000:03:00.0");
+ std::fs::rename(&viejo, &nuevo).unwrap();
+ let hw2 = Hardware::probe(&sys, &d.path().join("proc"));
+ assert_eq!(hw1.fingerprint(), hw2.fingerprint());
+ }
+
+ #[test]
+ fn el_usb_no_mueve_la_huella() {
+ let d = arbol_de_juguete();
+ let sys = d.path().join("sys");
+ let hw1 = Hardware::probe(&sys, &d.path().join("proc"));
+ let usb = sys.join("bus/usb/devices/1-1");
+ std::fs::create_dir_all(&usb).unwrap();
+ std::fs::write(usb.join("idVendor"), "0781\n").unwrap();
+ std::fs::write(usb.join("idProduct"), "5581\n").unwrap();
+ let hw2 = Hardware::probe(&sys, &d.path().join("proc"));
+ assert_eq!(hw2.usb.len(), 1, "se lee");
+ assert_eq!(hw1.fingerprint(), hw2.fingerprint(), "pero no se hashea");
+ }
+
+ #[test]
+ fn maquina_sin_dmi_deja_hueco_en_vez_de_fallar() {
+ let d = tempfile::tempdir().unwrap();
+ let hw = Hardware::probe(&d.path().join("sys"), &d.path().join("proc"));
+ assert!(!hw.gaps.is_empty());
+ // Y aun así da una huella: incompleta, pero declarada.
+ assert!(!hw.fingerprint().as_str().is_empty());
+ }
+}
diff --git a/crates/hammer-core/src/kernel/mod.rs b/crates/hammer-core/src/kernel/mod.rs
index c6873fa8..d4217473 100644
--- a/crates/hammer-core/src/kernel/mod.rs
+++ b/crates/hammer-core/src/kernel/mod.rs
@@ -16,8 +16,14 @@
//! (`scripts/config -e/-d`) y deja que el `olddefconfig` del propio kernel produzca el `.config`.
//! Ver [`kconfig`].
+pub mod catalog;
pub mod config;
+pub mod hw;
pub mod kconfig;
+pub mod reverse;
+pub use catalog::{Bundle, Catalog, Knob, Side};
pub use config::{ConfigValue, KernelConfig};
+pub use hw::Hardware;
pub use kconfig::{Expr, KconfigTree, SelectLeak, SymKind, Symbol};
+pub use reverse::{BundleState, HwSignal, ReverseReport};
diff --git a/crates/hammer-core/src/kernel/reverse.rs b/crates/hammer-core/src/kernel/reverse.rs
new file mode 100644
index 00000000..1dea1bfb
--- /dev/null
+++ b/crates/hammer-core/src/kernel/reverse.rs
@@ -0,0 +1,328 @@
+//! **Modo reversa** (#9 del handoff): leer el kernel que ya corre a través del lente de bundles.
+//!
+//! Es el primer movimiento por una razón concreta: no compila nada, no arriesga nada, y **valida el
+//! catálogo contra la realidad antes de que exista el compilador**. Si el modo reversa muestra
+//! basura, el diseño de N1 está mal y se sabe por el precio de dos días en vez de dos meses.
+//!
+//! Da tres cosas que ninguna lista de símbolos da sola:
+//!
+//! 1. **Cuánto de cada bundle ya está aplicado** en el kernel que arrancó esta máquina.
+//! 2. **Si el hardware lo contradice** — y sólo en ese sentido (ver [`super::hw`]).
+//! 3. **Si el catálogo envejeció**: cada `select` que entra a la clausura y no está declarado en el
+//! bundle es un símbolo que upstream agregó y nadie revisó. Es la mitad barata de la curación
+//! del delta (§3 del handoff), y sale de comparar el grafo con el catálogo, sin IA.
+
+use std::collections::BTreeSet;
+
+use serde::{Deserialize, Serialize};
+
+use super::catalog::{Bundle, Catalog};
+use super::hw::Hardware;
+use super::kconfig::{KconfigTree, SelectLeak};
+use super::{ConfigValue, KernelConfig};
+
+/// Cuánto de un bundle está ya en efecto en el `.config` mirado.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum BundleState {
+ /// Ni un símbolo de la clausura está encendido: el bundle ya rige.
+ Aplicado,
+ /// Algunos sí, algunos no.
+ Parcial,
+ /// Todo encendido: el kernel trae el subsistema entero.
+ Sin,
+}
+
+/// Qué dice el hardware sobre un bundle. **Sólo contradice, nunca poda.**
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case", tag = "señal")]
+pub enum HwSignal {
+ /// El bundle no declara ninguna señal: no se puede decir nada.
+ SinSeñal,
+ /// Hay hardware presente que este bundle apagaría. Aplicarlo es una decisión, no un descuido.
+ Presente { evidencia: Vec<String> },
+ /// Ninguna de las señales declaradas está presente.
+ Ausente,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BundleStatus {
+ pub id: String,
+ pub title: String,
+ /// Tamaño de la clausura del bundle en ESTE árbol de Kconfig.
+ pub closure_len: usize,
+ /// De la clausura, cuántos están `=y` en el config mirado.
+ pub on_builtin: usize,
+ /// …y cuántos `=m`.
+ pub on_module: usize,
+ pub state: BundleState,
+ pub hardware: HwSignal,
+ /// Símbolos que el bundle nombra y **no existen en este árbol**. Hoy un `-d` sobre uno de éstos
+ /// se pierde en silencio; acá se ve.
+ pub unknown_symbols: Vec<String>,
+ /// Fugas `select` que el catálogo no declara (ni cierra ni acepta): el bundle envejeció.
+ pub undeclared_leaks: Vec<SelectLeak>,
+}
+
+/// El informe entero del modo reversa.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ReverseReport {
+ /// Banner del `.config` leído, si lo trae.
+ pub kernel: Option<String>,
+ pub fingerprint: String,
+ pub config_symbols: usize,
+ pub bundles: Vec<BundleStatus>,
+ /// Bundles con `state != Aplicado` cuyo hardware está **ausente**: capacidad que este kernel
+ /// carga y esta máquina no usa. Es la frase del handoff §9 vuelta número.
+ pub applicable_savings: usize,
+ /// Bundles ya aplicados cuyo hardware SÍ está presente: la contradicción que hay que mirar.
+ pub contradictions: usize,
+}
+
+/// Corre el modo reversa. `hw` opcional: sin hardware el informe sigue valiendo, sólo que toda
+/// señal queda en `SinSeñal`.
+pub fn analyze(
+ catalog: &Catalog,
+ tree: &KconfigTree,
+ config: &KernelConfig,
+ hw: Option<&Hardware>,
+) -> ReverseReport {
+ let mut bundles = Vec::new();
+ for b in &catalog.bundles {
+ bundles.push(status_of(b, tree, config, hw));
+ }
+ let applicable_savings = bundles
+ .iter()
+ .filter(|s| s.state != BundleState::Aplicado && s.hardware == HwSignal::Ausente)
+ .count();
+ let contradictions = bundles
+ .iter()
+ .filter(|s| s.state == BundleState::Aplicado && matches!(s.hardware, HwSignal::Presente { .. }))
+ .count();
+ ReverseReport {
+ kernel: config.banner.clone(),
+ fingerprint: hw.map(|h| h.fingerprint().to_string()).unwrap_or_default(),
+ config_symbols: config.len(),
+ bundles,
+ applicable_savings,
+ contradictions,
+ }
+}
+
+fn status_of(
+ b: &Bundle,
+ tree: &KconfigTree,
+ config: &KernelConfig,
+ hw: Option<&Hardware>,
+) -> BundleStatus {
+ // Las raíces del bundle son lo que apaga MÁS las fugas que declaró cerrar.
+ let mut roots: Vec<String> = b.disable.clone();
+ roots.extend(b.close_leaks.iter().cloned());
+
+ let unknown_symbols: Vec<String> = roots
+ .iter()
+ .chain(b.enable.iter())
+ .filter(|s| !tree.symbols.contains_key(*s))
+ .cloned()
+ .collect();
+
+ let closure = tree.closure_off(&roots);
+ let mut on_builtin = 0usize;
+ let mut on_module = 0usize;
+ for s in &closure {
+ match config.get(s) {
+ Some(ConfigValue::Yes) => on_builtin += 1,
+ Some(ConfigValue::Module) => on_module += 1,
+ _ => {}
+ }
+ }
+ let on = on_builtin + on_module;
+ let state = if on == 0 {
+ BundleState::Aplicado
+ } else if on < closure.len() {
+ BundleState::Parcial
+ } else {
+ BundleState::Sin
+ };
+
+ // Fugas que el catálogo no contempla: ni las cerró (estarían dentro de la clausura) ni las
+ // aceptó por escrito.
+ let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
+ let undeclared_leaks: Vec<SelectLeak> = tree
+ .select_leaks(&closure)
+ .into_iter()
+ .filter(|l| !aceptadas.contains(l.target.as_str()))
+ .collect();
+
+ BundleStatus {
+ id: b.id.clone(),
+ title: b.title.clone(),
+ closure_len: closure.len(),
+ on_builtin,
+ on_module,
+ state,
+ hardware: hw_signal(b, hw),
+ unknown_symbols,
+ undeclared_leaks,
+ }
+}
+
+fn hw_signal(b: &Bundle, hw: Option<&Hardware>) -> HwSignal {
+ let c = &b.contradicted_by;
+ if c.is_empty() {
+ return HwSignal::SinSeñal;
+ }
+ let Some(hw) = hw else {
+ return HwSignal::SinSeñal;
+ };
+ let mut ev = Vec::new();
+ for p in &c.pci_class {
+ for d in hw.pci.iter().filter(|d| d.class.starts_with(p)) {
+ ev.push(format!(
+ "pci {}:{} clase {} ({})",
+ d.vendor,
+ d.device,
+ d.class,
+ d.driver.as_deref().unwrap_or("sin driver")
+ ));
+ }
+ }
+ for d in &c.driver {
+ if hw.has_driver(d) {
+ ev.push(format!("driver {d} con dispositivo bindeado"));
+ }
+ }
+ for f in &c.cpu_flag {
+ if hw.has_cpu_flag(f) {
+ ev.push(format!("cpu flag {f}"));
+ }
+ }
+ if ev.is_empty() {
+ HwSignal::Ausente
+ } else {
+ HwSignal::Presente { evidencia: ev }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeMap;
+
+ fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog) {
+ let d = tempfile::tempdir().unwrap();
+ std::fs::write(
+ d.path().join("Kconfig"),
+ r#"
+config WIRELESS
+ bool "Wireless"
+
+config CFG80211
+ tristate "cfg80211"
+ depends on WIRELESS
+
+config WLAN
+ bool "WLAN"
+ select WIRELESS
+
+config SOUND
+ bool "Sound"
+"#,
+ )
+ .unwrap();
+ let tree = KconfigTree::parse(d.path(), &BTreeMap::new()).unwrap();
+ let cat: Catalog = toml::from_str(
+ r#"
+version = 1
+[[bundle]]
+id = "sin-wifi"
+title = "No necesito wifi"
+disable = ["WIRELESS"]
+close_leaks = ["WLAN"]
+[bundle.contradicted_by]
+pci_class = ["0280"]
+
+[[bundle]]
+id = "sin-audio"
+title = "No necesito audio"
+disable = ["SOUND", "SND_QUE_NO_EXISTE"]
+"#,
+ )
+ .unwrap();
+ (d, tree, cat)
+ }
+
+ #[test]
+ fn cuenta_lo_aplicado_y_lo_que_falta() {
+ let (_d, tree, cat) = escenario();
+ let cfg = KernelConfig::parse(
+ "CONFIG_WIRELESS=y\nCONFIG_CFG80211=m\nCONFIG_WLAN=y\n# CONFIG_SOUND is not set\n",
+ );
+ let r = analyze(&cat, &tree, &cfg, None);
+ let wifi = &r.bundles[0];
+ assert_eq!(wifi.closure_len, 3, "WIRELESS + CFG80211 + WLAN");
+ assert_eq!(wifi.on_builtin, 2);
+ assert_eq!(wifi.on_module, 1);
+ assert_eq!(wifi.state, BundleState::Sin);
+
+ let audio = &r.bundles[1];
+ assert_eq!(audio.state, BundleState::Aplicado);
+ // El símbolo inventado se ve en vez de perderse en silencio.
+ assert_eq!(audio.unknown_symbols, vec!["SND_QUE_NO_EXISTE"]);
+ }
+
+ #[test]
+ fn el_hardware_contradice_pero_no_poda() {
+ let (_d, tree, cat) = escenario();
+ let cfg = KernelConfig::parse("# CONFIG_WIRELESS is not set\n# CONFIG_WLAN is not set\n");
+ let mut hw = Hardware::default();
+ hw.pci.push(super::super::hw::PciDevice {
+ slot: "0000:00:14.3".into(),
+ vendor: "8086".into(),
+ device: "a0f0".into(),
+ class: "028000".into(),
+ driver: Some("iwlwifi".into()),
+ });
+ let r = analyze(&cat, &tree, &cfg, Some(&hw));
+ let wifi = &r.bundles[0];
+ assert_eq!(wifi.state, BundleState::Aplicado);
+ assert!(matches!(wifi.hardware, HwSignal::Presente { .. }));
+ // Aplicado + hardware presente = la contradicción que hay que mirar, no un error.
+ assert_eq!(r.contradictions, 1);
+ }
+
+ #[test]
+ fn el_ahorro_es_bundle_no_aplicado_con_hardware_ausente() {
+ let (_d, tree, cat) = escenario();
+ let cfg = KernelConfig::parse("CONFIG_WIRELESS=y\nCONFIG_CFG80211=y\nCONFIG_WLAN=y\n");
+ let hw = Hardware::default(); // máquina sin PCI wifi
+ let r = analyze(&cat, &tree, &cfg, Some(&hw));
+ assert_eq!(r.bundles[0].hardware, HwSignal::Ausente);
+ assert_eq!(r.applicable_savings, 1);
+ }
+
+ #[test]
+ fn una_fuga_select_no_declarada_se_reporta() {
+ let (_d, tree, _cat) = escenario();
+ // Un catálogo que NO cierra la fuga WLAN→WIRELESS: es el bundle envejecido.
+ let cat: Catalog = toml::from_str(
+ r#"
+version = 1
+[[bundle]]
+id = "sin-wifi"
+title = "No necesito wifi"
+disable = ["WIRELESS"]
+"#,
+ )
+ .unwrap();
+ let cfg = KernelConfig::parse("CONFIG_WIRELESS=y\n");
+ let r = analyze(&cat, &tree, &cfg, None);
+ assert_eq!(
+ r.bundles[0].undeclared_leaks,
+ vec![SelectLeak {
+ selector: "WLAN".into(),
+ target: "WIRELESS".into()
+ }]
+ );
+ }
+}
diff --git a/docs/state/kernel-bundles.toml b/docs/state/kernel-bundles.toml
new file mode 100644
index 00000000..191a021c
--- /dev/null
+++ b/docs/state/kernel-bundles.toml
@@ -0,0 +1,255 @@
+# Catálogo del armador de kernel (SDD 22) — bundles de nivel 1 y perillas de nivel 2.
+#
+# UN BUNDLE NO ES UNA LISTA DE SÍMBOLOS. Es una raíz y su clausura sobre el grafo de Kconfig:
+# `disable = ["WIRELESS"]` son ~400 CONFIG_* que hammer calcula, no que alguien mantiene a mano.
+# Por eso se auto-cura: el driver wifi que entre en 6.17 cae dentro sin que nadie lo toque.
+#
+# LO QUE LA CLAUSURA NO CIERRA SOLA. `select` fuerza su destino IGNORANDO los `depends on` de éste,
+# así que un símbolo de fuera del bundle puede reencender lo que el bundle apagó. Cada fuga se
+# declara, una vez, de una de dos formas:
+# · `close_leaks` — se apaga también al que la provoca.
+# · `accept_leaks` — se deja abierta A SABIENDAS, con el motivo escrito.
+# `hammer kernel bundles --check` falla si aparece una fuga que no esté en ninguna de las dos: eso
+# es el catálogo envejeciendo, y es la mitad barata de la curación del delta entre versiones.
+#
+# CONTRADICTED_BY VA EN UN SOLO SENTIDO. Sirve para decirle al usuario «marcaste "no necesito wifi"
+# y tenés un AX211 activo ahora mismo». NUNCA para podar el config solo: no se puede detectar el
+# dock que se enchufa el mes que viene ni el fs del USB de rescate, y un kernel podado por
+# autodetección es cómo se fabrica un ladrillo.
+#
+# Regenerar los números / revisar las fugas:
+# hammer kernel bundles --kconfig <árbol> --check
+# hammer kernel closure --kconfig <árbol> WIRELESS --fixpoint
+
+version = 1
+reviewed_against = "linux-6.16.12"
+
+# =================================================================================================
+# NIVEL 1 — aserciones del dueño de la máquina. No son categorías de Kconfig: son decisiones de
+# vida. «No necesito wifi», «esta máquina nunca tuvo batería».
+# =================================================================================================
+
+[[bundle]]
+id = "sin-wifi"
+title = "No necesito wifi"
+help = """
+Apaga el stack 802.11 entero: cfg80211, mac80211 y todos los drivers de tarjeta. La máquina sigue
+teniendo ethernet. NO apaga el interruptor de radio (RFKILL): eso es «sin-radios», porque lo
+comparten bluetooth y NFC."""
+disable = ["WIRELESS"]
+# WLAN no DEPENDE de WIRELESS: lo SELECCIONA. Sin cerrarlo, apagar WIRELESS es un no-op — el
+# olddefconfig lo reenciende y no lo dice. Fue el hallazgo que validó el diseño (SDD 22 §9).
+close_leaks = ["WLAN", "IWLEGACY", "GELIC_WIRELESS"]
+notes = "406 símbolos en 6.16.12; el bundle a mano de recipes/linux.toml da 421, y los 15 de diferencia son todos de sin-radios."
+[bundle.contradicted_by]
+pci_class = ["0280"]
+
+[[bundle]]
+id = "sin-bluetooth"
+title = "No necesito bluetooth"
+disable = ["BT"]
+# Tarjeta combo wifi+BT: entra por select desde el lado wifi.
+close_leaks = ["RSI_91X"]
+[bundle.contradicted_by]
+driver = ["btusb", "btintel", "btrtl"]
+
+[[bundle]]
+id = "sin-nfc"
+title = "No necesito NFC"
+disable = ["NFC"]
+
+[[bundle]]
+id = "sin-radios"
+title = "No necesito el interruptor de radio (rfkill)"
+help = """
+RFKILL es el conmutador compartido de todas las radios: wifi, bluetooth y NFC lo declaran igual.
+Apagarlo sólo tiene sentido si ninguna de las tres se usa — y se lleva por delante los botones de
+radio de portátil (Dell, Lenovo, MSI…), que dependen de él."""
+disable = ["RFKILL"]
+notes = "Es un bundle propio, no parte de sin-wifi: el config a mano de linux.toml los escribía en la misma línea y por eso parecían uno solo."
+
+[[bundle]]
+id = "sin-audio"
+title = "No necesito audio"
+help = "Apaga ALSA y todo el stack de sonido, incluido el códec HDMI."
+disable = ["SOUND"]
+notes = """
+No cierra: los drivers de GPU hacen `select` de los helpers de audio HDMI. Cerrarlo obligaría a
+apagar la GPU, que es un precio que un escritorio no puede pagar — así que las fugas se aceptan y
+quedan escritas. En recipes/linux.toml sale gratis sólo porque ahí los gráficos TAMBIÉN están
+apagados."""
+[bundle.accept_leaks]
+SND_HDA_COMPONENT = "lo selecciona DRM_AMD_DC / DRM_NOUVEAU / DRM_RADEON: cerrarlo = quedarse sin GPU"
+SND_HDA_I915 = "lo seleccionan DRM_I915 y DRM_XE: ídem para gráficos Intel"
+SND_SOC_HDMI_CODEC = "lo seleccionan diez drivers DRM (casi todos SoC ARM, inertes en x86_64)"
+SND_AUDIO_GRAPH_CARD = "DRM_TEGRA, inerte en x86_64"
+SND_SIMPLE_CARD = "DRM_TEGRA, inerte en x86_64"
+SND_UMP = "USB_F_MIDI2, gadget USB: sólo entra si la máquina hace de dispositivo USB"
+SND_UMP_LEGACY_RAWMIDI = "USB_F_MIDI2, ídem"
+[bundle.contradicted_by]
+pci_class = ["0403"]
+driver = ["snd_hda_intel"]
+
+[[bundle]]
+id = "sin-graficos"
+title = "No necesito salida gráfica"
+help = """
+Kernel de servidor o de consola serie: sin DRM, sin framebuffer, sin retroiluminación. Si esta
+máquina tiene pantalla, este bundle la deja negra — es la aserción más peligrosa del catálogo."""
+disable = ["DRM", "FB", "AGP", "BACKLIGHT_CLASS_DEVICE"]
+# Framebuffers de PowerMac y de SoC ARM: no existen en x86_64, cerrarlos no cuesta nada.
+close_leaks = [
+ "FB_CONTROL", "FB_IMSTT", "FB_MAC", "FB_PLATINUM", "FB_VALKYRIE",
+ "FB_VT8500", "FB_WM8505", "PMAC_BACKLIGHT",
+]
+[bundle.accept_leaks]
+DRM_AUX_BRIDGE = "lo seleccionan los muxes USB-C (TYPEC_MUX_*): apagarlos rompería el USB-C de un portátil real"
+DRM_AUX_HPD_BRIDGE = "ídem, TYPEC_QCOM_PMIC / UCSI_HUAWEI_GAOKUN"
+BACKLIGHT_CLASS_DEVICE = "lo seleccionan drivers de plataforma (ACPI_CMPC, SAMSUNG_Q10, USB_APPLEDISPLAY)"
+DRM_DW_HDMI_I2S_AUDIO = "SND_SOC_RK3288_HDMI_ANALOG, SoC Rockchip: inerte en x86_64"
+[bundle.contradicted_by]
+pci_class = ["0300", "0380"]
+
+[[bundle]]
+id = "solo-ext4"
+title = "Sólo uso ext4"
+help = "Apaga los demás sistemas de ficheros locales. Ojo con el USB de rescate: lo que no está no se monta."
+disable = ["XFS_FS", "BTRFS_FS", "F2FS_FS", "JFS_FS", "GFS2_FS", "NTFS3_FS"]
+close_leaks = ["NTFS_FS"]
+notes = "REISERFS_FS NO va acá: upstream lo retiró y 6.16.12 ya no lo tiene. recipes/linux.toml todavía lo apaga, y ese `-d` es un no-op silencioso."
+
+[[bundle]]
+id = "sin-usb"
+title = "No necesito USB"
+help = "Kernel de VM con consola serie o de servidor sin puertos accesibles. En metal deja la máquina sin teclado."
+disable = ["USB_SUPPORT"]
+[bundle.contradicted_by]
+driver = ["usbhid", "usb_storage", "xhci_hcd"]
+
+[[bundle]]
+id = "sin-entrada-humana"
+title = "No necesito teclado ni ratón"
+help = """
+La aserción de un kernel de QEMU con consola serie: nadie va a tocar esta máquina. Es exactamente
+lo que hace recipes/linux.toml, y por eso el gate de no-regresión tiene que ser POR OBJETIVO — un
+gate global rechazaría esa receta, que está sana."""
+disable = ["HID_SUPPORT", "INPUT_MOUSE", "INPUT_JOYSTICK", "INPUT_TABLET", "INPUT_TOUCHSCREEN"]
+[bundle.contradicted_by]
+driver = ["usbhid", "hid_generic", "i8042"]
+
+[[bundle]]
+id = "sin-camaras-tv"
+title = "No necesito cámara ni sintonizador"
+disable = ["MEDIA_SUPPORT"]
+close_leaks = ["USB_F_UVC"]
+[bundle.accept_leaks]
+VIDEOBUF2_VMALLOC = "lo seleccionan pantallas táctiles (RMI4_F54, TOUCHSCREEN_ATMEL_MXT_T37) para sus modos de diagnóstico: decir «no quiero cámara» no debe matar el táctil"
+[bundle.contradicted_by]
+driver = ["uvcvideo"]
+
+[[bundle]]
+id = "sin-infiniband"
+title = "No necesito InfiniBand / RDMA"
+disable = ["INFINIBAND"]
+
+[[bundle]]
+id = "sin-bus-industrial"
+title = "No necesito buses industriales (CAN)"
+disable = ["CAN"]
+
+[[bundle]]
+id = "sin-thunderbolt-firewire"
+title = "No necesito Thunderbolt ni FireWire"
+help = "El símbolo se llama USB4 desde que upstream fundió Thunderbolt y USB4 en un solo driver."
+disable = ["USB4", "FIREWIRE"]
+notes = "Las CUATRO recetas de kernel apagan `THUNDERBOLT`, que 6.16.12 ya no tiene: ese `-d` es un no-op y el driver sigue entrando por el defconfig."
+[bundle.contradicted_by]
+driver = ["thunderbolt"]
+
+[[bundle]]
+id = "sin-lector-de-tarjetas"
+title = "No necesito lector de tarjetas SD/MMC"
+disable = ["MMC", "MEMSTICK"]
+[bundle.contradicted_by]
+driver = ["sdhci_pci", "rtsx_pci_sdmmc"]
+
+[[bundle]]
+id = "sin-virtualizacion"
+title = "No voy a virtualizar en esta máquina"
+help = "Apaga KVM. No afecta a correr DENTRO de una VM — sólo a hospedar una."
+disable = ["VIRTUALIZATION"]
+[bundle.contradicted_by]
+cpu_flag = ["vmx", "svm"]
+
+# =================================================================================================
+# NIVEL 2 — política y rendimiento. El campo `side` NO es decorativo: la mitad de N2 no es Kconfig
+# sino variables de receta. Las dos mitades mueven el ArtifactHash igual, pero se aplican en fases
+# distintas y fallan distinto; una UI que no lo sepa prometerá diffs que no puede explicar.
+# =================================================================================================
+
+[[knob]]
+id = "jaula-y-eio-moderna"
+title = "Jaula (Landlock + audit) y E/S moderna (io_uring, BPF)"
+help = """
+El contrato de PLAN-KIKIN §4.bis. Sin SECURITY_LANDLOCK+AUDIT, harkaq (SDD 16) certifica TODO como
+hermético en silencio: sin auditoría no se emite un solo registro de denegación, y la evidencia
+negativa se vuelve vacía."""
+side = "kconfig"
+enable = ["SECURITY_LANDLOCK", "AUDIT", "IO_URING", "BPF_SYSCALL"]
+notes = "linux-metal y linux-generic ya los declaran; linux (el de QEMU) deja IO_URING y BPF_SYSCALL al defconfig."
+
+[[knob]]
+id = "sin-info-de-depuracion"
+title = "Kernel sin información de depuración"
+help = "Baja mucho el tamaño de la imagen. DEBUG_INFO_BTF además exige pahole, que el toolchain de hammer no trae."
+side = "kconfig"
+disable = ["DEBUG_INFO", "DEBUG_INFO_BTF"]
+
+[[knob]]
+id = "unwinder-frame-pointer"
+title = "Desenrollado por frame pointer en vez de ORC"
+help = """
+Reduce el trabajo de objtool, pero NO lo elimina: en x86_64 las mitigaciones (retpoline,
+RETHUNK) seleccionan CONFIG_OBJTOOL igual, y objtool necesita libelf."""
+side = "kconfig"
+enable = ["UNWINDER_FRAME_POINTER"]
+disable = ["UNWINDER_ORC"]
+
+[[knob]]
+id = "preempcion-escritorio"
+title = "Preempción de escritorio (menos latencia, algo menos de rendimiento bruto)"
+side = "kconfig"
+enable = ["PREEMPT"]
+
+[[knob]]
+id = "tick-1000"
+title = "Reloj del sistema a 1000 Hz"
+side = "kconfig"
+enable = ["HZ_1000"]
+disable = ["HZ_250"]
+
+[[knob]]
+id = "compilador-lto"
+title = "LTO (optimización al enlazar)"
+help = """
+EL EJEMPLO DE PERILLA MIXTA. Su mitad Kconfig (LTO_CLANG_FULL) sólo se vuelve seleccionable si el
+kernel se compila con clang, que es una decisión de RECETA. Hoy recipes/linux.toml compila con gcc
+por decisión formal (la capa de traducción de flags de `zig cc` choca con el kbuild de x86_64), así
+que esta perilla está declarada y NO es aplicable todavía."""
+side = "recipe"
+recipe_field = "build.compiler + build.phases.compile (make LLVM=1)"
+notes = "Ver la cabecera de recipes/linux.toml: el kernel se queda en el gueto gcc hasta que se exponga el clang que zig empaqueta."
+
+[[knob]]
+id = "march-nativo"
+title = "Compilar para esta CPU exacta"
+help = "Mata la reproducibilidad ENTRE máquinas distintas, no la de una misma máquina: el artefacto sigue siendo bit a bit el mismo para la misma huella."
+side = "recipe"
+recipe_field = "build.flags"
+
+[[knob]]
+id = "debug-aparte"
+title = "Separar los símbolos de depuración del artefacto"
+side = "recipe"
+recipe_field = "build.strip_debug"
+notes = "SDD 23. Entra en hash_inputs sólo si está fijado, así que se puede activar receta a receta."