crates/nornir-funnel-embed/src/graph.rs
//! **The funnel DAG, rendered by the shared `facett-graphview` engine.**
//!
//! The funnel is a DAG — ideas decompose into plan nodes, and each node's `needs`
//! are the edges (`dwarves funnel-action decompose` returns exactly that shape;
//! `nornir funnel show` dumps the whole thing). `facett-plandag` renders it as a
//! metric-bearing card lane; this is the OTHER render — the node-link graph — on the
//! shared vello-backed [`facett_graphview`] engine, whose own header calls it "a
//! future drop-in for nornir's `draw_graph`".
//!
//! This is a domain ADAPTER, not a second renderer: it lowers a
//! [`funnel_core::plan_dag::PlanDag`] to the engine's [`GraphModel`], lays it out with
//! the engine's own [`DepGraphLayout`], and paints it with the engine's
//! [`DecoratedGraphView`]. The engine, the layout and the pane are reused verbatim.
//!
//! ## No node ceiling
//! nornir's CallGraph pane hardcodes `MAX_NODES = 160` and shows a subset beyond it.
//! Nothing here caps the funnel: the engine's spatial pick index makes a click cost a
//! neighbourhood rather than the dataset. [`FunnelGraphPane::report`] states
//! `node_cap: null` and carries the measured per-frame cost, so a large funnel is a
//! visible measurement rather than a silent truncation.
use facett_graphview::{
Color, Decorations, DepEdge, DepGraphLayout, GraphEdge, GraphModel, GraphNode, NodeDecoration,
Pos,
};
use funnel_core::plan_dag::{DagStatus, PlanDag};
/// Column spacing between dependency ranks (world units).
const COL_W: f32 = 244.0;
/// Row spacing between siblings in a rank.
const ROW_H: f32 = 52.0;
/// The status colour of a funnel node (idle / running / done / failed).
fn status_fill(s: DagStatus) -> Color {
match s {
DagStatus::Idle => Color::rgb(0x55, 0x5b, 0x69),
DagStatus::Running => Color::rgb(0x2f, 0x5c, 0x8a),
DagStatus::Done => Color::rgb(0x3c, 0x7a, 0x4e),
DagStatus::Failed => Color::rgb(0x8f, 0x33, 0x33),
}
}
/// **Lower a [`PlanDag`] onto the engine's model, laid out.**
///
/// Node ids and the `from → to` edges are the funnel's own; the layout is the
/// engine's [`DepGraphLayout`] longest-path ranking. A node the rank walk did not
/// reach — an isolated idea with no edges — is placed in a trailing column rather
/// than dropped: an absent node is indistinguishable from one that never existed.
pub fn plandag_to_model(dag: &PlanDag) -> (GraphModel, Decorations) {
let ids: Vec<String> = dag.nodes.iter().map(|n| n.id.clone()).collect();
let dep_edges: Vec<DepEdge> = dag
.edges
.iter()
.map(|e| DepEdge::new(e.from.clone(), e.to.clone(), &[]))
.collect();
let laid = DepGraphLayout::build(&ids, &dep_edges, false, &Default::default());
let mut pos: std::collections::HashMap<&str, Pos> = std::collections::HashMap::new();
let mut max_col = 0usize;
for n in &laid.nodes {
max_col = max_col.max(n.col);
pos.insert(
n.repo.as_str(),
Pos::new(n.col as f32 * COL_W, n.row as f32 * ROW_H),
);
}
let mut spill = 0usize;
for n in &dag.nodes {
if !pos.contains_key(n.id.as_str()) {
pos.insert(
n.id.as_str(),
Pos::new((max_col + 1) as f32 * COL_W, spill as f32 * ROW_H),
);
spill += 1;
}
}
let nodes: Vec<GraphNode> = dag
.nodes
.iter()
.map(|n| {
let title = if n.title.trim().is_empty() {
n.id.clone()
} else {
n.title.clone()
};
GraphNode {
id: n.id.clone(),
label: title,
fill: status_fill(n.status),
stroke: Color::rgb(230, 232, 240),
pos: pos
.get(n.id.as_str())
.copied()
.unwrap_or(Pos::new(0.0, 0.0)),
}
})
.collect();
let edges: Vec<GraphEdge> = dag
.edges
.iter()
.map(|e| GraphEdge {
from: e.from.clone(),
to: e.to.clone(),
color: Color::rgba(170, 178, 200, 190),
dashed: false,
label: None,
})
.collect();
// A bug node gets an emphasis ring; a failed node gets one too (both are "look
// here"). The badge is the node's status, so the render says WHY a node is lit.
let mut decorations = Decorations::default();
for n in &dag.nodes {
let ring = if n.is_bug {
Some(Color::rgb(0xd9, 0x53, 0x4f))
} else if n.status == DagStatus::Failed {
Some(Color::rgb(0xe0, 0xa8, 0x00))
} else {
None
};
if ring.is_none() {
continue;
}
decorations.nodes.insert(
n.id.clone(),
NodeDecoration {
ring,
badge: Some(if n.is_bug {
"bug".to_string()
} else {
n.status.as_str().to_string()
}),
badge_color: ring,
..Default::default()
},
);
}
(GraphModel { nodes, edges }, decorations)
}
/// **A measured funnel graph pane** over the shared [`DecoratedGraphView`].
///
/// Records what it drew — node count, edge count, and the wall time of the last
/// frame — so a graph that quietly caps its input or costs 700 ms a frame is visible
/// as a number rather than a silence.
pub struct FunnelGraphPane {
view: facett_graphview::DecoratedGraphView,
node_count: usize,
edge_count: usize,
last_frame_us: Option<u128>,
/// Every facett UI error this mount actually REACTED to (facett error-codes,
/// Phase 2) — the mounted code + the canonical reaction, published in
/// [`report`](Self::report) so a robot reads the CODE, never a rendered string.
facet_errors: facett_core::errcode::FacetRecorder,
}
impl Default for FunnelGraphPane {
fn default() -> Self {
Self::new()
}
}
impl FunnelGraphPane {
/// An empty pane, engine legibility on (label collision + edge ink budget).
pub fn new() -> Self {
Self {
view: facett_graphview::DecoratedGraphView::new("Funnel DAG").with_legibility(true),
node_count: 0,
edge_count: 0,
last_frame_us: None,
facet_errors: facett_core::errcode::FacetRecorder::new(),
}
}
/// Replace the graph from a [`PlanDag`]. The WHOLE DAG is loaded — no node cap.
///
/// This is the LOAD path, so it is where a facett UI failure is decided: a DAG
/// that lowered to no scene leaves the pane raising `facet-graphview-1`. The
/// Phase-2 reaction is taken here ([`react_to_pane`](Self::react_to_pane)) rather
/// than left to a caller who might not look.
pub fn set_dag(&mut self, dag: &PlanDag) {
let (model, decorations) = plandag_to_model(dag);
self.node_count = model.nodes.len();
self.edge_count = model.edges.len();
self.view = facett_graphview::DecoratedGraphView::new("Funnel DAG")
.with_legibility(true)
.with_model(model)
.with_decorations(decorations);
self.react_to_pane();
}
/// Paint, timing the call, then take the Phase-2 reaction to whatever the pane
/// raised on THIS frame (a render-time failure — a raster that produced no pixels,
/// a graph source that did not load — is only visible after the paint).
pub fn ui(&mut self, ui: &mut egui::Ui) {
let t0 = std::time::Instant::now();
facett_core::Facet::ui(&mut self.view, ui);
self.last_frame_us = Some(t0.elapsed().as_micros());
self.react_to_pane();
}
/// **The Phase-2 consumer reaction** — read the code the pane PUBLISHED and react
/// to it through the `dwarves/funnel` mount (see [`crate::facet_react`]).
///
/// Gated on the pane's structural [`Severity`](facett_core::Severity) so the happy
/// path never pays for the (node-by-node) `state_json` build: a pane reporting the
/// `Info` floor has raised nothing. Returns the reaction taken, for a caller that
/// wants to branch on it.
pub fn react_to_pane(&mut self) -> Option<facett_core::errcode::Reaction> {
if facett_core::Facet::severity(&self.view) == facett_core::Severity::Info {
return None;
}
let state = facett_core::Facet::state_json(&self.view);
crate::facet_react::react_to_pane_state(&mut self.facet_errors, &state)
}
/// Every facett UI error this mount reacted to, newest last — the mounted code
/// (`dwarves/funnel:facet-graphview-1`) and the canonical reaction.
pub fn facet_errors(&self) -> &facett_core::errcode::FacetRecorder {
&self.facet_errors
}
/// Nodes in the model (the WHOLE funnel — never a truncated subset).
pub fn node_count(&self) -> usize {
self.node_count
}
/// Edges in the model.
pub fn edge_count(&self) -> usize {
self.edge_count
}
/// Wall time of the last painted frame, microseconds (`None` before first paint).
pub fn last_frame_us(&self) -> Option<u128> {
self.last_frame_us
}
/// The measurement, as data.
pub fn report(&self) -> serde_json::Value {
serde_json::json!({
"nodes": self.node_count,
"edges": self.edge_count,
"last_frame_us": self.last_frame_us,
// There is none — stated, so its absence is a claim, not a silence.
"node_cap": serde_json::Value::Null,
// The Phase-2 reaction surface: the matrix this mount is contracted to
// satisfy plus every code it actually reacted to on this run.
"facet_errors": crate::facet_react::state_json(&self.facet_errors),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use funnel_core::plan_dag::{EdgeKind, PlanDagEdge, PlanDagNode};
fn node(id: &str, title: &str, status: DagStatus) -> PlanDagNode {
PlanDagNode {
id: id.into(),
title: title.into(),
group: None,
status,
is_bug: false,
staleness_hours: 0.0,
weight_bytes: 0,
fanout: 0,
}
}
fn chain_dag(n: usize) -> PlanDag {
let nodes = (0..n)
.map(|i| node(&format!("n{i}"), &format!("task {i}"), DagStatus::Idle))
.collect();
let edges = (1..n)
.map(|i| PlanDagEdge {
from: format!("n{i}"),
to: format!("n{}", i - 1),
kind: EdgeKind::Dep,
})
.collect();
PlanDag {
nodes,
edges,
sel_plan: None,
}
}
/// The lowering keeps every node and every `needs` edge, and ranks a chain across
/// columns (a DAG, not a pile).
#[test]
fn lowering_preserves_nodes_and_edges() {
let mut dag = chain_dag(4);
// An isolated idea with no edges must still be placed.
dag.nodes
.push(node("lonely", "orphan idea", DagStatus::Running));
let (model, _) = plandag_to_model(&dag);
assert_eq!(model.nodes.len(), 5, "no funnel node is dropped");
assert_eq!(model.edges.len(), 3, "every needs-edge survives");
let xs: std::collections::BTreeSet<i64> =
model.nodes.iter().map(|n| n.pos.x as i64).collect();
assert!(
xs.len() >= 4,
"the chain is ranked across columns, got {xs:?}"
);
}
/// **RED-when-broken: no node ceiling.** 2 000 funnel nodes in ⇒ 2 000 in the model.
#[test]
fn the_funnel_graph_has_no_node_cap() {
let mut pane = FunnelGraphPane::new();
pane.set_dag(&chain_dag(2_000));
assert_eq!(pane.node_count(), 2_000);
assert_eq!(pane.edge_count(), 1_999);
assert!(pane.report()["node_cap"].is_null());
}
/// **The `dwarves/funnel` mount REACTS on the real load path.** An empty plan DAG
/// lowers to an empty scene, the pane itself raises `facet-graphview-1`, and
/// `set_dag` — the production load path, not a test helper — must namespace it to
/// `dwarves/funnel:` and take the canonical `empty_state` reaction. No synthetic
/// `FacetError`, no direct recorder poke.
#[test]
fn an_empty_dag_makes_the_funnel_mount_react_on_the_real_load_path() {
use facett_core::errcode::Reaction;
let mut pane = FunnelGraphPane::new();
pane.set_dag(&PlanDag {
nodes: Vec::new(),
edges: Vec::new(),
sel_plan: None,
});
assert_eq!(pane.node_count(), 0);
assert!(
pane.facet_errors().saw("dwarves/funnel:facet-graphview-1"),
"the MOUNTED code landed from the load path: {:?}",
pane.facet_errors().entries()
);
assert_eq!(
pane.facet_errors().entries().last().unwrap().reaction,
Reaction::EmptyState
);
// …and it is published as DATA, so a robot reads the CODE, not a string.
let rep = pane.report();
assert_eq!(
rep["facet_errors"]["recorded"]["entries"][0]["mounted_code"],
"dwarves/funnel:facet-graphview-1"
);
assert_eq!(
rep["facet_errors"]["recorded"]["entries"][0]["reaction"],
"empty_state"
);
}
/// A populated DAG raises nothing — the reaction path is not a false-positive
/// machine, and the happy path costs only the severity check.
#[test]
fn a_populated_dag_raises_nothing() {
let mut pane = FunnelGraphPane::new();
pane.set_dag(&chain_dag(6));
assert_eq!(pane.react_to_pane(), None);
assert_eq!(pane.facet_errors().total(), 0);
}
/// **Every `dwarves/funnel` matrix row goes through the PRODUCTION seam.** For each
/// declared `(code × dwarves/funnel)` row, feed the code the way a pane publishes
/// it and assert the canonical reaction + the mounted code — through
/// `facet_react::react_to_pane_state`, the same function `react_to_pane` calls.
#[test]
fn every_funnel_matrix_row_is_handled_by_the_production_seam() {
let matrix = crate::facet_react::matrix();
assert!(
!matrix.is_empty(),
"dwarves declares a dwarves/funnel mount"
);
for (mounted, base, mount, expected) in &matrix {
assert_eq!(*mount, "dwarves/funnel");
let mut rec = facett_core::errcode::FacetRecorder::new();
let state = serde_json::json!({ "error_code": base });
assert_eq!(
crate::facet_react::react_to_pane_state(&mut rec, &state),
Some(*expected),
"the production seam must take the canonical reaction for {base}"
);
assert!(rec.saw(mounted), "the production seam recorded {mounted}");
}
}
/// The seam is registry-driven: it never claims another mount's code, and a pane
/// that raised nothing is left alone.
#[test]
fn the_seam_ignores_foreign_codes_and_silent_panes() {
let mut rec = facett_core::errcode::FacetRecorder::new();
let foreign = serde_json::json!({ "error_code": "facet-korp-1" });
assert_eq!(
crate::facet_react::react_to_pane_state(&mut rec, &foreign),
None
);
let quiet = serde_json::json!({ "node_count": 3, "error_code": serde_json::Value::Null });
assert_eq!(
crate::facet_react::react_to_pane_state(&mut rec, &quiet),
None
);
assert_eq!(rec.total(), 0);
}
}
#[cfg(test)]
mod scale_probe {
use super::*;
use funnel_core::plan_dag::{EdgeKind, PlanDagEdge, PlanDagNode};
fn dag(n: usize) -> PlanDag {
let nodes = (0..n)
.map(|i| PlanDagNode {
id: format!("n{i}"),
title: format!("task {i}"),
group: None,
status: if i % 4 == 0 {
DagStatus::Done
} else if i % 4 == 1 {
DagStatus::Running
} else {
DagStatus::Idle
},
is_bug: i % 7 == 0,
staleness_hours: 0.0,
weight_bytes: 0,
fanout: 0,
})
.collect();
let mut edges = Vec::new();
for i in 1..n {
edges.push(PlanDagEdge {
from: format!("n{i}"),
to: format!("n{}", i - 1),
kind: EdgeKind::Dep,
});
if i >= 4 {
edges.push(PlanDagEdge {
from: format!("n{i}"),
to: format!("n{}", i - 4),
kind: EdgeKind::Dep,
});
}
}
PlanDag {
nodes,
edges,
sel_plan: None,
}
}
#[test]
#[ignore]
fn measure_scale() {
for n in [12usize, 160, 1000, 5000] {
let d = dag(n);
let mut pane = FunnelGraphPane::new();
pane.set_dag(&d);
let cell = std::cell::RefCell::new(pane);
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1600.0, 1000.0))
.build_ui(|ui| {
cell.borrow_mut().ui(ui);
});
let mut best = u128::MAX;
for _ in 0..6 {
h.step();
best = best.min(cell.borrow().last_frame_us().unwrap_or(0));
}
let c = cell.borrow();
eprintln!(
"FUNNEL-SCALE n={:>5} nodes={:>5} edges={:>6} frame_min={:>7}us ({:.2}ms)",
n,
c.node_count(),
c.edge_count(),
best,
best as f64 / 1000.0
);
}
}
}