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}