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")
);
}