Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-build-thing/src/kernel_trim.rs

//! **The measured kernel trim — the STATIC half** (tunnr 1.1, lane ktrim,
//! owner decision 2026-10-30).
//!
//! The question an operator asks of a composed row kernel is *"what if this
//! row drops `CONFIG_X`?"*. [`crate::kconfig_graph`] already explains why each
//! symbol is in the `.config` (the chain UP to a head). This module answers the
//! other direction, which is the one a trim needs:
//!
//! * [`TrimModel::what_if`] — drop one or more symbols and settle the
//!   `.config` the way `olddefconfig` would: which derived symbols leave with
//!   them, which fragment lines would lose their gates (the build's canary
//!   would refuse them, so they go too), which symbols would turn ON, which
//!   drops cannot hold (a live `select` forces the symbol back, or it has no
//!   prompt), and — refused BY NAME — every consumer feature that requires one
//!   of the symbols that would leave.
//! * [`TrimModel::candidates`] — every symbol the row could drop without
//!   breaking a requirement, ranked by the size of what leaves with it
//!   (estimated from the built tree's objects, [`TreeSizes`]): the unclaimed
//!   `preset-only` lines and the biggest `kconfig-derived` subtrees by head.
//!   Nothing is applied; a candidate becomes a row veto only after the
//!   coverage half and the owner.
//! * [`InverseCheck`] — the verification that matters
//!   ([`crate::kconfig_graph`]'s own rule): recompose with the drops, build
//!   the bzImage, and compare. Every symbol the what-if listed must really be
//!   gone, and nothing else may have changed that the what-if did not name.
//!
//! # The model, and its honest limits
//!
//! A symbol stays ON when (Kconfig's own rules, `scripts/kconfig/symbol.c`):
//! a live symbol `select`s it (select ignores `depends on`), or its gates hold
//! and either the input `.config` gives a prompt symbol a value (the fragment
//! wins over the base), a live symbol `imply`s it, or the first `default`
//! whose condition holds is on. A `choice` member already on stays on while
//! its gates hold (the arbitration inside a choice is not re-derived). An
//! expression the parser cannot evaluate (`$(cc-option,…)`) is read as
//! "holds" when deciding whether something LEAVES and as "does not hold" when
//! deciding whether something APPEARS, so the model never invents a change.
//! Whatever the model gets wrong on the unchanged baseline (symbols it would
//! remove with no drop at all) is counted as `model_noise` and subtracted, so
//! a what-if reports only what the drop changes. The measured recompose
//! ([`InverseCheck`]) is the judge of all of it.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail};
use serde::Serialize;

use crate::kconfig_graph::{ConfigView, Expr, KconfigGraph, Tri};
use crate::kernel_requires::{KconfigValue, KernelRequirements, Requirer, WhyClass, WhyIndex};
use crate::syscall_join::{self, MeasuredRow, SyscallMap, Veto};

/// `CONFIG_X` → `X`.
pub fn bare(s: &str) -> &str {
    s.strip_prefix("CONFIG_").unwrap_or(s)
}

/// `X` / `CONFIG_X` → `CONFIG_X`.
pub fn full(s: &str) -> String {
    format!("CONFIG_{}", bare(s))
}

// ── the answer types ─────────────────────────────────────────────────────────

/// A consumer feature that requires a symbol the what-if would remove (or
/// vetoes one it would turn on) — the drop is refused by this name.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Refused {
    /// `CONFIG_X` — the symbol the requirement is on.
    pub symbol: String,
    /// What the requirer wants (`y`, `n`, `64`, …).
    pub wanted: String,
    /// `repo/variant` of the consumer.
    pub consumer: String,
    /// The feature on that consumer.
    pub feature: String,
    /// The declared why.
    pub reason: String,
}

impl Refused {
    /// `CONFIG_X=y ← tunnr/rusternetes-node:k8s (why)`.
    pub fn render(&self) -> String {
        format!("{}={} ← {}:{} ({})", self.symbol, self.wanted, self.consumer, self.feature, self.reason)
    }
}

/// A drop that would not hold after `olddefconfig`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Held {
    /// `CONFIG_X` — the dropped symbol.
    pub symbol: String,
    /// Why it comes back: `selected by CONFIG_Y`, `no prompt: …`.
    pub reason: String,
}

/// **The answer to "what if this row drops X?"**
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct WhatIf {
    /// The dropped symbols, `CONFIG_`-prefixed, as asked.
    pub drops: Vec<String>,
    /// Derived (fragment-unnamed) symbols that leave with the drops.
    pub leaving: Vec<String>,
    /// Fragment lines (`=y`) whose gates the drops remove: the build's canary
    /// would refuse them, so the recompose drops them too.
    pub declared_leaving: Vec<String>,
    /// Symbols that would turn ON (a `default y if !X`).
    pub appearing: Vec<String>,
    /// Drops that cannot hold.
    pub held: Vec<Held>,
    /// Requirements the drops would break — the drop is refused by these names.
    pub refused: Vec<Refused>,
    /// Symbols the model removes from the UNCHANGED baseline (its own error,
    /// subtracted from every answer). Zero on a fully explained tree.
    pub model_noise: usize,
    /// Estimated size of what leaves, when the built tree was read.
    pub estimate: Option<SizeEstimate>,
    /// Measured calls (the row's syscall profiles) whose kernel code leaves
    /// with the drops ([`crate::syscall_join`]): a non-near-miss refuses the
    /// drop by name, a near-miss warns.
    pub syscall_vetoes: Vec<Veto>,
    /// The row's measured profiles were joined (otherwise "not checked").
    pub syscalls_checked: bool,
}

/// Bytes an answer is estimated to remove.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct SizeEstimate {
    /// Allocated (loaded) bytes of the linked objects that leave — text,
    /// data, rodata, init; never `.bss` or debug sections.
    pub object_bytes: u64,
    /// `object_bytes` scaled by the baseline's measured bzImage/objects ratio.
    pub bz_bytes: u64,
}

impl WhatIf {
    /// Is the what-if clean: nothing refused (by a feature or by a measured
    /// call), every drop holds?
    pub fn is_clean(&self) -> bool {
        self.refused.is_empty() && self.held.is_empty() && !self.syscall_refused()
    }

    /// A measured call (not a near-miss) loses its kernel code.
    pub fn syscall_refused(&self) -> bool {
        self.syscall_vetoes.iter().any(|v| !v.near_miss)
    }

    /// `CLEAN`, `REFUSED` or `DOES NOT HOLD`.
    pub fn verdict(&self) -> &'static str {
        if self.is_clean() {
            "CLEAN"
        } else if !self.refused.is_empty() || self.syscall_refused() {
            "REFUSED"
        } else {
            "DOES NOT HOLD"
        }
    }

    /// Join the row's measured calls: name every call the drops take away.
    pub fn join_syscalls(&mut self, map: &SyscallMap, measured: &MeasuredRow) {
        self.syscall_vetoes = syscall_join::vetoes_for(map, measured, &self.drops, &self.expected_gone());
        self.syscalls_checked = true;
    }

    /// Every symbol the recompose is expected to lose: drops + leaving +
    /// declared lines that lose their gates.
    pub fn expected_gone(&self) -> BTreeSet<String> {
        self.drops.iter().chain(&self.leaving).chain(&self.declared_leaving).cloned().collect()
    }

    /// The fragment lines the recompose merges LAST: one `is not set` per
    /// drop and per declared line that loses its gates.
    pub fn fragment_lines(&self) -> String {
        let mut s = String::new();
        for sym in self.drops.iter().chain(&self.declared_leaving) {
            s.push_str(&format!("# {} is not set\n", full(sym)));
        }
        s
    }

    /// The human text.
    pub fn render(&self) -> String {
        let mut s = format!("# what-if: drop {}\n", self.drops.join(" "));
        s.push_str(&format!("verdict: {}\n", self.verdict()));
        if let Some(e) = self.estimate {
            s.push_str(&format!("estimate: {} object bytes, ~{} bzImage bytes\n", e.object_bytes, e.bz_bytes));
        }
        for r in &self.refused {
            s.push_str(&format!("REFUSED {}\n", r.render()));
        }
        for v in self.syscall_vetoes.iter().filter(|v| !v.near_miss) {
            s.push_str(&format!("REFUSED by a measured call: {}\n", v.render()));
        }
        for v in self.syscall_vetoes.iter().filter(|v| v.near_miss) {
            s.push_str(&format!("WARN near-miss: {}\n", v.render()));
        }
        if !self.syscalls_checked {
            s.push_str("syscalls: not checked (no measured profiles for this row: measure syscalls <row>)\n");
        } else if self.syscall_vetoes.is_empty() {
            s.push_str("syscalls: no measured call loses its kernel code\n");
        }
        for h in &self.held {
            s.push_str(&format!("HELD {} — {}\n", h.symbol, h.reason));
        }
        s.push_str(&format!("leaving ({}):", self.leaving.len()));
        for l in &self.leaving {
            s.push(' ');
            s.push_str(l);
        }
        s.push('\n');
        if !self.declared_leaving.is_empty() {
            s.push_str(&format!("fragment lines that lose their gates ({}): {}\n", self.declared_leaving.len(), self.declared_leaving.join(" ")));
        }
        if !self.appearing.is_empty() {
            s.push_str(&format!("appearing ({}): {}\n", self.appearing.len(), self.appearing.join(" ")));
        }
        if self.model_noise > 0 {
            s.push_str(&format!("model noise on the unchanged baseline: {} symbol(s), subtracted\n", self.model_noise));
        }
        s
    }
}

// ── the model ────────────────────────────────────────────────────────────────

/// A composed row kernel, ready to be asked: the graph, the built `.config`,
/// the base `.config`, the merged fragment and the consumers' requirements.
pub struct TrimModel<'a> {
    graph: &'a KconfigGraph,
    built: ConfigView,
    /// The prompt symbols' input values: the base `.config`, then the merged
    /// fragment over it (later wins) — what `olddefconfig` reads.
    user: BTreeMap<String, String>,
    declared: BTreeSet<String>,
    reqs: KernelRequirements,
    /// The baseline as the model settles it (built minus model noise).
    settled: BTreeMap<String, String>,
    noise: BTreeSet<String>,
    /// What the model would turn on in the unchanged baseline (its own
    /// error), subtracted from every `appearing`.
    base_appear: BTreeSet<String>,
    sizes: Option<&'a TreeSizes>,
    /// bzImage bytes per object byte, measured on the baseline.
    bz_ratio: Option<f64>,
}

impl<'a> TrimModel<'a> {
    /// Build the model of one composed kernel.
    pub fn new(graph: &'a KconfigGraph, fragment: &str, built: &str, base: &str, reqs: &KernelRequirements) -> Self {
        let built = ConfigView::parse(built).with_env(graph.env.clone());
        let frag = ConfigView::parse(fragment);
        let mut user = ConfigView::parse(base).values;
        for (k, v) in &frag.values {
            user.insert(k.clone(), v.clone());
        }
        let declared: BTreeSet<String> = frag.values.keys().cloned().collect();
        let mut m = TrimModel {
            graph,
            built: built.clone(),
            user,
            declared,
            reqs: reqs.clone(),
            settled: BTreeMap::new(),
            noise: BTreeSet::new(),
            base_appear: BTreeSet::new(),
            sizes: None,
            bz_ratio: None,
        };
        let start = live_values(&built);
        let settled = m.settle(start.clone(), &BTreeMap::new());
        m.noise = start.keys().filter(|k| !settled.contains_key(*k)).cloned().collect();
        m.base_appear = m.appear(&settled, &BTreeMap::new());
        m.settled = settled;
        m
    }

    /// Attach the built tree's object sizes (and the baseline bzImage size, for
    /// the bzImage estimate).
    pub fn with_sizes(mut self, sizes: &'a TreeSizes, bz_image_bytes: Option<u64>) -> Self {
        self.sizes = Some(sizes);
        let total = sizes.total_bytes();
        self.bz_ratio = bz_image_bytes.filter(|_| total > 0).map(|b| b as f64 / total as f64);
        self
    }

    /// Symbols the model removes from the unchanged baseline.
    pub fn model_noise(&self) -> &BTreeSet<String> {
        &self.noise
    }

    fn holds(t: Tri, optimistic: bool) -> bool {
        match t {
            Tri::Yes | Tri::Mod => true,
            Tri::No => false,
            Tri::Unknown => optimistic,
        }
    }

    fn cond_holds(cond: Option<&Expr>, cfg: &ConfigView, optimistic: bool) -> bool {
        match cond {
            None => true,
            Some(c) => Self::holds(c.eval(cfg), optimistic),
        }
    }

    /// Would `sym` be ON in `cfg` (the other symbols as they stand), with the
    /// input values `user` (+ `overrides`, the drops)?
    fn alive(&self, sym: &str, cfg: &ConfigView, overrides: &BTreeMap<String, String>, optimistic: bool, was_on: bool) -> bool {
        let Some(def) = self.graph.symbols.get(sym) else {
            // Not declared anywhere in the tree (a toolchain value the build
            // writes): nothing the graph says can move it.
            return was_on;
        };
        // 1. select forces, regardless of the target's gates.
        for (from, cond) in self.graph.selected_by.get(sym).into_iter().flatten() {
            if cfg.is_set(from) && Self::cond_holds(cond.as_ref(), cfg, optimistic) {
                return true;
            }
        }
        // 2. the gates.
        for d in &def.depends {
            if !Self::holds(d.eval(cfg), optimistic) {
                return false;
            }
        }
        // An int/hex/string option exists whenever it is visible.
        if !def.kind.is_boolean() {
            return was_on || optimistic;
        }
        // 3. an input value on a prompt symbol (the drop overrides first).
        if def.prompt {
            if let Some(v) = overrides.get(sym).or_else(|| self.user.get(sym)) {
                return v != "n";
            }
        }
        // 4. a choice member already chosen stays chosen while its gates hold.
        if def.in_choice && was_on {
            return true;
        }
        // 5. imply.
        for (from, cond) in self.graph.implied_by.get(sym).into_iter().flatten() {
            if cfg.is_set(from) && Self::cond_holds(cond.as_ref(), cfg, optimistic) {
                return true;
            }
        }
        // 6. the first default whose condition holds decides.
        for (val, cond) in &def.defaults {
            let c = match cond {
                None => Tri::Yes,
                Some(c) => c.eval(cfg),
            };
            match c {
                Tri::No => continue,
                Tri::Unknown => return optimistic,
                Tri::Yes | Tri::Mod => {
                    return Self::holds(val.eval(cfg), optimistic);
                }
            }
        }
        false
    }

    /// Settle a `.config` the way `olddefconfig` would, starting from `start`
    /// and only REMOVING (a monotone fixpoint), under `overrides`.
    fn settle(&self, start: BTreeMap<String, String>, overrides: &BTreeMap<String, String>) -> BTreeMap<String, String> {
        // The drops need no special pass: `alive` reads them as the prompt
        // symbol's input value (`n`), so a dropped symbol leaves on the first
        // round unless a live select forces it or it has no prompt.
        let mut cfg = ConfigView { values: start, env: self.built.env.clone(), open: false };
        for _ in 0..256 {
            let live: Vec<String> = cfg.values.keys().cloned().collect();
            let mut changed = false;
            for s in &live {
                if !self.alive(s, &cfg, overrides, true, true) {
                    cfg.values.remove(s);
                    changed = true;
                }
            }
            if !changed {
                break;
            }
        }
        cfg.values
    }

    /// The live symbol whose `select` forces `sym` on, if any.
    fn selected_live(&self, sym: &str, cfg: &ConfigView) -> Option<String> {
        self.graph
            .selected_by
            .get(sym)
            .into_iter()
            .flatten()
            .find(|(from, cond)| cfg.is_set(from) && Self::cond_holds(cond.as_ref(), cfg, true))
            .map(|(from, _)| from.clone())
    }

    /// The boolean symbols that would turn ON in `values` (pessimistic: an
    /// opaque condition turns nothing on), as a fixpoint.
    fn appear(&self, values: &BTreeMap<String, String>, overrides: &BTreeMap<String, String>) -> BTreeSet<String> {
        let mut cfg = ConfigView { values: values.clone(), env: self.built.env.clone(), open: false };
        let mut out = BTreeSet::new();
        for _ in 0..16 {
            let mut changed = false;
            for (name, def) in &self.graph.symbols {
                if cfg.values.contains_key(name) || !def.kind.is_boolean() || def.in_choice {
                    continue;
                }
                if overrides.get(name).map(|v| v == "n").unwrap_or(false) {
                    continue;
                }
                if self.alive(name, &cfg, overrides, false, false) {
                    cfg.values.insert(name.clone(), "y".into());
                    out.insert(name.clone());
                    changed = true;
                }
            }
            if !changed {
                break;
            }
        }
        out
    }

    /// **What if the row drops `drops`?** `with_appearing` also computes the
    /// symbols that would turn on (one pass over the whole tree; the candidate
    /// ranking skips it).
    pub fn what_if(&self, drops: &[String], with_appearing: bool) -> WhatIf {
        let drops_bare: Vec<String> = drops.iter().map(|d| bare(d).to_string()).collect();
        let overrides: BTreeMap<String, String> = drops_bare.iter().map(|d| (d.clone(), "n".to_string())).collect();
        let after = self.settle(self.settled.clone(), &overrides);
        let after_cfg = ConfigView { values: after.clone(), env: self.built.env.clone(), open: false };

        let mut w = WhatIf { drops: drops_bare.iter().map(|d| full(d)).collect(), model_noise: self.noise.len(), ..Default::default() };

        // Which drops hold?
        for d in &drops_bare {
            if !self.settled.contains_key(d) {
                w.held.push(Held { symbol: full(d), reason: "not on in this row's kernel".to_string() });
            } else if after.contains_key(d) {
                let prompt = self.graph.symbols.get(d).map(|def| def.prompt).unwrap_or(false);
                let reason = match self.selected_live(d, &after_cfg) {
                    Some(from) => format!("selected by CONFIG_{from}, which stays — drop it too"),
                    None if !prompt => "no prompt: an `is not set` line is ignored by olddefconfig; it leaves only with its causes".to_string(),
                    None => "the model keeps it on".to_string(),
                };
                w.held.push(Held { symbol: full(d), reason });
            }
        }

        for (s, _) in &self.settled {
            if after.contains_key(s) || overrides.contains_key(s) {
                continue;
            }
            if self.declared.contains(s) && self.user.get(s).map(|v| v != "n").unwrap_or(false) {
                w.declared_leaving.push(full(s));
            } else {
                w.leaving.push(full(s));
            }
        }

        if with_appearing {
            let now = self.appear(&after, &overrides);
            w.appearing = now.difference(&self.base_appear).map(|s| full(s)).collect();
        }

        // Refusals: every requirement on a symbol that leaves (or a veto on
        // one that appears).
        let gone: BTreeSet<String> = w.expected_gone();
        for sym in &gone {
            for (v, who) in self.reqs.rows.get(sym).into_iter().flatten() {
                if *v != KconfigValue::N {
                    w.refused.push(refused(sym, v, who));
                }
            }
        }
        for sym in &w.appearing {
            for (v, who) in self.reqs.rows.get(sym).into_iter().flatten() {
                if *v == KconfigValue::N {
                    w.refused.push(refused(sym, v, who));
                }
            }
        }
        w.refused.sort();
        w.refused.dedup();

        if let Some(sizes) = self.sizes {
            let gone_bare: BTreeSet<String> = gone.iter().map(|s| bare(s).to_string()).collect();
            let object_bytes = sizes.bytes_leaving(&gone_bare);
            let bz_bytes = self.bz_ratio.map(|r| (object_bytes as f64 * r).round() as u64).unwrap_or(0);
            w.estimate = Some(SizeEstimate { object_bytes, bz_bytes });
        }
        w
    }

    /// **The trim candidates** of this row: every ON boolean symbol with a
    /// prompt that no requirement keeps (directly or through what leaves with
    /// it) and that no live `select` forces back — each with its what-if —
    /// ranked by estimated size (or, without sizes, by how many symbols leave).
    pub fn candidates(&self, why: &WhyIndex) -> Candidates {
        let class_of: BTreeMap<&str, String> = why
            .rows
            .iter()
            .map(|r| {
                let c = match &r.class {
                    WhyClass::Feature { .. } => "feature".to_string(),
                    WhyClass::PresetOnly => "preset-only".to_string(),
                    WhyClass::KconfigDerived { head_class, .. } if !head_class.is_empty() => format!("derived:{head_class}"),
                    WhyClass::KconfigDerived { .. } => "derived".to_string(),
                };
                (r.symbol.as_str(), c)
            })
            .collect();
        let mut out = Candidates::default();
        for (sym, val) in &self.settled {
            let Some(def) = self.graph.symbols.get(sym) else { continue };
            if !def.prompt || !def.kind.is_boolean() || val == "n" {
                continue;
            }
            let class = class_of.get(full(sym).as_str()).cloned().unwrap_or_else(|| "derived".to_string());
            if class == "feature" {
                out.required += 1;
                continue;
            }
            let w = self.what_if(&[full(sym)], true);
            if !w.refused.is_empty() {
                out.refused += 1;
                continue;
            }
            if !w.held.is_empty() {
                out.held += 1;
                continue;
            }
            out.rows.push(Candidate {
                symbol: full(sym),
                class,
                leaving: w.leaving.len() + w.declared_leaving.len(),
                estimate: w.estimate,
                near_miss: Vec::new(),
                no_measured_caller: false,
            });
        }
        out.rows.sort_by(|a, b| {
            let ka = (a.estimate.map(|e| e.object_bytes).unwrap_or(0), a.leaving);
            let kb = (b.estimate.map(|e| e.object_bytes).unwrap_or(0), b.leaving);
            kb.cmp(&ka).then_with(|| a.symbol.cmp(&b.symbol))
        });
        out
    }
}

fn refused(sym: &str, v: &KconfigValue, who: &Requirer) -> Refused {
    Refused {
        symbol: full(sym),
        wanted: v.as_str(),
        consumer: who.consumer.clone(),
        feature: who.feature.clone(),
        reason: who.reason.clone(),
    }
}

fn live_values(c: &ConfigView) -> BTreeMap<String, String> {
    c.values.iter().filter(|(_, v)| v.as_str() != "n").map(|(k, v)| (k.clone(), v.clone())).collect()
}

/// One trim candidate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Candidate {
    /// `CONFIG_X`.
    pub symbol: String,
    /// `preset-only`, `derived:<head class>` (the why-index's class).
    pub class: String,
    /// How many symbols leave with it (itself not counted).
    pub leaving: usize,
    /// Estimated bytes, when the tree was read.
    pub estimate: Option<SizeEstimate>,
    /// Near-miss calls the drop would take away (a warning, not a veto).
    pub near_miss: Vec<String>,
    /// A syscall-gating option no measured call needs (a hint).
    pub no_measured_caller: bool,
}

/// The ranked candidates, with what was left out and why.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Candidates {
    /// Ranked, biggest first.
    pub rows: Vec<Candidate>,
    /// Symbols a feature requires directly (never candidates).
    pub required: usize,
    /// Symbols whose drop would take a required symbol along.
    pub refused: usize,
    /// Symbols a live `select` would force back.
    pub held: usize,
    /// Candidates refused because their drop takes a measured call away
    /// (one entry per call), by name.
    pub syscall_vetoed: Vec<Veto>,
    /// The row's measured profiles were joined.
    pub syscalls_checked: bool,
}

impl Candidates {
    /// The human table, top `n`.
    pub fn render(&self, n: usize) -> String {
        let mut s = format!(
            "# trim candidates: {} (left out: {} required by a feature, {} would take a required symbol along, {} forced back by a select)\n",
            self.rows.len(),
            self.required,
            self.refused,
            self.held
        );
        if !self.syscalls_checked {
            s.push_str("# syscalls: not checked (no measured profiles for this row: measure syscalls <row>)\n");
        } else {
            let names: Vec<String> = self.syscall_vetoed.iter().map(Veto::render).collect();
            if names.is_empty() {
                s.push_str("# vetoed by a measured call (0)\n");
            } else {
                s.push_str(&format!("# vetoed by a measured call ({}): {}\n", names.len(), names.join("; ")));
            }
        }
        s.push_str(&format!("{:<4} {:<40} {:<26} {:>8} {:>12} {:>12}  {}\n", "#", "symbol", "class", "leaving", "obj bytes", "~bz bytes", "syscalls"));
        for (i, c) in self.rows.iter().take(n).enumerate() {
            let (o, b) = c.estimate.map(|e| (e.object_bytes.to_string(), e.bz_bytes.to_string())).unwrap_or(("-".into(), "-".into()));
            let note = if !c.near_miss.is_empty() {
                format!("near-miss: {}", c.near_miss.join("; "))
            } else if c.no_measured_caller {
                "no measured caller".to_string()
            } else {
                String::new()
            };
            s.push_str(&format!("{:<4} {:<40} {:<26} {:>8} {:>12} {:>12}  {}\n", i + 1, c.symbol, c.class, c.leaving, o, b, note).trim_end());
            s.push('\n');
        }
        s
    }

    /// The `preset-only` candidates (unclaimed fragment lines), ranked.
    pub fn preset_only(&self) -> Vec<&Candidate> {
        self.rows.iter().filter(|c| c.class == "preset-only").collect()
    }
}

// ── sizes off the built tree ────────────────────────────────────────────────

/// **What each Kconfig symbol costs in the built tree** — read off the
/// kernel's own Makefiles (`obj-$(CONFIG_X) += foo.o bar/`, and composite
/// parts `foo-$(CONFIG_X) += part.o`) and the objects actually linked into
/// `vmlinux` (the members of the thin archive `vmlinux.a`), each sized by its
/// allocated ELF sections.
///
/// Limits, stated: code under `#ifdef CONFIG_X` inside a file that stays is
/// not attributed (a floor, not the full delta); `lib-y` objects (`lib.a`) and
/// `ifdef` blocks in Makefiles are not read. The measured bzImage delta of a
/// what-if is the judge.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TreeSizes {
    /// Linked object (path relative to the source root) → allocated bytes.
    pub objects: BTreeMap<String, u64>,
    /// Object path or directory path (`dir/`) → the symbols that gate it.
    pub gates: BTreeMap<String, BTreeSet<String>>,
    /// Composite parts: `(part path, parent object path, symbol, bytes)`.
    pub parts: Vec<(String, String, String, u64)>,
    /// Every listed part of a composite object (`foo-y += a.o`, `foo-objs :=
    /// a.o`, `foo-$(CONFIG_X) += a.o`) → its parent (`foo.o`): a built-in
    /// composite is linked as its parts, so the parent's gates are the part's.
    pub parents: BTreeMap<String, String>,
}

impl TreeSizes {
    /// The sum over every linked object.
    pub fn total_bytes(&self) -> u64 {
        self.objects.values().sum()
    }

    /// Build from Makefile texts (`(dir, text)`, dir `""` or `drivers/net/`),
    /// linked objects and a part sizer — the testable core of [`Self::read`].
    pub fn from_parts(makefiles: &[(String, String)], objects: BTreeMap<String, u64>, part_size: &dyn Fn(&str) -> Option<u64>) -> Self {
        let mut t = TreeSizes { objects, ..Default::default() };
        for (dir, text) in makefiles {
            for (prefix, items) in makefile_composites(text) {
                for item in items {
                    t.parents.insert(format!("{dir}{item}"), format!("{dir}{prefix}.o"));
                }
            }
            for (prefix, sym, items) in makefile_gates(text) {
                for item in items {
                    let path = format!("{dir}{item}");
                    if prefix == "obj" {
                        t.gates.entry(path).or_default().insert(sym.clone());
                    } else if item.ends_with(".o") {
                        let parent = format!("{dir}{prefix}.o");
                        if let Some(b) = part_size(&path) {
                            t.parts.push((path, parent, sym.clone(), b));
                        }
                    }
                }
            }
        }
        t
    }

    /// Every symbol gating an object: its own line plus every ancestor
    /// directory's line.
    pub fn gates_of(&self, obj: &str) -> BTreeSet<String> {
        let mut out = BTreeSet::new();
        if let Some(g) = self.gates.get(obj) {
            out.extend(g.iter().cloned());
        }
        let mut idx = 0;
        while let Some(p) = obj[idx..].find('/') {
            let dir = &obj[..idx + p + 1];
            if let Some(g) = self.gates.get(dir) {
                out.extend(g.iter().cloned());
            }
            idx += p + 1;
        }
        out
    }

    /// Bytes that leave when every symbol in `leaving` (bare names) is gone.
    pub fn bytes_leaving(&self, leaving: &BTreeSet<String>) -> u64 {
        let mut gone_objects = BTreeSet::new();
        let mut total = 0;
        for (obj, bytes) in &self.objects {
            if self.gates_of(obj).iter().any(|g| leaving.contains(g)) {
                gone_objects.insert(obj.as_str());
                total += bytes;
            }
        }
        for (_, parent, sym, bytes) in &self.parts {
            if leaving.contains(sym) && !gone_objects.contains(parent.as_str()) && self.objects.contains_key(parent) {
                total += bytes;
            }
        }
        total
    }

    /// **Read a built kernel tree**: every `Makefile`/`Kbuild` under `src`,
    /// the members of `vmlinux.a`, each sized off its ELF sections.
    pub fn read(src: &Path) -> Result<Self> {
        let archive = src.join("vmlinux.a");
        let members = thin_archive_members(&archive)?;
        let mut objects = BTreeMap::new();
        for m in members {
            if let Some(b) = elf_alloc_bytes(&src.join(&m)) {
                objects.insert(m, b);
            }
        }
        let mut makefiles = Vec::new();
        collect_makefiles(src, src, &mut makefiles)?;
        let root = src.to_path_buf();
        Ok(Self::from_parts(&makefiles, objects, &|p: &str| elf_alloc_bytes(&root.join(p))))
    }
}

fn collect_makefiles(root: &Path, dir: &Path, out: &mut Vec<(String, String)>) -> Result<()> {
    let rd = match std::fs::read_dir(dir) {
        Ok(r) => r,
        Err(_) => return Ok(()),
    };
    let mut subdirs = Vec::new();
    for e in rd.flatten() {
        let p = e.path();
        let Ok(ft) = e.file_type() else { continue };
        if ft.is_dir() {
            let name = e.file_name();
            let n = name.to_string_lossy();
            if !n.starts_with('.') && n != "Documentation" && n != "tools" {
                subdirs.push(p);
            }
        } else if ft.is_file() {
            let name = e.file_name();
            if name == "Makefile" || name == "Kbuild" {
                if let Ok(text) = std::fs::read_to_string(&p) {
                    let rel = dir.strip_prefix(root).unwrap_or(Path::new(""));
                    let mut d = rel.to_string_lossy().to_string();
                    if !d.is_empty() {
                        d.push('/');
                    }
                    out.push((d, text));
                }
            }
        }
    }
    for s in subdirs {
        collect_makefiles(root, &s, out)?;
    }
    Ok(())
}

/// The `<prefix>-$(CONFIG_X) <op> items…` lines of one Makefile (continuations
/// joined): `(prefix, X, items)`; items with a `$` are skipped.
pub fn makefile_gates(text: &str) -> Vec<(String, String, Vec<String>)> {
    let mut logical = Vec::new();
    let mut cur = String::new();
    for line in text.lines() {
        let l = line.split('#').next().unwrap_or("");
        if let Some(stripped) = l.trim_end().strip_suffix('\\') {
            cur.push_str(stripped);
            cur.push(' ');
            continue;
        }
        cur.push_str(l);
        logical.push(std::mem::take(&mut cur));
    }
    if !cur.is_empty() {
        logical.push(cur);
    }
    let mut out = Vec::new();
    for l in logical {
        let l = l.trim();
        let Some(pos) = l.find("-$(CONFIG_") else { continue };
        let prefix = &l[..pos];
        if prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
            continue;
        }
        let rest = &l[pos + "-$(CONFIG_".len()..];
        let Some(close) = rest.find(')') else { continue };
        let sym = &rest[..close];
        if sym.is_empty() || !sym.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
            continue;
        }
        let after = rest[close + 1..].trim_start();
        let items = if let Some(i) = after.strip_prefix("+=") {
            i
        } else if let Some(i) = after.strip_prefix(":=") {
            i
        } else if let Some(i) = after.strip_prefix('=') {
            i
        } else {
            continue;
        };
        let items: Vec<String> = items
            .split_whitespace()
            .filter(|i| !i.contains('$') && (i.ends_with(".o") || i.ends_with('/')))
            .map(str::to_string)
            .collect();
        if !items.is_empty() {
            out.push((prefix.to_string(), sym.to_string(), items));
        }
    }
    out
}

/// The kbuild list names that are not composite objects.
const KBUILD_LISTS: &[&str] = &[
    "obj", "lib", "extra", "always", "targets", "hostprogs", "userprogs", "subdir", "head", "core", "init", "libs", "drivers", "real-obj",
    "ccflags", "asflags", "ldflags", "subdir-ccflags", "clean-files", "no-clean-files",
];

/// The composite-object lists of one Makefile — `foo-y += a.o b.o`, `foo-objs
/// := a.o`, `foo-$(CONFIG_X) += a.o` — as `(foo, [a.o, …])`; kbuild's own
/// lists (`obj-y`, `lib-y`, …) are not composites.
pub fn makefile_composites(text: &str) -> Vec<(String, Vec<String>)> {
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut logical = Vec::new();
    for line in text.lines() {
        let l = line.split('#').next().unwrap_or("");
        if let Some(stripped) = l.trim_end().strip_suffix('\\') {
            cur.push_str(stripped);
            cur.push(' ');
            continue;
        }
        cur.push_str(l);
        logical.push(std::mem::take(&mut cur));
    }
    for l in logical {
        let l = l.trim();
        let Some(op) = l.find(|c| c == '+' || c == ':' || c == '=') else { continue };
        let lhs = l[..op].trim();
        let rhs = l[op..].trim_start_matches(['+', ':', '=']);
        let prefix = if let Some(p) = lhs.strip_suffix("-y").or_else(|| lhs.strip_suffix("-objs")) {
            p
        } else if let Some(pos) = lhs.find("-$(CONFIG_") {
            if !lhs.ends_with(')') {
                continue;
            }
            &lhs[..pos]
        } else {
            continue;
        };
        if prefix.is_empty() || KBUILD_LISTS.contains(&prefix) || !prefix.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
            continue;
        }
        let items: Vec<String> = rhs.split_whitespace().filter(|i| !i.contains('$') && i.ends_with(".o")).map(str::to_string).collect();
        if !items.is_empty() {
            out.push((prefix.to_string(), items));
        }
    }
    out
}

/// The member paths of a (thin, possibly nested) `ar` archive.
pub fn thin_archive_members(path: &Path) -> Result<Vec<String>> {
    let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
    let base = path.parent().unwrap_or(Path::new(""));
    let a = object::read::archive::ArchiveFile::parse(&*data).map_err(|e| anyhow!("{}: not an ar archive: {e}", path.display()))?;
    let mut out = Vec::new();
    for m in a.members() {
        let m = m.map_err(|e| anyhow!("{}: {e}", path.display()))?;
        let name = String::from_utf8_lossy(m.name()).to_string();
        if name.ends_with(".a") {
            let nested = base.join(&name);
            if let Ok(inner) = thin_archive_members(&nested) {
                let pre = Path::new(&name).parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
                for i in inner {
                    // Nested thin archive members are relative to the nested archive.
                    if pre.is_empty() || i.starts_with(&format!("{pre}/")) {
                        out.push(i);
                    } else {
                        out.push(format!("{pre}/{i}"));
                    }
                }
            }
            continue;
        }
        out.push(name);
    }
    if out.is_empty() {
        bail!("{}: no members", path.display());
    }
    Ok(out)
}

/// The allocated (loaded, file-backed) bytes of one ELF object.
pub fn elf_alloc_bytes(path: &Path) -> Option<u64> {
    use object::{Object, ObjectSection, SectionFlags, SectionKind};
    let data = std::fs::read(path).ok()?;
    let f = object::File::parse(&*data).ok()?;
    let mut total = 0;
    for s in f.sections() {
        let alloc = match s.flags() {
            SectionFlags::Elf { sh_flags, .. } => sh_flags.0 & 2 != 0,
            _ => false,
        };
        if !alloc || matches!(s.kind(), SectionKind::UninitializedData | SectionKind::UninitializedTls) {
            continue;
        }
        total += s.size();
    }
    Some(total)
}

// ── the inverse check ───────────────────────────────────────────────────────

/// **The measured answer** of a what-if: the recomposed kernel against the
/// baseline.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct InverseCheck {
    /// bzImage bytes, baseline.
    pub base_bz: u64,
    /// bzImage bytes, recomposed.
    pub whatif_bz: u64,
    /// Listed by the what-if and really gone.
    pub gone_as_listed: Vec<String>,
    /// Listed by the what-if but still ON in the recompose — the model was wrong.
    pub listed_but_stayed: Vec<String>,
    /// Gone in the recompose but never listed — unexpected.
    pub gone_unlisted: Vec<String>,
    /// ON in the recompose, not in the baseline, and not listed as appearing.
    pub appeared_unlisted: Vec<String>,
    /// Listed as appearing and really appeared.
    pub appeared_as_listed: Vec<String>,
    /// Same symbol, another value (an int/string option moved).
    pub changed_values: Vec<String>,
}

impl InverseCheck {
    /// Compare two built `.config`s against a what-if.
    pub fn of(w: &WhatIf, base_config: &str, whatif_config: &str, base_bz: u64, whatif_bz: u64) -> Self {
        let b = live_values(&ConfigView::parse(base_config));
        let a = live_values(&ConfigView::parse(whatif_config));
        let expected = w.expected_gone();
        let appearing: BTreeSet<String> = w.appearing.iter().cloned().collect();
        let mut c = InverseCheck { base_bz, whatif_bz, ..Default::default() };
        for sym in &expected {
            if a.contains_key(bare(sym)) {
                c.listed_but_stayed.push(sym.clone());
            } else {
                c.gone_as_listed.push(sym.clone());
            }
        }
        for (k, v) in &b {
            match a.get(k) {
                None if !expected.contains(&full(k)) => c.gone_unlisted.push(full(k)),
                Some(v2) if v2 != v => c.changed_values.push(format!("CONFIG_{k}: {v} -> {v2}")),
                _ => {}
            }
        }
        for k in a.keys() {
            if !b.contains_key(k) {
                if appearing.contains(&full(k)) {
                    c.appeared_as_listed.push(full(k));
                } else {
                    c.appeared_unlisted.push(full(k));
                }
            }
        }
        c
    }

    /// The inverse holds: everything listed is gone, nothing unlisted changed.
    pub fn holds(&self) -> bool {
        self.listed_but_stayed.is_empty() && self.gone_unlisted.is_empty() && self.appeared_unlisted.is_empty() && self.changed_values.is_empty()
    }

    /// bzImage delta (negative = smaller).
    pub fn delta(&self) -> i64 {
        self.whatif_bz as i64 - self.base_bz as i64
    }

    /// The human text.
    pub fn render(&self) -> String {
        let mut s = format!(
            "# measured: bzImage {} -> {} bytes ({:+})\ninverse: {}\ngone as listed: {}\n",
            self.base_bz,
            self.whatif_bz,
            self.delta(),
            if self.holds() { "HOLDS" } else { "BROKEN" },
            self.gone_as_listed.len()
        );
        let mut list = |name: &str, v: &Vec<String>| {
            if !v.is_empty() {
                s.push_str(&format!("{name} ({}): {}\n", v.len(), v.join(" ")));
            }
        };
        list("listed but stayed", &self.listed_but_stayed);
        list("gone but not listed", &self.gone_unlisted);
        list("appeared, listed", &self.appeared_as_listed);
        list("appeared, not listed", &self.appeared_unlisted);
        list("changed values", &self.changed_values);
        s
    }
}

// ── the composer seam + the CLI verbs ───────────────────────────────────────

/// A composed kernel on disk: everything the static half reads.
#[derive(Debug, Clone)]
pub struct Composed {
    /// `bzImage`.
    pub bz_image: PathBuf,
    /// The built `.config`.
    pub config: PathBuf,
    /// The base `.config` (before the fragment).
    pub base_config: PathBuf,
    /// The extracted, built source tree (`linux-<version>`).
    pub src: PathBuf,
}

/// **Compose one kernel** (the seam: the live leg builds it in the kernel
/// toolchain container; a test hands back fixtures).
pub trait Composer {
    /// Build `k` with `work_root` as its repo root (so `.kernel-out/` lands there).
    fn compose(&self, k: &crate::kernel::KernelBuild, work_root: &Path) -> Result<Composed>;
}

/// The paths a compose in `work_root` leaves (whether or not it exists yet).
pub fn composed_paths(k: &crate::kernel::KernelBuild, work_root: &Path) -> Composed {
    let out = crate::kernel::kernel_output_dir(work_root, k);
    Composed {
        bz_image: out.join("bzImage"),
        config: out.join("kernel.config"),
        base_config: out.join(crate::kernel::KERNEL_BASE_CONFIG),
        src: out.join("work").join("src").join(format!("linux-{}", k.version)),
    }
}

impl Composed {
    /// Is everything the static half reads present?
    pub fn complete(&self) -> bool {
        self.bz_image.is_file() && self.config.is_file() && self.base_config.is_file() && self.src.join("Kconfig").is_file()
    }
}

/// The LIVE composer: the same `build_kernel_with` every kernel row uses
/// (pinned tarball, the nordisk kernel toolchain image, airgapped).
#[cfg(feature = "kernel-build")]
pub struct LiveComposer;

#[cfg(feature = "kernel-build")]
impl Composer for LiveComposer {
    fn compose(&self, k: &crate::kernel::KernelBuild, work_root: &Path) -> Result<Composed> {
        let runner = crate::kernel::DraupnirKernelRunner::new();
        crate::kernel::build_kernel_with(&crate::kernel::UreqFetcher, &runner, k, work_root)?;
        let c = composed_paths(k, work_root);
        if !c.complete() {
            bail!("kernel '{}' composed in {} but the tree or a config is missing", k.name, work_root.display());
        }
        Ok(c)
    }
}

/// One row's kernel, as the recipe resolves it.
#[derive(Debug, Clone)]
pub struct RowKernel {
    /// The row (`rusternetes-node`).
    pub row: String,
    /// The `--build … --opt …` line it was resolved under.
    pub line: String,
    /// The chosen kernel declaration (decision + requirements landed).
    pub chosen: crate::kernel::KernelBuild,
    /// The repo's committed veto file + decision text (merged last).
    pub user_fragment: Option<String>,
}

impl RowKernel {
    /// The declaration with every file input frozen into text and `extra`
    /// lines merged last — buildable in any work root.
    pub fn frozen(&self, extra: &str) -> crate::kernel::KernelBuild {
        let mut k = self.chosen.clone();
        k.kconfig_fragment = None;
        let mut u = self.user_fragment.clone().unwrap_or_default();
        if !extra.is_empty() {
            if !u.is_empty() && !u.ends_with('\n') {
                u.push('\n');
            }
            u.push_str(extra);
        }
        k.decision_fragment = if u.is_empty() { None } else { Some(u) };
        k
    }

    /// The merged fragment of [`Self::frozen`].
    pub fn fragment(&self, extra: &str) -> Result<String> {
        let k = self.frozen(extra);
        crate::kernel::merged_fragment(&k, k.decision_fragment.as_deref())
    }

    fn requirements(&self) -> KernelRequirements {
        self.chosen.requirements.clone().unwrap_or_default()
    }

    /// The work root of one compose: `<work>/<row>-<sha12 of the fragment>`.
    pub fn work_root(&self, work: &Path, extra: &str) -> Result<PathBuf> {
        let frag = self.fragment(extra)?;
        let sha = nornir_hash::sha256_hex(frag.as_bytes());
        Ok(work.join(format!("{}-{}", self.row, &sha[..12])))
    }

    /// Compose (or reuse) one kernel.
    pub fn compose(&self, composer: &dyn Composer, work: &Path, extra: &str) -> Result<Composed> {
        let root = self.work_root(work, extra)?;
        let k = self.frozen(extra);
        let c = composed_paths(&k, &root);
        if c.complete() {
            return Ok(c);
        }
        std::fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
        composer.compose(&k, &root)
    }
}

/// What the static half loads off one composed baseline.
pub struct Loaded {
    /// The graph of the composed tree.
    pub graph: KconfigGraph,
    /// The merged fragment.
    pub fragment: String,
    /// The built `.config` text.
    pub config: String,
    /// The base `.config` text.
    pub base: String,
    /// The tree's sizes, when `vmlinux.a` is there.
    pub sizes: Option<TreeSizes>,
    /// bzImage bytes.
    pub bz_bytes: u64,
    /// The composed paths.
    pub composed: Composed,
}

/// Load a composed baseline for the static half.
pub fn load(row: &RowKernel, composed: Composed) -> Result<Loaded> {
    let k = &row.chosen;
    let graph = KconfigGraph::read(&composed.src, crate::kconfig_graph::kconfig_env(&k.arch, &k.version))?;
    let read = |p: &Path| std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()));
    Ok(Loaded {
        graph,
        fragment: row.fragment("")?,
        config: read(&composed.config)?,
        base: read(&composed.base_config)?,
        sizes: TreeSizes::read(&composed.src).ok(),
        bz_bytes: std::fs::metadata(&composed.bz_image).map(|m| m.len()).unwrap_or(0),
        composed,
    })
}

/// The parsed `kernel …` verb.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KernelVerb {
    /// `what-if`, `candidates` or `needs`.
    pub verb: String,
    /// The row.
    pub row: String,
    /// `--drop CONFIG_X` (what-if).
    pub drops: Vec<String>,
    /// `--measure` (what-if).
    pub measure: bool,
    /// `--top N` (candidates, default 20).
    pub top: usize,
    /// `--work DIR`.
    pub work: Option<PathBuf>,
    /// `--json`: the machine form (schemas `nornir.kernel-what-if/1`,
    /// `nornir.kernel-candidates/1`, `nornir.kernel-needs/1`).
    pub json: bool,
    /// The repo root whose `.nornir/profiles/<row>/` holds the row's
    /// measured syscalls (set by the door, not a flag). `None` = the join is
    /// not checked (what-if / candidates say so; `needs` refuses).
    pub repo_root: Option<PathBuf>,
    /// The rest: the `--build … --opt …` line.
    pub line: String,
    /// `--run <suite>` (coverage, veto; default `bridge`).
    pub suite: String,
    /// `veto <row> CONFIG_X` — the ONE symbol.
    pub symbol: Option<String>,
}

/// The verbs of the `kernel` door.
pub const KERNEL_VERBS: &[&str] = &["what-if", "candidates", "needs", "coverage", "veto", "vetoes"];

/// Parse `what-if <row> --drop CONFIG_X … [--measure] [--work D] [--json] [line…]` /
/// `candidates <row> [--top N] [--work D] [--json] [line…]` /
/// `needs <row> [--work D] [--json] [line…]` / `coverage <row> [--run
/// <suite>]` / `veto <row> CONFIG_X [--run <suite>]` / `vetoes <row>`.
pub fn parse_kernel_verb(args: &[String]) -> Result<KernelVerb> {
    let usage = "usage: kernel what-if <row> --drop CONFIG_X [--drop …] [--measure] [--work <dir>] [--json] [--build kernel --opt …] | kernel candidates <row> [--top N] [--work <dir>] [--json] [--build kernel --opt …] | kernel needs <row> [--work <dir>] [--json] [--build kernel --opt …] | kernel coverage <row> [--run <suite>] | kernel veto <row> CONFIG_X [--run <suite>] | kernel vetoes <row>";
    let verb = args.first().ok_or_else(|| anyhow!("{usage}"))?.clone();
    if !KERNEL_VERBS.contains(&verb.as_str()) {
        bail!("unknown kernel verb `{verb}` — {usage}");
    }
    let row = args.get(1).filter(|r| !r.starts_with("--")).ok_or_else(|| anyhow!("{usage}"))?.clone();
    let mut v = KernelVerb { verb, row, top: 20, suite: "bridge".to_string(), ..Default::default() };
    let mut rest = Vec::new();
    let mut i = 2;
    while i < args.len() {
        match args[i].as_str() {
            sym if sym.starts_with("CONFIG_") && v.verb == "veto" => {
                if let Some(first) = &v.symbol {
                    bail!("kernel veto takes ONE symbol per run ({first} and {sym} given) — each veto gets its own full rerun and receipt");
                }
                v.symbol = Some(sym.to_string());
                i += 1;
            }
            "--run" => {
                v.suite = args.get(i + 1).filter(|s| !s.starts_with("--")).ok_or_else(|| anyhow!("--run needs a suite name"))?.clone();
                i += 2;
            }
            "--drop" => {
                let s = args.get(i + 1).ok_or_else(|| anyhow!("--drop needs a CONFIG_ symbol"))?;
                if !s.starts_with("CONFIG_") || s.len() <= 7 {
                    bail!("--drop {s:?}: not a CONFIG_* symbol");
                }
                v.drops.push(s.clone());
                i += 2;
            }
            "--measure" => {
                v.measure = true;
                i += 1;
            }
            "--json" => {
                v.json = true;
                i += 1;
            }
            "--top" => {
                v.top = args.get(i + 1).and_then(|n| n.parse().ok()).ok_or_else(|| anyhow!("--top needs a number"))?;
                i += 2;
            }
            "--work" => {
                v.work = Some(PathBuf::from(args.get(i + 1).ok_or_else(|| anyhow!("--work needs a directory"))?));
                i += 2;
            }
            other => {
                rest.push(other.to_string());
                i += 1;
            }
        }
    }
    if v.verb == "what-if" && v.drops.is_empty() {
        bail!("what-if needs at least one --drop CONFIG_X — {usage}");
    }
    if v.verb != "what-if" && (v.measure || !v.drops.is_empty()) {
        bail!("{} takes no --drop/--measure — {usage}", v.verb);
    }
    if v.verb == "veto" && v.symbol.is_none() {
        bail!("kernel veto needs the ONE CONFIG_X to veto — {usage}");
    }
    let mut line = rest.join(" ");
    if !line.contains("--build") {
        line = if line.is_empty() { "--build kernel".to_string() } else { format!("--build kernel {line}") };
    }
    v.line = line;
    Ok(v)
}

/// What the coverage and veto verbs need besides the composer.
#[derive(Default)]
pub struct VerbContext<'a> {
    /// The row repo's root (`.nornir/kernel-coverage`, `.nornir/kernel-vetoes`).
    pub repo_root: Option<&'a Path>,
    /// The row's check suite (KVM).
    pub suite: Option<&'a dyn veto::SuiteRunner>,
    /// The row's veto list.
    pub store: Option<&'a dyn veto::VetoStore>,
    /// Tool lines for reports and receipts.
    pub tools: Vec<String>,
}

#[derive(Serialize)]
struct WhatIfJson<'a> {
    schema: &'static str,
    row: &'a str,
    kernel: &'a str,
    version: &'a str,
    baseline_bz: u64,
    verdict: &'static str,
    what_if: &'a WhatIf,
    measured: Option<&'a InverseCheck>,
}

#[derive(Serialize)]
struct CandidatesJson<'a> {
    schema: &'static str,
    row: &'a str,
    kernel: &'a str,
    version: &'a str,
    baseline_bz: u64,
    model_noise: usize,
    candidates: &'a Candidates,
}

/// **Run the `kernel` verb** for a row the recipe already resolved.
pub fn run_kernel_verb(v: &KernelVerb, row: &RowKernel, composer: &dyn Composer, default_work: &Path) -> Result<String> {
    run_kernel_verb_in(v, row, composer, default_work, &VerbContext::default())
}

/// [`run_kernel_verb`] with the coverage/veto context.
pub fn run_kernel_verb_in(v: &KernelVerb, row: &RowKernel, composer: &dyn Composer, default_work: &Path, cx: &VerbContext<'_>) -> Result<String> {
    let work = v.work.clone().unwrap_or_else(|| default_work.to_path_buf());
    let need_root = || cx.repo_root.ok_or_else(|| anyhow!("kernel {}: no repo root given to this door", v.verb));
    let tool = || {
        let mut t = vec![format!("nornir-build-thing {}", env!("CARGO_PKG_VERSION"))];
        t.extend(cx.tools.iter().cloned());
        t
    };
    match v.verb.as_str() {
        "coverage" => {
            let root = need_root()?;
            let suite = cx.suite.ok_or_else(|| anyhow!("kernel coverage: this door has no suite runner"))?;
            let out = work.join(format!("{}-coverage", row.row));
            let (r, p) = veto::run_coverage(row, &v.suite, suite, root, &out, &tool().join("; "))?;
            return Ok(format!("{}report: {}\n", r.render(v.top), p.display()));
        }
        "veto" => {
            let root = need_root()?;
            let suite = cx.suite.ok_or_else(|| anyhow!("kernel veto: this door has no suite runner"))?;
            let store = cx.store.ok_or_else(|| anyhow!("kernel veto: this door has no veto list"))?;
            let sym = v.symbol.as_deref().ok_or_else(|| anyhow!("kernel veto needs a CONFIG_X"))?;
            let out = work.join(format!("{}-veto", row.row));
            let vcx = veto::VetoContext { repo_root: root, store, composer, suite, work: &work, out: &out, tools: tool() };
            let (r, p) = veto::run_veto(row, sym, &v.suite, &vcx)?;
            return Ok(format!("{}receipt: {}\n", r.render(), p.display()));
        }
        "vetoes" => {
            let root = need_root()?;
            let list = match cx.store {
                Some(s) => veto::parse_vetoes(&s.read(&row.row)?)?,
                None => Vec::new(),
            };
            return Ok(veto::render_receipts(&row.row, &veto::list_receipts(root, &row.row)?, &list));
        }
        _ => {}
    }
    let base = row.compose(composer, &work, "")?;
    let l = load(row, base)?;
    let reqs = row.requirements();
    let mut model = TrimModel::new(&l.graph, &l.fragment, &l.config, &l.base, &reqs);
    if let Some(s) = &l.sizes {
        model = model.with_sizes(s, Some(l.bz_bytes));
    }
    // The join: the row's measured calls and the tree's syscall map (cached
    // per kernel version under the work dir).
    let measured = match &v.repo_root {
        Some(r) => MeasuredRow::load(r, &row.row)?,
        None => None,
    };
    let map = match &measured {
        Some(_) => Some(SyscallMap::cached(&l.composed.src, &work.join("syscall-map"), &row.chosen.arch, &row.chosen.version)?),
        None => None,
    };
    let joined = measured.as_ref().zip(map.as_ref());
    let mut out = format!(
        "# row {} ({}) — kernel {} {} — baseline bzImage {} bytes, {}\n",
        row.row,
        row.line,
        row.chosen.name,
        row.chosen.version,
        l.bz_bytes,
        l.composed.bz_image.display()
    );
    match v.verb.as_str() {
        "needs" => {
            let Some((m, map)) = joined else {
                bail!(
                    "no measured profiles for row {} ({}/{}/{}): run `measure syscalls {}` first",
                    row.row,
                    crate::measure::PROFILES_DIR,
                    row.row,
                    crate::measure::EVIDENCE,
                    row.row
                );
            };
            let mut n = syscall_join::needs(map, m, &l.config);
            n.kernel = row.chosen.name.clone();
            if v.json {
                return Ok(serde_json::to_string_pretty(&n)?);
            }
            out.push_str(&n.render());
        }
        "candidates" => {
            let why = WhyIndex::of(&l.fragment, &reqs).with_built_config(&l.config, &reqs);
            let why = why.with_kconfig_chains(&l.composed.src, &l.fragment, &l.config, &l.base, &row.chosen.arch, &row.chosen.version).unwrap_or_else(|_| WhyIndex::of(&l.fragment, &reqs).with_built_config(&l.config, &reqs));
            let mut c = model.candidates(&why);
            if let Some((m, map)) = joined {
                syscall_join::join_candidates(&mut c, &model, map, m, &l.config);
            }
            if v.json {
                let j = CandidatesJson {
                    schema: syscall_join::KERNEL_CANDIDATES_SCHEMA,
                    row: &row.row,
                    kernel: &row.chosen.name,
                    version: &row.chosen.version,
                    baseline_bz: l.bz_bytes,
                    model_noise: model.model_noise().len(),
                    candidates: &c,
                };
                return Ok(serde_json::to_string_pretty(&j)?);
            }
            out.push_str(&format!("# model noise on the unchanged baseline: {} symbol(s), subtracted from every answer\n", model.model_noise().len()));
            out.push_str(&c.render(v.top));
            if let Some(root) = cx.repo_root {
                match coverage::load_for(root, &row.row, &l.fragment)? {
                    Some(cov) => {
                        let protect = coverage::ProtectList::load(root, &row.row)?;
                        let rows = veto::annotate(&model, &c, &cov, &protect);
                        out.push_str(&format!("# coverage: suite {} measured {} ({})\n", cov.suite, cov.measured_on, cov.snapshots.join(" ")));
                        out.push_str(&veto::render_annotated(&rows, v.top));
                    }
                    None => out.push_str(&format!("# coverage: none measured on this fragment — `kernel coverage {} --run <suite>`\n", row.row)),
                }
            }
        }
        _ => {
            let mut w = model.what_if(&v.drops, true);
            if let Some((m, map)) = joined {
                w.join_syscalls(map, m);
            }
            out.push_str(&w.render());
            let mut measured_check = None;
            if v.measure {
                if !w.is_clean() {
                    bail!("{out}--measure refused: the what-if is not clean (see REFUSED/HELD above)");
                }
                let extra = w.fragment_lines();
                let after = row.compose(composer, &work, &extra)?;
                let read = |p: &Path| std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()));
                let wbz = std::fs::metadata(&after.bz_image).map(|m| m.len()).unwrap_or(0);
                let ic = InverseCheck::of(&w, &l.config, &read(&after.config)?, l.bz_bytes, wbz);
                out.push_str(&format!("recomposed: {}\n", after.bz_image.display()));
                out.push_str(&ic.render());
                measured_check = Some(ic);
            }
            if v.json {
                let j = WhatIfJson {
                    schema: syscall_join::KERNEL_WHAT_IF_SCHEMA,
                    row: &row.row,
                    kernel: &row.chosen.name,
                    version: &row.chosen.version,
                    baseline_bz: l.bz_bytes,
                    verdict: w.verdict(),
                    what_if: &w,
                    measured: measured_check.as_ref(),
                };
                return Ok(serde_json::to_string_pretty(&j)?);
            }
        }
    }
    Ok(out)
}

/// **The coverage half** — the DEV probe kernel (`kernel.coverage=gcov`), its
/// gcov counters read out of a booted VM's memory, and per `CONFIG_*` symbol
/// executed / never executed / unmeasured under the row's check suite.
pub mod coverage;
/// **Row vetoes, one at a time, each with a receipt** — `kernel veto <row>
/// CONFIG_X` applies one veto, recomposes, reruns the suite, keeps it only
/// when the suite stays green.
pub mod veto;

#[cfg(test)]
mod tests;