paramodel_elements/
runtime.rs1use 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#[derive(Debug, Clone)]
39pub struct TrialContext {
40 pub trial_id: TrialId,
42 pub trial: Arc<Trial>,
44 pub timestamp: Timestamp,
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
56#[serde(transparent)]
57pub struct ResolvedConfiguration(BTreeMap<ParameterName, Value>);
58
59impl ResolvedConfiguration {
60 #[must_use]
62 pub fn new() -> Self {
63 Self::default()
64 }
65
66 pub fn insert(&mut self, name: ParameterName, value: Value) -> Option<Value> {
69 self.0.insert(name, value)
70 }
71
72 #[must_use]
74 pub fn get(&self, name: &ParameterName) -> Option<&Value> {
75 self.0.get(name)
76 }
77
78 pub fn iter(&self) -> impl Iterator<Item = (&ParameterName, &Value)> {
80 self.0.iter()
81 }
82
83 #[must_use]
85 pub fn len(&self) -> usize {
86 self.0.len()
87 }
88
89 #[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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
106#[serde(transparent)]
107pub struct MaterializationOutputs(BTreeMap<ParameterName, Value>);
108
109impl MaterializationOutputs {
110 #[must_use]
112 pub fn new() -> Self {
113 Self::default()
114 }
115
116 pub fn insert(&mut self, name: ParameterName, value: Value) -> Option<Value> {
118 self.0.insert(name, value)
119 }
120
121 #[must_use]
123 pub fn get(&self, name: &ParameterName) -> Option<&Value> {
124 self.0.get(name)
125 }
126
127 pub fn iter(&self) -> impl Iterator<Item = (&ParameterName, &Value)> {
129 self.0.iter()
130 }
131
132 #[must_use]
134 pub fn len(&self) -> usize {
135 self.0.len()
136 }
137
138 #[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
151pub type StateTransitionListener = Box<dyn Fn(StateTransition) + Send + Sync + 'static>;
161
162pub trait StateObservation: Send + Sync + 'static {
165 fn cancel(&self);
167}
168
169#[async_trait]
179pub trait ElementRuntime: Send + Sync + 'static {
180 async fn materialize(
185 &self,
186 resolved: &ResolvedConfiguration,
187 ) -> Result<MaterializationOutputs>;
188
189 async fn dematerialize(&self) -> Result<()>;
191
192 async fn status_check(&self) -> LiveStatusSummary;
194
195 async fn on_trial_starting(&self, _ctx: &TrialContext) -> Result<()> {
197 Ok(())
198 }
199
200 async fn on_trial_ending(&self, _ctx: &TrialContext) -> Result<()> {
202 Ok(())
203 }
204
205 fn observe_state(
211 &self,
212 listener: StateTransitionListener,
213 ) -> Box<dyn StateObservation>;
214}
215
216pub trait ElementRuntimeRegistry: Send + Sync + std::fmt::Debug + 'static {
224 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 #[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}