Skip to main content

somatize_runtime/
graph_session.rs

1//! Graph session — the primary orchestrator for Graph → Compile → Execute.
2//!
3//! [`GraphSession`] binds a [`Graph`] with its [`NodeCatalog`], cache,
4//! event bus, and optional distributed components into a single object
5//! that can compile, fit, and execute.
6
7use crate::cache::MemoryCache;
8use crate::event_bus::EventBus;
9use crate::executor::{self, Context, GraphInfo};
10use crate::node_catalog::NodeCatalog;
11use crate::runner::Runner;
12use crate::runner::Transport;
13use crate::strategy::StrategyExecutor;
14use somatize_compiler::{CompileMode, CompileResult, compile};
15use somatize_core::cache::{CacheKey, CacheStore};
16use somatize_core::error::{Result, SomaError};
17use somatize_core::event::Event;
18use somatize_core::fingerprint::ArchitectureFingerprint;
19use somatize_core::graph::Graph;
20use somatize_core::store::{DataRef, DataStore};
21use somatize_core::strategy::TrainingStrategy;
22use somatize_core::util::timestamp_id;
23use somatize_core::value::Value;
24use std::collections::HashMap;
25use std::sync::Arc;
26
27/// The primary orchestrator: Graph + catalog + cache + events.
28///
29/// ```ignore
30/// let mut lib = NodeCatalog::new();
31/// lib.register("scaler", Box::new(MyScaler::new()));
32/// lib.register("model", Box::new(MyModel::new()));
33///
34/// let mut session = GraphSession::new(graph, lib);
35/// session.fit(&train_x, Some(&train_y))?;
36/// let output = session.forward(&test_x)?;
37/// ```
38pub struct GraphSession {
39    graph: Graph,
40    catalog: NodeCatalog,
41    cache: Arc<dyn CacheStore>,
42    event_bus: Arc<EventBus>,
43    data_store: Option<Arc<dyn DataStore>>,
44    transport: Option<Arc<dyn Transport>>,
45    /// One transport per worker, for a graph carrying a `TrainingStrategy`.
46    /// Separate from `transport` because a strategy indexes its workers —
47    /// `execute_on_worker(i, …)` — and a single transport cannot answer
48    /// that. Empty unless [`with_transports`](Self::with_transports) is
49    /// used.
50    transports: Vec<Arc<dyn Transport>>,
51    /// Who each transport talks to, in transport order. Only model
52    /// parallelism needs it — a partition is pinned to a worker by id or
53    /// tag, where every other strategy treats workers as interchangeable.
54    worker_identities: Vec<crate::strategy::WorkerIdentity>,
55    /// Performs and journals step effects. Only needed when the graph
56    /// contains a step; a purely computational graph leaves it unset and
57    /// keeps exactly the old behaviour.
58    driver: Option<crate::effects::EffectDriver>,
59    fitted: bool,
60}
61
62impl GraphSession {
63    /// A session over `graph` with an in-memory cache and its own event
64    /// bus; the `with_*` builders swap in shared or persistent components.
65    pub fn new(graph: Graph, catalog: NodeCatalog) -> Self {
66        Self {
67            graph,
68            catalog,
69            cache: Arc::new(MemoryCache::default()),
70            event_bus: Arc::new(EventBus::new(256)),
71            data_store: None,
72            transport: None,
73            transports: Vec::new(),
74            worker_identities: Vec::new(),
75            driver: None,
76            fitted: false,
77        }
78    }
79
80    /// Replace the default in-memory cache, e.g. with a tiered or
81    /// persistent store shared across sessions.
82    pub fn with_cache(mut self, cache: Arc<dyn CacheStore>) -> Self {
83        self.cache = cache;
84        self
85    }
86
87    /// Replace the session's own event bus, e.g. with one a tracker
88    /// is already subscribed to.
89    pub fn with_event_bus(mut self, bus: Arc<EventBus>) -> Self {
90        self.event_bus = bus;
91        self
92    }
93
94    /// Attach the data store batched forward passes read rows from.
95    pub fn with_data_store(mut self, store: Arc<dyn DataStore>) -> Self {
96        self.data_store = Some(store);
97        self
98    }
99
100    /// Attach one transport per worker, so a `TrainingStrategy` can run.
101    ///
102    /// Without this, setting a strategy on a graph records it and nothing
103    /// more — which is what it did for the whole life of the type. `fit`
104    /// consults the graph's strategy and, when it is not `Local` and
105    /// transports are present, hands execution to
106    /// [`StrategyExecutor`].
107    ///
108    /// [`StrategyExecutor`]: crate::strategy::StrategyExecutor
109    pub fn with_transports(mut self, transports: Vec<Arc<dyn Transport>>) -> Self {
110        self.transports = transports;
111        self
112    }
113
114    /// Name the workers behind the transports, in the same order.
115    ///
116    /// Needed only by `ModelParallel`, whose partitions are pinned to a
117    /// worker id or tag. Without it that strategy refuses rather than
118    /// sending a partition to whichever worker happened to be first.
119    pub fn with_worker_identities(
120        mut self,
121        identities: Vec<crate::strategy::WorkerIdentity>,
122    ) -> Self {
123        self.worker_identities = identities;
124        self
125    }
126
127    /// Attach the transport that carries `Remote` plan nodes to workers.
128    pub fn with_transport(mut self, transport: Arc<dyn Transport>) -> Self {
129        self.transport = Some(transport);
130        self
131    }
132
133    /// Attach the effect driver a graph containing steps needs.
134    ///
135    /// The session clones the driver per run and hands it the catalog *at
136    /// that moment*, so filters or steps registered through
137    /// [`Self::catalog_mut`] after this call still count. Without a driver,
138    /// executing a step keeps failing with the executor's own explanation.
139    pub fn with_driver(mut self, driver: crate::effects::EffectDriver) -> Self {
140        self.driver = Some(driver);
141        self
142    }
143
144    /// The stored driver, armed with the catalog as it stands right now.
145    fn run_driver(&self) -> Option<crate::effects::EffectDriver> {
146        self.driver
147            .as_ref()
148            .map(|d| d.clone().with_catalog(Arc::new(self.catalog.clone())))
149    }
150
151    // ── Core operations ──
152
153    /// Compile the graph and return diagnostics without executing.
154    pub fn compile(&self, mode: CompileMode) -> Result<CompileResult> {
155        compile(&self.graph, &self.catalog, mode, Some(self.cache.as_ref()))
156    }
157
158    /// Compile and execute the graph, returning all node outputs.
159    ///
160    /// Emits a `RunStarted`/`RunCompleted` (or `RunFailed`) bracket
161    /// around the node events so readers can compute total duration
162    /// and group the run.
163    pub fn run(&mut self, mode: CompileMode) -> Result<HashMap<String, Value>> {
164        let CompileResult { plan, diagnostics } =
165            compile(&self.graph, &self.catalog, mode, Some(self.cache.as_ref()))?;
166
167        for diag in &diagnostics {
168            tracing::warn!("compile diagnostic: {:?}", diag);
169        }
170
171        let graph_info = GraphInfo::from_graph(&self.graph);
172        let run_id = timestamp_id("graph_run");
173        let mut ctx =
174            Context::new(self.event_bus.clone(), run_id.clone()).with_graph_info(graph_info);
175
176        if let Some(store) = &self.data_store {
177            ctx = ctx.with_data_store(store.clone());
178        }
179        if let Some(transport) = &self.transport {
180            ctx = ctx.with_transport(transport.clone());
181        }
182        if let Some(driver) = self.run_driver() {
183            ctx = ctx.with_driver(driver);
184        }
185
186        self.event_bus.emit(Event::RunStarted {
187            run_id: run_id.clone(),
188            plan_summary: plan.summary(),
189        });
190        let start = std::time::Instant::now();
191        if let Err(e) = executor::execute(&plan, &mut ctx, &self.catalog, self.cache.as_ref()) {
192            self.event_bus.emit(Event::RunFailed {
193                run_id,
194                error: e.to_string(),
195            });
196            return Err(e);
197        }
198        self.event_bus.emit(Event::RunCompleted {
199            run_id,
200            duration: start.elapsed(),
201        });
202
203        Ok(ctx.into_outputs())
204    }
205
206    /// Fit all trainable filters in topological order.
207    /// Delegates to LocalRunner — same execution path as remote workers.
208    ///
209    /// Emits a `RunStarted`/`RunCompleted` (or `RunFailed`) bracket
210    /// tagged with the same run id as the node events inside it.
211    pub fn fit(&mut self, x: &Value, y: Option<&Value>) -> Result<HashMap<String, Value>> {
212        self.graph.validate()?;
213
214        let CompileResult { plan, .. } = compile(
215            &self.graph,
216            &self.catalog,
217            CompileMode::NoCache,
218            Some(self.cache.as_ref()),
219        )?;
220
221        let run_id = timestamp_id("fit");
222        self.event_bus.emit(Event::RunStarted {
223            run_id: run_id.clone(),
224            plan_summary: plan.summary(),
225        });
226        let start = std::time::Instant::now();
227
228        // A graph carrying a strategy trains through it, when there are
229        // workers to run it on. This branch is what the type was missing:
230        // `set_strategy` recorded an attribute nothing ever read.
231        let strategy = self.graph.effective_strategy().clone();
232        if !matches!(strategy, TrainingStrategy::Local) && !self.transports.is_empty() {
233            let node_ids: Vec<String> = plan.node_ids().into_iter().map(String::from).collect();
234            let strategy_ctx = crate::strategy::TransportContext::new(
235                self.transports.clone(),
236                &plan,
237                &self.catalog,
238                None,
239            )
240            .with_targets(self.worker_identities.clone());
241            let outcome = strategy.fit(&strategy_ctx, x, y, &node_ids);
242            return match outcome {
243                Ok(states) => {
244                    for (node_id, state) in &states {
245                        self.catalog.try_set_state(node_id.clone(), state.clone())?;
246                    }
247                    self.fitted = true;
248                    self.event_bus.emit(Event::RunCompleted {
249                        run_id,
250                        duration: start.elapsed(),
251                    });
252                    Ok(states)
253                }
254                Err(e) => {
255                    self.event_bus.emit(Event::RunFailed {
256                        run_id,
257                        error: e.to_string(),
258                    });
259                    Err(e)
260                }
261            };
262        }
263
264        let runner = crate::runner::LocalRunner;
265        let mut ctx = crate::runner::RunContext::new(
266            &self.catalog,
267            self.cache.as_ref(),
268            &self.event_bus,
269            &run_id,
270            GraphInfo::from_graph(&self.graph),
271        );
272        if let Some(driver) = self.run_driver() {
273            ctx = ctx.with_driver(driver);
274        }
275        let result = runner.fit(&plan, &ctx, x, y);
276        let (_last_output, mut all_outputs) = match result {
277            Ok(out) => {
278                self.event_bus.emit(Event::RunCompleted {
279                    run_id,
280                    duration: start.elapsed(),
281                });
282                out
283            }
284            Err(e) => {
285                self.event_bus.emit(Event::RunFailed {
286                    run_id,
287                    error: e.to_string(),
288                });
289                return Err(e);
290            }
291        };
292
293        // Store trained states from __state_ keys into NodeCatalog
294        for (key, value) in &all_outputs {
295            if let Some(node_id) = somatize_core::keys::node_of_state_key(key) {
296                self.catalog.try_set_state(node_id, value.clone())?;
297            }
298        }
299
300        // Remove __state_ keys from returned outputs (callers expect node IDs only)
301        all_outputs.retain(|k, _| somatize_core::keys::node_of_state_key(k).is_none());
302
303        self.fitted = true;
304        Ok(all_outputs)
305    }
306
307    /// Forward pass using the given strategy.
308    ///
309    /// Strategies define HOW data flows through the compiled graph:
310    /// - [`crate::forward::Standard`] — full input at once with inference caching (default)
311    /// - [`crate::forward::Stream`] — chunked input through StreamExecutor
312    /// - [`crate::forward::Batched`] — rows from DataStore, batch by batch
313    pub fn forward_with(
314        &self,
315        x: &Value,
316        strategy: &dyn crate::forward::ForwardStrategy,
317    ) -> Result<Value> {
318        let driver = self.run_driver();
319        strategy.forward(
320            &self.graph,
321            &crate::forward::ForwardEnv {
322                catalog: &self.catalog,
323                cache: self.cache.as_ref(),
324                event_bus: &self.event_bus,
325                data_store: self.data_store.as_ref(),
326                driver: driver.as_ref(),
327            },
328            x,
329        )
330    }
331
332    /// Standard forward pass (shortcut for `forward_with(x, &Standard)`).
333    pub fn forward(&self, x: &Value) -> Result<Value> {
334        self.forward_with(x, &crate::forward::Standard)
335    }
336
337    // ── State persistence ──
338
339    /// Persist all trained states to the data store.
340    pub fn persist_states(&self) -> Result<DataRef> {
341        let store = self
342            .data_store
343            .as_ref()
344            .ok_or_else(|| SomaError::Execution {
345                node_id: "session".into(),
346                message: "persist_states requires a data store".into(),
347            })?;
348
349        let sorted = self.graph.topological_sort()?;
350        let mut states_map = serde_json::Map::new();
351        for node_id in &sorted {
352            if let Some(state) = self.catalog.get_state(node_id) {
353                let json = serde_json::to_value(&*state)
354                    .map_err(|e| SomaError::Other(format!("state serialize: {e}")))?;
355                states_map.insert(node_id.to_string(), json);
356            }
357        }
358
359        let states_value = Value::json(serde_json::Value::Object(states_map));
360        let fingerprint = self.graph_config_hash()?;
361        let key = CacheKey::from_parts(&[b"graph_states", fingerprint.as_bytes()]);
362        store.put(&key, &states_value)
363    }
364
365    /// Load previously persisted states from a data store reference.
366    pub fn load_states(&mut self, data_ref: &DataRef) -> Result<()> {
367        let store = self
368            .data_store
369            .as_ref()
370            .ok_or_else(|| SomaError::Execution {
371                node_id: "session".into(),
372                message: "load_states requires a data store".into(),
373            })?;
374
375        let states_value = store.get(data_ref)?;
376        let states_json = states_value
377            .as_json()
378            .ok_or_else(|| SomaError::Other("persisted states must be JSON".into()))?;
379        let obj = states_json
380            .as_object()
381            .ok_or_else(|| SomaError::Other("persisted states must be a JSON object".into()))?;
382
383        for (node_id, json_val) in obj {
384            let value: Value = serde_json::from_value(json_val.clone())
385                .map_err(|e| SomaError::Other(format!("state deserialize: {e}")))?;
386            self.catalog.try_set_state(node_id.clone(), value)?;
387        }
388
389        self.fitted = true;
390        Ok(())
391    }
392
393    // ── Observability ──
394
395    /// Subscribe to execution events.
396    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Event> {
397        self.event_bus.subscribe()
398    }
399
400    /// Access the event bus directly.
401    pub fn event_bus(&self) -> &Arc<EventBus> {
402        &self.event_bus
403    }
404
405    /// Whether the session has been fitted.
406    pub fn is_fitted(&self) -> bool {
407        self.fitted
408    }
409
410    /// Access the graph.
411    pub fn graph(&self) -> &Graph {
412        &self.graph
413    }
414
415    /// Access the node catalog.
416    pub fn catalog(&self) -> &NodeCatalog {
417        &self.catalog
418    }
419
420    /// Mutable access to the node catalog (for registering nodes after creation).
421    pub fn catalog_mut(&mut self) -> &mut NodeCatalog {
422        &mut self.catalog
423    }
424
425    // ── Private helpers ──
426
427    /// The address under which this graph's trained states are persisted.
428    ///
429    /// It has to follow the graph's *shape*, not just its node names. The
430    /// previous form was `node_ids.join(",")`, so two graphs that shared
431    /// node ids but wired them differently — or configured them
432    /// differently — persisted to one address and read back each other's
433    /// states. [`ArchitectureFingerprint`] already computes exactly this,
434    /// canonically, for the experiment pool.
435    fn graph_config_hash(&self) -> Result<String> {
436        Ok(ArchitectureFingerprint::of(&self.graph)?.digest)
437    }
438}
439
440// ── Convenience free functions ──
441//
442// One-liners over [`GraphSession`]. They used to be separate
443// implementations, and `graph_fit` was the worst of them: a topological
444// loop written from scratch that never compiled a plan, so it ignored
445// parallelism, loops, branches and steps outright — and then discarded
446// every state it fitted instead of storing it. A graph that ran fine
447// through `GraphSession::fit` did something else here.
448
449/// Compile and execute a graph, returning all node outputs.
450pub fn graph_run(
451    graph: &Graph,
452    catalog: &NodeCatalog,
453    mode: CompileMode,
454    cache: Arc<dyn CacheStore>,
455) -> Result<HashMap<String, Value>> {
456    GraphSession::new(graph.clone(), catalog.clone())
457        .with_cache(cache)
458        .run(mode)
459}
460
461/// Fit all trainable filters, returning every node's output.
462pub fn graph_fit(
463    graph: &Graph,
464    catalog: &NodeCatalog,
465    x: &Value,
466    y: Option<&Value>,
467    cache: Arc<dyn CacheStore>,
468) -> Result<HashMap<String, Value>> {
469    GraphSession::new(graph.clone(), catalog.clone())
470        .with_cache(cache)
471        .fit(x, y)
472}
473
474/// Compile in Inference mode and execute, returning the output.
475pub fn graph_predict(
476    graph: &Graph,
477    catalog: &NodeCatalog,
478    x: &Value,
479    cache: Arc<dyn CacheStore>,
480) -> Result<Value> {
481    GraphSession::new(graph.clone(), catalog.clone())
482        .with_cache(cache)
483        .forward(x)
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::cache::MemoryCache;
490    use somatize_compiler::NodeRegistry;
491    use somatize_core::cache::CacheKey;
492    use somatize_core::error::Result;
493    use somatize_core::filter::{FilterKind, FilterMeta, StreamMode};
494    use somatize_core::graph::{Edge, Node};
495
496    // ── Test filters ──
497
498    struct DoublerFilter;
499    impl somatize_core::filter::Filter for DoublerFilter {
500        fn config_hash(&self) -> CacheKey {
501            CacheKey::from_parts(&[b"Doubler"])
502        }
503        fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
504            Ok(Value::Empty)
505        }
506        fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
507            let (data, shape) = x
508                .as_tensor()
509                .ok_or(SomaError::Other("need tensor".into()))?;
510            Ok(Value::tensor(
511                data.iter().map(|v| v * 2.0).collect(),
512                shape.to_vec(),
513            ))
514        }
515        fn meta(&self) -> FilterMeta {
516            FilterMeta {
517                name: "Doubler".into(),
518                kind: FilterKind::Stateless,
519                cacheable: true,
520                differentiable: true,
521                deterministic: true,
522                stream_mode: StreamMode::FixedState,
523                distribution: somatize_core::filter::Distribution::Local,
524                input_schema: None,
525                output_schema: None,
526            }
527        }
528    }
529
530    struct AdderFilter(f64);
531    impl somatize_core::filter::Filter for AdderFilter {
532        fn config_hash(&self) -> CacheKey {
533            CacheKey::from_parts(&[b"Adder", &self.0.to_le_bytes()])
534        }
535        fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
536            Ok(Value::Empty)
537        }
538        fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
539            let (data, shape) = x
540                .as_tensor()
541                .ok_or(SomaError::Other("need tensor".into()))?;
542            Ok(Value::tensor(
543                data.iter().map(|v| v + self.0).collect(),
544                shape.to_vec(),
545            ))
546        }
547        fn meta(&self) -> FilterMeta {
548            FilterMeta {
549                name: "Adder".into(),
550                kind: FilterKind::Stateless,
551                cacheable: true,
552                differentiable: true,
553                deterministic: true,
554                stream_mode: StreamMode::FixedState,
555                distribution: somatize_core::filter::Distribution::Local,
556                input_schema: None,
557                output_schema: None,
558            }
559        }
560    }
561
562    struct MeanFilter;
563    impl somatize_core::filter::Filter for MeanFilter {
564        fn config_hash(&self) -> CacheKey {
565            CacheKey::from_parts(&[b"Mean"])
566        }
567        fn fit(&self, x: &Value, _y: Option<&Value>) -> Result<Value> {
568            let (data, _) = x
569                .as_tensor()
570                .ok_or(SomaError::Other("need tensor".into()))?;
571            let mean = data.iter().sum::<f64>() / data.len() as f64;
572            Ok(Value::json(serde_json::json!({ "mean": mean })))
573        }
574        fn forward(&self, x: &Value, state: &Value) -> Result<Value> {
575            let (data, shape) = x
576                .as_tensor()
577                .ok_or(SomaError::Other("need tensor".into()))?;
578            let mean = state
579                .as_json()
580                .and_then(|j| j["mean"].as_f64())
581                .unwrap_or(0.0);
582            Ok(Value::tensor(
583                data.iter().map(|v| v - mean).collect(),
584                shape.to_vec(),
585            ))
586        }
587        fn meta(&self) -> FilterMeta {
588            FilterMeta {
589                name: "Mean".into(),
590                kind: FilterKind::Trainable,
591                cacheable: true,
592                differentiable: true,
593                deterministic: true,
594                stream_mode: StreamMode::FixedState,
595                distribution: somatize_core::filter::Distribution::Local,
596                input_schema: None,
597                output_schema: None,
598            }
599        }
600    }
601
602    fn linear_graph(ids: &[&str]) -> Graph {
603        let mut g = Graph::new();
604        for &id in ids {
605            g.nodes.push(Node::new(id, id, id));
606        }
607        for (i, pair) in ids.windows(2).enumerate() {
608            g.edges.push(Edge::data(format!("e{i}"), pair[0], pair[1]));
609        }
610        g
611    }
612
613    // ── GraphSession tests ──
614
615    #[test]
616    fn session_run_linear() {
617        let graph = linear_graph(&["double", "add"]);
618        let mut lib = NodeCatalog::new();
619        lib.register("double", Box::new(DoublerFilter));
620        lib.register("add", Box::new(AdderFilter(10.0)));
621
622        let mut session = GraphSession::new(graph, lib);
623
624        let cache = MemoryCache::default();
625        session = session.with_cache(Arc::new(cache));
626
627        // Manual compile + execute via run
628        let CompileResult { plan, .. } = session.compile(CompileMode::NoCache).unwrap();
629        let bus = Arc::new(EventBus::new(64));
630        let mut ctx =
631            Context::new(bus, "test").with_graph_info(GraphInfo::from_graph(session.graph()));
632        ctx.set(
633            somatize_core::keys::GRAPH_INPUT,
634            Value::tensor(vec![1.0, 2.0, 3.0], vec![3]),
635        );
636        executor::execute(&plan, &mut ctx, session.catalog(), &MemoryCache::default()).unwrap();
637
638        let outputs: HashMap<String, Value> = ctx.into_outputs();
639
640        let result = outputs.get("add").unwrap();
641        let (data, _) = result.as_tensor().unwrap();
642        assert_eq!(data, &[12.0, 14.0, 16.0]);
643    }
644
645    #[test]
646    fn session_fit_and_forward() {
647        let graph = linear_graph(&["mean", "double"]);
648        let mut lib = NodeCatalog::new();
649        lib.register("mean", Box::new(MeanFilter));
650        lib.register("double", Box::new(DoublerFilter));
651
652        let mut session = GraphSession::new(graph, lib);
653
654        let x = Value::tensor(vec![10.0, 20.0, 30.0], vec![3]);
655        let outputs = session.fit(&x, None).unwrap();
656
657        // mean: fit learns mean=20, forward: [10-20, 20-20, 30-20] = [-10, 0, 10]
658        // double: [-10, 0, 10] → [-20, 0, 20]
659        let result = outputs.get("double").unwrap();
660        let (data, _) = result.as_tensor().unwrap();
661        assert_eq!(data, &[-20.0, 0.0, 20.0]);
662
663        assert!(session.is_fitted());
664    }
665
666    #[test]
667    fn session_compile_diagnostics() {
668        let graph = linear_graph(&["double"]);
669        let mut lib = NodeCatalog::new();
670        lib.register("double", Box::new(DoublerFilter));
671
672        let session = GraphSession::new(graph, lib);
673        let result = session.compile(CompileMode::NoCache).unwrap();
674        assert!(result.plan.node_count() > 0);
675    }
676
677    // ── Free function tests (backward compat) ──
678
679    #[test]
680    fn graph_run_linear() {
681        let graph = linear_graph(&["double", "add"]);
682        let mut lib = NodeCatalog::new();
683        lib.register("double", Box::new(DoublerFilter));
684        lib.register("add", Box::new(AdderFilter(10.0)));
685
686        let cache = MemoryCache::default();
687
688        let outputs = {
689            let CompileResult { plan, .. } =
690                compile(&graph, &lib, CompileMode::NoCache, None).unwrap();
691            let bus = Arc::new(EventBus::new(64));
692            let mut ctx = Context::new(bus, "test").with_graph_info(GraphInfo::from_graph(&graph));
693            ctx.set(
694                somatize_core::keys::GRAPH_INPUT,
695                Value::tensor(vec![1.0, 2.0, 3.0], vec![3]),
696            );
697            executor::execute(&plan, &mut ctx, &lib, &cache).unwrap();
698            ctx.into_outputs()
699        };
700
701        let result = outputs.get("add").unwrap();
702        let (data, _) = result.as_tensor().unwrap();
703        assert_eq!(data, &[12.0, 14.0, 16.0]);
704    }
705
706    #[test]
707    fn graph_run_diamond() {
708        let mut graph = Graph::new();
709        graph.nodes.push(Node::new("double", "Double", "double"));
710        graph.nodes.push(Node::new("add", "Add", "add"));
711        graph.nodes.push(Node::new("merge", "Merge", "merge"));
712        graph.edges.push(Edge::data("e1", "double", "merge"));
713        graph.edges.push(Edge::data("e2", "add", "merge"));
714
715        let mut lib = NodeCatalog::new();
716        lib.register("double", Box::new(DoublerFilter));
717        lib.register("add", Box::new(AdderFilter(100.0)));
718
719        struct MergeFilter;
720        impl somatize_core::filter::Filter for MergeFilter {
721            fn config_hash(&self) -> CacheKey {
722                CacheKey::from_parts(&[b"Merge"])
723            }
724            fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
725                Ok(Value::Empty)
726            }
727            fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
728                Ok(x.clone())
729            }
730            fn meta(&self) -> FilterMeta {
731                FilterMeta {
732                    name: "Merge".into(),
733                    kind: FilterKind::Stateless,
734                    cacheable: true,
735                    differentiable: false,
736                    deterministic: true,
737                    stream_mode: StreamMode::FixedState,
738                    distribution: somatize_core::filter::Distribution::Local,
739                    input_schema: None,
740                    output_schema: None,
741                }
742            }
743        }
744        lib.register("merge", Box::new(MergeFilter));
745
746        let cache = MemoryCache::default();
747        let CompileResult { plan, .. } = compile(&graph, &lib, CompileMode::NoCache, None).unwrap();
748
749        let bus = Arc::new(EventBus::new(64));
750        let mut ctx = Context::new(bus, "test").with_graph_info(GraphInfo::from_graph(&graph));
751        ctx.set(
752            somatize_core::keys::GRAPH_INPUT,
753            Value::tensor(vec![5.0], vec![1]),
754        );
755        executor::execute(&plan, &mut ctx, &lib, &cache).unwrap();
756
757        let merge_output = ctx.get("merge").unwrap();
758        assert!(
759            merge_output.as_json().is_some(),
760            "merge should receive JSON from multiple predecessors"
761        );
762    }
763
764    #[test]
765    fn graph_fit_trainable() {
766        let graph = linear_graph(&["mean", "double"]);
767        let mut lib = NodeCatalog::new();
768        lib.register("mean", Box::new(MeanFilter));
769        lib.register("double", Box::new(DoublerFilter));
770
771        let cache = Arc::new(MemoryCache::default());
772        let x = Value::tensor(vec![10.0, 20.0, 30.0], vec![3]);
773
774        let outputs = graph_fit(&graph, &lib, &x, None, cache.clone()).unwrap();
775
776        let result = outputs.get("double").unwrap();
777        let (data, _) = result.as_tensor().unwrap();
778        assert_eq!(data, &[-20.0, 0.0, 20.0]);
779
780        assert!(!cache.is_empty());
781    }
782
783    #[test]
784    fn the_catalog_is_the_compiler_registry() {
785        let mut lib = NodeCatalog::new();
786        lib.register("a", Box::new(DoublerFilter));
787
788        let registry: &dyn NodeRegistry = &lib;
789        assert!(registry.meta("a").is_some());
790        assert_eq!(registry.meta("a").unwrap().name, "Doubler");
791        assert!(registry.config_hash("a").is_some());
792        assert!(registry.meta("b").is_none());
793    }
794
795    fn session_of(graph: Graph) -> GraphSession {
796        let mut lib = NodeCatalog::new();
797        for node in &graph.nodes {
798            lib.register(&node.id, Box::new(DoublerFilter));
799        }
800        GraphSession::new(graph, lib)
801    }
802
803    /// The persisted-state address follows the wiring, not just the names.
804    /// It used to be `node_ids.join(",")`, so these two graphs shared one
805    /// address and each would load back the other's trained states.
806    #[test]
807    fn state_address_separates_graphs_that_share_node_ids() {
808        let chain = session_of(linear_graph(&["a", "b", "c"]));
809
810        // Same three nodes, different wiring: a fan-out from `a`.
811        let mut fan = Graph::new();
812        for id in ["a", "b", "c"] {
813            fan.nodes.push(Node::new(id, id, id));
814        }
815        fan.edges.push(Edge::data("e0", "a", "b"));
816        fan.edges.push(Edge::data("e1", "a", "c"));
817        let fan = session_of(fan);
818
819        assert_ne!(
820            chain.graph_config_hash().unwrap(),
821            fan.graph_config_hash().unwrap(),
822            "two differently wired graphs must not persist states to one address"
823        );
824    }
825
826    /// The same graph built twice is the same address — otherwise nothing
827    /// persisted could ever be loaded back.
828    #[test]
829    fn state_address_is_stable_for_the_same_graph() {
830        assert_eq!(
831            session_of(linear_graph(&["a", "b"]))
832                .graph_config_hash()
833                .unwrap(),
834            session_of(linear_graph(&["a", "b"]))
835                .graph_config_hash()
836                .unwrap()
837        );
838    }
839}