Skip to main content

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}