Skip to main content

somatize_runtime/
forward.rs

1//! Forward execution strategies for [`crate::GraphSession`].
2//!
3//! Each strategy defines HOW input data flows through a compiled graph:
4//! - [`Standard`] — full input at once, with inference caching
5//! - [`Stream`] — chunked input through [`crate::StreamRun`], respecting StreamMode
6//! - [`Batched`] — rows from a [`DataStore`], batch by batch (memory-bounded)
7
8use crate::event_bus::EventBus;
9use crate::node_catalog::NodeCatalog;
10use crate::runner::{RunContext, Runner};
11use somatize_compiler::{CompileMode, CompileResult, compile, compile_stream};
12use somatize_core::cache::CacheStore;
13use somatize_core::error::{Result, SomaError};
14use somatize_core::graph::Graph;
15use somatize_core::store::{DataRef, DataStore};
16use somatize_core::value::Value;
17use std::sync::Arc;
18
19/// What a forward pass runs against, besides the graph and the data.
20///
21/// A struct rather than six parameters, for the same reason as
22/// [`RunContext`]: every strategy takes exactly this set, and a caller
23/// forgetting one of six positional arguments is a bug the compiler cannot
24/// name.
25pub struct ForwardEnv<'a> {
26    /// Implementations and trained states for every node in the graph.
27    pub catalog: &'a NodeCatalog,
28    /// Output cache consulted and filled during the pass.
29    pub cache: &'a dyn CacheStore,
30    /// Bus the pass emits its node events on.
31    pub event_bus: &'a Arc<EventBus>,
32    /// Row source [`Batched`] reads from; the other strategies ignore it.
33    pub data_store: Option<&'a Arc<dyn DataStore>>,
34    /// Performs and journals step effects; a graph without steps ignores
35    /// it, which is why it is an `Option` and not a requirement.
36    pub driver: Option<&'a crate::effects::EffectDriver>,
37}
38
39/// How a forward pass feeds data through the compiled graph.
40pub trait ForwardStrategy {
41    /// Execute a forward pass, returning the final output.
42    fn forward(&self, graph: &Graph, env: &ForwardEnv<'_>, x: &Value) -> Result<Value>;
43}
44
45/// Full input at once, with inference caching.
46pub struct Standard;
47
48impl ForwardStrategy for Standard {
49    fn forward(&self, graph: &Graph, env: &ForwardEnv<'_>, x: &Value) -> Result<Value> {
50        let CompileResult { plan, .. } =
51            compile(graph, env.catalog, CompileMode::Inference, Some(env.cache))?;
52        run_forward(graph, &plan, env, x)
53    }
54}
55
56/// Chunked input through [`crate::StreamRun`], respecting each
57/// filter's `StreamMode`.
58pub struct Stream {
59    /// Rows per chunk fed through the stream plan.
60    pub chunk_size: usize,
61}
62
63impl ForwardStrategy for Stream {
64    fn forward(&self, graph: &Graph, env: &ForwardEnv<'_>, x: &Value) -> Result<Value> {
65        let CompileResult { plan, .. } = compile_stream(graph, env.catalog, self.chunk_size)?;
66        run_forward(graph, &plan, env, x)
67    }
68}
69
70/// Run a compiled plan against the graph's *real* topology.
71///
72/// The runner used to derive its own from the plan's node order, chaining
73/// them as if every graph were a line. On a diamond it was simply wrong:
74/// `a → {b, c} → d` answered `d(c(…))`, with `d` never seeing `b` and `a`
75/// never seeing the input. Every strategy here has the graph, so every
76/// strategy passes it.
77fn run_forward(
78    graph: &Graph,
79    plan: &somatize_compiler::ExecutionPlan,
80    env: &ForwardEnv<'_>,
81    x: &Value,
82) -> Result<Value> {
83    let run_id = somatize_core::util::timestamp_id("forward");
84    let mut ctx = RunContext::new(
85        env.catalog,
86        env.cache,
87        env.event_bus,
88        &run_id,
89        crate::executor::GraphInfo::from_graph(graph),
90    );
91    if let Some(driver) = env.driver {
92        ctx = ctx.with_driver(driver.clone());
93    }
94    crate::runner::LocalRunner.forward(plan, &ctx, x)
95}
96
97/// Batched forward: read rows from a DataStore in fixed-size batches.
98/// Keeps memory bounded — only one batch is materialized at a time.
99pub struct Batched<'a> {
100    /// Which dataset to read from the [`ForwardEnv::data_store`].
101    pub data_ref: &'a DataRef,
102    /// Rows materialized per batch — the memory bound.
103    pub batch_size: usize,
104}
105
106impl ForwardStrategy for Batched<'_> {
107    fn forward(&self, graph: &Graph, env: &ForwardEnv<'_>, _x: &Value) -> Result<Value> {
108        let store = env.data_store.ok_or_else(|| SomaError::Execution {
109            node_id: "session".into(),
110            message: "Batched strategy requires a data store (use with_data_store)".into(),
111        })?;
112
113        let meta = store.meta(self.data_ref)?;
114        let total_rows = meta.total_rows;
115        if total_rows == 0 {
116            return Ok(Value::Empty);
117        }
118
119        // Compile once, reuse for each batch.
120        let CompileResult { plan, .. } =
121            compile(graph, env.catalog, CompileMode::Inference, Some(env.cache))?;
122
123        let mut all_values: Vec<f64> = Vec::new();
124        let mut result_shape: Option<Vec<usize>> = None;
125        let mut rows_processed = 0;
126
127        while rows_processed < total_rows {
128            let batch_len = self.batch_size.min(total_rows - rows_processed);
129            let batch = store.get_rows(self.data_ref, rows_processed, batch_len)?;
130            let output = run_forward(graph, &plan, env, &batch)?;
131
132            if let Value::Tensor { values, shape } = &output {
133                if result_shape.is_none() {
134                    result_shape = Some(shape.clone());
135                }
136                all_values.extend_from_slice(values.as_slice());
137            } else {
138                return Ok(output);
139            }
140
141            rows_processed += batch_len;
142        }
143
144        match result_shape {
145            Some(mut shape) => {
146                shape[0] = total_rows;
147                Ok(Value::tensor(all_values, shape))
148            }
149            None => Ok(Value::Empty),
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::cache::MemoryCache;
158    use crate::node_catalog::NodeCatalog;
159    use somatize_core::cache::CacheKey;
160    use somatize_core::error::Result as SomaResult;
161    use somatize_core::filter::{Distribution, Filter, FilterKind, FilterMeta, StreamMode};
162    use somatize_core::graph::{Graph, Node};
163
164    struct DoublerFilter;
165    impl Filter for DoublerFilter {
166        fn config_hash(&self) -> CacheKey {
167            CacheKey::from_parts(&[b"Doubler"])
168        }
169        fn fit(&self, _x: &Value, _y: Option<&Value>) -> SomaResult<Value> {
170            Ok(Value::Empty)
171        }
172        fn forward(&self, x: &Value, _state: &Value) -> SomaResult<Value> {
173            match x {
174                Value::Tensor { values, shape } => {
175                    let doubled: Vec<f64> = values.iter().map(|v| v * 2.0).collect();
176                    Ok(Value::tensor(doubled, shape.clone()))
177                }
178                _ => Ok(x.clone()),
179            }
180        }
181        fn meta(&self) -> FilterMeta {
182            FilterMeta {
183                name: "Doubler".into(),
184                kind: FilterKind::Stateless,
185                cacheable: false,
186                differentiable: false,
187                deterministic: true,
188                stream_mode: StreamMode::FixedState,
189                distribution: Distribution::Local,
190                input_schema: None,
191                output_schema: None,
192            }
193        }
194    }
195
196    fn make_session() -> (Graph, NodeCatalog, Arc<dyn CacheStore>, Arc<EventBus>) {
197        let mut graph = Graph::new();
198        graph.nodes.push(Node::new("double", "Double", "double"));
199
200        let mut catalog = NodeCatalog::new();
201        catalog.register("double", Box::new(DoublerFilter));
202
203        let cache: Arc<dyn CacheStore> = Arc::new(MemoryCache::default());
204        let bus = Arc::new(EventBus::new(64));
205        (graph, catalog, cache, bus)
206    }
207
208    fn env<'a>(
209        catalog: &'a NodeCatalog,
210        cache: &'a dyn CacheStore,
211        event_bus: &'a Arc<EventBus>,
212    ) -> ForwardEnv<'a> {
213        ForwardEnv {
214            catalog,
215            cache,
216            event_bus,
217            data_store: None,
218            driver: None,
219        }
220    }
221
222    #[test]
223    fn standard_forward() {
224        let (graph, catalog, cache, bus) = make_session();
225        let input = Value::tensor(vec![1.0, 2.0, 3.0], vec![3]);
226
227        let result = Standard
228            .forward(&graph, &env(&catalog, cache.as_ref(), &bus), &input)
229            .unwrap();
230        let (data, _) = result.as_tensor().unwrap();
231        assert_eq!(data, &[2.0, 4.0, 6.0]);
232    }
233
234    #[test]
235    fn stream_forward() {
236        let (graph, catalog, cache, bus) = make_session();
237        let input = Value::tensor(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![6]);
238
239        let result = Stream { chunk_size: 2 }
240            .forward(&graph, &env(&catalog, cache.as_ref(), &bus), &input)
241            .unwrap();
242        let (data, shape) = result.as_tensor().unwrap();
243        assert_eq!(data, &[2.0, 4.0, 6.0, 8.0, 10.0, 12.0]);
244        assert_eq!(shape, &[6]);
245    }
246
247    #[test]
248    fn stream_matches_standard() {
249        let (graph, catalog, cache, bus) = make_session();
250        let input = Value::tensor(vec![1.0, 2.0, 3.0, 4.0], vec![4]);
251
252        let standard = Standard
253            .forward(&graph, &env(&catalog, cache.as_ref(), &bus), &input)
254            .unwrap();
255        let streamed = Stream { chunk_size: 2 }
256            .forward(&graph, &env(&catalog, cache.as_ref(), &bus), &input)
257            .unwrap();
258        assert_eq!(standard, streamed);
259    }
260}