1use crate::error::{Result, WorkerError};
4use crate::protocol::*;
5use somatize_core::cache::CacheStore;
6use somatize_core::event::Event;
7use somatize_core::filter::Filter;
8use somatize_core::store::{DataStore, LocalDataStore};
9use somatize_core::value::Value;
10use somatize_runtime::{EventBus, MemoryCache, NodeCatalog, Runner};
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::Instant;
14
15pub struct Worker {
17 pub id: WorkerId,
19 pub capabilities: Capabilities,
22 event_bus: Arc<EventBus>,
23 cache: Arc<dyn CacheStore>,
24 catalog: NodeCatalog,
25 data_store: Option<Arc<dyn DataStore>>,
27 temp_store: Arc<LocalDataStore>,
29 env_manager: crate::env_manager::EnvManager,
31 python: String,
41}
42
43fn default_python() -> String {
49 std::env::var("SOMA_PYTHON")
50 .ok()
51 .filter(|p| !p.is_empty())
52 .unwrap_or_else(|| "python3".to_string())
53}
54
55impl Worker {
56 pub fn new(id: impl Into<String>, capabilities: Capabilities) -> Self {
61 let worker_id: String = id.into();
62 let temp_path = std::env::temp_dir().join(format!("soma-uploads-{worker_id}"));
63 let temp_store = LocalDataStore::new(temp_path);
64 let env_path = std::env::temp_dir().join(format!("soma-envs-{worker_id}"));
65 Self {
66 id: worker_id,
67 capabilities,
68 event_bus: Arc::new(EventBus::new(256)),
69 cache: Arc::new(MemoryCache::default()),
70 catalog: NodeCatalog::new(),
71 data_store: None,
72 temp_store: Arc::new(temp_store),
73 env_manager: crate::env_manager::EnvManager::new(
74 env_path,
75 crate::env_manager::EnvType::Venv,
76 ),
77 python: default_python(),
78 }
79 }
80
81 pub fn with_python(mut self, python: impl Into<String>) -> Self {
88 self.python = python.into();
89 self
90 }
91
92 pub fn with_cache(mut self, cache: Arc<dyn CacheStore>) -> Self {
94 self.cache = cache;
95 self
96 }
97
98 pub fn with_data_store(mut self, store: Arc<dyn DataStore>) -> Self {
100 self.data_store = Some(store);
101 self
102 }
103
104 pub fn with_temp_dir(mut self, path: std::path::PathBuf) -> Self {
106 self.temp_store = Arc::new(LocalDataStore::new(path));
107 self
108 }
109
110 pub fn temp_store(&self) -> &Arc<LocalDataStore> {
112 &self.temp_store
113 }
114
115 pub fn register_filter(&mut self, node_id: impl Into<String>, filter: Box<dyn Filter>) {
117 self.catalog.register(node_id, filter);
118 }
119
120 pub fn get_filter(&self, node_id: &str) -> Option<Arc<dyn Filter>> {
122 self.catalog.get(node_id)
123 }
124
125 pub fn catalog(&self) -> &NodeCatalog {
127 &self.catalog
128 }
129
130 pub fn event_bus(&self) -> &Arc<EventBus> {
132 &self.event_bus
133 }
134
135 pub fn cache(&self) -> &Arc<dyn CacheStore> {
137 &self.cache
138 }
139
140 pub fn get_filter_state(&self, node_id: &str) -> Arc<Value> {
142 self.catalog
143 .get_state(node_id)
144 .unwrap_or_else(|| Arc::new(Value::Empty))
145 }
146
147 fn subprocess_for(
152 &self,
153 node_id: &str,
154 ) -> Option<Arc<std::sync::Mutex<crate::python_process::PythonProcess>>> {
155 let filter = self.catalog.get(node_id)?;
156 let sf = filter
157 .as_any()
158 .downcast_ref::<crate::python_process::SubprocessFilter>()?;
159 Some(sf.process.clone())
160 }
161
162 pub fn read_states(&self, node_ids: &[String]) -> Result<HashMap<String, Value>> {
170 let mut out = HashMap::new();
171 for node_id in node_ids {
172 let Some(proc) = self.subprocess_for(node_id) else {
173 out.insert(node_id.clone(), (*self.get_filter_state(node_id)).clone());
175 continue;
176 };
177 let mut guard = proc
178 .lock()
179 .map_err(|e| WorkerError::Concurrency(format!("process mutex poisoned: {e}")))?;
180 out.insert(node_id.clone(), guard.get_state(node_id)?);
181 }
182 Ok(out)
183 }
184
185 pub fn write_states(&mut self, states: &HashMap<String, Value>) -> Result<()> {
187 for (node_id, state) in states {
188 if let Some(proc) = self.subprocess_for(node_id) {
189 let mut guard = proc.lock().map_err(|e| {
190 WorkerError::Concurrency(format!("process mutex poisoned: {e}"))
191 })?;
192 guard.set_state(node_id, state)?;
193 }
194 self.set_filter_state(node_id, state.clone());
195 }
196 Ok(())
197 }
198
199 pub fn read_gradients(&self, node_ids: &[String]) -> Result<HashMap<String, Value>> {
201 let mut out = HashMap::new();
202 for node_id in node_ids {
203 let proc = self.subprocess_for(node_id).ok_or_else(|| {
204 WorkerError::Env(format!(
205 "`{node_id}` has no Python process, so it has no gradients to read"
206 ))
207 })?;
208 let mut guard = proc
209 .lock()
210 .map_err(|e| WorkerError::Concurrency(format!("process mutex poisoned: {e}")))?;
211 out.insert(node_id.clone(), guard.get_gradients(node_id)?);
212 }
213 Ok(out)
214 }
215
216 pub fn write_gradients(&self, gradients: &HashMap<String, Value>) -> Result<()> {
218 for (node_id, grads) in gradients {
219 let proc = self.subprocess_for(node_id).ok_or_else(|| {
220 WorkerError::Env(format!(
221 "`{node_id}` has no Python process, so gradients cannot be applied"
222 ))
223 })?;
224 let mut guard = proc
225 .lock()
226 .map_err(|e| WorkerError::Concurrency(format!("process mutex poisoned: {e}")))?;
227 guard.apply_gradients(node_id, grads)?;
228 }
229 Ok(())
230 }
231
232 pub fn set_filter_state(&mut self, node_id: &str, state: Value) {
234 if let Err(e) = self.catalog.try_set_state(node_id, state) {
235 tracing::error!(node_id, "storing filter state failed: {e}");
236 }
237 }
238
239 pub fn wrap_output(&self, output: Value) -> OutputDelivery {
241 let size = serde_json::to_vec(&output).map(|v| v.len()).unwrap_or(0);
242 if size >= somatize_core::store::INLINE_THRESHOLD_BYTES {
243 let key = somatize_core::cache::CacheKey::hash_data(
244 &serde_json::to_vec(&output).unwrap_or_default(),
245 );
246 if let Ok(data_ref) = self.temp_store.put(&key, &output) {
247 return OutputDelivery::Reference { data_ref };
248 }
249 }
250 OutputDelivery::Inline { value: output }
251 }
252
253 pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Event> {
255 self.event_bus.subscribe()
256 }
257
258 pub fn registration_message(&self) -> WorkerToCoordinator {
260 WorkerToCoordinator::Register {
261 worker_id: self.id.clone(),
262 capabilities: self.capabilities.clone(),
263 }
264 }
265
266 pub fn execute_plan(&mut self, plan: &SerializedPlan) -> PlanResult {
276 let start = Instant::now();
277
278 if let Err(message) = plan.check_version() {
281 tracing::error!("{message}");
282 return PlanResult::Failed {
283 error: message,
284 duration_ms: start.elapsed().as_millis() as u64,
285 };
286 }
287
288 let _span = tracing::info_span!(
289 "execute_plan",
290 plan_id = %plan.plan_id,
291 n_filters = plan.filters.len(),
292 mode = ?plan.mode,
293 )
294 .entered();
295
296 tracing::info!(
297 "Plan received: {} filters, mode={:?}",
298 plan.filters.len(),
299 plan.mode
300 );
301
302 let all_reqs: Vec<String> = plan
304 .filters
305 .iter()
306 .flat_map(|sf| sf.requirements.iter().cloned())
307 .collect::<std::collections::HashSet<_>>()
308 .into_iter()
309 .collect();
310
311 let python_path = if all_reqs.is_empty() {
313 self.python.clone()
314 } else {
315 let reqs_str = all_reqs.join("\n");
316 let env_id = crate::env_manager::EnvManager::env_id_for(&reqs_str);
323 match self.env_manager.ensure_env(&env_id, &reqs_str) {
324 Ok(path) => {
325 tracing::info!("Using venv {env_id} for plan {}: {:?}", plan.plan_id, path);
326 path.to_string_lossy().to_string()
327 }
328 Err(e) => {
329 tracing::warn!("Failed to create venv, falling back to system python: {e}");
330 self.python.clone()
331 }
332 }
333 };
334
335 let filter_specs: Vec<(String, Vec<u8>, bool)> = plan
340 .filters
341 .iter()
342 .map(|sf| (sf.node_id.clone(), sf.pickled_filter.clone(), sf.trainable))
343 .collect();
344
345 if !filter_specs.is_empty() {
346 let filter_names: Vec<&str> =
347 plan.filters.iter().map(|sf| sf.node_id.as_str()).collect();
348 tracing::info!(
349 python = %python_path,
350 filters = ?filter_names,
351 "Spawning Python process for {} filters",
352 filter_specs.len()
353 );
354
355 let proc = crate::python_process::PythonProcess::spawn(&python_path, &filter_specs)
356 .map_err(|e| {
357 tracing::error!(python = %python_path, "failed to spawn Python: {e}");
362 e
363 });
364 let mut proc = match proc {
365 Ok(p) => p,
366 Err(e) => {
367 return PlanResult::Failed {
368 error: format!("could not start `{python_path}`: {e}"),
369 duration_ms: start.elapsed().as_millis() as u64,
370 };
371 }
372 };
373
374 for sf in &plan.filters {
376 if let Some(state) = &sf.state {
377 let size = match state {
378 Value::Bytes(b) => b.len(),
379 _ => 0,
380 };
381 tracing::info!(
382 node_id = %sf.node_id,
383 size_bytes = size,
384 "Loading trained state from previous epoch"
385 );
386 if let Err(e) = proc.set_state(&sf.node_id, state) {
387 tracing::warn!(
388 node_id = %sf.node_id,
389 error = %e,
390 "Failed to load state (will use fresh weights)"
391 );
392 }
393 }
394 }
395
396 let process = Arc::new(std::sync::Mutex::new(proc));
397
398 for sf in &plan.filters {
399 let config_hash = sf.config_hash.clone().unwrap_or_else(|| {
400 crate::python_process::SubprocessFilter::fallback_config_hash(
401 &sf.node_id,
402 &sf.pickled_filter,
403 )
404 });
405 let filter = Box::new(crate::python_process::SubprocessFilter::new(
406 process.clone(),
407 sf.node_id.clone(),
408 sf.trainable,
409 config_hash,
410 ));
411 self.catalog.register(&sf.node_id, filter);
412 if let Some(state) = &sf.state
413 && let Err(e) = self.catalog.try_set_state(&sf.node_id, state.clone())
414 {
415 tracing::error!(node_id = %sf.node_id, "storing filter state failed: {e}");
416 }
417 }
418
419 tracing::info!("Filters registered, Python process ready");
420 }
421
422 let input_value = match plan
427 .input
428 .as_ref()
429 .map(|src| src.resolve(self.data_store.as_deref(), &self.temp_store))
430 .transpose()
431 {
432 Ok(value) => value,
433 Err(e) => {
434 return PlanResult::Failed {
435 error: e.to_string(),
436 duration_ms: start.elapsed().as_millis() as u64,
437 };
438 }
439 };
440
441 if matches!(plan.mode, ExecutionMode::Forward)
456 && let Some(InputSource::Reference { data_ref }) = &plan.input
457 && let Some(store) = self.data_store.clone()
458 && let Ok(meta) = store.meta(data_ref)
459 && meta.total_rows > 1024
460 {
461 return self.execute_streamed_from_store(plan, &store, data_ref, &meta, start);
462 }
463
464 let runner = somatize_runtime::LocalRunner;
466 let x = input_value.unwrap_or(Value::Empty);
467
468 let result = match &plan.mode {
469 ExecutionMode::Fit { y, batch_size } => {
470 if let Some(bs) = batch_size {
472 tracing::info!(batch_size = bs, "Using batched fit");
473 let node_ids = plan
474 .plan
475 .node_ids()
476 .iter()
477 .map(|s| s.to_string())
478 .collect::<Vec<_>>();
479 if let Some(filter) = self.catalog.get(&node_ids[0]) {
480 if let Some(sf) = filter
481 .as_any()
482 .downcast_ref::<crate::python_process::SubprocessFilter>()
483 {
484 let result = sf
485 .process
486 .lock()
487 .map_err(|e| {
488 WorkerError::Concurrency(format!("process mutex poisoned: {e}"))
489 })
490 .and_then(|mut proc| {
491 proc.batched_fit(&node_ids, &x, y.as_ref(), *bs)
492 });
493 match result {
494 Ok((output, states)) => {
495 for (id, state) in &states {
496 if let Err(e) =
497 self.catalog.try_set_state(id, state.clone())
498 {
499 tracing::error!(
500 node_id = %id,
501 "storing filter state failed: {e}"
502 );
503 }
504 }
505 Ok((output, states))
506 }
507 Err(e) => Err(e.into()),
508 }
509 } else {
510 Err(somatize_core::error::SomaError::Other(
511 "batched_fit requires SubprocessFilter".into(),
512 ))
513 }
514 } else {
515 Err(somatize_core::error::SomaError::Other(
516 "no filters found".into(),
517 ))
518 }
519 } else {
520 let run_id = format!("worker_fit_{}", plan.plan_id);
521 let mut ctx = somatize_runtime::runner::RunContext::linear(
526 &self.catalog,
527 self.cache.as_ref(),
528 &self.event_bus,
529 &run_id,
530 &plan.plan,
531 );
532 ctx.seed = plan.seed;
533 runner
534 .fit(&plan.plan, &ctx, &x, y.as_ref())
535 .map(|(output, all_outputs)| {
536 let mut trained_states = std::collections::HashMap::new();
538 for (key, value) in &all_outputs {
539 if let Some(node_id) = somatize_core::keys::node_of_state_key(key) {
540 if let Err(e) =
541 self.catalog.try_set_state(node_id, value.clone())
542 {
543 tracing::error!(
544 node_id,
545 "storing filter state failed: {e}"
546 );
547 }
548 trained_states.insert(node_id.to_string(), value.clone());
549 }
550 }
551 (output, trained_states)
552 })
553 }
554 }
555 ExecutionMode::Forward => {
556 let run_id = format!("worker_forward_{}", plan.plan_id);
557 let mut ctx = somatize_runtime::runner::RunContext::linear(
558 &self.catalog,
559 self.cache.as_ref(),
560 &self.event_bus,
561 &run_id,
562 &plan.plan,
563 );
564 ctx.seed = plan.seed;
565 runner
566 .forward(&plan.plan, &ctx, &x)
567 .map(|output| (output, std::collections::HashMap::new()))
568 }
569 };
570
571 let elapsed = start.elapsed().as_millis() as u64;
572 match result {
573 Ok((output, states)) => {
574 tracing::info!(
575 duration_ms = elapsed,
576 n_states = states.len(),
577 "Plan completed successfully"
578 );
579 PlanResult::Success {
580 output: self.wrap_output(output),
581 duration_ms: elapsed,
582 states,
583 }
584 }
585 Err(e) => {
586 tracing::error!(duration_ms = elapsed, error = %e, "Plan failed");
587 PlanResult::Failed {
588 error: e.to_string(),
589 duration_ms: elapsed,
590 }
591 }
592 }
593 }
594
595 fn execute_streamed_from_store(
600 &mut self,
601 plan: &SerializedPlan,
602 store: &Arc<dyn DataStore>,
603 data_ref: &somatize_core::store::DataRef,
604 meta: &somatize_core::store::StoreMeta,
605 start: Instant,
606 ) -> PlanResult {
607 use somatize_runtime::{Context, StreamOutput, StreamRun};
608
609 const STREAM_CHUNK_ROWS: usize = 1024;
612
613 let node_ids: Vec<String> = plan.plan.node_ids().into_iter().map(String::from).collect();
614
615 let mut run = match StreamRun::new(&node_ids, &self.catalog) {
619 Ok(run) => run,
620 Err(e) => {
621 return PlanResult::Failed {
622 error: e.to_string(),
623 duration_ms: start.elapsed().as_millis() as u64,
624 };
625 }
626 };
627
628 let chunk_size = STREAM_CHUNK_ROWS;
629 let run_id = format!("worker_stream_{}", plan.plan_id);
630 let mut ctx = Context::new(self.event_bus.clone(), run_id.clone()).with_seed(plan.seed);
631
632 self.event_bus.emit(Event::RunStarted {
633 run_id: run_id.clone(),
634 plan_summary: somatize_core::event::PlanSummary {
635 total_nodes: node_ids.len(),
636 cached_nodes: 0,
637 parallel_branches: 0,
638 },
639 });
640 let fail = |bus: &EventBus, error: String, start: Instant| {
643 bus.emit(Event::RunFailed {
644 run_id: run_id.clone(),
645 error: error.clone(),
646 });
647 PlanResult::Failed {
648 error,
649 duration_ms: start.elapsed().as_millis() as u64,
650 }
651 };
652
653 let mut output = StreamOutput::new();
654 let total = meta.total_rows;
655 let mut chunk_idx = 0;
656
657 for row_start in (0..total).step_by(chunk_size) {
658 let len = chunk_size.min(total - row_start);
659 let chunk = match store.get_rows(data_ref, row_start, len) {
660 Ok(c) => c,
661 Err(e) => {
662 let error = format!("get_rows({row_start}..{}): {e}", row_start + len);
663 return fail(&self.event_bus, error, start);
664 }
665 };
666
667 match run.process_chunk(chunk, &mut ctx, self.cache.as_ref()) {
668 Ok(Some(out)) => output.push(out),
669 Ok(None) => {} Err(e) => {
671 return fail(
672 &self.event_bus,
673 format!("stream chunk {chunk_idx}: {e}"),
674 start,
675 );
676 }
677 }
678 chunk_idx += 1;
679 }
680
681 match run.flush(&mut ctx, self.cache.as_ref()) {
683 Ok(Some(out)) => output.push(out),
684 Ok(None) => {}
685 Err(e) => {
686 return fail(&self.event_bus, format!("stream flush: {e}"), start);
687 }
688 }
689 run.finish(&ctx);
690
691 tracing::info!(
692 "Streamed {chunk_idx} chunks ({total} rows) in {}ms",
693 start.elapsed().as_millis()
694 );
695
696 self.event_bus.emit(Event::RunCompleted {
697 run_id,
698 duration: start.elapsed(),
699 });
700 PlanResult::Success {
701 output: self.wrap_output(output.finish()),
702 duration_ms: start.elapsed().as_millis() as u64,
703 states: std::collections::HashMap::new(),
704 }
705 }
706
707 pub fn matches_target(&self, target: &somatize_core::filter::RemoteTarget) -> bool {
709 match target {
710 somatize_core::filter::RemoteTarget::WorkerId(id) => &self.id == id,
711 somatize_core::filter::RemoteTarget::Tag(tag) => self.capabilities.tags.contains(tag),
712 }
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use somatize_compiler::ExecutionPlan;
720 use somatize_core::cache::CacheKey;
721 use somatize_core::error::Result as SomaResult;
722 use somatize_core::filter::{FilterKind, FilterMeta, StreamMode};
723 use somatize_core::value::Value;
724
725 struct TestDoubler;
726
727 impl Filter for TestDoubler {
728 fn config_hash(&self) -> CacheKey {
729 CacheKey::from_parts(&[b"TestDoubler"])
730 }
731 fn fit(&self, _x: &Value, _y: Option<&Value>) -> SomaResult<Value> {
732 Ok(Value::Empty)
733 }
734 fn forward(&self, x: &Value, _state: &Value) -> SomaResult<Value> {
735 match x {
736 Value::Tensor { values, shape } => {
737 let doubled: Vec<f64> = values.iter().map(|v| v * 2.0).collect();
738 Ok(Value::tensor(doubled, shape.clone()))
739 }
740 _ => Ok(x.clone()),
741 }
742 }
743 fn meta(&self) -> FilterMeta {
744 FilterMeta {
745 name: "TestDoubler".into(),
746 kind: FilterKind::Stateless,
747 cacheable: true,
748 differentiable: true,
749 deterministic: true,
750 stream_mode: StreamMode::FixedState,
751 distribution: somatize_core::filter::Distribution::Local,
752 input_schema: None,
753 output_schema: None,
754 }
755 }
756 }
757
758 fn make_worker() -> Worker {
759 Worker::new(
760 "test_worker",
761 Capabilities {
762 cpu_cores: 4,
763 ram_bytes: 8_000_000_000,
764 gpus: vec![],
765 python_envs: vec![],
766 tags: vec!["cpu".into(), "test".into()],
767 },
768 )
769 }
770
771 #[test]
772 fn worker_registration() {
773 let worker = make_worker();
774 let msg = worker.registration_message();
775 if let WorkerToCoordinator::Register {
776 worker_id,
777 capabilities,
778 } = msg
779 {
780 assert_eq!(worker_id, "test_worker");
781 assert_eq!(capabilities.cpu_cores, 4);
782 } else {
783 panic!("wrong message type");
784 }
785 }
786
787 #[test]
788 fn worker_executes_plan_successfully() {
789 let mut worker = make_worker();
790 worker.register_filter("doubler", Box::new(TestDoubler));
791
792 let plan = SerializedPlan {
793 protocol_version: PROTOCOL_VERSION,
794 plan_id: "p_001".into(),
795 plan: ExecutionPlan::Execute {
796 node_id: "doubler".into(),
797 },
798 input: Some(crate::protocol::InputSource::Inline {
799 value: Value::tensor(vec![1.0, 2.0, 3.0], vec![3]),
800 }),
801 filters: vec![],
802 mode: ExecutionMode::default(),
803 seed: None,
804 metadata: serde_json::json!({}),
805 };
806
807 let result = worker.execute_plan(&plan);
808
809 if let PlanResult::Success {
810 output,
811 duration_ms,
812 ..
813 } = result
814 {
815 let value = match output {
816 OutputDelivery::Inline { value } => value,
817 _ => panic!("expected inline output"),
818 };
819 let (data, _) = value.as_tensor().unwrap();
820 assert_eq!(data, &[2.0, 4.0, 6.0]);
821 assert!(duration_ms < 1000);
822 } else {
823 panic!("expected success, got: {result:?}");
824 }
825 }
826
827 #[test]
828 fn worker_handles_missing_filter() {
829 let mut worker = make_worker();
830 let plan = SerializedPlan {
833 protocol_version: PROTOCOL_VERSION,
834 plan_id: "p_002".into(),
835 plan: ExecutionPlan::Execute {
836 node_id: "nonexistent".into(),
837 },
838 input: None,
839 filters: vec![],
840 mode: ExecutionMode::default(),
841 seed: None,
842 metadata: serde_json::json!({}),
843 };
844
845 let result = worker.execute_plan(&plan);
846 assert!(matches!(result, PlanResult::Failed { .. }));
847 }
848
849 #[test]
850 fn worker_matches_target_by_id() {
851 let worker = make_worker();
852 assert!(
853 worker.matches_target(&somatize_core::filter::RemoteTarget::WorkerId(
854 "test_worker".into()
855 ))
856 );
857 assert!(
858 !worker.matches_target(&somatize_core::filter::RemoteTarget::WorkerId(
859 "other".into()
860 ))
861 );
862 }
863
864 #[test]
865 fn worker_matches_target_by_tag() {
866 let worker = make_worker();
867 assert!(worker.matches_target(&somatize_core::filter::RemoteTarget::Tag("cpu".into())));
868 assert!(worker.matches_target(&somatize_core::filter::RemoteTarget::Tag("test".into())));
869 assert!(!worker.matches_target(&somatize_core::filter::RemoteTarget::Tag("gpu".into())));
870 }
871
872 #[test]
873 fn worker_executes_sequence() {
874 let mut worker = make_worker();
875 worker.register_filter("d1", Box::new(TestDoubler));
876 worker.register_filter("d2", Box::new(TestDoubler));
877
878 let plan = SerializedPlan {
879 protocol_version: PROTOCOL_VERSION,
880 plan_id: "p_003".into(),
881 plan: ExecutionPlan::Sequence(vec![
882 ExecutionPlan::Execute {
883 node_id: "d1".into(),
884 },
885 ExecutionPlan::Execute {
886 node_id: "d2".into(),
887 },
888 ]),
889 input: Some(crate::protocol::InputSource::Inline {
890 value: Value::tensor(vec![5.0], vec![1]),
891 }),
892 filters: vec![],
893 mode: ExecutionMode::default(),
894 seed: None,
895 metadata: serde_json::json!({}),
896 };
897
898 let result = worker.execute_plan(&plan);
899 if let PlanResult::Success { output, .. } = result {
900 let value = match output {
901 OutputDelivery::Inline { value } => value,
902 _ => panic!("expected inline output"),
903 };
904 let (data, _) = value.as_tensor().unwrap();
905 assert_eq!(data, &[20.0]); } else {
907 panic!("expected success");
908 }
909 }
910
911 #[test]
912 fn worker_emits_events() {
913 let mut worker = make_worker();
914 worker.register_filter("doubler", Box::new(TestDoubler));
915 let mut rx = worker.subscribe();
916
917 let plan = SerializedPlan {
918 protocol_version: PROTOCOL_VERSION,
919 plan_id: "p_004".into(),
920 plan: ExecutionPlan::Execute {
921 node_id: "doubler".into(),
922 },
923 input: Some(crate::protocol::InputSource::Inline {
924 value: Value::tensor(vec![1.0], vec![1]),
925 }),
926 filters: vec![],
927 mode: ExecutionMode::default(),
928 seed: None,
929 metadata: serde_json::json!({}),
930 };
931
932 worker.execute_plan(&plan);
933
934 let mut events = Vec::new();
935 while let Ok(e) = rx.try_recv() {
936 events.push(e);
937 }
938 assert!(
939 events
940 .iter()
941 .any(|e| matches!(e, Event::NodeStarted { .. }))
942 );
943 assert!(
944 events
945 .iter()
946 .any(|e| matches!(e, Event::NodeCompleted { .. }))
947 );
948 }
949
950 #[test]
959 fn a_fit_over_a_large_reference_is_not_silently_streamed() {
960 struct Counter;
961 impl Filter for Counter {
962 fn config_hash(&self) -> CacheKey {
963 CacheKey::from_parts(&[b"Counter"])
964 }
965 fn fit(&self, x: &Value, _y: Option<&Value>) -> SomaResult<Value> {
966 let n = match x {
970 Value::Tensor { values, .. } => values.len() as f64,
971 _ => 0.0,
972 };
973 Ok(Value::tensor(vec![n], vec![1]))
974 }
975 fn forward(&self, x: &Value, _state: &Value) -> SomaResult<Value> {
976 Ok(x.clone())
977 }
978 fn meta(&self) -> FilterMeta {
979 FilterMeta {
980 name: "Counter".into(),
981 kind: FilterKind::Trainable,
982 cacheable: true,
983 differentiable: false,
984 deterministic: true,
985 stream_mode: StreamMode::FixedState,
986 distribution: somatize_core::filter::Distribution::Local,
987 input_schema: None,
988 output_schema: None,
989 }
990 }
991 }
992
993 let dir = tempfile::tempdir().unwrap();
994 let store: Arc<dyn DataStore> = Arc::new(LocalDataStore::new(dir.path().join("data")));
995 let n = 2048usize; let key = somatize_core::cache::CacheKey::hash_data(b"fit-input");
997 let data_ref = store
998 .put(&key, &Value::tensor(vec![1.0; n], vec![n]))
999 .unwrap();
1000
1001 let mut worker = make_worker().with_data_store(store);
1002 worker.register_filter("counter", Box::new(Counter));
1003
1004 let mut plan = SerializedPlan::new(
1005 "p_fit_large",
1006 ExecutionPlan::Execute {
1007 node_id: "counter".into(),
1008 },
1009 );
1010 plan.input = Some(InputSource::Reference { data_ref });
1011 plan.mode = ExecutionMode::Fit {
1012 y: None,
1013 batch_size: None,
1014 };
1015
1016 let result = worker.execute_plan(&plan);
1017 assert!(
1018 matches!(result, PlanResult::Success { .. }),
1019 "the fit failed: {result:?}"
1020 );
1021 let state = worker.get_filter_state("counter");
1023 let (values, _) = state
1024 .as_tensor()
1025 .expect("no state was stored: the fit was streamed");
1026 assert_eq!(
1027 values[0], n as f64,
1028 "the fit saw a chunk, not the whole input"
1029 );
1030 }
1031
1032 #[test]
1041 fn a_stateful_filter_keeps_its_state_across_a_streamed_data_ref() {
1042 struct Centre;
1043 impl Filter for Centre {
1044 fn config_hash(&self) -> CacheKey {
1045 CacheKey::from_parts(&[b"Centre"])
1046 }
1047 fn fit(&self, _x: &Value, _y: Option<&Value>) -> SomaResult<Value> {
1048 Ok(Value::Empty)
1049 }
1050 fn forward(&self, x: &Value, state: &Value) -> SomaResult<Value> {
1051 let mean = match state {
1055 Value::Tensor { values, .. } if !values.is_empty() => values[0],
1056 other => {
1057 return Err(somatize_core::error::SomaError::Execution {
1058 node_id: "centre".into(),
1059 message: format!("no state reached the filter: {other:?}"),
1060 });
1061 }
1062 };
1063 match x {
1064 Value::Tensor { values, shape } => Ok(Value::tensor(
1065 values.iter().map(|v| v - mean).collect(),
1066 shape.clone(),
1067 )),
1068 other => Ok(other.clone()),
1069 }
1070 }
1071 fn meta(&self) -> FilterMeta {
1072 FilterMeta {
1073 name: "Centre".into(),
1074 kind: FilterKind::Trainable,
1075 cacheable: true,
1076 differentiable: false,
1077 deterministic: true,
1078 stream_mode: StreamMode::FixedState,
1079 distribution: somatize_core::filter::Distribution::Local,
1080 input_schema: None,
1081 output_schema: None,
1082 }
1083 }
1084 }
1085
1086 let dir = tempfile::tempdir().unwrap();
1087 let store: Arc<dyn DataStore> = Arc::new(LocalDataStore::new(dir.path().join("data")));
1088
1089 let n = 2048usize; let values: Vec<f64> = (0..n).map(|i| i as f64).collect();
1091 let mean = values.iter().sum::<f64>() / n as f64;
1092 let key = somatize_core::cache::CacheKey::hash_data(b"stateful-stream-input");
1093 let data_ref = store.put(&key, &Value::tensor(values, vec![n])).unwrap();
1094
1095 let mut worker = make_worker().with_data_store(store);
1096 worker.register_filter("centre", Box::new(Centre));
1097 worker.set_filter_state("centre", Value::tensor(vec![mean], vec![1]));
1098
1099 let mut plan = SerializedPlan::new(
1100 "p_stateful_stream",
1101 ExecutionPlan::Execute {
1102 node_id: "centre".into(),
1103 },
1104 );
1105 plan.input = Some(InputSource::Reference { data_ref });
1106
1107 let result = worker.execute_plan(&plan);
1108 let PlanResult::Success { output, .. } = result else {
1109 panic!("a stateful filter must keep its state when streamed: {result:?}");
1110 };
1111 let value = match output {
1112 OutputDelivery::Inline { value } => value,
1113 OutputDelivery::Reference { data_ref } => worker.temp_store().get(&data_ref).unwrap(),
1114 };
1115 let (data, shape) = value.as_tensor().unwrap();
1116 assert_eq!(shape, &[n]);
1117 assert!((data[0] - (0.0 - mean)).abs() < 1e-9, "{}", data[0]);
1119 assert!(
1120 (data[n - 1] - ((n - 1) as f64 - mean)).abs() < 1e-9,
1121 "{}",
1122 data[n - 1]
1123 );
1124 }
1125
1126 #[test]
1131 fn a_large_data_ref_streams_concatenated_through_stream_run() {
1132 let dir = tempfile::tempdir().unwrap();
1133 let store: Arc<dyn DataStore> = Arc::new(LocalDataStore::new(dir.path().join("data")));
1134
1135 let n = 2048usize;
1137 let values: Vec<f64> = (0..n).map(|i| i as f64).collect();
1138 let key = somatize_core::cache::CacheKey::hash_data(b"stream-input");
1139 let data_ref = store.put(&key, &Value::tensor(values, vec![n])).unwrap();
1140
1141 let mut worker = make_worker().with_data_store(store);
1142 let mut rx = worker.subscribe();
1143 worker.register_filter("doubler", Box::new(TestDoubler));
1144
1145 let mut plan = SerializedPlan::new(
1146 "p_stream",
1147 ExecutionPlan::Execute {
1148 node_id: "doubler".into(),
1149 },
1150 );
1151 plan.input = Some(InputSource::Reference { data_ref });
1152 plan.seed = Some(7);
1153
1154 let result = worker.execute_plan(&plan);
1155 let PlanResult::Success { output, .. } = result else {
1156 panic!("stream plan failed: {result:?}");
1157 };
1158 let value = match output {
1159 OutputDelivery::Inline { value } => value,
1160 OutputDelivery::Reference { data_ref } => worker.temp_store().get(&data_ref).unwrap(),
1161 };
1162 let (data, shape) = value.as_tensor().unwrap();
1163 assert_eq!(
1164 shape,
1165 &[n],
1166 "the output is the whole stream, not the last chunk"
1167 );
1168 assert_eq!(data[0], 0.0);
1169 assert_eq!(data[n - 1], (n - 1) as f64 * 2.0);
1170
1171 let mut started = 0;
1172 let mut node_completed = 0;
1173 let mut run_completed = 0;
1174 while let Ok(event) = rx.try_recv() {
1175 match event {
1176 Event::NodeStarted { node_id, .. } => {
1177 assert_eq!(node_id, "doubler");
1178 started += 1;
1179 }
1180 Event::NodeCompleted {
1181 node_id,
1182 output_summary,
1183 ..
1184 } => {
1185 assert_eq!(node_id, "doubler");
1186 assert!(output_summary.contains("2 chunks"), "{output_summary}");
1187 node_completed += 1;
1188 }
1189 Event::RunCompleted { .. } => run_completed += 1,
1190 _ => {}
1191 }
1192 }
1193 assert_eq!((started, node_completed), (1, 1), "one bracket per node");
1194 assert_eq!(run_completed, 1, "the run bracket must close");
1195 }
1196}