Skip to main content

somatize_agent/
action.rs

1//! What a research agent can decide to do.
2//!
3//! Two things, and deliberately no more. An agent that can run an experiment
4//! and an agent that can stop covers the whole loop; every other verb people
5//! reach for ("analyze", "summarize", "compare") is the model thinking, and
6//! thinking does not need a protocol.
7
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11/// An action the agent decided to take.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "action", rename_all = "snake_case")]
14#[non_exhaustive]
15pub enum Action {
16    /// Run an experiment with specific parameters.
17    RunExperiment {
18        /// Name for this experiment.
19        name: String,
20        /// Which research line this belongs to.
21        research_line: String,
22        /// What this experiment is meant to settle.
23        ///
24        /// Required, and required to be falsifiable — an experiment run
25        /// without one is a number nobody can interpret later, and the pool
26        /// exists to be read later.
27        hypothesis: String,
28        /// Parameters to apply to the pipeline, as `"<node>.<param>"`.
29        params: BTreeMap<String, serde_json::Value>,
30        /// The run this refines, when it refines one.
31        #[serde(default)]
32        parent: Option<String>,
33    },
34
35    /// Stop: the objective is met, or nothing left is worth trying.
36    Conclude {
37        /// What was concluded, in the agent's own words.
38        reason: String,
39    },
40}
41
42impl Action {
43    /// The JSON Schema a model is asked to answer in.
44    ///
45    /// Constraining the reply is what turns "the model suggested something"
46    /// into "the agent decided something" — a free-text plan has to be
47    /// parsed, and a parser for prose is a source of silent misreadings.
48    pub fn response_schema() -> serde_json::Value {
49        serde_json::json!({
50            "type": "object",
51            "properties": {
52                "action": {
53                    "type": "string",
54                    "enum": ["run_experiment", "conclude"]
55                },
56                "name": {"type": "string"},
57                "research_line": {"type": "string"},
58                "hypothesis": {"type": "string"},
59                "params": {"type": "object"},
60                "parent": {"type": ["string", "null"]},
61                "reason": {"type": "string"}
62            },
63            "required": ["action"]
64        })
65    }
66
67    /// Whether this ends the loop.
68    pub fn is_terminal(&self) -> bool {
69        matches!(self, Self::Conclude { .. })
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use serde_json::json;
77
78    #[test]
79    fn an_experiment_round_trips() {
80        let action: Action = serde_json::from_value(json!({
81            "action": "run_experiment",
82            "name": "exp_0001",
83            "research_line": "regularization",
84            "hypothesis": "stronger L2 lifts held-out F1 above 0.8",
85            "params": {"classifier.C": 0.1}
86        }))
87        .unwrap();
88
89        match &action {
90            Action::RunExperiment {
91                name,
92                params,
93                parent,
94                ..
95            } => {
96                assert_eq!(name, "exp_0001");
97                assert_eq!(params["classifier.C"], json!(0.1));
98                assert!(parent.is_none(), "parent is optional");
99            }
100            other => panic!("{other:?}"),
101        }
102        assert!(!action.is_terminal());
103    }
104
105    #[test]
106    fn a_conclusion_is_terminal() {
107        let action: Action =
108            serde_json::from_value(json!({"action": "conclude", "reason": "plateaued"})).unwrap();
109        assert!(action.is_terminal());
110    }
111
112    /// An experiment without a hypothesis is a number nobody can read later.
113    #[test]
114    fn an_experiment_without_a_hypothesis_is_rejected() {
115        let result: std::result::Result<Action, _> = serde_json::from_value(json!({
116            "action": "run_experiment",
117            "name": "exp",
118            "research_line": "l",
119            "params": {}
120        }));
121        assert!(result.is_err());
122    }
123}