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

nordisk/facett

facett-buildthing/src/galois_pane.rs

//! **The bidirectional feature↔dependency pane** — the two adjoints, clickable.
//!
//! The build-space panes next door draw what a variant *is*. This one draws the
//! question you actually ask while carving a tier: **if I turn this off, what
//! goes away — and what does that cost me?**
//!
//! Both directions are the same connection, read two ways:
//!
//! - click a **feature** off → every dependency that feature was holding up
//!   greys out, live;
//! - click a **dependency** → the features that pull it are turned off, live.
//!
//! There is ONE data source ([`FeatureAxis`], derived by `nornir-feature-graph`
//! from real manifests and carried through Mímir's `feature_dep_edges`) and one
//! predicate — [`FeatureAxis::in_build`] — that both directions call. There is no
//! second table for the reverse direction that would have to agree with the
//! first.
//!
//! ## The three classes, drawn differently — and the fourth
//!
//! A naive graph draws every dependency the same and lies about two of them:
//!
//! | class | ring | what a click does |
//! |---|---|---|
//! | **unconditional** | red, badge `ALWAYS` | nothing can remove it |
//! | pulled by exactly one feature | green | one click removes it |
//! | pulled by several, independently | amber, badge `ANY n` | needs ALL of them off |
//! | **only under a conjunction** | violet, badge `AND n` | ONE of the pair is enough |
//!
//! (Badges are ASCII on purpose: egui's default font has no glyph for the
//! mathematical symbols, and a badge that paints as an empty box says nothing.)
//!
//! That last row is the one that matters. A weak dependency (`pkg?/feat`) brings
//! a crate in only when two features are BOTH on, and the old model — "which
//! single feature pulls this?" — answered *nothing does*, and therefore *it
//! cannot be removed*. Measured on `nornir-auth-policy`, 2026-08-01: 39 of 140
//! crates (27.9%) arrive only under `auth-oidc ∧ online`, `reqwest` among them.
//! A UI that greys none of them out when you click `online` is repeating that
//! bug in pictures.
//!
//! So a conjunction gets its own node between the features and the crate, and
//! clicking it turns off the cheapest conjunct — because for an AND-group, one
//! is enough, and offering all of them would overstate the cost.
//!
//! ## Rendering
//!
//! Layout is three columns; painting is `facett_graphpan::draw_graph_opts`, the
//! SAME renderer the build graph and the arch board use. No second renderer.

use std::collections::{BTreeMap, BTreeSet, HashMap};

use egui::{Color32, Pos2, Ui};
use facett_graphpan::{
    Decorations, GraphEdge, GraphModel, GraphNode, GraphView, NodeDecoration,
};
use nornir_feature_graph::{CrateAnalysis, Reachability};
use serde::{Deserialize, Serialize};

/// World-space layout. Three columns, CENTRED on the origin: `draw_graph`'s
/// default view sits at pan 0 / zoom 1 with the origin mid-viewport, so laying
/// the columns out from x=0 rightwards clipped the crate column off the right
/// edge — visible in the first rendered frame, invisible to any state assertion.
const COL_W: f32 = 250.0;
const ROW_H: f32 = 46.0;
const COL_FEATURE: f32 = -COL_W;
const COL_AND: f32 = 0.0;
const COL_CRATE: f32 = COL_W;

/// **The declared feature axis of one repo**, derived — never hand-written.
///
/// This is the serde bridge: no egui type appears in it, so it crosses the wasm
/// boundary and can be shipped straight out of a warehouse read.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FeatureAxis {
    pub repo: String,
    /// The package the features belong to.
    pub root: String,
    /// `Φ` — the features a user may click, in menu order.
    pub features: Vec<String>,
    /// Per-crate analysis, keyed by crate name.
    pub analyses: BTreeMap<String, CrateAnalysis>,
    /// **How many `#[cfg]`-gated items stop compiling** when a feature goes off,
    /// from Mímir's `feature_gate_facts` (which knows the exact `file:line`).
    /// This is the consequence nothing else in the constellation can show.
    #[serde(default)]
    pub gated_items: BTreeMap<String, usize>,
    /// Optional per-crate byte size, for the size delta.
    #[serde(default)]
    pub sizes: BTreeMap<String, u64>,
    /// **Where these facts came from.** Printed in the pane. A picture derived
    /// from a warehouse must say WHICH warehouse — reading the wrong one and
    /// getting a confident empty answer is the failure this whole axis exists to
    /// stop being possible.
    #[serde(default)]
    pub provenance: String,
}

impl FeatureAxis {
    /// **The one membership predicate.** Both directions of the connection call
    /// this; neither has its own copy.
    ///
    /// A crate is in the build iff it is unconditional, or some minimal cause has
    /// every one of its features still enabled. That is exactly the resolver's
    /// rule, read off the same causes the resolver produced.
    pub fn in_build(&self, krate: &str, disabled: &BTreeSet<String>) -> bool {
        match self.analyses.get(krate) {
            None => false,
            Some(a) => match a.status {
                Reachability::Unconditional => true,
                Reachability::Never => false,
                Reachability::Conditional => {
                    a.causes.iter().any(|c| c.iter().all(|f| !disabled.contains(f)))
                }
            },
        }
    }

    /// Every crate currently in the build.
    pub fn present(&self, disabled: &BTreeSet<String>) -> BTreeSet<String> {
        self.analyses
            .keys()
            .filter(|k| self.in_build(k, disabled))
            .cloned()
            .collect()
    }

    /// How many gated items stop compiling under `disabled`.
    pub fn gated_lost(&self, disabled: &BTreeSet<String>) -> usize {
        disabled.iter().filter_map(|f| self.gated_items.get(f)).sum()
    }

    /// Bytes attributable to the crates that left. `None` when sizes are unknown
    /// for any of them — a partial sum presented as a total would be a number
    /// that looks measured and is not.
    pub fn bytes_lost(&self, disabled: &BTreeSet<String>) -> Option<u64> {
        let gone: Vec<&String> = self
            .analyses
            .keys()
            .filter(|k| self.in_build(k, &BTreeSet::new()) || self.analyses[*k].status == Reachability::Conditional)
            .filter(|k| !self.in_build(k, disabled))
            .collect();
        let mut total = 0u64;
        for k in gone {
            total += *self.sizes.get(k)?;
        }
        Some(total)
    }

    /// How this crate should read to a user.
    pub fn class_of(&self, krate: &str) -> CrateClass {
        match self.analyses.get(krate) {
            None => CrateClass::Unknown,
            Some(a) => match a.status {
                Reachability::Unconditional => CrateClass::Unconditional,
                Reachability::Never => CrateClass::Unreachable,
                Reachability::Conditional => {
                    if a.causes.iter().any(|c| c.len() > 1) {
                        CrateClass::Conjunction
                    } else if a.causes.len() == 1 {
                        CrateClass::Single
                    } else {
                        CrateClass::Several
                    }
                }
            },
        }
    }
}

/// The visual classes. Distinct because a click means something different in
/// each, and drawing two of them alike is how a graph lies.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CrateClass {
    /// ⊥ — in every build. No click removes it.
    Unconditional,
    /// Exactly one feature pulls it.
    Single,
    /// Several features pull it independently; ALL must go.
    Several,
    /// It arrives only under a conjunction — ONE conjunct is enough to lose it.
    Conjunction,
    /// Not in the build even with everything on.
    Unreachable,
    /// No analysis. Never silently drawn as "absent".
    Unknown,
}

impl CrateClass {
    pub fn ring(self) -> Color32 {
        match self {
            CrateClass::Unconditional => Color32::from_rgb(190, 70, 70),
            CrateClass::Single => Color32::from_rgb(90, 170, 100),
            CrateClass::Several => Color32::from_rgb(205, 160, 60),
            CrateClass::Conjunction => Color32::from_rgb(150, 110, 200),
            CrateClass::Unreachable => Color32::from_gray(90),
            CrateClass::Unknown => Color32::from_rgb(220, 60, 200),
        }
    }
    pub fn as_str(self) -> &'static str {
        match self {
            CrateClass::Unconditional => "unconditional",
            CrateClass::Single => "single",
            CrateClass::Several => "several",
            CrateClass::Conjunction => "conjunction",
            CrateClass::Unreachable => "unreachable",
            CrateClass::Unknown => "unknown",
        }
    }
}

/// What a click did — recorded so a headless driver can assert on the ACTION as
/// well as the resulting state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum Action {
    /// A feature was toggled directly.
    ToggleFeature { feature: String, now_enabled: bool },
    /// A dependency was clicked: these features were turned off to lose it.
    DropCrate { crate_name: String, disabled: Vec<String> },
    /// A dependency was clicked back on: these features returned.
    RestoreCrate { crate_name: String, enabled: Vec<String> },
    /// A conjunction node was clicked: one conjunct was turned off.
    BreakConjunction { conjuncts: Vec<String>, disabled: String },
    /// A click that could not do anything, and why. Rendered, not swallowed —
    /// a UI that ignores a click on an unremovable crate teaches the user
    /// nothing.
    Refused { target: String, reason: String },
}

/// Node id helpers. Namespaced so a feature and a crate of the same name are
/// different nodes.
pub fn feat_id(n: &str) -> String {
    format!("feat:{n}")
}
pub fn crate_id(n: &str) -> String {
    format!("crate:{n}")
}
pub fn and_id(krate: &str, i: usize) -> String {
    format!("and:{krate}:{i}")
}

/// **The pane.**
#[derive(Debug, Clone, Default)]
pub struct GaloisPane {
    axis: FeatureAxis,
    disabled: BTreeSet<String>,
    view: GraphView,
    last: Option<Action>,
    /// Hide the unconditional crates — there are usually many and none of them are
    /// actionable. Off by default: hiding them silently would make the graph
    /// look more removable than it is.
    hide_unconditional: bool,
}

impl GaloisPane {
    pub fn new(axis: FeatureAxis) -> Self {
        Self { axis, ..Default::default() }
    }

    pub fn axis(&self) -> &FeatureAxis {
        &self.axis
    }
    pub fn set_axis(&mut self, axis: FeatureAxis) {
        self.axis = axis;
        self.disabled.retain(|f| self.axis.features.contains(f));
    }
    pub fn disabled(&self) -> &BTreeSet<String> {
        &self.disabled
    }
    pub fn last_action(&self) -> Option<&Action> {
        self.last.as_ref()
    }
    pub fn is_enabled(&self, feature: &str) -> bool {
        !self.disabled.contains(feature)
    }
    /// Whether a crate is currently in the build. The applied output.
    pub fn in_build(&self, krate: &str) -> bool {
        self.axis.in_build(krate, &self.disabled)
    }
    pub fn set_hide_unconditional(&mut self, v: bool) {
        self.hide_unconditional = v;
    }
    pub fn reset(&mut self) {
        self.disabled.clear();
        self.last = None;
    }

    // ── The two adjoints, as clicks ─────────────────────────────────────────

    /// **Direction 1** — click a feature. Returns the crates whose membership
    /// changed, which is what a caller should assert on.
    pub fn toggle_feature(&mut self, feature: &str) -> Vec<(String, bool)> {
        if !self.axis.features.contains(&feature.to_string()) {
            self.last = Some(Action::Refused {
                target: feature.to_string(),
                reason: format!("`{feature}` is not a declared feature of {}", self.axis.root),
            });
            return Vec::new();
        }
        let before = self.axis.present(&self.disabled);
        let now_enabled = if self.disabled.remove(feature) {
            true
        } else {
            self.disabled.insert(feature.to_string());
            false
        };
        let after = self.axis.present(&self.disabled);
        self.last =
            Some(Action::ToggleFeature { feature: feature.to_string(), now_enabled });
        changed(&before, &after)
    }

    /// **Direction 2** — click a dependency. If it is in the build, turn off the
    /// features that hold it up; if it is already out, put them back.
    pub fn toggle_crate(&mut self, krate: &str) -> Vec<(String, bool)> {
        let Some(a) = self.axis.analyses.get(krate).cloned() else {
            self.last = Some(Action::Refused {
                target: krate.to_string(),
                reason: format!("no analysis for `{krate}` — it is not on this axis"),
            });
            return Vec::new();
        };
        let before = self.axis.present(&self.disabled);

        if self.axis.in_build(krate, &self.disabled) {
            match a.status {
                Reachability::Unconditional => {
                    self.last = Some(Action::Refused {
                        target: krate.to_string(),
                        reason: format!(
                            "`{krate}` is pulled unconditionally — no feature click removes it"
                        ),
                    });
                    return Vec::new();
                }
                Reachability::Never => return Vec::new(),
                Reachability::Conditional => {
                    let plan: Vec<String> =
                        a.drop.features_to_disable.iter().cloned().collect();
                    if plan.is_empty() {
                        self.last = Some(Action::Refused {
                            target: krate.to_string(),
                            reason: a.drop.note.clone(),
                        });
                        return Vec::new();
                    }
                    for f in &plan {
                        self.disabled.insert(f.clone());
                    }
                    self.last = Some(Action::DropCrate {
                        crate_name: krate.to_string(),
                        disabled: plan,
                    });
                }
            }
        } else {
            // Put back exactly the features that any cause needs. Restoring the
            // FIRST cause is enough to bring it back, and restoring all of them
            // would undo unrelated clicks the user made deliberately.
            let restore: Vec<String> = a
                .causes
                .first()
                .map(|c| c.iter().cloned().collect())
                .unwrap_or_default();
            for f in &restore {
                self.disabled.remove(f);
            }
            self.last = Some(Action::RestoreCrate {
                crate_name: krate.to_string(),
                enabled: restore,
            });
        }

        let after = self.axis.present(&self.disabled);
        changed(&before, &after)
    }

    /// Click a conjunction node: turn off ONE conjunct. For an AND-group one is
    /// enough, and this is the interaction that makes that visible.
    pub fn break_conjunction(&mut self, conjuncts: &[String]) -> Vec<(String, bool)> {
        let Some(pick) = conjuncts.iter().find(|f| !self.disabled.contains(*f)).cloned() else {
            return Vec::new();
        };
        let before = self.axis.present(&self.disabled);
        self.disabled.insert(pick.clone());
        let after = self.axis.present(&self.disabled);
        self.last = Some(Action::BreakConjunction {
            conjuncts: conjuncts.to_vec(),
            disabled: pick,
        });
        changed(&before, &after)
    }

    // ── Rendering ───────────────────────────────────────────────────────────

    /// The laid-out graph. Split from `ui` so a headless test can assert on the
    /// NODES AND EDGES that would be painted, not merely that painting happened.
    pub fn model(&self) -> (GraphModel, Decorations) {
        let mut nodes = Vec::new();
        let mut edges = Vec::new();
        let mut deco: HashMap<String, NodeDecoration> = HashMap::new();

        // Centre each column vertically on the origin too.
        let feat_mid = (self.axis.features.len().saturating_sub(1)) as f32 * ROW_H / 2.0;
        let shown = self
            .axis
            .analyses
            .keys()
            .filter(|k| {
                !(self.hide_unconditional
                    && self.axis.class_of(k) == CrateClass::Unconditional)
            })
            .count();
        let crate_mid = (shown.saturating_sub(1)) as f32 * ROW_H / 2.0;
        let and_total: usize = self
            .axis
            .analyses
            .values()
            .map(|a| a.causes.iter().filter(|c| c.len() > 1).count())
            .sum();
        let and_mid = (and_total.saturating_sub(1)) as f32 * ROW_H / 2.0;

        for (row, f) in self.axis.features.iter().enumerate() {
            let on = !self.disabled.contains(f);
            nodes.push(GraphNode {
                id: feat_id(f),
                label: if on { f.clone() } else { format!("{f}  (off)") },
                fill: if on {
                    Color32::from_rgb(46, 78, 60)
                } else {
                    Color32::from_gray(44)
                },
                stroke: if on {
                    Color32::from_rgb(110, 190, 130)
                } else {
                    Color32::from_gray(80)
                },
                pos: Pos2::new(COL_FEATURE, row as f32 * ROW_H - feat_mid),
            });
        }

        let mut and_row = 0.0f32;
        let mut crate_row = 0.0f32;
        for (name, a) in &self.axis.analyses {
            let class = self.axis.class_of(name);
            if self.hide_unconditional && class == CrateClass::Unconditional {
                continue;
            }
            let present = self.axis.in_build(name, &self.disabled);
            let cid = crate_id(name);
            nodes.push(GraphNode {
                id: cid.clone(),
                label: name.clone(),
                // A crate that has left the build is DIM, not absent: removing
                // the node would lose the very information the click produced.
                fill: if present {
                    Color32::from_rgb(38, 46, 66)
                } else {
                    Color32::from_gray(30)
                },
                stroke: if present {
                    Color32::from_rgb(110, 140, 200)
                } else {
                    Color32::from_gray(64)
                },
                pos: Pos2::new(COL_CRATE, crate_row - crate_mid),
            });
            crate_row += ROW_H;

            deco.insert(
                cid.clone(),
                NodeDecoration {
                    ring: Some(class.ring()),
                    badge: match class {
                        CrateClass::Conjunction => Some(format!(
                            "AND {}",
                            a.causes.iter().filter(|c| c.len() > 1).count()
                        )),
                        CrateClass::Several => Some(format!("ANY {}", a.causes.len())),
                        CrateClass::Unconditional => Some("ALWAYS".to_string()),
                        _ => None,
                    },
                    badge_color: Some(class.ring()),
                },
            );

            for (i, cause) in a.causes.iter().enumerate() {
                let satisfied = cause.iter().all(|f| !self.disabled.contains(f));
                if cause.len() == 1 {
                    let f = cause.iter().next().expect("len 1");
                    edges.push(GraphEdge {
                        from: feat_id(f),
                        to: cid.clone(),
                        color: if satisfied {
                            Color32::from_rgb(110, 190, 130)
                        } else {
                            Color32::from_gray(60)
                        },
                        dashed: !satisfied,
                        label: None,
                    });
                } else {
                    let aid = and_id(name, i);
                    nodes.push(GraphNode {
                        id: aid.clone(),
                        label: cause.iter().cloned().collect::<Vec<_>>().join("  AND  "),
                        fill: if satisfied {
                            Color32::from_rgb(58, 44, 74)
                        } else {
                            Color32::from_gray(34)
                        },
                        stroke: CrateClass::Conjunction.ring(),
                        pos: Pos2::new(COL_AND, and_row - and_mid),
                    });
                    and_row += ROW_H;
                    for f in cause {
                        edges.push(GraphEdge {
                            from: feat_id(f),
                            to: aid.clone(),
                            color: if self.disabled.contains(f) {
                                Color32::from_gray(60)
                            } else {
                                CrateClass::Conjunction.ring()
                            },
                            dashed: self.disabled.contains(f),
                            label: None,
                        });
                    }
                    edges.push(GraphEdge {
                        from: aid,
                        to: cid.clone(),
                        color: if satisfied {
                            CrateClass::Conjunction.ring()
                        } else {
                            Color32::from_gray(60)
                        },
                        dashed: !satisfied,
                        label: Some("both".to_string()),
                    });
                }
            }
        }

        (GraphModel { nodes, edges }, Decorations { nodes: deco, edges: Vec::new() })
    }

    /// The consequence line — what turning these features off actually costs.
    pub fn consequence(&self) -> String {
        let all = self.axis.present(&BTreeSet::new());
        let now = self.axis.present(&self.disabled);
        let lost = all.len().saturating_sub(now.len());
        let gated = self.axis.gated_lost(&self.disabled);
        let bytes = match self.axis.bytes_lost(&self.disabled) {
            Some(b) if b > 0 => format!(", −{b} bytes"),
            // Silent about size rather than printing a partial sum as a total.
            _ => String::new(),
        };
        if self.disabled.is_empty() {
            format!("{} crates, nothing disabled", all.len())
        } else {
            format!(
                "{} of {} features off  ->  -{lost} crates ({} left), {gated} gated items stop \
                 compiling{bytes}",
                self.disabled.len(),
                self.axis.features.len(),
                now.len()
            )
        }
    }

    /// **Everything observable**, for a headless driver. Asserting on this is
    /// asserting on applied output: the node set, the edge set, and which crates
    /// the click actually removed.
    pub fn state_json(&self) -> serde_json::Value {
        let (model, deco) = self.model();
        let present: Vec<&String> =
            self.axis.analyses.keys().filter(|k| self.in_build(k)).collect();
        let absent: Vec<&String> =
            self.axis.analyses.keys().filter(|k| !self.in_build(k)).collect();
        serde_json::json!({
            "repo": self.axis.repo,
            "root": self.axis.root,
            "provenance": self.axis.provenance,
            "features": self.axis.features,
            "disabled": self.disabled.iter().collect::<Vec<_>>(),
            "present": present,
            "absent": absent,
            "node_ids": model.nodes.iter().map(|n| n.id.clone()).collect::<Vec<_>>(),
            "node_labels": model.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>(),
            "edges": model.edges.iter()
                .map(|e| serde_json::json!({"from": e.from, "to": e.to, "dashed": e.dashed}))
                .collect::<Vec<_>>(),
            "conjunction_nodes": model.nodes.iter()
                .filter(|n| n.id.starts_with("and:"))
                .map(|n| n.id.clone()).collect::<Vec<_>>(),
            "classes": self.axis.analyses.keys()
                .map(|k| (k.clone(), self.axis.class_of(k).as_str()))
                .collect::<BTreeMap<_, _>>(),
            "badges": deco.nodes.iter()
                .filter_map(|(k, d)| d.badge.clone().map(|b| (k.clone(), b)))
                .collect::<BTreeMap<_, _>>(),
            "consequence": self.consequence(),
            "last_action": self.last,
            "gated_lost": self.axis.gated_lost(&self.disabled),
        })
    }

    /// Paint it, and apply whatever was clicked.
    pub fn ui(&mut self, ui: &mut Ui) {
        ui.horizontal(|ui| {
            ui.label(egui::RichText::new(format!("feature axis — {}", self.axis.root)).strong());
            ui.separator();
            if ui.button("reset").clicked() {
                self.reset();
            }
            let mut hide = self.hide_unconditional;
            if ui.checkbox(&mut hide, "hide the unconditional ones").changed() {
                self.hide_unconditional = hide;
            }
        });
        ui.label(self.consequence());
        if !self.axis.provenance.is_empty() {
            ui.label(egui::RichText::new(&self.axis.provenance).weak().small());
        }
        if let Some(Action::Refused { target, reason }) = &self.last {
            ui.colored_label(
                CrateClass::Unconditional.ring(),
                format!("{target}: {reason}"),
            );
        }
        ui.separator();

        // The clickable feature strip. The graph is the picture; this is the
        // control a keyboard/AccessKit driver can reach by name.
        ui.horizontal_wrapped(|ui| {
            let feats = self.axis.features.clone();
            for f in feats {
                let on = !self.disabled.contains(&f);
                if ui.selectable_label(on, &f).clicked() {
                    self.toggle_feature(&f);
                }
            }
        });
        ui.separator();

        let (model, deco) = self.model();
        // Colours from the LIVE visuals, not constants, so the pane follows
        // whatever theme facett-core's `look` presets put in force.
        let v = ui.visuals().clone();
        let resp = facett_graphpan::draw_graph(
            ui,
            &model,
            &deco,
            &mut self.view,
            v.extreme_bg_color,
            v.text_color(),
            v.selection.bg_fill,
            v.weak_text_color(),
        );
        if let Some(id) = resp.clicked_node {
            self.on_click(&id);
        }
    }

    /// Route a node click to the right adjoint. Public so a headless driver can
    /// exercise the exact same path the mouse takes — a test that called
    /// `toggle_crate` directly would be testing a method, not the pane.
    pub fn on_click(&mut self, node_id: &str) {
        if let Some(f) = node_id.strip_prefix("feat:") {
            let f = f.to_string();
            self.toggle_feature(&f);
        } else if let Some(c) = node_id.strip_prefix("crate:") {
            let c = c.to_string();
            self.toggle_crate(&c);
        } else if let Some(rest) = node_id.strip_prefix("and:") {
            // `and:<crate>:<i>`
            let (krate, idx) = match rest.rsplit_once(':') {
                Some((k, i)) => (k.to_string(), i.parse::<usize>().unwrap_or(0)),
                None => (rest.to_string(), 0),
            };
            let conj: Vec<String> = self
                .axis
                .analyses
                .get(&krate)
                .and_then(|a| a.causes.get(idx))
                .map(|c| c.iter().cloned().collect())
                .unwrap_or_default();
            if !conj.is_empty() {
                self.break_conjunction(&conj);
            }
        }
    }
}

fn changed(before: &BTreeSet<String>, after: &BTreeSet<String>) -> Vec<(String, bool)> {
    let mut out: Vec<(String, bool)> = before
        .difference(after)
        .map(|c| (c.clone(), false))
        .chain(after.difference(before).map(|c| (c.clone(), true)))
        .collect();
    out.sort();
    out
}

/// **The evaluated build space as a facett component** (2026-09-29, inventory item 6).
///
/// [`FeatureAxis`] is already the serde bridge (nornir-feature-graph evaluates the axis
/// on the HOST; `CrateAnalysis` is its serde output), so a pane over it is state in,
/// render out: this impl is what lets a deck, a scene slot or the Streamlit wrapper mount
/// it. The inherent `ui` / `state_json` are the whole behaviour; nothing is twinned.
impl facett_core::Facet for GaloisPane {
    fn title(&self) -> &str {
        if self.axis.root.is_empty() { "feature axis" } else { &self.axis.root }
    }
    fn ui(&mut self, ui: &mut Ui) {
        GaloisPane::ui(self, ui);
    }
    fn state_json(&self) -> serde_json::Value {
        GaloisPane::state_json(self)
    }
}