Skip to main content

entelix_graph/
state_graph.rs

1//! `StateGraph<S>` — the builder side of the graph contract.
2//!
3//! Surface:
4//!
5//! - `add_node(name, runnable)` — node is a `Runnable<S, S>` returning the
6//!   new full state.
7//! - `add_edge(from, to)` — single static next-hop per node.
8//! - `add_conditional_edges(from, selector, mapping)` — predicate-based
9//!   dispatch. The selector takes `&S`, returns a key, and the mapping
10//!   resolves it to a target node (or [`END`]).
11//! - `add_send_edges(from, targets, selector, join)` — parallel
12//!   fan-out. The selector returns `Vec<(target, branch_state)>`;
13//!   each branch runs its target node concurrently, results fold
14//!   via the state's [`StateMerge::merge`](crate::StateMerge::merge)
15//!   impl into the pre-fan-out state, then control flows to `join`.
16//!   The state struct supplies the merge story via
17//!   `#[derive(StateMerge)]` over per-field
18//!   [`Annotated<T, R>`](crate::Annotated) wrappers — adding new
19//!   state fields never edits send-edge call sites.
20//! - `set_entry_point(name)` — required.
21//! - `add_finish_point(name)` — running this node halts and returns state.
22//! - `with_recursion_limit(n)` — F6 mitigation, default 25.
23//! - `compile() → CompiledGraph<S>` — preflight validation; the result
24//!   implements `Runnable<S, S>` so it composes via `.pipe()` and serves
25//!   as a sub-graph node in a larger `StateGraph`.
26
27use std::collections::{HashMap, HashSet};
28use std::sync::Arc;
29
30use entelix_core::{Error, Result};
31use entelix_runnable::Runnable;
32
33use crate::checkpoint::Checkpointer;
34use crate::compiled::{
35    CompiledGraph, ConditionalEdge, EdgeSelector, SendEdge, SendMerger, SendSelector,
36};
37use crate::contributing_node::ContributingNodeAdapter;
38use crate::merge_node::MergeNodeAdapter;
39use crate::reducer::StateMerge;
40
41/// Default cap on node executions per `invoke` (F6 mitigation — guards
42/// against infinite cycles).
43pub const DEFAULT_RECURSION_LIMIT: usize = 25;
44
45/// Sentinel target meaning "terminate without running another node". Use
46/// in `add_conditional_edges` mapping when a branch should end the graph.
47pub const END: &str = "__entelix_graph_end__";
48
49/// How often the compiled graph writes a checkpoint when a
50/// `Checkpointer` is attached.
51///
52/// `PerNode` (the default) writes after every successful node
53/// completion — durable enough that a crash mid-graph loses at most
54/// one node's work. `Off` skips checkpointer writes entirely; the
55/// graph still runs end-to-end but cannot resume after a crash.
56#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
57#[non_exhaustive]
58pub enum CheckpointGranularity {
59    /// Skip checkpointer writes. Useful for ephemeral graphs or
60    /// when the checkpointer is attached purely to satisfy a
61    /// downstream API contract.
62    Off,
63    /// Write a checkpoint after each node completes successfully.
64    /// This is the default and matches the F8 mitigation.
65    #[default]
66    PerNode,
67}
68
69/// Builder for a state-machine graph parameterised over its state type `S`.
70///
71/// Nodes are `Runnable<S, S>` instances; each one consumes the current state
72/// and returns the new full state. Three node-registration shapes coexist:
73///
74/// - [`Self::add_node`] — full-state replace. The node owns the
75///   entire shape and returns the next state.
76/// - [`Self::add_node_with`] — delta + bespoke merger closure.
77///   Best when the merge logic is graph-specific.
78/// - [`Self::add_contributing_node`] — declarative per-field merge
79///   via the [`StateMerge`] trait. The state struct advertises its
80///   merge story (typically through `#[derive(StateMerge)]` and
81///   per-field [`Annotated<T, R>`](crate::Annotated) wrappers); the
82///   node returns an `Option`-wrapped `S::Contribution` naming
83///   exactly the slots it touched. Slots left as `None` keep the
84///   current value; slots set to `Some` merge through the
85///   per-field reducer.
86pub struct StateGraph<S>
87where
88    S: Clone + Send + Sync + 'static,
89{
90    nodes: HashMap<String, Arc<dyn Runnable<S, S>>>,
91    edges: HashMap<String, String>,
92    conditional_edges: HashMap<String, ConditionalEdge<S>>,
93    send_edges: HashMap<String, SendEdge<S>>,
94    entry_point: Option<String>,
95    finish_points: HashSet<String>,
96    recursion_limit: usize,
97    checkpointer: Option<Arc<dyn Checkpointer<S>>>,
98    checkpoint_granularity: CheckpointGranularity,
99    /// Nodes whose pre-execution position is a HITL pause point —
100    /// the runtime raises `Error::Interrupted` *before* invoking
101    /// the node, persists a checkpoint pointing back at the same
102    /// node, and lets the host application resume via
103    /// `Command::Resume` (re-runs the node) or `Command::Update`
104    /// (re-runs with new state).
105    interrupt_before: HashSet<String>,
106    /// Nodes whose post-execution position is a HITL pause point —
107    /// the runtime raises `Error::Interrupted` *after* the node
108    /// completes successfully, persists a checkpoint with the new
109    /// state pointing at the resolved next node, and lets the host
110    /// application resume forward (skipping the just-run node).
111    interrupt_after: HashSet<String>,
112}
113
114impl<S> std::fmt::Debug for StateGraph<S>
115where
116    S: Clone + Send + Sync + 'static,
117{
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        // Deterministic Debug output — see `CompiledGraph::fmt`
120        // for the rationale: HashMap / HashSet iteration order is
121        // unspecified, so sorted projections keep test snapshots
122        // and operator log diffs stable.
123        let mut nodes: Vec<&String> = self.nodes.keys().collect();
124        nodes.sort();
125        let mut edges: Vec<(&String, &String)> = self.edges.iter().collect();
126        edges.sort_by_key(|(k, _)| k.as_str());
127        let mut conditional: Vec<&String> = self.conditional_edges.keys().collect();
128        conditional.sort();
129        let mut send: Vec<&String> = self.send_edges.keys().collect();
130        send.sort();
131        let mut finish: Vec<&String> = self.finish_points.iter().collect();
132        finish.sort();
133        let mut interrupt_before: Vec<&String> = self.interrupt_before.iter().collect();
134        interrupt_before.sort();
135        let mut interrupt_after: Vec<&String> = self.interrupt_after.iter().collect();
136        interrupt_after.sort();
137        f.debug_struct("StateGraph")
138            .field("nodes", &nodes)
139            .field("edges", &edges)
140            .field("conditional_edges", &conditional)
141            .field("send_edges", &send)
142            .field("entry_point", &self.entry_point)
143            .field("finish_points", &finish)
144            .field("recursion_limit", &self.recursion_limit)
145            .field("has_checkpointer", &self.checkpointer.is_some())
146            .field("checkpoint_granularity", &self.checkpoint_granularity)
147            .field("interrupt_before", &interrupt_before)
148            .field("interrupt_after", &interrupt_after)
149            .finish()
150    }
151}
152
153impl<S> StateGraph<S>
154where
155    S: Clone + Send + Sync + 'static,
156{
157    /// Empty graph.
158    pub fn new() -> Self {
159        Self {
160            nodes: HashMap::new(),
161            edges: HashMap::new(),
162            conditional_edges: HashMap::new(),
163            send_edges: HashMap::new(),
164            entry_point: None,
165            finish_points: HashSet::new(),
166            recursion_limit: DEFAULT_RECURSION_LIMIT,
167            checkpointer: None,
168            checkpoint_granularity: CheckpointGranularity::default(),
169            interrupt_before: HashSet::new(),
170            interrupt_after: HashSet::new(),
171        }
172    }
173
174    /// Attach a checkpointer.
175    ///
176    /// When set, the compiled graph writes a checkpoint after every node
177    /// invocation if the executing `ExecutionContext` carries a
178    /// `thread_id`. Use [`CompiledGraph::resume`] to continue from the
179    /// most recent checkpoint after a crash. Tune the write frequency
180    /// via [`Self::with_checkpoint_granularity`].
181    #[must_use]
182    pub fn with_checkpointer(mut self, checkpointer: Arc<dyn Checkpointer<S>>) -> Self {
183        self.checkpointer = Some(checkpointer);
184        self
185    }
186
187    /// Override how often the compiled graph writes a checkpoint
188    /// when a checkpointer is attached. Defaults to
189    /// [`CheckpointGranularity::PerNode`].
190    #[must_use]
191    pub const fn with_checkpoint_granularity(mut self, g: CheckpointGranularity) -> Self {
192        self.checkpoint_granularity = g;
193        self
194    }
195
196    /// Register a node. A second registration with the same name replaces
197    /// the prior runnable (calls during construction are append-or-replace,
198    /// not append-only).
199    #[must_use]
200    pub fn add_node<R>(mut self, name: impl Into<String>, runnable: R) -> Self
201    where
202        R: Runnable<S, S> + 'static,
203    {
204        self.nodes.insert(name.into(), Arc::new(runnable));
205        self
206    }
207
208    /// Register a *delta-style* node. The inner runnable produces an
209    /// update of arbitrary type `U`; the merger combines it with the
210    /// inbound state into a fresh full state.
211    ///
212    /// Use this when the natural shape of a node is "compute and
213    /// return what changed" rather than "thread the entire state
214    /// through". The merger has access to both the snapshot of the
215    /// inbound state and the delta, so per-field
216    /// [`Reducer<T>`](crate::Reducer) calls (`Append`, `MergeMap`,
217    /// `Max`, …) plug in directly:
218    ///
219    /// ```ignore
220    /// graph.add_node_with(
221    ///     "plan",
222    ///     planner_runnable,
223    ///     |mut state: PlanState, update: PlannerOutput| {
224    ///         state.log = Append::<String>::new()
225    ///             .reduce(state.log, update.new_log_entries);
226    ///         state.iterations += 1;
227    ///         Ok(state)
228    ///     },
229    /// );
230    /// ```
231    ///
232    /// Existing [`Self::add_node`] (full-state replace) keeps working
233    /// unchanged — the two patterns coexist node-by-node.
234    #[must_use]
235    pub fn add_node_with<R, U, F>(self, name: impl Into<String>, runnable: R, merger: F) -> Self
236    where
237        R: Runnable<S, U> + 'static,
238        U: Send + Sync + 'static,
239        F: Fn(S, U) -> Result<S> + Send + Sync + 'static,
240    {
241        self.add_node(name, MergeNodeAdapter::new(runnable, merger))
242    }
243
244    /// Register a *contribution-style* node whose output names
245    /// exactly the slots it touched, folded into the current state
246    /// through [`StateMerge::merge_contribution`]. The inner
247    /// runnable returns `S::Contribution` — an `Option`-wrapped
248    /// shape mirroring LangGraph's TypedDict partial-return:
249    /// `None` slots keep the current value, `Some` slots merge
250    /// through the per-field reducer.
251    ///
252    /// Use this when the state type owns its merge story
253    /// declaratively (via `#[derive(StateMerge)]` over fields wrapped
254    /// in [`Annotated<T, R>`](crate::Annotated)). Adding a new
255    /// state field never edits the graph builder — the per-field
256    /// reducer annotation does the work.
257    ///
258    /// ```ignore
259    /// use entelix_graph::{Annotated, Append, Max, StateGraph, StateMerge};
260    /// use entelix_runnable::RunnableLambda;
261    ///
262    /// #[derive(Clone, Default, StateMerge)]
263    /// struct AgentState {
264    ///     log: Annotated<Vec<String>, Append<String>>,
265    ///     score: Annotated<i32, Max<i32>>,
266    ///     last_message: String,
267    /// }
268    ///
269    /// // Node writes only `log` and `last_message`; `score`
270    /// // stays at whatever the upstream produced (the contribution
271    /// // shape carries `None` for it, which means "I didn't touch this").
272    /// let planner = RunnableLambda::new(|_state: AgentState, _ctx| async {
273    ///     Ok(AgentStateContribution::default()
274    ///         .with_log(vec!["planned".into()])
275    ///         .with_last_message("scheduled".into()))
276    /// });
277    /// let graph = StateGraph::<AgentState>::new()
278    ///     .add_contributing_node("planner", planner);
279    /// ```
280    #[must_use]
281    pub fn add_contributing_node<R>(self, name: impl Into<String>, runnable: R) -> Self
282    where
283        R: Runnable<S, S::Contribution> + 'static,
284        S: StateMerge,
285    {
286        self.add_node(name, ContributingNodeAdapter::new(runnable))
287    }
288
289    /// Register a static `from → to` edge. Calling twice with the same
290    /// `from` replaces the previous target — single static next-hop per
291    /// node.
292    ///
293    /// A node may not have both a static edge and a conditional edge; the
294    /// `compile()` step rejects that combination.
295    #[must_use]
296    pub fn add_edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
297        self.edges.insert(from.into(), to.into());
298        self
299    }
300
301    /// Register a conditional dispatch: after `from` runs, evaluate
302    /// `selector(&state)` and route to the node named by
303    /// `mapping[selector_output]`. Mapping targets may be node names or
304    /// [`END`].
305    ///
306    /// A second call with the same `from` replaces the prior conditional.
307    /// Mixing with `add_edge` on the same `from` is rejected at compile
308    /// time.
309    #[must_use]
310    pub fn add_conditional_edges<F, K, V>(
311        mut self,
312        from: impl Into<String>,
313        selector: F,
314        mapping: impl IntoIterator<Item = (K, V)>,
315    ) -> Self
316    where
317        F: Fn(&S) -> String + Send + Sync + 'static,
318        K: Into<String>,
319        V: Into<String>,
320    {
321        let mapping: HashMap<String, String> = mapping
322            .into_iter()
323            .map(|(k, v)| (k.into(), v.into()))
324            .collect();
325        let edge = ConditionalEdge {
326            selector: Arc::new(selector) as EdgeSelector<S>,
327            mapping,
328        };
329        self.conditional_edges.insert(from.into(), edge);
330        self
331    }
332
333    /// Register a parallel fan-out from `from`.
334    ///
335    /// `targets` lists every node the selector may dispatch to —
336    /// statically declared so `compile()` can validate each name
337    /// resolves to a registered node and so leaf-validation knows
338    /// these nodes have a defined control path (the fan-out merges
339    /// their results back into the join node, no per-branch
340    /// outgoing edge is required).
341    ///
342    /// After `from` runs, the runtime evaluates `selector(&state)`
343    /// to obtain a list of `(target_node, branch_state)` pairs.
344    /// Each branch is invoked in parallel; the resulting per-branch
345    /// states fold via `reducer` into a single `S`. Control then
346    /// flows to the `join` node, which sees the reduced state.
347    ///
348    /// Selector outputs that name a node not in `targets` cause a
349    /// runtime [`Error::InvalidRequest`] — typo-resistant by
350    /// construction.
351    ///
352    /// Mutually exclusive with [`Self::add_edge`] /
353    /// [`Self::add_conditional_edges`] on the same `from` — `compile`
354    /// rejects the combination. The join target must be registered
355    /// or [`END`].
356    #[must_use]
357    pub fn add_send_edges<F, I, T>(
358        mut self,
359        from: impl Into<String>,
360        targets: I,
361        selector: F,
362        join: impl Into<String>,
363    ) -> Self
364    where
365        F: Fn(&S) -> Vec<(String, S)> + Send + Sync + 'static,
366        I: IntoIterator<Item = T>,
367        T: Into<String>,
368        S: StateMerge,
369    {
370        let edge = SendEdge::new(
371            targets.into_iter().map(Into::into),
372            Arc::new(selector) as SendSelector<S>,
373            Arc::new(<S as StateMerge>::merge) as SendMerger<S>,
374            join.into(),
375        );
376        self.send_edges.insert(from.into(), edge);
377        self
378    }
379
380    /// Mark the start node. Required at compile time.
381    #[must_use]
382    pub fn set_entry_point(mut self, name: impl Into<String>) -> Self {
383        self.entry_point = Some(name.into());
384        self
385    }
386
387    /// Mark a node as terminal — running it halts the graph and returns
388    /// the post-node state. A graph may have more than one finish point;
389    /// any path that reaches one terminates.
390    #[must_use]
391    pub fn add_finish_point(mut self, name: impl Into<String>) -> Self {
392        self.finish_points.insert(name.into());
393        self
394    }
395
396    /// Override the per-invocation recursion limit (F6 mitigation).
397    #[must_use]
398    pub const fn with_recursion_limit(mut self, n: usize) -> Self {
399        self.recursion_limit = n;
400        self
401    }
402
403    /// Mark `nodes` as HITL pause points evaluated **before** the
404    /// node runs. When control reaches a marked node the runtime
405    /// raises `Error::Interrupted` with
406    /// `kind: InterruptionKind::ScheduledPause { phase: Before, node }`
407    /// (the `payload` is `Value::Null` — every distinguishing
408    /// detail is on the typed kind) and (when a `Checkpointer` is
409    /// attached) persists a checkpoint pointing back at the same
410    /// node.
411    ///
412    /// Resume via the existing `Command` machinery:
413    /// - `Command::Resume` re-runs the marked node from the saved
414    ///   pre-state.
415    /// - `Command::Update(s)` re-runs the marked node from `s`.
416    /// - `Command::GoTo(other)` jumps to `other` instead.
417    ///
418    /// Calling twice unions the supplied node sets.
419    #[must_use]
420    pub fn interrupt_before<I, T>(mut self, nodes: I) -> Self
421    where
422        I: IntoIterator<Item = T>,
423        T: Into<String>,
424    {
425        self.interrupt_before
426            .extend(nodes.into_iter().map(Into::into));
427        self
428    }
429
430    /// Mark `nodes` as HITL pause points evaluated **after** the
431    /// node completes successfully. When such a node returns Ok
432    /// the runtime raises `Error::Interrupted` with
433    /// `kind: InterruptionKind::ScheduledPause { phase: After, node }`
434    /// and persists a checkpoint with the post-node state pointing
435    /// at the resolved next node — `Command::Resume` then continues
436    /// forward, skipping a re-run of the just-completed node.
437    #[must_use]
438    pub fn interrupt_after<I, T>(mut self, nodes: I) -> Self
439    where
440        I: IntoIterator<Item = T>,
441        T: Into<String>,
442    {
443        self.interrupt_after
444            .extend(nodes.into_iter().map(Into::into));
445        self
446    }
447
448    /// Number of registered nodes.
449    pub fn node_count(&self) -> usize {
450        self.nodes.len()
451    }
452
453    /// Number of registered static edges.
454    pub fn edge_count(&self) -> usize {
455        self.edges.len()
456    }
457
458    /// Number of nodes with a conditional dispatch.
459    pub fn conditional_edge_count(&self) -> usize {
460        self.conditional_edges.len()
461    }
462
463    /// Validate and freeze the graph.
464    ///
465    /// Returns `Err(Error::Config(_))` for:
466    /// - Missing entry point.
467    /// - Entry point referencing an unregistered node.
468    /// - Static edge referencing an unregistered `from` or `to`.
469    /// - Conditional edge `from` not registered, or any mapping target
470    ///   that is neither a registered node nor [`END`].
471    /// - A node with both a static edge AND a conditional edge.
472    /// - No finish points registered.
473    /// - Finish point referencing an unregistered node.
474    /// - A non-finish-point node has no outgoing edge (static or
475    ///   conditional).
476    /// - `interrupt_before` / `interrupt_after` referencing a node
477    ///   that is not registered.
478    pub fn compile(self) -> Result<CompiledGraph<S>> {
479        let entry = self
480            .entry_point
481            .as_ref()
482            .ok_or_else(|| Error::config("StateGraph: no entry point set"))?
483            .clone();
484        if !self.nodes.contains_key(&entry) {
485            return Err(Error::config(format!(
486                "StateGraph: entry point '{entry}' is not a registered node"
487            )));
488        }
489        self.validate_finish_points()?;
490        self.validate_static_edges()?;
491        self.validate_conditional_edges()?;
492        let send_branch_targets = self.validate_send_edges()?;
493        self.validate_node_termination(&send_branch_targets)?;
494        self.validate_interrupt_points()?;
495
496        Ok(CompiledGraph::new(
497            self.nodes,
498            self.edges,
499            self.conditional_edges,
500            self.send_edges,
501            entry,
502            self.finish_points,
503            self.recursion_limit,
504            self.checkpointer,
505            self.checkpoint_granularity,
506            self.interrupt_before,
507            self.interrupt_after,
508        ))
509    }
510
511    /// Every name in `interrupt_before` / `interrupt_after` must
512    /// resolve to a registered node — typo-resistant by
513    /// construction.
514    fn validate_interrupt_points(&self) -> Result<()> {
515        for name in &self.interrupt_before {
516            if !self.nodes.contains_key(name) {
517                return Err(Error::config(format!(
518                    "StateGraph: interrupt_before names '{name}' which is not a registered node"
519                )));
520            }
521        }
522        for name in &self.interrupt_after {
523            if !self.nodes.contains_key(name) {
524                return Err(Error::config(format!(
525                    "StateGraph: interrupt_after names '{name}' which is not a registered node"
526                )));
527            }
528        }
529        Ok(())
530    }
531
532    /// Validate finish-point set: at least one, every entry must
533    /// resolve to a registered node.
534    fn validate_finish_points(&self) -> Result<()> {
535        if self.finish_points.is_empty() {
536            return Err(Error::config(
537                "StateGraph: no finish points registered (graph would never terminate)",
538            ));
539        }
540        for fp in &self.finish_points {
541            if !self.nodes.contains_key(fp) {
542                return Err(Error::config(format!(
543                    "StateGraph: finish point '{fp}' is not a registered node"
544                )));
545            }
546        }
547        Ok(())
548    }
549
550    /// Validate static `from → to` edges.
551    fn validate_static_edges(&self) -> Result<()> {
552        for (from, to) in &self.edges {
553            if !self.nodes.contains_key(from) {
554                return Err(Error::config(format!(
555                    "StateGraph: edge source '{from}' is not a registered node"
556                )));
557            }
558            if !self.nodes.contains_key(to) {
559                return Err(Error::config(format!(
560                    "StateGraph: edge target '{to}' is not a registered node"
561                )));
562            }
563        }
564        Ok(())
565    }
566
567    /// Validate conditional-edge dispatch tables.
568    fn validate_conditional_edges(&self) -> Result<()> {
569        for (from, cond) in &self.conditional_edges {
570            if !self.nodes.contains_key(from) {
571                return Err(Error::config(format!(
572                    "StateGraph: conditional edge source '{from}' is not a registered node"
573                )));
574            }
575            if self.edges.contains_key(from) {
576                return Err(Error::config(format!(
577                    "StateGraph: node '{from}' has both a static edge and a conditional edge \
578                     — pick one"
579                )));
580            }
581            for target in cond.mapping.values() {
582                if target != END && !self.nodes.contains_key(target) {
583                    return Err(Error::config(format!(
584                        "StateGraph: conditional edge from '{from}' maps to '{target}' which is \
585                         neither a registered node nor END"
586                    )));
587                }
588            }
589        }
590        Ok(())
591    }
592
593    /// Validate send-edge fan-outs and return the union of
594    /// statically-declared branch targets (used by leaf-validation
595    /// to recognise these nodes as having a defined control path).
596    fn validate_send_edges(&self) -> Result<HashSet<String>> {
597        let mut send_branch_targets: HashSet<String> = HashSet::new();
598        for (from, send) in &self.send_edges {
599            if !self.nodes.contains_key(from) {
600                return Err(Error::config(format!(
601                    "StateGraph: send edge source '{from}' is not a registered node"
602                )));
603            }
604            if self.edges.contains_key(from) || self.conditional_edges.contains_key(from) {
605                return Err(Error::config(format!(
606                    "StateGraph: node '{from}' has more than one outgoing edge type — \
607                     send edges are mutually exclusive with static and conditional edges"
608                )));
609            }
610            if send.join != END && !self.nodes.contains_key(&send.join) {
611                return Err(Error::config(format!(
612                    "StateGraph: send edge from '{from}' joins on '{}' which is \
613                     neither a registered node nor END",
614                    send.join
615                )));
616            }
617            for target in send.targets() {
618                if !self.nodes.contains_key(target) {
619                    return Err(Error::config(format!(
620                        "StateGraph: send edge from '{from}' lists target '{target}' \
621                         which is not a registered node"
622                    )));
623                }
624                send_branch_targets.insert(target.clone());
625            }
626        }
627        Ok(send_branch_targets)
628    }
629
630    /// Every non-finish node must have a defined control-flow path:
631    /// a static edge, a conditional edge, a send edge, or be the
632    /// dispatch target of someone else's send edge.
633    fn validate_node_termination(&self, send_branch_targets: &HashSet<String>) -> Result<()> {
634        for name in self.nodes.keys() {
635            if !self.finish_points.contains(name)
636                && !self.edges.contains_key(name)
637                && !self.conditional_edges.contains_key(name)
638                && !self.send_edges.contains_key(name)
639                && !send_branch_targets.contains(name)
640            {
641                return Err(Error::config(format!(
642                    "StateGraph: node '{name}' has no outgoing edge and is not a finish point"
643                )));
644            }
645        }
646        Ok(())
647    }
648}
649
650impl<S> Default for StateGraph<S>
651where
652    S: Clone + Send + Sync + 'static,
653{
654    fn default() -> Self {
655        Self::new()
656    }
657}