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}