Skip to main content

fynd_core/derived/
computation.rs

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