Skip to main content

somatize_runtime/
executor.rs

1//! Plan executor — walks [`ExecutionPlan`] trees and runs filter nodes.
2//!
3//! Handles sequential, parallel (scoped threads), cached, remote, loop,
4//! and branch execution. Uses [`GraphInfo`] for topology-aware input resolution.
5
6use 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/// Graph topology information for input resolution.
24///
25/// Maps each node to its predecessor node IDs so the executor knows
26/// where to read inputs from in the context store.
27#[derive(Debug, Clone, Default)]
28pub struct GraphInfo {
29    /// node_id → list of predecessor node IDs
30    predecessors: HashMap<String, Vec<String>>,
31}
32
33impl GraphInfo {
34    /// An empty topology; every node resolves to no predecessors until
35    /// [`Self::set_predecessors`] says otherwise.
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Register predecessors for a node.
41    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    /// Build GraphInfo from a somatize_core::graph::Graph.
46    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    /// Build GraphInfo for a linear pipeline (each node depends on the previous).
60    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    /// Get predecessors for a node.
74    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/// What a run does to the nodes it visits.
83///
84/// Fitting and forwarding differ in exactly one way — whether a trainable
85/// node learns its state before it computes — and in nothing else. They
86/// used to be two whole execution loops: `run`/`forward` walked the plan
87/// through `run_node`, while `fit` had a second, filter-only walk that
88/// flattened the plan and re-implemented input resolution, events, caching
89/// and panic handling. Making the difference a *value* is what lets both
90/// go through one site.
91#[derive(Clone, Debug, Default)]
92pub enum RunMode {
93    /// Every node computes with the state it already has.
94    #[default]
95    Forward,
96    /// A trainable node learns its state from its resolved input and these
97    /// labels, then computes with it. Labels are shared by the whole run;
98    /// `None` means unsupervised.
99    Fit {
100        /// The run's labels; `None` means unsupervised.
101        y: Option<Value>,
102    },
103}
104
105impl RunMode {
106    /// The labels, if this is a fit.
107    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
119/// Execution context passed to filters during runtime.
120///
121/// Node outputs are stored as [`VirtualValue`]s — they may be materialized
122/// in memory, cached on disk, or deferred (not yet computed). The executor
123/// resolves them on demand when a downstream node needs the data.
124pub struct Context {
125    /// Fit or forward. See [`RunMode`].
126    pub mode: RunMode,
127    /// Node outputs as virtual values (may be lazy).
128    ///
129    /// Private, together with `execution_order`: the two are a pair.
130    /// `execute_parallel` works out what a branch contributed by diffing
131    /// `execution_order`, so a write that reached one and not the other
132    /// is silently dropped at the join. Going through [`Context::set`]
133    /// and [`Context::set_virtual`] is what keeps them in step.
134    store: HashMap<String, VirtualValue>,
135    /// Event bus for emitting runtime events.
136    pub event_bus: Arc<EventBus>,
137    /// Current run ID.
138    pub run_id: String,
139    /// Track execution order. Private for the reason above.
140    execution_order: Vec<String>,
141    /// Graph topology for input resolution.
142    pub graph_info: GraphInfo,
143    /// Optional transport for distributed plans.
144    pub transport: Option<Arc<dyn crate::runner::Transport>>,
145    /// Optional data store for persisting intermediate results.
146    pub data_store: Option<Arc<dyn DataStore>>,
147    /// Minimum value size (bytes) to spill to DataStore instead of keeping in memory.
148    /// Default: 0 (disabled — all values stay in memory).
149    pub spill_threshold: usize,
150    /// Memoized content hashes of node outputs, keyed by node id.
151    /// Invalidated whenever a node's output is (re)stored, so Loop
152    /// iterations that overwrite an output never reuse a stale hash.
153    output_hashes: HashMap<String, somatize_core::cache::CacheKey>,
154    /// Experiment seed for this run. Hashed into every cache key so
155    /// each seed owns an independent cache line (a 5-seed study is 5
156    /// resumable computations, not one).
157    pub seed: Option<i64>,
158    /// Performs and journals step effects. Only needed when the plan
159    /// contains a step; a purely computational graph leaves it unset.
160    ///
161    /// The steps themselves are not here: they live in the same
162    /// [`NodeCatalog`] as the filters,
163    /// which the executor already receives. Keeping a second registry in
164    /// the context is what let the branch arm decide a node's kind by
165    /// asking whether it happened to be in it.
166    pub driver: Option<crate::effects::EffectDriver>,
167}
168
169impl Context {
170    /// A forward-mode context with empty topology and no optional
171    /// components; the `with_*` builders add what the run needs.
172    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    /// Register the effect driver an effectful plan needs.
190    ///
191    /// The driver should already carry its catalog
192    /// ([`crate::effects::EffectDriver::with_catalog`]) if a step may fan
193    /// out dynamically — whoever builds the driver knows which catalog it
194    /// serves; the context does not.
195    pub fn with_driver(mut self, driver: crate::effects::EffectDriver) -> Self {
196        self.driver = Some(driver);
197        self
198    }
199
200    /// Set the topology used for input resolution.
201    pub fn with_graph_info(mut self, info: GraphInfo) -> Self {
202        self.graph_info = info;
203        self
204    }
205
206    /// Make this a fit: trainable nodes learn from `y` before computing.
207    pub fn fitting(mut self, y: Option<Value>) -> Self {
208        self.mode = RunMode::Fit { y };
209        self
210    }
211
212    /// Record a state a node just learned.
213    ///
214    /// Stored under the same `__state_{id}` key the worker and the session
215    /// already read, and appended to `execution_order` like any other
216    /// write: that list is how `execute_parallel` works out what a branch
217    /// contributed, so a state written inside a branch that skipped it
218    /// would be dropped at the join. Readers asking "which node ran last"
219    /// filter reserved keys out — see [`somatize_core::keys::is_reserved`].
220    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    /// Set the experiment seed (hashed into every cache key).
225    pub fn with_seed(mut self, seed: Option<i64>) -> Self {
226        self.seed = seed;
227        self
228    }
229
230    /// Set the transport a plan with `Remote` nodes executes through.
231    pub fn with_transport(mut self, transport: Arc<dyn crate::runner::Transport>) -> Self {
232        self.transport = Some(transport);
233        self
234    }
235
236    /// Set the data store used for spilling and remote data movement.
237    pub fn with_data_store(mut self, store: Arc<dyn DataStore>) -> Self {
238        self.data_store = Some(store);
239        self
240    }
241
242    /// Set spill threshold: values larger than this (in bytes) are offloaded
243    /// to the DataStore and replaced with a VirtualValue::Cached reference.
244    /// Requires a DataStore to be set via `with_data_store()`.
245    pub fn with_spill_threshold(mut self, bytes: usize) -> Self {
246        self.spill_threshold = bytes;
247        self
248    }
249
250    /// If a DataStore and spill threshold are configured, check if the value
251    /// should be offloaded. Returns VirtualValue (materialized or cached ref).
252    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; // approximate bytes (f64 = 8 bytes)
257            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    /// The nodes that ran, in the order they ran.
274    ///
275    /// Includes the run's reserved keys (see [`somatize_core::keys`]);
276    /// filter them out with `keys::is_reserved` if you want node ids only.
277    pub fn execution_order(&self) -> &[String] {
278        &self.execution_order
279    }
280
281    /// Every materialized value this run produced, keyed by node id.
282    ///
283    /// Consumes the context, because the point of asking is that the run
284    /// is over. Lazy values that were never resolved are skipped.
285    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    /// Get the materialized Value for a node, if present and materialized.
293    pub fn get(&self, node_id: &str) -> Option<&Value> {
294        self.store.get(node_id).and_then(|vv| vv.as_value())
295    }
296
297    /// Get the raw VirtualValue for a node.
298    pub fn get_virtual(&self, node_id: &str) -> Option<&VirtualValue> {
299        self.store.get(node_id)
300    }
301
302    /// Store a materialized value for a node.
303    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    /// Store a virtual value (which may be deferred or cached).
311    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    /// Content hash of a node's resolved input, memoized through the
319    /// single-predecessor fast path: the input of a 1-pred node IS that
320    /// predecessor's output, so sibling consumers (diamonds) reuse the
321    /// hash instead of re-serializing a potentially large value.
322    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            // Only trust the memo association when the pred's output is
333            // actually what resolve_input handed us (it may have fallen
334            // back to Value::Empty if the pred produced nothing).
335            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
362/// Execute a compiled plan.
363///
364/// Every arm delegates. The variants that need real work — a node, a loop,
365/// a branch, a fan-out — each have a function, so this reads as the list of
366/// things a plan can be rather than as their implementations.
367pub 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        // One call for both: a filter simply declares no handoffs.
377        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            // A composite block exists so that a set of differentiable
421            // filters can be fitted together in one process, with tensors
422            // passed directly and autograd intact. That is a property of
423            // the *block*, not of any node in it, which is why it is
424            // handled here and not inside `run_node`.
425            if ctx.mode.is_fit() && composite_fit(node_ids, ctx, catalog)? {
426                return Ok(());
427            }
428            // Otherwise, and always when forwarding: each node in order.
429            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        // `ExecutionPlan` is `#[non_exhaustive]`, so this arm is reachable
441        // from a plan built by a newer compiler — deserialized from a
442        // worker, say. It used to `warn!` and return `Ok(())`: a plan the
443        // runtime did not understand was reported as having run, naming
444        // neither the variant nor the node.
445        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/// Iterate `body` until the condition says stop, or the count runs out.
459#[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    // The loop node's value is its *carry*: what the body reads on each
474    // pass. A body entry's only predecessor is the loop node itself (that
475    // control edge is what makes it the body), so without seeding this the
476    // first iteration would run on `Empty`. Seed it with the loop's own
477    // input; after each iteration the condition node's output replaces it,
478    // which is what makes a refine loop actually refine rather than
479    // redraft the same thing N times.
480    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        // Advance the carry before testing the condition: even a loop with
488        // no stop signal has to move forward, or every pass repeats the
489        // first one.
490        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        // Termination is read from the node the compiler resolved, never
497        // from whichever node happened to run last — with a parallel body
498        // "last" is a race.
499        let LoopCondition::WhenSignaled(cond_node) = until else {
500            continue; // Exhaust: always run the full count
501        };
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
530/// Run the condition node, then the one arm it names.
531fn execute_branch(
532    node_id: &str,
533    arms: &[(String, ExecutionPlan)],
534    ctx: &mut Context,
535    catalog: &NodeCatalog,
536    cache: &dyn CacheStore,
537) -> Result<()> {
538    // The condition node may be an ordinary filter or an effectful step:
539    // an LLM deciding where a request goes is the routing case agentic
540    // graphs are built for. Which it is no longer needs asking — one call
541    // runs either, and the outcome says how the arm was chosen.
542    let request = resolve_input(node_id, ctx);
543
544    let selector = match run_node(node_id, ctx, catalog, cache)? {
545        // A routing step names its arm directly. This used to be
546        // unreachable: the branch called the step with no handoffs — its
547        // control edges having been consumed as the branch's arms — so a
548        // `Goto` always errored.
549        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    // The selector is control, not data. An arm's only predecessor is the
591    // branch node, so leaving the label there would hand the chosen agent
592    // the string "billing" instead of the customer's question — the
593    // handoff-context loss the multi-agent failure literature keeps
594    // finding. The branch passes its input through instead; put a filter
595    // *before* it if the request needs transforming.
596    ctx.set(node_id.to_string(), request);
597    execute(plan, ctx, catalog, cache)
598}
599
600/// Hand a node to a worker, or run it here if there is no transport.
601fn 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
621/// A control-flow construct finishing. It has no duration of its own —
622/// the time is in the nodes it ran.
623fn 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
632/// Salt a cache key with the run's experiment seed, when set.
633/// `None` leaves the key untouched (and distinct from any seeded key).
634pub(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
644/// What a panic payload says, when it says anything at all.
645///
646/// `panic!("...")` with arguments produces a `String`; a bare literal
647/// produces a `&str`. Anything else — `panic_any` with a custom type —
648/// carries no message we can read.
649pub(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
657// ── The primitives every execution path shares ──
658//
659// `run_node` composes these for the topological walk; the stream driver
660// composes the same three per chunk. Anything that must be true of every
661// node execution — the memoization guard, the one key derivation, panic
662// containment, provenance on writes — lives here and nowhere else.
663
664/// The node's output key, or `None` when it must not be memoized.
665///
666/// The single owner of the `cacheable && deterministic` guard and of the
667/// derivation `hash(config + state + input)`, salted with the run seed.
668/// Nondeterministic forwards are excluded because serving a recorded
669/// result would silently freeze what the user expects to vary.
670pub(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
688/// Run the node's own computation, containing any panic.
689///
690/// The `catch_unwind` around [`run_node_inner`] — a panic in user code
691/// (a Python filter or step alike) must not crash the process.
692pub(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
715/// Store a computed output with its provenance. Best-effort: a full
716/// cache disk must never fail the run.
717pub(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
735/// Run one node and act on how it finished.
736///
737/// The single entry point for both kinds. Everything that does not depend
738/// on which kind ran — resolving the input, the output cache, containing a
739/// panic, the start/complete/fail events — happens once, in [`run_node`].
740/// What is left here is the control flow only a step can ask for.
741fn 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        // A handoff: the node is finished and names who continues.
752        NodeOutcome::HandOff { target, .. } => {
753            let plan = select_handoff(node_id, &target, handoffs)?;
754            execute(plan, ctx, catalog, cache)
755        }
756
757        // Not a failure: the run paused. It travels as an error so the
758        // rest of the plan does not execute, and carries everything the
759        // caller needs to answer and resume.
760        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
769/// Which sub-plan a handoff target names.
770fn 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
800/// Everything running a node involves that is the same for both kinds.
801///
802/// Cacheable nodes are memoized: the output key is
803/// `hash(config + state + input)` — if a previous run (possibly in another
804/// process, via a persistent cache) already computed this exact forward,
805/// the stored output is used and the node never runs.
806///
807/// A step never reaches that path, and not because of a check here: its
808/// [`NodeMeta`](somatize_core::node::NodeMeta) declares
809/// `cacheable: false`, so the guard below skips it the way it skips a
810/// filter that declared the same. What makes a step re-runnable instead is
811/// the effect journal, which the driver consults per effect.
812///
813/// The produced value — or a handoff's carry — is stored under `node_id`
814/// before returning, so a successor resolves it as an ordinary predecessor
815/// output.
816fn 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    // In a fit, a trainable node learns before it computes. Everything
835    // after this point is identical to a forward — which is the whole
836    // reason fit no longer needs a walk of its own.
837    let fitted = fit_state_if_needed(node_id, &node, &meta, &input, ctx, cache)?;
838
839    // Borrow state via Arc — cloning the inner Value here would deep-copy
840    // potentially huge tensors (encoder outputs, model weights) on every
841    // forward call. Arc::clone is a cheap atomic increment.
842    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    // Nondeterministic forwards are excluded by `output_key` — their fit
849    // STATES still cache: any recorded training result is acceptable,
850    // constructive-trace semantics.
851    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    // `get_located`, not `get`: which tier served the value is the whole
860    // content of this event, and hardcoding `Memory` made every per-tier
861    // statistic report the same thing.
862    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        // The carried value is stored under *this* node, so the target
930        // resolves it as an ordinary predecessor output — no special path.
931        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        // Nothing to store: the node did not finish.
942        NodeOutcome::Paused { .. } => {}
943    }
944
945    Ok(outcome)
946}
947
948/// Fit a whole composite block through the first filter's `composite_fit`.
949///
950/// `Ok(false)` means the block was not fitted as a block — a node is
951/// missing, or the filter declined — and the caller runs the nodes one by
952/// one instead. `Ok(true)` means the results are already stored.
953fn 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    // A Composite block is built by the compiler from differentiable
958    // filters. A step inside one is a broken plan, and falling back to
959    // one-by-one execution would paper over it.
960    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
992/// Learn this node's state, if the run is a fit and the node is trainable.
993///
994/// Returns the fitted state, or `None` when there is nothing to fit — a
995/// forward run, a stateless or library-state filter, or a step (a step's
996/// re-run semantics belong to the journal, not to a state cache).
997///
998/// The state cache key includes the labels on purpose: the same features
999/// trained against different labels must not collide, and it is salted with
1000/// the run seed so a 5-seed study is five independent computations rather
1001/// than one recorded five times.
1002fn 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    // The metadata already said "trainable", which a step's meta never
1014    // does — this extraction is structural, not a second decision.
1015    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
1057/// The only place in the runtime that knows a filter from a step.
1058fn 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
1080/// Execute parallel branches concurrently using std::thread::scope.
1081///
1082/// Each branch gets a snapshot of the context. After all branches complete,
1083/// their new outputs are merged back into the main context.
1084fn execute_parallel(
1085    branches: &[ExecutionPlan],
1086    ctx: &mut Context,
1087    catalog: &NodeCatalog,
1088    cache: &dyn CacheStore,
1089) -> Result<()> {
1090    // What a branch contributes is what it *wrote*, not what happens to be
1091    // absent from the parent. Those differ the second time a parallel block
1092    // runs: inside a `Loop`, every body node already has a value from the
1093    // previous iteration, so filtering by "not already present" discarded
1094    // every fresh result and left downstream fan-in reading iteration one
1095    // forever — the nodes re-ran and their new outputs went nowhere.
1096    //
1097    // `execution_order` is appended to by every `set`/`set_virtual`, and a
1098    // snapshot copies it, so whatever a branch appended past this mark is
1099    // exactly its write set.
1100    let order_mark = ctx.execution_order.len();
1101
1102    // Use scoped threads for true parallelism without Send requirements
1103    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        // A branch thread that panicked comes back as an Err from `join`.
1122        // Unwrapping it here re-panics on the *parent* thread, which
1123        // aborts the process and undoes the `catch_unwind` that
1124        // `execute_node` installs precisely so a user filter cannot.
1125        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    // Merge results and propagate first error
1142    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
1152/// Resolve a VirtualValue to a concrete Value, loading from DataStore if needed.
1153fn 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            // Try to load from DataStore
1158            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
1171/// Resolve the input for a node from the context store using graph topology.
1172/// If a predecessor was spilled to DataStore, loads it back.
1173pub(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
1202/// Execute a stream plan: chunk the input and drive it through
1203/// [`StreamRun`](crate::executors::stream), which runs every chunk of
1204/// every node through the same primitives `run_node` composes. Events
1205/// are per node — `NodeStarted` at the first chunk, `NodeCompleted`
1206/// after the flush with an aggregated summary, a real `NodeFailed` on
1207/// error — so a stream run reads back like any other run.
1208fn 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    // Streaming has no training semantics; leaving this undefined would
1218    // silently skip every fit. Nothing invokes it today — keep it that
1219    // way explicitly.
1220    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    // Resolve input from the first node's predecessors.
1230    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    // Chunk the input along the first tensor dimension.
1236    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    // Incremental concatenation — bounded memory, see `StreamOutput`.
1242    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    // Flush barrier filters.
1252    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
1263/// Split a Value::Tensor along the first dimension into chunks.
1264fn 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    /// Panics in `meta()`, which — unlike `forward()` — runs outside the
1297    /// `catch_unwind` in `execute_node`, so the unwind reaches the thread
1298    /// boundary.
1299    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    /// `execute_parallel` used to `join().unwrap()`, which re-raises a
1317    /// branch's panic on the parent thread. Inside `std::thread::scope`
1318    /// that aborts the process — so a panic the runtime deliberately
1319    /// contains everywhere else took the whole host down when it happened
1320    /// in a parallel branch.
1321    #[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        // Keep the default hook from printing the backtrace this test
1342        // provokes on purpose.
1343        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    /// Slow filter that sleeps to verify parallelism.
1424    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        // Cacheable node, cold cache: miss → started → completed.
1535        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        // Running the same parallel block twice must leave the *second*
1571        // results in the context. Merging by "keys the parent lacks" passed
1572        // the first pass and silently dropped every later one, so anything
1573        // downstream of a parallel body inside a `Loop` read iteration one
1574        // forever while the nodes dutifully re-ran.
1575        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        // Same plan, new input — as a second loop iteration would.
1599        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        // If truly parallel: ~200ms. If sequential: ~400ms. The wide
1680        // margin keeps the discrimination robust on loaded CI runners.
1681        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        // 6-element input, chunk_size=2 → 3 chunks
1742        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        // double → add: [1,2,3,4] → [2,4,6,8] → [12,14,16,18]
1790        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    /// Counts forward() invocations — the probe for cache-hit tests.
1797    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        // "Restart": a fresh cache instance over the same directory.
1924        {
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); // spill everything
2009        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        // `a`'s output was spilled; `b` must still have received it:
2023        // input + 1.0 + 2.0 = [4.0, 5.0]
2024        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    /// Config carries a salt that does NOT affect the output — models a
2036    /// cosmetic/irrelevant config change upstream.
2037    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        // Downstream keys derive from input CONTENT hashes, not from
2078        // upstream provenance — so a config change in A that produces
2079        // identical bytes must not invalidate B (rustc/salsa-style
2080        // early cutoff; impossible under the old deep-provenance keys).
2081        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        // A's config changed (new salt) → A re-executes. Its output is
2122        // byte-identical, so B must hit.
2123        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, // declared nondeterministic
2160                    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        // chunk_size larger than input → single chunk
2211        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    /// Fails on any chunk containing a value >= its threshold.
2224    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    /// T1: one bracket per NODE, not one per plan and not one per chunk.
2263    /// Three chunks through two nodes is exactly two started/completed
2264    /// pairs, both under real node ids.
2265    #[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    /// T2: the failing node emits a real NodeFailed naming the chunk;
2311    /// the upstream node's span stays open — it died mid-node, which is
2312    /// literally true.
2313    #[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        // Doubling 3.0 -> 6.0 trips the wire on the second chunk.
2325        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    /// T6: the derivation is shared, so a single-chunk stream and the
2355    /// standard path land on ONE cache line — the second is a hit.
2356    #[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, // single chunk == the standard input
2381        };
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    /// T11: for a plain FixedState chain, the stream path's event set is
2408    /// the standard path's, modulo the per-chunk hit/miss events the
2409    /// stream deliberately aggregates.
2410    #[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    /// D10: a stream plan in fit mode is an explicit error, not an
2453    /// undefined skip of every fit.
2454    #[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}