diff --git a/execution_graph/CHANGELOG.md b/execution_graph/CHANGELOG.md index cd1850d..53e9935 100644 --- a/execution_graph/CHANGELOG.md +++ b/execution_graph/CHANGELOG.md @@ -13,6 +13,16 @@ You can find its changes [documented below](#001-2026-05-31). ## [Unreleased] +### Added + +- Added advisory graph node labels via `ExecutionGraph::set_node_label`, + `ExecutionGraph::clear_node_label`, and `ExecutionGraph::node_label`; labels can be included in + execution reports with `ReportDetailMask::NODE_LABEL` and are rendered in Graphviz DOT output. + +### Changed + +- `ReportDetailMask::FULL` now includes `ReportDetailMask::NODE_LABEL`. + ## [0.0.1][] (2026-05-31) This release has an [MSRV][] of 1.88. diff --git a/execution_graph/README.md b/execution_graph/README.md index bb4786c..1abbb30 100644 --- a/execution_graph/README.md +++ b/execution_graph/README.md @@ -102,7 +102,8 @@ For low overhead telemetry, `run_all` / `run_node` return only an executed-node For debugging and instrumentation: - `run_all_with_report` / `run_node_with_report` accept a `ReportDetailMask` so you can choose cheaper detail levels (for example, node + immediate cause key without path tracing). -- Use `ReportDetailMask::FULL` when you want full per-node cause paths. +- Use `set_node_label` to attach advisory debug names that can appear in reports and DOT. +- Use `ReportDetailMask::FULL` when you want labels plus full per-node cause paths. ## Demo diff --git a/execution_graph/src/dispatch.rs b/execution_graph/src/dispatch.rs index 8e1a183..d1d8fb2 100644 --- a/execution_graph/src/dispatch.rs +++ b/execution_graph/src/dispatch.rs @@ -221,11 +221,13 @@ mod tests { let r0 = NodeRunDetail { node: n0, + node_label: Some("first".into()), because_of: Some(ResourceKey::node_output(n0, "value")), why_path: Some(vec![ResourceKey::input("seed")]), }; let r1 = NodeRunDetail { node: n1, + node_label: Some("second".into()), because_of: Some(ResourceKey::node_output(n1, "value")), why_path: Some(vec![ResourceKey::input("seed")]), }; diff --git a/execution_graph/src/graph.rs b/execution_graph/src/graph.rs index c6f528d..75e7d86 100644 --- a/execution_graph/src/graph.rs +++ b/execution_graph/src/graph.rs @@ -206,6 +206,7 @@ pub(crate) enum NodeKind { #[derive(Debug)] pub(crate) struct Node { pub(crate) kind: NodeKind, + pub(crate) label: Option>, pub(crate) input_names: Vec>, pub(crate) input_slots: BTreeMap, Vec>, pub(crate) inputs: Vec>, @@ -390,6 +391,45 @@ impl ExecutionGraph { self.nodes.get(index)?.last_access.as_ref() } + /// Sets an advisory debug label for `node`. + /// + /// Labels do not affect scheduling, dependency keys, or graph identity. They are intended for + /// reports and DOT output, where a domain name is easier to read than a raw [`NodeId`]. + /// + /// Returns [`GraphError::BadNodeId`] for an unknown node. + pub fn set_node_label( + &mut self, + node: NodeId, + label: impl Into>, + ) -> Result<(), GraphError> { + let index = usize::try_from(node.as_u64()).map_err(|_| GraphError::BadNodeId)?; + let Some(n) = self.nodes.get_mut(index) else { + return Err(GraphError::BadNodeId); + }; + n.label = Some(label.into()); + Ok(()) + } + + /// Clears the advisory debug label for `node`. + /// + /// Returns [`GraphError::BadNodeId`] for an unknown node. + pub fn clear_node_label(&mut self, node: NodeId) -> Result<(), GraphError> { + let index = usize::try_from(node.as_u64()).map_err(|_| GraphError::BadNodeId)?; + let Some(n) = self.nodes.get_mut(index) else { + return Err(GraphError::BadNodeId); + }; + n.label = None; + Ok(()) + } + + /// Returns the advisory debug label for `node`, if one was set. + #[must_use] + #[inline] + pub fn node_label(&self, node: NodeId) -> Option<&str> { + let index = usize::try_from(node.as_u64()).ok()?; + self.nodes.get(index)?.label.as_deref() + } + /// Adds a node and returns its [`NodeId`]. /// /// `input_names` defines the mapping from per-node binding names to positional function args. @@ -453,6 +493,7 @@ impl ExecutionGraph { let n = Node { kind: NodeKind::Tape { program, entry }, + label: None, input_names, input_slots, inputs: alloc::vec![None; input_count], @@ -677,6 +718,7 @@ impl ExecutionGraph { /// Builds a report-capable plan from all currently affected dirty work. #[inline] fn plan_all_report(&mut self, detail_mask: ReportDetailMask) -> RunPlan { + let collect_label = detail_mask.contains(ReportDetailMask::NODE_LABEL); let collect_because = detail_mask.contains(ReportDetailMask::BECAUSE_OF); let collect_why = detail_mask.contains(ReportDetailMask::WHY_PATH); @@ -721,6 +763,7 @@ impl ExecutionGraph { node_report[index] = Some(NodeRunDetail { node, + node_label: Self::report_node_label(&self.nodes, node, collect_label), because_of: if collect_because { Some(because_of) } else { @@ -747,6 +790,7 @@ impl ExecutionGraph { node_report[index] = Some(NodeRunDetail { node: *node, + node_label: Self::report_node_label(&self.nodes, *node, collect_label), because_of: if collect_because { Some(key.clone()) } else { @@ -796,6 +840,7 @@ impl ExecutionGraph { return Err(GraphError::BadNodeId); }; let output_count = n.output_ids.len(); + let collect_label = detail_mask.contains(ReportDetailMask::NODE_LABEL); let collect_because = detail_mask.contains(ReportDetailMask::BECAUSE_OF); let collect_why = detail_mask.contains(ReportDetailMask::WHY_PATH); @@ -845,6 +890,11 @@ impl ExecutionGraph { node_report[scheduled_index] = Some(NodeRunDetail { node: scheduled_node, + node_label: Self::report_node_label( + &self.nodes, + scheduled_node, + collect_label, + ), because_of: if collect_because { Some(because_of) } else { @@ -878,6 +928,7 @@ impl ExecutionGraph { node_report[scheduled_index] = Some(NodeRunDetail { node: *node, + node_label: Self::report_node_label(&self.nodes, *node, collect_label), because_of: if collect_because { Some(key.clone()) } else { @@ -894,6 +945,15 @@ impl ExecutionGraph { .with_trace(RunPlanTrace::from_node_reports(node_report))) } + #[inline] + fn report_node_label(nodes: &[Node], node: NodeId, collect_label: bool) -> Option> { + if !collect_label { + return None; + } + let index = usize::try_from(node.as_u64()).ok()?; + nodes.get(index)?.label.clone() + } + #[inline] fn schedule_node_output_key(scratch: &mut Scratch, key: &ResourceKey) { let ResourceKey::NodeOutput { node, .. } = key else { @@ -1273,6 +1333,28 @@ mod tests { (Arc::new(pb.build_verified().unwrap()), f) } + #[test] + fn node_labels_are_advisory_metadata() { + let (prog, entry) = const_program(7); + let mut g = ExecutionGraph::new(HostNoop, Limits::default()); + let node = g.add_node(prog, entry, vec![]).unwrap(); + + assert_eq!(g.node_label(node), None); + g.set_node_label(node, "total").unwrap(); + assert_eq!(g.node_label(node), Some("total")); + g.clear_node_label(node).unwrap(); + assert_eq!(g.node_label(node), None); + + assert_eq!( + g.set_node_label(NodeId::new(99), "missing"), + Err(GraphError::BadNodeId) + ); + assert_eq!( + g.clear_node_label(NodeId::new(99)), + Err(GraphError::BadNodeId) + ); + } + #[test] fn graph_error_display_includes_actionable_context() { let bad_entry = GraphError::BadEntryFunc { func: FuncId(99) }.to_string(); @@ -1633,6 +1715,70 @@ mod tests { } } + #[test] + fn run_node_with_report_includes_node_labels_when_requested() { + fn make_identity_program(output_name: &str) -> (Arc, FuncId) { + let mut pb = ProgramBuilder::new(); + let mut a = Asm::new(); + a.ret(0, &[1]); + let f = pb + .push_function_checked( + a, + FunctionSig { + arg_types: vec![ValueType::I64], + ret_types: vec![ValueType::I64], + }, + ) + .unwrap(); + pb.set_function_output_name(f, 0, output_name).unwrap(); + (Arc::new(pb.build_verified().unwrap()), f) + } + + let mut g = ExecutionGraph::new(HostNoop, Limits::default()); + let (a_prog, a_entry) = make_identity_program("value"); + let (b_prog, b_entry) = make_identity_program("value"); + + let na = g.add_node(a_prog, a_entry, vec!["a".into()]).unwrap(); + let nb = g.add_node(b_prog, b_entry, vec!["b".into()]).unwrap(); + g.set_node_label(na, "source").unwrap(); + g.set_node_label(nb, "sink").unwrap(); + g.set_input_value(na, "a", Value::I64(1)).unwrap(); + g.connect(na, "value", nb, "b").unwrap(); + + g.run_all().unwrap(); + g.set_input_value(na, "a", Value::I64(2)).unwrap(); + g.invalidate_input("a"); + + let labels_only = g + .run_node_with_report(nb, ReportDetailMask::NODE_LABEL) + .unwrap(); + assert_eq!(labels_only.executed.len(), 2); + assert_eq!(labels_only.executed[0].node, na); + assert_eq!( + labels_only.executed[0].node_label.as_deref(), + Some("source") + ); + assert!(labels_only.executed[0].because_of.is_none()); + assert!(labels_only.executed[0].why_path.is_none()); + assert_eq!(labels_only.executed[1].node, nb); + assert_eq!(labels_only.executed[1].node_label.as_deref(), Some("sink")); + assert!(labels_only.executed[1].because_of.is_none()); + assert!(labels_only.executed[1].why_path.is_none()); + + g.set_input_value(na, "a", Value::I64(3)).unwrap(); + g.invalidate_input("a"); + + let why_only = g + .run_node_with_report(nb, ReportDetailMask::WHY_PATH) + .unwrap(); + assert_eq!(why_only.executed.len(), 2); + for e in &why_only.executed { + assert!(e.node_label.is_none()); + assert!(e.because_of.is_none()); + assert!(e.why_path.is_some()); + } + } + #[test] fn strict_deps_rejects_host_calls_without_accesses() { #[derive(Debug, Default)] diff --git a/execution_graph/src/lib.rs b/execution_graph/src/lib.rs index 7229c4e..f52b795 100644 --- a/execution_graph/src/lib.rs +++ b/execution_graph/src/lib.rs @@ -99,7 +99,8 @@ //! For debugging and instrumentation: //! - `run_all_with_report` / `run_node_with_report` accept a `ReportDetailMask` so you can choose //! cheaper detail levels (for example, node + immediate cause key without path tracing). -//! - Use `ReportDetailMask::FULL` when you want full per-node cause paths. +//! - Use `set_node_label` to attach advisory debug names that can appear in reports and DOT. +//! - Use `ReportDetailMask::FULL` when you want labels plus full per-node cause paths. //! //! ## Demo //! diff --git a/execution_graph/src/plan.rs b/execution_graph/src/plan.rs index fde354e..61fd1d0 100644 --- a/execution_graph/src/plan.rs +++ b/execution_graph/src/plan.rs @@ -134,6 +134,7 @@ mod tests { let node = NodeId::new(3); let report = NodeRunDetail { node, + node_label: Some("report-node".into()), because_of: Some(ResourceKey::node_output(node, "value")), why_path: Some(alloc::vec![ResourceKey::input("in")]), }; diff --git a/execution_graph/src/pretty.rs b/execution_graph/src/pretty.rs index ef72295..d5ee0d7 100644 --- a/execution_graph/src/pretty.rs +++ b/execution_graph/src/pretty.rs @@ -91,10 +91,14 @@ impl ExecutionGraph { let (node_line, entry_line) = match &node.kind { NodeKind::Tape { program, entry } => { let p = program.program(); - let nl = match p.name() { + let node_line = match p.name() { Some(name) => format!("node#{node_id} ({name})"), None => format!("node#{node_id}"), }; + let nl = match node.label.as_deref() { + Some(label) => format!("{label}\n{node_line}"), + None => node_line, + }; let el = match p.function_name(entry.0) { Some(name) => format!("entry=f{} ({name})", entry.0), None => format!("entry=f{}", entry.0), @@ -242,9 +246,11 @@ mod tests { let mut g = ExecutionGraph::new(HostNoop, Limits::default()); let n = g.add_node(prog, f, vec!["x".into()]).unwrap(); + g.set_node_label(n, "friendly node").unwrap(); g.set_input_value(n, "x", Value::I64(1)).unwrap(); let dot = g.to_dot(); + assert!(dot.contains("friendly node")); assert!(dot.contains("node#0 (named_program)")); assert!(dot.contains("entry=f0 (named_entry)")); } diff --git a/execution_graph/src/report.rs b/execution_graph/src/report.rs index 7dc1ae5..6826543 100644 --- a/execution_graph/src/report.rs +++ b/execution_graph/src/report.rs @@ -6,6 +6,7 @@ //! This module provides small, allocation-based report types intended for debugging and //! instrumentation. Formatting and UI are left to embedders. +use alloc::boxed::Box; use alloc::vec::Vec; use core::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign}; @@ -33,8 +34,10 @@ impl ReportDetailMask { /// This does not imply [`ReportDetailMask::BECAUSE_OF`]. Use /// [`ReportDetailMask::FULL`] or combine masks when both fields are needed. pub const WHY_PATH: Self = Self(1 << 1); + /// Include the node's advisory debug label, if one was set. + pub const NODE_LABEL: Self = Self(1 << 2); /// Include all optional fields. - pub const FULL: Self = Self(Self::BECAUSE_OF.0 | Self::WHY_PATH.0); + pub const FULL: Self = Self(Self::BECAUSE_OF.0 | Self::WHY_PATH.0 | Self::NODE_LABEL.0); /// Returns a mask with the bits from `self` and `other`. #[must_use] @@ -110,6 +113,11 @@ impl BitAndAssign for ReportDetailMask { pub struct NodeRunDetail { /// The node that executed. pub node: NodeId, + /// Advisory debug label for [`NodeRunDetail::node`]. + /// + /// This is populated when the report mask contains [`ReportDetailMask::NODE_LABEL`] and the + /// node has a label. + pub node_label: Option>, /// The (graph-local) key whose dirtiness caused this node to be scheduled. pub because_of: Option, /// One plausible cause path from a dirty root to the output key for this node. @@ -132,14 +140,17 @@ mod tests { #[test] fn report_detail_mask_composes_with_methods_and_operators() { - const FULL_FROM_UNION: ReportDetailMask = - ReportDetailMask::BECAUSE_OF.union(ReportDetailMask::WHY_PATH); + const FULL_FROM_UNION: ReportDetailMask = ReportDetailMask::BECAUSE_OF + .union(ReportDetailMask::WHY_PATH) + .union(ReportDetailMask::NODE_LABEL); assert_eq!(ReportDetailMask::default(), ReportDetailMask::NONE); assert!(ReportDetailMask::NONE.is_empty()); assert_eq!(FULL_FROM_UNION, ReportDetailMask::FULL); assert_eq!( - ReportDetailMask::BECAUSE_OF | ReportDetailMask::WHY_PATH, + ReportDetailMask::BECAUSE_OF + | ReportDetailMask::WHY_PATH + | ReportDetailMask::NODE_LABEL, ReportDetailMask::FULL ); assert_eq!( @@ -149,6 +160,11 @@ mod tests { let mut mask = ReportDetailMask::BECAUSE_OF; mask |= ReportDetailMask::WHY_PATH; + assert_eq!( + mask, + ReportDetailMask::BECAUSE_OF | ReportDetailMask::WHY_PATH + ); + mask |= ReportDetailMask::NODE_LABEL; assert_eq!(mask, ReportDetailMask::FULL); mask &= ReportDetailMask::WHY_PATH; assert_eq!(mask, ReportDetailMask::WHY_PATH); diff --git a/execution_graph_examples/src/bin/tax.rs b/execution_graph_examples/src/bin/tax.rs index 2916d5b..937490d 100644 --- a/execution_graph_examples/src/bin/tax.rs +++ b/execution_graph_examples/src/bin/tax.rs @@ -10,12 +10,11 @@ extern crate alloc; -use alloc::collections::BTreeMap; use alloc::rc::Rc; use alloc::sync::Arc; use core::cell::RefCell; -use execution_graph::{ExecutionGraph, GraphError, NodeId, ReportDetailMask, ResourceKey}; +use execution_graph::{ExecutionGraph, GraphError, ReportDetailMask, ResourceKey, RunDetailReport}; use execution_tape::asm::{Asm, FunctionSig, ProgramBuilder}; use execution_tape::host::{ Host, HostContext, HostError, HostSig, ResourceKeyRef, SigHash, ValueRef, sig_hash, @@ -132,23 +131,11 @@ fn program_total() -> (Arc, FuncId) { (Arc::new(pb.build_verified().unwrap()), f) } -fn label_for(node: NodeId) -> &'static str { - match node.as_u64() { - 0 => "price_subtotal", - 1 => "tax_amount", - 2 => "total", - _ => "", - } -} - -fn fmt_key(node_labels: &BTreeMap, key: &ResourceKey) -> String { +fn fmt_key(g: &ExecutionGraph, key: &ResourceKey) -> String { match key { ResourceKey::Input(name) => format!("Input({name})"), ResourceKey::NodeOutput { node, output } => { - let label = node_labels - .get(&node.as_u64()) - .copied() - .unwrap_or(""); + let label = g.node_label(*node).unwrap_or(""); format!("NodeOutput({label}:{output})") } ResourceKey::HostState { op, key } => format!("HostState(op={}, key={key})", op.as_u64()), @@ -156,6 +143,29 @@ fn fmt_key(node_labels: &BTreeMap, key: &ResourceKey) -> Stri } } +fn print_report(g: &ExecutionGraph, report: RunDetailReport) { + for r in report.executed { + let label = r + .node_label + .as_deref() + .or_else(|| g.node_label(r.node)) + .unwrap_or(""); + let because_of = r + .because_of + .as_ref() + .map(|k| fmt_key(g, k)) + .unwrap_or_else(|| "".to_string()); + println!( + " - {label} (node={}): because this is dirty: {because_of}", + r.node.as_u64() + ); + println!(" path:"); + for k in r.why_path.unwrap_or_default() { + println!(" - {}", fmt_key(g, &k)); + } + } +} + fn main() -> Result<(), GraphError> { let emit_dot = std::env::args().skip(1).any(|arg| arg == "--dot"); @@ -175,12 +185,9 @@ fn main() -> Result<(), GraphError> { let n_price = g.add_node(p_prog, p_entry, vec!["qty".into(), "unit_price".into()])?; let n_tax = g.add_node(t_prog, t_entry, vec!["subtotal".into()])?; let n_total = g.add_node(sum_prog, sum_entry, vec!["subtotal".into(), "tax".into()])?; - - let node_labels: BTreeMap = BTreeMap::from([ - (n_price.as_u64(), label_for(n_price)), - (n_tax.as_u64(), label_for(n_tax)), - (n_total.as_u64(), label_for(n_total)), - ]); + g.set_node_label(n_price, "price_subtotal")?; + g.set_node_label(n_tax, "tax_amount")?; + g.set_node_label(n_total, "total")?; g.set_input_value(n_price, "qty", Value::I64(2))?; g.set_input_value(n_price, "unit_price", Value::I64(120))?; @@ -218,25 +225,7 @@ fn main() -> Result<(), GraphError> { println!(); println!("🟧 why re-ran after qty change (one plausible path per executed node):"); println!(" 🟫 note: this is not an exhaustive explanation; other causes may exist."); - for r in report.executed { - let label = node_labels - .get(&r.node.as_u64()) - .copied() - .unwrap_or(""); - let because_of = r - .because_of - .as_ref() - .map(|k| fmt_key(&node_labels, k)) - .unwrap_or_else(|| "".to_string()); - println!( - " - {label} (node={}): because this is dirty: {because_of}", - r.node.as_u64() - ); - println!(" path:"); - for k in r.why_path.unwrap_or_default() { - println!(" - {}", fmt_key(&node_labels, &k)); - } - } + print_report(&g, report); // Change 2: the tax rate changes (host state). This should re-run only the nodes that depend // on that host state (tax_amount and total), not price_subtotal. @@ -264,25 +253,7 @@ fn main() -> Result<(), GraphError> { println!(); println!("🟧 why re-ran after tax rate change (one plausible path per executed node):"); println!(" 🟫 note: this is not an exhaustive explanation; other causes may exist."); - for r in report.executed { - let label = node_labels - .get(&r.node.as_u64()) - .copied() - .unwrap_or(""); - let because_of = r - .because_of - .as_ref() - .map(|k| fmt_key(&node_labels, k)) - .unwrap_or_else(|| "".to_string()); - println!( - " - {label} (node={}): because this is dirty: {because_of}", - r.node.as_u64() - ); - println!(" path:"); - for k in r.why_path.unwrap_or_default() { - println!(" - {}", fmt_key(&node_labels, &k)); - } - } + print_report(&g, report); Ok(()) }