1use crate::event_bus::EventBus;
7use crate::node_catalog::{NodeCatalog, NodeImpl};
8use somatize_compiler::ExecutionPlan;
9use somatize_core::cache::CacheStore;
10use somatize_core::control::{
11 LoopCondition, LoopSignal, is_default_arm, read_arm_selector, read_loop_signal,
12};
13use somatize_core::error::{Result, SomaError};
14use somatize_core::event::Event;
15use somatize_core::node::NodeOutcome;
16use somatize_core::store::DataStore;
17use somatize_core::value::Value;
18use somatize_core::virtual_value::VirtualValue;
19use std::collections::HashMap;
20use std::sync::Arc;
21use std::time::Instant;
22
23#[derive(Debug, Clone, Default)]
28pub struct GraphInfo {
29 predecessors: HashMap<String, Vec<String>>,
31}
32
33impl GraphInfo {
34 pub fn new() -> Self {
37 Self::default()
38 }
39
40 pub fn set_predecessors(&mut self, node_id: impl Into<String>, preds: Vec<String>) {
42 self.predecessors.insert(node_id.into(), preds);
43 }
44
45 pub fn from_graph(graph: &somatize_core::graph::Graph) -> Self {
47 let mut info = Self::new();
48 for node in &graph.nodes {
49 let preds: Vec<String> = graph
50 .predecessors(&node.id)
51 .into_iter()
52 .map(|s| s.to_string())
53 .collect();
54 info.set_predecessors(node.id.clone(), preds);
55 }
56 info
57 }
58
59 pub fn for_linear(node_ids: &[&str]) -> Self {
61 let mut info = Self::new();
62 for (i, &id) in node_ids.iter().enumerate() {
63 let preds = if i > 0 {
64 vec![node_ids[i - 1].to_string()]
65 } else {
66 vec![]
67 };
68 info.set_predecessors(id, preds);
69 }
70 info
71 }
72
73 pub fn predecessors(&self, node_id: &str) -> &[String] {
75 self.predecessors
76 .get(node_id)
77 .map(|v| v.as_slice())
78 .unwrap_or(&[])
79 }
80}
81
82#[derive(Clone, Debug, Default)]
92pub enum RunMode {
93 #[default]
95 Forward,
96 Fit {
100 y: Option<Value>,
102 },
103}
104
105impl RunMode {
106 fn labels(&self) -> Option<&Value> {
108 match self {
109 Self::Forward => None,
110 Self::Fit { y } => y.as_ref(),
111 }
112 }
113
114 fn is_fit(&self) -> bool {
115 matches!(self, Self::Fit { .. })
116 }
117}
118
119pub struct Context {
125 pub mode: RunMode,
127 store: HashMap<String, VirtualValue>,
135 pub event_bus: Arc<EventBus>,
137 pub run_id: String,
139 execution_order: Vec<String>,
141 pub graph_info: GraphInfo,
143 pub transport: Option<Arc<dyn crate::runner::Transport>>,
145 pub data_store: Option<Arc<dyn DataStore>>,
147 pub spill_threshold: usize,
150 output_hashes: HashMap<String, somatize_core::cache::CacheKey>,
154 pub seed: Option<i64>,
158 pub driver: Option<crate::effects::EffectDriver>,
167}
168
169impl Context {
170 pub fn new(event_bus: Arc<EventBus>, run_id: impl Into<String>) -> Self {
173 Self {
174 mode: RunMode::Forward,
175 store: HashMap::new(),
176 event_bus,
177 run_id: run_id.into(),
178 execution_order: Vec::new(),
179 graph_info: GraphInfo::new(),
180 transport: None,
181 data_store: None,
182 spill_threshold: 0,
183 output_hashes: HashMap::new(),
184 seed: None,
185 driver: None,
186 }
187 }
188
189 pub fn with_driver(mut self, driver: crate::effects::EffectDriver) -> Self {
196 self.driver = Some(driver);
197 self
198 }
199
200 pub fn with_graph_info(mut self, info: GraphInfo) -> Self {
202 self.graph_info = info;
203 self
204 }
205
206 pub fn fitting(mut self, y: Option<Value>) -> Self {
208 self.mode = RunMode::Fit { y };
209 self
210 }
211
212 pub fn record_state(&mut self, node_id: &str, state: Value) {
221 self.set(somatize_core::keys::state_key(node_id), state);
222 }
223
224 pub fn with_seed(mut self, seed: Option<i64>) -> Self {
226 self.seed = seed;
227 self
228 }
229
230 pub fn with_transport(mut self, transport: Arc<dyn crate::runner::Transport>) -> Self {
232 self.transport = Some(transport);
233 self
234 }
235
236 pub fn with_data_store(mut self, store: Arc<dyn DataStore>) -> Self {
238 self.data_store = Some(store);
239 self
240 }
241
242 pub fn with_spill_threshold(mut self, bytes: usize) -> Self {
246 self.spill_threshold = bytes;
247 self
248 }
249
250 fn maybe_spill(&self, node_id: &str, value: Value) -> VirtualValue {
253 if self.spill_threshold > 0
254 && let Some(store) = &self.data_store
255 {
256 let size = value.size() * 8; if size >= self.spill_threshold {
258 let key = somatize_core::cache::CacheKey::from_parts(&[
259 self.run_id.as_bytes(),
260 node_id.as_bytes(),
261 ]);
262 let vv_for_schema = VirtualValue::materialized(value.clone());
263 let schema = vv_for_schema.schema().clone();
264 if let Ok(_data_ref) = store.put(&key, &value) {
265 tracing::debug!("spilled node `{node_id}` ({size} bytes) to DataStore");
266 return VirtualValue::cached(key, schema);
267 }
268 }
269 }
270 VirtualValue::materialized(value)
271 }
272
273 pub fn execution_order(&self) -> &[String] {
278 &self.execution_order
279 }
280
281 pub fn into_outputs(self) -> HashMap<String, Value> {
286 self.store
287 .into_iter()
288 .filter_map(|(k, vv)| vv.as_value().cloned().map(|v| (k, v)))
289 .collect()
290 }
291
292 pub fn get(&self, node_id: &str) -> Option<&Value> {
294 self.store.get(node_id).and_then(|vv| vv.as_value())
295 }
296
297 pub fn get_virtual(&self, node_id: &str) -> Option<&VirtualValue> {
299 self.store.get(node_id)
300 }
301
302 pub fn set(&mut self, node_id: impl Into<String>, value: Value) {
304 let id = node_id.into();
305 self.execution_order.push(id.clone());
306 self.output_hashes.remove(&id);
307 self.store.insert(id, VirtualValue::materialized(value));
308 }
309
310 pub fn set_virtual(&mut self, node_id: impl Into<String>, vv: VirtualValue) {
312 let id = node_id.into();
313 self.execution_order.push(id.clone());
314 self.output_hashes.remove(&id);
315 self.store.insert(id, vv);
316 }
317
318 fn input_hash(&mut self, node_id: &str, input: &Value) -> somatize_core::cache::CacheKey {
323 let preds = self.graph_info.predecessors(node_id);
324 let single_pred = match preds {
325 [only] => Some(only.clone()),
326 _ => None,
327 };
328 if let Some(pred) = single_pred {
329 if let Some(h) = self.output_hashes.get(&pred) {
330 return h.clone();
331 }
332 if self.store.contains_key(&pred) {
336 let h = somatize_core::cache::CacheKey::for_value(input);
337 self.output_hashes.insert(pred, h.clone());
338 return h;
339 }
340 }
341 somatize_core::cache::CacheKey::for_value(input)
342 }
343
344 fn snapshot(&self) -> Self {
345 Self {
346 mode: self.mode.clone(),
347 store: self.store.clone(),
348 event_bus: self.event_bus.clone(),
349 run_id: self.run_id.clone(),
350 execution_order: self.execution_order.clone(),
351 graph_info: self.graph_info.clone(),
352 transport: self.transport.clone(),
353 data_store: self.data_store.clone(),
354 spill_threshold: self.spill_threshold,
355 output_hashes: self.output_hashes.clone(),
356 seed: self.seed,
357 driver: self.driver.clone(),
358 }
359 }
360}
361
362pub fn execute(
368 plan: &ExecutionPlan,
369 ctx: &mut Context,
370 catalog: &NodeCatalog,
371 cache: &dyn CacheStore,
372) -> Result<()> {
373 match plan {
374 ExecutionPlan::Empty => Ok(()),
375
376 ExecutionPlan::Execute { node_id } => execute_node(node_id, &[], ctx, catalog, cache),
378
379 ExecutionPlan::Step { node_id, handoffs } => {
380 execute_node(node_id, handoffs, ctx, catalog, cache)
381 }
382
383 ExecutionPlan::Sequence(steps) => {
384 for step in steps {
385 execute(step, ctx, catalog, cache)?;
386 }
387 Ok(())
388 }
389
390 ExecutionPlan::Parallel(branches) => execute_parallel(branches, ctx, catalog, cache),
391
392 ExecutionPlan::Loop {
393 node_id,
394 body,
395 max_iterations,
396 until,
397 carry_from,
398 } => execute_loop(
399 node_id,
400 body,
401 *max_iterations,
402 until,
403 carry_from.as_deref(),
404 ctx,
405 catalog,
406 cache,
407 ),
408
409 ExecutionPlan::Branch { node_id, arms } => {
410 execute_branch(node_id, arms, ctx, catalog, cache)
411 }
412
413 ExecutionPlan::Remote {
414 node_id,
415 target: _,
416 plan,
417 } => execute_remote(node_id, plan, ctx, catalog, cache),
418
419 ExecutionPlan::Composite { node_ids } => {
420 if ctx.mode.is_fit() && composite_fit(node_ids, ctx, catalog)? {
426 return Ok(());
427 }
428 for nid in node_ids {
430 execute_node(nid, &[], ctx, catalog, cache)?;
431 }
432 Ok(())
433 }
434
435 ExecutionPlan::Stream {
436 node_ids,
437 chunk_size,
438 } => execute_stream(node_ids, *chunk_size, ctx, catalog, cache),
439
440 other => Err(SomaError::Execution {
446 node_id: other
447 .node_ids()
448 .first()
449 .map_or_else(|| "<plan>".to_string(), |id| (*id).to_string()),
450 message: format!(
451 "this runtime does not know how to execute `{other:?}`. It was \
452 probably compiled by a newer version"
453 ),
454 }),
455 }
456}
457
458#[allow(clippy::too_many_arguments)]
460fn execute_loop(
461 node_id: &str,
462 body: &ExecutionPlan,
463 max_iterations: Option<usize>,
464 until: &LoopCondition,
465 carry_from: Option<&str>,
466 ctx: &mut Context,
467 catalog: &NodeCatalog,
468 cache: &dyn CacheStore,
469) -> Result<()> {
470 let max = max_iterations.unwrap_or(100);
471 let mut ran = 0usize;
472
473 let seed = resolve_input(node_id, ctx);
481 ctx.set(node_id.to_string(), seed);
482
483 for i in 0..max {
484 execute(body, ctx, catalog, cache)?;
485 ran = i + 1;
486
487 if let Some(source) = carry_from
491 && let Some(value) = ctx.get(source).cloned()
492 {
493 ctx.set(node_id.to_string(), value);
494 }
495
496 let LoopCondition::WhenSignaled(cond_node) = until else {
500 continue; };
502
503 let value = ctx.get(cond_node).ok_or_else(|| SomaError::Execution {
504 node_id: node_id.to_string(),
505 message: format!(
506 "loop condition node `{cond_node}` produced no output on iteration {ran}"
507 ),
508 })?;
509
510 let signal = read_loop_signal(value).ok_or_else(|| SomaError::Execution {
511 node_id: node_id.to_string(),
512 message: format!(
513 "loop condition node `{cond_node}` produced `{}`, which carries no \
514 termination signal. Return a bool, \"done\"/\"stop\", or \
515 {{\"done\": bool}}",
516 value.type_name()
517 ),
518 })?;
519
520 if signal == LoopSignal::Stop {
521 emit_control_completed(ctx, node_id, format!("Loop terminated at iteration {ran}"));
522 return Ok(());
523 }
524 }
525
526 emit_control_completed(ctx, node_id, format!("Loop exhausted {ran} iterations"));
527 Ok(())
528}
529
530fn execute_branch(
532 node_id: &str,
533 arms: &[(String, ExecutionPlan)],
534 ctx: &mut Context,
535 catalog: &NodeCatalog,
536 cache: &dyn CacheStore,
537) -> Result<()> {
538 let request = resolve_input(node_id, ctx);
543
544 let selector = match run_node(node_id, ctx, catalog, cache)? {
545 NodeOutcome::HandOff { target, .. } => target,
550
551 NodeOutcome::Produced(condition) => {
552 read_arm_selector(&condition).ok_or_else(|| SomaError::Execution {
553 node_id: node_id.to_string(),
554 message: format!(
555 "branch condition produced `{}`, which names no arm. Return the \
556 arm's label as a string, a bool, or {{\"branch\": \"<label>\"}}",
557 condition.type_name()
558 ),
559 })?
560 }
561
562 NodeOutcome::Paused { turn, reason } => {
563 return Err(SomaError::Suspended {
564 run_id: ctx.run_id.clone(),
565 node_id: node_id.to_string(),
566 turn,
567 reason: Box::new(reason),
568 });
569 }
570 };
571
572 let (label, plan) = arms
573 .iter()
574 .find(|(label, _)| label == &selector)
575 .or_else(|| arms.iter().find(|(label, _)| is_default_arm(label)))
576 .ok_or_else(|| SomaError::Execution {
577 node_id: node_id.to_string(),
578 message: format!(
579 "branch selected `{selector}`, which matches no arm ({}) and there is \
580 no `default` arm",
581 arms.iter()
582 .map(|(l, _)| l.as_str())
583 .collect::<Vec<_>>()
584 .join(", ")
585 ),
586 })?;
587
588 emit_control_completed(ctx, node_id, format!("Branch selected: {label}"));
589
590 ctx.set(node_id.to_string(), request);
597 execute(plan, ctx, catalog, cache)
598}
599
600fn execute_remote(
602 node_id: &str,
603 plan: &ExecutionPlan,
604 ctx: &mut Context,
605 catalog: &NodeCatalog,
606 cache: &dyn CacheStore,
607) -> Result<()> {
608 let Some(transport) = ctx.transport.clone() else {
609 return execute(plan, ctx, catalog, cache);
610 };
611 let input = ctx
612 .graph_info
613 .predecessors(node_id)
614 .first()
615 .and_then(|pred| ctx.get(pred));
616 let result = transport.execute_node(node_id, input)?;
617 ctx.set(node_id.to_string(), result);
618 Ok(())
619}
620
621fn emit_control_completed(ctx: &Context, node_id: &str, summary: String) {
624 ctx.event_bus.emit(Event::NodeCompleted {
625 run_id: ctx.run_id.clone(),
626 node_id: node_id.to_string(),
627 duration: std::time::Duration::ZERO,
628 output_summary: summary,
629 });
630}
631
632pub(crate) fn salt_with_seed(
635 key: somatize_core::cache::CacheKey,
636 seed: Option<i64>,
637) -> somatize_core::cache::CacheKey {
638 match seed {
639 Some(s) => somatize_core::cache::CacheKey::from_parts(&[b"seed", &s.to_le_bytes(), &key.0]),
640 None => key,
641 }
642}
643
644pub(crate) fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str {
650 payload
651 .downcast_ref::<String>()
652 .map(|s| s.as_str())
653 .or_else(|| payload.downcast_ref::<&str>().copied())
654 .unwrap_or("unknown panic")
655}
656
657pub(crate) fn output_key(
671 node: &NodeImpl,
672 meta: &somatize_core::node::NodeMeta,
673 state: &Value,
674 input_key: &somatize_core::cache::CacheKey,
675 seed: Option<i64>,
676) -> Option<somatize_core::cache::CacheKey> {
677 if !(meta.cacheable && meta.deterministic) {
678 return None;
679 }
680 let key = somatize_core::cache::CacheKey::for_output(
681 &node.config_hash(),
682 &somatize_core::cache::CacheKey::for_value(state),
683 input_key,
684 );
685 Some(salt_with_seed(key, seed))
686}
687
688pub(crate) fn compute_node(
693 node: &NodeImpl,
694 node_id: &str,
695 ctx: &Context,
696 input: &Value,
697 state: &Value,
698) -> Result<NodeOutcome> {
699 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
700 run_node_inner(node, node_id, ctx, input, state)
701 }));
702 match result {
703 Ok(inner) => inner,
704 Err(panic) => {
705 let msg = panic_message(&*panic);
706 tracing::error!(node_id, "node panicked: {msg}");
707 Err(SomaError::Execution {
708 node_id: node_id.to_string(),
709 message: format!("node panicked: {msg}"),
710 })
711 }
712 }
713}
714
715pub(crate) fn store_output(
718 cache: &dyn CacheStore,
719 key: &somatize_core::cache::CacheKey,
720 output: &Value,
721 node_id: &str,
722 run_id: &str,
723 duration: std::time::Duration,
724 deterministic: bool,
725) {
726 let origin = somatize_core::cache::Origin::Computed {
727 node_id: node_id.to_string(),
728 run_id: run_id.to_string(),
729 };
730 if let Err(e) = cache.put_computed(key, output, &origin, duration, deterministic) {
731 tracing::warn!(node_id, error = %e, "failed to cache node output");
732 }
733}
734
735fn execute_node(
742 node_id: &str,
743 handoffs: &[(String, ExecutionPlan)],
744 ctx: &mut Context,
745 catalog: &NodeCatalog,
746 cache: &dyn CacheStore,
747) -> Result<()> {
748 match run_node(node_id, ctx, catalog, cache)? {
749 NodeOutcome::Produced(_) => Ok(()),
750
751 NodeOutcome::HandOff { target, .. } => {
753 let plan = select_handoff(node_id, &target, handoffs)?;
754 execute(plan, ctx, catalog, cache)
755 }
756
757 NodeOutcome::Paused { turn, reason } => Err(SomaError::Suspended {
761 run_id: ctx.run_id.clone(),
762 node_id: node_id.to_string(),
763 turn,
764 reason: Box::new(reason),
765 }),
766 }
767}
768
769fn select_handoff<'p>(
771 node_id: &str,
772 target: &str,
773 handoffs: &'p [(String, ExecutionPlan)],
774) -> Result<&'p ExecutionPlan> {
775 handoffs
776 .iter()
777 .find(|(t, _)| t == target)
778 .map(|(_, p)| p)
779 .ok_or_else(|| SomaError::Execution {
780 node_id: node_id.to_string(),
781 message: if handoffs.is_empty() {
782 format!(
783 "step handed control to `{target}`, but it declares no \
784 handoffs. Add a control edge from `{node_id}` to `{target}`"
785 )
786 } else {
787 format!(
788 "step handed control to `{target}`, which is not among its \
789 declared handoffs ({})",
790 handoffs
791 .iter()
792 .map(|(t, _)| t.as_str())
793 .collect::<Vec<_>>()
794 .join(", ")
795 )
796 },
797 })
798}
799
800fn run_node(
817 node_id: &str,
818 ctx: &mut Context,
819 catalog: &NodeCatalog,
820 cache: &dyn CacheStore,
821) -> Result<NodeOutcome> {
822 let start = Instant::now();
823
824 let node = catalog
825 .node(node_id)
826 .ok_or_else(|| SomaError::NodeNotFound(node_id.to_string()))?
827 .clone();
828 let meta = node.meta();
829
830 let _span = tracing::info_span!("run_node", %node_id).entered();
831
832 let input = resolve_input(node_id, ctx);
833
834 let fitted = fit_state_if_needed(node_id, &node, &meta, &input, ctx, cache)?;
838
839 let state = catalog.get_state(node_id);
843 let state_ref: &Value = fitted
844 .as_ref()
845 .or(state.as_deref())
846 .unwrap_or(&Value::Empty);
847
848 let out_key = output_key(
852 &node,
853 &meta,
854 state_ref,
855 &ctx.input_hash(node_id, &input),
856 ctx.seed,
857 );
858
859 if let Some(key) = &out_key
863 && let Ok(Some((cached, tier))) = cache.get_located(key)
864 {
865 ctx.set(node_id.to_string(), cached.clone());
866 ctx.event_bus.emit(Event::NodeCacheHit {
867 run_id: ctx.run_id.clone(),
868 node_id: node_id.to_string(),
869 key: key.clone(),
870 tier,
871 load_time: start.elapsed(),
872 });
873 return Ok(NodeOutcome::Produced(cached));
874 }
875
876 if let Some(key) = &out_key {
877 ctx.event_bus.emit(Event::NodeCacheMiss {
878 run_id: ctx.run_id.clone(),
879 node_id: node_id.to_string(),
880 key: key.clone(),
881 });
882 }
883
884 ctx.event_bus.emit(Event::NodeStarted {
885 run_id: ctx.run_id.clone(),
886 node_id: node_id.to_string(),
887 kind: meta.kind,
888 effectful: meta.effectful,
889 });
890
891 let outcome = match compute_node(&node, node_id, ctx, &input, state_ref) {
892 Ok(outcome) => outcome,
893 Err(e) => {
894 tracing::error!(node_id, error = %e, "node execution failed");
895 ctx.event_bus.emit(Event::NodeFailed {
896 run_id: ctx.run_id.clone(),
897 node_id: node_id.to_string(),
898 error: e.to_string(),
899 });
900 return Err(e);
901 }
902 };
903
904 let duration = start.elapsed();
905 match &outcome {
906 NodeOutcome::Produced(output) => {
907 let summary = format!("{output}");
908 if let Some(key) = &out_key {
909 store_output(
910 cache,
911 key,
912 output,
913 node_id,
914 &ctx.run_id,
915 duration,
916 meta.deterministic,
917 );
918 }
919 let vv = ctx.maybe_spill(node_id, output.clone());
920 ctx.set_virtual(node_id, vv);
921 ctx.event_bus.emit(Event::NodeCompleted {
922 run_id: ctx.run_id.clone(),
923 node_id: node_id.to_string(),
924 duration,
925 output_summary: summary,
926 });
927 }
928
929 NodeOutcome::HandOff { target, carry } => {
932 ctx.set(node_id, carry.clone());
933 ctx.event_bus.emit(Event::NodeCompleted {
934 run_id: ctx.run_id.clone(),
935 node_id: node_id.to_string(),
936 duration,
937 output_summary: format!("handed off to {target}"),
938 });
939 }
940
941 NodeOutcome::Paused { .. } => {}
943 }
944
945 Ok(outcome)
946}
947
948fn composite_fit(node_ids: &[String], ctx: &mut Context, catalog: &NodeCatalog) -> Result<bool> {
954 let Some(first) = node_ids.first() else {
955 return Ok(false);
956 };
957 if let Some(step_id) = node_ids.iter().find(|id| catalog.step(id).is_some()) {
961 return Err(SomaError::Execution {
962 node_id: step_id.to_string(),
963 message: "a Composite block contains a step; composite fit is defined \
964 only over differentiable filters"
965 .into(),
966 });
967 }
968 let peers: Option<Vec<(String, Arc<dyn somatize_core::filter::Filter>)>> = node_ids
969 .iter()
970 .map(|id| catalog.get(id).map(|f| (id.clone(), f)))
971 .collect();
972 let (Some(peers), Some(filter)) = (peers, catalog.get(first)) else {
973 return Ok(false);
974 };
975
976 let input = resolve_input(first, ctx);
977 let y = ctx.mode.labels().cloned();
978 let Some(result) = filter.composite_fit(&peers, &input, y.as_ref()) else {
979 return Ok(false);
980 };
981 let (output, states) = result?;
982
983 for (id, state) in states {
984 ctx.record_state(&id, state);
985 }
986 if let Some(last) = node_ids.last() {
987 ctx.set(last.clone(), output);
988 }
989 Ok(true)
990}
991
992fn fit_state_if_needed(
1003 node_id: &str,
1004 node: &NodeImpl,
1005 meta: &somatize_core::node::NodeMeta,
1006 input: &Value,
1007 ctx: &mut Context,
1008 cache: &dyn CacheStore,
1009) -> Result<Option<Value>> {
1010 if !ctx.mode.is_fit() || !meta.trainable() {
1011 return Ok(None);
1012 }
1013 let NodeImpl::Filter(filter) = node else {
1016 return Ok(None);
1017 };
1018
1019 let y = ctx.mode.labels().cloned();
1020 let key = salt_with_seed(
1021 somatize_core::cache::CacheKey::for_state(
1022 &filter.config_hash(),
1023 &somatize_core::cache::CacheKey::for_value(input),
1024 y.as_ref()
1025 .map(somatize_core::cache::CacheKey::for_value)
1026 .as_ref(),
1027 ),
1028 ctx.seed,
1029 );
1030
1031 let state = match cache.get(&key)? {
1032 Some(cached) => cached,
1033 None => {
1034 let start = Instant::now();
1035 let learned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1036 filter.fit(input, y.as_ref())
1037 }))
1038 .map_err(|panic| SomaError::Execution {
1039 node_id: node_id.to_string(),
1040 message: format!("fit panicked: {}", panic_message(&*panic)),
1041 })??;
1042 let origin = somatize_core::cache::Origin::Computed {
1043 node_id: node_id.to_string(),
1044 run_id: ctx.run_id.clone(),
1045 };
1046 if let Err(e) = cache.put_computed(&key, &learned, &origin, start.elapsed(), true) {
1047 tracing::warn!(node_id, error = %e, "failed to cache fitted state");
1048 }
1049 learned
1050 }
1051 };
1052
1053 ctx.record_state(node_id, state.clone());
1054 Ok(Some(state))
1055}
1056
1057fn run_node_inner(
1059 node: &NodeImpl,
1060 node_id: &str,
1061 ctx: &Context,
1062 input: &Value,
1063 state: &Value,
1064) -> Result<NodeOutcome> {
1065 match node {
1066 NodeImpl::Filter(filter) => filter.forward(input, state).map(NodeOutcome::Produced),
1067
1068 NodeImpl::Step(step) => {
1069 let driver = ctx.driver.as_ref().ok_or_else(|| SomaError::Execution {
1070 node_id: node_id.to_string(),
1071 message: "the plan contains a step but no effect driver was registered; \
1072 build the context with `with_driver(...)`"
1073 .into(),
1074 })?;
1075 driver.run(step.as_ref(), &ctx.run_id, node_id, input)
1076 }
1077 }
1078}
1079
1080fn execute_parallel(
1085 branches: &[ExecutionPlan],
1086 ctx: &mut Context,
1087 catalog: &NodeCatalog,
1088 cache: &dyn CacheStore,
1089) -> Result<()> {
1090 let order_mark = ctx.execution_order.len();
1101
1102 let results: Vec<Result<Vec<(String, VirtualValue)>>> = std::thread::scope(|s| {
1104 let handles: Vec<_> = branches
1105 .iter()
1106 .map(|branch| {
1107 let mut branch_ctx = ctx.snapshot();
1108 s.spawn(move || {
1109 execute(branch, &mut branch_ctx, catalog, cache)?;
1110 let written: std::collections::HashSet<&String> =
1111 branch_ctx.execution_order[order_mark..].iter().collect();
1112 let new_entries: Vec<(String, VirtualValue)> = written
1113 .into_iter()
1114 .filter_map(|k| branch_ctx.store.get(k).map(|v| (k.clone(), v.clone())))
1115 .collect();
1116 Ok(new_entries)
1117 })
1118 })
1119 .collect();
1120
1121 handles
1126 .into_iter()
1127 .map(|h| match h.join() {
1128 Ok(result) => result,
1129 Err(panic) => {
1130 let msg = panic_message(&*panic);
1131 tracing::error!("parallel branch panicked: {msg}");
1132 Err(SomaError::Execution {
1133 node_id: "<parallel branch>".to_string(),
1134 message: format!("parallel branch panicked: {msg}"),
1135 })
1136 }
1137 })
1138 .collect()
1139 });
1140
1141 for result in results {
1143 let entries = result?;
1144 for (key, vv) in entries {
1145 ctx.set_virtual(key, vv);
1146 }
1147 }
1148
1149 Ok(())
1150}
1151
1152fn resolve_value(vv: &VirtualValue, data_store: &Option<Arc<dyn DataStore>>) -> Option<Value> {
1154 match vv {
1155 VirtualValue::Materialized { value, .. } => Some(value.clone()),
1156 VirtualValue::Cached { key, .. } => {
1157 if let Some(store) = data_store {
1159 let data_ref = somatize_core::store::DataRef::Cached {
1160 cache_key: key.clone(),
1161 };
1162 store.get(&data_ref).ok()
1163 } else {
1164 None
1165 }
1166 }
1167 _ => None,
1168 }
1169}
1170
1171pub(crate) fn resolve_input(node_id: &str, ctx: &Context) -> Value {
1174 let preds = ctx.graph_info.predecessors(node_id);
1175
1176 let resolve_node = |id: &str| -> Option<Value> {
1177 ctx.store
1178 .get(id)
1179 .and_then(|vv| resolve_value(vv, &ctx.data_store))
1180 };
1181
1182 match preds.len() {
1183 0 => ctx
1184 .execution_order
1185 .last()
1186 .and_then(|id| resolve_node(id))
1187 .unwrap_or(Value::Empty),
1188 1 => resolve_node(&preds[0]).unwrap_or(Value::Empty),
1189 _ => {
1190 let mut merged = serde_json::Map::new();
1191 for pred_id in preds {
1192 if let Some(val) = resolve_node(pred_id) {
1193 let json_val = val.to_plain_json();
1194 merged.insert(pred_id.clone(), json_val);
1195 }
1196 }
1197 Value::json(serde_json::Value::Object(merged))
1198 }
1199 }
1200}
1201
1202fn execute_stream(
1209 node_ids: &[String],
1210 chunk_size: usize,
1211 ctx: &mut Context,
1212 catalog: &NodeCatalog,
1213 cache: &dyn CacheStore,
1214) -> Result<()> {
1215 use crate::executors::stream::StreamRun;
1216
1217 if matches!(ctx.mode, RunMode::Fit { .. }) {
1221 return Err(SomaError::Execution {
1222 node_id: node_ids.first().cloned().unwrap_or_default(),
1223 message: "a stream plan cannot run in fit mode: fit the graph first, \
1224 then stream the forward"
1225 .into(),
1226 });
1227 }
1228
1229 let first_id = node_ids
1231 .first()
1232 .ok_or_else(|| SomaError::Other("stream plan has no nodes".into()))?;
1233 let input = resolve_input(first_id, ctx);
1234
1235 let chunks = chunk_value(&input, chunk_size);
1237
1238 let last_id = node_ids.last().unwrap().clone();
1239 let mut run = StreamRun::new(node_ids, catalog)?;
1240
1241 let mut output = crate::executors::StreamOutput::new();
1243
1244 for (i, chunk) in chunks.into_iter().enumerate() {
1245 tracing::debug!(node_id = %last_id, chunk = i, "streaming chunk");
1246 if let Some(out) = run.process_chunk(chunk, ctx, cache)? {
1247 output.push(out);
1248 }
1249 }
1250
1251 if let Some(flushed) = run.flush(ctx, cache)? {
1253 output.push(flushed);
1254 }
1255
1256 tracing::debug!(node_id = %last_id, chunks = run.chunks_processed(), "stream done");
1257 run.finish(ctx);
1258
1259 ctx.set(last_id, output.finish());
1260 Ok(())
1261}
1262
1263fn chunk_value(x: &Value, chunk_size: usize) -> Vec<Value> {
1265 match x {
1266 Value::Tensor { values, shape } if !values.is_empty() && chunk_size > 0 => {
1267 let row_size = if shape.len() > 1 {
1268 shape[1..].iter().product()
1269 } else {
1270 1
1271 };
1272 let n_rows = shape[0];
1273 let mut chunks = Vec::new();
1274 for start in (0..n_rows).step_by(chunk_size) {
1275 let end = (start + chunk_size).min(n_rows);
1276 let flat_start = start * row_size;
1277 let flat_end = end * row_size;
1278 let chunk_vals = values[flat_start..flat_end].to_vec();
1279 let mut chunk_shape = shape.clone();
1280 chunk_shape[0] = end - start;
1281 chunks.push(Value::tensor(chunk_vals, chunk_shape));
1282 }
1283 chunks
1284 }
1285 _ => vec![x.clone()],
1286 }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292 use crate::cache::MemoryCache;
1293 use somatize_core::cache::CacheKey;
1294 use somatize_core::filter::{Filter, FilterKind, FilterMeta, StreamMode};
1295
1296 struct PanicsInMeta;
1300
1301 impl Filter for PanicsInMeta {
1302 fn config_hash(&self) -> CacheKey {
1303 CacheKey::from_parts(&[b"PanicsInMeta"])
1304 }
1305 fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
1306 Ok(Value::Empty)
1307 }
1308 fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
1309 Ok(x.clone())
1310 }
1311 fn meta(&self) -> FilterMeta {
1312 panic!("meta blew up");
1313 }
1314 }
1315
1316 #[test]
1322 fn a_panicking_parallel_branch_becomes_an_error() {
1323 let mut lib = NodeCatalog::new();
1324 lib.register("boom", Box::new(PanicsInMeta));
1325 lib.register("fine", Box::new(DoublerFilter));
1326
1327 let cache = MemoryCache::default();
1328 let bus = Arc::new(EventBus::new(64));
1329 let mut ctx = Context::new(bus, "run-panic");
1330 ctx.set("input".to_string(), Value::tensor(vec![1.0], vec![1]));
1331
1332 let plan = ExecutionPlan::Parallel(vec![
1333 ExecutionPlan::Execute {
1334 node_id: "boom".into(),
1335 },
1336 ExecutionPlan::Execute {
1337 node_id: "fine".into(),
1338 },
1339 ]);
1340
1341 let previous = std::panic::take_hook();
1344 std::panic::set_hook(Box::new(|_| {}));
1345 let result = execute(&plan, &mut ctx, &lib, &cache);
1346 std::panic::set_hook(previous);
1347
1348 let err = result.expect_err("a panicking branch must not be a success");
1349 assert!(
1350 err.to_string().contains("meta blew up"),
1351 "the panic message should survive; got: {err}"
1352 );
1353 }
1354
1355 struct DoublerFilter;
1356
1357 impl Filter for DoublerFilter {
1358 fn config_hash(&self) -> CacheKey {
1359 CacheKey::from_parts(&[b"Doubler"])
1360 }
1361 fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
1362 Ok(Value::Empty)
1363 }
1364 fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
1365 match x {
1366 Value::Tensor { values, shape } => {
1367 let doubled: Vec<f64> = values.iter().map(|v| v * 2.0).collect();
1368 Ok(Value::tensor(doubled, shape.clone()))
1369 }
1370 _ => Ok(x.clone()),
1371 }
1372 }
1373 fn meta(&self) -> FilterMeta {
1374 FilterMeta {
1375 name: "Doubler".into(),
1376 kind: FilterKind::Stateless,
1377 cacheable: true,
1378 differentiable: true,
1379 deterministic: true,
1380 stream_mode: StreamMode::FixedState,
1381 distribution: somatize_core::filter::Distribution::Local,
1382 input_schema: None,
1383 output_schema: None,
1384 }
1385 }
1386 }
1387
1388 struct AdderFilter {
1389 amount: f64,
1390 }
1391
1392 impl Filter for AdderFilter {
1393 fn config_hash(&self) -> CacheKey {
1394 CacheKey::from_parts(&[b"Adder", &self.amount.to_le_bytes()])
1395 }
1396 fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
1397 Ok(Value::Empty)
1398 }
1399 fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
1400 match x {
1401 Value::Tensor { values, shape } => {
1402 let added: Vec<f64> = values.iter().map(|v| v + self.amount).collect();
1403 Ok(Value::tensor(added, shape.clone()))
1404 }
1405 _ => Ok(x.clone()),
1406 }
1407 }
1408 fn meta(&self) -> FilterMeta {
1409 FilterMeta {
1410 name: "Adder".into(),
1411 kind: FilterKind::Stateless,
1412 cacheable: true,
1413 differentiable: true,
1414 deterministic: true,
1415 stream_mode: StreamMode::FixedState,
1416 distribution: somatize_core::filter::Distribution::Local,
1417 input_schema: None,
1418 output_schema: None,
1419 }
1420 }
1421 }
1422
1423 struct SlowFilter {
1425 id: String,
1426 delay_ms: u64,
1427 }
1428
1429 impl Filter for SlowFilter {
1430 fn config_hash(&self) -> CacheKey {
1431 CacheKey::from_parts(&[b"Slow", self.id.as_bytes()])
1432 }
1433 fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
1434 Ok(Value::Empty)
1435 }
1436 fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
1437 std::thread::sleep(std::time::Duration::from_millis(self.delay_ms));
1438 Ok(x.clone())
1439 }
1440 fn meta(&self) -> FilterMeta {
1441 FilterMeta {
1442 name: format!("Slow_{}", self.id),
1443 kind: FilterKind::Stateless,
1444 cacheable: false,
1445 differentiable: true,
1446 deterministic: true,
1447 stream_mode: StreamMode::FixedState,
1448 distribution: somatize_core::filter::Distribution::Local,
1449 input_schema: None,
1450 output_schema: None,
1451 }
1452 }
1453 }
1454
1455 fn setup() -> (Arc<EventBus>, MemoryCache) {
1456 (Arc::new(EventBus::new(64)), MemoryCache::default())
1457 }
1458
1459 #[test]
1460 fn execute_single_node() {
1461 let (bus, cache) = setup();
1462 let mut ctx = Context::new(bus, "run_1");
1463 ctx.set("input", Value::tensor(vec![1.0, 2.0, 3.0], vec![3]));
1464 ctx.graph_info
1465 .set_predecessors("doubler", vec!["input".into()]);
1466
1467 let mut filters = NodeCatalog::new();
1468 filters.register("doubler", Box::new(DoublerFilter));
1469
1470 let plan = ExecutionPlan::Execute {
1471 node_id: "doubler".into(),
1472 };
1473
1474 execute(&plan, &mut ctx, &filters, &cache).unwrap();
1475
1476 let result = ctx.get("doubler").unwrap();
1477 let (data, _) = result.as_tensor().unwrap();
1478 assert_eq!(data, &[2.0, 4.0, 6.0]);
1479 }
1480
1481 #[test]
1482 fn execute_sequence_with_graph_info() {
1483 let (bus, cache) = setup();
1484 let mut ctx = Context::new(bus, "run_1");
1485 ctx.set("input", Value::tensor(vec![1.0, 2.0], vec![2]));
1486
1487 let graph_info = GraphInfo::for_linear(&["input", "add", "double"]);
1488 ctx.graph_info = graph_info;
1489
1490 let mut filters = NodeCatalog::new();
1491 filters.register("add", Box::new(AdderFilter { amount: 10.0 }));
1492 filters.register("double", Box::new(DoublerFilter));
1493
1494 let plan = ExecutionPlan::Sequence(vec![
1495 ExecutionPlan::Execute {
1496 node_id: "add".into(),
1497 },
1498 ExecutionPlan::Execute {
1499 node_id: "double".into(),
1500 },
1501 ]);
1502
1503 execute(&plan, &mut ctx, &filters, &cache).unwrap();
1504
1505 let result = ctx.get("double").unwrap();
1506 let (data, _) = result.as_tensor().unwrap();
1507 assert_eq!(data, &[22.0, 24.0]);
1508 }
1509
1510 #[test]
1511 fn execute_emits_events() {
1512 let bus = Arc::new(EventBus::new(64));
1513 let cache = MemoryCache::default();
1514 let mut rx = bus.subscribe();
1515
1516 let mut ctx = Context::new(bus, "run_1");
1517 ctx.set("input", Value::tensor(vec![1.0], vec![1]));
1518 ctx.graph_info
1519 .set_predecessors("double", vec!["input".into()]);
1520
1521 let mut filters = NodeCatalog::new();
1522 filters.register("double", Box::new(DoublerFilter));
1523
1524 execute(
1525 &ExecutionPlan::Execute {
1526 node_id: "double".into(),
1527 },
1528 &mut ctx,
1529 &filters,
1530 &cache,
1531 )
1532 .unwrap();
1533
1534 let e1 = rx.try_recv().unwrap();
1536 assert!(matches!(e1, Event::NodeCacheMiss { .. }), "got {e1:?}");
1537 let e2 = rx.try_recv().unwrap();
1538 assert!(matches!(e2, Event::NodeStarted { .. }), "got {e2:?}");
1539 let e3 = rx.try_recv().unwrap();
1540 assert!(matches!(e3, Event::NodeCompleted { .. }), "got {e3:?}");
1541 }
1542
1543 #[test]
1544 fn execute_missing_filter_errors() {
1545 let (bus, cache) = setup();
1546 let mut ctx = Context::new(bus, "run_1");
1547 let filters = NodeCatalog::new();
1548
1549 let result = execute(
1550 &ExecutionPlan::Execute {
1551 node_id: "nonexistent".into(),
1552 },
1553 &mut ctx,
1554 &filters,
1555 &cache,
1556 );
1557 assert!(matches!(result, Err(SomaError::NodeNotFound(_))));
1558 }
1559
1560 #[test]
1561 fn execute_empty_plan() {
1562 let (bus, cache) = setup();
1563 let mut ctx = Context::new(bus, "run_1");
1564 let filters = NodeCatalog::new();
1565 execute(&ExecutionPlan::Empty, &mut ctx, &filters, &cache).unwrap();
1566 }
1567
1568 #[test]
1569 fn parallel_merge_keeps_rerun_outputs() {
1570 let (bus, cache) = setup();
1576 let mut ctx = Context::new(bus, "run_1");
1577 ctx.graph_info
1578 .set_predecessors("double", vec!["input".into()]);
1579 ctx.graph_info.set_predecessors("add", vec!["input".into()]);
1580
1581 let mut filters = NodeCatalog::new();
1582 filters.register("double", Box::new(DoublerFilter));
1583 filters.register("add", Box::new(AdderFilter { amount: 100.0 }));
1584
1585 let plan = ExecutionPlan::Parallel(vec![
1586 ExecutionPlan::Execute {
1587 node_id: "double".into(),
1588 },
1589 ExecutionPlan::Execute {
1590 node_id: "add".into(),
1591 },
1592 ]);
1593
1594 ctx.set("input", Value::tensor(vec![5.0], vec![1]));
1595 execute(&plan, &mut ctx, &filters, &cache).unwrap();
1596 assert_eq!(ctx.get("double").unwrap().as_tensor().unwrap().0, &[10.0]);
1597
1598 ctx.set("input", Value::tensor(vec![7.0], vec![1]));
1600 execute(&plan, &mut ctx, &filters, &cache).unwrap();
1601
1602 assert_eq!(
1603 ctx.get("double").unwrap().as_tensor().unwrap().0,
1604 &[14.0],
1605 "second pass output was discarded by the merge"
1606 );
1607 assert_eq!(ctx.get("add").unwrap().as_tensor().unwrap().0, &[107.0]);
1608 }
1609
1610 #[test]
1611 fn execute_parallel_branches_merge_outputs() {
1612 let (bus, cache) = setup();
1613 let mut ctx = Context::new(bus, "run_1");
1614 ctx.set("input", Value::tensor(vec![5.0], vec![1]));
1615 ctx.graph_info
1616 .set_predecessors("double", vec!["input".into()]);
1617 ctx.graph_info.set_predecessors("add", vec!["input".into()]);
1618
1619 let mut filters = NodeCatalog::new();
1620 filters.register("double", Box::new(DoublerFilter));
1621 filters.register("add", Box::new(AdderFilter { amount: 100.0 }));
1622
1623 let plan = ExecutionPlan::Parallel(vec![
1624 ExecutionPlan::Execute {
1625 node_id: "double".into(),
1626 },
1627 ExecutionPlan::Execute {
1628 node_id: "add".into(),
1629 },
1630 ]);
1631
1632 execute(&plan, &mut ctx, &filters, &cache).unwrap();
1633
1634 let double_out = ctx.get("double").unwrap().as_tensor().unwrap().0;
1635 assert_eq!(double_out, &[10.0]);
1636 let add_out = ctx.get("add").unwrap().as_tensor().unwrap().0;
1637 assert_eq!(add_out, &[105.0]);
1638 }
1639
1640 #[test]
1641 fn parallel_branches_run_concurrently() {
1642 let (bus, cache) = setup();
1643 let mut ctx = Context::new(bus, "run_1");
1644 ctx.set("input", Value::tensor(vec![1.0], vec![1]));
1645 ctx.graph_info
1646 .set_predecessors("slow_a", vec!["input".into()]);
1647 ctx.graph_info
1648 .set_predecessors("slow_b", vec!["input".into()]);
1649
1650 let mut filters = NodeCatalog::new();
1651 filters.register(
1652 "slow_a",
1653 Box::new(SlowFilter {
1654 id: "a".into(),
1655 delay_ms: 200,
1656 }),
1657 );
1658 filters.register(
1659 "slow_b",
1660 Box::new(SlowFilter {
1661 id: "b".into(),
1662 delay_ms: 200,
1663 }),
1664 );
1665
1666 let plan = ExecutionPlan::Parallel(vec![
1667 ExecutionPlan::Execute {
1668 node_id: "slow_a".into(),
1669 },
1670 ExecutionPlan::Execute {
1671 node_id: "slow_b".into(),
1672 },
1673 ]);
1674
1675 let start = Instant::now();
1676 execute(&plan, &mut ctx, &filters, &cache).unwrap();
1677 let elapsed = start.elapsed();
1678
1679 assert!(
1682 elapsed.as_millis() < 350,
1683 "parallel branches took {}ms, expected <350ms (sequential would be ~400ms)",
1684 elapsed.as_millis()
1685 );
1686
1687 assert!(ctx.get("slow_a").is_some());
1688 assert!(ctx.get("slow_b").is_some());
1689 }
1690
1691 #[test]
1692 fn resolve_input_single_predecessor() {
1693 let bus = Arc::new(EventBus::new(8));
1694 let mut ctx = Context::new(bus, "r");
1695 ctx.set("A", Value::tensor(vec![42.0], vec![1]));
1696 ctx.graph_info.set_predecessors("B", vec!["A".into()]);
1697
1698 let input = resolve_input("B", &ctx);
1699 let (data, _) = input.as_tensor().unwrap();
1700 assert_eq!(data, &[42.0]);
1701 }
1702
1703 #[test]
1704 fn resolve_input_multiple_predecessors() {
1705 let bus = Arc::new(EventBus::new(8));
1706 let mut ctx = Context::new(bus, "r");
1707 ctx.set("A", Value::tensor(vec![1.0], vec![1]));
1708 ctx.set("B", Value::tensor(vec![2.0], vec![1]));
1709 ctx.graph_info
1710 .set_predecessors("C", vec!["A".into(), "B".into()]);
1711
1712 let input = resolve_input("C", &ctx);
1713 let json = input.as_json().unwrap();
1714 assert!(json.get("A").is_some());
1715 assert!(json.get("B").is_some());
1716 }
1717
1718 #[test]
1719 fn resolve_input_no_predecessors_fallback() {
1720 let bus = Arc::new(EventBus::new(8));
1721 let mut ctx = Context::new(bus, "r");
1722 ctx.set("prev", Value::tensor(vec![7.0], vec![1]));
1723
1724 let input = resolve_input("root", &ctx);
1725 let (data, _) = input.as_tensor().unwrap();
1726 assert_eq!(data, &[7.0]);
1727 }
1728
1729 #[test]
1730 fn graph_info_from_linear() {
1731 let info = GraphInfo::for_linear(&["a", "b", "c"]);
1732 assert!(info.predecessors("a").is_empty());
1733 assert_eq!(info.predecessors("b"), &["a"]);
1734 assert_eq!(info.predecessors("c"), &["b"]);
1735 }
1736
1737 #[test]
1738 fn execute_stream_chunks_input() {
1739 let (bus, cache) = setup();
1740 let mut ctx = Context::new(bus, "run_stream");
1741 ctx.set(
1743 "__input__",
1744 Value::tensor(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![6]),
1745 );
1746 ctx.graph_info
1747 .set_predecessors("double", vec!["__input__".into()]);
1748
1749 let mut filters = NodeCatalog::new();
1750 filters.register("double", Box::new(DoublerFilter));
1751
1752 let plan = ExecutionPlan::Stream {
1753 node_ids: vec!["double".into()],
1754 chunk_size: 2,
1755 };
1756
1757 execute(&plan, &mut ctx, &filters, &cache).unwrap();
1758
1759 let result = ctx.get("double").unwrap();
1760 let (data, shape) = result.as_tensor().unwrap();
1761 assert_eq!(data, &[2.0, 4.0, 6.0, 8.0, 10.0, 12.0]);
1762 assert_eq!(shape, &[6]);
1763 }
1764
1765 #[test]
1766 fn execute_stream_chain() {
1767 let (bus, cache) = setup();
1768 let mut ctx = Context::new(bus, "run_stream_chain");
1769 ctx.set(
1770 "__input__",
1771 Value::tensor(vec![1.0, 2.0, 3.0, 4.0], vec![4]),
1772 );
1773 ctx.graph_info
1774 .set_predecessors("double", vec!["__input__".into()]);
1775 ctx.graph_info
1776 .set_predecessors("add", vec!["double".into()]);
1777
1778 let mut filters = NodeCatalog::new();
1779 filters.register("double", Box::new(DoublerFilter));
1780 filters.register("add", Box::new(AdderFilter { amount: 10.0 }));
1781
1782 let plan = ExecutionPlan::Stream {
1783 node_ids: vec!["double".into(), "add".into()],
1784 chunk_size: 2,
1785 };
1786
1787 execute(&plan, &mut ctx, &filters, &cache).unwrap();
1788
1789 let result = ctx.get("add").unwrap();
1791 let (data, shape) = result.as_tensor().unwrap();
1792 assert_eq!(data, &[12.0, 14.0, 16.0, 18.0]);
1793 assert_eq!(shape, &[4]);
1794 }
1795
1796 struct CountingFilter {
1798 forwards: Arc<std::sync::atomic::AtomicUsize>,
1799 cacheable: bool,
1800 config: f64,
1801 }
1802
1803 impl Filter for CountingFilter {
1804 fn config_hash(&self) -> CacheKey {
1805 CacheKey::from_parts(&[b"Counting", &self.config.to_le_bytes()])
1806 }
1807 fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
1808 Ok(Value::Empty)
1809 }
1810 fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
1811 self.forwards
1812 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1813 match x {
1814 Value::Tensor { values, shape } => {
1815 let out: Vec<f64> = values.iter().map(|v| v + self.config).collect();
1816 Ok(Value::tensor(out, shape.clone()))
1817 }
1818 _ => Ok(x.clone()),
1819 }
1820 }
1821 fn meta(&self) -> FilterMeta {
1822 FilterMeta {
1823 name: "Counting".into(),
1824 kind: FilterKind::Stateless,
1825 cacheable: self.cacheable,
1826 differentiable: true,
1827 deterministic: true,
1828 stream_mode: StreamMode::FixedState,
1829 distribution: somatize_core::filter::Distribution::Local,
1830 input_schema: None,
1831 output_schema: None,
1832 }
1833 }
1834 }
1835
1836 fn counting_setup(
1837 cacheable: bool,
1838 ) -> (NodeCatalog, Arc<std::sync::atomic::AtomicUsize>, GraphInfo) {
1839 let forwards = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1840 let mut filters = NodeCatalog::new();
1841 filters.register(
1842 "a",
1843 Box::new(CountingFilter {
1844 forwards: forwards.clone(),
1845 cacheable,
1846 config: 1.0,
1847 }),
1848 );
1849 filters.register(
1850 "b",
1851 Box::new(CountingFilter {
1852 forwards: forwards.clone(),
1853 cacheable,
1854 config: 2.0,
1855 }),
1856 );
1857 let info = GraphInfo::for_linear(&["input", "a", "b"]);
1858 (filters, forwards, info)
1859 }
1860
1861 fn run_chain(cache: &dyn CacheStore, filters: &NodeCatalog, info: &GraphInfo) -> Value {
1862 let bus = Arc::new(EventBus::new(64));
1863 let mut ctx = Context::new(bus, "run").with_graph_info(info.clone());
1864 ctx.set("input", Value::tensor(vec![1.0, 2.0], vec![2]));
1865 let plan = ExecutionPlan::Sequence(vec![
1866 ExecutionPlan::Execute {
1867 node_id: "a".into(),
1868 },
1869 ExecutionPlan::Execute {
1870 node_id: "b".into(),
1871 },
1872 ]);
1873 execute(&plan, &mut ctx, filters, cache).unwrap();
1874 ctx.get("b").unwrap().clone()
1875 }
1876
1877 #[test]
1878 fn second_run_hits_cache_and_skips_execution() {
1879 let (filters, forwards, info) = counting_setup(true);
1880 let cache = MemoryCache::default();
1881
1882 let first = run_chain(&cache, &filters, &info);
1883 assert_eq!(forwards.load(std::sync::atomic::Ordering::SeqCst), 2);
1884
1885 let second = run_chain(&cache, &filters, &info);
1886 assert_eq!(
1887 forwards.load(std::sync::atomic::Ordering::SeqCst),
1888 2,
1889 "second run must not execute any filter"
1890 );
1891 assert_eq!(first, second);
1892 }
1893
1894 #[test]
1895 fn uncacheable_filter_always_executes() {
1896 let (filters, forwards, info) = counting_setup(false);
1897 let cache = MemoryCache::default();
1898
1899 run_chain(&cache, &filters, &info);
1900 run_chain(&cache, &filters, &info);
1901 assert_eq!(forwards.load(std::sync::atomic::Ordering::SeqCst), 4);
1902 }
1903
1904 #[test]
1905 fn cache_survives_process_restart() {
1906 use crate::cache::LocalCache;
1907 let dir = std::env::temp_dir().join(format!(
1908 "soma_exec_restart_{}_{}",
1909 std::process::id(),
1910 std::time::SystemTime::now()
1911 .duration_since(std::time::UNIX_EPOCH)
1912 .unwrap()
1913 .as_nanos()
1914 ));
1915 let (filters, forwards, info) = counting_setup(true);
1916
1917 {
1918 let cache = LocalCache::new(&dir).unwrap();
1919 run_chain(&cache, &filters, &info);
1920 }
1921 assert_eq!(forwards.load(std::sync::atomic::Ordering::SeqCst), 2);
1922
1923 {
1925 let cache = LocalCache::new(&dir).unwrap();
1926 run_chain(&cache, &filters, &info);
1927 }
1928 assert_eq!(
1929 forwards.load(std::sync::atomic::Ordering::SeqCst),
1930 2,
1931 "after restart the persisted cache must serve both nodes"
1932 );
1933
1934 let _ = std::fs::remove_dir_all(&dir);
1935 }
1936
1937 #[test]
1938 fn different_input_misses_cache() {
1939 let (filters, forwards, info) = counting_setup(true);
1940 let cache = MemoryCache::default();
1941
1942 run_chain(&cache, &filters, &info);
1943
1944 let bus = Arc::new(EventBus::new(64));
1945 let mut ctx = Context::new(bus, "run2").with_graph_info(info.clone());
1946 ctx.set("input", Value::tensor(vec![9.0, 9.0], vec![2]));
1947 let plan = ExecutionPlan::Sequence(vec![
1948 ExecutionPlan::Execute {
1949 node_id: "a".into(),
1950 },
1951 ExecutionPlan::Execute {
1952 node_id: "b".into(),
1953 },
1954 ]);
1955 execute(&plan, &mut ctx, &filters, &cache).unwrap();
1956 assert_eq!(
1957 forwards.load(std::sync::atomic::Ordering::SeqCst),
1958 4,
1959 "different input data must not hit the cache"
1960 );
1961 }
1962
1963 #[test]
1964 fn cache_hit_emits_cache_hit_event() {
1965 let (filters, _forwards, info) = counting_setup(true);
1966 let cache = MemoryCache::default();
1967 run_chain(&cache, &filters, &info);
1968
1969 let bus = Arc::new(EventBus::new(64));
1970 let mut rx = bus.subscribe();
1971 let mut ctx = Context::new(bus, "run2").with_graph_info(info.clone());
1972 ctx.set("input", Value::tensor(vec![1.0, 2.0], vec![2]));
1973 execute(
1974 &ExecutionPlan::Execute {
1975 node_id: "a".into(),
1976 },
1977 &mut ctx,
1978 &filters,
1979 &cache,
1980 )
1981 .unwrap();
1982
1983 let event = rx.try_recv().unwrap();
1984 assert!(
1985 matches!(event, Event::NodeCacheHit { ref node_id, .. } if node_id == "a"),
1986 "expected NodeCacheHit for `a`, got: {event:?}"
1987 );
1988 }
1989
1990 #[test]
1991 fn spill_roundtrip_through_datastore() {
1992 use somatize_core::store::LocalDataStore;
1993 let dir = std::env::temp_dir().join(format!(
1994 "soma_spill_test_{}_{}",
1995 std::process::id(),
1996 std::time::SystemTime::now()
1997 .duration_since(std::time::UNIX_EPOCH)
1998 .unwrap()
1999 .as_nanos()
2000 ));
2001 let store: Arc<dyn DataStore> = Arc::new(LocalDataStore::new(&dir));
2002
2003 let (filters, _forwards, info) = counting_setup(true);
2004 let bus = Arc::new(EventBus::new(64));
2005 let mut ctx = Context::new(bus, "run_spill")
2006 .with_graph_info(info.clone())
2007 .with_data_store(store)
2008 .with_spill_threshold(1); ctx.set("input", Value::tensor(vec![1.0, 2.0], vec![2]));
2010
2011 let plan = ExecutionPlan::Sequence(vec![
2012 ExecutionPlan::Execute {
2013 node_id: "a".into(),
2014 },
2015 ExecutionPlan::Execute {
2016 node_id: "b".into(),
2017 },
2018 ]);
2019 execute(&plan, &mut ctx, &filters, &cache_for_spill())
2020 .expect("spilled intermediate must be readable downstream");
2021
2022 let out = resolve_value(ctx.get_virtual("b").unwrap(), &ctx.data_store).unwrap();
2025 let (data, _) = out.as_tensor().unwrap();
2026 assert_eq!(data, &[4.0, 5.0]);
2027
2028 let _ = std::fs::remove_dir_all(&dir);
2029 }
2030
2031 fn cache_for_spill() -> MemoryCache {
2032 MemoryCache::default()
2033 }
2034
2035 struct SaltedFilter {
2038 salt: f64,
2039 forwards: Arc<std::sync::atomic::AtomicUsize>,
2040 }
2041
2042 impl Filter for SaltedFilter {
2043 fn config_hash(&self) -> CacheKey {
2044 CacheKey::from_parts(&[b"Salted", &self.salt.to_le_bytes()])
2045 }
2046 fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
2047 Ok(Value::Empty)
2048 }
2049 fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
2050 self.forwards
2051 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2052 match x {
2053 Value::Tensor { values, shape } => Ok(Value::tensor(
2054 values.iter().map(|v| v + 1.0).collect(),
2055 shape.clone(),
2056 )),
2057 _ => Ok(x.clone()),
2058 }
2059 }
2060 fn meta(&self) -> FilterMeta {
2061 FilterMeta {
2062 name: "Salted".into(),
2063 kind: FilterKind::Stateless,
2064 cacheable: true,
2065 differentiable: true,
2066 deterministic: true,
2067 stream_mode: StreamMode::FixedState,
2068 distribution: somatize_core::filter::Distribution::Local,
2069 input_schema: None,
2070 output_schema: None,
2071 }
2072 }
2073 }
2074
2075 #[test]
2076 fn early_cutoff_downstream_hits_when_upstream_output_unchanged() {
2077 let a_forwards = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2082 let b_forwards = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2083 let cache = MemoryCache::default();
2084 let info = GraphInfo::for_linear(&["input", "a", "b"]);
2085 let plan = ExecutionPlan::Sequence(vec![
2086 ExecutionPlan::Execute {
2087 node_id: "a".into(),
2088 },
2089 ExecutionPlan::Execute {
2090 node_id: "b".into(),
2091 },
2092 ]);
2093
2094 let run = |salt: f64| {
2095 let mut filters = NodeCatalog::new();
2096 filters.register(
2097 "a",
2098 Box::new(SaltedFilter {
2099 salt,
2100 forwards: a_forwards.clone(),
2101 }),
2102 );
2103 filters.register(
2104 "b",
2105 Box::new(CountingFilter {
2106 forwards: b_forwards.clone(),
2107 cacheable: true,
2108 config: 2.0,
2109 }),
2110 );
2111 let bus = Arc::new(EventBus::new(64));
2112 let mut ctx = Context::new(bus, "run").with_graph_info(info.clone());
2113 ctx.set("input", Value::tensor(vec![1.0, 2.0], vec![2]));
2114 execute(&plan, &mut ctx, &filters, &cache).unwrap();
2115 };
2116
2117 run(1.0);
2118 assert_eq!(a_forwards.load(std::sync::atomic::Ordering::SeqCst), 1);
2119 assert_eq!(b_forwards.load(std::sync::atomic::Ordering::SeqCst), 1);
2120
2121 run(2.0);
2124 assert_eq!(
2125 a_forwards.load(std::sync::atomic::Ordering::SeqCst),
2126 2,
2127 "A's config changed, it must re-execute"
2128 );
2129 assert_eq!(
2130 b_forwards.load(std::sync::atomic::Ordering::SeqCst),
2131 1,
2132 "B's input content is unchanged — early cutoff must serve it from cache"
2133 );
2134 }
2135
2136 #[test]
2137 fn nondeterministic_filter_is_never_cached() {
2138 struct RandomishFilter {
2139 forwards: Arc<std::sync::atomic::AtomicUsize>,
2140 }
2141 impl Filter for RandomishFilter {
2142 fn config_hash(&self) -> CacheKey {
2143 CacheKey::from_parts(&[b"Randomish"])
2144 }
2145 fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
2146 Ok(Value::Empty)
2147 }
2148 fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
2149 self.forwards
2150 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2151 Ok(x.clone())
2152 }
2153 fn meta(&self) -> FilterMeta {
2154 FilterMeta {
2155 name: "Randomish".into(),
2156 kind: FilterKind::Stateless,
2157 cacheable: true,
2158 differentiable: false,
2159 deterministic: false, stream_mode: StreamMode::FixedState,
2161 distribution: somatize_core::filter::Distribution::Local,
2162 input_schema: None,
2163 output_schema: None,
2164 }
2165 }
2166 }
2167
2168 let forwards = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2169 let mut filters = NodeCatalog::new();
2170 filters.register(
2171 "rng",
2172 Box::new(RandomishFilter {
2173 forwards: forwards.clone(),
2174 }),
2175 );
2176 let cache = MemoryCache::default();
2177 let info = GraphInfo::for_linear(&["input", "rng"]);
2178 for _ in 0..2 {
2179 let bus = Arc::new(EventBus::new(64));
2180 let mut ctx = Context::new(bus, "run").with_graph_info(info.clone());
2181 ctx.set("input", Value::tensor(vec![1.0], vec![1]));
2182 execute(
2183 &ExecutionPlan::Execute {
2184 node_id: "rng".into(),
2185 },
2186 &mut ctx,
2187 &filters,
2188 &cache,
2189 )
2190 .unwrap();
2191 }
2192 assert_eq!(
2193 forwards.load(std::sync::atomic::Ordering::SeqCst),
2194 2,
2195 "a filter declared nondeterministic must run every time"
2196 );
2197 }
2198
2199 #[test]
2200 fn execute_stream_single_chunk() {
2201 let (bus, cache) = setup();
2202 let mut ctx = Context::new(bus, "run_stream_single");
2203 ctx.set("__input__", Value::tensor(vec![5.0, 10.0], vec![2]));
2204 ctx.graph_info
2205 .set_predecessors("double", vec!["__input__".into()]);
2206
2207 let mut filters = NodeCatalog::new();
2208 filters.register("double", Box::new(DoublerFilter));
2209
2210 let plan = ExecutionPlan::Stream {
2212 node_ids: vec!["double".into()],
2213 chunk_size: 1000,
2214 };
2215
2216 execute(&plan, &mut ctx, &filters, &cache).unwrap();
2217
2218 let result = ctx.get("double").unwrap();
2219 let (data, _) = result.as_tensor().unwrap();
2220 assert_eq!(data, &[10.0, 20.0]);
2221 }
2222
2223 struct Tripwire {
2225 at: f64,
2226 }
2227 impl Filter for Tripwire {
2228 fn config_hash(&self) -> CacheKey {
2229 CacheKey::from_parts(&[b"Tripwire", &self.at.to_le_bytes()])
2230 }
2231 fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
2232 Ok(Value::Empty)
2233 }
2234 fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
2235 if let Value::Tensor { values, .. } = x
2236 && values.iter().any(|v| *v >= self.at)
2237 {
2238 return Err(SomaError::Other(format!("tripped at {}", self.at)));
2239 }
2240 Ok(x.clone())
2241 }
2242 fn meta(&self) -> FilterMeta {
2243 DoublerFilter.meta()
2244 }
2245 }
2246
2247 fn stream_events(
2248 rx: &mut tokio::sync::broadcast::Receiver<Event>,
2249 ) -> Vec<(String, &'static str)> {
2250 let mut seen = Vec::new();
2251 while let Ok(event) = rx.try_recv() {
2252 match event {
2253 Event::NodeStarted { node_id, .. } => seen.push((node_id, "started")),
2254 Event::NodeCompleted { node_id, .. } => seen.push((node_id, "completed")),
2255 Event::NodeFailed { node_id, .. } => seen.push((node_id, "failed")),
2256 _ => {}
2257 }
2258 }
2259 seen
2260 }
2261
2262 #[test]
2266 fn stream_emits_one_bracket_per_node() {
2267 let (bus, cache) = setup();
2268 let mut rx = bus.subscribe();
2269 let mut ctx = Context::new(bus, "run_stream_events");
2270 ctx.set(
2271 "__input__",
2272 Value::tensor(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![6]),
2273 );
2274 ctx.graph_info
2275 .set_predecessors("double", vec!["__input__".into()]);
2276
2277 let mut filters = NodeCatalog::new();
2278 filters.register("double", Box::new(DoublerFilter));
2279 filters.register("add", Box::new(AdderFilter { amount: 1.0 }));
2280
2281 let plan = ExecutionPlan::Stream {
2282 node_ids: vec!["double".into(), "add".into()],
2283 chunk_size: 2,
2284 };
2285 execute(&plan, &mut ctx, &filters, &cache).unwrap();
2286
2287 let seen = stream_events(&mut rx);
2288 for node in ["double", "add"] {
2289 assert_eq!(
2290 seen.iter()
2291 .filter(|(id, kind)| id == node && *kind == "started")
2292 .count(),
2293 1,
2294 "{node}: exactly one NodeStarted, got {seen:?}"
2295 );
2296 assert_eq!(
2297 seen.iter()
2298 .filter(|(id, kind)| id == node && *kind == "completed")
2299 .count(),
2300 1,
2301 "{node}: exactly one NodeCompleted, got {seen:?}"
2302 );
2303 }
2304 assert!(
2305 seen.iter().all(|(id, _)| id == "double" || id == "add"),
2306 "no made-up node ids: {seen:?}"
2307 );
2308 }
2309
2310 #[test]
2314 fn stream_node_failed_names_the_chunk() {
2315 let (bus, cache) = setup();
2316 let mut rx = bus.subscribe();
2317 let mut ctx = Context::new(bus, "run_stream_fail");
2318 ctx.set("__input__", Value::tensor(vec![1.0, 3.0], vec![2]));
2319 ctx.graph_info
2320 .set_predecessors("double", vec!["__input__".into()]);
2321
2322 let mut filters = NodeCatalog::new();
2323 filters.register("double", Box::new(DoublerFilter));
2324 filters.register("trip", Box::new(Tripwire { at: 5.0 }));
2326
2327 let plan = ExecutionPlan::Stream {
2328 node_ids: vec!["double".into(), "trip".into()],
2329 chunk_size: 1,
2330 };
2331 let err = execute(&plan, &mut ctx, &filters, &cache).unwrap_err();
2332 assert!(err.to_string().contains("tripped"), "{err}");
2333
2334 let mut failed = None;
2335 let mut double_completed = false;
2336 while let Ok(event) = rx.try_recv() {
2337 match event {
2338 Event::NodeFailed { node_id, error, .. } => failed = Some((node_id, error)),
2339 Event::NodeCompleted { node_id, .. } if node_id == "double" => {
2340 double_completed = true;
2341 }
2342 _ => {}
2343 }
2344 }
2345 let (node_id, error) = failed.expect("no NodeFailed was emitted");
2346 assert_eq!(node_id, "trip");
2347 assert!(error.contains("chunk 1"), "should name the chunk: {error}");
2348 assert!(
2349 !double_completed,
2350 "the upstream span must stay open: the run died mid-node"
2351 );
2352 }
2353
2354 #[test]
2357 fn stream_and_standard_share_one_cache_line() {
2358 let (bus, cache) = setup();
2359 let input = Value::tensor(vec![1.0, 2.0], vec![2]);
2360
2361 let mut ctx = Context::new(bus.clone(), "run_standard");
2362 ctx.set("__input__", input.clone());
2363 ctx.graph_info
2364 .set_predecessors("double", vec!["__input__".into()]);
2365 let mut filters = NodeCatalog::new();
2366 filters.register("double", Box::new(DoublerFilter));
2367 let standard = ExecutionPlan::Execute {
2368 node_id: "double".into(),
2369 };
2370 execute(&standard, &mut ctx, &filters, &cache).unwrap();
2371 assert_eq!(cache.len(), 1);
2372
2373 let mut rx = bus.subscribe();
2374 let mut ctx2 = Context::new(bus, "run_streamed");
2375 ctx2.set("__input__", input);
2376 ctx2.graph_info
2377 .set_predecessors("double", vec!["__input__".into()]);
2378 let streamed = ExecutionPlan::Stream {
2379 node_ids: vec!["double".into()],
2380 chunk_size: 1000, };
2382 execute(&streamed, &mut ctx2, &filters, &cache).unwrap();
2383
2384 assert_eq!(
2385 cache.len(),
2386 1,
2387 "the stream must read the standard path's line, not mint its own"
2388 );
2389 let mut completed_summary = String::new();
2390 while let Ok(event) = rx.try_recv() {
2391 if let Event::NodeCompleted {
2392 node_id,
2393 output_summary,
2394 ..
2395 } = event
2396 && node_id == "double"
2397 {
2398 completed_summary = output_summary;
2399 }
2400 }
2401 assert!(
2402 completed_summary.contains("1 hits"),
2403 "the chunk should have been a cache hit: {completed_summary}"
2404 );
2405 }
2406
2407 #[test]
2411 fn stream_events_match_standard_for_fixed_chains() {
2412 let run = |streamed: bool| -> Vec<(String, &'static str)> {
2413 let (bus, cache) = setup();
2414 let mut rx = bus.subscribe();
2415 let mut ctx = Context::new(bus, "run_compare");
2416 ctx.set("__input__", Value::tensor(vec![1.0, 2.0], vec![2]));
2417 ctx.graph_info
2418 .set_predecessors("double", vec!["__input__".into()]);
2419 ctx.graph_info
2420 .set_predecessors("add", vec!["double".into()]);
2421 let mut filters = NodeCatalog::new();
2422 filters.register("double", Box::new(DoublerFilter));
2423 filters.register("add", Box::new(AdderFilter { amount: 1.0 }));
2424 let plan = if streamed {
2425 ExecutionPlan::Stream {
2426 node_ids: vec!["double".into(), "add".into()],
2427 chunk_size: 1,
2428 }
2429 } else {
2430 ExecutionPlan::Sequence(vec![
2431 ExecutionPlan::Execute {
2432 node_id: "double".into(),
2433 },
2434 ExecutionPlan::Execute {
2435 node_id: "add".into(),
2436 },
2437 ])
2438 };
2439 execute(&plan, &mut ctx, &filters, &cache).unwrap();
2440 let mut seen = stream_events(&mut rx);
2441 seen.sort();
2442 seen
2443 };
2444
2445 assert_eq!(
2446 run(false),
2447 run(true),
2448 "same nodes, same brackets, whichever path executed them"
2449 );
2450 }
2451
2452 #[test]
2455 fn stream_refuses_fit_mode() {
2456 let (bus, cache) = setup();
2457 let mut ctx = Context::new(bus, "run_stream_fit");
2458 ctx.mode = RunMode::Fit { y: None };
2459 ctx.set("__input__", Value::tensor(vec![1.0], vec![1]));
2460 ctx.graph_info
2461 .set_predecessors("double", vec!["__input__".into()]);
2462 let mut filters = NodeCatalog::new();
2463 filters.register("double", Box::new(DoublerFilter));
2464
2465 let plan = ExecutionPlan::Stream {
2466 node_ids: vec!["double".into()],
2467 chunk_size: 2,
2468 };
2469 let err = execute(&plan, &mut ctx, &filters, &cache).unwrap_err();
2470 assert!(err.to_string().contains("fit"), "{err}");
2471 }
2472}