Skip to main content

somatize_runtime/effects/
graph_handler.rs

1//! Running a Soma graph as an effect.
2//!
3//! This is what makes a computational pipeline a first-class thing an agent
4//! can reach for. No `publish` mechanism, no tool wrapper, no bridge: the
5//! agent emits [`Effect::Graph`] and gets the output back, with the graph's
6//! own cache, schema checks and events all applying as usual.
7//!
8//! It also means an agentic run is journaled at the pipeline boundary. A
9//! research loop that crashes after its fourth experiment replays the first
10//! three from the journal instead of paying for them again.
11
12use crate::cache::MemoryCache;
13use crate::effects::{EffectDriver, EffectHandler, EffectJournal};
14use crate::event_bus::EventBus;
15use crate::graph_session::GraphSession;
16use crate::node_catalog::NodeCatalog;
17use somatize_core::cache::CacheStore;
18use somatize_core::effect::{Effect, EffectResult, GraphEffectMode};
19use somatize_core::error::{Result, SomaError};
20use std::sync::Arc;
21
22/// How deep agent → pipeline → agent nesting may go.
23///
24/// Each level is a step whose sub-graph contains another step. Real flows
25/// are one or two levels; a graph that reaches eight is almost certainly
26/// recursing on itself, and stopping with a readable failure beats a stack
27/// that grows until the OS ends the process.
28pub const MAX_GRAPH_DEPTH: usize = 8;
29
30/// Everything needed to drive steps inside a sub-graph.
31///
32/// `handlers` are the *sibling* handlers (llm, tools, sleep, custom) — never
33/// a `GraphHandler`. The recursion is rebuilt one level deeper instead, so
34/// each level carries its own depth and the nesting can be capped.
35#[derive(Clone)]
36struct StepRuntime {
37    handlers: Vec<Arc<dyn EffectHandler>>,
38    journal: EffectJournal,
39    event_bus: Option<Arc<EventBus>>,
40    depth: usize,
41}
42
43/// Runs graphs on behalf of a step.
44///
45/// Holds the filters those graphs are built from — a graph names its nodes,
46/// it does not carry their implementations — plus the cache they share.
47pub struct GraphHandler {
48    library: NodeCatalog,
49    cache: Arc<dyn CacheStore>,
50    step_runtime: Option<StepRuntime>,
51}
52
53impl GraphHandler {
54    /// A handler over `library`, with an in-memory cache.
55    ///
56    /// Without [`Self::with_step_runtime`] the sub-graphs it runs must be
57    /// purely computational; one that contains a step fails as an
58    /// [`EffectResult::Failed`] naming the missing runtime.
59    pub fn new(library: NodeCatalog) -> Self {
60        Self {
61            library,
62            cache: Arc::new(MemoryCache::new(64 * 1024 * 1024)),
63            step_runtime: None,
64        }
65    }
66
67    /// Share the caller's cache, so a pipeline the agent runs hits the same
68    /// entries the user's own runs wrote.
69    pub fn with_cache(mut self, cache: Arc<dyn CacheStore>) -> Self {
70        self.cache = cache;
71        self
72    }
73
74    /// Let sub-graphs contain steps of their own: agent → pipeline → agent.
75    ///
76    /// `handlers` are the sibling handlers the parent driver carries (llm,
77    /// tools, sleep — everything except graph handlers), and `journal` is
78    /// the parent's journal, so an inner model call is journaled in the same
79    /// store as an outer one. Nesting is capped at [`MAX_GRAPH_DEPTH`].
80    pub fn with_step_runtime(
81        mut self,
82        handlers: Vec<Arc<dyn EffectHandler>>,
83        journal: EffectJournal,
84    ) -> Self {
85        self.step_runtime = Some(StepRuntime {
86            handlers,
87            journal,
88            event_bus: None,
89            depth: 0,
90        });
91        self
92    }
93
94    /// Forward the sub-graphs' step events to this bus.
95    ///
96    /// Only meaningful together with [`Self::with_step_runtime`]; without
97    /// one there is no inner driver to emit anything.
98    pub fn with_event_bus(mut self, bus: Arc<EventBus>) -> Self {
99        if let Some(rt) = &mut self.step_runtime {
100            rt.event_bus = Some(bus);
101        }
102        self
103    }
104
105    /// The filters available to graphs run through this handler.
106    pub fn library(&self) -> &NodeCatalog {
107        &self.library
108    }
109
110    /// The driver a step-containing sub-graph gets: the sibling handlers,
111    /// plus a `GraphHandler` one level deeper. `None` past the depth cap.
112    fn child_driver(&self) -> Option<EffectDriver> {
113        let rt = self.step_runtime.as_ref()?;
114        if rt.depth + 1 >= MAX_GRAPH_DEPTH {
115            return None;
116        }
117        let mut child_rt = rt.clone();
118        child_rt.depth += 1;
119        let mut driver = EffectDriver::new(rt.journal.clone())
120            .with_catalog(Arc::new(self.library.clone()))
121            .with_handler(Arc::new(GraphHandler {
122                library: self.library.clone(),
123                cache: self.cache.clone(),
124                step_runtime: Some(child_rt),
125            }));
126        for handler in &rt.handlers {
127            driver = driver.with_handler(handler.clone());
128        }
129        if let Some(bus) = &rt.event_bus {
130            driver = driver.with_event_bus(bus.clone());
131        }
132        Some(driver)
133    }
134}
135
136impl EffectHandler for GraphHandler {
137    fn handles(&self, effect: &Effect) -> bool {
138        matches!(effect, Effect::Graph { .. })
139    }
140
141    fn perform(&self, effect: &Effect) -> Result<EffectResult> {
142        let Effect::Graph { graph, input, mode } = effect else {
143            return Err(SomaError::Other("not a graph effect".into()));
144        };
145
146        // The clone shares the state store, so a graph fitted by one effect
147        // is fitted for the next one.
148        let mut session = GraphSession::new((**graph).clone(), self.library.clone())
149            .with_cache(self.cache.clone());
150
151        if graph.contains_steps() {
152            match self.child_driver() {
153                Some(driver) => session = session.with_driver(driver),
154                // The executor's own "no effect driver" error is accurate
155                // but does not say *why* there is none here; the reason —
156                // no runtime, or too deep — is information the agent needs.
157                None => {
158                    return Ok(EffectResult::Failed {
159                        message: match &self.step_runtime {
160                            None => "the sub-graph contains a step, but this graph handler \
161                                     was built without a step runtime; build it with \
162                                     `GraphHandler::with_step_runtime(...)`"
163                                .into(),
164                            Some(rt) => format!(
165                                "the sub-graph contains a step, but nesting agents inside \
166                                 pipelines inside agents stops at depth {MAX_GRAPH_DEPTH} \
167                                 (this call is at depth {})",
168                                rt.depth + 1
169                            ),
170                        },
171                    });
172                }
173            }
174        }
175
176        let outcome = match mode {
177            GraphEffectMode::Fit => session
178                .fit(input, None)
179                .map(|outputs| somatize_core::value::Value::json(outputs_summary(&outputs))),
180            // `GraphEffectMode` is `#[non_exhaustive]`; anything added later
181            // is a mode this build does not know how to run, and guessing
182            // `forward` would silently skip a fit.
183            GraphEffectMode::Forward => session.forward(input),
184            other => Err(SomaError::Other(format!(
185                "unsupported graph effect mode: {other:?}"
186            ))),
187        };
188
189        match outcome {
190            Ok(value) => Ok(EffectResult::Graph(value)),
191            // A pipeline that fails is a result the agent has to read and
192            // act on — an unfittable configuration is information, and one
193            // of the more valuable kinds. Ending the run instead would
194            // throw away everything learned up to that point.
195            Err(e) => Ok(EffectResult::Failed {
196                message: e.to_string(),
197            }),
198        }
199    }
200}
201
202/// What a fit pass produced, as something a model can read.
203///
204/// One entry per node, minus the bulk: a node that produced a score or a
205/// threshold has said something the caller needs, and one that produced a
206/// 40-million-element tensor has not — and JSON-encoding that into an effect
207/// result would put it in the journal forever.
208///
209/// The runtime's own bookkeeping keys (`__input_*`, `__state_*`) are not
210/// results and do not belong in front of a model.
211fn outputs_summary(
212    outputs: &std::collections::HashMap<String, somatize_core::value::Value>,
213) -> serde_json::Value {
214    let mut summary = serde_json::Map::new();
215    for (node_id, value) in outputs {
216        if node_id.starts_with("__") {
217            continue;
218        }
219        summary.insert(node_id.clone(), summarize_state(value));
220    }
221    serde_json::Value::Object(summary)
222}
223
224/// How many elements a learned array can have before it counts as weights.
225const WEIGHTS_THRESHOLD: usize = 32;
226
227fn summarize_state(state: &somatize_core::value::Value) -> serde_json::Value {
228    let json = state.to_plain_json();
229    if is_bulk(&json) {
230        return serde_json::json!({ "fitted": true });
231    }
232    json
233}
234
235fn is_bulk(json: &serde_json::Value) -> bool {
236    match json {
237        serde_json::Value::Array(items) => {
238            items.len() > WEIGHTS_THRESHOLD || items.iter().any(is_bulk)
239        }
240        serde_json::Value::Object(map) => map.values().any(is_bulk),
241        _ => false,
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use somatize_core::filter::{Filter, FilterKind, FilterMeta, StreamMode};
249    use somatize_core::graph::{Graph, Node};
250    use somatize_core::value::Value;
251
252    struct Doubler;
253
254    impl Filter for Doubler {
255        fn meta(&self) -> FilterMeta {
256            FilterMeta {
257                name: "doubler".into(),
258                kind: FilterKind::Stateless,
259                cacheable: true,
260                differentiable: false,
261                deterministic: true,
262                stream_mode: StreamMode::FixedState,
263                distribution: somatize_core::filter::Distribution::Local,
264                input_schema: None,
265                output_schema: None,
266            }
267        }
268        fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
269            Ok(Value::Empty)
270        }
271        fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
272            let (data, shape) = x
273                .as_tensor()
274                .ok_or(SomaError::Other("not a tensor".into()))?;
275            Ok(Value::tensor(
276                data.iter().map(|v| v * 2.0).collect(),
277                shape.to_vec(),
278            ))
279        }
280        fn config_hash(&self) -> somatize_core::cache::CacheKey {
281            somatize_core::cache::CacheKey::from_parts(&[b"doubler"])
282        }
283    }
284
285    fn handler() -> GraphHandler {
286        let mut library = NodeCatalog::new();
287        library.register("double", Box::new(Doubler));
288        GraphHandler::new(library)
289    }
290
291    fn one_node_graph() -> Graph {
292        let mut graph = Graph::new();
293        graph.add_node(Node::filter_with_id("double", "doubler"));
294        graph
295    }
296
297    #[test]
298    fn a_graph_effect_runs_the_graph() {
299        let result = handler()
300            .perform(&Effect::Graph {
301                graph: Box::new(one_node_graph()),
302                input: Value::tensor(vec![1.0, 2.0], vec![2]),
303                mode: GraphEffectMode::Forward,
304            })
305            .unwrap();
306
307        match result {
308            EffectResult::Graph(value) => {
309                let (data, _) = value.as_tensor().unwrap();
310                assert_eq!(data, &[2.0, 4.0]);
311            }
312            other => panic!("{other:?}"),
313        }
314    }
315
316    /// A pipeline that will not run is a finding, not a crash: the agent
317    /// reads it and tries something else.
318    #[test]
319    fn a_failing_graph_comes_back_as_a_result() {
320        let mut graph = Graph::new();
321        graph.add_node(Node::filter_with_id("missing", "nowhere"));
322
323        let result = handler()
324            .perform(&Effect::Graph {
325                graph: Box::new(graph),
326                input: Value::tensor(vec![1.0], vec![1]),
327                mode: GraphEffectMode::Forward,
328            })
329            .unwrap();
330
331        assert!(matches!(result, EffectResult::Failed { .. }));
332    }
333
334    use somatize_core::effect::{LlmRequest, LlmResponse, StopReason};
335    use somatize_core::message::Message;
336    use somatize_core::step::{StepCtx, StepMeta, Transition};
337
338    /// Answers every model call with a fixed string.
339    struct CannedLlm(&'static str);
340
341    impl EffectHandler for CannedLlm {
342        fn handles(&self, effect: &Effect) -> bool {
343            matches!(effect, Effect::Llm(_))
344        }
345        fn perform(&self, _effect: &Effect) -> Result<EffectResult> {
346            Ok(EffectResult::Llm(LlmResponse {
347                message: Message::assistant(self.0),
348                stop_reason: StopReason::EndTurn,
349                usage: Default::default(),
350                model: None,
351            }))
352        }
353    }
354
355    /// Asks the model once, then hands back what it said.
356    struct AskOnce;
357
358    impl somatize_core::step::Step for AskOnce {
359        fn config_hash(&self) -> somatize_core::cache::CacheKey {
360            somatize_core::cache::CacheKey::from_parts(&[b"AskOnce"])
361        }
362        fn meta(&self) -> StepMeta {
363            StepMeta::new("AskOnce")
364        }
365        fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
366            if ctx.turn == 0 {
367                return Ok(Transition::Await(vec![Effect::Llm(LlmRequest::new(
368                    "claude-opus-5",
369                    vec![Message::user("inner question")].into(),
370                ))]));
371            }
372            let text = match ctx.result() {
373                Some(EffectResult::Llm(r)) => r.message.text(),
374                other => format!("unexpected result: {other:?}"),
375            };
376            Ok(Transition::Done(Value::text(text)))
377        }
378    }
379
380    fn journal() -> EffectJournal {
381        let dir = tempfile::tempdir().unwrap();
382        let store = Arc::new(crate::cache::FsActionStore::new(dir.keep()).unwrap());
383        EffectJournal::new(store.clone(), store)
384    }
385
386    /// Agent → pipeline → agent: a graph effect whose sub-graph itself
387    /// contains a step. The handler builds the inner step a driver of its
388    /// own, sharing the sibling handlers and the journal.
389    #[test]
390    fn a_sub_graph_containing_a_step_runs() {
391        let mut library = NodeCatalog::new();
392        library.register_step("ask", Box::new(AskOnce));
393
394        let llm: Arc<dyn EffectHandler> = Arc::new(CannedLlm("the inner answer"));
395        let handler = GraphHandler::new(library).with_step_runtime(vec![llm], journal());
396
397        let mut graph = Graph::new();
398        graph.add_node(Node::step("ask", "AskOnce"));
399
400        let result = handler
401            .perform(&Effect::Graph {
402                graph: Box::new(graph),
403                input: Value::text("outer input"),
404                mode: GraphEffectMode::Forward,
405            })
406            .unwrap();
407
408        match result {
409            EffectResult::Graph(value) => {
410                assert_eq!(value.as_text(), Some("the inner answer"));
411            }
412            other => panic!("expected the inner step's output, got {other:?}"),
413        }
414    }
415
416    /// Without a step runtime, a step-containing sub-graph is a readable
417    /// failure that names the fix, not the executor's generic error.
418    #[test]
419    fn a_step_sub_graph_without_a_runtime_names_the_fix() {
420        let mut library = NodeCatalog::new();
421        library.register_step("ask", Box::new(AskOnce));
422
423        let mut graph = Graph::new();
424        graph.add_node(Node::step("ask", "AskOnce"));
425
426        let result = GraphHandler::new(library)
427            .perform(&Effect::Graph {
428                graph: Box::new(graph),
429                input: Value::Empty,
430                mode: GraphEffectMode::Forward,
431            })
432            .unwrap();
433
434        match result {
435            EffectResult::Failed { message } => {
436                assert!(message.contains("with_step_runtime"), "{message}");
437            }
438            other => panic!("expected a failure, got {other:?}"),
439        }
440    }
441
442    /// A step that keeps running its own graph again. The recursion must
443    /// end at the depth cap with a failure the agent can read — not a stack
444    /// overflow.
445    struct Recurse;
446
447    impl somatize_core::step::Step for Recurse {
448        fn config_hash(&self) -> somatize_core::cache::CacheKey {
449            somatize_core::cache::CacheKey::from_parts(&[b"Recurse"])
450        }
451        fn meta(&self) -> StepMeta {
452            StepMeta::new("Recurse")
453        }
454        fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
455            if ctx.turn == 0 {
456                let mut graph = Graph::new();
457                graph.add_node(Node::step("recurse", "Recurse"));
458                return Ok(Transition::Await(vec![Effect::Graph {
459                    graph: Box::new(graph),
460                    input: Value::text("again"),
461                    mode: GraphEffectMode::Forward,
462                }]));
463            }
464            let text = match ctx.result() {
465                Some(EffectResult::Graph(v)) => v.as_text().unwrap_or_default().to_string(),
466                Some(EffectResult::Failed { message }) => message.clone(),
467                other => format!("unexpected: {other:?}"),
468            };
469            Ok(Transition::Done(Value::text(text)))
470        }
471    }
472
473    #[test]
474    fn nesting_stops_at_the_depth_cap() {
475        let mut library = NodeCatalog::new();
476        library.register_step("recurse", Box::new(Recurse));
477
478        let handler = GraphHandler::new(library).with_step_runtime(Vec::new(), journal());
479
480        let mut graph = Graph::new();
481        graph.add_node(Node::step("recurse", "Recurse"));
482
483        let result = handler
484            .perform(&Effect::Graph {
485                graph: Box::new(graph),
486                input: Value::text("go"),
487                mode: GraphEffectMode::Forward,
488            })
489            .unwrap();
490
491        // The innermost level fails at the cap; every level above hands the
492        // message outward as its own output.
493        match result {
494            EffectResult::Graph(value) => {
495                let text = value.as_text().unwrap_or_default();
496                assert!(
497                    text.contains(&format!("depth {MAX_GRAPH_DEPTH}")),
498                    "the failure should name the cap, got: {text}"
499                );
500            }
501            other => panic!("expected the propagated cap message, got {other:?}"),
502        }
503    }
504
505    use crate::effects::NodeOutcome;
506    use std::sync::atomic::{AtomicUsize, Ordering};
507
508    /// Doubles, counting how often the graph actually runs it.
509    ///
510    /// `cacheable: false`, deliberately: the sub-graph's own output cache
511    /// must not be able to spare the second run, so the only thing that
512    /// can is the journal — which is what the test is about.
513    struct CountingDoubler {
514        calls: Arc<AtomicUsize>,
515    }
516
517    impl Filter for CountingDoubler {
518        fn config_hash(&self) -> somatize_core::cache::CacheKey {
519            somatize_core::cache::CacheKey::from_parts(&[b"CountingDoubler"])
520        }
521        fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
522            Ok(Value::Empty)
523        }
524        fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
525            self.calls.fetch_add(1, Ordering::SeqCst);
526            let (data, shape) = x
527                .as_tensor()
528                .ok_or(SomaError::Other("not a tensor".into()))?;
529            Ok(Value::tensor(
530                data.iter().map(|v| v * 2.0).collect(),
531                shape.to_vec(),
532            ))
533        }
534        fn meta(&self) -> FilterMeta {
535            FilterMeta {
536                name: "counting".into(),
537                kind: FilterKind::Stateless,
538                cacheable: false,
539                differentiable: false,
540                deterministic: true,
541                stream_mode: StreamMode::FixedState,
542                distribution: somatize_core::filter::Distribution::Local,
543                input_schema: None,
544                output_schema: None,
545            }
546        }
547    }
548
549    /// Awaits one filter-only Forward graph effect, then reports its output.
550    struct RunsPipeline;
551
552    impl somatize_core::step::Step for RunsPipeline {
553        fn config_hash(&self) -> somatize_core::cache::CacheKey {
554            somatize_core::cache::CacheKey::from_parts(&[b"RunsPipeline"])
555        }
556        fn meta(&self) -> StepMeta {
557            StepMeta::new("RunsPipeline")
558        }
559        fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
560            if ctx.turn == 0 {
561                let mut graph = Graph::new();
562                graph.add_node(Node::filter_with_id("count", "counting"));
563                return Ok(Transition::Await(vec![Effect::Graph {
564                    graph: Box::new(graph),
565                    input: Value::tensor(vec![3.0], vec![1]),
566                    mode: GraphEffectMode::Forward,
567                }]));
568            }
569            match ctx.result() {
570                Some(EffectResult::Graph(v)) => Ok(Transition::Done(v.clone())),
571                other => Ok(Transition::Done(Value::text(format!(
572                    "unexpected: {other:?}"
573                )))),
574            }
575        }
576    }
577
578    /// A filter-only Forward graph effect is *pure*: it keys on content, so
579    /// the journal serves it to any run, like the filter cache it rides on.
580    /// Two different runs asking for the identical pipeline must cost one
581    /// execution — the second is a journal hit, not a re-run. This is the
582    /// property that makes a crashed research loop replay its finished
583    /// experiments instead of paying for them again.
584    #[test]
585    fn an_identical_pure_graph_effect_is_served_from_the_journal() {
586        let calls = Arc::new(AtomicUsize::new(0));
587        let mut library = NodeCatalog::new();
588        library.register(
589            "count",
590            Box::new(CountingDoubler {
591                calls: calls.clone(),
592            }),
593        );
594
595        let d = EffectDriver::new(journal()).with_handler(Arc::new(GraphHandler::new(library)));
596
597        // Different run ids on purpose: an *impure* effect would re-perform
598        // for run-B, a pure one must not.
599        let first = d
600            .run(&RunsPipeline, "run-A", "agent", &Value::Empty)
601            .unwrap();
602        let second = d
603            .run(&RunsPipeline, "run-B", "agent", &Value::Empty)
604            .unwrap();
605
606        assert_eq!(
607            calls.load(Ordering::SeqCst),
608            1,
609            "an identical pure graph effect re-ran the pipeline"
610        );
611        match (first, second) {
612            (NodeOutcome::Produced(a), NodeOutcome::Produced(b)) => {
613                assert_eq!(a.as_tensor().map(|(d, _)| d.to_vec()), Some(vec![6.0]));
614                assert_eq!(a, b, "the journal served a different answer");
615            }
616            other => panic!("expected two Done outcomes, got {other:?}"),
617        }
618    }
619
620    /// Learns the mean, then subtracts it — a filter whose fitted state has
621    /// a visible effect on a later forward.
622    struct MeanFilter;
623    impl Filter for MeanFilter {
624        fn config_hash(&self) -> somatize_core::cache::CacheKey {
625            somatize_core::cache::CacheKey::from_parts(&[b"Mean"])
626        }
627        fn fit(&self, x: &Value, _y: Option<&Value>) -> Result<Value> {
628            let (data, _) = x
629                .as_tensor()
630                .ok_or(SomaError::Other("need tensor".into()))?;
631            let mean = data.iter().sum::<f64>() / data.len() as f64;
632            Ok(Value::json(serde_json::json!({ "mean": mean })))
633        }
634        fn forward(&self, x: &Value, state: &Value) -> Result<Value> {
635            let (data, shape) = x
636                .as_tensor()
637                .ok_or(SomaError::Other("need tensor".into()))?;
638            let mean = state
639                .as_json()
640                .and_then(|j| j["mean"].as_f64())
641                .unwrap_or(0.0);
642            Ok(Value::tensor(
643                data.iter().map(|v| v - mean).collect(),
644                shape.to_vec(),
645            ))
646        }
647        fn meta(&self) -> FilterMeta {
648            FilterMeta {
649                name: "mean".into(),
650                kind: FilterKind::Trainable,
651                cacheable: true,
652                differentiable: false,
653                deterministic: true,
654                stream_mode: StreamMode::FixedState,
655                distribution: somatize_core::filter::Distribution::Local,
656                input_schema: None,
657                output_schema: None,
658            }
659        }
660    }
661
662    /// `Fit` mode answers with a JSON summary an agent can read — never the
663    /// bulk outputs, which would sit in the journal forever — and the fitted
664    /// states land in the handler's shared state store, so the *next*
665    /// Forward through the same handler runs fitted. That second half is the
666    /// claim the handler's session-clone comment makes; this is the test
667    /// that would catch a session that stopped sharing states.
668    #[test]
669    fn fit_mode_fits_and_summarizes() {
670        let mut library = NodeCatalog::new();
671        library.register("mean", Box::new(MeanFilter));
672        let handler = GraphHandler::new(library);
673
674        let mut graph = Graph::new();
675        graph.add_node(Node::filter_with_id("mean", "mean"));
676        let input = Value::tensor(vec![10.0, 20.0, 30.0], vec![3]);
677
678        let fitted = handler
679            .perform(&Effect::Graph {
680                graph: Box::new(graph.clone()),
681                input: input.clone(),
682                mode: GraphEffectMode::Fit,
683            })
684            .unwrap();
685        let EffectResult::Graph(summary) = fitted else {
686            panic!("expected a graph result, got {fitted:?}");
687        };
688        let json = summary
689            .as_json()
690            .expect("a fit answers with a JSON summary, not bulk output");
691        assert!(json.get("mean").is_some(), "no entry for the node: {json}");
692
693        // mean = 20 was learned above: forward must subtract it. An
694        // unfitted graph would fall back to 0 and echo the input.
695        let forwarded = handler
696            .perform(&Effect::Graph {
697                graph: Box::new(graph),
698                input,
699                mode: GraphEffectMode::Forward,
700            })
701            .unwrap();
702        let EffectResult::Graph(out) = forwarded else {
703            panic!("expected a graph result, got {forwarded:?}");
704        };
705        let (data, _) = out.as_tensor().expect("a tensor");
706        assert_eq!(
707            data,
708            &[-10.0, 0.0, 10.0],
709            "the forward did not see the state the fit just learned"
710        );
711    }
712
713    #[test]
714    fn the_handler_claims_only_graph_effects() {
715        let h = handler();
716        assert!(h.handles(&Effect::Graph {
717            graph: Box::new(Graph::new()),
718            input: Value::Empty,
719            mode: GraphEffectMode::Forward,
720        }));
721        assert!(!h.handles(&Effect::Sleep(std::time::Duration::from_secs(1))));
722    }
723}