Skip to main content

fynd_core/algorithm/water_fill/
mod.rs

1//! Water-fill split-routing algorithm (`water_fill`).
2//!
3//! For large orders, price impact makes it better to split the order across several parallel routes
4//! so the marginal price stays low. `WaterFillAlgorithm` builds up to four candidate routes and
5//! returns the one with the best output net of gas, never worse than the best single path.
6//!
7//! The four candidates are the best single path plus three ways to split it:
8//!
9//! * **20-chunk disjoint split** — splits across paths that share no component, in 20 chunks. This
10//!   is the safety net: it is cheap and always finishes, so a tight timeout cannot cut off the
11//!   split while leaving the single path. That is what makes the never-lose guarantee hold under
12//!   time pressure.
13//! * **256-chunk disjoint split** — the same component-disjoint paths in 256 chunks, in two phases:
14//!   first pick which paths to use at coarse (20-chunk) granularity, where each path's share is
15//!   large enough for its gas-activation gate to be meaningful; then allocate across the chosen
16//!   paths at fine (256-chunk) granularity with the gate off, since their gas is already covered.
17//! * **Fill-and-spill** — a split that lets paths share a component and branch at an intermediate
18//!   token (a tree route), which the component-disjoint splits cannot express.
19//!
20//! Every split fills the order in chunks: for each chunk, it checks how much each path returns for
21//! that chunk and gives the chunk to the best one. Because constant-product / tick AMMs are
22//! path-independent in cumulative input (one swap of `x` equals two back-to-back swaps summing to
23//! `x`), it simulates only the next chunk against the component state committed so far — O(chunks)
24//! instead of O(chunks^2). That saved work makes the 256-chunk split affordable.
25//!
26//! Candidate discovery (the "Candidate discovery" section below) combines an exhaustive path
27//! enumeration with a bounded, amount-aware frontier search, so connector and anchor routes
28//! (including the native-ETH sentinel) survive the spot × depth cutoff. Every returned route is
29//! assembled through the shared split primitives and can be encoded on-chain.
30
31mod config;
32mod models;
33
34use std::{
35    cmp::{Ordering, Reverse},
36    time::{Duration, Instant},
37};
38
39use models::{
40    CandidatePathState, CandidateSearchConfig, Deadline, Discovery, ExchangeMove,
41    FullAmountOutcome, FullAmountRanking, ScoredEdge, SetupResult, SolveInput, SolveStage,
42    SplitCandidate, StepResult,
43};
44use num_bigint::{BigInt, BigUint};
45use num_traits::Zero;
46use petgraph::{graph::NodeIndex, prelude::EdgeRef};
47use rustc_hash::{FxHashMap, FxHashSet};
48use tracing::{debug, instrument, trace};
49use tycho_simulation::tycho_common::{models::Address, simulation::protocol_sim::ProtocolSim};
50
51use super::{
52    most_liquid::DepthAndPrice,
53    paths,
54    sim_meter::{self, MeteredProtocolSim},
55    split_primitives::{
56        build_split_route, HopDescriptor, MarketOverrides, PathAllocation, SimulatedHop,
57    },
58    Algorithm, AlgorithmConfig, NoPathReason,
59};
60use crate::{
61    algorithm::{
62        paths::read_market,
63        swap_cache::{PoolDirection, Refusal, SwapCache, SwapResult},
64        water_fill::config::{
65            BASELINE_CANDIDATES, CANDIDATE_CONNECTOR_EDGES_PER_TOKEN,
66            CANDIDATE_DIRECT_EDGES_PER_TOKEN, CANDIDATE_EDGES_PER_STATE, CANDIDATE_STATES_PER_NODE,
67            COARSE_CHUNKS, DEFAULT_MAX_CANDIDATES, DEFAULT_MAX_PATHS, DERIVED_ANCHOR_COUNT,
68            EXCHANGE_DELTA_FLOOR, EXCHANGE_MAX_SIMS, FINE_CHUNKS, MAX_DISCOVERY_CANDIDATES,
69            SHARED_FULL_PATHS, SHARED_MARGIN_PATHS, SHARED_MARGIN_PROBE_PATHS,
70            SHARED_MAX_CANDIDATES,
71        },
72    },
73    derived::{computation::ComputationRequirements, types::TokenGasPrices, SharedDerivedDataRef},
74    feed::market_data::{MarketData, MarketDataView, MarketState, StateLabel},
75    graph::{EdgeData, GraphQueryFilter, Path, TopologyGraph, TopologyGraphManager},
76    types::{ComponentId, Order, RouteResult},
77    AlgorithmError,
78};
79
80/// Splits an order across component-disjoint (and, via fill-and-spill, component-sharing) paths to
81/// reduce price impact, returning the best net of the single path and several split allocations.
82pub struct WaterFillAlgorithm {
83    /// The hop bounds and connector tokens every route search runs under.
84    query: GraphQueryFilter,
85    timeout: Duration,
86    max_candidates: usize,
87    max_paths: usize,
88}
89
90impl WaterFillAlgorithm {
91    /// Creates a `WaterFillAlgorithm` from an `AlgorithmConfig`.
92    pub(crate) fn with_config(config: AlgorithmConfig) -> Result<Self, AlgorithmError> {
93        Ok(Self {
94            query: GraphQueryFilter {
95                min_hops: config.min_hops(),
96                max_hops: config.max_hops(),
97                connector_tokens: config.connector_tokens().cloned(),
98            },
99            timeout: config.timeout(),
100            max_candidates: config
101                .max_routes()
102                .unwrap_or(DEFAULT_MAX_CANDIDATES)
103                .max(DEFAULT_MAX_PATHS),
104            max_paths: DEFAULT_MAX_PATHS,
105        })
106    }
107
108    /// Simulates `amount` through `path`, reading each component from `overlay` if present else the
109    /// base state. Returns the output, summed gas, and the resulting per-component states so
110    /// the caller can commit them into an overlay.
111    fn simulate_step<'g>(
112        path: &Path<'g, DepthAndPrice>,
113        market: &MarketState,
114        overlay: &MarketOverrides,
115        amount: BigUint,
116    ) -> Option<StepResult> {
117        let mut current = amount;
118        let mut total_gas = BigUint::zero();
119        // A component touched twice in one path must see its own first swap, which needs an
120        // intra-path overlay carried across hops. That reuse is rare, so only pay the
121        // per-hop state clone when the path actually repeats a component; the common case
122        // skips the clone entirely.
123        let path_reuses_component = path_reuses_component(path);
124        let mut intra_path_states: FxHashMap<ComponentId, Box<dyn ProtocolSim>> =
125            FxHashMap::default();
126        let mut new_states: Vec<(ComponentId, Box<dyn ProtocolSim>)> =
127            Vec::with_capacity(path.len());
128
129        for (address_in, edge, address_out) in path.iter() {
130            let token_in = market.get_token(address_in)?;
131            let token_out = market.get_token(address_out)?;
132            let component_id = &edge.component_id;
133            let state = hop_state(market, component_id, Some(&intra_path_states), Some(overlay))?;
134            let result = state
135                .get_amount_out_metered(
136                    component_id,
137                    SolveStage::Chunking.label(),
138                    current.clone(),
139                    token_in,
140                    token_out,
141                )
142                .ok()?;
143            total_gas += &result.gas;
144            if path_reuses_component {
145                intra_path_states.insert(component_id.clone(), result.new_state.clone_box());
146            }
147            new_states.push((component_id.clone(), result.new_state));
148            current = result.amount;
149        }
150        Some(StepResult { amount_out: current, gas: total_gas, new_states })
151    }
152
153    /// Converts a gas amount to output-token terms. Returns `None` if no price is available.
154    fn gas_cost_in_token(
155        total_gas: &BigUint,
156        gas_price_wei: &BigUint,
157        token_prices: Option<&TokenGasPrices>,
158        token_out: &Address,
159    ) -> Option<BigUint> {
160        let price = token_prices?.get(token_out)?;
161        if price.denominator.is_zero() {
162            return None;
163        }
164        Some(total_gas * gas_price_wei * &price.numerator / &price.denominator)
165    }
166
167    /// A path's gas converted to output-token terms as a signed amount, or zero when no gas price
168    /// is available. Subtracted from gross output to get net, so gas-blind solves fall back to
169    /// gross ranking rather than erroring.
170    fn activation_cost(input: &SolveInput, gas: &BigUint) -> BigInt {
171        Self::gas_cost_in_token(
172            gas,
173            &input.gas_price,
174            input.token_prices.as_ref(),
175            input.order.token_out(),
176        )
177        .map(BigInt::from)
178        .unwrap_or_else(BigInt::zero)
179    }
180
181    /// Picks up to `max_paths` paths that share no component, so their outputs can be summed
182    /// without re-simulating — two paths through the same component compete for its liquidity,
183    /// so their separate outputs would not add up.
184    ///
185    /// Walks `ranked` best first and keeps a path only if none of its components are already used
186    /// by a kept path, skipping it otherwise. Returns the kept paths' indices into `ranked`.
187    fn select_disjoint(ranked: &[Path<DepthAndPrice>], max_paths: usize) -> Vec<usize> {
188        let mut visited_components: FxHashSet<&ComponentId> = FxHashSet::default();
189        let mut selected = Vec::new();
190        for (idx, path) in ranked.iter().enumerate() {
191            let path_components: Vec<&ComponentId> = path
192                .edge_iter()
193                .iter()
194                .map(|e| &e.component_id)
195                .collect();
196            if path_components
197                .iter()
198                .any(|c| visited_components.contains(*c))
199            {
200                continue;
201            }
202            for c in path_components {
203                visited_components.insert(c);
204            }
205            selected.push(idx);
206            if selected.len() >= max_paths {
207                break;
208            }
209        }
210        selected
211    }
212
213    /// Shared setup: enumerate + rank candidates, simulate at full amount, pick the best single
214    /// path if any (a path that fails at the full amount is kept as a split-only candidate).
215    #[instrument(level = "debug", skip_all)]
216    async fn setup<'o, 'g>(
217        &self,
218        graph: &'g TopologyGraph<DepthAndPrice>,
219        market: MarketData,
220        label: Option<StateLabel>,
221        derived: Option<SharedDerivedDataRef>,
222        order: &'o Order,
223        deadline: Deadline,
224    ) -> Result<SetupResult<'o, 'g>, AlgorithmError> {
225        let token_prices = if let Some(ref derived) = derived {
226            derived
227                .read()
228                .await
229                .token_prices()
230                .cloned()
231        } else {
232            None
233        };
234
235        let mut scored_paths = self.top_scored_paths(graph, order)?;
236        let mut joined_paths = Vec::new();
237
238        let market_view = read_market(&market, label).await?;
239
240        // Bounded amount-aware discovery (see the discovery section below): union its
241        // candidates ahead of the pre-ranked set, so connector/anchor routes (incl. the
242        // native-ETH sentinel) survive the spot×depth truncation. Discovery failure is not
243        // fatal — the pre-ranked set already guarantees a route.
244        let anchor_tokens = derive_anchor_tokens(graph);
245
246        // Discovery and ranking both swap against untouched state, so they share one cache: every
247        // frontier edge discovery simulates is an answer ranking would otherwise pay for again.
248        // It outlives setup because the allocation passes that read untouched state reuse it too.
249        let mut cache = SwapCache::new();
250        sim_meter::start_solve();
251
252        let discovered_paths = discover_paths(
253                graph,
254                &market_view,
255                order,
256                &mut cache,
257                CandidateSearchConfig {
258                    query: &self.query,
259                    max_candidates: MAX_DISCOVERY_CANDIDATES,
260                    anchor_tokens: &anchor_tokens,
261                    source_token: order.token_in(),
262                    deadline,
263                },
264            )
265            .inspect_err(|e| {
266                debug!(error = %e, "water-fill bounded discovery failed; using exhaustive candidates only")
267            })
268            .unwrap_or_default();
269
270        let mut keys: FxHashSet<Vec<ComponentId>> = scored_paths
271            .iter()
272            .map(path_key)
273            .collect();
274        for path in discovered_paths {
275            if keys.insert(path_key(&path)) {
276                joined_paths.push(path);
277            }
278        }
279        joined_paths.append(&mut scored_paths);
280
281        let component_ids: FxHashSet<&ComponentId> = joined_paths
282            .iter()
283            .flat_map(|p| {
284                p.edge_iter()
285                    .iter()
286                    .map(|e| &e.component_id)
287            })
288            .collect();
289        let market_state = market_view.extract_subset_with_overlay(&component_ids);
290        let gas_price = paths::fetch_gas_price(&market_state)?;
291        drop(market_view);
292
293        let amount_in = order.amount().clone();
294        // Holds the candidates in enumeration order to start with; ranking reorders them below.
295        let mut input = SolveInput {
296            ordered: joined_paths,
297            market: market_state,
298            gas_price,
299            token_prices,
300            order,
301            deadline,
302        };
303        let ranking = self.rank_at_full_amount(&input, &mut cache);
304
305        // The baseline is the bar every split has to beat, so it is settled on exact figures.
306        // Ranking may have read a path's amounts across two nearby ones, which understates and can
307        // demote a path that deserved the top place; taking the best of the top few as simulated
308        // costs a handful of builds and keeps the bar at the best single path really on offer.
309        // Building is what copies a component and a pool state per leg, which is why it is a few
310        // and not all of them.
311        let build_baseline = |path_ix: usize| {
312            paths::simulate_pool_path(
313                &input.ordered[path_ix],
314                &input.market,
315                input.token_prices.as_ref(),
316                amount_in.clone(),
317            )
318            .ok()
319        };
320        let best_single = ranking
321            .by_output_net_gas
322            .iter()
323            .take(BASELINE_CANDIDATES)
324            .filter_map(|&path_ix| build_baseline(path_ix))
325            .max_by(|a, b| {
326                a.net_amount_out()
327                    .cmp(b.net_amount_out())
328            })
329            .or_else(|| {
330                // None of the top few assembled; fall down the list for any route at all.
331                ranking
332                    .by_output_net_gas
333                    .iter()
334                    .skip(BASELINE_CANDIDATES)
335                    .find_map(|&path_ix| build_baseline(path_ix))
336            });
337
338        // No early exit on a missing single path: a split across thin components can fill an order
339        // that no single path can, so the caller decides — it only errors when neither a
340        // single path nor a split candidate fills the order.
341        input.ordered = ranking
342            .by_output
343            .iter()
344            .map(|&path_ix| input.ordered[path_ix].clone())
345            .collect();
346
347        debug!(
348            candidate_paths = input.ordered.len(),
349            elapsed_ms = deadline.elapsed().as_millis(),
350            "water-fill discovery + full-amount ranking"
351        );
352        Ok(SetupResult { input, best_single, cache })
353    }
354
355    /// Simulates every path at the full order amount and ranks them by what they pay.
356    ///
357    /// The paths overlap heavily — thousands open with the same pool, all carrying the whole order
358    /// into it — so every hop goes through `cache` and each distinct swap costs one simulation no
359    /// matter how many paths make it.
360    ///
361    /// A path that crosses one pool twice is dropped instead of ranked: its second crossing would
362    /// have to see its own earlier swap, and every swap here reads untouched state.
363    ///
364    /// Nothing here builds a route. Only the baseline `setup` picks out of the ranking is worth
365    /// copying a component and a pool state per leg.
366    fn rank_at_full_amount<'g>(
367        &self,
368        input: &SolveInput<'_, 'g>,
369        cache: &mut SwapCache<'g>,
370    ) -> FullAmountRanking {
371        let paths = &input.ordered;
372        let order_amount = input.order.amount();
373        let mut outcomes_by_path: Vec<Option<FullAmountOutcome>> = vec![None; paths.len()];
374
375        for (path_ix, path) in paths.iter().enumerate() {
376            if input.deadline.expired() {
377                break;
378            }
379            if path_reuses_component(path) {
380                continue;
381            }
382            outcomes_by_path[path_ix] = Some(
383                match simulate_path(
384                    path,
385                    &input.market,
386                    cache,
387                    order_amount.clone(),
388                    SolveStage::Ranking,
389                ) {
390                    Some(paid) => FullAmountOutcome::Filled(paid),
391                    None => FullAmountOutcome::Unfilled,
392                },
393            );
394        }
395
396        rank_outcomes(
397            outcomes_by_path,
398            &input.gas_price,
399            input.token_prices.as_ref(),
400            input.order.token_out(),
401        )
402    }
403
404    /// Every path the graph holds between the order's tokens, best spot-price-times-depth score
405    /// first, cut to `max_candidates`. A path no edge weight covers sorts behind every scored one.
406    fn top_scored_paths<'a>(
407        &self,
408        graph: &'a TopologyGraph<DepthAndPrice>,
409        order: &Order,
410    ) -> Result<Vec<Path<'a, DepthAndPrice>>, AlgorithmError> {
411        let all_paths =
412            paths::find_paths(graph, order.token_in(), order.token_out(), &self.query, None)?;
413        if all_paths.is_empty() {
414            return Err(AlgorithmError::NoPath {
415                from: order.token_in().clone(),
416                to: order.token_out().clone(),
417                reason: NoPathReason::NoGraphPath,
418            });
419        }
420
421        let path_count = all_paths.len();
422        let mut scored_count = 0usize;
423
424        // A path no edge weight covers sorts behind every scored one rather than level with a
425        // path scored at zero: the score is a spot price times a depth, so zero is a real score
426        // that a weightless path has not earned.
427        let mut scored: Vec<(Path<DepthAndPrice>, f64)> = all_paths
428            .into_iter()
429            .map(|path| {
430                let score = match paths::try_score_path(&path) {
431                    Some(score) => {
432                        scored_count += 1;
433                        score
434                    }
435                    None => f64::MIN,
436                };
437                (path, score)
438            })
439            .collect();
440        scored.sort_by(|(_, a), (_, b)| {
441            b.partial_cmp(a)
442                .unwrap_or(std::cmp::Ordering::Equal)
443        });
444
445        trace!(path_count, scored_count, limit = self.max_candidates, "water-fill path scoring");
446
447        scored.truncate(self.max_candidates);
448
449        Ok(scored
450            .into_iter()
451            .map(|(p, _)| p)
452            .collect())
453    }
454}
455
456impl Algorithm for WaterFillAlgorithm {
457    type GraphType = TopologyGraph<DepthAndPrice>;
458    type GraphManager = TopologyGraphManager<DepthAndPrice>;
459
460    fn name(&self) -> &str {
461        "water_fill"
462    }
463
464    #[instrument(level = "debug", skip_all, fields(order_id = %order.id()))]
465    async fn find_best_route(
466        &self,
467        graph: &Self::GraphType,
468        market: MarketData,
469        label: Option<StateLabel>,
470        derived: Option<SharedDerivedDataRef>,
471        order: &Order,
472    ) -> Result<RouteResult, AlgorithmError> {
473        let deadline = Deadline::new(Instant::now(), self.timeout);
474        if !order.is_sell() {
475            return Err(AlgorithmError::ExactOutNotSupported);
476        }
477
478        let SetupResult { input, best_single, mut cache } = self
479            .setup(graph, market, label, derived, order, deadline)
480            .await?;
481
482        // Build the split candidates; the best net of them competes with the single path.
483        let mut candidates: Vec<SplitCandidate> = Vec::new();
484        // One coarse (20-chunk) water-fill over the component-disjoint paths feeds both the floor
485        // split and the refined split, so run it once. It is cheap and always finishes, so
486        // a tight timeout cannot cut it off while leaving the single path — a winning split
487        // is never lost to the clock.
488        let disjoint = Self::select_disjoint(&input.ordered, self.max_paths);
489        let coarse = (disjoint.len() >= 2)
490            .then(|| self.disjoint_waterfill(&input, &disjoint, COARSE_CHUNKS, true))
491            .flatten();
492        if let Some(coarse) = coarse.as_deref() {
493            // The 20-chunk floor split: exactly the coarse allocation.
494            if let Some(c) = self.build_disjoint_legs(&input, &disjoint, coarse) {
495                candidates.push(c);
496            }
497            // Finer allocation over the same active set (a bonus; a timeout may cut it off, and
498            // then the floor stands).
499            if let Some(c) =
500                self.disjoint_refine(&input, &disjoint, coarse, FINE_CHUNKS, &mut cache)
501            {
502                candidates.push(c);
503            }
504        }
505        // Fill-and-spill: a split that lets paths share a component and branch at an intermediate
506        // token (a tree route), which the component-disjoint splits cannot express.
507        if let Some(c) = self.fillspill_alloc(&input, FINE_CHUNKS, &mut cache) {
508            candidates.push(c);
509        }
510
511        // Pick the best-net result. A split candidate must strictly beat the single-path baseline
512        // to win; with no baseline (no single path fills the order) the best split wins outright.
513        let candidate_count = candidates.len();
514        let baseline_net = best_single
515            .as_ref()
516            .map(|b| b.net_amount_out().clone());
517        let mut best: Option<(BigInt, SplitCandidate)> = None;
518        for cand in candidates {
519            let net = cand.net(&input);
520            let beats = match (&best, &baseline_net) {
521                (Some((current, _)), _) => net > *current,
522                (None, Some(base)) => net > *base,
523                (None, None) => true,
524            };
525            if beats {
526                best = Some((net, cand));
527            }
528        }
529        let split_won = best.is_some();
530        sim_meter::report(&input.market, || deadline.elapsed().as_millis() as u64);
531        debug!(
532            candidate_count,
533            split_won,
534            elapsed_ms = deadline.elapsed().as_millis(),
535            "water-fill selected {}",
536            if split_won { "split candidate" } else { "single path" }
537        );
538        match best {
539            Some((net, cand)) => Ok(RouteResult::new(cand.route, net, input.gas_price.clone())),
540            // No split won: return the single path if there is one, else nothing fills the order.
541            None => best_single.ok_or(AlgorithmError::InsufficientLiquidity),
542        }
543    }
544
545    fn computation_requirements(&self) -> ComputationRequirements {
546        ComputationRequirements::none()
547            .allow_stale("token_prices")
548            .expect("Conflicting Computation Requirements")
549    }
550
551    fn timeout(&self) -> Duration {
552        self.timeout
553    }
554}
555
556impl WaterFillAlgorithm {
557    /// Refines the 20-chunk floor split on a finer grid. Given the shared coarse water-fill the
558    /// floor is built from, it fixes the active path set (the coarse-gated paths with a nonzero
559    /// amount), re-allocates over that set on a fine grid with the gate off (gas already
560    /// justified), then runs the exchange-refinement pass. If a tight timeout cuts off either pass
561    /// this returns `None` and the caller falls back to the 20-chunk floor candidate.
562    fn disjoint_refine<'g>(
563        &self,
564        input: &SolveInput<'_, 'g>,
565        disjoint: &[usize],
566        coarse: &[BigUint],
567        fine_chunks: usize,
568        cache: &mut SwapCache<'g>,
569    ) -> Option<SplitCandidate> {
570        // The active set is the coarse-gated paths that were allocated a nonzero amount.
571        let active: Vec<usize> = disjoint
572            .iter()
573            .copied()
574            .zip(coarse.iter())
575            .filter(|(_, amt)| !amt.is_zero())
576            .map(|(idx, _)| idx)
577            .collect();
578        if active.is_empty() {
579            return None;
580        }
581
582        // Fine water-fill over the fixed active set, no gate (gas already justified).
583        let fine = self.disjoint_waterfill(input, &active, fine_chunks, false)?;
584
585        // Exchange refinement. The fine water-fill quantizes each path to a whole chunk, so it can
586        // sit up to one chunk off the equal-marginal optimum. Nudge flow between paths at sub-chunk
587        // resolution, accepting only strictly-improving moves (never-lose).
588        let refined = self.disjoint_exchange(input, &active, fine_chunks, fine, cache);
589        self.build_disjoint_legs(input, &active, &refined)
590    }
591
592    /// Net output (gross output minus gas cost in output-token terms) of `path` simulated in
593    /// isolation at `amount`. Component-disjoint paths never interfere, so an isolated
594    /// re-simulation is exact. A zero amount means the path is dropped from the route: it
595    /// yields no output and, since it is no longer swapped, no gas — so dropping a donor
596    /// credits its saved gas automatically.
597    fn path_net<'g>(
598        input: &SolveInput<'_, 'g>,
599        path: &Path<'g, DepthAndPrice>,
600        amount: &BigUint,
601        cache: &mut SwapCache<'g>,
602    ) -> Option<BigInt> {
603        if amount.is_zero() {
604            return Some(BigInt::zero());
605        }
606        let result =
607            simulate_path(path, &input.market, cache, amount.clone(), SolveStage::Exchange)?;
608        let activation = Self::activation_cost(input, &result.gas);
609        Some(BigInt::from(result.amount_out) - activation)
610    }
611
612    /// Exchange-refinement pass over the fixed active set, starting from the fine water-fill split.
613    /// Water-fill can never un-commit a chunk, so its split is only accurate to one fine chunk and
614    /// can miss the equal-marginal split. This shifts `delta` of input from an over-allocated donor
615    /// to an under-allocated recipient whenever the pair's summed net output strictly improves,
616    /// then halves `delta` once no move helps, down to a sub-chunk floor. Paths are
617    /// component-disjoint, so a trial re-simulates only the two paths it touches (unchanged
618    /// paths keep their cached net). Only strictly-improving moves are accepted, so the result
619    /// never scores below the split it started from.
620    fn disjoint_exchange<'g>(
621        &self,
622        input: &SolveInput<'_, 'g>,
623        active: &[usize],
624        fine_chunks: usize,
625        alloc: Vec<BigUint>,
626        cache: &mut SwapCache<'g>,
627    ) -> Vec<BigUint> {
628        let path_count = active.len();
629        if path_count < 2 {
630            return alloc;
631        }
632        let amount_in = input.order.amount().clone();
633        let fine_chunks = fine_chunks.max(1);
634        let mut delta = &amount_in / fine_chunks;
635        let min_delta = &amount_in / (fine_chunks * EXCHANGE_DELTA_FLOOR);
636        if delta.is_zero() {
637            return alloc;
638        }
639
640        let mut cumulative_amount_in = alloc;
641        // Cache each active path's net at its current cumulative_amount_in amount so a pair trial
642        // only re-simulates the two paths it moves flow between, not the whole active set.
643        let mut net_cache: Vec<BigInt> = Vec::with_capacity(path_count);
644        for (i, &path_idx) in active.iter().enumerate() {
645            let Some(net) =
646                Self::path_net(input, &input.ordered[path_idx], &cumulative_amount_in[i], cache)
647            else {
648                // The starting split does not simulate cleanly; refining it is unsafe, so keep it.
649                return cumulative_amount_in;
650            };
651            net_cache.push(net);
652        }
653
654        let mut sims = 0usize;
655        while delta >= min_delta && !delta.is_zero() {
656            if input.deadline.expired() || sims >= EXCHANGE_MAX_SIMS {
657                break;
658            }
659
660            let mut best: Option<ExchangeMove> = None;
661            for donor in 0..path_count {
662                if sims >= EXCHANGE_MAX_SIMS {
663                    break;
664                }
665                if cumulative_amount_in[donor] < delta {
666                    continue;
667                }
668                let donor_amt = &cumulative_amount_in[donor] - &delta;
669                let Some(donor_net) =
670                    Self::path_net(input, &input.ordered[active[donor]], &donor_amt, cache)
671                else {
672                    continue;
673                };
674                sims += 1;
675                for recipient in 0..path_count {
676                    if sims >= EXCHANGE_MAX_SIMS {
677                        break;
678                    }
679                    if recipient == donor {
680                        continue;
681                    }
682                    let recip_amt = &cumulative_amount_in[recipient] + &delta;
683                    let Some(recip_net) =
684                        Self::path_net(input, &input.ordered[active[recipient]], &recip_amt, cache)
685                    else {
686                        continue;
687                    };
688                    sims += 1;
689                    let before = &net_cache[donor] + &net_cache[recipient];
690                    let after = &donor_net + &recip_net;
691                    if after <= before {
692                        continue;
693                    }
694                    let gain = after - before;
695                    if best
696                        .as_ref()
697                        .map(|m| gain > m.gain)
698                        .unwrap_or(true)
699                    {
700                        best = Some(ExchangeMove {
701                            donor,
702                            recipient,
703                            donor_net: donor_net.clone(),
704                            recip_net,
705                            gain,
706                        });
707                    }
708                }
709            }
710
711            let Some(mv) = best else {
712                delta = &delta / 2usize;
713                continue;
714            };
715            cumulative_amount_in[mv.donor] = &cumulative_amount_in[mv.donor] - &delta;
716            cumulative_amount_in[mv.recipient] = &cumulative_amount_in[mv.recipient] + &delta;
717            net_cache[mv.donor] = mv.donor_net;
718            net_cache[mv.recipient] = mv.recip_net;
719        }
720        cumulative_amount_in
721    }
722
723    /// Simulates `amount` through `path`, reading and committing component states via `overrides`,
724    /// and returns the allocation the route assembly consumes.
725    fn allocation_commit<'g>(
726        path: &Path<'g, DepthAndPrice>,
727        market: &MarketState,
728        overrides: &mut MarketOverrides,
729        amount: BigUint,
730        flow_fraction: f64,
731    ) -> Option<PathAllocation> {
732        let amount_in = amount;
733        let mut current = amount_in.clone();
734        let mut hops = Vec::with_capacity(path.len());
735
736        for (address_in, edge, address_out) in path.iter() {
737            let token_in = market.get_token(address_in)?;
738            let token_out = market.get_token(address_out)?;
739            let component_id = &edge.component_id;
740            let state = hop_state(market, component_id, None, Some(overrides))?;
741            let result = state
742                .get_amount_out_metered(
743                    component_id,
744                    SolveStage::Assembly.label(),
745                    current.clone(),
746                    token_in,
747                    token_out,
748                )
749                .ok()?;
750            hops.push(SimulatedHop {
751                descriptor: HopDescriptor::new(
752                    component_id.clone(),
753                    token_in.clone(),
754                    token_out.clone(),
755                ),
756                amount_out: result.amount.clone(),
757                gas: result.gas.clone(),
758            });
759            overrides.insert(component_id.clone(), result.new_state);
760            current = result.amount;
761        }
762
763        Some(PathAllocation {
764            hops,
765            flow_fraction,
766            amount_in,
767            amount_out: current,
768            marginal_price_product: 0.0,
769        })
770    }
771
772    /// Assembles a route from the allocations via the shared split primitives (topological swap
773    /// order, tycho-execution remainder-split convention, route token map) and derives the
774    /// candidate's gross output and gas from the assembled route.
775    fn candidate_from_allocations(
776        input: &SolveInput,
777        allocations: &[PathAllocation],
778    ) -> Option<SplitCandidate> {
779        let route = build_split_route(allocations, &input.market, input.order).ok()?;
780        let token_out = input.order.token_out();
781        let gross = route
782            .swaps()
783            .iter()
784            .filter(|s| s.token_out() == token_out)
785            .fold(BigUint::zero(), |acc, s| acc + s.amount_out());
786        if gross.is_zero() {
787            return None;
788        }
789        let gas = route.total_gas();
790        Some(SplitCandidate { route, gross, gas })
791    }
792
793    /// Builds one independent leg per path at its allocated amount. `subset` and `alloc` are
794    /// aligned by index; component-disjoint paths never interfere, so each leg is a real
795    /// independent simulation.
796    fn build_disjoint_legs<'g>(
797        &self,
798        input: &SolveInput<'_, 'g>,
799        subset: &[usize],
800        alloc: &[BigUint],
801    ) -> Option<SplitCandidate> {
802        let amount_in = input.order.amount().clone();
803        let mut allocations = Vec::new();
804        for (i, &path_idx) in subset.iter().enumerate() {
805            if alloc[i].is_zero() {
806                continue;
807            }
808            // Fresh overrides per leg: legs are component-disjoint, but a component reused within
809            // one path must still see its own first swap.
810            let mut overrides = MarketOverrides::empty();
811            let allocation = Self::allocation_commit(
812                &input.ordered[path_idx],
813                &input.market,
814                &mut overrides,
815                alloc[i].clone(),
816                ratio(&alloc[i], &amount_in),
817            )?;
818            allocations.push(allocation);
819        }
820        if allocations.is_empty() {
821            return None;
822        }
823        Self::candidate_from_allocations(input, &allocations)
824    }
825
826    /// Incremental water-fill over a set of component-disjoint paths. Returns the amount allocated
827    /// to each path in `subset` order. With `gate`, a path only activates when its first chunk
828    /// covers its gas; without it, every path is eligible (used once the active set is fixed).
829    fn disjoint_waterfill<'g>(
830        &self,
831        input: &SolveInput<'_, 'g>,
832        subset: &[usize],
833        num_chunks: usize,
834        gate: bool,
835    ) -> Option<Vec<BigUint>> {
836        let amount_in = input.order.amount().clone();
837        let num_chunks = num_chunks.max(1);
838        let base_chunk = &amount_in / num_chunks;
839        if base_chunk.is_zero() {
840            return None;
841        }
842        let remainder = &amount_in - &base_chunk * num_chunks;
843        let path_count = subset.len();
844
845        let mut committed: Vec<MarketOverrides> = (0..path_count)
846            .map(|_| MarketOverrides::empty())
847            .collect();
848        let mut cumulative_amount_in: Vec<BigUint> = vec![BigUint::zero(); path_count];
849        let mut activated: Vec<bool> = vec![!gate; path_count];
850
851        // What each path last paid for a chunk. Only the path that wins a chunk commits anything,
852        // and these paths share no component, so every other path is asked the same question of the
853        // same untouched pools next chunk and pays the same. Its marginal is kept rather than
854        // simulated again.
855        let mut marginals: Vec<Option<StepResult>> = (0..path_count).map(|_| None).collect();
856        // The chunk every remembered marginal was priced at. The first one carries the remainder,
857        // so what the paths are asked changes once and nothing remembered still answers it.
858        let mut marginals_chunk: Option<BigUint> = None;
859
860        for chunk_idx in 0..num_chunks {
861            if input.deadline.expired() {
862                break;
863            }
864            let chunk = if chunk_idx == 0 { &base_chunk + &remainder } else { base_chunk.clone() };
865            if marginals_chunk.as_ref() != Some(&chunk) {
866                marginals
867                    .iter_mut()
868                    .for_each(|m| *m = None);
869                marginals_chunk = Some(chunk.clone());
870            }
871
872            let mut best: Option<(usize, BigInt)> = None;
873            for (i, &path_idx) in subset.iter().enumerate() {
874                if marginals[i].is_none() {
875                    marginals[i] = Self::simulate_step(
876                        &input.ordered[path_idx],
877                        &input.market,
878                        &committed[i],
879                        chunk.clone(),
880                    );
881                }
882                let Some(step) = marginals[i].as_ref() else {
883                    continue;
884                };
885                let gross_marginal = BigInt::from(step.amount_out.clone());
886                let net_marginal = if activated[i] {
887                    gross_marginal
888                } else {
889                    let activation = Self::activation_cost(input, &step.gas);
890                    gross_marginal - activation
891                };
892                if best
893                    .as_ref()
894                    .map(|(_, m)| &net_marginal > m)
895                    .unwrap_or(true)
896                {
897                    best = Some((i, net_marginal));
898                }
899            }
900
901            let Some((best_i, _)) = best else {
902                break;
903            };
904            // Take the winner's marginal rather than read it: its pools are about to move, so
905            // what it just paid no longer answers, and taking it is how that is forgotten.
906            let Some(step) = marginals[best_i].take() else {
907                break;
908            };
909
910            for (id, state) in step.new_states {
911                committed[best_i].insert(id, state);
912            }
913            cumulative_amount_in[best_i] += &chunk;
914            activated[best_i] = true;
915        }
916        Some(cumulative_amount_in)
917    }
918
919    /// Selects fill-and-spill candidates: the top full-amount paths plus the best first-chunk
920    /// marginal probes. The probe is what makes intermediate-token splits (tree routes) reachable:
921    /// the extra path often ranks poorly at full size but wins on the margin.
922    fn select_shared_candidates<'g>(
923        &self,
924        input: &SolveInput<'_, 'g>,
925        cache: &mut SwapCache<'g>,
926    ) -> Vec<usize> {
927        let mut candidates: Vec<usize> = (0..input
928            .ordered
929            .len()
930            .min(SHARED_FULL_PATHS))
931            .collect();
932        let first_chunk = input.order.amount() / COARSE_CHUNKS;
933        if first_chunk.is_zero() {
934            return candidates;
935        }
936        let mut marginal: Vec<(usize, BigInt)> = Vec::new();
937        for (idx, path) in input
938            .ordered
939            .iter()
940            .enumerate()
941            .take(SHARED_MARGIN_PROBE_PATHS)
942        {
943            if input.deadline.expired() {
944                break;
945            }
946            // Nothing is committed yet, so these probes read untouched state and go through the
947            // cache like every other swap that does.
948            let Some(probe) = simulate_path(
949                path,
950                &input.market,
951                cache,
952                first_chunk.clone(),
953                SolveStage::SetSelection,
954            ) else {
955                continue;
956            };
957            let activation = Self::activation_cost(input, &probe.gas);
958            marginal.push((idx, BigInt::from(probe.amount_out) - activation));
959        }
960        marginal.sort_by(|(_, a), (_, b)| b.cmp(a));
961        for (idx, net) in marginal
962            .into_iter()
963            .take(SHARED_MARGIN_PATHS)
964        {
965            if net <= BigInt::zero() {
966                continue;
967            }
968            if !candidates.contains(&idx) {
969                candidates.push(idx);
970            }
971            if candidates.len() >= SHARED_MAX_CANDIDATES {
972                break;
973            }
974        }
975        candidates
976    }
977
978    /// Coarse set-selection then fine allocation with shared-component fill-and-spill.
979    ///
980    /// Only the probes that pick the candidate set can use `cache`: the two water-fill passes below
981    /// commit each chunk into an overlay and must see the pools they have already drained.
982    fn fillspill_alloc<'g>(
983        &self,
984        input: &SolveInput<'_, 'g>,
985        fine_chunks: usize,
986        cache: &mut SwapCache<'g>,
987    ) -> Option<SplitCandidate> {
988        let candidates = self.select_shared_candidates(input, cache);
989        if candidates.len() < 2 {
990            return None;
991        }
992
993        // Phase 1: coarse gated pass to choose the active candidate set — the candidates that
994        // took at least one chunk.
995        let coarse = self.fillspill_waterfill(input, &candidates, COARSE_CHUNKS, true)?;
996        let took_a_chunk: FxHashSet<usize> = coarse.iter().map(|(i, _)| *i).collect();
997        let active: Vec<usize> = candidates
998            .iter()
999            .copied()
1000            .enumerate()
1001            .filter(|(i, _)| took_a_chunk.contains(i))
1002            .map(|(_, idx)| idx)
1003            .collect();
1004        if active.len() < 2 {
1005            return None;
1006        }
1007
1008        // Phase 2: fine ungated pass over the active set, with the commit schedule for replay.
1009        let schedule = self.fillspill_waterfill(input, &active, fine_chunks, false)?;
1010        if schedule.is_empty() {
1011            return None;
1012        }
1013
1014        self.build_fillspill_route(input, &active, &schedule)
1015    }
1016
1017    /// Incremental fill-and-spill water-fill over a single shared overlay. Returns the ordered
1018    /// commit schedule of `(subset_index, chunk_amount)`; which candidates took a chunk at all is
1019    /// read off it.
1020    fn fillspill_waterfill<'g>(
1021        &self,
1022        input: &SolveInput<'_, 'g>,
1023        subset: &[usize],
1024        num_chunks: usize,
1025        gate: bool,
1026    ) -> Option<Vec<(usize, BigUint)>> {
1027        let amount_in = input.order.amount().clone();
1028        let num_chunks = num_chunks.max(1);
1029        let base_chunk = &amount_in / num_chunks;
1030        if base_chunk.is_zero() {
1031            return None;
1032        }
1033        let remainder = &amount_in - &base_chunk * num_chunks;
1034
1035        let mut overlay = MarketOverrides::empty();
1036        let mut activated: Vec<bool> = vec![!gate; subset.len()];
1037        let mut active_count = if gate { 0 } else { subset.len() };
1038        let mut schedule: Vec<(usize, BigUint)> = Vec::with_capacity(num_chunks);
1039        // What each candidate last paid for a chunk, kept so a candidate the winning chunk did not
1040        // touch is not asked the same question again. Candidates here may share pools — that is the
1041        // point of fill-and-spill — so committing a chunk forgets every candidate crossing one of
1042        // the pools it moved, not only the winner.
1043        let mut marginals: Vec<Option<StepResult>> = (0..subset.len())
1044            .map(|_| None)
1045            .collect();
1046        // The chunk every remembered marginal was priced at. The first one carries the remainder,
1047        // so what the candidates are asked changes once and nothing remembered still answers it.
1048        let mut marginals_chunk: Option<BigUint> = None;
1049
1050        for chunk_idx in 0..num_chunks {
1051            if input.deadline.expired() {
1052                break;
1053            }
1054            let chunk = if chunk_idx == 0 { &base_chunk + &remainder } else { base_chunk.clone() };
1055            if marginals_chunk.as_ref() != Some(&chunk) {
1056                marginals
1057                    .iter_mut()
1058                    .for_each(|m| *m = None);
1059                marginals_chunk = Some(chunk.clone());
1060            }
1061
1062            let mut best: Option<(usize, BigInt)> = None;
1063            for (i, &path_idx) in subset.iter().enumerate() {
1064                if !activated[i] && active_count >= self.max_paths {
1065                    continue;
1066                }
1067                if marginals[i].is_none() {
1068                    marginals[i] = Self::simulate_step(
1069                        &input.ordered[path_idx],
1070                        &input.market,
1071                        &overlay,
1072                        chunk.clone(),
1073                    );
1074                }
1075                let Some(step) = marginals[i].as_ref() else {
1076                    continue;
1077                };
1078                let gross_marginal = BigInt::from(step.amount_out.clone());
1079                let net_marginal = if activated[i] {
1080                    gross_marginal
1081                } else {
1082                    let activation = Self::activation_cost(input, &step.gas);
1083                    gross_marginal - activation
1084                };
1085                if best
1086                    .as_ref()
1087                    .map(|(_, m)| &net_marginal > m)
1088                    .unwrap_or(true)
1089                {
1090                    best = Some((i, net_marginal));
1091                }
1092            }
1093
1094            let Some((best_i, _)) = best else {
1095                break;
1096            };
1097            // Take the winner's marginal rather than read it: taking is how it is forgotten.
1098            // `best` is only set while that entry holds a marginal, so the `else` cannot be
1099            // reached; it stands in for an unwrap the crate's lints forbid.
1100            let Some(step) = marginals[best_i].take() else {
1101                break;
1102            };
1103
1104            let pools_moved: FxHashSet<&ComponentId> = step
1105                .new_states
1106                .iter()
1107                .map(|(id, _)| id)
1108                .collect();
1109            for (i, &path_idx) in subset.iter().enumerate() {
1110                if input.ordered[path_idx]
1111                    .edge_iter()
1112                    .iter()
1113                    .any(|e| pools_moved.contains(&e.component_id))
1114                {
1115                    marginals[i] = None;
1116                }
1117            }
1118
1119            for (id, state) in step.new_states {
1120                overlay.insert(id, state);
1121            }
1122            if !activated[best_i] {
1123                activated[best_i] = true;
1124                active_count += 1;
1125            }
1126            schedule.push((best_i, chunk));
1127        }
1128
1129        Some(schedule)
1130    }
1131
1132    /// Rebuilds the fill-and-spill result as one leg per active path at its total allocated
1133    /// amount, committed sequentially (largest allocation first) against a shared overlay — the
1134    /// same execution model the router applies on-chain.
1135    fn build_fillspill_route<'g>(
1136        &self,
1137        input: &SolveInput<'_, 'g>,
1138        active: &[usize],
1139        schedule: &[(usize, BigUint)],
1140    ) -> Option<SplitCandidate> {
1141        let amount_in = input.order.amount().clone();
1142        let mut cand_in: Vec<BigUint> = vec![BigUint::zero(); active.len()];
1143        for (i, chunk) in schedule {
1144            cand_in[*i] += chunk;
1145        }
1146        let mut execution_order: Vec<usize> = (0..active.len())
1147            .filter(|&i| !cand_in[i].is_zero())
1148            .collect();
1149        if execution_order.len() < 2 {
1150            return None;
1151        }
1152        execution_order.sort_by(|&a, &b| cand_in[b].cmp(&cand_in[a]));
1153
1154        let mut overrides = MarketOverrides::empty();
1155        let mut allocations = Vec::new();
1156        for i in execution_order {
1157            let allocation = Self::allocation_commit(
1158                &input.ordered[active[i]],
1159                &input.market,
1160                &mut overrides,
1161                cand_in[i].clone(),
1162                ratio(&cand_in[i], &amount_in),
1163            )?;
1164            allocations.push(allocation);
1165        }
1166        Self::candidate_from_allocations(input, &allocations)
1167    }
1168}
1169
1170/// Whether `path` crosses the same component more than once. Such a path has to see its own
1171/// earlier swap, so it cannot be simulated against untouched component state.
1172fn path_reuses_component<W>(path: &Path<'_, W>) -> bool {
1173    let mut seen: FxHashSet<&ComponentId> =
1174        FxHashSet::with_capacity_and_hasher(path.len(), Default::default());
1175    !path
1176        .edge_iter()
1177        .iter()
1178        .all(|e| seen.insert(&e.component_id))
1179}
1180
1181/// Swaps `amount_in` through one hop against the market's untouched state. `None` when the market
1182/// holds no token or state for the hop, or the pool refuses the swap.
1183fn simulate_hop(
1184    market: &MarketState,
1185    component_id: &ComponentId,
1186    address_in: &Address,
1187    address_out: &Address,
1188    amount_in: &BigUint,
1189    pass: SolveStage,
1190) -> Result<SwapResult, Refusal> {
1191    let (Some(token_in), Some(token_out), Some(state)) = (
1192        market.get_token(address_in),
1193        market.get_token(address_out),
1194        market.get_simulation_state(component_id),
1195    ) else {
1196        return Err(Refusal::Failed);
1197    };
1198    state
1199        .get_amount_out_metered(component_id, pass.label(), amount_in.clone(), token_in, token_out)
1200        .map(|result| SwapResult { amount_out: result.amount, gas: result.gas })
1201        .map_err(|error| Refusal::of(&error))
1202}
1203
1204/// The state a hop swaps against: what this path has already moved, then what the pass has
1205/// committed, then the market as it stands.
1206///
1207/// The precedence lives here rather than in each pass's own loop, so two passes cannot quietly
1208/// disagree about which state a hop sees. `intra_path` is `None` for a pass whose paths never
1209/// cross a pool twice, and `committed` is `None` for one that reads untouched state.
1210fn hop_state<'s>(
1211    market: &'s MarketState,
1212    component_id: &ComponentId,
1213    intra_path: Option<&'s FxHashMap<ComponentId, Box<dyn ProtocolSim>>>,
1214    committed: Option<&'s MarketOverrides>,
1215) -> Option<&'s dyn ProtocolSim> {
1216    intra_path
1217        .and_then(|states| {
1218            states
1219                .get(component_id)
1220                .map(Box::as_ref)
1221        })
1222        .or_else(|| committed.and_then(|overrides| overrides.get(component_id)))
1223        .or_else(|| market.get_simulation_state(component_id))
1224}
1225
1226/// Swaps `amount_in` along `path` against untouched component state, going through `cache` so a
1227/// hop some other path already made is not made again. Returns what the path pays and its summed
1228/// gas, or `None` as soon as one hop refuses.
1229///
1230/// The pass must not hand this a path that crosses one pool twice: the second crossing would
1231/// have to see the first one's swap, and every hop here reads untouched state. Both path sets this
1232/// runs on are already free of them — `rank_at_full_amount` drops them, and discovery never builds
1233/// one because `can_extend_path` refuses to repeat a component.
1234fn simulate_path<'a>(
1235    path: &Path<'a, DepthAndPrice>,
1236    market: &MarketState,
1237    cache: &mut SwapCache<'a>,
1238    amount_in: BigUint,
1239    stage: SolveStage,
1240) -> Option<SwapResult> {
1241    // What the next hop swaps; once the last one is done, what the path pays out.
1242    let mut hop_amount_in = amount_in;
1243    let mut path_gas = BigUint::zero();
1244
1245    for (address_in, edge, address_out) in path.iter() {
1246        let direction = PoolDirection { component_id: &edge.component_id, address_in, address_out };
1247        let hop = cache.swap(
1248            direction,
1249            &hop_amount_in,
1250            stage.label(),
1251            || {
1252                simulate_hop(
1253                    market,
1254                    &edge.component_id,
1255                    address_in,
1256                    address_out,
1257                    &hop_amount_in,
1258                    stage,
1259                )
1260            },
1261            stage.may_interpolate(),
1262        )?;
1263        hop_amount_in = hop.amount_out;
1264        path_gas += hop.gas;
1265    }
1266
1267    Some(SwapResult { amount_out: hop_amount_in, gas: path_gas })
1268}
1269
1270/// Turns the full-amount pass's per-path outcomes into the two orderings `setup` consumes. Both
1271/// are built in path order and sorted stably, so paths paying the same amount keep the order they
1272/// were enumerated in. A path with no outcome never ran — ranking timed out before reaching it,
1273/// or it crossed a pool twice — and appears in neither ordering.
1274fn rank_outcomes(
1275    outcomes_by_path: Vec<Option<FullAmountOutcome>>,
1276    gas_price: &BigUint,
1277    token_prices: Option<&TokenGasPrices>,
1278    token_out: &Address,
1279) -> FullAmountRanking {
1280    // A path that took the whole order carries what it pays net of gas; one that could not takes
1281    // no place among them, whatever its gas would have been.
1282    let mut by_output: Vec<(usize, Option<BigInt>)> = Vec::with_capacity(outcomes_by_path.len());
1283    let mut by_output_net_gas: Vec<(usize, BigInt)> = Vec::new();
1284
1285    for (path_ix, outcome) in outcomes_by_path.into_iter().enumerate() {
1286        let Some(outcome) = outcome else {
1287            continue;
1288        };
1289        let net_output = match outcome {
1290            FullAmountOutcome::Filled(paid) => {
1291                let gas_cost = WaterFillAlgorithm::gas_cost_in_token(
1292                    &paid.gas,
1293                    gas_price,
1294                    token_prices,
1295                    token_out,
1296                );
1297                let net_output = BigInt::from(paid.amount_out.clone()) -
1298                    gas_cost.map_or_else(BigInt::zero, BigInt::from);
1299                by_output_net_gas.push((path_ix, net_output.clone()));
1300                Some(net_output)
1301            }
1302            FullAmountOutcome::Unfilled => None,
1303        };
1304        by_output.push((path_ix, net_output));
1305    }
1306
1307    // A path that could not take the whole order ranks below every path that did, however little
1308    // that one is left with: net output can be negative once gas costs more than the path pays,
1309    // and a path that filled for a loss still tells the split passes more than one that failed.
1310    by_output.sort_by(|(_, a), (_, b)| b.cmp(a));
1311    by_output_net_gas.sort_by(|(_, a), (_, b)| b.cmp(a));
1312    FullAmountRanking {
1313        by_output: by_output
1314            .into_iter()
1315            .map(|(path_ix, _)| path_ix)
1316            .collect(),
1317        by_output_net_gas: by_output_net_gas
1318            .into_iter()
1319            .map(|(path_ix, _)| path_ix)
1320            .collect(),
1321    }
1322}
1323
1324/// A path's identity for dedup: its ordered component-id sequence.
1325fn path_key<W>(path: &Path<'_, W>) -> Vec<ComponentId> {
1326    path.edge_iter()
1327        .iter()
1328        .map(|e| e.component_id.clone())
1329        .collect()
1330}
1331
1332/// Computes `numerator / denominator` as an `f64` fraction in `[0, 1]`.
1333fn ratio(numerator: &BigUint, denominator: &BigUint) -> f64 {
1334    use num_traits::ToPrimitive;
1335    let n = numerator.to_f64().unwrap_or(0.0);
1336    let d = denominator.to_f64().unwrap_or(1.0);
1337    if d == 0.0 {
1338        0.0
1339    } else {
1340        n / d
1341    }
1342}
1343
1344// ==================== Candidate discovery ====================
1345//
1346// Bounded, amount-aware frontier search, combined with the exhaustive enumeration by `setup`. It
1347// expands from the sell token: simulate frontier edges live and prefer edges into the output token,
1348// the configured connector-token allowlist, or a set of anchor tokens (a soft ranking hint) derived
1349// per solve from the graph — the most-connected tokens plus the native-ETH sentinel (see
1350// `derive_anchor_tokens`). Generic over the graph's edge weight `W`
1351// (discovery only reads `component_id`s) so it runs on the production `DepthAndPrice` graph while
1352// tests exercise it on a bare topology graph.
1353
1354/// Runs the bounded discovery and returns the candidate paths plus their `(index, full-amount
1355/// gross output)` ranking, best first.
1356fn discover_paths<'a, W>(
1357    graph: &'a TopologyGraph<W>,
1358    market: &MarketDataView<'_>,
1359    order: &Order,
1360    cache: &mut SwapCache<'a>,
1361    cfg: CandidateSearchConfig<'_>,
1362) -> Result<Vec<Path<'a, W>>, AlgorithmError>
1363where
1364    W: Clone,
1365{
1366    let (from_idx, to_idx) = get_token_ixs(graph, order)?;
1367
1368    let mut found = Vec::new();
1369    let mut frontier = vec![CandidatePathState {
1370        node: from_idx,
1371        path: Path::new(),
1372        amount_out: order.amount().clone(),
1373    }];
1374
1375    for _depth in 0..cfg.query.max_hops {
1376        if cfg.deadline.expired() || frontier.is_empty() {
1377            break;
1378        }
1379        let mut next_by_node: FxHashMap<NodeIndex, Vec<CandidatePathState<'a, W>>> =
1380            FxHashMap::default();
1381        for state in frontier {
1382            if state.node == to_idx && from_idx != to_idx {
1383                continue;
1384            }
1385
1386            let mut discovery = Discovery { graph, market, cfg: &cfg, cache };
1387            expand_candidate_state(&mut discovery, to_idx, state, &mut found, &mut next_by_node);
1388        }
1389        frontier = prune_candidate_frontier(next_by_node);
1390    }
1391
1392    rank_found_candidate_paths(found, cfg.max_candidates, order)
1393}
1394
1395/// The graph nodes holding the order's sell and buy tokens.
1396///
1397/// # Errors
1398///
1399/// [`AlgorithmError::NoPath`] naming whichever of the two the graph does not hold.
1400fn get_token_ixs<W>(
1401    graph: &TopologyGraph<W>,
1402    order: &Order,
1403) -> Result<(NodeIndex, NodeIndex), AlgorithmError> {
1404    let missing = |reason| AlgorithmError::NoPath {
1405        from: order.token_in().clone(),
1406        to: order.token_out().clone(),
1407        reason,
1408    };
1409    let from_idx = graph
1410        .get_token_ix(order.token_in())
1411        .ok_or_else(|| missing(NoPathReason::SourceTokenNotInGraph))?;
1412    let to_idx = graph
1413        .get_token_ix(order.token_out())
1414        .ok_or_else(|| missing(NoPathReason::DestinationTokenNotInGraph))?;
1415    Ok((from_idx, to_idx))
1416}
1417
1418fn expand_candidate_state<'a, W>(
1419    discovery: &mut Discovery<'a, '_, W>,
1420    target: NodeIndex,
1421    state: CandidatePathState<'a, W>,
1422    found: &mut Vec<(Path<'a, W>, BigUint)>,
1423    next_by_node: &mut FxHashMap<NodeIndex, Vec<CandidatePathState<'a, W>>>,
1424) where
1425    W: Clone,
1426{
1427    let cfg = discovery.cfg;
1428    let graph = discovery.graph;
1429    let edges = candidate_edges_for_state(discovery, target, &state);
1430    for candidate in edges {
1431        if cfg.deadline.expired() {
1432            break;
1433        }
1434        let mut path = state.path.clone();
1435        path.add_hop(&graph[state.node], candidate.edge, &graph[candidate.target]);
1436        let path_state = CandidatePathState {
1437            node: candidate.target,
1438            path: path.clone(),
1439            amount_out: candidate.amount_out,
1440        };
1441        if candidate.target == target && path.len() >= cfg.query.min_hops {
1442            found.push((path.clone(), path_state.amount_out.clone()));
1443        }
1444        if path.len() < cfg.query.max_hops {
1445            next_by_node
1446                .entry(candidate.target)
1447                .or_default()
1448                .push(path_state);
1449        }
1450    }
1451}
1452
1453fn candidate_edges_for_state<'a, W>(
1454    discovery: &mut Discovery<'a, '_, W>,
1455    target: NodeIndex,
1456    state: &CandidatePathState<'a, W>,
1457) -> Vec<ScoredEdge<'a, W>> {
1458    let mut preferred = score_candidate_edges(discovery, target, state, true);
1459    if preferred.is_empty() {
1460        preferred = score_candidate_edges(discovery, target, state, false);
1461    }
1462    select_candidate_edges(preferred, CANDIDATE_EDGES_PER_STATE)
1463}
1464
1465fn score_candidate_edges<'a, W>(
1466    discovery: &mut Discovery<'a, '_, W>,
1467    target: NodeIndex,
1468    state: &CandidatePathState<'a, W>,
1469    preferred_only: bool,
1470) -> Vec<ScoredEdge<'a, W>> {
1471    let Discovery { graph, market, cfg, cache } = discovery;
1472    let graph = *graph;
1473    let market = *market;
1474    let cfg = *cfg;
1475    let mut scored = Vec::new();
1476    for edge in graph.edges(state.node) {
1477        let next_node = edge.target();
1478        // Priority is a property of the token reached, so it is settled before looking at the pools
1479        // that reach it.
1480        let priority = match candidate_priority(graph, next_node, target, cfg) {
1481            Some(priority) => priority,
1482            None if preferred_only => continue,
1483            None => 3,
1484        };
1485        for pool in edge.weight().pools() {
1486            if !can_extend_path(graph, state, next_node, target, pool, cfg) {
1487                continue;
1488            }
1489            let address_in = &graph[state.node];
1490            let address_out = &graph[next_node];
1491            let direction =
1492                PoolDirection { component_id: &pool.component_id, address_in, address_out };
1493            let Some(hop) = cache.swap(
1494                direction,
1495                &state.amount_out,
1496                SolveStage::Discovery.label(),
1497                || simulate_edge(market, &state.amount_out, address_in, pool, address_out),
1498                SolveStage::Discovery.may_interpolate(),
1499            ) else {
1500                continue;
1501            };
1502            scored.push(ScoredEdge {
1503                target: next_node,
1504                edge: pool,
1505                amount_out: hop.amount_out,
1506                priority,
1507            });
1508        }
1509    }
1510    scored
1511}
1512
1513/// Derives bounded discovery's soft anchor set from the live graph: the `DERIVED_ANCHOR_COUNT`
1514/// most-connected tokens (most pools trading them) plus the native-ETH sentinel. Degree is
1515/// the same connectivity signal `derive-connector-tokens` ranks by, so anchoring stays correct on
1516/// any chain without a hardcoded per-chain list. The native-ETH zero address carries near-zero
1517/// degree but is load-bearing for `WETH → ETH → token` routes where Tycho models native ETH as
1518/// `0x0`, so it is anchored explicitly.
1519///
1520/// Pools, not trading partners: this graph holds one edge per token pair with the pools inside it,
1521/// so counting edges would rank a token on five thin pairs above one on three deep ones.
1522fn derive_anchor_tokens<W>(graph: &TopologyGraph<W>) -> FxHashSet<Address> {
1523    let mut by_pool_count: Vec<(NodeIndex, usize)> = graph
1524        .node_indices()
1525        .map(|node| {
1526            let pools = graph
1527                .edges(node)
1528                .map(|edge| edge.weight().pools().len())
1529                .sum();
1530            (node, pools)
1531        })
1532        .collect();
1533    by_pool_count.sort_unstable_by_key(|(_, pools)| Reverse(*pools));
1534    let mut anchors: FxHashSet<Address> = by_pool_count
1535        .into_iter()
1536        .take(DERIVED_ANCHOR_COUNT)
1537        .map(|(node, _)| graph[node].clone())
1538        .collect();
1539    anchors.insert(Address::from([0u8; 20]));
1540    anchors
1541}
1542
1543fn candidate_priority<W>(
1544    graph: &TopologyGraph<W>,
1545    node: NodeIndex,
1546    target: NodeIndex,
1547    cfg: &CandidateSearchConfig<'_>,
1548) -> Option<u8> {
1549    if node == target {
1550        return Some(0);
1551    }
1552    let token = &graph[node];
1553    match cfg.query.connector_tokens.as_ref() {
1554        Some(tokens) => tokens.contains(token).then_some(1),
1555        None => cfg
1556            .anchor_tokens
1557            .contains(token)
1558            .then_some(2),
1559    }
1560}
1561
1562fn can_extend_path<W>(
1563    graph: &TopologyGraph<W>,
1564    state: &CandidatePathState<'_, W>,
1565    next_node: NodeIndex,
1566    target: NodeIndex,
1567    edge: &EdgeData<W>,
1568    cfg: &CandidateSearchConfig<'_>,
1569) -> bool {
1570    let next_addr = &graph[next_node];
1571    if state
1572        .path
1573        .edge_iter()
1574        .iter()
1575        .any(|existing| existing.component_id == edge.component_id)
1576    {
1577        return false;
1578    }
1579    if state.path.tokens.contains(&next_addr) {
1580        return false;
1581    }
1582    if next_addr == cfg.source_token {
1583        return false;
1584    }
1585    if next_node == target {
1586        return true;
1587    }
1588    cfg.query
1589        .connector_tokens
1590        .as_ref()
1591        .map(|tokens| tokens.contains(next_addr))
1592        .unwrap_or(true)
1593}
1594
1595fn simulate_edge<W>(
1596    market: &MarketDataView<'_>,
1597    amount: &BigUint,
1598    token_in_addr: &Address,
1599    edge: &EdgeData<W>,
1600    token_out_addr: &Address,
1601) -> Result<SwapResult, Refusal> {
1602    let (Some(token_in), Some(token_out), Some(state)) = (
1603        market.get_token(token_in_addr),
1604        market.get_token(token_out_addr),
1605        market.get_simulation_state(&edge.component_id),
1606    ) else {
1607        return Err(Refusal::Failed);
1608    };
1609    state
1610        .get_amount_out_metered(
1611            &edge.component_id,
1612            SolveStage::Discovery.label(),
1613            amount.clone(),
1614            token_in,
1615            token_out,
1616        )
1617        .map(|result| SwapResult { amount_out: result.amount, gas: result.gas })
1618        .map_err(|error| Refusal::of(&error))
1619}
1620
1621fn select_candidate_edges<W>(
1622    mut scored: Vec<ScoredEdge<'_, W>>,
1623    max_edges: usize,
1624) -> Vec<ScoredEdge<'_, W>> {
1625    scored.sort_by(compare_scored_edges);
1626    let mut selected = Vec::new();
1627    let mut per_target: FxHashMap<NodeIndex, usize> = FxHashMap::default();
1628    for edge in scored {
1629        let limit = if edge.priority == 0 {
1630            CANDIDATE_DIRECT_EDGES_PER_TOKEN
1631        } else {
1632            CANDIDATE_CONNECTOR_EDGES_PER_TOKEN
1633        };
1634        let count = per_target
1635            .entry(edge.target)
1636            .or_default();
1637        if *count >= limit {
1638            continue;
1639        }
1640        *count += 1;
1641        selected.push(edge);
1642        if selected.len() >= max_edges {
1643            break;
1644        }
1645    }
1646    selected
1647}
1648
1649fn compare_scored_edges<W>(a: &ScoredEdge<'_, W>, b: &ScoredEdge<'_, W>) -> Ordering {
1650    a.priority
1651        .cmp(&b.priority)
1652        .then_with(|| b.amount_out.cmp(&a.amount_out))
1653}
1654
1655fn prune_candidate_frontier<W>(
1656    by_node: FxHashMap<NodeIndex, Vec<CandidatePathState<'_, W>>>,
1657) -> Vec<CandidatePathState<'_, W>> {
1658    by_node
1659        .into_values()
1660        .flat_map(|mut states| {
1661            states.sort_by(|a, b| b.amount_out.cmp(&a.amount_out));
1662            states.truncate(CANDIDATE_STATES_PER_NODE);
1663            states
1664        })
1665        .collect()
1666}
1667
1668fn rank_found_candidate_paths<'a, W>(
1669    mut found: Vec<(Path<'a, W>, BigUint)>,
1670    max_candidates: usize,
1671    order: &Order,
1672) -> Result<Vec<Path<'a, W>>, AlgorithmError> {
1673    found.sort_by(|(_, a), (_, b)| b.cmp(a));
1674    let mut keys = FxHashSet::default();
1675    let mut paths = Vec::new();
1676    let mut scores = Vec::new();
1677
1678    for (path, amount_out) in found {
1679        if !keys.insert(path_key(&path)) {
1680            continue;
1681        }
1682        let idx = paths.len();
1683        paths.push(path);
1684        scores.push((idx, BigInt::from(amount_out)));
1685        if paths.len() >= max_candidates {
1686            break;
1687        }
1688    }
1689
1690    if paths.is_empty() {
1691        return Err(AlgorithmError::NoPath {
1692            from: order.token_in().clone(),
1693            to: order.token_out().clone(),
1694            reason: NoPathReason::NoGraphPath,
1695        });
1696    }
1697    Ok(paths)
1698}
1699
1700#[cfg(test)]
1701mod tests {
1702    use std::time::Duration;
1703
1704    use alloy::primitives::U256;
1705    use num_bigint::BigUint;
1706    use num_traits::ToPrimitive;
1707    use tycho_simulation::evm::protocol::uniswap_v2::state::UniswapV2State;
1708
1709    use super::{super::MostLiquidAlgorithm, *};
1710    use crate::{
1711        algorithm::{
1712            split_test_harness::{
1713                evaluate_scenario, optimal_two_component_output, split_metrics, split_scenarios,
1714                two_equal_weth_usdc, TWO_EQUAL_USDC_RESERVE, TWO_EQUAL_WETH_RESERVE,
1715            },
1716            test_utils::{
1717                addr, setup_market_unweighted_topology, setup_market_weighted_boxed,
1718                token_with_decimals, ConstantProductSim, DivByZeroSim,
1719            },
1720        },
1721        graph::GraphManager,
1722        types::quote::OrderSide,
1723    };
1724
1725    fn config() -> AlgorithmConfig {
1726        config_ms(2000)
1727    }
1728
1729    fn config_ms(ms: u64) -> AlgorithmConfig {
1730        AlgorithmConfig::new(1, 3, Duration::from_millis(ms), None).unwrap()
1731    }
1732
1733    fn whole_weth_order(token_in: &Address, token_out: &Address, weth: u64) -> Order {
1734        Order::new(
1735            token_in.clone(),
1736            token_out.clone(),
1737            BigUint::from(weth) * BigUint::from(10u64).pow(18),
1738            OrderSide::Sell,
1739            addr(0xFF),
1740        )
1741    }
1742
1743    fn v2_component(reserve_a: u128, reserve_b: u128) -> UniswapV2State {
1744        UniswapV2State::new(
1745            U256::from(reserve_a) * U256::from(10u64).pow(U256::from(18u64)),
1746            U256::from(reserve_b) * U256::from(10u64).pow(U256::from(18u64)),
1747        )
1748    }
1749
1750    // ==================== Portfolio behavior ====================
1751
1752    fn water_fill_default() -> WaterFillAlgorithm {
1753        WaterFillAlgorithm::with_config(
1754            AlgorithmConfig::new(1, 4, Duration::from_millis(5000), None).unwrap(),
1755        )
1756        .unwrap()
1757    }
1758
1759    /// Across every shared split scenario: never lose to the best single path, land within 5% of
1760    /// the analytical optimum, and return a structurally valid route.
1761    #[tokio::test]
1762    async fn test_water_fill_all_scenarios() {
1763        let algo = water_fill_default();
1764        for scenario in split_scenarios::all() {
1765            let name = scenario.name;
1766            let (market, gm) = scenario.build_market_weighted();
1767            let result = evaluate_scenario(&algo, &scenario, market, gm).await;
1768
1769            result.assert_passes_lower_bound();
1770            // water_fill reaches the analytical optimum within 5% on single-hop and shared-prefix
1771            // scenarios. DOUBLE_SPLIT re-splits at both hops with mismatched component sizes; the
1772            // token-merged disjoint/fill-and-spill allocation reaches ~90% of that cross-hop
1773            // optimum -- still well above the single-path floor, just short of PFW's per-hop
1774            // line search there.
1775            let tolerance_pct = if name == "DOUBLE_SPLIT" { 10 } else { 5 };
1776            assert!(
1777                result.within_pct_of_optimum(tolerance_pct),
1778                "'{name}': not within {tolerance_pct}% of optimum",
1779            );
1780            let route = result
1781                .route
1782                .as_ref()
1783                .unwrap_or_else(|| panic!("'{name}': expected a route"));
1784            assert!(route.validate().is_ok(), "'{name}': route validation failed");
1785        }
1786    }
1787
1788    /// When the extra-hop gas exceeds the split's gross benefit, water-fill returns a single path
1789    /// rather than a net-negative split.
1790    #[tokio::test]
1791    async fn test_water_fill_gas_kills_split() {
1792        let scenario = split_scenarios::gas_kills_split();
1793        let (market, gm) = scenario.build_market_weighted();
1794        let result = evaluate_scenario(&water_fill_default(), &scenario, market, gm).await;
1795        assert_eq!(result.path_count, 1, "high gas should prevent splitting");
1796    }
1797
1798    /// A split fills an order that no single path can. Two parallel constant-product components
1799    /// each revert once the input reaches their reserve, so the full order overruns either
1800    /// component alone; split in half it fits both. The portfolio returns the split even though
1801    /// the single-path baseline is absent — instead of the earlier `InsufficientLiquidity`
1802    /// short-circuit.
1803    #[tokio::test]
1804    async fn test_split_fills_when_no_single_path_can() {
1805        let a = token_with_decimals(0x01, "A", 18);
1806        let b = token_with_decimals(0x02, "B", 18);
1807        // reserve_0 (token A, the input side) = 800; a swap reverts once its input reaches it. The
1808        // full order (1000 A) overruns either component, but ~500 A per component in a 50/50 split
1809        // fits.
1810        let component = || {
1811            Box::new(ConstantProductSim {
1812                reserve_0: BigUint::from(800u64) * BigUint::from(10u64).pow(18),
1813                reserve_1: BigUint::from(10_000u64) * BigUint::from(10u64).pow(18),
1814                gas: 50_000,
1815            }) as Box<dyn ProtocolSim>
1816        };
1817        let (market, gm) = setup_market_weighted_boxed(vec![
1818            ("component_x", &a, &b, component()),
1819            ("component_y", &a, &b, component()),
1820        ]);
1821        let order = Order::new(
1822            a.address.clone(),
1823            b.address.clone(),
1824            BigUint::from(1_000u64) * BigUint::from(10u64).pow(18),
1825            OrderSide::Sell,
1826            addr(0xFF),
1827        );
1828
1829        // Premise: no single path fills the full order.
1830        let single = MostLiquidAlgorithm::with_config(config())
1831            .unwrap()
1832            .find_best_route(gm.graph(), market.clone(), None, None, &order)
1833            .await;
1834        assert!(single.is_err(), "premise: no single path fills the full order");
1835
1836        // The portfolio still returns a split across both components.
1837        let split = WaterFillAlgorithm::with_config(config())
1838            .unwrap()
1839            .find_best_route(gm.graph(), market.clone(), None, None, &order)
1840            .await
1841            .expect("water_fill returns a split when no single path fills");
1842        assert!(split.route().swaps().len() >= 2, "expected a split across both components");
1843    }
1844
1845    /// A component whose math panics mid-simulation must be skipped like any failing component,
1846    /// not unwind through the solver worker thread. The panicking component advertises huge depth
1847    /// so discovery ranks it first and the bulk fill loops actually simulate it.
1848    #[tokio::test]
1849    async fn test_water_fill_contains_simulation_panic() {
1850        let a = token_with_decimals(0x01, "A", 18);
1851        let b = token_with_decimals(0x02, "B", 18);
1852        let healthy = Box::new(ConstantProductSim {
1853            reserve_0: BigUint::from(10_000u64) * BigUint::from(10u64).pow(18),
1854            reserve_1: BigUint::from(10_000u64) * BigUint::from(10u64).pow(18),
1855            gas: 50_000,
1856        }) as Box<dyn ProtocolSim>;
1857        let (market, gm) = setup_market_weighted_boxed(vec![
1858            ("component_ok", &a, &b, healthy),
1859            ("component_panics", &a, &b, Box::new(DivByZeroSim::default())),
1860        ]);
1861        let order = Order::new(
1862            a.address.clone(),
1863            b.address.clone(),
1864            BigUint::from(100u64) * BigUint::from(10u64).pow(18),
1865            OrderSide::Sell,
1866            addr(0xFF),
1867        );
1868
1869        let result = WaterFillAlgorithm::with_config(config())
1870            .unwrap()
1871            .find_best_route(gm.graph(), market.clone(), None, None, &order)
1872            .await
1873            .expect("panicking component is skipped; the healthy component still fills the order");
1874
1875        assert!(
1876            result
1877                .route()
1878                .swaps()
1879                .iter()
1880                .all(|swap| swap.component_id() == "component_ok"),
1881            "route must only use the healthy component",
1882        );
1883    }
1884
1885    /// On two equal fee-free components the split's gross output must come within a tight tolerance
1886    /// of the analytical two-component optimum (a 50/50 split): the fine 256-chunk allocation
1887    /// finds the optimal split, not just any splitting one.
1888    #[tokio::test]
1889    async fn test_water_fill_output_near_two_component_optimum() {
1890        let m = two_equal_weth_usdc(1);
1891        let trade = 500u64;
1892        let order = whole_weth_order(&m.weth, &m.usdc, trade);
1893
1894        let result = WaterFillAlgorithm::with_config(config())
1895            .unwrap()
1896            .find_best_route(
1897                m.weighted.graph(),
1898                m.market.clone(),
1899                None,
1900                Some(m.derived.clone()),
1901                &order,
1902            )
1903            .await
1904            .expect("split solves");
1905        let (_, path_count, gross) = split_metrics(&result, &m.weth, &m.usdc);
1906        assert_eq!(path_count, 2, "the optimum uses both components");
1907
1908        // Fee-free reserves in raw units, so the no-fee two-component optimum applies directly.
1909        let reserve_in = TWO_EQUAL_WETH_RESERVE as f64 * 1e18;
1910        let reserve_out = TWO_EQUAL_USDC_RESERVE as f64 * 1e6;
1911        let trade_amount = trade as f64 * 1e18;
1912        let (_, optimum) = optimal_two_component_output(
1913            reserve_in,
1914            reserve_out,
1915            reserve_in,
1916            reserve_out,
1917            trade_amount,
1918        );
1919
1920        let gross = gross.to_f64().unwrap();
1921        assert!(
1922            gross >= optimum * 0.999 && gross <= optimum * 1.0001,
1923            "split gross {gross} should be within 0.1% of the two-component optimum {optimum}",
1924        );
1925    }
1926
1927    /// Under a tight timeout the split must never lose to the best single path: the 20-chunk split
1928    /// does the cheap coarse allocation, so cutting off later refinement cannot drop the result
1929    /// below the single path. A budget too tight to finish yields no route at all (the router falls
1930    /// back to other components) — a no-answer, not a loss — so the never-lose check runs only for
1931    /// budgets where water-fill returns a route. The 50ms budget comfortably exceeds this
1932    /// scenario's few-ms solve, so the comparison is always exercised.
1933    #[tokio::test]
1934    async fn test_water_fill_no_loss_under_tight_timeout() {
1935        for ms in [1u64, 5, 50] {
1936            let m = two_equal_weth_usdc(1_000_000_000);
1937            let order = whole_weth_order(&m.weth, &m.usdc, 500);
1938
1939            let split = WaterFillAlgorithm::with_config(config_ms(ms))
1940                .unwrap()
1941                .find_best_route(
1942                    m.weighted.graph(),
1943                    m.market.clone(),
1944                    None,
1945                    Some(m.derived.clone()),
1946                    &order,
1947                )
1948                .await;
1949            let single = MostLiquidAlgorithm::with_config(config_ms(ms))
1950                .unwrap()
1951                .find_best_route(
1952                    m.weighted.graph(),
1953                    m.market.clone(),
1954                    None,
1955                    Some(m.derived.clone()),
1956                    &order,
1957                )
1958                .await;
1959
1960            let (Ok(split), Ok(single)) = (split, single) else {
1961                continue;
1962            };
1963            let (split_net, _, _) = split_metrics(&split, &m.weth, &m.usdc);
1964            let (single_net, _, _) = split_metrics(&single, &m.weth, &m.usdc);
1965            assert!(
1966                split_net >= single_net,
1967                "split lost to single-path under {ms}ms timeout: split={split_net} single={single_net}",
1968            );
1969        }
1970    }
1971
1972    // ==================== Candidate discovery ====================
1973
1974    /// Bounded discovery finds both parallel components as candidate paths and ranks the deeper
1975    /// component first by simulated full-amount output, using live simulation only (no
1976    /// precomputed edge weights on the weightless graph).
1977    #[tokio::test]
1978    async fn test_discovery_finds_and_ranks_parallel_components() {
1979        let link = token_with_decimals(0x01, "LINK", 18);
1980        let weth = token_with_decimals(0x02, "WETH", 18);
1981        let (market, graph_manager) = setup_market_unweighted_topology(vec![
1982            (
1983                "a_weak_link_weth",
1984                &link,
1985                &weth,
1986                Box::new(v2_component(2_000_000, 264)) as Box<dyn ProtocolSim>,
1987            ),
1988            (
1989                "z_strong_link_weth",
1990                &link,
1991                &weth,
1992                Box::new(v2_component(2_000_000, 5_700)) as Box<dyn ProtocolSim>,
1993            ),
1994        ]);
1995        let order = Order::new(
1996            link.address.clone(),
1997            weth.address.clone(),
1998            BigUint::from(1_000u64) * BigUint::from(10u64).pow(18),
1999            OrderSide::Sell,
2000            addr(0xFF),
2001        );
2002
2003        let start = Instant::now();
2004        let view = market.read().await;
2005        let paths = discover_paths(
2006            graph_manager.graph(),
2007            &view,
2008            &order,
2009            &mut SwapCache::new(),
2010            CandidateSearchConfig {
2011                query: &GraphQueryFilter { min_hops: 1, max_hops: 3, connector_tokens: None },
2012                max_candidates: 128,
2013                anchor_tokens: &FxHashSet::default(),
2014                source_token: order.token_in(),
2015                deadline: Deadline::new(start, Duration::from_millis(2000)),
2016            },
2017        )
2018        .expect("discovery finds candidates");
2019
2020        assert_eq!(paths.len(), 2, "both parallel components should be discovered");
2021        // Scores are (path index, full-amount gross output), best first: the deeper component wins.
2022        let best_path = &paths[0];
2023        assert_eq!(
2024            best_path.edge_iter()[0].component_id,
2025            "z_strong_link_weth",
2026            "discovery should rank by simulated output, not topology or edge weights",
2027        );
2028    }
2029
2030    // ==================== Ranking the full-amount outcomes ====================
2031
2032    fn hop(amount_out: u64, gas: u64) -> SwapResult {
2033        SwapResult { amount_out: BigUint::from(amount_out), gas: BigUint::from(gas) }
2034    }
2035
2036    /// `Unfilled` paths rank last by output and are kept out of the net-of-gas ordering entirely,
2037    /// so one can never become the single-path baseline. A path with no outcome is in neither.
2038    #[test]
2039    fn test_rank_outcomes_places_unfilled_and_missing_paths() {
2040        let outcomes = vec![
2041            Some(FullAmountOutcome::Filled(hop(1000, 0))),
2042            Some(FullAmountOutcome::Unfilled),
2043            None,
2044            Some(FullAmountOutcome::Filled(hop(3000, 0))),
2045        ];
2046
2047        let ranking = rank_outcomes(outcomes, &BigUint::from(1u64), None, &addr(0x02));
2048
2049        assert_eq!(ranking.by_output, vec![3, 0, 1], "unfilled ranks last, missing is absent");
2050        assert_eq!(ranking.by_output_net_gas, vec![3, 0], "unfilled cannot be the baseline");
2051    }
2052
2053    /// Both orderings take gas off, so a path paying more gross ranks below a cheaper one in each.
2054    /// The candidate set the split passes are built from is sliced off `by_output`, and ranking
2055    /// that gross put a long route above a short one on an output its extra swaps hand back.
2056    #[test]
2057    fn test_rank_outcomes_orders_by_output_net_of_gas() {
2058        let token_out = addr(0x02);
2059        let mut token_prices = TokenGasPrices::default();
2060        token_prices.insert(
2061            token_out.clone(),
2062            tycho_simulation::tycho_common::simulation::protocol_sim::Price::new(
2063                BigUint::from(1u64),
2064                BigUint::from(1u64),
2065            ),
2066        );
2067        let outcomes = vec![
2068            Some(FullAmountOutcome::Filled(hop(1000, 500))),
2069            Some(FullAmountOutcome::Filled(hop(900, 10))),
2070        ];
2071
2072        let ranking =
2073            rank_outcomes(outcomes, &BigUint::from(1u64), Some(&token_prices), &token_out);
2074
2075        assert_eq!(ranking.by_output, vec![1, 0], "the cheaper path ranks first");
2076        assert_eq!(ranking.by_output_net_gas, vec![1, 0], "and the baseline ordering agrees");
2077    }
2078
2079    /// A path that filled but paid less than its gas ranks below every profitable path and above
2080    /// every path that could not fill: net output goes negative, and an unfilled path has no net
2081    /// at all rather than a zero that would float it over the loss-making ones.
2082    #[test]
2083    fn test_rank_outcomes_places_a_loss_making_path_above_an_unfilled_one() {
2084        let token_out = addr(0x02);
2085        let mut token_prices = TokenGasPrices::default();
2086        token_prices.insert(
2087            token_out.clone(),
2088            tycho_simulation::tycho_common::simulation::protocol_sim::Price::new(
2089                BigUint::from(1u64),
2090                BigUint::from(1u64),
2091            ),
2092        );
2093        let outcomes = vec![
2094            Some(FullAmountOutcome::Unfilled),
2095            // Gas costs more than it pays: net output is -400.
2096            Some(FullAmountOutcome::Filled(hop(100, 500))),
2097            Some(FullAmountOutcome::Filled(hop(900, 10))),
2098        ];
2099
2100        let ranking =
2101            rank_outcomes(outcomes, &BigUint::from(1u64), Some(&token_prices), &token_out);
2102
2103        assert_eq!(ranking.by_output, vec![2, 1, 0], "unfilled ranks below a loss-making path");
2104    }
2105
2106    /// With no price for the output token gas cannot be converted, so it is not taken off and the
2107    /// two orderings agree.
2108    #[test]
2109    fn test_rank_outcomes_ignores_gas_it_cannot_price() {
2110        let outcomes = vec![
2111            Some(FullAmountOutcome::Filled(hop(1000, 500))),
2112            Some(FullAmountOutcome::Filled(hop(900, 10))),
2113        ];
2114
2115        let ranking = rank_outcomes(outcomes, &BigUint::from(1u64), None, &addr(0x02));
2116
2117        assert_eq!(ranking.by_output_net_gas, vec![0, 1]);
2118    }
2119
2120    /// Anchors are the most-connected tokens (highest component-edge degree) plus the native-ETH
2121    /// sentinel, derived from the graph rather than a hardcoded list.
2122    #[test]
2123    fn test_derive_anchor_tokens_ranks_hub_and_includes_native_sentinel() {
2124        let hub = token_with_decimals(0x01, "HUB", 18);
2125        let a = token_with_decimals(0x02, "A", 18);
2126        let b = token_with_decimals(0x03, "B", 18);
2127        let c = token_with_decimals(0x04, "C", 18);
2128        let (_market, graph_manager) = setup_market_unweighted_topology(vec![
2129            ("hub_a", &hub, &a, Box::new(v2_component(1, 1)) as Box<dyn ProtocolSim>),
2130            ("hub_b", &hub, &b, Box::new(v2_component(1, 1)) as Box<dyn ProtocolSim>),
2131            ("hub_c", &hub, &c, Box::new(v2_component(1, 1)) as Box<dyn ProtocolSim>),
2132        ]);
2133
2134        let anchors = derive_anchor_tokens(graph_manager.graph());
2135
2136        assert!(anchors.contains(&hub.address), "highest-degree token should be anchored");
2137        assert!(
2138            anchors.contains(&Address::from([0u8; 20])),
2139            "native-ETH sentinel should always be anchored",
2140        );
2141    }
2142}