1use 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#[derive(Debug, Clone)]
40pub struct PathFrankWolfeConfig {
41 pub max_paths: usize,
43 pub max_probe: f64,
45 pub min_split: f64,
47 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
57pub struct PathFrankWolfeAlgorithm {
60 inner: BellmanFordAlgorithm,
61 config: PathFrankWolfeConfig,
62}
63
64impl PathFrankWolfeAlgorithm {
65 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 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 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 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 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 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 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 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 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 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 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 if evaluate_split(step_size) <= evaluate_split(0.0) {
432 return 0.0;
433 }
434 step_size
435 }
436
437 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 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 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 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 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 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 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 let step_size = self.optimize_step_size(&allocations, &candidate, total_amount, ctx);
578
579 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 if allocations.len() <= 1 {
591 return Ok(None);
592 }
593
594 let split_route = build_split_route(&allocations, &ctx.market_data, order)?;
595
596 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 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 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 sim_meter::start_solve();
675 let ctx = self
676 .inner
677 .build_context(request)
678 .await?;
679
680 let single_path_result =
682 self.inner
683 .find_single_route(&ctx, order, FindRouteOptions::default())?;
684
685 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 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 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 fn derived_with_token_prices(tokens: &[&Token]) -> SharedDerivedDataRef {
773 let mut prices = TokenGasPrices::default();
774 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 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 #[test]
812 fn test_probe_amount_low_impact() {
813 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 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 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 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 #[test]
878 fn test_probe_impact_redistribution() {
879 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(route.total_gas(), BigUint::from(150_000u64));
1356 }
1357
1358 #[tokio::test]
1359 async fn test_duplicate_path_stops_iteration() {
1360 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}