Skip to main content

async_nodes/
engine.rs

1//! The dynamic scheduler: token-based activation over BSP supersteps.
2//!
3//! Edge evaluation emits *tokens* onto edges; a node joins the next frontier when its
4//! [`Activation`] policy is satisfiable from pending (unconsumed) tokens, and activation
5//! consumes them. This is process-network/Kahn-style firing, and it makes loops fall out
6//! naturally: a back-edge simply delivers another token to an earlier node, which refires.
7//! There is no "executed once" set and no special loop construct.
8//!
9//! # Circuit breakers
10//!
11//! Loops make runaway execution possible by construction, so bounds are part of the core:
12//!
13//! - a global [`BspEngine::with_max_steps`] superstep cap producing
14//!   [`EngineResult::StepLimitReached`] — a first-class result carrying the live run
15//!   state, never a silent salvage;
16//! - per-edge `max_iterations` budgets raising [`BspError::LoopBoundExceeded`];
17//! - optional per-node `max_executions` caps;
18//! - a pending-token cap per edge ([`BspEngine::with_max_pending_tokens`]) against floods.
19//!
20//! # Joins inside loops are almost always `Any` or merger-joins
21//!
22//! An `All`-join on a cycle is the classic self-deadlock — one predecessor's token
23//! arrives once per iteration, the other's arrives once ever, so the join waits forever
24//! and the run ends in [`BspError::Deadlock`]:
25//!
26//! ```text
27//!        entry ──▶ J (All) ──▶ body
28//!                    ▲            │
29//!                    └────────────┘   J needs a token on BOTH edges per firing,
30//!                      back-edge      but entry only ever fires once → stall
31//! ```
32//!
33//! Validation warns on this shape; write the loop join as `Any` (or a merger-join).
34
35use std::{
36    collections::HashSet,
37    sync::{Arc, OnceLock},
38    time::{Duration, Instant},
39};
40
41use futures_util::stream::{self, StreamExt};
42use indexmap::{IndexMap, IndexSet};
43
44use super::{
45    BspContext,
46    condition::{Condition, ConditionEval},
47    error::BspError,
48    events::BspInterceptor,
49    graph::{BspGraph, ERROR_PORT, EdgeId, InputPin, NodeId, OutputPin, PortIndex},
50    merge::Merger,
51    node::{Activation, BspNode, NodeOutcome},
52    recovery::{Recovery, RecoveryPolicy},
53    run::{EngineResult, PendingSuspension, RunContext, RunState, SuspendedExecution, ValueStore},
54    value::{BspSkip, BspValue},
55};
56
57/// The default global superstep budget (circuit breaker). The pre-rewrite engine had no
58/// cap; loops make one mandatory, so a generous default is baked in — tune it with
59/// [`BspEngine::with_max_steps`].
60pub const DEFAULT_MAX_STEPS: u64 = 1024;
61
62/// The default cap on pending (unconsumed) tokens per edge (token-flood guard).
63pub const DEFAULT_MAX_PENDING_TOKENS: u64 = 1024;
64
65/// A transform applied to each branch-state clone at multi-node frontier formation,
66/// right after the clone is taken from the trunk (see
67/// [`BspEngine::with_branch_transform`]). The `usize` is the branch's ordinal within
68/// the frontier formation (declaration order, 0-based).
69pub type BranchTransform<S> = Arc<dyn Fn(&mut S, usize) + Send + Sync>;
70
71/// The outcome of one executed (or bypassed) node task.
72#[derive(Debug)]
73enum TaskResult {
74    /// The node finished; its outputs were written and its edges should be evaluated.
75    Success {
76        outputs: IndexMap<PortIndex, BspValue>,
77        skipped: bool,
78    },
79    /// The node requested suspension.
80    Suspended {
81        payload: serde_json::Value,
82        waiting_inputs: Vec<PortIndex>,
83    },
84    /// The node failed and its recovery policy routed the failure to error edges.
85    RoutedError(BspError),
86}
87
88/// What the scheduler decided at the head of a superstep.
89enum NextStep {
90    /// Execute this frontier.
91    Frontier(Vec<NodeId>),
92    /// The frontier emptied with no pending tokens: natural completion.
93    Complete,
94    /// The global superstep budget is exhausted.
95    StepLimit,
96}
97
98/// The result of graph validation: fatal errors plus non-fatal findings.
99///
100/// [`BspEngine::validate`] stays error-only; use [`BspEngine::validate_with_report`] to
101/// also see warnings (unbounded cycles, unreachable nodes, dead ends, missing mergers…).
102#[derive(Debug, Clone)]
103pub struct ValidationReport {
104    /// The first fatal validation error, if any.
105    pub error: Option<BspError>,
106    /// Non-fatal findings, in analysis order.
107    pub warnings: Vec<String>,
108}
109
110impl ValidationReport {
111    /// Returns whether validation found no fatal error.
112    #[must_use]
113    pub const fn is_ok(&self) -> bool {
114        self.error.is_none()
115    }
116}
117
118/// The dynamic scheduler that organizes nodes into supersteps and executes them
119/// concurrently (bounded), re-executing nodes as loops deliver new activation tokens.
120///
121/// `S` is the consumer-owned run state; it defaults to `()` for pure dataflow graphs.
122pub struct BspEngine<S = ()> {
123    graph: BspGraph<S>,
124    ctx: BspContext,
125    interceptor: Option<Arc<dyn BspInterceptor>>,
126    max_concurrency: Option<usize>,
127    max_steps: u64,
128    max_pending_tokens: u64,
129    branch_transform: Option<BranchTransform<S>>,
130    validation_cache: OnceLock<ValidationReport>,
131}
132
133impl<S> BspEngine<S> {
134    /// Instantiates a new BSP engine using a compiled graph layout.
135    #[must_use]
136    pub fn new(graph: BspGraph<S>) -> Self {
137        Self {
138            graph,
139            ctx: BspContext::new(),
140            interceptor: None,
141            max_concurrency: None,
142            max_steps: DEFAULT_MAX_STEPS,
143            max_pending_tokens: DEFAULT_MAX_PENDING_TOKENS,
144            branch_transform: None,
145            validation_cache: OnceLock::new(),
146        }
147    }
148
149    /// Attaches a shared execution context for dependency injection.
150    #[must_use]
151    pub fn with_context(mut self, ctx: BspContext) -> Self {
152        self.ctx = ctx;
153        self
154    }
155
156    /// Returns a reference to the active execution context.
157    #[must_use]
158    pub const fn context(&self) -> &BspContext {
159        &self.ctx
160    }
161
162    /// The graph this engine runs.
163    #[must_use]
164    pub const fn graph(&self) -> &BspGraph<S> {
165        &self.graph
166    }
167
168    /// Attaches a performance and audit logging interceptor.
169    #[must_use]
170    pub fn with_interceptor(mut self, interceptor: Arc<dyn BspInterceptor>) -> Self {
171        self.interceptor = Some(interceptor);
172        self
173    }
174
175    /// Configures the maximum concurrency (throttling limit) for task execution within a
176    /// superstep.
177    ///
178    /// The throttle is scoped to this engine instance (`buffer_unordered` per superstep);
179    /// a nested graph-as-node runs a child engine with its own independent throttle, so
180    /// nesting can never deadlock or halve capacity per level.
181    #[must_use]
182    pub const fn with_max_concurrency(mut self, limit: usize) -> Self {
183        self.max_concurrency = Some(limit);
184        self
185    }
186
187    /// Configures the global superstep budget. Exceeding it yields
188    /// [`EngineResult::StepLimitReached`] with the live run state — never a silent salvage.
189    #[must_use]
190    pub const fn with_max_steps(mut self, steps: u64) -> Self {
191        self.max_steps = steps;
192        self
193    }
194
195    /// Configures the per-edge pending-token cap (token-flood guard).
196    #[must_use]
197    pub const fn with_max_pending_tokens(mut self, cap: u64) -> Self {
198        self.max_pending_tokens = cap;
199        self
200    }
201
202    /// Attaches a transform applied to each branch state clone at multi-node frontier
203    /// formation, right after the clone is taken from the trunk.
204    ///
205    /// The second argument is the branch's *ordinal within the frontier formation*
206    /// (declaration order, 0-based). This is the hook for fork-fan-out semantics where
207    /// sibling clones must not all inherit the full trunk — the reference consumer
208    /// (`llm-net`) zeroes branch metrics for every sibling after the first so boundary
209    /// rollups never double-count the parent's history.
210    ///
211    /// The default (`None`) is the identity: every branch is an exact clone. The
212    /// transform never runs for single-node frontiers (they borrow the trunk directly)
213    /// or on the sequential path (`run_sequential` does no branch cloning).
214    #[must_use]
215    pub fn with_branch_transform(mut self, transform: BranchTransform<S>) -> Self {
216        self.branch_transform = Some(transform);
217        self
218    }
219
220    /// Validates the graph (errors only), looking for unfireable cycles, missing links,
221    /// invalid port configurations, and unregistered condition/merger references.
222    pub fn validate(&self) -> Result<(), BspError> {
223        let report = self.validation_report();
224        match &report.error {
225            Some(err) => Err(err.clone()),
226            None => Ok(()),
227        }
228    }
229
230    /// Validates the graph, additionally returning non-fatal warnings (loop-aware
231    /// analysis: unbounded cycles, unreachable nodes, dead ends, `All`-joins on cycles,
232    /// joins without mergers).
233    pub fn validate_with_report(&self) -> ValidationReport {
234        self.validation_report().clone()
235    }
236
237    fn validation_report(&self) -> &ValidationReport {
238        self.validation_cache
239            .get_or_init(|| compute_validation(&self.graph))
240    }
241
242    // =========================================================================
243    // SCHEDULER PRIMITIVES
244    // =========================================================================
245
246    /// Computes the next frontier: forced nodes (entries / resumed suspensions) plus every
247    /// node whose activation policy is satisfiable from pending tokens.
248    fn compute_frontier(&self, state: &RunState<S>, forced: IndexSet<NodeId>) -> IndexSet<NodeId> {
249        let mut frontier = forced;
250        for (&edge_id, &count) in &state.pending_tokens {
251            if count == 0 {
252                continue;
253            }
254            let target = self.graph.edges[edge_id].target;
255            if frontier.contains(&target) {
256                continue;
257            }
258            let node = &self.graph.nodes[&target];
259            if self.is_activatable(state, target, node.activation()) {
260                frontier.insert(target);
261            }
262        }
263        frontier
264    }
265
266    /// Assembles the next superstep's frontier, detecting the terminal conditions
267    /// (natural completion, deadlock, step-limit) and firing superstep events.
268    fn next_step(
269        &self,
270        state: &mut RunState<S>,
271        forced: &mut IndexSet<NodeId>,
272    ) -> Result<NextStep, BspError> {
273        let frontier = self.compute_frontier(state, std::mem::take(forced));
274
275        if frontier.is_empty() {
276            if state.pending_tokens.values().any(|&c| c > 0) {
277                tracing::error!("❌ [BspEngine] Deadlock detected during scheduling.");
278                return Err(BspError::Deadlock);
279            }
280            return Ok(NextStep::Complete);
281        }
282
283        if state.step >= self.max_steps {
284            tracing::error!(
285                "🛑 [BspEngine] Superstep budget of {} exhausted; returning live state.",
286                self.max_steps
287            );
288            return Ok(NextStep::StepLimit);
289        }
290
291        state.frontier = frontier.iter().copied().collect();
292        // Declaration order (auto-increment ids track insertion order), so barrier folds
293        // and event streams are deterministic regardless of token-emission interleavings.
294        let mut frontier_nodes: Vec<NodeId> = frontier.into_iter().collect();
295        frontier_nodes.sort_unstable();
296
297        if let Some(interceptor) = &self.interceptor {
298            interceptor.on_superstep_start(state.step, &frontier_nodes);
299        }
300        tracing::debug!(
301            "🌀 [BspEngine] Superstep {}: Launching {} concurrent tasks...",
302            state.step,
303            frontier_nodes.len()
304        );
305        Ok(NextStep::Frontier(frontier_nodes))
306    }
307
308    /// Whether a node's activation policy is satisfiable from pending tokens.
309    fn is_activatable(&self, state: &RunState<S>, node_id: NodeId, activation: Activation) -> bool {
310        let Some(incoming) = self.graph.incoming.get(&node_id) else {
311            return false;
312        };
313        match activation {
314            Activation::Any => incoming
315                .iter()
316                .any(|e| state.pending_tokens.get(e).is_some_and(|&c| c > 0)),
317            Activation::All => {
318                !incoming.is_empty()
319                    && incoming
320                        .iter()
321                        .all(|e| state.pending_tokens.get(e).is_some_and(|&c| c > 0))
322            }
323        }
324    }
325
326    /// Consumes the tokens backing a node's activation: exactly one token (from the
327    /// lowest-declared incoming edge holding any) for `Any` — "fire on the first pending
328    /// token" — and exactly one per incoming edge for `All` (Kahn-style firing).
329    fn consume_tokens(&self, state: &mut RunState<S>, node_id: NodeId, activation: Activation) {
330        let Some(incoming) = self.graph.incoming.get(&node_id) else {
331            return;
332        };
333        match activation {
334            Activation::Any => {
335                let selected = incoming
336                    .iter()
337                    .find(|e| state.pending_tokens.get(*e).is_some_and(|&c| c > 0))
338                    .copied();
339                if let Some(e) = selected
340                    && let Some(count) = state.pending_tokens.get_mut(&e)
341                {
342                    *count = count.saturating_sub(1);
343                    if *count == 0 {
344                        state.pending_tokens.swap_remove(&e);
345                    }
346                }
347            }
348            Activation::All => {
349                let mut emptied = Vec::new();
350                for &e in incoming {
351                    if let Some(count) = state.pending_tokens.get_mut(&e) {
352                        *count = count.saturating_sub(1);
353                        if *count == 0 {
354                            emptied.push(e);
355                        }
356                    }
357                }
358                for e in emptied {
359                    state.pending_tokens.swap_remove(&e);
360                }
361            }
362        }
363    }
364
365    /// Prepares one frontier node for execution: enforces its execution cap, consumes its
366    /// activation tokens, and gathers its inputs.
367    fn prepare_node(
368        &self,
369        state: &mut RunState<S>,
370        node_id: NodeId,
371    ) -> Result<(u64, IndexMap<PortIndex, BspValue>, bool), BspError> {
372        let node = &self.graph.nodes[&node_id];
373        let exec_base = state.execution_count(node_id);
374        if let Some(max) = node.max_executions()
375            && exec_base >= max
376        {
377            return Err(BspError::MaxExecutionsExceeded { node_id, max });
378        }
379        self.consume_tokens(state, node_id, node.activation());
380        let (inputs, should_skip) = self.gather_node_inputs(node_id, node.as_ref(), state);
381        Ok((exec_base, inputs, should_skip))
382    }
383
384    /// Gathers input values for a node from static inputs, injected inputs, and wired
385    /// edges (declaration order preserved — multi-wire ports aggregate into a `Vec`).
386    fn gather_node_inputs(
387        &self,
388        node_id: NodeId,
389        node: &dyn BspNode<S>,
390        state: &RunState<S>,
391    ) -> (IndexMap<PortIndex, BspValue>, bool) {
392        let mut inputs = IndexMap::new();
393        let mut should_skip = false;
394
395        for &port in &node.input_ports() {
396            let pin = InputPin { node_id, port };
397            let mut gathered_values = Vec::new();
398
399            // 1. Static inputs
400            if let Some(static_val) = self.graph.static_inputs.get(&pin) {
401                gathered_values.push(static_val.clone());
402            }
403
404            // 2. Injected inputs (from resume)
405            if let Some(injected_val) = state.values.injected.get(&pin) {
406                gathered_values.push(injected_val.clone());
407            }
408
409            // 3. Wired inputs from completed source nodes (latest value store writes)
410            if let Some(edge_ids) = self.graph.incoming_by_pin.get(&pin) {
411                for &edge_id in edge_ids {
412                    let edge = &self.graph.edges[edge_id];
413                    let source_pin = OutputPin {
414                        node_id: edge.source,
415                        port: edge.source_port.unwrap_or(0),
416                    };
417                    if let Some(val) = state.values.outputs.get(&source_pin) {
418                        gathered_values.push(val.clone());
419                    }
420                }
421            }
422
423            // Dynamic Control-Flow: Detect if any incoming path carried a Skip marker
424            if gathered_values.iter().any(BspValue::is::<BspSkip>) {
425                should_skip = true;
426            }
427
428            if !gathered_values.is_empty() {
429                if gathered_values.len() == 1 {
430                    inputs.insert(port, gathered_values[0].clone());
431                } else {
432                    inputs.insert(port, BspValue::new(gathered_values));
433                }
434            }
435        }
436
437        (inputs, should_skip)
438    }
439
440    /// Emits activation tokens onto every outgoing edge of `source` whose condition
441    /// matches, enforcing iteration budgets and token caps and firing edge events.
442    ///
443    /// Borrows are split field-wise so both the trunk-aliasing paths and the barrier path
444    /// (owned branch states) can call it.
445    #[allow(clippy::too_many_arguments)]
446    fn emit_edges(
447        &self,
448        source: NodeId,
449        branch: &S,
450        values: &ValueStore,
451        pending_tokens: &mut IndexMap<EdgeId, u64>,
452        iterations: &mut IndexMap<EdgeId, u64>,
453        source_skipped: bool,
454        source_errored: bool,
455    ) -> Result<(), BspError> {
456        let Some(outgoing) = self.graph.outgoing.get(&source) else {
457            return Ok(());
458        };
459        for &edge_id in outgoing {
460            let edge = &self.graph.edges[edge_id];
461            let eval = ConditionEval {
462                state: branch,
463                values,
464                conditions: &self.graph.conditions,
465                source_skipped,
466                source_errored,
467            };
468            if !edge.condition.eval(&eval) {
469                continue;
470            }
471
472            if let Some(max) = edge.max_iterations {
473                let iteration = iterations.entry(edge_id).or_insert(0);
474                *iteration += 1;
475                let iteration = *iteration;
476                if let Some(interceptor) = &self.interceptor {
477                    interceptor.on_loop_iteration(edge_id, iteration, Some(max));
478                }
479                if iteration > max {
480                    return Err(BspError::LoopBoundExceeded {
481                        edge: edge_id,
482                        from: edge.source,
483                        to: edge.target,
484                        max,
485                    });
486                }
487            }
488
489            let pending = pending_tokens.entry(edge_id).or_insert(0);
490            if *pending >= self.max_pending_tokens {
491                return Err(BspError::TokenLimitExceeded {
492                    edge: edge_id,
493                    from: edge.source,
494                    to: edge.target,
495                    cap: self.max_pending_tokens,
496                });
497            }
498            *pending += 1;
499
500            if let Some(interceptor) = &self.interceptor {
501                interceptor.on_edge_taken(edge.source, edge.target, &edge.condition.label());
502            }
503        }
504        Ok(())
505    }
506
507    /// Resolves the merger for a multi-task barrier fold: the first (in frontier
508    /// declaration order) completed node whose outgoing edges reach a join naming a
509    /// registered merger.
510    fn resolve_merger(&self, frontier_nodes: &[NodeId]) -> Option<Arc<dyn Merger<S>>> {
511        for &node_id in frontier_nodes {
512            if let Some(outgoing) = self.graph.outgoing.get(&node_id) {
513                for &edge_id in outgoing {
514                    let target = self.graph.edges[edge_id].target;
515                    if let Some(node) = self.graph.nodes.get(&target)
516                        && let Some(name) = node.merger()
517                        && let Some(merger) = self.graph.mergers.get(name)
518                    {
519                        return Some(merger.clone());
520                    }
521                }
522            }
523        }
524        None
525    }
526
527    /// Executes one node's task: skip-bypass, then `execute` wrapped in the recovery loop.
528    ///
529    /// The task borrows the branch state it operates on, so the concurrent path can give
530    /// every frontier task its own clone while single-task frontiers borrow the trunk.
531    /// Returns the number of `execute` invocations (including retries) and the outcome.
532    async fn run_task(
533        &self,
534        node_id: NodeId,
535        branch: &mut S,
536        inputs: IndexMap<PortIndex, BspValue>,
537        should_skip: bool,
538        exec_base: u64,
539    ) -> (u64, Result<TaskResult, BspError>) {
540        let node = self
541            .graph
542            .nodes
543            .get(&node_id)
544            .expect("frontier nodes are graph members");
545        let name = node.name().to_string();
546
547        if let Some(interceptor) = &self.interceptor {
548            interceptor.on_node_start(node_id, &name);
549        }
550
551        if should_skip {
552            // Dynamic Control-Flow: bypass execution completely, propagating Skips
553            let mut outputs = IndexMap::new();
554            for &port in &node.output_ports() {
555                outputs.insert(port, BspValue::new(BspSkip));
556            }
557            if let Some(interceptor) = &self.interceptor {
558                interceptor.on_node_complete(node_id, &name, Duration::ZERO);
559            }
560            return (
561                1,
562                Ok(TaskResult::Success {
563                    outputs,
564                    skipped: true,
565                }),
566            );
567        }
568
569        let recovery = node.recovery();
570        let start = Instant::now();
571        let mut calls = 0_u32;
572
573        loop {
574            calls += 1;
575            let (result, staged, skip_flag) = {
576                let mut run = RunContext::new(
577                    &mut *branch,
578                    node_id,
579                    &self.ctx,
580                    exec_base + u64::from(calls) - 1,
581                    inputs.clone(),
582                );
583                let result = node.execute(&mut run).await;
584                let (staged, skip_flag) = run.take_staged();
585                (result, staged, skip_flag)
586            };
587
588            match result {
589                Ok(NodeOutcome::Success(mut map)) => {
590                    // Staged outputs merge under the returned map (explicit return wins).
591                    for (port, value) in staged {
592                        map.entry(port).or_insert(value);
593                    }
594                    let (outputs, skipped) = if skip_flag {
595                        let mut outputs = IndexMap::new();
596                        for &port in &node.output_ports() {
597                            outputs.insert(port, BspValue::new(BspSkip));
598                        }
599                        (outputs, true)
600                    } else {
601                        let skipped = map.values().any(BspValue::is::<BspSkip>);
602                        (map, skipped)
603                    };
604                    let duration = start.elapsed();
605                    if let Some(interceptor) = &self.interceptor {
606                        interceptor.on_node_complete(node_id, &name, duration);
607                    }
608                    return (
609                        u64::from(calls),
610                        Ok(TaskResult::Success { outputs, skipped }),
611                    );
612                }
613                Ok(NodeOutcome::Suspend {
614                    payload,
615                    waiting_inputs,
616                }) => {
617                    return (
618                        u64::from(calls),
619                        Ok(TaskResult::Suspended {
620                            payload,
621                            waiting_inputs,
622                        }),
623                    );
624                }
625                Err(err) => match &recovery {
626                    RecoveryPolicy::Retry { policy, classify } => match classify(&err) {
627                        Recovery::Retry { after } if calls <= policy.max_retries => {
628                            let wait = policy
629                                .backoff(calls - 1)
630                                .max(after.unwrap_or(Duration::ZERO));
631                            if let Some(interceptor) = &self.interceptor {
632                                interceptor.on_node_retry(node_id, calls, wait, &err);
633                            }
634                            super::recovery::sleep(wait).await;
635                        }
636                        _ => {
637                            if let Some(interceptor) = &self.interceptor {
638                                interceptor.on_node_error(node_id, &name, &err);
639                            }
640                            return (u64::from(calls), Err(err));
641                        }
642                    },
643                    RecoveryPolicy::RouteToErrorEdge => {
644                        if let Some(interceptor) = &self.interceptor {
645                            interceptor.on_node_error(node_id, &name, &err);
646                        }
647                        return (u64::from(calls), Ok(TaskResult::RoutedError(err)));
648                    }
649                    RecoveryPolicy::FailFast => {
650                        if let Some(interceptor) = &self.interceptor {
651                            interceptor.on_node_error(node_id, &name, &err);
652                        }
653                        return (u64::from(calls), Err(err));
654                    }
655                },
656            }
657        }
658    }
659
660    /// Applies one task's outcome: writes outputs, evaluates edges, folds the branch state
661    /// into the trunk.
662    ///
663    /// With `branch == None` the task already mutated the trunk in place (single-task and
664    /// sequential paths); otherwise the owned branch folds in — via the barrier's merger
665    /// when one is named, deterministically last-writer-wins otherwise. Suspended branches
666    /// fold too: staged state mutations must survive the suspension.
667    fn process_result(
668        &self,
669        state: &mut RunState<S>,
670        node_id: NodeId,
671        branch: Option<(S, Option<&dyn Merger<S>>)>,
672        result: Result<TaskResult, BspError>,
673        suspensions: &mut Vec<PendingSuspension>,
674    ) -> Result<(), BspError> {
675        let RunState {
676            user,
677            values,
678            pending_tokens,
679            iterations,
680            errors,
681            ..
682        } = state;
683
684        match result {
685            Ok(TaskResult::Success { outputs, skipped }) => {
686                for (port, value) in outputs {
687                    values.outputs.insert(OutputPin { node_id, port }, value);
688                }
689                let branch_state: &S = branch.as_ref().map_or(&*user, |(b, _)| b);
690                self.emit_edges(
691                    node_id,
692                    branch_state,
693                    values,
694                    pending_tokens,
695                    iterations,
696                    skipped,
697                    false,
698                )?;
699            }
700            Ok(TaskResult::Suspended {
701                payload,
702                waiting_inputs,
703            }) => {
704                suspensions.push(PendingSuspension {
705                    node_id,
706                    waiting_inputs,
707                    payload,
708                });
709            }
710            Ok(TaskResult::RoutedError(err)) => {
711                errors.insert(node_id, err.clone());
712                values.outputs.insert(
713                    OutputPin {
714                        node_id,
715                        port: ERROR_PORT,
716                    },
717                    BspValue::new(err),
718                );
719                let branch_state: &S = branch.as_ref().map_or(&*user, |(b, _)| b);
720                self.emit_edges(
721                    node_id,
722                    branch_state,
723                    values,
724                    pending_tokens,
725                    iterations,
726                    false,
727                    true,
728                )?;
729            }
730            Err(err) => return Err(err),
731        }
732
733        if let Some((branch_state, merger)) = branch {
734            match merger {
735                Some(merger) => merger.merge(user, branch_state),
736                None => *user = branch_state,
737            }
738        }
739        Ok(())
740    }
741
742    /// Packages a suspension if the superstep produced one, otherwise advances the step
743    /// counter. Shared by both execution loops.
744    fn epilogue(&self, state: &mut RunState<S>, suspensions: &[PendingSuspension]) -> bool {
745        if suspensions.is_empty() {
746            state.step += 1;
747            return false;
748        }
749        tracing::warn!(
750            "⏸️ [BspEngine] {} node(s) requested execution suspension.",
751            suspensions.len()
752        );
753        if let Some(interceptor) = &self.interceptor {
754            interceptor.on_suspended(suspensions);
755        }
756        state.frontier = suspensions.iter().map(|s| s.node_id).collect();
757        true
758    }
759
760    /// The Bulk Synchronous Parallel execution loop.
761    ///
762    /// `forced` nodes join the first frontier unconditionally: graph entries on a fresh
763    /// run, suspended nodes on a resume (their activation tokens were consumed when they
764    /// first fired, so resumption re-arms them directly).
765    async fn execute_loop(
766        &self,
767        mut state: RunState<S>,
768        forced: IndexSet<NodeId>,
769    ) -> Result<EngineResult<S>, BspError>
770    where
771        S: Clone,
772    {
773        if let Some(err) = &self.validation_report().error {
774            return Err(err.clone());
775        }
776
777        let mut forced = forced;
778
779        loop {
780            let frontier_nodes = match self.next_step(&mut state, &mut forced)? {
781                NextStep::Frontier(nodes) => nodes,
782                NextStep::Complete => {
783                    tracing::info!("✅ [BspEngine] Graph executed to completion.");
784                    if let Some(interceptor) = &self.interceptor {
785                        interceptor.on_complete(state.step);
786                    }
787                    return Ok(EngineResult::Complete(Box::new(state)));
788                }
789                NextStep::StepLimit => return Ok(EngineResult::StepLimitReached(Box::new(state))),
790            };
791
792            let mut suspensions = Vec::new();
793
794            if frontier_nodes.len() == 1 {
795                // Single-node frontier: borrow the trunk directly — linear chains and
796                // simple loops never pay for a state clone.
797                let node_id = frontier_nodes[0];
798                let (exec_base, inputs, should_skip) = self.prepare_node(&mut state, node_id)?;
799                let (calls, result) = self
800                    .run_task(node_id, &mut state.user, inputs, should_skip, exec_base)
801                    .await;
802                state.executions.insert(node_id, exec_base + calls);
803                self.process_result(&mut state, node_id, None, result, &mut suspensions)?;
804            } else {
805                // Multi-node frontier: each task gets its own clone of the trunk state
806                // (Rust aliasing forbids N concurrent `&mut S`); branch states fold back
807                // at the barrier in frontier declaration order — never completion order.
808                let merger = self.resolve_merger(&frontier_nodes);
809                if merger.is_none() {
810                    tracing::warn!(
811                        "🔀 [BspEngine] Folding {} branch states without a named merger: last-writer-wins.",
812                        frontier_nodes.len()
813                    );
814                }
815
816                let mut prepared = Vec::with_capacity(frontier_nodes.len());
817                for (ordinal, &node_id) in frontier_nodes.iter().enumerate() {
818                    let (exec_base, inputs, should_skip) =
819                        self.prepare_node(&mut state, node_id)?;
820                    prepared.push((ordinal, node_id, exec_base, inputs, should_skip));
821                }
822
823                let limit = match self.max_concurrency {
824                    Some(0) | None => prepared.len(),
825                    Some(n) => n,
826                };
827                let tasks = prepared.into_iter().map(
828                    |(ordinal, node_id, exec_base, inputs, should_skip)| {
829                        let mut branch = state.user.clone();
830                        if let Some(transform) = &self.branch_transform {
831                            transform(&mut branch, ordinal);
832                        }
833                        async move {
834                            let (calls, result) = self
835                                .run_task(node_id, &mut branch, inputs, should_skip, exec_base)
836                                .await;
837                            (ordinal, node_id, exec_base, calls, branch, result)
838                        }
839                    },
840                );
841                // `buffer_unordered` yields in *completion* order, but branch results must
842                // fold in *declaration* order — each task carries its frontier ordinal and
843                // results are re-sorted before folding.
844                let mut results = stream::iter(tasks)
845                    .buffer_unordered(limit)
846                    .collect::<Vec<_>>()
847                    .await;
848                results.sort_by_key(|(ordinal, ..)| *ordinal);
849
850                for (_, node_id, exec_base, calls, branch, result) in results {
851                    state.executions.insert(node_id, exec_base + calls);
852                    self.process_result(
853                        &mut state,
854                        node_id,
855                        Some((branch, merger.as_deref())),
856                        result,
857                        &mut suspensions,
858                    )?;
859                }
860
861                // Barrier fold complete: give the merger its one-shot finalization hook
862                // (all-at-once reducers collapse the accumulated branches here).
863                if let Some(merger) = &merger {
864                    merger.finish(&mut state.user).await?;
865                }
866            }
867
868            if self.epilogue(&mut state, &suspensions) {
869                return Ok(EngineResult::Suspended(Box::new(SuspendedExecution {
870                    suspensions,
871                    state,
872                })));
873            }
874        }
875    }
876
877    /// The sequential execution loop for `!Clone` states: frontier tasks are awaited one
878    /// at a time in declaration order, each borrowing the trunk state directly. Linear and
879    /// loop graphs behave exactly as in the concurrent loop; multi-node frontiers run
880    /// serially instead of in isolated clones (no `Clone` bound, no branch merging).
881    async fn execute_loop_sequential(
882        &self,
883        mut state: RunState<S>,
884        forced: IndexSet<NodeId>,
885    ) -> Result<EngineResult<S>, BspError> {
886        if let Some(err) = &self.validation_report().error {
887            return Err(err.clone());
888        }
889
890        let mut forced = forced;
891
892        loop {
893            let frontier_nodes = match self.next_step(&mut state, &mut forced)? {
894                NextStep::Frontier(nodes) => nodes,
895                NextStep::Complete => {
896                    tracing::info!("✅ [BspEngine] Graph executed to completion.");
897                    if let Some(interceptor) = &self.interceptor {
898                        interceptor.on_complete(state.step);
899                    }
900                    return Ok(EngineResult::Complete(Box::new(state)));
901                }
902                NextStep::StepLimit => return Ok(EngineResult::StepLimitReached(Box::new(state))),
903            };
904
905            let mut suspensions = Vec::new();
906            for node_id in frontier_nodes {
907                let (exec_base, inputs, should_skip) = self.prepare_node(&mut state, node_id)?;
908                let (calls, result) = self
909                    .run_task(node_id, &mut state.user, inputs, should_skip, exec_base)
910                    .await;
911                state.executions.insert(node_id, exec_base + calls);
912                self.process_result(&mut state, node_id, None, result, &mut suspensions)?;
913            }
914
915            if self.epilogue(&mut state, &suspensions) {
916                return Ok(EngineResult::Suspended(Box::new(SuspendedExecution {
917                    suspensions,
918                    state,
919                })));
920            }
921        }
922    }
923}
924
925impl<S: Clone> BspEngine<S> {
926    /// Initiates a clean run of the graph with the given initial consumer state
927    /// (`run(())` for pure dataflow graphs).
928    ///
929    /// Requires `S: Clone` because concurrent frontiers execute against per-task clones
930    /// (see the module docs); single-node frontiers borrow the trunk directly. For
931    /// `!Clone` states, use [`BspEngine::run_sequential`].
932    pub async fn run(&self, state: S) -> Result<EngineResult<S>, BspError> {
933        tracing::info!("🏁 [BspEngine] Initiating graph execution pass...");
934        let forced = self.graph.entries();
935        self.execute_loop(RunState::new(state), forced).await
936    }
937
938    /// Like [`BspEngine::run`], but forces the given nodes as the run's entry frontier
939    /// instead of the graph's declared entries.
940    ///
941    /// This is the resumable-control-flow primitive for hosts that checkpoint only the
942    /// consumer state `S` (not the engine's [`SuspendedExecution`] snapshot): resuming is
943    /// "start a fresh run forced at the recorded cursor node(s)", exactly the model the
944    /// reference consumer's `resume_at` graph cursor uses. Forced nodes join the first
945    /// frontier unconditionally, even when their incoming edges hold no tokens.
946    pub async fn run_with_entries(
947        &self,
948        state: S,
949        entries: impl IntoIterator<Item = NodeId>,
950    ) -> Result<EngineResult<S>, BspError> {
951        tracing::info!("🏁 [BspEngine] Initiating graph execution pass with forced entries...");
952        self.execute_loop(RunState::new(state), entries.into_iter().collect())
953            .await
954    }
955
956    /// Resumes a suspended graph execution, injecting external values into the waiting
957    /// ports. Iteration and execution counters continue — they never reset.
958    pub async fn resume(
959        &self,
960        mut suspended: SuspendedExecution<S>,
961        injections: IndexMap<InputPin, BspValue>,
962    ) -> Result<EngineResult<S>, BspError> {
963        tracing::info!(
964            "🔄 [BspEngine] Resuming execution with {} injected inputs...",
965            injections.len()
966        );
967        for (pin, val) in injections {
968            suspended.state.values.injected.insert(pin, val);
969        }
970        let forced = suspended.suspensions.iter().map(|s| s.node_id).collect();
971        self.execute_loop(suspended.state, forced).await
972    }
973}
974
975impl<S> BspEngine<S> {
976    /// Like [`BspEngine::run`] but without a `Clone` bound on `S`: frontier tasks
977    /// execute sequentially, each borrowing the trunk state. Linear chains and loops
978    /// behave identically; parallel frontiers serialize and share the trunk in
979    /// declaration order (no isolation, no merging).
980    pub async fn run_sequential(&self, state: S) -> Result<EngineResult<S>, BspError> {
981        tracing::info!("🏁 [BspEngine] Initiating sequential graph execution pass...");
982        let forced = self.graph.entries();
983        self.execute_loop_sequential(RunState::new(state), forced)
984            .await
985    }
986
987    /// Sequential counterpart of [`BspEngine::resume`] for `!Clone` states.
988    pub async fn resume_sequential(
989        &self,
990        mut suspended: SuspendedExecution<S>,
991        injections: IndexMap<InputPin, BspValue>,
992    ) -> Result<EngineResult<S>, BspError> {
993        tracing::info!(
994            "🔄 [BspEngine] Resuming sequential execution with {} injected inputs...",
995            injections.len()
996        );
997        for (pin, val) in injections {
998            suspended.state.values.injected.insert(pin, val);
999        }
1000        let forced = suspended.suspensions.iter().map(|s| s.node_id).collect();
1001        self.execute_loop_sequential(suspended.state, forced).await
1002    }
1003}
1004
1005// =========================================================================
1006// VALIDATION
1007// =========================================================================
1008
1009/// Runs the full validation analysis: structural errors first, then loop-aware warnings.
1010#[allow(clippy::too_many_lines)]
1011fn compute_validation<S>(graph: &BspGraph<S>) -> ValidationReport {
1012    let mut warnings = Vec::new();
1013
1014    macro_rules! fail {
1015        ($err:expr) => {
1016            return ValidationReport {
1017                error: Some($err),
1018                warnings,
1019            }
1020        };
1021    }
1022
1023    // -- Structural: duplicate edges --------------------------------------------------
1024    // Dataflow links with identical pins are always bugs. Control-flow edges may share
1025    // endpoints legitimately (branch edges with different conditions), so the condition
1026    // label is part of the identity.
1027    let mut unique_edges = HashSet::new();
1028    for edge in &graph.edges {
1029        let key = (
1030            edge.source,
1031            edge.target,
1032            edge.source_port,
1033            edge.target_port,
1034            edge.condition.label(),
1035        );
1036        if !unique_edges.insert(key) {
1037            let fmt_port = |p: Option<PortIndex>| p.map_or("-".to_string(), |p| p.to_string());
1038            fail!(BspError::ValidationError(format!(
1039                "Duplicate link detected from Node {} (Port {}) to Node {} (Port {})",
1040                edge.source,
1041                fmt_port(edge.source_port),
1042                edge.target,
1043                fmt_port(edge.target_port)
1044            )));
1045        }
1046    }
1047
1048    // -- Structural: edge endpoints and ports ------------------------------------------
1049    for edge in &graph.edges {
1050        let Some(source_node) = graph.nodes.get(&edge.source) else {
1051            fail!(BspError::NodeNotFound {
1052                node_id: edge.source
1053            });
1054        };
1055        let Some(target_node) = graph.nodes.get(&edge.target) else {
1056            fail!(BspError::NodeNotFound {
1057                node_id: edge.target
1058            });
1059        };
1060
1061        if let Some(port) = edge.source_port
1062            && !source_node.is_dynamic_output()
1063            && !source_node.output_ports().contains(&port)
1064        {
1065            fail!(BspError::ValidationError(format!(
1066                "Link setup references invalid output port {} on Node {} ('{}')",
1067                port,
1068                edge.source,
1069                source_node.name()
1070            )));
1071        }
1072
1073        if let Some(port) = edge.target_port
1074            && !target_node.input_ports().contains(&port)
1075        {
1076            fail!(BspError::ValidationError(format!(
1077                "Link setup references invalid input port {} on Node {} ('{}')",
1078                port,
1079                edge.target,
1080                target_node.name()
1081            )));
1082        }
1083    }
1084
1085    // -- Structural: static inputs ------------------------------------------------------
1086    for pin in graph.static_inputs.keys() {
1087        let Some(node) = graph.nodes.get(&pin.node_id) else {
1088            fail!(BspError::NodeNotFound {
1089                node_id: pin.node_id
1090            });
1091        };
1092        if !node.input_ports().contains(&pin.port) {
1093            fail!(BspError::ValidationError(format!(
1094                "Static input targets invalid port {} on Node {} ('{}')",
1095                pin.port,
1096                pin.node_id,
1097                node.name()
1098            )));
1099        }
1100    }
1101
1102    // -- Registry completeness (programmatic graphs too: fail fast, never mid-run) ------
1103    let mut missing: Vec<String> = Vec::new();
1104    for edge in &graph.edges {
1105        let mut named = Vec::new();
1106        edge.condition.collect_named(&mut named);
1107        for name in named {
1108            if !graph.conditions.contains_key(&name) {
1109                missing.push(format!("condition '{name}'"));
1110            }
1111        }
1112    }
1113    for node in graph.nodes.values() {
1114        if let Some(merger) = node.merger()
1115            && !graph.mergers.contains_key(merger)
1116        {
1117            missing.push(format!("merger '{merger}'"));
1118        }
1119    }
1120    if !missing.is_empty() {
1121        missing.sort();
1122        missing.dedup();
1123        fail!(BspError::ValidationError(format!(
1124            "graph references unregistered names: {}",
1125            missing.join(", ")
1126        )));
1127    }
1128
1129    // -- Topology analysis (Never edges are treated as absent) ---------------------------
1130    let live_edges: Vec<EdgeId> = graph
1131        .edges
1132        .iter()
1133        .enumerate()
1134        .filter(|(_, e)| !matches!(e.condition, Condition::Never))
1135        .map(|(id, _)| id)
1136        .collect();
1137
1138    let mut incoming_live: IndexMap<NodeId, Vec<EdgeId>> = IndexMap::new();
1139    let mut adjacency: IndexMap<NodeId, Vec<NodeId>> = IndexMap::new();
1140    for &edge_id in &live_edges {
1141        let edge = &graph.edges[edge_id];
1142        incoming_live.entry(edge.target).or_default().push(edge_id);
1143        adjacency.entry(edge.source).or_default().push(edge.target);
1144    }
1145
1146    // Can-fire fixpoint: entries fire; a node fires when its activation is reachable.
1147    let mut fireable: IndexSet<NodeId> = IndexSet::new();
1148    for &id in &graph.entry {
1149        fireable.insert(id);
1150    }
1151    for &id in graph.nodes.keys() {
1152        if incoming_live.get(&id).is_none_or(Vec::is_empty) {
1153            fireable.insert(id);
1154        }
1155    }
1156    loop {
1157        let mut changed = false;
1158        for (&id, node) in &graph.nodes {
1159            if fireable.contains(&id) {
1160                continue;
1161            }
1162            let incoming = &incoming_live[&id];
1163            let fires = match node.activation() {
1164                Activation::Any => incoming
1165                    .iter()
1166                    .any(|e| fireable.contains(&graph.edges[*e].source)),
1167                Activation::All => incoming
1168                    .iter()
1169                    .all(|e| fireable.contains(&graph.edges[*e].source)),
1170            };
1171            if fires {
1172                fireable.insert(id);
1173                changed = true;
1174            }
1175        }
1176        if !changed {
1177            break;
1178        }
1179    }
1180
1181    // Strongly connected components for cycle analysis.
1182    let sccs = strongly_connected_components(&adjacency, graph.nodes.keys().copied());
1183    let mut cyclic_nodes: HashSet<NodeId> = HashSet::new();
1184    for scc in &sccs {
1185        if scc.len() > 1 {
1186            cyclic_nodes.extend(scc.iter().copied());
1187        }
1188    }
1189    for &edge_id in &live_edges {
1190        let edge = &graph.edges[edge_id];
1191        if edge.source == edge.target {
1192            cyclic_nodes.insert(edge.source);
1193        }
1194    }
1195
1196    // -- Unfireable nodes: a cycle that can never start is an error ----------------------
1197    let unfireable: Vec<NodeId> = graph
1198        .nodes
1199        .keys()
1200        .copied()
1201        .filter(|id| !fireable.contains(id))
1202        .collect();
1203    if !unfireable.is_empty() {
1204        if let Some(&first) = unfireable.iter().find(|id| cyclic_nodes.contains(id)) {
1205            fail!(BspError::CycleDetected { node_id: first });
1206        }
1207        // Unfireable without a cycle (e.g. reachable only through Never edges): warn.
1208        for id in &unfireable {
1209            warnings.push(format!(
1210                "Node {id} ('{}') is unreachable: no live path from any entry",
1211                graph.nodes[id].name()
1212            ));
1213        }
1214    }
1215
1216    // -- Loop-aware warnings -------------------------------------------------------------
1217    for scc in &sccs {
1218        let is_cycle = scc.len() > 1
1219            || (scc.len() == 1 && {
1220                let id = scc[0];
1221                live_edges.iter().any(|&e| {
1222                    let edge = &graph.edges[e];
1223                    edge.source == id && edge.target == id
1224                })
1225            });
1226        if !is_cycle {
1227            continue;
1228        }
1229        let members: HashSet<NodeId> = scc.iter().copied().collect();
1230        let bounded = live_edges.iter().any(|&e| {
1231            let edge = &graph.edges[e];
1232            members.contains(&edge.source)
1233                && members.contains(&edge.target)
1234                && edge.max_iterations.is_some()
1235        });
1236        if !bounded {
1237            warnings.push(format!(
1238                "Cycle involving nodes {scc:?} has no max_iterations budget on any edge; \
1239                 an unbounded loop only stops at the global max_steps limit"
1240            ));
1241        }
1242        for &id in scc {
1243            // Starvation needs ≥2 live incoming edges: one arriving once-ever, another
1244            // once-per-iteration. A single-incoming All node on a cycle cannot starve.
1245            let incoming_arity = incoming_live.get(&id).map_or(0, Vec::len);
1246            if incoming_arity >= 2
1247                && fireable.contains(&id)
1248                && graph.nodes[&id].activation() == Activation::All
1249            {
1250                warnings.push(format!(
1251                    "Node {id} ('{}') is an All-join on a cycle: tokens on one incoming edge \
1252                     arrive once per iteration while another's may arrive once ever, starving \
1253                     the join. Prefer Any or a merger-join inside loops",
1254                    graph.nodes[&id].name()
1255                ));
1256            }
1257        }
1258    }
1259
1260    for (&id, node) in &graph.nodes {
1261        if let Some(outgoing) = graph.outgoing.get(&id) {
1262            let live: Vec<EdgeId> = outgoing
1263                .iter()
1264                .copied()
1265                .filter(|e| !matches!(graph.edges[*e].condition, Condition::Never))
1266                .collect();
1267            // Dead-end: outgoing live edges exist but none is an unconditional fallback.
1268            if !live.is_empty()
1269                && !live
1270                    .iter()
1271                    .any(|&e| matches!(graph.edges[e].condition, Condition::Always))
1272            {
1273                warnings.push(format!(
1274                    "Node {id} ('{}') has no Always fallback on its outgoing edges; if no \
1275                     condition matches the run dead-ends here",
1276                    node.name()
1277                ));
1278            }
1279        }
1280
1281        // Error routing declared but no error edge to route to (applies even when the
1282        // node has no outgoing edges at all).
1283        if matches!(node.recovery(), RecoveryPolicy::RouteToErrorEdge) {
1284            let has_error_edge = graph.outgoing.get(&id).is_some_and(|outgoing| {
1285                outgoing
1286                    .iter()
1287                    .any(|&e| matches!(graph.edges[e].condition, Condition::OnError))
1288            });
1289            if !has_error_edge {
1290                warnings.push(format!(
1291                    "Node {id} ('{}') uses RouteToErrorEdge recovery but has no outgoing \
1292                     OnError edge; failures will abort the run",
1293                    node.name()
1294                ));
1295            }
1296        }
1297
1298        // Join without a merger: branch states fold last-writer-wins at the barrier.
1299        if let Some(incoming) = graph.incoming.get(&id) {
1300            let distinct_sources: HashSet<NodeId> =
1301                incoming.iter().map(|&e| graph.edges[e].source).collect();
1302            if distinct_sources.len() >= 2 && node.merger().is_none() {
1303                warnings.push(format!(
1304                    "Node {id} ('{}') joins multiple branches without a named merger; \
1305                     concurrent branch states fold last-writer-wins (harmless only if nodes \
1306                     do not mutate shared state)",
1307                    node.name()
1308                ));
1309            }
1310        }
1311    }
1312
1313    // Static inputs a GraphDef cannot represent are skipped by `to_def` — warn here so
1314    // the loss is visible before a save/load round-trip silently drops them.
1315    for (pin, value) in &graph.static_inputs {
1316        if crate::def::value_to_json(value).is_none()
1317            && let Some(node) = graph.nodes.get(&pin.node_id)
1318        {
1319            warnings.push(format!(
1320                "Static input on node {} ('{}') port {} is not GraphDef-serializable; \
1321                 `to_def` will skip it",
1322                pin.node_id,
1323                node.name(),
1324                pin.port
1325            ));
1326        }
1327    }
1328
1329    ValidationReport {
1330        error: None,
1331        warnings,
1332    }
1333}
1334
1335/// Kosaraju's algorithm: strongly connected components over the live-edge adjacency.
1336fn strongly_connected_components(
1337    adjacency: &IndexMap<NodeId, Vec<NodeId>>,
1338    nodes: impl Iterator<Item = NodeId>,
1339) -> Vec<Vec<NodeId>> {
1340    let all: Vec<NodeId> = nodes.collect();
1341
1342    // Pass 1: finishing order via iterative post-order DFS.
1343    let mut visited: HashSet<NodeId> = HashSet::new();
1344    let mut finish_order: Vec<NodeId> = Vec::new();
1345    for &start in &all {
1346        if visited.contains(&start) {
1347            continue;
1348        }
1349        let mut stack: Vec<(NodeId, usize)> = vec![(start, 0)];
1350        visited.insert(start);
1351        while let Some((node, child_idx)) = stack.pop() {
1352            let neighbors: &[NodeId] = adjacency.get(&node).map_or(&[], Vec::as_slice);
1353            if child_idx < neighbors.len() {
1354                stack.push((node, child_idx + 1));
1355                let next = neighbors[child_idx];
1356                if visited.insert(next) {
1357                    stack.push((next, 0));
1358                }
1359            } else {
1360                finish_order.push(node);
1361            }
1362        }
1363    }
1364
1365    // Pass 2: DFS on the reversed graph in reverse finishing order.
1366    let mut reverse: IndexMap<NodeId, Vec<NodeId>> = IndexMap::new();
1367    for (from, targets) in adjacency {
1368        for &to in targets {
1369            reverse.entry(to).or_default().push(*from);
1370        }
1371    }
1372
1373    let mut visited: HashSet<NodeId> = HashSet::new();
1374    let mut sccs = Vec::new();
1375    for &start in finish_order.iter().rev() {
1376        if visited.contains(&start) {
1377            continue;
1378        }
1379        let mut scc = Vec::new();
1380        let mut stack = vec![start];
1381        visited.insert(start);
1382        while let Some(node) = stack.pop() {
1383            scc.push(node);
1384            if let Some(predecessors) = reverse.get(&node) {
1385                for &pred in predecessors {
1386                    if visited.insert(pred) {
1387                        stack.push(pred);
1388                    }
1389                }
1390            }
1391        }
1392        sccs.push(scc);
1393    }
1394    sccs
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399    use super::*;
1400
1401    #[test]
1402    fn test_sccs_find_cycles() {
1403        let mut adjacency: IndexMap<NodeId, Vec<NodeId>> = IndexMap::new();
1404        adjacency.insert(1, vec![2]);
1405        adjacency.insert(2, vec![1, 3]);
1406        adjacency.insert(3, vec![4]);
1407        adjacency.insert(4, vec![3]);
1408        let sccs = strongly_connected_components(&adjacency, adjacency.keys().copied());
1409        let mut sizes: Vec<usize> = sccs.iter().map(Vec::len).collect();
1410        sizes.sort_unstable();
1411        assert_eq!(sizes, vec![2, 2]);
1412    }
1413
1414    #[test]
1415    fn test_sccs_of_dag_are_singletons() {
1416        let mut adjacency: IndexMap<NodeId, Vec<NodeId>> = IndexMap::new();
1417        adjacency.insert(1, vec![2, 3]);
1418        adjacency.insert(2, vec![4]);
1419        adjacency.insert(3, vec![4]);
1420        let sccs = strongly_connected_components(&adjacency, adjacency.keys().copied());
1421        assert!(sccs.iter().all(|scc| scc.len() == 1));
1422        assert_eq!(sccs.len(), 4);
1423    }
1424}