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