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