corpus_ipc/models/stimulus.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3use serde::{Deserialize, Serialize};
4
5use crate::validation::{
6 ProtocolLimits, Validate, ValidationError, check_count, check_finite_slice, check_opt_string,
7};
8
9use super::BatchMetadata;
10use super::de::{de_opt_session_id, de_valid_mask, de_values, validate_optional_metadata};
11
12/// Canonical wire batch of continuous, domain-neutral runtime stimulus values.
13///
14/// This is the typed replacement for consumers' ad-hoc stimulus payloads,
15/// such as a flat `{"type":"Stimuli","values":[...]}` UDP JSON message or
16/// a private ingress packet struct. `corpus-ipc` owns this schema; consumers
17/// should decode/encode
18/// through [`IpcMessage::Stimuli`](crate::IpcMessage::Stimuli) instead of a private struct or raw
19/// `serde_json::Value` field indexing.
20///
21/// # Channel width
22///
23/// `values.len()` is the channel count. It is **not fixed** by this crate —
24/// do not encode any particular network's input width (such as one model's
25/// current axon count) into this type. Consumers determine width at runtime
26/// from the batch itself.
27///
28/// # Invalid / missing channel semantics
29///
30/// `valid_mask`, when `Some`, must be the same length as `values`.
31/// `valid_mask[i] == false` means channel `i` has no valid reading this tick;
32/// the corresponding `values[i]` is a placeholder (`0.0` by convention) and
33/// must **not** be interpreted as a real zero-valued reading. When
34/// `valid_mask` is `None`, every entry in `values` is valid.
35///
36/// This is a deliberate improvement over ad-hoc formats that silently coerce
37/// missing or non-numeric channels to `0.0`
38/// with no way to distinguish "sensor read zero" from "no data this tick."
39///
40/// Fields are public (matching this crate's other wire batches, e.g.
41/// [`SpikeBatch`](crate::SpikeBatch)) for direct Rust construction, but **deserialization
42/// enforces the `valid_mask`-length invariant**: a JSON/wire payload with a
43/// `valid_mask` whose length differs from `values.len()` fails to
44/// deserialize (via [`StimulusBatch::validate`] through a `TryFrom` shadow
45/// type), rather than silently producing an inconsistent instance. Call
46/// [`Validate::validate`] explicitly after constructing one directly in
47/// Rust (which bypasses deserialization) to get the same check.
48#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
49#[serde(try_from = "StimulusBatchWire")]
50pub struct StimulusBatch {
51 /// Optional session ID for concurrent experiment isolation.
52 pub session_id: Option<String>,
53 /// Unique batch identifier for correlation.
54 pub batch_id: u64,
55 /// Timestamp in nanoseconds (UTC or relative).
56 pub timestamp: u64,
57 /// Continuous stimulus values. Length is the channel count; not fixed by this crate.
58 pub values: Vec<f32>,
59 /// Optional per-channel validity mask, same length as `values` when present.
60 /// `false` marks a channel as invalid/missing for this tick (see type docs).
61 pub valid_mask: Option<Vec<bool>>,
62 /// Optional batch-level metadata.
63 pub metadata: Option<BatchMetadata>,
64}
65
66impl Validate for StimulusBatch {
67 fn validate_with(&self, limits: ProtocolLimits) -> Result<(), ValidationError> {
68 check_opt_string("session_id", self.session_id.as_deref(), limits)?;
69 check_count("values", self.values.len(), limits.max_channel_values)?;
70 check_finite_slice("values", &self.values)?;
71 if let Some(mask) = &self.valid_mask {
72 check_count("valid_mask", mask.len(), limits.max_channel_values)?;
73 if mask.len() != self.values.len() {
74 return Err(ValidationError::length_mismatch(
75 "valid_mask",
76 mask.len(),
77 self.values.len(),
78 ));
79 }
80 }
81 validate_optional_metadata(&self.metadata, limits, self.values.len())
82 }
83}
84
85/// Deserialization-only shadow of [`StimulusBatch`] with the identical wire
86/// shape. `StimulusBatch`'s real `Deserialize` impl goes through this type and
87/// [`Validate::validate`] so a malformed `valid_mask` length, non-finite
88/// value, or oversize payload is rejected at deserialization.
89#[derive(Deserialize)]
90struct StimulusBatchWire {
91 #[serde(deserialize_with = "de_opt_session_id")]
92 session_id: Option<String>,
93 batch_id: u64,
94 timestamp: u64,
95 #[serde(deserialize_with = "de_values")]
96 values: Vec<f32>,
97 #[serde(deserialize_with = "de_valid_mask")]
98 valid_mask: Option<Vec<bool>>,
99 metadata: Option<BatchMetadata>,
100}
101
102impl TryFrom<StimulusBatchWire> for StimulusBatch {
103 type Error = ValidationError;
104
105 fn try_from(wire: StimulusBatchWire) -> Result<Self, Self::Error> {
106 let batch = StimulusBatch {
107 session_id: wire.session_id,
108 batch_id: wire.batch_id,
109 timestamp: wire.timestamp,
110 values: wire.values,
111 valid_mask: wire.valid_mask,
112 metadata: wire.metadata,
113 };
114 batch.validate()?;
115 Ok(batch)
116 }
117}