Skip to main content

fynd_core/algorithm/
bellman_ford.rs

1//! Bellman-Ford algorithm with SPFA optimization for simulation-driven routing.
2//!
3//! Runs actual pool simulations (`get_amount_out()`) during edge relaxation to find
4//! optimal A-to-B routes that account for slippage, fees, and pool mechanics at the
5//! given trade size.
6//!
7//! Key features:
8//! - **Gas-aware relaxation**: When token prices and gas price are available, relaxation compares
9//!   net amounts (gross output minus cumulative gas cost in token terms) instead of gross output
10//!   alone. Falls back to gross comparison when data is unavailable.
11//! - **Subgraph extraction**: BFS prunes the graph to nodes reachable within `max_hops`
12//! - **SPFA (Shortest Path Faster Algorithm) queuing**: Only re-relaxes edges from nodes whose
13//!   amount improved
14//! - **Forbid revisits**: Skips edges that would revisit a token or pool already in the path
15
16use std::{
17    collections::{HashMap, HashSet, VecDeque},
18    time::{Duration, Instant},
19};
20
21use num_bigint::{BigInt, BigUint};
22use num_traits::{ToPrimitive, Zero};
23use petgraph::{graph::NodeIndex, prelude::EdgeRef};
24use tracing::{debug, instrument, trace, warn};
25use tycho_simulation::{
26    tycho_common::models::Address,
27    tycho_core::{models::token::Token, simulation::protocol_sim::Price},
28};
29
30use super::{Algorithm, AlgorithmConfig, AlgorithmError, NoPathReason};
31use crate::{
32    derived::{
33        computation::ComputationRequirements,
34        types::{SpotPrices, TokenGasPrices},
35        SharedDerivedDataRef,
36    },
37    feed::market_data::SharedMarketDataRef,
38    graph::{petgraph::StableDiGraph, PetgraphStableDiGraphManager},
39    types::{ComponentId, Order, Route, RouteResult, Swap},
40};
41
42/// BFS subgraph: adjacency list, token node set, and component ID set.
43type Subgraph =
44    (HashMap<NodeIndex, Vec<(NodeIndex, ComponentId)>>, HashSet<NodeIndex>, HashSet<ComponentId>);
45
46pub struct BellmanFordAlgorithm {
47    max_hops: usize,
48    timeout: Duration,
49    gas_aware: bool,
50}
51
52impl BellmanFordAlgorithm {
53    pub(crate) fn with_config(config: AlgorithmConfig) -> Result<Self, AlgorithmError> {
54        Ok(Self {
55            max_hops: config.max_hops(),
56            timeout: config.timeout(),
57            gas_aware: config.gas_aware(),
58        })
59    }
60
61    /// Computes gas-adjusted net amount: gross_amount - gas_cost_in_token.
62    ///
63    /// If `token_price` is None (no conversion rate available), returns the gross amount
64    /// unchanged (falls back to gross comparison for this node).
65    fn gas_adjusted_amount(
66        gross: &BigUint,
67        cumul_gas: &BigUint,
68        gas_price_wei: &BigUint,
69        token_price: Option<&Price>,
70    ) -> BigInt {
71        match token_price {
72            Some(price) if !price.denominator.is_zero() => {
73                let gas_cost = cumul_gas * gas_price_wei * &price.numerator / &price.denominator;
74                BigInt::from(gross.clone()) - BigInt::from(gas_cost)
75            }
76            _ => BigInt::from(gross.clone()),
77        }
78    }
79
80    /// Computes the cumulative spot price product when extending a path by one edge.
81    ///
82    /// Returns `parent_spot * spot_price(component, token_u, token_v)`.
83    /// Returns 0.0 if the spot price is unavailable (disables the fallback for this path).
84    fn compute_edge_spot_product(
85        parent_spot: f64,
86        component_id: &ComponentId,
87        u_addr: Option<&Address>,
88        v_addr: Option<&Address>,
89        spot_prices: Option<&SpotPrices>,
90    ) -> f64 {
91        if parent_spot == 0.0 {
92            return 0.0;
93        }
94        let (Some(u), Some(v), Some(prices)) = (u_addr, v_addr, spot_prices) else {
95            return 0.0;
96        };
97        let key = (component_id.clone(), u.clone(), v.clone());
98        match prices.get(&key) {
99            Some(&spot) if spot > 0.0 => parent_spot * spot,
100            _ => 0.0,
101        }
102    }
103
104    /// Resolves the gas-to-token conversion rate for gas cost calculation.
105    ///
106    /// 1. Primary: use `token_prices[v_addr]` from derived data (direct lookup).
107    /// 2. Fallback: if `token_prices[token_in]` exists and `spot_product > 0`, estimate the rate as
108    ///    `token_prices[token_in] * spot_product` (converted to a Price).
109    /// 3. Last resort: returns None (gas adjustment skipped for this comparison).
110    fn resolve_token_price(
111        v_addr: Option<&Address>,
112        token_prices: Option<&TokenGasPrices>,
113        spot_product: f64,
114        token_in_addr: Option<&Address>,
115    ) -> Option<Price> {
116        let prices = token_prices?;
117        let addr = v_addr?;
118
119        // Primary: direct lookup
120        if let Some(price) = prices.get(addr) {
121            return Some(price.clone());
122        }
123
124        // Fallback: token_in price * cumulative spot product
125        if spot_product > 0.0 {
126            if let Some(in_price) = token_in_addr.and_then(|a| prices.get(a)) {
127                let in_rate_f64 = in_price.numerator.to_f64()? / in_price.denominator.to_f64()?;
128                let estimated_rate = in_rate_f64 * spot_product;
129                let denom = BigUint::from(10u64).pow(18);
130                let numer_f64 = estimated_rate * 1e18;
131                if numer_f64.is_finite() && numer_f64 > 0.0 {
132                    return Some(Price {
133                        numerator: BigUint::from(numer_f64 as u128),
134                        denominator: denom,
135                    });
136                }
137            }
138        }
139
140        None
141    }
142
143    /// Checks whether the target node or pool conflicts with the existing path to `from`.
144    /// Walks the predecessor chain once, checking both conditions simultaneously.
145    fn path_has_conflict(
146        from: NodeIndex,
147        target_node: NodeIndex,
148        target_pool: &ComponentId,
149        predecessor: &[Option<(NodeIndex, ComponentId)>],
150    ) -> bool {
151        let mut current = from;
152        loop {
153            if current == target_node {
154                return true;
155            }
156            match &predecessor[current.index()] {
157                Some((prev, cid)) => {
158                    if cid == target_pool {
159                        return true;
160                    }
161                    current = *prev;
162                }
163                None => return false,
164            }
165        }
166    }
167
168    /// Reconstructs the path from token_out back to token_in by walking the predecessor
169    /// array.
170    fn reconstruct_path(
171        token_out: NodeIndex,
172        token_in: NodeIndex,
173        predecessor: &[Option<(NodeIndex, ComponentId)>],
174    ) -> Result<Vec<(NodeIndex, NodeIndex, ComponentId)>, AlgorithmError> {
175        let mut path = Vec::new();
176        let mut current = token_out;
177        let mut visited = HashSet::new();
178
179        while current != token_in {
180            if !visited.insert(current) {
181                return Err(AlgorithmError::Other("cycle in predecessor chain".to_string()));
182            }
183
184            let idx = current.index();
185            match &predecessor
186                .get(idx)
187                .and_then(|p| p.as_ref())
188            {
189                Some((prev_node, component_id)) => {
190                    path.push((*prev_node, current, component_id.clone()));
191                    current = *prev_node;
192                }
193                None => {
194                    return Err(AlgorithmError::Other(format!(
195                        "broken predecessor chain at node {idx}"
196                    )));
197                }
198            }
199        }
200
201        path.reverse();
202        Ok(path)
203    }
204
205    /// Extracts the subgraph reachable from `token_in_node` within `max_hops` via BFS.
206    ///
207    /// Returns `(adjacency_list, token_nodes, component_ids)` or `NoPath` if the
208    /// subgraph is empty (no outgoing edges from the source).
209    fn get_subgraph(
210        graph: &StableDiGraph<()>,
211        token_in_node: NodeIndex,
212        max_hops: usize,
213        order: &Order,
214    ) -> Result<Subgraph, AlgorithmError> {
215        let mut adj: HashMap<NodeIndex, Vec<(NodeIndex, ComponentId)>> = HashMap::new();
216        let mut token_nodes: HashSet<NodeIndex> = HashSet::new();
217        let mut component_ids: HashSet<ComponentId> = HashSet::new();
218        let mut visited = HashSet::new();
219        let mut queue = VecDeque::new();
220
221        visited.insert(token_in_node);
222        token_nodes.insert(token_in_node);
223        queue.push_back((token_in_node, 0usize));
224
225        while let Some((node, depth)) = queue.pop_front() {
226            if depth >= max_hops {
227                continue;
228            }
229            for edge in graph.edges(node) {
230                let target = edge.target();
231                let cid = edge.weight().component_id.clone();
232
233                adj.entry(node)
234                    .or_default()
235                    .push((target, cid.clone()));
236                component_ids.insert(cid);
237                token_nodes.insert(target);
238
239                if visited.insert(target) {
240                    queue.push_back((target, depth + 1));
241                }
242            }
243        }
244
245        if adj.is_empty() {
246            return Err(AlgorithmError::NoPath {
247                from: order.token_in().clone(),
248                to: order.token_out().clone(),
249                reason: NoPathReason::NoGraphPath,
250            });
251        }
252
253        Ok((adj, token_nodes, component_ids))
254    }
255
256    /// Computes net_amount_out by subtracting gas costs from the output amount.
257    ///
258    /// Uses the same resolution strategy as relaxation: direct token price lookup
259    /// first, then cumulative spot price product fallback for tokens not in the price
260    /// table.
261    #[allow(clippy::too_many_arguments)]
262    fn compute_net_amount_out(
263        amount_out: &BigUint,
264        route: &Route,
265        gas_price: &BigUint,
266        token_prices: Option<&TokenGasPrices>,
267        spot_product: &[f64],
268        node_address: &HashMap<NodeIndex, Address>,
269        token_in_node: NodeIndex,
270    ) -> BigInt {
271        let Some(last_swap) = route.swaps().last() else {
272            return BigInt::from(amount_out.clone());
273        };
274
275        let total_gas = route.total_gas();
276
277        if gas_price.is_zero() {
278            warn!("missing gas price, returning gross amount_out");
279            return BigInt::from(amount_out.clone());
280        }
281
282        let gas_cost_wei = &total_gas * gas_price;
283
284        // Find the output token's node to get its spot_product for the fallback
285        let out_addr = last_swap.token_out();
286        let out_node_spot = node_address
287            .iter()
288            .find(|(_, addr)| *addr == out_addr)
289            .and_then(|(node, _)| spot_product.get(node.index()).copied())
290            .unwrap_or(0.0);
291
292        let output_price = Self::resolve_token_price(
293            Some(out_addr),
294            token_prices,
295            out_node_spot,
296            node_address.get(&token_in_node),
297        );
298
299        match output_price {
300            Some(price) if !price.denominator.is_zero() => {
301                let gas_cost = &gas_cost_wei * &price.numerator / &price.denominator;
302                BigInt::from(amount_out.clone()) - BigInt::from(gas_cost)
303            }
304            _ => {
305                warn!("no gas price for output token, returning gross amount_out");
306                BigInt::from(amount_out.clone())
307            }
308        }
309    }
310}
311
312impl Algorithm for BellmanFordAlgorithm {
313    type GraphType = StableDiGraph<()>;
314    type GraphManager = PetgraphStableDiGraphManager<()>;
315
316    fn name(&self) -> &str {
317        "bellman_ford"
318    }
319
320    #[instrument(level = "debug", skip_all, fields(order_id = %order.id()))]
321    async fn find_best_route(
322        &self,
323        graph: &Self::GraphType,
324        market: SharedMarketDataRef,
325        derived: Option<SharedDerivedDataRef>,
326        order: &Order,
327    ) -> Result<RouteResult, AlgorithmError> {
328        let start = Instant::now();
329
330        if !order.is_sell() {
331            return Err(AlgorithmError::ExactOutNotSupported);
332        }
333
334        let (token_prices, spot_prices) = if let Some(ref derived) = derived {
335            let guard = derived.read().await;
336            (guard.token_prices().cloned(), guard.spot_prices().cloned())
337        } else {
338            (None, None)
339        };
340
341        let token_in_node = graph
342            .node_indices()
343            .find(|&n| &graph[n] == order.token_in())
344            .ok_or(AlgorithmError::NoPath {
345                from: order.token_in().clone(),
346                to: order.token_out().clone(),
347                reason: NoPathReason::SourceTokenNotInGraph,
348            })?;
349        let token_out_node = graph
350            .node_indices()
351            .find(|&n| &graph[n] == order.token_out())
352            .ok_or(AlgorithmError::NoPath {
353                from: order.token_in().clone(),
354                to: order.token_out().clone(),
355                reason: NoPathReason::DestinationTokenNotInGraph,
356            })?;
357
358        // BFS from token_in up to max_hops, building adjacency list and component set.
359        let (adj, token_nodes, component_ids) =
360            Self::get_subgraph(graph, token_in_node, self.max_hops, order)?;
361
362        // Acquire read lock only for market data extraction, then release.
363        let (token_map, market_subset) = {
364            let market = market.read().await;
365
366            let token_map: HashMap<NodeIndex, Token> = token_nodes
367                .iter()
368                .filter_map(|&node| {
369                    market
370                        .get_token(&graph[node])
371                        .cloned()
372                        .map(|t| (node, t))
373                })
374                .collect();
375
376            let market_subset = market.extract_subset(&component_ids);
377
378            (token_map, market_subset)
379        };
380
381        debug!(
382            edges = adj
383                .values()
384                .map(Vec::len)
385                .sum::<usize>(),
386            tokens = token_map.len(),
387            "subgraph extracted"
388        );
389
390        // SPFA relaxation with forbid-revisits.
391        // amount[node] = best gross output amount reachable at node.
392        // edge_gas[node] = gas estimate for the edge that last improved amount[node].
393        // cumul_gas[node] = total gas units along the best path to this node.
394        let max_idx = graph
395            .node_indices()
396            .map(|n| n.index())
397            .max()
398            .unwrap_or(0) +
399            1;
400
401        let mut amount: Vec<BigUint> = vec![BigUint::ZERO; max_idx];
402        let mut predecessor: Vec<Option<(NodeIndex, ComponentId)>> = vec![None; max_idx];
403        let mut edge_gas: Vec<BigUint> = vec![BigUint::ZERO; max_idx];
404        let mut cumul_gas: Vec<BigUint> = vec![BigUint::ZERO; max_idx];
405
406        amount[token_in_node.index()] = order.amount().clone();
407
408        // Gas-aware relaxation: pre-compute gas price and build address map for lookups.
409        let gas_price_wei = market_subset
410            .gas_price()
411            .map(|gp| gp.effective_gas_price().clone());
412
413        // Build node->address map for token price lookups during relaxation.
414        let node_address: HashMap<NodeIndex, Address> = token_map
415            .iter()
416            .map(|(&node, token)| (node, token.address.clone()))
417            .collect();
418
419        // Track cumulative spot price product from token_in for fallback gas estimation.
420        // spot_product[v] = product of spot prices along the path from token_in to v.
421        let mut spot_product: Vec<f64> = vec![0.0; max_idx];
422        spot_product[token_in_node.index()] = 1.0;
423
424        let gas_aware = self.gas_aware && gas_price_wei.is_some() && token_prices.is_some();
425        if !gas_aware && self.gas_aware {
426            debug!("gas-aware comparison disabled (missing gas_price or token_prices)");
427        } else if !self.gas_aware {
428            debug!("gas-aware comparison disabled by config");
429        }
430
431        let mut active_nodes: Vec<NodeIndex> = vec![token_in_node];
432
433        for round in 0..self.max_hops {
434            if start.elapsed() >= self.timeout {
435                debug!(round, "timeout during relaxation");
436                break;
437            }
438            if active_nodes.is_empty() {
439                debug!(round, "no active nodes, stopping early");
440                break;
441            }
442
443            let mut next_active: HashSet<NodeIndex> = HashSet::new();
444
445            for &u in &active_nodes {
446                let u_idx = u.index();
447                if amount[u_idx].is_zero() {
448                    continue;
449                }
450
451                let Some(token_u) = token_map.get(&u) else { continue };
452                let Some(edges) = adj.get(&u) else { continue };
453
454                for (v, component_id) in edges {
455                    let v_idx = v.index();
456
457                    // Single predecessor walk: skip if target token or pool already in path
458                    if Self::path_has_conflict(u, *v, component_id, &predecessor) {
459                        continue;
460                    }
461
462                    let Some(token_v) = token_map.get(v) else { continue };
463                    let Some(sim) = market_subset.get_simulation_state(component_id) else {
464                        continue;
465                    };
466
467                    let result = match sim.get_amount_out(amount[u_idx].clone(), token_u, token_v) {
468                        Ok(r) => r,
469                        Err(e) => {
470                            trace!(
471                                component_id,
472                                error = %e,
473                                "simulation failed, skipping edge"
474                            );
475                            continue;
476                        }
477                    };
478
479                    let candidate_cumul_gas = &cumul_gas[u_idx] + &result.gas;
480
481                    // Compute spot price product for the candidate path (used for
482                    // gas-aware comparison and for final net amount calculation).
483                    let candidate_spot = Self::compute_edge_spot_product(
484                        spot_product[u_idx],
485                        component_id,
486                        node_address.get(&u),
487                        node_address.get(v),
488                        spot_prices.as_ref(),
489                    );
490
491                    // Gas-aware comparison: compare net amounts (gross - gas cost in token terms)
492                    let is_better = if gas_aware {
493                        let v_price = Self::resolve_token_price(
494                            node_address.get(v),
495                            token_prices.as_ref(),
496                            candidate_spot,
497                            node_address.get(&token_in_node),
498                        );
499
500                        let net_candidate = Self::gas_adjusted_amount(
501                            &result.amount,
502                            &candidate_cumul_gas,
503                            gas_price_wei.as_ref().unwrap(),
504                            v_price.as_ref(),
505                        );
506                        let net_existing = Self::gas_adjusted_amount(
507                            &amount[v_idx],
508                            &cumul_gas[v_idx],
509                            gas_price_wei.as_ref().unwrap(),
510                            v_price.as_ref(),
511                        );
512                        net_candidate > net_existing
513                    } else {
514                        result.amount > amount[v_idx]
515                    };
516
517                    if is_better {
518                        spot_product[v_idx] = candidate_spot;
519                        amount[v_idx] = result.amount;
520                        predecessor[v_idx] = Some((u, component_id.clone()));
521                        edge_gas[v_idx] = result.gas;
522                        cumul_gas[v_idx] = candidate_cumul_gas;
523                        next_active.insert(*v);
524                    }
525                }
526            }
527
528            active_nodes = next_active.into_iter().collect();
529        }
530
531        // Check if destination was reached
532        let out_idx = token_out_node.index();
533        if amount[out_idx].is_zero() {
534            return Err(AlgorithmError::NoPath {
535                from: order.token_in().clone(),
536                to: order.token_out().clone(),
537                reason: NoPathReason::NoGraphPath,
538            });
539        }
540
541        // Reconstruct path and build route directly from stored distances/gas
542        // (no re-simulation needed since forbid-revisits guarantees relaxation
543        // amounts match sequential execution).
544        let path_edges = Self::reconstruct_path(token_out_node, token_in_node, &predecessor)?;
545
546        let mut swaps = Vec::with_capacity(path_edges.len());
547        for (from_node, to_node, component_id) in &path_edges {
548            let token_in = token_map
549                .get(from_node)
550                .ok_or_else(|| AlgorithmError::DataNotFound {
551                    kind: "token",
552                    id: Some(format!("{:?}", from_node)),
553                })?;
554            let token_out = token_map
555                .get(to_node)
556                .ok_or_else(|| AlgorithmError::DataNotFound {
557                    kind: "token",
558                    id: Some(format!("{:?}", to_node)),
559                })?;
560            let component = market_subset
561                .get_component(component_id)
562                .ok_or_else(|| AlgorithmError::DataNotFound {
563                    kind: "component",
564                    id: Some(component_id.clone()),
565                })?;
566            let sim_state = market_subset
567                .get_simulation_state(component_id)
568                .ok_or_else(|| AlgorithmError::DataNotFound {
569                    kind: "simulation state",
570                    id: Some(component_id.clone()),
571                })?;
572
573            swaps.push(Swap::new(
574                component_id.clone(),
575                component.protocol_system.clone(),
576                token_in.address.clone(),
577                token_out.address.clone(),
578                amount[from_node.index()].clone(),
579                amount[to_node.index()].clone(),
580                edge_gas[to_node.index()].clone(),
581                component.clone(),
582                sim_state.clone_box(),
583            ));
584        }
585
586        let route = Route::new(swaps);
587        let final_amount_out = amount[out_idx].clone();
588
589        let gas_price = gas_price_wei.unwrap_or_default();
590
591        let net_amount_out = Self::compute_net_amount_out(
592            &final_amount_out,
593            &route,
594            &gas_price,
595            token_prices.as_ref(),
596            &spot_product,
597            &node_address,
598            token_in_node,
599        );
600
601        let result = RouteResult::new(route, net_amount_out, gas_price);
602
603        let solve_time_ms = start.elapsed().as_millis() as u64;
604        debug!(
605            solve_time_ms,
606            hops = result.route().swaps().len(),
607            amount_in = %order.amount(),
608            amount_out = %final_amount_out,
609            net_amount_out = %result.net_amount_out(),
610            "bellman_ford route found"
611        );
612
613        Ok(result)
614    }
615
616    fn computation_requirements(&self) -> ComputationRequirements {
617        // Static requirements for independent computations; cannot conflict.
618        // The trait returns ComputationRequirements (not Result), so expect is
619        // the appropriate pattern for this infallible case.
620        ComputationRequirements::none()
621            .allow_stale("token_prices")
622            .expect("token_prices requirement conflicts (bug)")
623            .allow_stale("spot_prices")
624            .expect("spot_prices requirement conflicts (bug)")
625    }
626
627    fn timeout(&self) -> Duration {
628        self.timeout
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use std::sync::Arc;
635
636    use num_bigint::BigInt;
637    use tokio::sync::RwLock;
638    use tycho_simulation::{
639        tycho_common::{models::Address, simulation::protocol_sim::ProtocolSim},
640        tycho_ethereum::gas::{BlockGasPrice, GasPrice},
641    };
642
643    use super::*;
644    use crate::{
645        algorithm::test_utils::{component, order, token, MockProtocolSim},
646        derived::{types::TokenGasPrices, DerivedData},
647        feed::market_data::SharedMarketData,
648        graph::GraphManager,
649        types::quote::OrderSide,
650    };
651
652    // ==================== Test Utilities ====================
653
654    /// Sets up market and graph with `()` edge weights for BellmanFord tests.
655    fn setup_market_bf(
656        pools: Vec<(&str, &Token, &Token, MockProtocolSim)>,
657    ) -> (Arc<RwLock<SharedMarketData>>, PetgraphStableDiGraphManager<()>) {
658        let mut market = SharedMarketData::new();
659
660        market.update_gas_price(BlockGasPrice {
661            block_number: 1,
662            block_hash: Default::default(),
663            block_timestamp: 0,
664            pricing: GasPrice::Legacy { gas_price: BigUint::from(100u64) },
665        });
666        market.update_last_updated(crate::types::BlockInfo::new(1, "0x00".into(), 0));
667
668        for (pool_id, token_in, token_out, state) in pools {
669            let tokens = vec![token_in.clone(), token_out.clone()];
670            let comp = component(pool_id, &tokens);
671            market.upsert_components(std::iter::once(comp));
672            market.update_states([(pool_id.to_string(), Box::new(state) as Box<dyn ProtocolSim>)]);
673            market.upsert_tokens(tokens);
674        }
675
676        let mut graph_manager = PetgraphStableDiGraphManager::default();
677        graph_manager.initialize_graph(&market.component_topology());
678
679        (Arc::new(RwLock::new(market)), graph_manager)
680    }
681
682    fn setup_derived_with_token_prices(
683        token_addresses: &[Address],
684    ) -> crate::derived::SharedDerivedDataRef {
685        use tycho_simulation::tycho_core::simulation::protocol_sim::Price;
686
687        let mut token_prices: TokenGasPrices = HashMap::new();
688        for address in token_addresses {
689            token_prices.insert(
690                address.clone(),
691                Price { numerator: BigUint::from(1u64), denominator: BigUint::from(1u64) },
692            );
693        }
694
695        let mut derived_data = DerivedData::new();
696        derived_data.set_token_prices(token_prices, vec![], 1, true);
697        Arc::new(RwLock::new(derived_data))
698    }
699
700    fn bf_algorithm(max_hops: usize, timeout_ms: u64) -> BellmanFordAlgorithm {
701        BellmanFordAlgorithm::with_config(
702            AlgorithmConfig::new(1, max_hops, Duration::from_millis(timeout_ms), None).unwrap(),
703        )
704        .unwrap()
705    }
706
707    // ==================== Unit Tests ====================
708
709    #[tokio::test]
710    async fn test_linear_path_found() {
711        let token_a = token(0x01, "A");
712        let token_b = token(0x02, "B");
713        let token_c = token(0x03, "C");
714        let token_d = token(0x04, "D");
715
716        let (market, manager) = setup_market_bf(vec![
717            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
718            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
719            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(4.0)),
720        ]);
721
722        let algo = bf_algorithm(4, 1000);
723        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
724
725        let result = algo
726            .find_best_route(manager.graph(), market, None, &ord)
727            .await
728            .unwrap();
729
730        assert_eq!(result.route().swaps().len(), 3);
731        // A->B: 100*2=200, B->C: 200*3=600, C->D: 600*4=2400
732        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(200u64));
733        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
734        assert_eq!(result.route().swaps()[2].amount_out(), &BigUint::from(2400u64));
735    }
736
737    #[tokio::test]
738    async fn test_picks_better_of_two_paths() {
739        // Diamond graph: A->B->D (2*3=6x) vs A->C->D (4*1=4x)
740        let token_a = token(0x01, "A");
741        let token_b = token(0x02, "B");
742        let token_c = token(0x03, "C");
743        let token_d = token(0x04, "D");
744
745        let (market, manager) = setup_market_bf(vec![
746            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
747            ("pool_bd", &token_b, &token_d, MockProtocolSim::new(3.0)),
748            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(4.0)),
749            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(1.0)),
750        ]);
751
752        let algo = bf_algorithm(3, 1000);
753        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
754
755        let result = algo
756            .find_best_route(manager.graph(), market, None, &ord)
757            .await
758            .unwrap();
759
760        // A->B->D: 100*2*3=600 is better than A->C->D: 100*4*1=400
761        assert_eq!(result.route().swaps().len(), 2);
762        assert_eq!(result.route().swaps()[0].component_id(), "pool_ab");
763        assert_eq!(result.route().swaps()[1].component_id(), "pool_bd");
764        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
765    }
766
767    #[tokio::test]
768    async fn test_parallel_pools() {
769        // Two pools between A and B with different multipliers
770        let token_a = token(0x01, "A");
771        let token_b = token(0x02, "B");
772
773        let (market, manager) = setup_market_bf(vec![
774            ("pool1", &token_a, &token_b, MockProtocolSim::new(2.0)),
775            ("pool2", &token_a, &token_b, MockProtocolSim::new(5.0)),
776        ]);
777
778        let algo = bf_algorithm(2, 1000);
779        let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
780
781        let result = algo
782            .find_best_route(manager.graph(), market, None, &ord)
783            .await
784            .unwrap();
785
786        assert_eq!(result.route().swaps().len(), 1);
787        assert_eq!(result.route().swaps()[0].component_id(), "pool2");
788        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(500u64));
789    }
790
791    #[tokio::test]
792    async fn test_no_path_returns_error() {
793        let token_a = token(0x01, "A");
794        let token_b = token(0x02, "B");
795        let token_c = token(0x03, "C");
796
797        // A-B connected, C disconnected
798        let (market, manager) =
799            setup_market_bf(vec![("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
800
801        // Add token_c to market without connecting it
802        {
803            let mut m = market.write().await;
804            m.upsert_tokens(vec![token_c.clone()]);
805        }
806
807        let algo = bf_algorithm(3, 1000);
808        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
809
810        let result = algo
811            .find_best_route(manager.graph(), market, None, &ord)
812            .await;
813        assert!(matches!(result, Err(AlgorithmError::NoPath { .. })));
814    }
815
816    #[tokio::test]
817    async fn test_source_not_in_graph() {
818        let token_a = token(0x01, "A");
819        let token_b = token(0x02, "B");
820        let token_x = token(0x99, "X");
821
822        let (market, manager) =
823            setup_market_bf(vec![("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
824
825        let algo = bf_algorithm(3, 1000);
826        let ord = order(&token_x, &token_b, 100, OrderSide::Sell);
827
828        let result = algo
829            .find_best_route(manager.graph(), market, None, &ord)
830            .await;
831        assert!(matches!(
832            result,
833            Err(AlgorithmError::NoPath { reason: NoPathReason::SourceTokenNotInGraph, .. })
834        ));
835    }
836
837    #[tokio::test]
838    async fn test_destination_not_in_graph() {
839        let token_a = token(0x01, "A");
840        let token_b = token(0x02, "B");
841        let token_x = token(0x99, "X");
842
843        let (market, manager) =
844            setup_market_bf(vec![("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
845
846        let algo = bf_algorithm(3, 1000);
847        let ord = order(&token_a, &token_x, 100, OrderSide::Sell);
848
849        let result = algo
850            .find_best_route(manager.graph(), market, None, &ord)
851            .await;
852        assert!(matches!(
853            result,
854            Err(AlgorithmError::NoPath { reason: NoPathReason::DestinationTokenNotInGraph, .. })
855        ));
856    }
857
858    #[tokio::test]
859    async fn test_respects_max_hops() {
860        // Path A->B->C->D exists but requires 3 hops; max_hops=2
861        let token_a = token(0x01, "A");
862        let token_b = token(0x02, "B");
863        let token_c = token(0x03, "C");
864        let token_d = token(0x04, "D");
865
866        let (market, manager) = setup_market_bf(vec![
867            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
868            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
869            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(4.0)),
870        ]);
871
872        let algo = bf_algorithm(2, 1000);
873        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
874
875        let result = algo
876            .find_best_route(manager.graph(), market, None, &ord)
877            .await;
878        assert!(
879            matches!(result, Err(AlgorithmError::NoPath { .. })),
880            "Should not find 3-hop path with max_hops=2"
881        );
882    }
883
884    #[tokio::test]
885    async fn test_source_token_revisit_blocked() {
886        // Forbid-revisits prevents paths like A->B->A->B->C. The algorithm
887        // should find the direct A->B->C path instead.
888        let token_a = token(0x01, "A");
889        let token_b = token(0x02, "B");
890        let token_c = token(0x03, "C");
891
892        let (market, manager) = setup_market_bf(vec![
893            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
894            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
895        ]);
896
897        let algo = bf_algorithm(4, 1000);
898        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
899
900        let result = algo
901            .find_best_route(manager.graph(), market, None, &ord)
902            .await
903            .unwrap();
904
905        // Should find exactly the 2-hop path A->B->C = 100*2*3 = 600
906        assert_eq!(result.route().swaps().len(), 2);
907        assert_eq!(result.route().swaps()[0].component_id(), "pool_ab");
908        assert_eq!(result.route().swaps()[1].component_id(), "pool_bc");
909        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
910    }
911
912    #[tokio::test]
913    async fn test_hub_token_revisit_blocked() {
914        // Forbid-revisits blocks A->B->C->B->D (B visited twice).
915        // The algorithm should find the direct A->B->D = 400 instead.
916        let token_a = token(0x01, "A");
917        let token_c = token(0x02, "C");
918        let token_b = token(0x03, "B");
919        let token_d = token(0x04, "D");
920
921        let (market, manager) = setup_market_bf(vec![
922            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
923            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
924            ("pool_cb", &token_c, &token_b, MockProtocolSim::new(100.0)),
925            ("pool_bd", &token_b, &token_d, MockProtocolSim::new(2.0)),
926        ]);
927
928        let algo = bf_algorithm(4, 1000);
929        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
930
931        let result = algo
932            .find_best_route(manager.graph(), market, None, &ord)
933            .await
934            .unwrap();
935
936        // Should find A->B->D = 100*2*2 = 400 (the direct 2-hop path)
937        // The 4-hop revisit path A->B->C->B->D is blocked
938        assert_eq!(result.route().swaps().len(), 2, "should use direct 2-hop path");
939        assert_eq!(result.route().swaps()[0].component_id(), "pool_ab");
940        assert_eq!(result.route().swaps()[1].component_id(), "pool_bd");
941        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(400u64));
942    }
943
944    #[tokio::test]
945    async fn test_route_amounts_are_sequential() {
946        // Verify that swap amount_in[i+1] == amount_out[i] in the built route
947        let token_a = token(0x01, "A");
948        let token_b = token(0x02, "B");
949        let token_c = token(0x03, "C");
950
951        let (market, manager) = setup_market_bf(vec![
952            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
953            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
954        ]);
955
956        let algo = bf_algorithm(3, 1000);
957        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
958
959        let result = algo
960            .find_best_route(manager.graph(), market, None, &ord)
961            .await
962            .unwrap();
963
964        assert_eq!(result.route().swaps().len(), 2);
965        // amount_in of second swap == amount_out of first swap
966        assert_eq!(result.route().swaps()[1].amount_in(), result.route().swaps()[0].amount_out());
967    }
968
969    #[tokio::test]
970    async fn test_gas_deduction() {
971        let token_a = token(0x01, "A");
972        let token_b = token(0x02, "B");
973
974        let (market, manager) = setup_market_bf(vec![(
975            "pool1",
976            &token_a,
977            &token_b,
978            MockProtocolSim::new(2.0).with_gas(10),
979        )]);
980
981        let algo = bf_algorithm(2, 1000);
982        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
983
984        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
985
986        let result = algo
987            .find_best_route(manager.graph(), market, Some(derived), &ord)
988            .await
989            .unwrap();
990
991        // Output: 1000 * 2 = 2000
992        // Gas: 10 gas units * 100 gas_price = 1000 wei * 1/1 price = 1000
993        // Net: 2000 - 1000 = 1000
994        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(2000u64));
995        assert_eq!(result.net_amount_out(), &BigInt::from(1000));
996    }
997
998    #[tokio::test]
999    async fn test_timeout_respected() {
1000        let token_a = token(0x01, "A");
1001        let token_b = token(0x02, "B");
1002        let token_c = token(0x03, "C");
1003
1004        let (market, manager) = setup_market_bf(vec![
1005            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1006            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1007        ]);
1008
1009        // 0ms timeout
1010        let algo = bf_algorithm(3, 0);
1011        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1012
1013        let result = algo
1014            .find_best_route(manager.graph(), market, None, &ord)
1015            .await;
1016
1017        // With 0ms timeout, we expect either:
1018        // - A partial result (if some layers completed before timeout check)
1019        // - Timeout error
1020        // - NoPath (if timeout prevented completing enough layers to reach dest)
1021        match result {
1022            Ok(r) => {
1023                assert!(!r.route().swaps().is_empty());
1024            }
1025            Err(AlgorithmError::Timeout { .. }) | Err(AlgorithmError::NoPath { .. }) => {
1026                // Both are acceptable for 0ms timeout
1027            }
1028            Err(e) => panic!("Unexpected error: {:?}", e),
1029        }
1030    }
1031
1032    // ==================== Integration-style Tests ====================
1033
1034    #[tokio::test]
1035    async fn test_with_fees() {
1036        let token_a = token(0x01, "A");
1037        let token_b = token(0x02, "B");
1038
1039        // Pool with 10% fee
1040        let (market, manager) = setup_market_bf(vec![(
1041            "pool1",
1042            &token_a,
1043            &token_b,
1044            MockProtocolSim::new(2.0).with_fee(0.1),
1045        )]);
1046
1047        let algo = bf_algorithm(2, 1000);
1048        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1049
1050        let result = algo
1051            .find_best_route(manager.graph(), market, None, &ord)
1052            .await
1053            .unwrap();
1054
1055        // 1000 * 2 * (1-0.1) = 1800
1056        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(1800u64));
1057    }
1058
1059    #[tokio::test]
1060    async fn test_large_trade_slippage() {
1061        let token_a = token(0x01, "A");
1062        let token_b = token(0x02, "B");
1063
1064        // Pool with limited liquidity (500 tokens)
1065        let (market, manager) = setup_market_bf(vec![(
1066            "pool1",
1067            &token_a,
1068            &token_b,
1069            MockProtocolSim::new(2.0).with_liquidity(500),
1070        )]);
1071
1072        let algo = bf_algorithm(2, 1000);
1073        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1074
1075        // Should fail due to insufficient liquidity
1076        let result = algo
1077            .find_best_route(manager.graph(), market, None, &ord)
1078            .await;
1079        assert!(
1080            matches!(result, Err(AlgorithmError::NoPath { .. })),
1081            "Should fail when trade exceeds pool liquidity"
1082        );
1083    }
1084
1085    #[tokio::test]
1086    async fn test_disconnected_tokens_return_no_path() {
1087        // A-B connected, D-E disconnected. Routing A->E should fail.
1088        let token_a = token(0x01, "A");
1089        let token_b = token(0x02, "B");
1090        let token_d = token(0x04, "D");
1091        let token_e = token(0x05, "E");
1092
1093        let (market, manager) = setup_market_bf(vec![
1094            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1095            ("pool_de", &token_d, &token_e, MockProtocolSim::new(4.0)),
1096        ]);
1097
1098        let algo = bf_algorithm(3, 1000);
1099        let ord = order(&token_a, &token_e, 100, OrderSide::Sell);
1100
1101        let result = algo
1102            .find_best_route(manager.graph(), market, None, &ord)
1103            .await;
1104        assert!(
1105            matches!(result, Err(AlgorithmError::NoPath { .. })),
1106            "should not find path to disconnected component"
1107        );
1108    }
1109
1110    #[tokio::test]
1111    async fn test_spfa_skips_failed_simulations() {
1112        // Pool that will fail simulation (liquidity=0 would cause error for any amount)
1113        let token_a = token(0x01, "A");
1114        let token_b = token(0x02, "B");
1115        let token_c = token(0x03, "C");
1116
1117        let (market, manager) = setup_market_bf(vec![
1118            // Direct path with failing pool
1119            ("pool_ab_bad", &token_a, &token_b, MockProtocolSim::new(2.0).with_liquidity(0)),
1120            // Alternative path that works
1121            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(2.0)),
1122            ("pool_cb", &token_c, &token_b, MockProtocolSim::new(3.0)),
1123        ]);
1124
1125        let algo = bf_algorithm(3, 1000);
1126        let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
1127
1128        let result = algo
1129            .find_best_route(manager.graph(), market, None, &ord)
1130            .await;
1131
1132        // Should find A->C->B despite A->B failing
1133        // Note: MockProtocolSim with liquidity=0 will fail for amount > 0
1134        // The direct A->B edge should be skipped and the 2-hop path used
1135        match result {
1136            Ok(r) => {
1137                // Found alternative path
1138                assert!(!r.route().swaps().is_empty());
1139            }
1140            Err(AlgorithmError::NoPath { .. }) => {
1141                // Also acceptable if liquidity=0 blocks all paths through B
1142                // (since the failing pool might also block the reverse B->A edge)
1143            }
1144            Err(e) => panic!("Unexpected error: {:?}", e),
1145        }
1146    }
1147
1148    #[tokio::test]
1149    async fn test_resimulation_produces_correct_amounts() {
1150        // Verifies that re-simulation produces the same correct sequential amounts
1151        let token_a = token(0x01, "A");
1152        let token_b = token(0x02, "B");
1153        let token_c = token(0x03, "C");
1154
1155        let (market, manager) = setup_market_bf(vec![
1156            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1157            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1158        ]);
1159
1160        let algo = bf_algorithm(3, 1000);
1161        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1162
1163        let result = algo
1164            .find_best_route(manager.graph(), market, None, &ord)
1165            .await
1166            .unwrap();
1167
1168        // Verify the final amounts are from re-simulation, not relaxation
1169        // A->B: 100*2=200, B->C: 200*3=600
1170        assert_eq!(result.route().swaps()[0].amount_in(), &BigUint::from(100u64));
1171        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(200u64));
1172        assert_eq!(result.route().swaps()[1].amount_in(), &BigUint::from(200u64));
1173        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
1174    }
1175
1176    // ==================== Trait getter tests ====================
1177
1178    #[test]
1179    fn algorithm_name() {
1180        let algo = bf_algorithm(4, 200);
1181        assert_eq!(algo.name(), "bellman_ford");
1182    }
1183
1184    #[test]
1185    fn algorithm_timeout() {
1186        let algo = bf_algorithm(4, 200);
1187        assert_eq!(algo.timeout(), Duration::from_millis(200));
1188    }
1189
1190    // ==================== Forbid-revisit helper tests ====================
1191
1192    #[tokio::test]
1193    async fn test_gas_aware_relaxation_picks_cheaper_path() {
1194        // Diamond graph: A -> B -> D vs A -> C -> D
1195        // Path via B: higher gross output (3x * 2x = 6x) but extreme gas (100M per hop)
1196        // Path via C: lower gross output (2x * 2x = 4x) but cheap gas (100 per hop)
1197        //
1198        // With gas_price=100, token_prices[D]=1:1 for WETH conversion:
1199        // Path B gas cost: (100M + 100M) * 100 * 1 = 20B
1200        // Path C gas cost: (100 + 100) * 100 * 1 = 20K
1201        //
1202        // For an input of 1B:
1203        // Path B: gross = 6B, net = 6B - 20B = -14B
1204        // Path C: gross = 4B, net = 4B - 20K ≈ 4B
1205        //
1206        // Without gas awareness: Path B wins (6B > 4B)
1207        // With gas awareness: Path C wins (4B net > -14B net)
1208        let token_a = token(0x01, "A");
1209        let token_b = token(0x02, "B");
1210        let token_c = token(0x03, "C");
1211        let token_d = token(0x04, "D");
1212
1213        let high_gas: u64 = 100_000_000;
1214        let low_gas: u64 = 100;
1215
1216        let (market, manager) = setup_market_bf(vec![
1217            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(3.0).with_gas(high_gas)),
1218            ("pool_bd", &token_b, &token_d, MockProtocolSim::new(2.0).with_gas(high_gas)),
1219            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(2.0).with_gas(low_gas)),
1220            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(2.0).with_gas(low_gas)),
1221        ]);
1222
1223        let algo = bf_algorithm(3, 1000);
1224        let ord = order(&token_a, &token_d, 1_000_000_000, OrderSide::Sell);
1225
1226        // With gas-aware relaxation (derived data with token prices + gas price in market)
1227        let derived = setup_derived_with_token_prices(&[
1228            token_a.address.clone(),
1229            token_b.address.clone(),
1230            token_c.address.clone(),
1231            token_d.address.clone(),
1232        ]);
1233
1234        let result = algo
1235            .find_best_route(manager.graph(), market, Some(derived), &ord)
1236            .await
1237            .unwrap();
1238
1239        // Gas-aware relaxation should pick the cheaper path A -> C -> D
1240        assert_eq!(result.route().swaps().len(), 2);
1241        assert_eq!(result.route().swaps()[0].component_id(), "pool_ac");
1242        assert_eq!(result.route().swaps()[1].component_id(), "pool_cd");
1243    }
1244
1245    #[tokio::test]
1246    async fn test_gas_aware_falls_back_to_gross_without_derived() {
1247        // Same diamond graph as above, but without derived data.
1248        // Should fall back to gross comparison and pick Path B (higher gross).
1249        let token_a = token(0x01, "A");
1250        let token_b = token(0x02, "B");
1251        let token_c = token(0x03, "C");
1252        let token_d = token(0x04, "D");
1253
1254        let high_gas: u64 = 100_000_000;
1255        let low_gas: u64 = 100;
1256
1257        let (market, manager) = setup_market_bf(vec![
1258            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(3.0).with_gas(high_gas)),
1259            ("pool_bd", &token_b, &token_d, MockProtocolSim::new(2.0).with_gas(high_gas)),
1260            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(2.0).with_gas(low_gas)),
1261            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(2.0).with_gas(low_gas)),
1262        ]);
1263
1264        let algo = bf_algorithm(3, 1000);
1265        let ord = order(&token_a, &token_d, 1_000_000_000, OrderSide::Sell);
1266
1267        // No derived data: should fall back to gross comparison
1268        let result = algo
1269            .find_best_route(manager.graph(), market, None, &ord)
1270            .await
1271            .unwrap();
1272
1273        // Without gas awareness, picks the higher-gross path A -> B -> D
1274        assert_eq!(result.route().swaps().len(), 2);
1275        assert_eq!(result.route().swaps()[0].component_id(), "pool_ab");
1276        assert_eq!(result.route().swaps()[1].component_id(), "pool_bd");
1277    }
1278
1279    #[test]
1280    fn test_path_has_conflict_detects_node_and_pool() {
1281        // Path: 0 -[pool_a]-> 1 -[pool_b]-> 2
1282        let mut pred: Vec<Option<(NodeIndex, ComponentId)>> = vec![None; 4];
1283        pred[1] = Some((NodeIndex::new(0), "pool_a".into()));
1284        pred[2] = Some((NodeIndex::new(1), "pool_b".into()));
1285
1286        // Node conflicts: node 0 is in path, node 3 is not
1287        assert!(BellmanFordAlgorithm::path_has_conflict(
1288            NodeIndex::new(2),
1289            NodeIndex::new(0),
1290            &"any".into(),
1291            &pred
1292        ));
1293        assert!(!BellmanFordAlgorithm::path_has_conflict(
1294            NodeIndex::new(2),
1295            NodeIndex::new(3),
1296            &"any".into(),
1297            &pred
1298        ));
1299        // Self-check: node 2 is itself in the "path from 2"
1300        assert!(BellmanFordAlgorithm::path_has_conflict(
1301            NodeIndex::new(2),
1302            NodeIndex::new(2),
1303            &"any".into(),
1304            &pred
1305        ));
1306
1307        // Pool conflicts: pool_a and pool_b are used, pool_c is not
1308        assert!(BellmanFordAlgorithm::path_has_conflict(
1309            NodeIndex::new(2),
1310            NodeIndex::new(3),
1311            &"pool_a".into(),
1312            &pred
1313        ));
1314        assert!(BellmanFordAlgorithm::path_has_conflict(
1315            NodeIndex::new(2),
1316            NodeIndex::new(3),
1317            &"pool_b".into(),
1318            &pred
1319        ));
1320        assert!(!BellmanFordAlgorithm::path_has_conflict(
1321            NodeIndex::new(2),
1322            NodeIndex::new(3),
1323            &"pool_c".into(),
1324            &pred
1325        ));
1326    }
1327}