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/tests/kconfig_solver_live.rs

//! **THE INVERSE TEST — the only thing that can tell a real chain from a
//! decorative one.** (lane AP, 2026-08-30, backlog #10.)
//!
//! The kconfig solver ([`nornir_build_thing::kconfig_graph`]) says, for each
//! `kconfig-derived` symbol of a composed kernel, WHICH declaration pulled it
//! in. Every test that reads the solver's own output would pass just as
//! happily if those chains were invented, because the solver is both the
//! claimant and the witness. So this door asserts through a DIFFERENT one: the
//! kernel's own `scripts/kconfig/conf`, the program `olddefconfig` runs.
//!
//! For each declared head H of the merged fragment:
//!
//! 1. withdraw the declaration (`CONFIG_H=y` → `# CONFIG_H is not set`),
//! 2. recompose the `.config` exactly as `build_script` does — base, merge,
//!    `olddefconfig` — with the REAL `conf`,
//! 3. and check both directions:
//!    * **(a) causal** — every symbol whose ONLY head is H must be GONE.
//!      A chain that survives its own head's removal was decoration.
//!    * **(b) sound** — every symbol that DID disappear must have H among its
//!      heads. Something that vanishes with H and that the solver could not
//!      link to H is an edge the graph does not see.
//!
//! Both are needed. (a) alone passes a solver that claims nothing; (b) alone
//! passes a solver that claims everything.
//!
//! # Running it
//!
//! It is `#[ignore]`d because it needs an extracted kernel source with its
//! `scripts/kconfig/conf` built, and it runs ~50 composes.
//!
//! ```text
//! NORNIR_KCONFIG_SRC=/path/to/linux-6.12.104 \
//! NORNIR_KCONFIG_FRAGMENT=/path/to/work/fragment.config \
//!   cargo test -p nornir-build-thing --test kconfig_solver_live -- --ignored --nocapture
//! ```
//!
//! Set `NORNIR_KCONFIG_HEADS` to a `·`-separated list to test only those heads
//! (that is how the RED is produced: pick one, break the chain, watch it fail).

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

use nornir_build_thing::kconfig_graph::{Cause, Head, KconfigGraph, Solver, kconfig_env};

/// Symbols set to anything but `n` in a `.config`, without the `CONFIG_` prefix.
fn live_symbols(text: &str) -> BTreeSet<String> {
    text.lines()
        .filter_map(|l| l.trim().strip_prefix("CONFIG_"))
        .filter_map(|r| r.split_once('='))
        .filter(|(_, v)| *v != "n")
        .map(|(s, _)| s.to_string())
        .collect()
}

/// `merge_config.sh -m` in twelve lines: later wins, by symbol.
fn merge(base: &str, frag: &str) -> String {
    let sym = |l: &str| -> Option<String> {
        let t = l.trim();
        if let Some(r) = t.strip_prefix("# CONFIG_") {
            return r.strip_suffix(" is not set").map(str::to_string);
        }
        t.strip_prefix("CONFIG_").and_then(|r| r.split_once('=')).map(|(s, _)| s.to_string())
    };
    let over: BTreeSet<String> = frag.lines().filter_map(sym).collect();
    let mut out: Vec<String> = base
        .lines()
        .filter(|l| sym(l).map(|s| !over.contains(&s)).unwrap_or(true))
        .map(str::to_string)
        .collect();
    out.extend(frag.lines().filter(|l| sym(l).is_some()).map(str::to_string));
    out.join("\n") + "\n"
}

/// Run the kernel's own `conf` in `dir`, over the source at `src`.
fn conf(src: &Path, dir: &Path, args: &[&str], allconfig: Option<&Path>) {
    let mut c = Command::new(src.join("scripts/kconfig/conf"));
    c.current_dir(dir)
        .env("srctree", src)
        .env("ARCH", "x86_64")
        .env("SRCARCH", "x86")
        .env("KERNELVERSION", "6.12.104")
        .env("CC", "gcc")
        .env("HOSTCC", "gcc")
        .env("LD", "ld")
        .args(args)
        .arg("Kconfig");
    if let Some(a) = allconfig {
        c.env("KCONFIG_ALLCONFIG", a);
    }
    let out = c.output().expect("run scripts/kconfig/conf — is the source tree built?");
    assert!(out.status.success(), "conf {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
}

/// `make tinyconfig` + merge the fragment + `olddefconfig`, exactly the order
/// `nornir_build_thing::kernel::build_script` renders into the container.
fn compose(src: &Path, dir: &Path, fragment: &str) -> (String, String) {
    std::fs::create_dir_all(dir).unwrap();
    conf(src, dir, &["--allnoconfig"], Some(&src.join("kernel/configs/tiny-base.config")));
    let merged = merge(
        &std::fs::read_to_string(dir.join(".config")).unwrap(),
        &std::fs::read_to_string(src.join("kernel/configs/tiny.config")).unwrap(),
    );
    std::fs::write(dir.join(".config"), merged).unwrap();
    conf(src, dir, &["--olddefconfig"], None);
    let base = std::fs::read_to_string(dir.join(".config")).unwrap();
    let merged = merge(&base, fragment);
    std::fs::write(dir.join(".config"), merged).unwrap();
    conf(src, dir, &["--olddefconfig"], None);
    (std::fs::read_to_string(dir.join(".config")).unwrap(), base)
}

fn env_path(k: &str) -> Option<PathBuf> {
    std::env::var_os(k).map(PathBuf::from)
}

#[test]
#[ignore = "needs an extracted kernel source with scripts/kconfig/conf built (NORNIR_KCONFIG_SRC)"]
fn removing_the_head_of_a_chain_removes_the_symbol_the_chain_explains() {
    let src = env_path("NORNIR_KCONFIG_SRC").expect("set NORNIR_KCONFIG_SRC");
    let fragment_path = env_path("NORNIR_KCONFIG_FRAGMENT").expect("set NORNIR_KCONFIG_FRAGMENT");
    let fragment = std::fs::read_to_string(&fragment_path).unwrap();
    let scratch = std::env::temp_dir().join(format!("nornir-kconfig-inverse-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&scratch);

    let (built, base) = compose(&src, &scratch.join("ref"), &fragment);
    let graph = KconfigGraph::read(&src, kconfig_env("x86_64", "6.12.104")).unwrap();
    let solver = Solver::new(&graph, &fragment, &built, &base);

    // What the solver claims, per head. TWO relations, because they answer two
    // different questions and only both together are a real test:
    //   sole  — the symbol's ONLY cause is this declaration (the hard claim);
    //   gates — anything whose withdrawal COULD remove it (cause or `depends`).
    let derived: BTreeSet<String> = solver.derived().into_iter().collect();
    let mut sole: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for sym in &derived {
        let Cause::Explained { heads, .. } = solver.explain(sym) else { continue };
        let declared: Vec<&Head> =
            heads.iter().filter(|h| matches!(h, Head::Declared(_))).collect();
        if heads.len() == 1 && declared.len() == 1 {
            sole.entry(declared[0].name().to_string()).or_default().insert(sym.clone());
        }
    }
    let mut reach: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for (sym, hs) in solver.gate_closure() {
        if !derived.contains(&sym) {
            continue;
        }
        for h in hs.iter().filter(|h| matches!(h, Head::Declared(_))) {
            reach.entry(h.name().to_string()).or_default().insert(sym.clone());
        }
    }

    // The ANTI-OVER-CLAIM floor. Arm (a) is satisfied by a solver that claims
    // nothing, and arm (b) by one that claims everything — and "everything"
    // destroys soleness, so the size of the sole set is what stands between the
    // two. MEASURED 112 over the shipped `tunnr-kernel` fragment, 2026-08-30;
    // the corrupted solver that produced this test's first RED (keep only the
    // first cause) inflated it instead, and arm (a) caught that.
    let sole_total: usize = sole.values().map(BTreeSet::len).sum();
    eprintln!("derived symbols with exactly ONE declared head: {sole_total}");
    assert!(
        sole_total >= 100,
        "only {sole_total} derived symbols have a single declared head (112 when this floor \
         was set) — a solver that hands every symbol a crowd of heads makes arm (a) vacuous"
    );

    let only: Option<BTreeSet<String>> = std::env::var("NORNIR_KCONFIG_HEADS")
        .ok()
        .map(|s| s.split('·').map(|x| x.trim().to_string()).collect());

    let before = live_symbols(&built);
    // Only the declarations that are actually ON can be withdrawn. A fragment
    // line that already says `n` composes to the identical `.config` and would
    // add ~30 no-op recomposes (40 s each) that prove nothing.
    let mut heads: Vec<String> = solver
        .declared
        .iter()
        .filter(|h| solver.config.is_set(h))
        .filter(|h| fragment.lines().any(|l| l.starts_with(&format!("CONFIG_{h}="))))
        .cloned()
        .collect();
    heads.retain(|h| only.as_ref().map(|o| o.contains(h)).unwrap_or(true));

    let (mut claimed, mut claimed_gone) = (0usize, 0usize);
    let (mut vanished, mut vanished_linked) = (0usize, 0usize);
    let mut survivors: Vec<String> = Vec::new();
    let mut unlinked: Vec<String> = Vec::new();
    let mut vacuous: Vec<String> = Vec::new();
    let mut fell_total = 0usize;

    for h in &heads {
        // Withdraw the declaration.
        let withdrawn: String = fragment
            .lines()
            .map(|l| {
                if l.starts_with(&format!("CONFIG_{h}=")) {
                    format!("# CONFIG_{h} is not set")
                } else {
                    l.to_string()
                }
            })
            .collect::<Vec<_>>()
            .join("\n");
        let (after, _) = compose(&src, &scratch.join(h), &withdrawn);
        // A withdrawal ANOTHER declaration undoes proves nothing. `LIBCRC32C`
        // selects `CRYPTO_CRC32C` back the moment the latter's own line is
        // taken out, so scoring that head either way would be a lie — nine of
        // 55 heads on the shipped fragment are like this.
        if live_symbols(&after).contains(h.as_str()) {
            vacuous.push(h.clone());
            eprintln!("{h:28} VACUOUS — another declaration selects it back");
            continue;
        }
        let mut all_gone = &before - &live_symbols(&after);
        all_gone.remove(h.as_str());
        // A DECLARATION that fell with the head is not a solver miss — it is
        // lane AN's canary bug seen from the other side (`SERIAL_8250=n` legally
        // narrows the parent and `SERIAL_8250_CONSOLE=y` goes with it). It is
        // counted apart because it is a different defect with a different fix.
        let fell: BTreeSet<String> =
            all_gone.iter().filter(|s| solver.declared.contains(*s)).cloned().collect();
        fell_total += fell.len();
        let gone = &all_gone - &fell;

        let pred = sole.get(h).cloned().unwrap_or_default();
        let can = reach.get(h).cloned().unwrap_or_default();
        claimed += pred.len();
        claimed_gone += pred.intersection(&gone).count();
        vanished += gone.len();
        vanished_linked += gone.intersection(&can).count();
        survivors.extend((&pred - &gone).iter().map(|s| format!("{h} ⇒ {s}")));
        unlinked.extend((&gone - &can).iter().map(|s| format!("{h} ⇒ {s}")));
        eprintln!(
            "{h:28} sole={:3} gone={:3}   vanished={:3} linked={:3}   declarations-that-fell={:?}",
            pred.len(),
            pred.intersection(&gone).count(),
            gone.len(),
            gone.intersection(&can).count(),
            fell
        );
    }

    eprintln!("\n(a) sole-head symbols that really disappeared: {claimed_gone}/{claimed}");
    eprintln!("(b) vanished symbols the solver could reach  : {vanished_linked}/{vanished}");
    eprintln!("    vacuous withdrawals (head came back)     : {} {vacuous:?}", vacuous.len());
    eprintln!("    declarations that fell with another one  : {fell_total}");
    let _ = std::fs::remove_dir_all(&scratch);

    // (a) is the LAW-2 arm: a chain that claims to be a symbol's ONLY reason
    //     and does not remove it is decoration.
    assert!(
        survivors.is_empty(),
        "{} chain(s) claimed to be a symbol's sole cause and the symbol SURVIVED the head's \
         removal — that chain is decorative:\n{}",
        survivors.len(),
        survivors.join("\n")
    );
    assert!(claimed > 0, "no head claimed a sole cause at all — this test proved nothing");
    assert!(vanished > 0, "nothing disappeared at all — the withdrawals did not bite");

    // (b) is the completeness arm. It is a RATIO with a floor rather than an
    // equality, and the floor is stated where it was measured: 569/570 on the
    // shipped `tunnr-kernel` fragment, 2026-08-30. The one miss is `TREE_SRCU`,
    // held up by a NEGATED gate (`default y if !TINY_RCU`, and `TINY_RCU` is
    // `def_bool y if !SMP`) — the solver follows live parents, and a symbol
    // that is on because another is OFF has none.
    let ratio = vanished_linked as f64 / vanished as f64;
    assert!(
        ratio >= 0.98,
        "the gate closure reached only {vanished_linked}/{vanished} ({:.1} %) of the symbols \
         that really disappeared — it was 99.8 % when this floor was set. Unreached:\n{}",
        ratio * 100.0,
        unlinked.join("\n")
    );
}