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