Skip to main content

somatize_worker/
protocol.rs

1//! Wire protocol for coordinator ↔ worker communication.
2//!
3//! Defines message types for plan assignment, results, heartbeats,
4//! Python job management, and worker capabilities.
5
6use crate::error::{Result, WorkerError};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use somatize_compiler::ExecutionPlan;
10use somatize_core::event::Event;
11use somatize_core::store::{DataRef, DataStore};
12use somatize_core::value::Value;
13
14/// Unique worker identifier.
15pub type WorkerId = String;
16
17/// Unique plan execution identifier.
18pub type PlanId = String;
19
20/// What this build speaks.
21///
22/// The wire had no version at all, while the two other formats this
23/// workspace persists — tracking records and experiment records — both
24/// carry one. A driver and a worker from different builds simply
25/// exchanged JSON and hoped: a field the receiver did not know was
26/// dropped by `#[serde(default)]`, so a plan compiled by a newer
27/// coordinator ran with pieces of it silently missing, and the failure
28/// surfaced as a wrong result rather than as a refusal.
29///
30/// Bump it whenever a change alters what a peer must understand to
31/// execute a plan correctly — not for a purely additive field that an
32/// older peer can safely ignore.
33pub const PROTOCOL_VERSION: u32 = 1;
34
35/// Version carried by a payload written before the field existed.
36///
37/// Distinct from 1 so a peer can tell "did not say" from "said 1".
38fn unversioned() -> u32 {
39    0
40}
41
42/// Hardware and software capabilities of a worker.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Capabilities {
45    /// Number of CPU cores.
46    pub cpu_cores: usize,
47    /// Total RAM in bytes.
48    pub ram_bytes: u64,
49    /// GPU information.
50    pub gpus: Vec<GpuInfo>,
51    /// Available Python environments.
52    pub python_envs: Vec<String>,
53    /// User-defined tags for routing (e.g. "gpu", "training", "inference").
54    pub tags: Vec<String>,
55}
56
57/// GPU hardware info.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct GpuInfo {
60    /// Device name as the driver reports it (e.g. "A100").
61    pub name: String,
62    /// Total device memory in bytes.
63    pub memory_bytes: u64,
64}
65
66/// Current load metrics reported by a worker.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct LoadMetrics {
69    /// CPU utilization across all cores, 0.0–1.0.
70    pub cpu_usage: f32,
71    /// RAM utilization as a fraction of total, 0.0–1.0.
72    pub memory_usage: f32,
73    /// Per-GPU utilization, in the same order as [`Capabilities::gpus`].
74    pub gpu_usage: Vec<f32>,
75    /// Plans currently executing.
76    pub active_plans: usize,
77    /// Plans accepted but not yet started.
78    pub queue_depth: usize,
79    /// When this snapshot was taken; heartbeats carry it so the
80    /// coordinator can tell a fresh reading from a stale one.
81    pub timestamp: DateTime<Utc>,
82}
83
84/// How input data is provided to a worker.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(tag = "source")]
87#[non_exhaustive]
88pub enum InputSource {
89    /// Data embedded directly in the message (small payloads).
90    Inline {
91        /// The value itself, carried in the message.
92        value: Value,
93    },
94    /// Data referenced in a remote store (large payloads).
95    Reference {
96        /// Where to fetch the value from; see [`InputSource::resolve`].
97        data_ref: DataRef,
98    },
99}
100
101impl InputSource {
102    /// Resolve the input to a concrete Value.
103    ///
104    /// Tries the persistent [`DataStore`] first, then the temp store that
105    /// HTTP uploads land in.
106    ///
107    /// [`DataStore`]: somatize_core::store::DataStore
108    ///
109    /// A reference that resolves nowhere is an **error**, and it did not
110    /// used to be: this logged a warning and returned [`Value::Empty`].
111    /// That value went on to the filter, so the failure surfaced as a
112    /// `TypeError` inside somebody's own `fit` — hundreds of thousands of
113    /// rows after the actual problem, and pointing at their code. The
114    /// usual cause is the asymmetry this error names: the client uploaded
115    /// to a store the worker was never given.
116    pub fn resolve(
117        &self,
118        data_store: Option<&dyn somatize_core::store::DataStore>,
119        temp_store: &somatize_core::store::LocalDataStore,
120    ) -> Result<Value> {
121        match self {
122            InputSource::Inline { value } => Ok(value.clone()),
123            InputSource::Reference { data_ref } => {
124                if let Some(store) = data_store
125                    && let Ok(val) = store.get(data_ref)
126                {
127                    return Ok(val);
128                }
129                temp_store.get(data_ref).map_err(|e| {
130                    let where_it_looked = if data_store.is_some() {
131                        "neither this worker's DataStore nor its temp store"
132                    } else {
133                        "this worker's temp store, and it has no DataStore \
134                         configured — a client that uploads to one must be \
135                         talking to a worker given the same one"
136                    };
137                    WorkerError::Transport(format!(
138                        "cannot resolve the input reference {data_ref:?}: \
139                         looked in {where_it_looked} ({e})"
140                    ))
141                })
142            }
143        }
144    }
145}
146
147/// A serialized filter: cloudpickle bytes to reconstruct on the worker.
148///
149/// Uses cloudpickle (like Spark/Dask/Ray) to serialize the full Python object
150/// including bytecode, closures, and cross-module dependencies.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct SerializedFilter {
153    /// Node ID this filter is registered under.
154    pub node_id: String,
155    /// cloudpickle.dumps() bytes (base64-encoded for JSON transport).
156    #[serde(with = "base64_bytes")]
157    pub pickled_filter: Vec<u8>,
158    /// Trained state (if fitted).
159    pub state: Option<Value>,
160    /// Pip requirements detected from the filter's imports (e.g. ["torch", "transformers"]).
161    #[serde(default)]
162    pub requirements: Vec<String>,
163    /// Whether the filter is trainable (has meaningful fit()) or stateless.
164    #[serde(default)]
165    pub trainable: bool,
166    /// The filter's real config hash from the coordinator, so cache keys
167    /// computed on the worker match those computed locally. `None` for
168    /// payloads from older coordinators — the worker then falls back to
169    /// hashing the pickled filter bytes (config changes still invalidate).
170    #[serde(default)]
171    pub config_hash: Option<somatize_core::cache::CacheKey>,
172}
173
174/// Serde helper: `Vec<u8>` ↔ base64 string for JSON-safe binary transport.
175mod base64_bytes {
176    use base64::engine::{Engine, general_purpose::STANDARD};
177    use serde::{Deserialize, Deserializer, Serialize, Serializer};
178
179    pub fn serialize<S: Serializer>(bytes: &Vec<u8>, s: S) -> Result<S::Ok, S::Error> {
180        STANDARD.encode(bytes).serialize(s)
181    }
182
183    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
184        let s = String::deserialize(d)?;
185        STANDARD.decode(s).map_err(serde::de::Error::custom)
186    }
187}
188
189/// Execution mode: fit (training) or forward (inference).
190#[derive(Debug, Clone, Serialize, Deserialize, Default)]
191#[non_exhaustive]
192pub enum ExecutionMode {
193    /// Training: fit each filter, then forward to propagate outputs.
194    Fit {
195        /// Supervised labels (optional).
196        y: Option<Value>,
197        /// If set, the worker splits the input into batches internally.
198        /// Model is loaded once, batches processed in a loop.
199        #[serde(default)]
200        batch_size: Option<usize>,
201    },
202    /// Inference: forward only (default).
203    #[default]
204    Forward,
205}
206
207/// A serialized plan ready for remote execution.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct SerializedPlan {
210    /// What the sender speaks. See [`PROTOCOL_VERSION`].
211    #[serde(default = "unversioned")]
212    pub protocol_version: u32,
213    /// Identifies this execution end to end — results, events and
214    /// cancellations all refer back to it.
215    pub plan_id: PlanId,
216    /// The compiled plan the worker will execute.
217    pub plan: ExecutionPlan,
218    /// Input data — inline for small values, DataRef for large ones.
219    pub input: Option<InputSource>,
220    /// Filter definitions for the worker to reconstruct.
221    #[serde(default)]
222    pub filters: Vec<SerializedFilter>,
223    /// Fit or Forward.
224    #[serde(default)]
225    pub mode: ExecutionMode,
226    /// The run's experiment seed, folded into every cache key on the
227    /// worker exactly as it is locally. Absent (the pre-seed wire
228    /// format) means unseeded — which shares cache lines across a
229    /// sweep's seeds, the bug this field exists to close.
230    #[serde(default)]
231    pub seed: Option<i64>,
232    /// Free-form annotations that travel with the plan (experiment name,
233    /// submitter, ...). The worker carries them; it never interprets them.
234    pub metadata: serde_json::Value,
235}
236
237/// Encode a streaming frame.
238///
239/// `to_vec_named`, not `to_vec`. msgpack can write a struct either as a map
240/// of named fields or as a bare array of values, and `rmp_serde::to_vec`
241/// chooses the array. `Value` is an adjacently-tagged enum, which can only
242/// be *read back* from named fields — so every frame carrying a tensor
243/// encoded fine and then failed to decode with "invalid type: sequence,
244/// expected struct variant Value::Tensor".
245///
246/// Nobody saw it because both receivers dropped the error: one behind
247/// `if let Ok(..)`, the other behind `unwrap_or_default()`, which sent an
248/// empty frame. The chunk simply never arrived.
249pub fn encode_frame(msg: &StreamMessage) -> somatize_core::error::Result<Vec<u8>> {
250    rmp_serde::to_vec_named(msg).map_err(|e| {
251        somatize_core::error::SomaError::Other(format!("encoding a stream frame: {e}"))
252    })
253}
254
255/// Decode a streaming frame.
256pub fn decode_frame(bytes: &[u8]) -> somatize_core::error::Result<StreamMessage> {
257    rmp_serde::from_slice(bytes).map_err(|e| {
258        somatize_core::error::SomaError::Other(format!("decoding a stream frame: {e}"))
259    })
260}
261
262impl SerializedPlan {
263    /// A plan tagged with the version this build speaks.
264    ///
265    /// Callers build plans through this rather than the literal, so the
266    /// version cannot be forgotten at one of the six construction sites.
267    pub fn new(plan_id: impl Into<PlanId>, plan: ExecutionPlan) -> Self {
268        Self {
269            protocol_version: PROTOCOL_VERSION,
270            plan_id: plan_id.into(),
271            plan,
272            input: None,
273            filters: Vec::new(),
274            mode: ExecutionMode::Forward,
275            seed: None,
276            metadata: serde_json::json!({}),
277        }
278    }
279
280    /// Attach the plan's input data — inline or by reference.
281    pub fn with_input(mut self, input: InputSource) -> Self {
282        self.input = Some(input);
283        self
284    }
285
286    /// Attach the filter payloads the worker must reconstruct before
287    /// the plan can run.
288    pub fn with_filters(mut self, filters: Vec<SerializedFilter>) -> Self {
289        self.filters = filters;
290        self
291    }
292
293    /// Choose fit or forward execution (the default is
294    /// [`ExecutionMode::Forward`]).
295    pub fn with_mode(mut self, mode: ExecutionMode) -> Self {
296        self.mode = mode;
297        self
298    }
299
300    /// Replace the free-form metadata (defaults to `{}`).
301    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
302        self.metadata = metadata;
303        self
304    }
305
306    /// Can this build execute the plan as its sender meant it?
307    ///
308    /// Refusing is the point. Executing a plan you only partly understand
309    /// produces a number, and nothing downstream can tell it apart from a
310    /// correct one.
311    pub fn check_version(&self) -> std::result::Result<(), String> {
312        if self.protocol_version == PROTOCOL_VERSION {
313            return Ok(());
314        }
315        Err(format!(
316            "protocol mismatch: this worker speaks version {PROTOCOL_VERSION}, \
317             the plan was sent as version {} ({}). Upgrade whichever side is older",
318            self.protocol_version,
319            if self.protocol_version == 0 {
320                "a build from before the wire was versioned"
321            } else if self.protocol_version < PROTOCOL_VERSION {
322                "older"
323            } else {
324                "newer"
325            }
326        ))
327    }
328}
329
330/// Messages from Worker → Coordinator.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(tag = "type")]
333pub enum WorkerToCoordinator {
334    /// Worker announces itself.
335    Register {
336        /// The identity this worker will report in every later message.
337        worker_id: WorkerId,
338        /// What the worker can run — the coordinator places plans by these.
339        capabilities: Capabilities,
340    },
341
342    /// Periodic health check.
343    Heartbeat {
344        /// Sender.
345        worker_id: WorkerId,
346        /// A load snapshot the coordinator reads for placement decisions.
347        load: LoadMetrics,
348    },
349
350    /// Execution event streamed back in real-time.
351    Event {
352        /// Sender.
353        worker_id: WorkerId,
354        /// Which execution the event belongs to.
355        plan_id: PlanId,
356        /// The runtime event, forwarded verbatim.
357        event: Event,
358    },
359
360    /// Plan execution completed.
361    PlanResult {
362        /// Sender.
363        worker_id: WorkerId,
364        /// Which execution finished.
365        plan_id: PlanId,
366        /// Success with its output, or failure with the error.
367        result: PlanResult,
368    },
369
370    /// Python job progress update.
371    JobProgress {
372        /// Sender.
373        worker_id: WorkerId,
374        /// Which Python job is reporting.
375        job_id: String,
376        /// Coarse stage label ("environment", "execute", ...).
377        phase: String,
378        /// Which phase the job is in, 1-based.
379        step: u32,
380        /// How many phases there are in total.
381        total: u32,
382        /// Job-defined metrics at this point; `{}` when it has none yet.
383        metrics: serde_json::Value,
384    },
385
386    /// Python job result.
387    JobResult {
388        /// Sender.
389        worker_id: WorkerId,
390        /// Which Python job finished.
391        job_id: String,
392        /// Whether the job's process exited cleanly.
393        success: bool,
394        /// The last JSON line the job printed to stdout — the job's way
395        /// of reporting final metrics; `{}` when it printed none.
396        metrics: serde_json::Value,
397        /// Captured stdout on success; stderr followed by stdout on
398        /// failure, so the traceback comes first.
399        output: String,
400        /// Wall-clock execution time in milliseconds.
401        duration_ms: u64,
402    },
403
404    // ── Distributed training responses ──
405    /// Response to GetState: trained filter states.
406    StateResult {
407        /// Sender.
408        worker_id: WorkerId,
409        /// Which execution the states came from.
410        plan_id: PlanId,
411        /// Trained state per requested node id.
412        states: std::collections::HashMap<String, Value>,
413    },
414
415    /// A command failed, and the client can read why.
416    ///
417    /// Without this variant an error was sent as a bare `{"error": …}`,
418    /// which is not a `WorkerToCoordinator` at all — and the client skips
419    /// what it cannot parse, so it waited for a reply that had already
420    /// been sent, until the socket closed. Every failure the worker
421    /// reported over WebSocket hung its caller.
422    Error {
423        /// What went wrong, as the worker saw it.
424        message: String,
425    },
426
427    /// A command that produces no data succeeded.
428    ///
429    /// `SetState` and `ApplyGradients` need *a* reply: the client blocks
430    /// until it can parse one, so an unknown `{"type":"Ack"}` would leave
431    /// it waiting until the socket closed.
432    Ack {
433        /// Sender.
434        worker_id: WorkerId,
435    },
436
437    /// Response to GetGradients: gradient data.
438    GradientsResult {
439        /// Sender.
440        worker_id: WorkerId,
441        /// Which execution produced the gradients.
442        plan_id: PlanId,
443        /// Gradient payload per requested node id, opaque bytes for the
444        /// aggregator to combine.
445        gradients: std::collections::HashMap<String, Value>,
446    },
447}
448
449/// A Python pipeline job: source files + requirements for isolated execution.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct PythonPipelineJob {
452    /// Identifies this job in progress updates and its result.
453    pub job_id: String,
454    /// Which pipeline the files define. Also names the isolated
455    /// environment, so re-running the same pipeline reuses its venv.
456    pub pipeline_id: String,
457    /// The investigation this job belongs to — grouping across jobs,
458    /// carried for the record.
459    pub investigation_id: String,
460    /// Source files: path → content
461    pub files: Vec<PipelineFile>,
462    /// pip requirements (content of requirements.txt)
463    pub requirements: String,
464    /// Entry point: which file/function to execute
465    pub entry_point: String,
466    /// Input data (JSON-serialized)
467    pub input_data: Option<serde_json::Value>,
468    /// Extra parameters
469    pub params: serde_json::Value,
470}
471
472/// A source file in a pipeline job.
473#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct PipelineFile {
475    /// Destination path, relative to the job's working directory.
476    pub path: String,
477    /// Full file content, written verbatim.
478    pub content: String,
479}
480
481/// Messages from Coordinator → Worker.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483#[serde(tag = "type")]
484pub enum CoordinatorToWorker {
485    /// Accept worker registration.
486    Registered {
487        /// Echoes the id the worker registered under.
488        worker_id: WorkerId,
489    },
490
491    /// Assign a native Soma plan for execution.
492    AssignPlan {
493        /// The plan, its input, and the filters to reconstruct.
494        plan: SerializedPlan,
495    },
496
497    /// Assign a Python pipeline job (with environment isolation).
498    AssignPythonJob {
499        /// Sources, requirements and entry point to run in isolation.
500        job: PythonPipelineJob,
501    },
502
503    /// Cancel a running plan/job.
504    CancelPlan {
505        /// Which execution to stop.
506        plan_id: PlanId,
507    },
508
509    /// Request current status.
510    StatusRequest,
511
512    /// Ping for keepalive.
513    Ping,
514
515    /// Graceful shutdown: worker should finish running plans and exit.
516    Shutdown {
517        /// Why the coordinator asked; for the worker's log, not logic.
518        reason: String,
519    },
520
521    // ── Distributed training messages ──
522    /// Request trained states from specific filters.
523    GetState {
524        /// Which execution holds the filters.
525        plan_id: PlanId,
526        /// Nodes whose trained state is wanted.
527        node_ids: Vec<String>,
528    },
529
530    /// Load states into filters (e.g. after FedAvg aggregation).
531    SetState {
532        /// Which execution holds the filters.
533        plan_id: PlanId,
534        /// Replacement state per node id, loaded into each filter.
535        states: std::collections::HashMap<String, Value>,
536    },
537
538    /// Request gradients from filters (for AllReduce in DataParallel).
539    GetGradients {
540        /// Which execution holds the filters.
541        plan_id: PlanId,
542        /// Nodes whose gradients are wanted.
543        node_ids: Vec<String>,
544    },
545
546    /// Apply aggregated gradients (after AllReduce).
547    ApplyGradients {
548        /// Which execution holds the filters.
549        plan_id: PlanId,
550        /// Aggregated gradients per node id; each filter's optimizer
551        /// steps with them.
552        gradients: std::collections::HashMap<String, Value>,
553    },
554}
555
556/// How output is delivered in PlanResult.
557#[derive(Debug, Clone, Serialize, Deserialize)]
558#[serde(tag = "delivery")]
559#[non_exhaustive]
560pub enum OutputDelivery {
561    /// Small output — embedded directly in the WS message.
562    Inline {
563        /// The output itself.
564        value: Value,
565    },
566    /// Large output — stored on worker, download via HTTP GET /download?key=...
567    Reference {
568        /// The download key; `WsTransport::resolve_output` turns it back
569        /// into a value.
570        data_ref: somatize_core::store::DataRef,
571    },
572}
573
574// `OutputDelivery::resolve` lived here and had no callers. It downloaded a
575// referenced output over HTTP and mapped *every* failure — connection
576// refused, auth rejected, malformed body — to `Value::Empty`, so a failed
577// download was indistinguishable from a plan that legitimately produced
578// nothing. The working implementation is `WsTransport::resolve_output`,
579// which does the same download and returns `Result`; keeping a lenient
580// duplicate beside it only invited a caller to pick the wrong one.
581
582/// Result of a plan execution.
583#[derive(Debug, Clone, Serialize, Deserialize)]
584#[serde(tag = "status")]
585pub enum PlanResult {
586    /// The plan ran to completion.
587    Success {
588        /// The final output — inline, or a reference to download.
589        output: OutputDelivery,
590        /// Wall-clock execution time in milliseconds.
591        duration_ms: u64,
592        /// Trained states returned after Fit mode (node_id → state).
593        /// Empty for Forward mode.
594        #[serde(default)]
595        states: std::collections::HashMap<String, Value>,
596    },
597    /// The plan did not complete.
598    Failed {
599        /// What went wrong, as the worker reported it.
600        error: String,
601        /// Wall-clock time until the failure, in milliseconds.
602        duration_ms: u64,
603    },
604}
605
606/// Streaming protocol: chunked data transfer over WebSocket Binary frames.
607///
608/// Wire format: msgpack-encoded StreamMessage (efficient binary, no JSON overhead).
609/// Client sends StreamBegin + N × ChunkData + StreamEnd.
610/// Worker responds with ChunkResult per chunk — except while a
611/// Barrier-mode node is accumulating, which yields nothing until the
612/// flush — and StreamComplete at the end.
613///
614/// The worker drives each session with the runtime's `StreamRun`, the
615/// same stream executor a local `Graph.stream()` uses, so chunk caching
616/// and StreamMode semantics do not fork between local and remote.
617#[derive(Debug, Clone, Serialize, Deserialize)]
618#[serde(tag = "type")]
619#[non_exhaustive]
620pub enum StreamMessage {
621    /// Begin a streaming session.
622    StreamBegin {
623        /// Names the session; every later frame quotes it.
624        stream_id: String,
625        /// The execution id the session runs under.
626        plan_id: PlanId,
627        /// Number of chunks (None if unknown ahead of time).
628        total_chunks: Option<usize>,
629        /// The plan to execute — input comes via chunks, not inline.
630        plan: Box<SerializedPlan>,
631    },
632    /// A single chunk of input data.
633    ChunkData {
634        /// Which session the chunk belongs to.
635        stream_id: String,
636        /// Position in the stream, echoed back in the matching
637        /// [`StreamMessage::ChunkResult`].
638        chunk_index: usize,
639        /// The chunk itself.
640        value: Value,
641    },
642    /// All chunks have been sent.
643    StreamEnd {
644        /// Which session the sender has finished feeding.
645        stream_id: String,
646    },
647    /// Result for a processed chunk (streamed back to client).
648    ChunkResult {
649        /// Which session produced the result.
650        stream_id: String,
651        /// Index of the input chunk this result answers.
652        chunk_index: usize,
653        /// The processed chunk.
654        value: Value,
655    },
656    /// Final result after all chunks processed.
657    StreamComplete {
658        /// Which session finished.
659        stream_id: String,
660        /// The flush output on success — where Barrier-mode results
661        /// arrive — or the failure that ended the run.
662        result: PlanResult,
663    },
664}
665
666#[cfg(test)]
667mod tests {
668    // ── Resolving an input that is not there ──
669
670    #[test]
671    fn an_unresolvable_reference_is_an_error_not_an_empty_value() {
672        // It used to warn and return Value::Empty. That value travelled on
673        // into the filter, so the failure surfaced as a TypeError inside
674        // the user's own fit — long after the real problem and pointing at
675        // their code.
676        let temp = somatize_core::store::LocalDataStore::new(
677            std::env::temp_dir().join("soma-resolve-test-empty"),
678        );
679        let source = InputSource::Reference {
680            data_ref: somatize_core::store::DataRef::S3 {
681                bucket: "nowhere".into(),
682                key: "missing".into(),
683                region: None,
684            },
685        };
686        let err = source.resolve(None, &temp).unwrap_err().to_string();
687        assert!(err.contains("cannot resolve the input reference"), "{err}");
688        // And it says WHY, because the usual cause is a client configured
689        // with a store the worker was never given.
690        assert!(err.contains("no DataStore configured"), "{err}");
691    }
692
693    #[test]
694    fn an_inline_input_resolves_to_itself() {
695        let temp = somatize_core::store::LocalDataStore::new(
696            std::env::temp_dir().join("soma-resolve-test-inline"),
697        );
698        let source = InputSource::Inline {
699            value: Value::tensor(vec![1.0, 2.0], vec![2]),
700        };
701        assert_eq!(
702            source.resolve(None, &temp).unwrap(),
703            Value::tensor(vec![1.0, 2.0], vec![2])
704        );
705    }
706
707    use super::*;
708    use somatize_core::event::PlanSummary;
709
710    fn sample_plan() -> SerializedPlan {
711        SerializedPlan::new(
712            "p1",
713            ExecutionPlan::Execute {
714                node_id: "a".into(),
715            },
716        )
717        .with_input(InputSource::Inline {
718            value: Value::tensor(vec![1.0, 2.0], vec![2]),
719        })
720    }
721
722    /// Every `StreamMessage` variant survives the encoding it actually
723    /// travels in.
724    ///
725    /// The streaming half of the protocol goes over WebSocket *binary*
726    /// frames as msgpack, and had no round-trip test at all — every
727    /// existing test covered the JSON path, which these messages never
728    /// take. `rmp_serde` and `serde_json` disagree about enough
729    /// (integer widths, `Option` in adjacently-tagged enums) that passing
730    /// one proves nothing about the other.
731    #[test]
732    fn every_stream_message_survives_msgpack() {
733        let messages = vec![
734            StreamMessage::StreamBegin {
735                stream_id: "s1".into(),
736                plan_id: "p1".into(),
737                total_chunks: Some(3),
738                plan: Box::new(sample_plan()),
739            },
740            StreamMessage::StreamBegin {
741                stream_id: "s1".into(),
742                plan_id: "p1".into(),
743                total_chunks: None,
744                plan: Box::new(sample_plan()),
745            },
746            StreamMessage::ChunkData {
747                stream_id: "s1".into(),
748                chunk_index: 2,
749                value: Value::tensor(vec![1.0, 2.0], vec![2]),
750            },
751            StreamMessage::StreamEnd {
752                stream_id: "s1".into(),
753            },
754            StreamMessage::ChunkResult {
755                stream_id: "s1".into(),
756                chunk_index: 2,
757                value: Value::text("done"),
758            },
759            StreamMessage::StreamComplete {
760                stream_id: "s1".into(),
761                result: PlanResult::Success {
762                    output: OutputDelivery::Inline {
763                        value: Value::text("out"),
764                    },
765                    duration_ms: 12,
766                    states: Default::default(),
767                },
768            },
769            StreamMessage::StreamComplete {
770                stream_id: "s1".into(),
771                result: PlanResult::Failed {
772                    error: "boom".into(),
773                    duration_ms: 3,
774                },
775            },
776        ];
777
778        for msg in messages {
779            let bytes = encode_frame(&msg).expect("encode");
780            let back = decode_frame(&bytes)
781                .unwrap_or_else(|e| panic!("msgpack round-trip failed for {msg:?}: {e}"));
782            assert_eq!(format!("{msg:?}"), format!("{back:?}"));
783        }
784    }
785
786    /// A `SerializedFilter` carries cloudpickle bytes, which JSON cannot
787    /// hold — hence the base64 helper, which nothing tested.
788    #[test]
789    fn pickled_filter_bytes_survive_json() {
790        let filter = SerializedFilter {
791            node_id: "clf".into(),
792            pickled_filter: vec![0x80, 0x05, 0x00, 0xff, 0xfe],
793            state: None,
794            requirements: vec!["numpy".into()],
795            trainable: true,
796            config_hash: None,
797        };
798        let json = serde_json::to_string(&filter).unwrap();
799        let back: SerializedFilter = serde_json::from_str(&json).unwrap();
800        assert_eq!(back.pickled_filter, filter.pickled_filter);
801        assert_eq!(back.requirements, filter.requirements);
802    }
803
804    /// A plan from a build that speaks a different version is refused.
805    #[test]
806    fn a_version_mismatch_is_refused_not_executed() {
807        let mut plan = sample_plan();
808        assert!(plan.check_version().is_ok());
809
810        plan.protocol_version = PROTOCOL_VERSION + 1;
811        let err = plan.check_version().expect_err("newer must be refused");
812        assert!(err.contains("newer"), "{err}");
813
814        plan.protocol_version = 0;
815        let err = plan
816            .check_version()
817            .expect_err("unversioned must be refused");
818        assert!(err.contains("before the wire was versioned"), "{err}");
819    }
820
821    /// A payload written before the field existed reads as version 0, not
822    /// as "this build's version".
823    #[test]
824    fn a_plan_without_a_version_field_does_not_claim_ours() {
825        let json = serde_json::json!({
826            "plan_id": "old",
827            "plan": {"Execute": {"node_id": "a"}},
828            "input": null,
829            "metadata": {}
830        });
831        let plan: SerializedPlan = serde_json::from_value(json).expect("decodes");
832        assert_eq!(plan.protocol_version, 0);
833        assert!(plan.check_version().is_err());
834    }
835
836    #[test]
837    fn capabilities_serde() {
838        let caps = Capabilities {
839            cpu_cores: 8,
840            ram_bytes: 32 * 1024 * 1024 * 1024,
841            gpus: vec![GpuInfo {
842                name: "A100".into(),
843                memory_bytes: 80 * 1024 * 1024 * 1024,
844            }],
845            python_envs: vec!["py310".into(), "py311".into()],
846            tags: vec!["gpu".into(), "training".into()],
847        };
848        let json = serde_json::to_string(&caps).unwrap();
849        let deserialized: Capabilities = serde_json::from_str(&json).unwrap();
850        assert_eq!(deserialized.cpu_cores, 8);
851        assert_eq!(deserialized.gpus.len(), 1);
852        assert_eq!(deserialized.tags, vec!["gpu", "training"]);
853    }
854
855    #[test]
856    fn worker_message_serde() {
857        let msg = WorkerToCoordinator::Register {
858            worker_id: "worker_01".into(),
859            capabilities: Capabilities {
860                cpu_cores: 4,
861                ram_bytes: 16_000_000_000,
862                gpus: vec![],
863                python_envs: vec![],
864                tags: vec!["cpu".into()],
865            },
866        };
867        let json = serde_json::to_string(&msg).unwrap();
868        assert!(json.contains("Register"));
869        let deserialized: WorkerToCoordinator = serde_json::from_str(&json).unwrap();
870        if let WorkerToCoordinator::Register { worker_id, .. } = deserialized {
871            assert_eq!(worker_id, "worker_01");
872        } else {
873            panic!("wrong variant");
874        }
875    }
876
877    #[test]
878    fn coordinator_message_serde() {
879        let msg = CoordinatorToWorker::AssignPlan {
880            plan: SerializedPlan {
881                protocol_version: PROTOCOL_VERSION,
882                plan_id: "plan_001".into(),
883                plan: ExecutionPlan::Execute {
884                    node_id: "train".into(),
885                },
886                input: Some(InputSource::Inline {
887                    value: Value::tensor(vec![1.0, 2.0], vec![2]),
888                }),
889                filters: vec![],
890                mode: ExecutionMode::default(),
891                seed: None,
892                metadata: serde_json::json!({"experiment": "test"}),
893            },
894        };
895        let json = serde_json::to_string(&msg).unwrap();
896        let deserialized: CoordinatorToWorker = serde_json::from_str(&json).unwrap();
897        assert!(matches!(
898            deserialized,
899            CoordinatorToWorker::AssignPlan { .. }
900        ));
901    }
902
903    #[test]
904    fn plan_result_serde() {
905        let success = PlanResult::Success {
906            output: OutputDelivery::Inline {
907                value: Value::tensor(vec![0.95], vec![1]),
908            },
909            duration_ms: 1234,
910            states: std::collections::HashMap::new(),
911        };
912        let json = serde_json::to_string(&success).unwrap();
913        let deserialized: PlanResult = serde_json::from_str(&json).unwrap();
914        assert!(matches!(deserialized, PlanResult::Success { .. }));
915
916        let failed = PlanResult::Failed {
917            error: "OOM".into(),
918            duration_ms: 500,
919        };
920        let json = serde_json::to_string(&failed).unwrap();
921        let deserialized: PlanResult = serde_json::from_str(&json).unwrap();
922        assert!(matches!(deserialized, PlanResult::Failed { .. }));
923    }
924
925    #[test]
926    fn event_message_serde() {
927        let msg = WorkerToCoordinator::Event {
928            worker_id: "w1".into(),
929            plan_id: "p1".into(),
930            event: Event::RunStarted {
931                run_id: "r1".into(),
932                plan_summary: PlanSummary {
933                    total_nodes: 3,
934                    cached_nodes: 1,
935                    parallel_branches: 0,
936                },
937            },
938        };
939        let json = serde_json::to_string(&msg).unwrap();
940        let deserialized: WorkerToCoordinator = serde_json::from_str(&json).unwrap();
941        assert!(matches!(deserialized, WorkerToCoordinator::Event { .. }));
942    }
943
944    #[test]
945    fn heartbeat_serde() {
946        let msg = WorkerToCoordinator::Heartbeat {
947            worker_id: "w1".into(),
948            load: LoadMetrics {
949                cpu_usage: 0.45,
950                memory_usage: 0.72,
951                gpu_usage: vec![0.88],
952                active_plans: 2,
953                queue_depth: 5,
954                timestamp: Utc::now(),
955            },
956        };
957        let json = serde_json::to_string(&msg).unwrap();
958        let deserialized: WorkerToCoordinator = serde_json::from_str(&json).unwrap();
959        if let WorkerToCoordinator::Heartbeat { load, .. } = deserialized {
960            assert!(load.cpu_usage > 0.0);
961            assert_eq!(load.active_plans, 2);
962        }
963    }
964}