Skip to main content

somatize_core/
node.rs

1//! What the compiler and executor need to know about a node, whichever
2//! kind it is.
3//!
4//! A graph has two sorts of node. A [`Filter`](crate::filter::Filter) is a
5//! function: same config, same state, same input, same output, which is
6//! what makes content-addressed caching sound. A [`Step`](crate::step::Step)
7//! calls models, reads the world and may pause for a person.
8//!
9//! That difference matters to *them*. It should not reach the machinery
10//! around them: resolving inputs from predecessors, catching a panic,
11//! emitting a start and a completion, deciding whether an output may be
12//! cached — none of it depends on which kind ran. So the two metadata
13//! types collapse into one here, and the distinction survives as **data**
14//! on it rather than as a second code path.
15//!
16//! The load-bearing part is [`NodeMeta::cacheable`] and
17//! [`NodeMeta::deterministic`]: `From<StepMeta>` sets both to `false`, so
18//! the cache guard the executor already runs skips a step without anyone
19//! writing `if is_step`.
20
21use crate::effect::SuspendReason;
22use crate::filter::{Distribution, FilterKind, FilterMeta};
23use crate::graph::NodeId;
24use crate::schema::Schema;
25use crate::step::StepMeta;
26use crate::value::Value;
27use serde::{Deserialize, Serialize};
28
29/// How a node finished.
30///
31/// The three ways execution can leave a node, whichever kind it was. A
32/// filter only ever produces; a step can also hand control on or stop and
33/// wait. The runtime used to carry this as `StepOutcome` and translate it
34/// three times on the way out — into control flow, then into an error
35/// with the reason flattened to a JSON string that nothing parsed back.
36///
37/// Deliberately *not* `#[non_exhaustive]`, against the convention for
38/// public enums here. Every consumer of this type is deciding control
39/// flow, and a wildcard arm in that position is a silent wrong answer —
40/// the pattern that let an unhandled plan variant become a successful
41/// no-op. If a fourth way to finish is ever added, the places that must
42/// think about it should stop compiling.
43#[derive(Debug)]
44pub enum NodeOutcome {
45    /// A value, which the node's successors read as its output.
46    Produced(Value),
47
48    /// Control passes to another node, carrying a value.
49    ///
50    /// The carry is stored under the *handing* node, so the target reads
51    /// it as an ordinary predecessor output rather than through a special
52    /// path.
53    HandOff {
54        /// The node control passes to; must have been declared as a handoff.
55        target: NodeId,
56        /// The value the target reads as this node's output.
57        carry: Value,
58    },
59
60    /// The run stopped, pending something outside it. `turn` is where to
61    /// deliver the answer when resuming.
62    Paused {
63        /// The step's turn counter at suspension — resume delivers here.
64        turn: usize,
65        /// What the run is waiting for (a person, an external event).
66        reason: SuspendReason,
67    },
68}
69
70/// A node's contract, independent of whether it computes or acts.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct NodeMeta {
73    /// The name/type identifier of the implementation behind this node.
74    pub name: String,
75
76    /// Does it reach outside the graph — models, tools, people?
77    ///
78    /// The one thing downstream genuinely needs to tell apart: an
79    /// effectful node is journaled rather than memoized, and it is
80    /// reported as itself in events instead of borrowing a
81    /// [`FilterKind`].
82    pub effectful: bool,
83
84    /// Classification of a computational node's behaviour.
85    ///
86    /// [`FilterKind::Opaque`] for an effectful node, which has no trained
87    /// state to speak of.
88    pub kind: FilterKind,
89
90    /// May this node's output be cached?
91    pub cacheable: bool,
92
93    /// Same inputs, same output? See [`FilterMeta::deterministic`].
94    pub deterministic: bool,
95
96    /// Does `forward()` keep a differentiable graph?
97    pub differentiable: bool,
98
99    /// Where it may run.
100    pub distribution: Distribution,
101
102    /// What it accepts (`None` = anything).
103    pub input_schema: Option<Schema>,
104
105    /// What it produces (`None` = unknown).
106    pub output_schema: Option<Schema>,
107}
108
109impl NodeMeta {
110    /// Is there trained state to learn and cache?
111    pub fn trainable(&self) -> bool {
112        !self.effectful && self.kind == FilterKind::Trainable
113    }
114}
115
116impl From<FilterMeta> for NodeMeta {
117    fn from(m: FilterMeta) -> Self {
118        Self {
119            name: m.name,
120            effectful: false,
121            kind: m.kind,
122            cacheable: m.cacheable,
123            deterministic: m.deterministic,
124            differentiable: m.differentiable,
125            distribution: m.distribution,
126            input_schema: m.input_schema,
127            output_schema: m.output_schema,
128        }
129    }
130}
131
132impl From<StepMeta> for NodeMeta {
133    fn from(m: StepMeta) -> Self {
134        Self {
135            name: m.name,
136            effectful: true,
137            // A step has no state to fit, so nothing about it is trainable
138            // or differentiable.
139            kind: FilterKind::Opaque,
140            // The two fields that replace an `if is_step` in the executor.
141            // A step's output is not a function of its input — the model
142            // is on the other end of it — so serving a recorded one would
143            // be a lie. Its *effects* are still journaled, which is the
144            // replay mechanism that actually fits an effectful node.
145            cacheable: false,
146            deterministic: false,
147            differentiable: false,
148            distribution: m.distribution,
149            input_schema: m.input_schema,
150            output_schema: m.output_schema,
151        }
152    }
153}
154
155impl NodeMeta {
156    /// The computational half, for consumers that only speak `FilterMeta`.
157    ///
158    /// Lossy by design: an effectful node has no honest `FilterMeta`, and
159    /// callers should ask [`NodeMeta::effectful`] before reaching for this.
160    pub fn as_filter_meta(&self) -> FilterMeta {
161        FilterMeta {
162            name: self.name.clone(),
163            kind: self.kind,
164            cacheable: self.cacheable,
165            differentiable: self.differentiable,
166            deterministic: self.deterministic,
167            stream_mode: crate::filter::StreamMode::FixedState,
168            distribution: self.distribution.clone(),
169            input_schema: self.input_schema.clone(),
170            output_schema: self.output_schema.clone(),
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn filter_meta() -> FilterMeta {
180        FilterMeta {
181            name: "Scaler".into(),
182            kind: FilterKind::Trainable,
183            cacheable: true,
184            differentiable: false,
185            deterministic: true,
186            stream_mode: crate::filter::StreamMode::FixedState,
187            distribution: Distribution::Local,
188            input_schema: None,
189            output_schema: None,
190        }
191    }
192
193    #[test]
194    fn a_filter_keeps_its_caching_contract() {
195        let meta = NodeMeta::from(filter_meta());
196        assert!(!meta.effectful);
197        assert!(meta.cacheable);
198        assert!(meta.deterministic);
199        assert!(meta.trainable());
200    }
201
202    /// The whole point of the type: "a step is not output-cacheable" is a
203    /// pair of fields, so the executor's existing guard handles it and no
204    /// one writes `if is_step`.
205    #[test]
206    fn a_step_is_not_output_cacheable() {
207        let meta = NodeMeta::from(StepMeta::new("ReactStep"));
208        assert!(meta.effectful);
209        assert!(!meta.cacheable);
210        assert!(!meta.deterministic);
211        assert!(!meta.differentiable);
212        assert!(!meta.trainable());
213    }
214
215    #[test]
216    fn schemas_survive_both_directions() {
217        let mut sm = StepMeta::new("Judge");
218        sm.input_schema = Some(Schema::text());
219        sm.output_schema = Some(Schema::messages());
220        let meta = NodeMeta::from(sm);
221        assert_eq!(meta.input_schema, Some(Schema::text()));
222        assert_eq!(meta.output_schema, Some(Schema::messages()));
223    }
224}