dwarves-gui/src/kernel_scene.rs
//! **The KERNEL FEATURE SCENE — a preset's kconfig symbol set as a rotating 3D graph.**
//!
//! The `kernel` KIND already builds real kernels (a pinned kernel.org tarball compiled
//! inside a toolchain container, presets `full`/`normal`/`slim`/`nuked`) and the ⚒
//! matrix already has a cell to click. What an operator still could not do was SEE the
//! machine a preset describes, or ask the only question that matters before a compile
//! starts: *remove this part — what else stops being needed, and what breaks?*
//!
//! This pane answers that from the COMMITTED fragments alone. It runs no solver, shells
//! out to nothing, and starts no container.
//!
//! ## Reuse — this is the THIRD consumer of one camera, not a fourth 3D painter
//!
//! | piece | taken from | what it gives |
//! |---|---|---|
//! | the camera | `facett_graphview::camera3d::project_azimuth` | yaw-about-Y → pitch-about-X → orthographic `[x, y, depth]`. The SAME shared camera the funnel's 3D Plan-DAG and the warehouse world graph project through |
//! | the layout + cloud | `facett_graph3d::Graph3D` (`Layout3D::Force`) | the node/edge model and its deterministic `[-1,1]³` placement, computed ONCE in `Graph3D::new` and read back off `nodes[].pos` every frame |
//! | the state vocabulary | `facett_graph3d::NodeState{Ok, Changed, Glitch}` | the paint buckets: steady, amber-changed, red-glitch. Imported, not re-spelled |
//! | the discipline | this repo's `nornir-funnel-embed::graph3d` | auto-spin ≈15°/s, fixed gentle pitch, labels elided at 48 chars, and the invariant below |
//!
//! `Graph3D`'s own `view()` is never called: this pane paints its own domain colours
//! (the node-state vocabulary above), so the yaw it projects with lives HERE and there
//! is exactly one camera angle in the pane rather than two that can disagree.
//!
//! ## The rotate invariant (camera-only) — and it also survives a click
//!
//! The layout is keyed on the SYMBOL SET (preset × hardening), never on the camera
//! angle and never on which symbols you clicked off. So:
//!
//! * spinning re-projects cached positions — **0 rebuilds**;
//! * clicking a symbol off recolours nodes — **0 rebuilds** (a cascade is a repaint,
//! not a relayout; the graph must not jump under the pointer that just clicked it);
//! * changing the preset genuinely rebuilds — exactly once.
//!
//! [`kernel_graph_build_calls`] is process-wide and public precisely so a black-box
//! robot binary can assert `build_count == 1` across rotation frames, the same guard
//! `robot_funnel_dag_3d` uses.
//!
//! ## What the edges ARE — and what they are not
//!
//! There is no Kconfig DAG in this repository. The kernel tree's `depends on` / `select`
//! statements live in the tarball the build unpacks, and reading them means a container
//! round-trip this pane refuses to make. So the edge relation is derived from the
//! committed fragments and NOTHING else:
//!
//! * **the parent relation is the Kconfig NAME hierarchy** — `P` is the parent of `C`
//! when `P` is the longest symbol in the set such that `C` starts with `P` followed by
//! `_`. `CONFIG_VIRTIO → CONFIG_VIRTIO_PCI`, `CONFIG_SERIAL_8250 →
//! CONFIG_SERIAL_8250_CONSOLE`, `CONFIG_NLS → CONFIG_NLS_CODEPAGE_437`. Every edge it
//! produces over these fragments is a real dependency, checked by hand;
//! * a **closure edge** is one whose parent came from the tinyconfig dependency-closure
//! fragment — literally "the parent that enables this", the one relation the committed
//! files were written to record. `slim` has four of them, and **three** land on a boot
//! contract symbol (`contract_closure_edges`);
//! * **a symbol with no derivable parent and no derivable child is an ISOLATED node, and
//! the pane says how many there are.** MEASURED: `normal` 8 of 16, `slim` 16 of 32,
//! `nuked` 29 of 48. The closure fragment's own header claims it closes TWELVE contract
//! symbols; only three of those twelve pairs nest by name (`EFI → EFI_STUB`,
//! `NLS → NLS_CODEPAGE_437`, `NLS → NLS_ISO8859_1`). The other nine — `PCI` under
//! `VIRTIO_PCI`, `TTY` under `SERIAL_8250`, `BLOCK` under `VIRTIO_BLK`, `NETDEVICES`
//! under `TUN`, `FAT_FS` under `VFAT_FS` and the rest — are real dependencies whose
//! names do not nest, and this pane DOES NOT DRAW THEM. Inventing them would make the
//! cascade lie, and a cascade that lies is worse than no cascade.
//!
//! ## The floor this pane cannot see
//!
//! A preset is `base + fragment`, and `base` is `defconfig` or `tinyconfig` — a make
//! target inside the kernel tree. `full` therefore draws NOTHING (it contributes no
//! fragment), and `normal`'s sixteen nodes are its DELTA over a distro-ish kernel, not
//! its whole feature set. The pane states that in the canvas rather than implying a
//! sixteen-symbol kernel. It is also why `nuked` draws MORE nodes than `normal` while
//! building a far smaller kernel: `nuked`'s contents are stated as ABSENCE, and an
//! absence takes a line too. The honest "smaller" number is
//! `undecided` — how much of the kind's committed symbol universe (73 symbols) a preset
//! leaves to that invisible floor: `full` 73, `normal` 57, `slim` 41, `nuked` 25, and
//! `nuked` + `hardened` exactly 0.
//!
//! ## The solver's half — real Kconfig edges, read off a built kernel (2026-10-03)
//!
//! The name hierarchy is still what the CANVAS draws. What a click now also answers,
//! when the picked kernel has been built, is the cascade by Kconfig's OWN edges: edda's
//! kconfig solver (`nornir_build_thing::kconfig_graph`, lane AP 2026-08-30 — on an
//! unmerged branch until it was ported to edda `release-3` and `main` on 2026-10-03)
//! reads the donor's extracted `Kconfig` tree during the build and writes, for every
//! `kconfig-derived` symbol of the built `.config`, the chain back to the declaration,
//! base config, kconfig default or arch responsible. The pane reads that file
//! ([`KconfigWhy`], through edda's one reader) and starts no container: the
//! container round trip this pane refuses was already made by the build.
//!
//! The answer belongs to ONE built kernel. [`KconfigWhy::applies_to`] keeps a `nuked`
//! build's chains off a `slim` pick, and the canvas says which kernel they describe.
use std::sync::atomic::{AtomicU64, Ordering};
use egui::{Color32, Pos2, Sense, Stroke, Vec2};
use facett_core::{theme, Facet, Theme};
use facett_graph3d::{Graph3D, Layout3D, NodeState};
use facett_graphview::camera3d::project_azimuth;
use nornir_build_thing::kernel::{HardeningProfile, Preset};
use nornir_build_thing::kernel_requires::{WhyClass, WhyIndex};
/// Fixed camera pitch (radians) — the gentle tilt that makes the cloud read as 3D at
/// yaw 0. Same value as the funnel's 3D Plan-DAG and the world graph.
const PITCH: f32 = 0.42;
/// Auto-spin rate (radians/second) ≈ 15°/s — the constellation's one spin rate.
const ROTATE_RAD_PER_S: f32 = 0.26;
/// Longest symbol label painted beside a node. `CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT`
/// is 39 characters, so nothing in the committed fragments actually elides today — the
/// cap is here for the user fragment a repo is allowed to merge last.
const LABEL_MAX_CHARS: usize = 48;
/// Width of the clickable symbol list beside the canvas, px.
const LIST_W: f32 = 250.0;
/// The amber a cascade-dropped node glows — `NodeState::Changed`'s paint.
const AMBER: Color32 = Color32::from_rgb(255, 176, 32);
/// The red a refused node carries — `NodeState::Glitch`'s paint.
const RED: Color32 = Color32::from_rgb(255, 45, 55);
/// Process-wide count of genuine 3D-layout builds. Public so a black-box robot binary
/// (a separate process) can assert the camera-only invariant.
static BUILD_CALLS: AtomicU64 = AtomicU64::new(0);
/// Total 3D-layout builds process-wide. Rotation must never bump this, and neither must
/// clicking a symbol off.
#[must_use]
pub fn kernel_graph_build_calls() -> u64 {
BUILD_CALLS.load(Ordering::Relaxed)
}
/// Every preset the kind offers, in picker order.
pub const PRESETS: [Preset; 4] = [Preset::Full, Preset::Normal, Preset::Slim, Preset::Nuked];
/// **What the graph REFLECTS — the honesty note, on the canvas and in its state.**
///
/// The canvas draws `KernelFeatureModel::derive` (edda `kernel_features.rs`): edda's
/// COMMITTED fragments (`kconfig/base.config`, `deps-tinyconfig`, `hardened`, `nuked`,
/// `profile`, `minimal` — some eighty symbols), with edges from the NAME HIERARCHY (`P`
/// parents `C` when `C` starts with `P_`), not from Kconfig `depends on` / `select`. The
/// CHOICES are true (the fragment is what the kernel is built from); the MAP is curated
/// and its edges are guessed. Until `options("kernel")` parses real Kconfig from fetched
/// source (edda stage 2, the `fetch(coordinate)` host import), the pane says so — here,
/// verbatim, so a reader of the graph and a reader of its state get the same sentence.
pub const REFLECTS: &str = "grafen speglar eddas incheckade fragment (~80 symboler); kanterna är namnhierarkin, inte Kconfig depends/select — valen är sanna, kartan är kurerad. Riktig Kconfig kommer med options(\"kernel\") över fetch(coordinate)";
// ── the node-state vocabulary (design §10) — LOWERED to edda ─────────────────
//
// `SymbolState` and the symbol model used to be spelled here, fused to the camera
// that painted them — a twin of the derivation edda already owned (LAW 5, and this
// file said so about itself). They now live in
// `nornir_build_thing::kernel_features`, where every build thing that pulls a
// kernel reuses ONE derivation instead of parsing kconfig for itself (the owner,
// 2026-08-26). This pane keeps only what is genuinely paint: the camera, the
// labels, and the state→colour mapping below.
pub use nornir_build_thing::kernel_features::{
committed_universe, FeatureState as SymbolState, KernelFeature as Symbol, KernelFeatureModel,
ToggleOutcome,
};
/// The RENDER half of a [`SymbolState`] — the one thing about a state that is the
/// pane's to decide and not the model's.
trait SymbolPaint {
/// The shared 3D paint bucket. Amber is `Changed`, red is `Glitch` — the vocabulary
/// is imported from the 3D graph crate rather than re-spelled here, so "amber" means
/// the same thing in this pane as in every other 3D surface in the constellation.
fn paint(self) -> NodeState;
/// Dimmed paint — a clicked-off node is still drawn (you must be able to click it
/// back), just faded.
fn dimmed(self) -> bool;
}
impl SymbolPaint for SymbolState {
fn paint(self) -> NodeState {
match self {
SymbolState::Kept | SymbolState::Locked | SymbolState::ClickedOff => NodeState::Ok,
SymbolState::CascadeDropped => NodeState::Changed,
SymbolState::Refused => NodeState::Glitch,
}
}
fn dimmed(self) -> bool {
matches!(self, SymbolState::ClickedOff)
}
}
/// Shape a symbol name into its painted label — elided at [`LABEL_MAX_CHARS`]. The ONE
/// shaping `ui()` and [`KernelScene::visible_labels`] both go through, so the reported
/// text IS the painted text.
fn label_text(name: &str) -> String {
if name.chars().count() <= LABEL_MAX_CHARS {
name.to_string()
} else {
let mut s: String = name.chars().take(LABEL_MAX_CHARS - 1).collect();
s.push('…');
s
}
}
// ── the pane ─────────────────────────────────────────────────────────────────
/// **The kernel feature scene.** Pick a preset, watch its symbol set spin, click a
/// symbol off and read the cascade — all from committed text, with nothing built.
/// **What a RUN measured, as the pane consumes it**: `CONFIG symbol -> hits`,
/// summed over every function in every object that symbol compiles.
///
/// This is the join edda's `KernelRun::unused_configs` performs, brought over as
/// DATA so the pane depends on a measurement rather than on a live kernel build.
/// A report is produced by a profiled run (design §22/§23); until one is loaded,
/// the pane shows the DECLARED graph and says so — it must never draw a symbol
/// as a drop candidate on no evidence.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunReport {
/// Where it came from, shown to the operator so a stale report is visible.
pub source: String,
/// `CONFIG symbol -> total hits` for every symbol the mapping claimed.
pub hits: std::collections::BTreeMap<String, u64>,
}
impl RunReport {
/// Parse the container's `fn-to-config.txt` (`<function> <object> <CONFIG>`)
/// against the functions a run reported as executed.
///
/// Both halves are required, and that is the point: the mapping alone says
/// what EXISTS, the hit list alone says what RAN, and only the join says
/// what ran under a symbol. A function whose object no Makefile claims
/// carries `-` and is skipped — it is a finding for the build side, not a
/// symbol a person can click.
#[must_use]
pub fn from_mapping(source: impl Into<String>, mapping: &str, executed: &[String]) -> Self {
let ran: std::collections::BTreeSet<&str> = executed.iter().map(String::as_str).collect();
let mut hits: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
for line in mapping.lines() {
let mut it = line.split_whitespace();
let (Some(func), Some(_obj), Some(cfg)) = (it.next(), it.next(), it.next()) else {
continue;
};
if cfg == "-" {
continue;
}
let e = hits.entry(cfg.to_string()).or_insert(0);
if ran.contains(func) {
*e += 1;
}
}
RunReport {
source: source.into(),
hits,
}
}
/// Symbols the report covers whose functions never ran.
#[must_use]
pub fn never_touched(&self) -> Vec<&str> {
self.hits
.iter()
.filter(|(_, h)| **h == 0)
.map(|(k, _)| k.as_str())
.collect()
}
}
// ── what the kconfig SOLVER said about a BUILT kernel ──────────────────────
/// **The kconfig solver's answer for a BUILT kernel, as the pane consumes it** —
/// the `kernel.why.toml` a build leaves beside its `.config`, read through edda's
/// one reader ([`WhyIndex::from_toml`]).
///
/// This is the half of "real Kconfig" the pane CAN have without a container round
/// trip: edda's solver (`nornir_build_thing::kconfig_graph`) reads the donor's own
/// `Kconfig` tree WHILE the build has it extracted, walks `select` / `imply` /
/// `default` / `depends on` back to whatever is responsible, and writes the chain
/// into the index. The pane reads the file; it runs no solver and parses no Kconfig.
///
/// What it answers is exactly the question the cascade exists for — *"remove this
/// part, what else goes?"* — with Kconfig's own edges instead of the name
/// hierarchy: [`goes_with`](Self::goes_with) is every symbol whose ONLY head is the
/// clicked declaration ([`WhyIndex::sole_declared_head`]), the set edda's inverse
/// test proves leaves the `.config` when the declaration is withdrawn.
///
/// It describes ONE built kernel. A pick of another preset or profile is a
/// different kernel, so the pane says which kernel the chains belong to
/// ([`applies_to`](Self::applies_to)) rather than painting another preset's
/// answer onto this one.
#[derive(Debug, Clone, PartialEq)]
pub struct KconfigWhy {
/// Where the index was read from, shown so a stale one is visible.
pub source: String,
/// The preset the built kernel was composed from (its pin's `preset`).
pub preset: String,
/// The hardening profile it was composed with (its pin's `profile`).
pub profile: String,
/// The index itself.
pub index: WhyIndex,
}
impl KconfigWhy {
/// Parse a `kernel.why.toml` for a kernel built as `preset` × `profile`.
pub fn parse(
source: impl Into<String>,
preset: &str,
profile: &str,
why_toml: &str,
) -> Result<Self, String> {
let source = source.into();
let index = WhyIndex::from_toml(why_toml).map_err(|e| format!("{source}: {e}"))?;
Ok(Self {
source,
preset: preset.to_string(),
profile: profile.to_string(),
index,
})
}
/// **Read the index a build left in `out_dir`** (`kernel_output_dir(root, k)`):
/// `kernel.why.toml` plus `<name>.kernel-pin.json` for the preset it describes.
///
/// `Ok(None)` when nothing has been built there — the normal state of a fresh
/// checkout, and not an error. A file that exists and does not read IS one, and
/// is returned as the sentence the pane shows.
pub fn read(out_dir: &std::path::Path, kernel_name: &str) -> Result<Option<Self>, String> {
let why_path = out_dir.join(nornir_build_thing::kernel_requires::WHY_FILE);
let Ok(text) = std::fs::read_to_string(&why_path) else {
return Ok(None);
};
let pin_path = out_dir.join(format!("{kernel_name}.kernel-pin.json"));
let pin: nornir_build_thing::kernel::KernelPin = std::fs::read_to_string(&pin_path)
.map_err(|e| {
format!(
"{}: {e} — an index with no pin cannot say which kernel it describes",
pin_path.display()
)
})
.and_then(|t| {
serde_json::from_str(&t).map_err(|e| format!("{}: {e}", pin_path.display()))
})?;
Self::parse(
why_path.display().to_string(),
&pin.preset,
&pin.profile,
&text,
)
.map(Some)
}
/// Whether this index describes the kernel `preset` × `profile` composes.
#[must_use]
pub fn applies_to(&self, preset: Preset, profile: HardeningProfile) -> bool {
self.preset == preset.as_str() && self.profile == profile.as_str()
}
/// The third class, split: `(derived, chained, unexplained, unsolved)`. A row the
/// solver never looked at (no source, or a kernel built before it existed) is
/// UNSOLVED, never "nothing pulled it in".
#[must_use]
pub fn derived_counts(&self) -> (usize, usize, usize, usize) {
let mut c = (0, 0, 0, 0);
for r in &self.index.rows {
if let WhyClass::KconfigDerived {
chain, unexplained, ..
} = &r.class
{
c.0 += 1;
if !chain.is_empty() {
c.1 += 1;
} else if !unexplained.is_empty() {
c.2 += 1;
} else {
c.3 += 1;
}
}
}
c
}
/// **What goes if `symbol` goes** — every derived symbol whose ONLY head is the
/// declaration `symbol`, by Kconfig's own edges.
#[must_use]
pub fn goes_with(&self, symbol: &str) -> Vec<&str> {
self.index.sole_declared_head(symbol)
}
/// **Why `symbol` is in the built kernel**, as one sentence: who asked for it, or
/// the chain that pulled it in, or why there is none. `None` when the built
/// `.config` does not carry it at all.
#[must_use]
pub fn explain(&self, symbol: &str) -> Option<String> {
let r = self.index.rows.iter().find(|r| r.symbol == symbol)?;
Some(match &r.class {
WhyClass::Feature { requirers } => format!(
"declared — {}",
requirers
.iter()
.map(|q| format!("{} : {}", q.consumer, q.feature))
.collect::<Vec<_>>()
.join(", ")
),
WhyClass::PresetOnly => {
"declared by a preset fragment, and no feature asks for it".to_string()
}
WhyClass::KconfigDerived { chain, .. } if !chain.is_empty() => {
format!("kconfig-derived: {chain}")
}
WhyClass::KconfigDerived { unexplained, .. } if !unexplained.is_empty() => {
format!("kconfig-derived, no chain: {unexplained}")
}
WhyClass::KconfigDerived { .. } => {
"kconfig-derived, not solved for this build".to_string()
}
})
}
}
// ── the JOBS the buttons fire (design §5 / §25 gap 4) ───────────────────────
/// Which job a button asks for. Each is a real build-thing dispatch, not a
/// pane-local action: the pane names WHAT it wants and the host decides HOW.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KernelJobKind {
/// Build the declared kernel as it stands.
Build,
/// Build it with the SAT minify pass on (`solver = "klocalizer"`).
Minify,
/// Boot a profiled kernel and measure what a workload executes.
Run,
}
impl KernelJobKind {
/// The label the button carries — and the label a robot clicks.
#[must_use]
pub fn label(&self) -> &'static str {
match self {
KernelJobKind::Build => "BYGG",
KernelJobKind::Minify => "MINIFY",
KernelJobKind::Run => "RUN",
}
}
}
/// **What the pane asks the host to do.** Pure data, so a fake takes it whole
/// and a test can assert the DECLARATION a click produced — including the
/// decision fragment, which is the only reason a click matters.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KernelJobRequest {
pub kind: KernelJobKind,
/// The variant to build, as declared in a repo's recipe.
pub variant: String,
pub preset: String,
pub profile: String,
/// The clicked-off decision rendered as kconfig ([`KernelScene::decision_fragment`]).
/// Empty when nothing is clicked off.
pub fragment: String,
}
/// **A job's progress, composed rather than invented** (design §5).
///
/// `make` emits no percentage, so a single number would be a lie. The phase is
/// always known and always shown; `fraction` is `None` whenever the honest
/// answer is "no idea", and `estimated` marks the compile phase where the total
/// comes from the PREVIOUS build's object count rather than from this one.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct KernelJobProgress {
/// `fetch` / `unpack` / `solve` / `compile` / `link` / `probe` — whatever
/// the worker last reported.
pub phase: String,
/// `0.0..=1.0` when it can be known honestly, `None` when it cannot.
pub fraction: Option<f32>,
/// The compile phase's fraction is an ESTIMATE against a previous build.
pub estimated: bool,
/// The most recent line, so "what is it doing right now" is answerable.
pub current: String,
/// Terminal outcome: `Some(Ok(()))` green, `Some(Err(why))` a named
/// refusal, `None` still running.
pub done: Option<Result<(), String>>,
}
impl KernelJobProgress {
/// Read a worker line into a phase + an honest fraction.
///
/// The phases come from the kernel kind's own script, in its order, so this
/// cannot drift from what the container actually does without the strings
/// changing there first.
#[must_use]
pub fn from_line(line: &str, prev_objects: Option<u64>, objects_seen: u64) -> Self {
let l = line.trim();
let low = l.to_ascii_lowercase();
let (phase, fraction, estimated) = if low.contains("kernel-config-refused") {
("canary", None, false)
} else if low.contains("kernel-solver-refused") || low.contains("klocalizer") {
("solve", None, false)
} else if low.starts_with("cc ")
|| low.contains(" cc ")
|| low.contains(".o\n")
|| l.contains(".o") && low.contains("cc")
{
// The compile phase is the long one, and its total is the PREVIOUS
// build's object count — an estimate, and labelled as one.
let f = prev_objects
.filter(|t| *t > 0)
.map(|t| (objects_seen as f32 / t as f32).clamp(0.0, 1.0));
("compile", f, true)
} else if low.contains("nordisk-run-") || low.contains("nordisk-probe-") {
("probe", None, false)
} else if low.contains("tar -xf") || low.contains("unpack") {
("unpack", None, false)
} else if low.contains("fetch") || low.contains("cdn.kernel.org") {
("fetch", None, false)
} else if low.contains("bzimage") || low.contains(" ld ") {
("link", None, false)
} else {
("", None, false)
};
KernelJobProgress {
phase: phase.to_string(),
fraction,
estimated,
current: l.to_string(),
done: None,
}
}
}
/// **The seam the buttons fire through.** The pane knows WHAT it wants; the host
/// knows how to dispatch it (through the build-thing door, as a jera job) and
/// how to poll it. A fake implements this in three lines, which is what lets the
/// buttons be tested without starting a kernel compile.
pub trait KernelJobs {
/// Dispatch. Returning `Err` is a NAMED refusal the pane shows verbatim.
fn start(&mut self, req: &KernelJobRequest) -> Result<(), String>;
/// The running job's progress, or `None` when nothing is running.
fn progress(&mut self) -> Option<KernelJobProgress>;
}
pub struct KernelScene {
preset: Preset,
profile: HardeningProfile,
/// The derived model — symbols, edges, cascade, fragment. LOWERED to edda
/// (`nornir_build_thing::kernel_features`) so every build thing that pulls a
/// kernel reuses the ONE derivation; this pane renders it and derives nothing.
model: KernelFeatureModel,
/// The cached 3D cloud — built ONCE per symbol set, re-projected every frame. Its
/// own `view()` is never called; only `nodes[].pos` and the layout are used.
cloud: Option<Graph3D>,
/// The signature the current layout was built for (preset × profile × symbol set).
sig: Option<u64>,
/// Names the operator clicked off, in click order.
clicked_off: Vec<String>,
/// The last refusal: `(the symbol you clicked, the contract symbols it would break)`.
refusal: Option<(String, Vec<String>)>,
/// Live camera azimuth (radians). Fed ONLY to the projection, never the layout.
yaw: f32,
/// Auto-spin. ON by default — this pane is a rotating graph or it is a table.
rotate: bool,
/// Draw the symbol name beside each node.
labels: bool,
/// The node the pointer (or the list) last picked.
selected: Option<usize>,
/// Instance-local genuine layout rebuilds (the deterministic twin of the
/// process-wide [`kernel_graph_build_calls`]).
builds: u64,
/// Wall time of the last painted frame, microseconds.
last_frame_us: Option<u128>,
/// Where the buttons dispatch. `None` means the pane is a VIEWER — the
/// buttons render disabled and say why, rather than being absent (a missing
/// button is indistinguishable from a broken one).
jobs: Option<Box<dyn KernelJobs>>,
/// The variant a job builds, from the recipe. Empty until the host names it.
variant: String,
/// The last dispatch's refusal, shown verbatim.
job_error: Option<String>,
/// The running job's last progress.
job: Option<KernelJobProgress>,
/// The loaded RUN report, when there is one. `None` means the pane is
/// showing the DECLARED graph and nothing has been measured — which it says
/// out loud rather than painting drop candidates on no evidence.
report: Option<RunReport>,
/// What the kconfig SOLVER said about the built kernel, when the host found one
/// ([`KconfigWhy`]). `None` means nothing has been built (or nothing was read),
/// and the pane says the cascade is the name hierarchy's alone.
why: Option<KconfigWhy>,
/// The index the host found and could NOT read — said on the canvas, because a
/// file that exists and does not parse is a finding, not an absence.
why_error: Option<String>,
/// **Does this pane draw its own preset picker and dispatch buttons?**
///
/// `true` in the ⚙ tab, where this pane IS the surface. `false` when it is a
/// leaf of ⚒ ([`embedded`](Self::embedded)), where the host already owns both:
/// the preset is a `Setting` in the settings block a few rows up — the one
/// that rides into `Act::Build` — and BYGG is the door. A second preset strip
/// there would put two controls on one value, and two widgets with the same
/// accessible name make BOTH unaddressable to a robot and to a screen reader.
/// Three disabled job buttons under an armed BYGG would say the tab cannot
/// build the very thing it is about to build.
chrome: bool,
}
impl Default for KernelScene {
fn default() -> Self {
Self::new()
}
}
impl KernelScene {
/// A pane on the DEFAULT preset (`normal` — what the appliances ship today).
#[must_use]
pub fn new() -> Self {
let mut s = Self {
preset: Preset::Normal,
profile: HardeningProfile::Vanilla,
model: KernelFeatureModel::derive(Preset::Normal, HardeningProfile::Vanilla),
cloud: None,
sig: None,
clicked_off: Vec::new(),
refusal: None,
yaw: 0.0,
rotate: true,
labels: true,
selected: None,
builds: 0,
last_frame_us: None,
report: None,
why: None,
why_error: None,
jobs: None,
variant: String::new(),
job_error: None,
job: None,
chrome: true,
};
s.resync();
s
}
/// **The same pane as a LEAF of another surface** — no preset strip, no job
/// buttons, because the host owns both.
///
/// The owner went looking for this configurator inside ⚒ Build Thing three
/// separate times and was told three times that it lives in the ⚙ tab BY
/// DESIGN. Build Thing is where you choose what gets built; a preset is a
/// choice about what gets built. So the same facet is hosted twice, and this
/// is the shape it takes when it is not the whole tab.
#[must_use]
pub fn embedded() -> Self {
let mut s = Self::new();
s.chrome = false;
s
}
/// Whether this instance draws its own preset picker and job buttons.
#[must_use]
pub fn draws_own_chrome(&self) -> bool {
self.chrome
}
/// **Turn this instance's own preset strip and job buttons on or off, per frame.**
///
/// The ⚒ tab hosts ONE instance in two situations that want opposite answers: with a
/// kernel row picked the preset is a `Setting` that rides into `Act::Build`, so a
/// second strip here would be a control that changes the picture and not the build;
/// with NOTHING picked there is no host control at all, and a graph with no way to
/// change the preset cannot answer *"what is in a `slim` kernel"* — the one question
/// the retired ⚙ tab existed for.
///
/// It is a setter and not a second constructor precisely so it stays ONE instance:
/// two panes would be two presets, and the operator would pick in one and build the
/// other. The two states are mutually exclusive per frame, so exactly one preset
/// control is ever on screen and no two widgets share an accessible name.
///
/// Draws nothing by itself and touches no layout — the symbol set, the cloud and the
/// camera are untouched, so toggling it costs no rebuild.
pub fn set_chrome(&mut self, on: bool) {
self.chrome = on;
}
/// The picked preset.
#[must_use]
pub fn preset(&self) -> Preset {
self.preset
}
/// Pick a preset. A genuine change clears the click-off set (the symbols it named
/// may not exist in the new pick) and rebuilds the layout exactly once.
pub fn set_preset(&mut self, p: Preset) {
if self.preset == p {
return;
}
self.preset = p;
self.clicked_off.clear();
self.refusal = None;
self.selected = None;
self.resync();
}
/// The hardening overlay — ORTHOGONAL to the preset, so every preset composes with
/// both spellings.
#[must_use]
pub fn profile(&self) -> HardeningProfile {
self.profile
}
/// Turn the hardening overlay on/off.
pub fn set_profile(&mut self, p: HardeningProfile) {
if self.profile == p {
return;
}
self.profile = p;
self.clicked_off.clear();
self.refusal = None;
self.selected = None;
self.resync();
}
/// Instance-local genuine layout rebuilds so far.
#[must_use]
pub fn build_count(&self) -> u64 {
self.builds
}
/// Whether the camera is auto-spinning.
#[must_use]
pub fn is_rotating(&self) -> bool {
self.rotate
}
/// Turn the auto-spin on/off.
pub fn set_rotate(&mut self, on: bool) {
self.rotate = on;
}
/// The live camera azimuth (radians).
#[must_use]
pub fn yaw(&self) -> f32 {
self.yaw
}
/// Advance the camera by `dt` seconds of spin (pure — no clock), so a headless test
/// can rotate without an animation loop.
pub fn advance(&mut self, dt: f32) {
if self.rotate {
self.yaw = (self.yaw + ROTATE_RAD_PER_S * dt).rem_euclid(std::f32::consts::TAU);
}
}
/// The symbols of the current pick.
#[must_use]
pub fn symbols(&self) -> &[Symbol] {
&self.model.symbols
}
/// **Wire the buttons to a host** that can actually dispatch, naming the
/// recipe variant they build. Without this the pane is a viewer and says so.
pub fn wire_jobs(&mut self, variant: impl Into<String>, jobs: Box<dyn KernelJobs>) {
self.variant = variant.into();
self.jobs = Some(jobs);
}
/// Whether the buttons can fire at all.
#[must_use]
pub fn can_dispatch(&self) -> bool {
self.jobs.is_some() && !self.variant.is_empty()
}
/// **Fire a button.** Builds the request from the CURRENT pick — including
/// the decision fragment, which is the only reason a click matters — and
/// hands it to the host. A refusal is kept and shown verbatim.
pub fn dispatch(&mut self, kind: KernelJobKind) -> Result<(), String> {
if self.variant.is_empty() || self.jobs.is_none() {
let why = "this pane is not wired to a forge — it can show a kernel and not build one"
.to_string();
self.job_error = Some(why.clone());
return Err(why);
}
let req = KernelJobRequest {
kind,
variant: self.variant.clone(),
preset: self.preset.as_str().to_string(),
profile: self.profile.as_str().to_string(),
fragment: if self.clicked_off.is_empty() {
String::new()
} else {
self.decision_fragment()
},
};
let out = self.jobs.as_mut().expect("checked").start(&req);
match &out {
Ok(()) => {
self.job_error = None;
self.job = Some(KernelJobProgress::default());
}
Err(e) => {
self.job_error = Some(e.clone());
self.job = None;
}
}
out
}
/// Poll the running job. Called every frame by `ui()`.
pub fn poll_job(&mut self) {
if let Some(j) = self.jobs.as_mut() {
if let Some(p) = j.progress() {
self.job = Some(p);
}
}
}
/// The running job's progress, if any.
#[must_use]
pub fn job(&self) -> Option<&KernelJobProgress> {
self.job.as_ref()
}
/// The last dispatch refusal, if any.
#[must_use]
pub fn job_error(&self) -> Option<&str> {
self.job_error.as_deref()
}
/// **Load a RUN report** and re-derive, so the graph carries hit counts.
/// Passing `None` drops back to the declared-only view.
pub fn set_report(&mut self, report: Option<RunReport>) {
self.report = report;
self.resync();
}
/// The loaded RUN report, if any. Named `run_report` because `report()` is
/// already this pane's robot-facing JSON fold.
#[must_use]
pub fn run_report(&self) -> Option<&RunReport> {
self.report.as_ref()
}
/// **Load what the kconfig solver said about the built kernel.** Pure data, like
/// [`set_report`](Self::set_report): no relayout (the symbol set is the
/// fragment's, not the index's), so the rotate invariant is untouched.
pub fn set_why(&mut self, why: Option<KconfigWhy>) {
self.why = why;
self.why_error = None;
}
/// [`set_why`](Self::set_why) from what [`KconfigWhy::read`] returned: an index
/// that exists and does not read is KEPT as the sentence the pane shows.
pub fn set_why_read(&mut self, read: Result<Option<KconfigWhy>, String>) {
match read {
Ok(w) => self.set_why(w),
Err(e) => {
self.why = None;
self.why_error = Some(e);
}
}
}
/// The loaded solver answer, if any.
#[must_use]
pub fn kconfig_why(&self) -> Option<&KconfigWhy> {
self.why.as_ref()
}
/// The loaded solver answer IF it describes the kernel this pick composes —
/// another preset's chains are never painted onto this one.
#[must_use]
pub fn why_for_pick(&self) -> Option<&KconfigWhy> {
self.why
.as_ref()
.filter(|w| w.applies_to(self.preset, self.profile))
}
/// **The solver's cascade for the selected symbol**: what leaves the built
/// `.config` with it, by Kconfig's own edges. Empty with no selection, no index,
/// or an index for another pick.
#[must_use]
pub fn selected_goes_with(&self) -> Vec<&str> {
let Some(w) = self.why_for_pick() else {
return Vec::new();
};
self.selected
.and_then(|i| self.model.symbols.get(i))
.map(|s| w.goes_with(&s.name))
.unwrap_or_default()
}
/// **The drop candidates IN THIS PICK**: symbols the loaded report measured
/// at zero hits AND that this preset actually declares. Empty with no
/// report — absence of evidence is never painted as evidence of absence.
#[must_use]
pub fn drop_candidates(&self) -> Vec<&str> {
self.model
.symbols
.iter()
.filter(|s| s.run_hits == Some(0))
.map(|s| s.name.as_str())
.collect()
}
/// Symbols in this pick that the report says nothing about — the honest
/// remainder, shown so a small candidate list is not read as "everything
/// else is used".
#[must_use]
pub fn unmeasured(&self) -> usize {
self.model
.symbols
.iter()
.filter(|s| s.run_hits.is_none())
.count()
}
/// The state of one symbol by name, if it is in the pick.
#[must_use]
pub fn state_of(&self, name: &str) -> Option<SymbolState> {
let i = self.model.symbols.iter().position(|s| s.name == name)?;
Some(self.states()[i])
}
/// The labels painted this frame (all nodes while labels are on, else the
/// selection) — exactly what `ui()` draws.
#[must_use]
pub fn visible_labels(&self) -> Vec<String> {
if self.labels {
self.model
.symbols
.iter()
.map(|s| label_text(&s.name))
.collect()
} else {
self.selected
.and_then(|i| self.model.symbols.get(i))
.map(|s| vec![label_text(&s.name)])
.unwrap_or_default()
}
}
// ── derivation ───────────────────────────────────────────────────────────
/// Rebuild the symbol model + the cached 3D layout for the current pick. The ONLY
/// place `builds` moves.
fn resync(&mut self) {
// THE derivation — one call into the shared model this pane used to twin.
// Symbols, the parent forest, contract/closure joins: all edda's now.
let mut model = KernelFeatureModel::derive(self.preset, self.profile);
// The MEASUREMENT, joined onto the declared graph. `Some(0)` (measured and
// never entered) and `None` (no measurement covers this symbol) are kept
// apart deliberately: one is a drop candidate, the other is ignorance.
if let Some(r) = &self.report {
model.join_run_hits(&r.hits);
}
// The layout cache key: the pick's symbol set, NOT the camera and NOT the
// click-off set.
let sig = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
self.preset.as_str().hash(&mut h);
self.profile.as_str().hash(&mut h);
for s in &model.symbols {
s.directive.hash(&mut h);
}
h.finish()
};
self.model = model;
if self.sig == Some(sig) {
return; // identical pick — reuse the cached cloud
}
self.sig = Some(sig);
// THE expensive build, and the only one: `Graph3D::new` runs `Layout3D::Force`
// once and freezes `nodes[].pos`. Everything after this is projection.
let nodes: Vec<(String, Color32)> = self
.model
.symbols
.iter()
.map(|s| (s.name.clone(), Color32::from_gray(160)))
.collect();
let idx_edges: Vec<(usize, usize)> =
self.model.edges.iter().map(|&(a, b, _)| (a, b)).collect();
self.cloud = Some(Graph3D::new(nodes, idx_edges, Layout3D::Force).with_title("kconfig"));
self.builds += 1;
BUILD_CALLS.fetch_add(1, Ordering::Relaxed);
}
// ── the cascade ──────────────────────────────────────────────────────────
/// **Click a symbol off (or back on).** Returns the resulting state of that symbol.
///
/// A drop that would reach the measured boot contract is REFUSED: nothing is
/// dropped, and [`refused_because`](Self::refused_because) names the contract
/// symbols it would have broken. That is the difference between a pane that says no
/// and a pane that says nothing.
pub fn toggle(&mut self, name: &str) -> Option<SymbolState> {
let i = self.model.symbols.iter().position(|s| s.name == name)?;
self.selected = Some(i);
// The DECISION is the shared model's (`try_toggle` — the same door the CLI's
// `--opt kernel.<symbol>=off` goes through); this pane keeps only the
// selection and the standing refusal, which are pointer state.
match self.model.try_toggle(&mut self.clicked_off, name)? {
ToggleOutcome::Refused(broken) => {
self.refusal = Some((name.to_string(), broken));
Some(SymbolState::Refused)
}
ToggleOutcome::On | ToggleOutcome::Off => {
self.refusal = None;
Some(self.states()[i])
}
}
}
/// **THE DECISION, AS A KCONFIG FRAGMENT** — what a click is actually FOR.
///
/// Until this existed the pane painted cascade states and stopped: clicking
/// a symbol off changed some colours and produced nothing anyone could
/// build. This renders the current pick as a fragment a person can commit
/// beside `nuked.config` and name in `kconfig_fragment`, so a decision made
/// here reaches a real build.
///
/// Every dropped symbol is written as `# CONFIG_X is not set` — the veto
/// spelling, NOT an omission — because the canary catches a veto creeping
/// back in and cannot catch a line that was never written. The cascade is
/// included: dropping a parent drops its children, and the fragment says all
/// of them so the file states the whole consequence rather than the click.
///
/// The header records WHY each symbol was dropped, and the evidence is
/// exactly as strong as the pane has: a measured zero-hit count when a RUN
/// report is loaded, and "operator's choice" when it is not. It never claims
/// a measurement it does not hold.
///
/// The rendering itself is the shared model's
/// ([`KernelFeatureModel::decision_fragment`]) — the CLI's `--opt kernel.*`
/// produces byte-for-byte the same file, which is the point of the lowering.
#[must_use]
pub fn decision_fragment(&self) -> String {
self.model.decision_fragment(
&self.clicked_off,
self.report.as_ref().map(|r| r.source.as_str()),
)
}
/// The symbols currently clicked off, in click order.
#[must_use]
pub fn clicked_off(&self) -> &[String] {
&self.clicked_off
}
/// The last refusal — `(the symbol clicked, the contract symbols the drop would
/// break)`.
#[must_use]
pub fn refused_because(&self) -> Option<(&str, &[String])> {
self.refusal
.as_ref()
.map(|(n, b)| (n.as_str(), b.as_slice()))
}
/// Which symbols go if `off` goes, as a per-index mask — the shared model's
/// cascade, verbatim (see [`KernelFeatureModel::cascade_for`] for the two
/// directions and the never-guess rule).
fn cascade_for(&self, off: &[String]) -> Vec<bool> {
self.model.cascade_for(off)
}
/// The state of every symbol, in `symbols()` order — the shared model's verdict
/// under THIS pane's click-off set and standing refusal.
#[must_use]
pub fn states(&self) -> Vec<SymbolState> {
self.model.states(
&self.clicked_off,
self.refusal
.as_ref()
.map(|(n, b)| (n.as_str(), b.as_slice())),
)
}
/// Who still holds `i` — the kept children that keep a `locked` symbol alive.
fn holders(&self, i: usize, dropped: &[bool]) -> Vec<String> {
self.model.holders(i, dropped)
}
/// Why the canvas is empty, when it is. `None` means there is a graph to draw.
///
/// The never-empty-return rule: this pane does not return early without drawing. No
/// data means the frame is drawn at its full height with the REASON inside it —
/// absence is a sentence, not a gap.
#[must_use]
pub fn empty_reason(&self) -> Option<String> {
if !self.model.symbols.is_empty() {
return None;
}
Some(format!(
"'{}' contributes no committed fragment — its whole feature set is `make {}`, \
which lives in the kernel tree and is not enumerable here",
self.preset.as_str(),
self.preset.base()
))
}
// ── the report ───────────────────────────────────────────────────────────
/// The pane's measurement, as `state_json` data.
#[must_use]
pub fn report(&self) -> serde_json::Value {
let states = self.states();
let dropped = self.cascade_for(&self.clicked_off);
let universe = committed_universe();
let named = self.model.symbols.len();
let count = |k: SymbolState| states.iter().filter(|s| **s == k).count();
serde_json::json!({
// The pane's own identity, the way every other dwarves tab folds one.
"kind": "kernel_scene",
// The honesty note, as data: what this graph reflects and what it does not.
"reflects": REFLECTS,
// FALSE when this instance is a leaf of another surface, which is
// also the statement "the preset control you can see is the host's".
"chrome": self.chrome,
"preset": self.preset.as_str(),
"base": self.preset.base(),
"profile": self.profile.as_str(),
"graph": {
"nodes": self.model.symbols.len(),
"edges": self.model.edges.len(),
// Edges whose parent came from the tinyconfig closure fragment — the one
// relation the committed files were written to record.
"closure_edges": self.model.edges.iter().filter(|e| e.2).count(),
// …and the subset of those that land on a boot-contract symbol: the
// literal "this parent is what enables that contract option" relation the
// closure fragment exists to record. Three of the twelve it claims.
"contract_closure_edges": self
.model
.edges
.iter()
.filter(|e| e.2 && self.model.symbols[e.1].contract)
.count(),
// Nodes with no derivable edge at all. A number, not a silence.
"isolated": self.model.symbols.iter().filter(|s| s.isolated()).count(),
"enabled": self.model.symbols.iter().filter(|s| s.enabled).count(),
"vetoed": self.model.symbols.iter().filter(|s| !s.enabled).count(),
"contract": self.model.symbols.iter().filter(|s| s.contract).count(),
"closure": self.model.symbols.iter().filter(|s| s.closure).count(),
// How much of the kind's committed symbol universe this preset leaves to
// the invisible base floor. THE honest "smaller machine" number.
"universe": universe.len(),
"undecided": universe.len().saturating_sub(named),
"rotate": self.rotate,
"rotate_angle": self.yaw,
"labels": self.labels,
"label_texts": self.visible_labels(),
"build_count": self.builds,
"last_frame_us": self.last_frame_us,
"empty_reason": self.empty_reason(),
},
"states": {
"kept": count(SymbolState::Kept),
"locked": count(SymbolState::Locked),
"clicked_off": count(SymbolState::ClickedOff),
"cascade_dropped": count(SymbolState::CascadeDropped),
"refused": count(SymbolState::Refused),
},
"clicked_off": self.clicked_off,
"refused_symbol": self.refusal.as_ref().map(|(n, _)| n.clone()),
"refused_breaks": self.refusal.as_ref().map(|(_, b)| b.clone()),
"selected": self.selected.and_then(|i| self.model.symbols.get(i)).map(|s| s.name.clone()),
// The kconfig SOLVER's answer for the built kernel, when one was read:
// which kernel it describes, whether that is this pick, the third class
// split, and — for the selection — what goes with it by Kconfig's edges.
"kconfig_solver_error": self.why_error,
"kconfig_solver": self.why.as_ref().map(|w| {
let (derived, chained, unexplained, unsolved) = w.derived_counts();
let applies = w.applies_to(self.preset, self.profile);
let sel = self.selected.and_then(|i| self.model.symbols.get(i)).map(|s| s.name.as_str());
serde_json::json!({
"source": w.source,
"built_preset": w.preset,
"built_profile": w.profile,
"applies": applies,
"derived": derived,
"chained": chained,
"unexplained": unexplained,
"unsolved": unsolved,
"selected_why": sel.filter(|_| applies).and_then(|n| w.explain(n)),
"selected_goes_with": self.selected_goes_with(),
})
}),
"symbols": self.model.symbols.iter().enumerate().map(|(i, s)| serde_json::json!({
"name": s.name,
"directive": s.directive,
"enabled": s.enabled,
"contract": s.contract,
"closure": s.closure,
"isolated": s.isolated(),
"parent": s.parent.map(|p| self.model.symbols[p].name.clone()),
"held_by": self.holders(i, &dropped),
"state": states[i].key(),
"paint": states[i].paint().label(),
})).collect::<Vec<_>>(),
})
}
// ── paint ────────────────────────────────────────────────────────────────
/// The fill for one state, depth-cued.
fn state_color(state: SymbolState, th: &Theme, dark: bool) -> Color32 {
match state.paint() {
NodeState::Changed => AMBER,
NodeState::Glitch => RED,
NodeState::Ok => {
if state.dimmed() {
th.text_dim
} else if dark {
Color32::from_rgb(0x5a, 0x9b, 0xe0)
} else {
Color32::from_rgb(0x2f, 0x5c, 0x8a)
}
}
}
}
/// Paint the rotating cloud into `rect`. Re-projects the cached layout at the
/// current yaw — never rebuilds it. Returns the symbol clicked this frame.
fn paint(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
resp: &egui::Response,
) -> Option<String> {
let th = theme(ui);
let dark = th.bg.r() < 128;
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, th.bg);
// Never-empty-return: the box is already allocated at full height; say WHY.
if let Some(reason) = self.empty_reason() {
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
reason,
egui::FontId::proportional(13.0),
th.text_dim,
);
return None;
}
let cloud = self.cloud.as_ref()?;
let states = self.states();
let half = rect.width().min(rect.height()) * 0.5 * 0.86;
let center = rect.center();
let proj: Vec<(Pos2, f32)> = cloud
.nodes
.iter()
.map(|n| {
let [sx, sy, depth] = project_azimuth(n.pos, self.yaw, PITCH);
(Pos2::new(center.x + sx * half, center.y - sy * half), depth)
})
.collect();
for &(a, b, is_closure) in &self.model.edges {
let (pa, pb) = (proj[a].0, proj[b].0);
// A closure edge — "this parent is what enables that contract symbol" — is
// the load-bearing one and paints solid; everything else is the name
// hierarchy and paints dimmer, so the picture does not overclaim.
let col = if is_closure {
th.accent
} else {
Color32::from_rgba_unmultiplied(th.edge.r(), th.edge.g(), th.edge.b(), 110)
};
painter.line_segment(
[pa, pb],
Stroke::new(if is_closure { 1.6 } else { 1.0 }, col),
);
}
let base_r = (900.0 / self.model.symbols.len().max(1) as f32)
.sqrt()
.clamp(4.0, 11.0);
let mut order: Vec<usize> = (0..self.model.symbols.len()).collect();
order.sort_by(|&i, &j| {
proj[i]
.1
.partial_cmp(&proj[j].1)
.unwrap_or(std::cmp::Ordering::Equal)
});
for &i in &order {
let (p, depth) = proj[i];
let dcue = 0.62 + 0.38 * (depth * 0.5 + 0.5).clamp(0.0, 1.0);
let r = base_r * dcue;
let base = Self::state_color(states[i], &th, dark);
let fill = Color32::from_rgb(
(base.r() as f32 * dcue) as u8,
(base.g() as f32 * dcue) as u8,
(base.b() as f32 * dcue) as u8,
);
painter.circle_filled(p, r, fill);
if states[i] == SymbolState::Locked {
// §10's OUTLINE: something you kept still needs it.
painter.circle_stroke(p, r + 2.0, Stroke::new(1.6, th.accent));
} else if self.selected == Some(i) {
painter.circle_stroke(p, r + 2.5, Stroke::new(2.5, th.text));
} else {
painter.circle_stroke(p, r, Stroke::new(1.0, th.node_stroke));
}
// A vetoed symbol is an ABSENCE the preset states out loud — a cross, so it
// never reads as a feature the kernel has.
if !self.model.symbols[i].enabled {
let d = r * 0.7;
painter.line_segment(
[p + Vec2::new(-d, -d), p + Vec2::new(d, d)],
Stroke::new(1.2, th.bg),
);
}
}
if self.labels || self.selected.is_some() {
let idxs: Vec<usize> = if self.labels {
(0..self.model.symbols.len()).collect()
} else {
self.selected.into_iter().collect()
};
for i in idxs {
let (p, depth) = proj[i];
let dcue = 0.62 + 0.38 * (depth * 0.5 + 0.5).clamp(0.0, 1.0);
let a = (110.0 + 145.0 * (depth * 0.5 + 0.5).clamp(0.0, 1.0)) as u8;
painter.text(
p + Vec2::new(base_r * dcue + 5.0, 0.0),
egui::Align2::LEFT_CENTER,
label_text(&self.model.symbols[i].name),
egui::FontId::proportional(10.5),
Color32::from_rgba_unmultiplied(th.text.r(), th.text.g(), th.text.b(), a),
);
}
}
// Click → nearest node within a generous radius.
if resp.clicked() {
if let Some(pos) = resp.interact_pointer_pos() {
let mut best: Option<(usize, f32)> = None;
for (i, (p, depth)) in proj.iter().enumerate() {
let dcue = 0.62 + 0.38 * (depth * 0.5 + 0.5).clamp(0.0, 1.0);
let hit = (base_r * dcue + 5.0).max(8.0);
let d2 = (p.x - pos.x).powi(2) + (p.y - pos.y).powi(2);
if d2 <= hit * hit && best.map(|(_, bd)| d2 < bd).unwrap_or(true) {
best = Some((i, d2));
}
}
if let Some((i, _)) = best {
return Some(self.model.symbols[i].name.clone());
}
}
}
None
}
}
impl Facet for KernelScene {
fn title(&self) -> &str {
"⚙ Kernel features"
}
fn ui(&mut self, ui: &mut egui::Ui) {
let t0 = std::time::Instant::now();
let th = theme(ui);
self.poll_job();
// ── what this graph REFLECTS, before anything is read off it ─────────
// Said on the canvas itself, every frame, until real Kconfig answers: a map
// whose edges are guessed must not be read as a dependency graph.
ui.label(egui::RichText::new(REFLECTS).color(th.text_dim).small());
// ── the buttons (design §5) ──────────────────────────────────────────
// Rendered whether or not this pane is wired, and DISABLED with the
// reason beside them when it is not: a missing button is
// indistinguishable from a broken one, and an operator deserves to know
// which they are looking at.
let armed = self.can_dispatch();
let mut fire: Option<KernelJobKind> = None;
// Not drawn when this pane is a LEAF of ⚒ — see `chrome`. The host's own
// BYGG is the door there, and three disabled buttons beside an armed one
// is a worse sentence than no buttons at all.
if self.chrome {
ui.horizontal_wrapped(|ui| {
for k in [
KernelJobKind::Build,
KernelJobKind::Minify,
KernelJobKind::Run,
] {
if ui
.add_enabled(armed, egui::Button::new(k.label()))
.clicked()
{
fire = Some(k);
}
}
if !armed {
ui.label(
egui::RichText::new(
"not wired to a forge — this pane can show a kernel and not build one",
)
.color(th.text_dim),
);
} else if !self.clicked_off.is_empty() {
ui.label(
egui::RichText::new(format!(
"carrying {} clicked-off symbol(s) as a fragment",
self.clicked_off.len()
))
.color(th.text_dim),
);
}
});
}
if let Some(k) = fire {
let _ = self.dispatch(k);
}
// The composed progress line: the PHASE is always known and always
// shown; the bar appears only where a fraction can be honest, and says
// "estimated" where the total came from the previous build.
if let Some(j) = self.job.clone() {
ui.horizontal_wrapped(|ui| {
ui.label(
egui::RichText::new(if j.phase.is_empty() {
"working"
} else {
&j.phase
})
.strong(),
);
match j.fraction {
Some(f) => {
ui.add(egui::ProgressBar::new(f).desired_width(160.0));
if j.estimated {
ui.label(egui::RichText::new("estimated").color(th.text_dim));
}
}
None => {
ui.label(
egui::RichText::new("no honest percentage for this phase")
.color(th.text_dim),
);
}
}
if !j.current.is_empty() {
ui.label(egui::RichText::new(label_text(&j.current)).color(th.text_dim));
}
});
match &j.done {
Some(Ok(())) => {
ui.label(egui::RichText::new("done").color(th.accent));
}
Some(Err(e)) => {
ui.label(egui::RichText::new(format!("REFUSED: {e}")).color(RED));
}
None => {}
}
}
if let Some(e) = self.job_error.clone() {
ui.label(egui::RichText::new(format!("REFUSED: {e}")).color(RED));
}
// ── the pick ─────────────────────────────────────────────────────────
let mut want: Option<Preset> = None;
let chrome = self.chrome;
ui.horizontal_wrapped(|ui| {
// The PRESET strip is the host's when this pane is embedded: in ⚒ the
// preset is a `Setting` that rides into `Act::Build`, and a second
// strip here would be a control that changes the picture and not the
// build. The rest of the row (hardening, spin, labels) is about how
// the graph is DRAWN and stays either way.
if chrome {
ui.label("preset");
for p in PRESETS {
if ui.selectable_label(self.preset == p, p.as_str()).clicked() {
want = Some(p);
}
}
ui.separator();
}
let mut hardened = self.profile == HardeningProfile::Hardened;
if ui.checkbox(&mut hardened, "hardened").changed() {
self.set_profile(if hardened {
HardeningProfile::Hardened
} else {
HardeningProfile::Vanilla
});
}
ui.separator();
let mut rotate = self.rotate;
if ui.checkbox(&mut rotate, "3D rotating").changed() {
self.rotate = rotate;
}
let mut labels = self.labels;
if ui.checkbox(&mut labels, "labels").changed() {
self.labels = labels;
}
});
if let Some(p) = want {
self.set_preset(p);
}
// ── the header. ALWAYS drawn, with the reason inside it ──────────────
let r = self.report();
ui.label(
egui::RichText::new(format!(
"{} symbols · {} edges ({} closure) · {} isolated · {} enabled · {} vetoed · \
over `make {}`",
r["graph"]["nodes"],
r["graph"]["edges"],
r["graph"]["closure_edges"],
r["graph"]["isolated"],
r["graph"]["enabled"],
r["graph"]["vetoed"],
self.preset.base(),
))
.color(th.text_dim)
.size(11.0),
);
if let Some((clicked, broken)) = self.refused_because() {
ui.label(
egui::RichText::new(format!(
"REFUSED: dropping {clicked} breaks the boot contract — {}",
broken.join(", ")
))
.color(RED),
);
}
// ── the kconfig SOLVER's answer, when a built kernel left one ─────────
// Said beside the name-hierarchy cascade, never instead of it: the chains
// describe ONE built kernel, and a pick of another preset is another kernel.
match &self.why {
None => match &self.why_error {
Some(e) => {
ui.label(
egui::RichText::new(format!(
"kconfig solver: the index does not read — {e}"
))
.color(RED)
.size(11.0),
);
}
None => {
ui.label(
egui::RichText::new(
"kconfig solver: no built kernel.why.toml for this kernel yet — the cascade \
above is the name hierarchy's; BYGG writes the chains",
)
.color(th.text_dim)
.size(11.0),
);
}
},
Some(w) if !w.applies_to(self.preset, self.profile) => {
ui.label(
egui::RichText::new(format!(
"kconfig solver: the built kernel is `{}` × `{}`, not this pick — its chains \
answer that kernel only ({})",
w.preset, w.profile, w.source
))
.color(th.text_dim)
.size(11.0),
);
}
Some(w) => {
let (derived, chained, unexplained, unsolved) = w.derived_counts();
ui.label(
egui::RichText::new(format!(
"kconfig solver: {derived} kconfig-derived · {chained} chained · \
{unexplained} unexplained · {unsolved} unsolved — {}",
w.source
))
.color(th.text_dim)
.size(11.0),
);
if let Some(name) = self
.selected
.and_then(|i| self.model.symbols.get(i))
.map(|s| s.name.clone())
{
if let Some(why) = w.explain(&name) {
ui.label(egui::RichText::new(format!("{name}: {why}")).size(11.0));
}
let goes = w.goes_with(&name);
if !goes.is_empty() {
const SHOWN: usize = 12;
let more = goes.len().saturating_sub(SHOWN);
ui.label(
egui::RichText::new(format!(
"off with {name}, by Kconfig's own edges: {} symbol(s) — {}{}",
goes.len(),
goes.iter()
.take(SHOWN)
.copied()
.collect::<Vec<_>>()
.join(", "),
if more > 0 {
format!(" … +{more}")
} else {
String::new()
}
))
.color(AMBER)
.size(11.0),
);
}
}
}
}
// ── the graph. Stable height: the box is reserved before it has content ──
//
// The canvas is bounded by the VIEWPORT, not by `available_size()`. Measured by
// this pane's own robot on its first run: an unbounded parent `Ui` reports ~2250
// px of available width in a 1500 px window, the canvas eats all of it, and the
// symbol list lands at x≈1989 — off the surface, unclickable, invisible. The
// robot's own words were "the user cannot see it, and a click at its centre
// lands off-surface". A list you cannot reach is the same defect as no list.
//
// ⚠ **IT IS THE EDGE THAT IS CLAMPED, NOT THE WIDTH — and it used to be the
// width.** `avail.x.min(screen.x)` is correct only for a pane whose left edge is
// at x≈0: a pane starting at `x = L` and taking `screen.x` of width overruns the
// right edge by exactly `L`. At this pane's origin as a whole tab (8 px) that
// error was invisible, which is how it survived; inside a HORIZONTAL SPLIT it is
// hundreds of pixels, and the ⚒ five-leaf body puts this configurator in exactly
// such a split (`build-thing-choose`, settings │ kernel), so mounting the leaves
// is what turns it from latent into live. The same mistake in the same shape is
// what cost facett's tab strip its last headers — fixed there in `d272f1a0` by
// this arithmetic, reported back to this lane by the lane that fixed it.
//
// Narrowing only, and the floor sits on the LIMIT rather than on the row: a pane
// that already fits is byte-identical, a child's own narrower `max_rect` still
// wins, and a genuinely narrow pane is never widened into its neighbour.
let limit = ui.clip_rect().intersect(ui.ctx().viewport_rect());
let origin = ui.max_rect().min;
let avail = ui.available_size();
let canvas = Vec2::new(
(avail.x.min(limit.right() - origin.x.max(limit.left())) - LIST_W - 18.0).max(240.0),
(avail.y.min(limit.bottom() - origin.y.max(limit.top())) - 8.0).max(320.0),
);
let mut clicked: Option<String> = None;
ui.horizontal_top(|ui| {
let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click());
clicked = self.paint(ui, rect, &resp);
// The symbol list beside it: every symbol is a REAL widget carrying its own
// name, so an operator (and a robot) can click one off without hunting a
// moving node in a spinning cloud.
//
// The `with_layout` is load-bearing, not decoration: a `ScrollArea` inherits
// the enclosing layout, and this one is enclosed by `horizontal_top`. Without
// it the forty-eight symbols laid out LEFT TO RIGHT in a single row — the
// robot found `CONFIG_NET_CORE` sitting at x≈1585 in a 1500 px window, the
// ninth item of a row that ran off the edge of the world.
egui::ScrollArea::vertical()
.max_width(LIST_W)
.show(ui, |ui| {
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
ui.set_max_width(LIST_W);
ui.set_min_width(LIST_W);
let states = self.states();
for (i, s) in self.model.symbols.iter().enumerate() {
let col = Self::state_color(states[i], &th, th.bg.r() < 128);
if ui
.selectable_label(
self.selected == Some(i),
egui::RichText::new(&s.name).color(col).size(11.0),
)
.clicked()
{
clicked = Some(s.name.clone());
}
}
});
});
});
if let Some(name) = clicked {
self.toggle(&name);
}
// Keep the spin alive.
if self.rotate {
let dt = ui.input(|i| i.stable_dt).min(0.1);
self.advance(dt);
ui.ctx().request_repaint();
}
self.last_frame_us = Some(t0.elapsed().as_micros());
}
fn state_json(&self) -> serde_json::Value {
self.report()
}
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
return;
};
if let Some(p) = v
.get("preset")
.and_then(|p| p.as_str())
.and_then(Preset::parse)
{
self.set_preset(p);
}
if let Some(p) = v
.get("profile")
.and_then(|p| p.as_str())
.and_then(HardeningProfile::parse)
{
self.set_profile(p);
}
if let Some(on) = v.get("rotate").and_then(serde_json::Value::as_bool) {
self.rotate = on;
}
if let Some(on) = v.get("labels").and_then(serde_json::Value::as_bool) {
self.labels = on;
}
if let Some(name) = v.get("toggle").and_then(|t| t.as_str()) {
self.toggle(name);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A built kernel's `kernel.why.toml`, as the solver writes it: `declared` is a
/// symbol of the pick, `goes` is pulled in by it alone, `shared` by it AND a
/// default (so it must NOT be in the "goes with" list), `probe` has no chain.
fn solved_index(declared: &str, goes: &str, shared: &str) -> WhyIndex {
let derived = |head: &str, chain: String, heads: Vec<String>, unexplained: &str| {
WhyClass::KconfigDerived {
head: head.to_string(),
head_class: if head.is_empty() {
String::new()
} else {
"declared".into()
},
chain,
heads,
unexplained: unexplained.into(),
}
};
WhyIndex {
rows: vec![
nornir_build_thing::kernel_requires::WhyRow {
symbol: declared.into(),
value: nornir_build_thing::kernel_requires::KconfigValue::Y,
class: WhyClass::PresetOnly,
},
nornir_build_thing::kernel_requires::WhyRow {
symbol: goes.into(),
value: nornir_build_thing::kernel_requires::KconfigValue::Y,
class: derived(
declared,
format!("{declared} [declared] --select--> {goes}"),
vec![format!("declared:{declared}")],
"",
),
},
nornir_build_thing::kernel_requires::WhyRow {
symbol: shared.into(),
value: nornir_build_thing::kernel_requires::KconfigValue::Y,
class: derived(
declared,
format!("{declared} [declared] --select--> {shared}"),
vec![
format!("declared:{declared}"),
format!("kconfig-default:{shared}"),
],
"",
),
},
nornir_build_thing::kernel_requires::WhyRow {
symbol: "CONFIG_CC_HAS_INT128".into(),
value: nornir_build_thing::kernel_requires::KconfigValue::Y,
class: derived(
"",
String::new(),
Vec::new(),
"a toolchain probe — the compiler decides it",
),
},
],
missing: Vec::new(),
}
}
#[test]
fn the_solver_says_what_goes_with_a_clicked_symbol_for_the_kernel_it_describes() {
let mut scene = KernelScene::new();
let declared = scene
.symbols()
.iter()
.find(|s| s.enabled)
.expect("normal enables something")
.name
.clone();
let text = solved_index(&declared, "CONFIG_ONLY_BY_IT", "CONFIG_ALSO_DEFAULT").to_toml();
// No index: the pane says the cascade is the name hierarchy's, and the
// state carries no solver block at all.
assert!(scene.report()["kconfig_solver"].is_null());
assert!(scene.selected_goes_with().is_empty());
// The index of THIS pick (normal × vanilla), read off the file text.
let why = KconfigWhy::parse("fixture/kernel.why.toml", "normal", "vanilla", &text)
.expect("the writer's own text reads");
assert_eq!(
why.derived_counts(),
(3, 2, 1, 0),
"3 derived: 2 chained, 1 unexplained, 0 unsolved"
);
let builds = scene.build_count();
scene.set_why(Some(why));
assert_eq!(
scene.build_count(),
builds,
"loading an index is data, not a relayout"
);
scene.toggle(&declared);
assert_eq!(
scene.selected_goes_with(),
vec!["CONFIG_ONLY_BY_IT"],
"only the SOLE-head symbol goes; one a default also reaches stays"
);
let r = scene.report();
assert_eq!(r["kconfig_solver"]["applies"], true);
assert_eq!(r["kconfig_solver"]["chained"], 2);
assert_eq!(r["kconfig_solver"]["unexplained"], 1);
assert_eq!(
r["kconfig_solver"]["selected_goes_with"][0],
"CONFIG_ONLY_BY_IT"
);
assert!(
r["kconfig_solver"]["selected_why"]
.as_str()
.unwrap()
.contains("preset fragment"),
"{}",
r["kconfig_solver"]["selected_why"]
);
assert!(scene
.kconfig_why()
.unwrap()
.explain("CONFIG_ONLY_BY_IT")
.unwrap()
.contains("--select-->"));
// Another pick is another kernel: the chains are NOT painted onto it.
scene.set_preset(Preset::Slim);
assert!(scene.why_for_pick().is_none());
assert_eq!(scene.report()["kconfig_solver"]["applies"], false);
assert!(scene.report()["kconfig_solver"]["selected_why"].is_null());
}
#[test]
fn a_built_kernels_index_is_read_off_its_output_dir_with_its_pin() {
let dir = std::env::temp_dir().join(format!("dwarves-kconfig-why-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Nothing built yet: not an error, just nothing to say.
assert_eq!(KconfigWhy::read(&dir, "tunnr-kernel"), Ok(None));
std::fs::write(
dir.join(nornir_build_thing::kernel_requires::WHY_FILE),
solved_index("CONFIG_VT", "CONFIG_INPUT", "CONFIG_HID").to_toml(),
)
.unwrap();
// An index with no pin cannot say which kernel it describes — refused BY NAME.
let e = KconfigWhy::read(&dir, "tunnr-kernel").unwrap_err();
assert!(e.contains("tunnr-kernel.kernel-pin.json"), "{e}");
let pin = serde_json::json!({
"schema": nornir_build_thing::kernel::KERNEL_PIN_SCHEMA,
"name": "tunnr-kernel",
"kernel_version": "6.12.104",
"source_url": "https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.104.tar.xz",
"tarball_sha256": "0".repeat(64),
"toolchain_image": "localhost/nordisk-kernel-toolchain:pinned",
"profile": "vanilla",
"preset": "nuked",
"fragment_sha256": "1".repeat(64),
"bzimage_sha256": "2".repeat(64),
"config_sha256": "3".repeat(64),
});
std::fs::write(dir.join("tunnr-kernel.kernel-pin.json"), pin.to_string()).unwrap();
let why = KconfigWhy::read(&dir, "tunnr-kernel")
.unwrap()
.expect("built");
assert_eq!(
(why.preset.as_str(), why.profile.as_str()),
("nuked", "vanilla")
);
assert!(why.applies_to(Preset::Nuked, HardeningProfile::Vanilla));
assert!(!why.applies_to(Preset::Nuked, HardeningProfile::Hardened));
assert_eq!(why.goes_with("CONFIG_VT"), vec!["CONFIG_INPUT"]);
let _ = std::fs::remove_dir_all(&dir);
}
// `the_parent_relation_stops_at_an_underscore` moved to edda with the function it
// tests (`nornir_build_thing::kernel_features`) — a test of a private rule belongs
// beside the rule, and the rule left this file with the rest of the model.
/// The measured shape of each preset, so a change to the committed fragments shows
/// up here rather than as a silently different picture.
///
/// Re-measured 2026-08-30 (lane AK) — normal 16→**33** · slim 32→**48** · nuked
/// 48→**69**, vetoed 16→**21**, enabled 32→**48**. The growth is lanes AE/AF/L/S
/// declaring what the appliance really needs of its kernel (`target`, `data.fs`,
/// `data.io`, NR_CPUS, the md set): the picture followed the fragments, this test
/// had not, and it was RED on the tree before this lane touched it.
#[test]
fn each_preset_lowers_the_symbols_it_actually_names() {
let mut s = KernelScene::new();
s.set_preset(Preset::Full);
assert_eq!(s.symbols().len(), 0, "`full` contributes no fragment");
assert!(
s.empty_reason().is_some(),
"and the pane says WHY, at full height"
);
s.set_preset(Preset::Normal);
assert_eq!(s.symbols().len(), 33, "the measured boot contract");
assert!(s.symbols().iter().all(|x| x.contract));
s.set_preset(Preset::Slim);
assert_eq!(
s.symbols().len(),
48,
"the contract plus its tinyconfig closure"
);
s.set_preset(Preset::Nuked);
assert_eq!(s.symbols().len(), 69, "…plus the absences, stated by name");
assert_eq!(s.report()["graph"]["vetoed"], 21);
assert_eq!(s.report()["graph"]["enabled"], 48);
}
/// **The cascade, both directions.** Watched failing: before `cascade_for` grew its
/// backward arm, clicking `CONFIG_NET_CORE` off left `CONFIG_NET` `locked` — the
/// pane could not answer the one question §10 is about.
#[test]
fn dropping_a_leaf_drops_the_parent_that_only_existed_for_it() {
let mut s = KernelScene::new();
s.set_preset(Preset::Slim);
assert_eq!(s.state_of("CONFIG_NET"), Some(SymbolState::Locked));
assert_eq!(s.state_of("CONFIG_NET_CORE"), Some(SymbolState::Kept));
s.toggle("CONFIG_NET_CORE");
assert_eq!(s.state_of("CONFIG_NET_CORE"), Some(SymbolState::ClickedOff));
assert_eq!(
s.state_of("CONFIG_NET"),
Some(SymbolState::CascadeDropped),
"nothing else needs it any more — it goes too"
);
// The counterweight: a symbol with several kept children does NOT go.
assert_eq!(s.state_of("CONFIG_VIRTIO"), Some(SymbolState::Locked));
}
/// **The refusal.** Dropping a closure parent whose children are the boot contract
/// is refused BY NAME, and nothing is dropped.
#[test]
fn a_drop_that_reaches_the_boot_contract_is_refused_by_name() {
let mut s = KernelScene::new();
s.set_preset(Preset::Slim);
assert_eq!(s.toggle("CONFIG_NLS"), Some(SymbolState::Refused));
let (clicked, broken) = s.refused_because().expect("a refusal names its reason");
assert_eq!(clicked, "CONFIG_NLS");
assert!(
broken.contains(&"CONFIG_NLS_CODEPAGE_437".to_string()),
"the contract symbol it would break is NAMED: {broken:?}"
);
assert!(s.clicked_off().is_empty(), "a refused drop does not take");
}
/// **The camera-only invariant at the model layer**, plus its red counterweight: a
/// genuine preset change DOES rebuild, so `build_count == 1` is not a constant.
#[test]
fn rotation_and_cascade_reproject_a_cached_layout() {
let mut s = KernelScene::new();
s.set_preset(Preset::Nuked);
let base = s.build_count();
let probe = s
.cloud
.as_ref()
.unwrap()
.nodes
.iter()
.position(|n| n.pos[0].abs() + n.pos[2].abs() > 1e-3)
.expect("an off-axis node to probe the projection with");
let pos_before = s.cloud.as_ref().unwrap().nodes[probe].pos;
let before = project_azimuth(pos_before, s.yaw(), PITCH);
for _ in 0..24 {
s.advance(0.05);
}
s.toggle("CONFIG_NET_CORE");
let after = project_azimuth(pos_before, s.yaw(), PITCH);
let moved = (after[0] - before[0]).abs() + (after[1] - before[1]).abs();
assert!(
moved > 1e-3,
"the projection really advanced (Δ={moved:.5})"
);
assert_eq!(
s.cloud.as_ref().unwrap().nodes[probe].pos,
pos_before,
"the cached layout position never moved"
);
assert_eq!(s.build_count(), base, "0 rebuilds across spin + a cascade");
s.set_preset(Preset::Normal);
assert_eq!(
s.build_count(),
base + 1,
"a genuine pick rebuilds exactly once"
);
}
/// The honest "smaller machine" number: `nuked` leaves FEWER symbols of the kind's
/// committed universe to the invisible base floor than `normal` does — while drawing
/// MORE nodes, because its content is stated as absence.
#[test]
fn nuked_decides_more_of_the_universe_than_normal() {
let mut s = KernelScene::new();
s.set_preset(Preset::Normal);
let n = s.report();
s.set_preset(Preset::Nuked);
let k = s.report();
assert!(
k["graph"]["undecided"].as_u64().unwrap() < n["graph"]["undecided"].as_u64().unwrap(),
"nuked leaves less to the floor: {} vs {}",
k["graph"]["undecided"],
n["graph"]["undecided"]
);
assert!(
k["graph"]["nodes"].as_u64().unwrap() > n["graph"]["nodes"].as_u64().unwrap(),
"…and draws more nodes doing it"
);
}
}