gioser

sergio/takana

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

TOML 188Rust 55Shell 30Markdown 281211 Definiciones

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>

cf2cf54914e5917613be71ffb49109894c1791c7 · por Sergio · (hace 2 meses) · padre 39873fd86 · ver el árbol en este commit

Delta semántico · 73

crates/hammer-cli/src/kernel_cmd.rs

crates/hammer-core/src/kernel/catalog.rs

crates/hammer-core/src/kernel/hw.rs

crates/hammer-core/src/kernel/mod.rs

crates/hammer-core/src/kernel/reverse.rs

6 archivos · +1640 −0

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."