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

nordisk/nornir

src/viz/graph.rs

//! Rectangular dep-graph renderer (🔗 Dep Graph tab).
//!
//! Nodes (repos) are **rectangular** cards laid out by topological rank — a
//! repo's dependencies sit to its right, consumers to its left — from the
//! pure-data [`DepGraphLayout`] (`super::depgraph_layout`), which also computes
//! the deep transitive closure (C5) and classifies every edge **direct** vs
//! **transitive** (C7).
//!
//! Navigable canvas (I3):
//!   * **pan** — drag the background, or scroll;
//!   * **zoom** — Ctrl/Cmd + scroll (or the +/− buttons);
//!   * **click a node** — toggle collapse/expand of its transitive subtree;
//!   * **deep toggle** — explode to the full transitive closure.
//!
//! The whole graph (nodes, positions, classified edges, collapse set) is mirrored
//! into `state_json` via [`DepGraphView::state_json`] (LAW #6), so the headless
//! snapshot/robot tests assert on the exact structure painted.

use std::collections::BTreeSet;

use eframe::egui::{
    self, Align2, Color32, CornerRadius, FontId, Pos2, Rect, Sense, Stroke, Ui, Vec2,
};
use uuid::Uuid;

use super::depgraph_layout::{DepGraphLayout, EdgeClass};
use super::facett_theme::ThemeExt; // nornir ramps over the shared facett palette (L5)
use super::model::Timeline;

const NODE_W: f32 = 160.0;
const NODE_H: f32 = 56.0;
const COL_GAP: f32 = 220.0;
const ROW_GAP: f32 = 90.0;
const LEFT_PAD: f32 = 30.0;
const TOP_PAD: f32 = 40.0;

// Edge palette (C7/C8) — direct deps take the palette accent, transitive deps the
// dimmer palette edge, so the eye separates "A depends on B" from "A reaches C
// through B" and both shift with the active facett palette.

/// Persistent view state for the 🔗 Dep Graph tab (owned by the app). Carries the
/// navigation transform (pan/zoom), the C5 **deep** toggle, the I3 collapse set,
/// and the last laid-out graph so its structure can be dumped to `state_json`.
#[derive(Default)]
pub struct DepGraphView {
    /// C5: explode to the full transitive closure when true.
    pub deep: bool,
    /// Pan offset (screen px) applied to the whole graph.
    pub pan: Vec2,
    /// Zoom factor (1.0 = 100%).
    pub zoom: f32,
    /// I3: repos the user has collapsed (their exclusive subtree hidden).
    pub collapsed: BTreeSet<String>,
    /// C8: active facett palette — every painted colour derives from this so a
    /// palette switch re-skins the pane.
    pub theme: super::facett_theme::Theme,
    /// Memoized transitive-closure for the selected repo's drill-down panel.
    /// `draw_drilldown` runs every frame; without this it recomputed the BFS
    /// closure each repaint. Keyed on `(repo, edges-fingerprint)` so it
    /// recomputes only when the selection or the underlying edges change.
    closure_cache: std::cell::RefCell<Option<ClosureMemo>>,
}

/// One memoized `transitive_closure` result + the key that produced it.
#[derive(Default)]
struct ClosureMemo {
    repo: String,
    /// Cheap fingerprint of `snap.edges` (count + folded hash) — if it matches,
    /// the edges are unchanged and the cached closure is reused.
    fingerprint: u64,
    closure: BTreeSet<String>,
    /// Diagnostics for the inject-assert test: bumped only on a real recompute.
    recomputes: u64,
}

impl DepGraphView {
    pub fn new() -> Self {
        Self {
            deep: false,
            pan: Vec2::ZERO,
            zoom: 1.0,
            collapsed: BTreeSet::new(),
            theme: super::facett_theme::Theme::default(),
            closure_cache: std::cell::RefCell::new(None),
        }
    }

    /// Transitive closure of `repo` over `edges`, **memoized** across frames.
    /// `draw_drilldown` calls this every repaint; the BFS only re-runs when the
    /// selected repo or the edge set changes (cheap fingerprint compare). The
    /// returned set is cloned out so the `RefCell` borrow doesn't escape.
    ///
    /// Returns `(closure, recomputed)` — `recomputed` is `true` only when a real
    /// BFS happened, so a test can assert it's `false` on an unchanged repaint.
    pub fn closure_memo(
        &self,
        repo: &str,
        edges: &[crate::warehouse::dep_graph::CrossRepoEdge],
    ) -> (BTreeSet<String>, bool) {
        let fp = edges_fingerprint(edges);
        let mut slot = self.closure_cache.borrow_mut();
        if let Some(memo) = slot.as_ref() {
            if memo.repo == repo && memo.fingerprint == fp {
                return (memo.closure.clone(), false); // cache hit — no recompute
            }
        }
        let closure = DepGraphLayout::transitive_closure(repo, edges);
        let recomputes = slot.as_ref().map(|m| m.recomputes).unwrap_or(0) + 1;
        *slot = Some(ClosureMemo {
            repo: repo.to_string(),
            fingerprint: fp,
            closure: closure.clone(),
            recomputes,
        });
        (closure, true)
    }

    /// Total number of real closure recomputes so far — for the inject-assert
    /// test that asserts an unchanged repaint does not recompute.
    pub fn closure_recomputes(&self) -> u64 {
        self.closure_cache
            .borrow()
            .as_ref()
            .map(|m| m.recomputes)
            .unwrap_or(0)
    }

    /// C8: swap the active palette; the next draw re-skins from `t`.
    pub fn set_palette(&mut self, t: super::facett_theme::Theme) {
        self.theme = t;
    }

    /// The laid-out graph as JSON for the introspection dump, computed fresh
    /// from `tl` + the current view settings (deep + collapse). Used by
    /// `state_json` so the headless matrix sees the structure without a draw.
    /// Returns `Null` when there is no snapshot to lay out.
    pub fn state_json_for(
        &self,
        tl: &Timeline,
        selected_release: Option<Uuid>,
    ) -> serde_json::Value {
        // An empty timeline (no release/snapshot to lay out) still EMITS a
        // readable "empty" slice (LAW #6 / coverage-guard) — never a bare null the
        // headless matrix can't tell apart from "this pane has no emitter".
        let mut v = self
            .layout_for(tl, selected_release)
            .map(|l| l.state_json())
            .unwrap_or_else(|| {
                serde_json::json!({
                    "empty": true,
                    "node_count": 0,
                    "edge_count": 0,
                })
            });
        if let serde_json::Value::Object(obj) = &mut v {
            obj.insert("palette".into(), serde_json::json!(self.theme.name));
        }
        v
    }

    fn layout_for(&self, tl: &Timeline, selected_release: Option<Uuid>) -> Option<DepGraphLayout> {
        // DEFAULT-LOAD (never blank while ANY snapshot exists): prefer the
        // explicitly-selected release's snapshot; else fall back to the MOST RECENT
        // release that HAS a dep-graph snapshot (walk `release_order` newest-first);
        // else the latest loaded snapshot. Only truly-empty workspaces (no snapshot
        // anywhere) return `None`. Previously this returned `None` whenever the
        // last release / the selection lacked a pinned snapshot, blanking the tab
        // even though the workspace held other snapshots.
        let snapshot = selected_release
            .and_then(|r| tl.snapshot_for(&r))
            .or_else(|| {
                tl.release_order
                    .iter()
                    .rev()
                    .find_map(|r| tl.snapshot_for(r))
            })
            .or(tl.latest_snapshot.as_ref())?;
        let repos: Vec<String> = tl.lanes.iter().map(|l| l.repo.clone()).collect();
        Some(DepGraphLayout::build(
            &repos,
            &snapshot.edges,
            self.deep,
            &self.collapsed,
        ))
    }
}

/// Draw the dep-graph tab. `selected_repo` is the app's drill-down selection
/// (kept so the existing right-panel keeps working). Returns nothing; mutates
/// `view` (pan/zoom/collapse/deep) and `selected_repo` from interaction.
pub fn draw_dep_graph(
    ui: &mut Ui,
    tl: &Timeline,
    selected_release: Option<Uuid>,
    selected_repo: &mut Option<String>,
    view: &mut DepGraphView,
) {
    if view.zoom <= 0.0 {
        view.zoom = 1.0;
    }
    let theme = view.theme;
    let direct_color = theme.accent;
    let transitive_color = theme.edge;

    // ── toolbar: deep toggle, zoom, reset, legend ──────────────────────────
    ui.horizontal(|ui| {
        let deep_label = if view.deep {
            "🌳 deep: ON"
        } else {
            "🌳 deep: off"
        };
        if ui
            .selectable_label(view.deep, deep_label)
            .on_hover_text("explode to the FULL transitive closure (C5)")
            .clicked()
        {
            view.deep = !view.deep;
        }
        ui.separator();
        if ui.button("➖").on_hover_text("zoom out").clicked() {
            view.zoom = (view.zoom * 0.9).max(0.25);
        }
        ui.label(format!("{:.0}%", view.zoom * 100.0));
        if ui.button("➕").on_hover_text("zoom in").clicked() {
            view.zoom = (view.zoom * 1.1).min(4.0);
        }
        if ui
            .button("⟲ reset view")
            .on_hover_text("reset pan + zoom")
            .clicked()
        {
            view.pan = Vec2::ZERO;
            view.zoom = 1.0;
        }
        if !view.collapsed.is_empty()
            && ui
                .button(format!("expand all ({})", view.collapsed.len()))
                .on_hover_text("re-expand every collapsed node")
                .clicked()
        {
            view.collapsed.clear();
        }
        ui.separator();
        // Legend (C7).
        legend_swatch(ui, direct_color, "direct dep");
        legend_swatch(ui, transitive_color, "transitive dep");
    });

    let Some(layout) = view.layout_for(tl, selected_release) else {
        // No release in the WHOLE workspace has a dep-graph snapshot yet (the
        // fallback above already default-loads the most recent one that does). A
        // snapshot is captured whenever a workspace is (re)populated / released, so
        // point the user at that path rather than leaving a bare "empty".
        ui.label("no dependency-graph snapshot recorded in this workspace yet");
        ui.weak("a snapshot is captured when the workspace is populated / released — run a populate (🧬 nornir → Populate) or `nornir workspace republish` to record one.");
        return;
    };

    // ── canvas ─────────────────────────────────────────────────────────────
    let n_cols = layout.nodes.iter().map(|n| n.col).max().unwrap_or(0) + 1;
    let n_rows = layout.nodes.iter().map(|n| n.row).max().unwrap_or(0) + 1;
    let available = ui.available_size();
    let content_w = LEFT_PAD * 2.0 + n_cols as f32 * COL_GAP + NODE_W;
    let content_h = TOP_PAD * 2.0 + n_rows as f32 * ROW_GAP + NODE_H;
    let canvas = Vec2::new(available.x.max(400.0), available.y.max(360.0));
    let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click_and_drag());
    let painter = ui.painter_at(rect);
    painter.rect_filled(rect, CornerRadius::ZERO, theme.bg);

    // ── pan + zoom interaction (I3) ──────────────────────────────────────────
    if resp.dragged() {
        view.pan += resp.drag_delta();
    }
    let hovered = resp.hovered();
    if hovered {
        ui.input(|i| {
            // scroll → pan; Ctrl/Cmd+scroll → zoom about the cursor.
            let scroll = i.smooth_scroll_delta;
            if i.modifiers.command || i.modifiers.ctrl {
                let factor = (1.0 + scroll.y * 0.0015).clamp(0.5, 2.0);
                view.zoom = (view.zoom * factor).clamp(0.25, 4.0);
            } else if scroll != Vec2::ZERO {
                view.pan += scroll;
            }
        });
    }

    // world → screen: center the content, then apply pan + zoom.
    let base = rect.left_top().to_vec2() + Vec2::new(LEFT_PAD, TOP_PAD);
    let z = view.zoom;
    let offset = base
        + view.pan
        + Vec2::new(
            ((rect.width() - content_w * z).max(0.0)) / 2.0,
            ((rect.height() - content_h * z).max(0.0)) / 2.0,
        );
    let node_w = NODE_W * z;
    let node_h = NODE_H * z;
    let pos_of = |col: usize, row: usize| -> Pos2 {
        (offset + Vec2::new(col as f32 * COL_GAP * z, row as f32 * ROW_GAP * z)).to_pos2()
    };
    let node_pos =
        |repo: &str| -> Option<Pos2> { layout.position(repo).map(|(c, r)| pos_of(c, r)) };
    let node_rect = |repo: &str| -> Option<Rect> {
        node_pos(repo).map(|p| Rect::from_min_size(p, Vec2::new(node_w, node_h)))
    };

    // ── edges first (under nodes), coloured by class (C7) ───────────────────
    // Painted-primitive counters (testmatrix only): the emit-fallback signal that
    // the egui draw produced a NON-BLANK scene. Stripped from release.
    #[cfg(feature = "testmatrix")]
    let (mut painted_edges, mut painted_nodes) = (0usize, 0usize);
    for e in &layout.edges {
        let (Some(fr), Some(tr)) = (node_rect(&e.from), node_rect(&e.to)) else {
            continue;
        };
        #[cfg(feature = "testmatrix")]
        {
            painted_edges += 1;
        }
        let from = fr.center();
        let to = tr.center();
        let color = match e.class {
            EdgeClass::Direct => direct_color,
            EdgeClass::Transitive => transitive_color,
        };
        let dashed = e.class == EdgeClass::Transitive;
        draw_arrow(&painter, from, to, color, node_w, dashed, z);
        // Label: via crates for a direct edge, hop count for a transitive one.
        let mid = Pos2::new((from.x + to.x) / 2.0, (from.y + to.y) / 2.0 - 8.0 * z);
        let label = match e.class {
            EdgeClass::Direct => {
                let via = e.via.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
                if e.via.len() > 2 {
                    format!("{via} +{}", e.via.len() - 2)
                } else {
                    via
                }
            }
            EdgeClass::Transitive => format!("⤳ {} hops", e.hops),
        };
        if z > 0.45 && !label.is_empty() {
            painter.text(
                mid,
                Align2::CENTER_BOTTOM,
                &label,
                FontId::monospace(10.0 * z.min(1.4)),
                if dashed { theme.text_dim } else { theme.text },
            );
        }
    }

    // ── nodes (rectangular cards — KEEP the look) ───────────────────────────
    for node in &layout.nodes {
        let Some(nrect) = node_rect(&node.repo) else {
            continue;
        };
        #[cfg(feature = "testmatrix")]
        {
            painted_nodes += 1;
        }
        let lane = tl.lanes.iter().find(|l| l.repo == node.repo);
        let release = selected_release.or_else(|| tl.release_order.last().copied());
        let status = lane
            .and_then(|l| release.and_then(|rid| l.nodes.iter().find(|n| n.release_id == rid)))
            .map(|n| status_color(&theme, &n.gate_status))
            .unwrap_or(theme.node_stroke);
        let is_sel = selected_repo.as_deref() == Some(node.repo.as_str());
        let fill = if is_sel {
            theme.accent.linear_multiply(0.18)
        } else {
            theme.node_fill
        };
        painter.rect_filled(nrect, CornerRadius::same(6), fill);
        let (bw, bc) = if is_sel {
            (3.0_f32, theme.accent)
        } else if node.collapsed {
            (2.5_f32, super::facett_theme::AMBER)
        } else {
            (2.0_f32, status)
        };
        painter.rect_stroke(
            nrect,
            CornerRadius::same(6),
            Stroke::new(bw, bc),
            egui::StrokeKind::Inside,
        );
        // collapse marker (▸ collapsed / ▾ expanded) at the top-left.
        if z > 0.5 {
            painter.text(
                nrect.left_top() + Vec2::new(6.0, 4.0),
                Align2::LEFT_TOP,
                if node.collapsed { "▸" } else { "▾" },
                FontId::monospace(12.0),
                theme.text,
            );
        }
        painter.text(
            nrect.center_top() + Vec2::new(0.0, 12.0 * z),
            Align2::CENTER_TOP,
            &node.repo,
            FontId::proportional(15.0 * z.clamp(0.6, 1.4)),
            theme.text,
        );
        if z > 0.55 {
            if let Some(n) = lane
                .and_then(|l| release.and_then(|rid| l.nodes.iter().find(|n| n.release_id == rid)))
            {
                painter.text(
                    nrect.center_top() + Vec2::new(0.0, 32.0 * z),
                    Align2::CENTER_TOP,
                    &n.sha[..n.sha.len().min(12)],
                    FontId::monospace(11.0 * z.clamp(0.6, 1.4)),
                    theme.text,
                );
            }
        }
    }

    // ── click: a node toggles collapse/expand (I3); empty space deselects ───
    if resp.clicked() {
        if let Some(pos) = resp.interact_pointer_pos() {
            let hit = layout
                .nodes
                .iter()
                .map(|n| n.repo.clone())
                .find(|r| node_rect(r).is_some_and(|rc| rc.contains(pos)));
            if let Some(repo) = hit {
                // Select for the drill-down panel AND toggle collapse.
                *selected_repo = Some(repo.clone());
                if view.collapsed.contains(&repo) {
                    view.collapsed.remove(&repo);
                } else {
                    view.collapsed.insert(repo);
                }
            } else {
                *selected_repo = None;
            }
        }
    }

    // ── drill-down panel for the selected repo (unchanged behaviour) ────────
    if let (Some(repo), Some(release)) = (
        selected_repo.clone(),
        selected_release.or_else(|| tl.release_order.last().copied()),
    ) {
        if let Some(snap) = tl.snapshot_for(&release) {
            draw_drilldown(view, &painter, rect, &repo, snap, &layout, &theme);
        }
    }

    // ── emit-fallback (testmatrix only): the egui paint is a side-effect that
    // can't be read back from outside, so record the painted-primitive count as
    // a functional row. ok ⇔ at least one node card was painted ⇒ scene NOT blank.
    //
    // Gate on a non-empty layout: only assert "non-blank" when the timeline
    // actually carried repos to lay out. A dep-graph pane drawn over an empty
    // warehouse (no dep_graph snapshot) legitimately has nothing to paint — that
    // is an empty dataset, not a render failure, so it must not stamp a RED row.
    #[cfg(feature = "testmatrix")]
    if !layout.nodes.is_empty() {
        crate::selftest::emit(
            "viz/graph (DepGraphView egui draw)",
            "dep_graph_rendered_non_blank",
            painted_nodes > 0,
            &format!(
                "painted {painted_nodes} node card(s) + {painted_edges} edge(s) on a \
                 {:.0}x{:.0}px canvas",
                rect.width(),
                rect.height()
            ),
        );
    }
}

/// Cheap order-insensitive-enough fingerprint of the recorded edge set, used to
/// invalidate the drill-down closure memo. Edges are produced deterministically
/// from a snapshot, so a fold over `(from, to)` in stored order is a stable key:
/// identical edges → identical fingerprint → cache hit (no recompute).
fn edges_fingerprint(edges: &[crate::warehouse::dep_graph::CrossRepoEdge]) -> u64 {
    use std::hash::{Hash, Hasher};
    let mut h = std::collections::hash_map::DefaultHasher::new();
    edges.len().hash(&mut h);
    for e in edges {
        e.from.hash(&mut h);
        e.to.hash(&mut h);
    }
    h.finish()
}

fn legend_swatch(ui: &mut Ui, color: Color32, label: &str) {
    let (rect, _) = ui.allocate_exact_size(Vec2::new(22.0, 12.0), Sense::hover());
    ui.painter().rect_filled(rect, CornerRadius::same(2), color);
    ui.label(label);
}

fn draw_drilldown(
    view: &DepGraphView,
    painter: &egui::Painter,
    rect: Rect,
    repo: &str,
    snap: &crate::warehouse::dep_graph::DepGraphSnapshot,
    layout: &DepGraphLayout,
    theme: &super::facett_theme::Theme,
) {
    let deps: Vec<String> = snap
        .edges
        .iter()
        .filter(|e| e.from == repo)
        .map(|e| {
            format!(
                "  → {}   [{}]",
                e.to,
                e.via.iter().cloned().collect::<Vec<_>>().join(", ")
            )
        })
        .collect();
    let users: Vec<String> = snap
        .edges
        .iter()
        .filter(|e| e.to == repo)
        .map(|e| {
            format!(
                "  ← {}   [{}]",
                e.from,
                e.via.iter().cloned().collect::<Vec<_>>().join(", ")
            )
        })
        .collect();
    // Memoized across frames: the per-repaint BFS recompute is the draw-path
    // cost EPIC #44 calls out. `closure_memo` re-runs only when repo/edges change.
    let (trans, _recomputed) = view.closure_memo(repo, &snap.edges);
    let rows = 5 + deps.len() + users.len();
    let panel = Rect::from_min_size(
        rect.left_top() + Vec2::new(8.0, 8.0),
        Vec2::new(390.0, rows as f32 * 16.0 + 16.0),
    );
    painter.rect_filled(panel, CornerRadius::same(6), theme.panel_bg);
    painter.rect_stroke(
        panel,
        CornerRadius::same(6),
        Stroke::new(1.0_f32, theme.panel_stroke),
        egui::StrokeKind::Inside,
    );
    let mut y = panel.top() + 8.0;
    let mut put = |s: &str, c: Color32| {
        painter.text(
            Pos2::new(panel.left() + 10.0, y),
            Align2::LEFT_TOP,
            s,
            FontId::proportional(12.0),
            c,
        );
        y += 16.0;
    };
    let collapsed = layout.nodes.iter().any(|n| n.repo == repo && n.collapsed);
    put(
        &format!(
            "◆ {repo}  (click node to {})",
            if collapsed { "expand" } else { "collapse" }
        ),
        theme.accent,
    );
    put(&format!("direct deps ({}):", deps.len()), theme.accent);
    for d in &deps {
        put(d, theme.text);
    }
    put(
        &format!(
            "transitive closure ({}): {}",
            trans.len(),
            trans.iter().cloned().collect::<Vec<_>>().join(", ")
        ),
        theme.edge,
    );
    put(&format!("used by ({}):", users.len()), theme.text_dim);
    for u in &users {
        put(u, theme.text);
    }
}

fn status_color(theme: &super::facett_theme::Theme, status: &str) -> Color32 {
    theme.status_color(status)
}

/// Arrow from card-center to card-center, stopping at the target card's edge.
/// `dashed` draws a transitive edge as a dashed line.
fn draw_arrow(
    painter: &egui::Painter,
    from: Pos2,
    to: Pos2,
    color: Color32,
    node_w: f32,
    dashed: bool,
    z: f32,
) {
    let dir = (to - from).normalized();
    let tip = to - dir * (node_w / 2.0 + 4.0);
    let stroke = Stroke::new(2.0 * z.clamp(0.6, 1.5), color);
    if dashed {
        // simple manual dashes
        let total = (tip - from).length();
        let step = 10.0 * z;
        let mut t = 0.0;
        while t < total {
            let a = from + dir * t;
            let b = from + dir * (t + step * 0.6).min(total);
            painter.line_segment([a, b], stroke);
            t += step;
        }
    } else {
        painter.line_segment([from, tip], stroke);
    }
    // arrowhead
    let perp = Vec2::new(-dir.y, dir.x);
    let base = tip - dir * 10.0 * z;
    let a = base + perp * 5.0 * z;
    let b = base - perp * 5.0 * z;
    painter.add(egui::Shape::convex_polygon(
        vec![tip, a, b],
        color,
        Stroke::NONE,
    ));
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::warehouse::dep_graph::CrossRepoEdge;

    fn edge(from: &str, to: &str) -> CrossRepoEdge {
        CrossRepoEdge::normal(from, to, BTreeSet::new())
    }

    #[test]
    fn closure_memo_is_correct_and_not_recomputed_per_frame() {
        // INJECT a known chain A→B→C, A→C: A's transitive closure is {B, C}.
        let edges = vec![edge("A", "B"), edge("B", "C"), edge("A", "C")];
        let view = DepGraphView::new();

        // First call: a real recompute, correct closure.
        let (c1, recomputed1) = view.closure_memo("A", &edges);
        assert!(recomputed1, "first call must recompute");
        assert_eq!(
            c1.into_iter().collect::<Vec<_>>(),
            vec!["B".to_string(), "C".to_string()],
            "closure of A is {{B, C}}"
        );
        assert_eq!(view.closure_recomputes(), 1);

        // Repaint with UNCHANGED repo + edges (what every idle frame does): the
        // cache must serve it WITHOUT recomputing — the draw-path cost #44 named.
        for _ in 0..10 {
            let (_, recomputed) = view.closure_memo("A", &edges);
            assert!(
                !recomputed,
                "unchanged repaint must NOT recompute the closure"
            );
        }
        assert_eq!(
            view.closure_recomputes(),
            1,
            "still only one BFS for 11 frames"
        );

        // Selecting a different repo invalidates → one more recompute, new answer.
        let (cb, recomputed_b) = view.closure_memo("B", &edges);
        assert!(recomputed_b, "selection change recomputes");
        assert_eq!(cb.into_iter().collect::<Vec<_>>(), vec!["C".to_string()]);
        assert_eq!(view.closure_recomputes(), 2);

        // Changing the edge set (new fingerprint) also invalidates.
        let mut edges2 = edges.clone();
        edges2.push(edge("C", "D"));
        let (cd, recomputed_d) = view.closure_memo("A", &edges2);
        assert!(recomputed_d, "edge-set change recomputes");
        assert!(
            cd.contains("D"),
            "new transitive node D reached after edge added"
        );
        assert_eq!(view.closure_recomputes(), 3);
    }
}