use std::borrow::Borrow;
use std::collections::{HashMap, VecDeque};
use std::hash::{BuildHasherDefault, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
mod expansion;
mod progress;
mod spec;
pub use progress::WalkerTally;
pub use spec::SearchSpec;
use expansion::WalkExpansion;
use progress::ProgressReporter;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownVariant {
pub raw: String,
pub known: Vec<&'static str>,
}
impl std::fmt::Display for UnknownVariant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unknown variant {:?}: expected one of {}",
self.raw,
self.known.join(", ")
)
}
}
impl std::error::Error for UnknownVariant {}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[non_exhaustive]
pub enum PoolKind {
V2,
V3,
V4,
}
impl PoolKind {
pub const ALL: [PoolKind; 3] = [PoolKind::V2, PoolKind::V3, PoolKind::V4];
#[must_use]
pub const fn as_u8(self) -> u8 {
match self {
PoolKind::V2 => 0,
PoolKind::V3 => 1,
PoolKind::V4 => 2,
}
}
#[must_use]
pub const fn tag(self) -> &'static str {
match self {
PoolKind::V2 => "V2",
PoolKind::V3 => "V3",
PoolKind::V4 => "V4",
}
}
#[must_use]
pub const fn from_u8(val: u8) -> Option<Self> {
let mut i = 0;
while i < Self::ALL.len() {
if Self::ALL[i].as_u8() == val {
return Some(Self::ALL[i]);
}
i += 1;
}
None
}
pub const KNOWN_KINDS: &'static [(&'static str, Self)] = &[
("uniswap_v2", Self::V2),
("sushiswap_v2", Self::V2),
("pancakeswap_v2", Self::V2),
("aerodrome_v2", Self::V2),
("camelot_v2", Self::V2),
("swapbased_v2", Self::V2),
("uniswap_v3", Self::V3),
("sushiswap_v3", Self::V3),
("pancakeswap_v3", Self::V3),
("aerodrome_v3", Self::V3),
("uniswap_v4", Self::V4),
];
pub const DECLARED_UNSUPPORTED_KINDS: &'static [&'static str] = &["lfj_binned"];
#[must_use]
pub fn is_declared_unsupported(kind: &str) -> bool {
Self::DECLARED_UNSUPPORTED_KINDS.contains(&kind)
}
}
impl TryFrom<&str> for PoolKind {
type Error = UnknownVariant;
fn try_from(kind: &str) -> Result<Self, Self::Error> {
Self::KNOWN_KINDS
.iter()
.find(|(name, _)| *name == kind)
.map(|(_, pool_kind)| *pool_kind)
.ok_or_else(|| UnknownVariant {
raw: kind.to_owned(),
known: Self::KNOWN_KINDS.iter().map(|(name, _)| *name).collect(),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct Edge {
pub neighbor: u64,
pub pool_id: u64,
pub pool_kind: PoolKind,
}
pub type EdgeKey = (u64, PoolKind);
#[derive(Default)]
pub(crate) struct U64Hasher {
hash: u64,
}
impl Hasher for U64Hasher {
#[inline]
fn finish(&self) -> u64 {
self.hash
}
#[inline]
fn write_u64(&mut self, n: u64) {
self.hash = (self.hash.rotate_left(5) ^ n).wrapping_mul(0x517C_C1B7_2722_0A95);
}
#[inline]
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.hash =
(self.hash.rotate_left(5) ^ u64::from(b)).wrapping_mul(0x517C_C1B7_2722_0A95);
}
}
}
pub(crate) type U64BuildHasher = BuildHasherDefault<U64Hasher>;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) struct CompactEdge {
neighbor: u32,
pool_idx: u32,
}
#[derive(Clone)]
pub struct PathGraph {
adj_offsets: Vec<u32>,
adj_flat: Vec<CompactEdge>,
token_index: HashMap<u64, u32, U64BuildHasher>,
pools: Vec<(u64, PoolKind)>,
bundle_offsets: Vec<u32>,
bundle_pools_flat: Vec<u32>,
bundle_pairs: Vec<(u32, u32)>,
bundle_kinds: Vec<u32>,
}
impl PathGraph {
#[must_use]
pub(crate) fn adj_of(&self, node: u32) -> &[CompactEdge] {
let start = self.adj_offsets[node as usize] as usize;
let end = self.adj_offsets[node as usize + 1] as usize;
&self.adj_flat[start..end]
}
#[must_use]
pub(crate) fn hop_distances(&self, sources: &[u32]) -> Vec<u32> {
let n = self.nodes();
let mut dist: Vec<u32> = vec![u32::MAX; n];
let mut queue: VecDeque<u32> = VecDeque::new();
for &s in sources {
if (s as usize) < n {
dist[s as usize] = 0;
queue.push_back(s);
}
}
while let Some(x) = queue.pop_front() {
let next = dist[x as usize].saturating_add(1);
for edge in self.adj_of(x) {
if dist[edge.neighbor as usize] > next {
dist[edge.neighbor as usize] = next;
queue.push_back(edge.neighbor);
}
}
}
dist
}
fn nodes(&self) -> usize {
self.adj_offsets.len() - 1
}
#[must_use]
pub(crate) fn bundle_pools(&self, b: u32) -> &[u32] {
let start = self.bundle_offsets[b as usize] as usize;
let end = self.bundle_offsets[b as usize + 1] as usize;
&self.bundle_pools_flat[start..end]
}
#[must_use]
pub(crate) fn bundle_kind_mask(&self, b: u32) -> u32 {
self.bundle_kinds[b as usize]
}
#[must_use]
pub(crate) fn token_bundles_csr(&self) -> (Vec<u32>, Vec<(u32, u32)>) {
let n = self.nodes();
let mut offsets = vec![0u32; n + 1];
for &(a, t) in &self.bundle_pairs {
offsets[a as usize + 1] += 1;
if t != a {
offsets[t as usize + 1] += 1;
}
}
for w in 1..=n {
offsets[w] += offsets[w - 1];
}
let mut flat = vec![(0u32, 0u32); offsets[n] as usize];
let mut cursor: Vec<u32> = offsets[..n].to_vec();
for (b, &(a, t)) in self.bundle_pairs.iter().enumerate() {
let bundle = expect_u32(b, "bundle count exceeds u32::MAX");
flat[cursor[a as usize] as usize] = (t, bundle);
cursor[a as usize] += 1;
if t != a {
flat[cursor[t as usize] as usize] = (a, bundle);
cursor[t as usize] += 1;
}
}
(offsets, flat)
}
#[must_use]
pub fn from_edges(edges: Vec<(u64, u64, u64, PoolKind)>) -> Self {
let n = edges.len();
let mut token_index: HashMap<u64, u32, U64BuildHasher> =
HashMap::with_capacity_and_hasher(n * 2, U64BuildHasher::default());
let mut pools: Vec<(u64, PoolKind)> = Vec::with_capacity(n);
let mut arena: Vec<(u32, u32)> = Vec::with_capacity(n);
let mut bundle_id: HashMap<u64, u32, U64BuildHasher> =
HashMap::with_capacity_and_hasher(n, U64BuildHasher::default());
let mut bundle_pairs: Vec<(u32, u32)> = Vec::with_capacity(n);
let mut bundle_kinds: Vec<u32> = Vec::with_capacity(n);
let mut pool_bundle: Vec<u32> = Vec::with_capacity(n);
for (token0, token1, pool_id, pool_kind) in edges {
pools.push((pool_id, pool_kind));
let idx0 = Self::intern_token(&mut token_index, token0);
let idx1 = Self::intern_token(&mut token_index, token1);
let packed = pack_token_pair(idx0, idx1);
let kind_bit = 1u32 << pool_kind.as_u8();
let bundle = if let Some(&b) = bundle_id.get(&packed) {
bundle_kinds[b as usize] |= kind_bit;
b
} else {
let b = expect_u32(bundle_pairs.len(), "bundle count exceeds u32::MAX");
bundle_id.insert(packed, b);
bundle_pairs.push((idx0.min(idx1), idx0.max(idx1)));
bundle_kinds.push(kind_bit);
b
};
pool_bundle.push(bundle);
arena.push((idx0, idx1));
}
let node_count = token_index.len();
#[expect(clippy::expect_used)]
let node_count_u32 = u32::try_from(node_count).expect("node count exceeds u32::MAX");
let mut deg = vec![0u32; node_count];
for (a, b) in &arena {
deg[*a as usize] += 1;
deg[*b as usize] += 1;
}
let mut adj_offsets: Vec<u32> = Vec::with_capacity(node_count + 1);
let mut running: u32 = 0;
adj_offsets.push(0);
for &d in ° {
running += d;
adj_offsets.push(running);
}
let total = running as usize;
let mut adj_flat: Vec<CompactEdge> = vec![
CompactEdge {
neighbor: node_count_u32,
pool_idx: u32::MAX,
};
total
];
let mut cursor: Vec<u32> = adj_offsets[..node_count].to_vec();
for (pool_idx_usize, (a, b)) in arena.iter().enumerate() {
#[expect(clippy::expect_used)]
let pool_idx = u32::try_from(pool_idx_usize).expect("pool index exceeds u32::MAX");
let ca = &mut cursor[*a as usize];
adj_flat[*ca as usize] = CompactEdge {
neighbor: *b,
pool_idx,
};
*ca += 1;
let cb = &mut cursor[*b as usize];
adj_flat[*cb as usize] = CompactEdge {
neighbor: *a,
pool_idx,
};
*cb += 1;
}
let bundle_count = bundle_pairs.len();
let mut bundle_offsets: Vec<u32> = vec![0; bundle_count + 1];
for &b in &pool_bundle {
bundle_offsets[b as usize + 1] += 1;
}
for w in 1..=bundle_count {
bundle_offsets[w] += bundle_offsets[w - 1];
}
let mut bundle_pools_flat: Vec<u32> = vec![0; pool_bundle.len()];
let mut bcursor: Vec<u32> = bundle_offsets[..bundle_count].to_vec();
for (pool_idx_usize, &b) in pool_bundle.iter().enumerate() {
#[expect(clippy::expect_used)]
let pool_idx = u32::try_from(pool_idx_usize).expect("pool index exceeds u32::MAX");
bundle_pools_flat[bcursor[b as usize] as usize] = pool_idx;
bcursor[b as usize] += 1;
}
Self {
adj_offsets,
adj_flat,
token_index,
pools,
bundle_offsets,
bundle_pools_flat,
bundle_pairs,
bundle_kinds,
}
}
fn intern_token(token_index: &mut HashMap<u64, u32, U64BuildHasher>, token: u64) -> u32 {
if let Some(&idx) = token_index.get(&token) {
idx
} else {
#[expect(clippy::expect_used)] let idx = u32::try_from(token_index.len()).expect("token count exceeds u32::MAX");
token_index.insert(token, idx);
idx
}
}
#[must_use]
fn compact_index(&self, token: u64) -> Option<u32> {
self.token_index.get(&token).copied()
}
#[must_use]
pub fn contains_node(&self, node: u64) -> bool {
self.token_index.contains_key(&node)
}
#[must_use]
pub fn node_count(&self) -> usize {
self.nodes()
}
#[must_use]
pub fn degree(&self, token: u64) -> Option<usize> {
self.compact_index(token).map(|i| self.adj_of(i).len())
}
pub fn prune_dead_ends(&mut self) {
let n = self.nodes();
let node_count_u32 = expect_u32(self.nodes(), "node count exceeds u32::MAX");
let mut degree: Vec<usize> = (0..node_count_u32).map(|i| self.adj_of(i).len()).collect();
let mut removed = vec![false; n];
let mut queue: Vec<usize> = Vec::with_capacity(n / 8);
for (i, &d) in degree.iter().enumerate() {
if d <= 1 {
queue.push(i);
}
}
let mut head = 0usize;
while head < queue.len() {
let i = queue[head];
head += 1;
if removed[i] {
continue;
}
removed[i] = true;
for e in self.adj_of(expect_u32(i, "node index exceeds u32::MAX")) {
let j = e.neighbor as usize;
if removed[j] {
continue;
}
degree[j] -= 1;
if degree[j] <= 1 {
queue.push(j);
}
}
}
let mut new_flat: Vec<CompactEdge> = Vec::with_capacity(self.adj_flat.len());
let mut new_offsets: Vec<u32> = Vec::with_capacity(n + 1);
new_offsets.push(0);
for (i, &start) in self.adj_offsets.iter().enumerate().take(n) {
if !removed[i] {
let end = self.adj_offsets[i + 1] as usize;
for e in &self.adj_flat[start as usize..end] {
if !removed[e.neighbor as usize] {
new_flat.push(*e);
}
}
}
#[expect(clippy::expect_used)]
new_offsets.push(u32::try_from(new_flat.len()).expect("edge count exceeds u32::MAX"));
}
self.adj_flat = new_flat;
self.adj_offsets = new_offsets;
self.token_index
.retain(|_, &mut idx| !removed[idx as usize]);
}
#[must_use]
pub fn compute_node_valid_depths(
&self,
pool_type_per_depth: &[Option<Vec<PoolKind>>],
) -> Vec<Vec<bool>> {
let mut result = Vec::with_capacity(self.nodes());
for i in 0..self.nodes() {
let edges = self.adj_of(expect_u32(i, "node index exceeds u32::MAX"));
let mut kinds = [false; PoolKind::ALL.len()];
for e in edges {
let kind = self.pools[e.pool_idx as usize].1;
debug_assert!((kind.as_u8() as usize) < PoolKind::ALL.len());
kinds[kind.as_u8() as usize] = true;
}
let mut valid = vec![false; pool_type_per_depth.len()];
for (d, allowed) in pool_type_per_depth.iter().enumerate() {
match allowed {
None => valid[d] = true,
Some(allowed_kinds) => {
valid[d] = allowed_kinds.iter().any(|k| kinds[k.as_u8() as usize]);
}
}
}
result.push(valid);
}
result
}
}
#[derive(PartialEq, Eq)]
enum AdvanceOutcome {
Exhausted,
Forward,
Reversed,
}
struct DfsFrame {
node: u32,
edge_idx: usize,
yield_checked: bool,
}
#[derive(Clone, Copy)]
enum Boundary {
Absent,
Present { start: u32, end: u32 },
}
pub struct BundledSearch<B: Borrow<PathGraph>> {
graph: B,
end: Option<u32>,
min_depth: usize,
effective_max_depth: Option<usize>,
include_reverse: bool,
pool_type_per_depth: Option<Vec<Option<Vec<PoolKind>>>>,
node_valid_depths: Option<Vec<Vec<bool>>>,
filter_len: usize,
allowed_masks: Vec<u32>,
tok_bundle_offsets: Vec<u32>,
tok_bundle_flat: Vec<(u32, u32)>,
prune: Option<Vec<u32>>,
stack: Vec<DfsFrame>,
walk_bundles: Vec<u32>,
bundle_use: Vec<u32>,
bundle_mult: Vec<u32>,
expansion: Option<WalkExpansion>,
pending_reverse: bool,
emitted: Vec<u32>,
done: bool,
cancel: Option<Arc<AtomicBool>>,
reporter: ProgressReporter,
}
#[inline]
fn pack_token_pair(a: u32, b: u32) -> u64 {
(u64::from(a.min(b)) << 32) | u64::from(a.max(b))
}
#[expect(clippy::expect_used)]
fn expect_u32(value: usize, what: &'static str) -> u32 {
u32::try_from(value).expect(what)
}
impl<B: Borrow<PathGraph>> BundledSearch<B> {
fn with_params(graph: B, spec: SearchSpec, node_valid_depths: Option<Vec<Vec<bool>>>) -> Self {
let mut spec = spec;
spec.validate();
let SearchSpec {
start,
end,
min_depth,
max_depth: effective_max_depth,
include_reverse,
pool_type_per_depth,
} = spec;
let filter_len = pool_type_per_depth.as_ref().map_or(0, Vec::len);
let src = graph.borrow();
let boundary = match (src.compact_index(start), src.compact_index(end)) {
(Some(s), Some(e)) => Boundary::Present { start: s, end: e },
_ => Boundary::Absent,
};
let prune = match (&boundary, effective_max_depth) {
(Boundary::Present { end, .. }, Some(_)) => Some(src.hop_distances(&[*end])),
_ => None,
};
let allowed_masks: Vec<u32> = pool_type_per_depth
.as_ref()
.map(|filter| {
filter
.iter()
.map(|allowed| match allowed {
None => u32::MAX,
Some(kinds) => kinds.iter().fold(0u32, |acc, k| acc | (1u32 << k.as_u8())),
})
.collect()
})
.unwrap_or_default();
let (tok_bundle_offsets, mut tok_bundle_flat) = src.token_bundles_csr();
if let Some(prune) = prune.as_ref() {
for w in 0..src.nodes() {
let start = tok_bundle_offsets[w] as usize;
let stop = tok_bundle_offsets[w + 1] as usize;
tok_bundle_flat[start..stop].sort_by_key(|(nbr, _)| prune[*nbr as usize]);
}
}
let n_bundles = src.bundle_pairs.len();
let bundle_mult: Vec<u32> = src.bundle_offsets.windows(2).map(|w| w[1] - w[0]).collect();
let now = Instant::now();
let (stack, done) = match boundary {
Boundary::Present { start, .. } => (
vec![DfsFrame {
node: start,
edge_idx: 0,
yield_checked: false,
}],
false,
),
Boundary::Absent => (Vec::new(), true),
};
Self {
graph,
end: match boundary {
Boundary::Present { end, .. } => Some(end),
Boundary::Absent => None,
},
min_depth,
effective_max_depth,
include_reverse,
pool_type_per_depth,
node_valid_depths,
filter_len,
allowed_masks,
tok_bundle_offsets,
tok_bundle_flat,
prune,
stack,
walk_bundles: Vec::with_capacity(16),
bundle_mult,
bundle_use: vec![0; n_bundles],
expansion: None,
pending_reverse: false,
emitted: Vec::new(),
done,
cancel: None,
reporter: ProgressReporter::new(now),
}
}
#[must_use]
pub fn with_progress(
mut self,
every: Duration,
sink: impl FnMut(&WalkerTally) + Send + Sync + 'static,
) -> Self {
self.reporter.install_hook(every, Box::new(sink));
self
}
#[must_use]
pub fn with_cancel(mut self, cancel: Arc<AtomicBool>) -> Self {
self.cancel = Some(cancel);
self
}
#[must_use]
fn cancelled(&self) -> bool {
self.cancel
.as_ref()
.is_some_and(|c| c.load(Ordering::Relaxed))
}
fn advance(&mut self) -> AdvanceOutcome {
if self.done || self.cancelled() {
self.done = true;
return AdvanceOutcome::Exhausted;
}
loop {
if let Some(outcome) = self.emit_pending() {
return outcome;
}
if self.step_walk().is_some() {
continue;
}
self.reporter.finish();
self.done = true;
return AdvanceOutcome::Exhausted;
}
}
fn emit_pending(&mut self) -> Option<AdvanceOutcome> {
self.expansion.as_ref()?;
if self.pending_reverse {
self.pending_reverse = false;
self.reporter.on_yield();
return Some(AdvanceOutcome::Reversed);
}
let next_ready = match self.expansion.as_mut() {
Some(exp) => exp.next_assignment_into(&mut self.emitted),
None => false,
};
if next_ready {
self.pending_reverse = self.include_reverse;
self.reporter.on_yield();
return Some(AdvanceOutcome::Forward);
}
self.expansion = None;
None
}
fn step_walk(&mut self) -> Option<AdvanceOutcome> {
let filter_slice = self.pool_type_per_depth.as_deref();
let node_valid_depths = self.node_valid_depths.as_deref();
let masks: &[u32] = &self.allowed_masks;
let prune_ref = self.prune.as_ref();
let src: &PathGraph = self.graph.borrow();
loop {
if self
.cancel
.as_ref()
.is_some_and(|c| c.load(Ordering::Relaxed))
{
break;
}
let stack_len = self.stack.len();
if stack_len == 0 {
break;
}
self.reporter.on_advance(stack_len);
let DfsFrame {
node,
edge_idx,
yield_checked,
} = &mut self.stack[stack_len - 1];
if !*yield_checked {
*yield_checked = true;
if Some(*node) == self.end && self.walk_bundles.len() >= self.min_depth {
let expansion = self.build_expansion();
self.expansion = Some(expansion);
break;
}
}
if let Some(effective_max_depth) = self.effective_max_depth {
if self.walk_bundles.len() >= effective_max_depth {
self.stack.pop();
if let Some(bundle) = self.walk_bundles.pop() {
self.bundle_use[bundle as usize] -= 1;
}
continue;
}
}
let remaining_budget = self.effective_max_depth.map(|effective_max_depth| {
u32::try_from(effective_max_depth - self.walk_bundles.len() - 1).unwrap_or(u32::MAX)
});
let entry_base = self.tok_bundle_offsets[*node as usize] as usize;
let entries_len = self.tok_bundle_offsets[*node as usize + 1] as usize - entry_base;
let mut found_bundle = false;
while *edge_idx < entries_len {
let (nbr, bundle) = self.tok_bundle_flat[entry_base + *edge_idx];
if let Some(prune) = prune_ref {
if prune[nbr as usize] > remaining_budget.unwrap_or(u32::MAX) {
break;
}
}
*edge_idx += 1;
if self.bundle_use[bundle as usize] >= self.bundle_mult[bundle as usize] {
continue;
}
if filter_slice.is_some() {
let depth = self.walk_bundles.len();
if depth >= self.filter_len {
continue;
}
if src.bundle_kind_mask(bundle) & masks.get(depth).copied().unwrap_or(0) == 0 {
continue;
}
let next_depth = depth + 1;
if next_depth < self.filter_len {
if let Some(valid) = node_valid_depths
.and_then(|node_valid_depths| node_valid_depths.get(nbr as usize))
{
if !valid[next_depth] {
continue;
}
}
}
}
self.stack.push(DfsFrame {
node: nbr,
edge_idx: 0,
yield_checked: false,
});
self.walk_bundles.push(bundle);
self.bundle_use[bundle as usize] += 1;
found_bundle = true;
break;
}
if !found_bundle {
self.stack.pop();
if let Some(bundle) = self.walk_bundles.pop() {
self.bundle_use[bundle as usize] -= 1;
}
}
}
if self.expansion.is_some() {
return Some(AdvanceOutcome::Forward);
}
None
}
fn build_expansion(&self) -> WalkExpansion {
let graph = self.graph.borrow();
let filtered = self.pool_type_per_depth.is_some();
let slots: Vec<Vec<u32>> = self
.walk_bundles
.iter()
.enumerate()
.map(|(depth, &bundle)| {
if !filtered {
return graph.bundle_pools(bundle).to_vec();
}
let mask = self.allowed_masks.get(depth).copied().unwrap_or(0);
graph
.bundle_pools(bundle)
.iter()
.copied()
.filter(|&pool_idx| {
(1u32 << graph.pools[pool_idx as usize].1.as_u8()) & mask != 0
})
.collect()
})
.collect();
WalkExpansion::new(self.walk_bundles.clone(), slots)
}
#[must_use]
pub fn next_path(&mut self) -> Option<Vec<EdgeKey>> {
match self.advance() {
AdvanceOutcome::Exhausted => None,
AdvanceOutcome::Forward => Some(
self.emitted
.iter()
.map(|&idx| self.graph.borrow().pools[idx as usize])
.collect(),
),
AdvanceOutcome::Reversed => Some(
self.emitted
.iter()
.rev()
.map(|&idx| self.graph.borrow().pools[idx as usize])
.collect(),
),
}
}
#[must_use]
pub fn next_path_indices_into(&mut self, out: &mut Vec<u32>) -> Option<usize> {
match self.advance() {
AdvanceOutcome::Exhausted => None,
AdvanceOutcome::Forward => {
let len = self.emitted.len();
out.extend(self.emitted.iter().copied());
Some(len)
}
AdvanceOutcome::Reversed => {
let len = self.emitted.len();
out.extend(self.emitted.iter().rev().copied());
Some(len)
}
}
}
#[must_use]
pub fn pool_edge_key(&self, pool_idx: u32) -> EdgeKey {
self.graph.borrow().pools[pool_idx as usize]
}
}
impl<B: Borrow<PathGraph>> Iterator for BundledSearch<B> {
type Item = Vec<EdgeKey>;
fn next(&mut self) -> Option<Self::Item> {
self.next_path()
}
}
pub type PathFinder<'a> = BundledSearch<&'a PathGraph>;
pub type OwnedPathFinder = BundledSearch<Box<PathGraph>>;
impl BundledSearch<Box<PathGraph>> {
#[must_use]
pub fn new(graph: PathGraph, spec: SearchSpec) -> Self {
let node_valid_depths = spec
.pool_type_per_depth
.as_ref()
.map(|filter| graph.compute_node_valid_depths(filter));
Self::with_params(Box::new(graph), spec, node_valid_depths)
}
}
impl PathGraph {
#[must_use]
pub fn find_paths_iter<'a>(
&'a self,
spec: SearchSpec,
node_valid_depths: Option<&'a [Vec<bool>]>,
) -> PathFinder<'a> {
let node_valid_depths = node_valid_depths.map(<[Vec<bool>]>::to_vec);
BundledSearch::with_params(self, spec, node_valid_depths)
}
#[must_use]
pub fn find_paths(
&self,
spec: SearchSpec,
node_valid_depths: Option<&[Vec<bool>]>,
) -> Vec<Vec<EdgeKey>> {
self.find_paths_iter(spec, node_valid_depths).collect()
}
}
#[cfg(test)]
mod tests {
#![expect(clippy::unwrap_used)]
use super::*;
use std::collections::{BTreeSet, HashSet};
#[test]
fn try_from_kind_str_projects_every_known_kind() {
for (kind, expected) in PoolKind::KNOWN_KINDS {
assert_eq!(PoolKind::try_from(*kind), Ok(*expected));
}
}
#[test]
fn try_from_kind_str_unknown_names_the_offending_string() {
for kind in ["sushiswap_v9", "", "lfj_binned"] {
let err = PoolKind::try_from(kind).unwrap_err();
assert_eq!(err.raw, kind);
let message = err.to_string();
assert!(
message.contains(&format!("{kind:?}")),
"raw value {kind:?} missing from: {message}"
);
assert!(
message.contains("uniswap_v2") && message.contains("uniswap_v4"),
"known set missing from: {message}"
);
}
}
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
const WETH: u64 = 1;
const A: u64 = 2;
const B: u64 = 3;
const POOL_WETH_A_1: u64 = 100;
const POOL_WETH_A_2: u64 = 101;
const POOL_A_B: u64 = 102;
const POOL_B_WETH: u64 = 103;
fn build_fixture_graph() -> PathGraph {
PathGraph::from_edges(vec![
(WETH, A, POOL_WETH_A_1, PoolKind::V2),
(WETH, A, POOL_WETH_A_2, PoolKind::V2),
(A, B, POOL_A_B, PoolKind::V2),
(B, WETH, POOL_B_WETH, PoolKind::V2),
])
}
fn edges_to_pool_ids(path: &[EdgeKey]) -> Vec<u64> {
path.iter().map(|(pid, _)| *pid).collect()
}
fn spec(
start: u64,
end: u64,
min_depth: usize,
max_depth: Option<usize>,
include_reverse: bool,
pool_type_per_depth: Option<Vec<Option<Vec<PoolKind>>>>,
) -> SearchSpec {
SearchSpec::new(
start,
end,
min_depth,
max_depth,
include_reverse,
pool_type_per_depth,
)
}
#[test]
fn test_from_edges_builds_adjacency() {
let graph = build_fixture_graph();
assert_eq!(graph.node_count(), 3); assert!(graph.contains_node(WETH));
assert!(graph.contains_node(A));
assert!(graph.contains_node(B));
}
#[test]
fn test_parallel_edges_preserved() {
let graph = build_fixture_graph();
assert_eq!(graph.degree(WETH), Some(3));
assert_eq!(graph.degree(A), Some(3));
}
#[test]
fn test_prune_dead_ends() {
let mut graph =
PathGraph::from_edges(vec![(A, B, 1, PoolKind::V2), (B, 99, 2, PoolKind::V2)]);
graph.prune_dead_ends();
assert!(!graph.contains_node(99));
assert!(!graph.contains_node(B));
assert!(!graph.contains_node(A));
}
#[test]
fn test_prune_preserves_cycle() {
let mut graph = build_fixture_graph();
graph.prune_dead_ends();
assert!(graph.contains_node(WETH));
assert!(graph.contains_node(A));
assert!(graph.contains_node(B));
}
#[test]
fn test_two_hop_pathfinding() {
let graph = build_fixture_graph();
let paths = graph.find_paths(spec(WETH, WETH, 2, Some(2), false, None), None);
assert!(!paths.is_empty(), "Should find 2-hop WETH cycles");
for path in &paths {
assert_eq!(path.len(), 2, "Each path should be exactly 2 hops");
}
}
#[test]
fn test_three_hop_pathfinding() {
let graph = build_fixture_graph();
let paths = graph.find_paths(spec(WETH, WETH, 3, Some(3), false, None), None);
assert!(!paths.is_empty(), "Should find 3-hop WETH cycles");
for path in &paths {
assert_eq!(path.len(), 3, "Each path should be exactly 3 hops");
}
}
#[test]
fn test_min_depth_excludes_shorter() {
let graph = build_fixture_graph();
let paths = graph.find_paths(spec(WETH, WETH, 3, Some(3), false, None), None);
for path in &paths {
assert_eq!(path.len(), 3, "min_depth=3 should exclude shorter paths");
}
}
#[test]
fn test_max_depth_caps() {
let graph = build_fixture_graph();
let paths = graph.find_paths(spec(WETH, WETH, 2, Some(2), false, None), None);
for path in &paths {
assert!(path.len() <= 2, "max_depth=2 should cap path length");
}
}
#[test]
fn test_include_reverse_doubles_output() {
let graph = build_fixture_graph();
let forward = graph.find_paths(spec(WETH, WETH, 2, Some(2), false, None), None);
let with_reverse = graph.find_paths(spec(WETH, WETH, 2, Some(2), true, None), None);
assert_eq!(
with_reverse.len(),
forward.len() * 2,
"include_reverse should double the output count"
);
}
#[test]
fn test_absent_end_token_yields_no_paths() {
let graph = build_fixture_graph();
let paths = graph.find_paths(spec(WETH, 9999, 3, Some(3), true, None), None);
assert!(paths.is_empty(), "absent end token must yield no paths");
let paths_start = graph.find_paths(spec(9999, WETH, 3, Some(3), true, None), None);
assert!(
paths_start.is_empty(),
"absent start token must yield no paths"
);
let paths_both = graph.find_paths(spec(9999, 9998, 3, Some(3), true, None), None);
assert!(
paths_both.is_empty(),
"absent start+end must yield no paths"
);
let ok = graph.find_paths(spec(WETH, WETH, 3, Some(3), false, None), None);
assert!(!ok.is_empty(), "present end (WETH) must still yield cycles");
}
#[test]
fn test_three_hop_filter_yields_no_two_hop_cycles() {
let graph = build_fixture_graph();
let filter = vec![
Some(vec![PoolKind::V2]),
Some(vec![PoolKind::V2]),
Some(vec![PoolKind::V2]),
];
let node_valid_depths = graph.compute_node_valid_depths(&filter);
let paths = graph.find_paths(
spec(
WETH,
WETH,
2, Some(3), false,
Some(filter),
),
Some(&node_valid_depths),
);
assert!(
!paths.is_empty(),
"3-depth filter should yield at least one 3-hop path"
);
for path in &paths {
assert_eq!(
path.len(),
3,
"3-depth filter with min_depth=2 must yield only 3-hop paths"
);
}
}
#[test]
fn test_pool_type_per_depth_caps_max_depth() {
let graph = build_fixture_graph();
let filter = vec![Some(vec![PoolKind::V2]), Some(vec![PoolKind::V2])];
let node_valid_depths = graph.compute_node_valid_depths(&filter);
let paths = graph.find_paths(
spec(
WETH,
WETH,
2,
Some(3), false,
Some(filter),
),
Some(&node_valid_depths),
);
for path in &paths {
assert_eq!(
path.len(),
2,
"2-depth filter should cap at 2-hop paths even with max_depth=3"
);
}
}
#[test]
fn test_pool_type_per_depth_with_max_depth_none() {
let graph = build_fixture_graph();
let filter = vec![Some(vec![PoolKind::V2]), Some(vec![PoolKind::V2])];
let node_valid_depths = graph.compute_node_valid_depths(&filter);
let paths = graph.find_paths(
spec(
WETH,
WETH,
2,
None, false,
Some(filter),
),
Some(&node_valid_depths),
);
for path in &paths {
assert_eq!(path.len(), 2);
}
}
#[test]
fn test_none_entry_allows_all_kinds() {
let graph = build_fixture_graph();
let filter = vec![None, Some(vec![PoolKind::V4])];
let node_valid_depths = graph.compute_node_valid_depths(&filter);
let paths = graph.find_paths(
spec(WETH, WETH, 2, Some(2), false, Some(filter)),
Some(&node_valid_depths),
);
assert!(
paths.is_empty(),
"V4 filter on V2-only graph should yield nothing"
);
}
#[test]
fn test_cycle_detection_prevents_reusing_pools() {
let graph = build_fixture_graph();
let paths = graph.find_paths(spec(WETH, WETH, 2, Some(3), false, None), None);
for path in &paths {
let pool_ids = edges_to_pool_ids(path);
let unique: HashSet<u64> = pool_ids.iter().copied().collect();
assert_eq!(
pool_ids.len(),
unique.len(),
"Path should not reuse a pool: {pool_ids:?}"
);
}
}
#[test]
fn test_node_not_in_graph_returns_empty() {
let graph = build_fixture_graph();
let paths = graph.find_paths(spec(999, 999, 2, Some(2), false, None), None);
assert!(paths.is_empty());
}
#[test]
fn test_poolkind_roundtrip() {
assert_eq!(PoolKind::V2.as_u8(), 0);
assert_eq!(PoolKind::V3.as_u8(), 1);
assert_eq!(PoolKind::V4.as_u8(), 2);
assert_eq!(PoolKind::from_u8(0), Some(PoolKind::V2));
assert_eq!(PoolKind::from_u8(1), Some(PoolKind::V3));
assert_eq!(PoolKind::from_u8(2), Some(PoolKind::V4));
assert_eq!(PoolKind::from_u8(3), None);
}
#[test]
fn test_mixed_pool_kinds() {
let graph = PathGraph::from_edges(vec![
(WETH, A, 1, PoolKind::V2),
(A, B, 2, PoolKind::V4),
(B, WETH, 3, PoolKind::V2),
]);
let filter = vec![
Some(vec![PoolKind::V2]),
Some(vec![PoolKind::V4]),
Some(vec![PoolKind::V2]),
];
let node_valid_depths = graph.compute_node_valid_depths(&filter);
let paths = graph.find_paths(
spec(WETH, WETH, 3, Some(3), false, Some(filter)),
Some(&node_valid_depths),
);
assert!(!paths.is_empty(), "Should find a V2-V4-V2 path");
for path in &paths {
assert_eq!(path.len(), 3);
assert_eq!(path[0].1, PoolKind::V2);
assert_eq!(path[1].1, PoolKind::V4);
assert_eq!(path[2].1, PoolKind::V2);
}
}
#[test]
fn test_cancel_flag_exhausts_search() {
let graph = build_fixture_graph();
let cancel = Arc::new(AtomicBool::new(false));
let mut finder = OwnedPathFinder::new(graph, spec(WETH, WETH, 2, Some(3), true, None))
.with_cancel(Arc::clone(&cancel));
assert!(finder.next_path().is_some(), "fixture must yield a path");
cancel.store(true, Ordering::Release);
assert!(
finder.next_path().is_none(),
"a set cancel flag must exhaust the search immediately"
);
assert!(finder.next_path().is_none());
}
#[test]
fn test_borrowed_finder_cancel_before_first_advance() {
let graph = build_fixture_graph();
let cancel = Arc::new(AtomicBool::new(true));
let mut finder = graph
.find_paths_iter(spec(WETH, WETH, 2, Some(3), true, None), None)
.with_cancel(cancel);
assert!(
finder.next_path().is_none(),
"an armed cancel flag must yield nothing"
);
assert!(finder.next_path().is_none(), "exhaustion is sticky");
}
#[test]
fn test_borrowed_finder_cancel_mid_stream() {
let graph = build_fixture_graph();
let cancel = Arc::new(AtomicBool::new(false));
let mut finder = graph
.find_paths_iter(spec(WETH, WETH, 2, Some(3), true, None), None)
.with_cancel(Arc::clone(&cancel));
assert!(
finder.next_path().is_some(),
"the first cycle yields before any cancel"
);
cancel.store(true, Ordering::Release);
assert!(
finder.next_path().is_none(),
"a flag set between advances stops the search promptly"
);
}
#[test]
fn test_progress_hook_fires_mid_walk_with_the_tally() {
const SPOKES: u64 = 9000;
const HUB: u64 = 10_000;
const END: u64 = 11_000;
let mut edges: Vec<(u64, u64, u64, PoolKind)> = Vec::new();
let mut pool = 1000u64;
for leaf in 1..=SPOKES {
edges.push((HUB, HUB + leaf, pool, PoolKind::V2));
pool += 1;
}
edges.push((HUB + SPOKES, END, pool, PoolKind::V2));
let graph = PathGraph::from_edges(edges);
let calls = Arc::new(AtomicUsize::new(0));
let saw_mid_walk = Arc::new(AtomicBool::new(false));
{
let calls = Arc::clone(&calls);
let saw_mid_walk = Arc::clone(&saw_mid_walk);
let mut finder = graph
.find_paths_iter(spec(HUB, END, 2, None, false, None), None)
.with_progress(Duration::ZERO, move |t| {
calls.fetch_add(1, Ordering::Relaxed);
if t.advances_since_yield > 0 {
saw_mid_walk.store(true, Ordering::Relaxed);
}
});
assert!(finder.next_path().is_some(), "the lone closing path yields");
}
assert!(calls.load(Ordering::Relaxed) >= 1, "the hook fired");
assert!(
saw_mid_walk.load(Ordering::Relaxed),
"a tally arrived while the DFS was mid-grind (advances > 0, no yield yet)"
);
}
#[test]
fn test_heartbeat_diagnostics_do_not_alter_enumeration() {
let graph = build_fixture_graph();
let run_one: Vec<Vec<u64>> = graph
.find_paths(spec(WETH, WETH, 2, Some(3), true, None), None)
.into_iter()
.map(|p| edges_to_pool_ids(&p))
.collect();
let graph2 = build_fixture_graph();
let run_two: Vec<Vec<u64>> = graph2
.find_paths(spec(WETH, WETH, 2, Some(3), true, None), None)
.into_iter()
.map(|p| edges_to_pool_ids(&p))
.collect();
assert!(!run_one.is_empty(), "fixture must yield paths");
assert_eq!(
run_one, run_two,
"enumeration must be stable + unaffected by heartbeat wiring"
);
}
#[test]
fn test_golden_ordered_yields_unfiltered_no_reverse() {
let graph = build_fixture_graph();
let paths: Vec<Vec<u64>> = graph
.find_paths(spec(WETH, WETH, 2, Some(3), false, None), None)
.into_iter()
.map(|p| edges_to_pool_ids(&p))
.collect();
assert_eq!(
paths,
vec![
vec![POOL_WETH_A_1, POOL_WETH_A_2],
vec![POOL_WETH_A_2, POOL_WETH_A_1],
vec![POOL_WETH_A_1, POOL_A_B, POOL_B_WETH],
vec![POOL_WETH_A_2, POOL_A_B, POOL_B_WETH],
vec![POOL_B_WETH, POOL_A_B, POOL_WETH_A_1],
vec![POOL_B_WETH, POOL_A_B, POOL_WETH_A_2],
]
);
}
#[test]
fn test_golden_ordered_yields_unfiltered_with_reverse() {
let graph = build_fixture_graph();
let paths: Vec<Vec<u64>> = graph
.find_paths(spec(WETH, WETH, 2, Some(3), true, None), None)
.into_iter()
.map(|p| edges_to_pool_ids(&p))
.collect();
assert_eq!(
paths,
vec![
vec![POOL_WETH_A_1, POOL_WETH_A_2], vec![POOL_WETH_A_2, POOL_WETH_A_1], vec![POOL_WETH_A_2, POOL_WETH_A_1], vec![POOL_WETH_A_1, POOL_WETH_A_2], vec![POOL_WETH_A_1, POOL_A_B, POOL_B_WETH], vec![POOL_B_WETH, POOL_A_B, POOL_WETH_A_1], vec![POOL_WETH_A_2, POOL_A_B, POOL_B_WETH], vec![POOL_B_WETH, POOL_A_B, POOL_WETH_A_2], vec![POOL_B_WETH, POOL_A_B, POOL_WETH_A_1], vec![POOL_WETH_A_1, POOL_A_B, POOL_B_WETH], vec![POOL_B_WETH, POOL_A_B, POOL_WETH_A_2], vec![POOL_WETH_A_2, POOL_A_B, POOL_B_WETH], ]
);
let forward: Vec<Vec<u64>> = build_fixture_graph()
.find_paths(spec(WETH, WETH, 2, Some(3), false, None), None)
.into_iter()
.map(|p| edges_to_pool_ids(&p))
.collect();
let interleaved: Vec<Vec<u64>> = forward
.iter()
.flat_map(|p| {
let mut rev = p.clone();
rev.reverse();
[p.clone(), rev]
})
.collect();
assert_eq!(paths, interleaved);
}
#[test]
fn test_golden_ordered_yields_filtered_no_reverse() {
let graph = build_fixture_graph();
let filter = vec![
Some(vec![PoolKind::V2]),
Some(vec![PoolKind::V2]),
Some(vec![PoolKind::V2]),
];
let node_valid_depths = graph.compute_node_valid_depths(&filter);
let paths: Vec<Vec<u64>> = graph
.find_paths(
spec(
WETH,
WETH,
2, Some(3),
false,
Some(filter),
),
Some(&node_valid_depths),
)
.into_iter()
.map(|p| edges_to_pool_ids(&p))
.collect();
assert_eq!(
paths,
vec![
vec![POOL_WETH_A_1, POOL_A_B, POOL_B_WETH],
vec![POOL_WETH_A_2, POOL_A_B, POOL_B_WETH],
vec![POOL_B_WETH, POOL_A_B, POOL_WETH_A_1],
vec![POOL_B_WETH, POOL_A_B, POOL_WETH_A_2],
]
);
}
#[test]
fn test_golden_ordered_yields_filtered_with_reverse() {
let graph = build_fixture_graph();
let filter = vec![Some(vec![PoolKind::V2]), Some(vec![PoolKind::V2])];
let node_valid_depths = graph.compute_node_valid_depths(&filter);
let paths: Vec<Vec<u64>> = graph
.find_paths(
spec(WETH, WETH, 2, Some(2), true, Some(filter)),
Some(&node_valid_depths),
)
.into_iter()
.map(|p| edges_to_pool_ids(&p))
.collect();
assert_eq!(
paths,
vec![
vec![POOL_WETH_A_1, POOL_WETH_A_2], vec![POOL_WETH_A_2, POOL_WETH_A_1], vec![POOL_WETH_A_2, POOL_WETH_A_1], vec![POOL_WETH_A_1, POOL_WETH_A_2], ]
);
let forward: Vec<Vec<u64>> = {
let graph = build_fixture_graph();
let filter = vec![Some(vec![PoolKind::V2]), Some(vec![PoolKind::V2])];
let node_valid_depths = graph.compute_node_valid_depths(&filter);
graph
.find_paths(
spec(WETH, WETH, 2, Some(2), false, Some(filter)),
Some(&node_valid_depths),
)
.into_iter()
.map(|p| edges_to_pool_ids(&p))
.collect()
};
let interleaved: Vec<Vec<u64>> = forward
.iter()
.flat_map(|p| {
let mut rev = p.clone();
rev.reverse();
[p.clone(), rev]
})
.collect();
assert_eq!(paths, interleaved);
}
#[expect(clippy::expect_used)]
fn graph_compact(graph: &PathGraph, token: u64) -> u32 {
graph
.compact_index(token)
.expect("test fixture token is interned")
}
#[expect(clippy::cast_possible_truncation)]
fn intern_ref(map: &mut HashMap<u64, u32>, adj: &mut Vec<Vec<(u32, u32)>>, t: u64) -> u32 {
if let Some(&i) = map.get(&t) {
i
} else {
let i = map.len() as u32;
map.insert(t, i);
adj.push(Vec::new());
i
}
}
fn parallel_hub(m: u64, base_pool_id: u64) -> Vec<(u64, u64, u64, PoolKind)> {
let mut edges: Vec<(u64, u64, u64, PoolKind)> = Vec::new();
for i in 0..m {
edges.push((1, 2, base_pool_id + i, PoolKind::V2));
}
edges.push((2, 3, base_pool_id + 99_001, PoolKind::V2));
edges.push((3, 1, base_pool_id + 99_002, PoolKind::V2));
edges
}
fn battery_parallel_hub_trio() -> Vec<(u64, u64, u64, PoolKind)> {
parallel_hub(40, 700_000)
}
fn reference_trails(
edges: &[(u64, u64, u64, PoolKind)],
start: u64,
end: u64,
min_depth: usize,
max_depth: Option<usize>,
) -> BTreeSet<Vec<u64>> {
struct OracleCtx<'a> {
end: u32,
min_depth: usize,
max_depth: Option<usize>,
adj: &'a [Vec<(u32, u32)>],
pool_ids: &'a [u64],
}
fn dfs(
node: u32,
ctx: &OracleCtx<'_>,
visited: &mut [bool],
path: &mut Vec<u32>,
out: &mut BTreeSet<Vec<u64>>,
) {
if node == ctx.end && path.len() >= ctx.min_depth {
out.insert(path.iter().map(|&i| ctx.pool_ids[i as usize]).collect());
}
if ctx.max_depth.is_some_and(|md| path.len() >= md) {
return;
}
for (nbr, pool_idx) in &ctx.adj[node as usize] {
if visited[*pool_idx as usize] {
continue;
}
visited[*pool_idx as usize] = true;
path.push(*pool_idx);
dfs(*nbr, ctx, visited, path, out);
path.pop();
visited[*pool_idx as usize] = false;
}
}
let mut token_index: HashMap<u64, u32> = HashMap::new();
let mut adj: Vec<Vec<(u32, u32)>> = Vec::new();
let mut pool_ids: Vec<u64> = Vec::new();
for (t0, t1, pid, _kind) in edges {
let a = intern_ref(&mut token_index, &mut adj, *t0);
let b = intern_ref(&mut token_index, &mut adj, *t1);
#[expect(clippy::expect_used)]
let fresh_pool = u32::try_from(pool_ids.len())
.expect("oracle pool count exceeds u32::MAX");
pool_ids.push(*pid);
adj[a as usize].push((b, fresh_pool));
adj[b as usize].push((a, fresh_pool));
}
let s = intern_ref(&mut token_index, &mut adj, start);
let mut out: BTreeSet<Vec<u64>> = BTreeSet::new();
let mut visited: Vec<bool> = vec![false; pool_ids.len()];
let mut path: Vec<u32> = Vec::new();
dfs(
s,
&OracleCtx {
end: intern_ref(&mut token_index, &mut adj, end),
min_depth,
max_depth,
adj: &adj,
pool_ids: &pool_ids,
},
&mut visited,
&mut path,
&mut out,
);
out
}
fn battery_go_around() -> Vec<(u64, u64, u64, PoolKind)> {
vec![
(1, 2, 101, PoolKind::V2), (1, 2, 102, PoolKind::V2), (2, 3, 103, PoolKind::V2), (3, 4, 104, PoolKind::V2), (4, 1, 105, PoolKind::V2), (2, 99, 106, PoolKind::V2), (99, 98, 107, PoolKind::V2), (1, 5, 108, PoolKind::V2), (5, 6, 109, PoolKind::V2), ]
}
fn battery_bridge_in_2core() -> Vec<(u64, u64, u64, PoolKind)> {
vec![
(1, 2, 201, PoolKind::V2),
(1, 2, 202, PoolKind::V3),
(2, 3, 203, PoolKind::V2),
(3, 1, 204, PoolKind::V2),
(1, 4, 205, PoolKind::V2),
(4, 5, 206, PoolKind::V2),
(5, 1, 207, PoolKind::V3),
(2, 4, 208, PoolKind::V2), ]
}
fn hub_node(s: u64) -> u64 {
100 * s + s
}
fn battery_hub_parallel() -> Vec<(u64, u64, u64, PoolKind)> {
let mut edges = Vec::new();
let mut pid = 300u64;
for spoke in 11..16u64 {
for _ in 0..2 {
pid += 1;
edges.push((1, hub_node(spoke), pid, PoolKind::V2));
}
}
for s in 11..15u64 {
pid += 1;
edges.push((hub_node(s), hub_node(s + 1), pid, PoolKind::V2));
}
edges
}
fn assert_search_parity(
edges: &[(u64, u64, u64, PoolKind)],
start: u64,
end: u64,
label: &str,
) {
for min_depth in [1usize, 2, 3] {
for max_depth in [Some(min_depth), Some(min_depth + 1), Some(min_depth + 2)] {
let mut found: BTreeSet<Vec<u64>> = BTreeSet::new();
let mut finder = OwnedPathFinder::new(
PathGraph::from_edges(edges.to_vec()),
spec(start, end, min_depth, max_depth, false, None),
);
while let Some(path) = finder.next_path() {
found.insert(path.into_iter().map(|(pid, _)| pid).collect());
}
let reference = reference_trails(edges, start, end, min_depth, max_depth);
assert_eq!(
found, reference,
"{label} min={min_depth} max={max_depth:?}: pruned search diverged"
);
}
}
}
#[test]
#[ignore = "manual perf harness: cargo test -p degenbot-pathfinding --release perf_core -- --ignored --nocapture"]
#[expect(clippy::print_stderr)]
fn perf_core_grid_search() {
let w = 60usize;
let gid = |r: usize, c: usize| 500_000u64 + (r * w + c) as u64;
let mut edges: Vec<(u64, u64, u64, PoolKind)> = Vec::new();
let mut pid = 900_000u64;
for r in 0..w {
for c in 0..w {
if c + 1 < w {
pid += 1;
edges.push((gid(r, c), gid(r, c + 1), pid, PoolKind::V2));
}
if r + 1 < w {
pid += 1;
edges.push((gid(r, c), gid(r + 1, c), pid, PoolKind::V2));
}
if r + 1 < w && c + 1 < w {
pid += 1;
edges.push((gid(r, c + 1), gid(r + 1, c), pid, PoolKind::V2));
}
}
}
let n_runs = 20;
let mut best = f64::MAX;
let mut best_build = f64::MAX;
for _ in 0..n_runs {
let d_start = std::time::Instant::now();
let graph = PathGraph::from_edges(edges.clone());
let build_start = std::time::Instant::now();
let mut finder =
OwnedPathFinder::new(graph, spec(500_000, 500_000, 3, Some(5), false, None));
let build_dt = build_start.elapsed().as_secs_f64() * 1e3;
let mut count = 0u64;
while finder.next_path().is_some() {
count += 1;
}
let dt = d_start.elapsed().as_secs_f64() * 1e3;
if dt < best {
best = dt;
}
if build_dt < best_build {
best_build = build_dt;
}
assert_eq!(count, 8); }
eprintln!("perf_core_grid_search best={best:.3} ms (finder build best={best_build:.3} ms)");
}
#[test]
#[ignore = "manual perf harness: cargo test -p degenbot-pathfinding --release perf_core -- --ignored --nocapture"]
#[expect(clippy::print_stderr)]
fn perf_core_parallel_hub() {
let m = 40u64;
let edges = parallel_hub(m, 700_000);
let n_runs = 5;
let mut best = f64::MAX;
for _ in 0..n_runs {
let d_start = std::time::Instant::now();
let graph = PathGraph::from_edges(edges.clone());
let mut finder = OwnedPathFinder::new(graph, spec(1, 1, 2, Some(4), false, None));
let mut count = 0u64;
while finder.next_path().is_some() {
count += 1;
}
let dt = d_start.elapsed().as_secs_f64() * 1e3;
if dt < best {
best = dt;
}
let p2 = m * (m - 1);
let p4 = p2 * (m - 2) * (m - 3);
assert_eq!(
count,
p2 + 2 * m + p4,
"falling-factorial expansion contract"
);
}
eprintln!("perf_core_parallel_hub best={best:.3} ms");
}
#[test]
fn test_hop_distances_basics() {
let graph = build_fixture_graph();
let d = graph.hop_distances(&[graph_compact(&graph, WETH)]);
assert_eq!(d[graph_compact(&graph, WETH) as usize], 0);
assert_eq!(d[graph_compact(&graph, A) as usize], 1);
assert_eq!(d[graph_compact(&graph, B) as usize], 1);
let d_from_b = graph.hop_distances(&[graph_compact(&graph, B)]);
assert_eq!(d_from_b[graph_compact(&graph, WETH) as usize], 1);
assert_eq!(d_from_b[graph_compact(&graph, A) as usize], 1);
}
#[test]
fn test_hop_distances_unreachable_is_max() {
let graph = PathGraph::from_edges(vec![
(1, 2, 1, PoolKind::V2),
(8, 9, 2, PoolKind::V2), ]);
let d = graph.hop_distances(&[graph_compact(&graph, 1)]);
for token in [8, 9] {
assert_eq!(
d[graph_compact(&graph, token) as usize],
u32::MAX,
"token {token} unreachable"
);
}
}
#[test]
fn test_regress_trail_closing_earlier_than_budget_is_yielded() {
let edges = {
let mut e = Vec::new();
for pool in 100..103u64 {
e.push((1u64, 2u64, pool, PoolKind::V2));
}
e
};
let mut two_hop: Vec<Vec<u64>> = Vec::new();
let mut finder = OwnedPathFinder::new(
PathGraph::from_edges(edges.clone()),
spec(1, 1, 2, Some(3), false, None),
);
while let Some(path) = finder.next_path() {
if path.len() == 2 {
two_hop.push(path.iter().map(|(pid, _)| *pid).collect());
}
}
assert_eq!(
two_hop.len(),
6,
"2-pool cycles closing before the max budget must be yielded: {two_hop:?}"
);
}
#[test]
fn pruned_search_parity_go_around() {
assert_search_parity(&battery_go_around(), 1, 1, "go_around");
}
#[test]
fn pruned_search_parity_bridge_in_2core() {
assert_search_parity(&battery_bridge_in_2core(), 1, 1, "bridge_in_2core");
}
#[test]
fn pruned_search_parity_hub_parallel() {
assert_search_parity(&battery_hub_parallel(), 1, 1, "hub_parallel");
}
#[test]
fn pruned_search_parity_parallel_hub_trio() {
assert_search_parity(&battery_parallel_hub_trio(), 1, 1, "parallel_hub_trio");
}
#[test]
fn pruned_search_parity_open_paths() {
assert_search_parity(&battery_go_around(), 1, 5, "go_around_open");
assert_search_parity(&battery_bridge_in_2core(), 1, 4, "bridge_open");
}
#[test]
fn test_parity_parallel_bundle_revisited_pair() {
let mut edges = Vec::new();
for pool in 400..404u64 {
edges.push((1u64, 2u64, pool, PoolKind::V2));
}
let mut finder = OwnedPathFinder::new(
PathGraph::from_edges(edges.clone()),
spec(1, 1, 2, Some(4), false, None),
);
let mut count = 0;
while finder.next_path().is_some() {
count += 1;
}
assert_eq!(count, 12 + 24, "falling-factorial expansion: {count}");
assert_search_parity(&edges, 1, 1, "revisit_pair");
}
#[test]
fn test_parity_mixed_kinds_parallel_bundle_with_filter() {
fn collect(
edges: &[(u64, u64, u64, PoolKind)],
filter: Option<Vec<Option<Vec<PoolKind>>>>,
) -> BTreeSet<Vec<u64>> {
let mut finder = OwnedPathFinder::new(
PathGraph::from_edges(edges.to_vec()),
spec(1, 1, 2, Some(2), false, filter),
);
let mut out = BTreeSet::new();
while let Some(path) = finder.next_path() {
out.insert(path.into_iter().map(|(pid, _)| pid).collect());
}
out
}
let edges = vec![
(1u64, 2u64, 500, PoolKind::V2),
(1, 2, 501, PoolKind::V3),
(1, 2, 502, PoolKind::V2),
];
let out = collect(&edges, None);
assert_eq!(out.len(), 6, "unfiltered parallel bundle: {out:?}");
let out = collect(&edges, Some(vec![Some(vec![PoolKind::V3]), None]));
let expected: BTreeSet<Vec<u64>> = BTreeSet::from([vec![501, 500], vec![501, 502]]);
assert_eq!(out, expected, "V3-first filter expansion: {out:?}");
let out = collect(
&edges,
Some(vec![Some(vec![PoolKind::V3]), Some(vec![PoolKind::V3])]),
);
assert!(
out.is_empty(),
"kind-infeasible re-visit must yield nothing: {out:?}"
);
assert_search_parity(&edges, 1, 1, "mixed_kinds_bundle");
}
}