Skip to main content

paramodel_elements/
element.rs

1// Copyright (c) Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The `Element` struct — anchor type of the whole system.
5//!
6//! `Element` is the declarative shape that flows through plans,
7//! compilation, and persistence. Behaviour (materialize, dematerialize,
8//! observe state) lives in the [`ElementRuntime`](crate::ElementRuntime)
9//! trait and is implemented in the hyperplane tier.
10//!
11//! Construction uses [`bon::Builder`]: callers assemble field-by-field
12//! and call `.build()`, then run [`Element::validate`] against an
13//! [`ElementTypeDescriptorRegistry`] to enforce SRD-0007's invariants
14//! (type label in registry, unique parameter names, config keys
15//! reference real parameters, namespace uniqueness across tiers,
16//! concurrency caps).
17
18use std::collections::BTreeSet;
19
20use crate::{
21    Attributed, ElementName, Labels, Parameter, Plug, Pluggable, Socket, Tags,
22    attributes::{label, validate_namespace},
23};
24use serde::{Deserialize, Serialize};
25
26use crate::configuration::{Configuration, Exports};
27use crate::dependency::Dependency;
28use crate::error::ElementError;
29use crate::lifecycle::{HealthCheckSpec, ShutdownSemantics};
30use crate::types::{ElementTypeDescriptorRegistry, TypeId};
31
32/// One element in the Element Graph.
33///
34/// Every field is serialisable so the struct round-trips through plan
35/// storage and the wire unchanged. Run [`Self::validate`] after
36/// construction — the builder assembles the shape, validation enforces
37/// the cross-cutting invariants.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
39pub struct Element {
40    /// Unique identifier within a test plan.
41    pub name: ElementName,
42
43    /// Intrinsic facts. Must contain a `type` entry whose value is
44    /// registered in the type descriptor registry.
45    #[builder(default)]
46    pub labels: Labels,
47
48    /// Extrinsic organisation (owner, priority, environment, …).
49    #[builder(default)]
50    pub tags: Tags,
51
52    /// Points where this element needs upstream connections.
53    #[builder(default)]
54    pub plugs: Vec<Plug>,
55
56    /// Points where downstream elements can connect to this one.
57    #[builder(default)]
58    pub sockets: Vec<Socket>,
59
60    /// Configurable input dimensions.
61    #[builder(default)]
62    pub parameters: Vec<Parameter>,
63
64    /// Typed output dimensions this element publishes after
65    /// materialization.
66    #[builder(default)]
67    pub result_parameters: Vec<Parameter>,
68
69    /// Authored parameter bindings (literal values or token
70    /// references). Axis bindings override these per trial; see
71    /// SRD-0007 D21.
72    #[builder(default)]
73    pub configuration: Configuration,
74
75    /// Named values this element publishes to downstreams.
76    #[builder(default)]
77    pub exports: Exports,
78
79    /// Typed dependency edges — the Element Graph edges.
80    #[builder(default)]
81    pub dependencies: Vec<Dependency>,
82
83    /// Readiness-check timing. `None` → ready immediately after
84    /// starting.
85    pub health_check: Option<HealthCheckSpec>,
86
87    /// How this element terminates.
88    #[builder(default)]
89    pub shutdown_semantics: ShutdownSemantics,
90
91    /// Explicit trial-element override. `None` → auto-detect via
92    /// reducto's leaf-node heuristic.
93    pub trial_element: Option<bool>,
94
95    /// Max active instances of this element globally.
96    pub max_concurrency: Option<u32>,
97
98    /// Max active instances within one coalesced group.
99    pub max_group_concurrency: Option<u32>,
100}
101
102impl Element {
103    /// Validate the element against a type-descriptor registry.
104    ///
105    /// Enforces SRD-0007 invariants: `type` label is registered,
106    /// required / forbidden labels match the descriptor, parameter
107    /// names are unique in each list, configuration keys reference
108    /// real parameters, namespace uniqueness across labels / tags /
109    /// ports / parameter names, and concurrency caps are sane.
110    pub fn validate(
111        &self,
112        registry: &dyn ElementTypeDescriptorRegistry,
113    ) -> Result<(), ElementError> {
114        // 1. Type label → descriptor lookup.
115        let type_key = label::r#type();
116        let type_value = self
117            .labels
118            .get(&type_key)
119            .ok_or(ElementError::MissingTypeLabel)?;
120        let type_id = TypeId::new(type_value.as_str())?;
121        let descriptor = registry
122            .descriptor(&type_id)
123            .ok_or_else(|| ElementError::UnknownElementType {
124                type_id: type_value.as_str().to_owned(),
125            })?;
126
127        // 2. Descriptor-driven label requirements.
128        for required in &descriptor.required_labels {
129            if !self.labels.contains_key(required) {
130                return Err(ElementError::MissingRequiredLabel {
131                    key: required.as_str().to_owned(),
132                });
133            }
134        }
135        for (forbidden, reason) in &descriptor.forbidden_labels {
136            if self.labels.contains_key(forbidden) {
137                return Err(ElementError::ForbiddenLabelPresent {
138                    key:    forbidden.as_str().to_owned(),
139                    reason: reason.clone(),
140                });
141            }
142        }
143
144        // 3. Parameter-name uniqueness within each list.
145        let mut seen = BTreeSet::new();
146        for p in &self.parameters {
147            if !seen.insert(p.name().as_str()) {
148                return Err(ElementError::DuplicateParameterName {
149                    name: p.name().as_str().to_owned(),
150                });
151            }
152        }
153        let mut seen_results = BTreeSet::new();
154        for p in &self.result_parameters {
155            if !seen_results.insert(p.name().as_str()) {
156                return Err(ElementError::DuplicateResultParameterName {
157                    name: p.name().as_str().to_owned(),
158                });
159            }
160        }
161
162        // 4. Configuration keys reference declared parameters.
163        let param_names: BTreeSet<&str> =
164            self.parameters.iter().map(|p| p.name().as_str()).collect();
165        for key in self.configuration.keys() {
166            if !param_names.contains(key.as_str()) {
167                return Err(ElementError::UnknownConfigurationParameter {
168                    name: key.as_str().to_owned(),
169                });
170            }
171        }
172
173        // 5. Namespace uniqueness across labels / tags / plugs / sockets.
174        validate_namespace(&self.labels, &self.tags, &self.plugs, &self.sockets)?;
175
176        // 6. Parameter names must not collide with any attribute /
177        //    port name on the same element (SRD-0005 D5 extended to
178        //    parameters per the Element invariants).
179        let mut attribute_keys: BTreeSet<&str> = BTreeSet::new();
180        for k in self.labels.keys() {
181            attribute_keys.insert(k.as_str());
182        }
183        for k in self.tags.keys() {
184            attribute_keys.insert(k.as_str());
185        }
186        for p in &self.plugs {
187            attribute_keys.insert(p.name.as_str());
188        }
189        for s in &self.sockets {
190            attribute_keys.insert(s.name.as_str());
191        }
192        for p in &self.parameters {
193            if attribute_keys.contains(p.name().as_str()) {
194                return Err(ElementError::ParameterNameCollidesWithAttribute {
195                    name: p.name().as_str().to_owned(),
196                });
197            }
198        }
199
200        // 7. Concurrency caps.
201        if let Some(mc) = self.max_concurrency
202            && mc < 1
203        {
204            return Err(ElementError::InvalidMaxConcurrency);
205        }
206        if let (Some(group), Some(global)) =
207            (self.max_group_concurrency, self.max_concurrency)
208            && group > global
209        {
210            return Err(ElementError::GroupConcurrencyExceedsGlobal { group, global });
211        }
212
213        Ok(())
214    }
215}
216
217impl Attributed for Element {
218    fn labels(&self) -> &Labels {
219        &self.labels
220    }
221    fn tags(&self) -> &Tags {
222        &self.tags
223    }
224}
225
226impl Pluggable for Element {
227    fn plugs(&self) -> &[Plug] {
228        &self.plugs
229    }
230    fn sockets(&self) -> &[Socket] {
231        &self.sockets
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use std::collections::BTreeSet;
238
239    use crate::{
240        Facet, IntConstraint, IntegerParameter, LabelKey, LabelValue, ParameterName,
241        PortName, TagKey, TagValue, Tier,
242    };
243
244    use super::*;
245    use crate::configuration::{ConfigEntry, ExportName, TokenExpr};
246    use crate::types::{ElementTypeDescriptor, OpenRegistry};
247
248    fn ename(s: &str) -> ElementName {
249        ElementName::new(s).unwrap()
250    }
251
252    fn pname(s: &str) -> ParameterName {
253        ParameterName::new(s).unwrap()
254    }
255
256    fn lk(s: &str) -> LabelKey {
257        LabelKey::new(s).unwrap()
258    }
259    fn lv(s: &str) -> LabelValue {
260        LabelValue::new(s).unwrap()
261    }
262
263    fn element_with_type(name: &str, type_value: &str) -> Element {
264        let mut labels = Labels::new();
265        labels.insert(label::r#type(), lv(type_value));
266        Element::builder()
267            .name(ename(name))
268            .labels(labels)
269            .build()
270    }
271
272    // ---------- Type label ----------
273
274    #[test]
275    fn validate_requires_type_label() {
276        let e = Element::builder().name(ename("svc")).build();
277        let reg = OpenRegistry::new();
278        assert!(matches!(
279            e.validate(&reg),
280            Err(ElementError::MissingTypeLabel)
281        ));
282    }
283
284    #[test]
285    fn open_registry_accepts_any_type_value() {
286        let e = element_with_type("svc", "whatever");
287        let reg = OpenRegistry::new();
288        assert!(e.validate(&reg).is_ok());
289    }
290
291    #[derive(Debug)]
292    struct StrictRegistry {
293        types: Vec<ElementTypeDescriptor>,
294    }
295
296    impl ElementTypeDescriptorRegistry for StrictRegistry {
297        fn descriptors(&self) -> Vec<ElementTypeDescriptor> {
298            self.types.clone()
299        }
300    }
301
302    #[test]
303    fn strict_registry_rejects_unknown_type() {
304        let reg = StrictRegistry {
305            types: vec![ElementTypeDescriptor::builder()
306                .type_id(TypeId::new("service").unwrap())
307                .build()],
308        };
309        let e = element_with_type("svc", "node");
310        assert!(matches!(
311            e.validate(&reg),
312            Err(ElementError::UnknownElementType { .. })
313        ));
314    }
315
316    // ---------- Required / forbidden labels ----------
317
318    #[test]
319    fn descriptor_required_labels_are_enforced() {
320        let reg = StrictRegistry {
321            types: vec![ElementTypeDescriptor::builder()
322                .type_id(TypeId::new("service").unwrap())
323                .required_labels({
324                    let mut s = BTreeSet::new();
325                    s.insert(lk("owner"));
326                    s
327                })
328                .build()],
329        };
330        let e = element_with_type("svc", "service");
331        assert!(matches!(
332            e.validate(&reg),
333            Err(ElementError::MissingRequiredLabel { .. })
334        ));
335    }
336
337    #[test]
338    fn descriptor_forbidden_labels_are_enforced() {
339        let reg = StrictRegistry {
340            types: vec![ElementTypeDescriptor::builder()
341                .type_id(TypeId::new("service").unwrap())
342                .forbidden_labels({
343                    let mut m = std::collections::BTreeMap::new();
344                    m.insert(lk("legacy"), "deprecated".to_owned());
345                    m
346                })
347                .build()],
348        };
349        let mut labels = Labels::new();
350        labels.insert(label::r#type(), lv("service"));
351        labels.insert(lk("legacy"), lv("1"));
352        let e = Element::builder()
353            .name(ename("svc"))
354            .labels(labels)
355            .build();
356        assert!(matches!(
357            e.validate(&reg),
358            Err(ElementError::ForbiddenLabelPresent { .. })
359        ));
360    }
361
362    // ---------- Parameter uniqueness ----------
363
364    #[test]
365    fn duplicate_parameter_names_rejected() {
366        let p = Parameter::Integer(IntegerParameter::range(pname("n"), 1, 10).unwrap());
367        let mut labels = Labels::new();
368        labels.insert(label::r#type(), lv("service"));
369        let e = Element::builder()
370            .name(ename("svc"))
371            .labels(labels)
372            .parameters(vec![p.clone(), p])
373            .build();
374        let reg = OpenRegistry::new();
375        assert!(matches!(
376            e.validate(&reg),
377            Err(ElementError::DuplicateParameterName { .. })
378        ));
379    }
380
381    // ---------- Configuration validates against parameter list ----------
382
383    #[test]
384    fn configuration_keys_must_reference_declared_parameters() {
385        let p = Parameter::Integer(IntegerParameter::range(pname("n"), 1, 10).unwrap());
386        let mut labels = Labels::new();
387        labels.insert(label::r#type(), lv("service"));
388        let mut cfg = Configuration::new();
389        // Orphan reference — no `ghost` parameter declared.
390        cfg.insert(
391            pname("ghost"),
392            ConfigEntry::literal(crate::Value::integer(pname("ghost"), 1, None)),
393        );
394        let e = Element::builder()
395            .name(ename("svc"))
396            .labels(labels)
397            .parameters(vec![p])
398            .configuration(cfg)
399            .build();
400        let reg = OpenRegistry::new();
401        assert!(matches!(
402            e.validate(&reg),
403            Err(ElementError::UnknownConfigurationParameter { .. })
404        ));
405    }
406
407    // ---------- Namespace collisions ----------
408
409    #[test]
410    fn parameter_name_colliding_with_label_is_rejected() {
411        let mut labels = Labels::new();
412        labels.insert(label::r#type(), lv("service"));
413        labels.insert(lk("threads"), lv("collides"));
414        let p = Parameter::Integer(IntegerParameter::range(pname("threads"), 1, 10).unwrap());
415        let e = Element::builder()
416            .name(ename("svc"))
417            .labels(labels)
418            .parameters(vec![p])
419            .build();
420        let reg = OpenRegistry::new();
421        assert!(matches!(
422            e.validate(&reg),
423            Err(ElementError::ParameterNameCollidesWithAttribute { .. })
424        ));
425    }
426
427    #[test]
428    fn cross_tier_duplicate_key_is_rejected() {
429        let mut labels = Labels::new();
430        labels.insert(label::r#type(), lv("service"));
431        labels.insert(lk("owner"), lv("ops"));
432        let mut tags = Tags::new();
433        tags.insert(TagKey::new("owner").unwrap(), TagValue::new("bench").unwrap());
434        let e = Element::builder()
435            .name(ename("svc"))
436            .labels(labels)
437            .tags(tags)
438            .build();
439        let reg = OpenRegistry::new();
440        match e.validate(&reg) {
441            Err(ElementError::Attribute(crate::AttributeError::DuplicateKey {
442                tiers,
443                ..
444            })) => {
445                assert!(tiers.contains(&Tier::Label));
446                assert!(tiers.contains(&Tier::Tag));
447            }
448            other => panic!("expected cross-tier duplicate, got {other:?}"),
449        }
450    }
451
452    // ---------- Concurrency caps ----------
453
454    #[test]
455    fn zero_max_concurrency_is_rejected() {
456        let mut labels = Labels::new();
457        labels.insert(label::r#type(), lv("service"));
458        let e = Element::builder()
459            .name(ename("svc"))
460            .labels(labels)
461            .max_concurrency(0)
462            .build();
463        let reg = OpenRegistry::new();
464        assert!(matches!(
465            e.validate(&reg),
466            Err(ElementError::InvalidMaxConcurrency)
467        ));
468    }
469
470    #[test]
471    fn group_concurrency_exceeding_global_is_rejected() {
472        let mut labels = Labels::new();
473        labels.insert(label::r#type(), lv("service"));
474        let e = Element::builder()
475            .name(ename("svc"))
476            .labels(labels)
477            .max_concurrency(4)
478            .max_group_concurrency(8)
479            .build();
480        let reg = OpenRegistry::new();
481        assert!(matches!(
482            e.validate(&reg),
483            Err(ElementError::GroupConcurrencyExceedsGlobal { .. })
484        ));
485    }
486
487    // ---------- Happy-path construction ----------
488
489    #[test]
490    fn full_element_builds_and_validates() {
491        let mut labels = Labels::new();
492        labels.insert(label::r#type(), lv("service"));
493        labels.insert(lk("owner"), lv("bench"));
494
495        let mut tags = Tags::new();
496        tags.insert(TagKey::new("env").unwrap(), TagValue::new("staging").unwrap());
497
498        let plug = Plug::new(
499            PortName::new("upstream").unwrap(),
500            {
501                let mut s = BTreeSet::new();
502                s.insert(Facet::new("kind", "database").unwrap());
503                s
504            },
505        )
506        .unwrap();
507
508        let param = Parameter::Integer(
509            IntegerParameter::range(pname("threads"), 1, 64)
510                .unwrap()
511                .with_constraint(IntConstraint::Min { n: 1 })
512                .with_default(8)
513                .unwrap(),
514        );
515
516        let mut cfg = Configuration::new();
517        cfg.insert(
518            pname("threads"),
519            ConfigEntry::literal(crate::Value::integer(pname("threads"), 16, None)),
520        );
521
522        let mut exports = Exports::new();
523        exports.insert(
524            ExportName::new("endpoint").unwrap(),
525            TokenExpr::new("${self.ip}:4567").unwrap(),
526        );
527
528        let e = Element::builder()
529            .name(ename("harness"))
530            .labels(labels)
531            .tags(tags)
532            .plugs(vec![plug])
533            .parameters(vec![param])
534            .configuration(cfg)
535            .exports(exports)
536            .dependencies(vec![Dependency::shared(ename("db"))])
537            .shutdown_semantics(ShutdownSemantics::Service)
538            .max_concurrency(8)
539            .build();
540
541        let reg = OpenRegistry::new();
542        assert!(e.validate(&reg).is_ok());
543    }
544
545    // ---------- Attributed / Pluggable traits ----------
546
547    #[test]
548    fn attributed_and_pluggable_read_through() {
549        let e = element_with_type("svc", "service");
550        assert_eq!(<Element as Attributed>::labels(&e).len(), 1);
551        assert!(<Element as Pluggable>::plugs(&e).is_empty());
552    }
553
554    // ---------- serde ----------
555
556    #[test]
557    fn element_serde_roundtrip() {
558        let e = element_with_type("svc", "service");
559        let json = serde_json::to_string(&e).unwrap();
560        let back: Element = serde_json::from_str(&json).unwrap();
561        assert_eq!(e, back);
562    }
563}