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