Skip to main content

somatize_runtime/runner/
local.rs

1//! LocalRunner — executes plans locally using the Executor.
2//!
3//! This is the default runner. The worker's RemoteRunner delegates here
4//! after preparing the environment (deserializing filters, resolving input).
5
6use super::{RunContext, Runner};
7use crate::executor::{Context, RunMode};
8
9use somatize_compiler::ExecutionPlan;
10use somatize_core::error::{Result, SomaError};
11use somatize_core::keys::{GRAPH_INPUT, input_key};
12use somatize_core::value::Value;
13use std::collections::HashMap;
14
15/// Executes plans locally — same logic for local and remote execution.
16pub struct LocalRunner;
17
18impl LocalRunner {
19    /// Build the run's context and walk the plan.
20    ///
21    /// Fit and forward differ only in [`RunMode`], so they share this. Fit
22    /// used to have a walk of its own — flattening the plan into a list and
23    /// re-deriving inputs, events, caching and panic handling — which is
24    /// why it ignored `Parallel`, `Loop` and `Branch`, never emitted a cache
25    /// event, and failed outright on a graph containing a step.
26    fn walk(
27        &self,
28        plan: &ExecutionPlan,
29        ctx: &RunContext<'_>,
30        input: &Value,
31        mode: RunMode,
32    ) -> Result<Context> {
33        let mut exec = Context::new(ctx.events.clone(), ctx.run_id)
34            .with_graph_info(ctx.graph_info.clone())
35            .with_seed(ctx.seed);
36        exec.mode = mode;
37        exec.driver = ctx.driver();
38
39        if let Some(first) = plan.node_ids().first() {
40            exec.set(input_key(first), input.clone());
41        }
42        exec.set(GRAPH_INPUT, input.clone());
43
44        crate::executor::execute(plan, &mut exec, ctx.catalog, ctx.cache)?;
45        Ok(exec)
46    }
47
48    /// The output of the node that ran last, not of the last node listed.
49    ///
50    /// On a branch only one arm runs, so plan order is the wrong question.
51    fn last_output(exec: &Context) -> Option<Value> {
52        exec.execution_order()
53            .iter()
54            .rev()
55            .find(|id| !somatize_core::keys::is_reserved(id))
56            .and_then(|id| exec.get(id).cloned())
57    }
58}
59
60impl Runner for LocalRunner {
61    fn fit(
62        &self,
63        plan: &ExecutionPlan,
64        ctx: &RunContext<'_>,
65        input: &Value,
66        y: Option<&Value>,
67    ) -> Result<(Value, HashMap<String, Value>)> {
68        let exec = self.walk(plan, ctx, input, RunMode::Fit { y: y.cloned() })?;
69
70        // Node outputs plus the states that were learned. The `__state_*`
71        // entries travel in the same map because that is what the session
72        // and the worker already read out of it.
73        let last = Self::last_output(&exec).unwrap_or(Value::Empty);
74        let mut produced = exec.into_outputs();
75        // The run's own inputs are not something a node produced.
76        produced.retain(|id, _| !somatize_core::keys::is_input_key(id));
77
78        Ok((last, produced))
79    }
80
81    fn forward(&self, plan: &ExecutionPlan, ctx: &RunContext<'_>, input: &Value) -> Result<Value> {
82        let exec = self.walk(plan, ctx, input, RunMode::Forward)?;
83        Self::last_output(&exec).ok_or_else(|| SomaError::Other("no output produced".into()))
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::EventBus;
91    use crate::cache::MemoryCache;
92    use crate::executor::GraphInfo;
93    use crate::node_catalog::NodeCatalog;
94    use somatize_core::cache::{CacheKey, CacheStore};
95    use somatize_core::filter::{Filter, FilterKind, FilterMeta, StreamMode};
96    use std::sync::Arc;
97    use std::sync::atomic::{AtomicUsize, Ordering};
98
99    /// Counts fit() invocations — probe for state-cache key tests.
100    struct CountingFitFilter {
101        fits: Arc<AtomicUsize>,
102    }
103
104    impl Filter for CountingFitFilter {
105        fn config_hash(&self) -> CacheKey {
106            CacheKey::from_parts(&[b"CountingFit"])
107        }
108        fn fit(&self, _x: &Value, y: Option<&Value>) -> Result<Value> {
109            self.fits.fetch_add(1, Ordering::SeqCst);
110            Ok(y.cloned().unwrap_or(Value::Empty))
111        }
112        fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
113            Ok(x.clone())
114        }
115        fn meta(&self) -> FilterMeta {
116            FilterMeta {
117                name: "CountingFit".into(),
118                kind: FilterKind::Trainable,
119                cacheable: true,
120                differentiable: false,
121                deterministic: true,
122                stream_mode: StreamMode::FixedState,
123                distribution: somatize_core::filter::Distribution::Local,
124                input_schema: None,
125                output_schema: None,
126            }
127        }
128    }
129
130    #[test]
131    fn state_cache_key_is_sensitive_to_labels() {
132        let fits = Arc::new(AtomicUsize::new(0));
133        let mut filters = NodeCatalog::new();
134        filters.register("clf", Box::new(CountingFitFilter { fits: fits.clone() }));
135
136        let cache = MemoryCache::default();
137        let bus = Arc::new(EventBus::new(64));
138        let plan = ExecutionPlan::Execute {
139            node_id: "clf".into(),
140        };
141        let runner = LocalRunner;
142        // A single node: the fabricated linear topology is the real one.
143        fn ctx<'a>(
144            filters: &'a NodeCatalog,
145            cache: &'a dyn CacheStore,
146            bus: &'a Arc<EventBus>,
147        ) -> RunContext<'a> {
148            RunContext::new(filters, cache, bus, "test_run", GraphInfo::new())
149        }
150        let x = Value::tensor(vec![1.0, 2.0], vec![2]);
151        let y_a = Value::tensor(vec![0.0, 1.0], vec![2]);
152        let y_b = Value::tensor(vec![1.0, 0.0], vec![2]);
153
154        runner
155            .fit(&plan, &ctx(&filters, &cache, &bus), &x, Some(&y_a))
156            .unwrap();
157        assert_eq!(fits.load(Ordering::SeqCst), 1);
158
159        // Same features, same labels → cached state, no refit.
160        runner
161            .fit(&plan, &ctx(&filters, &cache, &bus), &x, Some(&y_a))
162            .unwrap();
163        assert_eq!(fits.load(Ordering::SeqCst), 1);
164
165        // Same features, DIFFERENT labels → must refit.
166        runner
167            .fit(&plan, &ctx(&filters, &cache, &bus), &x, Some(&y_b))
168            .unwrap();
169        assert_eq!(
170            fits.load(Ordering::SeqCst),
171            2,
172            "different labels must not reuse the cached state"
173        );
174
175        // Unsupervised (no labels) → distinct from both supervised keys.
176        runner
177            .fit(&plan, &ctx(&filters, &cache, &bus), &x, None)
178            .unwrap();
179        assert_eq!(fits.load(Ordering::SeqCst), 3);
180    }
181}