Skip to main content

fynd_core/derived/
computation.rs

1//! Core computation trait and types.
2
3use std::collections::HashSet;
4
5use async_trait::async_trait;
6
7use super::{
8    error::ComputationError,
9    manager::{ChangedComponents, SharedDerivedDataRef},
10    store::DerivedData,
11};
12use crate::feed::market_data::MarketData;
13
14/// Unique identifier for a computation type.
15///
16/// Used for event discrimination, storage keys, and readiness tracking.
17pub type ComputationId = &'static str;
18
19/// Error when building computation requirements.
20#[derive(Debug, Clone, thiserror::Error)]
21#[error("conflicting requirement: '{id}' cannot be both fresh and stale")]
22pub struct RequirementConflict {
23    /// The computation ID that was added with conflicting freshness.
24    pub(crate) id: ComputationId,
25}
26
27impl RequirementConflict {
28    /// Returns the conflicting computation ID.
29    pub fn id(&self) -> ComputationId {
30        self.id
31    }
32}
33
34/// Requirements for derived data computations.
35///
36/// Each algorithm declares which computations it needs and their freshness requirements:
37///
38/// - `require_fresh`: Data must be from the current block (same block as MarketState). Workers wait
39///   for these computations to complete for the current block before solving.
40///
41/// - `allow_stale`: Data can be from any past block, as long as it has been computed at least once.
42///   Workers only check that the data exists, not that it's from the current block.
43///
44///
45/// # Example
46///
47/// ```ignore
48/// // Token prices don't change much block-to-block, stale is fine
49/// ComputationRequirements::stale(["token_prices"])
50///
51/// // Spot prices must be fresh for accurate routing
52/// ComputationRequirements::fresh(["spot_prices"])
53/// ```
54#[derive(Debug, Clone, Default)]
55pub struct ComputationRequirements {
56    /// Computations that must be from the current block.
57    pub(crate) require_fresh: HashSet<ComputationId>,
58    /// Computations that can use data from any past block.
59    ///
60    /// TODO: Stale data can be dangerous if stale for too long. In the future, associate staleness
61    /// to a block limit might be implemented.
62    pub(crate) allow_stale: HashSet<ComputationId>,
63}
64
65impl ComputationRequirements {
66    /// Returns the set of computations that require fresh data.
67    pub fn fresh_requirements(&self) -> &HashSet<ComputationId> {
68        &self.require_fresh
69    }
70
71    /// Returns the set of computations that allow stale data.
72    pub fn stale_requirements(&self) -> &HashSet<ComputationId> {
73        &self.allow_stale
74    }
75
76    /// Creates empty requirements (no derived data needed).
77    pub fn none() -> Self {
78        Self::default()
79    }
80
81    /// Creates requirements that need the given computations from the current block.
82    pub fn fresh<I: IntoIterator<Item = ComputationId>>(ids: I) -> Self {
83        Self { require_fresh: ids.into_iter().collect(), allow_stale: HashSet::new() }
84    }
85
86    /// Creates requirements that accept the given computations from any past block.
87    pub fn stale<I: IntoIterator<Item = ComputationId>>(ids: I) -> Self {
88        Self { require_fresh: HashSet::new(), allow_stale: ids.into_iter().collect() }
89    }
90
91    /// Builder method to add a computation that requires fresh data (current block).
92    ///
93    /// # Errors
94    ///
95    /// Returns `RequirementConflict` if the same ID is already in `allow_stale`.
96    pub fn require_fresh(mut self, id: ComputationId) -> Result<Self, RequirementConflict> {
97        if self.allow_stale.contains(&id) {
98            return Err(RequirementConflict { id });
99        }
100        self.require_fresh.insert(id);
101        Ok(self)
102    }
103
104    /// Builder method to add a computation that allows stale data (any past block).
105    ///
106    /// # Errors
107    ///
108    /// Returns `RequirementConflict` if the same ID is already in `require_fresh`.
109    pub fn allow_stale(mut self, id: ComputationId) -> Result<Self, RequirementConflict> {
110        if self.require_fresh.contains(&id) {
111            return Err(RequirementConflict { id });
112        }
113        self.allow_stale.insert(id);
114        Ok(self)
115    }
116
117    /// Returns true if there are any requirements.
118    pub fn has_requirements(&self) -> bool {
119        !self.require_fresh.is_empty() || !self.allow_stale.is_empty()
120    }
121
122    /// Returns true if the given computation is required (fresh or stale).
123    pub fn is_required(&self, id: ComputationId) -> bool {
124        self.require_fresh.contains(&id) || self.allow_stale.contains(&id)
125    }
126}
127
128/// Typed error for a failed computation item.
129#[derive(Debug, Clone, PartialEq, thiserror::Error)]
130pub enum FailedItemError {
131    /// The pool's simulation state was not available in shared market data.
132    #[error("missing simulation state")]
133    MissingSimulationState,
134
135    /// Token metadata (decimals, symbol) was not found for the pool's tokens.
136    #[error("missing token metadata")]
137    MissingTokenMetadata,
138
139    /// A required spot price was not yet computed for this edge.
140    #[error("missing spot price")]
141    MissingSpotPrice,
142
143    /// The decimal difference between two tokens is too large for a meaningful price.
144    #[error("extreme decimal mismatch ({from}\u{2192}{to})")]
145    ExtremeDecimalMismatch {
146        /// Source token decimals.
147        from: u32,
148        /// Target token decimals.
149        to: u32,
150    },
151
152    /// The computed spot price is below the minimum threshold.
153    #[error("spot price too small: {0}")]
154    SpotPriceTooSmall(f64),
155
156    /// Protocol simulation returned an error.
157    #[error("simulation failed: {0}")]
158    SimulationFailed(String),
159
160    /// Every simulation path for this pool failed.
161    #[error("all simulation paths failed")]
162    AllSimulationPathsFailed,
163}
164
165/// A single item that failed during a computation.
166#[derive(Debug, Clone)]
167pub struct FailedItem {
168    /// Human-readable key for the failed item.
169    /// - spot_prices/pool_depths: "component_id/token_in/token_out"
170    /// - token_prices: "token_address"
171    pub key: String,
172    /// Typed error describing the failure.
173    pub error: FailedItemError,
174}
175
176/// Computation result with optional partial failure details.
177///
178/// `Err(...)` = total failure (no usable data).
179/// `Ok(output)` = some data produced; `output.failed_items` may be non-empty.
180#[derive(Debug, Clone)]
181pub struct ComputationOutput<T> {
182    pub data: T,
183    pub failed_items: Vec<FailedItem>,
184}
185
186impl<T> ComputationOutput<T> {
187    pub fn success(data: T) -> Self {
188        Self { data, failed_items: vec![] }
189    }
190
191    pub fn with_failures(data: T, failed_items: Vec<FailedItem>) -> Self {
192        Self { data, failed_items }
193    }
194
195    pub fn has_failures(&self) -> bool {
196        !self.failed_items.is_empty()
197    }
198}
199
200/// Trait for derived data computations.
201///
202/// Implement this trait to define a new type of derived data that can be
203/// computed from market data.
204///
205/// # Design
206///
207/// - `requirements()` declares upstream computations so `ComputationManager` can order computations
208///   into dependency stages
209/// - Access previous results via the store getters (`store.token_prices()` etc.)
210/// - Each computation is registered with `ComputationManager`
211/// - Computations receive `Arc<RwLock<>>` references and acquire locks as needed, allowing early
212///   release and granular locking strategies
213///
214/// # Example
215///
216/// ```ignore
217/// pub struct TokenPriceComputation {
218///     gas_token: Address,
219/// }
220///
221/// #[async_trait]
222/// impl DerivedComputation for TokenPriceComputation {
223///     type Output = TokenPrices;
224///     const ID: ComputationId = "token_prices";
225///
226///     async fn compute(
227///         &self,
228///         market: &MarketData,
229///         store: &SharedDerivedDataRef,
230///         changed: &ChangedComponents,
231///     ) -> Result<Self::Output, ComputationError> {
232///         if changed.is_full_recompute {
233///             // Full recompute: process all components
234///         } else {
235///             // Incremental: only process changed components
236///         }
237///     }
238/// }
239/// ```
240#[async_trait]
241pub trait DerivedComputation: Send + Sync + 'static {
242    /// The output type produced by this computation.
243    ///
244    /// Must be `Clone` for storage retrieval and `Send + Sync` for thread safety.
245    type Output: Clone + Send + Sync + 'static;
246
247    /// Unique identifier for this computation.
248    ///
249    /// Used for event discrimination, storage keys, and readiness tracking.
250    const ID: ComputationId;
251
252    /// Upstream computations this one reads from the store, by freshness.
253    ///
254    /// The manager uses this to order computations and to fail dependents when a
255    /// dependency fails. Defaults to none (a source computation with no upstream).
256    fn requirements(&self) -> ComputationRequirements {
257        ComputationRequirements::none()
258    }
259
260    /// Persists this computation's output into the store under [`Self::ID`].
261    ///
262    /// The default writes the output value into the store's generic slot and ignores
263    /// partial failures, so a computation needs no change to `DerivedData` to be
264    /// stored. Computations that keep a typed failure map (or other bespoke storage)
265    /// override this. The manager calls it after [`Self::compute`].
266    fn persist(
267        store: &mut DerivedData,
268        output: ComputationOutput<Self::Output>,
269        block: u64,
270        is_full_recompute: bool,
271    ) {
272        let _ = is_full_recompute;
273        store.set_output(Self::ID, output.data, block);
274    }
275
276    /// Computes the derived data from market state.
277    ///
278    /// # Arguments
279    ///
280    /// * `market` - Reference to shared market data (computation acquires lock as needed)
281    /// * `store` - Reference to derived data store (computation acquires lock as needed)
282    /// * `changed` - Information about which components changed, enabling incremental computation
283    ///
284    /// # Returns
285    ///
286    /// The computed output, or an error if computation failed.
287    ///
288    /// # Incremental Computation
289    ///
290    /// Implementations should use `changed` to only recompute data affected by the changes:
291    /// - `changed.is_full_recompute` - If true, recompute everything (startup/lag recovery)
292    /// - `changed.added` - New components to compute
293    /// - `changed.removed` - Components to remove from results
294    /// - `changed.updated` - Components whose state changed
295    ///
296    /// # Lock Management
297    ///
298    /// Computations should acquire locks only when needed and release them as early
299    /// as possible to minimize contention. Use `.read().await` for async lock acquisition.
300    async fn compute(
301        &self,
302        market: &MarketData,
303        store: &SharedDerivedDataRef,
304        changed: &ChangedComponents,
305    ) -> Result<ComputationOutput<Self::Output>, ComputationError>;
306}