Skip to main content

dfir_lang/graph/
flat_to_partitioned.rs

1//! Subgraph partioning algorithm
2
3use std::collections::BTreeSet;
4
5use itertools::Itertools;
6use slotmap::{SecondaryMap, SparseSecondaryMap};
7
8use super::meta_graph::DfirGraph;
9use super::ops::DelayType;
10use super::{
11    Color, GraphEdgeId, GraphLoopId, GraphNode, GraphNodeId, GraphSubgraphId, HandoffKind,
12};
13use crate::diagnostic::{Diagnostic, Level};
14use crate::graph::graph_algorithms::{SubgraphMerge, validate_topo_sort};
15
16/// Find edge barriers: edges whose destination operator declares an input delay type.
17///
18/// Returns:
19/// - Tick/TickLazy/Loop/LoopLazy edges keyed by edge ID (for topo-sort exclusion and handoff marking).
20/// - All barrier (src, dst) node pairs (for the enemies set).
21fn find_edge_barriers(
22    partitioned_graph: &DfirGraph,
23) -> (
24    SecondaryMap<GraphEdgeId, DelayType>,
25    Vec<(GraphNodeId, GraphNodeId)>,
26) {
27    let mut tick_edges = SecondaryMap::new();
28    let mut barrier_pairs = Vec::new();
29
30    for (edge_id, (src, dst)) in partitioned_graph.edges() {
31        let Some(op_inst) = partitioned_graph.node_op_inst(dst) else {
32            continue;
33        };
34        let (_src_port, dst_port) = partitioned_graph.edge_ports(edge_id);
35        let Some(delay_type) = (op_inst.op_constraints.input_delaytype_fn)(dst_port) else {
36            continue;
37        };
38
39        barrier_pairs.push((src, dst));
40        tick_edges.insert(edge_id, delay_type);
41    }
42
43    (tick_edges, barrier_pairs)
44}
45
46/// Find handoff reference access group ordering constraints: for the same handoff target, operators in
47/// lower access groups must run before operators in higher access groups.
48fn find_access_group_ordering(partitioned_graph: &DfirGraph) -> Vec<(GraphNodeId, GraphNodeId)> {
49    let mut pairs = Vec::new();
50    let refs_by_target = partitioned_graph.node_handoff_reference_groups();
51    for (_handoff, groups) in refs_by_target {
52        for (group_a, group_b) in groups.values().tuple_windows() {
53            for &(node_a, _, _) in group_a {
54                for &(node_b, _, _) in group_b {
55                    // TODO(mingwei): handle with diagnostics.
56                    assert_ne!(
57                        node_a, node_b,
58                        "encounted conflicted or cyclical handoff references\n{:?}\n{:?}",
59                        group_a, group_b,
60                    );
61                    pairs.push((node_a, node_b));
62                }
63            }
64        }
65    }
66    pairs
67}
68
69fn find_subgraph_unionfind(
70    partitioned_graph: &DfirGraph,
71    tick_edges: &SecondaryMap<GraphEdgeId, DelayType>,
72    edge_barrier_pairs: &[(GraphNodeId, GraphNodeId)],
73    access_group_pairs: &[(GraphNodeId, GraphNodeId)],
74) -> Result<(SubgraphMerge<GraphNodeId>, BTreeSet<GraphEdgeId>), Diagnostic> {
75    // Modality (color) of nodes, push or pull.
76    // TODO(mingwei)? This does NOT consider `DelayType` barriers (which generally imply `Pull`),
77    // which makes it inconsistant with the final output in `as_code()`. But this doesn't create
78    // any bugs since we exclude `DelayType` edges from joining subgraphs anyway.
79    let mut node_color = partitioned_graph
80        .node_ids()
81        .filter_map(|node_id| {
82            let op_color = partitioned_graph.node_color(node_id)?;
83            Some((node_id, op_color))
84        })
85        .collect::<SparseSecondaryMap<_, _>>();
86
87    // Pre-compute all predecessor edges for the topological sort.
88    let mut all_preds: SecondaryMap<GraphNodeId, Vec<GraphNodeId>> = SecondaryMap::new();
89
90    // Pipe predecessors (excluding tick edges which are cross-tick).
91    for (edge_id, (src, dst)) in partitioned_graph.edges() {
92        if !tick_edges.contains_key(edge_id) {
93            all_preds.entry(dst).unwrap().or_default().push(src);
94        }
95    }
96
97    // Handoff references: producer must run before consumer.
98    for node_id in partitioned_graph.node_ids() {
99        for handoff_ref in partitioned_graph.node_handoff_references(node_id).iter() {
100            if let Some(src) = handoff_ref.node_id {
101                all_preds.entry(node_id).unwrap().or_default().push(src);
102                // Extra ordering: if the ref target is a handoff, its pipe consumers
103                // depend on the borrower (borrower runs before consumer).
104                if let GraphNode::Handoff { .. } = partitioned_graph.node(src) {
105                    for (_edge, consumer) in partitioned_graph.node_successors(src) {
106                        all_preds
107                            .entry(consumer)
108                            .unwrap()
109                            .or_default()
110                            .push(node_id);
111                    }
112                }
113            }
114        }
115    }
116
117    // Access group ordering.
118    for &(src, dst) in access_group_pairs {
119        all_preds.entry(dst).unwrap().or_default().push(src);
120    }
121
122    // Loop-ingress ordering constraints.
123    // Fix https://github.com/hydro-project/hydro/issues/3048
124    //
125    // `make_loops_contiguous` (run later) gathers *all* of a loop's subgraphs to the
126    // flat-order position of the loop's *first* subgraph. That is only sound if every
127    // subgraph feeding into the loop from *outside* is already ordered before the loop's
128    // earliest subgraph. Otherwise an ingress "receiver" inside the loop can be hoisted
129    // ahead of its external "sender", violating topological order (which then produces a
130    // handoff-buffer "use before declaration" compile error downstream).
131    //
132    // To guarantee this, we require: for each forward (non-tick) edge `src -> dst` where
133    // `dst` lies inside a loop that does not contain `src`, `src` must precede *every*
134    // node directly inside such loop.
135    {
136        for (edge_id, (src_id, dst_id)) in partitioned_graph.edges() {
137            if tick_edges.contains_key(edge_id) {
138                continue;
139            }
140            let src_loop = partitioned_graph.node_loop(src_id);
141            let dst_loop = partitioned_graph.node_loop(dst_id);
142            if let Some(dst_loop_some) = dst_loop
143                && src_loop == partitioned_graph.loop_parent(dst_loop_some)
144            {
145                for &inside_dst_id in partitioned_graph.loop_nodes(dst_loop_some) {
146                    all_preds
147                        .entry(inside_dst_id)
148                        .unwrap()
149                        .or_default()
150                        .push(src_id);
151                }
152            }
153        }
154    }
155
156    // Build enemies: all node pairs that must not be in the same subgraph.
157    let enemies = edge_barrier_pairs
158        .iter()
159        .copied()
160        .chain(access_group_pairs.iter().copied())
161        .chain(partitioned_graph.node_ids().flat_map(|dst| {
162            partitioned_graph
163                .node_handoff_references(dst)
164                .iter()
165                .filter_map(|r| r.node_id)
166                .map(move |src| (src, dst))
167        }));
168
169    let mut subgraph_unionfind = SubgraphMerge::<GraphNodeId>::new(
170        partitioned_graph.node_ids(),
171        |node_id| all_preds.get(node_id).into_iter().flatten().copied(),
172        enemies,
173    )
174    .map_err(|cycle| {
175        let span = cycle
176            .first()
177            .map(|&node_id| partitioned_graph.node(node_id).span())
178            .unwrap_or_else(proc_macro2::Span::call_site);
179        let node_cycle = cycle
180            .iter()
181            .map(|&node_id| partitioned_graph.node(node_id).to_pretty_string())
182            .collect::<Vec<_>>();
183        Diagnostic::spanned(
184            span,
185            Level::Error,
186            format!(
187                "Cyclical dataflow within a tick is not supported. Use `defer_tick()` or `defer_tick_lazy()` to break the cycle across ticks. \
188                Cycle: {:?}",
189                node_cycle,
190            ),
191        )
192    })?;
193
194    // Will contain all edges which need handoffs added. Starts out with all edges and
195    // we remove from this set as we combine nodes into subgraphs.
196    let mut handoff_edges: BTreeSet<GraphEdgeId> = partitioned_graph.edge_ids().collect();
197    // Would sort edges here for priority (for now, no sort/priority).
198
199    // Each edge gets looked at in order. However we may not know if a linear
200    // chain of operators is PUSH vs PULL until we look at the ends. A fancier
201    // algorithm would know to handle linear chains from the outside inward.
202    // But instead we just run through the edges in a loop until no more
203    // progress is made. Could have some sort of O(N^2) pathological worst
204    // case.
205    let mut progress = true;
206    while progress {
207        progress = false;
208        // TODO(mingwei): Could this iterate `handoff_edges` instead? (Modulo ownership). Then no case (1) below.
209        for (edge_id, (src, dst)) in partitioned_graph.edges().collect::<Vec<_>>() {
210            // Ignore existing handoffs, remove `edge_id` from `handoff_edges`.
211            if matches!(partitioned_graph.node(src), GraphNode::Handoff { .. })
212                || matches!(partitioned_graph.node(dst), GraphNode::Handoff { .. })
213            {
214                handoff_edges.remove(&edge_id);
215                continue;
216            }
217
218            // Ignore (1) already added edges as well as (2) new self-cycles. (Unless reference edge).
219            if subgraph_unionfind.same_set(src, dst) {
220                // Note that the _edge_ `edge_id` might not be in the subgraph even when both `src` and `dst` are. This prevents case 2.
221                // Handoffs will be inserted later for this self-loop.
222                continue;
223            }
224
225            // Do not connect across loop contexts.
226            if partitioned_graph.node_loop(src) != partitioned_graph.node_loop(dst) {
227                continue;
228            }
229
230            if can_connect_colorize(&mut node_color, src, dst) {
231                // At this point we have selected this edge and its src & dst to be
232                // within a single subgraph.
233                let ok = subgraph_unionfind.try_merge(src, dst);
234                if ok {
235                    assert!(handoff_edges.remove(&edge_id));
236                    progress = true;
237                }
238            }
239        }
240    }
241
242    Ok((subgraph_unionfind, handoff_edges))
243}
244
245/// Find subgraphs and insert handoffs.
246fn make_subgraphs(
247    partitioned_graph: &mut DfirGraph,
248    tick_edges: &mut SecondaryMap<GraphEdgeId, DelayType>,
249    edge_barrier_pairs: &[(GraphNodeId, GraphNodeId)],
250    access_group_pairs: &[(GraphNodeId, GraphNodeId)],
251) -> Result<(), Diagnostic> {
252    let (subgraph_merge, handoff_edges) = find_subgraph_unionfind(
253        partitioned_graph,
254        tick_edges,
255        edge_barrier_pairs,
256        access_group_pairs,
257    )?;
258
259    // Insert handoffs between subgraphs (or on subgraph self-loop edges)
260    for edge_id in handoff_edges {
261        let (src_id, dst_id) = partitioned_graph.edge(edge_id);
262
263        // Already has a handoff, no need to insert one.
264        let src_node = partitioned_graph.node(src_id);
265        let dst_node = partitioned_graph.node(dst_id);
266        if matches!(src_node, GraphNode::Handoff { .. })
267            || matches!(dst_node, GraphNode::Handoff { .. })
268        {
269            continue;
270        }
271
272        let hoff = GraphNode::Handoff {
273            kind: HandoffKind::Vec,
274            src_span: src_node.span(),
275            dst_span: dst_node.span(),
276        };
277        let (_node_id, out_edge_id) = partitioned_graph.insert_intermediate_node(edge_id, hoff);
278
279        // Update tick_edges for inserted node.
280        if let Some(delay_type) = tick_edges.remove(edge_id) {
281            tick_edges.insert(out_edge_id, delay_type);
282        }
283    }
284
285    // Register subgraphs. `SubgraphMerge` maintains operators in topo-sorted order per subgraph.
286    // Filter out handoff nodes — they are not part of any subgraph.
287    // Collect subgraph IDs in flat topological order.
288    let mut flat_toposort = Vec::new();
289    for nodes in subgraph_merge.subgraphs() {
290        if nodes.is_empty() {
291            continue;
292        }
293        // Skip single-node "subgraphs" that are handoff nodes.
294        if nodes
295            .iter()
296            .any(|&n| matches!(partitioned_graph.node(n), GraphNode::Handoff { .. }))
297        {
298            continue;
299        }
300        let sg_id = partitioned_graph.insert_subgraph(nodes.to_vec()).unwrap();
301        flat_toposort.push(sg_id);
302    }
303
304    // Rearrange the flat toposort to make loop subgraphs contiguous.
305    let subgraph_toposort = make_loops_contiguous(partitioned_graph, &flat_toposort);
306
307    // Defensive check: the toposort must still be valid after making loops contiguous.
308    // A violation here indicates a bug in the ordering/contiguity logic.
309    assert!(
310        validate_topo_sort(
311            subgraph_toposort
312                .iter()
313                .flat_map(|&sg_id| partitioned_graph.subgraph(sg_id))
314                .copied(),
315            |succ_id| {
316                partitioned_graph
317                    .node_predecessors(succ_id)
318                    .filter_map(|(edge_id, pred_id)| {
319                        (!tick_edges.contains_key(edge_id)).then_some(pred_id)
320                    })
321                    .map(|pred_id| {
322                        // Jump over handoff nodes.
323                        if matches!(partitioned_graph.node(pred_id), GraphNode::Handoff { .. }) {
324                            partitioned_graph
325                                .node_predecessor_nodes(pred_id)
326                                .next()
327                                .unwrap()
328                        } else {
329                            pred_id
330                        }
331                    })
332            }
333        )
334        .is_ok(),
335        "bug: toposort is invalid after `make_loops_contiguous`.",
336    );
337
338    partitioned_graph.set_subgraph_toposort(subgraph_toposort);
339
340    Ok(())
341}
342
343/// Rearranges a flat topological order of subgraphs so that all subgraphs within a loop are contiguous, while
344/// preserving the relative topological order.
345///
346/// PRECONDITION: The first member of a loop MUST COME AFTER all outer subgraphs which send into the loop. Subgraphs
347/// from different loop contexts that appear interleaved in the flat order have no dependency between them **EXCEPT**
348/// ingress (from outer to inner loop) or egress (from inner to outer). This method hoists all members of a loop to the
349/// **first** subgraph of the loop in the flat order, hence the correctness requirement only on the ingress side.
350///
351/// https://github.com/hydro-project/hydro/issues/3048
352fn make_loops_contiguous(
353    graph: &DfirGraph,
354    flat_order: &[GraphSubgraphId],
355) -> Vec<GraphSubgraphId> {
356    use std::collections::HashMap;
357
358    // Pre-compute: for each loop, collect *all* descendant subgraphs in flat-order (not just direct).
359    // Each sg_id is inserted into every ancestor loop.
360    let mut loop_descendants = HashMap::<GraphLoopId, Vec<GraphSubgraphId>>::new();
361    for &sg_id in flat_order {
362        let mut current = graph.subgraph_loop(sg_id);
363        while let Some(loop_id) = current {
364            loop_descendants.entry(loop_id).or_default().push(sg_id);
365            current = graph.loop_parent(loop_id);
366        }
367    }
368
369    fn helper(
370        graph: &DfirGraph,
371        flat_order: &[GraphSubgraphId],
372        current_loop: Option<GraphLoopId>,
373        loop_descendants: &mut HashMap<GraphLoopId, Vec<GraphSubgraphId>>,
374        output: &mut Vec<GraphSubgraphId>,
375    ) {
376        for &sg_id in flat_order {
377            let sg_loop = graph.subgraph_loop(sg_id);
378            if current_loop == sg_loop {
379                // Directly at this level — emit in place.
380                output.push(sg_id);
381                continue;
382            }
383            let sg_loop = sg_loop.expect("root-level subgraph cannot be within a loop: `sg_loop == None` implies `current_loop == None`");
384            if current_loop == graph.loop_parent(sg_loop) {
385                // In a direct child loop of current_loop, recurse if we haven't yet (tracked by `loop_descendant`
386                // entry removal).
387                if let Some(inner_order) = loop_descendants.remove(&sg_loop) {
388                    helper(graph, &inner_order, Some(sg_loop), loop_descendants, output);
389                }
390            }
391            // else: Deeper nested — skip, will be handled by recursion into its ancestor.
392        }
393    }
394
395    let mut output = Vec::with_capacity(flat_order.len());
396    helper(graph, flat_order, None, &mut loop_descendants, &mut output);
397    output
398}
399
400/// Set `src` or `dst` color if `None` based on the other (if possible):
401/// `None` indicates an op could be pull or push i.e. unary-in & unary-out.
402/// So in that case we color `src` or `dst` based on its newfound neighbor (the other one).
403///
404/// Returns if `src` and `dst` can be in the same subgraph.
405fn can_connect_colorize(
406    node_color: &mut SparseSecondaryMap<GraphNodeId, Color>,
407    src: GraphNodeId,
408    dst: GraphNodeId,
409) -> bool {
410    // Pull -> Pull
411    // Push -> Push
412    // Pull -> [Computation] -> Push
413    // Push -> [Handoff] -> Pull
414    let can_connect = match (node_color.get(src), node_color.get(dst)) {
415        // Linear chain, can't connect because it may cause future conflicts.
416        // But if it doesn't in the _future_ we can connect it (once either/both ends are determined).
417        (None, None) => false,
418
419        // Infer left side.
420        (None, Some(Color::Pull | Color::Comp)) => {
421            node_color.insert(src, Color::Pull);
422            true
423        }
424        (None, Some(Color::Push | Color::Hoff)) => {
425            node_color.insert(src, Color::Push);
426            true
427        }
428
429        // Infer right side.
430        (Some(Color::Pull | Color::Hoff), None) => {
431            node_color.insert(dst, Color::Pull);
432            true
433        }
434        (Some(Color::Comp | Color::Push), None) => {
435            node_color.insert(dst, Color::Push);
436            true
437        }
438
439        // Both sides already specified.
440        (Some(Color::Pull), Some(Color::Pull)) => true,
441        (Some(Color::Pull), Some(Color::Comp)) => true,
442        (Some(Color::Pull), Some(Color::Push)) => true,
443
444        (Some(Color::Comp), Some(Color::Pull)) => false,
445        (Some(Color::Comp), Some(Color::Comp)) => false,
446        (Some(Color::Comp), Some(Color::Push)) => true,
447
448        (Some(Color::Push), Some(Color::Pull)) => false,
449        (Some(Color::Push), Some(Color::Comp)) => false,
450        (Some(Color::Push), Some(Color::Push)) => true,
451
452        // Handoffs are not part of subgraphs.
453        (Some(Color::Hoff), Some(_)) => false,
454        (Some(_), Some(Color::Hoff)) => false,
455    };
456    can_connect
457}
458
459/// Marks tick-boundary (`defer_tick` / `defer_tick_lazy`) handoffs with their delay type
460/// for double-buffered codegen in `as_code`.
461///
462/// Also remaps `DelayType::Tick` → `DelayType::Loop` (and `TickLazy` → `LoopLazy`) for
463/// handoffs whose consumer is inside a nested loop. This allows a single `defer_tick()`
464/// operator to work in both tick-level and loop-iteration contexts.
465fn mark_tick_boundary_handoffs(
466    partitioned_graph: &mut DfirGraph,
467    tick_edges: &SecondaryMap<GraphEdgeId, DelayType>,
468) {
469    let tick_handoffs: Vec<_> = partitioned_graph
470        .nodes()
471        .filter_map(|(hoff_id, hoff)| {
472            if !matches!(hoff, GraphNode::Handoff { .. }) {
473                return None;
474            }
475            if partitioned_graph.node_degree_out(hoff_id) == 0 {
476                return None;
477            }
478            let (succ_edge, _) = partitioned_graph.node_successors(hoff_id).next().unwrap();
479            let &delay_type = tick_edges.get(succ_edge)?;
480
481            // Remap Tick/TickLazy → Loop/LoopLazy if the consumer is in a nested loop.
482            let consumer_loop = partitioned_graph
483                .node_successors(hoff_id)
484                .next()
485                .and_then(|(_, succ)| partitioned_graph.node_loop(succ));
486            let effective_delay_type = if let Some(loop_id) = consumer_loop {
487                if partitioned_graph.loop_parent(loop_id).is_some() {
488                    // Nested loop: remap to loop-level delay.
489                    match delay_type {
490                        DelayType::Tick => DelayType::Loop,
491                        DelayType::TickLazy => DelayType::LoopLazy,
492                        other => other,
493                    }
494                } else {
495                    delay_type
496                }
497            } else {
498                delay_type
499            };
500
501            Some((hoff_id, effective_delay_type))
502        })
503        .collect();
504
505    for (hoff_id, delay_type) in tick_handoffs {
506        partitioned_graph.set_handoff_delay_type(hoff_id, delay_type);
507    }
508}
509
510/// Main method for this module. Partitions a flat [`DfirGraph`] into one with subgraphs.
511///
512/// Returns an error if an intra-tick cycle exists in the graph.
513pub fn partition_graph(flat_graph: DfirGraph) -> Result<DfirGraph, Diagnostic> {
514    let (mut tick_edges, edge_barrier_pairs) = find_edge_barriers(&flat_graph);
515    let access_group_pairs = find_access_group_ordering(&flat_graph);
516    let mut partitioned_graph = flat_graph;
517
518    // Partition into subgraphs and insert handoffs.
519    make_subgraphs(
520        &mut partitioned_graph,
521        &mut tick_edges,
522        &edge_barrier_pairs,
523        &access_group_pairs,
524    )?;
525
526    // Mark tick-boundary handoffs for double-buffering.
527    mark_tick_boundary_handoffs(&mut partitioned_graph, &tick_edges);
528
529    Ok(partitioned_graph)
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::graph::GraphNode;
536
537    /// Regression test for a loop-contiguity toposort bug.
538    /// https://github.com/hydro-project/hydro/issues/3048
539    ///
540    /// [`make_loops_contiguous`] gathers every subgraph of a loop at the position of the
541    /// loop's *first* subgraph in the flat order. If an external subgraph that *feeds into*
542    /// the loop (loop ingress) happens to sit — in the flat topological order — after the
543    /// loop's first subgraph but before a later loop subgraph, then gathering the loop
544    /// pulls that later (ingress-receiving) subgraph *ahead* of its producer.
545    ///
546    /// Concretely, the flat order `[S1, loopA, S2, loopB]` (valid, since `S2 -> loopB`)
547    /// would become `[S1, loopA, loopB, S2]`, so the loop-ingress *receiver* `loopB` would
548    /// be emitted before its *sender* `S2`. Downstream this produces a handoff-buffer "use
549    /// before declaration" compile error (and, logically, a drained-but-never-filled
550    /// buffer).
551    ///
552    /// The fix adds loop-ingress ordering constraints so every ingress sender precedes the
553    /// entire loop block, keeping gather-at-first sound.
554    #[test]
555    fn test_make_loops_contiguous_ingress_toposort() {
556        use crate::graph::{FlatGraphBuilder, FlatGraphBuilderOutput};
557
558        let mut builder = FlatGraphBuilder::new();
559        // External source #1 (feeds the loop's first ingress).
560        builder.add_dfir(
561            syn::parse_quote! {
562                inp1 = source_iter([1, 2, 3]);
563            },
564            None,
565            None,
566        );
567        let loop_id = builder.insert_loop(None);
568        // Loop ingress A — independent of `inp2`, so the flat toposort can place it before
569        // `inp2`.
570        builder.add_dfir(
571            syn::parse_quote! {
572                inp1 -> batch() -> for_each(std::mem::drop);
573            },
574            Some(loop_id),
575            None,
576        );
577        // External source #2 — the loop-ingress "sender", defined after loop ingress A so
578        // the flat toposort interleaves it: `[inp1, loopA, inp2, loopB]`.
579        builder.add_dfir(
580            syn::parse_quote! {
581                inp2 = source_iter([4, 5, 6]);
582            },
583            None,
584            None,
585        );
586        // Loop ingress B — the "receiver" of `inp2`.
587        builder.add_dfir(
588            syn::parse_quote! {
589                inp2 -> batch() -> for_each(std::mem::drop);
590            },
591            Some(loop_id),
592            None,
593        );
594
595        let FlatGraphBuilderOutput { flat_graph, .. } =
596            builder.build().expect("should build without errors");
597        let partitioned = partition_graph(flat_graph).expect("should partition without errors");
598
599        // Verify: every forward (non-delay) handoff has its producer subgraph *before* its
600        // consumer subgraph in the final `subgraph_toposort`.
601        let order = partitioned.subgraph_toposort();
602        let pos: std::collections::HashMap<_, _> =
603            order.iter().enumerate().map(|(i, &sg)| (sg, i)).collect();
604
605        for (hoff_id, node) in partitioned.nodes() {
606            if !matches!(node, GraphNode::Handoff { .. }) {
607                continue;
608            }
609            // Skip delay (back-edge) handoffs, which are allowed to point "backwards".
610            if partitioned.handoff_delay_type(hoff_id).is_some() {
611                continue;
612            }
613            let Some((_, pred)) = partitioned.node_predecessors(hoff_id).next() else {
614                continue;
615            };
616            let Some((_, succ)) = partitioned.node_successors(hoff_id).next() else {
617                continue;
618            };
619            let (Some(pred_sg), Some(succ_sg)) = (
620                partitioned.node_subgraph(pred),
621                partitioned.node_subgraph(succ),
622            ) else {
623                continue;
624            };
625            let (Some(&pp), Some(&sp)) = (pos.get(&pred_sg), pos.get(&succ_sg)) else {
626                continue;
627            };
628            assert!(
629                pp < sp,
630                "toposort violated: producer subgraph {pred_sg:?} (pos {pp}) emitted after \
631                 consumer subgraph {succ_sg:?} (pos {sp}) via handoff {hoff_id:?}; order = {order:?}",
632            );
633        }
634    }
635
636    fn make_op_node(graph: &mut DfirGraph, loop_ctx: Option<GraphLoopId>) -> GraphNodeId {
637        let operator: crate::parse::Operator = syn::parse_quote! { identity() };
638        graph.insert_node(GraphNode::Operator(operator), None, loop_ctx)
639    }
640
641    /// Test that subgraphs within a loop are contiguous after rearrangement,
642    /// even when the flat input order interleaves them with unrelated subgraphs.
643    #[test]
644    fn test_make_loops_contiguous_basic() {
645        let mut graph = DfirGraph::default();
646
647        // Create a loop.
648        let loop_id = graph.insert_loop(None);
649
650        // Create operator nodes: two outside the loop, two inside.
651        let op_a = make_op_node(&mut graph, None);
652        let op_b = make_op_node(&mut graph, None);
653        let op_c = make_op_node(&mut graph, Some(loop_id));
654        let op_d = make_op_node(&mut graph, Some(loop_id));
655
656        // Create subgraphs (one per operator node).
657        let sg_a = graph.insert_subgraph(vec![op_a]).unwrap();
658        let sg_b = graph.insert_subgraph(vec![op_b]).unwrap();
659        let sg_c = graph.insert_subgraph(vec![op_c]).unwrap();
660        let sg_d = graph.insert_subgraph(vec![op_d]).unwrap();
661
662        // Flat order: A, C, B, D — the loop subgraphs (C, D) are not contiguous.
663        let flat_order = vec![sg_a, sg_c, sg_b, sg_d];
664        let result = make_loops_contiguous(&graph, &flat_order);
665
666        // After rearrangement: A, C, D, B — loop subgraphs are now contiguous.
667        assert_eq!(result, vec![sg_a, sg_c, sg_d, sg_b]);
668    }
669
670    /// Test that two independent sibling loops each have their subgraphs contiguous.
671    #[test]
672    fn test_make_loops_contiguous_independent_loops() {
673        let mut graph = DfirGraph::default();
674
675        let loop1 = graph.insert_loop(None);
676        let loop2 = graph.insert_loop(None);
677
678        // Loop1: C, D. Loop2: E, F.
679        let op_c = make_op_node(&mut graph, Some(loop1));
680        let op_d = make_op_node(&mut graph, Some(loop1));
681        let op_e = make_op_node(&mut graph, Some(loop2));
682        let op_f = make_op_node(&mut graph, Some(loop2));
683
684        let sg_c = graph.insert_subgraph(vec![op_c]).unwrap();
685        let sg_d = graph.insert_subgraph(vec![op_d]).unwrap();
686        let sg_e = graph.insert_subgraph(vec![op_e]).unwrap();
687        let sg_f = graph.insert_subgraph(vec![op_f]).unwrap();
688
689        // Flat order interleaves the two loops: C, E, D, F.
690        let flat_order = vec![sg_c, sg_e, sg_d, sg_f];
691        let result = make_loops_contiguous(&graph, &flat_order);
692
693        // Both loops should be contiguous. Loop1 appears first (C seen first).
694        let pos_c = result.iter().position(|&s| s == sg_c).unwrap();
695        let pos_d = result.iter().position(|&s| s == sg_d).unwrap();
696        let pos_e = result.iter().position(|&s| s == sg_e).unwrap();
697        let pos_f = result.iter().position(|&s| s == sg_f).unwrap();
698
699        // C before D, E before F (relative order preserved).
700        assert!(pos_c < pos_d);
701        assert!(pos_e < pos_f);
702
703        // Contiguity: C and D are adjacent, E and F are adjacent.
704        assert_eq!(pos_d - pos_c, 1, "Loop1 subgraphs must be contiguous");
705        assert_eq!(pos_f - pos_e, 1, "Loop2 subgraphs must be contiguous");
706    }
707
708    /// Test nested loops: inner loop subgraphs are contiguous within the outer loop block.
709    #[test]
710    fn test_make_loops_contiguous_nested() {
711        let mut graph = DfirGraph::default();
712
713        let outer = graph.insert_loop(None);
714        let inner = graph.insert_loop(Some(outer));
715
716        // Outer loop has: op_a, then inner loop (op_b, op_c), then op_d.
717        let op_a = make_op_node(&mut graph, Some(outer));
718        let op_b = make_op_node(&mut graph, Some(inner));
719        let op_c = make_op_node(&mut graph, Some(inner));
720        let op_d = make_op_node(&mut graph, Some(outer));
721
722        let sg_a = graph.insert_subgraph(vec![op_a]).unwrap();
723        let sg_b = graph.insert_subgraph(vec![op_b]).unwrap();
724        let sg_c = graph.insert_subgraph(vec![op_c]).unwrap();
725        let sg_d = graph.insert_subgraph(vec![op_d]).unwrap();
726
727        // Flat order: A, B, C, D (already valid).
728        let flat_order = vec![sg_a, sg_b, sg_c, sg_d];
729        let result = make_loops_contiguous(&graph, &flat_order);
730
731        // Expected: A, B, C, D (all contiguous within outer, B/C contiguous within inner).
732        assert_eq!(result, vec![sg_a, sg_b, sg_c, sg_d]);
733    }
734
735    /// Test nested loops with interleaving: outer-level subgraph between inner loop items.
736    #[test]
737    fn test_make_loops_contiguous_nested_interleaved() {
738        let mut graph = DfirGraph::default();
739
740        let outer = graph.insert_loop(None);
741        let inner = graph.insert_loop(Some(outer));
742
743        let op_a = make_op_node(&mut graph, Some(outer));
744        let op_b = make_op_node(&mut graph, Some(inner));
745        let op_c = make_op_node(&mut graph, Some(inner));
746        let op_d = make_op_node(&mut graph, Some(outer));
747
748        let sg_a = graph.insert_subgraph(vec![op_a]).unwrap();
749        let sg_b = graph.insert_subgraph(vec![op_b]).unwrap();
750        let sg_c = graph.insert_subgraph(vec![op_c]).unwrap();
751        let sg_d = graph.insert_subgraph(vec![op_d]).unwrap();
752
753        // Flat order: A, B, D, C — D (outer) is between B and C (inner).
754        let flat_order = vec![sg_a, sg_b, sg_d, sg_c];
755        let result = make_loops_contiguous(&graph, &flat_order);
756
757        // After rearrangement within outer: A, B, C, D — inner loop (B, C) contiguous.
758        assert_eq!(result, vec![sg_a, sg_b, sg_c, sg_d]);
759    }
760}