Skip to main content

vyre_primitives/graph/
toposort.rs

1//! Kahn-style topological sort with LIFO worklist  -  CPU reference +
2//! single-invocation GPU `Program` builder.
3//!
4//! Consumed by optimizer reaching-defs, dominator-tree, and graph-IR analyses
5//! that need a DAG walk.
6//!
7//! AUDIT_2026-04-24 F-TS-04: `toposort_program` emits a single-invocation
8//! Program that runs Kahn's algorithm serially on lane 0. The serial
9//! lane-0 builder is the current Tier-2.5 contract because topological
10//! ordering has a loop-carried dependency; callers that need large-DAG
11//! throughput compose this with graph partitioning or SCC batching.
12//!
13//! AUDIT_2026-04-24 F-TS-02: the classical Kahn presentation uses a
14//! FIFO queue (BFS-ish). This module uses a stack (LIFO) via
15//! `Vec::pop` because it is O(1), has better cache locality on the
16//! worklist, and produces an equally valid topological order  -  both
17//! orderings satisfy the Kahn invariant (a node is emitted only
18//! after all its prerequisites). If a caller needs stable BFS order
19//! for deterministic diffs, swap in a `VecDeque` worklist; the
20//! correctness of the sort does not depend on the worklist policy.
21
22use std::sync::Arc;
23
24use vyre_foundation::ir::model::expr::Ident;
25use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
26
27/// Canonical op id.
28pub const OP_ID: &str = "vyre-primitives::graph::toposort";
29/// Canonical dispatch input label for CSR offsets.
30pub const TOPOSORT_OFFSETS_BUFFER: &str = "toposort offsets";
31/// Canonical dispatch input label for CSR targets.
32pub const TOPOSORT_TARGETS_BUFFER: &str = "toposort targets";
33/// Canonical dispatch scratch label for indegrees.
34pub const TOPOSORT_INDEGREE_SCRATCH_BUFFER: &str = "toposort indeg_scratch";
35/// Canonical dispatch scratch label for the work queue.
36pub const TOPOSORT_QUEUE_SCRATCH_BUFFER: &str = "toposort queue_scratch";
37/// Canonical dispatch output label for the emitted order.
38pub const TOPOSORT_ORDER_OUT_BUFFER: &str = "toposort order_out";
39/// Single-lane Kahn dispatch grid.
40pub const TOPOSORT_DISPATCH_GRID: [u32; 3] = [1, 1, 1];
41
42/// Errors from topological sorting.
43#[derive(Debug, Clone, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum ToposortError {
46    /// The input graph contains a cycle  -  returned with the first
47    /// node id that participates in the cycle, for diagnostic use.
48    Cycle {
49        /// One node id on the cycle. Callers can walk the adjacency
50        /// list from here to enumerate the full cycle.
51        node: u32,
52    },
53    /// An edge references a node id not present in `node_count`.
54    UnknownNode {
55        /// Offending edge index.
56        edge: usize,
57        /// The out-of-range node id that tripped the check.
58        node: u32,
59    },
60    /// A node's dependency count exceeded the `u32` counter used by the
61    /// compact scheduler representation.
62    IndegreeOverflow {
63        /// Node whose dependency count overflowed.
64        node: u32,
65    },
66    /// Kahn's invariant was violated after input validation, indicating
67    /// inconsistent derived adjacency state.
68    InconsistentState {
69        /// Actionable diagnostic.
70        message: String,
71    },
72}
73
74/// Errors from CSR topological-sort shape or order validation.
75#[derive(Debug, Clone, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum ToposortCsrError {
78    /// CSR row pointers or targets are malformed for the declared node count.
79    BadCsr {
80        /// Actionable diagnostic.
81        message: String,
82    },
83    /// The supplied topological order is not a full valid permutation.
84    BadOrder {
85        /// Actionable diagnostic.
86        message: String,
87    },
88}
89
90/// Validated dispatch layout for primitive-native CSR topological sorting.
91///
92/// The primitive owns these derived counts so dispatch wrappers do not fork CSR
93/// offset or node scratch sizing rules.
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub struct ToposortCsrLayout {
96    /// Number of nodes accepted by the primitive.
97    pub node_count: u32,
98    /// Number of words required by node-indexed scratch and output buffers.
99    pub node_words: usize,
100    /// Number of words required by the CSR offsets buffer.
101    pub offset_words: usize,
102    /// Number of words required by the CSR targets buffer.
103    pub target_words: usize,
104}
105
106/// Primitive-owned dispatch plan for CSR topological sort.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct ToposortCsrDispatchPlan {
109    /// Validated CSR layout.
110    pub layout: ToposortCsrLayout,
111    /// Dispatch grid override.
112    pub grid: [u32; 3],
113    /// Words in the offsets input buffer.
114    pub offset_words: usize,
115    /// Words in the targets input buffer.
116    pub target_words: usize,
117    /// Words in each node-indexed scratch/output buffer.
118    pub node_words: usize,
119}
120
121/// Primitive-owned identity for reusable topological-sort static inputs.
122///
123/// Dispatch wrappers use this key to decide whether the CSR graph inputs can
124/// remain resident across calls. Keeping it in the primitive prevents each
125/// wrapper from inventing a private fingerprint contract for the same graph
126/// representation.
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub struct ToposortCsrStaticInputKey {
129    /// Number of nodes in the CSR graph.
130    pub node_count: u32,
131    /// Words in node-indexed scratch/output buffers.
132    pub node_words: usize,
133    /// Words in the CSR offsets buffer.
134    pub offset_words: usize,
135    /// Words in the CSR targets buffer.
136    pub target_words: usize,
137    /// Stable content fingerprint for CSR offsets.
138    pub offsets_hash: u64,
139    /// Stable content fingerprint for CSR targets.
140    pub targets_hash: u64,
141}
142
143impl ToposortCsrDispatchPlan {
144    /// Build the single-lane topological-sort program for this plan.
145    #[must_use]
146    pub fn program(&self) -> Program {
147        toposort_program(
148            self.layout.node_count,
149            TOPOSORT_OFFSETS_BUFFER,
150            TOPOSORT_TARGETS_BUFFER,
151            TOPOSORT_INDEGREE_SCRATCH_BUFFER,
152            TOPOSORT_QUEUE_SCRATCH_BUFFER,
153            TOPOSORT_ORDER_OUT_BUFFER,
154        )
155    }
156
157    /// Return the primitive-owned cache identity for this plan's static graph inputs.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`ToposortCsrError::BadCsr`] if the supplied CSR slices no
162    /// longer match the validated dispatch plan shape.
163    pub fn static_input_key(
164        &self,
165        offsets: &[u32],
166        targets: &[u32],
167    ) -> Result<ToposortCsrStaticInputKey, ToposortCsrError> {
168        if offsets.len() != self.offset_words {
169            return Err(ToposortCsrError::BadCsr {
170                message: format!(
171                    "Fix: toposort_csr static key expected {} offset words, got {}.",
172                    self.offset_words,
173                    offsets.len()
174                ),
175            });
176        }
177        if targets.len() != self.target_words {
178            return Err(ToposortCsrError::BadCsr {
179                message: format!(
180                    "Fix: toposort_csr static key expected {} target words, got {}.",
181                    self.target_words,
182                    targets.len()
183                ),
184            });
185        }
186        Ok(ToposortCsrStaticInputKey {
187            node_count: self.layout.node_count,
188            node_words: self.node_words,
189            offset_words: self.offset_words,
190            target_words: self.target_words,
191            offsets_hash: toposort_csr_slice_fingerprint(offsets),
192            targets_hash: toposort_csr_slice_fingerprint(targets),
193        })
194    }
195}
196
197/// Stable primitive-owned fingerprint for CSR topological-sort u32 slices.
198#[must_use]
199pub fn toposort_csr_slice_fingerprint(values: &[u32]) -> u64 {
200    super::u32_slice_fingerprint(values)
201}
202
203/// CPU reference over the primitive-native CSR adjacency shape.
204///
205/// `offsets` has `node_count + 1` entries and `targets` stores outgoing
206/// edges from each prerequisite node to its dependent nodes. The returned
207/// order is valid iff every prerequisite appears before every dependent.
208///
209/// # Errors
210///
211/// Returns [`ToposortCsrError::BadCsr`] when the CSR shape is malformed and
212/// [`ToposortCsrError::BadOrder`] only if derived state violates the
213/// topological-order contract after input validation.
214pub fn toposort_csr(
215    node_count: u32,
216    offsets: &[u32],
217    targets: &[u32],
218) -> Result<Vec<u32>, ToposortCsrError> {
219    let mut order = Vec::new();
220    toposort_csr_into(node_count, offsets, targets, &mut order)?;
221    Ok(order)
222}
223
224/// Caller-owned workspace for repeated CSR topological-sort CPU oracle runs.
225///
226/// The CPU oracle is used heavily by conformance and CUDA parity paths. Keeping
227/// indegree and queue storage outside the call lets proof runners amortize heap
228/// growth across thousands of generated graphs without changing the public
229/// allocating convenience API.
230#[derive(Debug, Default, Clone)]
231pub struct ToposortCsrScratch {
232    /// Per-node incoming-edge counts rebuilt for each run.
233    pub indeg: Vec<u32>,
234    /// Zero-indegree work queue consumed by Kahn traversal.
235    pub queue: Vec<u32>,
236}
237
238impl ToposortCsrScratch {
239    /// Create an empty reusable topological-sort workspace.
240    pub fn new() -> Self {
241        Self::default()
242    }
243}
244
245/// CPU reference over primitive-native CSR adjacency, reusing caller storage.
246///
247/// # Errors
248///
249/// Returns [`ToposortCsrError::BadCsr`] when CSR validation fails and
250/// [`ToposortCsrError::BadOrder`] when the derived order violates the
251/// primitive contract.
252pub fn toposort_csr_into(
253    node_count: u32,
254    offsets: &[u32],
255    targets: &[u32],
256    order: &mut Vec<u32>,
257) -> Result<(), ToposortCsrError> {
258    let mut scratch = ToposortCsrScratch::default();
259    toposort_csr_into_with_scratch(node_count, offsets, targets, order, &mut scratch)
260}
261
262/// CPU reference over primitive-native CSR adjacency with caller-owned output
263/// and scratch storage.
264///
265/// # Errors
266///
267/// Returns [`ToposortCsrError::BadCsr`] when CSR validation fails and
268/// [`ToposortCsrError::BadOrder`] when the derived order violates the
269/// primitive contract. Validation happens before any caller-owned storage is
270/// cleared, so rejected inputs do not clobber reusable buffers.
271pub fn toposort_csr_into_with_scratch(
272    node_count: u32,
273    offsets: &[u32],
274    targets: &[u32],
275    order: &mut Vec<u32>,
276    scratch: &mut ToposortCsrScratch,
277) -> Result<(), ToposortCsrError> {
278    let layout = validate_toposort_csr_inputs(node_count, offsets, targets)?;
279    order.clear();
280    scratch.indeg.clear();
281    scratch.queue.clear();
282    if node_count == 0 {
283        return Ok(());
284    }
285
286    let node_words = layout.node_words;
287    crate::graph::scratch::reserve_graph_items_with(
288        &mut scratch.indeg,
289        node_words,
290        "toposort CSR CPU oracle",
291        "toposort_csr indegree scratch",
292        toposort_csr_allocation,
293    )?;
294    scratch.indeg.resize(node_words, 0);
295    for (idx, &target) in targets.iter().enumerate() {
296        scratch.indeg[target as usize] =
297            scratch.indeg[target as usize]
298                .checked_add(1)
299                .ok_or_else(|| ToposortCsrError::BadCsr {
300                    message: format!(
301                    "Fix: toposort_csr target node {target} indegree overflowed at targets[{idx}]."
302                ),
303                })?;
304    }
305
306    crate::graph::scratch::reserve_graph_items_with(
307        &mut scratch.queue,
308        node_words,
309        "toposort CSR CPU oracle",
310        "toposort_csr zero-indegree queue",
311        toposort_csr_allocation,
312    )?;
313    for node in 0..node_count {
314        if scratch.indeg[node as usize] == 0 {
315            scratch.queue.push(node);
316        }
317    }
318    crate::graph::scratch::reserve_graph_items_with(
319        order,
320        node_words,
321        "toposort CSR CPU oracle",
322        "toposort_csr output order",
323        toposort_csr_allocation,
324    )?;
325    while let Some(node) = scratch.queue.pop() {
326        order.push(node);
327        let start = offsets[node as usize] as usize;
328        let end = offsets[node as usize + 1] as usize;
329        for (edge_offset, &dependent) in targets[start..end].iter().enumerate() {
330            let slot = &mut scratch.indeg[dependent as usize];
331            *slot = slot
332                .checked_sub(1)
333                .ok_or_else(|| ToposortCsrError::BadOrder {
334                    message: format!(
335                    "Fix: toposort_csr indegree underflow for edge {} from {node} to {dependent}.",
336                    start + edge_offset
337                ),
338                })?;
339            if *slot == 0 {
340                scratch.queue.push(dependent);
341            }
342        }
343    }
344
345    validate_toposort_csr_order_with_layout(&layout, offsets, targets, order)
346}
347
348/// Validate primitive-native CSR input shape.
349///
350/// # Errors
351///
352/// Returns [`ToposortCsrError::BadCsr`] when offsets are the wrong length, not
353/// monotonic, inconsistent with `targets`, or when a target is out of range.
354pub fn validate_toposort_csr_inputs(
355    node_count: u32,
356    offsets: &[u32],
357    targets: &[u32],
358) -> Result<ToposortCsrLayout, ToposortCsrError> {
359    if node_count == 0 {
360        if offsets != [0] || !targets.is_empty() {
361            return Err(ToposortCsrError::BadCsr {
362                message:
363                    "Fix: toposort_csr zero-node graph requires offsets == [0] and empty targets."
364                        .to_string(),
365            });
366        }
367        return Ok(ToposortCsrLayout {
368            node_count,
369            node_words: 0,
370            offset_words: 1,
371            target_words: 0,
372        });
373    }
374    let expected_offsets =
375        (node_count as usize)
376            .checked_add(1)
377            .ok_or_else(|| ToposortCsrError::BadCsr {
378                message: format!(
379                    "Fix: toposort_csr node_count + 1 overflows usize for node_count={node_count}."
380                ),
381            })?;
382    if offsets.len() != expected_offsets {
383        return Err(ToposortCsrError::BadCsr {
384            message: format!(
385                "Fix: toposort_csr requires offsets.len() == node_count + 1, got len={}, node_count={node_count}.",
386                offsets.len()
387            ),
388        });
389    }
390    if offsets[0] != 0 {
391        return Err(ToposortCsrError::BadCsr {
392            message: format!(
393                "Fix: toposort_csr requires offsets[0] == 0, got {}.",
394                offsets[0]
395            ),
396        });
397    }
398    for (idx, pair) in offsets.windows(2).enumerate() {
399        if pair[0] > pair[1] {
400            return Err(ToposortCsrError::BadCsr {
401                message: format!(
402                    "Fix: toposort_csr offsets must be monotonic, but offsets[{idx}]={} > offsets[{}]={}.",
403                    pair[0],
404                    idx + 1,
405                    pair[1]
406                ),
407            });
408        }
409    }
410    if offsets[node_count as usize] as usize != targets.len() {
411        return Err(ToposortCsrError::BadCsr {
412            message: format!(
413                "Fix: toposort_csr offsets[node_count] must equal targets.len(), got {} vs {}.",
414                offsets[node_count as usize],
415                targets.len()
416            ),
417        });
418    }
419    for (idx, &target) in targets.iter().enumerate() {
420        if target >= node_count {
421            return Err(ToposortCsrError::BadCsr {
422                message: format!(
423                    "Fix: toposort_csr targets[{idx}]={target} is outside node_count={node_count}."
424                ),
425            });
426        }
427    }
428    Ok(ToposortCsrLayout {
429        node_count,
430        node_words: node_count as usize,
431        offset_words: expected_offsets,
432        target_words: targets.len(),
433    })
434}
435
436/// Validate primitive-native CSR inputs and return the full dispatch plan.
437pub fn plan_toposort_csr_dispatch(
438    node_count: u32,
439    offsets: &[u32],
440    targets: &[u32],
441) -> Result<ToposortCsrDispatchPlan, ToposortCsrError> {
442    let layout = validate_toposort_csr_inputs(node_count, offsets, targets)?;
443    Ok(ToposortCsrDispatchPlan {
444        offset_words: layout.offset_words,
445        target_words: layout.target_words,
446        node_words: layout.node_words,
447        layout,
448        grid: TOPOSORT_DISPATCH_GRID,
449    })
450}
451
452#[cfg(test)]
453mod dispatch_plan_tests {
454    use super::*;
455
456    #[test]
457    fn dispatch_plan_owns_scratch_sizes_and_grid() {
458        let plan = plan_toposort_csr_dispatch(3, &[0, 2, 3, 3], &[1, 2, 2])
459            .expect("Fix: valid DAG CSR should plan topological-sort dispatch");
460
461        assert_eq!(plan.grid, TOPOSORT_DISPATCH_GRID);
462        assert_eq!(plan.offset_words, 4);
463        assert_eq!(plan.target_words, 3);
464        assert_eq!(plan.node_words, 3);
465        assert_eq!(plan.layout.node_count, 3);
466    }
467
468    #[test]
469    fn empty_dispatch_plan_is_non_dispatchable_but_well_shaped() {
470        let plan = plan_toposort_csr_dispatch(0, &[0], &[])
471            .expect("Fix: canonical empty CSR should plan without dispatch");
472
473        assert_eq!(plan.grid, TOPOSORT_DISPATCH_GRID);
474        assert_eq!(plan.offset_words, 1);
475        assert_eq!(plan.target_words, 0);
476        assert_eq!(plan.node_words, 0);
477        assert_eq!(plan.layout.node_count, 0);
478    }
479
480    #[test]
481    fn csr_into_emits_order_accepted_by_public_validator() {
482        let offsets = [0, 2, 3, 3];
483        let targets = [1, 2, 2];
484        let mut order = Vec::with_capacity(3);
485
486        toposort_csr_into(3, &offsets, &targets, &mut order)
487            .expect("Fix: valid DAG CSR should topologically sort.");
488
489        validate_toposort_csr_order(3, &offsets, &targets, &order)
490            .expect("Fix: toposort_csr_into output must satisfy the public order validator.");
491        assert_eq!(order.len(), 3);
492    }
493
494    #[test]
495    fn csr_order_validator_rejects_dependency_inversion() {
496        let err = validate_toposort_csr_order(3, &[0, 2, 3, 3], &[1, 2, 2], &[2, 1, 0])
497            .expect_err("Fix: dependency-inverted CSR order must be rejected.");
498
499        assert!(matches!(err, ToposortCsrError::BadOrder { .. }));
500    }
501
502    #[test]
503    fn static_input_key_tracks_content_not_only_shape() {
504        let plan = plan_toposort_csr_dispatch(4, &[0, 2, 3, 3, 3], &[1, 2, 3])
505            .expect("Fix: valid CSR should plan topological-sort dispatch");
506        let first = plan
507            .static_input_key(&[0, 2, 3, 3, 3], &[1, 2, 3])
508            .expect("Fix: static key should accept matching slices");
509        let same = plan
510            .static_input_key(&[0, 2, 3, 3, 3], &[1, 2, 3])
511            .expect("Fix: identical CSR should produce identical key");
512        let changed_targets = plan
513            .static_input_key(&[0, 2, 3, 3, 3], &[2, 3, 3])
514            .expect("Fix: same-shape CSR content change should still key");
515
516        assert_eq!(first, same);
517        assert_eq!(first.node_count, 4);
518        assert_eq!(first.node_words, 4);
519        assert_eq!(first.offset_words, 5);
520        assert_eq!(first.target_words, 3);
521        assert_ne!(first, changed_targets);
522        assert_eq!(first.offsets_hash, changed_targets.offsets_hash);
523        assert_ne!(first.targets_hash, changed_targets.targets_hash);
524    }
525
526    #[test]
527    fn static_input_key_rejects_plan_slice_drift() {
528        let plan = plan_toposort_csr_dispatch(3, &[0, 1, 2, 2], &[1, 2])
529            .expect("Fix: valid CSR should plan topological-sort dispatch");
530
531        let err = plan
532            .static_input_key(&[0, 1, 2, 2], &[1])
533            .expect_err("Fix: stale plan must not accept mismatched target slices");
534
535        assert!(matches!(err, ToposortCsrError::BadCsr { .. }));
536    }
537}
538
539/// Validate that `order` is a full topological permutation for the
540/// primitive-native CSR adjacency shape.
541///
542/// # Errors
543///
544/// Returns [`ToposortCsrError::BadCsr`] for malformed CSR input and
545/// [`ToposortCsrError::BadOrder`] for malformed, partial, duplicate, or
546/// dependency-violating orders.
547pub fn validate_toposort_csr_order(
548    node_count: u32,
549    offsets: &[u32],
550    targets: &[u32],
551    order: &[u32],
552) -> Result<(), ToposortCsrError> {
553    let layout = validate_toposort_csr_inputs(node_count, offsets, targets)?;
554    validate_toposort_csr_order_with_layout(&layout, offsets, targets, order)
555}
556
557fn validate_toposort_csr_order_with_layout(
558    layout: &ToposortCsrLayout,
559    offsets: &[u32],
560    targets: &[u32],
561    order: &[u32],
562) -> Result<(), ToposortCsrError> {
563    let node_count = layout.node_count;
564    if order.len() != node_count as usize {
565        return Err(ToposortCsrError::BadOrder {
566            message: format!(
567                "Fix: toposort_csr expected {} order entries, got {}.",
568                node_count,
569                order.len()
570            ),
571        });
572    }
573    let mut pos: Vec<usize> = Vec::new();
574    crate::graph::scratch::reserve_graph_items_with(
575        &mut pos,
576        layout.node_words,
577        "toposort CSR CPU oracle",
578        "toposort_csr order positions",
579        toposort_csr_allocation,
580    )?;
581    pos.resize(layout.node_words, usize::MAX);
582    for (idx, &node) in order.iter().enumerate() {
583        if node >= node_count {
584            return Err(ToposortCsrError::BadOrder {
585                message: format!(
586                    "Fix: toposort_csr order[{idx}]={node} is outside node_count={node_count}."
587                ),
588            });
589        }
590        let slot = &mut pos[node as usize];
591        if *slot != usize::MAX {
592            return Err(ToposortCsrError::BadOrder {
593                message: format!(
594                    "Fix: toposort_csr order contains duplicate node {node}; graph may be cyclic or backend output is malformed."
595                ),
596            });
597        }
598        *slot = idx;
599    }
600    if let Some((missing, _)) = pos
601        .iter()
602        .enumerate()
603        .find(|(_, position)| **position == usize::MAX)
604    {
605        return Err(ToposortCsrError::BadOrder {
606            message: format!(
607                "Fix: toposort_csr order omitted node {missing}; graph may be cyclic."
608            ),
609        });
610    }
611
612    for prereq in 0..node_count {
613        let start = offsets[prereq as usize] as usize;
614        let end = offsets[prereq as usize + 1] as usize;
615        for &dependent in &targets[start..end] {
616            if pos[prereq as usize] >= pos[dependent as usize] {
617                return Err(ToposortCsrError::BadOrder {
618                    message: format!(
619                        "Fix: toposort_csr order violates dependency edge {prereq}->{dependent}; prerequisite position {} must be before dependent position {}.",
620                        pos[prereq as usize],
621                        pos[dependent as usize]
622                    ),
623                });
624            }
625        }
626    }
627    Ok(())
628}
629
630/// CPU reference: Kahn's algorithm over `(node_count, edges)`.
631///
632/// `edges` is a slice of `(from, to)` u32 pairs  -  `from` depends on
633/// `to`, so `to` comes first in the sort. Returns a `Vec<u32>` in
634/// topological order on success, or `ToposortError::Cycle` if the
635/// graph has a cycle.
636///
637/// # Errors
638///
639/// Returns `ToposortError::Cycle` when the input has a cycle, or
640/// `ToposortError::UnknownNode` when an edge names a node id
641/// outside `0..node_count`.
642pub fn toposort(node_count: u32, edges: &[(u32, u32)]) -> Result<Vec<u32>, ToposortError> {
643    const NONE: usize = usize::MAX;
644
645    validate_toposort_edge_ids(node_count, edges)?;
646
647    let n = node_count as usize;
648    let mut indeg: Vec<u32> = Vec::new();
649    crate::graph::scratch::reserve_graph_items_with(
650        &mut indeg,
651        n,
652        "toposort CPU oracle",
653        "toposort indegree scratch",
654        toposort_allocation,
655    )?;
656    indeg.resize(n, 0);
657    let mut outgoing_head: Vec<usize> = Vec::new();
658    crate::graph::scratch::reserve_graph_items_with(
659        &mut outgoing_head,
660        n,
661        "toposort CPU oracle",
662        "toposort outgoing heads",
663        toposort_allocation,
664    )?;
665    outgoing_head.resize(n, NONE);
666    let mut outgoing_to: Vec<u32> = Vec::new();
667    crate::graph::scratch::reserve_graph_items_with(
668        &mut outgoing_to,
669        edges.len(),
670        "toposort CPU oracle",
671        "toposort outgoing targets",
672        toposort_allocation,
673    )?;
674    let mut outgoing_next: Vec<usize> = Vec::new();
675    crate::graph::scratch::reserve_graph_items_with(
676        &mut outgoing_next,
677        edges.len(),
678        "toposort CPU oracle",
679        "toposort outgoing links",
680        toposort_allocation,
681    )?;
682    let mut depends_head: Vec<usize> = Vec::new();
683    crate::graph::scratch::reserve_graph_items_with(
684        &mut depends_head,
685        n,
686        "toposort CPU oracle",
687        "toposort dependency heads",
688        toposort_allocation,
689    )?;
690    depends_head.resize(n, NONE);
691    let mut depends_to: Vec<u32> = Vec::new();
692    crate::graph::scratch::reserve_graph_items_with(
693        &mut depends_to,
694        edges.len(),
695        "toposort CPU oracle",
696        "toposort dependency targets",
697        toposort_allocation,
698    )?;
699    let mut depends_next: Vec<usize> = Vec::new();
700    crate::graph::scratch::reserve_graph_items_with(
701        &mut depends_next,
702        edges.len(),
703        "toposort CPU oracle",
704        "toposort dependency links",
705        toposort_allocation,
706    )?;
707
708    for &(from, to) in edges {
709        let outgoing_idx = outgoing_to.len();
710        outgoing_to.push(from);
711        outgoing_next.push(outgoing_head[to as usize]);
712        outgoing_head[to as usize] = outgoing_idx;
713
714        let depends_idx = depends_to.len();
715        depends_to.push(to);
716        depends_next.push(depends_head[from as usize]);
717        depends_head[from as usize] = depends_idx;
718
719        indeg[from as usize] = indeg[from as usize]
720            .checked_add(1)
721            .ok_or(ToposortError::IndegreeOverflow { node: from })?;
722    }
723
724    let mut queue: Vec<u32> = Vec::new();
725    crate::graph::scratch::reserve_graph_items_with(
726        &mut queue,
727        n,
728        "toposort CPU oracle",
729        "toposort zero-indegree queue",
730        toposort_allocation,
731    )?;
732    for v in 0..node_count {
733        if indeg[v as usize] == 0 {
734            queue.push(v);
735        }
736    }
737    let mut out: Vec<u32> = Vec::new();
738    crate::graph::scratch::reserve_graph_items_with(
739        &mut out,
740        n,
741        "toposort CPU oracle",
742        "toposort output order",
743        toposort_allocation,
744    )?;
745
746    while let Some(&v) = queue.last() {
747        queue.pop();
748        out.push(v);
749        let mut edge = outgoing_head[v as usize];
750        while edge != NONE {
751            let u = outgoing_to[edge];
752            let slot = &mut indeg[u as usize];
753            *slot = slot.checked_sub(1).ok_or_else(|| {
754                ToposortError::InconsistentState {
755                    message: format!(
756                        "toposort indegree underflow for node {u}. Fix: rebuild dependency edges before scheduling."
757                    ),
758                }
759            })?;
760            if *slot == 0 {
761                queue.push(u);
762            }
763            edge = outgoing_next[edge];
764        }
765    }
766
767    if out.len() != n {
768        // AUDIT_2026-04-24 F-TS-03: returning the first node with
769        // indeg > 0 is misleading  -  that node may be *downstream* of
770        // a cycle (its predecessor is stuck, not itself). Instead,
771        // walk outgoing "depends on" edges from any unemitted node
772        // until we revisit a node already on the walk  -  that revisit
773        // point is guaranteed to lie on the cycle.
774        let seed = indeg
775            .iter()
776            .enumerate()
777            .find(|(_, deg)| **deg > 0)
778            .map(|(i, _)| i as u32)
779            .ok_or_else(|| {
780                ToposortError::InconsistentState {
781                    message: format!(
782                        "toposort could not find a positive-indegree seed while output_len={} node_count={n}. Fix: rebuild dependency indegrees before scheduling.",
783                        out.len()
784                    ),
785                }
786            });
787        let seed = seed?;
788        let mut on_stack: Vec<bool> = Vec::new();
789        crate::graph::scratch::reserve_graph_items_with(
790            &mut on_stack,
791            n,
792            "toposort CPU oracle",
793            "toposort cycle diagnosis stack",
794            toposort_allocation,
795        )?;
796        on_stack.resize(n, false);
797        let mut cursor = seed;
798        let cycle_node = loop {
799            if on_stack[cursor as usize] {
800                break cursor;
801            }
802            on_stack[cursor as usize] = true;
803            let mut edge = depends_head[cursor as usize];
804            let mut next = None;
805            while edge != NONE {
806                let candidate = depends_to[edge];
807                if indeg[candidate as usize] > 0 {
808                    next = Some(candidate);
809                    break;
810                }
811                edge = depends_next[edge];
812            }
813            match next {
814                Some(n) => cursor = n,
815                None => {
816                    return Err(ToposortError::InconsistentState {
817                        message: format!(
818                            "toposort cycle diagnosis found stuck node {cursor} without an unemitted dependency. Fix: rebuild the dependency adjacency; this state is inconsistent with Kahn's invariant."
819                        ),
820                    });
821                }
822            }
823        };
824        return Err(ToposortError::Cycle { node: cycle_node });
825    }
826    Ok(out)
827}
828
829fn validate_toposort_edge_ids(node_count: u32, edges: &[(u32, u32)]) -> Result<(), ToposortError> {
830    for (edge_idx, &(from, to)) in edges.iter().enumerate() {
831        if from >= node_count {
832            return Err(ToposortError::UnknownNode {
833                edge: edge_idx,
834                node: from,
835            });
836        }
837        if to >= node_count {
838            return Err(ToposortError::UnknownNode {
839                edge: edge_idx,
840                node: to,
841            });
842        }
843    }
844    Ok(())
845}
846
847fn toposort_csr_allocation(message: String) -> ToposortCsrError {
848    ToposortCsrError::BadCsr { message }
849}
850
851fn toposort_allocation(message: String) -> ToposortError {
852    ToposortError::InconsistentState { message }
853}
854
855/// Build a single-invocation Program that runs Kahn's algorithm
856/// serially on lane 0.
857///
858/// `offsets_buf` is a CSR row-pointer array with `node_count + 1`
859/// entries; `targets_buf` is the CSR column array. `indeg_scratch`
860/// and `queue_scratch` are caller-provided temporary buffers of
861/// length `node_count`. `order_out` receives the topological order
862/// (length `node_count` on an acyclic graph; fewer on a cyclic
863/// graph because the kernel does not diagnose cycles).
864///
865/// Workgroup size is `[1, 1, 1]`. The kernel only executes on
866/// invocation 0; other lanes return immediately.
867#[must_use]
868pub fn toposort_program(
869    node_count: u32,
870    offsets_buf: &str,
871    targets_buf: &str,
872    indeg_scratch: &str,
873    queue_scratch: &str,
874    order_out: &str,
875) -> Program {
876    let lane0 = Expr::eq(Expr::InvocationId { axis: 0 }, Expr::u32(0));
877
878    let body = vec![
879        // Zero out indeg_scratch.
880        Node::loop_for(
881            "i",
882            Expr::u32(0),
883            Expr::u32(node_count),
884            vec![Node::store(indeg_scratch, Expr::var("i"), Expr::u32(0))],
885        ),
886        // Fill indegrees from edges. Edge count = offsets_buf[node_count].
887        Node::let_bind("edge_count", Expr::load(offsets_buf, Expr::u32(node_count))),
888        Node::loop_for(
889            "e",
890            Expr::u32(0),
891            Expr::var("edge_count"),
892            vec![
893                Node::let_bind("t", Expr::load(targets_buf, Expr::var("e"))),
894                Node::let_bind("old", Expr::load(indeg_scratch, Expr::var("t"))),
895                Node::store(
896                    indeg_scratch,
897                    Expr::var("t"),
898                    Expr::add(Expr::var("old"), Expr::u32(1)),
899                ),
900            ],
901        ),
902        // Seed queue with every node whose indegree is zero.
903        Node::let_bind("write_head", Expr::u32(0)),
904        Node::loop_for(
905            "v",
906            Expr::u32(0),
907            Expr::u32(node_count),
908            vec![Node::if_then(
909                Expr::eq(Expr::load(indeg_scratch, Expr::var("v")), Expr::u32(0)),
910                vec![
911                    Node::store(queue_scratch, Expr::var("write_head"), Expr::var("v")),
912                    Node::assign(
913                        "write_head",
914                        Expr::add(Expr::var("write_head"), Expr::u32(1)),
915                    ),
916                ],
917            )],
918        ),
919        // Pop / decrement / push until the queue is empty.
920        Node::let_bind("read_head", Expr::u32(0)),
921        Node::let_bind("out_idx", Expr::u32(0)),
922        Node::loop_for(
923            "step",
924            Expr::u32(0),
925            Expr::u32(node_count),
926            vec![Node::if_then(
927                Expr::lt(Expr::var("read_head"), Expr::var("write_head")),
928                vec![
929                    Node::let_bind("v", Expr::load(queue_scratch, Expr::var("read_head"))),
930                    Node::assign("read_head", Expr::add(Expr::var("read_head"), Expr::u32(1))),
931                    Node::store(order_out, Expr::var("out_idx"), Expr::var("v")),
932                    Node::assign("out_idx", Expr::add(Expr::var("out_idx"), Expr::u32(1))),
933                    Node::let_bind("edge_start", Expr::load(offsets_buf, Expr::var("v"))),
934                    Node::let_bind(
935                        "edge_end",
936                        Expr::load(offsets_buf, Expr::add(Expr::var("v"), Expr::u32(1))),
937                    ),
938                    Node::loop_for(
939                        "e",
940                        Expr::var("edge_start"),
941                        Expr::var("edge_end"),
942                        vec![
943                            Node::let_bind("u", Expr::load(targets_buf, Expr::var("e"))),
944                            Node::let_bind(
945                                "new_deg",
946                                Expr::sub(Expr::load(indeg_scratch, Expr::var("u")), Expr::u32(1)),
947                            ),
948                            Node::store(indeg_scratch, Expr::var("u"), Expr::var("new_deg")),
949                            Node::if_then(
950                                Expr::eq(Expr::var("new_deg"), Expr::u32(0)),
951                                vec![
952                                    Node::store(
953                                        queue_scratch,
954                                        Expr::var("write_head"),
955                                        Expr::var("u"),
956                                    ),
957                                    Node::assign(
958                                        "write_head",
959                                        Expr::add(Expr::var("write_head"), Expr::u32(1)),
960                                    ),
961                                ],
962                            ),
963                        ],
964                    ),
965                ],
966            )],
967        ),
968    ];
969
970    Program::wrapped(
971        vec![
972            BufferDecl::storage(offsets_buf, 0, BufferAccess::ReadOnly, DataType::U32)
973                .with_count(node_count.saturating_add(1)),
974            BufferDecl::storage(targets_buf, 1, BufferAccess::ReadOnly, DataType::U32),
975            BufferDecl::storage(indeg_scratch, 2, BufferAccess::ReadWrite, DataType::U32)
976                .with_count(node_count.max(1)),
977            BufferDecl::storage(queue_scratch, 3, BufferAccess::ReadWrite, DataType::U32)
978                .with_count(node_count.max(1)),
979            BufferDecl::storage(order_out, 4, BufferAccess::ReadWrite, DataType::U32)
980                .with_count(node_count.max(1)),
981        ],
982        [1, 1, 1],
983        vec![Node::Region {
984            generator: Ident::from(OP_ID),
985            source_region: None,
986            body: Arc::new(vec![Node::if_then(lane0, body)]),
987        }],
988    )
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994
995    #[test]
996    fn empty_graph_sorts_to_empty() {
997        assert_eq!(toposort(0, &[]), Ok(Vec::new()));
998    }
999
1000    #[test]
1001    fn no_edges_returns_all_nodes() {
1002        let got = toposort(3, &[])
1003            .expect("Fix: no-cycle case; restore this invariant before continuing.");
1004        assert_eq!(got.len(), 3);
1005        let mut sorted = got.clone();
1006        sorted.sort_unstable();
1007        assert_eq!(sorted, vec![0, 1, 2]);
1008    }
1009
1010    #[test]
1011    fn linear_chain_respects_order() {
1012        // 0 depends on 1 depends on 2 → sort places 2 before 1 before 0.
1013        let got = toposort(3, &[(0, 1), (1, 2)])
1014            .expect("Fix: linear chain is acyclic; restore this invariant before continuing.");
1015        let pos = |v: u32| got.iter().position(|&x| x == v).unwrap();
1016        assert!(pos(2) < pos(1));
1017        assert!(pos(1) < pos(0));
1018    }
1019
1020    #[test]
1021    fn cycle_is_rejected() {
1022        let err = toposort(2, &[(0, 1), (1, 0)]).expect_err("2-cycle must be detected");
1023        assert!(matches!(err, ToposortError::Cycle { .. }));
1024    }
1025
1026    #[test]
1027    fn cycle_diagnostic_names_node_on_cycle_not_downstream() {
1028        // AUDIT_2026-04-24 F-TS-03: graph where node 0 depends on
1029        // the cycle {1 → 2 → 3 → 1} but is not on it. Prior code
1030        // reported the first `indeg > 0` node (node 0  -  downstream of
1031        // the cycle), which was misleading because 0 itself is not on
1032        // any cycle. Diagnostic must name a node actually on a cycle.
1033        let err = toposort(4, &[(0, 1), (1, 2), (2, 3), (3, 1)])
1034            .expect_err("3-cycle with downstream consumer must be detected");
1035        match err {
1036            ToposortError::Cycle { node } => {
1037                assert!(
1038                    matches!(node, 1..=3),
1039                    "cycle node {node} must be on the cycle {{1,2,3}}, not the downstream node 0"
1040                );
1041            }
1042            other => panic!("expected Cycle error, got {other:?}"),
1043        }
1044    }
1045
1046    #[test]
1047    fn unknown_node_surfaces_edge_index() {
1048        let err = toposort(2, &[(0, 5)]).expect_err("node 5 is out of range");
1049        match err {
1050            ToposortError::UnknownNode { edge, node } => {
1051                assert_eq!(edge, 0);
1052                assert_eq!(node, 5);
1053            }
1054            _ => panic!("expected UnknownNode"),
1055        }
1056    }
1057
1058    #[test]
1059    fn diamond_graph_sorts() {
1060        // 0 depends on 1 and 2; both depend on 3.
1061        let got = toposort(4, &[(0, 1), (0, 2), (1, 3), (2, 3)])
1062            .expect("Fix: diamond is acyclic; restore this invariant before continuing.");
1063        let pos = |v: u32| got.iter().position(|&x| x == v).unwrap();
1064        assert!(pos(3) < pos(1));
1065        assert!(pos(3) < pos(2));
1066        assert!(pos(1) < pos(0));
1067        assert!(pos(2) < pos(0));
1068    }
1069
1070    #[test]
1071    fn emitted_program_has_expected_buffers_and_workgroup_size() {
1072        let p = toposort_program(4, "offsets", "targets", "indeg", "queue", "order");
1073        assert_eq!(p.workgroup_size, [1, 1, 1]);
1074        let names: Vec<&str> = p.buffers.iter().map(|b| b.name()).collect();
1075        assert_eq!(names, vec!["offsets", "targets", "indeg", "queue", "order"]);
1076        assert_eq!(p.buffers[0].count(), 5); // node_count + 1
1077        assert_eq!(p.buffers[2].count(), 4); // node_count
1078        assert_eq!(p.buffers[3].count(), 4); // node_count
1079        assert_eq!(p.buffers[4].count(), 4); // node_count
1080    }
1081
1082    #[test]
1083    fn csr_reference_sorts_prerequisites_before_dependents() {
1084        let order = toposort_csr(3, &[0, 2, 3, 3], &[1, 2, 2]).unwrap();
1085        let pos = |v: u32| order.iter().position(|&x| x == v).unwrap();
1086        assert!(pos(0) < pos(1));
1087        assert!(pos(0) < pos(2));
1088        assert!(pos(1) < pos(2));
1089    }
1090
1091    #[test]
1092    fn csr_reference_reuses_output_storage() {
1093        let mut order = Vec::with_capacity(8);
1094        toposort_csr_into(3, &[0, 1, 2, 2], &[1, 2], &mut order).unwrap();
1095        let capacity = order.capacity();
1096        assert_eq!(order.len(), 3);
1097
1098        toposort_csr_into(2, &[0, 1, 1], &[1], &mut order).unwrap();
1099        assert_eq!(order.capacity(), capacity);
1100        assert_eq!(order.len(), 2);
1101    }
1102
1103    #[test]
1104    fn csr_reference_with_scratch_reuses_storage_and_clears_stale_state() {
1105        let mut order = Vec::with_capacity(8);
1106        order.extend_from_slice(&[99, 98, 97]);
1107        let mut queue = Vec::with_capacity(8);
1108        queue.extend_from_slice(&[6, 5, 4]);
1109        let mut scratch = ToposortCsrScratch {
1110            indeg: vec![7; 8],
1111            queue,
1112        };
1113        let order_capacity = order.capacity();
1114        let indeg_capacity = scratch.indeg.capacity();
1115        let queue_capacity = scratch.queue.capacity();
1116
1117        toposort_csr_into_with_scratch(4, &[0, 2, 3, 3, 3], &[1, 2, 3], &mut order, &mut scratch)
1118            .expect("Fix: valid DAG must sort while reusing caller-owned scratch.");
1119
1120        validate_toposort_csr_order(4, &[0, 2, 3, 3, 3], &[1, 2, 3], &order)
1121            .expect("Fix: scratch-backed topological order must satisfy the CSR contract.");
1122        assert_eq!(order.capacity(), order_capacity);
1123        assert_eq!(scratch.indeg.capacity(), indeg_capacity);
1124        assert_eq!(scratch.queue.capacity(), queue_capacity);
1125        assert_eq!(
1126            scratch.indeg,
1127            vec![0, 0, 0, 0],
1128            "Fix: scratch-backed traversal must not retain stale indegree counts."
1129        );
1130        assert!(
1131            scratch.queue.is_empty(),
1132            "Fix: scratch-backed traversal must consume stale and live queue entries."
1133        );
1134
1135        toposort_csr_into_with_scratch(2, &[0, 1, 1], &[1], &mut order, &mut scratch)
1136            .expect("Fix: second smaller DAG must reuse the same workspace.");
1137        validate_toposort_csr_order(2, &[0, 1, 1], &[1], &order)
1138            .expect("Fix: reused workspace must not leak prior graph state.");
1139        assert_eq!(order.capacity(), order_capacity);
1140        assert_eq!(scratch.indeg.capacity(), indeg_capacity);
1141        assert_eq!(scratch.queue.capacity(), queue_capacity);
1142        assert_eq!(scratch.indeg, vec![0, 0]);
1143        assert!(scratch.queue.is_empty());
1144    }
1145
1146    #[test]
1147    fn csr_reference_with_scratch_validates_before_mutating_reused_storage() {
1148        let mut order = vec![9, 8, 7];
1149        let mut scratch = ToposortCsrScratch {
1150            indeg: vec![1, 2],
1151            queue: vec![3],
1152        };
1153        let err = toposort_csr_into_with_scratch(2, &[0, 2, 1], &[1], &mut order, &mut scratch)
1154            .expect_err("Fix: malformed CSR offsets must be rejected.");
1155
1156        assert!(matches!(err, ToposortCsrError::BadCsr { .. }));
1157        assert_eq!(
1158            order,
1159            vec![9, 8, 7],
1160            "Fix: validation failures must not clobber reusable output storage."
1161        );
1162        assert_eq!(
1163            scratch.indeg,
1164            vec![1, 2],
1165            "Fix: validation failures must not clear reusable indegree scratch."
1166        );
1167        assert_eq!(
1168            scratch.queue,
1169            vec![3],
1170            "Fix: validation failures must not clear reusable queue scratch."
1171        );
1172    }
1173
1174    #[test]
1175    fn generated_csr_reference_with_scratch_matches_allocating_reference() {
1176        let mut order = Vec::new();
1177        let mut scratch = ToposortCsrScratch::new();
1178
1179        for case in 0..2048usize {
1180            let n = case % 17;
1181            let mut offsets = Vec::with_capacity(n + 1);
1182            let mut targets = Vec::new();
1183            offsets.push(0);
1184            for src in 0..n {
1185                for dst in src + 1..n {
1186                    let mixed = case
1187                        .wrapping_mul(31)
1188                        .wrapping_add(src.wrapping_mul(17))
1189                        .wrapping_add(dst.wrapping_mul(13));
1190                    if mixed % 5 == 0 || (case % 11 == 0 && dst == src + 1) {
1191                        targets.push(dst as u32);
1192                    }
1193                }
1194                offsets.push(targets.len() as u32);
1195            }
1196
1197            let expected = toposort_csr(n as u32, &offsets, &targets)
1198                .expect("Fix: generated lower-triangular CSR graph must be a valid DAG.");
1199            toposort_csr_into_with_scratch(n as u32, &offsets, &targets, &mut order, &mut scratch)
1200                .expect("Fix: scratch-backed oracle must accept every generated valid DAG.");
1201            assert_eq!(
1202                order, expected,
1203                "Fix: scratch-backed oracle diverged from allocating oracle at generated case {case}."
1204            );
1205        }
1206    }
1207
1208    #[test]
1209    fn csr_validation_rejects_bad_shape() {
1210        let err = validate_toposort_csr_inputs(2, &[0, 2, 1], &[1]).unwrap_err();
1211        assert!(matches!(err, ToposortCsrError::BadCsr { .. }));
1212    }
1213
1214    #[test]
1215    fn csr_validation_returns_dispatch_layout() {
1216        assert_eq!(
1217            validate_toposort_csr_inputs(3, &[0, 2, 3, 3], &[1, 2, 2]).unwrap(),
1218            ToposortCsrLayout {
1219                node_count: 3,
1220                node_words: 3,
1221                offset_words: 4,
1222                target_words: 3,
1223            }
1224        );
1225        assert_eq!(
1226            validate_toposort_csr_inputs(0, &[0], &[]).unwrap(),
1227            ToposortCsrLayout {
1228                node_count: 0,
1229                node_words: 0,
1230                offset_words: 1,
1231                target_words: 0,
1232            }
1233        );
1234    }
1235
1236    #[test]
1237    fn csr_order_validation_rejects_duplicate_backend_output() {
1238        let err = validate_toposort_csr_order(3, &[0, 1, 2, 2], &[1, 2], &[0, 1, 1]).unwrap_err();
1239        assert!(matches!(err, ToposortCsrError::BadOrder { .. }));
1240    }
1241
1242    #[test]
1243    fn csr_order_validation_rejects_dependency_inversion() {
1244        let err = validate_toposort_csr_order(2, &[0, 1, 1], &[1], &[1, 0]).unwrap_err();
1245        assert!(matches!(err, ToposortCsrError::BadOrder { .. }));
1246    }
1247
1248    // ------------------------------------------------------------------
1249    // Adversarial fixtures  -  empty/single/disconnected/self-loop/max-size.
1250    // ------------------------------------------------------------------
1251
1252    #[test]
1253    fn single_node_no_edges() {
1254        assert_eq!(toposort(1, &[]), Ok(vec![0]));
1255    }
1256
1257    #[test]
1258    fn self_loops_only_rejected() {
1259        // Every node has a self-loop  -  each is a 1-cycle.
1260        let err = toposort(3, &[(0, 0), (1, 1), (2, 2)]).expect_err("self-loops are cycles");
1261        assert!(matches!(err, ToposortError::Cycle { .. }));
1262    }
1263
1264    #[test]
1265    fn disconnected_components_sorts_all() {
1266        // Component A: 0 depends on 1. Component B: 2 depends on 3.
1267        let got = toposort(4, &[(0, 1), (2, 3)]).unwrap();
1268        assert_eq!(got.len(), 4);
1269        let pos = |v: u32| got.iter().position(|&x| x == v).unwrap();
1270        assert!(pos(1) < pos(0), "1 must come before 0");
1271        assert!(pos(3) < pos(2), "3 must come before 2");
1272    }
1273
1274    #[test]
1275    fn max_node_count_min_edges() {
1276        // 1000 nodes, one chain edge 0→1.
1277        let got = toposort(1000, &[(0, 1)]).unwrap();
1278        assert_eq!(got.len(), 1000);
1279        let pos = |v: u32| got.iter().position(|&x| x == v).unwrap();
1280        assert!(pos(1) < pos(0), "1 must come before 0");
1281    }
1282
1283    #[test]
1284    fn cycle_on_large_graph_diagnostic_is_on_cycle() {
1285        // 100 nodes in a line, back-edge creating cycle 50→51→…→99→50.
1286        let mut edges: Vec<(u32, u32)> = (0..99).map(|i| (i, i + 1)).collect();
1287        edges.push((99, 50));
1288        let err = toposort(100, &edges).expect_err("cycle must be detected");
1289        match err {
1290            ToposortError::Cycle { node } => {
1291                assert!(
1292                    (50..=99).contains(&node),
1293                    "cycle node {node} must be on the back-edge cycle"
1294                );
1295            }
1296            other => panic!("expected Cycle, got {other:?}"),
1297        }
1298    }
1299}