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

nordisk/dwarves

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