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