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::collections::HashMap;
19use std::time::{Duration, Instant};
20
21/// Discriminant for the three pool-table families.
22///
23/// `V2` corresponds to `UniswapV2PoolTableBase` (Uniswap V2 and V2-style
24/// forks); `V3` corresponds to `UniswapV3PoolTableBase` (Uniswap V3 and
25/// V3-style forks); `V4` corresponds to `UniswapV4PoolTable`.
26///
27/// All V2/V3 subtypes share the `pools` database table (single ID
28/// sequence), so a `pool_id` is unique within V2 and within V3 — no
29/// collision between the two. V4 pools use a separate `managed_pools`
30/// table, so the `PoolKind` discriminant disambiguates V4 IDs from V2/V3
31/// IDs.
32#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
33#[non_exhaustive]
34pub enum PoolKind {
35    V2,
36    V3,
37    V4,
38}
39
40impl PoolKind {
41    /// Convert to the `u8` discriminant used at the `PyO3` boundary.
42    #[must_use]
43    pub const fn as_u8(self) -> u8 {
44        match self {
45            PoolKind::V2 => 0,
46            PoolKind::V3 => 1,
47            PoolKind::V4 => 2,
48        }
49    }
50
51    /// Convert from the `u8` discriminant used at the `PyO3` boundary.
52    ///
53    /// Returns `None` for unknown discriminants.
54    #[must_use]
55    pub const fn from_u8(val: u8) -> Option<Self> {
56        match val {
57            0 => Some(PoolKind::V2),
58            1 => Some(PoolKind::V3),
59            2 => Some(PoolKind::V4),
60            _ => None,
61        }
62    }
63}
64
65/// A pool edge in the external (database) form. Retained for API
66/// compatibility; the internal adjacency list uses the compact [`CompactEdge`]
67/// (8 bytes, two `u32` indices) for cache density.
68#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
69pub struct Edge {
70    /// The token ID this edge leads to.
71    pub neighbor: u64,
72    /// The pool ID providing this liquidity connection.
73    pub pool_id: u64,
74    /// Which pool-table family this pool belongs to.
75    pub pool_kind: PoolKind,
76}
77
78/// A key uniquely identifying a pool within a traversal.
79pub type EdgeKey = (u64, PoolKind);
80
81/// Compact internal edge: neighbor is a compact token index, `pool_idx`
82/// identifies the pool in the graph's `pools` table. 8 bytes, cache-dense.
83#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
84struct CompactEdge {
85    /// Compact index of the neighbor token (into `PathGraph::adj`).
86    neighbor: u32,
87    /// Compact index of the pool (into `PathGraph::pools`).
88    pool_idx: u32,
89}
90
91/// A multigraph: token IDs are nodes, liquidity pools are edges.
92///
93/// Stored as a compact adjacency list (`Vec<Vec<CompactEdge>>`) indexed by
94/// remapped contiguous token indices. External token IDs (`u64`) are mapped
95/// to compact indices (`u32`) via `token_index`, so the hot DFS loop does
96/// direct array indexing instead of hashing. Parallel edges (multiple pools
97/// connecting the same token pair) are naturally supported and preserve
98/// insertion order for deterministic traversal.
99///
100/// Pools are also remapped to compact indices; the `pools` table maps each
101/// compact pool index back to its `(pool_id, PoolKind)` for yielding, and the
102/// visited set is an O(1) `Vec<bool>` indexed by pool index.
103pub struct PathGraph {
104    /// Adjacency list indexed by compact token index. Each entry is the list
105    /// of outgoing `CompactEdge`s, in insertion order.
106    adj: Vec<Vec<CompactEdge>>,
107    /// External token ID → compact token index.
108    token_index: HashMap<u64, u32>,
109    /// Compact pool index → `(pool_id, PoolKind)` for yielding results.
110    pools: Vec<(u64, PoolKind)>,
111}
112
113impl PathGraph {
114    /// Build from a flat list of `(token0, token1, pool_id, pool_kind)` edges.
115    ///
116    /// Each edge is added in both directions (the graph is undirected, like
117    /// the `networkx.MultiGraph` it replaces). Edge insertion order within
118    /// each node's adjacency list is preserved for deterministic traversal.
119    /// External token IDs are remapped to compact contiguous indices.
120    ///
121    /// # Panics
122    ///
123    /// Panics if the number of distinct pools or tokens exceeds `u32::MAX`
124    /// (compact index overflow). This is an architectural bound of the
125    /// compact-index representation and unreachable in practice.
126    #[must_use]
127    pub fn from_edges(edges: Vec<(u64, u64, u64, PoolKind)>) -> Self {
128        let n = edges.len();
129        let mut token_index: HashMap<u64, u32> = HashMap::with_capacity(n);
130        let mut pools: Vec<(u64, PoolKind)> = Vec::with_capacity(n);
131        let mut adj: Vec<Vec<CompactEdge>> = Vec::new();
132
133        for (token0, token1, pool_id, pool_kind) in edges {
134            // u32 compact-index invariant: a graph cannot reach u32::MAX nodes.
135            #[expect(clippy::expect_used)]
136            let pool_idx = u32::try_from(pools.len()).expect("pool count exceeds u32::MAX");
137            pools.push((pool_id, pool_kind));
138
139            let idx0 = Self::intern_token(&mut token_index, &mut adj, token0);
140            let idx1 = Self::intern_token(&mut token_index, &mut adj, token1);
141
142            adj[idx0 as usize].push(CompactEdge {
143                neighbor: idx1,
144                pool_idx,
145            });
146            adj[idx1 as usize].push(CompactEdge {
147                neighbor: idx0,
148                pool_idx,
149            });
150        }
151
152        Self {
153            adj,
154            token_index,
155            pools,
156        }
157    }
158
159    /// Assign (or look up) the compact index for an external token ID, growing
160    /// the adjacency list if the token is new.
161    fn intern_token(
162        token_index: &mut HashMap<u64, u32>,
163        adj: &mut Vec<Vec<CompactEdge>>,
164        token: u64,
165    ) -> u32 {
166        if let Some(&idx) = token_index.get(&token) {
167            idx
168        } else {
169            #[expect(clippy::expect_used)] // u32 compact-index invariant (see `from_edges`)
170            let idx = u32::try_from(token_index.len()).expect("token count exceeds u32::MAX");
171            token_index.insert(token, idx);
172            adj.push(Vec::new());
173            idx
174        }
175    }
176
177    /// Map an external token ID to its compact index, if present.
178    #[must_use]
179    fn compact_index(&self, token: u64) -> Option<u32> {
180        self.token_index.get(&token).copied()
181    }
182
183    /// Returns `true` if the node exists in the graph.
184    #[must_use]
185    pub fn contains_node(&self, node: u64) -> bool {
186        self.token_index.contains_key(&node)
187    }
188
189    /// The number of nodes (tokens) in the graph.
190    #[must_use]
191    pub fn node_count(&self) -> usize {
192        self.adj.len()
193    }
194
195    /// The number of pool edges incident to a token (its degree).
196    #[must_use]
197    pub fn degree(&self, token: u64) -> Option<usize> {
198        self.compact_index(token)
199            .map(|i| self.adj[i as usize].len())
200    }
201
202    /// Remove nodes with degree ≤ 1, repeating until no such nodes remain.
203    ///
204    /// This mirrors the Python `_prepare_graph` dead-end pruning loop:
205    /// `while tokens_to_prune := tuple(t for t, d in graph.degree() if d <= 1):
206    ///     graph.remove_nodes_from(tokens_to_prune)`
207    ///
208    /// Pruning a node removes all its incident edges, which may reduce other
209    /// nodes' degrees below 2 — hence the iterative fixpoint. Removed nodes
210    /// are dropped from `token_index` (so `contains_node` returns `false`)
211    /// and their adjacencies are cleared. Their compact indices are not
212    /// recycled (would require reindexing), but this only wastes a slot — it
213    /// never affects correctness or the hot DFS path.
214    ///
215    /// # Panics
216    ///
217    /// Panics if the number of nodes exceeds `u32::MAX` when mapping a
218    /// compact index (architectural bound of the index type; unreachable in
219    /// practice).
220    pub fn prune_dead_ends(&mut self) {
221        let n = self.adj.len();
222        // Track which compact indices have been removed so we don't re-scan
223        // already-pruned (degree-0) nodes forever.
224        let mut removed = vec![false; n];
225        loop {
226            // Collect live nodes whose current degree (count of surviving
227            // incident edges) is ≤ 1.
228            let to_prune: Vec<u32> = (0..n)
229                .filter(|&i| !removed[i] && self.adj[i].len() <= 1)
230                .map(|i| {
231                    // u32 node-index invariant: a graph cannot reach u32::MAX nodes.
232                    #[expect(clippy::expect_used)]
233                    u32::try_from(i).expect("node index exceeds u32::MAX")
234                })
235                .collect();
236            if to_prune.is_empty() {
237                break;
238            }
239            for &node_idx in &to_prune {
240                removed[node_idx as usize] = true;
241            }
242            self.remove_nodes(&to_prune);
243        }
244        // Drop the external token IDs of all pruned nodes so contains_node
245        // reflects the post-prune state.
246        self.token_index
247            .retain(|_, &mut idx| !removed[idx as usize]);
248    }
249
250    /// Remove a set of nodes and all their incident edges.
251    fn remove_nodes(&mut self, nodes: &[u32]) {
252        // Collect all reverse-edge removals first to avoid borrowing self.adj
253        // as both immutable (reading edges) and mutable (removing from neighbors).
254        let mut reverse_removals: Vec<(u32, u32)> = Vec::new(); // (neighbor, pool_idx)
255        for &node_idx in nodes {
256            for edge in &self.adj[node_idx as usize] {
257                reverse_removals.push((edge.neighbor, edge.pool_idx));
258            }
259        }
260
261        // Apply reverse-edge removals from neighbors.
262        for (neighbor, pool_idx) in reverse_removals {
263            if let Some(neighbor_edges) = self.adj.get_mut(neighbor as usize) {
264                neighbor_edges.retain(|e| e.pool_idx != pool_idx);
265            }
266        }
267
268        // Clear the pruned nodes' adjacencies (compact index retained).
269        for &node_idx in nodes {
270            self.adj[node_idx as usize].clear();
271        }
272    }
273
274    /// Precompute valid depth positions per node, for lookahead pruning.
275    ///
276    /// For each node, determine which depth positions its edges satisfy. A
277    /// node can appear at depth `d` if it has at least one incident edge whose
278    /// `pool_kind` is in the allowed set at depth `d` (or `allowed[d]` is
279    /// `None`, meaning all kinds are allowed).
280    ///
281    /// Returns a `Vec` indexed by compact token index, where entry `i` is a
282    /// `Vec<bool>` whose index `d` is `true` if token `i` can participate at
283    /// depth `d`.
284    #[must_use]
285    pub fn compute_node_valid_depths(
286        &self,
287        pool_type_per_depth: &[Option<Vec<PoolKind>>],
288    ) -> Vec<Vec<bool>> {
289        let mut result = Vec::with_capacity(self.adj.len());
290        for edges in &self.adj {
291            // Collect all pool kinds this node has edges for (a node can use
292            // any pool it touches at any depth).
293            let mut kinds = [false; 3];
294            for e in edges {
295                let kind = self.pools[e.pool_idx as usize].1;
296                kinds[kind.as_u8() as usize] = true;
297            }
298            let mut valid = vec![false; pool_type_per_depth.len()];
299            for (d, allowed) in pool_type_per_depth.iter().enumerate() {
300                match allowed {
301                    None => valid[d] = true,
302                    Some(allowed_kinds) => {
303                        valid[d] = allowed_kinds.iter().any(|k| kinds[k.as_u8() as usize]);
304                    }
305                }
306            }
307            result.push(valid);
308        }
309        result
310    }
311}
312
313/// A lazy, stateful depth-first search iterator over valid cycles.
314///
315/// This struct holds the DFS stack, working path, and visited set,
316/// yielding one path at a time via [`PathFinder::next_path`]. This avoids
317/// collecting all results into memory at once — essential for large
318/// graphs that produce millions of paths.
319///
320/// Created by [`PathGraph::find_paths_iter`].
321pub struct PathFinder<'a> {
322    graph: &'a PathGraph,
323    end: u32,
324    min_depth: usize,
325    effective_max_depth: Option<usize>,
326    include_reverse: bool,
327    pool_type_per_depth: Option<&'a [Option<Vec<PoolKind>>]>,
328    node_valid_depths: Option<&'a [Vec<bool>]>,
329    filter_len: usize,
330    stack: Vec<(u32, usize, bool)>,
331    working_path: Vec<u32>,
332    visited: Vec<bool>,
333    pending_reverse: Option<Vec<EdgeKey>>,
334    done: bool,
335}
336
337impl PathFinder<'_> {
338    /// Advance the DFS and return the next complete path, or `None` if
339    /// the search is exhausted.
340    ///
341    /// If `include_reverse` is set, each found cycle yields the forward path
342    /// first, then the reversed path on the next call.
343    #[must_use]
344    pub fn next_path(&mut self) -> Option<Vec<EdgeKey>> {
345        if self.done {
346            return None;
347        }
348
349        // If a reversed path is pending from the last yield, emit it now.
350        if let Some(rev) = self.pending_reverse.take() {
351            return Some(rev);
352        }
353
354        while let Some(frame) = self.stack.last_mut() {
355            let (node, edge_idx, yield_checked) = frame;
356
357            // Check yield condition (once per frame arrival).
358            if !*yield_checked {
359                *yield_checked = true;
360                if *node == self.end && self.working_path.len() >= self.min_depth {
361                    let path = self.path_to_edge_keys();
362                    if self.include_reverse {
363                        let rev = self.reversed_path_to_edge_keys();
364                        self.pending_reverse = Some(rev);
365                    }
366                    return Some(path);
367                }
368            }
369
370            // Stop recursion if the working path has reached the maximum depth.
371            if let Some(emd) = self.effective_max_depth {
372                if self.working_path.len() >= emd {
373                    // Backtrack.
374                    self.stack.pop();
375                    if let Some(popped) = self.working_path.pop() {
376                        self.visited[popped as usize] = false;
377                    }
378                    continue;
379                }
380            }
381
382            // Find the next valid edge to explore from this node.
383            let neighbors = if let Some(n) = self.graph.adj.get(*node as usize) {
384                n.as_slice()
385            } else {
386                // No edges from this node — backtrack.
387                self.stack.pop();
388                continue;
389            };
390
391            // If the next hop reaches the maximum depth, only edges that close
392            // the cycle (reach `end`) can possibly yield — skip the rest
393            // without pushing a dead frame that would just backtrack. This is
394            // the single biggest DFS cost saver: at the closing depth, every
395            // non-`end` neighbor is pure waste.
396            let final_hop = matches!(
397                self.effective_max_depth,
398                Some(emd) if self.working_path.len() + 1 == emd
399            );
400
401            let mut found_edge = false;
402            while *edge_idx < neighbors.len() {
403                let edge = &neighbors[*edge_idx];
404                *edge_idx += 1;
405                let pool_idx = edge.pool_idx;
406
407                // Cycle detection: skip pools already on the working path.
408                if self.visited[pool_idx as usize] {
409                    continue;
410                }
411
412                // Final-hop restriction: the closing hop must reach `end`.
413                if final_hop && edge.neighbor != self.end {
414                    continue;
415                }
416
417                // Per-depth pool-type filter.
418                if let Some(filter) = self.pool_type_per_depth {
419                    let depth = self.working_path.len();
420                    // depth < filter_len is guaranteed by effective_max_depth,
421                    // but guard defensively.
422                    if depth >= self.filter_len {
423                        continue;
424                    }
425                    if let Some(allowed_kinds) = &filter[depth] {
426                        let kind = self.graph.pools[pool_idx as usize].1;
427                        if !allowed_kinds.contains(&kind) {
428                            continue;
429                        }
430                    }
431
432                    // Lookahead pruning: skip if the neighbor can't continue
433                    // at the next depth.
434                    let next_depth = depth + 1;
435                    if next_depth < self.filter_len {
436                        if let Some(nvd) = self.node_valid_depths {
437                            if let Some(valid) = nvd.get(edge.neighbor as usize) {
438                                if !valid[next_depth] {
439                                    continue;
440                                }
441                            }
442                        }
443                    }
444                }
445
446                // Found a valid edge — extend the path and push the neighbor.
447                self.working_path.push(pool_idx);
448                self.visited[pool_idx as usize] = true;
449                self.stack.push((edge.neighbor, 0, false));
450                found_edge = true;
451                break;
452            }
453
454            if !found_edge {
455                // No more edges to explore from this node — backtrack.
456                self.stack.pop();
457                if let Some(popped) = self.working_path.pop() {
458                    self.visited[popped as usize] = false;
459                }
460            }
461        }
462
463        // Search exhausted.
464        self.done = true;
465        None
466    }
467
468    /// Convert the current working path (pool indices) to `EdgeKey`s for yielding.
469    fn path_to_edge_keys(&self) -> Vec<EdgeKey> {
470        self.working_path
471            .iter()
472            .map(|&idx| self.graph.pools[idx as usize])
473            .collect()
474    }
475
476    /// Convert the reversed working path to `EdgeKey`s.
477    fn reversed_path_to_edge_keys(&self) -> Vec<EdgeKey> {
478        self.working_path
479            .iter()
480            .rev()
481            .map(|&idx| self.graph.pools[idx as usize])
482            .collect()
483    }
484}
485
486impl Iterator for PathFinder<'_> {
487    type Item = Vec<EdgeKey>;
488
489    fn next(&mut self) -> Option<Self::Item> {
490        self.next_path()
491    }
492}
493
494/// An owning, lazy DFS iterator that owns the graph and filter data.
495///
496/// This is the PyO3-friendly version of [`PathFinder`] — it has no lifetime
497/// parameters, so it can be stored in a `#[pyclass]` and iterated from
498/// Python one path at a time. The graph, filter, and node-valid-depths are
499/// all owned, eliminating self-referential borrow issues.
500pub struct OwnedPathFinder {
501    graph: PathGraph,
502    end: u32,
503    min_depth: usize,
504    effective_max_depth: Option<usize>,
505    include_reverse: bool,
506    pool_type_per_depth: Option<Vec<Option<Vec<PoolKind>>>>,
507    node_valid_depths: Option<Vec<Vec<bool>>>,
508    filter_len: usize,
509    /// Per-node list of pool indices whose edge reaches `end` (the cycle's
510    /// closing token). Built once per search in [`OwnedPathFinder::new`]: the
511    /// closing hop can only traverse an edge to `end`, so iterating this
512    /// compact list instead of the full adjacency avoids scanning (and
513    /// skipping) every non-`end` neighbor of each penultimate node.
514    end_edges: Vec<Vec<u32>>,
515    stack: Vec<(u32, usize, bool)>,
516    working_path: Vec<u32>,
517    visited: Vec<bool>,
518    /// Whether the reversed form of the most recently yielded cycle is still
519    /// pending emission (used only when `include_reverse` is set). A flag
520    /// rather than an owned `Vec<EdgeKey>` because the working path is still
521    /// intact between yielding a cycle and emitting its reverse — the reversed
522    /// path can be read directly from `working_path` on demand.
523    pending_reverse: bool,
524    done: bool,
525    // --- discovery-phase heartbeat diagnostics (NY4EFN) ---
526    // A silently-stalled DFS grinds here with the GIL released — the asyncio
527    // event loop on the same thread is blocked, so a Python-side progress log
528    // cannot fire. This heartbeat emits to stderr (GIL-free, zero deps) every
529    // so a future zero-yield hang is visible at a
530    // glance, not just "78% CPU, no logs". Purely diagnostic — never alters
531    // `advance()`'s return values or enumeration order.
532    search_started: Instant,
533    paths_yielded: u64,
534    advances_since_yield: u64,
535    last_heartbeat: Instant,
536    /// Peak DFS stack depth observed — distinguishes "stuck shallow" (ordering
537    /// gap) from "grinding deep" (graph-size variance) on a real run.
538    max_stack_depth: usize,
539}
540
541/// Minimum elapsed wall-clock between discovery heartbeat emissions.
542///
543/// ~10s keeps a long search quiet but surfaces a hang within the ~5-min
544/// bounded-time target (NY4EFN). Tuned so small synthetic test fixtures
545/// (which complete in µs) never emit.
546const DISCOVERY_HEARTBEAT: Duration = Duration::from_secs(10);
547
548/// Check the heartbeat clock every this many stack-frame iterations (amortizes
549/// `Instant::now` out of the hot per-edge DFS loop). Power-of-two so the modulo
550/// is a bitmask.
551const HEARTBEAT_CHECK_EVERY: u64 = 4096;
552
553// Discovery-heartbeat output interpretation on a live hang (NY4EFN root-cause
554// mapping):
555// - `paths_yielded` climbing slowly → graph-size variance (cause c);
556//   discovery is progressing, just slow.
557// - `paths_yielded` frozen at 0 with `advances_since_yield` climbing + low
558//   `max_stack_depth` → DFS not yielding a first valid cycle (cause a: an
559//   ordering/pruning gap, or no valid cycle exists for this filter).
560// - `paths_yielded` climbing while the example's `[build_paths]` registered
561//   count stays flat → the stall is per-path `build_pool` in the example
562//   (cause b), NOT the DFS.
563
564/// Outcome of one DFS advance: which path form (if any) is ready to yield.
565#[derive(PartialEq, Eq)]
566enum AdvanceOutcome {
567    /// The search is exhausted; no more paths.
568    Exhausted,
569    /// The current `working_path` is a complete cycle ready to yield forward.
570    Forward,
571    /// A pending reverse of the previous cycle is ready to yield.
572    Reversed,
573}
574
575impl OwnedPathFinder {
576    /// Create from owned graph + search parameters.
577    #[must_use]
578    pub fn new(
579        graph: PathGraph,
580        start: u64,
581        end: u64,
582        min_depth: usize,
583        max_depth: Option<usize>,
584        include_reverse: bool,
585        pool_type_per_depth: Option<Vec<Option<Vec<PoolKind>>>>,
586    ) -> Self {
587        let effective_max_depth: Option<usize> = match &pool_type_per_depth {
588            Some(filter) => {
589                let filter_len = filter.len();
590                match max_depth {
591                    Some(md) => Some(md.min(filter_len)),
592                    None => Some(filter_len),
593                }
594            }
595            None => max_depth,
596        };
597
598        let filter_len = pool_type_per_depth.as_ref().map_or(0, Vec::len);
599
600        let node_valid_depths = pool_type_per_depth
601            .as_ref()
602            .map(|filter| graph.compute_node_valid_depths(filter));
603
604        // Remap external start/end token IDs to compact indices. If EITHER
605        // boundary token is absent from the (filtered) graph, no start->end path
606        // exists - yield nothing. `end` must NOT fall back to a synthetic index:
607        // remapping an absent end to compact index 0 made the DFS search for
608        // cycles ending at an unrelated token (compact 0), yielding non-closing
609        // paths that tripped the direction-resolution fail-stop.
610        let start_idx = graph.compact_index(start);
611        let end_idx = graph.compact_index(end);
612
613        let (stack, done) = match (start_idx, end_idx) {
614            (Some(s), Some(_e)) => (vec![(s, 0, false)], false),
615            _ => (Vec::new(), true),
616        };
617        let end_idx = end_idx.unwrap_or(0);
618        let n_pools = graph.pools.len();
619
620        // Precompute, per node, the pool indices of edges that reach `end`.
621        // The closing hop only traverses `end`-reaching edges, so iterating
622        // this compact list avoids scanning/skipping every non-`end` neighbor
623        // of each penultimate node. Insertion order is preserved so traversal
624        // order (hence enumeration order) is unchanged.
625        let mut end_edges: Vec<Vec<u32>> = vec![Vec::new(); graph.adj.len()];
626        for (node_idx, edges) in graph.adj.iter().enumerate() {
627            for e in edges {
628                if e.neighbor == end_idx {
629                    end_edges[node_idx].push(e.pool_idx);
630                }
631            }
632        }
633
634        let now = Instant::now();
635        Self {
636            graph,
637            end: end_idx,
638            min_depth,
639            effective_max_depth,
640            include_reverse,
641            pool_type_per_depth,
642            node_valid_depths,
643            filter_len,
644            end_edges,
645            stack,
646            working_path: Vec::with_capacity(16),
647            visited: vec![false; n_pools],
648            pending_reverse: false,
649            done,
650            search_started: now,
651            paths_yielded: 0,
652            advances_since_yield: 0,
653            last_heartbeat: now,
654            max_stack_depth: 0,
655        }
656    }
657
658    /// Advance the DFS by one yield without materializing the path.
659    ///
660    /// Returns [`AdvanceOutcome::Forward`] when a complete cycle is ready
661    /// (read it from `working_path`), [`AdvanceOutcome::Reversed`] when a
662    /// pending reverse of the previous cycle is ready (read reversed
663    /// `working_path`), or [`AdvanceOutcome::Exhausted`] when the search is
664    /// done. This is the shared DFS core — both [`Self::next_path`] (which
665    /// materializes `EdgeKey`s) and [`Self::next_path_indices_into`] (which
666    /// appends pool indices, avoiding allocation) dispatch through it.
667    #[expect(clippy::too_many_lines)]
668    fn advance(&mut self) -> AdvanceOutcome {
669        if self.done {
670            return AdvanceOutcome::Exhausted;
671        }
672
673        // Emit a pending reversed cycle before doing any further DFS work.
674        if self.pending_reverse {
675            self.pending_reverse = false;
676            self.paths_yielded += 1;
677            self.advances_since_yield = 0;
678            return AdvanceOutcome::Reversed;
679        }
680
681        let filter_slice = self.pool_type_per_depth.as_deref();
682        let nvd_ref = self.node_valid_depths.as_deref();
683
684        loop {
685            let stack_len = self.stack.len();
686            if stack_len == 0 {
687                break;
688            }
689            // Discovery heartbeat: amortized (checked every `HEARTBEAT_CHECK_EVERY`
690            // stack-frame iterations, not per edge) — `Instant::now` is ~10ns
691            // but the hot DFS loop runs millions of iterations, so the modulo
692            // keeps it out of the inner per-edge path. Fires a GIL-free stderr
693            // line every `DISCOVERY_HEARTBEAT` while grinding, so a zero-yield
694            // hang surfaces immediately (NY4EFN). Touches only disjoint
695            // heartbeat fields + the `stack_len` copy, so it cannot borrow
696            // `self.stack` while the mutable `frame` below is live.
697            self.advances_since_yield = self.advances_since_yield.wrapping_add(1);
698            if stack_len > self.max_stack_depth {
699                self.max_stack_depth = stack_len;
700            }
701            if self
702                .advances_since_yield
703                .is_multiple_of(HEARTBEAT_CHECK_EVERY)
704            {
705                // Inlined (not a `&mut self` method) so the heartbeat touches
706                // only disjoint fields — `pool_type_per_depth` is borrowed
707                // immutably for the whole loop body via `filter_slice`.
708                let now = Instant::now();
709                if now.duration_since(self.last_heartbeat) >= DISCOVERY_HEARTBEAT {
710                    self.last_heartbeat = now;
711                    let elapsed = now.duration_since(self.search_started);
712                    // Low-frequency stderr diagnostic on a zero-dependency leaf; no
713                    // logging crate is available and this runs off the hot path.
714                    #[expect(clippy::print_stderr)]
715                    {
716                        eprintln!(
717                            "[pathfinding] discovery heartbeat: elapsed={elapsed:?} \
718                             paths_yielded={} advances_since_yield={} max_stack_depth={}",
719                            self.paths_yielded, self.advances_since_yield, self.max_stack_depth
720                        );
721                    }
722                }
723            }
724            let frame = &mut self.stack[stack_len - 1];
725            let (node, edge_idx, yield_checked) = frame;
726
727            // Check yield condition (once per frame arrival).
728            if !*yield_checked {
729                *yield_checked = true;
730                if *node == self.end && self.working_path.len() >= self.min_depth {
731                    if self.include_reverse {
732                        // working_path stays intact until the reverse is
733                        // emitted on the next advance(), so we only need a
734                        // flag — no owned Vec to carry over.
735                        self.pending_reverse = true;
736                    }
737                    self.paths_yielded += 1;
738                    self.advances_since_yield = 0;
739                    return AdvanceOutcome::Forward;
740                }
741            }
742
743            // Stop recursion if the working path has reached the maximum depth.
744            if let Some(emd) = self.effective_max_depth {
745                if self.working_path.len() >= emd {
746                    // Backtrack.
747                    self.stack.pop();
748                    if let Some(popped) = self.working_path.pop() {
749                        self.visited[popped as usize] = false;
750                    }
751                    continue;
752                }
753            }
754
755            // If the next hop reaches the maximum depth, only edges that close
756            // the cycle (reach `end`) can possibly yield — skip the rest
757            // without pushing a dead frame that would just backtrack. This is
758            // the single biggest DFS cost saver: at the closing depth, every
759            // non-`end` neighbor is pure waste.
760            let final_hop = matches!(
761                self.effective_max_depth,
762                Some(emd) if self.working_path.len() + 1 == emd
763            );
764            // Penultimate hop (next iteration after this push is the closing
765            // one). Loop-invariant within the inner edge-scan loop below —
766            // working_path.len() only changes on push-after-break — so hoist
767            // the depth comparison out of the per-edge loop.
768            let penultimate_hop = matches!(
769                self.effective_max_depth,
770                Some(emd) if self.working_path.len() + 2 == emd
771            );
772
773            let mut found_edge = false;
774
775            if final_hop {
776                // Closing hop: only `end`-reaching edges can complete the
777                // cycle. Iterate the precomputed compact end-edge list
778                // (instead of scanning + skipping the full adjacency) — this
779                // avoids touching every non-`end` neighbor of each penultimate
780                // node. All edges here reach `end`, so the pushed neighbor is
781                // always `end` and the final-hop skip check is unnecessary.
782                let end_list: &[u32] = self
783                    .end_edges
784                    .get(*node as usize)
785                    .map_or([].as_slice(), Vec::as_slice);
786                while *edge_idx < end_list.len() {
787                    let pool_idx = end_list[*edge_idx];
788                    *edge_idx += 1;
789
790                    // Cycle detection: skip pools already on the working path.
791                    if self.visited[pool_idx as usize] {
792                        continue;
793                    }
794
795                    // Per-depth pool-type filter (lookahead never applies at
796                    // the closing hop: next_depth == effective_max_depth is
797                    // never < filter_len).
798                    if let Some(filter) = filter_slice {
799                        let depth = self.working_path.len();
800                        if depth < self.filter_len {
801                            if let Some(allowed_kinds) = &filter[depth] {
802                                let kind = self.graph.pools[pool_idx as usize].1;
803                                if !allowed_kinds.contains(&kind) {
804                                    continue;
805                                }
806                            }
807                        }
808                    }
809
810                    self.working_path.push(pool_idx);
811                    self.visited[pool_idx as usize] = true;
812                    self.stack.push((self.end, 0, false));
813                    found_edge = true;
814                    break;
815                }
816            } else {
817                // Find the next valid edge to explore from this node.
818                let neighbors = if let Some(n) = self.graph.adj.get(*node as usize) {
819                    n.as_slice()
820                } else {
821                    // No edges from this node — backtrack.
822                    self.stack.pop();
823                    continue;
824                };
825
826                while *edge_idx < neighbors.len() {
827                    let edge = &neighbors[*edge_idx];
828                    *edge_idx += 1;
829                    let pool_idx = edge.pool_idx;
830
831                    // Cycle detection: skip pools already on the working path.
832                    if self.visited[pool_idx as usize] {
833                        continue;
834                    }
835
836                    // Penultimate-hop reachability prune (analog of the
837                    // final-hop restriction, one level up): the hop being
838                    // chosen now leads to a node that must make the closing
839                    // hop on the next iteration. If that neighbor has no
840                    // edge to `end`, no cycle through it can close, so skip
841                    // it without pushing a dead frame. Sound (never skips a
842                    // valid cycle); conservative (a node whose only end-edges
843                    // are visited still passes this check and is pruned only
844                    // when the closing loop finds nothing).
845                    if penultimate_hop {
846                        let neighbor_can_close = self
847                            .end_edges
848                            .get(edge.neighbor as usize)
849                            .is_some_and(|l| !l.is_empty());
850                        if !neighbor_can_close {
851                            continue;
852                        }
853                    }
854
855                    // Per-depth pool-type filter.
856                    if let Some(filter) = filter_slice {
857                        let depth = self.working_path.len();
858                        if depth >= self.filter_len {
859                            continue;
860                        }
861                        if let Some(allowed_kinds) = &filter[depth] {
862                            let kind = self.graph.pools[pool_idx as usize].1;
863                            if !allowed_kinds.contains(&kind) {
864                                continue;
865                            }
866                        }
867
868                        // Lookahead pruning.
869                        let next_depth = depth + 1;
870                        if next_depth < self.filter_len {
871                            if let Some(nvd) = nvd_ref {
872                                if let Some(valid) = nvd.get(edge.neighbor as usize) {
873                                    if !valid[next_depth] {
874                                        continue;
875                                    }
876                                }
877                            }
878                        }
879                    }
880
881                    // Found a valid edge — extend the path and push the neighbor.
882                    self.working_path.push(pool_idx);
883                    self.visited[pool_idx as usize] = true;
884                    self.stack.push((edge.neighbor, 0, false));
885                    found_edge = true;
886                    break;
887                }
888            }
889
890            if !found_edge {
891                // No more edges to explore from this node — backtrack.
892                self.stack.pop();
893                if let Some(popped) = self.working_path.pop() {
894                    self.visited[popped as usize] = false;
895                }
896            }
897        }
898
899        // Search exhausted — emit a final heartbeat so the operator sees
900        // the total when discovery completes (even if it ran fast).
901        self.emit_discovery_complete();
902        self.done = true;
903        AdvanceOutcome::Exhausted
904    }
905
906    /// Emit a final discovery-complete line so the operator sees the total at
907    /// search end (cheap; covers the common fast-search case that never tripped
908    /// the throttled heartbeat).
909    fn emit_discovery_complete(&self) {
910        let elapsed = self.search_started.elapsed();
911        // Low-frequency stderr diagnostic on a zero-dependency leaf (no logging
912        // crate available); one line at discovery completion.
913        #[expect(clippy::print_stderr)]
914        {
915            eprintln!(
916                "[pathfinding] discovery complete: elapsed={elapsed:?} paths_yielded={} max_stack_depth={}",
917                self.paths_yielded, self.max_stack_depth
918            );
919        }
920    }
921
922    /// Advance the DFS and return the next complete path, or `None` if
923    /// the search is exhausted.
924    ///
925    /// If `include_reverse` is set, each found cycle yields the forward path
926    /// first, then the reversed path on the next call.
927    #[must_use]
928    pub fn next_path(&mut self) -> Option<Vec<EdgeKey>> {
929        match self.advance() {
930            AdvanceOutcome::Exhausted => None,
931            AdvanceOutcome::Forward => Some(self.path_to_edge_keys()),
932            AdvanceOutcome::Reversed => Some(self.reversed_path_to_edge_keys()),
933        }
934    }
935
936    /// Advance the DFS and append the next path's **pool indices** into `out`,
937    /// returning the number of indices appended (the path length), or `None`
938    /// if the search is exhausted.
939    ///
940    /// This is the allocation-free hot path used by the `PyO3` iterator: instead
941    /// of materializing a `Vec<EdgeKey>` per yielded path (96k small
942    /// allocations for a typical search), it appends the compact `u32` pool
943    /// indices into a caller-owned flat buffer. The FFI layer converts
944    /// indices → `(pool_id, kind_u8)` lazily while building Python objects.
945    #[must_use]
946    pub fn next_path_indices_into(&mut self, out: &mut Vec<u32>) -> Option<usize> {
947        match self.advance() {
948            AdvanceOutcome::Exhausted => None,
949            AdvanceOutcome::Forward => {
950                let len = self.working_path.len();
951                out.extend(self.working_path.iter().copied());
952                Some(len)
953            }
954            AdvanceOutcome::Reversed => {
955                let len = self.working_path.len();
956                out.extend(self.working_path.iter().rev().copied());
957                Some(len)
958            }
959        }
960    }
961
962    /// Resolve a compact pool index to its `EdgeKey` `(pool_id, PoolKind)`.
963    ///
964    /// Lets the `PyO3` layer convert buffered pool indices to Python tuples
965    /// without exposing the graph's internal `pools` field.
966    #[must_use]
967    pub fn pool_edge_key(&self, pool_idx: u32) -> EdgeKey {
968        self.graph.pools[pool_idx as usize]
969    }
970
971    /// Convert the current working path (pool indices) to `EdgeKey`s for yielding.
972    fn path_to_edge_keys(&self) -> Vec<EdgeKey> {
973        self.working_path
974            .iter()
975            .map(|&idx| self.graph.pools[idx as usize])
976            .collect()
977    }
978
979    /// Convert the reversed working path to `EdgeKey`s.
980    fn reversed_path_to_edge_keys(&self) -> Vec<EdgeKey> {
981        self.working_path
982            .iter()
983            .rev()
984            .map(|&idx| self.graph.pools[idx as usize])
985            .collect()
986    }
987}
988
989impl Iterator for OwnedPathFinder {
990    type Item = Vec<EdgeKey>;
991
992    fn next(&mut self) -> Option<Self::Item> {
993        self.next_path()
994    }
995}
996
997impl PathGraph {
998    /// Create a lazy iterator over all valid paths from `start` back to `end`.
999    ///
1000    /// This is a stateful, resumable version of the DFS. The iterator yields
1001    /// one path at a time, avoiding the memory cost of collecting all results
1002    /// into a `Vec`. Use this when the graph may produce a large number of
1003    /// paths.
1004    #[expect(clippy::too_many_arguments)]
1005    #[must_use]
1006    pub fn find_paths_iter<'a>(
1007        &'a self,
1008        start: u64,
1009        end: u64,
1010        min_depth: usize,
1011        max_depth: Option<usize>,
1012        include_reverse: bool,
1013        pool_type_per_depth: Option<&'a [Option<Vec<PoolKind>>]>,
1014        node_valid_depths: Option<&'a [Vec<bool>]>,
1015    ) -> PathFinder<'a> {
1016        let effective_max_depth: Option<usize> = match pool_type_per_depth {
1017            Some(filter) => {
1018                let filter_len = filter.len();
1019                match max_depth {
1020                    Some(md) => Some(md.min(filter_len)),
1021                    None => Some(filter_len),
1022                }
1023            }
1024            None => max_depth,
1025        };
1026
1027        let filter_len = pool_type_per_depth.map_or(0, <[Option<Vec<PoolKind>>]>::len);
1028
1029        let start_idx = self.compact_index(start);
1030        let end_idx = self.compact_index(end);
1031
1032        // Match OwnedPathFinder::new: a boundary token absent from the graph
1033        // must yield NO paths (no `end` fallback to a synthetic index 0).
1034        let (stack, done) = match (start_idx, end_idx) {
1035            (Some(s), Some(_e)) => (vec![(s, 0, false)], false),
1036            _ => (Vec::new(), true),
1037        };
1038        let end_idx = end_idx.unwrap_or(0);
1039
1040        PathFinder {
1041            graph: self,
1042            end: end_idx,
1043            min_depth,
1044            effective_max_depth,
1045            include_reverse,
1046            pool_type_per_depth,
1047            node_valid_depths,
1048            filter_len,
1049            stack,
1050            working_path: Vec::with_capacity(16),
1051            visited: vec![false; self.pools.len()],
1052            pending_reverse: None,
1053            done,
1054        }
1055    }
1056
1057    /// Depth-first search for all valid paths from `start` back to `end`.
1058    ///
1059    /// This is an eager version that collects all results. For large graphs
1060    /// that may produce millions of paths, use [`PathGraph::find_paths_iter`]
1061    /// instead to avoid excessive memory usage.
1062    ///
1063    /// # Arguments
1064    /// * `start` — The token ID where the search begins.
1065    /// * `end` — The token ID the path must return to.
1066    /// * `min_depth` — Minimum number of hops in a completed path.
1067    /// * `max_depth` — Maximum number of hops, or `None` for no limit.
1068    /// * `include_reverse` — If `true`, yield each found path again reversed.
1069    /// * `pool_type_per_depth` — Optional per-depth allowed pool kinds. A
1070    ///   `None` entry allows all kinds at that depth. Implicitly caps max
1071    ///   depth at its length.
1072    /// * `node_valid_depths` — Optional precomputed valid-depth sets (from
1073    ///   `compute_node_valid_depths`) for lookahead pruning.
1074    ///
1075    /// # Returns
1076    /// A `Vec` of paths, each a `Vec` of `(pool_id, PoolKind)` hops.
1077    #[expect(clippy::too_many_arguments)]
1078    #[must_use]
1079    pub fn find_paths(
1080        &self,
1081        start: u64,
1082        end: u64,
1083        min_depth: usize,
1084        max_depth: Option<usize>,
1085        include_reverse: bool,
1086        pool_type_per_depth: Option<&[Option<Vec<PoolKind>>]>,
1087        node_valid_depths: Option<&[Vec<bool>]>,
1088    ) -> Vec<Vec<EdgeKey>> {
1089        self.find_paths_iter(
1090            start,
1091            end,
1092            min_depth,
1093            max_depth,
1094            include_reverse,
1095            pool_type_per_depth,
1096            node_valid_depths,
1097        )
1098        .collect()
1099    }
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104    use super::*;
1105    use std::collections::HashSet;
1106
1107    // Token IDs for the synthetic 4-pool V2 fixture (mirrors the in-memory
1108    // DB fixture from test_permutation_filter_min_depth.py).
1109    // Graph:
1110    //     WETH ===pool1=== A
1111    //     WETH ===pool2=== A      (parallel edge -> 2-hop cycle WETH-A-WETH)
1112    //     A     ===pool3=== B
1113    //     B     ===pool4=== WETH   (completes 3-hop cycle WETH-A-B-WETH)
1114    const WETH: u64 = 1;
1115    const A: u64 = 2;
1116    const B: u64 = 3;
1117    const POOL_WETH_A_1: u64 = 100;
1118    const POOL_WETH_A_2: u64 = 101;
1119    const POOL_A_B: u64 = 102;
1120    const POOL_B_WETH: u64 = 103;
1121
1122    fn build_fixture_graph() -> PathGraph {
1123        PathGraph::from_edges(vec![
1124            (WETH, A, POOL_WETH_A_1, PoolKind::V2),
1125            (WETH, A, POOL_WETH_A_2, PoolKind::V2),
1126            (A, B, POOL_A_B, PoolKind::V2),
1127            (B, WETH, POOL_B_WETH, PoolKind::V2),
1128        ])
1129    }
1130
1131    fn edges_to_pool_ids(path: &[EdgeKey]) -> Vec<u64> {
1132        path.iter().map(|(pid, _)| *pid).collect()
1133    }
1134
1135    #[test]
1136    fn test_from_edges_builds_adjacency() {
1137        let graph = build_fixture_graph();
1138        assert_eq!(graph.node_count(), 3); // WETH, A, B
1139        assert!(graph.contains_node(WETH));
1140        assert!(graph.contains_node(A));
1141        assert!(graph.contains_node(B));
1142    }
1143
1144    #[test]
1145    fn test_parallel_edges_preserved() {
1146        let graph = build_fixture_graph();
1147        // WETH has 3 edges: pool1->A, pool2->A, pool4->B
1148        assert_eq!(graph.degree(WETH), Some(3));
1149        // A has 3 edges: pool1->WETH, pool2->WETH, pool3->B
1150        assert_eq!(graph.degree(A), Some(3));
1151    }
1152
1153    #[test]
1154    fn test_prune_dead_ends() {
1155        // Build a graph with a dead-end chain: A-B-C where C only connects to B.
1156        // After pruning, C (degree 1) is removed, then B (now degree 1) is removed.
1157        let mut graph = PathGraph::from_edges(vec![
1158            (A, B, 1, PoolKind::V2),
1159            (B, 99, 2, PoolKind::V2), // 99 is a dead end (degree 1)
1160        ]);
1161        graph.prune_dead_ends();
1162        // 99 is removed (degree 1). Then B has degree 1 (only edge to A).
1163        // Wait — A-B is bidirectional, so A has 1 edge (to B) and B has 1 edge
1164        // (to A) after 99 is removed. Both get pruned.
1165        // Actually: A has edges [B], B has edges [A, 99]. 99 has edges [B].
1166        // 99 (degree 1) pruned. Now B has edges [A] (degree 1) pruned.
1167        // Now A has edges [B] but B is removed, so A has 0 edges pruned.
1168        assert!(!graph.contains_node(99));
1169        assert!(!graph.contains_node(B));
1170        assert!(!graph.contains_node(A));
1171    }
1172
1173    #[test]
1174    fn test_prune_preserves_cycle() {
1175        // The fixture graph has cycles; pruning should not remove WETH, A, B.
1176        let mut graph = build_fixture_graph();
1177        graph.prune_dead_ends();
1178        assert!(graph.contains_node(WETH));
1179        assert!(graph.contains_node(A));
1180        assert!(graph.contains_node(B));
1181    }
1182
1183    #[test]
1184    fn test_two_hop_pathfinding() {
1185        // WETH -> A -> WETH (2-hop cycle via parallel edges)
1186        let graph = build_fixture_graph();
1187        let paths = graph.find_paths(WETH, WETH, 2, Some(2), false, None, None);
1188        assert!(!paths.is_empty(), "Should find 2-hop WETH cycles");
1189        for path in &paths {
1190            assert_eq!(path.len(), 2, "Each path should be exactly 2 hops");
1191        }
1192    }
1193
1194    #[test]
1195    fn test_three_hop_pathfinding() {
1196        // WETH -> A -> B -> WETH (3-hop cycle)
1197        let graph = build_fixture_graph();
1198        let paths = graph.find_paths(WETH, WETH, 3, Some(3), false, None, None);
1199        assert!(!paths.is_empty(), "Should find 3-hop WETH cycles");
1200        for path in &paths {
1201            assert_eq!(path.len(), 3, "Each path should be exactly 3 hops");
1202        }
1203    }
1204
1205    #[test]
1206    fn test_min_depth_excludes_shorter() {
1207        // With min_depth=3, no 2-hop paths should be yielded.
1208        let graph = build_fixture_graph();
1209        let paths = graph.find_paths(WETH, WETH, 3, Some(3), false, None, None);
1210        for path in &paths {
1211            assert_eq!(path.len(), 3, "min_depth=3 should exclude shorter paths");
1212        }
1213    }
1214
1215    #[test]
1216    fn test_max_depth_caps() {
1217        // With max_depth=2, no 3-hop paths.
1218        let graph = build_fixture_graph();
1219        let paths = graph.find_paths(WETH, WETH, 2, Some(2), false, None, None);
1220        for path in &paths {
1221            assert!(path.len() <= 2, "max_depth=2 should cap path length");
1222        }
1223    }
1224
1225    #[test]
1226    fn test_include_reverse_doubles_output() {
1227        let graph = build_fixture_graph();
1228        let forward = graph.find_paths(WETH, WETH, 2, Some(2), false, None, None);
1229        let with_reverse = graph.find_paths(WETH, WETH, 2, Some(2), true, None, None);
1230        assert_eq!(
1231            with_reverse.len(),
1232            forward.len() * 2,
1233            "include_reverse should double the output count"
1234        );
1235    }
1236
1237    #[test]
1238    fn test_absent_end_token_yields_no_paths() {
1239        // A boundary token that is NOT a node in the (filtered) graph - e.g. a
1240        // NATIVE/0x0 token with no connecting pool among the candidate edges -
1241        // must yield NO paths. Regression: the old
1242        // `compact_index(end).unwrap_or(0)` silently remapped an absent end to
1243        // compact index 0, so a "WETH -> <absent-token>" search actually ran as
1244        // a "<index-0> -> ..." search and emitted non-closing cycles that
1245        // tripped the direction-resolution fail-stop on the live bot.
1246        let graph = build_fixture_graph();
1247        // start present, end absent -> no paths.
1248        let paths = graph.find_paths(WETH, 9999, 3, Some(3), true, None, None);
1249        assert!(paths.is_empty(), "absent end token must yield no paths");
1250        // start absent -> no paths.
1251        let paths_start = graph.find_paths(9999, WETH, 3, Some(3), true, None, None);
1252        assert!(
1253            paths_start.is_empty(),
1254            "absent start token must yield no paths"
1255        );
1256        // both absent -> no paths.
1257        let paths_both = graph.find_paths(9999, 9998, 3, Some(3), true, None, None);
1258        assert!(
1259            paths_both.is_empty(),
1260            "absent start+end must yield no paths"
1261        );
1262        // Sanity: a present end still yields its cycles.
1263        let ok = graph.find_paths(WETH, WETH, 3, Some(3), false, None, None);
1264        assert!(!ok.is_empty(), "present end (WETH) must still yield cycles");
1265    }
1266
1267    #[test]
1268    fn test_three_hop_filter_yields_no_two_hop_cycles() {
1269        // A 3-depth V2-V2-V2 filter must yield only 3-hop paths.
1270        // The synthetic graph contains both a 2-hop cycle (WETH-A-WETH via
1271        // parallel pools) and a 3-hop cycle (WETH-A-B-WETH). The 2-hop cycle
1272        // matches the filter's depths 0 and 1, so without the implicit
1273        // min_depth floor from the filter length, it would leak through.
1274        let graph = build_fixture_graph();
1275        let filter = vec![
1276            Some(vec![PoolKind::V2]),
1277            Some(vec![PoolKind::V2]),
1278            Some(vec![PoolKind::V2]),
1279        ];
1280        let nvd = graph.compute_node_valid_depths(&filter);
1281        let _paths = graph.find_paths(
1282            WETH,
1283            WETH,
1284            2,       // caller min_depth
1285            Some(3), // caller max_depth
1286            false,
1287            Some(&filter),
1288            Some(&nvd),
1289        );
1290
1291        // The filter caps max_depth at 3 and the effective min_depth should
1292        // be max(2, 3) = 3 (floor applied by the Python caller). But the Rust
1293        // core does NOT apply the floor — the caller does. Here we test with
1294        // min_depth=2 to verify that the filter alone does not leak 2-hop
1295        // paths... actually, it CAN leak 2-hop paths if min_depth=2.
1296        //
1297        // The Python find_paths applies: effective_min_depth = max(min_depth,
1298        // len(pool_type_per_depth)). So the caller would pass min_depth=3.
1299        // Let's test that explicitly:
1300        let paths_floored = graph.find_paths(
1301            WETH,
1302            WETH,
1303            3, // effective min_depth = max(2, 3) = 3
1304            Some(3),
1305            false,
1306            Some(&filter),
1307            Some(&nvd),
1308        );
1309        for path in &paths_floored {
1310            assert_eq!(
1311                path.len(),
1312                3,
1313                "3-depth filter with min_depth=3 should yield only 3-hop paths"
1314            );
1315        }
1316        assert!(
1317            !paths_floored.is_empty(),
1318            "3-depth filter should yield at least one 3-hop path"
1319        );
1320    }
1321
1322    #[test]
1323    fn test_pool_type_per_depth_caps_max_depth() {
1324        // A 2-depth filter with max_depth=3 must not IndexError and must
1325        // cap at 2-hop paths.
1326        let graph = build_fixture_graph();
1327        let filter = vec![Some(vec![PoolKind::V2]), Some(vec![PoolKind::V2])];
1328        let nvd = graph.compute_node_valid_depths(&filter);
1329        let paths = graph.find_paths(
1330            WETH,
1331            WETH,
1332            2,
1333            Some(3), // exceeds filter length
1334            false,
1335            Some(&filter),
1336            Some(&nvd),
1337        );
1338        for path in &paths {
1339            assert_eq!(
1340                path.len(),
1341                2,
1342                "2-depth filter should cap at 2-hop paths even with max_depth=3"
1343            );
1344        }
1345    }
1346
1347    #[test]
1348    fn test_pool_type_per_depth_with_max_depth_none() {
1349        // A 2-depth filter with max_depth=None must cap at 2-hop paths.
1350        let graph = build_fixture_graph();
1351        let filter = vec![Some(vec![PoolKind::V2]), Some(vec![PoolKind::V2])];
1352        let nvd = graph.compute_node_valid_depths(&filter);
1353        let paths = graph.find_paths(
1354            WETH,
1355            WETH,
1356            2,
1357            None, // no explicit max
1358            false,
1359            Some(&filter),
1360            Some(&nvd),
1361        );
1362        for path in &paths {
1363            assert_eq!(path.len(), 2);
1364        }
1365    }
1366
1367    #[test]
1368    fn test_none_entry_allows_all_kinds() {
1369        // A filter with None at depth 0 allows all pool kinds.
1370        let graph = build_fixture_graph();
1371        let filter = vec![None, Some(vec![PoolKind::V4])];
1372        let nvd = graph.compute_node_valid_depths(&filter);
1373        // The fixture has only V2 pools, and depth 1 requires V4.
1374        // So no V4 paths should be found (node_valid_depths will show A and B
1375        // are invalid at depth 1).
1376        let paths = graph.find_paths(WETH, WETH, 2, Some(2), false, Some(&filter), Some(&nvd));
1377        // No V4 pools exist, so no paths match the filter.
1378        assert!(
1379            paths.is_empty(),
1380            "V4 filter on V2-only graph should yield nothing"
1381        );
1382    }
1383
1384    #[test]
1385    fn test_cycle_detection_prevents_reusing_pools() {
1386        // A path must not visit the same pool twice.
1387        let graph = build_fixture_graph();
1388        let paths = graph.find_paths(WETH, WETH, 2, Some(3), false, None, None);
1389        for path in &paths {
1390            let pool_ids = edges_to_pool_ids(path);
1391            let unique: HashSet<u64> = pool_ids.iter().copied().collect();
1392            assert_eq!(
1393                pool_ids.len(),
1394                unique.len(),
1395                "Path should not reuse a pool: {pool_ids:?}"
1396            );
1397        }
1398    }
1399
1400    #[test]
1401    fn test_node_not_in_graph_returns_empty() {
1402        let graph = build_fixture_graph();
1403        let paths = graph.find_paths(999, 999, 2, Some(2), false, None, None);
1404        assert!(paths.is_empty());
1405    }
1406
1407    #[test]
1408    fn test_poolkind_roundtrip() {
1409        assert_eq!(PoolKind::V2.as_u8(), 0);
1410        assert_eq!(PoolKind::V3.as_u8(), 1);
1411        assert_eq!(PoolKind::V4.as_u8(), 2);
1412        assert_eq!(PoolKind::from_u8(0), Some(PoolKind::V2));
1413        assert_eq!(PoolKind::from_u8(1), Some(PoolKind::V3));
1414        assert_eq!(PoolKind::from_u8(2), Some(PoolKind::V4));
1415        assert_eq!(PoolKind::from_u8(3), None);
1416    }
1417
1418    #[test]
1419    fn test_mixed_pool_kinds() {
1420        // Build a graph with both V2 and V4 pools.
1421        // WETH --V2-- A --V4-- B --V2-- WETH (3-hop mixed cycle)
1422        let graph = PathGraph::from_edges(vec![
1423            (WETH, A, 1, PoolKind::V2),
1424            (A, B, 2, PoolKind::V4),
1425            (B, WETH, 3, PoolKind::V2),
1426        ]);
1427        let filter = vec![
1428            Some(vec![PoolKind::V2]),
1429            Some(vec![PoolKind::V4]),
1430            Some(vec![PoolKind::V2]),
1431        ];
1432        let nvd = graph.compute_node_valid_depths(&filter);
1433        let paths = graph.find_paths(WETH, WETH, 3, Some(3), false, Some(&filter), Some(&nvd));
1434        assert!(!paths.is_empty(), "Should find a V2-V4-V2 path");
1435        for path in &paths {
1436            assert_eq!(path.len(), 3);
1437            assert_eq!(path[0].1, PoolKind::V2);
1438            assert_eq!(path[1].1, PoolKind::V4);
1439            assert_eq!(path[2].1, PoolKind::V2);
1440        }
1441    }
1442
1443    /// The discovery-heartbeat diagnostics (NY4EFN) + the `while let` → `loop`
1444    /// refactor of `OwnedPathFinder::advance` must not alter enumeration order
1445    /// or yield count. Two independent searches on the same graph must produce
1446    /// identical, stable output — the heartbeat is purely diagnostic stderr.
1447    #[test]
1448    fn test_heartbeat_diagnostics_do_not_alter_enumeration() {
1449        let graph = build_fixture_graph();
1450        let run_one: Vec<Vec<u64>> = graph
1451            .find_paths(WETH, WETH, 2, Some(3), true, None, None)
1452            .into_iter()
1453            .map(|p| edges_to_pool_ids(&p))
1454            .collect();
1455        // Re-run on a fresh graph instance — determinism + no heartbeat side
1456        // effects across runs.
1457        let graph2 = build_fixture_graph();
1458        let run_two: Vec<Vec<u64>> = graph2
1459            .find_paths(WETH, WETH, 2, Some(3), true, None, None)
1460            .into_iter()
1461            .map(|p| edges_to_pool_ids(&p))
1462            .collect();
1463        assert!(!run_one.is_empty(), "fixture must yield paths");
1464        assert_eq!(
1465            run_one, run_two,
1466            "enumeration must be stable + unaffected by heartbeat wiring"
1467        );
1468    }
1469}