Skip to main content

paramodel_elements/
runtime.rs

1// Copyright (c) Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The behavioural surface of an element.
5//!
6//! `Element` is declarative; `ElementRuntime` is behavioural. The
7//! executor pairs each element with a concrete `ElementRuntime`
8//! implementation (via `ElementRuntimeRegistry`) at execution time and
9//! calls into this trait to materialize, observe, and tear down
10//! resources.
11//!
12//! The trait uses the `async_trait` attribute pending stable
13//! async-fn-in-dyn-trait (SRD-0007 D19). All implementations live in
14//! the hyperplane tier; paramodel defines the trait shape.
15
16use std::collections::BTreeMap;
17use std::sync::Arc;
18
19use async_trait::async_trait;
20use jiff::Timestamp;
21use crate::{ParameterName, TrialId, Value};
22use crate::trial::Trial;
23use serde::{Deserialize, Serialize};
24
25use crate::element::Element;
26use crate::error::Result;
27use crate::lifecycle::{LiveStatusSummary, StateTransition};
28
29// ---------------------------------------------------------------------------
30// TrialContext.
31// ---------------------------------------------------------------------------
32
33/// Read-only trial context passed to [`ElementRuntime`] lifecycle hooks.
34///
35/// Carries a reference to the owning [`Trial`] (defined in
36/// `paramodel-trials`) so runtime implementations can read bound
37/// parameter values and trial identity.
38#[derive(Debug, Clone)]
39pub struct TrialContext {
40    /// The trial's id.
41    pub trial_id:  TrialId,
42    /// Shared reference to the trial. Cloning the `Arc` is cheap.
43    pub trial:     Arc<Trial>,
44    /// Observation timestamp.
45    pub timestamp: Timestamp,
46}
47
48// ---------------------------------------------------------------------------
49// ResolvedConfiguration / MaterializationOutputs.
50// ---------------------------------------------------------------------------
51
52/// Fully-interpolated configuration values handed to
53/// [`ElementRuntime::materialize`]. Every entry is a concrete
54/// [`Value`]; all tokens have been resolved.
55#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
56#[serde(transparent)]
57pub struct ResolvedConfiguration(BTreeMap<ParameterName, Value>);
58
59impl ResolvedConfiguration {
60    /// Empty map.
61    #[must_use]
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Insert or replace a resolved value. Returns the previous value
67    /// for `name`, if any.
68    pub fn insert(&mut self, name: ParameterName, value: Value) -> Option<Value> {
69        self.0.insert(name, value)
70    }
71
72    /// Look up a resolved value.
73    #[must_use]
74    pub fn get(&self, name: &ParameterName) -> Option<&Value> {
75        self.0.get(name)
76    }
77
78    /// Sorted-by-key iterator.
79    pub fn iter(&self) -> impl Iterator<Item = (&ParameterName, &Value)> {
80        self.0.iter()
81    }
82
83    /// Map size.
84    #[must_use]
85    pub fn len(&self) -> usize {
86        self.0.len()
87    }
88
89    /// `true` when empty.
90    #[must_use]
91    pub fn is_empty(&self) -> bool {
92        self.0.is_empty()
93    }
94}
95
96impl FromIterator<(ParameterName, Value)> for ResolvedConfiguration {
97    fn from_iter<I: IntoIterator<Item = (ParameterName, Value)>>(iter: I) -> Self {
98        Self(iter.into_iter().collect())
99    }
100}
101
102/// Typed values an element publishes after materialization. Keyed by
103/// `result_parameters` names; consumed by downstream elements that
104/// reference the export via tokens.
105#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
106#[serde(transparent)]
107pub struct MaterializationOutputs(BTreeMap<ParameterName, Value>);
108
109impl MaterializationOutputs {
110    /// Empty map.
111    #[must_use]
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    /// Insert or replace an output value.
117    pub fn insert(&mut self, name: ParameterName, value: Value) -> Option<Value> {
118        self.0.insert(name, value)
119    }
120
121    /// Look up an output.
122    #[must_use]
123    pub fn get(&self, name: &ParameterName) -> Option<&Value> {
124        self.0.get(name)
125    }
126
127    /// Sorted-by-key iterator.
128    pub fn iter(&self) -> impl Iterator<Item = (&ParameterName, &Value)> {
129        self.0.iter()
130    }
131
132    /// Map size.
133    #[must_use]
134    pub fn len(&self) -> usize {
135        self.0.len()
136    }
137
138    /// `true` when empty.
139    #[must_use]
140    pub fn is_empty(&self) -> bool {
141        self.0.is_empty()
142    }
143}
144
145impl FromIterator<(ParameterName, Value)> for MaterializationOutputs {
146    fn from_iter<I: IntoIterator<Item = (ParameterName, Value)>>(iter: I) -> Self {
147        Self(iter.into_iter().collect())
148    }
149}
150
151// ---------------------------------------------------------------------------
152// State observation.
153// ---------------------------------------------------------------------------
154
155/// Listener callback for [`ElementRuntime::observe_state`].
156///
157/// Implementations must deliver a synthetic initial transition from
158/// `Unknown` to the current state as soon as the listener is
159/// registered, so registration acts as catch-up for the observer.
160pub type StateTransitionListener = Box<dyn Fn(StateTransition) + Send + Sync + 'static>;
161
162/// Handle returned by [`ElementRuntime::observe_state`]. Calling
163/// [`Self::cancel`] removes the listener.
164pub trait StateObservation: Send + Sync + 'static {
165    /// Cancel the observation.
166    fn cancel(&self);
167}
168
169// ---------------------------------------------------------------------------
170// ElementRuntime trait.
171// ---------------------------------------------------------------------------
172
173/// The async behavioural surface of an element.
174///
175/// Implementations live in the hyperplane tier (one per element type).
176/// Paramodel ships a mock implementation in `paramodel-mock` for TCK
177/// tests.
178#[async_trait]
179pub trait ElementRuntime: Send + Sync + 'static {
180    /// Provision the element's concrete resources.
181    ///
182    /// `resolved` carries fully interpolated configuration. Returns
183    /// typed values keyed by `result_parameters` names.
184    async fn materialize(
185        &self,
186        resolved: &ResolvedConfiguration,
187    ) -> Result<MaterializationOutputs>;
188
189    /// Release the element's provisioned resources. Idempotent.
190    async fn dematerialize(&self) -> Result<()>;
191
192    /// Report the element's current operational state.
193    async fn status_check(&self) -> LiveStatusSummary;
194
195    /// Trial is starting — hook for per-trial setup. Default no-op.
196    async fn on_trial_starting(&self, _ctx: &TrialContext) -> Result<()> {
197        Ok(())
198    }
199
200    /// Trial is ending — hook for per-trial teardown. Default no-op.
201    async fn on_trial_ending(&self, _ctx: &TrialContext) -> Result<()> {
202        Ok(())
203    }
204
205    /// Register a state-transition listener.
206    ///
207    /// Implementations deliver a synthetic `Unknown → current`
208    /// transition immediately so the observer doesn't miss the
209    /// current state.
210    fn observe_state(
211        &self,
212        listener: StateTransitionListener,
213    ) -> Box<dyn StateObservation>;
214}
215
216// ---------------------------------------------------------------------------
217// ElementRuntimeRegistry.
218// ---------------------------------------------------------------------------
219
220/// Host-provided service pairing each [`Element`] declaration with a
221/// concrete [`ElementRuntime`]. Dispatch is typically on the element's
222/// `type` label.
223pub trait ElementRuntimeRegistry: Send + Sync + std::fmt::Debug + 'static {
224    /// Pick (or construct) a runtime for this element.
225    fn runtime_for(&self, element: &Element) -> Result<Arc<dyn ElementRuntime>>;
226}
227
228#[cfg(test)]
229mod tests {
230    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
231    use std::sync::Arc;
232
233    use ulid::Ulid;
234
235    use super::*;
236    use crate::lifecycle::OperationalState;
237
238    fn tid() -> TrialId {
239        TrialId::from_ulid(Ulid::from_parts(1_700_000_000_000, 1))
240    }
241
242    fn trial() -> Trial {
243        Trial::builder()
244            .id(tid())
245            .assignments(crate::Assignments::empty())
246            .build()
247    }
248
249    // A minimal in-memory runtime for verifying the trait shape.
250    #[derive(Debug)]
251    struct MockRuntime {
252        materialized: AtomicBool,
253        trial_starts: AtomicUsize,
254    }
255
256    #[async_trait]
257    impl ElementRuntime for MockRuntime {
258        async fn materialize(
259            &self,
260            _resolved: &ResolvedConfiguration,
261        ) -> Result<MaterializationOutputs> {
262            self.materialized.store(true, Ordering::SeqCst);
263            Ok(MaterializationOutputs::new())
264        }
265
266        async fn dematerialize(&self) -> Result<()> {
267            self.materialized.store(false, Ordering::SeqCst);
268            Ok(())
269        }
270
271        async fn status_check(&self) -> LiveStatusSummary {
272            LiveStatusSummary {
273                state:   if self.materialized.load(Ordering::SeqCst) {
274                    OperationalState::Ready
275                } else {
276                    OperationalState::Inactive
277                },
278                summary: "mock".to_owned(),
279            }
280        }
281
282        async fn on_trial_starting(&self, _ctx: &TrialContext) -> Result<()> {
283            self.trial_starts.fetch_add(1, Ordering::SeqCst);
284            Ok(())
285        }
286
287        fn observe_state(
288            &self,
289            _listener: StateTransitionListener,
290        ) -> Box<dyn StateObservation> {
291            Box::new(NoopObservation)
292        }
293    }
294
295    #[derive(Debug)]
296    struct NoopObservation;
297    impl StateObservation for NoopObservation {
298        fn cancel(&self) {}
299    }
300
301    #[tokio::test]
302    async fn mock_runtime_materialize_and_status_check() {
303        let rt = MockRuntime {
304            materialized: AtomicBool::new(false),
305            trial_starts: AtomicUsize::new(0),
306        };
307        let r = rt.status_check().await;
308        assert_eq!(r.state, OperationalState::Inactive);
309        rt.materialize(&ResolvedConfiguration::new()).await.unwrap();
310        let r = rt.status_check().await;
311        assert_eq!(r.state, OperationalState::Ready);
312        rt.dematerialize().await.unwrap();
313    }
314
315    #[tokio::test]
316    async fn mock_runtime_trial_hooks_dispatch() {
317        let rt = MockRuntime {
318            materialized: AtomicBool::new(false),
319            trial_starts: AtomicUsize::new(0),
320        };
321        let ctx = TrialContext {
322            trial_id:  tid(),
323            trial:     Arc::new(trial()),
324            timestamp: Timestamp::from_second(0).unwrap(),
325        };
326        rt.on_trial_starting(&ctx).await.unwrap();
327        rt.on_trial_ending(&ctx).await.unwrap();
328        assert_eq!(rt.trial_starts.load(Ordering::SeqCst), 1);
329    }
330
331    #[test]
332    fn resolved_configuration_iter_is_sorted() {
333        let mut rc = ResolvedConfiguration::new();
334        rc.insert(
335            ParameterName::new("zebra").unwrap(),
336            Value::integer(ParameterName::new("zebra").unwrap(), 1, None),
337        );
338        rc.insert(
339            ParameterName::new("apple").unwrap(),
340            Value::integer(ParameterName::new("apple").unwrap(), 2, None),
341        );
342        let names: Vec<&str> = rc.iter().map(|(n, _)| n.as_str()).collect();
343        assert_eq!(names, vec!["apple", "zebra"]);
344    }
345
346    #[test]
347    fn materialization_outputs_serde_roundtrip() {
348        let mut o = MaterializationOutputs::new();
349        o.insert(
350            ParameterName::new("endpoint").unwrap(),
351            Value::string(
352                ParameterName::new("endpoint").unwrap(),
353                "http://example:4567",
354                None,
355            ),
356        );
357        let json = serde_json::to_string(&o).unwrap();
358        let back: MaterializationOutputs = serde_json::from_str(&json).unwrap();
359        assert_eq!(o, back);
360    }
361}