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