somatize_core/control.rs
1//! Data-dependent control flow: how a loop decides to stop and how a branch
2//! picks an arm.
3//!
4//! Both decisions read a node's output `Value`. The contract lives here, in
5//! one place, because the compiler resolves it and the executor applies it —
6//! and because it is the surface a Python filter has to satisfy.
7//!
8//! The rule throughout: **an unreadable signal is an error, never a default.**
9//! Guessing (continue looping, take the first arm) turns a typo into a silent
10//! wrong answer that surfaces hours later as a bad result rather than a
11//! stack trace.
12
13use crate::graph::NodeId;
14use crate::value::Value;
15use serde::{Deserialize, Serialize};
16
17/// What ends a loop.
18///
19/// The compiler resolves this to a concrete form before the executor sees it,
20/// so at runtime there is never a question of *which* node decides.
21// Adjacently tagged, not internally: serde cannot put an internal tag on a
22// newtype variant wrapping a string, so `#[serde(tag = "type")]` made every
23// graph containing a resolved loop unserializable — including the
24// `graph.json` snapshot the experiment pool writes for it.
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(tag = "type", content = "node")]
27#[non_exhaustive]
28pub enum LoopCondition {
29 /// Resolve at compile time to the body's single terminal node — the one
30 /// no other body node depends on. Compilation fails if the body has more
31 /// than one, rather than picking whichever finished last.
32 ///
33 /// This is the default for `Node::loop_node`.
34 #[default]
35 BodyTerminal,
36
37 /// Stop when the named node's output signals completion.
38 WhenSignaled(NodeId),
39
40 /// Ignore signals; run the body exactly `max_iterations` times.
41 Exhaust,
42}
43
44/// A loop body's verdict on whether to go round again.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum LoopSignal {
47 /// Run the body again.
48 Continue,
49 /// The loop is done; do not run the body again.
50 Stop,
51}
52
53/// Read a termination signal out of a node's output.
54///
55/// Recognized as **stop**: `true`, `"done"`, `"stop"`, `{"done": true}`,
56/// and `Value::Empty` (nothing left to produce).
57/// Recognized as **continue**: `false`, `{"done": false}`.
58///
59/// Returns `None` for anything else — including tensors. A body that only
60/// produces tensors cannot express termination, and the honest response is to
61/// say so rather than silently run to `max_iterations`.
62pub fn read_loop_signal(value: &Value) -> Option<LoopSignal> {
63 use LoopSignal::{Continue, Stop};
64
65 match value {
66 Value::Empty => Some(Stop),
67 Value::Text(s) => match s.as_ref() {
68 "done" | "stop" => Some(Stop),
69 "continue" => Some(Continue),
70 _ => None,
71 },
72 Value::Json(j) => {
73 if let Some(b) = j.as_bool() {
74 return Some(if b { Stop } else { Continue });
75 }
76 if let Some(s) = j.as_str() {
77 return match s {
78 "done" | "stop" => Some(Stop),
79 "continue" => Some(Continue),
80 _ => None,
81 };
82 }
83 j.get("done")
84 .and_then(|d| d.as_bool())
85 .map(|b| if b { Stop } else { Continue })
86 }
87 _ => None,
88 }
89}
90
91/// Read the arm label a branch condition selected.
92///
93/// Recognized: a string (`"billing"`), a bool (`"true"` / `"false"`), or an
94/// object with a `"branch"` field. Returns `None` for anything else, so the
95/// executor can report an unusable condition instead of running arm zero.
96pub fn read_arm_selector(value: &Value) -> Option<String> {
97 match value {
98 Value::Text(s) => Some(s.to_string()),
99 Value::Json(j) => j
100 .as_str()
101 .map(String::from)
102 .or_else(|| j.as_bool().map(|b| b.to_string()))
103 .or_else(|| j.get("branch").and_then(|b| b.as_str()).map(String::from)),
104 _ => None,
105 }
106}
107
108/// Labels treated as the catch-all arm when no label matches the selector.
109pub const DEFAULT_ARM_LABELS: [&str; 2] = ["default", "else"];
110
111/// Is this label a catch-all arm?
112pub fn is_default_arm(label: &str) -> bool {
113 DEFAULT_ARM_LABELS.contains(&label)
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 /// Every variant has to survive JSON, or the graph containing it cannot
121 /// be written to a run directory, sent to a worker, or read by anything
122 /// outside this process. `WhenSignaled` is the one that broke: an
123 /// internal tag cannot be applied to a newtype variant wrapping a string.
124 #[test]
125 fn every_condition_round_trips_through_json() {
126 for condition in [
127 LoopCondition::BodyTerminal,
128 LoopCondition::WhenSignaled("critic".into()),
129 LoopCondition::Exhaust,
130 ] {
131 let text = serde_json::to_string(&condition).expect("serializable");
132 let back: LoopCondition = serde_json::from_str(&text).expect("readable");
133 assert_eq!(back, condition, "{text}");
134 }
135 }
136
137 #[test]
138 fn stop_signals() {
139 for v in [
140 Value::Empty,
141 Value::json(serde_json::json!(true)),
142 Value::json(serde_json::json!("done")),
143 Value::json(serde_json::json!("stop")),
144 Value::json(serde_json::json!({"done": true})),
145 Value::text("done"),
146 Value::text("stop"),
147 ] {
148 assert_eq!(read_loop_signal(&v), Some(LoopSignal::Stop), "{v:?}");
149 }
150 }
151
152 #[test]
153 fn continue_signals() {
154 for v in [
155 Value::json(serde_json::json!(false)),
156 Value::json(serde_json::json!({"done": false})),
157 Value::text("continue"),
158 ] {
159 assert_eq!(read_loop_signal(&v), Some(LoopSignal::Continue), "{v:?}");
160 }
161 }
162
163 /// A tensor carries no termination signal. Reporting that is the whole
164 /// point — the old executor read `_ => false` and burned 100 iterations.
165 #[test]
166 fn tensors_are_not_signals() {
167 assert_eq!(read_loop_signal(&Value::tensor(vec![1.0], vec![1])), None);
168 assert_eq!(read_loop_signal(&Value::json(serde_json::json!(42))), None);
169 assert_eq!(
170 read_loop_signal(&Value::json(serde_json::json!({"score": 0.9}))),
171 None
172 );
173 }
174
175 #[test]
176 fn arm_selectors() {
177 assert_eq!(
178 read_arm_selector(&Value::json(serde_json::json!("billing"))),
179 Some("billing".into())
180 );
181 assert_eq!(
182 read_arm_selector(&Value::json(serde_json::json!(true))),
183 Some("true".into())
184 );
185 assert_eq!(
186 read_arm_selector(&Value::json(serde_json::json!({"branch": "retry"}))),
187 Some("retry".into())
188 );
189 assert_eq!(read_arm_selector(&Value::text("tech")), Some("tech".into()));
190 }
191
192 /// No selector must mean "error", not "arm zero".
193 #[test]
194 fn unusable_selectors_are_none() {
195 assert_eq!(read_arm_selector(&Value::tensor(vec![1.0], vec![1])), None);
196 assert_eq!(read_arm_selector(&Value::Empty), None);
197 assert_eq!(
198 read_arm_selector(&Value::json(serde_json::json!({"score": 1}))),
199 None
200 );
201 }
202
203 #[test]
204 fn default_arm_labels() {
205 assert!(is_default_arm("default"));
206 assert!(is_default_arm("else"));
207 assert!(!is_default_arm("billing"));
208 }
209}