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