Skip to main content

degenbot_pathfinding/
graph.rs

1//! The pathfinding multigraph and iterative depth-first search.
2//!
3//! This module is the pure-Rust core of the pathfinding algorithm. It
4//! replaces the Python `networkx.MultiGraph` + recursive `_dfs` with a lean
5//! adjacency-list graph and an iterative DFS using a `Vec<bool>` visited-set
6//! for O(1) cycle detection.
7//!
8//! # Performance design
9//!
10//! External token IDs (`u64`) are remapped to compact contiguous indices
11//! (`u32`) at construction. The adjacency list is a flat `Vec<Vec<CompactEdge>>`
12//! indexed by compact token index — a direct array lookup with no hashing.
13//! Pools are likewise remapped to compact indices so the visited set is a
14//! `Vec<bool>` (indexed by pool index) instead of a `HashSet`, eliminating
15//! hashing on every edge explored. Each `CompactEdge` is 8 bytes (two `u32`)
16//! versus the 24-byte `Edge`, improving cache density for the hot DFS loop.
17
18use std::borrow::Borrow;
19use std::collections::{HashMap, VecDeque};
20use std::hash::{BuildHasherDefault, Hasher};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::Arc;
23use std::time::{Duration, Instant};
24
25/// Discriminant for the three pool-table families.
26///
27/// `V2` corresponds to `UniswapV2PoolTableBase` (Uniswap V2 and V2-style
28/// forks); `V3` corresponds to `UniswapV3PoolTableBase` (Uniswap V3 and
29/// V3-style forks); `V4` corresponds to `UniswapV4PoolTable`.
30///
31/// All V2/V3 subtypes share the `pools` database table (single ID
32/// sequence), so a `pool_id` is unique within V2 and within V3 — no
33/// collision between the two. V4 pools use a separate `managed_pools`
34/// table, so the `PoolKind` discriminant disambiguates V4 IDs from V2/V3
35/// IDs.
36#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
37#[non_exhaustive]
38pub enum PoolKind {
39    V2,
40    V3,
41    V4,
42}
43
44impl PoolKind {
45    /// Convert to the `u8` discriminant used at the `PyO3` boundary.
46    #[must_use]
47    pub const fn as_u8(self) -> u8 {
48        match self {
49            PoolKind::V2 => 0,
50            PoolKind::V3 => 1,
51            PoolKind::V4 => 2,
52        }
53    }
54
55    /// Convert from the `u8` discriminant used at the `PyO3` boundary.
56    ///
57    /// Returns `None` for unknown discriminants.
58    #[must_use]
59    pub const fn from_u8(val: u8) -> Option<Self> {
60        match val {
61            0 => Some(PoolKind::V2),
62            1 => Some(PoolKind::V3),
63            2 => Some(PoolKind::V4),
64            _ => None,
65        }
66    }
67
68    /// The single table of persisted `kind` discriminators the graph
69    /// vocabulary admits, paired with their tag.
70    ///
71    /// ADR-059 D1: this is the one enumeration [`Self::from_kind_str`] (and
72    /// therefore every schema `is_v*_kind` helper and graph reader) projects
73    /// through; `degenbot-db`'s golden test pins its membership so a taxonomy
74    /// species cannot be added without a graph tag.
75    pub const KNOWN_KINDS: &'static [(&'static str, Self)] = &[
76        ("uniswap_v2", Self::V2),
77        ("sushiswap_v2", Self::V2),
78        ("pancakeswap_v2", Self::V2),
79        ("aerodrome_v2", Self::V2),
80        ("camelot_v2", Self::V2),
81        ("swapbased_v2", Self::V2),
82        ("uniswap_v3", Self::V3),
83        ("sushiswap_v3", Self::V3),
84        ("pancakeswap_v3", Self::V3),
85        ("aerodrome_v3", Self::V3),
86        ("uniswap_v4", Self::V4),
87    ];
88
89    /// Project a persisted pool `kind` discriminator onto the graph vocabulary
90    /// (ADR-059 D1).
91    ///
92    /// The discovery tier receives a pool's taxonomy species as the database
93    /// `pools.kind` / `managed_pools.kind` string, not a `degenbot-pools`
94    /// `Identity`: this crate is the graph leaf and carries no taxonomy
95    /// dependency. The `kind` string is therefore the minimal taxonomy input
96    /// the tier can construct from its own data.
97    ///
98    /// Returns `None` for a kind outside the V2/V3/V4 graph vocabulary; the
99    /// caller refuses it loudly rather than dropping the row.
100    #[must_use]
101    pub fn from_kind_str(kind: &str) -> Option<Self> {
102        Self::KNOWN_KINDS
103            .iter()
104            .find(|(name, _)| *name == kind)
105            .map(|(_, pool_kind)| *pool_kind)
106    }
107
108    /// `pools.kind` / `managed_pools.kind` discriminators whose family the
109    /// taxonomy declares but this graph vocabulary does not admit (ADR-059
110    /// D8). These are NOT supported: [`Self::from_kind_str`] returns `None`
111    /// for them, and a DB row carrying one flows into the loud
112    /// `load_unsupported` roster instead of being unclassifiable.
113    ///
114    /// `lfj_binned` is the LFJ (Trader Joe) binned-liquidity family — the
115    /// first genuinely new pool structure through the kernel, declared here
116    /// before any tier admits it.
117    pub const DECLARED_UNSUPPORTED_KINDS: &'static [&'static str] = &["lfj_binned"];
118
119    /// `true` if `kind` is a declared-but-unsupported family discriminator
120    /// (present in the taxonomy, absent from the supported [`Self::KNOWN_KINDS`]).
121    #[must_use]
122    pub fn is_declared_unsupported(kind: &str) -> bool {
123        Self::DECLARED_UNSUPPORTED_KINDS.contains(&kind)
124    }
125}
126
127/// A pool edge in the external (database) form. Retained for API
128/// compatibility; the internal adjacency list uses the compact [`CompactEdge`]
129/// (8 bytes, two `u32` indices) for cache density.
130#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
131pub struct Edge {
132    /// The token ID this edge leads to.
133    pub neighbor: u64,
134    /// The pool ID providing this liquidity connection.
135    pub pool_id: u64,
136    /// Which pool-table family this pool belongs to.
137    pub pool_kind: PoolKind,
138}
139
140/// A key uniquely identifying a pool within a traversal.
141pub type EdgeKey = (u64, PoolKind);
142
143/// Hasher for `u64` token IDs: single multiply-xor round (FxHash-style).
144/// Token-ID hashing runs ~1.5M times during graph construction; the default
145/// `SipHash` costs several instructions per byte for 8-byte keys with no
146/// security benefit here (keys are internal IDs, not adversarial input).
147#[derive(Default)]
148pub(crate) struct U64Hasher {
149    hash: u64,
150}
151
152impl Hasher for U64Hasher {
153    #[inline]
154    fn finish(&self) -> u64 {
155        self.hash
156    }
157
158    #[inline]
159    fn write_u64(&mut self, n: u64) {
160        self.hash = (self.hash.rotate_left(5) ^ n).wrapping_mul(0x517C_C1B7_2722_0A95);
161    }
162
163    #[inline]
164    fn write(&mut self, bytes: &[u8]) {
165        for &b in bytes {
166            self.hash =
167                (self.hash.rotate_left(5) ^ u64::from(b)).wrapping_mul(0x517C_C1B7_2722_0A95);
168        }
169    }
170}
171
172/// Hasher-builder for token-ID maps.
173pub(crate) type U64BuildHasher = BuildHasherDefault<U64Hasher>;
174
175/// Compact internal edge: neighbor is a compact token index, `pool_idx`
176/// identifies the pool in the graph's `pools` table. 8 bytes, cache-dense.
177#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
178pub(crate) struct CompactEdge {
179    /// Compact index of the neighbor token (into `PathGraph::adj`).
180    neighbor: u32,
181    /// Compact index of the pool (into `PathGraph::pools`).
182    pool_idx: u32,
183}
184
185/// A multigraph: token IDs are nodes, liquidity pools are edges.
186///
187/// Stored as a compact adjacency list (`Vec<Vec<CompactEdge>>`) indexed by
188/// remapped contiguous token indices. External token IDs (`u64`) are mapped
189/// to compact indices (`u32`) via `token_index`, so the hot DFS loop does
190/// direct array indexing instead of hashing. Parallel edges (multiple pools
191/// connecting the same token pair) are naturally supported and preserve
192/// insertion order for deterministic traversal.
193///
194/// Pools are also remapped to compact indices; the `pools` table maps each
195/// compact pool index back to its `(pool_id, PoolKind)` for yielding, and the
196/// visited set is an O(1) `Vec<bool>` indexed by pool index.
197#[derive(Clone)]
198pub struct PathGraph {
199    /// CSR adjacency: `adj_flat[adj_offsets[i]..adj_offsets[i + 1]]` is the
200    /// list of outgoing `CompactEdge`s of compact token index `i`, in edge
201    /// insertion order (identical order to the previous `Vec<Vec<_>>`),
202    /// contiguous in memory for cache-dense DFS hops.
203    adj_offsets: Vec<u32>,
204    adj_flat: Vec<CompactEdge>,
205    /// External token ID → compact token index.
206    token_index: HashMap<u64, u32, U64BuildHasher>,
207    /// Compact pool index → `(pool_id, PoolKind)` for yielding results.
208    pools: Vec<(u64, PoolKind)>,
209    /// Bundle layer, computed once at construction: parallel pools between
210    /// the same unordered token pair collapse into one bundle. Bundle ids
211    /// follow first-appearance order in the edge list (deterministic; ties
212    /// in per-token sorts resolve back to edge order). Pools of bundle `b`
213    /// are `bundle_pools_flat[bundle_offsets[b] .. bundle_offsets[b + 1]]`
214    /// in ascending pool index — CSR, so no per-bundle allocations.
215    bundle_offsets: Vec<u32>,
216    bundle_pools_flat: Vec<u32>,
217    /// Per bundle: the unordered compact token pair it spans.
218    bundle_pairs: Vec<(u32, u32)>,
219    /// Per bundle: bitmask of `PoolKind`s present among its pools.
220    bundle_kinds: Vec<u32>,
221}
222
223/// Admissible prune data for ONE bounded search (start, end, budget).
224///
225/// `to_end[x]` is the fewest hops from `x` to the target — a lower bound on
226/// the trail hops still needed once `x` is reached, so a step onto `x` is
227/// cut only when no within-budget trail can close through it.
228///
229/// A start-side/ball mask (`d_start + d_end > budget` exclusion) would be
230/// redundant here: the DFS only ever reaches `start`-reachable nodes, and a
231/// node arrived at length `len` passes the cutoff only when
232/// `d_end[x] <= emd - len - 1 <= emd - d_start[x] - 1` — exactly the ball
233/// condition, enforced en route at zero extra cost.
234struct SearchPrune {
235    to_end: Vec<u32>,
236}
237
238impl SearchPrune {
239    fn build(graph: &PathGraph, end: u32) -> Self {
240        Self {
241            to_end: graph.hop_distances(&[end]),
242        }
243    }
244}
245
246impl PathGraph {
247    /// Slice of the outgoing edges of a compact node.
248    #[must_use]
249    pub(crate) fn adj_of(&self, node: u32) -> &[CompactEdge] {
250        let start = self.adj_offsets[node as usize] as usize;
251        let end = self.adj_offsets[node as usize + 1] as usize;
252        &self.adj_flat[start..end]
253    }
254
255    /// Multi-source BFS hop distances to the target node set (undirected).
256    /// `u32::MAX` marks unreachable. On the token multigraph this is a LOWER
257    /// bound on the trail-hop count between any two nodes, so it is an
258    /// admissible quantity for search cutoffs: skipping a step can only
259    /// discard trails that provably exceed the budget.
260    #[must_use]
261    pub(crate) fn hop_distances(&self, sources: &[u32]) -> Vec<u32> {
262        let n = self.nodes();
263        let mut dist: Vec<u32> = vec![u32::MAX; n];
264        let mut queue: VecDeque<u32> = VecDeque::new();
265        for &s in sources {
266            if (s as usize) < n {
267                dist[s as usize] = 0;
268                queue.push_back(s);
269            }
270        }
271        while let Some(x) = queue.pop_front() {
272            let next = dist[x as usize].saturating_add(1);
273            for edge in self.adj_of(x) {
274                if dist[edge.neighbor as usize] > next {
275                    dist[edge.neighbor as usize] = next;
276                    queue.push_back(edge.neighbor);
277                }
278            }
279        }
280        dist
281    }
282
283    /// Compact node count (=`adj_offsets.len() - 1`).
284    fn nodes(&self) -> usize {
285        self.adj_offsets.len() - 1
286    }
287
288    /// Member pools of bundle `b` (ascending pool index).
289    #[must_use]
290    pub(crate) fn bundle_pools(&self, b: u32) -> &[u32] {
291        let start = self.bundle_offsets[b as usize] as usize;
292        let end = self.bundle_offsets[b as usize + 1] as usize;
293        &self.bundle_pools_flat[start..end]
294    }
295
296    /// Kind bitmask of bundle `b`.
297    #[must_use]
298    pub(crate) fn bundle_kind_mask(&self, b: u32) -> u32 {
299        self.bundle_kinds[b as usize]
300    }
301
302    /// Per-token bundle incidence as CSR: `(other token, bundle)` pairs, in
303    /// bundle first-appearance order. The search clones the flat array
304    /// (memcpy) and stable-sorts each token's slice by prune distance — the
305    /// only search-dependent ordering.
306    #[must_use]
307    pub(crate) fn token_bundles_csr(&self) -> (Vec<u32>, Vec<(u32, u32)>) {
308        let n = self.nodes();
309        let mut offsets = vec![0u32; n + 1];
310        for &(a, t) in &self.bundle_pairs {
311            offsets[a as usize + 1] += 1;
312            if t != a {
313                offsets[t as usize + 1] += 1;
314            }
315        }
316        for w in 1..=n {
317            offsets[w] += offsets[w - 1];
318        }
319        let mut flat = vec![(0u32, 0u32); offsets[n] as usize];
320        let mut cursor: Vec<u32> = offsets[..n].to_vec();
321        for (b, &(a, t)) in self.bundle_pairs.iter().enumerate() {
322            let bundle = u32::try_from(b).unwrap_or(u32::MAX);
323            flat[cursor[a as usize] as usize] = (t, bundle);
324            cursor[a as usize] += 1;
325            if t != a {
326                flat[cursor[t as usize] as usize] = (a, bundle);
327                cursor[t as usize] += 1;
328            }
329        }
330        (offsets, flat)
331    }
332
333    /// Build from a flat list of `(token0, token1, pool_id, pool_kind)` edges.
334    ///
335    /// Each edge is added in both directions (the graph is undirected, like
336    /// the `networkx.MultiGraph` it replaces). Edge insertion order within
337    /// each node's adjacency list is preserved for deterministic traversal
338    /// (per-node cursor fill over the edge list = insertion order). External
339    /// token IDs are remapped to compact contiguous indices.
340    ///
341    /// Two passes over the edge list into a flat CSR array: no per-node
342    /// `Vec` allocations, no reallocation churn (~1.5M heap operations on a
343    /// 742k-edge graph, down from ~750k edge pushes into growing per-node
344    /// vectors plus map-insert adjacency growth).
345    ///
346    /// # Panics
347    ///
348    /// Panics if the number of distinct pools or tokens exceeds `u32::MAX`
349    /// (compact index overflow). This is an architectural bound of the
350    /// compact-index representation and unreachable in practice.
351    #[must_use]
352    pub fn from_edges(edges: Vec<(u64, u64, u64, PoolKind)>) -> Self {
353        let n = edges.len();
354        // Upper bound on distinct tokens: 2 per edge. Saves rehashing.
355        let mut token_index: HashMap<u64, u32, U64BuildHasher> =
356            HashMap::with_capacity_and_hasher(n * 2, U64BuildHasher::default());
357        let mut pools: Vec<(u64, PoolKind)> = Vec::with_capacity(n);
358        // Pass 1: intern endpoints; arena keeps (compactnode0, node1) per
359        // edge, and parallel pools intern into one bundle in first-
360        // appearance order (packed-pair keys keep lookups off SipHash).
361        let mut arena: Vec<(u32, u32)> = Vec::with_capacity(n);
362        let mut bundle_id: HashMap<u64, u32, U64BuildHasher> =
363            HashMap::with_capacity_and_hasher(n, U64BuildHasher::default());
364        let mut bundle_pairs: Vec<(u32, u32)> = Vec::with_capacity(n);
365        let mut bundle_kinds: Vec<u32> = Vec::with_capacity(n);
366        let mut pool_bundle: Vec<u32> = Vec::with_capacity(n);
367
368        for (token0, token1, pool_id, pool_kind) in edges {
369            pools.push((pool_id, pool_kind));
370
371            let idx0 = Self::intern_token(&mut token_index, token0);
372            let idx1 = Self::intern_token(&mut token_index, token1);
373
374            let packed = pack_token_pair(idx0, idx1);
375            let kind_bit = 1u32 << pool_kind.as_u8();
376            let bundle = if let Some(&b) = bundle_id.get(&packed) {
377                bundle_kinds[b as usize] |= kind_bit;
378                b
379            } else {
380                let b = u32::try_from(bundle_pairs.len()).unwrap_or(u32::MAX);
381                bundle_id.insert(packed, b);
382                bundle_pairs.push((idx0.min(idx1), idx0.max(idx1)));
383                bundle_kinds.push(kind_bit);
384                b
385            };
386            pool_bundle.push(bundle);
387
388            arena.push((idx0, idx1));
389        }
390
391        // Pass 2: degrees → offsets → cursor fill (insertion order per node).
392        let node_count = token_index.len();
393        #[expect(clippy::expect_used)]
394        let node_count_u32 = u32::try_from(node_count).expect("node count exceeds u32::MAX");
395        let mut deg = vec![0u32; node_count];
396        for (a, b) in &arena {
397            deg[*a as usize] += 1;
398            deg[*b as usize] += 1;
399        }
400        let mut adj_offsets: Vec<u32> = Vec::with_capacity(node_count + 1);
401        let mut running: u32 = 0;
402        adj_offsets.push(0);
403        for &d in &deg {
404            running += d;
405            adj_offsets.push(running);
406        }
407        let total = running as usize;
408        let mut adj_flat: Vec<CompactEdge> = vec![
409            CompactEdge {
410                neighbor: node_count_u32,
411                pool_idx: u32::MAX,
412            };
413            total
414        ];
415        let mut cursor: Vec<u32> = adj_offsets[..node_count].to_vec();
416        for (pool_idx_usize, (a, b)) in arena.iter().enumerate() {
417            #[expect(clippy::expect_used)]
418            let pool_idx = u32::try_from(pool_idx_usize).expect("pool index exceeds u32::MAX");
419            let ca = &mut cursor[*a as usize];
420            adj_flat[*ca as usize] = CompactEdge {
421                neighbor: *b,
422                pool_idx,
423            };
424            *ca += 1;
425            let cb = &mut cursor[*b as usize];
426            adj_flat[*cb as usize] = CompactEdge {
427                neighbor: *a,
428                pool_idx,
429            };
430            *cb += 1;
431        }
432
433        // Bundle pools as CSR (count + cursor fill): pools land grouped by
434        // bundle, ascending in pool index within a bundle, with zero
435        // per-bundle allocations.
436        let bundle_count = bundle_pairs.len();
437        let mut bundle_offsets: Vec<u32> = vec![0; bundle_count + 1];
438        for &b in &pool_bundle {
439            bundle_offsets[b as usize + 1] += 1;
440        }
441        for w in 1..=bundle_count {
442            bundle_offsets[w] += bundle_offsets[w - 1];
443        }
444        let mut bundle_pools_flat: Vec<u32> = vec![0; pool_bundle.len()];
445        let mut bcursor: Vec<u32> = bundle_offsets[..bundle_count].to_vec();
446        for (pool_idx_usize, &b) in pool_bundle.iter().enumerate() {
447            #[expect(clippy::expect_used)]
448            let pool_idx = u32::try_from(pool_idx_usize).expect("pool index exceeds u32::MAX");
449            bundle_pools_flat[bcursor[b as usize] as usize] = pool_idx;
450            bcursor[b as usize] += 1;
451        }
452
453        Self {
454            adj_offsets,
455            adj_flat,
456            token_index,
457            pools,
458            bundle_offsets,
459            bundle_pools_flat,
460            bundle_pairs,
461            bundle_kinds,
462        }
463    }
464
465    /// Assign (or look up) the compact index for an external token ID.
466    fn intern_token(token_index: &mut HashMap<u64, u32, U64BuildHasher>, token: u64) -> u32 {
467        if let Some(&idx) = token_index.get(&token) {
468            idx
469        } else {
470            #[expect(clippy::expect_used)] // u32 compact-index invariant (see `from_edges`)
471            let idx = u32::try_from(token_index.len()).expect("token count exceeds u32::MAX");
472            token_index.insert(token, idx);
473            idx
474        }
475    }
476
477    /// Map an external token ID to its compact index, if present.
478    #[must_use]
479    fn compact_index(&self, token: u64) -> Option<u32> {
480        self.token_index.get(&token).copied()
481    }
482
483    /// Returns `true` if the node exists in the graph.
484    #[must_use]
485    pub fn contains_node(&self, node: u64) -> bool {
486        self.token_index.contains_key(&node)
487    }
488
489    /// The number of nodes (tokens) in the graph.
490    #[must_use]
491    pub fn node_count(&self) -> usize {
492        self.nodes()
493    }
494
495    /// The number of pool edges incident to a token (its degree).
496    #[must_use]
497    pub fn degree(&self, token: u64) -> Option<usize> {
498        self.compact_index(token).map(|i| self.adj_of(i).len())
499    }
500
501    /// Remove nodes with degree ≤ 1, repeating until no such nodes remain.
502    ///
503    /// Mirrors Python `_prepare_graph`'s iterative dead-end pruning: pruning a
504    /// node may drop another node's live degree below 2, so peeling continues
505    /// to a fixpoint. Nodes on cycles always retain degree ≥ 2, so the
506    /// surviving subgraph (the 2-core) is identical regardless of peel order.
507    ///
508    /// Complexity: O(V + E) — a degree-array work queue visits each edge a
509    /// constant number of times, then one ordered CSR rebuild pass
510    /// (preserving edge insertion order, so DFS enumeration order is
511    /// unchanged).
512    ///
513    /// # Panics
514    ///
515    /// Panics if the number of surviving edges exceeds `u32::MAX`
516    /// (architectural bound of the CSR offset type; unreachable in practice).
517    pub fn prune_dead_ends(&mut self) {
518        let n = self.nodes();
519        // 2-core peel (degree array + work queue): a node is on a pruning
520        // path iff iteratively reducing it drops its live degree to <= 1.
521        // This is the SAME fixpoint the previous round-based implementation
522        // computed (nodes on cycles always retain degree >= 2; a removed
523        // node's edges cannot re-connect anything), but it visits each edge
524        // O(1) times instead of rescanning the whole graph per round
525        // (O(V+E) total; the round-based version was O(rounds * (V+E)) and
526        // took ~55s on a 742k-edge mainnet graph).
527        let node_count_u32 = u32::try_from(self.nodes()).unwrap_or(u32::MAX);
528        let mut degree: Vec<usize> = (0..node_count_u32).map(|i| self.adj_of(i).len()).collect();
529        let mut removed = vec![false; n];
530
531        // Only degree-1 nodes /* and isolated degree-0 leftovers */ can start
532        let mut queue: Vec<usize> = Vec::with_capacity(n / 8);
533        for (i, &d) in degree.iter().enumerate() {
534            if d <= 1 {
535                queue.push(i);
536            }
537        }
538        let mut head = 0usize;
539        while head < queue.len() {
540            let i = queue[head];
541            head += 1;
542            if removed[i] {
543                continue;
544            }
545            removed[i] = true;
546            for e in self.adj_of(u32::try_from(i).unwrap_or(u32::MAX)) {
547                let j = e.neighbor as usize;
548                if removed[j] {
549                    continue;
550                }
551                degree[j] -= 1;
552                if degree[j] <= 1 {
553                    queue.push(j);
554                }
555            }
556        }
557        // Single-pass rebuild of the surviving adjacencies (order kept):
558        // walk the old CSR, writing kept edges into a fresh flat array.
559        let mut new_flat: Vec<CompactEdge> = Vec::with_capacity(self.adj_flat.len());
560        let mut new_offsets: Vec<u32> = Vec::with_capacity(n + 1);
561        new_offsets.push(0);
562        for (i, &start) in self.adj_offsets.iter().enumerate().take(n) {
563            if !removed[i] {
564                let end = self.adj_offsets[i + 1] as usize;
565                for e in &self.adj_flat[start as usize..end] {
566                    if !removed[e.neighbor as usize] {
567                        new_flat.push(*e);
568                    }
569                }
570            }
571            #[expect(clippy::expect_used)]
572            new_offsets.push(u32::try_from(new_flat.len()).expect("edge count exceeds u32::MAX"));
573        }
574        self.adj_flat = new_flat;
575        self.adj_offsets = new_offsets;
576        // Drop the external token IDs of all pruned nodes so contains_node
577        // reflects the post-prune state.
578        self.token_index
579            .retain(|_, &mut idx| !removed[idx as usize]);
580    }
581
582    /// Precompute valid depth positions per node, for lookahead pruning.
583    ///
584    /// For each node, determine which depth positions its edges satisfy. A
585    /// node can appear at depth `d` if it has at least one incident edge whose
586    /// `pool_kind` is in the allowed set at depth `d` (or `allowed[d]` is
587    /// `None`, meaning all kinds are allowed).
588    ///
589    /// Returns a `Vec` indexed by compact token index, where entry `i` is a
590    /// `Vec<bool>` whose index `d` is `true` if token `i` can participate at
591    /// depth `d`.
592    #[must_use]
593    pub fn compute_node_valid_depths(
594        &self,
595        pool_type_per_depth: &[Option<Vec<PoolKind>>],
596    ) -> Vec<Vec<bool>> {
597        let mut result = Vec::with_capacity(self.nodes());
598        for i in 0..self.nodes() {
599            let edges = self.adj_of(u32::try_from(i).unwrap_or(u32::MAX));
600            // Collect all pool kinds this node has edges for (a node can use
601            // any pool it touches at any depth).
602            let mut kinds = [false; 3];
603            for e in edges {
604                let kind = self.pools[e.pool_idx as usize].1;
605                kinds[kind.as_u8() as usize] = true;
606            }
607            let mut valid = vec![false; pool_type_per_depth.len()];
608            for (d, allowed) in pool_type_per_depth.iter().enumerate() {
609                match allowed {
610                    None => valid[d] = true,
611                    Some(allowed_kinds) => {
612                        valid[d] = allowed_kinds.iter().any(|k| kinds[k.as_u8() as usize]);
613                    }
614                }
615            }
616            result.push(valid);
617        }
618        result
619    }
620}
621
622/// Minimum elapsed wall-clock between discovery heartbeat emissions.
623///
624/// ~10s keeps a long search quiet but surfaces a hang within the ~5-min
625/// bounded-time target. Tuned so small synthetic test fixtures
626/// (which complete in µs) never emit.
627const DISCOVERY_HEARTBEAT: Duration = Duration::from_secs(10);
628
629/// Check the heartbeat clock every this many stack-frame iterations (amortizes
630/// `Instant::now` out of the hot per-edge DFS loop). Power-of-two so the modulo
631/// is a bitmask.
632const HEARTBEAT_CHECK_EVERY: u64 = 4096;
633
634/// A long-running walk's live progress, delivered to a caller-installed
635/// observation hook ([`BundledSearch::with_progress`]) on the heartbeat
636/// checkpoint. A pure snapshot: reading it never perturbs the enumeration.
637#[derive(Clone, Copy, Debug)]
638pub struct WalkerTally {
639    /// Wall clock since the search's first advance.
640    pub elapsed: Duration,
641    /// Completed paths yielded so far.
642    pub paths_yielded: u64,
643    /// Stack-frame advances since the most recent yield: a large value with a
644    /// small `paths_yielded` is the no-yield straggler shape.
645    pub advances_since_yield: u64,
646    /// Peak DFS stack depth observed — distinguishes "stuck shallow" from
647    /// "grinding deep" on a real run.
648    pub max_stack_depth: usize,
649}
650
651/// One pending yield's expansion state: the walk's per-step candidate pool
652/// lists (kind-filtered), an odometer over those lists, and same-bundle
653/// injectivity validation. Each valid odometer state is one concrete pool
654/// assignment of the walk — the bundle-level equivalent of the per-pool
655/// DFS branching the old engine performed eagerly.
656struct WalkExpansion {
657    /// Step `i` of the walk used `slot_bundle[i]`'s bundle: assignments must
658    /// choose distinct pools across all slots of one bundle (the trail's
659    /// pools are distinct; different bundles share no pool by construction).
660    slot_bundle: Vec<u32>,
661    /// Per step: candidate pool indices, limited to pools whose kind passes
662    /// the depth's filter (full bundle list when unfiltered).
663    slots: Vec<Vec<u32>>,
664    /// Odometer position per slot; `chosen[i] = slots[i][cursor[i]]`.
665    cursor: Vec<usize>,
666    chosen: Vec<u32>,
667    started: bool,
668    finished: bool,
669}
670
671impl WalkExpansion {
672    /// Advance to the next concrete assignment (mixed-radix over the slot
673    /// candidate lists, skimming invalid same-bundle duplicates). On success
674    /// the step-ordered pool list is written into `out` (reusing its
675    /// capacity — the hot path allocates nothing per assignment).
676    fn next_assignment_into(&mut self, out: &mut Vec<u32>) -> bool {
677        let n = self.slots.len();
678        if n == 0 {
679            // An empty walk (min_depth == 0) has exactly one assignment.
680            if self.started {
681                return false;
682            }
683            self.started = true;
684            out.clear();
685            return true;
686        }
687        loop {
688            if self.finished {
689                return false;
690            }
691            if self.started {
692                // Increment the odometer from the last slot, carrying left;
693                // on a carry, higher slots reset to their first candidate.
694                let mut j = n;
695                loop {
696                    if j == 0 {
697                        self.finished = true;
698                        return false;
699                    }
700                    j -= 1;
701                    self.cursor[j] += 1;
702                    if self.cursor[j] < self.slots[j].len() {
703                        self.chosen[j] = self.slots[j][self.cursor[j]];
704                        for k in j + 1..n {
705                            self.cursor[k] = 0;
706                            self.chosen[k] = self.slots[k][0];
707                        }
708                        break;
709                    }
710                }
711            } else {
712                self.started = true;
713                for i in 0..n {
714                    self.cursor[i] = 0;
715                    self.chosen[i] = self.slots[i][0];
716                }
717            }
718            if self.same_bundle_distinct() {
719                out.clear();
720                out.extend_from_slice(&self.chosen);
721                return true;
722            }
723            // Invalid (two slots of one bundle chose the same pool): keep
724            // advancing. The walk-level kind check only proved a necessary
725            // condition; this is where exact feasibility is decided.
726        }
727    }
728
729    fn same_bundle_distinct(&self) -> bool {
730        for i in 0..self.slots.len() {
731            for j in i + 1..self.slots.len() {
732                if self.slot_bundle[i] == self.slot_bundle[j] && self.chosen[i] == self.chosen[j] {
733                    return false;
734                }
735            }
736        }
737        true
738    }
739}
740
741/// Outcome of one DFS advance: which path form (if any) is ready to yield.
742#[derive(PartialEq, Eq)]
743enum AdvanceOutcome {
744    /// The search is exhausted; no more paths.
745    Exhausted,
746    /// A complete walk expansion is ready (pools in `emitted`).
747    Forward,
748    /// The reversed form of the previous yield is ready.
749    Reversed,
750}
751
752/// Depth-first trail search over the **bundle graph**: all pools between the
753/// same unordered token pair form one bundle with multiplicity `m`, and the
754/// DFS walks token-pair BUNDLES (a per-bundle use counter instead of a
755/// per-pool visited mask), expanding each walk to its concrete pool
756/// assignments lazily at yield time.
757///
758/// Why: a hub token with `m` parallel pools to the same neighbor used to
759/// branch the entire remaining DFS subtree `m` times — the subtrees are
760/// token-wise identical, differing only in which pool each step consumed.
761/// The bundled walk explores the token shape once and enumerates the
762/// `m·(m-1)·…·(m-u+1)` ordered distinct-pool assignments (`u` = visits of
763/// that bundle) during expansion: exactly the multiset the per-pool DFS
764/// produced, in deterministic lexicographic pool order.
765///
766/// Correctness invariants (differential parity tests against the naive
767/// reference enumerator):
768/// - a trail's constraint is DISTINCT POOLS, not distinct tokens or
769///   bundles: a walk may revisit the same pair, consuming another pool;
770/// - the walk-level kind/nvd checks are necessary conditions only; exact
771///   per-step pool-kind feasibility is decided in the expansion;
772/// - hop-distance cutoffs, the sorted prefix break, `min_depth`, the
773///   `include_reverse` interleave, cancellation, and the discovery
774///   heartbeat all behave as before.
775pub struct BundledSearch<B: Borrow<PathGraph>> {
776    graph: B,
777    end: u32,
778    min_depth: usize,
779    effective_max_depth: Option<usize>,
780    include_reverse: bool,
781    pool_type_per_depth: Option<Vec<Option<Vec<PoolKind>>>>,
782    node_valid_depths: Option<Vec<Vec<bool>>>,
783    filter_len: usize,
784    /// Per-depth allowed-kind bitmask (a depth with no qualifying pool kind
785    /// prunes every step into it). Empty when no filter was supplied.
786    allowed_masks: Vec<u32>,
787    /// Bundle incidence per token — CSR over `(other token, bundle)` pairs,
788    /// stable-sorted by hop distance to `end` so the stop-admissible cutoff
789    /// is a prefix break. Member pools and kind masks live on the graph's
790    /// bundle layer (see `PathGraph::bundle_pools`).
791    tok_bundle_offsets: Vec<u32>,
792    tok_bundle_flat: Vec<(u32, u32)>,
793    /// Admissible cutoff data for the bounded search (None when unbounded).
794    prune: Option<SearchPrune>,
795    stack: Vec<(u32, usize, bool)>,
796    /// Bundle chosen at each walk step, parallel to the DFS path.
797    walk_bundles: Vec<u32>,
798    /// Active-walk use count per bundle: the `u`-th visit of a bundle
799    /// consumes the `(u+1)`-th distinct member pool, so a visit is only
800    /// possible while `use < multiplicity`.
801    bundle_use: Vec<u32>,
802    /// Flat per-bundle multiplicity (member pool count), copied from the
803    /// graph's bundle offsets so the hot capacity check is two flat reads.
804    bundle_mult: Vec<u32>,
805    /// Live expansion of the walk currently parked at the `end` token.
806    expansion: Option<WalkExpansion>,
807    /// Whether the reversed form of the most recent yield is still pending
808    /// emission (used only when `include_reverse` is set).
809    pending_reverse: bool,
810    /// Concrete pool indices of the most recent yield, in walk order.
811    emitted: Vec<u32>,
812    done: bool,
813    /// Cooperative cancellation flag (4IOEVT). When set, [`Self::advance`]
814    /// stops the search at its next loop iteration and reports exhaustion.
815    /// The async batch iterator's `Drop` impl sets it so a consumer that
816    /// abandons a sweep (aclose / GC) releases a mid-grind DFS promptly
817    /// instead of pinning a tokio worker until the search finishes.
818    cancel: Option<Arc<AtomicBool>>,
819    // --- discovery-phase heartbeat diagnostics ---
820    // A silently-stalled DFS grinds here with the GIL released. On the async
821    // path (4IOEVT) that grind runs on a tokio worker, so the Python event
822    // loop keeps turning and a Python-side progress log cannot reflect the
823    // DFS's internal progress. This heartbeat emits to stderr (GIL-free, zero
824    // deps) so a future zero-yield hang is visible at a glance, not just
825    // "78% CPU, no logs". Purely diagnostic — never alters `advance()`'s
826    // return values or enumeration order.
827    search_started: Instant,
828    paths_yielded: u64,
829    advances_since_yield: u64,
830    last_heartbeat: Instant,
831    /// Peak DFS stack depth observed — distinguishes "stuck shallow" (ordering
832    /// gap) from "grinding deep" (graph-size variance) on a real run.
833    max_stack_depth: usize,
834    /// Caller-installed long-run observation hook; when present it
835    /// replaces the default stderr heartbeat as the emit channel.
836    progress: Option<Box<ProgressHook>>,
837    /// Wall clock between progress reports. The hook's interval when a hook
838    /// is installed; [`DISCOVERY_HEARTBEAT`] for the default stderr line.
839    progress_every: Duration,
840}
841
842/// Caller-installed long-run observation hook ([`WalkerTally`]); when
843/// present it replaces the default stderr heartbeat as the emit channel.
844type ProgressHook = dyn FnMut(&WalkerTally) + Send + Sync;
845
846/// Unordered compact token pair packed into one u64 (identity-hashed keys).
847#[inline]
848fn pack_token_pair(a: u32, b: u32) -> u64 {
849    (u64::from(a.min(b)) << 32) | u64::from(a.max(b))
850}
851
852impl<B: Borrow<PathGraph>> BundledSearch<B> {
853    /// Assemble from explicit parameters (both public constructors funnel
854    /// here). The filter's `node_valid_depths` lookahead table is passed in
855    /// precomputed so the borrowing and owning constructors can differ in
856    /// where it comes from.
857    #[expect(clippy::too_many_arguments)]
858    fn with_params(
859        graph: B,
860        start: u64,
861        end: u64,
862        min_depth: usize,
863        max_depth: Option<usize>,
864        include_reverse: bool,
865        pool_type_per_depth: Option<Vec<Option<Vec<PoolKind>>>>,
866        node_valid_depths: Option<Vec<Vec<bool>>>,
867    ) -> Self {
868        let effective_max_depth: Option<usize> = match &pool_type_per_depth {
869            Some(filter) => {
870                let filter_len = filter.len();
871                match max_depth {
872                    Some(md) => Some(md.min(filter_len)),
873                    None => Some(filter_len),
874                }
875            }
876            None => max_depth,
877        };
878
879        let filter_len = pool_type_per_depth.as_ref().map_or(0, Vec::len);
880
881        let src = graph.borrow();
882
883        // Remap external start/end token IDs to compact indices. If EITHER
884        // boundary token is absent from the graph, no start->end path exists
885        // - yield nothing. `end` must NOT fall back to a synthetic index:
886        // remapping an absent end to compact index 0 made the DFS search for
887        // cycles ending at an unrelated token (compact 0), yielding
888        // non-closing paths that tripped the direction-resolution fail-stop.
889        let start_idx = src.compact_index(start);
890        let end_idx = src.compact_index(end);
891        let boundaries_present = matches!((start_idx, end_idx), (Some(_), Some(_)));
892        let end_idx = end_idx.unwrap_or(0);
893
894        // Admissible prune data (single BFS distance table). Only meaningful
895        // for a bounded search; an unbounded one has no budget to cut against.
896        let prune = match effective_max_depth {
897            Some(_) if boundaries_present => Some(SearchPrune::build(src, end_idx)),
898            _ => None,
899        };
900
901        // Per-depth allowed-kind bitmasks (0 bits = no pool qualifies there).
902        let allowed_masks: Vec<u32> = pool_type_per_depth
903            .as_ref()
904            .map(|filter| {
905                filter
906                    .iter()
907                    .map(|allowed| match allowed {
908                        None => u32::MAX,
909                        Some(kinds) => kinds.iter().fold(0u32, |acc, k| acc | (1u32 << k.as_u8())),
910                    })
911                    .collect()
912            })
913            .unwrap_or_default();
914
915        // Per-token bundle incidence, stable-sorted by hop distance to the
916        // target (ties keep bundle first-appearance order), so the
917        // stop-admissible cutoff reduces to a prefix break: at a hub with
918        // 10^4+ parallel pools and 1 hop of budget left, the scan stops
919        // after the handful of target-adjacent bundles. The CSR comes from
920        // the graph (built once at construction); only the memcpy'd flat
921        // array gets re-ordered per search.
922        let (tok_bundle_offsets, mut tok_bundle_flat) = src.token_bundles_csr();
923        if let Some(prune_data) = prune.as_ref() {
924            for w in 0..src.nodes() {
925                let start = tok_bundle_offsets[w] as usize;
926                let stop = tok_bundle_offsets[w + 1] as usize;
927                tok_bundle_flat[start..stop]
928                    .sort_by_key(|(nbr, _)| prune_data.to_end[*nbr as usize]);
929            }
930        }
931
932        let n_bundles = src.bundle_pairs.len();
933        let bundle_mult: Vec<u32> = src.bundle_offsets.windows(2).map(|w| w[1] - w[0]).collect();
934        let now = Instant::now();
935        let (stack, done) = if boundaries_present {
936            (vec![(start_idx.unwrap_or(0), 0, false)], false)
937        } else {
938            (Vec::new(), true)
939        };
940
941        Self {
942            graph,
943            end: end_idx,
944            min_depth,
945            effective_max_depth,
946            include_reverse,
947            pool_type_per_depth,
948            node_valid_depths,
949            filter_len,
950            allowed_masks,
951            tok_bundle_offsets,
952            tok_bundle_flat,
953            prune,
954            stack,
955            walk_bundles: Vec::with_capacity(16),
956            bundle_mult,
957            bundle_use: vec![0; n_bundles],
958            expansion: None,
959            pending_reverse: false,
960            emitted: Vec::new(),
961            done,
962            cancel: None,
963            search_started: now,
964            paths_yielded: 0,
965            advances_since_yield: 0,
966            last_heartbeat: now,
967            max_stack_depth: 0,
968            progress: None,
969            progress_every: DISCOVERY_HEARTBEAT,
970        }
971    }
972
973    /// Install a long-run observation hook on top of the default heartbeat.
974    ///
975    /// The hook replaces the stderr line as the emit channel: once `every` of
976    /// wall clock has passed on a heartbeat checkpoint (every
977    /// [`HEARTBEAT_CHECK_EVERY`] stack-frame advances), the walk's
978    /// [`WalkerTally`] snapshot is handed to `sink`. Callers that run walks
979    /// to completion (no cancellation) use this to surface no-yield
980    /// stragglers — the tally's `advances_since_yield` is large precisely
981    /// when the DFS is grinding with nothing ready to return.
982    #[must_use]
983    pub fn with_progress(
984        mut self,
985        every: Duration,
986        sink: impl FnMut(&WalkerTally) + Send + Sync + 'static,
987    ) -> Self {
988        self.progress = Some(Box::new(sink));
989        self.progress_every = every;
990        self
991    }
992
993    /// Attach a cooperative cancellation flag checked on every DFS advance.
994    ///
995    /// The flag is owned by the caller (the async batch iterator's `Drop`
996    /// sets it), so a consumer that drops the iterator mid-search stops the
997    /// DFS at its next loop iteration instead of running to completion.
998    #[must_use]
999    pub fn with_cancel(mut self, cancel: Arc<AtomicBool>) -> Self {
1000        self.cancel = Some(cancel);
1001        self
1002    }
1003
1004    /// Whether the attached cancellation flag has been set.
1005    #[must_use]
1006    fn cancelled(&self) -> bool {
1007        self.cancel
1008            .as_ref()
1009            .is_some_and(|c| c.load(Ordering::Relaxed))
1010    }
1011
1012    /// Advance the DFS by one yield without materializing the path.
1013    ///
1014    /// Returns [`AdvanceOutcome::Forward`] when a complete cycle is ready
1015    /// (read it from `emitted`), [`AdvanceOutcome::Reversed`] when the
1016    /// pending reverse of the previous yield is ready, or
1017    /// [`AdvanceOutcome::Exhausted`] when the search is done. Shared DFS
1018    /// core: `next_path` (materializing `EdgeKey`s) and
1019    /// `next_path_indices_into` (appending pool indices) dispatch through it.
1020    #[expect(clippy::too_many_lines)]
1021    fn advance(&mut self) -> AdvanceOutcome {
1022        if self.done || self.cancelled() {
1023            self.done = true;
1024            return AdvanceOutcome::Exhausted;
1025        }
1026
1027        let filter_slice = self.pool_type_per_depth.as_deref();
1028        let nvd_ref = self.node_valid_depths.as_deref();
1029        let masks: &[u32] = &self.allowed_masks;
1030        let prune_ref = self.prune.as_ref();
1031        let src: &PathGraph = self.graph.borrow();
1032
1033        loop {
1034            // 1. Emit from a live expansion before doing any further DFS
1035            //    work: forward and reversed assignments of the parked walk
1036            //    interleave exactly as the old per-pool engine interleaved
1037            //    forwards and reverses.
1038            if self.expansion.is_some() {
1039                if self.pending_reverse {
1040                    self.pending_reverse = false;
1041                    self.paths_yielded += 1;
1042                    self.advances_since_yield = 0;
1043                    return AdvanceOutcome::Reversed;
1044                }
1045                let next_ready = match self.expansion.as_mut() {
1046                    Some(exp) => exp.next_assignment_into(&mut self.emitted),
1047                    None => false,
1048                };
1049                if next_ready {
1050                    self.pending_reverse = self.include_reverse;
1051                    self.paths_yielded += 1;
1052                    self.advances_since_yield = 0;
1053                    return AdvanceOutcome::Forward;
1054                }
1055                // Expansion exhausted: resume walking from the end frame
1056                // (its yield flag is consumed; scanning continues).
1057                self.expansion = None;
1058            }
1059
1060            // 2. Walk the bundle DFS until the next expansion begins.
1061            loop {
1062                // Cooperative cancellation: a set flag stops the search at
1063                // the next loop iteration.
1064                if self
1065                    .cancel
1066                    .as_ref()
1067                    .is_some_and(|c| c.load(Ordering::Relaxed))
1068                {
1069                    break;
1070                }
1071
1072                let stack_len = self.stack.len();
1073                if stack_len == 0 {
1074                    break;
1075                }
1076
1077                // Discovery heartbeat: amortized (checked every
1078                // `HEARTBEAT_CHECK_EVERY` stack-frame iterations, not per
1079                // edge) so `Instant::now` stays out of the hot inner loop.
1080                self.advances_since_yield = self.advances_since_yield.wrapping_add(1);
1081                if stack_len > self.max_stack_depth {
1082                    self.max_stack_depth = stack_len;
1083                }
1084                if self
1085                    .advances_since_yield
1086                    .is_multiple_of(HEARTBEAT_CHECK_EVERY)
1087                {
1088                    let now = Instant::now();
1089                    if now.duration_since(self.last_heartbeat) >= self.progress_every {
1090                        self.last_heartbeat = now;
1091                        let tally = WalkerTally {
1092                            elapsed: now.duration_since(self.search_started),
1093                            paths_yielded: self.paths_yielded,
1094                            advances_since_yield: self.advances_since_yield,
1095                            max_stack_depth: self.max_stack_depth,
1096                        };
1097                        match self.progress.as_mut() {
1098                            Some(sink) => sink(&tally),
1099                            // Default diagnostic on a zero-dependency leaf; no
1100                            // logging crate is available and this runs off the
1101                            // hot path.
1102                            None => {
1103                                #[expect(clippy::print_stderr)]
1104                                {
1105                                    eprintln!(
1106                                        "discovery heartbeat: elapsed={:?} \
1107                                         paths_yielded={} advances_since_yield={} max_stack_depth={}",
1108                                        tally.elapsed, tally.paths_yielded,
1109                                        tally.advances_since_yield, tally.max_stack_depth
1110                                    );
1111                                }
1112                            }
1113                        }
1114                    }
1115                }
1116
1117                let frame = &mut self.stack[stack_len - 1];
1118                let (node, edge_idx, yield_checked) = frame;
1119
1120                // Check yield condition (once per frame arrival): the walk
1121                // reached the end token with enough pools. The expansion
1122                // emits canonical forward assignment(s) — one concrete path
1123                // per call — starting on the next outer-loop iteration.
1124                if !*yield_checked {
1125                    *yield_checked = true;
1126                    if *node == self.end && self.walk_bundles.len() >= self.min_depth {
1127                        let expansion = self.build_expansion();
1128                        self.expansion = Some(expansion);
1129                        break;
1130                    }
1131                }
1132
1133                // Stop recursion if the walk has reached the maximum depth.
1134                if let Some(emd) = self.effective_max_depth {
1135                    if self.walk_bundles.len() >= emd {
1136                        // Backtrack.
1137                        self.stack.pop();
1138                        if let Some(bundle) = self.walk_bundles.pop() {
1139                            self.bundle_use[bundle as usize] -= 1;
1140                        }
1141                        continue;
1142                    }
1143                }
1144
1145                // Remaining hop budget for the step chosen at THIS node
1146                // visit. A trail extended by one bundle must still reach the
1147                // target within `emd - len - 1` hops; the BFS hop distance is
1148                // a lower bound on how many hops that takes, so the cutoff
1149                // never discards a yieldable trail. Len < emd is guaranteed
1150                // by the max-depth backtrack above, so the subtraction cannot
1151                // underflow. (At the closing depth the remaining budget is 0,
1152                // and since `end` is the unique token at hop distance 0, the
1153                // prefix break admits only target-adjacent bundles — the
1154                // old engine's separate final-hop end-edge list is implicit.)
1155                let remaining_budget = self.effective_max_depth.map(|emd| {
1156                    u32::try_from(emd - self.walk_bundles.len() - 1).unwrap_or(u32::MAX)
1157                });
1158                let entry_base = self.tok_bundle_offsets[*node as usize] as usize;
1159                let entries_len = self.tok_bundle_offsets[*node as usize + 1] as usize - entry_base;
1160                let mut found_bundle = false;
1161                while *edge_idx < entries_len {
1162                    let (nbr, bundle) = self.tok_bundle_flat[entry_base + *edge_idx];
1163
1164                    // Sorted-prefix break: adjacency is ordered by hop
1165                    // distance, so the first neighbor beyond the remaining
1166                    // budget terminates the scan: every later neighbor is at
1167                    // least as far, and none of them can be on a yieldable
1168                    // trail.
1169                    if let Some(prune_data) = prune_ref {
1170                        if prune_data.to_end[nbr as usize] > remaining_budget.unwrap_or(u32::MAX) {
1171                            break;
1172                        }
1173                    }
1174
1175                    *edge_idx += 1;
1176
1177                    // Capacity: the `u`-th visit of a bundle consumes another
1178                    // distinct member pool, so a visit is only possible while
1179                    // the count is below the multiplicity.
1180                    if self.bundle_use[bundle as usize] >= self.bundle_mult[bundle as usize] {
1181                        continue;
1182                    }
1183
1184                    // Per-depth pool-type filter (walk-level necessary
1185                    // condition; the expansion enforces the exact sets).
1186                    if filter_slice.is_some() {
1187                        let depth = self.walk_bundles.len();
1188                        if depth >= self.filter_len {
1189                            continue;
1190                        }
1191                        if src.bundle_kind_mask(bundle) & masks.get(depth).copied().unwrap_or(0)
1192                            == 0
1193                        {
1194                            continue;
1195                        }
1196
1197                        // Lookahead pruning: skip if the neighbor token
1198                        // can't continue at the next depth.
1199                        let next_depth = depth + 1;
1200                        if next_depth < self.filter_len {
1201                            if let Some(valid) = nvd_ref.and_then(|nvd| nvd.get(nbr as usize)) {
1202                                if !valid[next_depth] {
1203                                    continue;
1204                                }
1205                            }
1206                        }
1207                    }
1208
1209                    // Found a bundle — extend the walk and descend.
1210                    self.stack.push((nbr, 0, false));
1211                    self.walk_bundles.push(bundle);
1212                    self.bundle_use[bundle as usize] += 1;
1213                    found_bundle = true;
1214                    break;
1215                }
1216
1217                if !found_bundle {
1218                    // No more bundles to explore from this token — backtrack.
1219                    self.stack.pop();
1220                    if let Some(bundle) = self.walk_bundles.pop() {
1221                        self.bundle_use[bundle as usize] -= 1;
1222                    }
1223                }
1224            }
1225
1226            // A yield fired mid-walk and parked an expansion on the finder:
1227            // return to the emission dispatcher at the top of the outer loop
1228            // instead of exhausting.
1229            if self.expansion.is_some() {
1230                continue;
1231            }
1232
1233            // Search exhausted (or cancelled) — emit the final status line.
1234            self.emit_discovery_complete();
1235            self.done = true;
1236            return AdvanceOutcome::Exhausted;
1237        }
1238    }
1239
1240    /// Build the expansion for a walk parked at `end`: per-step candidate
1241    /// pools limited to the depth's allowed kinds (the full bundle when
1242    /// unfiltered).
1243    fn build_expansion(&self) -> WalkExpansion {
1244        let graph = self.graph.borrow();
1245        let filtered = self.pool_type_per_depth.is_some();
1246        let slots: Vec<Vec<u32>> = self
1247            .walk_bundles
1248            .iter()
1249            .enumerate()
1250            .map(|(depth, &bundle)| {
1251                if !filtered {
1252                    return graph.bundle_pools(bundle).to_vec();
1253                }
1254                let mask = self.allowed_masks.get(depth).copied().unwrap_or(0);
1255                graph
1256                    .bundle_pools(bundle)
1257                    .iter()
1258                    .copied()
1259                    .filter(|&pool_idx| {
1260                        (1u32 << graph.pools[pool_idx as usize].1.as_u8()) & mask != 0
1261                    })
1262                    .collect()
1263            })
1264            .collect();
1265        let n = slots.len();
1266        WalkExpansion {
1267            slot_bundle: self.walk_bundles.clone(),
1268            slots,
1269            cursor: vec![0; n],
1270            chosen: vec![0; n],
1271            started: false,
1272            finished: false,
1273        }
1274    }
1275
1276    /// Emit a final discovery-complete line so the operator sees the total at
1277    /// search end (cheap; covers the common fast-search case that never
1278    /// tripped the throttled heartbeat).
1279    fn emit_discovery_complete(&self) {
1280        let elapsed = self.search_started.elapsed();
1281        // Low-frequency stderr diagnostic on a zero-dependency leaf (no
1282        // logging crate available); one line at discovery completion.
1283        #[expect(clippy::print_stderr)]
1284        {
1285            eprintln!(
1286                "discovery complete: elapsed={elapsed:?} paths_yielded={} max_stack_depth={}",
1287                self.paths_yielded, self.max_stack_depth
1288            );
1289        }
1290    }
1291
1292    /// Advance the DFS and return the next complete path, or `None` if the
1293    /// search is exhausted.
1294    ///
1295    /// If `include_reverse` is set, each found cycle yields the forward path
1296    /// first, then the reversed path on the next call.
1297    #[must_use]
1298    pub fn next_path(&mut self) -> Option<Vec<EdgeKey>> {
1299        match self.advance() {
1300            AdvanceOutcome::Exhausted => None,
1301            AdvanceOutcome::Forward => Some(
1302                self.emitted
1303                    .iter()
1304                    .map(|&idx| self.graph.borrow().pools[idx as usize])
1305                    .collect(),
1306            ),
1307            AdvanceOutcome::Reversed => Some(
1308                self.emitted
1309                    .iter()
1310                    .rev()
1311                    .map(|&idx| self.graph.borrow().pools[idx as usize])
1312                    .collect(),
1313            ),
1314        }
1315    }
1316
1317    /// Advance the DFS and append the next path's **pool indices** into
1318    /// `out`, returning the number of indices appended (the path length), or
1319    /// `None` if the search is exhausted.
1320    ///
1321    /// This is the allocation-free hot path used by the `PyO3` iterator:
1322    /// instead of materializing a `Vec<EdgeKey>` per yielded path, it
1323    /// appends the compact `u32` pool indices into a caller-owned flat
1324    /// buffer. The FFI layer converts indices → `(pool_id, kind_u8)` lazily
1325    /// while building Python objects.
1326    #[must_use]
1327    pub fn next_path_indices_into(&mut self, out: &mut Vec<u32>) -> Option<usize> {
1328        match self.advance() {
1329            AdvanceOutcome::Exhausted => None,
1330            AdvanceOutcome::Forward => {
1331                let len = self.emitted.len();
1332                out.extend(self.emitted.iter().copied());
1333                Some(len)
1334            }
1335            AdvanceOutcome::Reversed => {
1336                let len = self.emitted.len();
1337                out.extend(self.emitted.iter().rev().copied());
1338                Some(len)
1339            }
1340        }
1341    }
1342
1343    /// Resolve a compact pool index to its `EdgeKey` `(pool_id, PoolKind)`.
1344    ///
1345    /// Lets the `PyO3` layer convert buffered pool indices to Python tuples
1346    /// without exposing the graph's internal `pools` field.
1347    #[must_use]
1348    pub fn pool_edge_key(&self, pool_idx: u32) -> EdgeKey {
1349        self.graph.borrow().pools[pool_idx as usize]
1350    }
1351}
1352
1353impl<B: Borrow<PathGraph>> Iterator for BundledSearch<B> {
1354    type Item = Vec<EdgeKey>;
1355
1356    fn next(&mut self) -> Option<Self::Item> {
1357        self.next_path()
1358    }
1359}
1360
1361/// Borrowing lazy DFS over a shared [`PathGraph`] (the bundled core; see
1362/// [`BundledSearch`] for the invariants and the bundling rationale).
1363pub type PathFinder<'a> = BundledSearch<&'a PathGraph>;
1364
1365/// Owning lazy DFS — the PyO3-friendly form (no lifetime parameters, so it
1366/// can be stored in a `#[pyclass]` and iterated from Python one path at a
1367/// time). The graph, filter, and node-valid-depths are owned by the finder.
1368pub type OwnedPathFinder = BundledSearch<Box<PathGraph>>;
1369
1370impl BundledSearch<Box<PathGraph>> {
1371    /// Create from an owned graph plus search parameters. Computes the
1372    /// lookahead `node_valid_depths` table from the filter.
1373    #[must_use]
1374    pub fn new(
1375        graph: PathGraph,
1376        start: u64,
1377        end: u64,
1378        min_depth: usize,
1379        max_depth: Option<usize>,
1380        include_reverse: bool,
1381        pool_type_per_depth: Option<Vec<Option<Vec<PoolKind>>>>,
1382    ) -> Self {
1383        let node_valid_depths = pool_type_per_depth
1384            .as_ref()
1385            .map(|filter| graph.compute_node_valid_depths(filter));
1386        Self::with_params(
1387            Box::new(graph),
1388            start,
1389            end,
1390            min_depth,
1391            max_depth,
1392            include_reverse,
1393            pool_type_per_depth,
1394            node_valid_depths,
1395        )
1396    }
1397}
1398impl PathGraph {
1399    /// Create a lazy iterator over all valid paths from `start` back to `end`.
1400    ///
1401    /// This is a stateful, resumable version of the DFS. The iterator yields
1402    /// one path at a time, avoiding the memory cost of collecting all results
1403    /// into a `Vec`. Use this when the graph may produce a large number of
1404    /// paths.
1405    #[expect(clippy::too_many_arguments)]
1406    #[must_use]
1407    pub fn find_paths_iter<'a>(
1408        &'a self,
1409        start: u64,
1410        end: u64,
1411        min_depth: usize,
1412        max_depth: Option<usize>,
1413        include_reverse: bool,
1414        pool_type_per_depth: Option<&'a [Option<Vec<PoolKind>>]>,
1415        node_valid_depths: Option<&'a [Vec<bool>]>,
1416    ) -> PathFinder<'a> {
1417        let filter = pool_type_per_depth.map(<[Option<Vec<PoolKind>>]>::to_vec);
1418        let nvd = node_valid_depths.map(<[Vec<bool>]>::to_vec);
1419        BundledSearch::with_params(
1420            self,
1421            start,
1422            end,
1423            min_depth,
1424            max_depth,
1425            include_reverse,
1426            filter,
1427            nvd,
1428        )
1429    }
1430
1431    /// Depth-first search for all valid paths from `start` back to `end`.
1432    ///
1433    /// This is an eager version that collects all results. For large graphs
1434    /// that may produce millions of paths, use [`PathGraph::find_paths_iter`]
1435    /// instead to avoid excessive memory usage.
1436    ///
1437    /// # Arguments
1438    /// * `start` — The token ID where the search begins.
1439    /// * `end` — The token ID the path must return to.
1440    /// * `min_depth` — Minimum number of hops in a completed path.
1441    /// * `max_depth` — Maximum number of hops, or `None` for no limit.
1442    /// * `include_reverse` — If `true`, yield each found path again reversed.
1443    /// * `pool_type_per_depth` — Optional per-depth allowed pool kinds. A
1444    ///   `None` entry allows all kinds at that depth. Implicitly caps max
1445    ///   depth at its length.
1446    /// * `node_valid_depths` — Optional precomputed valid-depth sets (from
1447    ///   `compute_node_valid_depths`) for lookahead pruning.
1448    ///
1449    /// # Returns
1450    /// A `Vec` of paths, each a `Vec` of `(pool_id, PoolKind)` hops.
1451    #[expect(clippy::too_many_arguments)]
1452    #[must_use]
1453    pub fn find_paths(
1454        &self,
1455        start: u64,
1456        end: u64,
1457        min_depth: usize,
1458        max_depth: Option<usize>,
1459        include_reverse: bool,
1460        pool_type_per_depth: Option<&[Option<Vec<PoolKind>>]>,
1461        node_valid_depths: Option<&[Vec<bool>]>,
1462    ) -> Vec<Vec<EdgeKey>> {
1463        self.find_paths_iter(
1464            start,
1465            end,
1466            min_depth,
1467            max_depth,
1468            include_reverse,
1469            pool_type_per_depth,
1470            node_valid_depths,
1471        )
1472        .collect()
1473    }
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478    use super::*;
1479    use std::collections::{BTreeSet, HashSet};
1480    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1481    use std::sync::Arc;
1482
1483    // Token IDs for the synthetic 4-pool V2 fixture (mirrors the in-memory
1484    // DB fixture from test_permutation_filter_min_depth.py).
1485    // Graph:
1486    //     WETH ===pool1=== A
1487    //     WETH ===pool2=== A      (parallel edge -> 2-hop cycle WETH-A-WETH)
1488    //     A     ===pool3=== B
1489    //     B     ===pool4=== WETH   (completes 3-hop cycle WETH-A-B-WETH)
1490    const WETH: u64 = 1;
1491    const A: u64 = 2;
1492    const B: u64 = 3;
1493    const POOL_WETH_A_1: u64 = 100;
1494    const POOL_WETH_A_2: u64 = 101;
1495    const POOL_A_B: u64 = 102;
1496    const POOL_B_WETH: u64 = 103;
1497
1498    fn build_fixture_graph() -> PathGraph {
1499        PathGraph::from_edges(vec![
1500            (WETH, A, POOL_WETH_A_1, PoolKind::V2),
1501            (WETH, A, POOL_WETH_A_2, PoolKind::V2),
1502            (A, B, POOL_A_B, PoolKind::V2),
1503            (B, WETH, POOL_B_WETH, PoolKind::V2),
1504        ])
1505    }
1506
1507    fn edges_to_pool_ids(path: &[EdgeKey]) -> Vec<u64> {
1508        path.iter().map(|(pid, _)| *pid).collect()
1509    }
1510
1511    #[test]
1512    fn test_from_edges_builds_adjacency() {
1513        let graph = build_fixture_graph();
1514        assert_eq!(graph.node_count(), 3); // WETH, A, B
1515        assert!(graph.contains_node(WETH));
1516        assert!(graph.contains_node(A));
1517        assert!(graph.contains_node(B));
1518    }
1519
1520    #[test]
1521    fn test_parallel_edges_preserved() {
1522        let graph = build_fixture_graph();
1523        // WETH has 3 edges: pool1->A, pool2->A, pool4->B
1524        assert_eq!(graph.degree(WETH), Some(3));
1525        // A has 3 edges: pool1->WETH, pool2->WETH, pool3->B
1526        assert_eq!(graph.degree(A), Some(3));
1527    }
1528
1529    #[test]
1530    fn test_prune_dead_ends() {
1531        // Build a graph with a dead-end chain: A-B-C where C only connects to B.
1532        // After pruning, C (degree 1) is removed, then B (now degree 1) is removed.
1533        let mut graph = PathGraph::from_edges(vec![
1534            (A, B, 1, PoolKind::V2),
1535            (B, 99, 2, PoolKind::V2), // 99 is a dead end (degree 1)
1536        ]);
1537        graph.prune_dead_ends();
1538        // 99 is removed (degree 1). Then B has degree 1 (only edge to A).
1539        // Wait — A-B is bidirectional, so A has 1 edge (to B) and B has 1 edge
1540        // (to A) after 99 is removed. Both get pruned.
1541        // Actually: A has edges [B], B has edges [A, 99]. 99 has edges [B].
1542        // 99 (degree 1) pruned. Now B has edges [A] (degree 1) pruned.
1543        // Now A has edges [B] but B is removed, so A has 0 edges pruned.
1544        assert!(!graph.contains_node(99));
1545        assert!(!graph.contains_node(B));
1546        assert!(!graph.contains_node(A));
1547    }
1548
1549    #[test]
1550    fn test_prune_preserves_cycle() {
1551        // The fixture graph has cycles; pruning should not remove WETH, A, B.
1552        let mut graph = build_fixture_graph();
1553        graph.prune_dead_ends();
1554        assert!(graph.contains_node(WETH));
1555        assert!(graph.contains_node(A));
1556        assert!(graph.contains_node(B));
1557    }
1558
1559    #[test]
1560    fn test_two_hop_pathfinding() {
1561        // WETH -> A -> WETH (2-hop cycle via parallel edges)
1562        let graph = build_fixture_graph();
1563        let paths = graph.find_paths(WETH, WETH, 2, Some(2), false, None, None);
1564        assert!(!paths.is_empty(), "Should find 2-hop WETH cycles");
1565        for path in &paths {
1566            assert_eq!(path.len(), 2, "Each path should be exactly 2 hops");
1567        }
1568    }
1569
1570    #[test]
1571    fn test_three_hop_pathfinding() {
1572        // WETH -> A -> B -> WETH (3-hop cycle)
1573        let graph = build_fixture_graph();
1574        let paths = graph.find_paths(WETH, WETH, 3, Some(3), false, None, None);
1575        assert!(!paths.is_empty(), "Should find 3-hop WETH cycles");
1576        for path in &paths {
1577            assert_eq!(path.len(), 3, "Each path should be exactly 3 hops");
1578        }
1579    }
1580
1581    #[test]
1582    fn test_min_depth_excludes_shorter() {
1583        // With min_depth=3, no 2-hop paths should be yielded.
1584        let graph = build_fixture_graph();
1585        let paths = graph.find_paths(WETH, WETH, 3, Some(3), false, None, None);
1586        for path in &paths {
1587            assert_eq!(path.len(), 3, "min_depth=3 should exclude shorter paths");
1588        }
1589    }
1590
1591    #[test]
1592    fn test_max_depth_caps() {
1593        // With max_depth=2, no 3-hop paths.
1594        let graph = build_fixture_graph();
1595        let paths = graph.find_paths(WETH, WETH, 2, Some(2), false, None, None);
1596        for path in &paths {
1597            assert!(path.len() <= 2, "max_depth=2 should cap path length");
1598        }
1599    }
1600
1601    #[test]
1602    fn test_include_reverse_doubles_output() {
1603        let graph = build_fixture_graph();
1604        let forward = graph.find_paths(WETH, WETH, 2, Some(2), false, None, None);
1605        let with_reverse = graph.find_paths(WETH, WETH, 2, Some(2), true, None, None);
1606        assert_eq!(
1607            with_reverse.len(),
1608            forward.len() * 2,
1609            "include_reverse should double the output count"
1610        );
1611    }
1612
1613    #[test]
1614    fn test_absent_end_token_yields_no_paths() {
1615        // A boundary token that is NOT a node in the (filtered) graph - e.g. a
1616        // NATIVE/0x0 token with no connecting pool among the candidate edges -
1617        // must yield NO paths. Regression: the old
1618        // `compact_index(end).unwrap_or(0)` silently remapped an absent end to
1619        // compact index 0, so a "WETH -> <absent-token>" search actually ran as
1620        // a "<index-0> -> ..." search and emitted non-closing cycles that
1621        // tripped the direction-resolution fail-stop on the live bot.
1622        let graph = build_fixture_graph();
1623        // start present, end absent -> no paths.
1624        let paths = graph.find_paths(WETH, 9999, 3, Some(3), true, None, None);
1625        assert!(paths.is_empty(), "absent end token must yield no paths");
1626        // start absent -> no paths.
1627        let paths_start = graph.find_paths(9999, WETH, 3, Some(3), true, None, None);
1628        assert!(
1629            paths_start.is_empty(),
1630            "absent start token must yield no paths"
1631        );
1632        // both absent -> no paths.
1633        let paths_both = graph.find_paths(9999, 9998, 3, Some(3), true, None, None);
1634        assert!(
1635            paths_both.is_empty(),
1636            "absent start+end must yield no paths"
1637        );
1638        // Sanity: a present end still yields its cycles.
1639        let ok = graph.find_paths(WETH, WETH, 3, Some(3), false, None, None);
1640        assert!(!ok.is_empty(), "present end (WETH) must still yield cycles");
1641    }
1642
1643    #[test]
1644    fn test_three_hop_filter_yields_no_two_hop_cycles() {
1645        // A 3-depth V2-V2-V2 filter must yield only 3-hop paths.
1646        // The synthetic graph contains both a 2-hop cycle (WETH-A-WETH via
1647        // parallel pools) and a 3-hop cycle (WETH-A-B-WETH). The 2-hop cycle
1648        // matches the filter's depths 0 and 1, so without the implicit
1649        // min_depth floor from the filter length, it would leak through.
1650        let graph = build_fixture_graph();
1651        let filter = vec![
1652            Some(vec![PoolKind::V2]),
1653            Some(vec![PoolKind::V2]),
1654            Some(vec![PoolKind::V2]),
1655        ];
1656        let nvd = graph.compute_node_valid_depths(&filter);
1657        let _paths = graph.find_paths(
1658            WETH,
1659            WETH,
1660            2,       // caller min_depth
1661            Some(3), // caller max_depth
1662            false,
1663            Some(&filter),
1664            Some(&nvd),
1665        );
1666
1667        // The filter caps max_depth at 3 and the effective min_depth should
1668        // be max(2, 3) = 3 (floor applied by the Python caller). But the Rust
1669        // core does NOT apply the floor — the caller does. Here we test with
1670        // min_depth=2 to verify that the filter alone does not leak 2-hop
1671        // paths... actually, it CAN leak 2-hop paths if min_depth=2.
1672        //
1673        // The Python find_paths applies: effective_min_depth = max(min_depth,
1674        // len(pool_type_per_depth)). So the caller would pass min_depth=3.
1675        // Let's test that explicitly:
1676        let paths_floored = graph.find_paths(
1677            WETH,
1678            WETH,
1679            3, // effective min_depth = max(2, 3) = 3
1680            Some(3),
1681            false,
1682            Some(&filter),
1683            Some(&nvd),
1684        );
1685        for path in &paths_floored {
1686            assert_eq!(
1687                path.len(),
1688                3,
1689                "3-depth filter with min_depth=3 should yield only 3-hop paths"
1690            );
1691        }
1692        assert!(
1693            !paths_floored.is_empty(),
1694            "3-depth filter should yield at least one 3-hop path"
1695        );
1696    }
1697
1698    #[test]
1699    fn test_pool_type_per_depth_caps_max_depth() {
1700        // A 2-depth filter with max_depth=3 must not IndexError and must
1701        // cap at 2-hop paths.
1702        let graph = build_fixture_graph();
1703        let filter = vec![Some(vec![PoolKind::V2]), Some(vec![PoolKind::V2])];
1704        let nvd = graph.compute_node_valid_depths(&filter);
1705        let paths = graph.find_paths(
1706            WETH,
1707            WETH,
1708            2,
1709            Some(3), // exceeds filter length
1710            false,
1711            Some(&filter),
1712            Some(&nvd),
1713        );
1714        for path in &paths {
1715            assert_eq!(
1716                path.len(),
1717                2,
1718                "2-depth filter should cap at 2-hop paths even with max_depth=3"
1719            );
1720        }
1721    }
1722
1723    #[test]
1724    fn test_pool_type_per_depth_with_max_depth_none() {
1725        // A 2-depth filter with max_depth=None must cap at 2-hop paths.
1726        let graph = build_fixture_graph();
1727        let filter = vec![Some(vec![PoolKind::V2]), Some(vec![PoolKind::V2])];
1728        let nvd = graph.compute_node_valid_depths(&filter);
1729        let paths = graph.find_paths(
1730            WETH,
1731            WETH,
1732            2,
1733            None, // no explicit max
1734            false,
1735            Some(&filter),
1736            Some(&nvd),
1737        );
1738        for path in &paths {
1739            assert_eq!(path.len(), 2);
1740        }
1741    }
1742
1743    #[test]
1744    fn test_none_entry_allows_all_kinds() {
1745        // A filter with None at depth 0 allows all pool kinds.
1746        let graph = build_fixture_graph();
1747        let filter = vec![None, Some(vec![PoolKind::V4])];
1748        let nvd = graph.compute_node_valid_depths(&filter);
1749        // The fixture has only V2 pools, and depth 1 requires V4.
1750        // So no V4 paths should be found (node_valid_depths will show A and B
1751        // are invalid at depth 1).
1752        let paths = graph.find_paths(WETH, WETH, 2, Some(2), false, Some(&filter), Some(&nvd));
1753        // No V4 pools exist, so no paths match the filter.
1754        assert!(
1755            paths.is_empty(),
1756            "V4 filter on V2-only graph should yield nothing"
1757        );
1758    }
1759
1760    #[test]
1761    fn test_cycle_detection_prevents_reusing_pools() {
1762        // A path must not visit the same pool twice.
1763        let graph = build_fixture_graph();
1764        let paths = graph.find_paths(WETH, WETH, 2, Some(3), false, None, None);
1765        for path in &paths {
1766            let pool_ids = edges_to_pool_ids(path);
1767            let unique: HashSet<u64> = pool_ids.iter().copied().collect();
1768            assert_eq!(
1769                pool_ids.len(),
1770                unique.len(),
1771                "Path should not reuse a pool: {pool_ids:?}"
1772            );
1773        }
1774    }
1775
1776    #[test]
1777    fn test_node_not_in_graph_returns_empty() {
1778        let graph = build_fixture_graph();
1779        let paths = graph.find_paths(999, 999, 2, Some(2), false, None, None);
1780        assert!(paths.is_empty());
1781    }
1782
1783    #[test]
1784    fn test_poolkind_roundtrip() {
1785        assert_eq!(PoolKind::V2.as_u8(), 0);
1786        assert_eq!(PoolKind::V3.as_u8(), 1);
1787        assert_eq!(PoolKind::V4.as_u8(), 2);
1788        assert_eq!(PoolKind::from_u8(0), Some(PoolKind::V2));
1789        assert_eq!(PoolKind::from_u8(1), Some(PoolKind::V3));
1790        assert_eq!(PoolKind::from_u8(2), Some(PoolKind::V4));
1791        assert_eq!(PoolKind::from_u8(3), None);
1792    }
1793
1794    #[test]
1795    fn test_mixed_pool_kinds() {
1796        // Build a graph with both V2 and V4 pools.
1797        // WETH --V2-- A --V4-- B --V2-- WETH (3-hop mixed cycle)
1798        let graph = PathGraph::from_edges(vec![
1799            (WETH, A, 1, PoolKind::V2),
1800            (A, B, 2, PoolKind::V4),
1801            (B, WETH, 3, PoolKind::V2),
1802        ]);
1803        let filter = vec![
1804            Some(vec![PoolKind::V2]),
1805            Some(vec![PoolKind::V4]),
1806            Some(vec![PoolKind::V2]),
1807        ];
1808        let nvd = graph.compute_node_valid_depths(&filter);
1809        let paths = graph.find_paths(WETH, WETH, 3, Some(3), false, Some(&filter), Some(&nvd));
1810        assert!(!paths.is_empty(), "Should find a V2-V4-V2 path");
1811        for path in &paths {
1812            assert_eq!(path.len(), 3);
1813            assert_eq!(path[0].1, PoolKind::V2);
1814            assert_eq!(path[1].1, PoolKind::V4);
1815            assert_eq!(path[2].1, PoolKind::V2);
1816        }
1817    }
1818
1819    #[test]
1820    fn test_cancel_flag_exhausts_search() {
1821        let graph = build_fixture_graph();
1822        let cancel = Arc::new(AtomicBool::new(false));
1823        let mut finder = OwnedPathFinder::new(graph, WETH, WETH, 2, Some(3), true, None)
1824            .with_cancel(Arc::clone(&cancel));
1825        assert!(finder.next_path().is_some(), "fixture must yield a path");
1826        cancel.store(true, Ordering::Release);
1827        assert!(
1828            finder.next_path().is_none(),
1829            "a set cancel flag must exhaust the search immediately"
1830        );
1831        assert!(finder.next_path().is_none());
1832    }
1833
1834    /// The borrowed walker (`find_paths_iter`) must support the same
1835    /// cooperative cancellation the owned one does: an armed flag before the
1836    /// first advance exhausts immediately, and a flag set mid-stream stops
1837    /// the search at its next loop iteration.
1838    #[test]
1839    fn test_borrowed_finder_cancel_before_first_advance() {
1840        let graph = build_fixture_graph();
1841        let cancel = Arc::new(AtomicBool::new(true));
1842        let mut finder = graph
1843            .find_paths_iter(WETH, WETH, 2, Some(3), true, None, None)
1844            .with_cancel(cancel);
1845        assert!(
1846            finder.next_path().is_none(),
1847            "an armed cancel flag must yield nothing"
1848        );
1849        assert!(finder.next_path().is_none(), "exhaustion is sticky");
1850    }
1851
1852    #[test]
1853    fn test_borrowed_finder_cancel_mid_stream() {
1854        let graph = build_fixture_graph();
1855        let cancel = Arc::new(AtomicBool::new(false));
1856        let mut finder = graph
1857            .find_paths_iter(WETH, WETH, 2, Some(3), true, None, None)
1858            .with_cancel(Arc::clone(&cancel));
1859        assert!(
1860            finder.next_path().is_some(),
1861            "the first cycle yields before any cancel"
1862        );
1863        cancel.store(true, Ordering::Release);
1864        assert!(
1865            finder.next_path().is_none(),
1866            "a flag set between advances stops the search promptly"
1867        );
1868    }
1869
1870    /// The discovery-heartbeat diagnostics + the `while let` → `loop`
1871    /// refactor of `OwnedPathFinder::advance` must not alter enumeration order
1872    /// or yield count. Two independent searches on the same graph must produce
1873    /// identical, stable output — the heartbeat is purely diagnostic stderr.
1874    /// A walk that grinds past the heartbeat checkpoint BEFORE its first
1875    /// yield reports through the caller's hook: hub-rooted walks (the
1876    /// production frame that anchored 0x1d8168b6…) run 9000 spokes before
1877    /// reaching the closing leaf, so a zero-interval hook observes un-yielded
1878    /// DFS work mid-walk with the running tally.
1879    #[test]
1880    fn test_progress_hook_fires_mid_walk_with_the_tally() {
1881        const SPOKES: u64 = 9000;
1882        const HUB: u64 = 10_000;
1883        const END: u64 = 11_000;
1884        let mut edges: Vec<(u64, u64, u64, PoolKind)> = Vec::new();
1885        let mut pool = 1000u64;
1886        for leaf in 1..=SPOKES {
1887            edges.push((HUB, HUB + leaf, pool, PoolKind::V2));
1888            pool += 1;
1889        }
1890        // The lone closing edge hangs off the last spoke; unbounded depth (no
1891        // prune sort) keeps the closing path at the END of the scan order.
1892        edges.push((HUB + SPOKES, END, pool, PoolKind::V2));
1893        let graph = PathGraph::from_edges(edges);
1894
1895        let calls = Arc::new(AtomicUsize::new(0));
1896        let saw_mid_walk = Arc::new(AtomicBool::new(false));
1897        {
1898            let calls = Arc::clone(&calls);
1899            let saw_mid_walk = Arc::clone(&saw_mid_walk);
1900            let mut finder = graph
1901                .find_paths_iter(HUB, END, 2, None, false, None, None)
1902                .with_progress(Duration::ZERO, move |t| {
1903                    calls.fetch_add(1, Ordering::Relaxed);
1904                    if t.advances_since_yield > 0 {
1905                        saw_mid_walk.store(true, Ordering::Relaxed);
1906                    }
1907                });
1908            assert!(finder.next_path().is_some(), "the lone closing path yields");
1909        }
1910        assert!(calls.load(Ordering::Relaxed) >= 1, "the hook fired");
1911        assert!(
1912            saw_mid_walk.load(Ordering::Relaxed),
1913            "a tally arrived while the DFS was mid-grind (advances > 0, no yield yet)"
1914        );
1915    }
1916
1917    #[test]
1918    fn test_heartbeat_diagnostics_do_not_alter_enumeration() {
1919        let graph = build_fixture_graph();
1920        let run_one: Vec<Vec<u64>> = graph
1921            .find_paths(WETH, WETH, 2, Some(3), true, None, None)
1922            .into_iter()
1923            .map(|p| edges_to_pool_ids(&p))
1924            .collect();
1925        // Re-run on a fresh graph instance — determinism + no heartbeat side
1926        // effects across runs.
1927        let graph2 = build_fixture_graph();
1928        let run_two: Vec<Vec<u64>> = graph2
1929            .find_paths(WETH, WETH, 2, Some(3), true, None, None)
1930            .into_iter()
1931            .map(|p| edges_to_pool_ids(&p))
1932            .collect();
1933        assert!(!run_one.is_empty(), "fixture must yield paths");
1934        assert_eq!(
1935            run_one, run_two,
1936            "enumeration must be stable + unaffected by heartbeat wiring"
1937        );
1938    }
1939
1940    /// Token id -> compact index (tests live in-module, so the private
1941    /// `compact_index` seam is directly reachable).
1942    #[expect(clippy::expect_used)]
1943    fn graph_compact(graph: &PathGraph, token: u64) -> u32 {
1944        graph
1945            .compact_index(token)
1946            .expect("test fixture token is interned")
1947    }
1948
1949    /// Token id -> compact index for hand-built graphs without fixtures.
1950    #[expect(clippy::cast_possible_truncation)]
1951    fn intern_ref(map: &mut HashMap<u64, u32>, adj: &mut Vec<Vec<(u32, u32)>>, t: u64) -> u32 {
1952        if let Some(&i) = map.get(&t) {
1953            i
1954        } else {
1955            let i = map.len() as u32;
1956            map.insert(t, i);
1957            adj.push(Vec::new());
1958            i
1959        }
1960    }
1961
1962    /// A big parallel bundle on A<->B plus single-pool B<->C, C<->A: forces
1963    /// walks that traverse the SAME bundle two and four times, and 3-hop
1964    /// triangles closing before the max budget.
1965    fn battery_parallel_hub_trio() -> Vec<(u64, u64, u64, PoolKind)> {
1966        let mut edges: Vec<(u64, u64, u64, PoolKind)> = Vec::new();
1967        for i in 0..40u64 {
1968            edges.push((1, 2, 700_000 + i, PoolKind::V2));
1969        }
1970        edges.push((2, 3, 799_001, PoolKind::V2));
1971        edges.push((3, 1, 799_002, PoolKind::V2));
1972        edges
1973    }
1974
1975    /// Reference enumerator: ALL edge-trails from `start` to `end` with
1976    /// `min_depth <= len <= max_depth`, by naive full backtracking. This is
1977    /// the independent oracle every pruned search must reproduce.
1978    fn reference_trails(
1979        edges: &[(u64, u64, u64, PoolKind)],
1980        start: u64,
1981        end: u64,
1982        min_depth: usize,
1983        max_depth: Option<usize>,
1984    ) -> BTreeSet<Vec<u64>> {
1985        let mut token_index: HashMap<u64, u32> = HashMap::new();
1986        let mut adj: Vec<Vec<(u32, u32)>> = Vec::new();
1987        let mut pool_ids: Vec<u64> = Vec::new();
1988        for (t0, t1, pid, _kind) in edges {
1989            let a = intern_ref(&mut token_index, &mut adj, *t0);
1990            let b = intern_ref(&mut token_index, &mut adj, *t1);
1991            let fresh_pool = u32::try_from(pool_ids.len()).unwrap_or(u32::MAX);
1992            pool_ids.push(*pid);
1993            adj[a as usize].push((b, fresh_pool));
1994            adj[b as usize].push((a, fresh_pool));
1995        }
1996        let s = intern_ref(&mut token_index, &mut adj, start);
1997
1998        let mut out: BTreeSet<Vec<u64>> = BTreeSet::new();
1999        let mut visited: Vec<bool> = vec![false; pool_ids.len()];
2000        let mut path: Vec<u32> = Vec::new();
2001
2002        #[expect(clippy::too_many_arguments, clippy::items_after_statements)]
2003        fn dfs(
2004            node: u32,
2005            end: u32,
2006            min_depth: usize,
2007            max_depth: Option<usize>,
2008            adj: &[Vec<(u32, u32)>],
2009            pool_ids: &[u64],
2010            visited: &mut [bool],
2011            path: &mut Vec<u32>,
2012            out: &mut BTreeSet<Vec<u64>>,
2013        ) {
2014            if node == end && path.len() >= min_depth {
2015                out.insert(path.iter().map(|&i| pool_ids[i as usize]).collect());
2016            }
2017            if max_depth.is_some_and(|md| path.len() >= md) {
2018                return;
2019            }
2020            for (nbr, pool_idx) in &adj[node as usize] {
2021                if visited[*pool_idx as usize] {
2022                    continue;
2023                }
2024                visited[*pool_idx as usize] = true;
2025                path.push(*pool_idx);
2026                dfs(
2027                    *nbr, end, min_depth, max_depth, adj, pool_ids, visited, path, out,
2028                );
2029                path.pop();
2030                visited[*pool_idx as usize] = false;
2031            }
2032        }
2033        dfs(
2034            s,
2035            intern_ref(&mut token_index, &mut adj, end),
2036            min_depth,
2037            max_depth,
2038            &adj,
2039            &pool_ids,
2040            &mut visited,
2041            &mut path,
2042            &mut out,
2043        );
2044        out
2045    }
2046
2047    /// A cycle that must walk AWAY from the target before closing, plus the
2048    /// shortcut 2-cycle, dead-end chains, a chain-through tail, and a pendant
2049    /// far tail. Classic trap for overly tight admissible cutoffs.
2050    fn battery_go_around() -> Vec<(u64, u64, u64, PoolKind)> {
2051        vec![
2052            (1, 2, 101, PoolKind::V2),   // WETH-A (2-cycle shortcut)
2053            (1, 2, 102, PoolKind::V2),   // parallel
2054            (2, 3, 103, PoolKind::V2),   // A-B
2055            (3, 4, 104, PoolKind::V2),   // B-C
2056            (4, 1, 105, PoolKind::V2),   // C-WETH closes the 4-cycle
2057            (2, 99, 106, PoolKind::V2),  // dead-end chain A-X
2058            (99, 98, 107, PoolKind::V2), // X chain-through, no other exit
2059            (1, 5, 108, PoolKind::V2),   // pendant far tail WETH-E
2060            (5, 6, 109, PoolKind::V2),   // E-F
2061        ]
2062    }
2063
2064    /// Two triangles sharing the hub WETH, joined by a single-pool bridge
2065    /// (A-C) that sits inside the 2-core, with parallel bundles.
2066    fn battery_bridge_in_2core() -> Vec<(u64, u64, u64, PoolKind)> {
2067        vec![
2068            (1, 2, 201, PoolKind::V2),
2069            (1, 2, 202, PoolKind::V3),
2070            (2, 3, 203, PoolKind::V2),
2071            (3, 1, 204, PoolKind::V2),
2072            (1, 4, 205, PoolKind::V2),
2073            (4, 5, 206, PoolKind::V2),
2074            (5, 1, 207, PoolKind::V3),
2075            (2, 4, 208, PoolKind::V2), // the bridge between the triangles
2076        ]
2077    }
2078
2079    fn hub_node(s: u64) -> u64 {
2080        100 * s + s
2081    }
2082
2083    /// Hub WETH with 2 parallel pools to each of 5 spokes, plus a spoke ring.
2084    /// The multiplicity bundle a per-step cutoff must survive.
2085    fn battery_hub_parallel() -> Vec<(u64, u64, u64, PoolKind)> {
2086        let mut edges = Vec::new();
2087        let mut pid = 300u64;
2088        for spoke in 11..16u64 {
2089            for _ in 0..2 {
2090                pid += 1;
2091                edges.push((1, hub_node(spoke), pid, PoolKind::V2));
2092            }
2093        }
2094        for s in 11..15u64 {
2095            pid += 1;
2096            edges.push((hub_node(s), hub_node(s + 1), pid, PoolKind::V2));
2097        }
2098        edges
2099    }
2100
2101    fn assert_search_parity(
2102        edges: &[(u64, u64, u64, PoolKind)],
2103        start: u64,
2104        end: u64,
2105        label: &str,
2106    ) {
2107        for min_depth in [1usize, 2, 3] {
2108            for max_depth in [Some(min_depth), Some(min_depth + 1), Some(min_depth + 2)] {
2109                let mut found: BTreeSet<Vec<u64>> = BTreeSet::new();
2110                let mut finder = OwnedPathFinder::new(
2111                    PathGraph::from_edges(edges.to_vec()),
2112                    start,
2113                    end,
2114                    min_depth,
2115                    max_depth,
2116                    false,
2117                    None,
2118                );
2119                while let Some(path) = finder.next_path() {
2120                    found.insert(path.into_iter().map(|(pid, _)| pid).collect());
2121                }
2122                let reference = reference_trails(edges, start, end, min_depth, max_depth);
2123                assert_eq!(
2124                    found, reference,
2125                    "{label} min={min_depth} max={max_depth:?}: pruned search diverged"
2126                );
2127            }
2128        }
2129    }
2130
2131    /// Core DFS throughput (no FFI/yield conversion): 2-connected grid with
2132    /// yields only near the start, so dead-branch churn dominates.
2133    /// `#[ignore]`d — run explicitly with
2134    /// `cargo test -p degenbot-pathfinding --release perf_core -- --ignored --nocapture`.
2135    #[test]
2136    #[ignore = "manual perf harness: cargo test -p degenbot-pathfinding --release perf_core -- --ignored --nocapture"]
2137    #[expect(clippy::print_stderr)]
2138    fn perf_core_grid_search() {
2139        let w = 60usize;
2140        let gid = |r: usize, c: usize| 500_000u64 + (r * w + c) as u64;
2141        let mut edges: Vec<(u64, u64, u64, PoolKind)> = Vec::new();
2142        let mut pid = 900_000u64;
2143        for r in 0..w {
2144            for c in 0..w {
2145                if c + 1 < w {
2146                    pid += 1;
2147                    edges.push((gid(r, c), gid(r, c + 1), pid, PoolKind::V2));
2148                }
2149                if r + 1 < w {
2150                    pid += 1;
2151                    edges.push((gid(r, c), gid(r + 1, c), pid, PoolKind::V2));
2152                }
2153                if r + 1 < w && c + 1 < w {
2154                    pid += 1;
2155                    edges.push((gid(r, c + 1), gid(r + 1, c), pid, PoolKind::V2));
2156                }
2157            }
2158        }
2159        let n_runs = 20;
2160        let mut best = f64::MAX;
2161        let mut best_build = f64::MAX;
2162        for _ in 0..n_runs {
2163            let d_start = std::time::Instant::now();
2164            let graph = PathGraph::from_edges(edges.clone());
2165            let build_start = std::time::Instant::now();
2166            let mut finder = OwnedPathFinder::new(graph, 500_000, 500_000, 3, Some(5), false, None);
2167            let build_dt = build_start.elapsed().as_secs_f64() * 1e3;
2168            let mut count = 0u64;
2169            while finder.next_path().is_some() {
2170                count += 1;
2171            }
2172            let dt = d_start.elapsed().as_secs_f64() * 1e3;
2173            if dt < best {
2174                best = dt;
2175            }
2176            if build_dt < best_build {
2177                best_build = build_dt;
2178            }
2179            assert_eq!(count, 8); // 8 unique cycles, forward only here
2180        }
2181        eprintln!("perf_core_grid_search best={best:.3} ms (finder build best={best_build:.3} ms)");
2182    }
2183
2184    /// Bundle-heavy core throughput: one big parallel bundle on A<->B plus
2185    /// single-pool B<->C, C<->A. Depth-4 sweeps force the walk to traverse
2186    /// the bundle four times (40P4 concrete expansions). The per-pool engine
2187    /// multiplies the whole subtree by the multiplicity at each level; the
2188    /// bundled walk enumerates the token shape once.
2189    #[test]
2190    #[ignore = "manual perf harness: cargo test -p degenbot-pathfinding --release perf_core -- --ignored --nocapture"]
2191    #[expect(clippy::print_stderr)]
2192    fn perf_core_parallel_hub() {
2193        let m = 40u64;
2194        let mut edges: Vec<(u64, u64, u64, PoolKind)> = Vec::new();
2195        for i in 0..m {
2196            edges.push((1, 2, 700_000 + i, PoolKind::V2));
2197        }
2198        edges.push((2, 3, 799_001, PoolKind::V2));
2199        edges.push((3, 1, 799_002, PoolKind::V2));
2200
2201        let n_runs = 5;
2202        let mut best = f64::MAX;
2203        for _ in 0..n_runs {
2204            let d_start = std::time::Instant::now();
2205            let graph = PathGraph::from_edges(edges.clone());
2206            let mut finder = OwnedPathFinder::new(graph, 1, 1, 2, Some(4), false, None);
2207            let mut count = 0u64;
2208            while finder.next_path().is_some() {
2209                count += 1;
2210            }
2211            let dt = d_start.elapsed().as_secs_f64() * 1e3;
2212            if dt < best {
2213                best = dt;
2214            }
2215            // len-2: 40P2; len-3 via C (both directions): 2m;
2216            // len-4 (two round trips): 40P4.
2217            let p2 = m * (m - 1);
2218            let p4 = p2 * (m - 2) * (m - 3);
2219            assert_eq!(
2220                count,
2221                p2 + 2 * m + p4,
2222                "falling-factorial expansion contract"
2223            );
2224        }
2225        eprintln!("perf_core_parallel_hub best={best:.3} ms");
2226    }
2227
2228    #[test]
2229    fn test_hop_distances_basics() {
2230        let graph = build_fixture_graph();
2231        // Fixture: WETH-A (2 parallel pools), A-B, B-WETH — B connects to
2232        // WETH directly, so every fixture node sits within 1 hop of WETH.
2233        let d = graph.hop_distances(&[graph_compact(&graph, WETH)]);
2234        assert_eq!(d[graph_compact(&graph, WETH) as usize], 0);
2235        assert_eq!(d[graph_compact(&graph, A) as usize], 1);
2236        assert_eq!(d[graph_compact(&graph, B) as usize], 1);
2237        let d_from_b = graph.hop_distances(&[graph_compact(&graph, B)]);
2238        assert_eq!(d_from_b[graph_compact(&graph, WETH) as usize], 1);
2239        assert_eq!(d_from_b[graph_compact(&graph, A) as usize], 1);
2240    }
2241
2242    #[test]
2243    fn test_hop_distances_unreachable_is_max() {
2244        let graph = PathGraph::from_edges(vec![
2245            (1, 2, 1, PoolKind::V2),
2246            (8, 9, 2, PoolKind::V2), // separate component
2247        ]);
2248        let d = graph.hop_distances(&[graph_compact(&graph, 1)]);
2249        for token in [8, 9] {
2250            assert_eq!(
2251                d[graph_compact(&graph, token) as usize],
2252                u32::MAX,
2253                "token {token} unreachable"
2254            );
2255        }
2256    }
2257
2258    /// Trails that close on the target EARLIER than the max budget must
2259    /// still be yielded whenever `min_depth` allows them. Regression: the
2260    /// former penultimate-hop reachability prune checked
2261    /// `end_edges[neighbor]` on the closing step's predecessor — but when the
2262    /// neighbor IS the target, `end_edges[end]` is empty (no self-loops), so
2263    /// every shorter-than-budget close was silently amputated (a depth-3
2264    /// sweep yielded no 2-pool cycles at all; 360/360 parallel-pool 2-hop
2265    /// cycles vanished on the 60-spoke hub fixture). The hop-distance cutoff
2266    /// admits the target itself (`d_end[end] = 0 <= remaining`), restoring
2267    /// them. The differential parity batteries also cover this, but this
2268    /// test names the exact shape.
2269    #[test]
2270    fn test_regress_trail_closing_earlier_than_budget_is_yielded() {
2271        // 3 parallel hubs pools WETH<->A, nothing else.
2272        let edges = {
2273            let mut e = Vec::new();
2274            for pool in 100..103u64 {
2275                e.push((1u64, 2u64, pool, PoolKind::V2));
2276            }
2277            e
2278        };
2279        // min_depth=2, max_depth=3 (production's floor/limit relationship):
2280        // the 2-hop cycles are within budget and must be yielded.
2281        let mut two_hop: Vec<Vec<u64>> = Vec::new();
2282        let mut finder = OwnedPathFinder::new(
2283            PathGraph::from_edges(edges.clone()),
2284            1,
2285            1,
2286            2,
2287            Some(3),
2288            false,
2289            None,
2290        );
2291        while let Some(path) = finder.next_path() {
2292            if path.len() == 2 {
2293                two_hop.push(path.iter().map(|(pid, _)| *pid).collect());
2294            }
2295        }
2296        // 6 distinct directed trails (3 pools * 2 orderings, distinct pools
2297        // per trail). Zero would be the regression shape.
2298        assert_eq!(
2299            two_hop.len(),
2300            6,
2301            "2-pool cycles closing before the max budget must be yielded: {two_hop:?}"
2302        );
2303    }
2304
2305    #[test]
2306    fn pruned_search_parity_go_around() {
2307        assert_search_parity(&battery_go_around(), 1, 1, "go_around");
2308    }
2309
2310    #[test]
2311    fn pruned_search_parity_bridge_in_2core() {
2312        assert_search_parity(&battery_bridge_in_2core(), 1, 1, "bridge_in_2core");
2313    }
2314
2315    #[test]
2316    fn pruned_search_parity_hub_parallel() {
2317        assert_search_parity(&battery_hub_parallel(), 1, 1, "hub_parallel");
2318    }
2319
2320    #[test]
2321    fn pruned_search_parity_parallel_hub_trio() {
2322        assert_search_parity(&battery_parallel_hub_trio(), 1, 1, "parallel_hub_trio");
2323    }
2324
2325    #[test]
2326    fn pruned_search_parity_open_paths() {
2327        // start != end: the pruned DFS must serve open traversals too.
2328        assert_search_parity(&battery_go_around(), 1, 5, "go_around_open");
2329        assert_search_parity(&battery_bridge_in_2core(), 1, 4, "bridge_open");
2330    }
2331
2332    /// A token walk may traverse the SAME unordered pool pair more than
2333    /// once, consuming a DISTINCT pool per visit. Counting contract for the
2334    /// bundled engine: `u` visits of a bundle with multiplicity `m` expand
2335    /// to the ordered selections `m·(m-1)·...·(m-u+1)`.
2336    ///
2337    /// Shape: 4 parallel pools WETH<->A, nothing else. Walks from WETH back
2338    /// to WETH with `min=2, max=4`: len-2 cycles use the bundle twice
2339    /// (4·3 = 12) and len-4 cycles use it four times (4·3·2·1 = 24).
2340    #[test]
2341    fn test_parity_parallel_bundle_revisited_pair() {
2342        let mut edges = Vec::new();
2343        for pool in 400..404u64 {
2344            edges.push((1u64, 2u64, pool, PoolKind::V2));
2345        }
2346        let mut finder = OwnedPathFinder::new(
2347            PathGraph::from_edges(edges.clone()),
2348            1,
2349            1,
2350            2,
2351            Some(4),
2352            false,
2353            None,
2354        );
2355        let mut count = 0;
2356        while finder.next_path().is_some() {
2357            count += 1;
2358        }
2359        assert_eq!(count, 12 + 24, "falling-factorial expansion: {count}");
2360        assert_search_parity(&edges, 1, 1, "revisit_pair");
2361    }
2362
2363    /// Mixed-kind parallel bundle + per-depth kind filter: the walk-level
2364    /// kind check is a NECESSARY condition (any kind-allowed pool exists);
2365    /// the expansion enforces the exact per-step kind sets and the
2366    /// same-bundle distinctness across the walk's visits.
2367    ///
2368    /// Bundle WETH<->A: p1=V2, p2=V3, p3=V2. 2-hop cycles WETH-A-WETH.
2369    #[test]
2370    fn test_parity_mixed_kinds_parallel_bundle_with_filter() {
2371        fn collect(
2372            edges: &[(u64, u64, u64, PoolKind)],
2373            filter: Option<Vec<Option<Vec<PoolKind>>>>,
2374        ) -> BTreeSet<Vec<u64>> {
2375            let mut finder = OwnedPathFinder::new(
2376                PathGraph::from_edges(edges.to_vec()),
2377                1,
2378                1,
2379                2,
2380                Some(2),
2381                false,
2382                filter,
2383            );
2384            let mut out = BTreeSet::new();
2385            while let Some(path) = finder.next_path() {
2386                out.insert(path.into_iter().map(|(pid, _)| pid).collect());
2387            }
2388            out
2389        }
2390
2391        let edges = vec![
2392            (1u64, 2u64, 500, PoolKind::V2),
2393            (1, 2, 501, PoolKind::V3),
2394            (1, 2, 502, PoolKind::V2),
2395        ];
2396
2397        // No filter: ordered pairs over the full bundle: 3·2 = 6.
2398        let out = collect(&edges, None);
2399        assert_eq!(out.len(), 6, "unfiltered parallel bundle: {out:?}");
2400
2401        // [V3, None]: slot 0 must be p(501); slot 1 any other pool: 2 paths.
2402        let out = collect(&edges, Some(vec![Some(vec![PoolKind::V3]), None]));
2403        let expected: BTreeSet<Vec<u64>> = BTreeSet::from([vec![501, 500], vec![501, 502]]);
2404        assert_eq!(out, expected, "V3-first filter expansion: {out:?}");
2405
2406        // [V3, V3]: both slots need p(501) — impossible (distinct pools):
2407        // the walk passes the kind-necessity check but expansion yields 0.
2408        let out = collect(
2409            &edges,
2410            Some(vec![Some(vec![PoolKind::V3]), Some(vec![PoolKind::V3])]),
2411        );
2412        assert!(
2413            out.is_empty(),
2414            "kind-infeasible re-visit must yield nothing: {out:?}"
2415        );
2416
2417        // Oracle parity on the same shape with reverse doubling.
2418        assert_search_parity(&edges, 1, 1, "mixed_kinds_bundle");
2419    }
2420}