Skip to main content

nmbrs_workload/
op_templates.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Op templates: reusable op definitions an op instantiates with
5//! `uses:`.
6//!
7//! A document's top-level `op_templates:` maps a template name to an
8//! op body — typically a protocol request shape (an http `method` /
9//! `uri` / `body`) with a typed `abstract:` interface naming the wires
10//! it `needs`. The templates run nowhere by themselves. An op in a
11//! phase, a block, or the top-level `ops:` instantiates one:
12//!
13//! ```yaml
14//! extends: petstore_ops           # a library of op_templates
15//! phases:
16//!   read:
17//!     cycles: 1000
18//!     ops:
19//!       fetch:
20//!         uses: getPetById        # the template
21//!         bindings: |             # qualifies what it needs
22//!           petId := mod(hash(cycle), 1000)
23//! ```
24//!
25//! Resolution is a load-time document rewrite, before ops are parsed:
26//! the op's body becomes the template's body with the op's own keys
27//! folded in, so everything downstream sees an ordinary op. The rules
28//! follow SRD-108's binder: a key both sides declare is a load error
29//! (a template's request shape is fixed), except `params` — the
30//! override surface, where the op re-defaults — and `tags`, which merge
31//! with the op's winning. The instantiated op keeps the template's
32//! `abstract:` interface as a *bound* interface: the parser checks that
33//! every `needs` wire is supplied, and pre-map synthesis type-checks it
34//! like any bound SRD-108 slot.
35
36use std::collections::{BTreeMap, BTreeSet};
37
38use serde_json::{Map, Value as JVal};
39
40/// The document key that declares op templates.
41pub const OP_TEMPLATES_KEY: &str = "op_templates";
42
43/// The op key that instantiates a template.
44pub const USES_KEY: &str = "uses";
45
46/// Which ops the rewrite instantiated from a template, by where they
47/// live: the parser marks their interfaces bound and checks their needs.
48#[derive(Debug, Default)]
49pub(crate) struct Instantiated {
50    /// Phase name → op names instantiated in that phase.
51    pub phases: BTreeMap<String, BTreeSet<String>>,
52    /// Op names instantiated in the top-level op pool (`ops:` and
53    /// `blocks:`), from which tag-selected phases also draw.
54    pub pool: BTreeSet<String>,
55    /// Op name → the template it instantiates, for error messages.
56    pub template_of: BTreeMap<String, String>,
57}
58
59/// Rewrite every `uses:` op in `doc` into the template it names, and
60/// remove `op_templates:` from the document.
61pub(crate) fn instantiate(doc: &mut Map<String, JVal>) -> Result<Instantiated, String> {
62    let templates = match doc.remove(OP_TEMPLATES_KEY) {
63        None => Map::new(),
64        Some(JVal::Object(m)) => m,
65        Some(other) => {
66            return Err(format!(
67                "`{OP_TEMPLATES_KEY}:` must be a mapping of template name -> op body, got {other}"
68            ));
69        }
70    };
71    for (name, body) in &templates {
72        if !body.is_object() {
73            return Err(format!(
74                "op template '{name}' must be a mapping (an op body), got {body}"
75            ));
76        }
77        if body.get(USES_KEY).is_some() {
78            return Err(format!(
79                "op template '{name}' declares `uses:` — a template is a complete \
80                 op body and does not instantiate another"
81            ));
82        }
83    }
84
85    let mut out = Instantiated::default();
86    if let Some(ops) = doc.get_mut("ops") {
87        instantiate_ops(
88            ops,
89            &templates,
90            "top-level ops",
91            &mut out.pool,
92            &mut out.template_of,
93        )?;
94    }
95    if let Some(JVal::Object(blocks)) = doc.get_mut("blocks") {
96        for (block_name, block) in blocks.iter_mut() {
97            if let Some(ops) = block.get_mut("ops") {
98                instantiate_ops(
99                    ops,
100                    &templates,
101                    &format!("block '{block_name}'"),
102                    &mut out.pool,
103                    &mut out.template_of,
104                )?;
105            }
106        }
107    }
108    if let Some(JVal::Object(phases)) = doc.get_mut("phases") {
109        for (phase_name, phase) in phases.iter_mut() {
110            if let Some(ops) = phase.get_mut("ops") {
111                let mut used = BTreeSet::new();
112                instantiate_ops(
113                    ops,
114                    &templates,
115                    &format!("phase '{phase_name}'"),
116                    &mut used,
117                    &mut out.template_of,
118                )?;
119                if !used.is_empty() {
120                    out.phases.insert(phase_name.clone(), used);
121                }
122            }
123        }
124    }
125    Ok(out)
126}
127
128/// Instantiate the `uses:` ops of one op container, in both the map
129/// form (`name: body`) and the list form (`- {name: …, uses: …}` or
130/// `- name: {uses: …}`).
131fn instantiate_ops(
132    ops: &mut JVal,
133    templates: &Map<String, JVal>,
134    container: &str,
135    used: &mut BTreeSet<String>,
136    template_of: &mut BTreeMap<String, String>,
137) -> Result<(), String> {
138    match ops {
139        JVal::Object(map) => {
140            for (name, body) in map.iter_mut() {
141                if let JVal::Object(op) = body
142                    && op.contains_key(USES_KEY)
143                {
144                    let template = instantiate_one(op, templates, name, container)?;
145                    template_of.insert(name.clone(), template);
146                    used.insert(name.clone());
147                }
148            }
149        }
150        JVal::Array(items) => {
151            for item in items.iter_mut() {
152                let JVal::Object(entry) = item else { continue };
153                if entry.contains_key(USES_KEY) {
154                    let name = entry
155                        .get("name")
156                        .and_then(JVal::as_str)
157                        .ok_or_else(|| {
158                            format!("{container}: a list-form op with `uses:` needs a `name:`")
159                        })?
160                        .to_string();
161                    let template = instantiate_one(entry, templates, &name, container)?;
162                    template_of.insert(name.clone(), template);
163                    used.insert(name);
164                } else if entry.len() == 1 {
165                    let (name, body) = entry.iter_mut().next().expect("one entry");
166                    if let JVal::Object(op) = body
167                        && op.contains_key(USES_KEY)
168                    {
169                        let template = instantiate_one(op, templates, name, container)?;
170                        template_of.insert(name.clone(), template);
171                        used.insert(name.clone());
172                    }
173                }
174            }
175        }
176        _ => {}
177    }
178    Ok(())
179}
180
181/// Replace `op` with the template it names, its own keys folded in.
182/// Returns the template's name.
183fn instantiate_one(
184    op: &mut Map<String, JVal>,
185    templates: &Map<String, JVal>,
186    op_name: &str,
187    container: &str,
188) -> Result<String, String> {
189    let template_name = match op.remove(USES_KEY) {
190        Some(JVal::String(s)) => s,
191        Some(other) => {
192            return Err(format!(
193                "{container}: op '{op_name}': `uses:` must name an op template, got {other}"
194            ));
195        }
196        None => unreachable!("called only for ops with `uses:`"),
197    };
198    let Some(JVal::Object(template)) = templates.get(&template_name) else {
199        let known: Vec<&str> = templates.keys().map(String::as_str).collect();
200        return Err(format!(
201            "{container}: op '{op_name}' uses '{template_name}', but no op template \
202             has that name (known: [{}]) — declare it under `{OP_TEMPLATES_KEY}:` or \
203             `extends:` the library that does",
204            known.join(", ")
205        ));
206    };
207    let mut merged = template.clone();
208    for (key, value) in std::mem::take(op) {
209        match merged.get_mut(&key) {
210            None => {
211                merged.insert(key, value);
212            }
213            // The override surface: the op re-defaults a template param.
214            Some(JVal::Object(base)) if key == "params" || key == "tags" => {
215                let JVal::Object(over) = value else {
216                    return Err(format!(
217                        "{container}: op '{op_name}': `{key}:` must be a mapping"
218                    ));
219                };
220                base.extend(over);
221            }
222            Some(_) => {
223                return Err(format!(
224                    "{container}: op '{op_name}' sets `{key}`, which op template \
225                     '{template_name}' already defines — a template's request shape \
226                     is fixed; qualify it through the wires it needs (bindings or \
227                     params), or declare a new template"
228                ));
229            }
230        }
231    }
232    *op = merged;
233    Ok(template_name)
234}
235
236/// Mark every op instantiated from a template as a *bound* interface,
237/// and check that each one's `needs` are supplied.
238///
239/// Runs on the parsed workload, after tag-selected phases have drawn
240/// their ops from the pool (a selected clone of an instantiated pool op
241/// is itself instantiated). A need is supplied by any wire the op can
242/// resolve: a declared workload param, a workload, phase, or op binding,
243/// the phase's `for_each` variables, or a name the scenario tree binds
244/// (`set:` / `bindings:` / `for_each` / a do-loop counter). An op's own
245/// `params:` are activity settings, not wires, so they supply nothing.
246/// The types are proved later, at pre-map synthesis, like any bound
247/// SRD-108 interface.
248pub(crate) fn bind_and_check(
249    inst: &Instantiated,
250    phases: &mut std::collections::HashMap<String, crate::model::WorkloadPhase>,
251    pool: &mut [crate::model::ParsedOp],
252    declared_params: &[String],
253    doc_bindings: &crate::model::BindingsDef,
254    scenarios: &std::collections::HashMap<String, Vec<crate::model::ScenarioNode>>,
255) -> Result<(), String> {
256    for op in pool.iter_mut() {
257        if inst.pool.contains(&op.name) && op.abstract_interface.is_some() {
258            op.interface_bound = true;
259        }
260    }
261    if inst.pool.is_empty() && inst.phases.is_empty() {
262        return Ok(());
263    }
264
265    let mut workload_wide: BTreeSet<String> = declared_params.iter().cloned().collect();
266    workload_wide.extend(binding_names(doc_bindings));
267    for nodes in scenarios.values() {
268        scenario_names(nodes, &mut workload_wide);
269    }
270
271    for (phase_name, phase) in phases.iter_mut() {
272        let mut phase_wide = workload_wide.clone();
273        phase_wide.extend(binding_names(&phase.bindings));
274        if let Some(spec) = phase.for_each.as_deref()
275            && let Ok(comp) = polydat::iteration::comprehension::spec::parse_inline(spec)
276        {
277            phase_wide.extend(comp.coordinate_names());
278        }
279        let in_phase = inst.phases.get(phase_name);
280        for op in phase.ops.iter_mut() {
281            let from_template =
282                in_phase.is_some_and(|s| s.contains(&op.name)) || inst.pool.contains(&op.name);
283            if !from_template {
284                continue;
285            }
286            let Some(iface) = op.abstract_interface.as_ref() else {
287                continue;
288            };
289            let mut provided = phase_wide.clone();
290            provided.extend(binding_names(&op.bindings));
291            for (need, typ) in &iface.needs {
292                if !provided.contains(need) {
293                    let template = inst.template_of.get(&op.name).map_or("?", String::as_str);
294                    return Err(format!(
295                        "op '{phase_name}.{}' uses op template '{template}', which needs \
296                         '{need}' ({typ}) — supply it in the op's `bindings:`, or from \
297                         the phase, workload, or scenario (a binding or a declared param)",
298                        op.name
299                    ));
300                }
301            }
302            op.interface_bound = true;
303        }
304    }
305    Ok(())
306}
307
308/// The wire names a bindings block declares.
309fn binding_names(bindings: &crate::model::BindingsDef) -> Vec<String> {
310    match bindings {
311        crate::model::BindingsDef::PolydatSource(s) => crate::inline::binding_wire_names(s),
312        crate::model::BindingsDef::Map(m) => m.keys().cloned().collect(),
313    }
314}
315
316/// Every name a scenario tree binds for the phases beneath it.
317fn scenario_names(nodes: &[crate::model::ScenarioNode], out: &mut BTreeSet<String>) {
318    use crate::model::ScenarioNode as N;
319    for node in nodes {
320        match node {
321            N::Phase(_) => {}
322            N::Comprehension {
323                comprehension,
324                children,
325                ..
326            } => {
327                out.extend(comprehension.coordinate_names());
328                scenario_names(children, out);
329            }
330            N::DoWhile {
331                counter, children, ..
332            }
333            | N::DoUntil {
334                counter, children, ..
335            } => {
336                out.extend(counter.iter().cloned());
337                scenario_names(children, out);
338            }
339            N::Bindings { source, children } => {
340                out.extend(crate::inline::binding_wire_names(source));
341                scenario_names(children, out);
342            }
343            N::IncludedScenario { children, .. } => scenario_names(children, out),
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use serde_json::json;
352
353    fn doc(v: JVal) -> Map<String, JVal> {
354        v.as_object().expect("object").clone()
355    }
356
357    #[test]
358    fn a_phase_op_becomes_its_template_with_its_own_keys() {
359        let mut d = doc(json!({
360            "op_templates": {
361                "getPet": {
362                    "abstract": {"needs": {"petId": "u64"}},
363                    "method": "GET",
364                    "uri": "{base_url}/pets/{petId}"
365                }
366            },
367            "phases": {"read": {"ops": {"fetch": {
368                "uses": "getPet",
369                "bindings": "petId := 7"
370            }}}}
371        }));
372        let out = instantiate(&mut d).expect("instantiates");
373        assert!(d.get(OP_TEMPLATES_KEY).is_none());
374        let op = &d["phases"]["read"]["ops"]["fetch"];
375        assert_eq!(op["method"], "GET");
376        assert_eq!(op["bindings"], "petId := 7");
377        assert!(op.get(USES_KEY).is_none());
378        assert!(out.phases["read"].contains("fetch"));
379        assert_eq!(out.template_of["fetch"], "getPet");
380    }
381
382    #[test]
383    fn redefining_a_template_field_is_a_load_error() {
384        let mut d = doc(json!({
385            "op_templates": {"getPet": {"method": "GET", "uri": "/pets"}},
386            "phases": {"read": {"ops": {"fetch": {"uses": "getPet", "method": "POST"}}}}
387        }));
388        let err = instantiate(&mut d).unwrap_err();
389        assert!(err.contains("sets `method`"), "{err}");
390    }
391
392    #[test]
393    fn params_and_tags_merge_with_the_op_winning() {
394        let mut d = doc(json!({
395            "op_templates": {"t": {"stmt": "x", "params": {"a": "1", "b": "2"}, "tags": {"k": "v"}}},
396            "ops": {"o": {"uses": "t", "params": {"b": "3"}, "tags": {"z": "q"}}}
397        }));
398        let out = instantiate(&mut d).expect("instantiates");
399        let op = &d["ops"]["o"];
400        assert_eq!(op["params"], json!({"a": "1", "b": "3"}));
401        assert_eq!(op["tags"], json!({"k": "v", "z": "q"}));
402        assert!(out.pool.contains("o"));
403    }
404
405    #[test]
406    fn an_unknown_template_names_the_known_ones() {
407        let mut d = doc(json!({
408            "op_templates": {"getPet": {"stmt": "x"}},
409            "phases": {"p": {"ops": [{"name": "o", "uses": "nope"}]}}
410        }));
411        let err = instantiate(&mut d).unwrap_err();
412        assert!(err.contains("'nope'") && err.contains("getPet"), "{err}");
413    }
414}