1use crate::error::{Result, WorkerError};
8use base64::engine::{Engine, general_purpose::STANDARD};
9use somatize_core::cache::CacheKey;
10use somatize_core::error::SomaError;
11use somatize_core::filter::{Filter, FilterKind, FilterMeta, StreamMode};
12use somatize_core::value::Value;
13use std::collections::HashMap;
14use std::io::{BufRead, BufReader, BufWriter, Write};
15use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
16use std::sync::{Arc, Mutex};
17
18const DAEMON_SCRIPT: &str = r#"
20import json, sys, base64, cloudpickle, io, pickle
21
22# stdout is the protocol. A `print` inside a user's filter — the most
23# natural thing in the world to write while debugging one — landed in the
24# middle of the JSON dialogue and the worker died parsing its own reply.
25# The protocol keeps the real handle; everything else is sent to stderr,
26# where the worker already forwards it.
27_protocol = sys.stdout
28sys.stdout = sys.stderr
29
30def _reply(payload):
31 print(json.dumps(payload), file=_protocol, flush=True)
32
33filters = {}
34
35def _unwrap(out):
36 """A filter's forward may return `(out, aux)`; the chain wants both."""
37 if isinstance(out, tuple) and len(out) == 2 and isinstance(out[1], dict):
38 return out[0], out[1]
39 return out, {}
40
41def _backward_pass(f, data, y):
42 """Forward, loss, backward — leaving the gradients on the parameters.
43
44 This is what a *remote* fit of a DifferentiableFilter has to do and did
45 not: its `fit` learns no state (the parameters live in `_module`), so a
46 worker ran `fit`, got `{}` back, and reported a trained model whose
47 parameters had never seen a gradient. `data_parallel` then averaged
48 nothing across replicas.
49
50 Deliberately does NOT step. Whoever owns the round owns the step: in a
51 data-parallel round the gradients are averaged across replicas first,
52 and stepping here would apply each replica's own gradient before the
53 average.
54
55 Returns the filter's state, or None when it is not a differentiable
56 filter and this does not apply.
57 """
58 module = getattr(f, "_module", None)
59 if module is None and not getattr(f, "_differentiable", False):
60 return None
61 import torch
62 was_training = getattr(f, "training", False)
63 f.training = True # so forward returns a live tensor, not a list
64 try:
65 out, aux = _unwrap(f.forward(data, {}))
66 if not hasattr(out, "backward"):
67 return None
68 y_t = y if hasattr(y, "shape") else torch.tensor(y, dtype=torch.float32)
69 if y_t.shape != out.shape and y_t.numel() == out.numel():
70 y_t = y_t.reshape(out.shape)
71 if y_t.shape != out.shape:
72 raise ValueError(
73 "the targets have shape %s and the output %s; they cannot be "
74 "paired" % (tuple(y_t.shape), tuple(out.shape)))
75 loss = f.compute_loss(out, y_t, aux) if hasattr(f, "compute_loss") \
76 else torch.nn.functional.mse_loss(out, y_t)
77 loss.backward()
78 finally:
79 f.training = was_training
80 module = getattr(f, "_module", None)
81 if module is None:
82 return None
83 buf = io.BytesIO()
84 torch.save(module.state_dict(), buf)
85 return {"weights_b64": base64.b64encode(buf.getvalue()).decode()}
86
87def _encode(obj):
88 """Encode a Python object to JSON-safe format."""
89 if obj is None:
90 return None
91 if isinstance(obj, (list, int, float, str, bool)):
92 return obj
93 if isinstance(obj, dict):
94 return {k: _encode(v) for k, v in obj.items()}
95 # Fall back to pickle + base64
96 return {"__pickle_b64__": base64.b64encode(pickle.dumps(obj)).decode()}
97
98def _decode(obj):
99 """Decode from JSON-safe format back to Python object."""
100 if obj is None:
101 return None
102 if isinstance(obj, dict):
103 if "__pickle_b64__" in obj:
104 return pickle.loads(base64.b64decode(obj["__pickle_b64__"]))
105 if "type" in obj and "data" in obj:
106 # Soma Value format
107 t, d = obj["type"], obj["data"]
108 if t == "Tensor":
109 # The shape travels with the values and used to be dropped
110 # here, so a (8, 2) tensor reached the filter as 16 loose
111 # floats. Anything that reads `x.shape[1:]` — every
112 # DifferentiableFilter, when it sizes its module — then saw
113 # an empty tuple and died with "tuple index out of range",
114 # seven layers from the cause.
115 vals, shape = d.get("values", []), d.get("shape") or []
116 if len(shape) <= 1:
117 return vals
118 def _nest(flat, dims):
119 if len(dims) == 1:
120 return list(flat)
121 step = 1
122 for k in dims[1:]:
123 step *= k
124 return [_nest(flat[i * step:(i + 1) * step], dims[1:])
125 for i in range(dims[0])]
126 return _nest(vals, list(shape))
127 if t == "Text":
128 return d
129 if t == "Json":
130 return d
131 if t == "Empty":
132 return {}
133 if t == "Bytes":
134 return bytes(d)
135 if t == "Object":
136 return pickle.loads(bytes(d))
137 return {k: _decode(v) for k, v in obj.items()}
138 if isinstance(obj, list):
139 return [_decode(v) for v in obj]
140 return obj
141
142for line in sys.stdin:
143 line = line.strip()
144 if not line:
145 continue
146 try:
147 cmd = json.loads(line)
148 except json.JSONDecodeError as e:
149 _reply(({"ok": False, "error": f"invalid JSON: {e}"}))
150 continue
151
152 try:
153 action = cmd.get("cmd", "")
154
155 if action == "LOAD":
156 for f in cmd["filters"]:
157 obj = cloudpickle.loads(base64.b64decode(f["pickle_b64"]))
158 filters[f["id"]] = {"obj": obj, "trainable": f.get("trainable", True)}
159 _reply(({"ok": True}))
160
161 elif action == "FIT":
162 f = filters[cmd["node_id"]]["obj"]
163 data = _decode(cmd.get("data"))
164 y = _decode(cmd.get("y"))
165 result = f.fit(data, y)
166 if y is not None:
167 trained = _backward_pass(f, data, y)
168 if trained is not None:
169 result = trained
170 _reply(({"ok": True, "result": _encode(result)}))
171
172 elif action == "FORWARD":
173 f = filters[cmd["node_id"]]["obj"]
174 data = _decode(cmd.get("data"))
175 state = _decode(cmd.get("state", {}))
176 result = f.forward(data, state)
177 _reply(({"ok": True, "result": _encode(result)}))
178
179 elif action == "COMPOSITE_FORWARD":
180 node_ids = cmd["node_ids"]
181 data = _decode(cmd.get("data"))
182 try:
183 import torch
184 if isinstance(data, list):
185 x = torch.tensor(data, dtype=torch.float32, requires_grad=True)
186 else:
187 x = data
188 except ImportError:
189 x = data
190
191 out = x
192 for nid in node_ids:
193 f = filters[nid]["obj"]
194 state = _decode(cmd.get("states", {}).get(nid, {}))
195 out, _ = _unwrap(f.forward(out, state))
196
197 result = out.detach().tolist() if hasattr(out, 'detach') else out
198 _reply(({"ok": True, "result": _encode(result)}))
199
200 elif action == "COMPOSITE_FIT":
201 node_ids = cmd["node_ids"]
202 data = _decode(cmd.get("data"))
203 y = _decode(cmd.get("y"))
204
205 # Step 1: fit each trainable filter to get states
206 fit_states = {}
207 fit_input = data
208 for nid in node_ids:
209 f = filters[nid]["obj"]
210 if filters[nid].get("trainable", True):
211 state = f.fit(fit_input, y)
212 fit_states[nid] = state
213 else:
214 fit_states[nid] = {}
215 # Forward to propagate output to next filter. `forward`
216 # may answer `(out, aux)` — chaining the tuple fed the next
217 # filter a 2-tuple where it wanted a tensor.
218 fit_input, _ = _unwrap(f.forward(fit_input, fit_states[nid]))
219
220 # Step 2: forward with autograd if torch available
221 try:
222 import torch
223 if isinstance(data, list):
224 x = torch.tensor(data, dtype=torch.float32, requires_grad=True)
225 else:
226 x = data
227 except ImportError:
228 x = data
229
230 out = x
231 aux = {}
232 for nid in node_ids:
233 f = filters[nid]["obj"]
234 # Training mode, so a DifferentiableFilter hands back a live
235 # tensor rather than a detached list — without this the
236 # `hasattr(out, 'backward')` below was never true and the
237 # whole backward block was dead code.
238 _was = getattr(f, "training", False)
239 f.training = True
240 try:
241 out, aux = _unwrap(f.forward(out, fit_states.get(nid, {})))
242 finally:
243 f.training = _was
244
245 # Backward. This used to be wrapped in `except Exception: pass`,
246 # so a loss that could not be computed — wrong shape, wrong dtype,
247 # a `compute_loss` that raised — produced a fit that reported
248 # success and had trained nothing. A backward that cannot run is
249 # an error; the parameters are the whole point of being here.
250 #
251 # The gradients are deliberately LEFT on the parameters. A
252 # data-parallel round reads them with GET_GRADIENTS after this
253 # returns, averages them across replicas, and steps in
254 # APPLY_GRADIENTS. Stepping here as well would apply each
255 # replica's own gradient before the average, which is not
256 # data-parallel SGD and not anything else either.
257 if y is not None and hasattr(out, 'backward'):
258 last = filters[node_ids[-1]]["obj"]
259 try:
260 import torch
261 except ImportError:
262 _reply(({"ok": False, "error":
263 "composite fit of %s produced a differentiable output "
264 "but torch is not importable" % node_ids}))
265 continue
266 if isinstance(y, list):
267 y_t = torch.tensor(y, dtype=torch.float32)
268 else:
269 y_t = y
270 try:
271 # `compute_loss` is the DifferentiableFilter contract;
272 # `loss_fn` is the older duck-typed attribute, still read
273 # so a filter that predates the base class keeps working.
274 if hasattr(last, 'compute_loss'):
275 loss = last.compute_loss(out, y_t, aux)
276 elif hasattr(last, 'loss_fn'):
277 loss = last.loss_fn(out, y_t)
278 else:
279 loss = torch.nn.functional.mse_loss(out, y_t)
280 loss.backward()
281 except Exception as e:
282 _reply(({"ok": False, "error":
283 "composite fit of %s: the backward pass failed: %s: %s"
284 % (node_ids, type(e).__name__, e)}))
285 continue
286 # A filter carrying its own optimizer keeps its own loop: it
287 # is not part of a gradient-averaging round.
288 for nid in node_ids:
289 f = filters[nid]["obj"]
290 if hasattr(f, 'optimizer'):
291 f.optimizer.step()
292 f.optimizer.zero_grad()
293
294 states = {}
295 for nid in node_ids:
296 f = filters[nid]["obj"]
297 if hasattr(f, 'state_dict'):
298 buf = io.BytesIO()
299 try:
300 import torch
301 torch.save(f.state_dict(), buf)
302 except ImportError:
303 buf.write(cloudpickle.dumps(f))
304 states[nid] = base64.b64encode(buf.getvalue()).decode()
305
306 result = out.detach().tolist() if hasattr(out, 'detach') else out
307 _reply(({"ok": True, "result": _encode(result), "states": states}))
308
309 elif action == "GET_STATE":
310 nid = cmd["node_id"]
311 f = filters[nid]["obj"]
312 _mod = getattr(f, "_module", None)
313 if _mod is not None and hasattr(_mod, "state_dict"):
314 # A DifferentiableFilter is not itself an nn.Module, so the
315 # branch below would cloudpickle the whole filter object —
316 # a state no local graph could load. Its state is the
317 # `{"weights_b64": …}` dict its own `forward` reads back and
318 # the local fit path writes, so send exactly that.
319 import torch
320 buf = io.BytesIO()
321 torch.save(_mod.state_dict(), buf)
322 _reply(({"ok": True, "state": {
323 "weights_b64": base64.b64encode(buf.getvalue()).decode()}}))
324 continue
325 buf = io.BytesIO()
326 if hasattr(f, 'state_dict'):
327 try:
328 import torch
329 torch.save(f.state_dict(), buf)
330 except ImportError:
331 buf.write(cloudpickle.dumps(f))
332 else:
333 buf.write(cloudpickle.dumps(f))
334 state_b64 = base64.b64encode(buf.getvalue()).decode()
335 _reply(({"ok": True, "state_b64": state_b64}))
336
337 elif action == "SET_STATE":
338 nid = cmd["node_id"]
339 f = filters[nid]["obj"]
340 _state = cmd.get("state")
341 if isinstance(_state, dict) and "weights_b64" in _state:
342 # The symmetric read of the GET_STATE branch above.
343 import torch
344 _mod = getattr(f, "_module", None)
345 if _mod is None:
346 _reply(({"ok": False, "error":
347 "`%s` was sent weights but has no module to load them "
348 "into: it was never materialized on this worker" % nid}))
349 continue
350 _mod.load_state_dict(torch.load(
351 io.BytesIO(base64.b64decode(_state["weights_b64"])),
352 weights_only=True))
353 _reply(({"ok": True}))
354 continue
355 state_bytes = base64.b64decode(cmd["state_b64"])
356 buf = io.BytesIO(state_bytes)
357 if hasattr(f, 'load_state_dict'):
358 try:
359 import torch
360 f.load_state_dict(torch.load(buf, weights_only=True))
361 except ImportError:
362 filters[nid]["obj"] = cloudpickle.loads(buf.read())
363 else:
364 filters[nid]["obj"] = cloudpickle.loads(buf.read())
365 _reply(({"ok": True}))
366
367 elif action == "GET_GRADIENTS":
368 nid = cmd["node_id"]
369 f = filters[nid]["obj"]
370 # A DifferentiableFilter is not itself an nn.Module: it builds
371 # one and keeps it in `_module`. Looking only at `f` found no
372 # parameters and returned an EMPTY buffer, so AllReduce averaged
373 # nothing and the round reported success — the gradients simply
374 # never left the worker.
375 module = getattr(f, "_module", None) or f
376 if not hasattr(module, "named_parameters"):
377 _reply(({"ok": False, "error":
378 "`%s` has no parameters: it is neither an nn.Module nor a "
379 "materialized DifferentiableFilter, so there are no "
380 "gradients to read" % nid}))
381 continue
382 try:
383 import torch
384 except ImportError:
385 _reply(({"ok": False, "error":
386 "`%s` cannot produce gradients: torch is not installed in "
387 "this worker's environment" % nid}))
388 continue
389 # Nested lists, not a torch pickle. The aggregator that averages
390 # these lives in Rust, and a pickle is opaque to it: AllReduce
391 # over two `Value::Bytes` blobs could only refuse. Plain JSON is
392 # also what makes the average independent of the torch version
393 # each worker happens to have.
394 grads = {n: p.grad.detach().cpu().tolist()
395 for n, p in module.named_parameters() if p.grad is not None}
396 if not grads:
397 _reply(({"ok": False, "error":
398 "`%s` has parameters but none carry a gradient. Run a "
399 "backward pass before asking for them" % nid}))
400 continue
401 _reply(({"ok": True, "gradients": grads}))
402
403 elif action == "APPLY_GRADIENTS":
404 nid = cmd["node_id"]
405 f = filters[nid]["obj"]
406 module = getattr(f, "_module", None) or f
407 if not hasattr(module, "named_parameters"):
408 _reply(({"ok": False, "error":
409 "`%s` has no parameters to apply gradients to" % nid}))
410 continue
411 try:
412 import torch
413 except ImportError:
414 _reply(({"ok": False, "error":
415 "`%s` cannot take gradients: torch is not installed" % nid}))
416 continue
417 grads = cmd.get("gradients") or {}
418 applied = 0
419 mismatch = None
420 for name, p in module.named_parameters():
421 if name not in grads:
422 continue
423 g = torch.tensor(grads[name], dtype=p.dtype, device=p.device)
424 if tuple(g.shape) != tuple(p.shape):
425 mismatch = ("`%s`: aggregated gradient for `%s` has shape %s, "
426 "the parameter has %s"
427 % (nid, name, tuple(g.shape), tuple(p.shape)))
428 break
429 p.grad = g
430 applied += 1
431 if mismatch is not None:
432 _reply(({"ok": False, "error": mismatch}))
433 continue
434 if applied == 0 and grads:
435 _reply(({"ok": False, "error":
436 "`%s`: none of the %d aggregated gradients matched a "
437 "parameter name. The replicas are not the same model"
438 % (nid, len(grads))}))
439 continue
440 # Applying an averaged gradient and stopping there would leave
441 # every replica exactly where the round started: data-parallel
442 # SGD *is* the step. The optimizer is built once and kept on the
443 # filter, so its moments survive across rounds — an Adam rebuilt
444 # every round is plain SGD wearing its name.
445 stepped = False
446 if applied and hasattr(f, "make_optimizer"):
447 opt = getattr(f, "_soma_dp_optimizer", None)
448 if opt is None:
449 opt = f.make_optimizer([module])
450 f._soma_dp_optimizer = opt
451 opt.step()
452 opt.zero_grad()
453 stepped = True
454 _reply(({"ok": True, "applied": applied, "stepped": stepped}))
455
456 elif action == "BATCHED_FIT":
457 # Process dataset in batches — model loaded ONCE, batches processed in loop
458 node_ids = cmd["node_ids"]
459 data = _decode(cmd.get("data"))
460 y = _decode(cmd.get("y"))
461 batch_size = cmd.get("batch_size", 32)
462
463 # Find the list dimension to batch on
464 if isinstance(data, dict):
465 list_keys = [k for k, v in data.items() if isinstance(v, list)]
466 total = len(data[list_keys[0]]) if list_keys else 0
467 elif isinstance(data, list):
468 total = len(data)
469 else:
470 total = 0
471
472 all_states = {}
473 n_batches = (total + batch_size - 1) // batch_size if total > 0 else 1
474
475 for b in range(n_batches):
476 start = b * batch_size
477 end = min(start + batch_size, total)
478
479 # Slice the batch
480 if isinstance(data, dict):
481 batch = {}
482 for k, v in data.items():
483 if isinstance(v, list):
484 batch[k] = v[start:end]
485 else:
486 batch[k] = v
487 elif isinstance(data, list):
488 batch = data[start:end]
489 else:
490 batch = data
491
492 y_batch = None
493 if y is not None:
494 if isinstance(y, list):
495 y_batch = y[start:end]
496 elif isinstance(y, dict):
497 y_batch = {k: (v[start:end] if isinstance(v, list) else v) for k, v in y.items()}
498 else:
499 y_batch = y
500
501 # Fit + forward for this batch through all filters
502 batch_input = batch
503 for nid in node_ids:
504 f = filters[nid]["obj"]
505 if filters[nid].get("trainable", True):
506 state = f.fit(batch_input, y_batch)
507 all_states[nid] = state
508 else:
509 if nid not in all_states:
510 all_states[nid] = {}
511 batch_input = f.forward(batch_input, all_states.get(nid, {}))
512
513 import sys
514 print(f" Batch {b+1}/{n_batches} complete", file=sys.stderr)
515
516 # Encode final states
517 encoded_states = {}
518 for nid, state in all_states.items():
519 encoded_states[nid] = _encode(state)
520
521 result = _encode(batch_input) if batch_input is not None else None
522 _reply(({"ok": True, "result": result, "states": encoded_states}))
523
524 elif action == "SHUTDOWN":
525 _reply(({"ok": True}))
526 break
527
528 else:
529 _reply(({"ok": False, "error": f"unknown command: {action}"}))
530
531 except Exception as e:
532 import traceback
533 tb = traceback.format_exc()
534 _reply(({"ok": False, "error": str(e), "traceback": tb}))
535"#;
536
537pub struct PythonProcess {
539 child: Child,
540 stdin: BufWriter<ChildStdin>,
541 stdout: BufReader<ChildStdout>,
542 node_ids: Vec<String>,
543}
544
545impl PythonProcess {
546 pub fn spawn(
548 python_path: &str,
549 filters: &[(String, Vec<u8>, bool)], ) -> Result<Self> {
551 let mut child = Command::new(python_path)
552 .args(["-c", DAEMON_SCRIPT])
553 .stdin(Stdio::piped())
554 .stdout(Stdio::piped())
555 .stderr(Stdio::inherit()) .spawn()
557 .map_err(|e| WorkerError::Python(format!("failed to spawn python: {e}")))?;
558
559 let stdin = BufWriter::new(
560 child
561 .stdin
562 .take()
563 .ok_or_else(|| WorkerError::Python("no stdin".into()))?,
564 );
565 let stdout = BufReader::new(
566 child
567 .stdout
568 .take()
569 .ok_or_else(|| WorkerError::Python("no stdout".into()))?,
570 );
571
572 let node_ids: Vec<String> = filters.iter().map(|(id, _, _)| id.clone()).collect();
573
574 let mut proc = Self {
575 child,
576 stdin,
577 stdout,
578 node_ids,
579 };
580
581 let filter_specs: Vec<serde_json::Value> = filters
583 .iter()
584 .map(|(id, pickled, trainable)| {
585 serde_json::json!({
586 "id": id,
587 "pickle_b64": STANDARD.encode(pickled),
588 "trainable": trainable,
589 })
590 })
591 .collect();
592
593 let resp = proc.send(serde_json::json!({
594 "cmd": "LOAD",
595 "filters": filter_specs,
596 }))?;
597
598 if resp.get("ok") != Some(&serde_json::Value::Bool(true)) {
599 let error = resp
600 .get("error")
601 .and_then(|e| e.as_str())
602 .unwrap_or("unknown error");
603 return Err(WorkerError::Python(format!("LOAD failed: {error}")));
604 }
605
606 Ok(proc)
607 }
608
609 fn send(&mut self, cmd: serde_json::Value) -> Result<serde_json::Value> {
611 let action = cmd
612 .get("cmd")
613 .and_then(|c| c.as_str())
614 .unwrap_or("?")
615 .to_string();
616 let node_id = cmd
617 .get("node_id")
618 .and_then(|n| n.as_str())
619 .unwrap_or("")
620 .to_string();
621
622 tracing::debug!(action = %action, node_id = %node_id, "→ Python");
623 let start = std::time::Instant::now();
624
625 let line = serde_json::to_string(&cmd)
626 .map_err(|e| WorkerError::Encoding(format!("serialize cmd: {e}")))?;
627
628 writeln!(self.stdin, "{line}")
629 .map_err(|e| WorkerError::Python(format!("write to python stdin: {e}")))?;
630 self.stdin
631 .flush()
632 .map_err(|e| WorkerError::Python(format!("flush stdin: {e}")))?;
633
634 let mut response = String::new();
635 self.stdout
636 .read_line(&mut response)
637 .map_err(|e| WorkerError::Python(format!("read from python stdout: {e}")))?;
638
639 let duration_ms = start.elapsed().as_millis();
640
641 if response.is_empty() {
642 tracing::error!(action = %action, "Python process closed stdout (crashed?)");
643 return Err(WorkerError::Python(
644 "python process closed stdout (crashed?)".into(),
645 ));
646 }
647
648 let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| {
649 WorkerError::Python(format!("parse python response: {e}\nraw: {response}"))
650 })?;
651
652 let ok = parsed.get("ok") == Some(&serde_json::Value::Bool(true));
653 if ok {
654 tracing::debug!(action = %action, node_id = %node_id, duration_ms, "← Python OK");
655 } else {
656 let error = parsed.get("error").and_then(|e| e.as_str()).unwrap_or("?");
657 let traceback = parsed
658 .get("traceback")
659 .and_then(|t| t.as_str())
660 .unwrap_or("");
661 tracing::error!(action = %action, node_id = %node_id, error, "Python filter error");
662 if !traceback.is_empty() {
663 tracing::error!("Python traceback:\n{traceback}");
664 }
665 }
666
667 Ok(parsed)
668 }
669
670 fn response_to_value(resp: &serde_json::Value) -> Result<Value> {
672 if resp.get("ok") != Some(&serde_json::Value::Bool(true)) {
673 let error = resp
674 .get("error")
675 .and_then(|e| e.as_str())
676 .unwrap_or("unknown error");
677 let traceback = resp.get("traceback").and_then(|t| t.as_str()).unwrap_or("");
678 return Err(WorkerError::Python(format!(
679 "Python error: {error}\n{traceback}"
680 )));
681 }
682
683 if let Some(result) = resp.get("result") {
684 return Self::json_to_value(result);
685 }
686
687 Ok(Value::Empty)
688 }
689
690 fn json_to_value(v: &serde_json::Value) -> Result<Value> {
692 if v.is_null() {
693 return Ok(Value::Empty);
694 }
695 if let Some(arr) = v.as_array() {
696 let values: Vec<f64> = arr.iter().filter_map(|x| x.as_f64()).collect();
697 if values.len() == arr.len() && !values.is_empty() {
698 return Ok(Value::tensor(values.clone(), vec![values.len()]));
699 }
700 if let Some(first) = arr.first()
702 && first.is_array()
703 {
704 let rows = arr.len();
705 let cols = first.as_array().map(|a| a.len()).unwrap_or(0);
706 let flat: Vec<f64> = arr
707 .iter()
708 .filter_map(|row| row.as_array())
709 .flat_map(|row| row.iter().filter_map(|x| x.as_f64()))
710 .collect();
711 if flat.len() == rows * cols {
712 return Ok(Value::tensor(flat, vec![rows, cols]));
713 }
714 }
715 }
716 if let Some(s) = v.as_str() {
722 return Ok(match serde_json::from_str(s) {
723 Ok(parsed) => Value::json(parsed),
724 Err(_) => Value::text(s),
725 });
726 }
727 Ok(Value::json(v.clone()))
728 }
729
730 fn value_to_json(v: &Value) -> serde_json::Value {
732 serde_json::to_value(v).unwrap_or(serde_json::Value::Null)
733 }
734
735 pub fn fit(&mut self, node_id: &str, data: &Value, y: Option<&Value>) -> Result<Value> {
740 let mut cmd = serde_json::json!({
741 "cmd": "FIT",
742 "node_id": node_id,
743 "data": Self::value_to_json(data),
744 });
745 if let Some(y_val) = y {
746 cmd["y"] = Self::value_to_json(y_val);
747 }
748 let resp = self.send(cmd)?;
749 Self::response_to_value(&resp)
750 }
751
752 pub fn forward(&mut self, node_id: &str, data: &Value, state: &Value) -> Result<Value> {
755 let resp = self.send(serde_json::json!({
756 "cmd": "FORWARD",
757 "node_id": node_id,
758 "data": Self::value_to_json(data),
759 "state": Self::value_to_json(state),
760 }))?;
761 Self::response_to_value(&resp)
762 }
763
764 pub fn composite_fit(
772 &mut self,
773 node_ids: &[String],
774 data: &Value,
775 y: Option<&Value>,
776 ) -> Result<(Value, HashMap<String, Value>)> {
777 let mut cmd = serde_json::json!({
778 "cmd": "COMPOSITE_FIT",
779 "node_ids": node_ids,
780 "data": Self::value_to_json(data),
781 });
782 if let Some(y_val) = y {
783 cmd["y"] = Self::value_to_json(y_val);
784 }
785 let resp = self.send(cmd)?;
786 let output = Self::response_to_value(&resp)?;
787
788 let mut states = HashMap::new();
789 if let Some(state_map) = resp.get("states").and_then(|s| s.as_object()) {
790 for (id, b64) in state_map {
791 if let Some(s) = b64.as_str() {
792 let bytes = STANDARD
793 .decode(s)
794 .map_err(|e| WorkerError::Encoding(format!("decode state: {e}")))?;
795 states.insert(id.clone(), Value::bytes(bytes));
796 }
797 }
798 }
799 Ok((output, states))
800 }
801
802 pub fn batched_fit(
805 &mut self,
806 node_ids: &[String],
807 data: &Value,
808 y: Option<&Value>,
809 batch_size: usize,
810 ) -> Result<(Value, HashMap<String, Value>)> {
811 let mut cmd = serde_json::json!({
812 "cmd": "BATCHED_FIT",
813 "node_ids": node_ids,
814 "data": Self::value_to_json(data),
815 "batch_size": batch_size,
816 });
817 if let Some(y_val) = y {
818 cmd["y"] = Self::value_to_json(y_val);
819 }
820 let resp = self.send(cmd)?;
821 let output = Self::response_to_value(&resp)?;
822
823 let mut states = HashMap::new();
824 if let Some(state_map) = resp.get("states").and_then(|s| s.as_object()) {
825 for (id, val) in state_map {
826 if let Ok(v) = Self::json_to_value(val) {
827 states.insert(id.clone(), v);
828 }
829 }
830 }
831 Ok((output, states))
832 }
833
834 pub fn composite_forward(&mut self, node_ids: &[String], data: &Value) -> Result<Value> {
837 let resp = self.send(serde_json::json!({
838 "cmd": "COMPOSITE_FORWARD",
839 "node_ids": node_ids,
840 "data": Self::value_to_json(data),
841 }))?;
842 Self::response_to_value(&resp)
843 }
844
845 pub fn get_state(&mut self, node_id: &str) -> Result<Value> {
854 let resp = self.send(serde_json::json!({"cmd": "GET_STATE", "node_id": node_id}))?;
855 if let Some(state) = resp.get("state") {
856 return Ok(Value::json(state.clone()));
857 }
858 if let Some(b64) = resp.get("state_b64").and_then(|s| s.as_str()) {
859 let bytes = STANDARD
860 .decode(b64)
861 .map_err(|e| WorkerError::Encoding(format!("decode state: {e}")))?;
862 Ok(Value::bytes(bytes))
863 } else {
864 Self::response_to_value(&resp)
865 }
866 }
867
868 pub fn set_state(&mut self, node_id: &str, state: &Value) -> Result<()> {
875 if let Value::Json(j) = state {
876 let resp = self.send(serde_json::json!({
877 "cmd": "SET_STATE", "node_id": node_id, "state": (**j).clone(),
878 }))?;
879 if resp.get("ok") != Some(&serde_json::Value::Bool(true)) {
880 let error = resp.get("error").and_then(|e| e.as_str()).unwrap_or("?");
881 return Err(WorkerError::Python(format!("set_state: {error}")));
882 }
883 return Ok(());
884 }
885 let b64 = match state {
886 Value::Bytes(b) => STANDARD.encode(b.as_slice()),
887 _ => {
888 return Err(WorkerError::Encoding(
889 "set_state expects Value::Bytes".into(),
890 ));
891 }
892 };
893 let resp = self
894 .send(serde_json::json!({"cmd": "SET_STATE", "node_id": node_id, "state_b64": b64}))?;
895 if resp.get("ok") != Some(&serde_json::Value::Bool(true)) {
896 let error = resp.get("error").and_then(|e| e.as_str()).unwrap_or("?");
897 return Err(WorkerError::Python(format!("set_state: {error}")));
898 }
899 Ok(())
900 }
901
902 pub fn get_gradients(&mut self, node_id: &str) -> Result<Value> {
919 let resp = self.send(serde_json::json!({"cmd": "GET_GRADIENTS", "node_id": node_id}))?;
920 if let Some(grads) = resp.get("gradients") {
921 return Ok(Value::json(grads.clone()));
922 }
923 let error = resp
924 .get("error")
925 .and_then(|e| e.as_str())
926 .unwrap_or("the worker returned no gradients and said nothing about why");
927 Err(WorkerError::Python(format!("get_gradients: {error}")))
928 }
929
930 pub fn apply_gradients(&mut self, node_id: &str, gradients: &Value) -> Result<()> {
935 let json = match gradients {
936 Value::Json(j) => (**j).clone(),
937 other => {
938 return Err(WorkerError::Python(format!(
939 "apply_gradients expects the JSON gradients get_gradients \
940 returns, got {}",
941 other.type_name()
942 )));
943 }
944 };
945 let resp = self.send(
946 serde_json::json!({"cmd": "APPLY_GRADIENTS", "node_id": node_id, "gradients": json}),
947 )?;
948 if resp.get("ok") != Some(&serde_json::Value::Bool(true)) {
952 let error = resp.get("error").and_then(|e| e.as_str()).unwrap_or("?");
953 return Err(WorkerError::Python(format!("apply_gradients: {error}")));
954 }
955 Ok(())
956 }
957
958 pub fn shutdown(&mut self) {
961 let _ = self.send(serde_json::json!({"cmd": "SHUTDOWN"}));
962 }
963
964 pub fn node_ids(&self) -> &[String] {
966 &self.node_ids
967 }
968}
969
970impl Drop for PythonProcess {
971 fn drop(&mut self) {
972 self.shutdown();
973 let _ = self.child.kill();
974 let _ = self.child.wait();
975 }
976}
977
978pub struct SubprocessFilter {
983 pub(crate) process: Arc<Mutex<PythonProcess>>,
984 node_id: String,
985 trainable: bool,
986 config_hash: CacheKey,
990}
991
992impl SubprocessFilter {
993 pub fn new(
997 process: Arc<Mutex<PythonProcess>>,
998 node_id: String,
999 trainable: bool,
1000 config_hash: CacheKey,
1001 ) -> Self {
1002 Self {
1003 process,
1004 node_id,
1005 trainable,
1006 config_hash,
1007 }
1008 }
1009
1010 pub fn fallback_config_hash(node_id: &str, pickled_filter: &[u8]) -> CacheKey {
1015 CacheKey::from_parts(&[b"subprocess-filter", node_id.as_bytes(), pickled_filter])
1016 }
1017}
1018
1019impl Filter for SubprocessFilter {
1026 fn config_hash(&self) -> CacheKey {
1027 self.config_hash.clone()
1028 }
1029
1030 fn fit(&self, x: &Value, y: Option<&Value>) -> somatize_core::error::Result<Value> {
1031 Ok(self
1032 .process
1033 .lock()
1034 .map_err(|e| WorkerError::Concurrency(format!("process mutex poisoned: {e}")))?
1035 .fit(&self.node_id, x, y)?)
1036 }
1037
1038 fn forward(&self, x: &Value, state: &Value) -> somatize_core::error::Result<Value> {
1039 Ok(self
1040 .process
1041 .lock()
1042 .map_err(|e| WorkerError::Concurrency(format!("process mutex poisoned: {e}")))?
1043 .forward(&self.node_id, x, state)?)
1044 }
1045
1046 fn meta(&self) -> FilterMeta {
1047 FilterMeta {
1048 name: self.node_id.clone(),
1049 kind: if self.trainable {
1050 FilterKind::Trainable
1051 } else {
1052 FilterKind::Stateless
1053 },
1054 cacheable: true,
1055 differentiable: self.trainable,
1056 deterministic: true,
1057 stream_mode: StreamMode::FixedState,
1058 distribution: somatize_core::filter::Distribution::Local,
1059 input_schema: None,
1060 output_schema: None,
1061 }
1062 }
1063
1064 fn composite_fit(
1065 &self,
1066 peers: &[(String, std::sync::Arc<dyn somatize_core::filter::Filter>)],
1067 x: &Value,
1068 y: Option<&Value>,
1069 ) -> Option<somatize_core::error::Result<(Value, HashMap<String, Value>)>> {
1070 let node_ids: Vec<String> = peers.iter().map(|(id, _)| id.clone()).collect();
1074 tracing::info!(nodes = ?node_ids, "Composite fit via subprocess");
1075 Some(
1076 self.process
1077 .lock()
1078 .map_err(|e| WorkerError::Concurrency(format!("process mutex poisoned: {e}")))
1079 .and_then(|mut proc| proc.composite_fit(&node_ids, x, y))
1080 .map_err(SomaError::from),
1081 )
1082 }
1083}