Skip to main content

fynd_core/algorithm/
path_frank_wolfe.rs

1//! Path-based Frank-Wolfe split-routing algorithm.
2//!
3//! Wraps [`BellmanFordAlgorithm`] to find multiple candidate paths and then
4//! optimally split the input amount across them. The inner BF instance handles
5//! single-path discovery; this module layers on the Frank-Wolfe optimisation
6//! loop to determine the best split fractions.
7
8use std::time::{Duration, Instant};
9
10use num_bigint::{BigInt, BigUint};
11use num_traits::{ToPrimitive, Zero};
12use tracing::debug;
13
14use super::{
15    bellman_ford::{BellmanFordContext, FindRouteOptions},
16    split_primitives::{
17        build_post_swap_overrides, build_split_route, compute_marginal_price_product,
18        evaluate_total_output, golden_section_search, normalize_fractions, simulate_path,
19        split_amount, HopDescriptor, MarketOverrides, PathAllocation, SimulatedHop,
20    },
21    Algorithm, AlgorithmConfig, AlgorithmError, BellmanFordAlgorithm,
22};
23use crate::{
24    derived::{computation::ComputationRequirements, SharedDerivedDataRef},
25    feed::market_data::{MarketData, StateLabel},
26    graph::{petgraph::StableDiGraph, PetgraphStableDiGraphManager},
27    types::{quote::Order, OrderSide, Route, RouteResult},
28};
29
30/// Tuning parameters for the path-based Frank-Wolfe split-routing loop.
31#[derive(Debug, Clone)]
32pub struct PathFrankWolfeConfig {
33    /// Maximum number of distinct paths to split across.
34    pub max_paths: usize,
35    /// Cap beyond which no split is attempted (e.g. 0.25 = 25%).
36    pub max_probe: f64,
37    /// Minimum share of total input allocated to any single path (e.g. 0.05 = 5%).
38    pub min_split: f64,
39    /// Number of function evaluations for the golden-section line search.
40    pub line_search_evals: usize,
41}
42
43impl Default for PathFrankWolfeConfig {
44    fn default() -> Self {
45        Self { max_paths: 4, max_probe: 0.25, min_split: 0.05, line_search_evals: 12 }
46    }
47}
48
49/// Split-routing algorithm that discovers multiple paths via Bellman-Ford
50/// and optimises the input split across them using a Frank-Wolfe loop.
51pub struct PathFrankWolfeAlgorithm {
52    inner: BellmanFordAlgorithm,
53    config: PathFrankWolfeConfig,
54}
55
56impl PathFrankWolfeAlgorithm {
57    /// Creates a new `PathFrankWolfeAlgorithm`.
58    pub(crate) fn new(algorithm_config: AlgorithmConfig, config: PathFrankWolfeConfig) -> Self {
59        let inner = BellmanFordAlgorithm::with_config(algorithm_config);
60        Self { inner, config }
61    }
62}
63
64impl Default for PathFrankWolfeAlgorithm {
65    fn default() -> Self {
66        Self::new(AlgorithmConfig::default(), PathFrankWolfeConfig::default())
67    }
68}
69
70impl PathFrankWolfeAlgorithm {
71    /// Computes the minimum probe amount from the initial route's price impact.
72    ///
73    /// Returns `None` when the probe exceeds `config.max_probe × total_amount`,
74    /// signalling that splitting is not worthwhile.
75    fn compute_probe_amount(
76        &self,
77        total_amount: &BigUint,
78        price_impact: f64,
79        gas_cost_output_tokens: f64,
80    ) -> Option<BigUint> {
81        if price_impact <= 0.0 {
82            return None;
83        }
84        let gas_floor = gas_cost_output_tokens / price_impact;
85
86        let probe_amount = BigUint::from(gas_floor.ceil() as u128);
87        let (max_probe_amount, _remainder) = split_amount(total_amount, self.config.max_probe);
88        if probe_amount > max_probe_amount {
89            return None;
90        }
91
92        Some(probe_amount)
93    }
94
95    /// Flow-fraction-weighted average price impact across all active paths.
96    ///
97    /// Per-path price impact measures how much the realized output falls short
98    /// of the ideal (marginal-price) output. Paths are weighted by their share of
99    /// total flow, not averaged equally — a 95/5 split means the big path
100    /// dominates the result and the small path barely matters.
101    fn compute_average_price_impact(paths: &[PathAllocation]) -> Result<f64, AlgorithmError> {
102        let mut weighted_price_impact = 0.0;
103        for path in paths {
104            let first_hop = path
105                .hops
106                .first()
107                .ok_or_else(|| AlgorithmError::Other("path has no hops".to_string()))?;
108            let last_hop = path
109                .hops
110                .last()
111                .ok_or_else(|| AlgorithmError::Other("path has no hops".to_string()))?;
112
113            let amount_in = path.amount_in.to_f64().ok_or_else(|| {
114                AlgorithmError::Other(format!("amount_in too large for f64: {}", path.amount_in))
115            })?;
116            let amount_out = path
117                .amount_out
118                .to_f64()
119                .ok_or_else(|| {
120                    AlgorithmError::Other(format!(
121                        "amount_out too large for f64: {}",
122                        path.amount_out
123                    ))
124                })?;
125            if amount_in <= 0.0 {
126                return Err(AlgorithmError::Other(format!("non-positive amount_in ({amount_in})")));
127            }
128            if path.marginal_price_product <= 0.0 {
129                return Err(AlgorithmError::Other(format!(
130                    "non-positive marginal_price_product ({})",
131                    path.marginal_price_product
132                )));
133            }
134
135            // marginal_price_product is in decimal-normalized units (from
136            // spot_price), so convert raw amounts before comparing.
137            let amount_in_decimal =
138                amount_in / 10f64.powi(first_hop.descriptor.token_in.decimals as i32);
139            let amount_out_decimal =
140                amount_out / 10f64.powi(last_hop.descriptor.token_out.decimals as i32);
141            let ideal_out = amount_in_decimal * path.marginal_price_product;
142            let price_impact = 1.0 - amount_out_decimal / ideal_out;
143            weighted_price_impact += path.flow_fraction * price_impact;
144        }
145        Ok(weighted_price_impact)
146    }
147
148    /// Finds the next candidate routing path for the Frank-Wolfe algorithm.
149    ///
150    /// Builds a post-swap market state that reflects `current_allocations` (applying each
151    /// allocation's simulated pool outputs as overrides), then runs Bellman-Ford at
152    /// `probe_amount` to discover the best remaining route.
153    ///
154    /// Pools already present in `current_allocations` are promoted to zero-gas so their
155    /// committed gas cost is not counted again as marginal cost for the new path.
156    ///
157    /// Returns an ordered sequence of [`SimulatedHop`]s representing the discovered path,
158    /// or an error if no route exists.
159    pub(crate) fn find_candidate_path(
160        &self,
161        ctx: &BellmanFordContext,
162        current_allocations: &[PathAllocation],
163        probe_amount: &BigUint,
164    ) -> Result<Vec<SimulatedHop>, AlgorithmError> {
165        let mut overrides = build_post_swap_overrides(current_allocations, &ctx.market_data);
166
167        // Pools committed in the current solution are executed once on-chain — their gas is
168        // already priced into the combined transaction. Zero out protocol gas so BF doesn't
169        // double-charge them when evaluating extensions. We track by (component_id, token_in,
170        // token_out) because different token pairs through the same pool are separate on-chain
171        // swaps with independent gas costs.
172        for alloc in current_allocations {
173            for hop in &alloc.hops {
174                overrides = overrides.with_zero_gas(
175                    hop.descriptor.component_id.clone(),
176                    hop.descriptor.token_in.address.clone(),
177                    hop.descriptor.token_out.address.clone(),
178                );
179            }
180        }
181
182        let token_in = ctx
183            .node_address
184            .get(&ctx.token_in_node)
185            .cloned()
186            .ok_or_else(|| AlgorithmError::DataNotFound {
187                kind: "token_in node index",
188                id: Some(format!("{:?}", ctx.token_in_node)),
189            })?;
190        let token_out = ctx
191            .node_address
192            .get(&ctx.token_out_node)
193            .cloned()
194            .ok_or_else(|| AlgorithmError::DataNotFound {
195                kind: "token_out node index",
196                id: Some(format!("{:?}", ctx.token_out_node)),
197            })?;
198        let probe_order = Order::new(
199            token_in,
200            token_out,
201            probe_amount.clone(),
202            OrderSide::Sell,
203            Default::default(),
204        );
205
206        let result =
207            self.inner
208                .find_single_route(ctx, &probe_order, FindRouteOptions { overrides })?;
209
210        let route = result.route();
211        let tokens = route.tokens();
212        route
213            .swaps()
214            .iter()
215            .map(|swap| {
216                let token_in = tokens
217                    .get(swap.token_in())
218                    .cloned()
219                    .ok_or_else(|| AlgorithmError::DataNotFound {
220                        kind: "token",
221                        id: Some(format!("{:?}", swap.token_in())),
222                    })?;
223                let token_out = tokens
224                    .get(swap.token_out())
225                    .cloned()
226                    .ok_or_else(|| AlgorithmError::DataNotFound {
227                        kind: "token",
228                        id: Some(format!("{:?}", swap.token_out())),
229                    })?;
230                Ok(SimulatedHop {
231                    descriptor: HopDescriptor::new(
232                        swap.component_id().to_string(),
233                        token_in,
234                        token_out,
235                    ),
236                    amount_out: swap.amount_out().clone(),
237                    gas: swap.gas_estimate().clone(),
238                })
239            })
240            .collect()
241    }
242
243    /// Computes the gas cost of a route in output-token units as `f64`.
244    fn gas_cost_output_tokens(
245        route: &Route,
246        ctx: &BellmanFordContext,
247    ) -> Result<f64, AlgorithmError> {
248        let gas_price = match &ctx.gas_price_wei {
249            Some(gp) if !gp.is_zero() => gp,
250            _ => return Ok(0.0),
251        };
252        let last_swap = route
253            .swaps()
254            .last()
255            .ok_or_else(|| AlgorithmError::Other("route has no swaps".to_string()))?;
256        let price = match ctx
257            .token_prices
258            .as_ref()
259            .and_then(|tp| tp.get(last_swap.token_out()))
260        {
261            Some(p) if !p.denominator.is_zero() => p,
262            _ => return Ok(0.0),
263        };
264        let gas_cost_wei = &route.total_gas() * gas_price;
265        let gas_cost_tokens = &gas_cost_wei * &price.numerator / &price.denominator;
266        Ok(gas_cost_tokens.to_f64().unwrap_or(0.0))
267    }
268
269    /// Converts a `Route` (from BF's initial solve) into a single `PathAllocation`.
270    fn route_to_allocation(
271        route: &Route,
272        order: &Order,
273        ctx: &BellmanFordContext,
274    ) -> Result<PathAllocation, AlgorithmError> {
275        let tokens = route.tokens();
276        let hops: Vec<SimulatedHop> = route
277            .swaps()
278            .iter()
279            .map(|swap| {
280                let token_in = tokens
281                    .get(swap.token_in())
282                    .cloned()
283                    .ok_or_else(|| AlgorithmError::DataNotFound {
284                        kind: "token",
285                        id: Some(format!("{:?}", swap.token_in())),
286                    })?;
287                let token_out = tokens
288                    .get(swap.token_out())
289                    .cloned()
290                    .ok_or_else(|| AlgorithmError::DataNotFound {
291                        kind: "token",
292                        id: Some(format!("{:?}", swap.token_out())),
293                    })?;
294                Ok(SimulatedHop {
295                    descriptor: HopDescriptor::new(
296                        swap.component_id().to_string(),
297                        token_in,
298                        token_out,
299                    ),
300                    amount_out: swap.amount_out().clone(),
301                    gas: swap.gas_estimate().clone(),
302                })
303            })
304            .collect::<Result<_, AlgorithmError>>()?;
305
306        if hops.is_empty() {
307            return Err(AlgorithmError::DataNotFound {
308                kind: "swap",
309                id: Some("route contains no swaps".to_string()),
310            });
311        }
312
313        let descriptors: Vec<HopDescriptor> = hops
314            .iter()
315            .map(|h| h.descriptor.clone())
316            .collect();
317        let overrides = MarketOverrides::empty();
318        let marginal_price_product =
319            compute_marginal_price_product(&descriptors, &ctx.market_data, &overrides)?;
320        let amount_out = hops
321            .last()
322            .map(|h| h.amount_out.clone())
323            .unwrap_or_default();
324
325        Ok(PathAllocation {
326            hops,
327            flow_fraction: 1.0,
328            amount_in: order.amount().clone(),
329            amount_out,
330            marginal_price_product,
331        })
332    }
333
334    /// Golden-section search over the step size `∈ [0, 1]`.
335    ///
336    /// At each probe point, builds trial fractions (existing paths scaled by
337    /// `1 − step_size`, candidate at `step_size`) and evaluates the combined
338    /// output via `evaluate_total_output`.
339    fn optimize_step_size(
340        &self,
341        current_allocations: &[PathAllocation],
342        candidate: &[SimulatedHop],
343        total_amount: &BigUint,
344        ctx: &BellmanFordContext,
345    ) -> f64 {
346        let existing_descriptors: Vec<Vec<HopDescriptor>> = current_allocations
347            .iter()
348            .map(|a| {
349                a.hops
350                    .iter()
351                    .map(|h| h.descriptor.clone())
352                    .collect()
353            })
354            .collect();
355        let candidate_descriptors: Vec<HopDescriptor> = candidate
356            .iter()
357            .map(|h| h.descriptor.clone())
358            .collect();
359        let overrides = MarketOverrides::empty();
360
361        // Evaluates the total output of the split that results from shifting
362        // `step_size` fraction of flow from existing paths to the candidate.
363        let evaluate_split = |step_size: f64| -> f64 {
364            let mut trial_fractions: Vec<f64> = current_allocations
365                .iter()
366                .map(|a| a.flow_fraction * (1.0 - step_size))
367                .collect();
368            trial_fractions.push(step_size);
369
370            let mut trial_paths: Vec<&[HopDescriptor]> = existing_descriptors
371                .iter()
372                .map(|v| v.as_slice())
373                .collect();
374            trial_paths.push(&candidate_descriptors);
375
376            match evaluate_total_output(
377                &trial_paths,
378                &trial_fractions,
379                total_amount,
380                &ctx.market_data,
381                &overrides,
382            ) {
383                Ok((total_output, _gas)) => total_output.to_f64().unwrap_or(0.0),
384                Err(_) => 0.0,
385            }
386        };
387
388        golden_section_search(evaluate_split, 0.0, 1.0, self.config.line_search_evals)
389    }
390
391    /// Applies a Frank-Wolfe step: shifts `step_size` fraction of flow to the
392    /// candidate path, re-simulates all paths, and drops any path whose fraction
393    /// falls below `config.min_split` (renormalizing the remainder).
394    fn apply_step(
395        &self,
396        allocations: &mut Vec<PathAllocation>,
397        candidate: &[SimulatedHop],
398        step_size: f64,
399        total_amount: &BigUint,
400        ctx: &BellmanFordContext,
401    ) -> Result<(), AlgorithmError> {
402        for alloc in allocations.iter_mut() {
403            alloc.flow_fraction *= 1.0 - step_size;
404        }
405
406        allocations.push(PathAllocation {
407            hops: candidate.to_vec(),
408            flow_fraction: step_size,
409            amount_in: BigUint::zero(),
410            amount_out: BigUint::zero(),
411            marginal_price_product: 0.0,
412        });
413
414        allocations.retain(|a| a.flow_fraction >= self.config.min_split);
415
416        let mut remaining_fractions: Vec<f64> = allocations
417            .iter()
418            .map(|a| a.flow_fraction)
419            .collect();
420        normalize_fractions(&mut remaining_fractions)
421            .map_err(|e| AlgorithmError::Other(e.to_string()))?;
422        let overrides = MarketOverrides::empty();
423        for (alloc, &frac) in allocations
424            .iter_mut()
425            .zip(remaining_fractions.iter())
426        {
427            alloc.flow_fraction = frac;
428            let (alloc_amount_in, _) = split_amount(total_amount, frac);
429            let hop_descriptors: Vec<HopDescriptor> = alloc
430                .hops
431                .iter()
432                .map(|h| h.descriptor.clone())
433                .collect();
434            let sim =
435                simulate_path(&hop_descriptors, &alloc_amount_in, &ctx.market_data, &overrides)?;
436            alloc.amount_in = alloc_amount_in;
437            alloc.amount_out = sim.amount_out;
438            alloc.marginal_price_product = sim.marginal_price_product;
439
440            // Sync per-hop amount_out and gas with the final optimised split values so
441            // build_split_route emits the correct swap amounts.
442            for (hop, (amount_out, gas)) in alloc
443                .hops
444                .iter_mut()
445                .zip(sim.hop_results)
446            {
447                hop.amount_out = amount_out;
448                hop.gas = gas;
449            }
450        }
451
452        Ok(())
453    }
454
455    /// Computes `net_amount_out` for a split route, mirroring
456    /// `BellmanFordAlgorithm::compute_net_amount_out`.
457    fn compute_split_net_amount_out(
458        route: &Route,
459        ctx: &BellmanFordContext,
460    ) -> Result<BigInt, AlgorithmError> {
461        let last_swap = route
462            .swaps()
463            .last()
464            .ok_or_else(|| AlgorithmError::Other("route has no swaps".to_string()))?;
465        let output_token = last_swap.token_out();
466        let total_out: BigUint = route
467            .swaps()
468            .iter()
469            .filter(|s| s.token_out() == output_token)
470            .map(|s| s.amount_out().clone())
471            .fold(BigUint::zero(), |acc, x| acc + x);
472
473        let gas_cost = Self::gas_cost_output_tokens(route, ctx)?;
474        let gas_cost_tokens = BigUint::from(gas_cost.ceil() as u128);
475        Ok(BigInt::from(total_out) - BigInt::from(gas_cost_tokens))
476    }
477
478    /// Returns `true` if `candidate` has the same ordered sequence of
479    /// `(component_id, token_in, token_out)` as any existing allocation.
480    ///
481    /// Both the pool and the token pair must match at every hop — the same pool used with
482    /// different tokens (e.g. in a multi-token pool) is a distinct path.
483    ///
484    /// Paths that share only a prefix but diverge at a later hop are **not** duplicates — the
485    /// shared hops are handled by `build_split_route`, which emits a single combined swap for
486    /// the common segment.
487    pub(crate) fn is_duplicate_path(
488        candidate: &[SimulatedHop],
489        existing: &[PathAllocation],
490    ) -> bool {
491        existing.iter().any(|alloc| {
492            alloc.hops.len() == candidate.len() &&
493                alloc
494                    .hops
495                    .iter()
496                    .zip(candidate.iter())
497                    .all(|(a, b)| {
498                        a.descriptor.component_id == b.descriptor.component_id &&
499                            a.descriptor.token_in.address == b.descriptor.token_in.address &&
500                            a.descriptor.token_out.address == b.descriptor.token_out.address
501                    })
502        })
503    }
504}
505
506impl Algorithm for PathFrankWolfeAlgorithm {
507    type GraphType = StableDiGraph<()>;
508    type GraphManager = PetgraphStableDiGraphManager<()>;
509
510    fn name(&self) -> &str {
511        "path_frank_wolfe"
512    }
513
514    async fn find_best_route(
515        &self,
516        graph: &Self::GraphType,
517        market: MarketData,
518        label: Option<StateLabel>,
519        derived: Option<SharedDerivedDataRef>,
520        order: &Order,
521    ) -> Result<RouteResult, AlgorithmError> {
522        let start = Instant::now();
523        let ctx = self
524            .inner
525            .build_context(graph, market, label, derived, order)
526            .await?;
527
528        // Step 1: initial single-path route via BF at full amount.
529        let single_path_result =
530            self.inner
531                .find_single_route(&ctx, order, FindRouteOptions::default())?;
532
533        let mut allocations =
534            vec![Self::route_to_allocation(single_path_result.route(), order, &ctx)?];
535
536        // Compute gas cost and initial probe.
537        let gas_cost = Self::gas_cost_output_tokens(single_path_result.route(), &ctx)?;
538        let total_amount = order.amount();
539        let initial_pi = Self::compute_average_price_impact(&allocations)?;
540        if self
541            .compute_probe_amount(total_amount, initial_pi, gas_cost)
542            .is_none()
543        {
544            debug!(pi = initial_pi, gas_cost, "price impact too low to justify splitting");
545            return Ok(single_path_result);
546        }
547
548        // Step 2: Frank-Wolfe loop — discover up to max_paths - 1 additional paths.
549        for iteration in 1..self.config.max_paths {
550            if start.elapsed() >= self.timeout() {
551                debug!(iteration, "pfw timeout, returning partial result");
552                break;
553            }
554
555            let pi = Self::compute_average_price_impact(&allocations)?;
556            let probe_amount = match self.compute_probe_amount(total_amount, pi, gas_cost) {
557                Some(p) => p,
558                None => {
559                    debug!(iteration, pi, "probe exceeds cap, stopping");
560                    break;
561                }
562            };
563
564            let candidate = match self.find_candidate_path(&ctx, &allocations, &probe_amount) {
565                Ok(c) => c,
566                Err(e) => {
567                    debug!(
568                        iteration,
569                        ?e,
570                        "no additional candidate path found, stopping further searches"
571                    );
572                    break;
573                }
574            };
575
576            if Self::is_duplicate_path(&candidate, &allocations) {
577                debug!(iteration, "duplicate path, exploration exhausted");
578                break;
579            }
580
581            // golden-section line search for optimal step size.
582            let step_size = self.optimize_step_size(&allocations, &candidate, total_amount, &ctx);
583
584            // step too small → no benefit.
585            if step_size < self.config.min_split {
586                debug!(iteration, step_size, "step size below min_split, stopping");
587                break;
588            }
589
590            self.apply_step(&mut allocations, &candidate, step_size, total_amount, &ctx)?;
591            debug!(iteration, paths = allocations.len(), step_size, "pfw iteration complete");
592        }
593
594        // Step 3: if we only have one path, the initial result is already optimal.
595        if allocations.len() <= 1 {
596            return Ok(single_path_result);
597        }
598
599        // Build the split route and compare with the initial single-path result.
600        let split_route = build_split_route(&allocations, &ctx.market_data, order)?;
601        let gas_price = ctx
602            .gas_price_wei
603            .clone()
604            .unwrap_or_default();
605        let split_net = Self::compute_split_net_amount_out(&split_route, &ctx)?;
606        let split_result = RouteResult::new(split_route, split_net, gas_price);
607
608        if split_result.net_amount_out() > single_path_result.net_amount_out() {
609            debug!(
610                split_net = %split_result.net_amount_out(),
611                initial_net = %single_path_result.net_amount_out(),
612                paths = allocations.len(),
613                "split route beats single path"
614            );
615            Ok(split_result)
616        } else {
617            debug!(
618                split_net = %split_result.net_amount_out(),
619                initial_net = %single_path_result.net_amount_out(),
620                "single path still best"
621            );
622            Ok(single_path_result)
623        }
624    }
625
626    fn computation_requirements(&self) -> ComputationRequirements {
627        ComputationRequirements::none()
628            .allow_stale("token_prices")
629            .expect("token_prices requirement conflicts (bug)")
630            .allow_stale("spot_prices")
631            .expect("spot_prices requirement conflicts (bug)")
632    }
633
634    fn timeout(&self) -> Duration {
635        self.inner.timeout()
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use std::{sync::Arc, time::Duration as StdDuration};
642
643    use tokio::sync::RwLock;
644    use tycho_simulation::tycho_common::{
645        models::token::Token,
646        simulation::protocol_sim::{Price, ProtocolSim},
647    };
648
649    use super::*;
650    use crate::{
651        algorithm::{
652            split_primitives::{build_split_route, MarketOverrides},
653            test_utils::{
654                order, setup_market_unweighted, token, token_with_decimals, ConstantProductSim,
655                MockProtocolSim,
656            },
657            AlgorithmConfig,
658        },
659        derived::{types::TokenGasPrices, DerivedData, SharedDerivedDataRef},
660        graph::GraphManager,
661        types::OrderSide,
662    };
663
664    /// Creates a single dummy hop for test PathAllocations that need token
665    /// decimal info but don't care about the actual path.
666    fn dummy_hops(decimals_in: u32, decimals_out: u32) -> Vec<SimulatedHop> {
667        vec![SimulatedHop {
668            descriptor: HopDescriptor::new(
669                "dummy".to_string(),
670                token_with_decimals(0xAA, "IN", decimals_in),
671                token_with_decimals(0xBB, "OUT", decimals_out),
672            ),
673            amount_out: BigUint::ZERO,
674            gas: BigUint::ZERO,
675        }]
676    }
677
678    /// Builds a `SharedDerivedDataRef` with token prices for the given tokens.
679    ///
680    /// Price is set so gas costs are small but non-zero relative to test trade
681    /// amounts. With `setup_market_unweighted` (gas_price=100 wei) and pool
682    /// gas=50,000, each hop costs ~5 output tokens.
683    fn derived_with_token_prices(tokens: &[&Token]) -> SharedDerivedDataRef {
684        let mut prices = TokenGasPrices::new();
685        // 1 token = 1,000,000 wei of gas token.
686        // gas_cost_tokens = (gas × gas_price) / 1,000,000
687        //                 = (50,000 × 100) / 1,000,000 = 5 tokens per hop
688        let price = Price::new(BigUint::from(1u64), BigUint::from(1_000_000u64));
689        for token in tokens {
690            prices.insert(token.address.clone(), price.clone());
691        }
692        let mut derived = DerivedData::new();
693        derived.set_token_prices(prices, vec![], 1, true);
694        Arc::new(RwLock::new(derived))
695    }
696
697    impl PathFrankWolfeAlgorithm {
698        /// Returns a reference to the PFW-specific tuning config.
699        fn pfw_config(&self) -> &PathFrankWolfeConfig {
700            &self.config
701        }
702    }
703
704    #[test]
705    fn test_with_pfw_config_override() {
706        let pfw_config = PathFrankWolfeConfig {
707            max_paths: 8,
708            max_probe: 0.5,
709            min_split: 0.1,
710            line_search_evals: 24,
711        };
712        let algo = PathFrankWolfeAlgorithm::new(AlgorithmConfig::default(), pfw_config);
713
714        assert_eq!(algo.pfw_config().max_paths, 8);
715        assert!((algo.pfw_config().max_probe - 0.5).abs() < f64::EPSILON);
716        assert!((algo.pfw_config().min_split - 0.1).abs() < f64::EPSILON);
717        assert_eq!(algo.pfw_config().line_search_evals, 24);
718    }
719
720    // ==================== compute_probe_amount ====================
721
722    #[test]
723    fn test_probe_amount_low_impact() {
724        // Very small price impact makes gas_floor huge, exceeding max_probe cap → None.
725        //   gas_floor = 100_000 / 0.001 = 100_000_000
726        //   max_probe = 1_000_000 * 0.25 = 250_000
727        let total = BigUint::from(1_000_000u64);
728        let algo = PathFrankWolfeAlgorithm::default();
729
730        let result = algo.compute_probe_amount(&total, 0.001, 100_000.0);
731        assert!(result.is_none());
732    }
733
734    #[test]
735    fn test_probe_amount_scaling() {
736        // Higher price impact → lower probe floor (inversely proportional).
737        //   probe = gas_cost / price_impact, so doubling price impact halves
738        //   the probe.
739        let total = BigUint::from(10_000_000u64);
740        let algo = PathFrankWolfeAlgorithm::default();
741        let gas_cost = 1000.0;
742
743        let probe_high_pi = algo
744            .compute_probe_amount(&total, 0.10, gas_cost)
745            .unwrap();
746        let probe_low_pi = algo
747            .compute_probe_amount(&total, 0.05, gas_cost)
748            .unwrap();
749
750        assert!(probe_high_pi < probe_low_pi);
751
752        // price_impact ratio is 0.10/0.05 = 2×, so the probe ratio should be
753        // the inverse: 0.5×. Verify within 1% tolerance.
754        let ratio = probe_high_pi.to_f64().unwrap() / probe_low_pi.to_f64().unwrap();
755        assert!(
756            (ratio - 0.5).abs() < 0.01,
757            "expected ratio ~0.5 (inverse proportionality), got {ratio}"
758        );
759    }
760
761    #[test]
762    fn test_probe_amount_within_cap() {
763        // Moderate price impact where probe fits within max_probe cap → Some.
764        //   gas_floor = 1000 / 0.10 = 10_000
765        //   max_probe = 1_000_000 * 0.25 = 250_000
766        let total = BigUint::from(1_000_000u64);
767        let algo = PathFrankWolfeAlgorithm::default();
768
769        let probe_amount = algo
770            .compute_probe_amount(&total, 0.10, 1000.0)
771            .unwrap();
772        assert_eq!(probe_amount, BigUint::from(10_000u64));
773    }
774
775    #[test]
776    fn test_probe_amount_zero_price_impact() {
777        let total = BigUint::from(1_000_000u64);
778        let algo = PathFrankWolfeAlgorithm::default();
779
780        assert!(algo
781            .compute_probe_amount(&total, 0.0, 1000.0)
782            .is_none());
783    }
784
785    // ==================== compute_average_price_impact ====================
786
787    #[test]
788    fn test_average_price_impact_redistribution() {
789        // Splitting flow across more paths should reduce average price impact.
790        // Uses constant-product pool outputs (reserve_in=1M, reserve_out=2M) to construct
791        // allocations at 1, 2, and 3 paths.
792        let hops = dummy_hops(18, 18);
793        let iter_0 = [PathAllocation {
794            hops: hops.clone(),
795            flow_fraction: 1.0,
796            amount_in: BigUint::from(100_000u64),
797            amount_out: BigUint::from(181_818u64),
798            marginal_price_product: 2.0,
799        }];
800
801        let iter_1 = [
802            PathAllocation {
803                hops: hops.clone(),
804                flow_fraction: 0.5,
805                amount_in: BigUint::from(50_000u64),
806                amount_out: BigUint::from(95_238u64),
807                marginal_price_product: 2.0,
808            },
809            PathAllocation {
810                hops: hops.clone(),
811                flow_fraction: 0.5,
812                amount_in: BigUint::from(50_000u64),
813                amount_out: BigUint::from(95_238u64),
814                marginal_price_product: 2.0,
815            },
816        ];
817
818        let third = 1.0 / 3.0;
819        let iter_2 = [
820            PathAllocation {
821                hops: hops.clone(),
822                flow_fraction: third,
823                amount_in: BigUint::from(33_333u64),
824                amount_out: BigUint::from(64_514u64),
825                marginal_price_product: 2.0,
826            },
827            PathAllocation {
828                hops: hops.clone(),
829                flow_fraction: third,
830                amount_in: BigUint::from(33_333u64),
831                amount_out: BigUint::from(64_514u64),
832                marginal_price_product: 2.0,
833            },
834            PathAllocation {
835                hops: hops.clone(),
836                flow_fraction: third,
837                amount_in: BigUint::from(33_334u64),
838                amount_out: BigUint::from(64_516u64),
839                marginal_price_product: 2.0,
840            },
841        ];
842
843        let pi_0 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_0).unwrap();
844        let pi_1 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_1).unwrap();
845        let pi_2 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_2).unwrap();
846
847        assert!(pi_1 < pi_0, "price impact should decrease after first split: {pi_1} >= {pi_0}");
848        assert!(pi_2 < pi_1, "price impact should decrease after second split: {pi_2} >= {pi_1}");
849
850        assert!((pi_0 - 0.09091).abs() < 1e-5, "expected ~0.0909, got {pi_0}");
851        assert!((pi_1 - 0.04762).abs() < 1e-5, "expected ~0.0476, got {pi_1}");
852        assert!((pi_2 - 0.03228).abs() < 1e-5, "expected ~0.0323, got {pi_2}");
853    }
854
855    #[test]
856    fn test_average_price_impact_weighting() {
857        // Weighted average: 90% of flow with 10% price impact + 10% of flow
858        // with 50% price impact = 0.14, not the simple mean of 0.30.
859        //   Path 1: flow=0.9, price_impact = 1 − 900/1000 = 0.10
860        //   Path 2: flow=0.1, price_impact = 1 − 50/100  = 0.50
861        //   Weighted = 0.9 × 0.10 + 0.1 × 0.50 = 0.14
862        let hops = dummy_hops(18, 18);
863        let allocations = [
864            PathAllocation {
865                hops: hops.clone(),
866                flow_fraction: 0.9,
867                amount_in: BigUint::from(1000u64),
868                amount_out: BigUint::from(900u64),
869                marginal_price_product: 1.0,
870            },
871            PathAllocation {
872                hops: hops.clone(),
873                flow_fraction: 0.1,
874                amount_in: BigUint::from(100u64),
875                amount_out: BigUint::from(50u64),
876                marginal_price_product: 1.0,
877            },
878        ];
879
880        let pi = PathFrankWolfeAlgorithm::compute_average_price_impact(&allocations).unwrap();
881        assert!((pi - 0.14).abs() < 1e-10, "expected 0.14, got {pi}");
882    }
883
884    #[test]
885    fn test_average_price_impact_mixed_decimals() {
886        // USDC (6 dec) → WETH (18 dec): spot_price = 0.0005 (human units).
887        // Trade: 2000 USDC → ~1 WETH with 10% price impact.
888        //   amount_in  = 2000 * 10^6  = 2_000_000_000 (raw USDC)
889        //   amount_out = 0.9 * 10^18  = 900_000_000_000_000_000 (raw WETH)
890        //   human_in   = 2000, human_out = 0.9
891        //   ideal_out  = 2000 * 0.0005 = 1.0
892        //   PI = 1 - 0.9/1.0 = 0.10
893        let hops = dummy_hops(6, 18);
894        let allocations = [PathAllocation {
895            hops,
896            flow_fraction: 1.0,
897            amount_in: BigUint::from(2_000_000_000u64),
898            amount_out: BigUint::from(900_000_000_000_000_000u64),
899            marginal_price_product: 0.0005,
900        }];
901
902        let pi = PathFrankWolfeAlgorithm::compute_average_price_impact(&allocations).unwrap();
903        assert!((pi - 0.10).abs() < 1e-10, "expected 0.10 for cross-decimal pair, got {pi}");
904    }
905
906    #[tokio::test]
907    async fn test_pi_exit_criterion_with_high_gas() {
908        // Three parallel pools, each A→B:
909        //
910        //        ┌──[P1]──┐
911        //   A ───┼──[P2]──┼─── B
912        //        └──[P3]──┘
913        //
914        // High gas costs relative to trade size mean that after the first split
915        // lowers PI, `compute_probe_amount` returns None before iteration 2 can
916        // discover the third pool → the loop exits via PI criterion at 2 swaps
917        // instead of the 3 it would produce with lower gas.
918        //
919        // Math (constant-product, reserves R=5000, trade=2000):
920        //   Initial PI (full amount, one pool): 2000/7000 ≈ 0.286
921        //   After ~50/50 split (1000 each):     1000/6000 ≈ 0.167
922        //   gas_cost = 1_000_000 × 100 / 1_000_000 = 100 output tokens
923        //   PI threshold = gas_cost / (total × max_probe) = 100 / 500 = 0.2
924        //   0.286 > 0.2 → enters loop; 0.167 < 0.2 → exits via PI criterion.
925        let token_a = token(0x01, "A");
926        let token_b = token(0x02, "B");
927
928        let cp = |gas: u64| -> Box<dyn ProtocolSim> {
929            Box::new(ConstantProductSim {
930                reserve_0: BigUint::from(5_000u64),
931                reserve_1: BigUint::from(5_000u64),
932                gas,
933            })
934        };
935
936        // High-gas run: PI exit should cap the result at 2 swaps.
937        let (market_hi, gm_hi) = setup_market_unweighted(vec![
938            ("P1", &token_a, &token_b, cp(1_000_000)),
939            ("P2", &token_a, &token_b, cp(1_000_000)),
940            ("P3", &token_a, &token_b, cp(1_000_000)),
941        ]);
942
943        let config = PathFrankWolfeConfig {
944            max_paths: 4,
945            max_probe: 0.25,
946            min_split: 0.01,
947            ..Default::default()
948        };
949        let algo = pfw_algo_with_config(2, config.clone());
950        let derived = derived_with_token_prices(&[&token_a, &token_b]);
951        let ord = order(&token_a, &token_b, 2_000, OrderSide::Sell);
952
953        let result_hi = algo
954            .find_best_route(gm_hi.graph(), market_hi, None, Some(derived.clone()), &ord)
955            .await
956            .unwrap();
957
958        assert_eq!(
959            result_hi.route().swaps().len(),
960            2,
961            "PI exit should stop the loop after the first split"
962        );
963
964        // Lower-gas control: same pools but gas_cost=50 output tokens.
965        // PI threshold = 50 / 500 = 0.1, below post-split PI (~0.167),
966        // so PI exit never fires and the algorithm discovers all three pools.
967        let (market_lo, gm_lo) = setup_market_unweighted(vec![
968            ("P1", &token_a, &token_b, cp(500_000)),
969            ("P2", &token_a, &token_b, cp(500_000)),
970            ("P3", &token_a, &token_b, cp(500_000)),
971        ]);
972
973        let algo_lo = pfw_algo_with_config(2, config);
974        let result_lo = algo_lo
975            .find_best_route(gm_lo.graph(), market_lo, None, Some(derived), &ord)
976            .await
977            .unwrap();
978
979        assert_eq!(
980            result_lo.route().swaps().len(),
981            3,
982            "without PI exit, all three pools should be used"
983        );
984    }
985
986    // ==================== find_candidate_path / is_duplicate_path ====================
987
988    fn pfw_algo(max_hops: usize) -> PathFrankWolfeAlgorithm {
989        PathFrankWolfeAlgorithm::new(
990            AlgorithmConfig::new(1, max_hops, StdDuration::from_millis(1000), None).unwrap(),
991            PathFrankWolfeConfig::default(),
992        )
993    }
994
995    #[test]
996    fn test_is_duplicate_path_exact_match() {
997        let token_a = token(0x01, "A");
998        let token_b = token(0x02, "B");
999        let candidate =
1000            vec![HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
1001                .with_amounts(BigUint::from(200u64), BigUint::from(50_000u64))];
1002        let alloc = PathAllocation {
1003            hops: vec![HopDescriptor::new("P1".to_string(), token_a, token_b)
1004                .with_amounts(BigUint::from(200u64), BigUint::from(50_000u64))],
1005            flow_fraction: 1.0,
1006            amount_in: BigUint::from(100u64),
1007            amount_out: BigUint::from(200u64),
1008            marginal_price_product: 2.0,
1009        };
1010        assert!(PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
1011    }
1012
1013    #[test]
1014    fn test_is_duplicate_path_shared_prefix() {
1015        // Existing: A──[P1]──B──[P2]──C
1016        // Candidate: A──[P1]──B──[P3]──C
1017        //
1018        // Shared first hop (P1) but divergent second hop → not a duplicate.
1019        let token_a = token(0x01, "A");
1020        let token_b = token(0x02, "B");
1021        let token_c = token(0x03, "C");
1022
1023        let zero = BigUint::from(0u64);
1024        let alloc = PathAllocation {
1025            hops: vec![
1026                HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
1027                    .with_amounts(zero.clone(), zero.clone()),
1028                HopDescriptor::new("P2".to_string(), token_b.clone(), token_c.clone())
1029                    .with_amounts(zero.clone(), zero.clone()),
1030            ],
1031            flow_fraction: 1.0,
1032            amount_in: BigUint::from(100u64),
1033            amount_out: BigUint::from(200u64),
1034            marginal_price_product: 1.0,
1035        };
1036
1037        // [P1, P3] shares first hop with [P1, P2] but diverges at hop 2
1038        let candidate = vec![
1039            HopDescriptor::new("P1".to_string(), token_a, token_b.clone())
1040                .with_amounts(zero.clone(), zero.clone()),
1041            HopDescriptor::new("P3".to_string(), token_b, token_c)
1042                .with_amounts(zero.clone(), zero.clone()),
1043        ];
1044        assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
1045    }
1046
1047    #[test]
1048    fn test_is_duplicate_path_same_pool_different_tokens() {
1049        // Existing:  A──[P1]──B
1050        // Candidate: A──[P1]──C
1051        //
1052        // Same pool but different output tokens → not a duplicate.
1053        let token_a = token(0x01, "A");
1054        let token_b = token(0x02, "B");
1055        let token_c = token(0x03, "C");
1056        let zero = BigUint::from(0u64);
1057        let alloc = PathAllocation {
1058            hops: vec![HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
1059                .with_amounts(zero.clone(), zero.clone())],
1060            flow_fraction: 1.0,
1061            amount_in: BigUint::from(100u64),
1062            amount_out: BigUint::from(200u64),
1063            marginal_price_product: 2.0,
1064        };
1065        let candidate = vec![HopDescriptor::new("P1".to_string(), token_a, token_c)
1066            .with_amounts(zero.clone(), zero.clone())];
1067        assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
1068    }
1069
1070    #[tokio::test]
1071    async fn test_shared_first_pool_two_outputs() {
1072        // Diamond topology — two paths share the entry pool:
1073        //
1074        //                ┌──[P2]──┐
1075        //   A ──[P1]── B ┤        ├ C
1076        //                └──[P3]──┘
1077        //
1078        // Path 1: A─[P1]─B─[P2]─C  (1.5× rate, degrades after first allocation)
1079        // Path 2: A─[P1]─B─[P3]─C  (1.0× rate, discovered second)
1080        //
1081        // Verifies: `is_duplicate_path` returns false, `build_split_route` emits 3 swaps,
1082        // P1 gas counted once.
1083        let token_a = token(0x01, "A");
1084        let token_b = token(0x02, "B");
1085        let token_c = token(0x03, "C");
1086
1087        let (market, graph_manager) = setup_market_unweighted(vec![
1088            (
1089                "P1",
1090                &token_a,
1091                &token_b,
1092                Box::new(ConstantProductSim {
1093                    reserve_0: BigUint::from(10_000u64),
1094                    reserve_1: BigUint::from(10_000u64),
1095                    gas: 50_000,
1096                }) as Box<dyn ProtocolSim>,
1097            ),
1098            (
1099                "P2",
1100                &token_b,
1101                &token_c,
1102                Box::new(ConstantProductSim {
1103                    reserve_0: BigUint::from(1_000u64),
1104                    reserve_1: BigUint::from(1_500u64),
1105                    gas: 50_000,
1106                }) as Box<dyn ProtocolSim>,
1107            ),
1108            (
1109                "P3",
1110                &token_b,
1111                &token_c,
1112                Box::new(ConstantProductSim {
1113                    reserve_0: BigUint::from(1_000u64),
1114                    reserve_1: BigUint::from(1_000u64),
1115                    gas: 50_000,
1116                }) as Box<dyn ProtocolSim>,
1117            ),
1118        ]);
1119
1120        let algo = pfw_algo(3);
1121        let probe_amount = BigUint::from(1_000u64);
1122        let ord = order(&token_a, &token_c, 1_000, OrderSide::Sell);
1123
1124        let ctx = algo
1125            .inner
1126            .build_context(graph_manager.graph(), market, None, None, &ord)
1127            .await
1128            .unwrap();
1129
1130        // First candidate: P2 has 1.5x rate vs P3's 1.0x → finds [P1, P2].
1131        let first_path = algo
1132            .find_candidate_path(&ctx, &[], &probe_amount)
1133            .unwrap();
1134        assert_eq!(first_path[0].descriptor.component_id, "P1");
1135        assert_eq!(first_path[1].descriptor.component_id, "P2");
1136
1137        let first_amount_out = first_path[1].amount_out.clone();
1138        let first_alloc = PathAllocation {
1139            hops: first_path,
1140            flow_fraction: 0.5,
1141            amount_in: probe_amount.clone(),
1142            amount_out: first_amount_out,
1143            marginal_price_product: 1.5,
1144        };
1145
1146        // After allocating 1000 A on [P1, P2], P2 degrades enough that BF finds [P1, P3].
1147        let second_path = algo
1148            .find_candidate_path(&ctx, std::slice::from_ref(&first_alloc), &probe_amount)
1149            .unwrap();
1150        assert_eq!(second_path[0].descriptor.component_id, "P1");
1151        assert_eq!(second_path[1].descriptor.component_id, "P3");
1152
1153        // Shared prefix [P1] does not make these duplicates.
1154        assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(
1155            &second_path,
1156            std::slice::from_ref(&first_alloc)
1157        ));
1158
1159        let second_amount_out = second_path[1].amount_out.clone();
1160        let second_alloc = PathAllocation {
1161            hops: second_path,
1162            flow_fraction: 0.5,
1163            amount_in: probe_amount.clone(),
1164            amount_out: second_amount_out,
1165            marginal_price_product: 1.0,
1166        };
1167
1168        // build_split_route must emit 3 swaps: one combined A→B (P1), two B→C (P2, P3).
1169        let all_allocs = [first_alloc, second_alloc];
1170        let route = build_split_route(&all_allocs, &ctx.market_data, &ord).unwrap();
1171        let swaps = route.swaps();
1172        assert_eq!(swaps.len(), 3, "expected P1 + P2 + P3 = 3 swaps");
1173        let ids: Vec<&str> = swaps
1174            .iter()
1175            .map(|s| s.component_id())
1176            .collect();
1177        assert_eq!(
1178            ids.iter()
1179                .filter(|&&id| id == "P1")
1180                .count(),
1181            1,
1182            "P1 deduplicated"
1183        );
1184        assert!(ids.contains(&"P2"));
1185        assert!(ids.contains(&"P3"));
1186        // P1 gas counted once: P1(50k) + P2(50k) + P3(50k) = 150k.
1187        assert_eq!(route.total_gas(), BigUint::from(150_000u64));
1188    }
1189
1190    #[tokio::test]
1191    async fn test_duplicate_path_stops_iteration() {
1192        // When BF repeatedly returns the same path, `is_duplicate_path` detects it so the
1193        // Frank-Wolfe loop can stop.
1194        let token_a = token(0x01, "A");
1195        let token_b = token(0x02, "B");
1196
1197        let (market, graph_manager) = setup_market_unweighted(vec![(
1198            "P1",
1199            &token_a,
1200            &token_b,
1201            Box::new(MockProtocolSim::new(2.0)) as Box<dyn ProtocolSim>,
1202        )]);
1203
1204        let algo = pfw_algo(2);
1205        let probe_amount = BigUint::from(100u64);
1206        let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
1207
1208        let ctx = algo
1209            .inner
1210            .build_context(graph_manager.graph(), market, None, None, &ord)
1211            .await
1212            .unwrap();
1213
1214        let first_path = algo
1215            .find_candidate_path(&ctx, &[], &probe_amount)
1216            .unwrap();
1217        assert_eq!(first_path[0].descriptor.component_id, "P1");
1218
1219        let first_alloc = PathAllocation {
1220            hops: first_path,
1221            flow_fraction: 1.0,
1222            amount_in: probe_amount.clone(),
1223            amount_out: BigUint::from(200u64),
1224            marginal_price_product: 2.0,
1225        };
1226
1227        // P1 is the only pool — BF returns it again.
1228        let second_path = algo
1229            .find_candidate_path(&ctx, std::slice::from_ref(&first_alloc), &probe_amount)
1230            .unwrap();
1231        assert!(PathFrankWolfeAlgorithm::is_duplicate_path(
1232            &second_path,
1233            std::slice::from_ref(&first_alloc)
1234        ));
1235    }
1236
1237    #[test]
1238    fn test_with_zero_gas_zeroes_gas_keeps_amounts() {
1239        let token_a = token(0x01, "A");
1240        let token_b = token(0x02, "B");
1241        let sim = MockProtocolSim::new(2.0).with_gas(50_000);
1242
1243        let overrides = MarketOverrides::empty()
1244            .with_override("P1".to_string(), Box::new(sim.clone()))
1245            .with_zero_gas("P1".to_string(), token_a.address.clone(), token_b.address.clone());
1246
1247        let result = overrides
1248            .get(&"P1".to_string())
1249            .unwrap()
1250            .get_amount_out(BigUint::from(100u64), &token_a, &token_b)
1251            .unwrap();
1252
1253        assert_eq!(result.amount, BigUint::from(200u64), "amount unaffected");
1254        assert_eq!(result.gas, BigUint::ZERO, "gas zeroed by with_zero_gas");
1255    }
1256
1257    // ==================== find_best_route main loop ====================
1258
1259    fn pfw_algo_with_config(
1260        max_hops: usize,
1261        pfw_config: PathFrankWolfeConfig,
1262    ) -> PathFrankWolfeAlgorithm {
1263        PathFrankWolfeAlgorithm::new(
1264            AlgorithmConfig::new(1, max_hops, StdDuration::from_millis(5000), None).unwrap(),
1265            pfw_config,
1266        )
1267    }
1268
1269    #[tokio::test]
1270    async fn test_single_path_no_split() {
1271        // Only one pool exists → the loop terminates via duplicate detection and
1272        // returns the single-path result unchanged.
1273        let token_a = token(0x01, "A");
1274        let token_b = token(0x02, "B");
1275
1276        let (market, graph_manager) = setup_market_unweighted(vec![(
1277            "P1",
1278            &token_a,
1279            &token_b,
1280            Box::new(ConstantProductSim {
1281                reserve_0: BigUint::from(10_000u64),
1282                reserve_1: BigUint::from(10_000u64),
1283                gas: 50_000,
1284            }) as Box<dyn ProtocolSim>,
1285        )]);
1286
1287        let algo = pfw_algo(2);
1288        let derived = derived_with_token_prices(&[&token_a, &token_b]);
1289        let ord = order(&token_a, &token_b, 1_000, OrderSide::Sell);
1290
1291        let result = algo
1292            .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1293            .await
1294            .unwrap();
1295
1296        let swaps = result.route().swaps();
1297        assert_eq!(swaps.len(), 1, "single path, single swap");
1298        assert_eq!(swaps[0].component_id(), "P1");
1299    }
1300
1301    #[tokio::test]
1302    async fn test_two_parallel_pools_symmetric() {
1303        //        ┌──[P1]──┐
1304        //   A ───┤        ├─── B
1305        //        └──[P2]──┘
1306        //
1307        // Two identical pools → should split ~50/50.
1308        let token_a = token(0x01, "A");
1309        let token_b = token(0x02, "B");
1310
1311        let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1312            Box::new(ConstantProductSim {
1313                reserve_0: BigUint::from(reserve),
1314                reserve_1: BigUint::from(reserve),
1315                gas: 50_000,
1316            })
1317        };
1318
1319        let (market, graph_manager) = setup_market_unweighted(vec![
1320            ("P1", &token_a, &token_b, cp(100_000)),
1321            ("P2", &token_a, &token_b, cp(100_000)),
1322        ]);
1323
1324        let algo = pfw_algo_with_config(
1325            2,
1326            PathFrankWolfeConfig {
1327                max_paths: 4,
1328                max_probe: 0.25,
1329                min_split: 0.01,
1330                ..Default::default()
1331            },
1332        );
1333        let derived = derived_with_token_prices(&[&token_a, &token_b]);
1334        let ord = order(&token_a, &token_b, 10_000, OrderSide::Sell);
1335
1336        let result = algo
1337            .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1338            .await
1339            .unwrap();
1340
1341        let swaps = result.route().swaps();
1342        assert_eq!(swaps.len(), 2, "should use both pools");
1343        let ids: Vec<&str> = swaps
1344            .iter()
1345            .map(|s| s.component_id())
1346            .collect();
1347        assert!(ids.contains(&"P1"));
1348        assert!(ids.contains(&"P2"));
1349
1350        // Both pools are identical → amounts should be roughly equal (within 10%).
1351        let amounts: Vec<f64> = swaps
1352            .iter()
1353            .map(|s| s.amount_in().to_f64().unwrap())
1354            .collect();
1355        let ratio = amounts[0] / amounts[1];
1356        assert!(
1357            (0.8..=1.2).contains(&ratio),
1358            "expected roughly equal split, got ratio {ratio} (amounts: {amounts:?})"
1359        );
1360    }
1361
1362    #[tokio::test]
1363    async fn test_two_parallel_pools_asymmetric() {
1364        //        ┌──[deep: 200k]───┐
1365        //   A ───┤                  ├─── B
1366        //        └──[shallow: 50k]──┘
1367        //
1368        // Large trade should favor the deep pool but still use the shallow one.
1369        let token_a = token(0x01, "A");
1370        let token_b = token(0x02, "B");
1371
1372        let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1373            Box::new(ConstantProductSim {
1374                reserve_0: BigUint::from(reserve),
1375                reserve_1: BigUint::from(reserve),
1376                gas: 50_000,
1377            })
1378        };
1379
1380        let (market, graph_manager) = setup_market_unweighted(vec![
1381            ("deep", &token_a, &token_b, cp(200_000)),
1382            ("shallow", &token_a, &token_b, cp(50_000)),
1383        ]);
1384
1385        let algo = pfw_algo_with_config(
1386            2,
1387            PathFrankWolfeConfig {
1388                max_paths: 4,
1389                max_probe: 0.5,
1390                min_split: 0.01,
1391                ..Default::default()
1392            },
1393        );
1394        let derived = derived_with_token_prices(&[&token_a, &token_b]);
1395        let ord = order(&token_a, &token_b, 30_000, OrderSide::Sell);
1396
1397        let result = algo
1398            .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1399            .await
1400            .unwrap();
1401
1402        let swaps = result.route().swaps();
1403        assert_eq!(swaps.len(), 2, "should use both pools");
1404
1405        let deep_swap = swaps
1406            .iter()
1407            .find(|s| s.component_id() == "deep")
1408            .unwrap();
1409        let shallow_swap = swaps
1410            .iter()
1411            .find(|s| s.component_id() == "shallow")
1412            .unwrap();
1413
1414        assert!(
1415            deep_swap.amount_in() > shallow_swap.amount_in(),
1416            "deep pool should get more flow: deep={}, shallow={}",
1417            deep_swap.amount_in(),
1418            shallow_swap.amount_in()
1419        );
1420    }
1421
1422    #[tokio::test]
1423    async fn test_split_vs_single_route() {
1424        //        ┌──[P1: 100k]──┐
1425        //   A ───┤              ├─── B
1426        //        └──[P2: 100k]──┘
1427        //
1428        // Large trade (50k) through two parallel pools should produce more
1429        // output than routing everything through just one.
1430        let token_a = token(0x01, "A");
1431        let token_b = token(0x02, "B");
1432
1433        let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1434            Box::new(ConstantProductSim {
1435                reserve_0: BigUint::from(reserve),
1436                reserve_1: BigUint::from(reserve),
1437                gas: 50_000,
1438            })
1439        };
1440
1441        let (market, graph_manager) = setup_market_unweighted(vec![
1442            ("P1", &token_a, &token_b, cp(100_000)),
1443            ("P2", &token_a, &token_b, cp(100_000)),
1444        ]);
1445
1446        let algo = pfw_algo_with_config(
1447            2,
1448            PathFrankWolfeConfig {
1449                max_paths: 4,
1450                max_probe: 0.25,
1451                min_split: 0.01,
1452                ..Default::default()
1453            },
1454        );
1455        // Large trade: 50% of each pool's reserves → significant price impact.
1456        let derived = derived_with_token_prices(&[&token_a, &token_b]);
1457        let ord = order(&token_a, &token_b, 50_000, OrderSide::Sell);
1458
1459        let split_result = algo
1460            .find_best_route(
1461                graph_manager.graph(),
1462                market.clone(),
1463                None,
1464                Some(derived.clone()),
1465                &ord,
1466            )
1467            .await
1468            .unwrap();
1469
1470        // Single-path: route everything through one pool.
1471        let single_algo =
1472            pfw_algo_with_config(2, PathFrankWolfeConfig { max_paths: 1, ..Default::default() });
1473        let single_result = single_algo
1474            .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1475            .await
1476            .unwrap();
1477
1478        assert!(
1479            split_result.net_amount_out() > single_result.net_amount_out(),
1480            "split output ({}) should beat single ({})",
1481            split_result.net_amount_out(),
1482            single_result.net_amount_out()
1483        );
1484    }
1485
1486    #[tokio::test]
1487    async fn test_three_paths_discovered() {
1488        //        ┌──[P1: 100k]──┐
1489        //   A ───┼──[P2:  80k]──┼─── B
1490        //        └──[P3:  60k]──┘
1491        //
1492        // Three parallel routes — with enough max_paths the algorithm
1493        // should discover all three.
1494        let token_a = token(0x01, "A");
1495        let token_b = token(0x02, "B");
1496
1497        let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1498            Box::new(ConstantProductSim {
1499                reserve_0: BigUint::from(reserve),
1500                reserve_1: BigUint::from(reserve),
1501                gas: 50_000,
1502            })
1503        };
1504
1505        let (market, graph_manager) = setup_market_unweighted(vec![
1506            ("P1", &token_a, &token_b, cp(100_000)),
1507            ("P2", &token_a, &token_b, cp(80_000)),
1508            ("P3", &token_a, &token_b, cp(60_000)),
1509        ]);
1510
1511        let algo = pfw_algo_with_config(
1512            2,
1513            PathFrankWolfeConfig {
1514                max_paths: 5,
1515                max_probe: 0.5,
1516                min_split: 0.01,
1517                line_search_evals: 16,
1518            },
1519        );
1520        let derived = derived_with_token_prices(&[&token_a, &token_b]);
1521        let ord = order(&token_a, &token_b, 30_000, OrderSide::Sell);
1522
1523        let result = algo
1524            .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1525            .await
1526            .unwrap();
1527
1528        let swaps = result.route().swaps();
1529        let ids: Vec<&str> = swaps
1530            .iter()
1531            .map(|s| s.component_id())
1532            .collect();
1533        assert_eq!(ids.len(), 3, "expected 3 paths, got {ids:?}");
1534        assert!(ids.contains(&"P1"), "missing P1");
1535        assert!(ids.contains(&"P2"), "missing P2");
1536        assert!(ids.contains(&"P3"), "missing P3");
1537    }
1538
1539    #[tokio::test]
1540    async fn test_shared_pool_degradation() {
1541        //        ┌──[P1]──┐
1542        //   A ───┤        ├─── B ──[P_shared]── C
1543        //        └──[P2]──┘
1544        //
1545        // Path 1: A─[P1]─B─[P_shared]─C
1546        // Path 2: A─[P2]─B─[P_shared]─C
1547        //
1548        // Both routes share the interior pool P_shared. Sequential simulation
1549        // through P_shared must degrade state correctly.
1550        let token_a = token(0x01, "A");
1551        let token_b = token(0x02, "B");
1552        let token_c = token(0x03, "C");
1553
1554        let (market, graph_manager) = setup_market_unweighted(vec![
1555            (
1556                "P1",
1557                &token_a,
1558                &token_b,
1559                Box::new(ConstantProductSim {
1560                    reserve_0: BigUint::from(100_000u64),
1561                    reserve_1: BigUint::from(100_000u64),
1562                    gas: 50_000,
1563                }) as Box<dyn ProtocolSim>,
1564            ),
1565            (
1566                "P2",
1567                &token_a,
1568                &token_b,
1569                Box::new(ConstantProductSim {
1570                    reserve_0: BigUint::from(100_000u64),
1571                    reserve_1: BigUint::from(100_000u64),
1572                    gas: 50_000,
1573                }) as Box<dyn ProtocolSim>,
1574            ),
1575            (
1576                "P_shared",
1577                &token_b,
1578                &token_c,
1579                Box::new(ConstantProductSim {
1580                    reserve_0: BigUint::from(200_000u64),
1581                    reserve_1: BigUint::from(200_000u64),
1582                    gas: 50_000,
1583                }) as Box<dyn ProtocolSim>,
1584            ),
1585        ]);
1586
1587        let algo = pfw_algo_with_config(
1588            3,
1589            PathFrankWolfeConfig {
1590                max_paths: 4,
1591                max_probe: 0.5,
1592                min_split: 0.01,
1593                ..Default::default()
1594            },
1595        );
1596        let derived = derived_with_token_prices(&[&token_a, &token_b, &token_c]);
1597        let ord = order(&token_a, &token_c, 20_000, OrderSide::Sell);
1598
1599        let result = algo
1600            .find_best_route(
1601                graph_manager.graph(),
1602                market.clone(),
1603                None,
1604                Some(derived.clone()),
1605                &ord,
1606            )
1607            .await
1608            .unwrap();
1609
1610        let swaps = result.route().swaps();
1611        let ids: Vec<&str> = swaps
1612            .iter()
1613            .map(|s| s.component_id())
1614            .collect();
1615
1616        // Should use both entry pools plus the shared pool.
1617        assert!(ids.contains(&"P1") && ids.contains(&"P2"), "should use both entry pools");
1618        assert!(ids.contains(&"P_shared"), "must use shared B→C pool");
1619
1620        // Output should be better than single-path (which goes through one entry
1621        // pool and hits P_shared with the full amount).
1622        let single_algo =
1623            pfw_algo_with_config(3, PathFrankWolfeConfig { max_paths: 1, ..Default::default() });
1624        let single_result = single_algo
1625            .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1626            .await
1627            .unwrap();
1628
1629        assert!(
1630            result.net_amount_out() > single_result.net_amount_out(),
1631            "split ({}) should be > single ({})",
1632            result.net_amount_out(),
1633            single_result.net_amount_out()
1634        );
1635    }
1636
1637    #[tokio::test]
1638    async fn test_timeout_mid_iteration() {
1639        //        ┌──[P0]──┐
1640        //        ├──[P1]──┤
1641        //   A ───┼──[P2]──┼─── B     (8 identical parallel pools)
1642        //        ├── ⋯  ──┤
1643        //        └──[P7]──┘
1644        //
1645        // With a generous timeout the algo splits across all pools.
1646        // With a near-zero timeout it returns fewer paths, proving the FW loop
1647        // was cut short while still producing a valid result.
1648        let token_a = token(0x01, "A");
1649        let token_b = token(0x02, "B");
1650
1651        let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1652            Box::new(ConstantProductSim {
1653                reserve_0: BigUint::from(reserve),
1654                reserve_1: BigUint::from(reserve),
1655                gas: 50_000,
1656            })
1657        };
1658
1659        let pool_names: [&str; 8] = ["P0", "P1", "P2", "P3", "P4", "P5", "P6", "P7"];
1660
1661        let pfw_config =
1662            PathFrankWolfeConfig { max_paths: 8, min_split: 0.001, ..Default::default() };
1663        let ord = order(&token_a, &token_b, 80_000, OrderSide::Sell);
1664
1665        // Generous timeout — should split across many pools.
1666        let pools: Vec<_> = pool_names
1667            .iter()
1668            .map(|id| (*id, &token_a, &token_b, cp(100_000)))
1669            .collect();
1670        let (market, graph_manager) = setup_market_unweighted(pools);
1671        let generous_algo = pfw_algo_with_config(2, pfw_config.clone());
1672        let derived = derived_with_token_prices(&[&token_a, &token_b]);
1673        let generous_result = generous_algo
1674            .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1675            .await
1676            .unwrap();
1677        let generous_swaps = generous_result.route().swaps().len();
1678
1679        // Near-zero timeout — should produce a valid result with fewer paths.
1680        let pools: Vec<_> = pool_names
1681            .iter()
1682            .map(|id| (*id, &token_a, &token_b, cp(100_000)))
1683            .collect();
1684        let (market, graph_manager) = setup_market_unweighted(pools);
1685        let timeout_algo = PathFrankWolfeAlgorithm::new(
1686            AlgorithmConfig::new(1, 2, StdDuration::from_millis(1), None).unwrap(),
1687            pfw_config,
1688        );
1689        let derived = derived_with_token_prices(&[&token_a, &token_b]);
1690        let timeout_result = timeout_algo
1691            .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1692            .await
1693            .unwrap();
1694        let timeout_swaps = timeout_result.route().swaps().len();
1695
1696        assert!(
1697            !timeout_result
1698                .route()
1699                .swaps()
1700                .is_empty(),
1701            "timed-out result must still contain at least one swap"
1702        );
1703        assert!(
1704            timeout_swaps < generous_swaps,
1705            "timed-out result ({timeout_swaps} swaps) should use fewer paths \
1706             than generous result ({generous_swaps} swaps)"
1707        );
1708    }
1709}