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: plan (receta derivada) y diff-back — dos hashes para el mismo código fuente

Paso 4 del §8 del SDD 22, y el corazón del armador.

hammer kernel plan --recipe recipes/linux.toml --bundle sin-wifi --bundle sin-audio
--bundle solo-ext4 --knob jaula-y-eio-moderna
  base      b3:cb926743…
  derivada  b3:47a52b2e…   (16 banderas, 1775 símbolos con su clausura)

El config vive en la fase `configure` y las fases entran en hash_inputs ⇒ el config ES la
identidad del artefacto. Por eso el plan emite una RECETA DERIVADA y no finge que el
kernel sea un binario parametrizable. Y como el plan DETERMINA el artefacto, el JSON lleva
su ArtifactHash: la UI puede decir "esto ya está construido y firmado" sin construir nada.

Tres decisiones que no eran obvias:
  · Se emiten RAÍCES, no clausuras: 16 banderas, no 1775 líneas. La clausura la calcula el
    olddefconfig del propio kernel. hammer la sabe sólo para poder explicarla — la app
    nunca escribe un .config.
  · La fase derivada AÑADE una segunda ronda (…&& scripts/config … && make olddefconfig)
    en vez de reescribir la base: no hay que parsear el shell de nadie, olddefconfig es
    idempotente, y la base sigue siendo literalmente la de siempre en el diff.
  · Los conflictos se RECHAZAN, no se ordenan. Resolver por orden de aparición sería una
    respuesta plausible y arbitraria. Y hay un segundo conflicto que el símbolo solo no
    delata: encender algo que cae DENTRO de la clausura de lo que otro bundle apaga —
    olddefconfig lo descartaría sin decir nada.

diff-back: la mitad que faltaba del §6 del handoff. Clasifica cada símbolo pedido en
cumplido / INCUMPLIDO (el .config dice otra cosa) / ausente (el kernel ni lo menciona: la
bandera fue un no-op), con la procedencia de quién lo pidió, y sale != 0 si el config no
honra el plan. Probado contra /proc/config.gz de gioser: 3 incumplidos, 1 ausente.

La procedencia por símbolo (#7 del handoff) sale de regalo: cada bandera carga quién la
pidió y por qué (raíz del bundle / fuga select cerrada / perilla).

Y el orden del fragmento es estable a propósito: ese texto entra al hash, así que un orden
que dependiera del recorrido daría dos hashes para el mismo plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

02991dd9d85898c24f1cf52eb27bf7dfdf0f21f7 · por Sergio · (hace 2 meses) · padre cf2cf5491 · ver el árbol en este commit

Delta semántico · 31

crates/hammer-cli/src/kernel_cmd.rs

crates/hammer-cli/src/main.rs

  • cambió por dentro fn main

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

  • nueva mod plan pub

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

5 archivos · +821 −2

diff --git a/crates/hammer-cli/src/kernel_cmd.rs b/crates/hammer-cli/src/kernel_cmd.rs
index b1b2254b..9b2651b8 100644
--- a/crates/hammer-cli/src/kernel_cmd.rs
+++ b/crates/hammer-cli/src/kernel_cmd.rs
@@ -84,6 +84,48 @@ pub enum KernelCmd {
         #[arg(long)]
         json: bool,
     },
+    /// De una selección de bundles a una **receta derivada**, sin construir nada.
+    ///
+    /// El config del kernel vive en la fase `configure` y las fases entran en `hash_inputs` ⇒ el
+    /// config ES la identidad del artefacto. Por eso esto emite una receta con su propio
+    /// `ArtifactHash`, y no finge que el kernel sea un binario parametrizable.
+    Plan {
+        /// Receta base (p.ej. `recipes/linux.toml`).
+        #[arg(long)]
+        recipe: PathBuf,
+        /// Id de bundle del catálogo. Repetible.
+        #[arg(long = "bundle")]
+        bundles: Vec<String>,
+        /// Id de perilla del catálogo. Repetible.
+        #[arg(long = "knob")]
+        knobs: Vec<String>,
+        #[arg(long)]
+        kconfig: Option<PathBuf>,
+        #[arg(long)]
+        catalog: Option<PathBuf>,
+        /// Escribe el plan en JSON (el contrato con las UIs).
+        #[arg(long)]
+        out: Option<PathBuf>,
+        /// Escribe la receta derivada. Para construirla hay que dejarla donde sus `deps.build`
+        /// resuelvan — es decir, junto a la receta base.
+        #[arg(long)]
+        recipe_out: Option<PathBuf>,
+        #[arg(long)]
+        json: bool,
+    },
+    /// **Diff-back**: contrasta un plan contra el `.config` que produjo el kernel.
+    ///
+    /// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
+    DiffBack {
+        /// El JSON que emitió `hammer kernel plan --out`.
+        #[arg(long)]
+        plan: PathBuf,
+        /// El `.config` resultante (el que la receta instala en `/out/boot/config-<versión>`).
+        #[arg(long)]
+        config: PathBuf,
+        #[arg(long)]
+        json: bool,
+    },
     /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
     Stats {
         #[arg(long)]
@@ -94,7 +136,7 @@ pub enum KernelCmd {
     },
 }
 
-pub fn run(cmd: KernelCmd) -> Result<()> {
+pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
     match cmd {
         KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
         KernelCmd::Probe {
@@ -116,6 +158,27 @@ pub fn run(cmd: KernelCmd) -> Result<()> {
             check,
             json,
         } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
+        KernelCmd::Plan {
+            recipe,
+            bundles: bs,
+            knobs,
+            kconfig,
+            catalog,
+            out,
+            recipe_out,
+            json,
+        } => plan(
+            &recipe,
+            &bs,
+            &knobs,
+            &kconfig_root(kconfig)?,
+            &catalog_path(catalog)?,
+            out.as_deref(),
+            recipe_out.as_deref(),
+            store,
+            json,
+        ),
+        KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
         KernelCmd::Closure {
             symbols,
             kconfig,
@@ -775,6 +838,165 @@ fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()>
     Ok(())
 }
 
+// ---------------------------------------------------------------------------------------------
+// plan / diff-back
+// ---------------------------------------------------------------------------------------------
+
+#[allow(clippy::too_many_arguments)]
+fn plan(
+    recipe_path: &Path,
+    bundle_ids: &[String],
+    knob_ids: &[String],
+    kroot: &Path,
+    catalog: &Path,
+    out: Option<&Path>,
+    recipe_out: Option<&Path>,
+    store: &str,
+    json: bool,
+) -> Result<()> {
+    if bundle_ids.is_empty() && knob_ids.is_empty() {
+        anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
+    }
+    let base = hammer_core::Recipe::load_from_path(recipe_path)?;
+    let tree = load_tree(kroot)?;
+    let cat = hammer_core::kernel::Catalog::load(catalog)?;
+
+    let mut p = hammer_core::kernel::plan::make_plan(
+        &base,
+        &recipe_path.display().to_string(),
+        &cat,
+        &tree,
+        bundle_ids,
+        knob_ids,
+    )?;
+
+    // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
+    // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
+    // dice que no se pudo en vez de emitir un hash inventado.
+    let derived = hammer_core::kernel::plan::derive_recipe(&base, &p);
+    match hammer_core::Store::open(Path::new(store))
+        .map_err(anyhow::Error::from)
+        .and_then(|s| hammer_build::artifact_hash(&derived, &s).map_err(anyhow::Error::from))
+    {
+        Ok(h) => p.artifact_hash = Some(h.to_string()),
+        Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
+    }
+
+    if let Some(o) = out {
+        if let Some(d) = o.parent() {
+            std::fs::create_dir_all(d).ok();
+        }
+        std::fs::write(o, serde_json::to_string_pretty(&p)?)
+            .with_context(|| format!("escribiendo {}", o.display()))?;
+    }
+    if let Some(ro) = recipe_out {
+        if let Some(d) = ro.parent() {
+            std::fs::create_dir_all(d).ok();
+        }
+        let cabecera = format!(
+            "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
+             #   base    {}\n#   bundles {}\n#   perillas {}\n\
+             # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
+             # resuelven ({}).\n",
+            p.base_recipe,
+            if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") },
+            if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") },
+            recipe_path
+                .parent()
+                .map(|d| d.display().to_string())
+                .unwrap_or_default(),
+        );
+        let cuerpo = hammer_core::kernel::plan::render_recipe_toml(&derived)?;
+        std::fs::write(ro, format!("{cabecera}{cuerpo}"))
+            .with_context(|| format!("escribiendo {}", ro.display()))?;
+    }
+
+    if json {
+        println!("{}", serde_json::to_string_pretty(&p)?);
+        return Ok(());
+    }
+
+    println!("base        {} ({})", p.base_name, p.base_recipe);
+    println!("derivada    {}", p.derived_name);
+    match &p.artifact_hash {
+        Some(h) => println!("hash        {h}"),
+        None => println!("hash        (no calculado)"),
+    }
+    println!(
+        "selección   {} bundle(s), {} perilla(s)",
+        p.bundles.len(),
+        p.knobs.len()
+    );
+    println!();
+    println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
+    println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
+    for s in &p.symbols {
+        let signo = match s.action {
+            hammer_core::kernel::plan::Action::Enable => "+",
+            hammer_core::kernel::plan::Action::Disable => "-",
+        };
+        println!("  {signo}{:<24} ← {}  ({})", s.symbol, s.from, s.reason);
+    }
+    if !p.unknown_symbols.is_empty() {
+        println!();
+        println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
+        for s in &p.unknown_symbols {
+            println!("  {} ← {}", s.symbol, s.from);
+        }
+    }
+    if !p.recipe_side.is_empty() {
+        println!();
+        println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
+        for k in &p.recipe_side {
+            println!("  {:<24} {} — {}", k.id, k.recipe_field, k.title);
+        }
+    }
+    println!();
+    if let Some(o) = out {
+        println!("plan     → {}", o.display());
+    }
+    if let Some(ro) = recipe_out {
+        println!("receta   → {}", ro.display());
+    }
+    Ok(())
+}
+
+fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
+    let p: hammer_core::kernel::Plan = serde_json::from_str(
+        &std::fs::read_to_string(plan_path)
+            .with_context(|| format!("leyendo {}", plan_path.display()))?,
+    )
+    .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
+    let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
+    if cfg.is_empty() {
+        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
+    }
+    let d = hammer_core::kernel::diff_back(&p, &cfg);
+
+    if json {
+        println!("{}", serde_json::to_string_pretty(&d)?);
+    } else {
+        println!("plan        {} ({})", p.derived_name, plan_path.display());
+        println!("config      {}", config.display());
+        println!("cumplidos   {}", d.honored.len());
+        println!("ausentes    {}", d.absent.len());
+        for s in &d.absent {
+            println!("  {s} — el kernel ni lo menciona: la bandera fue un no-op");
+        }
+        println!("INCUMPLIDOS {}", d.violated.len());
+        for v in &d.violated {
+            println!(
+                "  {} — se pidió {:?} y quedó {} (lo pidió {})",
+                v.symbol, v.asked, v.got, v.from
+            );
+        }
+    }
+    if !d.is_clean() {
+        anyhow::bail!("el .config no honra el plan");
+    }
+    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
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 91426266..2ec075fc 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -1422,7 +1422,7 @@ fn main() -> anyhow::Result<()> {
                 }
             }
         }
-        Cmd::Kernel { sub } => kernel_cmd::run(sub)?,
+        Cmd::Kernel { sub } => kernel_cmd::run(sub, &cli.store)?,
     }
     Ok(())
 }
diff --git a/crates/hammer-core/src/kernel/mod.rs b/crates/hammer-core/src/kernel/mod.rs
index d4217473..31bbeae3 100644
--- a/crates/hammer-core/src/kernel/mod.rs
+++ b/crates/hammer-core/src/kernel/mod.rs
@@ -20,10 +20,12 @@ pub mod catalog;
 pub mod config;
 pub mod hw;
 pub mod kconfig;
+pub mod plan;
 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 plan::{diff_back, make_plan, DiffBack, Plan};
 pub use reverse::{BundleState, HwSignal, ReverseReport};
diff --git a/crates/hammer-core/src/kernel/plan.rs b/crates/hammer-core/src/kernel/plan.rs
new file mode 100644
index 00000000..970d005f
--- /dev/null
+++ b/crates/hammer-core/src/kernel/plan.rs
@@ -0,0 +1,558 @@
+//! `plan` — de una selección de bundles a una **receta derivada**.
+//!
+//! ## Por qué una receta y no un parámetro
+//! Las fases de build entran en `Recipe::hash_inputs` y el config del kernel vive en la fase
+//! `configure` ⇒ **el config ES la identidad del artefacto** (SDD 22 §1). No hay forma de tener un
+//! config variable sin generar recetas: una «perilla» de la UI no es un parámetro de runtime, es
+//! una edición de receta. Fingir lo contrario daría un store que dice que todo está al día mientras
+//! los artefactos ya no corresponden — en un sistema direccionado por contenido, el peor fallo
+//! posible no es fallar, es **mentir**.
+//!
+//! De ahí sale gratis lo que §7 del handoff pedía: el plan **determina** el artefacto, así que el
+//! JSON lleva su `ArtifactHash` y la UI puede decir «esto ya está construido y firmado» sin
+//! construir nada.
+//!
+//! ## Qué se emite y qué no
+//! Se emiten **raíces**, no clausuras. `-d WIRELESS -d WLAN` son dos banderas; los ~400 símbolos
+//! que caen con ellas los calcula el `olddefconfig` **del propio kernel**. hammer sabe cuáles son
+//! (para poder explicarlos), pero no los escribe: la app nunca escribe un `.config`.
+//!
+//! ## Procedencia por símbolo
+//! Cada símbolo emitido carga quién lo pidió ([`PlannedSymbol::from`]). Es el #7 del handoff, y con
+//! él el diff entre dos generaciones deja de ser «347 símbolos cambiaron» para ser «activaste
+//! bluetooth y entraron 12 símbolos».
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::catalog::{Catalog, Side};
+use super::kconfig::KconfigTree;
+use crate::recipe::Recipe;
+
+/// Qué se le pide a `scripts/config` para un símbolo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Action {
+    Enable,
+    Disable,
+}
+
+impl Action {
+    fn flag(self) -> &'static str {
+        match self {
+            Action::Enable => "-e",
+            Action::Disable => "-d",
+        }
+    }
+}
+
+/// Un símbolo emitido, con su procedencia.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PlannedSymbol {
+    pub symbol: String,
+    pub action: Action,
+    /// `bundle:sin-wifi`, `knob:tick-1000`.
+    pub from: String,
+    /// Por qué está: raíz declarada, o fuga `select` que hubo que cerrar.
+    pub reason: String,
+}
+
+/// Una perilla que **no** cae del lado de Kconfig: el plan la declara y no la aplica.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RecipeKnob {
+    pub id: String,
+    pub title: String,
+    pub recipe_field: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Plan {
+    pub base_recipe: String,
+    pub base_name: String,
+    pub derived_name: String,
+    pub bundles: Vec<String>,
+    pub knobs: Vec<String>,
+    /// Símbolos que se emiten, con procedencia.
+    pub symbols: Vec<PlannedSymbol>,
+    /// **Diff-back estático**: pedidos que no existen en este árbol. Hoy un `-d` sobre uno de éstos
+    /// es un no-op que nadie ve — le pasa a `THUNDERBOLT` y a `REISERFS_FS` en las cuatro recetas
+    /// de kernel del repo.
+    pub unknown_symbols: Vec<PlannedSymbol>,
+    /// Perillas `side = "recipe"` seleccionadas: declaradas, no aplicadas.
+    pub recipe_side: Vec<RecipeKnob>,
+    /// Cuántos símbolos apaga de verdad la selección, contando la clausura. Es informativo: quien
+    /// la calcula de vuelta, y manda, es `olddefconfig`.
+    pub closure_size: usize,
+    /// La invocación de `scripts/config` que se inserta.
+    pub fragment: String,
+    /// La fase `configure` derivada, entera.
+    pub configure: String,
+    /// El `ArtifactHash` de la receta derivada. `None` si no se pudo calcular (deps sin resolver).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub artifact_hash: Option<String>,
+}
+
+/// Construye el plan. No toca el disco.
+pub fn make_plan(
+    base: &Recipe,
+    base_path: &str,
+    cat: &Catalog,
+    tree: &KconfigTree,
+    bundle_ids: &[String],
+    knob_ids: &[String],
+) -> crate::Result<Plan> {
+    let mut wanted: BTreeMap<String, PlannedSymbol> = BTreeMap::new();
+    let mut disable_roots: Vec<String> = Vec::new();
+
+    let push = |wanted: &mut BTreeMap<String, PlannedSymbol>,
+                    sym: &str,
+                    action: Action,
+                    from: String,
+                    reason: &str|
+     -> crate::Result<()> {
+        // Dos selecciones que se contradicen sobre el mismo símbolo no se resuelven por orden de
+        // aparición: se rechazan. El orden sería una respuesta plausible y arbitraria.
+        if let Some(prev) = wanted.get(sym) {
+            if prev.action != action {
+                return Err(crate::Error::Recipe(format!(
+                    "conflicto en {sym}: «{}» lo quiere {:?} y «{from}» lo quiere {:?}",
+                    prev.from, prev.action, action
+                )));
+            }
+            return Ok(());
+        }
+        wanted.insert(
+            sym.to_string(),
+            PlannedSymbol {
+                symbol: sym.to_string(),
+                action,
+                from,
+                reason: reason.to_string(),
+            },
+        );
+        Ok(())
+    };
+
+    for id in bundle_ids {
+        let b = cat.bundle(id).ok_or_else(|| {
+            crate::Error::Recipe(format!("no existe el bundle «{id}» en el catálogo"))
+        })?;
+        let from = format!("bundle:{id}");
+        for s in &b.disable {
+            push(&mut wanted, s, Action::Disable, from.clone(), "raíz del bundle")?;
+            disable_roots.push(s.clone());
+        }
+        for s in &b.close_leaks {
+            push(
+                &mut wanted,
+                s,
+                Action::Disable,
+                from.clone(),
+                "fuga `select` cerrada: sin esto el olddefconfig lo reenciende",
+            )?;
+            disable_roots.push(s.clone());
+        }
+        for s in &b.enable {
+            push(&mut wanted, s, Action::Enable, from.clone(), "raíz del bundle")?;
+        }
+    }
+
+    let mut recipe_side = Vec::new();
+    for id in knob_ids {
+        let k = cat.knob(id).ok_or_else(|| {
+            crate::Error::Recipe(format!("no existe la perilla «{id}» en el catálogo"))
+        })?;
+        let from = format!("knob:{id}");
+        if k.side == Side::Recipe {
+            recipe_side.push(RecipeKnob {
+                id: k.id.clone(),
+                title: k.title.clone(),
+                recipe_field: k.recipe_field.clone().unwrap_or_default(),
+            });
+            continue;
+        }
+        for s in &k.enable {
+            push(&mut wanted, s, Action::Enable, from.clone(), "perilla")?;
+        }
+        for s in &k.disable {
+            push(&mut wanted, s, Action::Disable, from.clone(), "perilla")?;
+            disable_roots.push(s.clone());
+        }
+    }
+
+    // Un `enable` que cae DENTRO de lo que otro bundle apaga es un conflicto que el símbolo solo no
+    // delata: `olddefconfig` lo descartaría en silencio por dependencia insatisfecha.
+    let closure = tree.closure_off(&disable_roots);
+    for p in wanted.values() {
+        if p.action == Action::Enable && closure.contains(&p.symbol) {
+            return Err(crate::Error::Recipe(format!(
+                "conflicto: «{}» quiere encender {} pero cae dentro de lo que apaga la selección \
+                 (el olddefconfig lo descartaría sin decir nada)",
+                p.from, p.symbol
+            )));
+        }
+    }
+
+    let (known, unknown_symbols): (Vec<_>, Vec<_>) = wanted
+        .into_values()
+        .partition(|p| tree.symbols.contains_key(&p.symbol));
+
+    let arch = detect_arch(base);
+    let fragment = render_fragment(&known);
+    let base_configure = base
+        .build
+        .phases
+        .configure
+        .as_deref()
+        .ok_or_else(|| {
+            crate::Error::Recipe(format!(
+                "la receta base «{}» no tiene fase `configure`: no hay dónde insertar el config",
+                base.name
+            ))
+        })?
+        .trim_end()
+        .trim_end_matches('\\')
+        .trim_end()
+        .to_string();
+
+    // Se AÑADE una segunda ronda en vez de reescribir la fase base: no hay que parsear el shell de
+    // nadie, la base sigue siendo literalmente la de siempre en el diff, y `olddefconfig` es
+    // idempotente. Lo que la app aporta es un fragmento; resolver sigue siendo del kernel.
+    let configure = format!("{base_configure} && \\\n{fragment} && \\\nmake ARCH={arch} olddefconfig\n");
+
+    let derived_name = derive_name(&base.name, bundle_ids, knob_ids);
+
+    Ok(Plan {
+        base_recipe: base_path.to_string(),
+        base_name: base.name.clone(),
+        derived_name,
+        bundles: bundle_ids.to_vec(),
+        knobs: knob_ids.to_vec(),
+        symbols: known,
+        unknown_symbols,
+        recipe_side,
+        closure_size: closure.len(),
+        fragment,
+        configure,
+        artifact_hash: None,
+    })
+}
+
+/// La receta derivada: la base con la fase `configure` del plan y un nombre propio.
+///
+/// Conserva el `base_dir` de la base a propósito — es lo que hace resolubles sus `deps.build` y,
+/// con ellas, calculable el `ArtifactHash`.
+pub fn derive_recipe(base: &Recipe, plan: &Plan) -> Recipe {
+    let mut r = base.clone();
+    r.name = plan.derived_name.clone();
+    r.build.phases.configure = Some(plan.configure.clone());
+    r
+}
+
+/// Serializa la receta derivada a TOML. Vive acá y no en la CLI porque el formato de receta es del
+/// core: quien lo cambie tiene que cambiarlo en un solo sitio.
+pub fn render_recipe_toml(r: &Recipe) -> crate::Result<String> {
+    toml::to_string_pretty(r).map_err(|e| crate::Error::Serde(e.to_string()))
+}
+
+fn detect_arch(base: &Recipe) -> String {
+    let hay = base
+        .build
+        .phases
+        .configure
+        .as_deref()
+        .unwrap_or_default();
+    for tok in hay.split_whitespace() {
+        if let Some(v) = tok.strip_prefix("ARCH=") {
+            if !v.is_empty() {
+                return v.to_string();
+            }
+        }
+    }
+    "x86_64".to_string()
+}
+
+/// `scripts/config -d A -d B -e C …`, en líneas de ancho razonable y **orden estable**: el texto
+/// entra al hash, así que un orden que dependiera del recorrido daría dos hashes para el mismo plan.
+fn render_fragment(syms: &[PlannedSymbol]) -> String {
+    let mut out = String::from("scripts/config");
+    let mut col = out.len();
+    for p in syms {
+        let pieza = format!(" {} {}", p.action.flag(), p.symbol);
+        if col + pieza.len() > 96 {
+            out.push_str(" \\\n ");
+            col = 1;
+        }
+        col += pieza.len();
+        out.push_str(&pieza);
+    }
+    out
+}
+
+/// Nombre de la receta derivada. Lleva un hash corto de la SELECCIÓN, no del contenido: dos planes
+/// con los mismos bundles dan el mismo nombre, y el `ArtifactHash` sigue siendo quien manda.
+fn derive_name(base: &str, bundles: &[String], knobs: &[String]) -> String {
+    let mut material = String::new();
+    let mut b: Vec<&String> = bundles.iter().collect();
+    b.sort();
+    for x in b {
+        material.push_str("bundle:");
+        material.push_str(x);
+        material.push('\n');
+    }
+    let mut k: Vec<&String> = knobs.iter().collect();
+    k.sort();
+    for x in k {
+        material.push_str("knob:");
+        material.push_str(x);
+        material.push('\n');
+    }
+    let h = crate::hash::ArtifactHash::of_bytes(material.as_bytes());
+    let hex = h.as_str().trim_start_matches("b3:");
+    format!("{base}-plan-{}", &hex[..8])
+}
+
+/// **Diff-back** (§6 del handoff, la mitad que faltaba): ¿sobrevivió cada símbolo pedido al
+/// `olddefconfig`?
+///
+/// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DiffBack {
+    pub honored: Vec<String>,
+    /// Pedidos que el `.config` resultante contradice. **Cada uno es una promesa incumplida.**
+    pub violated: Vec<Violation>,
+    /// Pedidos sobre símbolos que el `.config` ni menciona: no existen en esa versión del kernel.
+    pub absent: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Violation {
+    pub symbol: String,
+    pub asked: Action,
+    /// Cómo quedó de verdad.
+    pub got: String,
+    pub from: String,
+}
+
+impl DiffBack {
+    pub fn is_clean(&self) -> bool {
+        self.violated.is_empty() && self.absent.is_empty()
+    }
+}
+
+/// Contrasta el plan contra el `.config` que produjo el kernel.
+pub fn diff_back(plan: &Plan, produced: &super::KernelConfig) -> DiffBack {
+    use super::ConfigValue;
+    let mut d = DiffBack {
+        honored: Vec::new(),
+        violated: Vec::new(),
+        absent: Vec::new(),
+    };
+    for p in &plan.symbols {
+        match (p.action, produced.get(&p.symbol)) {
+            (_, None) => d.absent.push(p.symbol.clone()),
+            (Action::Enable, Some(v)) if v.is_on() => d.honored.push(p.symbol.clone()),
+            (Action::Disable, Some(ConfigValue::No)) => d.honored.push(p.symbol.clone()),
+            (asked, Some(v)) => d.violated.push(Violation {
+                symbol: p.symbol.clone(),
+                asked,
+                got: format!("{v:?}"),
+                from: p.from.clone(),
+            }),
+        }
+    }
+    // Los que ya sabíamos inexistentes en el árbol también son promesa incumplida.
+    for p in &plan.unknown_symbols {
+        d.absent.push(p.symbol.clone());
+    }
+    d.absent.sort();
+    d.absent.dedup();
+    d
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::collections::BTreeMap;
+
+    fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog, Recipe) {
+        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 AUDIT
+	bool "Audit"
+
+config MAC80211_LEDS
+	bool "leds"
+	depends on CFG80211
+"#,
+        )
+        .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]]
+id = "con-leds"
+title = "Quiero los leds"
+enable = ["MAC80211_LEDS"]
+
+[[knob]]
+id = "auditoria"
+title = "Auditoría"
+side = "kconfig"
+enable = ["AUDIT"]
+
+[[knob]]
+id = "lto"
+title = "LTO"
+side = "recipe"
+recipe_field = "build.compiler"
+"#,
+        )
+        .unwrap();
+        let recipe: Recipe = toml::from_str(
+            r#"
+name = "linux"
+version = "6.16.12"
+[source]
+tarball = "https://x/linux.tar.gz"
+sha256 = "aa"
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+[build.phases]
+configure = "make ARCH=x86_64 defconfig && make ARCH=x86_64 olddefconfig"
+compile = "make bzImage"
+"#,
+        )
+        .unwrap();
+        (d, tree, cat, recipe)
+    }
+
+    #[test]
+    fn emite_raices_no_clausuras() {
+        let (_d, tree, cat, base) = escenario();
+        let p = make_plan(&base, "recipes/linux.toml", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+        // Dos banderas, no cuatro símbolos: la clausura la calcula olddefconfig.
+        assert_eq!(p.symbols.len(), 2);
+        assert!(p.fragment.contains("-d WIRELESS"));
+        assert!(p.fragment.contains("-d WLAN"));
+        assert!(!p.fragment.contains("CFG80211"));
+        // …pero hammer SABE cuántos caen, para poder explicarlo.
+        assert_eq!(p.closure_size, 4);
+    }
+
+    #[test]
+    fn la_fase_derivada_conserva_la_base_y_reresuelve() {
+        let (_d, tree, cat, base) = escenario();
+        let p = make_plan(&base, "recipes/linux.toml", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+        assert!(p.configure.starts_with("make ARCH=x86_64 defconfig"));
+        assert!(p.configure.trim_end().ends_with("make ARCH=x86_64 olddefconfig"));
+        // El fragmento va ANTES del olddefconfig final: si fuera después, no se resolvería.
+        let i_frag = p.configure.find("scripts/config").unwrap();
+        let i_last = p.configure.rfind("olddefconfig").unwrap();
+        assert!(i_frag < i_last);
+    }
+
+    #[test]
+    fn el_nombre_derivado_es_estable_e_independiente_del_orden() {
+        let (_d, tree, cat, base) = escenario();
+        let a = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
+        let b = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
+        assert_eq!(a.derived_name, b.derived_name);
+        assert!(a.derived_name.starts_with("linux-plan-"));
+        let c = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+        assert_ne!(a.derived_name, c.derived_name);
+    }
+
+    #[test]
+    fn encender_algo_que_la_seleccion_apaga_es_conflicto() {
+        let (_d, tree, cat, base) = escenario();
+        // MAC80211_LEDS depende de CFG80211, que cae con WIRELESS. olddefconfig lo tiraría callado.
+        let e = make_plan(
+            &base,
+            "r",
+            &cat,
+            &tree,
+            &["sin-wifi".into(), "con-leds".into()],
+            &[],
+        )
+        .unwrap_err()
+        .to_string();
+        assert!(e.contains("MAC80211_LEDS"), "{e}");
+    }
+
+    #[test]
+    fn la_perilla_de_receta_se_declara_y_no_se_aplica() {
+        let (_d, tree, cat, base) = escenario();
+        let p = make_plan(&base, "r", &cat, &tree, &[], &["lto".into()]).unwrap();
+        assert!(p.symbols.is_empty());
+        assert_eq!(p.recipe_side.len(), 1);
+        assert_eq!(p.recipe_side[0].recipe_field, "build.compiler");
+    }
+
+    #[test]
+    fn el_simbolo_inexistente_no_se_pierde_en_silencio() {
+        let (_d, tree, _cat, base) = escenario();
+        let cat: Catalog = toml::from_str(
+            r#"
+version = 1
+[[bundle]]
+id = "fantasma"
+title = "Apaga algo que ya no existe"
+disable = ["REISERFS_FS"]
+"#,
+        )
+        .unwrap();
+        let p = make_plan(&base, "r", &cat, &tree, &["fantasma".into()], &[]).unwrap();
+        assert!(p.symbols.is_empty(), "no se emite una bandera que sería no-op");
+        assert_eq!(p.unknown_symbols.len(), 1);
+        assert_eq!(p.unknown_symbols[0].symbol, "REISERFS_FS");
+    }
+
+    #[test]
+    fn el_diff_back_ve_la_promesa_incumplida() {
+        let (_d, tree, cat, base) = escenario();
+        let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
+        // El kernel produjo un config donde WLAN sobrevivió y AUDIT no se pudo encender.
+        let cfg = super::super::KernelConfig::parse(
+            "# CONFIG_WIRELESS is not set\nCONFIG_WLAN=y\n# CONFIG_AUDIT is not set\n",
+        );
+        let d = diff_back(&p, &cfg);
+        assert!(!d.is_clean());
+        let violados: Vec<&str> = d.violated.iter().map(|v| v.symbol.as_str()).collect();
+        assert_eq!(violados, vec!["AUDIT", "WLAN"]);
+        assert_eq!(d.honored, vec!["WIRELESS"]);
+    }
+
+    #[test]
+    fn el_diff_back_limpio_es_limpio() {
+        let (_d, tree, cat, base) = escenario();
+        let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+        let cfg = super::super::KernelConfig::parse(
+            "# CONFIG_WIRELESS is not set\n# CONFIG_WLAN is not set\n",
+        );
+        assert!(diff_back(&p, &cfg).is_clean());
+    }
+}
diff --git a/docs/22-configurador-kernel.md b/docs/22-configurador-kernel.md
index b985ce0c..2fdfffa7 100644
--- a/docs/22-configurador-kernel.md
+++ b/docs/22-configurador-kernel.md
@@ -304,6 +304,43 @@ Para cerrar «no necesito audio» hay que tragarse drivers gráficos. En `linux.
 porque los gráficos también están apagados, pero en un perfil de escritorio **no**: ahí la fuga es
 una decisión, y por eso el punto fijo la muestra en vez de aplicarla.
 
+## 10. Pasos 1 y 4 hechos: modo reversa, plan y diff-back
+
+**Modo reversa** (`hammer kernel probe`) sobre gioser, con el catálogo `docs/state/kernel-bundles.toml`
+(15 bundles N1, 8 perillas N2): 10 551 símbolos declarados, 20 dispositivos PCI, 38 drivers
+bindeados, **7 bundles con capacidad que este hardware no usa**. Y avisa de lo que el propio caso
+destapó: el config vivo 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.
+
+**Plan** (`hammer kernel plan`): emite una **receta derivada**, como manda §1. Medido sobre
+`recipes/linux.toml` con `sin-wifi` + `sin-audio` + `solo-ext4` + `jaula-y-eio-moderna`:
+
+| | |
+|---|---|
+| banderas emitidas | **16** |
+| símbolos que caen con su clausura | **1775** |
+| `ArtifactHash` de la base | `b3:cb926743…` |
+| `ArtifactHash` de la derivada | `b3:47a52b2e…` |
+
+Dos hashes distintos para el mismo código fuente: **el config es la identidad**, y ahora se ve. El
+plan lleva su hash dentro ⇒ la UI puede decir «esto ya está construido y firmado» sin construir.
+
+Tres detalles del plan que no eran obvios:
+- **Se emiten raíces, no clausuras.** 16 banderas, no 1775 líneas. La clausura la calcula el
+  `olddefconfig` del propio kernel; hammer la sabe sólo para poder explicarla.
+- **La fase derivada AÑADE una segunda ronda** (`… && scripts/config … && make olddefconfig`) en vez
+  de reescribir la base. No hay que parsear el shell de nadie y la base sigue siendo literalmente la
+  de siempre en el diff.
+- **Los conflictos se rechazan, no se ordenan.** Dos selecciones que se contradicen sobre un símbolo
+  darían una respuesta plausible y arbitraria según el orden de aparición. Y hay un segundo conflicto
+  que el símbolo solo no delata: encender algo que **cae dentro de la clausura** de lo que otro
+  bundle apaga — `olddefconfig` lo descartaría sin decir nada.
+
+**Diff-back** (`hammer kernel diff-back --plan … --config …`): la mitad que faltaba del §6 del
+handoff. Clasifica cada símbolo pedido en cumplido / **incumplido** (el `.config` dice otra cosa) /
+ausente (el kernel ni lo menciona: la bandera fue un no-op), con la procedencia de quién lo pidió, y
+sale distinto de cero si el config no honra el plan.
+
 ### Un hallazgo de regalo
 `recipes/linux.toml` apaga **`REISERFS_FS`, que no existe en 6.16.12** (upstream lo retiró). El
 `scripts/config -d REISERFS_FS` es un no-op que nadie vio. Es exactamente el modo de fallo que el