Skip to main content

fynd_core/algorithm/
split_primitives.rs

1use std::collections::VecDeque;
2
3use num_bigint::BigUint;
4use num_traits::{CheckedSub, ToPrimitive, Zero};
5use rustc_hash::{FxHashMap, FxHashSet};
6use tycho_simulation::tycho_common::{
7    dto::ProtocolStateDelta,
8    models::token::Token,
9    simulation::{
10        errors::{SimulationError, TransitionError},
11        protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
12    },
13    Bytes,
14};
15
16use super::{sim_meter, sim_meter::MeteredProtocolSim};
17use crate::{
18    algorithm::AlgorithmError,
19    feed::market_data::MarketState,
20    types::{ComponentId, Order, Route, Swap},
21};
22
23/// The stage every swap [`simulate_path`] makes is booked under.
24const SIMULATE_PATH_STAGE: sim_meter::StageLabel = "simulate_path";
25
26/// The stage every swap [`execute_split_plan`] makes is booked under.
27const SPLIT_PLAN_STAGE: sim_meter::StageLabel = "execute_split_plan";
28
29#[derive(Clone)]
30/// One leg of a path: the pool, and the direction it trades.
31pub struct HopDescriptor {
32    /// The pool this hop goes through.
33    pub component_id: ComponentId,
34    /// Token going in.
35    pub token_in: Token,
36    /// Token coming out.
37    pub token_out: Token,
38}
39
40impl HopDescriptor {
41    /// A hop through `component_id`, trading `token_in` for `token_out`.
42    pub fn new(component_id: ComponentId, token_in: Token, token_out: Token) -> Self {
43        Self { component_id, token_in, token_out }
44    }
45
46    #[cfg(test)]
47    /// This hop paired with what it paid, for a test that builds an allocation by hand.
48    pub fn with_amounts(self, amount_out: BigUint, gas: BigUint) -> SimulatedHop {
49        SimulatedHop { descriptor: self, amount_out, gas }
50    }
51}
52
53/// A [`HopDescriptor`] paired with its simulation result. Used in
54/// [`PathAllocation::hops`] where the solving algorithm has already
55/// computed per-hop outputs and gas.
56#[derive(Clone)]
57pub struct SimulatedHop {
58    /// The hop this simulated.
59    pub descriptor: HopDescriptor,
60    /// What the pool paid out.
61    pub amount_out: BigUint,
62    /// What the swap costs in gas units.
63    pub gas: BigUint,
64}
65
66/// A fully-simulated path allocation.
67///
68/// One path in the current split solution, with the fraction of total `amount_in`
69/// currently allocated to it. All fractions across allocations sum to 1.0.
70#[derive(Clone)]
71pub struct PathAllocation {
72    /// The path's hops, in order.
73    pub hops: Vec<SimulatedHop>,
74    /// Fraction of total input on this path (0 < f <= 1).
75    pub flow_fraction: f64,
76    /// What this path carries into its first hop.
77    pub amount_in: BigUint,
78    /// What its last hop paid out.
79    pub amount_out: BigUint,
80    /// Product of marginal prices along all hops at the time this allocation was
81    /// last simulated.
82    pub marginal_price_product: f64,
83}
84
85impl PathAllocation {
86    /// Validates that this path does not revisit any token.
87    ///
88    /// A token appearing more than once means `merge_shared_hops` would
89    /// incorrectly collapse distinct hops into one. The only exception is
90    /// a round-trip where the final output equals the first input.
91    pub fn validate_token_cycles(&self) -> Result<(), AlgorithmError> {
92        if self.hops.is_empty() {
93            return Err(AlgorithmError::Other("path has no hops".to_string()));
94        }
95        let first_token = &self.hops[0].descriptor.token_in.address;
96        let mut seen = FxHashSet::default();
97        seen.insert(first_token.clone());
98        let last_idx = self.hops.len() - 1;
99        for (i, hop) in self.hops.iter().enumerate() {
100            let out_addr = &hop.descriptor.token_out.address;
101            if !seen.insert(out_addr.clone()) {
102                let is_valid_round_trip = i == last_idx && out_addr == first_token;
103                if !is_valid_round_trip {
104                    return Err(AlgorithmError::Other(format!(
105                        "path revisits token {out_addr} at hop {i} \
106                         (would corrupt merge_shared_hops)",
107                    )));
108                }
109            }
110        }
111        Ok(())
112    }
113}
114
115/// Output of simulating one path at a given input amount.
116pub struct SimResult {
117    /// What the last hop paid out.
118    pub amount_out: BigUint,
119    /// Spot prices multiplied along the path, at the state each hop executed against.
120    pub marginal_price_product: f64,
121    /// Per-hop `(amount_out, gas)` in path order.
122    pub hop_results: Vec<(BigUint, BigUint)>,
123    /// Per-hop post-swap component states in path order. Apply these as overrides
124    /// before simulating another path so shared components see depleted reserves.
125    pub post_swap_states: Vec<(ComponentId, Box<dyn ProtocolSim>)>,
126}
127
128/// Component state overrides for passing degraded states to `find_single_route`.
129#[derive(Default)]
130pub struct MarketOverrides(FxHashMap<ComponentId, Box<dyn ProtocolSim>>);
131
132impl MarketOverrides {
133    /// No overrides: every component is read from the market.
134    #[must_use]
135    pub fn empty() -> Self {
136        Self::default()
137    }
138
139    /// Insert a degraded component state as an override.
140    pub fn with_override(mut self, id: ComponentId, sim: Box<dyn ProtocolSim>) -> Self {
141        self.0.insert(id, sim);
142        self
143    }
144
145    /// Wraps an existing override entry so that `get_amount_out().gas` is zero for
146    /// the specified `(token_in, token_out)` pair, but unchanged for other pairs
147    /// through the same component.
148    ///
149    /// Different token pairs through the same component are separate on-chain swaps with
150    /// independent gas costs, so only committed pairs should be zeroed. Call this
151    /// once per committed `(component_id, token_in, token_out)` triple.
152    ///
153    /// Multiple calls for the same component accumulate pairs. If the ID has no
154    /// override entry, this is a no-op.
155    pub fn with_zero_gas(mut self, id: ComponentId, token_in: Bytes, token_out: Bytes) -> Self {
156        if let Some(sim) = self.0.remove(&id) {
157            // If already wrapped, add the new pair to the existing set.
158            let wrapped = if let Some(selective) = sim
159                .as_any()
160                .downcast_ref::<SelectiveZeroGasSim>()
161            {
162                let mut pairs = selective.zero_gas_pairs.clone();
163                pairs.insert((token_in, token_out));
164                Box::new(SelectiveZeroGasSim {
165                    inner: selective.inner.clone_box(),
166                    zero_gas_pairs: pairs,
167                }) as Box<dyn ProtocolSim>
168            } else {
169                let mut pairs = FxHashSet::default();
170                pairs.insert((token_in, token_out));
171                Box::new(SelectiveZeroGasSim { inner: sim, zero_gas_pairs: pairs })
172            };
173            self.0.insert(id, wrapped);
174        }
175        self
176    }
177
178    /// The overridden state for `id`, if one was set.
179    #[must_use]
180    pub fn get(&self, id: &ComponentId) -> Option<&dyn ProtocolSim> {
181        self.0.get(id).map(|b| b.as_ref())
182    }
183
184    /// Commits a post-swap component state, replacing whatever was there.
185    ///
186    /// The building counterpart to [`MarketOverrides::with_override`], for the passes that fill an
187    /// overlay chunk by chunk rather than declaring one up front.
188    pub fn insert(&mut self, id: ComponentId, sim: Box<dyn ProtocolSim>) {
189        self.0.insert(id, sim);
190    }
191}
192
193/// Wrapper that delegates all [`ProtocolSim`] calls unchanged except
194/// [`get_amount_out`](ProtocolSim::get_amount_out), where it zeroes the returned gas
195/// only for token pairs in `zero_gas_pairs`. Other pairs pass through unchanged.
196#[derive(Debug, serde::Serialize, serde::Deserialize)]
197struct SelectiveZeroGasSim {
198    inner: Box<dyn ProtocolSim>,
199    zero_gas_pairs: FxHashSet<(Bytes, Bytes)>,
200}
201
202#[typetag::serde]
203impl ProtocolSim for SelectiveZeroGasSim {
204    fn fee(&self) -> f64 {
205        self.inner.fee()
206    }
207
208    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
209        self.inner.spot_price(base, quote)
210    }
211
212    fn get_amount_out(
213        &self,
214        amount_in: BigUint,
215        token_in: &Token,
216        token_out: &Token,
217    ) -> Result<GetAmountOutResult, SimulationError> {
218        let mut result = self
219            .inner
220            .get_amount_out(amount_in, token_in, token_out)?;
221        if self
222            .zero_gas_pairs
223            .contains(&(token_in.address.clone(), token_out.address.clone()))
224        {
225            result.gas = BigUint::ZERO;
226        }
227        result.new_state = Box::new(SelectiveZeroGasSim {
228            inner: result.new_state,
229            zero_gas_pairs: self.zero_gas_pairs.clone(),
230        });
231        Ok(result)
232    }
233
234    fn get_limits(
235        &self,
236        sell_token: Bytes,
237        buy_token: Bytes,
238    ) -> Result<(BigUint, BigUint), SimulationError> {
239        self.inner
240            .get_limits(sell_token, buy_token)
241    }
242
243    fn delta_transition(
244        &mut self,
245        delta: ProtocolStateDelta,
246        tokens: &std::collections::HashMap<Bytes, Token>,
247        balances: &Balances,
248    ) -> Result<(), TransitionError> {
249        self.inner
250            .delta_transition(delta, tokens, balances)
251    }
252
253    fn clone_box(&self) -> Box<dyn ProtocolSim> {
254        Box::new(SelectiveZeroGasSim {
255            inner: self.inner.clone_box(),
256            zero_gas_pairs: self.zero_gas_pairs.clone(),
257        })
258    }
259
260    fn as_any(&self) -> &dyn std::any::Any {
261        self
262    }
263
264    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
265        self
266    }
267
268    fn eq(&self, other: &dyn ProtocolSim) -> bool {
269        other
270            .as_any()
271            .downcast_ref::<Self>()
272            .map(|o| self.inner.eq(&*o.inner) && self.zero_gas_pairs == o.zero_gas_pairs)
273            .unwrap_or(false)
274    }
275}
276
277/// Find the `x` in `[lo, hi]` that maximises `f(x)` using golden-section search.
278///
279/// Assumes `f` is roughly unimodal (has one maximum). `max_evals` controls the
280/// number of function evaluations (higher = more precise but slower).
281pub fn golden_section_search(
282    mut f: impl FnMut(f64) -> f64,
283    mut lo: f64,
284    mut hi: f64,
285    max_evals: usize,
286) -> f64 {
287    let inv_phi = (5_f64.sqrt() - 1.0) / 2.0;
288
289    let mut x1 = hi - inv_phi * (hi - lo);
290    let mut x2 = lo + inv_phi * (hi - lo);
291    let mut f1 = f(x1);
292    let mut f2 = f(x2);
293    // Two evaluations consumed so far.
294    let remaining = max_evals.saturating_sub(2);
295
296    for _ in 0..remaining {
297        if f1 < f2 {
298            lo = x1;
299            x1 = x2;
300            f1 = f2;
301            x2 = lo + inv_phi * (hi - lo);
302            f2 = f(x2);
303        } else {
304            hi = x2;
305            x2 = x1;
306            f2 = f1;
307            x1 = hi - inv_phi * (hi - lo);
308            f1 = f(x1);
309        }
310    }
311
312    if f1 >= f2 {
313        x1
314    } else {
315        x2
316    }
317}
318
319/// Split `total` into `(part, remainder)` where `part ≈ total * fraction`.
320///
321/// Both values always sum exactly to `total` — no tokens lost to rounding.
322/// `fraction` is clamped to `[0.0, 1.0]` before use.
323pub fn split_amount(total: &BigUint, fraction: f64) -> (BigUint, BigUint) {
324    let clamped = fraction.clamp(0.0, 1.0);
325    // Scale fraction to fixed-point with 18 decimal digits of precision.
326    let scale: u64 = 1_000_000_000_000_000_000;
327    let numerator = (clamped * scale as f64) as u64;
328    let part = (total * BigUint::from(numerator)) / BigUint::from(scale);
329    let remainder = total - &part;
330    (part, remainder)
331}
332
333/// Errors from split-routing math utilities.
334#[derive(Debug, Clone, PartialEq, thiserror::Error)]
335pub enum SplitMathError {
336    /// No fractions were given.
337    #[error("fractions slice must not be empty")]
338    EmptyFractions,
339    /// Every fraction was zero, so there is nothing to divide by.
340    #[error("all fractions are zero, cannot normalize")]
341    AllZeroFractions,
342    /// A fraction was negative.
343    #[error("fractions must not be negative")]
344    NegativeFraction,
345    /// The fractions ask for more than there is to divide.
346    #[error("the fractions ask for {asked} of {total}")]
347    ExceedsTotal {
348        /// What the fractions add up to.
349        asked: BigUint,
350        /// What there was to divide.
351        total: BigUint,
352    },
353}
354
355/// Normalize a slice of fractions so they sum to 1.0.
356///
357/// # Errors
358///
359/// Returns [`SplitMathError::EmptyFractions`] if the slice is empty, or
360/// [`SplitMathError::AllZeroFractions`] if every element is zero.
361pub fn normalize_fractions(fractions: &mut [f64]) -> Result<(), SplitMathError> {
362    if fractions.is_empty() {
363        return Err(SplitMathError::EmptyFractions);
364    }
365    if fractions.iter().any(|&f| f < 0.0) {
366        return Err(SplitMathError::NegativeFraction);
367    }
368    let sum: f64 = fractions.iter().sum();
369    if sum == 0.0 {
370        return Err(SplitMathError::AllZeroFractions);
371    }
372    for f in fractions.iter_mut() {
373        *f /= sum;
374    }
375    Ok(())
376}
377
378/// Convert fractions (summing to 1.0) into `BigUint` amounts summing exactly
379/// to `total`.
380///
381/// The last element absorbs any rounding remainder so the sum is exact.
382///
383/// # Errors
384///
385/// Returns [`SplitMathError::EmptyFractions`] if `fractions` is empty, or
386/// [`SplitMathError::ExceedsTotal`] if the fractions before the last one already ask for more than
387/// `total`. Callers that cannot guarantee fractions summing to 1.0 — an out-of-crate algorithm
388/// sizing its own paths — get that error instead of a panic.
389pub fn fractions_to_amounts(
390    total: &BigUint,
391    fractions: &[f64],
392) -> Result<Vec<BigUint>, SplitMathError> {
393    if fractions.is_empty() {
394        return Err(SplitMathError::EmptyFractions);
395    }
396    let n = fractions.len();
397    let mut amounts = Vec::with_capacity(n);
398    let mut running_sum = BigUint::zero();
399
400    for &frac in &fractions[..n - 1] {
401        let (part, _) = split_amount(total, frac);
402        running_sum += &part;
403        amounts.push(part);
404    }
405
406    // Last element gets the remainder to guarantee exact sum. There is only a remainder if the
407    // fractions before it left one.
408    let remainder = total
409        .checked_sub(&running_sum)
410        .ok_or_else(|| SplitMathError::ExceedsTotal {
411            asked: running_sum.clone(),
412            total: total.clone(),
413        })?;
414    amounts.push(remainder);
415    Ok(amounts)
416}
417
418/// Product of spot prices along a path — approximates the exchange rate at
419/// near-zero input.
420pub fn compute_marginal_price_product(
421    hops: &[HopDescriptor],
422    market: &MarketState,
423    overrides: &MarketOverrides,
424) -> Result<f64, AlgorithmError> {
425    let mut product = 1.0;
426    for hop in hops {
427        let sim = overrides
428            .get(&hop.component_id)
429            .or_else(|| market.get_simulation_state(&hop.component_id))
430            .ok_or_else(|| AlgorithmError::DataNotFound {
431                kind: "simulation state",
432                id: Some(hop.component_id.clone()),
433            })?;
434        let price = sim
435            .spot_price(&hop.token_in, &hop.token_out)
436            .map_err(|e| AlgorithmError::SimulationFailed {
437                component_id: hop.component_id.clone(),
438                error: e.to_string(),
439            })?;
440        product *= price;
441    }
442    Ok(product)
443}
444
445/// Simulates a path hop-by-hop, threading output of each hop as input to the
446/// next.
447///
448/// For each hop, the path's own post-swap states are checked first, then
449/// `overrides`, then the live market state. Returns the final output amount,
450/// per-hop results, the post-swap component states, and the marginal price product, the spot
451/// prices at the state each hop executed against.
452pub fn simulate_path(
453    hops: &[HopDescriptor],
454    amount_in: &BigUint,
455    market: &MarketState,
456    overrides: &MarketOverrides,
457) -> Result<SimResult, AlgorithmError> {
458    let mut current_amount = amount_in.clone();
459    let mut hop_results = Vec::with_capacity(hops.len());
460    let mut post_swap_states: Vec<(ComponentId, Box<dyn ProtocolSim>)> =
461        Vec::with_capacity(hops.len());
462    let mut marginal_price_product = 1.0;
463
464    for hop in hops {
465        // Prefer this path's own post-swap state so a component reused by an
466        // earlier hop is simulated on depleted reserves, not fresh ones.
467        let sim = post_swap_states
468            .iter()
469            .rev()
470            .find(|(id, _)| id == &hop.component_id)
471            .map(|(_, state)| state.as_ref())
472            .or_else(|| overrides.get(&hop.component_id))
473            .or_else(|| market.get_simulation_state(&hop.component_id))
474            .ok_or_else(|| AlgorithmError::DataNotFound {
475                kind: "simulation state",
476                id: Some(hop.component_id.clone()),
477            })?;
478
479        let price = sim
480            .spot_price(&hop.token_in, &hop.token_out)
481            .map_err(|e| AlgorithmError::SimulationFailed {
482                component_id: hop.component_id.clone(),
483                error: e.to_string(),
484            })?;
485        marginal_price_product *= price;
486
487        let result = sim
488            .get_amount_out_metered(
489                &hop.component_id,
490                SIMULATE_PATH_STAGE,
491                current_amount,
492                &hop.token_in,
493                &hop.token_out,
494            )
495            .map_err(|e| AlgorithmError::SimulationFailed {
496                component_id: hop.component_id.clone(),
497                error: e.to_string(),
498            })?;
499
500        hop_results.push((result.amount.clone(), result.gas));
501        current_amount = result.amount;
502        post_swap_states.push((hop.component_id.clone(), result.new_state));
503    }
504
505    Ok(SimResult {
506        amount_out: current_amount,
507        marginal_price_product,
508        hop_results,
509        post_swap_states,
510    })
511}
512
513/// Builds post-swap component states after all paths in a split-route solution
514/// have been executed.
515///
516/// For example, if the current solution splits 1000 USDC→ETH across:
517///   - Path 1: USDC→WETH via Uniswap (600 USDC)
518///   - Path 2: USDC→WBTC→WETH via Curve+Balancer (400 USDC)
519///
520/// this function simulates both swaps and returns overrides where Uniswap,
521/// Curve, and Balancer all reflect their post-swap reserves. Pass the result
522/// to `find_single_route` for the next iteration.
523///
524/// Swaps are simulated in the same topological order as the final route, so
525/// candidate discovery sees the exact state the executable split leaves behind.
526pub fn build_post_swap_overrides(
527    paths: &[PathAllocation],
528    market: &MarketState,
529) -> Result<MarketOverrides, AlgorithmError> {
530    let Some(root_hop) = paths
531        .first()
532        .and_then(|path| path.hops.first())
533    else {
534        return Ok(MarketOverrides::empty());
535    };
536    let total_amount = paths
537        .iter()
538        .map(|path| path.amount_in.clone())
539        .sum();
540    Ok(execute_split_plan(
541        paths,
542        market,
543        &root_hop.descriptor.token_in.address,
544        &total_amount,
545        &MarketOverrides::empty(),
546        StateCapture::Skip,
547    )?
548    .post_swap)
549}
550
551/// What makes two paths' hops the same on-chain swap: one pool, taken in one direction.
552type HopKey = (ComponentId, Bytes, Bytes);
553
554fn hop_key(hop: &HopDescriptor) -> HopKey {
555    (hop.component_id.clone(), hop.token_in.address.clone(), hop.token_out.address.clone())
556}
557
558struct SplitSwap {
559    hop: HopDescriptor,
560    split: f64,
561    amount_in: BigUint,
562}
563
564/// One component swap in a split route, after it has been simulated.
565struct SimulatedSplitSwap {
566    hop: HopDescriptor,
567    split: f64,
568    amount_in: BigUint,
569    amount_out: BigUint,
570    gas: BigUint,
571    /// The component state the swap ran against, which the encoder needs to re-price it.
572    ///
573    /// `None` when the plan ran under [`StateCapture::Skip`], which is every scoring pass.
574    pre_swap_state: Option<Box<dyn ProtocolSim>>,
575}
576
577/// Whether the plan keeps the component state each swap ran against.
578///
579/// Keeping it deep-copies the pool, and a `vm:*` pool carries thousands of ticks. Only
580/// [`build_split_route`] reads the copy; the line search behind [`evaluate_total_output`] runs the
581/// plan tens of times per solve and reads nothing but the amounts.
582#[derive(Clone, Copy)]
583enum StateCapture {
584    /// Drop the state the swap ran against.
585    Skip,
586    /// Keep the state the swap ran against, for the [`Swap`] the route carries.
587    Keep,
588}
589
590/// The outcome of simulating a whole split route.
591struct SplitExecution {
592    swaps: Vec<SimulatedSplitSwap>,
593    available: FxHashMap<Bytes, BigUint>,
594    post_swap: MarketOverrides,
595    total_gas: u64,
596}
597
598/// Merge shared hops across paths, summing their flow fractions, and return
599/// them collected by `token_in` (sorted by amount descending within each
600/// branch collection).
601fn merge_shared_hops(paths: &[PathAllocation]) -> FxHashMap<Bytes, Vec<SplitSwap>> {
602    let mut hops: FxHashMap<HopKey, SplitSwap> = FxHashMap::default();
603
604    for path in paths {
605        for hop in &path.hops {
606            let desc = &hop.descriptor;
607            hops.entry(hop_key(desc))
608                .or_insert(SplitSwap {
609                    hop: HopDescriptor::new(
610                        desc.component_id.clone(),
611                        desc.token_in.clone(),
612                        desc.token_out.clone(),
613                    ),
614                    // Both set by `splits_from_amounts`, from the amounts the paths standing at
615                    // this token actually carry.
616                    split: 0.0,
617                    amount_in: BigUint::ZERO,
618                });
619        }
620    }
621
622    let mut branch_collections: FxHashMap<Bytes, Vec<SplitSwap>> = FxHashMap::default();
623    for (_, swap) in hops {
624        branch_collections
625            .entry(swap.hop.token_in.address.clone())
626            .or_default()
627            .push(swap);
628    }
629    // Only for determinism: `splits_from_amounts` re-sorts each collection by the amount its swap
630    // carries, and this decides the order of swaps carrying equal amounts.
631    for branch_collection in branch_collections.values_mut() {
632        branch_collection.sort_by(|a, b| {
633            a.hop
634                .component_id
635                .cmp(&b.hop.component_id)
636                .then_with(|| {
637                    a.hop
638                        .token_in
639                        .address
640                        .cmp(&b.hop.token_in.address)
641                })
642                .then_with(|| {
643                    a.hop
644                        .token_out
645                        .address
646                        .cmp(&b.hop.token_out.address)
647                })
648        });
649    }
650    branch_collections
651}
652
653/// Turns the amounts the execution wants into the fractions the encoder carries — and then back
654/// into the amounts the encoder will actually produce.
655///
656/// On chain a split swap does not carry an amount. It carries the share of the balance held in its
657/// input token that it should take, and the last swap of a group takes whatever is left, which is
658/// what `split = 0.0` means. The fractions therefore come from the amounts the execution
659/// attributed to each path.
660///
661/// The round trip back through [`fractions_to_amounts`] is not redundant. A fraction is an `f64`
662/// and the amounts are integers, so the amount a fraction produces is not exactly the amount it
663/// came from. `replay_route` derives its amounts from the fractions, so the execution has to as
664/// well, or the two disagree on what the same route pays.
665///
666/// It does not make the quote exact against the chain. `tycho-execution` encodes the share as a
667/// `uint24`, so what the router divides by is the fraction rounded to one part in 2^24 — about
668/// `6e-8` of the branch, which the `split = 0.0` swap absorbs for the whole group.
669///
670/// # Errors
671///
672/// [`AlgorithmError::Other`] when the swaps ask for more than stands at the token. Inside the
673/// crate the amounts come from the balance itself, so this is the out-of-crate caller that sized
674/// its paths above the order.
675fn splits_from_amounts(
676    mut hops: Vec<SplitSwap>,
677    total_available: &BigUint,
678) -> Result<Vec<SplitSwap>, AlgorithmError> {
679    // Largest first, so the remainder convention lands on the smallest share, as it did when the
680    // order came from the paths' summed flow fractions. The sort also fixes the order the swaps
681    // execute in, and swaps sharing a pool deplete it for each other, so reversing it would move
682    // the quoted output rather than only moving the rounding remainder.
683    hops.sort_by(|left, right| right.amount_in.cmp(&left.amount_in));
684
685    let last = hops.len().saturating_sub(1);
686    let fractions: Vec<f64> = hops
687        .iter()
688        .enumerate()
689        .map(|(ix, swap)| if ix == last { 0.0 } else { share_of(&swap.amount_in, total_available) })
690        .collect();
691
692    let amounts = fractions_to_amounts(total_available, &fractions).map_err(|error| {
693        AlgorithmError::Other(format!(
694            "cannot divide the {total_available} standing at this token between {} swaps: {error}",
695            fractions.len(),
696        ))
697    })?;
698    for ((swap, split), amount) in hops
699        .iter_mut()
700        .zip(fractions)
701        .zip(amounts)
702    {
703        swap.split = split;
704        swap.amount_in = amount;
705    }
706    Ok(hops)
707}
708
709/// What share of `total` the `part` is, or zero when there is nothing to divide by.
710fn share_of(part: &BigUint, total: &BigUint) -> f64 {
711    match (part.to_f64(), total.to_f64()) {
712        (Some(part), Some(total)) if total > 0.0 => part / total,
713        _ => 0.0,
714    }
715}
716
717/// Divides `output` between the paths that fed a swap, in proportion to what each put in.
718///
719/// One pool swapped once pays one amount, and each path's share of it is its share of the input —
720/// that is what the on-chain swap does, and there is nothing else it could mean. The last path
721/// takes the rounding remainder so the shares add back to `output` exactly.
722fn share_output(output: &BigUint, fed_amounts: &[BigUint]) -> Vec<BigUint> {
723    let total: BigUint = fed_amounts.iter().sum();
724    if total.is_zero() {
725        return vec![BigUint::ZERO; fed_amounts.len()];
726    }
727    let mut shares: Vec<BigUint> = fed_amounts
728        .iter()
729        .map(|fed| output * fed / &total)
730        .collect();
731    let assigned: BigUint = shares.iter().sum();
732    if let Some(last) = shares.last_mut() {
733        *last += output - assigned;
734    }
735    shares
736}
737
738/// Counts, per token, how many swaps produce it, so the traversal only swaps a
739/// token once all its inflows have arrived.
740fn build_in_degree(hops_by_token: &FxHashMap<Bytes, Vec<SplitSwap>>) -> FxHashMap<Bytes, usize> {
741    let mut in_degree: FxHashMap<Bytes, usize> = FxHashMap::default();
742    for (token_in_addr, branch_collection) in hops_by_token {
743        in_degree
744            .entry(token_in_addr.clone())
745            .or_insert(0);
746        for swap in branch_collection {
747            *in_degree
748                .entry(swap.hop.token_out.address.clone())
749                .or_insert(0) += 1;
750        }
751    }
752    in_degree
753}
754
755/// Each path's own money, and how far along its hops it has travelled.
756///
757/// The two vectors are indexed together by path, which is why they live behind one type: a path's
758/// amount and its position are meaningless apart. Tracking them is what stops an intermediate token
759/// being pooled and re-divided by the paths' shares of the *order* — see [`execute_split_plan`].
760struct PathLedger {
761    amount_in: Vec<BigUint>,
762    next_hop_ix: Vec<usize>,
763}
764
765impl PathLedger {
766    /// Starts each path on the amount its allocation carries.
767    ///
768    /// The amounts come from the allocations rather than being re-derived from their fractions:
769    /// the caller has already decided what each path carries, and a fraction is an `f64`. They set
770    /// the proportions only — every swap amount is divided out of the balance actually standing at
771    /// its token — so they are not required to sum to the order exactly.
772    ///
773    /// Asking for more than the order carries is not a panic. The amounts standing at a token
774    /// become fractions of the balance there, and fractions that ask for more than it holds fail
775    /// the plan through [`SplitMathError::ExceedsTotal`].
776    ///
777    /// # Errors
778    ///
779    /// [`AlgorithmError::Other`] when every path carries nothing. That describes no split at all,
780    /// and guessing an allocation for it would misprice the quote it produces.
781    fn new(paths: &[PathAllocation]) -> Result<Self, AlgorithmError> {
782        let amount_in: Vec<BigUint> = paths
783            .iter()
784            .map(|path| path.amount_in.clone())
785            .collect();
786        if amount_in.iter().all(BigUint::is_zero) {
787            return Err(AlgorithmError::Other(
788                "cannot divide the order across these paths: every path carries a zero amount"
789                    .to_string(),
790            ));
791        }
792        Ok(Self { next_hop_ix: vec![0; paths.len()], amount_in })
793    }
794
795    /// Which paths are standing at `token`, grouped by the swap each is about to make.
796    ///
797    /// Every path that passes through the token has arrived: the token is only released once every
798    /// hop producing it has run.
799    fn standing_at(
800        &self,
801        paths: &[PathAllocation],
802        token: &Bytes,
803    ) -> FxHashMap<HopKey, Vec<usize>> {
804        let mut by_hop: FxHashMap<HopKey, Vec<usize>> = FxHashMap::default();
805        for (path_ix, path) in paths.iter().enumerate() {
806            let Some(hop) = path.hops.get(self.next_hop_ix[path_ix]) else {
807                continue;
808            };
809            if hop.descriptor.token_in.address == *token {
810                by_hop
811                    .entry(hop_key(&hop.descriptor))
812                    .or_default()
813                    .push(path_ix);
814            }
815        }
816        by_hop
817    }
818
819    /// What the paths feeding one swap carry between them.
820    fn fed_amounts(&self, fed: &[usize]) -> Vec<BigUint> {
821        fed.iter()
822            .map(|&path_ix| self.amount_in[path_ix].clone())
823            .collect()
824    }
825
826    /// What the paths feeding one swap carry between them, added up.
827    fn fed_total(&self, fed: &[usize]) -> BigUint {
828        let mut total = BigUint::ZERO;
829        for &path_ix in fed {
830            total += &self.amount_in[path_ix];
831        }
832        total
833    }
834
835    /// Divides `total` between the paths that fed a swap, in proportion to what each put in.
836    fn rescale(&mut self, fed: &[usize], total: &BigUint) {
837        let fed_amounts = self.fed_amounts(fed);
838        for (&path_ix, share) in fed
839            .iter()
840            .zip(share_output(total, &fed_amounts))
841        {
842            self.amount_in[path_ix] = share;
843        }
844    }
845
846    /// Moves the paths that fed a swap on to their next hop.
847    fn advance(&mut self, fed: &[usize]) {
848        for &path_ix in fed {
849            self.next_hop_ix[path_ix] += 1;
850        }
851    }
852}
853
854/// What stands at each token as the plan executes.
855struct TokenBalances(FxHashMap<Bytes, BigUint>);
856
857impl TokenBalances {
858    fn starting(token: &Bytes, amount: &BigUint) -> Self {
859        Self(FxHashMap::from_iter([(token.clone(), amount.clone())]))
860    }
861
862    /// What stands at `token`, which is nothing for a token nothing has produced.
863    fn at(&self, token: &Bytes) -> BigUint {
864        self.0
865            .get(token)
866            .cloned()
867            .unwrap_or_default()
868    }
869
870    /// Takes what a swap spends out of its input token.
871    ///
872    /// Without this a path ending back on the token it started from would count the order's own
873    /// input as output, because [`evaluate_total_output`] reads the balance standing at each
874    /// terminal token.
875    ///
876    /// # Errors
877    ///
878    /// [`AlgorithmError::Other`] when the swap spends more than stands at the token. The traversal
879    /// releases a token only once every hop producing it has run, so that means the plan disagrees
880    /// with itself.
881    fn spend(
882        &mut self,
883        token: &Token,
884        component_id: &ComponentId,
885        amount: &BigUint,
886    ) -> Result<(), AlgorithmError> {
887        let standing = self
888            .0
889            .entry(token.address.clone())
890            .or_default();
891        if *standing < *amount {
892            return Err(AlgorithmError::Other(format!(
893                "the swap through {component_id} spends {amount} {}, more than the {standing} \
894                 standing at it",
895                token.symbol,
896            )));
897        }
898        *standing -= amount;
899        Ok(())
900    }
901
902    fn credit(&mut self, token: &Bytes, amount: &BigUint) {
903        *self.0.entry(token.clone()).or_default() += amount;
904    }
905
906    fn into_inner(self) -> FxHashMap<Bytes, BigUint> {
907        self.0
908    }
909}
910
911/// Sizes one token's merged swaps from the amounts the paths standing at it carry, and pairs each
912/// with the paths that feed it.
913///
914/// # Errors
915///
916/// [`AlgorithmError::Other`] when a merged swap has no path standing at it. Every merged swap was
917/// built from the hops of these paths, and a token is only released once every hop producing it has
918/// run, so one of them is standing here. No feeder means the traversal is broken, and a zero-amount
919/// swap in the route would hide that.
920///
921/// [`AlgorithmError::Other`] again when the swaps ask for more than stands at the token — see
922/// [`splits_from_amounts`].
923fn amounts_for_branch(
924    branch: Vec<SplitSwap>,
925    standing: &FxHashMap<HopKey, Vec<usize>>,
926    ledger: &PathLedger,
927    total: &BigUint,
928) -> Result<Vec<(SplitSwap, Vec<usize>)>, AlgorithmError> {
929    let sized: Vec<SplitSwap> = branch
930        .into_iter()
931        .map(|mut split_swap| {
932            let fed = standing
933                .get(&hop_key(&split_swap.hop))
934                .ok_or_else(|| {
935                    AlgorithmError::Other(format!(
936                        "no path feeds the swap through {} at this point in the plan",
937                        split_swap.hop.component_id,
938                    ))
939                })?;
940            split_swap.amount_in = ledger.fed_total(fed);
941            Ok(split_swap)
942        })
943        .collect::<Result<_, AlgorithmError>>()?;
944
945    // Paired after the sort, so each swap keeps the feeders it was sized from.
946    Ok(splits_from_amounts(sized, total)?
947        .into_iter()
948        .map(|split_swap| {
949            let fed = standing
950                .get(&hop_key(&split_swap.hop))
951                .cloned()
952                .unwrap_or_default();
953            (split_swap, fed)
954        })
955        .collect())
956}
957
958/// Simulates one merged swap against the freshest state the plan holds for its component.
959///
960/// Returns the executed swap and the component state it left behind.
961///
962/// # Errors
963///
964/// [`AlgorithmError::DataNotFound`] when no state can be found for the component, and
965/// [`AlgorithmError::SimulationFailed`] when the component refuses the swap.
966fn run_merged_swap(
967    swap: SplitSwap,
968    market: &MarketState,
969    base_overrides: &MarketOverrides,
970    post_swap: &MarketOverrides,
971    capture: StateCapture,
972) -> Result<(SimulatedSplitSwap, Box<dyn ProtocolSim>), AlgorithmError> {
973    let sim = post_swap
974        .get(&swap.hop.component_id)
975        .or_else(|| base_overrides.get(&swap.hop.component_id))
976        .or_else(|| market.get_simulation_state(&swap.hop.component_id))
977        .ok_or_else(|| AlgorithmError::DataNotFound {
978            kind: "simulation state",
979            id: Some(swap.hop.component_id.clone()),
980        })?;
981
982    let result = sim
983        .get_amount_out_metered(
984            &swap.hop.component_id,
985            SPLIT_PLAN_STAGE,
986            swap.amount_in.clone(),
987            &swap.hop.token_in,
988            &swap.hop.token_out,
989        )
990        .map_err(|e| AlgorithmError::SimulationFailed {
991            component_id: swap.hop.component_id.clone(),
992            error: e.to_string(),
993        })?;
994
995    let executed = SimulatedSplitSwap {
996        hop: swap.hop,
997        split: swap.split,
998        amount_in: swap.amount_in,
999        amount_out: result.amount,
1000        gas: result.gas,
1001        pre_swap_state: match capture {
1002            StateCapture::Keep => Some(sim.clone_box()),
1003            StateCapture::Skip => None,
1004        },
1005    };
1006    Ok((executed, result.new_state))
1007}
1008
1009/// Simulates a split route from start token to outputs and returns the outcome.
1010///
1011/// Swaps run in dependency order, so a token is only traded once every hop producing it has run,
1012/// and every swap sees the component state left by the swaps before it — so paths sharing a
1013/// component no longer each assume fresh liquidity.
1014///
1015/// A token is *not* pooled and re-divided. Each path keeps its own amount through its own hops, and
1016/// a swap several paths share divides its output between them in proportion to what each put in,
1017/// which is what the single on-chain swap does. This one pass backs scoring, candidate discovery,
1018/// and route assembly, so all three agree on the same executable route. Round-trips that
1019/// end on the input token are supported.
1020///
1021/// Errors if a path revisits an intermediate token, a component cannot be simulated, the merged
1022/// swaps cannot be ordered (a genuine dependency cycle), every path carries a zero amount, or a
1023/// merged swap has no path standing at it when its token is released.
1024fn execute_split_plan(
1025    paths: &[PathAllocation],
1026    market: &MarketState,
1027    start_token: &Bytes,
1028    start_amount: &BigUint,
1029    base_overrides: &MarketOverrides,
1030    capture: StateCapture,
1031) -> Result<SplitExecution, AlgorithmError> {
1032    for path in paths {
1033        path.validate_token_cycles()?;
1034    }
1035
1036    let mut hops_by_token = merge_shared_hops(paths);
1037    let mut in_degree = build_in_degree(&hops_by_token);
1038    let mut ready = VecDeque::from([start_token.clone()]);
1039
1040    let mut ledger = PathLedger::new(paths)?;
1041    let mut balances = TokenBalances::starting(start_token, start_amount);
1042    let mut swaps = Vec::new();
1043    let mut post_swap = MarketOverrides::empty();
1044    let mut total_gas: u64 = 0;
1045
1046    while let Some(token_addr) = ready.pop_front() {
1047        let Some(branch) = hops_by_token.remove(&token_addr) else {
1048            continue;
1049        };
1050        let standing = ledger.standing_at(paths, &token_addr);
1051        let sized = amounts_for_branch(branch, &standing, &ledger, &balances.at(&token_addr))?;
1052
1053        // The executed amount can differ by a wei from what the paths standing here asked for,
1054        // because it came back through the encoder's own fraction arithmetic. Re-attribute it so
1055        // their amounts add up to what is really being swapped.
1056        for (split_swap, fed) in &sized {
1057            ledger.rescale(fed, &split_swap.amount_in);
1058        }
1059
1060        for (split_swap, fed) in sized {
1061            let token_in = split_swap.hop.token_in.clone();
1062            let token_out = split_swap.hop.token_out.address.clone();
1063            let component_id = split_swap.hop.component_id.clone();
1064
1065            balances.spend(&token_in, &component_id, &split_swap.amount_in)?;
1066            let (executed, new_state) =
1067                run_merged_swap(split_swap, market, base_overrides, &post_swap, capture)?;
1068            balances.credit(&token_out, &executed.amount_out);
1069
1070            // Hand the output back to the paths that fed this swap, in proportion to what each put
1071            // in, and move them to their next hop. A path's amount stays its own rather than being
1072            // pooled at the token and re-divided by shares that describe the order.
1073            ledger.rescale(&fed, &executed.amount_out);
1074            ledger.advance(&fed);
1075
1076            total_gas = total_gas.saturating_add(
1077                executed
1078                    .gas
1079                    .to_u64()
1080                    .unwrap_or(u64::MAX),
1081            );
1082            swaps.push(executed);
1083            post_swap = post_swap.with_override(component_id, new_state);
1084
1085            // Decrement in-degree; enqueue when all inflows are ready.
1086            if let Some(deg) = in_degree.get_mut(&token_out) {
1087                *deg = deg.saturating_sub(1);
1088                if *deg == 0 {
1089                    ready.push_back(token_out);
1090                }
1091            }
1092        }
1093    }
1094
1095    if !hops_by_token.is_empty() {
1096        let stuck: Vec<_> = hops_by_token
1097            .keys()
1098            .map(|k| format!("{k}"))
1099            .collect();
1100        return Err(AlgorithmError::Other(format!(
1101            "dependency cycle — unprocessed tokens: [{}]",
1102            stuck.join(", "),
1103        )));
1104    }
1105
1106    Ok(SplitExecution { swaps, available: balances.into_inner(), post_swap, total_gas })
1107}
1108
1109/// Turns the parallel paths/fractions the line search works in into the
1110/// allocations `execute_split_plan` consumes, dividing the input amount across
1111/// paths by fraction. Simulation-derived fields are left empty for the plan to
1112/// fill in.
1113///
1114/// Errors if the two slices differ in length or any path is empty.
1115fn allocations_from_descriptors(
1116    paths: &[&[HopDescriptor]],
1117    fractions: &[f64],
1118    total_amount: &BigUint,
1119) -> Result<Vec<PathAllocation>, AlgorithmError> {
1120    if paths.len() != fractions.len() {
1121        return Err(AlgorithmError::Other(format!(
1122            "paths/fractions length mismatch: {} paths, {} fractions",
1123            paths.len(),
1124            fractions.len(),
1125        )));
1126    }
1127    let amounts = fractions_to_amounts(total_amount, fractions)
1128        .map_err(|e| AlgorithmError::Other(e.to_string()))?;
1129
1130    paths
1131        .iter()
1132        .zip(fractions.iter())
1133        .zip(amounts)
1134        .map(|((path, &flow_fraction), amount_in)| {
1135            if path.is_empty() {
1136                return Err(AlgorithmError::Other("path has no hops".to_string()));
1137            }
1138            Ok(PathAllocation {
1139                hops: path
1140                    .iter()
1141                    .cloned()
1142                    .map(|descriptor| SimulatedHop {
1143                        descriptor,
1144                        amount_out: BigUint::ZERO,
1145                        gas: BigUint::ZERO,
1146                    })
1147                    .collect(),
1148                flow_fraction,
1149                amount_in,
1150                amount_out: BigUint::ZERO,
1151                marginal_price_product: 0.0,
1152            })
1153        })
1154        .collect()
1155}
1156
1157/// Simulates all paths at their current fractions and returns
1158/// `(total_amount_out, total_gas)`. `paths[i]` corresponds to `fractions[i]`.
1159///
1160/// Uses the same merged topological execution plan as `build_split_route`, so
1161/// the optimiser scores the route that will actually be emitted.
1162pub fn evaluate_total_output(
1163    paths: &[&[HopDescriptor]],
1164    fractions: &[f64],
1165    total_amount: &BigUint,
1166    market: &MarketState,
1167    overrides: &MarketOverrides,
1168) -> Result<(BigUint, u64), AlgorithmError> {
1169    let first_hop = paths
1170        .first()
1171        .and_then(|path| path.first())
1172        .ok_or_else(|| AlgorithmError::Other("paths must not be empty".to_string()))?;
1173    let terminal_tokens: FxHashSet<Bytes> = paths
1174        .iter()
1175        .map(|path| {
1176            path.last()
1177                .map(|hop| hop.token_out.address.clone())
1178                .ok_or_else(|| AlgorithmError::Other("path has no hops".to_string()))
1179        })
1180        .collect::<Result<_, AlgorithmError>>()?;
1181    let allocations = allocations_from_descriptors(paths, fractions, total_amount)?;
1182    let execution = execute_split_plan(
1183        &allocations,
1184        market,
1185        &first_hop.token_in.address,
1186        total_amount,
1187        overrides,
1188        StateCapture::Skip,
1189    )?;
1190    let total_out = terminal_tokens
1191        .iter()
1192        .map(|token| {
1193            execution
1194                .available
1195                .get(token)
1196                .cloned()
1197                .unwrap_or_default()
1198        })
1199        .sum();
1200    Ok((total_out, execution.total_gas))
1201}
1202
1203/// Assembles a [`Route`] from split-route path allocations with shared-hop
1204/// deduplication.
1205///
1206/// Paths may share component hops (same `component_id`, `token_in`, `token_out`).
1207/// When they do, this function emits one combined swap rather than duplicates.
1208/// Within each branch collection of swaps sharing a `token_in`, the tycho-execution
1209/// remainder convention is applied: sorted by amount descending, all but the
1210/// last receive their explicit split fraction, while the last gets
1211/// `split = 0.0` (meaning "use all remaining balance").
1212///
1213/// # Swap ordering
1214///
1215/// Swaps are emitted in topological order (Kahn's algorithm): a token's
1216/// outgoing swaps are only emitted once every upstream swap producing it
1217/// has been emitted.
1218///
1219/// Why this matters:
1220/// - `merge_shared_hops` collapses a shared component hop into one swap (not one per path), saving
1221///   gas by calling the component once with combined input.
1222/// - That single swap's split fraction is computed against the full token balance, so all inflows
1223///   must be complete before it is emitted.
1224/// - The in-degree of each token tracks how many upstream swaps produce it; the token is processed
1225///   once all of them are done.
1226///
1227/// Note: the TychoRouter contract *could* support interleaved splits
1228/// (partial consume, more inflows, consume rest), but that would require
1229/// an extra swap on the same component, spending more gas.
1230///
1231/// For example, given paths of different lengths that converge on the same
1232/// intermediate token:
1233///
1234/// ```text
1235/// Path 1 (2 hops): WETH -> USDC -(component A)-> DAI
1236/// Path 2 (3 hops): WETH -> USDT -> USDC -(component A)-> DAI
1237/// ```
1238///
1239/// Component A (USDC→DAI) is merged into one swap. If USDC were visited before
1240/// the USDT→USDC hop completes, Component A would see only Path 1's USDC. The
1241/// topological sort prevents this by waiting for all inflows to USDC
1242/// before emitting Component A's swap. This extends to downstream splits too:
1243///
1244/// ```text
1245/// Path 1: WETH -> USDC -> DAI (Component A) -> PEPE (Component B)  (0.5)
1246/// Path 2: WETH -> USDC -> DAI (Component A) -> PEPE (Component C)  (0.5)
1247/// Path 3: WETH -> USDT -> USDC -> DAI (Component A) -> PEPE (Component B or C)
1248/// ```
1249///
1250/// The DAI→PEPE split between Component B and Component C must wait until all DAI
1251/// has been produced (from both paths through the merged Component A swap).
1252pub fn build_split_route(
1253    paths: &[PathAllocation],
1254    market: &MarketState,
1255    order: &Order,
1256) -> Result<Route, AlgorithmError> {
1257    let execution = execute_split_plan(
1258        paths,
1259        market,
1260        order.token_in(),
1261        order.amount(),
1262        &MarketOverrides::empty(),
1263        StateCapture::Keep,
1264    )?;
1265    let mut swaps = Vec::new();
1266    let mut route_tokens: FxHashMap<Bytes, Token> = FxHashMap::default();
1267
1268    for mut executed in execution.swaps {
1269        let component = market
1270            .get_component(&executed.hop.component_id)
1271            .ok_or_else(|| AlgorithmError::DataNotFound {
1272                kind: "protocol component",
1273                id: Some(executed.hop.component_id.clone()),
1274            })?;
1275
1276        let pre_swap_state = executed
1277            .pre_swap_state
1278            .take()
1279            .ok_or_else(|| {
1280                AlgorithmError::Other(format!(
1281                    "the plan kept no state for the swap through {}",
1282                    executed.hop.component_id,
1283                ))
1284            })?;
1285
1286        let in_addr = executed.hop.token_in.address.clone();
1287        let out_addr = executed.hop.token_out.address.clone();
1288        swaps.push(
1289            Swap::new(
1290                executed.hop.component_id,
1291                component.protocol_system.clone(),
1292                in_addr.clone(),
1293                out_addr.clone(),
1294                executed.amount_in,
1295                executed.amount_out,
1296                executed.gas,
1297                component.clone(),
1298                pre_swap_state,
1299            )
1300            .with_split(executed.split),
1301        );
1302        route_tokens
1303            .entry(in_addr)
1304            .or_insert(executed.hop.token_in);
1305        route_tokens
1306            .entry(out_addr.clone())
1307            .or_insert(executed.hop.token_out);
1308    }
1309
1310    Ok(Route::new(swaps, route_tokens)?)
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315    use num_bigint::BigInt;
1316    use rstest::rstest;
1317
1318    use super::*;
1319    use crate::{
1320        algorithm::test_utils::{
1321            component, order, token, ConstantProductSim, DivByZeroSim, MockProtocolSim,
1322        },
1323        types::OrderSide,
1324    };
1325
1326    fn make_market(components: Vec<(&str, Vec<Token>, Box<dyn ProtocolSim>)>) -> MarketState {
1327        let mut market = MarketState::new();
1328        for (component_id, tokens, sim) in components {
1329            market.upsert_components(std::iter::once(component(component_id, &tokens)));
1330            market.update_states([(component_id.to_string(), sim)]);
1331            market.upsert_tokens(tokens);
1332        }
1333        market
1334    }
1335
1336    #[test]
1337    fn test_split_amount_exact_sum() {
1338        let total = BigUint::from(1_000_000_000_000_000_000_u64);
1339        for fraction in [0.1, 0.5, 0.9, 0.999] {
1340            let (part, remainder) = split_amount(&total, fraction);
1341            assert_eq!(
1342                &part + &remainder,
1343                total,
1344                "part + remainder must equal total for fraction={fraction}"
1345            );
1346        }
1347    }
1348
1349    #[test]
1350    fn test_split_amount_edge_fraction_zero() {
1351        let total = BigUint::from(1_000_000_000_000_000_000_u64);
1352        let (part, remainder) = split_amount(&total, 0.0);
1353        assert!(part.is_zero());
1354        assert_eq!(remainder, total);
1355    }
1356
1357    #[test]
1358    fn test_split_amount_clamps_above_one() {
1359        let total = BigUint::from(1_000_000_000_000_000_000_u64);
1360        let (part, remainder) = split_amount(&total, 1.5);
1361        assert_eq!(part, total);
1362        assert!(remainder.is_zero());
1363    }
1364
1365    #[test]
1366    fn test_split_amount_clamps_negative() {
1367        let total = BigUint::from(1_000_000_000_000_000_000_u64);
1368        let (part, remainder) = split_amount(&total, -0.5);
1369        assert!(part.is_zero());
1370        assert_eq!(remainder, total);
1371    }
1372
1373    #[test]
1374    fn test_fractions_to_amounts_exact_sum() {
1375        let total = BigUint::from(999_999_999_999_999_999_u64);
1376        let fractions = [0.3, 0.5, 0.2];
1377        let amounts = fractions_to_amounts(&total, &fractions).unwrap();
1378        assert_eq!(amounts.len(), 3);
1379        let sum: BigUint = amounts.iter().sum();
1380        assert_eq!(sum, total, "amounts must sum exactly to total");
1381    }
1382
1383    #[test]
1384    fn test_fractions_to_amounts_empty() {
1385        let total = BigUint::from(1_000_u64);
1386        let err = fractions_to_amounts(&total, &[]).unwrap_err();
1387        assert_eq!(err, SplitMathError::EmptyFractions);
1388    }
1389
1390    #[test]
1391    fn test_fractions_to_amounts_exceeds_total() {
1392        let total = BigUint::from(1_000_u64);
1393        let err = fractions_to_amounts(&total, &[0.6, 0.6, 0.0]).unwrap_err();
1394        assert_eq!(
1395            err,
1396            SplitMathError::ExceedsTotal {
1397                asked: BigUint::from(1_200_u64),
1398                total: BigUint::from(1_000_u64),
1399            }
1400        );
1401    }
1402
1403    #[rstest]
1404    #[case::already_normalized(&[0.3, 0.5, 0.2])]
1405    #[case::drift(&[0.33, 0.33, 0.33])]
1406    fn test_normalize_fractions(#[case] input: &[f64]) {
1407        let mut fractions = input.to_vec();
1408        normalize_fractions(&mut fractions).unwrap();
1409        let sum: f64 = fractions.iter().sum();
1410        assert!((sum - 1.0).abs() < f64::EPSILON);
1411    }
1412
1413    #[rstest]
1414    #[case::empty(&[], SplitMathError::EmptyFractions)]
1415    #[case::all_zeros(&[0.0, 0.0, 0.0], SplitMathError::AllZeroFractions)]
1416    #[case::negative(&[-0.5, 0.5], SplitMathError::NegativeFraction)]
1417    fn test_normalize_fractions_invalid(#[case] input: &[f64], #[case] expected: SplitMathError) {
1418        let mut fractions = input.to_vec();
1419        let err = normalize_fractions(&mut fractions).unwrap_err();
1420        assert_eq!(err, expected);
1421    }
1422
1423    #[test]
1424    fn test_golden_section_finds_maximum() {
1425        // Maximize -(x - 0.3)^2; true maximum at x = 0.3.
1426        let f = |x: f64| -(x - 0.3) * (x - 0.3);
1427        let result = golden_section_search(f, 0.0, 1.0, 100);
1428        assert!((result - 0.3).abs() < 1e-4, "expected ~0.3, got {result}");
1429    }
1430
1431    // ==================== PathAllocation::validate_token_cycles Tests ====================
1432
1433    #[test]
1434    fn test_validate_token_cycles_valid_path() {
1435        let gas = BigUint::from(50_000u64);
1436        let path = PathAllocation {
1437            hops: vec![
1438                HopDescriptor::new("p1".to_string(), token(0x01, "A"), token(0x02, "B"))
1439                    .with_amounts(BigUint::from(100u64), gas.clone()),
1440                HopDescriptor::new("p2".to_string(), token(0x02, "B"), token(0x03, "C"))
1441                    .with_amounts(BigUint::from(100u64), gas),
1442            ],
1443            flow_fraction: 1.0,
1444            amount_in: BigUint::from(100u64),
1445            amount_out: BigUint::from(100u64),
1446            marginal_price_product: 1.0,
1447        };
1448        assert!(path.validate_token_cycles().is_ok());
1449    }
1450
1451    #[test]
1452    fn test_validate_token_cycles_empty_hops() {
1453        let path = PathAllocation {
1454            hops: vec![],
1455            flow_fraction: 1.0,
1456            amount_in: BigUint::from(100u64),
1457            amount_out: BigUint::from(100u64),
1458            marginal_price_product: 1.0,
1459        };
1460        assert!(path.validate_token_cycles().is_err());
1461    }
1462
1463    #[test]
1464    fn test_validate_token_cycles_valid_round_trip() {
1465        // A → B → A is a valid round-trip (first == last).
1466        let gas = BigUint::from(50_000u64);
1467        let path = PathAllocation {
1468            hops: vec![
1469                HopDescriptor::new("p1".to_string(), token(0x01, "A"), token(0x02, "B"))
1470                    .with_amounts(BigUint::from(100u64), gas.clone()),
1471                HopDescriptor::new("p2".to_string(), token(0x02, "B"), token(0x01, "A"))
1472                    .with_amounts(BigUint::from(100u64), gas),
1473            ],
1474            flow_fraction: 1.0,
1475            amount_in: BigUint::from(100u64),
1476            amount_out: BigUint::from(100u64),
1477            marginal_price_product: 1.0,
1478        };
1479        assert!(path.validate_token_cycles().is_ok());
1480    }
1481
1482    #[test]
1483    fn test_validate_token_cycles_rejects_mid_path_cycle() {
1484        // A → B → C → A → D: token A revisited mid-path (not a round-trip).
1485        // merge_shared_hops would incorrectly merge both A→? hops.
1486        let gas = BigUint::from(50_000u64);
1487        let path = PathAllocation {
1488            hops: vec![
1489                HopDescriptor::new("p1".to_string(), token(0x01, "A"), token(0x02, "B"))
1490                    .with_amounts(BigUint::from(100u64), gas.clone()),
1491                HopDescriptor::new("p2".to_string(), token(0x02, "B"), token(0x03, "C"))
1492                    .with_amounts(BigUint::from(100u64), gas.clone()),
1493                HopDescriptor::new("p3".to_string(), token(0x03, "C"), token(0x01, "A"))
1494                    .with_amounts(BigUint::from(100u64), gas.clone()),
1495                HopDescriptor::new("p4".to_string(), token(0x01, "A"), token(0x04, "D"))
1496                    .with_amounts(BigUint::from(100u64), gas),
1497            ],
1498            flow_fraction: 1.0,
1499            amount_in: BigUint::from(100u64),
1500            amount_out: BigUint::from(100u64),
1501            marginal_price_product: 1.0,
1502        };
1503        assert!(path.validate_token_cycles().is_err());
1504    }
1505
1506    #[test]
1507    fn test_validate_token_cycles_rejects_intermediate_revisit() {
1508        // A → B → C → B → D: token B revisited.
1509        let gas = BigUint::from(50_000u64);
1510        let path = PathAllocation {
1511            hops: vec![
1512                HopDescriptor::new("p1".to_string(), token(0x01, "A"), token(0x02, "B"))
1513                    .with_amounts(BigUint::from(100u64), gas.clone()),
1514                HopDescriptor::new("p2".to_string(), token(0x02, "B"), token(0x03, "C"))
1515                    .with_amounts(BigUint::from(100u64), gas.clone()),
1516                HopDescriptor::new("p3".to_string(), token(0x03, "C"), token(0x02, "B"))
1517                    .with_amounts(BigUint::from(100u64), gas.clone()),
1518                HopDescriptor::new("p4".to_string(), token(0x02, "B"), token(0x04, "D"))
1519                    .with_amounts(BigUint::from(100u64), gas),
1520            ],
1521            flow_fraction: 1.0,
1522            amount_in: BigUint::from(100u64),
1523            amount_out: BigUint::from(100u64),
1524            marginal_price_product: 1.0,
1525        };
1526        assert!(path.validate_token_cycles().is_err());
1527    }
1528
1529    // ==================== Simulation Utility Tests ====================
1530
1531    #[test]
1532    fn test_compute_marginal_price_product_single_hop() {
1533        let token_a = token(0x0A, "A");
1534        let token_b = token(0x0B, "B");
1535        let market = make_market(vec![(
1536            "component_ab",
1537            vec![token_a.clone(), token_b.clone()],
1538            Box::new(MockProtocolSim::new(3.0)),
1539        )]);
1540
1541        let hops = [HopDescriptor::new("component_ab".to_string(), token_a, token_b)];
1542
1543        let product =
1544            compute_marginal_price_product(&hops, &market, &MarketOverrides::empty()).unwrap();
1545        assert!((product - 3.0).abs() < f64::EPSILON, "expected 3.0, got {product}");
1546    }
1547
1548    #[test]
1549    fn test_compute_marginal_price_product_multi_hop() {
1550        let token_a = token(0x0A, "A");
1551        let token_b = token(0x0B, "B");
1552        let token_c = token(0x0C, "C");
1553        let market = make_market(vec![
1554            (
1555                "component_ab",
1556                vec![token_a.clone(), token_b.clone()],
1557                Box::new(MockProtocolSim::new(2.0)),
1558            ),
1559            (
1560                "component_bc",
1561                vec![token_b.clone(), token_c.clone()],
1562                Box::new(MockProtocolSim::new(4.0)),
1563            ),
1564        ]);
1565
1566        let hops = [
1567            HopDescriptor::new("component_ab".to_string(), token_a, token_b.clone()),
1568            HopDescriptor::new("component_bc".to_string(), token_b, token_c),
1569        ];
1570
1571        let product =
1572            compute_marginal_price_product(&hops, &market, &MarketOverrides::empty()).unwrap();
1573        // 2.0 * 4.0 = 8.0
1574        assert!((product - 8.0).abs() < f64::EPSILON, "expected 8.0, got {product}");
1575    }
1576
1577    #[test]
1578    fn test_compute_marginal_price_product_uses_overrides() {
1579        let token_a = token(0x0A, "A");
1580        let token_b = token(0x0B, "B");
1581        let market = make_market(vec![(
1582            "component_ab",
1583            vec![token_a.clone(), token_b.clone()],
1584            Box::new(MockProtocolSim::new(3.0)),
1585        )]);
1586
1587        let hops = [HopDescriptor::new("component_ab".to_string(), token_a, token_b)];
1588
1589        // Override component_ab with a different spot price.
1590        let overrides = MarketOverrides::empty()
1591            .with_override("component_ab".to_string(), Box::new(MockProtocolSim::new(7.0)));
1592
1593        let product = compute_marginal_price_product(&hops, &market, &overrides).unwrap();
1594        assert!((product - 7.0).abs() < f64::EPSILON, "expected 7.0, got {product}");
1595    }
1596
1597    #[test]
1598    fn test_simulate_path_correct_output() {
1599        // 2-hop path A→B→C with spot prices 2.0 and 3.0.
1600        // Input 1000 should thread through: 1000*2=2000, 2000*3=6000.
1601        let token_a = token(0x0A, "A");
1602        let token_b = token(0x0B, "B");
1603        let token_c = token(0x0C, "C");
1604        let market = make_market(vec![
1605            (
1606                "component_ab",
1607                vec![token_a.clone(), token_b.clone()],
1608                Box::new(MockProtocolSim::new(2.0)),
1609            ),
1610            (
1611                "component_bc",
1612                vec![token_b.clone(), token_c.clone()],
1613                Box::new(MockProtocolSim::new(3.0)),
1614            ),
1615        ]);
1616
1617        let hops = [
1618            HopDescriptor::new("component_ab".to_string(), token_a, token_b.clone()),
1619            HopDescriptor::new("component_bc".to_string(), token_b, token_c),
1620        ];
1621
1622        let amount_in = BigUint::from(1000u64);
1623        let overrides = MarketOverrides::empty();
1624        let result = simulate_path(&hops, &amount_in, &market, &overrides).unwrap();
1625
1626        assert_eq!(result.amount_out, BigUint::from(6000u64));
1627
1628        // spot_price(A→B) = 2.0, spot_price(B→C) = 3.0 → product = 6.0
1629        assert!(
1630            (result.marginal_price_product - 6.0).abs() < f64::EPSILON,
1631            "expected marginal_price_product 6.0, got {}",
1632            result.marginal_price_product
1633        );
1634    }
1635
1636    #[test]
1637    fn test_simulate_path_contains_simulation_panic() {
1638        // Component math that panics (e.g. U256 division by zero on degenerate amounts) must
1639        // surface as a SimulationFailed error, not unwind through the solver thread.
1640        let token_a = token(0x0A, "A");
1641        let token_b = token(0x0B, "B");
1642        let market = make_market(vec![(
1643            "component_ab",
1644            vec![token_a.clone(), token_b.clone()],
1645            Box::new(DivByZeroSim::default()),
1646        )]);
1647
1648        let hops = [HopDescriptor::new("component_ab".to_string(), token_a, token_b)];
1649        let result =
1650            simulate_path(&hops, &BigUint::from(1000u64), &market, &MarketOverrides::empty());
1651
1652        match result {
1653            Err(AlgorithmError::SimulationFailed { component_id, error }) => {
1654                assert_eq!(component_id, "component_ab");
1655                assert!(error.contains("panic"), "error should mention the panic: {error}");
1656            }
1657            Err(other) => panic!("expected SimulationFailed, got {other:?}"),
1658            Ok(_) => panic!("expected SimulationFailed, got Ok"),
1659        }
1660    }
1661
1662    #[test]
1663    fn test_market_overrides_with_zero_gas() {
1664        let token_a = token(0x0A, "A");
1665        let token_b = token(0x0B, "B");
1666        let token_c = token(0x0C, "C");
1667        let sim_ab = MockProtocolSim::new(2.0).with_gas(100_000);
1668        let sim_bc = MockProtocolSim::new(3.0).with_gas(70_000);
1669        let market = make_market(vec![
1670            ("component_ab", vec![token_a.clone(), token_b.clone()], Box::new(sim_ab.clone())),
1671            ("component_bc", vec![token_b.clone(), token_c.clone()], Box::new(sim_bc.clone())),
1672        ]);
1673
1674        // Zero gas on component_ab, leave component_bc as a normal override.
1675        let overrides = MarketOverrides::empty()
1676            .with_override("component_ab".to_string(), Box::new(sim_ab))
1677            .with_zero_gas(
1678                "component_ab".to_string(),
1679                token_a.address.clone(),
1680                token_b.address.clone(),
1681            )
1682            .with_override("component_bc".to_string(), Box::new(sim_bc));
1683
1684        let hops_ab =
1685            [HopDescriptor::new("component_ab".to_string(), token_a.clone(), token_b.clone())];
1686        let hops_bc = [HopDescriptor::new("component_bc".to_string(), token_b, token_c)];
1687        let amount_in = BigUint::from(1000u64);
1688
1689        let hop_gas_sum = |sim: &SimResult| -> BigUint {
1690            sim.hop_results
1691                .iter()
1692                .map(|(_, gas)| gas)
1693                .sum()
1694        };
1695
1696        let normal_ab =
1697            simulate_path(&hops_ab, &amount_in, &market, &MarketOverrides::empty()).unwrap();
1698        let zero_gas_ab = simulate_path(&hops_ab, &amount_in, &market, &overrides).unwrap();
1699
1700        assert_eq!(normal_ab.amount_out, zero_gas_ab.amount_out);
1701        assert!(hop_gas_sum(&normal_ab) > BigUint::ZERO, "normal gas should be non-zero");
1702        assert_eq!(
1703            hop_gas_sum(&zero_gas_ab),
1704            BigUint::ZERO,
1705            "zero-gas override should report gas=0"
1706        );
1707
1708        // component_bc is a normal override — its gas should be unaffected.
1709        let result_bc = simulate_path(&hops_bc, &amount_in, &market, &overrides).unwrap();
1710        assert_eq!(
1711            hop_gas_sum(&result_bc),
1712            BigUint::from(70_000u64),
1713            "non-zero-gas override should keep its gas"
1714        );
1715    }
1716
1717    #[test]
1718    fn test_evaluate_total_output_two_paths() {
1719        // 50/50 split of 1000 across two parallel 1-hop paths:
1720        //
1721        //       500 -- component_1 (price=2.0) --> 1000
1722        //      /                                   \
1723        //  1000                                     2500
1724        //      \                                   /
1725        //       500 -- component_2 (price=3.0) --> 1500
1726        //
1727        // total_gas = 50k + 60k = 110k
1728        let token_a = token(0x0A, "A");
1729        let token_b = token(0x0B, "B");
1730        let market = make_market(vec![
1731            (
1732                "component_1",
1733                vec![token_a.clone(), token_b.clone()],
1734                Box::new(MockProtocolSim::new(2.0).with_gas(50_000)),
1735            ),
1736            (
1737                "component_2",
1738                vec![token_a.clone(), token_b.clone()],
1739                Box::new(MockProtocolSim::new(3.0).with_gas(60_000)),
1740            ),
1741        ]);
1742
1743        let hops_1 =
1744            [HopDescriptor::new("component_1".to_string(), token_a.clone(), token_b.clone())];
1745        let hops_2 = [HopDescriptor::new("component_2".to_string(), token_a, token_b)];
1746
1747        let paths: Vec<&[HopDescriptor]> = vec![&hops_1, &hops_2];
1748        let fractions = [0.5, 0.5];
1749        let total_amount = BigUint::from(1000u64);
1750        let overrides = MarketOverrides::empty();
1751
1752        let (total_out, total_gas) =
1753            evaluate_total_output(&paths, &fractions, &total_amount, &market, &overrides).unwrap();
1754
1755        assert_eq!(total_out, BigUint::from(2500u64));
1756        assert_eq!(total_gas, 110_000);
1757    }
1758
1759    #[test]
1760    fn test_evaluate_total_output_shared_component_depletes() {
1761        // Two "paths" through the SAME constant-product component. Sequential
1762        // simulation must thread the post-swap state, so the combined output
1763        // matches one full-amount swap instead of double-counting the fresh
1764        // reserves for each half.
1765        let token_a = token(0x0A, "A");
1766        let token_b = token(0x0B, "B");
1767        let cp = ConstantProductSim {
1768            reserve_0: BigUint::from(10_000u64),
1769            reserve_1: BigUint::from(10_000u64),
1770            gas: 50_000,
1771        };
1772        let market = make_market(vec![(
1773            "component",
1774            vec![token_a.clone(), token_b.clone()],
1775            Box::new(cp.clone()),
1776        )]);
1777
1778        let hops_1 =
1779            [HopDescriptor::new("component".to_string(), token_a.clone(), token_b.clone())];
1780        let hops_2 =
1781            [HopDescriptor::new("component".to_string(), token_a.clone(), token_b.clone())];
1782        let paths: Vec<&[HopDescriptor]> = vec![&hops_1, &hops_2];
1783        let total_amount = BigUint::from(1000u64);
1784
1785        let (total_out, _) = evaluate_total_output(
1786            &paths,
1787            &[0.5, 0.5],
1788            &total_amount,
1789            &market,
1790            &MarketOverrides::empty(),
1791        )
1792        .unwrap();
1793
1794        let full_swap_out = cp
1795            .get_amount_out(total_amount, &token_a, &token_b)
1796            .unwrap()
1797            .amount;
1798        let half_fresh_out = cp
1799            .get_amount_out(BigUint::from(500u64), &token_a, &token_b)
1800            .unwrap()
1801            .amount;
1802
1803        // Double-counting would report ~2 × half_fresh_out; the honest value
1804        // equals the full-amount swap up to per-chunk rounding.
1805        assert!(
1806            total_out < &half_fresh_out * 2u32,
1807            "shared component must deplete between paths: {total_out} >= {}",
1808            &half_fresh_out * 2u32
1809        );
1810        let diff = BigInt::from(total_out) - BigInt::from(full_swap_out);
1811        assert!(
1812            diff.magnitude() <= &BigUint::from(2u32),
1813            "sequential split should match one full swap (±rounding), diff {diff}"
1814        );
1815    }
1816
1817    #[test]
1818    fn test_evaluate_total_output_gas_deduplication() {
1819        // Two paths share component P1 (pre-split hop). P1's gas should be
1820        // counted once, not twice.
1821        //
1822        //              P2 (50k gas) --> C
1823        //             /
1824        //  A -- P1 --+
1825        //             \
1826        //              P3 (70k gas) --> D
1827        //
1828        let token_a = token(0x0A, "A");
1829        let token_b = token(0x0B, "B");
1830        let token_c = token(0x0C, "C");
1831        let token_d = token(0x0D, "D");
1832        let market = make_market(vec![
1833            (
1834                "P1",
1835                vec![token_a.clone(), token_b.clone()],
1836                Box::new(MockProtocolSim::new(2.0).with_gas(100_000)),
1837            ),
1838            (
1839                "P2",
1840                vec![token_b.clone(), token_c.clone()],
1841                Box::new(MockProtocolSim::new(1.5).with_gas(50_000)),
1842            ),
1843            (
1844                "P3",
1845                vec![token_b.clone(), token_d.clone()],
1846                Box::new(MockProtocolSim::new(3.0).with_gas(70_000)),
1847            ),
1848        ]);
1849
1850        // Path 1: A -> P1 -> B -> P2 -> C (uses P1 and P2)
1851        let hops_1 = [
1852            HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone()),
1853            HopDescriptor::new("P2".to_string(), token_b.clone(), token_c),
1854        ];
1855        // Path 2: A -> P1 -> B -> P3 -> D (uses P1 and P3)
1856        let hops_2 = [
1857            HopDescriptor::new("P1".to_string(), token_a, token_b.clone()),
1858            HopDescriptor::new("P3".to_string(), token_b, token_d),
1859        ];
1860
1861        let paths: Vec<&[HopDescriptor]> = vec![&hops_1, &hops_2];
1862        let fractions = [0.5, 0.5];
1863        let total_amount = BigUint::from(1000u64);
1864        let overrides = MarketOverrides::empty();
1865
1866        let (_, total_gas) =
1867            evaluate_total_output(&paths, &fractions, &total_amount, &market, &overrides).unwrap();
1868
1869        // P1 counted once: 100k + 50k + 70k = 220k
1870        assert_eq!(total_gas, 220_000);
1871    }
1872
1873    #[test]
1874    fn test_evaluate_total_output_matches_route_order_for_same_component_branches() {
1875        // Both source branches use the same component with different token
1876        // pairs. The input path order is B then C, but the route emits C then
1877        // B because the C branch has the larger split. Since MockProtocolSim
1878        // increments its spot price after each swap, path-order simulation
1879        // would produce a different total than route-order simulation.
1880        let token_a = token(0x0A, "A");
1881        let token_b = token(0x0B, "B");
1882        let token_c = token(0x0C, "C");
1883        let token_d = token(0x0D, "D");
1884        let market = make_market(vec![
1885            (
1886                "tricomponent",
1887                vec![token_a.clone(), token_b.clone(), token_c.clone()],
1888                Box::new(MockProtocolSim::new(2.0).with_gas(80_000)),
1889            ),
1890            (
1891                "component_bd",
1892                vec![token_b.clone(), token_d.clone()],
1893                Box::new(MockProtocolSim::new(1.0)),
1894            ),
1895            (
1896                "component_cd",
1897                vec![token_c.clone(), token_d.clone()],
1898                Box::new(MockProtocolSim::new(1.0)),
1899            ),
1900        ]);
1901
1902        let hops_b = [
1903            HopDescriptor::new("tricomponent".to_string(), token_a.clone(), token_b.clone()),
1904            HopDescriptor::new("component_bd".to_string(), token_b.clone(), token_d.clone()),
1905        ];
1906        let hops_c = [
1907            HopDescriptor::new("tricomponent".to_string(), token_a.clone(), token_c.clone()),
1908            HopDescriptor::new("component_cd".to_string(), token_c.clone(), token_d.clone()),
1909        ];
1910
1911        let total_amount = BigUint::from(1000u64);
1912        let paths: Vec<&[HopDescriptor]> = vec![&hops_b, &hops_c];
1913        let fractions = [0.4, 0.6];
1914        let (total_out, total_gas) = evaluate_total_output(
1915            &paths,
1916            &fractions,
1917            &total_amount,
1918            &market,
1919            &MarketOverrides::empty(),
1920        )
1921        .unwrap();
1922
1923        let zero = BigUint::ZERO;
1924        let allocations = vec![
1925            PathAllocation {
1926                hops: hops_b
1927                    .iter()
1928                    .cloned()
1929                    .map(|hop| hop.with_amounts(zero.clone(), zero.clone()))
1930                    .collect(),
1931                flow_fraction: 0.4,
1932                amount_in: BigUint::from(400u64),
1933                amount_out: zero.clone(),
1934                marginal_price_product: 0.0,
1935            },
1936            PathAllocation {
1937                hops: hops_c
1938                    .iter()
1939                    .cloned()
1940                    .map(|hop| hop.with_amounts(zero.clone(), zero.clone()))
1941                    .collect(),
1942                flow_fraction: 0.6,
1943                amount_in: BigUint::from(600u64),
1944                amount_out: zero,
1945                marginal_price_product: 0.0,
1946            },
1947        ];
1948        let ord = order(&token_a, &token_d, 1000, OrderSide::Sell);
1949        let route = build_split_route(&allocations, &market, &ord).unwrap();
1950        let route_out: BigUint = route
1951            .swaps()
1952            .iter()
1953            .filter(|swap| swap.token_out() == &token_d.address)
1954            .map(|swap| swap.amount_out().clone())
1955            .sum();
1956
1957        assert_eq!(
1958            route.swaps()[0].token_out(),
1959            &token_c.address,
1960            "larger C branch should execute first"
1961        );
1962        assert_eq!(total_out, BigUint::from(2400u64));
1963        assert_eq!(route_out, total_out);
1964        assert_eq!(route.total_gas().to_u64().unwrap(), total_gas);
1965    }
1966
1967    #[test]
1968    fn test_gas_dedup_different_tokens() {
1969        // A single 3-token component used for two different token pairs is two
1970        // distinct hops — gas must be counted for each.
1971        //
1972        //  A -- TRICOMPONENT (A→B) --> B    (path 1)
1973        //  B -- TRICOMPONENT (B→C) --> C    (path 2)
1974        //
1975        let token_a = token(0x0A, "A");
1976        let token_b = token(0x0B, "B");
1977        let token_c = token(0x0C, "C");
1978        let market = make_market(vec![(
1979            "tricomponent",
1980            vec![token_a.clone(), token_b.clone(), token_c.clone()],
1981            Box::new(MockProtocolSim::new(1.0).with_gas(80_000)),
1982        )]);
1983
1984        let hops_1 = [HopDescriptor::new("tricomponent".to_string(), token_a, token_b.clone())];
1985        let hops_2 = [HopDescriptor::new("tricomponent".to_string(), token_b, token_c)];
1986
1987        let paths: Vec<&[HopDescriptor]> = vec![&hops_1, &hops_2];
1988        let fractions = [0.5, 0.5];
1989        let total_amount = BigUint::from(1000u64);
1990        let overrides = MarketOverrides::empty();
1991
1992        let (_, total_gas) =
1993            evaluate_total_output(&paths, &fractions, &total_amount, &market, &overrides).unwrap();
1994
1995        // Different token pairs on the same component: 80k + 80k = 160k
1996        assert_eq!(total_gas, 160_000);
1997    }
1998
1999    #[test]
2000    fn test_build_post_swap_overrides_degrades_used_components() {
2001        let token_a = token(0x0A, "A");
2002        let token_b = token(0x0B, "B");
2003        let market = make_market(vec![(
2004            "component_ab",
2005            vec![token_a.clone(), token_b.clone()],
2006            Box::new(ConstantProductSim {
2007                reserve_0: BigUint::from(10_000u64),
2008                reserve_1: BigUint::from(20_000u64),
2009                gas: 50_000,
2010            }),
2011        )]);
2012
2013        let allocation = PathAllocation {
2014            hops: vec![SimulatedHop {
2015                descriptor: HopDescriptor::new(
2016                    "component_ab".to_string(),
2017                    token_a.clone(),
2018                    token_b.clone(),
2019                ),
2020                amount_out: BigUint::from(1818u64),
2021                gas: BigUint::from(50_000u64),
2022            }],
2023            flow_fraction: 1.0,
2024            amount_in: BigUint::from(1000u64),
2025            amount_out: BigUint::from(1818u64),
2026            marginal_price_product: 2.0,
2027        };
2028
2029        let degraded = build_post_swap_overrides(&[allocation], &market).unwrap();
2030
2031        // xy=k: amount_out = amount_in * reserve_out / (reserve_in + amount_in)
2032        // Fresh component (10000/20000): 100 * 20000 / (10000 + 100) = 198
2033        let probe = BigUint::from(100u64);
2034        let fresh_out = market
2035            .get_simulation_state("component_ab")
2036            .unwrap()
2037            .get_amount_out(probe.clone(), &token_a, &token_b)
2038            .unwrap()
2039            .amount;
2040        assert_eq!(fresh_out, BigUint::from(198u64));
2041
2042        // The 1000-in allocation produces 1000*20000/(10000+1000) = 1818 out,
2043        // shifting reserves to (10000+1000, 20000-1818) = (11000, 18182).
2044        // Degraded component: 100 * 18182 / (11000 + 100) = 163
2045        let degraded_out = degraded
2046            .get(&"component_ab".to_string())
2047            .unwrap()
2048            .get_amount_out(probe, &token_a, &token_b)
2049            .unwrap()
2050            .amount;
2051        assert_eq!(degraded_out, BigUint::from(163u64));
2052    }
2053
2054    // ==================== merge / allocate Tests ====================
2055
2056    #[test]
2057    fn test_merge_shared_hops_combines_fractions() {
2058        // Two paths share the first hop A→B via P1; second hops diverge.
2059        //
2060        //                P2
2061        //               /    \
2062        //  A -- P1 --> B      C
2063        //               \    /
2064        //                P3
2065        let token_a = token(0x0A, "A");
2066        let token_b = token(0x0B, "B");
2067        let token_c = token(0x0C, "C");
2068
2069        let gas = BigUint::from(50_000u64);
2070        let paths = vec![
2071            PathAllocation {
2072                hops: vec![
2073                    SimulatedHop {
2074                        descriptor: HopDescriptor::new(
2075                            "P1".to_string(),
2076                            token_a.clone(),
2077                            token_b.clone(),
2078                        ),
2079                        amount_out: BigUint::from(1200u64),
2080                        gas: gas.clone(),
2081                    },
2082                    SimulatedHop {
2083                        descriptor: HopDescriptor::new(
2084                            "P2".to_string(),
2085                            token_b.clone(),
2086                            token_c.clone(),
2087                        ),
2088                        amount_out: BigUint::from(3600u64),
2089                        gas: gas.clone(),
2090                    },
2091                ],
2092                flow_fraction: 0.6,
2093                amount_in: BigUint::from(600u64),
2094                amount_out: BigUint::from(3600u64),
2095                marginal_price_product: 6.0,
2096            },
2097            PathAllocation {
2098                hops: vec![
2099                    SimulatedHop {
2100                        descriptor: HopDescriptor::new(
2101                            "P1".to_string(),
2102                            token_a.clone(),
2103                            token_b.clone(),
2104                        ),
2105                        amount_out: BigUint::from(800u64),
2106                        gas: gas.clone(),
2107                    },
2108                    SimulatedHop {
2109                        descriptor: HopDescriptor::new(
2110                            "P3".to_string(),
2111                            token_b.clone(),
2112                            token_c.clone(),
2113                        ),
2114                        amount_out: BigUint::from(1600u64),
2115                        gas,
2116                    },
2117                ],
2118                flow_fraction: 0.4,
2119                amount_in: BigUint::from(400u64),
2120                amount_out: BigUint::from(1600u64),
2121                marginal_price_product: 4.0,
2122            },
2123        ];
2124
2125        let hops_by_token = merge_shared_hops(&paths);
2126
2127        // Branch collection at A: both paths cross P1 on the same pair, so it merges to one swap.
2128        let branch_collection_a = &hops_by_token[&token_a.address];
2129        assert_eq!(branch_collection_a.len(), 1);
2130        assert_eq!(branch_collection_a[0].hop.component_id, "P1");
2131
2132        // Branch collection at B: two swaps, ordered by component id so equal amounts do not
2133        // reorder between runs. The split each carries is `splits_from_amounts`' to set.
2134        let branch_collection_b = &hops_by_token[&token_b.address];
2135        let at_b: Vec<&str> = branch_collection_b
2136            .iter()
2137            .map(|swap| swap.hop.component_id.as_str())
2138            .collect();
2139        assert_eq!(at_b, vec!["P2", "P3"]);
2140        assert!(
2141            branch_collection_b
2142                .iter()
2143                .chain(branch_collection_a)
2144                .all(|swap| swap.split == 0.0),
2145            "merging assigns no split; the amounts standing at the token decide it"
2146        );
2147    }
2148
2149    #[test]
2150    fn test_splits_from_amounts() {
2151        let token_a = token(0x0A, "A");
2152        let token_b = token(0x0B, "B");
2153
2154        let branch_collection = vec![
2155            SplitSwap {
2156                hop: HopDescriptor::new("component1".to_string(), token_a.clone(), token_b.clone()),
2157                // Stale, and deliberately inconsistent with the amounts: the fractions are derived
2158                // from what the execution assigned, not from what a caller once guessed.
2159                split: 0.1,
2160                amount_in: BigUint::from(700u64),
2161            },
2162            SplitSwap {
2163                hop: HopDescriptor::new("component2".to_string(), token_a.clone(), token_b.clone()),
2164                split: 0.9,
2165                amount_in: BigUint::from(300u64),
2166            },
2167        ];
2168
2169        let result = splits_from_amounts(branch_collection, &BigUint::from(1000u64)).unwrap();
2170
2171        assert_eq!(result.len(), 2);
2172        assert_eq!(result[0].amount_in, BigUint::from(700u64));
2173        assert!((result[0].split - 0.7).abs() < 1e-9);
2174        // The last swap takes whatever is left, which on chain is what `split = 0.0` means.
2175        assert_eq!(result[1].amount_in, BigUint::from(300u64));
2176        assert_eq!(result[1].split, 0.0);
2177    }
2178
2179    /// The sort is what decides which swap carries the remainder, so it has to be exercised by
2180    /// input that is not already in the order it produces.
2181    #[test]
2182    fn test_splits_from_amounts_ascending_amounts() {
2183        let token_a = token(0x0A, "A");
2184        let token_b = token(0x0B, "B");
2185
2186        let branch_collection = vec![
2187            SplitSwap {
2188                hop: HopDescriptor::new("small".to_string(), token_a.clone(), token_b.clone()),
2189                split: 0.0,
2190                amount_in: BigUint::from(300u64),
2191            },
2192            SplitSwap {
2193                hop: HopDescriptor::new("large".to_string(), token_a.clone(), token_b.clone()),
2194                split: 0.0,
2195                amount_in: BigUint::from(700u64),
2196            },
2197        ];
2198
2199        let result = splits_from_amounts(branch_collection, &BigUint::from(1000u64)).unwrap();
2200
2201        let order: Vec<&str> = result
2202            .iter()
2203            .map(|swap| swap.hop.component_id.as_str())
2204            .collect();
2205        assert_eq!(order, vec!["large", "small"], "the ascending input is sorted largest first");
2206        assert!((result[0].split - 0.7).abs() < 1e-9);
2207        // The smallest runs last and takes what is left, which on chain is what `split = 0.0` asks
2208        // the router for.
2209        assert_eq!(result[1].split, 0.0);
2210        assert_eq!(result[1].amount_in, BigUint::from(300u64));
2211    }
2212
2213    /// A path that ends on the token it started from must be scored on what it produced, not on
2214    /// the order's own input still standing at that token.
2215    #[test]
2216    fn test_execute_split_plan_round_trip() {
2217        let token_a = token(0x0A, "A");
2218        let token_b = token(0x0B, "B");
2219        let market = make_market(vec![
2220            ("there", vec![token_a.clone(), token_b.clone()], Box::new(MockProtocolSim::new(2.0))),
2221            ("back", vec![token_b.clone(), token_a.clone()], Box::new(MockProtocolSim::new(1.0))),
2222        ]);
2223
2224        let hops: Vec<HopDescriptor> = vec![
2225            HopDescriptor::new("there".to_string(), token_a.clone(), token_b.clone()),
2226            HopDescriptor::new("back".to_string(), token_b, token_a.clone()),
2227        ];
2228        let (total_out, _) = evaluate_total_output(
2229            &[&hops],
2230            &[1.0],
2231            &BigUint::from(1000u64),
2232            &market,
2233            &MarketOverrides::empty(),
2234        )
2235        .expect("the round trip simulates");
2236
2237        // 1000 A buys 2000 B, which buys 2000 A back. The 1000 that was spent is gone.
2238        assert_eq!(total_out, BigUint::from(2000u64), "the order's own input is not output");
2239    }
2240
2241    /// A path set carrying nothing describes no split, and guessing an allocation for it would
2242    /// misprice the quote.
2243    /// A swap spending more than stands at its token means the plan disagrees with itself: the
2244    /// traversal releases a token only once every hop producing it has run.
2245    #[test]
2246    fn test_token_balances_overspend() {
2247        let token_a = token(0x0A, "A");
2248        let mut balances = TokenBalances::starting(&token_a.address, &BigUint::from(100u64));
2249
2250        let error = balances
2251            .spend(&token_a, &"component1".to_string(), &BigUint::from(101u64))
2252            .expect_err("the swap spends more than stands at the token");
2253
2254        assert!(
2255            error
2256                .to_string()
2257                .contains("more than the 100 standing at it"),
2258            "the error states what the swap spends and what stands there: {error}"
2259        );
2260    }
2261
2262    /// Every merged swap is built from the hops of the paths handed in, so a branch swap no path
2263    /// stands at means the traversal is broken. Sizing it at zero would leave that in the route.
2264    #[test]
2265    fn test_amounts_for_branch_without_a_feeder() {
2266        let token_a = token(0x0A, "A");
2267        let token_b = token(0x0B, "B");
2268        let paths = vec![PathAllocation {
2269            hops: vec![SimulatedHop {
2270                descriptor: HopDescriptor::new(
2271                    "component1".to_string(),
2272                    token_a.clone(),
2273                    token_b.clone(),
2274                ),
2275                amount_out: BigUint::from(2000u64),
2276                gas: BigUint::from(50_000u64),
2277            }],
2278            flow_fraction: 1.0,
2279            amount_in: BigUint::from(1000u64),
2280            amount_out: BigUint::from(2000u64),
2281            marginal_price_product: 2.0,
2282        }];
2283        let ledger = PathLedger::new(&paths).expect("the path carries an amount");
2284        // A swap through a component none of the paths hop over, so nothing stands at it.
2285        let branch = vec![SplitSwap {
2286            hop: HopDescriptor::new("component2".to_string(), token_a, token_b),
2287            split: 0.0,
2288            amount_in: BigUint::ZERO,
2289        }];
2290
2291        let Err(error) =
2292            amounts_for_branch(branch, &FxHashMap::default(), &ledger, &BigUint::from(1000u64))
2293        else {
2294            panic!("a branch swap no path stands at must not be sized");
2295        };
2296
2297        assert!(
2298            error
2299                .to_string()
2300                .contains("no path feeds the swap through component2"),
2301            "the error names the swap that no path feeds: {error}"
2302        );
2303    }
2304
2305    #[test]
2306    fn test_execute_split_plan_zero_amount_paths() {
2307        let token_a = token(0x0A, "A");
2308        let token_b = token(0x0B, "B");
2309        let market = make_market(vec![(
2310            "component1",
2311            vec![token_a.clone(), token_b.clone()],
2312            Box::new(MockProtocolSim::new(2.0)),
2313        )]);
2314        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
2315
2316        let paths = vec![PathAllocation {
2317            hops: vec![SimulatedHop {
2318                descriptor: HopDescriptor::new("component1".to_string(), token_a, token_b),
2319                amount_out: BigUint::ZERO,
2320                gas: BigUint::from(50_000u64),
2321            }],
2322            flow_fraction: 0.0,
2323            amount_in: BigUint::ZERO,
2324            amount_out: BigUint::ZERO,
2325            marginal_price_product: 0.0,
2326        }];
2327
2328        let error = build_split_route(&paths, &market, &ord)
2329            .expect_err("a path carrying nothing cannot be divided");
2330
2331        assert!(
2332            error
2333                .to_string()
2334                .contains("every path carries a zero amount"),
2335            "unexpected error: {error}"
2336        );
2337    }
2338
2339    #[test]
2340    fn test_splits_from_amounts_single_hop() {
2341        let token_a = token(0x0A, "A");
2342        let token_b = token(0x0B, "B");
2343
2344        let total = BigUint::from(1000u64);
2345        let branch_collection = vec![SplitSwap {
2346            hop: HopDescriptor::new("component1".to_string(), token_a, token_b),
2347            split: 1.0,
2348            amount_in: total.clone(),
2349        }];
2350
2351        let result = splits_from_amounts(branch_collection, &total).unwrap();
2352
2353        assert_eq!(result.len(), 1);
2354        assert_eq!(result[0].split, 0.0);
2355        assert_eq!(result[0].amount_in, total);
2356    }
2357
2358    #[test]
2359    fn test_share_output() {
2360        let shares =
2361            share_output(&BigUint::from(1000u64), &[BigUint::from(300u64), BigUint::from(100u64)]);
2362
2363        assert_eq!(shares, vec![BigUint::from(750u64), BigUint::from(250u64)]);
2364    }
2365
2366    /// The shares add back to the output exactly, whatever the division leaves over.
2367    #[test]
2368    fn test_share_output_uneven_division() {
2369        let output = BigUint::from(1000u64);
2370        let shares =
2371            share_output(&output, &[BigUint::from(1u64), BigUint::from(1u64), BigUint::from(1u64)]);
2372
2373        assert_eq!(shares.iter().sum::<BigUint>(), output);
2374        assert_eq!(shares[2], BigUint::from(334u64));
2375    }
2376
2377    // ==================== build_split_route Tests ====================
2378
2379    #[test]
2380    fn test_build_split_route_remainder_convention() {
2381        // 3 paths splitting at source: last swap at the split point must
2382        // have split=0.0.
2383        //
2384        //       500 -- component1 (price=2) --> 1000
2385        //      /
2386        //  1000---- 300 -- component2 (price=3) -->  900
2387        //      \
2388        //       200 -- component3 (price=4) -->  800
2389        let token_a = token(0x0A, "A");
2390        let token_b = token(0x0B, "B");
2391        let market = make_market(vec![
2392            (
2393                "component1",
2394                vec![token_a.clone(), token_b.clone()],
2395                Box::new(MockProtocolSim::new(2.0)),
2396            ),
2397            (
2398                "component2",
2399                vec![token_a.clone(), token_b.clone()],
2400                Box::new(MockProtocolSim::new(3.0)),
2401            ),
2402            (
2403                "component3",
2404                vec![token_a.clone(), token_b.clone()],
2405                Box::new(MockProtocolSim::new(4.0)),
2406            ),
2407        ]);
2408        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
2409
2410        let gas = BigUint::from(50_000u64);
2411        let paths = vec![
2412            PathAllocation {
2413                hops: vec![SimulatedHop {
2414                    descriptor: HopDescriptor::new(
2415                        "component1".to_string(),
2416                        token_a.clone(),
2417                        token_b.clone(),
2418                    ),
2419                    amount_out: BigUint::from(1000u64),
2420                    gas: gas.clone(),
2421                }],
2422                flow_fraction: 0.5,
2423                amount_in: BigUint::from(500u64),
2424                amount_out: BigUint::from(1000u64),
2425                marginal_price_product: 2.0,
2426            },
2427            PathAllocation {
2428                hops: vec![SimulatedHop {
2429                    descriptor: HopDescriptor::new(
2430                        "component2".to_string(),
2431                        token_a.clone(),
2432                        token_b.clone(),
2433                    ),
2434                    amount_out: BigUint::from(900u64),
2435                    gas: gas.clone(),
2436                }],
2437                flow_fraction: 0.3,
2438                amount_in: BigUint::from(300u64),
2439                amount_out: BigUint::from(900u64),
2440                marginal_price_product: 3.0,
2441            },
2442            PathAllocation {
2443                hops: vec![SimulatedHop {
2444                    descriptor: HopDescriptor::new(
2445                        "component3".to_string(),
2446                        token_a.clone(),
2447                        token_b.clone(),
2448                    ),
2449                    amount_out: BigUint::from(800u64),
2450                    gas,
2451                }],
2452                flow_fraction: 0.2,
2453                amount_in: BigUint::from(200u64),
2454                amount_out: BigUint::from(800u64),
2455                marginal_price_product: 4.0,
2456            },
2457        ];
2458
2459        let route = build_split_route(&paths, &market, &ord).unwrap();
2460        let swaps = route.swaps();
2461
2462        assert_eq!(swaps.len(), 3);
2463
2464        // Sorted descending: component1 (0.5), component2 (0.3), component3 (0.2).
2465        assert_eq!(swaps[0].component_id(), "component1");
2466        assert_eq!(*swaps[0].split(), 0.5);
2467        assert_eq!(swaps[1].component_id(), "component2");
2468        assert_eq!(*swaps[1].split(), 0.3);
2469        assert_eq!(swaps[2].component_id(), "component3");
2470        assert_eq!(*swaps[2].split(), 0.0);
2471    }
2472
2473    #[test]
2474    fn test_build_split_route_single_path() {
2475        // Single path A→B→C: all splits must be 0.0.
2476        let token_a = token(0x0A, "A");
2477        let token_b = token(0x0B, "B");
2478        let token_c = token(0x0C, "C");
2479        let market = make_market(vec![
2480            (
2481                "component_ab",
2482                vec![token_a.clone(), token_b.clone()],
2483                Box::new(MockProtocolSim::new(2.0)),
2484            ),
2485            (
2486                "component_bc",
2487                vec![token_b.clone(), token_c.clone()],
2488                Box::new(MockProtocolSim::new(3.0)),
2489            ),
2490        ]);
2491        let ord = order(&token_a, &token_c, 1000, OrderSide::Sell);
2492
2493        let gas = BigUint::from(50_000u64);
2494        let paths = vec![PathAllocation {
2495            hops: vec![
2496                SimulatedHop {
2497                    descriptor: HopDescriptor::new(
2498                        "component_ab".to_string(),
2499                        token_a.clone(),
2500                        token_b.clone(),
2501                    ),
2502                    amount_out: BigUint::from(2000u64),
2503                    gas: gas.clone(),
2504                },
2505                SimulatedHop {
2506                    descriptor: HopDescriptor::new("component_bc".to_string(), token_b, token_c),
2507                    amount_out: BigUint::from(6000u64),
2508                    gas,
2509                },
2510            ],
2511            flow_fraction: 1.0,
2512            amount_in: BigUint::from(1000u64),
2513            amount_out: BigUint::from(6000u64),
2514            marginal_price_product: 6.0,
2515        }];
2516
2517        let route = build_split_route(&paths, &market, &ord).unwrap();
2518        let swaps = route.swaps();
2519
2520        assert_eq!(swaps.len(), 2);
2521        for swap in swaps {
2522            assert_eq!(*swap.split(), 0.0, "single path should produce all-zero splits");
2523        }
2524    }
2525
2526    #[test]
2527    fn test_build_split_route_oversubscribed_paths() {
2528        // Three paths, each carrying 600 of a 1000 order: an allocation an out-of-crate algorithm
2529        // can hand in. The two explicit shares alone ask for 1200 of the 1000 standing at A.
2530        let token_a = token(0x0A, "A");
2531        let token_b = token(0x0B, "B");
2532        let market = make_market(
2533            ["component_1", "component_2", "component_3"]
2534                .into_iter()
2535                .map(|component_id| {
2536                    (
2537                        component_id,
2538                        vec![token_a.clone(), token_b.clone()],
2539                        Box::new(MockProtocolSim::new(2.0)) as Box<dyn ProtocolSim>,
2540                    )
2541                })
2542                .collect(),
2543        );
2544        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
2545
2546        let paths: Vec<PathAllocation> = ["component_1", "component_2", "component_3"]
2547            .into_iter()
2548            .map(|component_id| PathAllocation {
2549                hops: vec![SimulatedHop {
2550                    descriptor: HopDescriptor::new(
2551                        component_id.to_string(),
2552                        token_a.clone(),
2553                        token_b.clone(),
2554                    ),
2555                    amount_out: BigUint::from(1200u64),
2556                    gas: BigUint::from(50_000u64),
2557                }],
2558                flow_fraction: 0.6,
2559                amount_in: BigUint::from(600u64),
2560                amount_out: BigUint::from(1200u64),
2561                marginal_price_product: 2.0,
2562            })
2563            .collect();
2564
2565        let err = build_split_route(&paths, &market, &ord).unwrap_err();
2566
2567        assert!(
2568            err.to_string()
2569                .contains("the fractions ask for"),
2570            "an oversubscribed allocation must report the amounts, not panic: {err}"
2571        );
2572    }
2573
2574    #[test]
2575    fn test_build_split_route_shared_first_component() {
2576        // Two paths sharing component P1 at A→B, diverging at B→C (P2 vs P3).
2577        //
2578        //                  P2 (price=3) --> C
2579        //                 /
2580        //  A -- P1 (2) --B
2581        //                 \
2582        //                  P3 (price=4) --> C
2583        let token_a = token(0x0A, "A");
2584        let token_b = token(0x0B, "B");
2585        let token_c = token(0x0C, "C");
2586        let market = make_market(vec![
2587            ("P1", vec![token_a.clone(), token_b.clone()], Box::new(MockProtocolSim::new(2.0))),
2588            ("P2", vec![token_b.clone(), token_c.clone()], Box::new(MockProtocolSim::new(3.0))),
2589            ("P3", vec![token_b.clone(), token_c.clone()], Box::new(MockProtocolSim::new(4.0))),
2590        ]);
2591        let ord = order(&token_a, &token_c, 1000, OrderSide::Sell);
2592
2593        let gas = BigUint::from(50_000u64);
2594        let paths = vec![
2595            PathAllocation {
2596                hops: vec![
2597                    SimulatedHop {
2598                        descriptor: HopDescriptor::new(
2599                            "P1".to_string(),
2600                            token_a.clone(),
2601                            token_b.clone(),
2602                        ),
2603                        amount_out: BigUint::from(1400u64),
2604                        gas: gas.clone(),
2605                    },
2606                    SimulatedHop {
2607                        descriptor: HopDescriptor::new(
2608                            "P2".to_string(),
2609                            token_b.clone(),
2610                            token_c.clone(),
2611                        ),
2612                        amount_out: BigUint::from(4200u64),
2613                        gas: gas.clone(),
2614                    },
2615                ],
2616                flow_fraction: 0.7,
2617                amount_in: BigUint::from(700u64),
2618                amount_out: BigUint::from(4200u64),
2619                marginal_price_product: 6.0,
2620            },
2621            PathAllocation {
2622                hops: vec![
2623                    SimulatedHop {
2624                        descriptor: HopDescriptor::new(
2625                            "P1".to_string(),
2626                            token_a.clone(),
2627                            token_b.clone(),
2628                        ),
2629                        amount_out: BigUint::from(600u64),
2630                        gas: gas.clone(),
2631                    },
2632                    SimulatedHop {
2633                        descriptor: HopDescriptor::new(
2634                            "P3".to_string(),
2635                            token_b.clone(),
2636                            token_c.clone(),
2637                        ),
2638                        amount_out: BigUint::from(2400u64),
2639                        gas,
2640                    },
2641                ],
2642                flow_fraction: 0.3,
2643                amount_in: BigUint::from(300u64),
2644                amount_out: BigUint::from(1200u64),
2645                marginal_price_product: 8.0,
2646            },
2647        ];
2648
2649        let route = build_split_route(&paths, &market, &ord).unwrap();
2650        let swaps = route.swaps();
2651
2652        // Exactly 3 swaps: one combined A→B, two divergent B→C.
2653        assert_eq!(swaps.len(), 3, "expected 3 swaps, got {}", swaps.len());
2654
2655        // First swap: combined A→B via P1 — amount_out is sum of per-path outputs.
2656        let ab_swap = &swaps[0];
2657        assert_eq!(ab_swap.component_id(), "P1");
2658        assert_eq!(
2659            *ab_swap.amount_in(),
2660            BigUint::from(1000u64),
2661            "A→B swap amount_in should equal sum of both paths"
2662        );
2663        assert_eq!(
2664            *ab_swap.amount_out(),
2665            BigUint::from(2000u64),
2666            "A→B amount_out should be sum of per-path outputs (1400+600)"
2667        );
2668        assert_eq!(
2669            *ab_swap.split(),
2670            0.0,
2671            "A→B is the sole swap in its branch collection, so it gets the remainder convention (split = 0.0)"
2672        );
2673
2674        // B→C swaps: P2 (0.7) first, P3 (0.3) last.
2675        assert_eq!(swaps[1].component_id(), "P2");
2676        assert_eq!(*swaps[1].split(), 0.7);
2677        assert_eq!(swaps[2].component_id(), "P3");
2678        assert_eq!(*swaps[2].split(), 0.0);
2679    }
2680
2681    #[test]
2682    fn test_build_split_route_source_level_split_different_intermediates() {
2683        // Paths A→B→Z and A→C→Z: source-level split with different
2684        // intermediate tokens.
2685        //
2686        //       component_ab --> B -- component_bz
2687        //      /                         \
2688        //  A --                           Z
2689        //      \                         /
2690        //       component_ac --> C -- component_cz
2691        let token_a = token(0x0A, "A");
2692        let token_b = token(0x0B, "B");
2693        let token_c = token(0x0C, "C");
2694        let token_z = token(0x1A, "Z");
2695        let market = make_market(vec![
2696            (
2697                "component_ab",
2698                vec![token_a.clone(), token_b.clone()],
2699                Box::new(MockProtocolSim::new(2.0)),
2700            ),
2701            (
2702                "component_ac",
2703                vec![token_a.clone(), token_c.clone()],
2704                Box::new(MockProtocolSim::new(3.0)),
2705            ),
2706            (
2707                "component_bz",
2708                vec![token_b.clone(), token_z.clone()],
2709                Box::new(MockProtocolSim::new(4.0)),
2710            ),
2711            (
2712                "component_cz",
2713                vec![token_c.clone(), token_z.clone()],
2714                Box::new(MockProtocolSim::new(5.0)),
2715            ),
2716        ]);
2717        let ord = order(&token_a, &token_z, 1000, OrderSide::Sell);
2718
2719        let gas = BigUint::from(50_000u64);
2720        let paths = vec![
2721            PathAllocation {
2722                hops: vec![
2723                    SimulatedHop {
2724                        descriptor: HopDescriptor::new(
2725                            "component_ab".to_string(),
2726                            token_a.clone(),
2727                            token_b.clone(),
2728                        ),
2729                        amount_out: BigUint::from(1200u64),
2730                        gas: gas.clone(),
2731                    },
2732                    SimulatedHop {
2733                        descriptor: HopDescriptor::new(
2734                            "component_bz".to_string(),
2735                            token_b,
2736                            token_z.clone(),
2737                        ),
2738                        amount_out: BigUint::from(4800u64),
2739                        gas: gas.clone(),
2740                    },
2741                ],
2742                flow_fraction: 0.6,
2743                amount_in: BigUint::from(600u64),
2744                amount_out: BigUint::from(4800u64),
2745                marginal_price_product: 8.0,
2746            },
2747            PathAllocation {
2748                hops: vec![
2749                    SimulatedHop {
2750                        descriptor: HopDescriptor::new(
2751                            "component_ac".to_string(),
2752                            token_a.clone(),
2753                            token_c.clone(),
2754                        ),
2755                        amount_out: BigUint::from(1200u64),
2756                        gas: gas.clone(),
2757                    },
2758                    SimulatedHop {
2759                        descriptor: HopDescriptor::new(
2760                            "component_cz".to_string(),
2761                            token_c,
2762                            token_z,
2763                        ),
2764                        amount_out: BigUint::from(6000u64),
2765                        gas,
2766                    },
2767                ],
2768                flow_fraction: 0.4,
2769                amount_in: BigUint::from(400u64),
2770                amount_out: BigUint::from(6000u64),
2771                marginal_price_product: 15.0,
2772            },
2773        ];
2774
2775        let route = build_split_route(&paths, &market, &ord).unwrap();
2776        let swaps = route.swaps();
2777
2778        assert_eq!(swaps.len(), 4, "expected 4 swaps (2 source + 2 intermediate)");
2779
2780        // Source-level split: component_ab (0.6) first, component_ac (0.4) last.
2781        assert_eq!(swaps[0].component_id(), "component_ab");
2782        assert_eq!(*swaps[0].split(), 0.6);
2783        assert_eq!(*swaps[0].amount_in(), BigUint::from(600u64));
2784        assert_eq!(*swaps[0].amount_out(), BigUint::from(1200u64));
2785
2786        assert_eq!(swaps[1].component_id(), "component_ac");
2787        assert_eq!(*swaps[1].split(), 0.0);
2788        assert_eq!(*swaps[1].amount_in(), BigUint::from(400u64));
2789        assert_eq!(*swaps[1].amount_out(), BigUint::from(1200u64));
2790
2791        // Intermediate swaps: single hops from B and C, all split=0.0.
2792        assert_eq!(swaps[2].component_id(), "component_bz");
2793        assert_eq!(*swaps[2].split(), 0.0);
2794        assert_eq!(*swaps[2].amount_in(), BigUint::from(1200u64));
2795        assert_eq!(*swaps[2].amount_out(), BigUint::from(4800u64));
2796
2797        assert_eq!(swaps[3].component_id(), "component_cz");
2798        assert_eq!(*swaps[3].split(), 0.0);
2799        assert_eq!(*swaps[3].amount_in(), BigUint::from(1200u64));
2800        assert_eq!(*swaps[3].amount_out(), BigUint::from(6000u64));
2801    }
2802
2803    #[test]
2804    fn test_build_split_route_cross_depth_shared_component() {
2805        // Two paths of different lengths share Component A (USDC→DAI).
2806        // The BFS must process all USDC inflows before visiting USDC's
2807        // outgoing swaps.
2808        //
2809        //  WETH ──┬────────────────────▶ USDC ─── component_a ──▶ DAI
2810        //         │                      ▲
2811        //         └──────────▶ USDT ─────┘
2812        //
2813        // Path 1 (2 hops): WETH → USDC → DAI      (0.6 fraction)
2814        // Path 2 (3 hops): WETH → USDT → USDC → DAI (0.4 fraction)
2815        //
2816        // Component A appears in both paths with (USDC, DAI). After merging,
2817        // Component A's amount_in must reflect USDC from *both* paths.
2818        let weth = token(0x01, "WETH");
2819        let usdc = token(0x02, "USDC");
2820        let usdt = token(0x03, "USDT");
2821        let dai = token(0x04, "DAI");
2822        let market = make_market(vec![
2823            (
2824                "component_weth_usdc",
2825                vec![weth.clone(), usdc.clone()],
2826                Box::new(MockProtocolSim::new(2.0)),
2827            ),
2828            (
2829                "component_weth_usdt",
2830                vec![weth.clone(), usdt.clone()],
2831                Box::new(MockProtocolSim::new(3.0)),
2832            ),
2833            (
2834                "component_usdt_usdc",
2835                vec![usdt.clone(), usdc.clone()],
2836                Box::new(MockProtocolSim::new(1.0)),
2837            ),
2838            ("component_a", vec![usdc.clone(), dai.clone()], Box::new(MockProtocolSim::new(1.0))),
2839        ]);
2840        let ord = order(&weth, &dai, 1000, OrderSide::Sell);
2841
2842        let gas = BigUint::from(50_000u64);
2843
2844        // Path 1: WETH --(component_weth_usdc)--> USDC --(component_a)--> DAI
2845        // 600 WETH in, 1200 USDC out from first hop, 1200 DAI out from component_a
2846        let path1 = PathAllocation {
2847            hops: vec![
2848                HopDescriptor::new("component_weth_usdc".to_string(), weth.clone(), usdc.clone())
2849                    .with_amounts(BigUint::from(1200u64), gas.clone()),
2850                HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
2851                    .with_amounts(BigUint::from(1200u64), gas.clone()),
2852            ],
2853            flow_fraction: 0.6,
2854            amount_in: BigUint::from(600u64),
2855            amount_out: BigUint::from(1200u64),
2856            marginal_price_product: 2.0,
2857        };
2858
2859        // Path 2: WETH --(component_weth_usdt)--> USDT --(component_usdt_usdc)--> USDC
2860        //         --(component_a)--> DAI
2861        // 400 WETH in, 1200 USDT out, 1200 USDC out, 1200 DAI out from component_a
2862        let path2 = PathAllocation {
2863            hops: vec![
2864                HopDescriptor::new("component_weth_usdt".to_string(), weth.clone(), usdt.clone())
2865                    .with_amounts(BigUint::from(1200u64), gas.clone()),
2866                HopDescriptor::new("component_usdt_usdc".to_string(), usdt.clone(), usdc.clone())
2867                    .with_amounts(BigUint::from(1200u64), gas.clone()),
2868                HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
2869                    .with_amounts(BigUint::from(1200u64), gas),
2870            ],
2871            flow_fraction: 0.4,
2872            amount_in: BigUint::from(400u64),
2873            amount_out: BigUint::from(1200u64),
2874            marginal_price_product: 3.0,
2875        };
2876
2877        let route = build_split_route(&[path1, path2], &market, &ord).unwrap();
2878        let swaps = route.swaps();
2879
2880        // Component A is shared and merged: it should receive the total USDC
2881        // from both paths (1200 + 1200 = 2400).
2882        let component_a_swap = swaps
2883            .iter()
2884            .find(|s| s.component_id() == "component_a")
2885            .expect("component_a swap must exist");
2886        assert_eq!(
2887            *component_a_swap.amount_in(),
2888            BigUint::from(2400u64),
2889            "component_a must receive USDC from both paths (1200 + 1200)"
2890        );
2891        assert_eq!(
2892            *component_a_swap.amount_out(),
2893            BigUint::from(2400u64),
2894            "component_a amount_out should be the merged total"
2895        );
2896
2897        // Component A is merged into one swap, so its gas is counted once.
2898        // Total = 4 distinct components × 50k gas = 200k (not 5 × 50k).
2899        assert_eq!(swaps.len(), 4, "component_a must appear once, not once per path");
2900        assert_eq!(
2901            route.total_gas(),
2902            BigUint::from(200_000u64),
2903            "gas must be counted once per component, not once per path"
2904        );
2905    }
2906
2907    #[test]
2908    fn test_build_split_route_cross_depth_convergence_with_downstream_split() {
2909        // Cross-depth convergence on Component A (USDC→DAI) followed by a
2910        // downstream split at DAI (Component B and Component C → PEPE).
2911        //
2912        //  WETH ──┬──────────────▶ USDC ── component_a ──▶ DAI ──┬── component_b ──▶ PEPE
2913        //         │                  ▲                      │
2914        //         └──────▶ USDT ─────┘                      └── component_c ──▶ PEPE
2915        //
2916        // Path 1: WETH → USDC → DAI → PEPE (Component B)    fraction 0.3
2917        // Path 2: WETH → USDC → DAI → PEPE (Component C)    fraction 0.3
2918        // Path 3: WETH → USDT → USDC → DAI → PEPE (Component B) fraction 0.4
2919        //
2920        // Component A is shared across all 3 paths. The DAI split between Component B
2921        // and Component C must wait until all DAI has been produced (from both
2922        // the direct and USDT-detour paths through the merged Component A swap).
2923        let weth = token(0x01, "WETH");
2924        let usdc = token(0x02, "USDC");
2925        let usdt = token(0x03, "USDT");
2926        let dai = token(0x04, "DAI");
2927        let pepe = token(0x05, "PEPE");
2928        let market = make_market(vec![
2929            ("component_wu", vec![weth.clone(), usdc.clone()], Box::new(MockProtocolSim::new(2.0))),
2930            ("component_wt", vec![weth.clone(), usdt.clone()], Box::new(MockProtocolSim::new(3.0))),
2931            ("component_tu", vec![usdt.clone(), usdc.clone()], Box::new(MockProtocolSim::new(1.0))),
2932            ("component_a", vec![usdc.clone(), dai.clone()], Box::new(MockProtocolSim::new(1.0))),
2933            ("component_b", vec![dai.clone(), pepe.clone()], Box::new(MockProtocolSim::new(5.0))),
2934            ("component_c", vec![dai.clone(), pepe.clone()], Box::new(MockProtocolSim::new(4.0))),
2935        ]);
2936        let ord = order(&weth, &pepe, 1000, OrderSide::Sell);
2937        let gas = BigUint::from(50_000u64);
2938
2939        // Path 1: WETH → USDC → DAI → PEPE (Component B)
2940        let path1 = PathAllocation {
2941            hops: vec![
2942                HopDescriptor::new("component_wu".to_string(), weth.clone(), usdc.clone())
2943                    .with_amounts(BigUint::from(600u64), gas.clone()),
2944                HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
2945                    .with_amounts(BigUint::from(600u64), gas.clone()),
2946                HopDescriptor::new("component_b".to_string(), dai.clone(), pepe.clone())
2947                    .with_amounts(BigUint::from(3000u64), gas.clone()),
2948            ],
2949            flow_fraction: 0.3,
2950            amount_in: BigUint::from(300u64),
2951            amount_out: BigUint::from(3000u64),
2952            marginal_price_product: 10.0,
2953        };
2954
2955        // Path 2: WETH → USDC → DAI → PEPE (Component C)
2956        let path2 = PathAllocation {
2957            hops: vec![
2958                HopDescriptor::new("component_wu".to_string(), weth.clone(), usdc.clone())
2959                    .with_amounts(BigUint::from(600u64), gas.clone()),
2960                HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
2961                    .with_amounts(BigUint::from(600u64), gas.clone()),
2962                HopDescriptor::new("component_c".to_string(), dai.clone(), pepe.clone())
2963                    .with_amounts(BigUint::from(2400u64), gas.clone()),
2964            ],
2965            flow_fraction: 0.3,
2966            amount_in: BigUint::from(300u64),
2967            amount_out: BigUint::from(2400u64),
2968            marginal_price_product: 8.0,
2969        };
2970
2971        // Path 3: WETH → USDT → USDC → DAI → PEPE (Component B)
2972        let path3 = PathAllocation {
2973            hops: vec![
2974                HopDescriptor::new("component_wt".to_string(), weth.clone(), usdt.clone())
2975                    .with_amounts(BigUint::from(1200u64), gas.clone()),
2976                HopDescriptor::new("component_tu".to_string(), usdt.clone(), usdc.clone())
2977                    .with_amounts(BigUint::from(1200u64), gas.clone()),
2978                HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
2979                    .with_amounts(BigUint::from(1200u64), gas.clone()),
2980                HopDescriptor::new("component_b".to_string(), dai.clone(), pepe.clone())
2981                    .with_amounts(BigUint::from(6000u64), gas),
2982            ],
2983            flow_fraction: 0.4,
2984            amount_in: BigUint::from(400u64),
2985            amount_out: BigUint::from(6000u64),
2986            marginal_price_product: 15.0,
2987        };
2988
2989        let route = build_split_route(&[path1, path2, path3], &market, &ord).unwrap();
2990        let swaps = route.swaps();
2991
2992        // Component A is merged: total USDC in = 600+600+1200 = 2400,
2993        // total DAI out = 600+600+1200 = 2400.
2994        let component_a_swap = swaps
2995            .iter()
2996            .find(|s| s.component_id() == "component_a")
2997            .expect("component_a swap must exist");
2998        assert_eq!(
2999            *component_a_swap.amount_in(),
3000            BigUint::from(2400u64),
3001            "component_a must receive all USDC from both direct and USDT-detour paths"
3002        );
3003
3004        // The DAI split follows what each path *brought to DAI*, not what share of the order it
3005        // started with. Paths 1 and 3 feed component_b and path 2 feeds component_c, and the DAI
3006        // they arrive with is 600 / 600 / 1200 — because path 3 reached USDC through a better pair
3007        // of hops (WETH→USDT at 3.0 then USDT→USDC at 1.0) than paths 1 and 2 (WETH→USDC at 2.0).
3008        // So component_b takes 1800 of the 2400 DAI and component_c takes 600.
3009        let component_b_swap = swaps
3010            .iter()
3011            .find(|s| s.component_id() == "component_b")
3012            .expect("component_b swap must exist");
3013        let component_c_swap = swaps
3014            .iter()
3015            .find(|s| s.component_id() == "component_c")
3016            .expect("component_c swap must exist");
3017
3018        assert_eq!(*component_b_swap.amount_in(), BigUint::from(1800u64));
3019        assert_eq!(
3020            *component_b_swap.amount_out(),
3021            BigUint::from(9000u64),
3022            "component_b amount_out should be simulated from emitted amount_in"
3023        );
3024        assert_eq!(*component_c_swap.amount_in(), BigUint::from(600u64));
3025        assert_eq!(
3026            *component_c_swap.amount_out(),
3027            BigUint::from(2400u64),
3028            "component_c amount_out should be simulated from remainder amount_in"
3029        );
3030
3031        // Verify ordering: component_a must appear before component_b and component_c
3032        // (DAI must be fully produced before splitting).
3033        let component_a_idx = swaps
3034            .iter()
3035            .position(|s| s.component_id() == "component_a")
3036            .unwrap();
3037        let component_b_idx = swaps
3038            .iter()
3039            .position(|s| s.component_id() == "component_b")
3040            .unwrap();
3041        let component_c_idx = swaps
3042            .iter()
3043            .position(|s| s.component_id() == "component_c")
3044            .unwrap();
3045        assert!(
3046            component_a_idx < component_b_idx && component_a_idx < component_c_idx,
3047            "component_a (idx {component_a_idx}) must appear before component_b (idx {component_b_idx}) \
3048             and component_c (idx {component_c_idx})"
3049        );
3050
3051        // Also verify USDT→USDC appears before component_a (USDC→DAI).
3052        let component_tu_idx = swaps
3053            .iter()
3054            .position(|s| s.component_id() == "component_tu")
3055            .unwrap();
3056        assert!(
3057            component_tu_idx < component_a_idx,
3058            "component_tu (idx {component_tu_idx}) must appear before component_a (idx {component_a_idx})"
3059        );
3060
3061        // Component A is merged into one swap, so its gas is counted once.
3062        // Total = 6 distinct components × 50k gas = 300k (not 8 × 50k).
3063        assert_eq!(swaps.len(), 6, "component_a must appear once, not once per path");
3064        assert_eq!(
3065            route.total_gas(),
3066            BigUint::from(300_000u64),
3067            "gas must be counted once per component, not once per path"
3068        );
3069    }
3070
3071    #[test]
3072    fn test_build_split_route_rejects_reverse_order_shared_components() {
3073        // Two paths use Component A and Component B in opposite order:
3074        //
3075        //         ┌── USDC ── component_a ──▶ DAI ── PEPE ── component_b ──▶ UNI ── WBTC
3076        //  WETH ──┤
3077        //         └── PEPE ── component_b ──▶ UNI ── USDC ── component_a ──▶ DAI ── WBTC
3078        //
3079        // merge_shared_hops collapses Component A and Component B into single swaps,
3080        // creating the cycle: USDC → DAI → PEPE → UNI → USDC.
3081        let weth = token(0x01, "WETH");
3082        let usdc = token(0x02, "USDC");
3083        let dai = token(0x03, "DAI");
3084        let pepe = token(0x04, "PEPE");
3085        let uni = token(0x05, "UNI");
3086        let wbtc = token(0x06, "WBTC");
3087        let market = make_market(vec![
3088            ("component_wu", vec![weth.clone(), usdc.clone()], Box::new(MockProtocolSim::new(2.0))),
3089            ("component_a", vec![usdc.clone(), dai.clone()], Box::new(MockProtocolSim::new(1.0))),
3090            ("component_dp", vec![dai.clone(), pepe.clone()], Box::new(MockProtocolSim::new(5.0))),
3091            ("component_b", vec![pepe.clone(), uni.clone()], Box::new(MockProtocolSim::new(1.0))),
3092            ("component_uw", vec![uni.clone(), wbtc.clone()], Box::new(MockProtocolSim::new(3.0))),
3093            ("component_wp", vec![weth.clone(), pepe.clone()], Box::new(MockProtocolSim::new(4.0))),
3094            ("component_us", vec![uni.clone(), usdc.clone()], Box::new(MockProtocolSim::new(1.0))),
3095            ("component_dw", vec![dai.clone(), wbtc.clone()], Box::new(MockProtocolSim::new(2.0))),
3096        ]);
3097        let ord = order(&weth, &wbtc, 1000, OrderSide::Sell);
3098        let gas = BigUint::from(50_000u64);
3099
3100        let path1 = PathAllocation {
3101            hops: vec![
3102                HopDescriptor::new("component_wu".to_string(), weth.clone(), usdc.clone())
3103                    .with_amounts(BigUint::from(1200u64), gas.clone()),
3104                HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
3105                    .with_amounts(BigUint::from(1200u64), gas.clone()),
3106                HopDescriptor::new("component_dp".to_string(), dai.clone(), pepe.clone())
3107                    .with_amounts(BigUint::from(6000u64), gas.clone()),
3108                HopDescriptor::new("component_b".to_string(), pepe.clone(), uni.clone())
3109                    .with_amounts(BigUint::from(6000u64), gas.clone()),
3110                HopDescriptor::new("component_uw".to_string(), uni.clone(), wbtc.clone())
3111                    .with_amounts(BigUint::from(18000u64), gas.clone()),
3112            ],
3113            flow_fraction: 0.6,
3114            amount_in: BigUint::from(600u64),
3115            amount_out: BigUint::from(18000u64),
3116            marginal_price_product: 30.0,
3117        };
3118
3119        let path2 = PathAllocation {
3120            hops: vec![
3121                HopDescriptor::new("component_wp".to_string(), weth.clone(), pepe.clone())
3122                    .with_amounts(BigUint::from(1600u64), gas.clone()),
3123                HopDescriptor::new("component_b".to_string(), pepe.clone(), uni.clone())
3124                    .with_amounts(BigUint::from(1600u64), gas.clone()),
3125                HopDescriptor::new("component_us".to_string(), uni.clone(), usdc.clone())
3126                    .with_amounts(BigUint::from(1600u64), gas.clone()),
3127                HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
3128                    .with_amounts(BigUint::from(1600u64), gas.clone()),
3129                HopDescriptor::new("component_dw".to_string(), dai.clone(), wbtc.clone())
3130                    .with_amounts(BigUint::from(3200u64), gas),
3131            ],
3132            flow_fraction: 0.4,
3133            amount_in: BigUint::from(400u64),
3134            amount_out: BigUint::from(3200u64),
3135            marginal_price_product: 8.0,
3136        };
3137
3138        // merge_shared_hops collapses Component A and Component B into single entries.
3139        let merged = merge_shared_hops(&[path1.clone(), path2.clone()]);
3140        assert_eq!(
3141            merged[&usdc.address]
3142                .iter()
3143                .filter(|s| s.hop.component_id == "component_a")
3144                .count(),
3145            1,
3146            "merge_shared_hops merges component_a into one"
3147        );
3148        assert_eq!(
3149            merged[&pepe.address]
3150                .iter()
3151                .filter(|s| s.hop.component_id == "component_b")
3152                .count(),
3153            1,
3154            "merge_shared_hops merges component_b into one"
3155        );
3156
3157        // build_split_route rejects the combination.
3158        let err = build_split_route(&[path1, path2], &market, &ord)
3159            .expect_err("must reject cyclic path combination");
3160        assert!(
3161            matches!(&err, AlgorithmError::Other(msg) if msg.contains("dependency cycle")),
3162            "expected AlgorithmError::Other with dependency cycle, got: {err}"
3163        );
3164    }
3165}