1use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::process::{Command, Stdio};
24
25const DRIVER: &str = r#"
28import base64, importlib, importlib.util, json, os, sys, traceback
29
30spec = json.loads(sys.stdin.read())
31sys.path.insert(0, os.getcwd())
32result_path = spec["result_path"]
33
34def _fail(message, detail=None):
35 with open(result_path, "w") as fh:
36 json.dump({"ok": False, "error": message, "detail": detail}, fh)
37 sys.exit(0)
38
39def _load_from_file(path, cls_name):
40 mod_name = "_soma_mcp_" + os.path.basename(path).replace(".", "_")
41 spec_ = importlib.util.spec_from_file_location(mod_name, path)
42 if spec_ is None or spec_.loader is None:
43 raise ImportError("cannot load %s" % path)
44 module = importlib.util.module_from_spec(spec_)
45 sys.modules[mod_name] = module
46 spec_.loader.exec_module(module)
47 return getattr(module, cls_name)
48
49def _resolve(ref, candidates):
50 """`module.Class`, `path/to/file.py:Class`, or a bare class name.
51
52 A bare name is only looked for in the files the server already found
53 with `list_filters` — importing every .py under the project to go
54 looking would run whatever else is down there.
55 """
56 if ":" in ref:
57 path, _, cls_name = ref.partition(":")
58 return _load_from_file(path, cls_name)
59 if "." in ref:
60 mod_name, _, cls_name = ref.rpartition(".")
61 try:
62 return getattr(importlib.import_module(mod_name), cls_name)
63 except (ImportError, AttributeError):
64 pass # may still be a bare dotted name; fall through to the scan
65 tried = []
66 for path in candidates:
67 try:
68 obj = _load_from_file(path, ref.rpartition(".")[2] or ref)
69 except Exception as e:
70 tried.append("%s: %s" % (path, e))
71 continue
72 return obj
73 raise ImportError(
74 "cannot resolve filter %r. Give it as `module.Class` or "
75 "`path/to/file.py:Class`. Tried: %s" % (ref, "; ".join(tried) or "nothing")
76 )
77
78def _config(value):
79 """Config values, with `{"__search__": {...}}` becoming a search dimension.
80
81 That is what makes a study expressible without a second vocabulary:
82 the same node spec describes a fixed value and a searched one, and
83 `graph.search_space()` picks the dimension up on its own.
84 """
85 import soma
86 if isinstance(value, dict):
87 if "__search__" in value:
88 return soma.search(**value["__search__"])
89 return {k: _config(v) for k, v in value.items()}
90 if isinstance(value, list):
91 return [_config(v) for v in value]
92 return value
93
94def _searchable(cls, config):
95 """Move `{"__search__": …}` values onto a subclass, where they count.
96
97 `search()` is a CLASS-level descriptor — `FilterMeta` collects the
98 ones it finds in a class body into `_soma_search_space`, and that is
99 what `graph.search_space()` reads. Passing one to a constructor sets
100 an instance attribute holding a descriptor, which is not a dimension
101 and cannot be hashed into a cache key either.
102
103 So the searched values become a subclass's body, and the constructor
104 is called WITHOUT them: its own default supplies a concrete value for
105 the identity, and each trial supplies the sampled one.
106 """
107 from soma.search import SearchDescriptor
108 searched = {k: v for k, v in config.items() if isinstance(v, SearchDescriptor)}
109 if not searched:
110 return cls, config
111 subclass = type(cls)(cls.__name__, (cls,), dict(searched))
112 return subclass, {k: v for k, v in config.items() if k not in searched}
113
114def _build(spec, overrides=None):
115 import soma
116 overrides = overrides or {}
117 g = soma.Graph(cache=spec.get("cache", "memory"))
118 candidates = spec.get("filter_files", [])
119 for node in spec["nodes"]:
120 cls = _resolve(node["filter"], candidates)
121 config = {k: _config(v) for k, v in (node.get("config") or {}).items()}
122 cls, config = _searchable(cls, config)
123 for key, value in overrides.get(node["id"], {}).items():
124 config[key] = value
125 try:
126 instance = cls(**config) if config else cls()
127 except TypeError as e:
128 raise TypeError(
129 "constructing node %r as %s(%s): %s"
130 % (node["id"], getattr(cls, "__name__", node["filter"]),
131 ", ".join("%s=%r" % kv for kv in config.items()), e))
132 if node.get("target"):
133 g.node(node["id"], instance, target=node["target"])
134 else:
135 g.node(node["id"], instance)
136 for edge in spec.get("edges", []):
137 g.connect(edge[0], edge[1])
138 return g
139
140def _run_once(spec, g):
141 """Fit when asked (or when targets were given), then forward.
142
143 A graph with a trainable node refuses to forward before it is fitted,
144 so `fit` defaults to true rather than to "only when y is present".
145 """
146 x, y = spec.get("input"), spec.get("y")
147 if spec.get("fit", True):
148 g.fit(x, y)
149 return g.forward(x)
150
151def _jsonable(value, limit=2000):
152 """Outputs can be large; the model needs the shape more than the tail."""
153 if isinstance(value, list):
154 if len(value) > limit:
155 return {"truncated": True, "length": len(value),
156 "head": _jsonable(value[:limit], limit)}
157 return [_jsonable(v, limit) for v in value]
158 if isinstance(value, dict):
159 return {k: _jsonable(v, limit) for k, v in value.items()}
160 if isinstance(value, (int, float, str, bool)) or value is None:
161 return value
162 for attr in ("tolist", "item"):
163 if hasattr(value, attr):
164 try:
165 return _jsonable(getattr(value, attr)(), limit)
166 except Exception:
167 pass
168 return repr(value)
169
170try:
171 import soma
172except ImportError as e:
173 _fail("soma is not importable by %s: %s" % (sys.executable, e),
174 "Install it in the interpreter the server uses, or set SOMA_PYTHON "
175 "to one that has it.")
176
177try:
178 if spec["kind"] == "pipeline":
179 g = _build(spec)
180 payload = {"plan": str(g.compile())}
181 if spec.get("track", True):
182 with g.track_run(spec.get("name", "mcp-run"),
183 tags=list(spec.get("tags", [])),
184 params=spec.get("params") or None) as run:
185 out = _run_once(spec, g)
186 # Absolute: the contract is that a model with file tools
187 # can go and read the directory, and it does not share
188 # this subprocess's working directory.
189 payload["run_dir"] = os.path.abspath(run.dir)
190 else:
191 out = _run_once(spec, g)
192 payload["ok"] = True
193 payload["output"] = _jsonable(out)
194 payload["state"] = _jsonable(g.state())
195 payload["mermaid"] = g.to_mermaid()
196 else:
197 g = _build(spec)
198 space = g.search_space()
199 if not space:
200 _fail("this graph has no search space: no node config used "
201 '{"__search__": {...}}, so every trial would be identical',
202 "Mark the dimensions to search in the node configs.")
203 metric = spec.get("metric", "score")
204 study = soma.Study(
205 spec.get("name", "mcp-study"),
206 search_space=space,
207 strategy=spec.get("strategy", "random"),
208 n_trials=int(spec.get("n_trials", 10)),
209 objectives=[(metric, spec.get("direction", "minimize"))],
210 seed=spec.get("seed"),
211 )
212
213 def executor(trial):
214 # A trial names its dimensions `node.field` — the same names
215 # `search_space()` produced — so the graph is rebuilt with the
216 # sampled values in place rather than mutated afterwards.
217 overrides = {}
218 for key in trial.keys():
219 node_id, _, field = key.rpartition(".")
220 overrides.setdefault(node_id, {})[field] = trial[key]
221 out = _run_once(spec, _build(spec, overrides))
222 value = out
223 if isinstance(value, dict):
224 if metric not in value:
225 raise KeyError(
226 "the graph produced %s, which has no %r to optimize. "
227 "Name the metric your last node emits, or end the graph "
228 "with one that emits it (soma.library.Eval does)."
229 % (sorted(value), metric))
230 value = value[metric]
231 if isinstance(value, list):
232 if len(value) != 1:
233 raise ValueError(
234 "the graph produced %d values; an objective needs one. "
235 "End it with a node that reduces to a single number."
236 % len(value))
237 value = value[0]
238 return {metric: float(value)}
239
240 study.run(executor)
241 payload = {
242 "ok": True,
243 "n_trials": study.n_trials,
244 "best_trial": _jsonable(study.best_trial),
245 "trials": _jsonable(study.trials),
246 "run_dir": os.path.abspath(study.run_dir) if study.run_dir else None,
247 "objectives": [list(o) for o in study.objectives],
248 }
249 with open(result_path, "w") as fh:
250 json.dump(payload, fh)
251except BaseException as e:
252 _fail("%s: %s" % (type(e).__name__, e), traceback.format_exc())
253"#;
254
255pub struct GraphRunner {
257 project_dir: PathBuf,
258 python: String,
259}
260
261impl GraphRunner {
262 pub fn new(project_dir: impl Into<PathBuf>) -> Self {
269 Self {
270 project_dir: project_dir.into(),
271 python: std::env::var("SOMA_PYTHON").unwrap_or_else(|_| "python3".into()),
272 }
273 }
274
275 pub fn run(&self, spec: &serde_json::Value) -> Result<serde_json::Value, String> {
282 let result_file = std::env::temp_dir().join(format!(
283 "soma-mcp-{}-{}.json",
284 std::process::id(),
285 somatize_core::util::timestamp_id("r")
286 ));
287 let mut spec = spec.clone();
288 spec["result_path"] = serde_json::json!(result_file.to_string_lossy());
289 spec["filter_files"] = serde_json::json!(self.filter_files());
290
291 let mut child = Command::new(&self.python)
292 .arg("-c")
293 .arg(DRIVER)
294 .current_dir(&self.project_dir)
295 .stdin(Stdio::piped())
296 .stdout(Stdio::piped())
297 .stderr(Stdio::piped())
298 .spawn()
299 .map_err(|e| {
300 format!(
301 "cannot start `{}`: {e}. Set SOMA_PYTHON to an interpreter \
302 that can import soma",
303 self.python
304 )
305 })?;
306
307 {
308 let stdin = child
309 .stdin
310 .as_mut()
311 .ok_or_else(|| "the driver has no stdin".to_string())?;
312 stdin
313 .write_all(spec.to_string().as_bytes())
314 .map_err(|e| format!("sending the spec to the driver: {e}"))?;
315 }
316
317 let out = child
318 .wait_with_output()
319 .map_err(|e| format!("waiting for the driver: {e}"))?;
320 let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
321 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
322
323 let raw = std::fs::read_to_string(&result_file).map_err(|_| {
324 format!(
325 "the driver wrote no result (exit {}).\nstderr:\n{}\nstdout:\n{}",
326 out.status,
327 if stderr.is_empty() {
328 "(empty)"
329 } else {
330 &stderr
331 },
332 if stdout.is_empty() {
333 "(empty)"
334 } else {
335 &stdout
336 },
337 )
338 })?;
339 let _ = std::fs::remove_file(&result_file);
340
341 let mut payload: serde_json::Value = serde_json::from_str(&raw)
342 .map_err(|e| format!("the driver's result is not JSON ({e}): {raw}"))?;
343 if !stdout.is_empty() {
346 payload["stdout"] = serde_json::json!(stdout);
347 }
348 if !stderr.is_empty() {
349 payload["stderr"] = serde_json::json!(stderr);
350 }
351 Ok(payload)
352 }
353
354 fn filter_files(&self) -> Vec<String> {
356 crate::context::find_filter_files(&self.project_dir)
357 .unwrap_or_default()
358 .iter()
359 .map(|p| relative_to(p, &self.project_dir))
360 .collect()
361 }
362}
363
364fn relative_to(path: &Path, base: &Path) -> String {
366 path.strip_prefix(base)
367 .unwrap_or(path)
368 .to_string_lossy()
369 .into_owned()
370}