Skip to main content

somatize_worker/
python_process.rs

1//! Python subprocess — persistent daemon for filter execution.
2//!
3//! Spawns a Python child process that loads filters via cloudpickle
4//! and executes fit/forward commands via stdin/stdout JSON Lines.
5//! The GIL is completely isolated from the Rust process — no segfaults.
6
7use 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
18/// The Python daemon script, embedded as a Rust string.
19const 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
537/// A persistent Python child process that executes filter commands.
538pub struct PythonProcess {
539    child: Child,
540    stdin: BufWriter<ChildStdin>,
541    stdout: BufReader<ChildStdout>,
542    node_ids: Vec<String>,
543}
544
545impl PythonProcess {
546    /// Spawn a Python daemon and load filters into it.
547    pub fn spawn(
548        python_path: &str,
549        filters: &[(String, Vec<u8>, bool)], // (node_id, pickled_bytes, trainable)
550    ) -> 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()) // Python stderr → worker stderr (for logs/tracing)
556            .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        // Send LOAD command with all filters
582        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    /// Send a JSON command and read the JSON response.
610    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    /// Convert a response to a Value, handling errors.
671    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    /// Convert a JSON value to a Soma Value.
691    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            // Could be nested array
701            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        // A bare string from Python becomes text unless it parses as JSON —
717        // byte-for-byte the rule `py_to_value` applies in-process. The two
718        // paths must agree: the same filter run locally and on a worker has
719        // to produce the same `Value`, or its cache key changes with where
720        // it ran.
721        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    /// Encode a Value to JSON for the Python process.
731    fn value_to_json(v: &Value) -> serde_json::Value {
732        serde_json::to_value(v).unwrap_or(serde_json::Value::Null)
733    }
734
735    // ── Public API ──
736
737    /// Fit the filter loaded under `node_id` on `data` (and optional
738    /// labels `y`), returning what its `fit` returned — the trained state.
739    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    /// Run the filter's `forward` on `data` with a previously trained
753    /// `state`, returning its output.
754    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    /// Fit a chain of filters in one command: each trainable filter fits,
765    /// then forwards to feed the next; if torch is importable the daemon
766    /// follows with one autograd forward/backward pass over the chain.
767    /// Returns the chain's output plus each node's serialized state
768    /// (torch `state_dict` bytes when available, cloudpickle otherwise).
769    /// One round-trip — intermediate values never cross the process
770    /// boundary, and the autograd graph stays whole.
771    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    /// Batched fit: send full dataset + batch_size, daemon splits internally.
803    /// Model loaded ONCE, batches processed in a loop.
804    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    /// Forward `data` through a chain of filters, in order, inside one
835    /// command — the composite counterpart of [`PythonProcess::forward`].
836    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    /// Extract one filter's state.
846    ///
847    /// A materialized `DifferentiableFilter` answers with its own state
848    /// convention — `Value::Json({"weights_b64": …})`, the dict its
849    /// `forward` reads back and the local fit path writes — so a state
850    /// read off a worker is loadable by a local graph. Anything else is
851    /// opaque bytes: a torch `state_dict` when the filter has one, the
852    /// cloudpickled filter otherwise.
853    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    /// Load what [`PythonProcess::get_state`] produced back into the
869    /// filter — how FedAvg-style aggregated states reach a worker.
870    ///
871    /// Mirrors `get_state` in both of its forms: a `Value::Json` state goes
872    /// as-is (and `{"weights_b64": …}` is loaded into the filter's module),
873    /// bytes go base64. Anything else is an encoding error.
874    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    /// Collect the filter's current gradients, one nested list per named
903    /// parameter, for AllReduce aggregation.
904    ///
905    /// Returns `Value::Json({param_name: nested list})` rather than the
906    /// torch pickle this used to send. The aggregator is in Rust
907    /// ([`somatize_runtime::strategy`]), and a pickle is opaque to it: the
908    /// average of two `Value::Bytes` blobs is not a thing that can be
909    /// computed, so the round died at the aggregation step having done all
910    /// the work. Plain JSON also makes the average independent of the
911    /// torch version each worker happens to have installed.
912    ///
913    /// A filter with no parameters, or no gradient on them, is an **error**
914    /// rather than `Value::Empty`. Returning empty meant the average was
915    /// taken over nothing and applied as nothing, and the round reported
916    /// success — a data-parallel step that trained no one. The daemon says
917    /// which of the three it is; this passes that on.
918    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    /// Hand aggregated gradients (the post-AllReduce mean of what
931    /// [`PythonProcess::get_gradients`] returned) to the filter: the daemon
932    /// writes them onto the matching parameters and steps the optimizer, so
933    /// the replica actually moves.
934    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        // The reply used to be discarded, so every failure the daemon
949        // reported here — a shape mismatch, a model that is not the same
950        // model — was a successful round that changed nothing.
951        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    /// Ask the daemon to exit its command loop. Best-effort — the reply
959    /// is ignored, and [`Drop`] kills the child regardless.
960    pub fn shutdown(&mut self) {
961        let _ = self.send(serde_json::json!({"cmd": "SHUTDOWN"}));
962    }
963
964    /// The node ids of the filters loaded into this process, in load order.
965    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
978// ── SubprocessFilter: implements Filter trait via PythonProcess ──
979
980/// A filter that delegates to a shared PythonProcess via stdin/stdout.
981/// Multiple SubprocessFilters can share the same process (`Arc<Mutex>`).
982pub struct SubprocessFilter {
983    pub(crate) process: Arc<Mutex<PythonProcess>>,
984    node_id: String,
985    trainable: bool,
986    /// Real config hash — must reflect the filter's configuration, never
987    /// just the node id (two configs under the same node id must not
988    /// share cache entries).
989    config_hash: CacheKey,
990}
991
992impl SubprocessFilter {
993    /// A proxy for the filter loaded under `node_id` in `process` —
994    /// shared, so every sibling filter of one plan talks to the same
995    /// interpreter.
996    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    /// Fallback identity for payloads that carry no explicit config hash:
1011    /// hash the pickled filter bytes — any config change changes the
1012    /// pickle, so stale cache hits are still impossible (the pickle is
1013    /// merely less stable across environments than a real config hash).
1014    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
1019/// The seam.
1020///
1021/// `Filter` is a `soma-core` trait, so these three return `SomaError`
1022/// while everything behind them is typed as a [`WorkerError`]. A subprocess
1023/// that died and a payload that would not decode stay distinguishable
1024/// right up to here, which is as far as the shared type can carry them.
1025impl 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        // Subprocess transport serialises the node_ids only — other filters
1071        // aren't shipped; the worker already has them deserialised from the
1072        // preceding prepare step.
1073        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}