Skip to main content

ocas_eval/
streaming.rs

1//! Streaming evaluation over large datasets.
2//!
3//! [`StreamingEvaluator`] wraps an [`ExpressionEvaluator`] with reusable
4//! buffers, so processing a stream of input rows uses constant memory
5//! regardless of stream length. This mirrors the semantics of Symbolica's
6//! `streaming.rs`: rows flow through the evaluator one at a time and
7//! results are consumed by a sink callback.
8
9use crate::domain::{EvaluationDomain, PowfExtension};
10use crate::error::Result;
11use crate::evaluator::ExpressionEvaluator;
12
13/// Streaming evaluator with reusable internal buffers.
14///
15/// Created from a borrowed [`ExpressionEvaluator`]; all scratch memory
16/// (parameter staging, evaluation stack, result buffer) is allocated
17/// once up front and reused for every row.
18///
19/// # Example
20///
21/// ```ignore
22/// let eval: ExpressionEvaluator<f64> = ExpressionEvaluator::compile(atom)?;
23/// let mut stream = StreamingEvaluator::new(&eval);
24/// let rows = (0..1_000_000).map(|i| [i as f64]);
25/// let n = stream.for_each(rows, |results| {
26///     // consume results (same buffer, valid only within the callback)
27/// })?;
28/// assert_eq!(n, 1_000_000);
29/// ```
30pub struct StreamingEvaluator<'a, T: EvaluationDomain> {
31    evaluator: &'a ExpressionEvaluator<T>,
32    params: Vec<T>,
33    stack: Vec<T>,
34    results: Vec<T>,
35}
36
37impl<T: EvaluationDomain + PowfExtension> StreamingEvaluator<'_, T> {
38    /// Create a streaming evaluator, pre-allocating all buffers.
39    pub fn new(evaluator: &ExpressionEvaluator<T>) -> StreamingEvaluator<'_, T> {
40        StreamingEvaluator {
41            evaluator,
42            params: Vec::with_capacity(evaluator.param_count()),
43            stack: Vec::with_capacity(evaluator.stack_size()),
44            results: Vec::with_capacity(evaluator.result_count()),
45        }
46    }
47
48    /// Process a stream of input rows, invoking `sink` with the result
49    /// slice for each row.
50    ///
51    /// Each row must contain exactly
52    /// [`param_count`](ExpressionEvaluator::param_count) values; the
53    /// slice passed to `sink` contains
54    /// [`result_count`](ExpressionEvaluator::result_count) values and is
55    /// only valid for the duration of the callback. Returns the number
56    /// of rows processed.
57    ///
58    /// Memory usage is constant: no allocation grows with the number of
59    /// rows.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`crate::EvaluationError`] if a row has the wrong arity
64    /// or an arithmetic error occurs during evaluation.
65    pub fn for_each<I, S, F>(&mut self, rows: I, mut sink: F) -> Result<usize>
66    where
67        I: IntoIterator<Item = S>,
68        S: AsRef<[T]>,
69        F: FnMut(&[T]),
70    {
71        let mut count = 0usize;
72        for row in rows {
73            let row = row.as_ref();
74            self.params.clear();
75            self.params.extend(row.iter().cloned());
76            self.evaluator
77                .evaluate_with_stack(&self.params, &mut self.stack, &mut self.results)?;
78            sink(&self.results);
79            count += 1;
80        }
81        Ok(count)
82    }
83
84    /// Process a chunk of rows and collect all results.
85    ///
86    /// Convenience method for bounded batches; prefer
87    /// [`for_each`](StreamingEvaluator::for_each) for unbounded streams.
88    /// Returns one result vector per row.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`crate::EvaluationError`] if a row has the wrong arity
93    /// or an arithmetic error occurs during evaluation.
94    pub fn evaluate_chunk<S: AsRef<[T]>>(&mut self, rows: &[S]) -> Result<Vec<Vec<T>>> {
95        let mut out = Vec::with_capacity(rows.len());
96        self.for_each(rows, |results| out.push(results.to_vec()))?;
97        Ok(out)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use ocas_atom::AtomArena;
105    use ocas_core::arena::Arena;
106
107    fn build_eval() -> (Arena, ExpressionEvaluator<f64>) {
108        let arena = Arena::new();
109        let ctx = AtomArena::new(&arena);
110        let sum = ctx.add(&[ctx.var("x"), ctx.var("y")]);
111        let prod = ctx.mul(&[ctx.var("x"), ctx.var("y")]);
112        let eval = ExpressionEvaluator::compile_multi(&[sum, prod]).unwrap();
113        (arena, eval)
114    }
115
116    #[test]
117    fn streaming_multi_output() {
118        let (_arena, eval) = build_eval();
119        let mut stream = StreamingEvaluator::new(&eval);
120        let rows: Vec<[f64; 2]> = (0..100).map(|i| [i as f64, 2.0]).collect();
121        let mut seen = Vec::new();
122        let n = stream
123            .for_each(&rows, |results| seen.push((results[0], results[1])))
124            .unwrap();
125        assert_eq!(n, 100);
126        for (i, &(sum, prod)) in seen.iter().enumerate() {
127            assert!((sum - (i as f64 + 2.0)).abs() < 1e-10);
128            assert!((prod - (i as f64 * 2.0)).abs() < 1e-10);
129        }
130    }
131
132    #[test]
133    fn streaming_constant_memory_million_rows() {
134        let (_arena, eval) = build_eval();
135        let mut stream = StreamingEvaluator::new(&eval);
136
137        // Warm up so buffers reach their steady-state capacity.
138        let warm: Vec<[f64; 2]> = vec![[1.0, 2.0]; 10];
139        stream.for_each(&warm, |_| {}).unwrap();
140        let stack_cap = stream.stack.capacity();
141        let results_cap = stream.results.capacity();
142        let params_cap = stream.params.capacity();
143
144        // One million rows produced lazily — no dataset allocation.
145        let rows = (0..1_000_000u64).map(|i| [i as f64 % 100.0, 3.0]);
146        let mut count = 0usize;
147        let mut checksum = 0.0f64;
148        let n = stream
149            .for_each(rows, |results| {
150                count += 1;
151                checksum += results[0];
152            })
153            .unwrap();
154        assert_eq!(n, 1_000_000);
155        assert_eq!(count, 1_000_000);
156        assert!(checksum > 0.0);
157
158        // Buffer capacities unchanged: memory is constant in stream length.
159        assert_eq!(stream.stack.capacity(), stack_cap);
160        assert_eq!(stream.results.capacity(), results_cap);
161        assert_eq!(stream.params.capacity(), params_cap);
162    }
163
164    #[test]
165    fn streaming_wrong_arity_errors() {
166        let (_arena, eval) = build_eval();
167        let mut stream = StreamingEvaluator::new(&eval);
168        let rows: Vec<Vec<f64>> = vec![vec![1.0], vec![2.0, 3.0]];
169        assert!(stream.for_each(&rows, |_| {}).is_err());
170    }
171
172    #[test]
173    fn streaming_evaluate_chunk() {
174        let (_arena, eval) = build_eval();
175        let mut stream = StreamingEvaluator::new(&eval);
176        let rows: Vec<[f64; 2]> = vec![[1.0, 2.0], [3.0, 4.0]];
177        let out = stream.evaluate_chunk(&rows).unwrap();
178        assert_eq!(out.len(), 2);
179        assert!((out[0][0] - 3.0).abs() < 1e-10);
180        assert!((out[0][1] - 2.0).abs() < 1e-10);
181        assert!((out[1][0] - 7.0).abs() < 1e-10);
182        assert!((out[1][1] - 12.0).abs() < 1e-10);
183    }
184}