Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions execution_graph/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion execution_graph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions execution_graph/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]),
};
Expand Down
146 changes: 146 additions & 0 deletions execution_graph/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ pub(crate) enum NodeKind {
#[derive(Debug)]
pub(crate) struct Node {
pub(crate) kind: NodeKind,
pub(crate) label: Option<Box<str>>,
pub(crate) input_names: Vec<Box<str>>,
pub(crate) input_slots: BTreeMap<Box<str>, Vec<usize>>,
pub(crate) inputs: Vec<Option<Binding>>,
Expand Down Expand Up @@ -390,6 +391,45 @@ impl<H: Host> ExecutionGraph<H> {
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<Box<str>>,
) -> 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.
Expand Down Expand Up @@ -453,6 +493,7 @@ impl<H: Host> ExecutionGraph<H> {

let n = Node {
kind: NodeKind::Tape { program, entry },
label: None,
input_names,
input_slots,
inputs: alloc::vec![None; input_count],
Expand Down Expand Up @@ -677,6 +718,7 @@ impl<H: Host> ExecutionGraph<H> {
/// 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);

Expand Down Expand Up @@ -721,6 +763,7 @@ impl<H: Host> ExecutionGraph<H> {

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 {
Expand All @@ -747,6 +790,7 @@ impl<H: Host> ExecutionGraph<H> {

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 {
Expand Down Expand Up @@ -796,6 +840,7 @@ impl<H: Host> ExecutionGraph<H> {
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);

Expand Down Expand Up @@ -845,6 +890,11 @@ impl<H: Host> ExecutionGraph<H> {

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 {
Expand Down Expand Up @@ -878,6 +928,7 @@ impl<H: Host> ExecutionGraph<H> {

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 {
Expand All @@ -894,6 +945,15 @@ impl<H: Host> ExecutionGraph<H> {
.with_trace(RunPlanTrace::from_node_reports(node_report)))
}

#[inline]
fn report_node_label(nodes: &[Node], node: NodeId, collect_label: bool) -> Option<Box<str>> {
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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<VerifiedProgram>, 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)]
Expand Down
3 changes: 2 additions & 1 deletion execution_graph/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand Down
1 change: 1 addition & 0 deletions execution_graph/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]),
};
Expand Down
8 changes: 7 additions & 1 deletion execution_graph/src/pretty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,14 @@ impl<H: Host> ExecutionGraph<H> {
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),
Expand Down Expand Up @@ -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)"));
}
Expand Down
24 changes: 20 additions & 4 deletions execution_graph/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<Box<str>>,
/// The (graph-local) key whose dirtiness caused this node to be scheduled.
pub because_of: Option<ResourceKey>,
/// One plausible cause path from a dirty root to the output key for this node.
Expand All @@ -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!(
Expand All @@ -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);
Expand Down
Loading