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

nordisk/dwarves

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