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}