Skip to main content

somatize_runtime/
node_catalog.rs

1//! One registry for every node in a graph — implementations, metadata,
2//! and trained states.
3//!
4//! [`NodeCatalog`] holds both kinds of node. Filters and steps used to
5//! live in separate registries joined by a borrow-pair adapter, which
6//! meant three ways to answer "what is this node", and callers that
7//! reached for the filter half alone silently skipped half the schema
8//! validation. There is one place to ask now.
9//!
10//! The compiler reads metadata through [`NodeRegistry`]; the executor
11//! reads implementations and states directly. No intermediate conversion.
12//!
13//! States live in a pluggable [`StateStore`] — by default
14//! [`MemoryStateStore`], but users can inject a disk- or S3-backed store
15//! for pipelines whose trained states don't fit comfortably in RAM. Steps
16//! hold no trained state: their history is the journal.
17
18use somatize_compiler::NodeRegistry;
19use somatize_core::cache::CacheKey;
20use somatize_core::error::Result;
21use somatize_core::filter::Filter;
22#[cfg(test)]
23use somatize_core::filter::FilterMeta;
24use somatize_core::node::NodeMeta;
25use somatize_core::state::{MemoryStateStore, StateStore};
26use somatize_core::step::Step;
27use somatize_core::value::Value;
28use std::collections::HashMap;
29use std::sync::Arc;
30
31/// What sits behind a node id.
32///
33/// The only place in the workspace that names the two kinds. Everything
34/// downstream asks [`NodeCatalog::node_meta`] instead, which answers for
35/// both.
36#[derive(Clone)]
37pub enum NodeImpl {
38    /// A computational node: memoizes by content, may learn state.
39    Filter(Arc<dyn Filter>),
40    /// An effectful node: journals its effects instead of caching output.
41    Step(Arc<dyn Step>),
42}
43
44impl NodeImpl {
45    /// The node's contract, whichever kind it is.
46    pub fn meta(&self) -> NodeMeta {
47        match self {
48            Self::Filter(f) => f.meta().into(),
49            Self::Step(s) => s.meta().into(),
50        }
51    }
52
53    /// The node's configuration identity, whichever kind it is.
54    pub fn config_hash(&self) -> CacheKey {
55        match self {
56            Self::Filter(f) => f.config_hash(),
57            Self::Step(s) => s.config_hash(),
58        }
59    }
60}
61
62/// Every node a graph can execute, plus the states its filters have learned.
63///
64/// ```ignore
65/// let mut lib = NodeCatalog::new();
66/// lib.register("scaler", Box::new(MyScaler { scale: 2.0 }));
67/// lib.register_step("researcher", Box::new(ReactStep::new("claude-opus-5")));
68///
69/// // Use as compiler registry
70/// let result = somatize_compiler::compile(&graph, &lib, mode, cache)?;
71///
72/// // Use directly with executor — no conversion needed
73/// executor::execute(&plan, &mut ctx, &lib, &cache)?;
74/// ```
75/// Cloning shares both the nodes and the state store, so a clone sees
76/// whatever the original has fitted. That is what lets one catalog serve
77/// many graph runs — an agent running pipelines back to back, for instance.
78#[derive(Clone)]
79pub struct NodeCatalog {
80    nodes: HashMap<String, NodeImpl>,
81    states: Arc<dyn StateStore>,
82}
83
84impl NodeCatalog {
85    /// Create a new catalog with an in-memory state store.
86    pub fn new() -> Self {
87        Self {
88            nodes: HashMap::new(),
89            states: Arc::new(MemoryStateStore::new()),
90        }
91    }
92
93    /// Create a catalog with a custom [`StateStore`] backend.
94    pub fn with_state_store(states: Arc<dyn StateStore>) -> Self {
95        Self {
96            nodes: HashMap::new(),
97            states,
98        }
99    }
100
101    /// Register a filter for a given node ID.
102    pub fn register(&mut self, node_id: impl Into<String>, filter: Box<dyn Filter>) {
103        self.nodes
104            .insert(node_id.into(), NodeImpl::Filter(Arc::from(filter)));
105    }
106
107    /// Register a step for a given node ID.
108    pub fn register_step(&mut self, node_id: impl Into<String>, step: Box<dyn Step>) {
109        self.nodes
110            .insert(node_id.into(), NodeImpl::Step(Arc::from(step)));
111    }
112
113    /// Register an already-shared step, for callers that built one
114    /// elsewhere (the Python bindings do).
115    pub fn register_step_arc(&mut self, node_id: impl Into<String>, step: Arc<dyn Step>) {
116        self.nodes.insert(node_id.into(), NodeImpl::Step(step));
117    }
118
119    /// Number of registered nodes, of either kind.
120    pub fn len(&self) -> usize {
121        self.nodes.len()
122    }
123
124    /// Whether the catalog is empty.
125    pub fn is_empty(&self) -> bool {
126        self.nodes.is_empty()
127    }
128
129    /// What sits behind a node id.
130    pub fn node(&self, node_id: &str) -> Option<&NodeImpl> {
131        self.nodes.get(node_id)
132    }
133
134    /// A node's contract, whichever kind it is.
135    pub fn node_meta(&self, node_id: &str) -> Option<NodeMeta> {
136        self.nodes.get(node_id).map(NodeImpl::meta)
137    }
138
139    /// Get a filter by node ID. `None` if the id is a step, or unknown.
140    pub fn get(&self, node_id: &str) -> Option<Arc<dyn Filter>> {
141        match self.nodes.get(node_id) {
142            Some(NodeImpl::Filter(f)) => Some(f.clone()),
143            _ => None,
144        }
145    }
146
147    /// Get a step by node ID. `None` if the id is a filter, or unknown.
148    pub fn step(&self, node_id: &str) -> Option<Arc<dyn Step>> {
149        match self.nodes.get(node_id) {
150            Some(NodeImpl::Step(s)) => Some(s.clone()),
151            _ => None,
152        }
153    }
154
155    /// Does the catalog hold any effectful node at all?
156    ///
157    /// The question a caller asks before building an effect driver, which
158    /// costs a provider catalog read and a journal directory.
159    pub fn has_steps(&self) -> bool {
160        self.nodes.values().any(|n| matches!(n, NodeImpl::Step(_)))
161    }
162
163    /// Copy every node of `other` into this catalog.
164    ///
165    /// Registering the same id twice with the same configuration is a no-op;
166    /// the same id behind a *different* configuration is an error, because
167    /// whichever one lost would silently answer for the other's cache
168    /// entries. States are not merged — they follow this catalog's store.
169    pub fn merge_from(&mut self, other: &NodeCatalog) -> somatize_core::error::Result<()> {
170        for (id, node) in &other.nodes {
171            if let Some(existing) = self.nodes.get(id)
172                && existing.config_hash() != node.config_hash()
173            {
174                return Err(somatize_core::error::SomaError::Other(format!(
175                    "node {id:?} is already registered with a different \
176                     configuration; rename one of the two"
177                )));
178            }
179            self.nodes.insert(id.clone(), node.clone());
180        }
181        Ok(())
182    }
183
184    /// Registered node ids, sorted, so listings are stable.
185    pub fn node_ids(&self) -> Vec<&str> {
186        let mut ids: Vec<&str> = self.nodes.keys().map(String::as_str).collect();
187        ids.sort_unstable();
188        ids
189    }
190
191    /// Store a trained state for a node.
192    ///
193    /// Errors bubble up from the underlying [`StateStore`] (e.g. I/O on
194    /// a disk-backed backend). The in-memory default never fails.
195    pub fn try_set_state(&self, node_id: impl Into<String>, state: Value) -> Result<()> {
196        let id = node_id.into();
197        self.states.set(&id, state)
198    }
199
200    /// Retrieve the trained state for a node. The returned `Arc<Value>`
201    /// can be dereferenced (`&*arc`) for the forward hot path without
202    /// cloning the underlying value.
203    pub fn get_state(&self, node_id: &str) -> Option<Arc<Value>> {
204        self.states.get(node_id).ok().flatten()
205    }
206
207    /// Drop all stored states (but keep the nodes).
208    pub fn clear_states(&self) {
209        let _ = self.states.clear();
210    }
211
212    /// Access the underlying [`StateStore`] (e.g. to share it across
213    /// sessions or inspect its contents).
214    pub fn state_store(&self) -> &Arc<dyn StateStore> {
215        &self.states
216    }
217}
218
219impl Default for NodeCatalog {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225/// Implements [`NodeRegistry`] so the compiler can read metadata directly
226/// from the registered implementations — filters *and* steps.
227///
228/// This is what closed the hole where a caller passing the filter half
229/// alone got the graph compiled with every step edge unchecked.
230impl NodeRegistry for NodeCatalog {
231    fn node_meta(&self, node_id: &str) -> Option<NodeMeta> {
232        NodeCatalog::node_meta(self, node_id)
233    }
234
235    fn config_hash(&self, node_id: &str) -> Option<CacheKey> {
236        self.nodes.get(node_id).map(NodeImpl::config_hash)
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use somatize_core::error::Result;
244    use somatize_core::filter::{FilterKind, StreamMode};
245
246    struct DummyFilter {
247        name: String,
248    }
249
250    impl Filter for DummyFilter {
251        fn config_hash(&self) -> CacheKey {
252            CacheKey::from_parts(&[self.name.as_bytes()])
253        }
254        fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
255            Ok(Value::Empty)
256        }
257        fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
258            Ok(x.clone())
259        }
260        fn meta(&self) -> FilterMeta {
261            FilterMeta {
262                name: self.name.clone(),
263                kind: FilterKind::Stateless,
264                cacheable: true,
265                differentiable: false,
266                deterministic: true,
267                stream_mode: StreamMode::FixedState,
268                distribution: somatize_core::filter::Distribution::Local,
269                input_schema: None,
270                output_schema: None,
271            }
272        }
273    }
274
275    #[test]
276    fn register_and_query() {
277        let mut lib = NodeCatalog::new();
278        lib.register("a", Box::new(DummyFilter { name: "A".into() }));
279        lib.register("b", Box::new(DummyFilter { name: "B".into() }));
280
281        assert_eq!(lib.len(), 2);
282        assert!(lib.get("a").is_some());
283        assert!(lib.get("missing").is_none());
284    }
285
286    #[test]
287    fn implements_filter_registry() {
288        let mut lib = NodeCatalog::new();
289        lib.register(
290            "node_1",
291            Box::new(DummyFilter {
292                name: "Scaler".into(),
293            }),
294        );
295
296        let meta = lib.meta("node_1").unwrap();
297        assert_eq!(meta.name, "Scaler");
298        assert!(meta.cacheable);
299
300        let hash = lib.config_hash("node_1").unwrap();
301        assert_eq!(hash, CacheKey::from_parts(&[b"Scaler"]));
302
303        assert!(lib.meta("nonexistent").is_none());
304    }
305
306    /// A store that refuses every write, the way a full disk or a revoked
307    /// S3 credential would.
308    struct FailingStateStore;
309
310    impl StateStore for FailingStateStore {
311        fn set(&self, _node_id: &str, _state: Value) -> Result<()> {
312            Err(somatize_core::error::SomaError::Other("disk full".into()))
313        }
314        fn get(&self, _node_id: &str) -> Result<Option<Arc<Value>>> {
315            Ok(None)
316        }
317        fn remove(&self, _node_id: &str) -> Result<()> {
318            Ok(())
319        }
320        fn clear(&self) -> Result<()> {
321            Ok(())
322        }
323        fn keys(&self) -> Result<Vec<String>> {
324            Ok(Vec::new())
325        }
326    }
327
328    /// A failing state store used to `panic!`, which aborts the host
329    /// process — a library taking the whole application down because a
330    /// disk filled up. It reports the failure now.
331    #[test]
332    fn a_failing_state_store_is_reported_not_fatal() {
333        let lib = NodeCatalog::with_state_store(Arc::new(FailingStateStore));
334
335        let err = lib.try_set_state("a", Value::Empty).unwrap_err();
336        assert!(err.to_string().contains("disk full"), "got: {err}");
337    }
338
339    struct DummyStep {
340        name: String,
341    }
342
343    impl Step for DummyStep {
344        fn config_hash(&self) -> CacheKey {
345            CacheKey::from_parts(&[self.name.as_bytes()])
346        }
347        fn meta(&self) -> somatize_core::step::StepMeta {
348            somatize_core::step::StepMeta::new(&self.name)
349        }
350        fn poll(
351            &self,
352            _ctx: &somatize_core::step::StepCtx<'_>,
353        ) -> Result<somatize_core::step::Transition> {
354            Ok(somatize_core::step::Transition::Done(Value::Empty))
355        }
356    }
357
358    /// `get()` and `step()` are typed views of one map: each answers only
359    /// for its own kind, so a caller can never run a step as a filter (or
360    /// the reverse) by holding the wrong accessor.
361    #[test]
362    fn a_step_registers_beside_filters_not_as_one() {
363        let mut lib = NodeCatalog::new();
364        lib.register("f", Box::new(DummyFilter { name: "F".into() }));
365        lib.register_step("s", Box::new(DummyStep { name: "S".into() }));
366
367        assert_eq!(lib.len(), 2);
368        assert!(lib.step("s").is_some());
369        assert!(lib.get("s").is_none(), "a step must not answer as a filter");
370        assert!(
371            lib.step("f").is_none(),
372            "a filter must not answer as a step"
373        );
374        assert!(lib.step("missing").is_none());
375    }
376
377    /// The executor's output-cache guard reads `cacheable && deterministic`
378    /// straight off `NodeMeta` — there is no `if is_step` anywhere. A step
379    /// whose meta said anything but `false/false` would be silently
380    /// memoized by content, freezing its first model answer forever.
381    #[test]
382    fn a_steps_node_meta_declares_it_effectful_and_uncacheable() {
383        let mut lib = NodeCatalog::new();
384        lib.register("f", Box::new(DummyFilter { name: "F".into() }));
385        lib.register_step("s", Box::new(DummyStep { name: "S".into() }));
386
387        let step_meta = lib.node_meta("s").unwrap();
388        assert!(step_meta.effectful);
389        assert!(!step_meta.cacheable);
390        assert!(!step_meta.deterministic);
391
392        let filter_meta = lib.node_meta("f").unwrap();
393        assert!(!filter_meta.effectful);
394        assert!(filter_meta.cacheable);
395    }
396
397    /// `has_steps` decides whether a session pays for an effect driver — a
398    /// provider catalog read and a journal directory. Answering `true` for
399    /// a filter-only catalog would charge every plain pipeline that cost.
400    #[test]
401    fn has_steps_flips_when_the_first_step_arrives() {
402        let mut lib = NodeCatalog::new();
403        assert!(!lib.has_steps());
404
405        lib.register("f", Box::new(DummyFilter { name: "F".into() }));
406        assert!(!lib.has_steps(), "a filter is not a step");
407
408        lib.register_step("s", Box::new(DummyStep { name: "S".into() }));
409        assert!(lib.has_steps());
410    }
411
412    /// `merge_from` copies both kinds; the same id under the same config is
413    /// a no-op, and the same id under a *different* config is refused —
414    /// whichever implementation lost would silently answer for the other's
415    /// cache entries.
416    #[test]
417    fn merge_from_merges_and_rejects_a_config_collision() {
418        let mut lib = NodeCatalog::new();
419        lib.register("x", Box::new(DummyFilter { name: "X".into() }));
420
421        let mut other = NodeCatalog::new();
422        // Same id, same config: allowed. Plus one of each kind to copy in.
423        other.register("x", Box::new(DummyFilter { name: "X".into() }));
424        other.register("y", Box::new(DummyFilter { name: "Y".into() }));
425        other.register_step("s", Box::new(DummyStep { name: "S".into() }));
426
427        lib.merge_from(&other).unwrap();
428        assert_eq!(lib.len(), 3);
429        assert!(lib.get("y").is_some());
430        assert!(lib.step("s").is_some(), "steps must merge too");
431
432        let mut clashing = NodeCatalog::new();
433        clashing.register(
434            "x",
435            Box::new(DummyFilter {
436                name: "DIFFERENT".into(),
437            }),
438        );
439        let err = lib.merge_from(&clashing).unwrap_err();
440        let msg = err.to_string();
441        assert!(msg.contains("x"), "should name the colliding id: {msg}");
442        assert!(msg.contains("different"), "should say why: {msg}");
443    }
444
445    #[test]
446    fn state_management() {
447        let mut lib = NodeCatalog::new();
448        lib.register("a", Box::new(DummyFilter { name: "A".into() }));
449
450        assert!(lib.get_state("a").is_none());
451
452        lib.try_set_state("a", Value::json(serde_json::json!({"mean": 5.0})))
453            .unwrap();
454        let state = lib.get_state("a").unwrap();
455        assert_eq!(state.as_json().unwrap()["mean"], 5.0);
456    }
457
458    #[test]
459    fn clear_states_keeps_filters() {
460        let mut lib = NodeCatalog::new();
461        lib.register("a", Box::new(DummyFilter { name: "A".into() }));
462        lib.try_set_state("a", Value::Empty).unwrap();
463
464        assert!(lib.get_state("a").is_some());
465        lib.clear_states();
466        assert!(lib.get_state("a").is_none());
467        assert!(lib.get("a").is_some());
468    }
469}