From 4006b4f244a04f5735c0ee8cb640e2dbca5e3593 Mon Sep 17 00:00:00 2001 From: Bruce Mitchener Date: Sun, 14 Jun 2026 16:48:05 +0700 Subject: [PATCH] execution_graph: harden report mask semantics Make `ReportDetailMask` a composable bitmask before the reporting API settles. This adds `Default`, const union/intersection helpers, and the standard bitwise operator impls so callers can build detail masks without waiting for named constants for every combination. Honor the detail bits independently: requesting `WHY_PATH` no longer also fills `because_of` unless `BECAUSE_OF` is requested. The expanded tests cover `NONE`, `BECAUSE_OF`, `WHY_PATH`, and combined masks so this behavior stays explicit. Mark the public report output structs as `non_exhaustive` while the API is still early, allowing future report fields to be added without another breaking shape change. --- execution_graph/src/graph.rs | 33 ++++++++++-- execution_graph/src/report.rs | 96 +++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/execution_graph/src/graph.rs b/execution_graph/src/graph.rs index b36b951..c6f528d 100644 --- a/execution_graph/src/graph.rs +++ b/execution_graph/src/graph.rs @@ -677,8 +677,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_because = detail_mask.contains(ReportDetailMask::BECAUSE_OF) - || detail_mask.contains(ReportDetailMask::WHY_PATH); + let collect_because = detail_mask.contains(ReportDetailMask::BECAUSE_OF); let collect_why = detail_mask.contains(ReportDetailMask::WHY_PATH); self.scratch.start_drain(self.nodes.len()); @@ -797,8 +796,7 @@ impl ExecutionGraph { return Err(GraphError::BadNodeId); }; let output_count = n.output_ids.len(); - let collect_because = detail_mask.contains(ReportDetailMask::BECAUSE_OF) - || detail_mask.contains(ReportDetailMask::WHY_PATH); + let collect_because = detail_mask.contains(ReportDetailMask::BECAUSE_OF); let collect_why = detail_mask.contains(ReportDetailMask::WHY_PATH); self.scratch.start_drain(self.nodes.len()); @@ -1606,6 +1604,33 @@ mod tests { assert!(e.because_of.is_some()); assert!(e.why_path.is_none()); } + + g.set_input_value(na, "a", Value::I64(4)).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.because_of.is_none()); + assert!(e.why_path.is_some()); + } + + g.set_input_value(na, "a", Value::I64(5)).unwrap(); + g.invalidate_input("a"); + + let full = g + .run_node_with_report( + nb, + ReportDetailMask::BECAUSE_OF | ReportDetailMask::WHY_PATH, + ) + .unwrap(); + assert_eq!(full.executed.len(), 2); + for e in &full.executed { + assert!(e.because_of.is_some()); + assert!(e.why_path.is_some()); + } } #[test] diff --git a/execution_graph/src/report.rs b/execution_graph/src/report.rs index f92627f..7dc1ae5 100644 --- a/execution_graph/src/report.rs +++ b/execution_graph/src/report.rs @@ -7,11 +7,13 @@ //! instrumentation. Formatting and UI are left to embedders. use alloc::vec::Vec; +use core::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign}; use crate::{NodeId, ResourceKey}; /// Cheap run summary for incremental execution. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] pub struct RunSummary { /// Number of nodes executed during the run. pub executed_nodes: usize, @@ -27,20 +29,84 @@ impl ReportDetailMask { /// Include the immediate dirty key that scheduled the node. pub const BECAUSE_OF: Self = Self(1 << 0); /// Include one plausible cause path from dirty root to output key. + /// + /// 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 all optional fields. pub const FULL: Self = Self(Self::BECAUSE_OF.0 | Self::WHY_PATH.0); + /// Returns a mask with the bits from `self` and `other`. + #[must_use] + #[inline] + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + /// Returns a mask with only the bits common to `self` and `other`. + #[must_use] + #[inline] + pub const fn intersection(self, other: Self) -> Self { + Self(self.0 & other.0) + } + /// Returns `true` if this mask contains every bit in `other`. #[must_use] #[inline] pub const fn contains(self, other: Self) -> bool { (self.0 & other.0) == other.0 } + + /// Returns `true` if this mask does not request any detail fields. + #[must_use] + #[inline] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } +} + +impl Default for ReportDetailMask { + #[inline] + fn default() -> Self { + Self::NONE + } +} + +impl BitOr for ReportDetailMask { + type Output = Self; + + #[inline] + fn bitor(self, rhs: Self) -> Self::Output { + self.union(rhs) + } +} + +impl BitOrAssign for ReportDetailMask { + #[inline] + fn bitor_assign(&mut self, rhs: Self) { + *self = self.union(rhs); + } +} + +impl BitAnd for ReportDetailMask { + type Output = Self; + + #[inline] + fn bitand(self, rhs: Self) -> Self::Output { + self.intersection(rhs) + } +} + +impl BitAndAssign for ReportDetailMask { + #[inline] + fn bitand_assign(&mut self, rhs: Self) { + *self = self.intersection(rhs); + } } /// Per-node detail record with optional payloads controlled by [`ReportDetailMask`]. #[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] pub struct NodeRunDetail { /// The node that executed. pub node: NodeId, @@ -54,7 +120,37 @@ pub struct NodeRunDetail { /// Detail report for a graph run. #[derive(Clone, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] pub struct RunDetailReport { /// Per-node detail records in execution order. pub executed: Vec, } + +#[cfg(test)] +mod tests { + use super::ReportDetailMask; + + #[test] + fn report_detail_mask_composes_with_methods_and_operators() { + const FULL_FROM_UNION: ReportDetailMask = + ReportDetailMask::BECAUSE_OF.union(ReportDetailMask::WHY_PATH); + + 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::FULL + ); + assert_eq!( + ReportDetailMask::FULL & ReportDetailMask::BECAUSE_OF, + ReportDetailMask::BECAUSE_OF + ); + + let mut mask = ReportDetailMask::BECAUSE_OF; + mask |= ReportDetailMask::WHY_PATH; + assert_eq!(mask, ReportDetailMask::FULL); + mask &= ReportDetailMask::WHY_PATH; + assert_eq!(mask, ReportDetailMask::WHY_PATH); + } +}