Skip to main content

rlmesh_runtime/
spec.rs

1//! The runtime session spec, its limits, and the report a finished run returns.
2
3use std::sync::LazyLock;
4use std::time::Duration;
5
6use rlmesh_proto::core::v1::{AutoresetMode, EnvContract};
7use rlmesh_proto::spaces::v1::SpaceSpec;
8use serde::{Deserialize, Serialize};
9
10/// Empty fallback returned by the internal `*_validated` accessors only on the
11/// unreachable path where the space is absent despite validation (see their
12/// `debug_assert!`s). Lets those accessors stay panic-free and lint-clean.
13static EMPTY_SPACE_SPEC: LazyLock<SpaceSpec> = LazyLock::new(SpaceSpec::default);
14
15/// Everything one route needs to run: its identity, the negotiated env
16/// contract, and the per-op limits. [`validate`](Self::validate) gates a spec
17/// before the driver runs it.
18#[derive(Debug, Clone, PartialEq)]
19pub struct RuntimeSessionSpec {
20    /// Correlation label only; OSS does not key on it (the managed layer owns
21    /// session lifecycle). Kept as plumbing for telemetry/logs — its removal is
22    /// the deferred closed split, out of scope here.
23    pub session_id: String,
24    /// The connected env container, UUIDv7 (minted by the runtime on attach).
25    /// The single routing key: replaces the old `route_id` + positional lane.
26    /// (Repurposed from the former descriptive-name field; the human env name
27    /// now lives only in the language SDK's own contract type.)
28    pub env_id: String,
29    pub env_component_id: String,
30    pub model_component_id: String,
31    /// Workflow edition negotiated at the env handshake. The runtime refuses an
32    /// edition it was not built to drive (see [`RuntimeSessionSpec::validate`]).
33    pub workflow_edition: String,
34    pub env_contract: EnvContract,
35    pub num_envs: usize,
36    pub base_seed: Option<i64>,
37    pub max_episodes: Option<u64>,
38    pub close_env_on_end: bool,
39    pub limits: RuntimeLimits,
40}
41
42impl RuntimeSessionSpec {
43    pub fn validate(&self) -> Result<(), String> {
44        if self.session_id.trim().is_empty() {
45            return Err("runtime session_id must not be empty".to_string());
46        }
47        if self.env_id.trim().is_empty() {
48            return Err("runtime env_id must not be empty".to_string());
49        }
50        if self.env_component_id.trim().is_empty() {
51            return Err("runtime env_component_id must not be empty".to_string());
52        }
53        if self.model_component_id.trim().is_empty() {
54            return Err("runtime model_component_id must not be empty".to_string());
55        }
56        if self.num_envs == 0 {
57            return Err("runtime num_envs must be greater than zero".to_string());
58        }
59        if self.observation_space().is_none() {
60            return Err("runtime env_contract is missing observation_space".to_string());
61        }
62        if self.action_space().is_none() {
63            return Err("runtime env_contract is missing action_space".to_string());
64        }
65        if self.max_episodes == Some(0) {
66            return Err("runtime max_episodes must be greater than zero when set".to_string());
67        }
68        // The runtime drives one of the editions it was built for; a session
69        // negotiated under an edition outside the support window is refused rather
70        // than run under semantics it never agreed to. Membership, not equality
71        // with CURRENT: a supported older edition (a graceful downgrade) is valid.
72        if !rlmesh_proto::is_supported_edition(&self.workflow_edition) {
73            return Err(format!(
74                "runtime cannot drive workflow edition {:?}; this build implements {:?}",
75                self.workflow_edition,
76                rlmesh_proto::SUPPORTED_WORKFLOW_EDITIONS
77            ));
78        }
79        // An autoreset mode this build does not understand (e.g. a newer peer's
80        // mode) must fail loudly at session setup, never silently fold to
81        // DISABLED and change lifecycle semantics.
82        if AutoresetMode::try_from(self.env_contract.autoreset_mode).is_err() {
83            return Err(format!(
84                "unknown autoreset mode {} on the wire; this build supports \
85                 UNSPECIFIED, NEXT_STEP, SAME_STEP, DISABLED only",
86                self.env_contract.autoreset_mode
87            ));
88        }
89        // SAME_STEP is reserved on the wire but not yet driven by the runtime:
90        // the driver currently aliases NEXT_STEP|SAME_STEP to a purely
91        // observational path, while the env server never rolls SAME_STEP episode
92        // ids -> done lanes would stall. Reject it here so it cannot reach the
93        // runtime under a false assumption of support.
94        if self.env_contract.autoreset_mode == AutoresetMode::SameStep as i32 {
95            return Err(
96                "SAME_STEP autoreset is reserved but not yet supported by the runtime; \
97                 construct the env with NEXT_STEP or DISABLED autoreset"
98                    .to_string(),
99            );
100        }
101        // Vectorized sessions require NEXT_STEP autoreset: the env resets each
102        // done lane itself, so the driver never needs per-lane reset. DISABLED
103        // (and the UNSPECIFIED default) would require resetting just the done
104        // lanes, which stock gymnasium vector envs cannot do. There is no
105        // partial-reset API, and a full reset clobbers the still-running lanes.
106        // Reject the combination up front instead of failing mid-run the first
107        // time lanes terminate at different steps. A future in-house vector
108        // engine with per-lane reset will lift this gate. (SAME_STEP is already
109        // rejected above, so the only mode that passes here is NEXT_STEP.)
110        if self.num_envs > 1 && self.env_contract.autoreset_mode != AutoresetMode::NextStep as i32 {
111            return Err(
112                "vectorized runtime sessions (num_envs > 1) require NEXT_STEP autoreset; \
113                 DISABLED autoreset needs per-lane reset, which is unavailable for stock \
114                 gymnasium vector envs. Use NEXT_STEP autoreset, or run with num_envs == 1."
115                    .to_string(),
116            );
117        }
118        Ok(())
119    }
120
121    pub fn env_context(&self) -> crate::hooks::RuntimeEnvContext {
122        crate::hooks::RuntimeEnvContext {
123            env_id: self.env_id.clone(),
124            env_component_id: self.env_component_id.clone(),
125            model_component_id: self.model_component_id.clone(),
126        }
127    }
128
129    /// Returns the observation space, or `None` if the spec has not been
130    /// populated/validated (`env_contract.observation_space` is unset).
131    ///
132    /// All `RuntimeSessionSpec` fields are public, so an unvalidated spec is
133    /// trivial to construct; this accessor never panics. The driver validates
134    /// the spec before running and uses the infallible internal accessor.
135    pub fn observation_space(&self) -> Option<&SpaceSpec> {
136        self.env_contract
137            .spec
138            .as_ref()
139            .and_then(|spec| spec.observation_space.as_ref())
140    }
141
142    /// Returns the action space, or `None` if the spec has not been
143    /// populated/validated (`env_contract.action_space` is unset).
144    ///
145    /// See [`RuntimeSessionSpec::observation_space`] for why this is fallible.
146    pub fn action_space(&self) -> Option<&SpaceSpec> {
147        self.env_contract
148            .spec
149            .as_ref()
150            .and_then(|spec| spec.action_space.as_ref())
151    }
152
153    /// Observation space for internal use after [`validate`](Self::validate)
154    /// has confirmed it is present.
155    pub(crate) fn observation_space_validated(&self) -> &SpaceSpec {
156        debug_assert!(
157            self.observation_space().is_some(),
158            "observation_space accessed before validate()"
159        );
160        // LazyLock<SpaceSpec> derefs to &SpaceSpec on the unreachable None path.
161        self.observation_space()
162            .unwrap_or_else(|| &EMPTY_SPACE_SPEC)
163    }
164
165    /// Action space for internal use after [`validate`](Self::validate) has
166    /// confirmed it is present.
167    pub(crate) fn action_space_validated(&self) -> &SpaceSpec {
168        debug_assert!(
169            self.action_space().is_some(),
170            "action_space accessed before validate()"
171        );
172        // LazyLock<SpaceSpec> derefs to &SpaceSpec on the unreachable None path.
173        self.action_space().unwrap_or_else(|| &EMPTY_SPACE_SPEC)
174    }
175}
176
177/// What a finished or aborted session returns: totals plus the durable
178/// telemetry aggregate.
179#[derive(Debug, Clone, PartialEq)]
180pub struct RuntimeReport {
181    pub session_id: String,
182    pub env_id: String,
183    pub total_steps: i64,
184    pub total_episodes: i64,
185    /// Session-total telemetry aggregate (per-op latency/percentiles/bytes) —
186    /// the durable pull counterpart to the live `RuntimeHooks::on_telemetry` push.
187    pub telemetry: crate::telemetry::Snapshot,
188}
189
190/// Per-op timeouts and the telemetry window for one session. Serialized with
191/// explicit millisecond field names (`*Ms`); legacy unsuffixed fields are rejected.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct RuntimeLimits {
195    #[serde(
196        default = "default_connect_timeout",
197        rename = "envConnectTimeoutMs",
198        serialize_with = "duration_millis::serialize",
199        deserialize_with = "duration_millis::deserialize"
200    )]
201    pub env_connect_timeout: Duration,
202    #[serde(
203        default = "default_model_connect_timeout",
204        rename = "modelConnectTimeoutMs",
205        serialize_with = "duration_millis::serialize",
206        deserialize_with = "duration_millis::deserialize"
207    )]
208    pub model_connect_timeout: Duration,
209    #[serde(
210        default = "default_configure_route_timeout",
211        rename = "configureRouteTimeoutMs",
212        serialize_with = "duration_millis::serialize",
213        deserialize_with = "duration_millis::deserialize"
214    )]
215    pub configure_route_timeout: Duration,
216    #[serde(
217        default = "default_env_reset_timeout",
218        rename = "envResetTimeoutMs",
219        serialize_with = "duration_millis::serialize",
220        deserialize_with = "duration_millis::deserialize"
221    )]
222    pub env_reset_timeout: Duration,
223    #[serde(
224        default = "default_model_predict_timeout",
225        rename = "modelPredictTimeoutMs",
226        serialize_with = "duration_millis::serialize",
227        deserialize_with = "duration_millis::deserialize"
228    )]
229    pub model_predict_timeout: Duration,
230    #[serde(
231        default = "default_env_step_timeout",
232        rename = "envStepTimeoutMs",
233        serialize_with = "duration_millis::serialize",
234        deserialize_with = "duration_millis::deserialize"
235    )]
236    pub env_step_timeout: Duration,
237    #[serde(
238        default = "default_service_close_timeout",
239        rename = "serviceCloseTimeoutMs",
240        serialize_with = "duration_millis::serialize",
241        deserialize_with = "duration_millis::deserialize"
242    )]
243    pub service_close_timeout: Duration,
244    /// How often the background ticker pushes Window + Session telemetry
245    /// snapshots to `on_telemetry`, on a wall clock. `0` disables live streaming
246    /// (the final session snapshot is still delivered at session end); any
247    /// non-zero value below 1ms is floored to 1ms.
248    #[serde(
249        default = "default_telemetry_window",
250        rename = "telemetryWindowMs",
251        serialize_with = "duration_millis::serialize",
252        deserialize_with = "duration_millis::deserialize"
253    )]
254    pub telemetry_window: Duration,
255}
256
257impl Default for RuntimeLimits {
258    fn default() -> Self {
259        Self {
260            env_connect_timeout: default_connect_timeout(),
261            model_connect_timeout: default_model_connect_timeout(),
262            configure_route_timeout: default_configure_route_timeout(),
263            env_reset_timeout: default_env_reset_timeout(),
264            model_predict_timeout: default_model_predict_timeout(),
265            env_step_timeout: default_env_step_timeout(),
266            service_close_timeout: default_service_close_timeout(),
267            telemetry_window: default_telemetry_window(),
268        }
269    }
270}
271
272impl RuntimeLimits {
273    pub fn env_step_timeout_ms(&self) -> i64 {
274        duration_ms_i64(self.env_step_timeout)
275    }
276
277    pub fn env_reset_timeout_ms(&self) -> i64 {
278        duration_ms_i64(self.env_reset_timeout)
279    }
280}
281
282fn default_connect_timeout() -> Duration {
283    Duration::from_secs(60)
284}
285
286fn default_model_connect_timeout() -> Duration {
287    Duration::from_secs(600)
288}
289
290fn default_configure_route_timeout() -> Duration {
291    Duration::from_secs(600)
292}
293
294fn default_env_reset_timeout() -> Duration {
295    Duration::from_secs(300)
296}
297
298fn default_model_predict_timeout() -> Duration {
299    Duration::from_secs(300)
300}
301
302fn default_env_step_timeout() -> Duration {
303    Duration::from_secs(300)
304}
305
306fn default_service_close_timeout() -> Duration {
307    Duration::from_secs(5)
308}
309
310fn default_telemetry_window() -> Duration {
311    Duration::from_secs(1)
312}
313
314fn duration_ms_i64(duration: Duration) -> i64 {
315    duration.as_millis().try_into().unwrap_or(i64::MAX)
316}
317
318mod duration_millis {
319    use std::time::Duration;
320
321    use serde::{Deserialize, Deserializer, Serializer};
322
323    pub(super) fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
324    where
325        S: Serializer,
326    {
327        let millis = duration.as_millis().try_into().unwrap_or(u64::MAX);
328        serializer.serialize_u64(millis)
329    }
330
331    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
332    where
333        D: Deserializer<'de>,
334    {
335        Ok(Duration::from_millis(u64::deserialize(deserializer)?))
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use std::time::Duration;
342
343    use rlmesh_proto::core::v1::{AutoresetMode, EnvContract, EnvSpec};
344    use rlmesh_proto::spaces::v1::SpaceSpec;
345    use serde_json::json;
346
347    use super::{RuntimeLimits, RuntimeSessionSpec};
348
349    fn valid_spec() -> RuntimeSessionSpec {
350        RuntimeSessionSpec {
351            session_id: "session".to_string(),
352            env_id: "env-id".to_string(),
353            env_component_id: "env".to_string(),
354            model_component_id: "model".to_string(),
355            workflow_edition: rlmesh_proto::CURRENT_WORKFLOW_EDITION.to_string(),
356            env_contract: EnvContract {
357                spec: Some(EnvSpec {
358                    observation_space: Some(SpaceSpec::default()),
359                    action_space: Some(SpaceSpec::default()),
360                    ..Default::default()
361                }),
362                num_envs: 1,
363                ..Default::default()
364            },
365            num_envs: 1,
366            base_seed: None,
367            max_episodes: Some(1),
368            close_env_on_end: true,
369            limits: RuntimeLimits::default(),
370        }
371    }
372
373    #[test]
374    fn validate_rejects_an_edition_the_runtime_cannot_drive() {
375        let mut spec = valid_spec();
376        spec.workflow_edition = "2099.01".to_string();
377        let error = spec.validate().unwrap_err();
378        assert!(
379            error.contains("2099.01") && error.contains("cannot drive"),
380            "expected an edition-refusal error, got: {error}"
381        );
382
383        // The edition the build implements is accepted.
384        spec.workflow_edition = rlmesh_proto::CURRENT_WORKFLOW_EDITION.to_string();
385        assert!(spec.validate().is_ok());
386    }
387
388    #[test]
389    fn space_accessors_return_none_on_unvalidated_spec() {
390        let mut spec = valid_spec();
391        // An unvalidated spec is trivially constructible since all fields are
392        // public; the accessors must not panic.
393        spec.env_contract = EnvContract::default();
394
395        assert!(spec.observation_space().is_none());
396        assert!(spec.action_space().is_none());
397    }
398
399    #[test]
400    fn space_accessors_return_some_on_populated_spec() {
401        let spec = valid_spec();
402        assert!(spec.observation_space().is_some());
403        assert!(spec.action_space().is_some());
404    }
405
406    #[test]
407    fn validate_accepts_vectorized_next_step_runtime_sessions() {
408        // num_envs > 1 is supported with NEXT_STEP autoreset: the env resets each
409        // done lane itself, so the driver never needs per-lane reset.
410        let mut spec = valid_spec();
411        spec.num_envs = 4;
412        spec.env_contract.num_envs = 4;
413        spec.env_contract.autoreset_mode = AutoresetMode::NextStep as i32;
414
415        assert!(spec.validate().is_ok());
416    }
417
418    #[test]
419    fn validate_rejects_disabled_vectorized_sessions() {
420        // DISABLED (and the UNSPECIFIED default) with num_envs > 1 needs per-lane
421        // reset, which stock gymnasium vector envs cannot do. Reject up front
422        // rather than failing mid-run on the first staggered termination.
423        for mode in [AutoresetMode::Disabled, AutoresetMode::Unspecified] {
424            let mut spec = valid_spec();
425            spec.num_envs = 4;
426            spec.env_contract.num_envs = 4;
427            spec.env_contract.autoreset_mode = mode as i32;
428
429            let error = spec.validate().unwrap_err();
430            assert!(
431                error.contains("NEXT_STEP"),
432                "expected a NEXT_STEP-guidance rejection for {mode:?}, got: {error}"
433            );
434        }
435    }
436
437    #[test]
438    fn validate_rejects_same_step_autoreset() {
439        // SAME_STEP is reserved but unsupported; validation must reject it so it
440        // cannot reach the runtime and stall lanes.
441        let mut spec = valid_spec();
442        spec.env_contract.autoreset_mode = AutoresetMode::SameStep as i32;
443
444        let error = spec.validate().unwrap_err();
445        assert!(
446            error.contains("SAME_STEP"),
447            "expected SAME_STEP rejection, got: {error}"
448        );
449    }
450
451    #[test]
452    fn runtime_limits_json_uses_explicit_millisecond_fields() {
453        let value = serde_json::to_value(RuntimeLimits::default()).unwrap();
454
455        assert_eq!(value["envConnectTimeoutMs"], json!(60_000));
456        assert_eq!(value["modelConnectTimeoutMs"], json!(600_000));
457        assert_eq!(value["configureRouteTimeoutMs"], json!(600_000));
458        assert_eq!(value["envResetTimeoutMs"], json!(300_000));
459        assert_eq!(value["modelPredictTimeoutMs"], json!(300_000));
460        assert_eq!(value["envStepTimeoutMs"], json!(300_000));
461        assert_eq!(value["serviceCloseTimeoutMs"], json!(5_000));
462        assert_eq!(value["telemetryWindowMs"], json!(1_000));
463        assert!(value.get("envConnectTimeout").is_none());
464
465        let parsed: RuntimeLimits = serde_json::from_value(json!({
466            "envConnectTimeoutMs": 1,
467            "modelConnectTimeoutMs": 2,
468            "configureRouteTimeoutMs": 3,
469            "envResetTimeoutMs": 4,
470            "modelPredictTimeoutMs": 5,
471            "envStepTimeoutMs": 6,
472            "serviceCloseTimeoutMs": 7,
473            "telemetryWindowMs": 8
474        }))
475        .unwrap();
476
477        assert_eq!(parsed.env_connect_timeout, Duration::from_millis(1));
478        assert_eq!(parsed.model_connect_timeout, Duration::from_millis(2));
479        assert_eq!(parsed.configure_route_timeout, Duration::from_millis(3));
480        assert_eq!(parsed.env_reset_timeout, Duration::from_millis(4));
481        assert_eq!(parsed.model_predict_timeout, Duration::from_millis(5));
482        assert_eq!(parsed.env_step_timeout, Duration::from_millis(6));
483        assert_eq!(parsed.service_close_timeout, Duration::from_millis(7));
484        assert_eq!(parsed.telemetry_window, Duration::from_millis(8));
485    }
486
487    #[test]
488    fn runtime_limits_reject_legacy_unsuffixed_fields() {
489        let error = serde_json::from_value::<RuntimeLimits>(json!({
490            "envConnectTimeout": 1
491        }))
492        .unwrap_err();
493
494        assert!(error.to_string().contains("envConnectTimeout"));
495    }
496}