Skip to main content

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}