Skip to main content

chronix_analytics/multivariate/
derived.rs

1//! Derived series engine with DAG-based dependency resolution.
2// Public API types — used by external consumers, suppress "never constructed" warnings.
3#![allow(dead_code)]
4
5use std::collections::HashMap;
6
7use crate::multivariate::context::MultiSeriesContext;
8use crate::multivariate::error::MultivariateError;
9
10/// An expression that produces a derived series from a context.
11pub trait DerivedSeriesExpr: Send + Sync {
12    /// Evaluate the expression against the given context.
13    fn evaluate(&self, ctx: &MultiSeriesContext) -> Result<Vec<f64>, MultivariateError>;
14}
15
16/// Definition of a derived series.
17pub struct DerivedSeriesDefinition {
18    /// Name of the derived series.
19    pub name: String,
20    /// Expression that computes the derived values.
21    pub expression: Box<dyn DerivedSeriesExpr>,
22    /// Names of the source series this definition depends on.
23    pub dependencies: Vec<String>,
24}
25
26// ─── Built-in expressions ───────────────────────────────────────────
27
28/// Arithmetic on two named series.
29pub struct ArithmeticExpr {
30    /// Name of the left operand series.
31    pub left: String,
32    /// Name of the right operand series.
33    pub right: String,
34    /// Binary arithmetic operator.
35    pub op: ArithOp,
36}
37
38/// Binary arithmetic operators for derived series.
39#[derive(Debug, Clone, Copy)]
40pub enum ArithOp {
41    /// Element-wise addition.
42    Add,
43    /// Element-wise subtraction.
44    Sub,
45    /// Element-wise multiplication.
46    Mul,
47    /// Element-wise division (safe: divides by zero yield 0.0).
48    Div,
49}
50
51impl DerivedSeriesExpr for ArithmeticExpr {
52    fn evaluate(&self, ctx: &MultiSeriesContext) -> Result<Vec<f64>, MultivariateError> {
53        let l = ctx
54            .matrix
55            .series_by_name(&self.left)
56            .ok_or_else(|| MultivariateError::SeriesNotFound(self.left.clone()))?;
57        let r = ctx
58            .matrix
59            .series_by_name(&self.right)
60            .ok_or_else(|| MultivariateError::SeriesNotFound(self.right.clone()))?;
61        if l.len() != r.len() {
62            return Err(MultivariateError::DimensionMismatch {
63                expected: l.len(),
64                got: r.len(),
65            });
66        }
67        let result = match self.op {
68            ArithOp::Add => l.iter().zip(r.iter()).map(|(a, b)| a + b).collect(),
69            ArithOp::Sub => l.iter().zip(r.iter()).map(|(a, b)| a - b).collect(),
70            ArithOp::Mul => l.iter().zip(r.iter()).map(|(a, b)| a * b).collect(),
71            ArithOp::Div => l
72                .iter()
73                .zip(r.iter())
74                .map(|(a, b)| if b.abs() > 1e-15 { a / b } else { 0.0 })
75                .collect(),
76        };
77        Ok(result)
78    }
79}
80
81/// Log returns: ln(p_t / p_{t-1}).
82pub struct LogReturnExpr {
83    /// Name of the price / value series.
84    pub series_name: String,
85}
86
87impl DerivedSeriesExpr for LogReturnExpr {
88    fn evaluate(&self, ctx: &MultiSeriesContext) -> Result<Vec<f64>, MultivariateError> {
89        let s = ctx
90            .matrix
91            .series_by_name(&self.series_name)
92            .ok_or_else(|| MultivariateError::SeriesNotFound(self.series_name.clone()))?;
93        let mut result = Vec::with_capacity(s.len());
94        result.push(0.0); // first point has no return
95        for w in s.windows(2) {
96            if w[0].abs() > 1e-15 {
97                let ratio = w[1] / w[0];
98                if ratio > 0.0 {
99                    result.push(ratio.ln());
100                } else {
101                    result.push(f64::NAN);
102                }
103            } else {
104                result.push(0.0);
105            }
106        }
107        Ok(result)
108    }
109}
110
111/// Rolling statistic expression.
112pub struct RollingStatExpr {
113    /// Name of the source series.
114    pub series_name: String,
115    /// Window size (number of observations).
116    pub window: usize,
117    /// Which rolling statistic to compute.
118    pub stat: RollingStat,
119}
120
121/// Supported rolling statistics.
122#[derive(Debug, Clone, Copy)]
123pub enum RollingStat {
124    /// Rolling arithmetic mean.
125    Mean,
126    /// Rolling standard deviation.
127    Std,
128    /// Rolling minimum.
129    Min,
130    /// Rolling maximum.
131    Max,
132}
133
134impl DerivedSeriesExpr for RollingStatExpr {
135    fn evaluate(&self, ctx: &MultiSeriesContext) -> Result<Vec<f64>, MultivariateError> {
136        let s = ctx
137            .matrix
138            .series_by_name(&self.series_name)
139            .ok_or_else(|| MultivariateError::SeriesNotFound(self.series_name.clone()))?;
140        let n = s.len();
141        let mut result = Vec::with_capacity(n);
142        let mut sum = 0.0;
143        let mut sum_sq = 0.0;
144        // Monotone deques for O(n) amortised rolling min/max
145        let mut min_deque: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
146        let mut max_deque: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
147
148        for i in 0..n {
149            sum += s[i];
150            sum_sq += s[i] * s[i];
151            if i >= self.window {
152                sum -= s[i - self.window];
153                sum_sq -= s[i - self.window] * s[i - self.window];
154            }
155
156            // Periodic FP drift recomputation every 1024 steps.
157            if i > 0 && i % 1024 == 0 {
158                let start = i.saturating_sub(self.window.saturating_sub(1));
159                sum = s[start..=i].iter().sum();
160                sum_sq = s[start..=i].iter().map(|&v| v * v).sum();
161            }
162
163            let count = (i + 1).min(self.window) as f64;
164            match self.stat {
165                RollingStat::Mean => {
166                    result.push(sum / count);
167                }
168                RollingStat::Std => {
169                    if count < 2.0 {
170                        result.push(0.0);
171                    } else {
172                        // Bessel's correction: sample std = sqrt(Var/(n-1))
173                        let var = ((sum_sq - sum * sum / count) / (count - 1.0)).max(0.0);
174                        result.push(var.sqrt());
175                    }
176                }
177                RollingStat::Min => {
178                    // Remove elements that fell out of the window
179                    while min_deque.front().is_some_and(|&f| f + self.window <= i) {
180                        min_deque.pop_front();
181                    }
182                    // Maintain increasing monotone deque
183                    while min_deque.back().is_some_and(|&b| s[b] >= s[i]) {
184                        min_deque.pop_back();
185                    }
186                    min_deque.push_back(i);
187                    result.push(s[min_deque[0]]);
188                }
189                RollingStat::Max => {
190                    // Remove elements that fell out of the window
191                    while max_deque.front().is_some_and(|&f| f + self.window <= i) {
192                        max_deque.pop_front();
193                    }
194                    // Maintain decreasing monotone deque
195                    while max_deque.back().is_some_and(|&b| s[b] <= s[i]) {
196                        max_deque.pop_back();
197                    }
198                    max_deque.push_back(i);
199                    result.push(s[max_deque[0]]);
200                }
201            }
202        }
203        Ok(result)
204    }
205}
206
207// ─── Lazy Derived Series ────────────────────────────────────────────
208
209/// Lazy wrapper around a derived series definition that caches its result.
210///
211/// The expression is only evaluated when `get()` is called for the first time
212/// (or after `invalidate()`), and the result is cached for subsequent reads.
213pub struct LazyDerivedSeries {
214    definition: DerivedSeriesDefinition,
215    cache: parking_lot::Mutex<Option<Vec<f64>>>,
216}
217
218impl LazyDerivedSeries {
219    /// Creates a new lazy-evaluated derived series.
220    pub fn new(definition: DerivedSeriesDefinition) -> Self {
221        Self {
222            definition,
223            cache: parking_lot::Mutex::new(None),
224        }
225    }
226
227    /// Returns the cached result, evaluating the expression if needed.
228    pub fn get(&self, ctx: &MultiSeriesContext) -> Result<Vec<f64>, MultivariateError> {
229        let mut cache = self.cache.lock();
230        if let Some(ref cached) = *cache {
231            return Ok(cached.clone());
232        }
233        let result = self.definition.expression.evaluate(ctx)?;
234        *cache = Some(result.clone());
235        Ok(result)
236    }
237
238    /// Invalidates the cached result, forcing re-evaluation on next `get()`.
239    pub fn invalidate(&self) {
240        let mut cache = self.cache.lock();
241        *cache = None;
242    }
243
244    /// Returns true if the result is currently cached.
245    pub fn is_cached(&self) -> bool {
246        self.cache.lock().is_some()
247    }
248
249    /// Returns the underlying definition.
250    pub fn definition(&self) -> &DerivedSeriesDefinition {
251        &self.definition
252    }
253}
254
255// ─── DAG Engine ─────────────────────────────────────────────────────
256
257/// Engine that evaluates derived series in topological order.
258///
259/// # Evaluation Strategy
260///
261/// Definitions are topologically sorted into **wave-front levels**:
262/// nodes within the same level share no mutual dependencies and are
263/// evaluated in parallel via `rayon`.  After each level completes,
264/// results are merged into the [`MultiSeriesContext`] so that the
265/// next level's expressions can reference them.
266pub struct DerivedSeriesEngine {
267    definitions: Vec<DerivedSeriesDefinition>,
268}
269
270impl DerivedSeriesEngine {
271    /// Creates a new engine from the given definitions.
272    pub fn new(definitions: Vec<DerivedSeriesDefinition>) -> Self {
273        Self { definitions }
274    }
275
276    /// Topologically sort and evaluate all definitions.
277    ///
278    /// Nodes within the same wave-front level are evaluated in parallel
279    /// via rayon.  Results are merged into the context after each level
280    /// so that the next level's expressions can reference them.
281    pub fn evaluate(
282        &self,
283        ctx: &mut MultiSeriesContext,
284    ) -> Result<HashMap<String, Vec<f64>>, MultivariateError> {
285        use rayon::prelude::*;
286
287        let _start = std::time::Instant::now();
288        let levels = self.topological_sort_levels()?;
289        let mut results = HashMap::new();
290
291        for level in &levels {
292            if level.len() == 1 {
293                // Single node — no parallelism overhead.
294                let def = &self.definitions[level[0]];
295                let vals = def.expression.evaluate(ctx)?;
296                Self::insert_into_context(ctx, &def.name, vals.clone());
297                results.insert(def.name.clone(), vals);
298            } else {
299                // Multiple independent nodes — evaluate in parallel.
300                let wave_results: Vec<Result<(String, Vec<f64>), MultivariateError>> = level
301                    .par_iter()
302                    .map(|&idx| {
303                        let def = &self.definitions[idx];
304                        let vals = def.expression.evaluate(ctx)?;
305                        Ok((def.name.clone(), vals))
306                    })
307                    .collect();
308
309                // Merge wave results into context (sequential).
310                for result in wave_results {
311                    let (name, vals) = result?;
312                    Self::insert_into_context(ctx, &name, vals.clone());
313                    results.insert(name, vals);
314                }
315            }
316        }
317
318        metrics::histogram!("chronix_derived_series_eval_duration_seconds")
319            .record(_start.elapsed().as_secs_f64());
320        Ok(results)
321    }
322
323    /// Insert or update a series in the context matrix.
324    fn insert_into_context(ctx: &mut MultiSeriesContext, name: &str, vals: Vec<f64>) {
325        if let Some(pos) = ctx.matrix.series_ids.iter().position(|n| n == name) {
326            ctx.matrix.data[pos] = vals;
327        } else {
328            ctx.matrix.series_ids.push(name.to_string());
329            ctx.matrix.data.push(vals);
330        }
331    }
332
333    /// Topological sort that returns levels (wave-fronts).
334    ///
335    /// All nodes within a level have zero remaining in-degree and can
336    /// be evaluated concurrently.
337    fn topological_sort_levels(&self) -> Result<Vec<Vec<usize>>, MultivariateError> {
338        let n = self.definitions.len();
339        let name_to_idx: HashMap<&str, usize> = self
340            .definitions
341            .iter()
342            .enumerate()
343            .map(|(i, d)| (d.name.as_str(), i))
344            .collect();
345
346        // Build adjacency list
347        let mut in_degree = vec![0usize; n];
348        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
349        for (i, def) in self.definitions.iter().enumerate() {
350            for dep in &def.dependencies {
351                if let Some(&j) = name_to_idx.get(dep.as_str()) {
352                    adj[j].push(i);
353                    in_degree[i] += 1;
354                }
355            }
356        }
357
358        // Kahn's algorithm — level-by-level.
359        let mut current_level: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
360        let mut levels = Vec::new();
361        let mut processed = 0usize;
362
363        while !current_level.is_empty() {
364            let mut next_level = Vec::new();
365            for &node in &current_level {
366                processed += 1;
367                for &next in &adj[node] {
368                    in_degree[next] -= 1;
369                    if in_degree[next] == 0 {
370                        next_level.push(next);
371                    }
372                }
373            }
374            levels.push(current_level);
375            current_level = next_level;
376        }
377
378        if processed != n {
379            return Err(MultivariateError::CycleDetected);
380        }
381        Ok(levels)
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    fn test_ctx() -> MultiSeriesContext {
390        MultiSeriesContext::build(
391            vec![
392                (
393                    "a".to_string(),
394                    vec![0, 1_000_000_000, 2_000_000_000],
395                    vec![10.0, 20.0, 30.0],
396                ),
397                (
398                    "b".to_string(),
399                    vec![0, 1_000_000_000, 2_000_000_000],
400                    vec![5.0, 10.0, 15.0],
401                ),
402            ],
403            None,
404        )
405        .unwrap()
406    }
407
408    #[test]
409    fn arithmetic_add() {
410        let ctx = test_ctx();
411        let expr = ArithmeticExpr {
412            left: "a".to_string(),
413            right: "b".to_string(),
414            op: ArithOp::Add,
415        };
416        let result = expr.evaluate(&ctx).unwrap();
417        assert!((result[0] - 15.0).abs() < 1e-6);
418        assert!((result[1] - 30.0).abs() < 1e-6);
419        assert!((result[2] - 45.0).abs() < 1e-6);
420    }
421
422    #[test]
423    fn log_return() {
424        let ctx = test_ctx();
425        let expr = LogReturnExpr {
426            series_name: "a".to_string(),
427        };
428        let result = expr.evaluate(&ctx).unwrap();
429        assert!((result[0] - 0.0).abs() < 1e-6);
430        assert!((result[1] - (20.0f64 / 10.0).ln()).abs() < 1e-6);
431    }
432
433    #[test]
434    fn dag_evaluation() {
435        let mut ctx = test_ctx();
436        let defs = vec![DerivedSeriesDefinition {
437            name: "spread".to_string(),
438            expression: Box::new(ArithmeticExpr {
439                left: "a".to_string(),
440                right: "b".to_string(),
441                op: ArithOp::Sub,
442            }),
443            dependencies: vec![],
444        }];
445        let engine = DerivedSeriesEngine::new(defs);
446        let results = engine.evaluate(&mut ctx).unwrap();
447        let spread = &results["spread"];
448        assert!((spread[0] - 5.0).abs() < 1e-6);
449        assert!((spread[1] - 10.0).abs() < 1e-6);
450    }
451
452    #[test]
453    fn cycle_detection() {
454        let defs = vec![
455            DerivedSeriesDefinition {
456                name: "x".to_string(),
457                expression: Box::new(ArithmeticExpr {
458                    left: "a".to_string(),
459                    right: "y".to_string(),
460                    op: ArithOp::Add,
461                }),
462                dependencies: vec!["y".to_string()],
463            },
464            DerivedSeriesDefinition {
465                name: "y".to_string(),
466                expression: Box::new(ArithmeticExpr {
467                    left: "a".to_string(),
468                    right: "x".to_string(),
469                    op: ArithOp::Add,
470                }),
471                dependencies: vec!["x".to_string()],
472            },
473        ];
474        let engine = DerivedSeriesEngine::new(defs);
475        let mut ctx = test_ctx();
476        assert!(engine.evaluate(&mut ctx).is_err());
477    }
478
479    #[test]
480    fn lazy_derived_series_caches_result() {
481        let ctx = test_ctx();
482
483        let def = DerivedSeriesDefinition {
484            name: "double_a".to_string(),
485            expression: Box::new(ArithmeticExpr {
486                left: "a".to_string(),
487                right: "a".to_string(),
488                op: ArithOp::Add,
489            }),
490            dependencies: vec!["a".to_string()],
491        };
492
493        let lazy = LazyDerivedSeries::new(def);
494
495        // Not cached initially
496        assert!(!lazy.is_cached());
497
498        // First call evaluates
499        let result = lazy.get(&ctx).unwrap();
500        assert_eq!(result, vec![20.0, 40.0, 60.0]);
501        assert!(lazy.is_cached());
502
503        // Second call returns cached
504        let result2 = lazy.get(&ctx).unwrap();
505        assert_eq!(result2, vec![20.0, 40.0, 60.0]);
506
507        // Invalidate clears cache
508        lazy.invalidate();
509        assert!(!lazy.is_cached());
510    }
511}