1use crate::action::Action;
23use somatize_core::effect::{Effect, EffectResult, GraphEffectMode, LlmRequest};
24use somatize_core::error::{Result, SomaError};
25use somatize_core::graph::Graph;
26use somatize_core::message::{Message, Messages};
27use somatize_core::step::{Step, StepCtx, StepMeta, Transition};
28use somatize_core::util::{extract_json, truncate};
29use somatize_core::value::Value;
30use somatize_memory::ExperimentRecord;
31
32const HISTORY_LIMIT: usize = 20;
34
35#[derive(serde::Serialize, somatize_core::SomaStep)]
37#[soma(cache_version = "soma-research-step-v1")]
38pub struct ResearchStep {
39 model: String,
40 objective: String,
41 pipeline: Graph,
44 max_iterations: usize,
45 seed: Vec<ExperimentRecord>,
49}
50
51impl ResearchStep {
52 pub fn new(model: impl Into<String>, objective: impl Into<String>, pipeline: Graph) -> Self {
58 Self {
59 model: model.into(),
60 objective: objective.into(),
61 pipeline,
62 max_iterations: 20,
63 seed: Vec::new(),
64 }
65 }
66
67 pub fn with_history(mut self, records: Vec<ExperimentRecord>) -> Self {
70 self.seed = records;
71 self
72 }
73
74 pub fn with_max_iterations(mut self, n: usize) -> Self {
77 self.max_iterations = n;
78 self
79 }
80
81 pub fn completed(&self, ctx: &StepCtx<'_>) -> Vec<ExperimentRecord> {
88 let mut records = self.seed.clone();
89 let mut proposed: Option<Action> = None;
90
91 for turn in ctx.history {
92 match turn.first() {
93 Some(EffectResult::Llm(response)) => {
96 proposed = self.parse_action(&response.message.text()).ok();
97 }
98 Some(outcome) => {
100 if let Some(action) = proposed.take()
101 && let Some(record) = self.record(&action, outcome)
102 {
103 records.push(record);
104 }
105 }
106 None => {}
107 }
108 }
109 records
110 }
111
112 fn ask(&self, ctx: &StepCtx<'_>) -> Effect {
114 LlmRequest::new(
115 &self.model,
116 Messages::from(vec![Message::user(
117 self.history_prompt(&self.completed(ctx)),
118 )]),
119 )
120 .with_system(self.system())
121 .with_schema(crate::action::Action::response_schema())
128 .into_effect()
129 }
130
131 fn system(&self) -> String {
132 format!(
133 "You are running an experimental campaign on a Soma pipeline.\n\n\
134 Objective: {}\n\n\
135 Each turn, propose ONE experiment or conclude; the reply must \
136 match the declared schema. Use `params` keys of the form \
137 `<node>.<param>`.\n\n\
138 Every experiment needs a falsifiable hypothesis — a result \
139 nobody can interpret later is a result nobody will read. Vary \
140 one thing at a time so the comparison means something, and \
141 conclude when the objective is met or the line has stopped \
142 paying.\n\n\
143 Pipeline nodes: {}",
144 self.objective,
145 self.pipeline.node_ids().join(", ")
146 )
147 }
148
149 fn history_prompt(&self, history: &[ExperimentRecord]) -> String {
151 if history.is_empty() {
152 return "No experiments yet. Propose the first one.".into();
153 }
154
155 let mut lines = vec![format!("{} experiments so far:", history.len())];
156 for record in history.iter().rev().take(HISTORY_LIMIT).rev() {
157 let mut metrics: Vec<String> = record
158 .metrics
159 .iter()
160 .map(|(k, v)| format!("{k}={v:.4}"))
161 .collect();
162 metrics.sort();
163 lines.push(format!(
164 "- {} [{}] {} → {}",
165 record.name,
166 record.research_line.as_deref().unwrap_or("unfiled"),
167 serde_json::to_string(&record.params).unwrap_or_default(),
168 if metrics.is_empty() {
169 "no metrics".to_string()
170 } else {
171 metrics.join(" ")
172 }
173 ));
174 }
175 lines.push("\nWhat next?".into());
176 lines.join("\n")
177 }
178
179 fn report(&self, reason: &str, done: &[ExperimentRecord]) -> Value {
183 Value::json(serde_json::json!({
184 "concluded": reason,
185 "objective": self.objective,
186 "experiments": done.len(),
187 "records": done,
188 }))
189 }
190
191 fn parse_action(&self, text: &str) -> Result<Action> {
193 let json = extract_json(text).ok_or_else(|| {
194 SomaError::Other(format!(
195 "the model replied with no JSON object, so there is no action \
196 to take: {}",
197 truncate(text, 200)
198 ))
199 })?;
200
201 serde_json::from_value(json).map_err(|e| {
202 SomaError::Other(format!(
203 "the model's reply is not an action ({e}): {}",
204 truncate(text, 200)
205 ))
206 })
207 }
208
209 fn experiment_effect(&self, params: &serde_json::Map<String, serde_json::Value>) -> Effect {
216 Effect::Graph {
217 graph: Box::new(self.pipeline.clone()),
218 input: Value::json(serde_json::Value::Object(params.clone())),
219 mode: GraphEffectMode::Fit,
220 }
221 }
222
223 fn record(&self, action: &Action, result: &EffectResult) -> Option<ExperimentRecord> {
224 let Action::RunExperiment {
225 name,
226 research_line,
227 hypothesis,
228 params,
229 parent,
230 } = action
231 else {
232 return None;
233 };
234
235 let mut record = ExperimentRecord::new(name.clone(), name.clone());
236 record.hypothesis = Some(hypothesis.clone());
237 record.research_line = Some(research_line.clone());
238 record.parent = parent.clone();
239 record.params = params.clone();
240 record.tags = vec!["agent".into()];
241 record.pipeline_summary = self.pipeline.node_ids().join(" → ");
242
243 match result {
244 EffectResult::Graph(value) => {
245 record.metrics = read_metrics(value);
246 }
247 EffectResult::Failed { message } => {
250 record.notes = Some(format!("failed: {message}"));
251 }
252 other => {
253 record.notes = Some(format!("unexpected result: {other:?}"));
254 }
255 }
256 Some(record)
257 }
258}
259
260impl Step for ResearchStep {
261 fn config_hash(&self) -> somatize_core::cache::CacheKey {
265 ResearchStep::config_hash(self)
266 }
267
268 fn meta(&self) -> StepMeta {
269 StepMeta::new("research")
270 .with_max_turns(self.max_iterations * 2 + 2)
271 .with_output_schema(somatize_core::schema::Schema::json())
272 }
273
274 fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
275 let last = ctx.results.first();
277
278 match last {
279 None => Ok(Transition::Await(vec![self.ask(ctx)])),
280
281 Some(EffectResult::Llm(response)) => {
283 response.reject_non_answers(ctx.node_id)?;
288 let action = self.parse_action(&response.message.text())?;
289 let done = self.completed(ctx);
290 match &action {
291 Action::Conclude { reason } => Ok(Transition::Done(self.report(reason, &done))),
292 Action::RunExperiment { params, .. } => {
293 if done.len() >= self.max_iterations {
294 return Ok(Transition::Done(
295 self.report("iteration budget exhausted", &done),
296 ));
297 }
298 Ok(Transition::Await(vec![
299 self.experiment_effect(&to_object(params)),
300 ]))
301 }
302 }
303 }
304
305 Some(_) => Ok(Transition::Await(vec![self.ask(ctx)])),
308 }
309 }
310}
311
312fn to_object(
313 params: &std::collections::BTreeMap<String, serde_json::Value>,
314) -> serde_json::Map<String, serde_json::Value> {
315 params.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
316}
317
318fn read_metrics(value: &Value) -> std::collections::BTreeMap<String, f64> {
325 let mut metrics = std::collections::BTreeMap::new();
326 collect_numbers(&value.to_plain_json(), "", &mut metrics);
327 metrics
328}
329
330fn collect_numbers(
331 json: &serde_json::Value,
332 path: &str,
333 out: &mut std::collections::BTreeMap<String, f64>,
334) {
335 match json {
336 serde_json::Value::Number(n) => {
337 if let Some(f) = n.as_f64() {
338 let name = if path.is_empty() { "value" } else { path };
339 out.insert(name.to_string(), f);
340 }
341 }
342 serde_json::Value::Object(map) => {
343 for (key, val) in map {
344 let child = if path.is_empty() {
345 key.clone()
346 } else {
347 format!("{path}.{key}")
348 };
349 collect_numbers(val, &child, out);
350 }
351 }
352 _ => {}
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use serde_json::json;
362
363 fn step() -> ResearchStep {
364 let mut graph = Graph::new();
365 graph.add_node(somatize_core::graph::Node::filter_with_id(
366 "classifier",
367 "svm",
368 ));
369 ResearchStep::new("mock/model", "beat 0.8 F1", graph)
370 }
371
372 #[test]
373 fn the_history_prompt_starts_empty() {
374 assert!(step().history_prompt(&[]).contains("No experiments yet"));
375 }
376
377 #[test]
378 fn the_history_prompt_lists_what_ran() {
379 let mut record = ExperimentRecord::new("exp_1", "exp_1");
380 record.research_line = Some("regularization".into());
381 record.params = [("classifier.C".to_string(), json!(0.1))]
382 .into_iter()
383 .collect();
384 record.metrics = [("f1".to_string(), 0.72)].into_iter().collect();
385
386 let prompt = step().history_prompt(&[record]);
387 assert!(prompt.contains("exp_1"), "{prompt}");
388 assert!(prompt.contains("regularization"), "{prompt}");
389 assert!(prompt.contains("f1=0.7200"), "{prompt}");
390 }
391
392 #[test]
393 fn an_action_is_read_out_of_a_fenced_reply() {
394 let action = step()
395 .parse_action(
396 "Here is my plan:\n```json\n{\"action\": \"conclude\", \
397 \"reason\": \"plateaued\"}\n```",
398 )
399 .unwrap();
400 assert!(action.is_terminal());
401 }
402
403 #[test]
406 fn prose_is_not_an_action() {
407 let err = step().parse_action("I think we should try more C values.");
408 assert!(err.is_err());
409 }
410
411 #[test]
412 fn a_reply_that_is_not_an_action_is_refused() {
413 let err = step().parse_action("{\"thoughts\": \"hmm\"}");
414 assert!(err.is_err());
415 }
416
417 #[test]
418 fn metrics_are_read_from_a_mapping() {
419 let value = Value::json(json!({"f1": 0.9, "notes": "fine", "loss": 0.1}));
420 let metrics = read_metrics(&value);
421 assert_eq!(metrics.len(), 2);
422 assert_eq!(metrics["f1"], 0.9);
423 }
424
425 #[test]
428 fn nested_metrics_are_qualified_by_node() {
429 let value = Value::json(json!({
430 "encoder": {"loss": 0.3},
431 "classifier": {"loss": 0.1, "f1": 0.9, "weights": [1.0, 2.0]}
432 }));
433 let metrics = read_metrics(&value);
434 assert_eq!(metrics["encoder.loss"], 0.3);
435 assert_eq!(metrics["classifier.loss"], 0.1);
436 assert_eq!(metrics["classifier.f1"], 0.9);
437 assert_eq!(metrics.len(), 3, "an array is data, not a metric");
438 }
439
440 #[test]
441 fn a_failed_experiment_is_still_recorded() {
442 let action: Action = serde_json::from_value(json!({
443 "action": "run_experiment",
444 "name": "exp_bad",
445 "research_line": "l",
446 "hypothesis": "h",
447 "params": {}
448 }))
449 .unwrap();
450
451 let record = step()
452 .record(
453 &action,
454 &EffectResult::Failed {
455 message: "no such filter".into(),
456 },
457 )
458 .unwrap();
459
460 assert!(record.metrics.is_empty());
461 assert!(record.notes.unwrap().contains("no such filter"));
462 }
463}