fynd_core/algorithm/mod.rs
1//! Route-finding algorithms.
2//!
3//! This module defines the Algorithm trait and built-in implementations.
4//! New algorithms can be added by implementing the trait.
5//!
6//! Algorithms are generic over their preferred graph type, allowing them to use
7//! different graph crates (petgraph, custom, etc.) and leverage built-in algorithms.
8//!
9//! # Adding a New Algorithm
10//!
11//! **External:** Implement the `Algorithm` trait in your own crate and plug it
12//! into a [`WorkerPoolBuilder`](crate::worker_pool::pool::WorkerPoolBuilder) via
13//! [`with_algorithm`](crate::worker_pool::pool::WorkerPoolBuilder::with_algorithm). No changes
14//! to fynd-core required. See the `custom_algorithm` example.
15//!
16//! **Built-in:** To add an algorithm to the built-in registry:
17//! 1. Create a new module with your algorithm implementation
18//! 2. Implement the `Algorithm` trait
19//! 3. Register it in `registry.rs`
20
21pub mod bellman_ford;
22pub mod most_liquid;
23pub mod path_frank_wolfe;
24#[allow(dead_code)]
25pub(crate) mod split_primitives;
26
27#[cfg(test)]
28pub mod split_test_harness;
29#[cfg(test)]
30pub mod test_utils;
31
32use std::{collections::HashSet, time::Duration};
33
34pub use bellman_ford::BellmanFordAlgorithm;
35pub use most_liquid::MostLiquidAlgorithm;
36pub use path_frank_wolfe::PathFrankWolfeAlgorithm;
37use tycho_simulation::tycho_core::models::Address;
38
39use crate::{
40 derived::{computation::ComputationRequirements, SharedDerivedDataRef},
41 feed::market_data::{MarketData, StateLabel},
42 graph::GraphManager,
43 types::{quote::Order, RouteResult},
44};
45
46/// Configuration for an Algorithm instance.
47#[must_use]
48#[derive(Debug, Clone)]
49pub struct AlgorithmConfig {
50 /// Minimum hops to search (must be >= 1).
51 min_hops: usize,
52 /// Maximum hops to search.
53 max_hops: usize,
54 /// Timeout for solving.
55 timeout: Duration,
56 /// Maximum number of paths to simulate. `None` means no cap.
57 max_routes: Option<usize>,
58 /// Enable gas-aware comparison (compares net amounts instead of gross during path selection).
59 /// Currently used by Bellman-Ford; ignored by other algorithms. Defaults to true.
60 gas_aware: bool,
61 /// Tokens allowed as intermediate hops. `None` = no restriction (all tokens reachable).
62 /// `token_in` and `token_out` for a given order are always allowed regardless.
63 connector_tokens: Option<HashSet<Address>>,
64}
65
66impl AlgorithmConfig {
67 /// Creates a new `AlgorithmConfig` with validation.
68 ///
69 /// # Errors
70 ///
71 /// Returns `InvalidConfiguration` if:
72 /// - `min_hops == 0` (at least one hop is required)
73 /// - `min_hops > max_hops`
74 /// - `max_routes` is `Some(0)`
75 pub fn new(
76 min_hops: usize,
77 max_hops: usize,
78 timeout: Duration,
79 max_routes: Option<usize>,
80 ) -> Result<Self, AlgorithmError> {
81 if min_hops == 0 {
82 return Err(AlgorithmError::InvalidConfiguration {
83 reason: "min_hops must be at least 1".to_string(),
84 });
85 }
86 if min_hops > max_hops {
87 return Err(AlgorithmError::InvalidConfiguration {
88 reason: format!("min_hops ({}) cannot exceed max_hops ({})", min_hops, max_hops),
89 });
90 }
91 if max_routes == Some(0) {
92 return Err(AlgorithmError::InvalidConfiguration {
93 reason: "max_routes must be at least 1".to_string(),
94 });
95 }
96 Ok(Self {
97 min_hops,
98 max_hops,
99 timeout,
100 max_routes,
101 gas_aware: true,
102 connector_tokens: None,
103 })
104 }
105
106 /// Returns the minimum number of hops to search.
107 pub fn min_hops(&self) -> usize {
108 self.min_hops
109 }
110
111 /// Returns the maximum number of hops to search.
112 pub fn max_hops(&self) -> usize {
113 self.max_hops
114 }
115
116 /// Returns the maximum number of paths to simulate.
117 pub fn max_routes(&self) -> Option<usize> {
118 self.max_routes
119 }
120
121 /// Returns the timeout for solving.
122 pub fn timeout(&self) -> Duration {
123 self.timeout
124 }
125
126 /// Returns whether gas-aware comparison is enabled.
127 pub fn gas_aware(&self) -> bool {
128 self.gas_aware
129 }
130
131 /// Sets gas-aware comparison.
132 pub fn with_gas_aware(mut self, enabled: bool) -> Self {
133 self.gas_aware = enabled;
134 self
135 }
136
137 /// Restricts intermediate hops to the given token set.
138 ///
139 /// When set, only these tokens may appear between `token_in` and `token_out`
140 /// in a multi-hop route. The order endpoints are always allowed regardless.
141 /// Pass an empty set to disallow all intermediate hops (only 1-hop routes possible).
142 pub fn with_connector_tokens(mut self, tokens: HashSet<Address>) -> Self {
143 self.connector_tokens = Some(tokens);
144 self
145 }
146
147 /// Returns the connector token allowlist, or `None` if all tokens are permitted.
148 pub fn connector_tokens(&self) -> Option<&HashSet<Address>> {
149 self.connector_tokens.as_ref()
150 }
151}
152
153impl Default for AlgorithmConfig {
154 fn default() -> Self {
155 // Default values are valid, so we can unwrap safely
156 Self::new(1, 3, Duration::from_millis(100), None).unwrap()
157 }
158}
159
160/// Trait for route-finding algorithms.
161///
162/// Algorithms are generic over their preferred graph type `G`, allowing them to:
163/// - Use different graph crates (petgraph, custom, etc.)
164/// - Leverage built-in algorithms from graph libraries
165/// - Optimize their graph representation for their specific needs
166///
167/// # Implementation Notes
168///
169/// - Algorithms should respect the timeout from `timeout()`
170/// - They should use `graph` for path finding (BFS/etc)
171/// - They should use `market` to read component states for simulation
172/// - They should NOT modify the graph or market data
173#[allow(async_fn_in_trait)]
174pub trait Algorithm: Send + Sync {
175 /// The graph type this algorithm uses.
176 type GraphType: Send + Sync;
177
178 /// The graph manager type for this algorithm.
179 /// This allows the solver to automatically create the appropriate graph manager.
180 type GraphManager: GraphManager<Self::GraphType> + Default;
181
182 /// Returns the algorithm's name.
183 fn name(&self) -> &str;
184
185 /// Finds the best route for the given order.
186 ///
187 /// # Arguments
188 ///
189 /// * `graph` - The algorithm's preferred graph type (e.g., petgraph::Graph)
190 /// * `market` - Shared reference to market data for state lookups (algorithms acquire their own
191 /// locks)
192 /// * `label` - Optional overlay label; when `Some`, the algorithm reads market state through
193 /// the named overlay so per-request pool overrides are applied during solving
194 /// * `derived` - Optional shared reference to derived data (token prices, etc.)
195 /// * `order` - The order to solve
196 ///
197 /// # Returns
198 ///
199 /// The best route and its gas-adjusted net output amount, or an error if no route could be
200 /// found.
201 async fn find_best_route(
202 &self,
203 graph: &Self::GraphType,
204 market: MarketData,
205 label: Option<StateLabel>,
206 derived: Option<SharedDerivedDataRef>,
207 order: &Order,
208 ) -> Result<RouteResult, AlgorithmError>;
209
210 /// Returns the derived data computation requirements for this algorithm.
211 ///
212 /// Algorithms declare freshness requirements for derived data:
213 /// - `require_fresh`: Data must be from the current block (same as MarketState)
214 /// - `allow_stale`: Data can be from any past block, as long as it exists
215 ///
216 /// Workers use this to determine when they can safely solve.
217 ///
218 /// Default implementation returns no requirements - algorithm works without
219 /// any derived data.
220 fn computation_requirements(&self) -> ComputationRequirements;
221
222 /// Returns the timeout for solving.
223 ///
224 /// Workers use this to set the maximum time to wait for derived data
225 /// before failing a solve request.
226 fn timeout(&self) -> Duration;
227}
228
229/// Errors that can occur during route finding.
230#[non_exhaustive]
231#[derive(Debug, Clone, thiserror::Error, PartialEq)]
232pub enum AlgorithmError {
233 /// Invalid algorithm configuration (programmer error).
234 #[non_exhaustive]
235 #[error("invalid configuration: {reason}")]
236 InvalidConfiguration {
237 /// Human-readable description of the invalid configuration.
238 reason: String,
239 },
240
241 /// No path exists between the tokens.
242 #[non_exhaustive]
243 #[error("no path from {from:?} to {to:?}: {reason}")]
244 NoPath {
245 /// Input token address.
246 from: Address,
247 /// Output token address.
248 to: Address,
249 /// Detailed reason why no path was found.
250 reason: NoPathReason,
251 },
252
253 /// Paths exist but none have sufficient liquidity.
254 #[error("insufficient liquidity on all paths")]
255 InsufficientLiquidity,
256
257 /// Route finding timed out.
258 #[non_exhaustive]
259 #[error("timeout after {elapsed_ms}ms")]
260 Timeout {
261 /// Elapsed time in milliseconds when the timeout fired.
262 elapsed_ms: u64,
263 },
264
265 /// Exact-out not supported by this algorithm.
266 #[error("exact-out orders not supported")]
267 ExactOutNotSupported,
268
269 /// Simulation failed for a specific component.
270 #[non_exhaustive]
271 #[error("simulation failed for {component_id}: {error}")]
272 SimulationFailed {
273 /// ID of the pool component that failed.
274 component_id: String,
275 /// Underlying simulation error message.
276 error: String,
277 },
278
279 /// Required data not found in market.
280 #[non_exhaustive]
281 #[error("{kind} not found{}", id.as_ref().map(|i| format!(": {i}")).unwrap_or_default())]
282 DataNotFound {
283 /// Category of the missing data (e.g. `"token"`, `"component"`).
284 kind: &'static str,
285 /// Optional identifier of the missing item.
286 id: Option<String>,
287 },
288
289 /// Other algorithm-specific error.
290 #[error("{0}")]
291 Other(String),
292}
293
294/// Reason why no path was found between tokens.
295#[non_exhaustive]
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum NoPathReason {
298 /// Source token not present in the routing graph.
299 SourceTokenNotInGraph,
300 /// Destination token not present in the routing graph.
301 DestinationTokenNotInGraph,
302 /// Both tokens exist but no edges connect them within hop limits.
303 NoGraphPath,
304 /// Paths exist but none could be scored (e.g., missing edge weights).
305 NoScorablePaths,
306}
307
308impl std::fmt::Display for NoPathReason {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 match self {
311 Self::SourceTokenNotInGraph => write!(f, "source token not in graph"),
312 Self::DestinationTokenNotInGraph => write!(f, "destination token not in graph"),
313 Self::NoGraphPath => write!(f, "no connecting path in graph"),
314 Self::NoScorablePaths => write!(f, "no paths with valid scores"),
315 }
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn test_connector_tokens_default_is_none() {
325 assert!(AlgorithmConfig::default()
326 .connector_tokens()
327 .is_none());
328 }
329
330 #[test]
331 fn test_with_connector_tokens_sets_field() {
332 let addr = Address::from([0x01u8; 20]);
333 let tokens: HashSet<Address> = [addr.clone()].into();
334 let config = AlgorithmConfig::default().with_connector_tokens(tokens);
335 let stored = config
336 .connector_tokens()
337 .expect("should be Some");
338 assert!(stored.contains(&addr));
339 assert_eq!(stored.len(), 1);
340 }
341
342 #[test]
343 fn test_with_connector_tokens_empty_set() {
344 let config = AlgorithmConfig::default().with_connector_tokens(HashSet::new());
345 assert_eq!(
346 config
347 .connector_tokens()
348 .map(|s| s.len()),
349 Some(0)
350 );
351 }
352}