Skip to main content

nmbrs_workload/
parse.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! YAML workload parser and normalizer.
5//!
6//! Parses a YAML workload definition and normalizes all shorthand
7//! forms into the canonical `ParsedOp` model.
8
9use crate::model::{
10    BindingsDef, ContinueIfSpec, MetricSpec, ParsedOp, ScenarioNode, ScopeLevel, StopConditionSpec,
11    Workload, WorkloadPhase,
12};
13use crate::template::expand_templates;
14use polydat::iteration::comprehension::Comprehension;
15use polydat::iteration::comprehension::spec::{ComprehensionSpec, ForSpec, parse_inline};
16use serde_json::Value as JVal;
17use std::collections::HashMap;
18
19/// Parse a YAML workload string into a normalized Workload.
20///
21/// In-memory entry point: callers that have already resolved the
22/// source text. **Rejects `extends:`** because there is no
23/// resolution context for the relative path (no including-file
24/// directory). Callers that need `extends:` support must use
25/// [`parse_workload_from_path`].
26pub fn parse_workload(
27    yaml_source: &str,
28    params: &HashMap<String, String>,
29) -> Result<Workload, String> {
30    // Stage 1: TEMPLATE expansion
31    let expanded = expand_templates(yaml_source, params);
32
33    // Stage 2: Parse YAML into generic Value
34    let mut doc: JVal =
35        serde_yaml::from_str(&expanded).map_err(|e| format!("YAML parse error: {e}"))?;
36
37    // SRD-72: `extends:` requires a resolution context. The
38    // text-only entry point has no including-file directory, so
39    // a top-level `extends:` here is unresolvable. Direct the
40    // caller to `parse_workload_from_path` instead.
41    if doc.get("extends").is_some() {
42        return Err(
43            "workload declares `extends:` but parse_workload was called \
44             without a file path; use parse_workload_from_path instead"
45                .to_string(),
46        );
47    }
48
49    // Stage 2.5: op templates. Every op that `uses:` a template becomes
50    // the template's body with its own keys folded in, and
51    // `op_templates:` leaves the document, so every later stage sees
52    // ordinary ops (see `crate::op_templates`). After the `extends:`
53    // check: a library's templates reach a workload only through the
54    // merged document.
55    let instantiated = crate::op_templates::instantiate(
56        doc.as_object_mut()
57            .ok_or("workload must be a YAML mapping")?,
58    )?;
59
60    let obj = doc.as_object().ok_or("workload must be a YAML mapping")?;
61
62    // Stage 3: Extract top-level fields
63    let description = obj
64        .get("description")
65        .and_then(|v| v.as_str())
66        .map(|s| s.to_string());
67
68    let mut scenario_parse_errors: Vec<String> = Vec::new();
69    let mut scenarios = parse_scenarios(obj.get("scenarios"), &mut scenario_parse_errors);
70    // Resolve `scenario: <name>` includes after every scenario
71    // has been parsed so forward references work and cycles are
72    // detected with the full graph available.
73    resolve_scenario_includes(&mut scenarios)?;
74
75    let doc_bindings = extract_bindings(obj.get("bindings"));
76    let doc_params = extract_value_map(obj.get("params"));
77    let doc_tags = extract_string_map(obj.get("tags"));
78
79    // Stage 4: Parse ops from blocks or top-level
80    let mut all_ops = Vec::new();
81
82    // SRD-13f Push D: workload-level `bindings:` live ONLY on
83    // `Workload.bindings` and compile directly to the
84    // workload-root Polydat Kernel. They no longer fold into ops at
85    // parse time — descendant ops resolve workload-level wires
86    // through the Polydat Kernel chain (workload-root → ... → op
87    // kernel) via the SRD-13f cell-on-outputs cascade. So we
88    // pass an empty `BindingsDef` to every op-producing path
89    // here: block-level YAML bindings (parse_blocks) are the
90    // only remaining parser-time "sugar" that expands into ops.
91    if let Some(blocks_val) = obj.get("blocks") {
92        parse_blocks(blocks_val, &doc_params, &doc_tags, &mut all_ops)?;
93    }
94
95    // Top-level ops (no blocks): no block sugar to inline.
96    for key in ["ops", "op", "operations", "statements", "statement"] {
97        if let Some(ops_val) = obj.get(key)
98            && obj.get("blocks").is_none()
99        {
100            parse_ops_field(
101                ops_val,
102                "block0",
103                &BindingsDef::default(),
104                &doc_params,
105                &doc_tags,
106                &mut all_ops,
107            )?;
108        }
109    }
110
111    // Stage 5: Parse phases
112    let (mut phases, phase_order) = parse_phases(obj.get("phases"), &doc_params, &doc_tags)?;
113
114    // Stage 6: Auto-tag all ops (top-level and phase inline ops)
115    for op in &mut all_ops {
116        if !op.tags.contains_key("name") {
117            op.tags.insert("name".to_string(), op.name.clone());
118        }
119        if !op.tags.contains_key("op") {
120            op.tags.insert("op".to_string(), op.name.clone());
121        }
122    }
123
124    // Stage 6.5 (SRD-108 Part A, completing SRD-20): a phase with
125    // no inline ops and a `tags:` selector resolves its ops HERE,
126    // from the merged document's top-level/block op pool. Parse
127    // time is deliberate: everything downstream — synthesis,
128    // validation, the SRD-107 config digest — sees a phase with
129    // ordinary resolved ops, exactly as if they were inline. This
130    // is the ad-hoc composition seam: an implementation workload
131    // `extends:` a blueprint and contributes tagged block ops
132    // that these selectors pick up.
133    for (phase_name, phase) in phases.iter_mut() {
134        let Some(selector) = phase.tags.clone() else {
135            continue;
136        };
137        if !phase.ops.is_empty() {
138            return Err(format!(
139                "phase '{phase_name}' declares both inline ops and a \
140                 `tags:` selector — a phase has exactly one source of \
141                 ops. Drop the selector or move the ops into a tagged \
142                 block."
143            ));
144        }
145        let mut selected = crate::tags::TagFilter::filter_ops(&all_ops, &selector)
146            .map_err(|e| format!("phase '{phase_name}' `tags:` selector: {e}"))?;
147        if selected.is_empty() {
148            return Err(format!(
149                "phase '{phase_name}' `tags:` selector '{selector}' \
150                 matched no ops ({} in the workload's op pool). A \
151                 selector-only phase must bind at least one op — \
152                 check the tag vocabulary against the blocks this \
153                 workload (or its extends chain) declares.",
154                all_ops.len()
155            ));
156        }
157        // Mirror the inline-op auto-tagging: selected clones gain
158        // the `phase` tag their inline siblings get at parse.
159        for op in &mut selected {
160            op.tags.insert("phase".to_string(), phase_name.clone());
161        }
162        phase.ops = selected;
163    }
164
165    // Stage 7: Resolve workload parameters
166    // Priority: CLI params > workload defaults > env vars
167    let yaml_params = extract_string_map(obj.get("params"));
168
169    // Stage 7.5: ops instantiated from op templates carry the
170    // template's interface as a BOUND interface; every `needs` wire
171    // must be supplied here, and synthesis type-checks them.
172    {
173        let declared: Vec<String> = yaml_params.keys().cloned().collect();
174        crate::op_templates::bind_and_check(
175            &instantiated,
176            &mut phases,
177            &mut all_ops,
178            &declared,
179            &doc_bindings,
180            &scenarios,
181        )?;
182    }
183    let mut resolved_params = HashMap::new();
184    for (key, default_value) in &yaml_params {
185        let resolved = if let Some(cli_value) = params.get(key) {
186            // CLI override — coerce to the declared default's type. The
187            // workload default is the source of truth for type, so a
188            // numeric default makes a suffixed override (`10m`, `4Ki`)
189            // resolve numerically rather than landing as a string; a
190            // non-numeric default leaves the override untouched.
191            crate::magnitude::coerce_param_override(default_value, cli_value)
192        } else if let Some(env_name) = default_value.strip_prefix("env:") {
193            // Environment variable lookup
194            std::env::var(env_name).unwrap_or_else(|_| default_value.clone())
195        } else {
196            default_value.clone()
197        };
198        resolved_params.insert(key.clone(), resolved);
199    }
200    // Also include CLI params that aren't in the workload defaults
201    // (ad-hoc parameters passed on the command line)
202    for (key, value) in params {
203        if !resolved_params.contains_key(key) {
204            resolved_params.insert(key.clone(), value.clone());
205        }
206    }
207
208    let declared_params: Vec<String> = yaml_params.keys().cloned().collect();
209
210    // Legacy `summary:` and `plot:` keys: removed, no shim.
211    // Operators must migrate to the unified `report:` block
212    // (SRD-46). The error message names both new homes.
213    if obj.contains_key("summary") || obj.contains_key("summaries") {
214        return Err("`summary:` / `summaries:` removed; use `report:` with \
215             `table <name> ...` directives instead (SRD-46)"
216            .to_string());
217    }
218    if obj.contains_key("plot") || obj.contains_key("plots") {
219        return Err("`plot:` / `plots:` removed; use `report:` with \
220             `plot <name> ...` directives instead (SRD-46)"
221            .to_string());
222    }
223
224    // ── Construction-model enforcement gate ──
225    // The document must validate against the enumerable
226    // construction grammar (docs/guide/construction_model.md):
227    // unknown elements on closed node kinds, value-form
228    // violations, and missing required elements are load errors,
229    // reported together with their document paths. Runs on the
230    // extends-merged document, after the targeted legacy-key
231    // rejections above (their migration messages are better than
232    // a generic unknown-element finding).
233    {
234        let doc = serde_json::Value::Object(obj.clone());
235        let violations =
236            crate::construction::validate_workload(&doc, crate::construction::Mode::Complete);
237        if !violations.is_empty() {
238            let mut lines: Vec<String> = violations
239                .iter()
240                .map(|v| format!("  {}: {}", v.path, v.message))
241                .collect();
242            lines.sort();
243            return Err(format!(
244                "workload construction: {} violation(s) \
245                 (docs/guide/construction_model.md):\n{}",
246                violations.len(),
247                lines.join("\n")
248            ));
249        }
250    }
251
252    // Unified `report:` block (SRD-46) — plots, tables, defaults,
253    // groups. Parser is in `crate::report::parse_report`. The
254    // returned warnings are stashed on the Workload for
255    // strict-mode promotion downstream (SRD-15).
256    let (report, report_warnings) = if let Some(val) = obj.get("report") {
257        let parsed = crate::report::parse_report(val).map_err(|e| format!("report: {e}"))?;
258        (parsed.report, parsed.warnings)
259    } else {
260        (crate::report::Report::default(), Vec::new())
261    };
262
263    // SRD 21 §"Parameter Resolution": CLI overrides are the
264    // outermost layer. Each op has already absorbed the
265    // doc → block → op closest-wins merge for YAML-declared
266    // params; now overlay the CLI map so `nmbrs run ...
267    // concurrency=200` replaces any inherited block-level
268    // value. Workload-level `resolved_params` was already
269    // CLI-resolved above (line 66–87); this pass extends the
270    // same rule down to per-op params.
271    if !params.is_empty() {
272        for op in &mut all_ops {
273            for (key, value) in params {
274                // Same SRD-32a exclusion as the inherited-params merge: a CLI
275                // `rate=…` / `cycles=…` is a phase/activity override, not an op
276                // field — don't leak it into op params (would trip rate).
277                if ACTIVITY_PARAM_KEYS.contains(&key.as_str()) {
278                    continue;
279                }
280                op.params
281                    .insert(key.clone(), serde_json::Value::String(value.clone()));
282            }
283        }
284    }
285
286    // SRD-44 §"Resume protocol": a phase declared
287    // `checkpoint: idempotent` that lives inside a do_while /
288    // do_until loop is rejected at workload load time. The
289    // do-loop iterates the same phase many times under one
290    // checkpoint identity; checkpointing presumes each phase
291    // execution is a discrete unit, which the loop directly
292    // contradicts. Operators who really want a do-loop'd phase
293    // to skip on resume must wrap the loop with explicit
294    // identity, not lean on the phase's `checkpoint:` flag.
295    for (scenario_name, nodes) in &scenarios {
296        let mut bad: Vec<String> = Vec::new();
297        collect_idempotent_under_do_loop(nodes, false, &phases, &mut bad);
298        if !bad.is_empty() {
299            return Err(format!(
300                "scenario '{scenario_name}': phase{plural} {names} \
301                 declared `checkpoint: idempotent` while nested inside \
302                 a do_while / do_until loop. The loop iterates the \
303                 same phase identity multiple times, which contradicts \
304                 the per-execution unit checkpointing assumes. Either \
305                 remove the `checkpoint:` declaration or restructure \
306                 the loop. (SRD-44 §\"Resume protocol\".)",
307                plural = if bad.len() == 1 { "" } else { "s" },
308                names = bad.join(", "),
309            ));
310        }
311    }
312
313    // Doc-root `status_metrics:` — workload-wide default that
314    // any phase without its own `status_metrics:` inherits.
315    // Same accept-list shapes as the per-phase parser: list of
316    // strings, single string, or comma-separated string.
317    let doc_status_metrics: Vec<String> = match obj.get("status_metrics") {
318        None => Vec::new(),
319        Some(JVal::Array(items)) => items
320            .iter()
321            .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
322            .filter(|s| !s.is_empty())
323            .collect(),
324        Some(JVal::String(s)) => s
325            .split(',')
326            .map(str::trim)
327            .filter(|s| !s.is_empty())
328            .map(String::from)
329            .collect(),
330        Some(other) => {
331            return Err(format!(
332                "status_metrics: must be a list of names/patterns, a \
333             comma-separated string, or omitted; got {other:?}"
334            ));
335        }
336    };
337    if !doc_status_metrics.is_empty() {
338        for phase in phases.values_mut() {
339            if phase.status_metrics.is_empty() {
340                phase.status_metrics = doc_status_metrics.clone();
341            }
342        }
343    }
344
345    // Doc-root `readouts:` block (SRD-63 §5.0). Three
346    // accepted shapes:
347    //   A. Single scalar string  → bound at on_update.
348    //   B. Mapping of slot → name | body string.
349    //   C. Mapping of slot → list of (name | body) strings.
350    // The slot keys must match the lower-cased
351    // Event::slot_name values (`on_update`, `on_phase_end`, …).
352    // Inline body strings keep their full text — the
353    // body-grammar parser in nmbrs-runtime::readouts::parse
354    // bakes them at activity-init time.
355    let readouts = parse_readouts_block(obj.get("readouts"))?;
356
357    // SRD-83 — top-level `stop_when:` (workload-shell conditions), same
358    // shape as the per-phase block.
359    let stop_when: Vec<crate::model::StopConditionSpec> = match obj.get("stop_when") {
360        Some(v) => serde_json::from_value(v.clone())
361            .map_err(|e| format!("invalid top-level `stop_when` block: {e}"))?,
362        None => Vec::new(),
363    };
364    // SRD-83 Part 5 — the effect verb is a closed vocabulary; an unknown
365    // verb would silently resolve to the shell default at the trip site.
366    for sc in &stop_when {
367        sc.validate()
368            .map_err(|e| format!("top-level `stop_when`: {e}"))?;
369    }
370
371    // SRD-108 Part B — `implements:` names the blueprint this
372    // document provides op bodies for. A non-string value is
373    // rejected rather than ignored.
374    let implements: Option<String> = match obj.get("implements") {
375        Some(v) => Some(
376            v.as_str()
377                .ok_or_else(|| {
378                    format!("`implements:` must be a workload reference string, got {v}")
379                })?
380                .to_string(),
381        ),
382        None => None,
383    };
384
385    // SRD-106 Part 3 — `stick_session:` top-level bool. A non-bool
386    // value is rejected rather than ignored (never-ignore-silently).
387    let stick_session: Option<bool> = match obj.get("stick_session") {
388        Some(v) => Some(
389            v.as_bool()
390                .ok_or_else(|| format!("`stick_session:` must be a boolean, got {v}"))?,
391        ),
392        None => None,
393    };
394
395    Ok(Workload {
396        description,
397        scenarios,
398        ops: all_ops,
399        bindings: doc_bindings,
400        params: resolved_params,
401        phases,
402        phase_order,
403        declared_params,
404        stop_when,
405        report,
406        report_warnings,
407        scenario_parse_errors,
408        resolution_warnings: Vec::new(),
409        status_metrics: doc_status_metrics,
410        readouts,
411        wrappers: None,
412        implements,
413        stick_session,
414    })
415}
416
417/// Path-based entry point: load a workload YAML from disk,
418/// follow its `extends:` chain (SRD-72), and parse the merged
419/// result into a [`Workload`].
420///
421/// `path` MUST be an existing file. Relative paths are resolved
422/// against the cwd before being passed to the loader.
423pub fn parse_workload_from_path(
424    path: &std::path::Path,
425    params: &HashMap<String, String>,
426) -> Result<Workload, String> {
427    let (merged_yaml, resolution_warnings) = crate::extends::load_and_merge(path)?;
428    let mut workload = parse_workload(&merged_yaml, params)?;
429    // Reference-resolution warnings ride the same surfacing
430    // channel as report warnings: stashed here, logged (or
431    // strict-promoted) by the runner.
432    workload.resolution_warnings.extend(resolution_warnings);
433    Ok(workload)
434}
435
436/// Parse the workload's `readouts:` block per SRD-63 §5.0.
437/// Returns the populated bindings struct or a load-time
438/// error.
439fn parse_readouts_block(value: Option<&JVal>) -> Result<crate::model::ReadoutsBindings, String> {
440    use crate::model::ReadoutsBindings;
441    let mut out = ReadoutsBindings::default();
442    let Some(value) = value else {
443        return Ok(out);
444    };
445
446    // Form A — scalar shorthand for `on_update`.
447    if let JVal::String(s) = value {
448        let trimmed = s.trim();
449        if trimmed.is_empty() {
450            return Ok(out);
451        }
452        out.on_update.push(trimmed.to_string());
453        return Ok(out);
454    }
455
456    // Forms B / C — mapping with slot keys.
457    let JVal::Object(map) = value else {
458        return Err(format!(
459            "readouts: must be a scalar (sugar for on_update) or a mapping \
460             of slot name → readout body; got {value:?}"
461        ));
462    };
463    for (key, val) in map {
464        let bodies: Vec<String> = match val {
465            JVal::String(s) => vec![s.trim().to_string()],
466            JVal::Array(items) => items
467                .iter()
468                .map(|item| match item {
469                    JVal::String(s) => Ok(s.trim().to_string()),
470                    other => Err(format!(
471                        "readouts.{key}: list entries must be strings; got {other:?}"
472                    )),
473                })
474                .collect::<Result<Vec<_>, _>>()?,
475            JVal::Null => continue,
476            other => {
477                return Err(format!(
478                    "readouts.{key}: must be a string or list of strings; got {other:?}"
479                ));
480            }
481        };
482        let bodies: Vec<String> = bodies.into_iter().filter(|s| !s.is_empty()).collect();
483        // Wildcard expansion (SRD-63 §4.1.1). The yaml key
484        // can match a family rather than a single slot:
485        //   `each_*`    → on_each_start + on_each_end
486        //   `phase_*`   → on_phase_start + on_phase_end
487        //   `scope_*`   → on_scope_start + on_scope_end
488        //   `session_*` → on_session_start + on_session_end
489        //   `*`         → every slot
490        // Wildcard bindings are duplicated into each
491        // matching slot so the binder's resolution doesn't
492        // need a separate wildcard list. Render order:
493        // explicit bindings first in declaration order
494        // (handled by parser top-down), then any wildcard
495        // expansions appended.
496        let target_slots: Vec<&str> = match key.as_str() {
497            "on_session_start" => vec!["on_session_start"],
498            "on_session_end" => vec!["on_session_end"],
499            "on_phase_start" => vec!["on_phase_start"],
500            "on_phase_end" => vec!["on_phase_end"],
501            "on_each_start" => vec!["on_each_start"],
502            "on_each_end" => vec!["on_each_end"],
503            "on_scope_start" => vec!["on_scope_start"],
504            "on_scope_end" => vec!["on_scope_end"],
505            "on_update" => vec!["on_update"],
506            "session_*" => vec!["on_session_start", "on_session_end"],
507            "phase_*" => vec!["on_phase_start", "on_phase_end"],
508            "each_*" => vec!["on_each_start", "on_each_end"],
509            "scope_*" => vec!["on_scope_start", "on_scope_end"],
510            "*" => vec![
511                "on_session_start",
512                "on_session_end",
513                "on_phase_start",
514                "on_phase_end",
515                "on_each_start",
516                "on_each_end",
517                "on_scope_start",
518                "on_scope_end",
519                "on_update",
520            ],
521            other => {
522                return Err(format!(
523                    "readouts: unknown slot '{other}'. Known: \
524                 on_session_start/end, on_phase_start/end, \
525                 on_each_start/end, on_scope_start/end, on_update; \
526                 wildcards: each_*, phase_*, scope_*, session_*, *"
527                ));
528            }
529        };
530        for slot in target_slots {
531            let target: &mut Vec<String> = match slot {
532                "on_session_start" => &mut out.on_session_start,
533                "on_session_end" => &mut out.on_session_end,
534                "on_phase_start" => &mut out.on_phase_start,
535                "on_phase_end" => &mut out.on_phase_end,
536                "on_each_start" => &mut out.on_each_start,
537                "on_each_end" => &mut out.on_each_end,
538                "on_scope_start" => &mut out.on_scope_start,
539                "on_scope_end" => &mut out.on_scope_end,
540                "on_update" => &mut out.on_update,
541                _ => unreachable!(),
542            };
543            target.extend(bodies.iter().cloned());
544        }
545    }
546    Ok(out)
547}
548
549/// Walk a scenario tree and collect names of phases declared
550/// `checkpoint: idempotent` that live under a `do_while` /
551/// `do_until` ancestor. Used by the workload-load validation
552/// step above (SRD-44).
553fn collect_idempotent_under_do_loop(
554    nodes: &[crate::model::ScenarioNode],
555    in_do_loop: bool,
556    phases: &HashMap<String, crate::model::WorkloadPhase>,
557    out: &mut Vec<String>,
558) {
559    use crate::model::ScenarioNode;
560    for node in nodes {
561        match node {
562            ScenarioNode::Phase(name) => {
563                if in_do_loop {
564                    let idempotent = phases
565                        .get(name)
566                        .and_then(|p| p.checkpoint.as_ref())
567                        .map(|c| c.idempotent)
568                        .unwrap_or(false);
569                    if idempotent {
570                        out.push(format!("'{name}'"));
571                    }
572                }
573            }
574            ScenarioNode::DoWhile { children, .. } | ScenarioNode::DoUntil { children, .. } => {
575                collect_idempotent_under_do_loop(children, true, phases, out);
576            }
577            ScenarioNode::Comprehension { children, .. } => {
578                collect_idempotent_under_do_loop(children, in_do_loop, phases, out);
579            }
580            ScenarioNode::IncludedScenario { children, .. } => {
581                collect_idempotent_under_do_loop(children, in_do_loop, phases, out);
582            }
583            ScenarioNode::Bindings { children, .. } => {
584                collect_idempotent_under_do_loop(children, in_do_loop, phases, out);
585            }
586        }
587    }
588}
589
590/// Parse a YAML source into just the list of normalized ParsedOps.
591/// Normalise an op-level `if:` clause so callers can write
592/// expressions naturally. The downstream pipeline expects
593/// the condition to be either a binding-name reference
594/// (`{name}`) or an inline expression (`{{expr}}`). When
595/// the operator writes a bare expression like
596/// `cql_dialect == 'cass'`, that doesn't match either
597/// form and the conditional dispenser fails at init when
598/// it tries to look up a Polydat binding literally named
599/// `cql_dialect == 'cass'`.
600///
601/// Heuristic: if the trimmed value already starts with `{`
602/// (any braced form), or is a plain identifier
603/// (`[A-Za-z_][A-Za-z0-9_]*`), pass through unchanged.
604/// Otherwise treat as an inline expression and wrap with
605/// `{{...}}` so the existing inline-expression machinery
606/// in `nmbrs-runtime::scope::build_scope` synthesises a
607/// hidden binding (`__expr_N := <expr>`) and rewrites the
608/// condition to reference it.
609pub fn normalize_condition_clause(s: &str) -> String {
610    let trimmed = s.trim();
611    if trimmed.is_empty() {
612        return trimmed.to_string();
613    }
614    // Already in any braced form — pass through. The
615    // downstream extractor handles `{{expr}}`, `{:=expr:=}`,
616    // `{name}`, and `{expr-with-operators}` itself.
617    if trimmed.starts_with('{') {
618        return trimmed.to_string();
619    }
620    // Plain identifier — leave as-is so the legacy "if:
621    // points at a single binding name" form keeps working.
622    let is_plain_ident = trimmed
623        .chars()
624        .next()
625        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
626        && trimmed
627            .chars()
628            .all(|c| c.is_ascii_alphanumeric() || c == '_');
629    if is_plain_ident {
630        return trimmed.to_string();
631    }
632    // Anything else (operators, quotes, parens, …) → wrap
633    // as an inline expression.
634    format!("{{{{{trimmed}}}}}")
635}
636
637pub fn parse_ops(yaml_source: &str) -> Result<Vec<ParsedOp>, String> {
638    let workload = parse_workload(yaml_source, &HashMap::new())?;
639    Ok(workload.ops)
640}
641
642// -----------------------------------------------------------------
643// Scenarios
644// -----------------------------------------------------------------
645
646fn parse_scenarios(
647    val: Option<&JVal>,
648    errors: &mut Vec<String>,
649) -> HashMap<String, Vec<ScenarioNode>> {
650    let mut scenarios = HashMap::new();
651    let Some(val) = val else {
652        return scenarios;
653    };
654    let Some(obj) = val.as_object() else {
655        return scenarios;
656    };
657
658    for (scenario_name, steps_val) in obj {
659        let nodes = parse_scenario_nodes_with_errors(steps_val, scenario_name, errors);
660        scenarios.insert(scenario_name.clone(), nodes);
661    }
662    scenarios
663}
664
665/// Wrapper around the legacy `parse_scenario_nodes` that
666/// accumulates `unknown-key` errors against a per-call sink.
667/// Used by `parse_scenarios` so the top-level builder can
668/// inspect the result and refuse to dispatch a malformed
669/// workload (per "Never Ignore Silently"). The legacy
670/// non-erroring shape is still exposed for the in-file
671/// recursive callers that pre-date the error contract;
672/// those paths quietly drop the unknown-key information,
673/// but the top-level entry now sees it.
674fn parse_scenario_nodes_with_errors(
675    val: &JVal,
676    scenario_name: &str,
677    errors: &mut Vec<String>,
678) -> Vec<ScenarioNode> {
679    match val {
680        JVal::Array(arr) => arr
681            .iter()
682            .flat_map(|item| parse_scenario_nodes_with_errors(item, scenario_name, errors))
683            .collect(),
684        JVal::Object(obj) => {
685            if has_recognised_scenario_key(obj) {
686                return parse_scenario_nodes(val);
687            }
688            // No recognized scenario-node key. Two legitimate
689            // shapes can land here:
690            //
691            // 1. **Legacy command-string form** — `{ name: "run
692            //    ..." }`. Each entry maps a step name to a CLI
693            //    command string; the catch-all in
694            //    `parse_scenario_nodes` produces one
695            //    `ScenarioNode::Phase(<name>)` per key. The
696            //    values are always strings (CLI command lines).
697            //    Accepted.
698            //
699            // 2. **Malformed unknown-key node** — `{ iterate: {
700            //    phases: [...] } }`, `{ for_with_typo: ... }`,
701            //    etc. The value is a map or array, which means
702            //    the author was trying to express structure the
703            //    parser doesn't understand. Reject loudly per
704            //    the project's "Never Ignore Silently" rule —
705            //    silently dropping these used to cause
706            //    confusing downstream errors (`phase 'iterate'
707            //    not found` masking a typoed `for_each` key).
708            let bad: Vec<&String> = obj
709                .iter()
710                .filter(|(_, v)| !matches!(v, JVal::String(_) | JVal::Null))
711                .map(|(k, _)| k)
712                .collect();
713            if !bad.is_empty() {
714                let bad_names: Vec<&str> = bad.iter().map(|s| s.as_str()).collect();
715                errors.push(format!(
716                    "scenario '{scenario_name}': unrecognised scenario-node key(s) \
717                     {bad_names:?} carry non-string values (a map or array). \
718                     Expected one of: `for_each` / `for`, `scenarios`, \
719                     `for_combinations`, `do_while`, `do_until`, `bindings`, \
720                     `set`, `scenario`. (The legacy `name: \"run ...\"` \
721                     command-string form is still accepted when the value is \
722                     a plain string.)"
723                ));
724                return Vec::new();
725            }
726            // All values are strings — legacy command-string
727            // form. Route through the legacy catch-all.
728            parse_scenario_nodes(val)
729        }
730        _ => parse_scenario_nodes(val),
731    }
732}
733
734/// True iff this scenario-node object has at least one of the
735/// recognized keys handled by `parse_scenario_nodes`. Used by
736/// the error-aware wrapper to distinguish "malformed node"
737/// (no recognized key) from "legitimate node" before the
738/// silent-catchall in the legacy path can fire.
739fn has_recognised_scenario_key(obj: &serde_json::Map<String, JVal>) -> bool {
740    crate::vocab::scenario_node_keys()
741        .iter()
742        .any(|k| obj.contains_key(*k))
743}
744
745/// Emit the polydat RHS for a scenario `set:` value. A **bare identifier is a
746/// wire REFERENCE**, consistent with comprehension r-values (SRD-18f): so
747/// `set: { x: mnc }` binds `mnc`'s value, and an unresolved bare name is a
748/// hard error at scope synthesis. A number/bool is that literal; a YAML
749/// sequence is a list (of references / literals). A STRING literal must be
750/// written explicitly as a polydat-quoted scalar — `'"verbose"'` in YAML —
751/// because the pipeline's serde round-trips strip ordinary YAML quotes,
752/// leaving a bare word indistinguishable from a reference.
753fn emit_set_value_literal(value: &JVal) -> String {
754    match value {
755        JVal::Number(n) => n.to_string(),
756        JVal::Bool(b) => b.to_string(),
757        JVal::Null => "\"\"".to_string(),
758        // A YAML sequence is a list literal; its elements are references
759        // (bare) or literals.
760        JVal::Array(elems) => {
761            let parts: Vec<String> = elems.iter().map(emit_set_array_element).collect();
762            format!("[{}]", parts.join(", "))
763        }
764        JVal::String(s) => {
765            let t = s.trim();
766            if is_polydat_quoted_string(t) {
767                // Explicit polydat string literal (`'"verbose"'` in YAML).
768                t.to_string()
769            } else if crate::bindpoints::is_bare_identifier(t) {
770                // Bare identifier ⇒ a wire reference (unquoted; polydat
771                // resolves it against the in-scope chain).
772                t.to_string()
773            } else {
774                // Free text / `{name}` templates ⇒ a string literal.
775                let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
776                format!("\"{escaped}\"")
777            }
778        }
779        other => {
780            let escaped = other.to_string().replace('\\', "\\\\").replace('"', "\\\"");
781            format!("\"{escaped}\"")
782        }
783    }
784}
785
786/// One element of a `set:` sequence value: a bare-identifier element is a
787/// reference (unquoted), a number/bool is its literal, any other string is a
788/// quoted string element.
789fn emit_set_array_element(e: &JVal) -> String {
790    match e {
791        JVal::String(s) if crate::bindpoints::is_bare_identifier(s.trim()) => s.trim().to_string(),
792        JVal::Number(n) => n.to_string(),
793        JVal::Bool(b) => b.to_string(),
794        JVal::String(s) => {
795            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
796            format!("\"{escaped}\"")
797        }
798        other => other.to_string(),
799    }
800}
801
802/// Recursively parse scenario nodes from YAML.
803///
804/// Handles:
805/// - String: phase name
806/// - Object with `for_each` + `phases`: for_each loop (phases parsed recursively)
807/// - Array: list of nodes
808///
809/// Map a `ScopeLevel` keyword (the `each:` vocabulary) from YAML text.
810fn parse_scope_level(s: &str) -> Option<ScopeLevel> {
811    match s {
812        "self" => Some(ScopeLevel::SelfScope),
813        "op" => Some(ScopeLevel::Op),
814        "phase" => Some(ScopeLevel::Phase),
815        "scenario" => Some(ScopeLevel::Scenario),
816        "workload" => Some(ScopeLevel::Workload),
817        _ => None,
818    }
819}
820
821/// SRD-101 — parse a `continue_if:` value into a [`ContinueIfSpec`]. Accepts
822/// the short string form (`continue_if: "end_of(p) <= max"` → `each: scenario`)
823/// and the long map form (`{ when, each }`, where `each` is a single level or a
824/// list). `each` defaults to `scenario` (the enclosing sweep).
825/// Render a JSON scalar (string or number) as a duration string for the
826/// `tries: {backoff: {min, max}}` map. A string passes through (`"100ms"`);
827/// a bare number becomes its decimal text (`100` → `"100"`, taken as ms by
828/// the runtime's duration parser). Non-scalars yield `None`.
829fn json_scalar_to_dur_string(v: &JVal) -> Option<String> {
830    match v {
831        JVal::String(s) => Some(s.clone()),
832        JVal::Number(n) => Some(n.to_string()),
833        _ => None,
834    }
835}
836
837fn parse_continue_if(val: Option<&JVal>) -> Option<ContinueIfSpec> {
838    match val? {
839        JVal::String(when) => Some(ContinueIfSpec {
840            when: when.clone(),
841            each: vec![ScopeLevel::Scenario],
842        }),
843        JVal::Object(map) => {
844            let when = map.get("when").and_then(|v| v.as_str())?.to_string();
845            let each: Vec<ScopeLevel> = match map.get("each") {
846                Some(JVal::String(s)) => parse_scope_level(s).into_iter().collect(),
847                Some(JVal::Array(arr)) => arr
848                    .iter()
849                    .filter_map(|v| v.as_str().and_then(parse_scope_level))
850                    .collect(),
851                _ => Vec::new(),
852            };
853            let each = if each.is_empty() {
854                vec![ScopeLevel::Scenario]
855            } else {
856                each
857            };
858            Some(ContinueIfSpec { when, each })
859        }
860        _ => None,
861    }
862}
863
864fn parse_scenario_nodes(val: &JVal) -> Vec<ScenarioNode> {
865    match val {
866        JVal::String(s) => vec![ScenarioNode::Phase(s.clone())],
867        JVal::Array(arr) => arr.iter().flat_map(parse_scenario_nodes).collect(),
868        JVal::Object(obj) => {
869            let children = obj
870                .get("phases")
871                .map(parse_scenario_nodes)
872                .unwrap_or_default();
873            let counter = obj
874                .get("counter")
875                .and_then(|v| v.as_str())
876                .map(|s| s.to_string());
877
878            // `for_each` is the canonical key; `for` is accepted as
879            // a shorter synonym ("for k in 10,100" reads more
880            // naturally and matches the Polydat comprehension text
881            // grammar). Both keys are interchangeable; if both
882            // appear, `for_each` wins so misconfigured workloads
883            // don't silently change shape.
884            if let Some(for_each_val) = obj.get("for_each").or_else(|| obj.get("for")) {
885                // for_each supports three YAML shapes (string,
886                // array, object) that collapse into one of three
887                // semantic variants (Cartesian / Union / single-
888                // clause). The detection rule lives in
889                // `ComprehensionSpec::into_legacy` (and ultimately
890                // in `comprehension_from_subspaces` underneath) —
891                // single source of truth.
892                //
893                // YAML-shape → ForSpec mapping:
894                //   "x in 1, y in 2"               → Inline (string)
895                //   "x in 1, x in 2"               → Inline (string,
896                //                                    name repeat ⇒ Union)
897                //   ["x in 1", "y in 2"]           → UnionOfClauseLists
898                //                                    (one entry per
899                //                                    sub-space; name
900                //                                    repeat across
901                //                                    entries ⇒ Union;
902                //                                    no repeat ⇒
903                //                                    Cartesian after
904                //                                    flattening)
905                //   { x: "1", y: "2" }             → Inline (string,
906                //                                    assembled from
907                //                                    key=value pairs)
908                let for_spec: Option<ForSpec> = match for_each_val {
909                    JVal::String(spec) => Some(ForSpec::Inline(spec.clone())),
910                    JVal::Array(arr) => {
911                        // One sub-space per array entry. Each
912                        // entry can hold multiple comma-separated
913                        // clauses (cartesian within); cross-entry
914                        // is unioned. Wrap each entry in a
915                        // singleton inner-list so the
916                        // UnionOfClauseLists shape carries the
917                        // "one entry = one sub-space" semantic.
918                        let groups: Vec<Vec<String>> = arr
919                            .iter()
920                            .filter_map(|item| item.as_str().map(|s| vec![s.to_string()]))
921                            .collect();
922                        if groups.is_empty() {
923                            None
924                        } else {
925                            Some(ForSpec::UnionOfClauseLists(groups))
926                        }
927                    }
928                    JVal::Object(map) => {
929                        // Map form is always a single sub-space
930                        // (keys are unique). Assemble into an
931                        // inline string and route through
932                        // ForSpec::Inline.
933                        if map.is_empty() {
934                            None
935                        } else {
936                            let inline = map
937                                .iter()
938                                .map(|(k, v)| format!("{k} in {}", v.as_str().unwrap_or("")))
939                                .collect::<Vec<_>>()
940                                .join(", ");
941                            Some(ForSpec::Inline(inline))
942                        }
943                    }
944                    _ => None,
945                };
946
947                match for_spec {
948                    None => vec![],
949                    Some(r#for) => {
950                        let spec = ComprehensionSpec {
951                            r#for,
952                            // Optional `where:` key carries a
953                            // filter predicate evaluated per
954                            // emitted tuple.
955                            r#where: obj.get("where").and_then(|v| v.as_str()).map(String::from),
956                            // Optional `order:` key carries a
957                            // traversal order spec (GK text form,
958                            // e.g. "extrema/1").
959                            order: obj.get("order").and_then(|v| v.as_str()).map(String::from),
960                        };
961                        match spec.into_algebra() {
962                            Ok(comprehension) => vec![ScenarioNode::Comprehension {
963                                comprehension,
964                                children,
965                                continue_if: parse_continue_if(obj.get("continue_if")),
966                                anchor: obj
967                                    .get("anchor")
968                                    .and_then(|v| v.as_str())
969                                    .map(String::from),
970                            }],
971                            Err(e) => {
972                                eprintln!("warning: comprehension: {e}");
973                                vec![]
974                            }
975                        }
976                    }
977                }
978            } else if let Some(scenario_val) = obj.get("scenario").and_then(|v| v.as_str()) {
979                // `scenario: <name>` — logical inclusion of
980                // another scenario at this point in the tree.
981                // Children remain empty here; resolution happens
982                // post-parse via `resolve_scenario_includes`,
983                // once every scenario in the workload is known.
984                vec![ScenarioNode::IncludedScenario {
985                    name: scenario_val.to_string(),
986                    children: Vec::new(),
987                }]
988            } else if let Some(scenarios_val) = obj.get("scenarios") {
989                // `scenarios: [name, name, ...]` — plural form
990                // for composing several named scenarios at one
991                // node in the tree. Each list entry expands to
992                // its own `IncludedScenario`; resolution happens
993                // post-parse via `resolve_scenario_includes`.
994                // Reads more naturally than repeating
995                // `- scenario: foo` for each entry; both forms
996                // are interchangeable.
997                //
998                // Map / object entries (`{ scenario: foo }`) are
999                // also accepted so a list can mix bare-string
1000                // includes with other scenario-node shapes
1001                // already supported by `parse_scenario_nodes`.
1002                match scenarios_val {
1003                    JVal::Array(arr) => arr
1004                        .iter()
1005                        .flat_map(|item| {
1006                            match item {
1007                                JVal::String(s) => vec![ScenarioNode::IncludedScenario {
1008                                    name: s.clone(),
1009                                    children: Vec::new(),
1010                                }],
1011                                // Anything else (object with
1012                                // `scenario:`, `for_each:`, etc.)
1013                                // routes through the standard parse
1014                                // path so list entries can be
1015                                // heterogeneous.
1016                                _ => parse_scenario_nodes(item),
1017                            }
1018                        })
1019                        .collect(),
1020                    JVal::String(s) => vec![ScenarioNode::IncludedScenario {
1021                        name: s.clone(),
1022                        children: Vec::new(),
1023                    }],
1024                    _ => Vec::new(),
1025                }
1026            } else if let Some(combo_val) = obj.get("for_combinations") {
1027                // Explicit for_combinations keyword (alias for
1028                // multi-clause for_each). Route through
1029                // ComprehensionSpec so the for_combinations branch
1030                // shares the for_each branch's single chokepoint.
1031                // Distinct-var input (the typical case) parses as
1032                // Cartesian via the name-repetition detection rule
1033                // — same semantics as the legacy direct
1034                // `Comprehension::cartesian(...)` path.
1035                let specs = parse_combination_specs(combo_val);
1036                let inline = specs
1037                    .iter()
1038                    .map(|(v, e)| format!("{v} in {e}"))
1039                    .collect::<Vec<_>>()
1040                    .join(", ");
1041                let spec = ComprehensionSpec {
1042                    r#for: ForSpec::Inline(inline),
1043                    r#where: obj.get("where").and_then(|v| v.as_str()).map(String::from),
1044                    order: obj.get("order").and_then(|v| v.as_str()).map(String::from),
1045                };
1046                match spec.into_algebra() {
1047                    Ok(comprehension) => vec![ScenarioNode::Comprehension {
1048                        comprehension,
1049                        children,
1050                        continue_if: parse_continue_if(obj.get("continue_if")),
1051                        anchor: obj.get("anchor").and_then(|v| v.as_str()).map(String::from),
1052                    }],
1053                    Err(e) => {
1054                        eprintln!("warning: for_combinations: {e}");
1055                        vec![]
1056                    }
1057                }
1058            } else if let Some(cond) = obj.get("do_while").and_then(|v| v.as_str()) {
1059                vec![ScenarioNode::DoWhile {
1060                    condition: cond.to_string(),
1061                    counter,
1062                    children,
1063                }]
1064            } else if let Some(cond) = obj.get("do_until").and_then(|v| v.as_str()) {
1065                vec![ScenarioNode::DoUntil {
1066                    condition: cond.to_string(),
1067                    counter,
1068                    children,
1069                }]
1070            } else if let Some(bindings_val) = obj.get("bindings") {
1071                // Scenario-tree-level `bindings:` — arbitrary GK
1072                // matter that installs as a scope-tree layer over
1073                // the parent. The source text is whatever the
1074                // author wrote (interpretation happens at kernel
1075                // build time via the canonical scope synthesizer).
1076                let source = match bindings_val {
1077                    JVal::String(s) => s.clone(),
1078                    JVal::Object(map) => {
1079                        // Map form: each `name: expr` produces
1080                        // one `name := <expr>` line. The GK
1081                        // compiler classifies the modifier from
1082                        // any leading `final`/`init`/`shared`
1083                        // keyword in `name`; the bare-name case
1084                        // becomes a cycle binding.
1085                        let mut out = String::new();
1086                        for (name, value) in map {
1087                            let v_text = match value {
1088                                JVal::String(s) => s.clone(),
1089                                JVal::Bool(b) => b.to_string(),
1090                                JVal::Number(n) => n.to_string(),
1091                                JVal::Null => String::new(),
1092                                other => other.to_string(),
1093                            };
1094                            out.push_str(&format!("{name} := {v_text}\n"));
1095                        }
1096                        out
1097                    }
1098                    _ => String::new(),
1099                };
1100                if source.trim().is_empty() {
1101                    Vec::new()
1102                } else {
1103                    if children.is_empty() {
1104                        eprintln!(
1105                            "warning: scenario-tree `bindings:` block has no \
1106                             `phases:` body — this is a no-op (the scope is \
1107                             entered and immediately exited with no descendants \
1108                             reading any of its declared names). If you meant \
1109                             to publish these bindings to a subtree, add a \
1110                             `phases:` block; if you meant to declare workload-\
1111                             level bindings, move them to the top-level \
1112                             `bindings:` field of the workload."
1113                        );
1114                    }
1115                    vec![ScenarioNode::Bindings { source, children }]
1116                }
1117            } else if let Some(set_val) = obj.get("set") {
1118                // `set: { name: value, ... }` — convenience sugar
1119                // that desugars to a `Bindings` node carrying GK
1120                // matter of the shape `const NAME := <polydat-literal>`.
1121                // The Polydat compiler handles workload-param
1122                // interpolation, string-literal interpolation,
1123                // and full const-expression evaluation at kernel
1124                // build time, so this form composes with every
1125                // other Polydat feature (no separate two-pass
1126                // evaluator). The `Bindings` node carries the
1127                // synthesized source; downstream code never sees
1128                // a SetParam-specific shape.
1129                //
1130                // String shorthand `set: name=value` is also
1131                // accepted for the single-override one-liner case.
1132                //
1133                // Map iteration order is preserved by serde_yaml
1134                // (insertion order); declaration order wins on
1135                // collision via the standard Polydat shadow semantics
1136                // for the same source.
1137                let pairs: Vec<(String, JVal)> = match set_val {
1138                    JVal::Object(map) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
1139                    JVal::String(s) => match s.split_once('=') {
1140                        Some((k, v)) => {
1141                            vec![(k.trim().to_string(), JVal::String(v.trim().to_string()))]
1142                        }
1143                        None => {
1144                            eprintln!(
1145                                "warning: scenario `set:` string form must be \
1146                                     `name=value`, got `{s}` — ignoring"
1147                            );
1148                            Vec::new()
1149                        }
1150                    },
1151                    _ => Vec::new(),
1152                };
1153                if pairs.is_empty() {
1154                    Vec::new()
1155                } else if children.is_empty() {
1156                    // Same no-op condition as the explicit
1157                    // `bindings:` form: a `set:` with no
1158                    // `phases:` body publishes overrides to
1159                    // nothing. Almost certainly an author error.
1160                    let names: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
1161                    eprintln!(
1162                        "warning: scenario-tree `set:` block (overriding {:?}) \
1163                         has no `phases:` body — this is a no-op. Add a `phases:` \
1164                         block listing what the override applies to.",
1165                        names
1166                    );
1167                    Vec::new()
1168                } else {
1169                    // Synthesize the Polydat source body. Each pair
1170                    // becomes `const NAME := <polydat-literal>` — the
1171                    // single canonical effectively-const binding
1172                    // shape. The compiler tries const-fold at
1173                    // compile time (pure literals fold; values
1174                    // containing `{name}` interpolation depend on
1175                    // extern slots that aren't populated until
1176                    // materialize-wiring) and falls back to
1177                    // scope-init pull when fold isn't possible.
1178                    // Either way the value is materialized once
1179                    // and then immutable for the scope's
1180                    // lifetime — author doesn't need to think
1181                    // about which path the runtime takes.
1182                    //
1183                    // Each value lowers to `const NAME := <rhs>` via
1184                    // `emit_set_value_literal`. A BARE identifier is a wire
1185                    // reference (consistent with comprehension r-values,
1186                    // SRD-18f) — `set: { x: mnc }` binds mnc's value, and an
1187                    // unresolved bare name is a hard error at scope synthesis.
1188                    // Numbers/bools are literals; a YAML sequence is a list. A
1189                    // string literal is the explicit polydat-quoted form
1190                    // `'"verbose"'` (ordinary YAML quotes don't survive the
1191                    // pipeline's serde round-trips).
1192                    let mut source = String::new();
1193                    for (name, value) in &pairs {
1194                        source.push_str(&format!(
1195                            "const {name} := {}\n",
1196                            emit_set_value_literal(value),
1197                        ));
1198                    }
1199                    vec![ScenarioNode::Bindings { source, children }]
1200                }
1201            } else {
1202                obj.iter()
1203                    .map(|(name, _cmd)| ScenarioNode::Phase(name.clone()))
1204                    .collect()
1205            }
1206        }
1207        _ => Vec::new(),
1208    }
1209}
1210
1211/// Resolve every `IncludedScenario { name, children: [] }` node
1212/// produced by [`parse_scenario_nodes`] into one whose `children`
1213/// hold a clone of the referenced scenario's resolved nodes.
1214///
1215/// Two failure modes are surfaced as parse errors:
1216///
1217/// 1. **Unknown scenario name** — `scenario: foo` where no
1218///    `scenarios.foo` exists.
1219/// 2. **Cycle** — `A` includes `B` which (transitively) includes
1220///    `A`. The error names the full cycle path so the operator
1221///    can fix the offending edge.
1222///
1223/// Resolution is depth-first with memoization so each scenario
1224/// is resolved at most once regardless of how many places
1225/// reference it. After this pass the workload model carries no
1226/// unresolved `IncludedScenario` nodes; downstream consumers
1227/// (scope tree, executor, runner) can treat the variant as a
1228/// fully-formed wrapper scope.
1229pub fn resolve_scenario_includes(
1230    scenarios: &mut HashMap<String, Vec<ScenarioNode>>,
1231) -> Result<(), String> {
1232    use std::collections::HashSet;
1233
1234    // Snapshot the input so resolution reads from a stable map
1235    // while we mutate the output. Each resolved scenario is
1236    // recorded back into `out`.
1237    let input: HashMap<String, Vec<ScenarioNode>> = scenarios.clone();
1238    let mut out: HashMap<String, Vec<ScenarioNode>> = HashMap::new();
1239
1240    fn resolve_nodes(
1241        nodes: &[ScenarioNode],
1242        input: &HashMap<String, Vec<ScenarioNode>>,
1243        out: &mut HashMap<String, Vec<ScenarioNode>>,
1244        stack: &mut Vec<String>,
1245    ) -> Result<Vec<ScenarioNode>, String> {
1246        let mut resolved = Vec::with_capacity(nodes.len());
1247        for n in nodes {
1248            resolved.push(resolve_one(n, input, out, stack)?);
1249        }
1250        Ok(resolved)
1251    }
1252
1253    fn resolve_one(
1254        node: &ScenarioNode,
1255        input: &HashMap<String, Vec<ScenarioNode>>,
1256        out: &mut HashMap<String, Vec<ScenarioNode>>,
1257        stack: &mut Vec<String>,
1258    ) -> Result<ScenarioNode, String> {
1259        match node {
1260            ScenarioNode::Phase(name) => Ok(ScenarioNode::Phase(name.clone())),
1261            ScenarioNode::IncludedScenario { name, .. } => {
1262                if stack.iter().any(|s| s == name) {
1263                    let mut path = stack.clone();
1264                    path.push(name.clone());
1265                    return Err(format!(
1266                        "scenario include cycle detected: {}",
1267                        path.join(" -> "),
1268                    ));
1269                }
1270                let target = input.get(name).ok_or_else(|| {
1271                    format!(
1272                        "scenario include 'scenario: {name}' references an unknown \
1273                     scenario. Known scenarios: {}",
1274                        {
1275                            let mut names: Vec<&str> = input.keys().map(|s| s.as_str()).collect();
1276                            names.sort();
1277                            names.join(", ")
1278                        },
1279                    )
1280                })?;
1281                stack.push(name.clone());
1282                let children = resolve_nodes(target, input, out, stack)?;
1283                stack.pop();
1284                // Memoize the resolved scenario for any later
1285                // include reference. Idempotent: equivalent
1286                // resolved children produced regardless of
1287                // entry point.
1288                out.entry(name.clone()).or_insert_with(|| children.clone());
1289                Ok(ScenarioNode::IncludedScenario {
1290                    name: name.clone(),
1291                    children,
1292                })
1293            }
1294            ScenarioNode::Comprehension {
1295                comprehension,
1296                children,
1297                continue_if,
1298                anchor,
1299            } => Ok(ScenarioNode::Comprehension {
1300                comprehension: comprehension.clone(),
1301                children: resolve_nodes(children, input, out, stack)?,
1302                continue_if: continue_if.clone(),
1303                anchor: anchor.clone(),
1304            }),
1305            ScenarioNode::DoWhile {
1306                condition,
1307                counter,
1308                children,
1309            } => Ok(ScenarioNode::DoWhile {
1310                condition: condition.clone(),
1311                counter: counter.clone(),
1312                children: resolve_nodes(children, input, out, stack)?,
1313            }),
1314            ScenarioNode::DoUntil {
1315                condition,
1316                counter,
1317                children,
1318            } => Ok(ScenarioNode::DoUntil {
1319                condition: condition.clone(),
1320                counter: counter.clone(),
1321                children: resolve_nodes(children, input, out, stack)?,
1322            }),
1323            ScenarioNode::Bindings { source, children } => Ok(ScenarioNode::Bindings {
1324                source: source.clone(),
1325                children: resolve_nodes(children, input, out, stack)?,
1326            }),
1327        }
1328    }
1329
1330    let mut visited: HashSet<String> = HashSet::new();
1331    let names: Vec<String> = scenarios.keys().cloned().collect();
1332    for name in names {
1333        if visited.contains(&name) {
1334            continue;
1335        }
1336        let mut stack = vec![name.clone()];
1337        let resolved = resolve_nodes(&input[&name], &input, &mut out, &mut stack)?;
1338        out.insert(name.clone(), resolved);
1339        visited.insert(name);
1340    }
1341    *scenarios = out;
1342    Ok(())
1343}
1344
1345/// Parse combination specs from any of three YAML forms:
1346///
1347/// **Map form** (keys = variables, values = expressions):
1348/// ```yaml
1349/// for_combinations:
1350///   profile: "matching_profiles('{dataset}', '{prefix}')"
1351///   k: "{k_values}"
1352/// ```
1353///
1354/// **List form** (reuses for_each "var in expr" syntax):
1355/// ```yaml
1356/// for_combinations:
1357///   - "profile in matching_profiles('{dataset}', '{prefix}')"
1358///   - "k in {k_values}"
1359/// ```
1360///
1361/// **Inline form** (compact comma-separated):
1362/// ```yaml
1363/// for_combinations: "profile in profiles, k in {k_values}"
1364/// ```
1365fn parse_combination_specs(val: &JVal) -> Vec<(String, String)> {
1366    match val {
1367        // Map form: { "profile": "expr", "k": "expr" }
1368        JVal::Object(map) => map
1369            .iter()
1370            .map(|(key, val)| {
1371                let expr = val.as_str().unwrap_or("").to_string();
1372                (key.clone(), expr)
1373            })
1374            .collect(),
1375        // List form: ["profile in expr", "k in expr"]
1376        JVal::Array(arr) => arr
1377            .iter()
1378            .filter_map(|item| {
1379                let s = item.as_str()?;
1380                clause_pairs_of(s)
1381            })
1382            .flatten()
1383            .collect(),
1384        // Inline form: "profile in expr, k in expr"
1385        // Split on commas that are NOT inside parentheses (respects
1386        // function calls like `matching_profiles('{dataset}', '{prefix}')`).
1387        JVal::String(s) => clause_pairs_of(s).unwrap_or_default(),
1388        _ => {
1389            eprintln!("warning: for_combinations value must be a map, list, or string");
1390            Vec::new()
1391        }
1392    }
1393}
1394
1395/// Parse clause text (`"var in expr"`, or a comma-separated list of
1396/// them) through polydat's spec entry and read the `(var, expr)` pairs
1397/// back off the algebra, warning and yielding `None` on a parse error.
1398fn clause_pairs_of(text: &str) -> Option<Vec<(String, String)>> {
1399    match parse_inline(text) {
1400        Ok(comp) => {
1401            let mut pairs = Vec::new();
1402            collect_clause_pairs(&comp, &mut pairs);
1403            Some(pairs)
1404        }
1405        Err(e) => {
1406            eprintln!("warning: for_combinations: {e}");
1407            None
1408        }
1409    }
1410}
1411
1412/// Every leaf clause of `c`, in order, as `(var, source text)`.
1413fn collect_clause_pairs(c: &Comprehension, out: &mut Vec<(String, String)>) {
1414    match c {
1415        Comprehension::Clause { name, source } => match source.to_text() {
1416            Some(text) => out.push((name.clone(), text)),
1417            None => eprintln!("warning: for_combinations: clause '{name}' has no text form"),
1418        },
1419        Comprehension::Cartesian { children }
1420        | Comprehension::Zip { children, .. }
1421        | Comprehension::Union { children } => {
1422            for child in children {
1423                collect_clause_pairs(child, out);
1424            }
1425        }
1426        Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
1427            collect_clause_pairs(child, out);
1428        }
1429    }
1430}
1431
1432// -----------------------------------------------------------------
1433// Phases
1434// -----------------------------------------------------------------
1435
1436/// Parse the `phases:` section of a workload YAML.
1437///
1438/// Each phase is a named map with optional `cycles`, `concurrency`,
1439/// `rate`, `adapter`, `errors`, `tags`, and `ops` fields.
1440/// Returns the phase map and a Vec preserving YAML definition order.
1441fn parse_phases(
1442    val: Option<&JVal>,
1443    doc_params: &HashMap<String, JVal>,
1444    doc_tags: &HashMap<String, String>,
1445) -> Result<(HashMap<String, WorkloadPhase>, Vec<String>), String> {
1446    let mut phases = HashMap::new();
1447    let mut phase_order = Vec::new();
1448    let Some(val) = val else {
1449        return Ok((phases, phase_order));
1450    };
1451    let Some(obj) = val.as_object() else {
1452        return Ok((phases, phase_order));
1453    };
1454
1455    for (phase_name, phase_val) in obj {
1456        let Some(phase_obj) = phase_val.as_object() else {
1457            continue;
1458        };
1459
1460        let cycles = phase_obj.get("cycles").map(|v| match v {
1461            JVal::Number(n) => n.to_string(),
1462            JVal::String(s) => s.clone(),
1463            other => other.to_string(),
1464        });
1465
1466        let concurrency = phase_obj.get("concurrency").map(|v| match v {
1467            JVal::Number(n) => n.to_string(),
1468            JVal::String(s) => s.clone(),
1469            other => other.to_string(),
1470        });
1471
1472        // A number or a `{param}` / iter-var reference (resolved at
1473        // the phase gather, the `timeout:` discipline). Anything
1474        // else is a load error — a malformed rate must never
1475        // silently become "unrated".
1476        let rate = match phase_obj.get("rate") {
1477            None => None,
1478            Some(JVal::Number(n)) => Some(n.to_string()),
1479            Some(JVal::String(s)) => Some(s.clone()),
1480            Some(other) => {
1481                return Err(format!(
1482                    "phase '{phase_name}': `rate` must be a number (ops/sec) \
1483                 or a {{param}} reference; got: {other}"
1484                ));
1485            }
1486        };
1487
1488        // SRD-82 Part 6 — daemon phase (runs concurrently with foreground
1489        // siblings, stopped when they complete). Accept bool / 0|1 / on|off.
1490        let daemon = phase_obj
1491            .get("daemon")
1492            .map(|v| match v {
1493                JVal::Bool(b) => *b,
1494                JVal::Number(n) => n.as_u64().map(|u| u != 0).unwrap_or(false),
1495                JVal::String(s) => matches!(
1496                    s.trim().to_ascii_lowercase().as_str(),
1497                    "true" | "on" | "yes" | "1"
1498                ),
1499                _ => false,
1500            })
1501            .unwrap_or(false);
1502
1503        let adapter = phase_obj
1504            .get("adapter")
1505            .and_then(|v| v.as_str())
1506            .map(|s| s.to_string());
1507
1508        let errors = phase_obj
1509            .get("errors")
1510            .and_then(|v| v.as_str())
1511            .map(|s| s.to_string());
1512
1513        let error_rate_max = phase_obj.get("error_rate_max").and_then(|v| v.as_f64());
1514
1515        // SRD-83 governance `timeout:` (GAP-12). A duration string, a
1516        // bare number (fractional seconds), or a `{param}` reference.
1517        // Param-bearing values resolve at the phase gather; literal
1518        // values must at least LOOK like a time value (non-empty, and
1519        // either numeric-leading or brace-opening) so a structural typo
1520        // fails at load, not at first dispatch. Full duration parsing
1521        // stays runtime-side (single parser: `timeval::parse_time_ms`).
1522        let timeout = match phase_obj.get("timeout") {
1523            None => None,
1524            Some(v) => {
1525                let s = match v {
1526                    serde_json::Value::String(s) => s.clone(),
1527                    n if n.is_u64() || n.is_f64() => n.to_string(),
1528                    other => {
1529                        return Err(format!(
1530                            "phase '{phase_name}': `timeout` must be a duration \
1531                         string (e.g. \"2.5h\"), a number of seconds, or a \
1532                         {{param}} reference; got: {other}"
1533                        ));
1534                    }
1535                };
1536                let t = s.trim();
1537                if t.is_empty()
1538                    || !(t.starts_with('{') || t.starts_with(|c: char| c.is_ascii_digit()))
1539                {
1540                    return Err(format!(
1541                        "phase '{phase_name}': `timeout: \"{s}\"` is not a \
1542                         duration ('{{param}}', \"2.5h\", \"150ms\", or \
1543                         seconds)"
1544                    ));
1545                }
1546                Some(s)
1547            }
1548        };
1549
1550        // Per-phase total-attempts budget — the phase-level surface of the
1551        // `tries` sigil (SRD-82 Part 3b). Inherits down to the phase's ops;
1552        // absent everywhere → no tries wrapper (single attempt). Two forms:
1553        // the sugared number (`tries: 20`) and the map form carrying retry
1554        // backoff (`tries: {count: 20, backoff: {ratio, min, max}}`).
1555        let (tries, tries_backoff) = match phase_obj.get("tries") {
1556            None => (None, None),
1557            Some(v) if v.is_u64() || v.is_i64() => (v.as_u64().map(|n| n as u32), None),
1558            Some(serde_json::Value::Object(m)) => {
1559                let count = m.get("count").and_then(|c| c.as_u64()).map(|n| n as u32);
1560                let backoff = m.get("backoff").and_then(|b| b.as_object()).map(|bo| {
1561                    crate::model::BackoffSpec {
1562                        ratio: bo.get("ratio").and_then(|r| r.as_f64()),
1563                        min: bo.get("min").and_then(json_scalar_to_dur_string),
1564                        max: bo.get("max").and_then(json_scalar_to_dur_string),
1565                    }
1566                });
1567                (count, backoff)
1568            }
1569            Some(other) => {
1570                return Err(format!(
1571                    "phase '{phase_name}': `tries` must be a number or a map \
1572                 {{count, backoff: {{ratio, min, max}}}}, got {other}"
1573                ));
1574            }
1575        };
1576
1577        // SRD-83 — `stop_when:` is a list of {when, trigger?, effect?}.
1578        // StopConditionSpec derives Deserialize, so deserialize the
1579        // sub-tree directly; surface a malformed block as a parse error.
1580        // SRD-83 §throttle — adaptive backpressure governor: bool
1581        // sugar or the full spec map (serde, deny_unknown_fields).
1582        let throttle: Option<crate::model::ThrottleField> = match phase_obj.get("throttle") {
1583            Some(v) => Some(
1584                serde_json::from_value(v.clone())
1585                    .map_err(|e| format!("phase '{phase_name}' invalid `throttle` block: {e}"))?,
1586            ),
1587            None => None,
1588        };
1589        let stop_when: Vec<StopConditionSpec> = match phase_obj.get("stop_when") {
1590            Some(v) => serde_json::from_value(v.clone())
1591                .map_err(|e| format!("invalid `stop_when` block: {e}"))?,
1592            None => Vec::new(),
1593        };
1594        // SRD-83 Part 5 — reject unknown effect verbs at load (a typo'd
1595        // verb would otherwise silently become the shell default).
1596        for sc in &stop_when {
1597            sc.validate()
1598                .map_err(|e| format!("phase `stop_when`: {e}"))?;
1599        }
1600
1601        let tags = phase_obj
1602            .get("tags")
1603            .and_then(|v| v.as_str())
1604            .map(|s| s.to_string());
1605
1606        // SRD-13f Push D: phase-level AND workload-level
1607        // `bindings:` are captured on their own scope's AST
1608        // only — they do NOT fold into per-op bindings.
1609        // Workload bindings live on `Workload.bindings` and
1610        // compile to the workload-root kernel; phase bindings
1611        // live on `WorkloadPhase.bindings` and compile to the
1612        // phase kernel. Both reach ops through the Polydat Kernel
1613        // chain (cell-on-outputs cascade, SRD-13f Push B.2),
1614        // not parser-time concat.
1615        let phase_bindings_only = extract_bindings(phase_obj.get("bindings"));
1616
1617        // Parse inline ops if present
1618        let mut inline_ops = Vec::new();
1619        for key in ["ops", "op", "operations", "statements", "statement"] {
1620            if let Some(ops_val) = phase_obj.get(key) {
1621                let phase_tags = {
1622                    let mut t = doc_tags.clone();
1623                    t.insert("phase".to_string(), phase_name.clone());
1624                    t
1625                };
1626                // Phase inline ops carry zero "outer YAML
1627                // bindings sugar" — they're directly under the
1628                // phase, no block wrapper. Workload + phase
1629                // bindings reach them via the Polydat Kernel chain.
1630                parse_ops_field(
1631                    ops_val,
1632                    phase_name,
1633                    &BindingsDef::default(),
1634                    doc_params,
1635                    &phase_tags,
1636                    &mut inline_ops,
1637                )?;
1638                break;
1639            }
1640        }
1641
1642        // Auto-tag inline ops
1643        for op in &mut inline_ops {
1644            if !op.tags.contains_key("name") {
1645                op.tags.insert("name".to_string(), op.name.clone());
1646            }
1647            if !op.tags.contains_key("op") {
1648                op.tags.insert("op".to_string(), op.name.clone());
1649            }
1650        }
1651
1652        let for_each = phase_obj
1653            .get("for_each")
1654            .or_else(|| phase_obj.get("for"))
1655            .and_then(|v| v.as_str())
1656            .map(|s| s.to_string());
1657        // SRD-101 — phase-level `continue_if` gate (bounds a `for_each` sweep).
1658        let continue_if = parse_continue_if(phase_obj.get("continue_if"));
1659
1660        let loop_scope = phase_obj
1661            .get("loop_scope")
1662            .and_then(|v| v.as_str())
1663            .map(|s| s.to_string());
1664        let iter_scope = phase_obj
1665            .get("iter_scope")
1666            .and_then(|v| v.as_str())
1667            .map(|s| s.to_string());
1668        // Phase-level `summary:` is gone (SRD-46 made `report:`
1669        // the canonical surface). Reject explicitly so silent
1670        // drops don't mislead operators migrating workloads.
1671        if phase_obj.contains_key("summary") {
1672            return Err(format!(
1673                "phase '{phase_name}': `summary:` is removed at phase level; \
1674                 use a `report:` block with `table <name> ...` items instead \
1675                 (SRD-46)"
1676            ));
1677        }
1678        // Per-phase `checkpoint:` declaration. Three forms
1679        // (short string, bool/none, full mapping) handled by
1680        // [`Checkpoint`]'s custom deserialize. Absent → None →
1681        // phase always re-runs on resume (per SRD-44 §"No
1682        // workload-level default").
1683        let checkpoint = phase_obj
1684            .get("checkpoint")
1685            .map(|v| serde_json::from_value::<crate::model::Checkpoint>(v.clone()))
1686            .transpose()
1687            .map_err(|e| format!("phase '{phase_name}' checkpoint: {e}"))?;
1688
1689        // Phase-level `poll:` block (SRD-75). When present,
1690        // the phase's cycle execution runs in a wall-clock
1691        // loop until a Polydat predicate over captures returns
1692        // `true` or `timeout_ms` elapses. Distinct from the
1693        // OP-level `poll:` field (which lives on a single op
1694        // and wraps a `PollingDispenser` — SRD-32). The two
1695        // forms coexist; phase-poll is the synchronizer
1696        // pattern (SRD-75 driver use case), per-op poll is
1697        // the await-emptiness pattern.
1698        let phase_poll = match phase_obj.get("poll") {
1699            None => None,
1700            Some(v) => {
1701                let map = v.as_object().ok_or_else(|| {
1702                    format!(
1703                        "phase '{phase_name}': phase-level `poll:` must be a \
1704                     mapping with at least `until: <expr>`. Got a non-object \
1705                     value; if you intended an OP-level `poll:` flag, attach \
1706                     it to a specific op under `ops:` instead. SRD-75."
1707                    )
1708                })?;
1709                let until = map
1710                    .get("until")
1711                    .and_then(|v| v.as_str())
1712                    .ok_or_else(|| {
1713                        format!(
1714                            "phase '{phase_name}': phase-level `poll:` requires \
1715                         `until: <polydat-boolean-expression>`. SRD-75."
1716                        )
1717                    })?
1718                    .to_string();
1719                // Numbers or `{param}` / iter-var references
1720                // (resolved at the phase gather); anything else is
1721                // a load error — a malformed bound must never
1722                // silently fall back to the default.
1723                let numeric_or_ref = |field: &str| -> Result<Option<String>, String> {
1724                    match map.get(field) {
1725                        None => Ok(None),
1726                        Some(JVal::Number(n)) => Ok(Some(n.to_string())),
1727                        Some(JVal::String(s)) => Ok(Some(s.clone())),
1728                        Some(other) => Err(format!(
1729                            "phase '{phase_name}' poll: `{field}` must be a \
1730                             number or a {{param}} reference; got: {other}. \
1731                             SRD-75."
1732                        )),
1733                    }
1734                };
1735                let interval_ms = numeric_or_ref("interval_ms")?;
1736                let timeout_ms = numeric_or_ref("timeout_ms")?;
1737                let max_error_retries = numeric_or_ref("max_error_retries")?;
1738                let metric_name = map
1739                    .get("metric_name")
1740                    .and_then(|v| v.as_str())
1741                    .map(|s| s.to_string());
1742                // SRD-75 §"Open questions" → §"on_timeout" — what
1743                // to do when the deadline fires. Closed vocabulary:
1744                // `error` (default; phase fails, scenario continues
1745                // by default error-routing policy) or `abort`
1746                // (request session stop; the whole run terminates).
1747                let on_timeout = match map.get("on_timeout") {
1748                    None => None,
1749                    Some(v) => {
1750                        let s = v.as_str().ok_or_else(|| {
1751                            format!(
1752                                "phase '{phase_name}' poll: `on_timeout` must be \
1753                             a string (`error` or `abort`). SRD-75."
1754                            )
1755                        })?;
1756                        let normalized = s.trim().to_ascii_lowercase();
1757                        if !matches!(normalized.as_str(), "error" | "abort") {
1758                            return Err(format!(
1759                                "phase '{phase_name}' poll: `on_timeout` must \
1760                                 be `error` or `abort`, got '{s}'. SRD-75."
1761                            ));
1762                        }
1763                        Some(normalized)
1764                    }
1765                };
1766                // SRD-75 (C5) — `require:` strict-gate selectors: a
1767                // single selector string or a list of them; each must
1768                // be a non-empty string.
1769                let require: Vec<String> = match map.get("require") {
1770                    None => Vec::new(),
1771                    Some(serde_json::Value::String(s)) => vec![s.clone()],
1772                    Some(serde_json::Value::Array(items)) => items
1773                        .iter()
1774                        .map(|v| {
1775                            v.as_str().map(str::to_string).ok_or_else(|| {
1776                                format!(
1777                                    "phase '{phase_name}' poll: `require` entries must \
1778                             be metric-selector strings. SRD-75."
1779                                )
1780                            })
1781                        })
1782                        .collect::<Result<Vec<_>, _>>()?,
1783                    Some(_) => {
1784                        return Err(format!(
1785                            "phase '{phase_name}' poll: `require` must be a \
1786                         selector string or a list of them. SRD-75."
1787                        ));
1788                    }
1789                };
1790                if let Some(bad) = require.iter().find(|s| s.trim().is_empty()) {
1791                    let _ = bad;
1792                    return Err(format!(
1793                        "phase '{phase_name}' poll: `require` entries must be \
1794                         non-empty metric selectors. SRD-75."
1795                    ));
1796                }
1797                // Reject keys outside the documented surface — a
1798                // typo like `tinerval_ms:` should fail loudly, not
1799                // silently default. SRD-30 unknown-field hygiene.
1800                let allowed = crate::vocab::phase_poll_fields();
1801                for k in map.keys() {
1802                    if !allowed.contains(&k.as_str()) {
1803                        return Err(format!(
1804                            "phase '{phase_name}' poll: unknown key '{k}'. \
1805                             Allowed: [{}]. SRD-75.",
1806                            allowed.join(", "),
1807                        ));
1808                    }
1809                }
1810                Some(crate::model::PhasePollSpec {
1811                    until,
1812                    interval_ms,
1813                    timeout_ms,
1814                    max_error_retries,
1815                    metric_name,
1816                    on_timeout,
1817                    require,
1818                })
1819            }
1820        };
1821        // SRD-75 §"Concurrency": phase-poll is sequential
1822        // within one phase activation — the predicate's
1823        // evaluation depends on a serial sequence of capture
1824        // writes. `concurrency > 1` against this shape doesn't
1825        // have a meaningful semantic; reject at parse time
1826        // rather than producing surprising runtime behavior.
1827        if phase_poll.is_some() {
1828            if let Some(ref c) = concurrency {
1829                let trimmed = c.trim();
1830                if trimmed != "1" && !trimmed.is_empty() {
1831                    return Err(format!(
1832                        "phase '{phase_name}': `poll:` (SRD-75) is \
1833                         incompatible with `concurrency: {c}` — phase-poll \
1834                         is sequential within one activation (the predicate \
1835                         depends on a serial sequence of capture writes). \
1836                         Drop `concurrency` or set it to 1."
1837                    ));
1838                }
1839            }
1840            if inline_ops.is_empty() {
1841                return Err(format!(
1842                    "phase '{phase_name}': `poll:` requires at least one op \
1843                     under `ops:` — captures are written by op execution; \
1844                     a phase with `poll:` and no ops has nothing to do. \
1845                     SRD-75."
1846                ));
1847            }
1848        }
1849
1850        // `status_metrics:` — names of relevancy aggregates to
1851        // surface on the inline progress line and the per-phase
1852        // ✓ DONE summary. Accepts a YAML list (`[name, name]`),
1853        // a single string, or a comma-separated string. Empty /
1854        // absent → no metrics tail (nothing presumed present).
1855        let status_metrics: Vec<String> = match phase_obj.get("status_metrics") {
1856            None => Vec::new(),
1857            Some(JVal::Array(items)) => items
1858                .iter()
1859                .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
1860                .filter(|s| !s.is_empty())
1861                .collect(),
1862            Some(JVal::String(s)) => s
1863                .split(',')
1864                .map(str::trim)
1865                .filter(|s| !s.is_empty())
1866                .map(String::from)
1867                .collect(),
1868            Some(other) => {
1869                return Err(format!(
1870                    "phase '{phase_name}' status_metrics: must be a list of metric \
1871                 names, a comma-separated string, or omitted; got {other:?}"
1872                ));
1873            }
1874        };
1875
1876        // Phase-level `metrics:` — same schema as op `metrics:`,
1877        // but evaluated once at phase completion. Raw `value:`
1878        // expressions are preserved (no auto-inject into bindings):
1879        // the phase synthesiser emits `volatile __metric_<name> :=
1880        // <value>` directly, so a nondeterministic value such as
1881        // `phase_elapsed(phase_start)` is volatility-acknowledged
1882        // for strict mode in one place.
1883        let metrics = parse_phase_metrics_field(phase_obj.get("metrics"), phase_name)
1884            .map_err(|e| format!("phase '{phase_name}' metrics: {e}"))?;
1885
1886        // Phase-level `dimensions:` — label names this phase introduces,
1887        // whose VALUES arrive from data through a metric's `cell:`. The
1888        // name is the structural declaration; declaring it here is what
1889        // lets a `cell:` reference be checked against the program before
1890        // any cycle runs, instead of surfacing as an attach-time panic on
1891        // the component tree.
1892        let dimensions = parse_dimensions_field(phase_obj.get("dimensions"), phase_name)?;
1893
1894        // SRD-86 — phase `optimize:` block (workload-local config). A bare
1895        // string is sugar for `{ objective: <string> }` (see
1896        // `OptimizeBlock::from_yaml_value`).
1897        let optimize = match phase_obj.get("optimize") {
1898            Some(v) => Some(
1899                crate::model::OptimizeBlock::from_yaml_value(v)
1900                    .map_err(|e| format!("phase '{phase_name}' invalid `optimize` block: {e}"))?,
1901            ),
1902            None => None,
1903        };
1904        // A `cell:` must name a DECLARED dimension. This is the payoff for
1905        // reifying dimensions: the reference is checked against the program
1906        // at load, so a typo'd or undeclared dimension is a workload error
1907        // here rather than a component-tree surprise at attach time (or, in
1908        // the label-ownership case, a runtime panic).
1909        validate_cell_dimensions(phase_name, &dimensions, &inline_ops, &metrics)?;
1910
1911        // SRD-109 — key-metric designations. Aggregate qualification is
1912        // MANDATORY: an unqualified family is a parse error carrying the
1913        // vocabulary, because there are no implied aggregates anywhere in
1914        // the reporting pipeline.
1915        let key_metrics: Vec<crate::model::KeyMetric> = match phase_obj.get("key_metrics") {
1916            None => Vec::new(),
1917            Some(v) => {
1918                let map = v.as_object().ok_or_else(|| {
1919                    format!(
1920                        "phase '{phase_name}': key_metrics must be a mapping of \
1921                     column: \"agg(family)\""
1922                    )
1923                })?;
1924                let mut out = Vec::new();
1925                for (col, spec) in map {
1926                    let spec = spec.as_str().ok_or_else(|| {
1927                        format!(
1928                            "phase '{phase_name}' key_metrics.{col}: expected a \
1929                         string \"agg(family)\""
1930                        )
1931                    })?;
1932                    let (agg_name, family) = spec
1933                        .trim()
1934                        .strip_suffix(')')
1935                        .and_then(|s| s.split_once('('))
1936                        .ok_or_else(|| {
1937                            format!(
1938                                "phase '{phase_name}' key_metrics.{col}: '{spec}' — \
1939                             aggregate qualification required; write agg(family). \
1940                             Aggregates: {}",
1941                                crate::model::KeyAgg::VOCAB
1942                            )
1943                        })?;
1944                    let agg = crate::model::KeyAgg::parse(agg_name.trim()).ok_or_else(|| {
1945                        format!(
1946                            "phase '{phase_name}' key_metrics.{col}: unknown \
1947                             aggregate '{}'. Aggregates: {}",
1948                            agg_name.trim(),
1949                            crate::model::KeyAgg::VOCAB
1950                        )
1951                    })?;
1952                    let family = family.trim().to_string();
1953                    match agg {
1954                        crate::model::KeyAgg::Span if !family.is_empty() => {
1955                            return Err(format!(
1956                                "phase '{phase_name}' key_metrics.{col}: span() is \
1957                                 family-less — it measures the activation wall clock"
1958                            ));
1959                        }
1960                        crate::model::KeyAgg::Span => {}
1961                        _ if family.is_empty() => {
1962                            return Err(format!(
1963                                "phase '{phase_name}' key_metrics.{col}: {}() needs \
1964                                 a family name",
1965                                agg_name.trim()
1966                            ));
1967                        }
1968                        _ => {}
1969                    }
1970                    out.push(crate::model::KeyMetric {
1971                        column: col.clone(),
1972                        agg,
1973                        family,
1974                    });
1975                }
1976                out
1977            }
1978        };
1979
1980        phases.insert(
1981            phase_name.clone(),
1982            WorkloadPhase {
1983                dimensions,
1984                cycles,
1985                concurrency,
1986                rate,
1987                daemon,
1988                adapter,
1989                errors,
1990                tries,
1991                tries_backoff,
1992                interval: phase_obj
1993                    .get("interval")
1994                    .and_then(|v| v.as_str().map(str::to_string)),
1995                repeat: phase_obj.get("repeat").and_then(|v| v.as_u64()),
1996                error_rate_max,
1997                timeout,
1998                stop_when,
1999                throttle,
2000                tags,
2001                ops: inline_ops,
2002                for_each,
2003                continue_if,
2004                loop_scope,
2005                iter_scope,
2006                checkpoint,
2007                status_metrics,
2008                bindings: phase_bindings_only,
2009                metrics,
2010                poll: phase_poll,
2011                optimize,
2012                key_metrics,
2013            },
2014        );
2015        phase_order.push(phase_name.clone());
2016    }
2017
2018    Ok((phases, phase_order))
2019}
2020
2021// -----------------------------------------------------------------
2022// Blocks
2023// -----------------------------------------------------------------
2024
2025fn parse_blocks(
2026    blocks_val: &JVal,
2027    doc_params: &HashMap<String, JVal>,
2028    doc_tags: &HashMap<String, String>,
2029    all_ops: &mut Vec<ParsedOp>,
2030) -> Result<(), String> {
2031    match blocks_val {
2032        JVal::Object(map) => {
2033            for (block_name, block_val) in map {
2034                parse_single_block(block_name, block_val, doc_params, doc_tags, all_ops)?;
2035            }
2036        }
2037        JVal::Array(arr) => {
2038            for (i, block_val) in arr.iter().enumerate() {
2039                let name = block_val
2040                    .get("name")
2041                    .and_then(|v| v.as_str())
2042                    .map(|s| s.to_string())
2043                    .unwrap_or_else(|| format!("block{}", i + 1));
2044                parse_single_block(&name, block_val, doc_params, doc_tags, all_ops)?;
2045            }
2046        }
2047        _ => {}
2048    }
2049    Ok(())
2050}
2051
2052fn parse_single_block(
2053    block_name: &str,
2054    block_val: &JVal,
2055    doc_params: &HashMap<String, JVal>,
2056    doc_tags: &HashMap<String, String>,
2057    all_ops: &mut Vec<ParsedOp>,
2058) -> Result<(), String> {
2059    let obj = match block_val.as_object() {
2060        Some(o) => o,
2061        None => return Ok(()),
2062    };
2063
2064    // SRD-13f Push D: workload-level `bindings:` no longer
2065    // merge into block-level. Blocks are YAML organizational
2066    // sugar (not a Polydat scope), so a block's own `bindings:` is
2067    // *syntactic sugar* expanded into each enclosed op's
2068    // `op.bindings` at parse time — it does not cross any
2069    // kernel boundary, and the only "merge" left is this
2070    // block-sugar → op inlining inside `normalize_op_object`.
2071    let block_bindings = extract_bindings(obj.get("bindings"));
2072    let block_params = merge_value_maps(doc_params, &extract_value_map(obj.get("params")));
2073    let mut block_tags = merge_string_maps(doc_tags, &extract_string_map(obj.get("tags")));
2074    block_tags.insert("block".to_string(), block_name.to_string());
2075
2076    // Find ops field
2077    for key in ["ops", "op", "operations", "statements", "statement"] {
2078        if let Some(ops_val) = obj.get(key) {
2079            parse_ops_field(
2080                ops_val,
2081                block_name,
2082                &block_bindings,
2083                &block_params,
2084                &block_tags,
2085                all_ops,
2086            )?;
2087            return Ok(());
2088        }
2089    }
2090
2091    // If no ops field, check if the block value itself is a string (single op)
2092    if let Some(s) = block_val.as_str() {
2093        let mut op = ParsedOp::simple("stmt1", s);
2094        op.bindings = block_bindings;
2095        op.params = block_params;
2096        op.tags = block_tags;
2097        all_ops.push(op);
2098    }
2099
2100    Ok(())
2101}
2102
2103// -----------------------------------------------------------------
2104// Ops
2105// -----------------------------------------------------------------
2106
2107fn parse_ops_field(
2108    ops_val: &JVal,
2109    block_name: &str,
2110    bindings: &BindingsDef,
2111    params: &HashMap<String, JVal>,
2112    tags: &HashMap<String, String>,
2113    all_ops: &mut Vec<ParsedOp>,
2114) -> Result<(), String> {
2115    // SRD-32a: activity/phase-scope param keys (cycles / concurrency /
2116    // rate / errors / error_rate_max) are consumed at phase/activity scope,
2117    // never as op fields. Strip them from the inherited params before they
2118    // reach any op, so an inherited `rate` doesn't collide with the `rate`
2119    // wrapper's field-ownership guard. A genuine op-level `rate:` reaches the
2120    // op via the typed `ParsedOp.rate` field, which is untouched.
2121    let op_scope_params = exclude_activity_keys(params);
2122    let params = &op_scope_params;
2123    let mut op_counter = 0;
2124
2125    match ops_val {
2126        // Single string: op: "SELECT ..."
2127        JVal::String(s) => {
2128            op_counter += 1;
2129            let name = format!("stmt{op_counter}");
2130            let mut op = ParsedOp::simple(&name, s);
2131            op.bindings = bindings.clone();
2132            op.params = params.clone();
2133            op.tags = tags.clone();
2134            op.tags.insert("block".to_string(), block_name.to_string());
2135            all_ops.push(op);
2136        }
2137
2138        // List of ops
2139        JVal::Array(arr) => {
2140            for item in arr {
2141                op_counter += 1;
2142                let auto_name = format!("stmt{op_counter}");
2143                let op = normalize_op_item(item, &auto_name, block_name, bindings, params, tags)?;
2144                all_ops.push(op);
2145            }
2146        }
2147
2148        // Map of named ops
2149        JVal::Object(map) => {
2150            for (key, val) in map {
2151                let op = normalize_op_entry(key, val, block_name, bindings, params, tags)?;
2152                all_ops.push(op);
2153            }
2154        }
2155
2156        _ => {}
2157    }
2158
2159    Ok(())
2160}
2161
2162/// Normalize a single op from a list item.
2163fn normalize_op_item(
2164    item: &JVal,
2165    auto_name: &str,
2166    block_name: &str,
2167    bindings: &BindingsDef,
2168    params: &HashMap<String, JVal>,
2169    tags: &HashMap<String, String>,
2170) -> Result<ParsedOp, String> {
2171    match item {
2172        JVal::String(s) => {
2173            let mut op = ParsedOp::simple(auto_name, s);
2174            op.bindings = bindings.clone();
2175            op.params = params.clone();
2176            op.tags = tags.clone();
2177            op.tags.insert("block".to_string(), block_name.to_string());
2178            Ok(op)
2179        }
2180        JVal::Object(map) => {
2181            // Check if first entry is name:stmt pattern
2182            if let Some((first_key, first_val)) = map.iter().next()
2183                && map.len() == 1
2184                && first_val.is_string()
2185            {
2186                let mut op = ParsedOp::simple(first_key, first_val.as_str().unwrap());
2187                op.bindings = bindings.clone();
2188                op.params = params.clone();
2189                op.tags = tags.clone();
2190                op.tags.insert("block".to_string(), block_name.to_string());
2191                return Ok(op);
2192            }
2193            // Full op object
2194            normalize_op_object(map, auto_name, block_name, bindings, params, tags)
2195        }
2196        _ => Ok(ParsedOp::simple(auto_name, "")),
2197    }
2198}
2199
2200/// Normalize a named op from a map entry.
2201fn normalize_op_entry(
2202    key: &str,
2203    val: &JVal,
2204    block_name: &str,
2205    bindings: &BindingsDef,
2206    params: &HashMap<String, JVal>,
2207    tags: &HashMap<String, String>,
2208) -> Result<ParsedOp, String> {
2209    match val {
2210        JVal::String(s) => {
2211            let mut op = ParsedOp::simple(key, s);
2212            op.bindings = bindings.clone();
2213            op.params = params.clone();
2214            op.tags = tags.clone();
2215            op.tags.insert("block".to_string(), block_name.to_string());
2216            Ok(op)
2217        }
2218        JVal::Object(map) => normalize_op_object(map, key, block_name, bindings, params, tags),
2219        JVal::Array(arr) => {
2220            // Array at op level → moved to op.stmt
2221            let mut op_fields = HashMap::new();
2222            op_fields.insert("stmt".to_string(), JVal::Array(arr.clone()));
2223            let mut op = ParsedOp {
2224                traverse: None,
2225                name: key.to_string(),
2226                description: None,
2227                op: op_fields,
2228                bindings: bindings.clone(),
2229                params: params.clone(),
2230                tags: tags.clone(),
2231                condition: None,
2232                delay: None,
2233                metrics: HashMap::new(),
2234                result: None,
2235                wrappers: None,
2236                captures: Vec::new(),
2237                abstract_interface: None,
2238                interface_bound: false,
2239                daemon: crate::model::DaemonSpec::Disabled,
2240                daemon_cancel_grace_ms: None,
2241                while_cond: None,
2242                rate: None,
2243            };
2244            op.tags.insert("block".to_string(), block_name.to_string());
2245            Ok(op)
2246        }
2247        _ => Ok(ParsedOp::simple(key, "")),
2248    }
2249}
2250
2251/// Human-readable name for a JSON value kind, for parse-time
2252/// error messages. ("string", "number", "array", etc.)
2253fn eval_value_kind(v: &JVal) -> &'static str {
2254    match v {
2255        JVal::Null => "null",
2256        JVal::Bool(_) => "bool",
2257        JVal::Number(_) => "number",
2258        JVal::String(_) => "string",
2259        JVal::Array(_) => "array",
2260        JVal::Object(_) => "mapping",
2261    }
2262}
2263
2264/// Sub-keys allowed inside an op-template's `evaluations:`
2265/// block. The block is a reserved closed-vocab wrapper for
2266/// post-execution validation / scoring config — distinct from
2267/// per-adapter op fields. Anything else inside it is rejected at
2268/// parse time so silent-ignore traps (a misspelled `relevency:`,
2269/// a misplaced wrapper) cannot hide a misconfigured op. New
2270/// evaluation kinds are added to the construction registry.
2271fn evaluations_vocab() -> Vec<&'static str> {
2272    crate::vocab::evaluation_kinds()
2273}
2274
2275/// Normalize a full op object (map of fields).
2276fn normalize_op_object(
2277    map: &serde_json::Map<String, JVal>,
2278    default_name: &str,
2279    block_name: &str,
2280    parent_bindings: &BindingsDef,
2281    parent_params: &HashMap<String, JVal>,
2282    parent_tags: &HashMap<String, String>,
2283) -> Result<ParsedOp, String> {
2284    let name = map
2285        .get("name")
2286        .and_then(|v| v.as_str())
2287        .unwrap_or(default_name)
2288        .to_string();
2289
2290    let description = map
2291        .get("description")
2292        .or_else(|| map.get("desc"))
2293        .and_then(|v| v.as_str())
2294        .map(|s| s.to_string());
2295
2296    // SRD-13f Push D: workload-level and phase-level
2297    // `bindings:` no longer touch ops at parse time — they
2298    // compile to their own kernels and reach ops via the GK
2299    // kernel chain. `parent_bindings` here carries ONLY
2300    // block-level YAML sugar (blocks are not a Polydat scope; their
2301    // `bindings:` field is a copy-paste reducer over each
2302    // enclosed op's bindings). The expansion below is the only
2303    // remaining parser-time inlining.
2304    let mut op_bindings =
2305        inline_block_sugar_into_op(parent_bindings, &extract_bindings(map.get("bindings")));
2306    let op_params = merge_value_maps(parent_params, &extract_value_map(map.get("params")));
2307    let mut op_tags = merge_string_maps(parent_tags, &extract_string_map(map.get("tags")));
2308    op_tags.insert("block".to_string(), block_name.to_string());
2309
2310    // Determine op payload
2311    //
2312    // `reserved` lists keys handled by the workload model itself
2313    // (name, bindings, etc.) — they never reach the adapter.
2314    // `evaluations` is in this list because it's a closed-vocab
2315    // wrapper for validation/scoring config (relevancy, verify)
2316    // — its sub-keys are extracted and hoisted into `op_params`
2317    // below so downstream consumers
2318    // (`crate::validation::parse_relevancy` etc.) find them at
2319    // the same address whether the workload writes the
2320    // canonical wrapped form or the legacy top-level shorthand.
2321    // `metrics` and `result` are CORE op-template fields (SRD-40b
2322    // and SRD-66 respectively) extracted into ParsedOp.metrics /
2323    // ParsedOp.result; they must be kept out of `op_fields` so
2324    // adapters with a closed-vocabulary `known_op_fields()` (HTTP,
2325    // testkit) don't reject them as unknown. The CQL adapter
2326    // returns `None` from `known_op_fields()` (open vocabulary)
2327    // which masked this for the existing workloads.
2328    let reserved = crate::vocab::op_model_fields();
2329    let op_field_names = crate::vocab::op_stmt_fields();
2330    // Activity-level params excised from op fields before the
2331    // adapter sees them. `relevancy` / `verify` stay listed here
2332    // for the legacy top-level shorthand
2333    // (`relevancy: { ... }` directly under the op); the canonical
2334    // form puts them inside `evaluations:` and is handled
2335    // separately below.
2336    let activity_params = [
2337        "ratio",
2338        "adapter",
2339        "driver",
2340        "space",
2341        "instrument",
2342        "start-timers",
2343        "stop-timers",
2344        "verify",
2345        "relevancy",
2346        "strict",
2347        "poll",
2348        "poll_interval_ms",
2349        "timeout_ms",
2350        "poll_metric_name",
2351        "emit",
2352        "batch",
2353        "max_batch_size",
2354        "batchtype",
2355        "memo",
2356        "gutter",
2357        // SRD-63 op-level status visibility. `readout: visible` opts the
2358        // op into its own timed status leaf; excised into params so the
2359        // `readout` wrapper's trigger (which reads params) sees it rather
2360        // than the field falling through to the adapter op payload.
2361        "readout",
2362        // SRD-82 op shell — a per-op error-routing override (`errors:
2363        // "<pattern>:<actions>"`), resolved into a child of the phase policy
2364        // and pinned to this op's dispenser, and the op-level `tries:`
2365        // total-attempts sigil for the conditional tries wrapper. Both are
2366        // excised from op fields so they never reach the adapter as
2367        // op-payload keys.
2368        "errors",
2369        "tries",
2370        // Tries-wrapper companion knobs (SRD-82 Part 3b): retry
2371        // pacing and retry-error exemplar sampling (`exec_events`).
2372        // Consumed at wrapper build from op params; excised here so
2373        // the documented op-level standalone form actually lands in
2374        // params instead of leaking to the adapter as payload keys.
2375        "retry_backoff",
2376        "retry_backoff_max",
2377        "retry_backoff_ratio",
2378        "retry_exemplar_rate",
2379        "retry_exemplar_max_hz",
2380        "retry_advisory",
2381    ];
2382
2383    let mut op_fields = if let Some(explicit_op) = op_field_names.iter().find_map(|k| map.get(*k)) {
2384        let mut m: HashMap<String, JVal> = match explicit_op {
2385            JVal::String(s) => {
2386                let mut m = HashMap::new();
2387                m.insert("stmt".to_string(), JVal::String(s.clone()));
2388                m
2389            }
2390            JVal::Object(o) => o.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
2391            other => {
2392                let mut m = HashMap::new();
2393                m.insert("stmt".to_string(), other.clone());
2394                m
2395            }
2396        };
2397        // Preserve sibling op-level fields so adapter-specific
2398        // extras (e.g. testkit's `result-latency`, `result-capacity`)
2399        // aren't silently dropped when the user writes shorthand:
2400        //
2401        //     insert:
2402        //       stmt: "INSERT ..."
2403        //       result-latency: "5ms"
2404        //
2405        // Without this loop the whole object would collapse to just
2406        // `stmt` and the sibling fields would never reach the adapter.
2407        // Keys already present in the explicit op payload win, so an
2408        // `op:` sub-object still has final say over its own shape.
2409        for (k, v) in map.iter() {
2410            if reserved.contains(&k.as_str())
2411                || op_field_names.contains(&k.as_str())
2412                || activity_params.contains(&k.as_str())
2413            {
2414                continue;
2415            }
2416            m.entry(k.clone()).or_insert_with(|| v.clone());
2417        }
2418        m
2419    } else {
2420        // All non-reserved, non-activity-param fields become op fields
2421        map.iter()
2422            .filter(|(k, _)| {
2423                !reserved.contains(&k.as_str())
2424                    && !op_field_names.contains(&k.as_str())
2425                    && !activity_params.contains(&k.as_str())
2426            })
2427            .map(|(k, v)| (k.clone(), v.clone()))
2428            .collect()
2429    };
2430
2431    // Excise activity-level params from op fields into params
2432    let mut op_params = op_params;
2433    for ap in &activity_params {
2434        if let Some(val) = map.get(*ap) {
2435            // Activity params excised from op fields into params map
2436            op_params.insert(ap.to_string(), val.clone());
2437        }
2438    }
2439
2440    // Canonical `evaluations:` wrapper — closed-vocab
2441    // validation/scoring config. Sub-keys are extracted and
2442    // hoisted into `op_params` so downstream consumers (e.g.
2443    // `crate::validation::parse_relevancy`,
2444    // `crate::validation::parse_assertions`) find them at the
2445    // same address whether the workload uses this canonical
2446    // form or the legacy top-level shorthand. Anything inside
2447    // `evaluations:` that isn't in `EVALUATIONS_VOCAB` is
2448    // rejected up front — the whole point of the wrapper is to
2449    // catch misspellings (`relevency:`) and misplaced wrappers
2450    // that the silent-routing path would otherwise drop on the
2451    // floor.
2452    if let Some(eval_val) = map.get("evaluations") {
2453        let eval_obj = eval_val.as_object().ok_or_else(|| {
2454            format!(
2455                "op '{name}' (block '{block_name}'): `evaluations:` must be a \
2456             mapping, got {kind}. Expected shape: \
2457             `evaluations: {{ relevancy: {{...}}, verify: [...] }}`.",
2458                kind = eval_value_kind(eval_val),
2459            )
2460        })?;
2461        for (k, v) in eval_obj.iter() {
2462            if !evaluations_vocab().contains(&k.as_str()) {
2463                return Err(format!(
2464                    "op '{name}' (block '{block_name}'): unknown key \
2465                     '{k}' under `evaluations:`. Allowed keys: [{}]. \
2466                     Each entry under `evaluations:` is a distinct \
2467                     post-execution evaluation kind — typos and \
2468                     misplaced wrappers are rejected here so silent \
2469                     skipped recall / verify can't happen.",
2470                    evaluations_vocab().join(", "),
2471                ));
2472            }
2473            // Top-level shorthand wins on collision so users
2474            // who already have `relevancy: {...}` at the op
2475            // level don't see their config replaced if they
2476            // also added `evaluations: { relevancy: {...} }`.
2477            // Warn so the duplicate is visible.
2478            if op_params.contains_key(k.as_str()) {
2479                eprintln!(
2480                    "warning: op '{name}' has '{k}' both at top level \
2481                     and under `evaluations:` — top-level wins. Pick \
2482                     one form.",
2483                );
2484                continue;
2485            }
2486            op_params.insert(k.clone(), v.clone());
2487        }
2488    }
2489
2490    // SRD-108 Part B (+ SRD-109 Part 3) — `abstract:` declares
2491    // this op as a typed slot: `{ needs: {name: type,…}, yields:
2492    // {name: type,…}, results: {name: type,…} }`. Unknown keys
2493    // are rejected; types are polydat DSL type names, verified
2494    // against the compiled op-template program at pre-map
2495    // synthesis.
2496    let abstract_interface: Option<crate::model::OpInterface> = match map.get("abstract") {
2497        None => None,
2498        Some(v) => {
2499            let obj = v.as_object().ok_or_else(|| format!(
2500                    "op '{name}': `abstract:` must be a mapping with                      `needs:` / `yields:` / `results:` maps of wire-name -> type"))?;
2501            let mut iface = crate::model::OpInterface::default();
2502            for (k, section) in obj {
2503                let target = match k.as_str() {
2504                    "needs" => &mut iface.needs,
2505                    "yields" => &mut iface.yields,
2506                    "results" => &mut iface.results,
2507                    other => {
2508                        return Err(format!(
2509                            "op '{name}': unknown key '{other}' under                              `abstract:` (allowed: needs, yields, results)"
2510                        ));
2511                    }
2512                };
2513                let entries = section.as_object().ok_or_else(|| format!(
2514                        "op '{name}': `abstract.{k}:` must be a mapping                          of wire-name -> type"))?;
2515                for (wire, typ) in entries {
2516                    let type_name = typ.as_str().ok_or_else(|| format!(
2517                            "op '{name}': `abstract.{k}.{wire}:` type                              must be a string, got {typ}"))?;
2518                    target.insert(wire.clone(), type_name.to_string());
2519                }
2520            }
2521            Some(iface)
2522        }
2523    };
2524
2525    let condition = map
2526        .get("if")
2527        .and_then(|v| v.as_str())
2528        .map(normalize_condition_clause);
2529
2530    let delay = match map.get("delay") {
2531        None => None,
2532        Some(v) => {
2533            Some(crate::model::parse_delay_spec_value(v).map_err(|e| format!("op '{name}': {e}"))?)
2534        }
2535    };
2536
2537    // Daemon-op declaration. Parses bool / int / "on"/"off" /
2538    // "true"/"false" via `parse_daemon_spec_value`. When set
2539    // to MaxFibers(N), the cycle-pool dispatch spawns the op
2540    // on a daemon fiber instead of awaiting inline; the cap
2541    // is enforced at spawn time.
2542    let daemon = match map.get("daemon") {
2543        Some(v) => crate::model::parse_daemon_spec_value(v)
2544            .map_err(|e| format!("op '{name}' (block '{block_name}'): {e}",))?,
2545        None => crate::model::DaemonSpec::Disabled,
2546    };
2547    let daemon_cancel_grace_ms = map.get("daemon_cancel_grace_ms").and_then(|v| {
2548        v.as_u64()
2549            .or_else(|| v.as_str().and_then(|s| s.parse::<u64>().ok()))
2550    });
2551    let daemon_enabled = !daemon.is_disabled();
2552    if daemon_enabled {
2553        // `cycles:` and `ratio:` on a daemon op are workload-shape
2554        // errors — the daemon's dispatch cadence is governed by
2555        // the cycle-pool walks + per-op `rate:`, not by cycles or
2556        // ratios. Silently accepting them would hide a mis-shaped
2557        // workload; reject at parse so the operator sees the
2558        // contradiction immediately.
2559        if let Some(c) = op_params.get("cycles") {
2560            return Err(format!(
2561                "op '{name}' (block '{block_name}'): `daemon:` and \
2562                 `cycles: {c}` are mutually exclusive. A daemon op's \
2563                 dispatch cadence is governed by the cycle-pool's \
2564                 stanza walk + per-op `rate:`, not by `cycles:`."
2565            ));
2566        }
2567        if let Some(r) = op_params.get("ratio") {
2568            return Err(format!(
2569                "op '{name}' (block '{block_name}'): `daemon:` and \
2570                 `ratio: {r}` are mutually exclusive — a daemon op \
2571                 does not participate in cycle-pool ratio scheduling."
2572            ));
2573        }
2574    }
2575    if !daemon_enabled && daemon_cancel_grace_ms.is_some() {
2576        return Err(format!(
2577            "op '{name}' (block '{block_name}'): \
2578             `daemon_cancel_grace_ms` is only meaningful when \
2579             `daemon:` is enabled (true / N). Either enable `daemon:` \
2580             or drop the grace field."
2581        ));
2582    }
2583
2584    // Loop / rate primitives (apply to both cycle-pool and
2585    // daemon ops, though the typical use case is daemon+while+rate).
2586    let while_cond = map
2587        .get("while")
2588        .and_then(|v| v.as_str())
2589        .map(|s| s.trim().to_string())
2590        .filter(|s| !s.is_empty());
2591    let rate = map.get("rate").and_then(|v| {
2592        v.as_str()
2593            .map(|s| s.to_string())
2594            .or_else(|| v.as_u64().map(|n| n.to_string()))
2595            .or_else(|| v.as_f64().map(|f| f.to_string()))
2596    });
2597    // Rate without while- or per-cycle dispatch is meaningless —
2598    // an op that fires once-per-cycle can't be rate-limited in
2599    // any observable way. Warn at parse rather than silently
2600    // accepting a no-op field.
2601    if rate.is_some() && while_cond.is_none() && daemon.is_disabled() {
2602        // Soft warn via stderr; this is a workload-shape smell
2603        // but not always an error (a workload-author may be
2604        // configuring forward-compatible op templates).
2605        eprintln!(
2606            "warning: op '{name}' (block '{block_name}'): `rate:` is set \
2607             but the op has neither `while:` nor `daemon:`. The rate \
2608             limit will only fire on each cycle-pool dispatch — likely \
2609             not the intended behavior. Add `while:` for a loop or \
2610             `daemon:` to enable fiber-kind dispatch.",
2611        );
2612    }
2613
2614    let metrics = parse_metrics_field(map.get("metrics"), &name, &mut op_bindings)
2615        .map_err(|e| format!("op '{name}' metrics: {e}"))?;
2616    let traverse = parse_traverse_field(map.get("traverse"), &name)?;
2617    let result = parse_result_field(map.get("result"), &name)
2618        .map_err(|e| format!("op '{name}' result: {e}"))?;
2619
2620    // Extract capture-point specs from every string-valued entry in
2621    // `op` and replace each value with the bracket-stripped form.
2622    // Adapters consume the cleaned text; `TraversingDispenser` and
2623    // other capture-aware wrappers read the harvested specs from
2624    // `ParsedOp.captures` directly — no re-parse at wrap-time.
2625    //
2626    // De-duplicates by `as_name` across multiple op-fields: the
2627    // same wire name appearing in two fields means the same
2628    // capture, not two separate writes.
2629    let mut captures: Vec<crate::bindpoints::CapturePoint> = Vec::new();
2630    // Declarative `capture:` map block — pulls JSON-Pointer-keyed
2631    // values out of structured response bodies (e.g. Jolokia
2632    // bulk-POST arrays). Each entry is a path string addressed
2633    // by [`serde_json::Value::pointer`]; a trailing `:count`
2634    // collapses the addressed sub-tree to a u64 count instead
2635    // of capturing it as-is. This complements the legacy
2636    // bracket form `[name]` embedded in op text — that form
2637    // still works for adapters whose statements have column
2638    // references; the declarative form is for adapters whose
2639    // responses are JSON and need positional / nested access.
2640    if let Some(cap_val) = map.get("capture") {
2641        let cap_obj = cap_val.as_object().ok_or_else(|| {
2642            format!(
2643                "op '{name}' (block '{block_name}'): `capture:` must be a \
2644             mapping of <wire-name> → <json-pointer-path>. Got {kind}.",
2645                kind = eval_value_kind(cap_val),
2646            )
2647        })?;
2648        for (wire_name, spec_val) in cap_obj.iter() {
2649            let raw = spec_val.as_str().ok_or_else(|| {
2650                format!(
2651                    "op '{name}' (block '{block_name}'): `capture.{wire_name}` \
2652                 must be a string (JSON-Pointer path, optionally with a \
2653                 `:count` suffix). Got {kind}.",
2654                    kind = eval_value_kind(spec_val),
2655                )
2656            })?;
2657            let (path, count, agg, row_filter) = match raw.strip_suffix(":count") {
2658                Some(p) => (p.to_string(), true, None, None),
2659                None => match parse_capture_agg_suffix(raw) {
2660                    Some((p, a, f)) => (p, false, Some(a), f),
2661                    None => (raw.to_string(), false, None, None),
2662                },
2663            };
2664            // Two accepted spellings, one meaning — but never a guess in
2665            // between:
2666            //
2667            //   `/value`, `/0/name`  RFC 6901 JSON-Pointer, used verbatim.
2668            //   `value`              a bare top-level member name, which is
2669            //                        exactly `/value` and normalized to it
2670            //                        here, so runtime has one form to resolve.
2671            //   ``                   the root document.
2672            //
2673            // A bare name may contain neither `/` nor `~`, and that is the
2674            // whole anti-ambiguity rule: `a/b` could be a pointer someone
2675            // forgot to lead with '/', or a member literally named "a/b"
2676            // (RFC 6901 spells that `/a~1b`), and `~0`/`~1` are pointer
2677            // escapes. Rather than pick a reading, name both and let the
2678            // author say which. `verify:`'s `field:` is a bare name by
2679            // definition, so an author moving between the two blocks lands
2680            // on the bare form naturally — it now works rather than erroring.
2681            let path = if path.is_empty() || path.starts_with('/') {
2682                path
2683            } else if !path.contains('/') && !path.contains('~') {
2684                format!("/{path}")
2685            } else {
2686                return Err(format!(
2687                    "op '{name}' (block '{block_name}'): \
2688                     `capture.{wire_name}` path '{path}' is ambiguous. Write \
2689                     a JSON-Pointer starting with '/' (e.g. '/{first}/...', \
2690                     escaping '~' as '~0' and '/' as '~1'), or a bare \
2691                     top-level member name containing neither '/' nor '~'. \
2692                     An empty path addresses the root document.",
2693                    first = path.split('/').next().unwrap_or(&path),
2694                ));
2695            };
2696            captures.push(crate::bindpoints::CapturePoint {
2697                row_filter,
2698                source_name: wire_name.clone(),
2699                as_name: wire_name.clone(),
2700                cast_type: None,
2701                slurp: false,
2702                path: Some(path),
2703                count,
2704                agg,
2705            });
2706        }
2707    }
2708    for value in op_fields.values_mut() {
2709        if let serde_json::Value::String(s) = value {
2710            let parsed = crate::bindpoints::parse_capture_points(s);
2711            if parsed.captures.is_empty() {
2712                continue;
2713            }
2714            for cap in parsed.captures {
2715                if !captures
2716                    .iter()
2717                    .any(|existing| existing.as_name == cap.as_name)
2718                {
2719                    captures.push(cap);
2720                }
2721            }
2722            *s = parsed.raw_template;
2723        }
2724    }
2725
2726    Ok(ParsedOp {
2727        traverse,
2728        name,
2729        description,
2730        op: op_fields,
2731        bindings: op_bindings,
2732        params: op_params,
2733        tags: op_tags,
2734        condition,
2735        delay,
2736        metrics,
2737        result,
2738        wrappers: None,
2739        captures,
2740        abstract_interface,
2741        interface_bound: false,
2742        daemon,
2743        daemon_cancel_grace_ms,
2744        while_cond,
2745        rate,
2746    })
2747}
2748
2749/// SRD-40b §1 + §2: parse the `metrics:` field on an op
2750/// template. Three YAML shapes accepted, dispatched on the
2751/// value's type:
2752///
2753/// - **Scalar** (bare string, §2.1): one metric with the
2754///   string as both family and `value:`.
2755/// - **Sequence** (list, §2.2): each entry is a bare-name
2756///   string OR a `name := <Polydat expression>` wire-expression.
2757///   Wire expressions are auto-injected into the op's
2758///   `bindings:` block; the metric is then a bare-name
2759///   reference to the new wire.
2760/// - **Mapping** (object, §2.3): canonical full-shape form
2761///   keyed by metric name. Each value is either a string
2762///   (treated as `value:`) or a full `MetricSpec` mapping.
2763/// Parses a `:min(field)` / `:max(field)` / `:sum(field)` suffix on a
2764/// declarative capture path, returning `(path_prefix, agg)`. The path
2765/// prefix may be empty (root = the whole rows array). Returns `None`
2766/// when the spec carries no recognized aggregation suffix.
2767/// Split an optional `where <field>='<value>'` predicate off the tail of an
2768/// aggregate's argument, returning `(field_expr, predicate)`.
2769///
2770/// `progress where kind='secondary index build'` →
2771/// `("progress", Some(("kind", "secondary index build")))`.
2772///
2773/// Single or double quotes; the value may contain spaces, which is why it is
2774/// quoted rather than whitespace-delimited.
2775fn split_capture_row_filter(arg: &str) -> (String, Option<(String, String)>) {
2776    let lower = arg.to_ascii_lowercase();
2777    let Some(idx) = lower.find(" where ") else {
2778        return (arg.trim().to_string(), None);
2779    };
2780    let field = arg[..idx].trim().to_string();
2781    let pred = arg[idx + " where ".len()..].trim();
2782    let Some((k, v)) = pred.split_once('=') else {
2783        return (arg.trim().to_string(), None);
2784    };
2785    let k = k.trim();
2786    let v = v.trim();
2787    let unquoted = v
2788        .strip_prefix('\'')
2789        .and_then(|r| r.strip_suffix('\''))
2790        .or_else(|| v.strip_prefix('"').and_then(|r| r.strip_suffix('"')));
2791    match unquoted {
2792        Some(val) if !k.is_empty() => (field, Some((k.to_string(), val.to_string()))),
2793        // An unquoted or malformed predicate is left alone rather than
2794        // silently half-applied — the caller then treats the whole string as a
2795        // field name and the unknown-field error names it.
2796        _ => (arg.trim().to_string(), None),
2797    }
2798}
2799
2800fn parse_capture_agg_suffix(
2801    raw: &str,
2802) -> Option<(
2803    String,
2804    crate::bindpoints::CaptureAgg,
2805    Option<(String, String)>,
2806)> {
2807    use crate::bindpoints::CaptureAgg;
2808    if !raw.ends_with(')') {
2809        return None;
2810    }
2811    for (tag, make) in [
2812        (":min(", CaptureAgg::Min as fn(String) -> CaptureAgg),
2813        (":max(", CaptureAgg::Max as fn(String) -> CaptureAgg),
2814        (":sum(", CaptureAgg::Sum as fn(String) -> CaptureAgg),
2815    ] {
2816        if let Some(idx) = raw.rfind(tag) {
2817            let arg = &raw[idx + tag.len()..raw.len() - 1];
2818            let (field, row_filter) = split_capture_row_filter(arg);
2819            if !field.is_empty() && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
2820                return Some((raw[..idx].to_string(), make(field), row_filter));
2821            }
2822        }
2823    }
2824    None
2825}
2826
2827fn parse_metrics_field(
2828    val: Option<&JVal>,
2829    op_name: &str,
2830    op_bindings: &mut BindingsDef,
2831) -> Result<HashMap<String, MetricSpec>, String> {
2832    use crate::model::MetricSpec;
2833    let Some(v) = val else {
2834        return Ok(HashMap::new());
2835    };
2836    let mut out: HashMap<String, MetricSpec> = HashMap::new();
2837    match v {
2838        JVal::String(s) => {
2839            let name = s.trim().to_string();
2840            if name.is_empty() {
2841                return Err("scalar form requires a metric name".into());
2842            }
2843            out.insert(
2844                name.clone(),
2845                MetricSpec {
2846                    value: name,
2847                    family: None,
2848                    kind: None,
2849                    unit: None,
2850                    format: None,
2851                    cell: Default::default(),
2852                },
2853            );
2854        }
2855        JVal::Array(items) => {
2856            for (idx, item) in items.iter().enumerate() {
2857                let raw = item.as_str().ok_or_else(|| {
2858                    format!(
2859                        "metrics list entry {idx}: must be a string \
2860                     (bare name or `name := <polydat expr>`)"
2861                    )
2862                })?;
2863                let trimmed = raw.trim();
2864                if let Some((name, expr)) = trimmed.split_once(":=") {
2865                    // Wire-expression form: declare the binding +
2866                    // register the metric.
2867                    let name = name.trim();
2868                    let expr = expr.trim();
2869                    if name.is_empty() || expr.is_empty() {
2870                        return Err(format!(
2871                            "metrics list entry {idx} '{raw}': wire \
2872                             expression must be `name := <expression>`"
2873                        ));
2874                    }
2875                    inject_wire_into_bindings(op_bindings, name, expr, op_name)?;
2876                    if out.contains_key(name) {
2877                        return Err(format!("duplicate metric wire '{name}' in metrics list"));
2878                    }
2879                    out.insert(
2880                        name.to_string(),
2881                        MetricSpec {
2882                            value: name.to_string(),
2883                            family: None,
2884                            kind: None,
2885                            unit: None,
2886                            format: None,
2887                            cell: Default::default(),
2888                        },
2889                    );
2890                } else {
2891                    // Bare-name form.
2892                    if trimmed.is_empty() {
2893                        return Err(format!("metrics list entry {idx}: empty name"));
2894                    }
2895                    if out.contains_key(trimmed) {
2896                        return Err(format!("duplicate metric '{trimmed}' in metrics list"));
2897                    }
2898                    out.insert(
2899                        trimmed.to_string(),
2900                        MetricSpec {
2901                            value: trimmed.to_string(),
2902                            family: None,
2903                            kind: None,
2904                            unit: None,
2905                            format: None,
2906                            cell: Default::default(),
2907                        },
2908                    );
2909                }
2910            }
2911        }
2912        JVal::Object(map) => {
2913            for (key, val) in map {
2914                if out.contains_key(key) {
2915                    return Err(format!("duplicate metric key '{key}' in metrics map"));
2916                }
2917                let mut spec = parse_metric_spec_value(val, key)?;
2918                // SRD-13d Phase 9 mapping-form auto-inject: if
2919                // `value:` isn't a bare name, promote it to an
2920                // op-template binding `<key> := <value>` and
2921                // replace the spec's `value:` with the bare key.
2922                // Mirrors the list-form `name := expr` flow.
2923                let value_trimmed = spec.value.trim();
2924                let bare = !value_trimmed.is_empty()
2925                    && value_trimmed
2926                        .chars()
2927                        .all(|c| c.is_alphanumeric() || c == '_');
2928                if !bare {
2929                    if !is_valid_ident(key) {
2930                        return Err(format!(
2931                            "metric '{key}' value '{value}' is a non-bare \
2932                             expression so the metric key must itself be a \
2933                             valid identifier (alphanumerics + underscore, \
2934                             not starting with a digit) so it can be used \
2935                             as a binding name. Rename the metric key, or \
2936                             move the expression into `bindings:` and set \
2937                             `value:` to the bare name.",
2938                            value = spec.value
2939                        ));
2940                    }
2941                    inject_wire_into_bindings(op_bindings, key, value_trimmed, op_name)?;
2942                    spec.value = key.clone();
2943                }
2944                out.insert(key.clone(), spec);
2945            }
2946        }
2947        _ => {
2948            return Err(format!(
2949                "metrics: expected scalar, sequence, or mapping; got {v:?}"
2950            ));
2951        }
2952    }
2953    Ok(out)
2954}
2955
2956/// Parse a phase-level `metrics:` field. Same three YAML shapes as
2957/// the op-level [`parse_metrics_field`] (scalar / sequence / mapping)
2958/// and the same [`MetricSpec`] schema, with one deliberate
2959/// difference: phase metrics do **not** auto-inject non-bare value
2960/// expressions into a `bindings:` block. Op metrics inject so the
2961/// closure-binding-economy walker can allocate magic-extern slots
2962/// (`body`/`count`/`ok`) for the value expression; phase metrics have
2963/// no result body and are pulled directly by the executor from
2964/// `__metric_<name>`, so the phase synthesiser emits
2965/// `volatile __metric_<name> := <value>` straight from the raw
2966/// `value:` expression preserved here.
2967/// Parse a phase-level `dimensions:` block.
2968///
2969/// ```yaml
2970/// dimensions:
2971///   tier: { type: str }
2972///   tier: str            # shorthand
2973/// ```
2974///
2975/// Unknown keys are rejected rather than dropped: a silently-ignored
2976/// dimension declaration would make a metric's `cell:` reference fail its
2977/// existence check for a reason nothing points at.
2978/// Check every `cell:` reference in a phase against its declared dimensions.
2979///
2980/// Both tiers are covered: the phase's own `metrics:` and each inline op's.
2981/// The error names the declared set, because the common failure is a name
2982/// declared one tier away rather than a nonsense name.
2983fn validate_cell_dimensions(
2984    phase_name: &str,
2985    dimensions: &std::collections::BTreeMap<String, crate::model::DimensionSpec>,
2986    ops: &[ParsedOp],
2987    phase_metrics: &HashMap<String, MetricSpec>,
2988) -> Result<(), String> {
2989    let declared = || {
2990        if dimensions.is_empty() {
2991            "none are declared on this phase".to_string()
2992        } else {
2993            format!(
2994                "declared here: {}",
2995                dimensions.keys().cloned().collect::<Vec<_>>().join(", ")
2996            )
2997        }
2998    };
2999    let check = |site: &str, metric: &str, spec: &MetricSpec| -> Result<(), String> {
3000        for dim in spec.cell.keys() {
3001            if !dimensions.contains_key(dim) {
3002                return Err(format!(
3003                    "phase '{phase_name}' {site} metric '{metric}': cell \
3004                     dimension '{dim}' is not declared. A dimension is a label \
3005                     name owned by exactly one tier, so declare it on the phase \
3006                     ({dim}: str) before placing a metric in it — {}",
3007                    declared()
3008                ));
3009            }
3010        }
3011        Ok(())
3012    };
3013    for (name, spec) in phase_metrics {
3014        check("phase-level", name, spec)?;
3015    }
3016    for op in ops {
3017        for (name, spec) in &op.metrics {
3018            check(&format!("op '{}'", op.name), name, spec)?;
3019        }
3020    }
3021    Ok(())
3022}
3023
3024fn parse_dimensions_field(
3025    val: Option<&JVal>,
3026    phase_name: &str,
3027) -> Result<std::collections::BTreeMap<String, crate::model::DimensionSpec>, String> {
3028    use crate::model::{DimensionSpec, DimensionType};
3029    let mut out = std::collections::BTreeMap::new();
3030    let Some(v) = val else { return Ok(out) };
3031    let JVal::Object(map) = v else {
3032        return Err(format!(
3033            "phase '{phase_name}' dimensions: expected a mapping of \
3034             <name>: <declaration>, got {v:?}"
3035        ));
3036    };
3037    for (name, decl) in map {
3038        if !is_valid_ident(name) {
3039            return Err(format!(
3040                "phase '{phase_name}' dimension '{name}': a dimension name \
3041                 becomes a metric label, so it must be a valid identifier \
3042                 (alphanumerics + underscore, not starting with a digit)"
3043            ));
3044        }
3045        let value_type = match decl {
3046            // Shorthand: `tier: str`
3047            JVal::String(s) => parse_dimension_type(s, phase_name, name)?,
3048            JVal::Object(obj) => {
3049                for k in obj.keys() {
3050                    if k != "type" {
3051                        return Err(format!(
3052                            "phase '{phase_name}' dimension '{name}': unknown \
3053                             field `{k}`. Recognised fields: type"
3054                        ));
3055                    }
3056                }
3057                match obj.get("type") {
3058                    None => DimensionType::default(),
3059                    Some(JVal::String(s)) => parse_dimension_type(s, phase_name, name)?,
3060                    Some(other) => {
3061                        return Err(format!(
3062                            "phase '{phase_name}' dimension '{name}' type: \
3063                         expected a string, got {other:?}"
3064                        ));
3065                    }
3066                }
3067            }
3068            other => {
3069                return Err(format!(
3070                    "phase '{phase_name}' dimension '{name}': expected a type \
3071                 name or a mapping, got {other:?}"
3072                ));
3073            }
3074        };
3075        out.insert(name.clone(), DimensionSpec { value_type });
3076    }
3077    Ok(out)
3078}
3079
3080fn parse_dimension_type(
3081    s: &str,
3082    phase_name: &str,
3083    dim: &str,
3084) -> Result<crate::model::DimensionType, String> {
3085    match s.trim().to_ascii_lowercase().as_str() {
3086        "str" | "string" => Ok(crate::model::DimensionType::Str),
3087        other => Err(format!(
3088            "phase '{phase_name}' dimension '{dim}' type '{other}': label \
3089             values are strings; `str` is the only supported type"
3090        )),
3091    }
3092}
3093
3094fn parse_phase_metrics_field(
3095    val: Option<&JVal>,
3096    phase_name: &str,
3097) -> Result<HashMap<String, MetricSpec>, String> {
3098    use crate::model::MetricSpec;
3099    let Some(v) = val else {
3100        return Ok(HashMap::new());
3101    };
3102    let mut out: HashMap<String, MetricSpec> = HashMap::new();
3103    match v {
3104        JVal::String(s) => {
3105            // Scalar: a bare wire name used as both family and value.
3106            let name = s.trim().to_string();
3107            if name.is_empty() {
3108                return Err("scalar form requires a metric name".into());
3109            }
3110            out.insert(
3111                name.clone(),
3112                MetricSpec {
3113                    value: name,
3114                    family: None,
3115                    kind: None,
3116                    unit: None,
3117                    format: None,
3118                    cell: Default::default(),
3119                },
3120            );
3121        }
3122        JVal::Array(items) => {
3123            // Sequence: bare wire names only (no `name := expr` form —
3124            // phase metrics don't inject bindings).
3125            for (idx, item) in items.iter().enumerate() {
3126                let raw = item
3127                    .as_str()
3128                    .ok_or_else(|| format!("metrics list entry {idx}: must be a bare wire name"))?;
3129                let name = raw.trim();
3130                if name.is_empty() {
3131                    return Err(format!("metrics list entry {idx}: empty name"));
3132                }
3133                if name.contains(":=") {
3134                    return Err(format!(
3135                        "metrics list entry {idx} '{raw}': the `name := expr` \
3136                         form is op-only; for a phase, declare the wire in the \
3137                         phase `bindings:` block and list its bare name here, \
3138                         or use the mapping form `{{ {name}: {{ value: <expr> }} }}`"
3139                    ));
3140                }
3141                if out.contains_key(name) {
3142                    return Err(format!("duplicate metric '{name}' in metrics list"));
3143                }
3144                out.insert(
3145                    name.to_string(),
3146                    MetricSpec {
3147                        value: name.to_string(),
3148                        family: None,
3149                        kind: None,
3150                        unit: None,
3151                        format: None,
3152                        cell: Default::default(),
3153                    },
3154                );
3155            }
3156        }
3157        JVal::Object(map) => {
3158            // Mapping: canonical full-shape form. Raw `value:` kept.
3159            for (key, val) in map {
3160                if out.contains_key(key) {
3161                    return Err(format!("duplicate metric key '{key}' in metrics map"));
3162                }
3163                out.insert(key.clone(), parse_metric_spec_value(val, key)?);
3164            }
3165        }
3166        _ => {
3167            return Err(format!(
3168                "phase '{phase_name}' metrics: expected scalar, sequence, or \
3169             mapping; got {v:?}"
3170            ));
3171        }
3172    }
3173    Ok(out)
3174}
3175
3176/// Parse one entry under the mapping form of `metrics:`.
3177/// Accepts a bare string (treated as `value:`) or a full
3178/// `MetricSpec` object.
3179fn parse_metric_spec_value(v: &JVal, key: &str) -> Result<crate::model::MetricSpec, String> {
3180    use crate::model::MetricSpec;
3181    match v {
3182        JVal::String(s) => Ok(MetricSpec {
3183            value: s.clone(),
3184            family: None,
3185            kind: None,
3186            unit: None,
3187            format: None,
3188            cell: Default::default(),
3189        }),
3190        JVal::Object(map) => {
3191            // SRD-30 unknown-field hygiene: reject any key outside the
3192            // MetricSpec surface rather than silently dropping it (a
3193            // dropped `kind:` would let a counter/histogram silently
3194            // default to gauge). The instrument-type discriminator is
3195            // `kind`, not `type` — `type` is the word OpenMetrics /
3196            // Prometheus use, so it's the predictable mistake; give it
3197            // a targeted hint.
3198            const KNOWN: &[&str] = &["value", "family", "kind", "unit", "format", "cell"];
3199            for k in map.keys() {
3200                if KNOWN.contains(&k.as_str()) {
3201                    continue;
3202                }
3203                let hint = if k == "type" {
3204                    " — the instrument-type discriminator is `kind` \
3205                     (gauge | histogram | counter)"
3206                } else {
3207                    ""
3208                };
3209                return Err(format!(
3210                    "metric '{key}': unknown field `{k}`{hint}. Recognised \
3211                     fields: value, family, kind, unit, format, cell"
3212                ));
3213            }
3214            let value = map
3215                .get("value")
3216                .and_then(|v| v.as_str())
3217                .ok_or_else(|| {
3218                    format!(
3219                        "metric '{key}': required field `value:` missing or \
3220                     not a string"
3221                    )
3222                })?
3223                .to_string();
3224            let family = map.get("family").and_then(|v| v.as_str()).map(String::from);
3225            let unit = map.get("unit").and_then(|v| v.as_str()).map(String::from);
3226            let format = map.get("format").and_then(|v| v.as_str()).map(String::from);
3227            // Validate format syntax at parse time so the user
3228            // hears about a bad `#.##` pattern at workload
3229            // load, not first-cycle. SRD-40b §1.
3230            if let Some(f) = format.as_deref() {
3231                crate::metric_format::parse_format_spec(f)
3232                    .map_err(|e| format!("metric '{key}' format '{f}': {e}"))?;
3233            }
3234            let kind = match map.get("kind") {
3235                None => None,
3236                Some(JVal::String(s)) => Some(parse_metric_kind(s, key)?),
3237                Some(other) => {
3238                    return Err(format!(
3239                        "metric '{key}' kind: expected string, got {other:?}"
3240                    ));
3241                }
3242            };
3243            // `cell:` — dimensional placement. Each entry is
3244            // `<dimension>: <polydat expression>`; the expression is
3245            // reified by scope synthesis as a typed kernel binding, so a
3246            // coordinate is compiled and checkable rather than a string
3247            // assembled at runtime.
3248            let mut cell = std::collections::BTreeMap::new();
3249            match map.get("cell") {
3250                None => {}
3251                Some(JVal::Object(dims)) => {
3252                    if dims.is_empty() {
3253                        return Err(format!(
3254                            "metric '{key}' cell: empty. Omit `cell:` entirely, \
3255                             or name at least one dimension."
3256                        ));
3257                    }
3258                    for (dim, expr) in dims {
3259                        let expr = expr.as_str().ok_or_else(|| {
3260                            format!(
3261                                "metric '{key}' cell '{dim}': expected a polydat \
3262                             expression string, got {expr:?}"
3263                            )
3264                        })?;
3265                        if expr.trim().is_empty() {
3266                            return Err(format!("metric '{key}' cell '{dim}': empty expression"));
3267                        }
3268                        if !is_valid_ident(dim) {
3269                            return Err(format!(
3270                                "metric '{key}' cell '{dim}': a dimension name \
3271                                 becomes a metric label, so it must be a valid \
3272                                 identifier (alphanumerics + underscore, not \
3273                                 starting with a digit)"
3274                            ));
3275                        }
3276                        cell.insert(dim.clone(), expr.trim().to_string());
3277                    }
3278                }
3279                Some(other) => {
3280                    return Err(format!(
3281                        "metric '{key}' cell: expected a mapping of \
3282                     <dimension>: <expression>, got {other:?}"
3283                    ));
3284                }
3285            }
3286            Ok(MetricSpec {
3287                value,
3288                family,
3289                kind,
3290                unit,
3291                format,
3292                cell,
3293            })
3294        }
3295        _ => Err(format!(
3296            "metric '{key}': expected string or mapping, got {v:?}"
3297        )),
3298    }
3299}
3300
3301fn parse_metric_kind(s: &str, key: &str) -> Result<crate::model::MetricKind, String> {
3302    use crate::model::MetricKind;
3303    match s.to_ascii_lowercase().as_str() {
3304        "gauge" => Ok(MetricKind::Gauge),
3305        "histogram" => Ok(MetricKind::Histogram),
3306        "counter" => Ok(MetricKind::Counter),
3307        other => Err(format!(
3308            "metric '{key}' kind '{other}': expected one of \
3309             gauge / histogram / counter"
3310        )),
3311    }
3312}
3313
3314/// Auto-inject `name := expr` into the op template's
3315/// `bindings:` block (per SRD-40b §2.2). Conflicts with an
3316/// existing declaration of the same name are a strict
3317/// workload parse error per §2.2.
3318fn inject_wire_into_bindings(
3319    bindings: &mut BindingsDef,
3320    name: &str,
3321    expr: &str,
3322    op_name: &str,
3323) -> Result<(), String> {
3324    // Look for an existing same-name declaration to refuse
3325    // shadowing. The check is textual: a line beginning with
3326    // `<name>` followed by whitespace + `:=`. Prefix matching
3327    // would surface false positives for `foo` vs `foobar`,
3328    // hence the boundary check.
3329    let line_to_inject = format!("{name} := {expr}\n");
3330    // BindingsDef has no `Empty` variant — `Map(empty)` is the
3331    // default. Detect emptiness via the existing helper, then
3332    // promote to PolydatSource for injection (we're adding a real
3333    // Polydat statement, not a name→expr pair the legacy Map form
3334    // can't carry alone).
3335    if bindings.is_empty() {
3336        *bindings = BindingsDef::PolydatSource(line_to_inject);
3337        return Ok(());
3338    }
3339    match bindings {
3340        BindingsDef::PolydatSource(src) => {
3341            if has_binding_named(src, name) {
3342                return Err(format!(
3343                    "metric wire '{name}' (op '{op_name}') collides \
3344                     with existing `bindings:` declaration of the \
3345                     same name"
3346                ));
3347            }
3348            if !src.ends_with('\n') {
3349                src.push('\n');
3350            }
3351            src.push_str(&line_to_inject);
3352        }
3353        BindingsDef::Map(map) => {
3354            if map.contains_key(name) {
3355                return Err(format!(
3356                    "metric wire '{name}' (op '{op_name}') collides \
3357                     with existing `bindings:` declaration of the \
3358                     same name"
3359                ));
3360            }
3361            map.insert(name.to_string(), expr.to_string());
3362        }
3363    }
3364    Ok(())
3365}
3366
3367/// True when `s` is a valid Polydat identifier: non-empty, first
3368/// char is a letter or underscore, remaining chars are
3369/// alphanumerics or underscore. Used by the mapping-form
3370/// metric auto-inject to confirm the metric key can stand in
3371/// as a binding name.
3372fn is_valid_ident(s: &str) -> bool {
3373    let mut chars = s.chars();
3374    match chars.next() {
3375        Some(c) if c.is_alphabetic() || c == '_' => {}
3376        _ => return false,
3377    }
3378    chars.all(|c| c.is_alphanumeric() || c == '_')
3379}
3380
3381/// True when the Polydat source contains a binding line
3382/// `<name> := …` at the start of any (whitespace-trimmed)
3383/// line. Used by the wire-expression injection to detect
3384/// shadowing without parsing the Polydat grammar.
3385fn has_binding_named(src: &str, name: &str) -> bool {
3386    for raw in src.lines() {
3387        let line = raw.trim_start();
3388        if let Some(rest) = line.strip_prefix(name) {
3389            // Boundary: next char must be whitespace or `:=`.
3390            let rest = rest.trim_start();
3391            if rest.starts_with(":=") {
3392                return true;
3393            }
3394        }
3395    }
3396    false
3397}
3398
3399/// SRD-66 §"Surface 1 §Schema": parse the vari-structured
3400/// `result:` field on an op template. Three shapes:
3401///
3402/// - **String** scalar — Polydat source block (multi-line or
3403///   single-line). Each `<name> := <expr>` declares one
3404///   wire.
3405/// - **List** sequence — each element is itself a
3406///   `ResultSpec` (recursively); fragments concatenate.
3407/// - **Mapping** — named-key short-forms; each value is a
3408///   string parsed as `count` / `ok` / path-expr / Polydat expr.
3409/// Parse an op's `traverse:` block.
3410///
3411/// Customises the always-installed traversal layer; it does not select it, so
3412/// absence means "defaults", not "no traversal". Unknown keys are rejected
3413/// rather than dropped — a silently ignored `on_missing:` would leave the
3414/// author believing absent captures are being policed when they are not.
3415fn parse_traverse_field(
3416    val: Option<&JVal>,
3417    op_name: &str,
3418) -> Result<Option<crate::model::TraverseSpec>, String> {
3419    use crate::model::{OnMissing, TraverseSpec};
3420    let Some(v) = val else { return Ok(None) };
3421    let JVal::Object(map) = v else {
3422        return Err(format!(
3423            "op '{op_name}' traverse: expected a mapping, got {v:?}"
3424        ));
3425    };
3426    const KNOWN: &[&str] = &["path", "on_missing"];
3427    for k in map.keys() {
3428        if !KNOWN.contains(&k.as_str()) {
3429            return Err(format!(
3430                "op '{op_name}' traverse: unknown field `{k}`. Recognised \
3431                 fields: path, on_missing"
3432            ));
3433        }
3434    }
3435    let path = match map.get("path") {
3436        None => None,
3437        Some(JVal::String(p)) => {
3438            let p = p.trim();
3439            if !p.is_empty() && !p.starts_with('/') {
3440                return Err(format!(
3441                    "op '{op_name}' traverse: path '{p}' must start with '/' \
3442                     (RFC 6901 JSON-Pointer); an empty path addresses the root"
3443                ));
3444            }
3445            Some(p.to_string())
3446        }
3447        Some(other) => {
3448            return Err(format!(
3449                "op '{op_name}' traverse: path expected a string, got {other:?}"
3450            ));
3451        }
3452    };
3453    let on_missing = match map.get("on_missing") {
3454        None => OnMissing::default(),
3455        Some(JVal::String(s)) => match s.trim().to_ascii_lowercase().as_str() {
3456            "ignore" => OnMissing::Ignore,
3457            "warn" => OnMissing::Warn,
3458            "error" | "fail" => OnMissing::Error,
3459            other => {
3460                return Err(format!(
3461                    "op '{op_name}' traverse: on_missing '{other}': expected one of \
3462                 ignore / warn / error"
3463                ));
3464            }
3465        },
3466        Some(other) => {
3467            return Err(format!(
3468                "op '{op_name}' traverse: on_missing expected a string, got {other:?}"
3469            ));
3470        }
3471    };
3472    Ok(Some(TraverseSpec { path, on_missing }))
3473}
3474
3475fn parse_result_field(
3476    val: Option<&JVal>,
3477    op_name: &str,
3478) -> Result<Option<crate::model::ResultSpec>, String> {
3479    let Some(v) = val else {
3480        return Ok(None);
3481    };
3482    let spec = parse_result_spec(v, op_name)?;
3483    if spec.is_empty() {
3484        Ok(None)
3485    } else {
3486        Ok(Some(spec))
3487    }
3488}
3489
3490fn parse_result_spec(v: &JVal, op_name: &str) -> Result<crate::model::ResultSpec, String> {
3491    use crate::model::ResultSpec;
3492    match v {
3493        JVal::Null => Ok(ResultSpec::String(String::new())),
3494        JVal::String(s) => Ok(ResultSpec::String(s.clone())),
3495        JVal::Array(items) => {
3496            let mut out: Vec<ResultSpec> = Vec::with_capacity(items.len());
3497            for item in items {
3498                out.push(parse_result_spec(item, op_name)?);
3499            }
3500            Ok(ResultSpec::List(out))
3501        }
3502        JVal::Object(map) => {
3503            let mut out: std::collections::BTreeMap<String, String> =
3504                std::collections::BTreeMap::new();
3505            for (key, val) in map {
3506                let source = match val {
3507                    JVal::String(s) => s.clone(),
3508                    JVal::Null => String::new(),
3509                    other => {
3510                        return Err(format!(
3511                            "op '{op_name}' result.{key}: expected string \
3512                         (short-form keyword `count`/`ok`, path \
3513                         expression, or Polydat expression); got {other}"
3514                        ));
3515                    }
3516                };
3517                if out.insert(key.clone(), source).is_some() {
3518                    return Err(format!("op '{op_name}' result: duplicate key '{key}'"));
3519                }
3520            }
3521            Ok(ResultSpec::Map(out))
3522        }
3523        _ => Err(format!(
3524            "op '{op_name}' result: expected string (GK source), \
3525             list (sequence of fragments), or mapping (named \
3526             short-forms); got {v}"
3527        )),
3528    }
3529}
3530
3531// -----------------------------------------------------------------
3532// Helpers
3533// -----------------------------------------------------------------
3534
3535/// Extract bindings from a YAML value.
3536///
3537/// If the value is a string, it's native Polydat grammar source.
3538/// If it's a mapping, it's legacy name→expression pairs.
3539fn extract_bindings(val: Option<&JVal>) -> BindingsDef {
3540    match val {
3541        Some(JVal::String(s)) => BindingsDef::PolydatSource(s.clone()),
3542        Some(JVal::Object(obj)) => {
3543            let mut map = HashMap::new();
3544            for (k, v) in obj {
3545                if let Some(s) = v.as_str() {
3546                    map.insert(k.clone(), s.to_string());
3547                } else {
3548                    map.insert(k.clone(), v.to_string());
3549                }
3550            }
3551            BindingsDef::Map(map)
3552        }
3553        _ => BindingsDef::default(),
3554    }
3555}
3556
3557/// SRD-13f Push D: inline a block's YAML-level `bindings:`
3558/// sugar into one of its enclosed ops.
3559///
3560/// Blocks are not a Polydat scope — they're YAML authoring sugar
3561/// (named groups for tag-filtering + shared defaults). A
3562/// block-level `bindings:` field is *syntactic sugar* meaning
3563/// "every op underneath has these bindings as part of its own
3564/// op-level bindings." This helper does that expansion at
3565/// parse time.
3566///
3567/// Semantics (preserves prior `merge_bindings` shape for the
3568/// only call site that still uses it):
3569/// - The op's own PolydatSource fully shadows the block's sugar
3570///   (the op declares its full binding set explicitly).
3571/// - Op Map merges with block Map (op keys override block).
3572/// - Empty op inherits the block sugar verbatim.
3573///
3574/// No cross-scope semantics: workload-level and phase-level
3575/// `bindings:` no longer flow through this helper. They reach
3576/// ops via the Polydat Kernel chain.
3577fn inline_block_sugar_into_op(block_sugar: &BindingsDef, op_own: &BindingsDef) -> BindingsDef {
3578    match (block_sugar, op_own) {
3579        (_, BindingsDef::PolydatSource(s)) if !s.trim().is_empty() => {
3580            BindingsDef::PolydatSource(s.clone())
3581        }
3582        (BindingsDef::Map(p), BindingsDef::Map(c)) => {
3583            let mut merged = p.clone();
3584            for (k, v) in c {
3585                merged.insert(k.clone(), v.clone());
3586            }
3587            BindingsDef::Map(merged)
3588        }
3589        (_, BindingsDef::Map(c)) if c.is_empty() => block_sugar.clone(),
3590        (_, child) => child.clone(),
3591    }
3592}
3593
3594/// Render a YAML/JSON param value as the text form
3595/// `add_param_binding` expects.
3596///
3597/// Scalar shapes pass through RAW (no extra quoting): a YAML
3598/// `iter_count: "3"` and a YAML `iter_count: 3` BOTH come out as
3599/// the string `"3"` here — the downstream classifier sees
3600/// numeric-shape and emits a bare U64 binding either way. YAML's
3601/// quotes are presentation, not semantic, for scalars.
3602///
3603/// Array shape gets the polydat array literal form
3604/// (`[v1, v2, v3]`) — that's the new convention the workload
3605/// surface needs to support. Array ELEMENTS are formatted with
3606/// polydat literal grammar (strings explicitly quoted) since
3607/// polydat's parser requires quotes inside array literals;
3608/// `format_jval_in_array_context` handles the recursion.
3609///
3610/// Object shape (rare for params) falls back to JSON
3611/// serialization — there's no polydat literal form for objects,
3612/// so the value passes through whatever-it-is for callers
3613/// downstream to handle.
3614pub(crate) fn format_jval_as_polydat_literal(v: &JVal) -> String {
3615    match v {
3616        JVal::Null => String::new(),
3617        JVal::Bool(b) => b.to_string(),
3618        JVal::Number(n) => n.to_string(),
3619        // Scalar strings pass through unquoted to preserve the
3620        // legacy "YAML quotes are presentation" behavior. The
3621        // downstream classifier (`add_param_binding`) figures
3622        // out the actual type from the content — numeric-shape
3623        // becomes U64/F64, identifier-shape becomes a reference,
3624        // string-shape becomes a polydat-quoted Str.
3625        JVal::String(s) => s.clone(),
3626        JVal::Array(items) => {
3627            let elts: Vec<String> = items.iter().map(format_jval_in_array_context).collect();
3628            format!("[{}]", elts.join(", "))
3629        }
3630        JVal::Object(_) => v.to_string(),
3631    }
3632}
3633
3634/// Element-context formatter: polydat array literals require
3635/// explicit quotes around string elements (`["a", "b"]`), unlike
3636/// the scalar-context formatter which leaves strings unquoted.
3637fn format_jval_in_array_context(v: &JVal) -> String {
3638    match v {
3639        JVal::String(s) => {
3640            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
3641            format!("\"{escaped}\"")
3642        }
3643        JVal::Array(items) => {
3644            let elts: Vec<String> = items.iter().map(format_jval_in_array_context).collect();
3645            format!("[{}]", elts.join(", "))
3646        }
3647        _ => format_jval_as_polydat_literal(v),
3648    }
3649}
3650
3651fn extract_string_map(val: Option<&JVal>) -> HashMap<String, String> {
3652    let mut map = HashMap::new();
3653    if let Some(JVal::Object(obj)) = val {
3654        for (k, v) in obj {
3655            // Format every YAML value as polydat-native source.
3656            // Strings come out quote-wrapped, arrays as
3657            // `[a, b, c]`, numbers / bools bare. The downstream
3658            // `add_param_binding` classifier reads this and emits
3659            // the const binding without re-quoting.
3660            map.insert(k.clone(), format_jval_as_polydat_literal(v));
3661        }
3662    }
3663    map
3664}
3665
3666// Shared classifier helpers — the `set:` block parser routes
3667// every value through the same shape detection so the numeric /
3668// array-literal / quoted-string surface is consistent.
3669
3670fn is_polydat_quoted_string(s: &str) -> bool {
3671    if s.len() < 2 {
3672        return false;
3673    }
3674    if !s.starts_with('"') || !s.ends_with('"') {
3675        return false;
3676    }
3677    let bytes = s.as_bytes();
3678    let mut i = 1;
3679    let last = bytes.len() - 1;
3680    while i < last {
3681        if bytes[i] == b'\\' {
3682            i += 2;
3683            continue;
3684        }
3685        if bytes[i] == b'"' {
3686            return false;
3687        }
3688        i += 1;
3689    }
3690    true
3691}
3692
3693fn extract_value_map(val: Option<&JVal>) -> HashMap<String, JVal> {
3694    let mut map = HashMap::new();
3695    if let Some(JVal::Object(obj)) = val {
3696        for (k, v) in obj {
3697            map.insert(k.clone(), v.clone());
3698        }
3699    }
3700    map
3701}
3702
3703fn merge_string_maps(
3704    parent: &HashMap<String, String>,
3705    child: &HashMap<String, String>,
3706) -> HashMap<String, String> {
3707    let mut merged = parent.clone();
3708    for (k, v) in child {
3709        merged.insert(k.clone(), v.clone());
3710    }
3711    merged
3712}
3713
3714/// Activity/phase-scope param keys that must never be blast-merged onto ops.
3715/// They are consumed at phase/activity scope; leaking them into op `params`
3716/// makes an inherited `rate` collide with the `rate` wrapper's
3717/// field-ownership guard (SRD-32a). The op-level `rate:` field reaches ops via
3718/// the typed [`ParsedOp::rate`] path, not params, so this exclusion is safe.
3719const ACTIVITY_PARAM_KEYS: &[&str] = &["cycles", "concurrency", "rate", "errors", "error_rate_max"];
3720
3721/// Clone `params` minus the activity/phase-scope keys ([`ACTIVITY_PARAM_KEYS`]).
3722fn exclude_activity_keys(params: &HashMap<String, JVal>) -> HashMap<String, JVal> {
3723    params
3724        .iter()
3725        .filter(|(k, _)| !ACTIVITY_PARAM_KEYS.contains(&k.as_str()))
3726        .map(|(k, v)| (k.clone(), v.clone()))
3727        .collect()
3728}
3729
3730fn merge_value_maps(
3731    parent: &HashMap<String, JVal>,
3732    child: &HashMap<String, JVal>,
3733) -> HashMap<String, JVal> {
3734    let mut merged = parent.clone();
3735    for (k, v) in child {
3736        merged.insert(k.clone(), v.clone());
3737    }
3738    merged
3739}
3740
3741#[cfg(test)]
3742mod tests {
3743    use super::*;
3744
3745    #[test]
3746    fn phase_metrics_mapping_form_preserves_raw_value() {
3747        // Phase-level `metrics:` keeps the raw `value:` expression
3748        // (no bare-key injection into bindings — the phase synthesiser
3749        // emits `volatile __metric_<name> := <value>` directly).
3750        let yaml = r#"
3751phases:
3752  build_index:
3753    metrics:
3754      time_to_index: { value: "current_epoch_millis() - phase_start", kind: gauge }
3755    ops:
3756      work: { stmt: "op" }
3757scenarios:
3758  default: [build_index]
3759"#;
3760        let wl = parse_workload(yaml, &HashMap::new()).expect("parse");
3761        let phase = wl.phases.get("build_index").expect("phase build_index");
3762        let m = phase
3763            .metrics
3764            .get("time_to_index")
3765            .expect("time_to_index metric");
3766        assert_eq!(
3767            m.value, "current_epoch_millis() - phase_start",
3768            "raw value must be preserved verbatim"
3769        );
3770        assert_eq!(m.kind, Some(crate::model::MetricKind::Gauge));
3771        assert!(
3772            phase.bindings.is_empty(),
3773            "phase metrics must NOT auto-inject into bindings: {:?}",
3774            phase.bindings
3775        );
3776    }
3777
3778    #[test]
3779    fn tries_accepts_sugared_number_and_map_form() {
3780        // Sugared: a bare number sets the count, no backoff overrides.
3781        let sugar = r#"
3782phases:
3783  load:
3784    tries: 20
3785    ops: { work: { stmt: "op" } }
3786scenarios: { default: [load] }
3787"#;
3788        let wl = parse_workload(sugar, &HashMap::new()).expect("parse sugar");
3789        let p = wl.phases.get("load").unwrap();
3790        assert_eq!(p.tries, Some(20));
3791        assert_eq!(p.tries_backoff, None);
3792
3793        // Map form: `count` + nested `backoff` (durations kept as strings).
3794        let map = r#"
3795phases:
3796  load:
3797    tries:
3798      count: 20
3799      backoff:
3800        ratio: 2.0
3801        min: 100ms
3802        max: 10s
3803    ops: { work: { stmt: "op" } }
3804scenarios: { default: [load] }
3805"#;
3806        let wl = parse_workload(map, &HashMap::new()).expect("parse map");
3807        let p = wl.phases.get("load").unwrap();
3808        assert_eq!(p.tries, Some(20));
3809        let bo = p.tries_backoff.as_ref().expect("backoff parsed");
3810        assert_eq!(bo.ratio, Some(2.0));
3811        assert_eq!(bo.min.as_deref(), Some("100ms"));
3812        assert_eq!(bo.max.as_deref(), Some("10s"));
3813
3814        // A bad shape (string) is a loud parse error, not a silent drop.
3815        let bad = r#"
3816phases: { load: { tries: "lots", ops: { w: { stmt: "op" } } } }
3817scenarios: { default: [load] }
3818"#;
3819        assert!(
3820            parse_workload(bad, &HashMap::new()).is_err(),
3821            "non-number, non-map tries must fail to parse"
3822        );
3823    }
3824
3825    #[test]
3826    fn metric_spec_rejects_unknown_field_type_with_hint() {
3827        // `type:` is the OpenMetrics word; ours is `kind`. Reject it
3828        // loudly with a hint rather than silently dropping it (which
3829        // would default the metric to gauge).
3830        let yaml = r#"
3831phases:
3832  p:
3833    metrics:
3834      m: { type: counter, value: "x" }
3835    ops:
3836      work: { stmt: "op" }
3837scenarios:
3838  default: [p]
3839"#;
3840        let err = parse_workload(yaml, &HashMap::new())
3841            .expect_err("unknown metric field `type` must be rejected");
3842        assert!(
3843            err.contains("unknown field `type`"),
3844            "must name the offending field; got: {err}"
3845        );
3846        assert!(
3847            err.contains("kind"),
3848            "must hint at the canonical `kind` field; got: {err}"
3849        );
3850    }
3851
3852    #[test]
3853    fn metric_spec_rejects_arbitrary_unknown_field() {
3854        let yaml = r#"
3855phases:
3856  p:
3857    metrics:
3858      m: { value: "x", flavour: gauge }
3859    ops:
3860      work: { stmt: "op" }
3861scenarios:
3862  default: [p]
3863"#;
3864        let err = parse_workload(yaml, &HashMap::new())
3865            .expect_err("arbitrary unknown metric field must be rejected");
3866        assert!(
3867            err.contains("unknown field `flavour`"),
3868            "must name the offending field; got: {err}"
3869        );
3870    }
3871
3872    #[test]
3873    fn metric_spec_accepts_all_known_fields() {
3874        // Regression guard: the unknown-field check must not reject any
3875        // legitimate field.
3876        let yaml = r#"
3877phases:
3878  p:
3879    metrics:
3880      m: { value: "x", family: fam, kind: counter, unit: bytes, format: "0.00" }
3881    ops:
3882      work: { stmt: "op" }
3883scenarios:
3884  default: [p]
3885"#;
3886        let wl = parse_workload(yaml, &HashMap::new()).expect("all known fields accepted");
3887        let m = wl.phases.get("p").unwrap().metrics.get("m").unwrap();
3888        assert_eq!(m.kind, Some(crate::model::MetricKind::Counter));
3889        assert_eq!(m.unit.as_deref(), Some("bytes"));
3890        assert_eq!(m.family.as_deref(), Some("fam"));
3891    }
3892
3893    #[test]
3894    fn phase_metrics_list_form_rejects_wire_expression() {
3895        // The `name := expr` list form is op-only; for a phase the
3896        // author must use the phase `bindings:` block + a bare name,
3897        // or the mapping form. Reject loudly rather than silently.
3898        let yaml = r#"
3899phases:
3900  p:
3901    metrics:
3902      - "te := current_epoch_millis() - phase_start"
3903    ops:
3904      work: { stmt: "op" }
3905scenarios:
3906  default: [p]
3907"#;
3908        let err = parse_workload(yaml, &HashMap::new())
3909            .expect_err("list `name := expr` form must be rejected for phases");
3910        assert!(
3911            err.contains("op-only") || err.contains("mapping form"),
3912            "diagnostic should point at the op-only form; got: {err}"
3913        );
3914    }
3915
3916    #[test]
3917    fn readouts_block_form_a_scalar_binds_on_update() {
3918        let workload: serde_yaml::Value =
3919            serde_yaml::from_str(r#"readouts: phase_status"#).unwrap();
3920        let json = serde_json::to_value(&workload).unwrap();
3921        let r = parse_readouts_block(json.get("readouts")).unwrap();
3922        assert_eq!(r.on_update, vec!["phase_status".to_string()]);
3923        assert!(r.on_phase_end.is_empty());
3924    }
3925
3926    #[test]
3927    fn readouts_block_form_b_mapping_binds_explicit_slots() {
3928        let yaml = serde_yaml::from_str::<serde_yaml::Value>(
3929            r#"
3930readouts:
3931  on_phase_end: phase_outcome
3932  on_update: "phase_status lod=compact"
3933"#,
3934        )
3935        .unwrap();
3936        let json = serde_json::to_value(&yaml).unwrap();
3937        let r = parse_readouts_block(json.get("readouts")).unwrap();
3938        assert_eq!(r.on_phase_end, vec!["phase_outcome".to_string()]);
3939        assert_eq!(r.on_update, vec!["phase_status lod=compact".to_string()]);
3940    }
3941
3942    #[test]
3943    fn readouts_block_form_c_list_composes() {
3944        let yaml = serde_yaml::from_str::<serde_yaml::Value>(
3945            r#"
3946readouts:
3947  on_phase_end:
3948    - phase_outcome
3949    - phase_failure_hint
3950"#,
3951        )
3952        .unwrap();
3953        let json = serde_json::to_value(&yaml).unwrap();
3954        let r = parse_readouts_block(json.get("readouts")).unwrap();
3955        assert_eq!(
3956            r.on_phase_end,
3957            vec![
3958                "phase_outcome".to_string(),
3959                "phase_failure_hint".to_string(),
3960            ]
3961        );
3962    }
3963
3964    #[test]
3965    fn readouts_block_each_wildcard_expands() {
3966        let yaml = serde_yaml::from_str::<serde_yaml::Value>(
3967            r#"
3968readouts:
3969  each_*: scope_bracket
3970"#,
3971        )
3972        .unwrap();
3973        let json = serde_json::to_value(&yaml).unwrap();
3974        let r = parse_readouts_block(json.get("readouts")).unwrap();
3975        assert_eq!(r.on_each_start, vec!["scope_bracket".to_string()]);
3976        assert_eq!(r.on_each_end, vec!["scope_bracket".to_string()]);
3977        // Other slots untouched.
3978        assert!(r.on_phase_end.is_empty());
3979        assert!(r.on_update.is_empty());
3980    }
3981
3982    #[test]
3983    fn readouts_block_phase_wildcard_expands() {
3984        let yaml = serde_yaml::from_str::<serde_yaml::Value>(
3985            r#"
3986readouts:
3987  phase_*: trace
3988"#,
3989        )
3990        .unwrap();
3991        let json = serde_json::to_value(&yaml).unwrap();
3992        let r = parse_readouts_block(json.get("readouts")).unwrap();
3993        assert_eq!(r.on_phase_start, vec!["trace".to_string()]);
3994        assert_eq!(r.on_phase_end, vec!["trace".to_string()]);
3995        assert!(r.on_each_start.is_empty());
3996    }
3997
3998    #[test]
3999    fn readouts_block_universal_wildcard_expands_to_all() {
4000        let yaml = serde_yaml::from_str::<serde_yaml::Value>(
4001            r#"
4002readouts:
4003  "*": trace
4004"#,
4005        )
4006        .unwrap();
4007        let json = serde_json::to_value(&yaml).unwrap();
4008        let r = parse_readouts_block(json.get("readouts")).unwrap();
4009        for slot in [
4010            &r.on_session_start,
4011            &r.on_session_end,
4012            &r.on_phase_start,
4013            &r.on_phase_end,
4014            &r.on_each_start,
4015            &r.on_each_end,
4016            &r.on_scope_start,
4017            &r.on_scope_end,
4018            &r.on_update,
4019        ] {
4020            assert_eq!(slot, &vec!["trace".to_string()]);
4021        }
4022    }
4023
4024    #[test]
4025    fn readouts_block_unknown_slot_is_error() {
4026        let yaml = serde_yaml::from_str::<serde_yaml::Value>(
4027            r#"
4028readouts:
4029  on_unknown: phase_outcome
4030"#,
4031        )
4032        .unwrap();
4033        let json = serde_json::to_value(&yaml).unwrap();
4034        let err = parse_readouts_block(json.get("readouts")).unwrap_err();
4035        assert!(
4036            err.contains("unknown slot 'on_unknown'"),
4037            "wrong message: {err}"
4038        );
4039    }
4040
4041    #[test]
4042    fn parse_single_string_op() {
4043        let ops = parse_ops("op: select * from bar.table;").unwrap();
4044        assert_eq!(ops.len(), 1);
4045        assert_eq!(ops[0].name, "stmt1");
4046        assert_eq!(ops[0].op["stmt"], "select * from bar.table;");
4047    }
4048
4049    #[test]
4050    fn parse_ops_list_of_strings() {
4051        let yaml = r#"
4052ops:
4053  - select * from t1;
4054  - select * from t2;
4055"#;
4056        let ops = parse_ops(yaml).unwrap();
4057        assert_eq!(ops.len(), 2);
4058        assert_eq!(ops[0].op["stmt"], "select * from t1;");
4059        assert_eq!(ops[1].op["stmt"], "select * from t2;");
4060    }
4061
4062    #[test]
4063    fn parse_ops_map_of_strings() {
4064        let yaml = r#"
4065ops:
4066  read: select * from t1;
4067  write: insert into t1 values (1);
4068"#;
4069        let ops = parse_ops(yaml).unwrap();
4070        assert_eq!(ops.len(), 2);
4071        let read = ops.iter().find(|o| o.name == "read").unwrap();
4072        assert_eq!(read.op["stmt"], "select * from t1;");
4073    }
4074
4075    #[test]
4076    fn parse_named_blocks() {
4077        let yaml = r#"
4078blocks:
4079  schema:
4080    ops:
4081      create: "CREATE TABLE t (id int PRIMARY KEY);"
4082  main:
4083    ops:
4084      read: "SELECT * FROM t WHERE id={id};"
4085"#;
4086        let ops = parse_ops(yaml).unwrap();
4087        assert_eq!(ops.len(), 2);
4088        let create = ops.iter().find(|o| o.name == "create").unwrap();
4089        assert_eq!(create.tags["block"], "schema");
4090        let read = ops.iter().find(|o| o.name == "read").unwrap();
4091        assert_eq!(read.tags["block"], "main");
4092    }
4093
4094    #[test]
4095    fn parse_property_inheritance() {
4096        let yaml = r#"
4097bindings:
4098  id: Identity()
4099params:
4100  prepared: true
4101tags:
4102  workload: test
4103blocks:
4104  main:
4105    bindings:
4106      id: Hash()
4107    ops:
4108      op1: "SELECT * FROM t;"
4109"#;
4110        let ops = parse_ops(yaml).unwrap();
4111        assert_eq!(ops.len(), 1);
4112        // Block-level binding overrides doc-level
4113        assert_eq!(ops[0].bindings.as_map()["id"], "Hash()");
4114        // Doc-level param inherited
4115        assert_eq!(ops[0].params["prepared"], true);
4116        // Doc-level tag inherited
4117        assert_eq!(ops[0].tags["workload"], "test");
4118        // Auto-tag
4119        assert_eq!(ops[0].tags["block"], "main");
4120    }
4121
4122    #[test]
4123    fn parse_auto_naming() {
4124        let yaml = r#"
4125ops:
4126  - "first op"
4127  - "second op"
4128"#;
4129        let ops = parse_ops(yaml).unwrap();
4130        assert_eq!(ops[0].name, "stmt1");
4131        assert_eq!(ops[1].name, "stmt2");
4132    }
4133
4134    #[test]
4135    fn parse_auto_tagging() {
4136        let yaml = r#"
4137ops:
4138  myop: "SELECT 1;"
4139"#;
4140        let ops = parse_ops(yaml).unwrap();
4141        assert_eq!(ops[0].tags["name"], "myop");
4142        assert_eq!(ops[0].tags["op"], "myop");
4143        assert_eq!(ops[0].tags["block"], "block0");
4144    }
4145
4146    #[test]
4147    fn condition_clause_passthrough_for_identifier() {
4148        // Bare identifier — legacy "name a binding" form.
4149        assert_eq!(normalize_condition_clause("my_flag"), "my_flag");
4150        assert_eq!(normalize_condition_clause(" my_flag "), "my_flag");
4151    }
4152
4153    #[test]
4154    fn condition_clause_passthrough_for_braced_forms() {
4155        assert_eq!(normalize_condition_clause("{my_flag}"), "{my_flag}");
4156        assert_eq!(normalize_condition_clause("{{x == 1}}"), "{{x == 1}}");
4157        assert_eq!(normalize_condition_clause("{:=x == 1:=}"), "{:=x == 1:=}");
4158    }
4159
4160    #[test]
4161    fn condition_clause_wraps_bare_expressions() {
4162        assert_eq!(
4163            normalize_condition_clause("cql_dialect == 'cass'"),
4164            "{{cql_dialect == 'cass'}}",
4165        );
4166        assert_eq!(
4167            normalize_condition_clause("a > 0 && b < 10"),
4168            "{{a > 0 && b < 10}}",
4169        );
4170        assert_eq!(normalize_condition_clause("foo(bar)"), "{{foo(bar)}}",);
4171    }
4172
4173    #[test]
4174    fn condition_clause_empty_passthrough() {
4175        assert_eq!(normalize_condition_clause(""), "");
4176        assert_eq!(normalize_condition_clause("   "), "");
4177    }
4178
4179    #[test]
4180    fn parse_op_with_fields() {
4181        let yaml = r#"
4182ops:
4183  op1:
4184    field1: value1
4185    field2: value2
4186"#;
4187        let ops = parse_ops(yaml).unwrap();
4188        assert_eq!(ops[0].op["field1"], "value1");
4189        assert_eq!(ops[0].op["field2"], "value2");
4190    }
4191
4192    #[test]
4193    fn parse_explicit_op_field() {
4194        let yaml = r#"
4195ops:
4196  op1:
4197    op:
4198      stmt: "SELECT * FROM t;"
4199      type: query
4200"#;
4201        let ops = parse_ops(yaml).unwrap();
4202        assert_eq!(ops[0].op["stmt"], "SELECT * FROM t;");
4203        assert_eq!(ops[0].op["type"], "query");
4204    }
4205
4206    #[test]
4207    fn parse_scenarios() {
4208        let yaml = r#"
4209scenarios:
4210  default:
4211    schema: run driver=cql tags==block:schema threads==1
4212    main: run driver=cql tags==block:main cycles=1M
4213ops:
4214  op1: "test"
4215"#;
4216        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4217        let default = &workload.scenarios["default"];
4218        assert_eq!(default.len(), 2);
4219        // Legacy command-string format: names are preserved as Phase nodes
4220        assert!(matches!(&default[0], ScenarioNode::Phase(n) if n == "schema"));
4221        assert!(matches!(&default[1], ScenarioNode::Phase(n) if n == "main"));
4222    }
4223
4224    #[test]
4225    fn parse_template_expansion() {
4226        let yaml = r#"
4227ops:
4228  op1: "SELECT * FROM t LIMIT TEMPLATE(limit, 100);"
4229"#;
4230        let ops = parse_ops(yaml).unwrap();
4231        assert_eq!(ops[0].op["stmt"], "SELECT * FROM t LIMIT 100;");
4232    }
4233
4234    #[test]
4235    fn parse_description() {
4236        let yaml = r#"
4237description: |
4238  This is a test workload.
4239ops:
4240  op1: "test"
4241"#;
4242        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4243        assert!(workload.description.unwrap().contains("test workload"));
4244    }
4245
4246    // ── Scenario malformed-node rejection (Never Ignore
4247    // Silently). The parser used to silently drop an
4248    // unrecognised scenario-node key like `iterate:` →
4249    // `{phases: [...]}`, leaving the scenario empty and
4250    // surfacing as a confusing downstream "phase not found"
4251    // error. These tests pin the loud-rejection behavior so a
4252    // refactor can't regress the silent-drop. ──
4253
4254    #[test]
4255    fn scenario_node_with_unknown_map_key_collects_parse_error() {
4256        let yaml = r#"
4257scenarios:
4258  bogus:
4259    - iterate:
4260        phases:
4261          - phase_x
4262phases:
4263  phase_x:
4264    ops:
4265      noop: "x"
4266"#;
4267        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4268        assert!(
4269            !workload.scenario_parse_errors.is_empty(),
4270            "malformed `iterate:` node MUST surface a scenario_parse_error \
4271             — silent drop is the safety bug being prevented"
4272        );
4273        let msg = workload.scenario_parse_errors[0].as_str();
4274        assert!(
4275            msg.contains("bogus"),
4276            "error must name the offending scenario: {msg}"
4277        );
4278        assert!(
4279            msg.contains("iterate"),
4280            "error must name the bad key: {msg}"
4281        );
4282        // Bogus scenario must NOT have absorbed phase_x as a Phase
4283        // node via the legacy catch-all.
4284        assert_eq!(
4285            workload.scenarios.get("bogus").map(|v| v.len()),
4286            Some(0),
4287            "malformed node must NOT silently produce ScenarioNode::Phase"
4288        );
4289    }
4290
4291    #[test]
4292    fn scenario_node_with_legacy_command_string_form_still_works() {
4293        // Pre-existing nosqlbench-style scenario shape: each
4294        // map key is a step name, value is a `run ...` CLI
4295        // string. The malformed-rejection refactor MUST NOT
4296        // break this — the discriminator is "map value is
4297        // a string" → legacy, "map value is a map/array" →
4298        // malformed.
4299        let yaml = r#"
4300scenarios:
4301  default:
4302    schema: run tags==block:schema
4303    main: run tags==block:main
4304ops:
4305  op1: "test"
4306"#;
4307        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4308        assert!(
4309            workload.scenario_parse_errors.is_empty(),
4310            "legacy command-string form must NOT be flagged as malformed: {:?}",
4311            workload.scenario_parse_errors
4312        );
4313        let default = &workload.scenarios["default"];
4314        assert_eq!(default.len(), 2);
4315        assert!(matches!(&default[0], ScenarioNode::Phase(n) if n == "schema"));
4316        assert!(matches!(&default[1], ScenarioNode::Phase(n) if n == "main"));
4317    }
4318
4319    #[test]
4320    fn scenario_node_malformed_error_lists_recognised_keys() {
4321        // The error message must point the operator at the
4322        // recognised key vocabulary so they can self-correct
4323        // without grepping the source.
4324        let yaml = r#"
4325scenarios:
4326  s1:
4327    - typoed_for_each:
4328        phases:
4329          - phase_x
4330phases:
4331  phase_x:
4332    ops:
4333      noop: "x"
4334"#;
4335        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4336        assert_eq!(workload.scenario_parse_errors.len(), 1);
4337        let msg = &workload.scenario_parse_errors[0];
4338        // Must mention at least the load-bearing alternatives.
4339        for expected in ["for_each", "scenarios", "do_while", "bindings"] {
4340            assert!(
4341                msg.contains(expected),
4342                "error message must mention `{expected}` as a valid \
4343                 alternative; got: {msg}"
4344            );
4345        }
4346    }
4347
4348    #[test]
4349    fn parse_polydat_source_bindings() {
4350        // SRD-13f Push D: workload-level `bindings:` live on
4351        // `Workload.bindings` and reach ops via the Polydat Kernel
4352        // chain at runtime — they are NOT folded into per-op
4353        // bindings at parse time.
4354        let yaml = r#"
4355bindings: |
4356  // Explicit wiring — every intermediate is named
4357  input cycle: u64
4358  h := hash(cycle)
4359  user_id := mod(h, 1000000)
4360  code_hash := hash(user_id)
4361  code := combinations(code_hash, '0-9A-Z')
4362
4363  // Equivalent concise form (nested composition):
4364  // user_id := mod(hash(cycle), 1000000)
4365  // code := combinations(hash(user_id), '0-9A-Z')
4366ops:
4367  insert: "INSERT INTO users (id, code) VALUES ({user_id}, '{code}');"
4368"#;
4369        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4370        match &workload.bindings {
4371            BindingsDef::PolydatSource(src) => {
4372                assert!(src.contains("input cycle: u64"));
4373                assert!(src.contains("user_id := mod(h, 1000000)"));
4374            }
4375            BindingsDef::Map(_) => panic!("expected PolydatSource at workload level, got Map"),
4376        }
4377        assert_eq!(workload.ops.len(), 1);
4378        // Op carries no workload bindings — they reach it via
4379        // the Polydat Kernel chain, not via parse-time merge.
4380        assert!(workload.ops[0].bindings.is_empty());
4381    }
4382
4383    #[test]
4384    fn parse_map_bindings_still_works() {
4385        // SRD-13f Push D: Map-form workload bindings live on
4386        // `Workload.bindings`, not on per-op bindings.
4387        let yaml = r#"
4388bindings:
4389  id: "Hash(); Mod(100)"
4390ops:
4391  op1: "SELECT * FROM t WHERE id={id};"
4392"#;
4393        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4394        assert_eq!(workload.bindings.as_map()["id"], "Hash(); Mod(100)");
4395        // The op itself carries no merged-in workload bindings.
4396        assert!(workload.ops[0].bindings.is_empty());
4397    }
4398
4399    #[test]
4400    fn parse_phased_workload() {
4401        let yaml = r#"
4402scenarios:
4403  default:
4404    - schema
4405    - main
4406
4407phases:
4408  schema:
4409    cycles: 1
4410    concurrency: 1
4411    ops:
4412      create_table:
4413        stmt: "CREATE TABLE t (id int PRIMARY KEY);"
4414  main:
4415    cycles: 1000
4416    concurrency: 10
4417    rate: 500.0
4418    ops:
4419      read:
4420        stmt: "SELECT * FROM t WHERE id={id};"
4421      write:
4422        stmt: "INSERT INTO t (id) VALUES ({id});"
4423"#;
4424        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4425
4426        // Phases parsed
4427        assert_eq!(workload.phases.len(), 2);
4428        assert!(workload.phases.contains_key("schema"));
4429        assert!(workload.phases.contains_key("main"));
4430
4431        // Phase order preserved
4432        assert_eq!(workload.phase_order, vec!["schema", "main"]);
4433
4434        // Schema phase config
4435        let schema = &workload.phases["schema"];
4436        assert_eq!(schema.cycles.as_deref(), Some("1"));
4437        assert_eq!(schema.concurrency.as_deref(), Some("1"));
4438        assert_eq!(schema.rate, None);
4439        assert_eq!(schema.ops.len(), 1);
4440        assert_eq!(schema.ops[0].name, "create_table");
4441
4442        // Main phase config
4443        let main = &workload.phases["main"];
4444        assert_eq!(main.cycles.as_deref(), Some("1000"));
4445        assert_eq!(main.concurrency.as_deref(), Some("10"));
4446        assert_eq!(main.rate.as_deref(), Some("500.0"));
4447        assert_eq!(main.ops.len(), 2);
4448
4449        // Scenario parsed as phase name list
4450        let default = &workload.scenarios["default"];
4451        assert_eq!(default.len(), 2);
4452        assert!(matches!(&default[0], ScenarioNode::Phase(n) if n == "schema"));
4453        assert!(matches!(&default[1], ScenarioNode::Phase(n) if n == "main"));
4454    }
4455
4456    #[test]
4457    fn parse_phased_workload_with_tags() {
4458        let yaml = r#"
4459blocks:
4460  schema:
4461    ops:
4462      create: "CREATE TABLE t (id int PRIMARY KEY);"
4463  main:
4464    ops:
4465      read: "SELECT * FROM t;"
4466
4467phases:
4468  setup:
4469    tags: "block:schema"
4470    cycles: 1
4471  run:
4472    tags: "block:main"
4473    cycles: 1000
4474"#;
4475        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4476        assert_eq!(workload.phases.len(), 2);
4477
4478        // SRD-108 Part A: the selector resolves at parse time —
4479        // the phase carries the selected block ops as if inline.
4480        let setup = &workload.phases["setup"];
4481        assert_eq!(setup.tags.as_deref(), Some("block:schema"));
4482        assert_eq!(setup.ops.len(), 1);
4483        assert_eq!(setup.ops[0].name, "create");
4484        assert_eq!(
4485            setup.ops[0].tags.get("phase").map(String::as_str),
4486            Some("setup")
4487        );
4488
4489        let run = &workload.phases["run"];
4490        assert_eq!(run.tags.as_deref(), Some("block:main"));
4491        assert_eq!(run.ops.len(), 1);
4492        assert_eq!(run.ops[0].name, "read");
4493    }
4494
4495    /// SRD-108 Part A — a selector matching nothing is a load
4496    /// error naming the phase, never a dispatch-time panic.
4497    #[test]
4498    fn phase_tag_selector_matching_nothing_is_a_load_error() {
4499        let yaml = r#"
4500blocks:
4501  schema:
4502    ops:
4503      create: "CREATE TABLE t (id int PRIMARY KEY);"
4504phases:
4505  setup:
4506    tags: "block:nonexistent"
4507    cycles: 1
4508"#;
4509        let err = parse_workload(yaml, &HashMap::new()).unwrap_err();
4510        assert!(
4511            err.contains("setup") && err.contains("matched no ops"),
4512            "error must name the phase and the failure: {err}"
4513        );
4514    }
4515
4516    /// SRD-108 Part A — inline ops and a selector together are
4517    /// rejected: one source of ops per phase.
4518    #[test]
4519    fn phase_with_inline_ops_and_selector_is_rejected() {
4520        let yaml = r#"
4521blocks:
4522  schema:
4523    ops:
4524      create: "CREATE TABLE t (id int PRIMARY KEY);"
4525phases:
4526  setup:
4527    tags: "block:schema"
4528    cycles: 1
4529    ops:
4530      also: "SELECT 1;"
4531"#;
4532        let err = parse_workload(yaml, &HashMap::new()).unwrap_err();
4533        assert!(
4534            err.contains("both inline ops and a"),
4535            "error must explain the conflict: {err}"
4536        );
4537    }
4538
4539    #[test]
4540    fn parse_phased_workload_polydat_cycles() {
4541        let yaml = r#"
4542phases:
4543  rampup:
4544    cycles: "{train_count}"
4545    concurrency: 100
4546    ops:
4547      insert:
4548        stmt: "INSERT INTO t (id) VALUES ({id});"
4549"#;
4550        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4551        let rampup = &workload.phases["rampup"];
4552        assert_eq!(rampup.cycles.as_deref(), Some("{train_count}"));
4553    }
4554
4555    // ── SRD-40b §1 + §2: `metrics:` discriminant on op template ──
4556
4557    #[test]
4558    fn parse_metrics_full_mapping_form() {
4559        let yaml = r##"
4560phases:
4561  predict:
4562    bindings: |
4563      example_factor := 1.0 + 2.5
4564    ops:
4565      synth:
4566        stmt: "noop"
4567        metrics:
4568          example_factor:
4569            value: example_factor
4570            kind: gauge
4571            unit: ratio
4572            format: "#.##"
4573"##;
4574        let wl = parse_workload(yaml, &HashMap::new()).unwrap();
4575        let op = &wl.phases["predict"].ops[0];
4576        assert_eq!(op.name, "synth");
4577        let m = &op.metrics["example_factor"];
4578        assert_eq!(m.value, "example_factor");
4579        assert_eq!(m.kind, Some(crate::model::MetricKind::Gauge));
4580        assert_eq!(m.unit.as_deref(), Some("ratio"));
4581        assert_eq!(m.format.as_deref(), Some("#.##"));
4582    }
4583
4584    #[test]
4585    fn parse_metrics_bare_string_sugar() {
4586        let yaml = r#"
4587phases:
4588  p:
4589    bindings: |
4590      overscan := 1.0
4591    ops:
4592      o:
4593        stmt: "noop"
4594        metrics: overscan
4595"#;
4596        let wl = parse_workload(yaml, &HashMap::new()).unwrap();
4597        let op = &wl.phases["p"].ops[0];
4598        let m = &op.metrics["overscan"];
4599        // Bare-string form: family + value both = "overscan"
4600        // (defaults), kind unset (defaults to gauge at runtime).
4601        assert_eq!(m.value, "overscan");
4602        assert_eq!(m.family, None);
4603        assert_eq!(m.kind, None);
4604    }
4605
4606    #[test]
4607    fn parse_metrics_list_with_wire_expression() {
4608        let yaml = r#"
4609phases:
4610  p:
4611    ops:
4612      o:
4613        stmt: "noop"
4614        metrics:
4615          - latency_pred := 0.5 + 1.5 * pow(limit, -0.4)
4616          - already_bound
4617"#;
4618        let wl = parse_workload(yaml, &HashMap::new()).unwrap();
4619        let op = &wl.phases["p"].ops[0];
4620        // Both metric entries registered.
4621        assert!(op.metrics.contains_key("latency_pred"));
4622        assert!(op.metrics.contains_key("already_bound"));
4623        // Wire expression auto-injected into op bindings.
4624        match &op.bindings {
4625            BindingsDef::PolydatSource(src) => {
4626                assert!(
4627                    src.contains("latency_pred := 0.5 + 1.5 * pow(limit, -0.4)"),
4628                    "wire not injected; bindings: {src:?}"
4629                );
4630            }
4631            other => panic!("expected PolydatSource bindings, got {other:?}"),
4632        }
4633    }
4634
4635    #[test]
4636    fn parse_metrics_mapping_form_with_wire_expression() {
4637        let yaml = r#"
4638phases:
4639  p:
4640    bindings: |
4641      base := 10
4642    ops:
4643      o:
4644        stmt: "noop"
4645        metrics:
4646          scaled:
4647            value: base * 2
4648            kind: gauge
4649"#;
4650        let wl = parse_workload(yaml, &HashMap::new()).unwrap();
4651        let op = &wl.phases["p"].ops[0];
4652        let m = &op.metrics["scaled"];
4653        // After auto-inject the spec's `value:` is the bare key.
4654        assert_eq!(m.value, "scaled");
4655        // The non-bare expression landed in op-template bindings.
4656        match &op.bindings {
4657            BindingsDef::PolydatSource(src) => {
4658                assert!(
4659                    src.contains("scaled := base * 2"),
4660                    "expression not injected; bindings: {src:?}"
4661                );
4662            }
4663            other => panic!("expected PolydatSource bindings, got {other:?}"),
4664        }
4665    }
4666
4667    #[test]
4668    fn parse_metrics_mapping_form_invalid_key_for_non_bare_value() {
4669        // Non-bare value + key that can't be a binding name → reject.
4670        let yaml = r#"
4671phases:
4672  p:
4673    ops:
4674      o:
4675        stmt: "noop"
4676        metrics:
4677          "1bad":
4678            value: foo + 1
4679            kind: gauge
4680"#;
4681        let err = parse_workload(yaml, &HashMap::new()).unwrap_err();
4682        assert!(
4683            err.contains("must itself be a valid identifier"),
4684            "expected identifier diagnostic, got: {err}"
4685        );
4686    }
4687
4688    #[test]
4689    fn parse_metrics_format_validation_runs_at_load() {
4690        let yaml = r##"
4691phases:
4692  p:
4693    ops:
4694      o:
4695        stmt: "noop"
4696        metrics:
4697          x:
4698            value: x
4699            format: "%3.2f"
4700"##;
4701        let err = parse_workload(yaml, &HashMap::new()).unwrap_err();
4702        assert!(
4703            err.contains("printf-style"),
4704            "format error not surfaced at parse time: {err}"
4705        );
4706    }
4707
4708    #[test]
4709    fn parse_metrics_wire_expression_collision_errors() {
4710        let yaml = r#"
4711phases:
4712  p:
4713    ops:
4714      o:
4715        stmt: "noop"
4716        bindings: |
4717          foo := 1.0
4718        metrics:
4719          - foo := 2.0
4720"#;
4721        let err = parse_workload(yaml, &HashMap::new()).unwrap_err();
4722        assert!(err.contains("collides"), "collision not detected: {err}");
4723    }
4724
4725    #[test]
4726    fn parse_phase_bindings_round_trip() {
4727        // SRD-13d Phase 1: phase-level `bindings:` block must
4728        // land on WorkloadPhase.bindings (not just merged into
4729        // ops) so HasGkMatter can classify it.
4730        let yaml = r#"
4731phases:
4732  p:
4733    bindings: |
4734      phase_factor := 7
4735    ops:
4736      o:
4737        stmt: "noop"
4738"#;
4739        let wl = parse_workload(yaml, &HashMap::new()).unwrap();
4740        match &wl.phases["p"].bindings {
4741            BindingsDef::PolydatSource(s) => assert!(s.contains("phase_factor := 7")),
4742            other => panic!("expected PolydatSource, got {other:?}"),
4743        }
4744    }
4745
4746    #[test]
4747    fn parse_phased_workload_default_scenario_from_order() {
4748        // No scenarios section — phases should run in definition order
4749        let yaml = r#"
4750phases:
4751  alpha:
4752    cycles: 1
4753    ops:
4754      op1:
4755        stmt: "a"
4756  beta:
4757    cycles: 2
4758    ops:
4759      op2:
4760        stmt: "b"
4761  gamma:
4762    cycles: 3
4763    ops:
4764      op3:
4765        stmt: "c"
4766"#;
4767        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4768        assert_eq!(workload.phase_order, vec!["alpha", "beta", "gamma"]);
4769        assert!(workload.scenarios.is_empty());
4770    }
4771
4772    #[test]
4773    fn parse_backward_compat_no_phases() {
4774        // Workload without phases should work exactly as before
4775        let yaml = r#"
4776ops:
4777  op1: "SELECT 1;"
4778  op2: "SELECT 2;"
4779"#;
4780        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4781        assert!(workload.phases.is_empty());
4782        assert!(workload.phase_order.is_empty());
4783        assert_eq!(workload.ops.len(), 2);
4784    }
4785
4786    #[test]
4787    fn block_level_params_override_workload_default() {
4788        // SRD 21 §"Parameter Resolution": closest-wins. The DDL block declares
4789        // `consistency=1`, overriding the workload-level default `100` for ops
4790        // in that block. Uses an op-scope param (`consistency`); activity-scope
4791        // keys (`concurrency`/`rate`/`cycles`/`errors`) are deliberately NOT
4792        // merged onto ops (SRD-32a — see `ACTIVITY_PARAM_KEYS`), so this tests
4793        // op-param precedence with a key that actually reaches ops.
4794        let yaml = r#"
4795params:
4796  consistency: "100"
4797blocks:
4798  ddl:
4799    params:
4800      consistency: "1"
4801    ops:
4802      schema_create: "CREATE TABLE foo (id int PRIMARY KEY);"
4803  bulk:
4804    ops:
4805      insert: "INSERT INTO foo (id) VALUES (?);"
4806"#;
4807        let ops = parse_ops(yaml).unwrap();
4808        let ddl = ops.iter().find(|o| o.name == "schema_create").unwrap();
4809        let bulk = ops.iter().find(|o| o.name == "insert").unwrap();
4810        assert_eq!(
4811            ddl.params.get("consistency").and_then(|v| v.as_str()),
4812            Some("1"),
4813            "block-level override should win for ddl op",
4814        );
4815        assert_eq!(
4816            bulk.params.get("consistency").and_then(|v| v.as_str()),
4817            Some("100"),
4818            "non-overriding block inherits workload-level default",
4819        );
4820    }
4821
4822    #[test]
4823    fn cli_overrides_block_level_params() {
4824        // CLI is the outermost layer per SRD 21 — it wins even over block-level
4825        // explicit overrides. Uses an op-scope param (`consistency`); see
4826        // `block_level_params_override_workload_default` for why not an
4827        // activity-scope key like `concurrency`.
4828        let yaml = r#"
4829params:
4830  consistency: "100"
4831blocks:
4832  ddl:
4833    params:
4834      consistency: "1"
4835    ops:
4836      schema_create: "CREATE TABLE foo (id int PRIMARY KEY);"
4837"#;
4838        let mut cli = HashMap::new();
4839        cli.insert("consistency".to_string(), "200".to_string());
4840        let workload = parse_workload(yaml, &cli).unwrap();
4841        let ddl = workload
4842            .ops
4843            .iter()
4844            .find(|o| o.name == "schema_create")
4845            .unwrap();
4846        assert_eq!(
4847            ddl.params.get("consistency").and_then(|v| v.as_str()),
4848            Some("200"),
4849            "CLI override should beat block-level",
4850        );
4851        // Workload-level params likewise reflect CLI.
4852        assert_eq!(
4853            workload.params.get("consistency").map(|s| s.as_str()),
4854            Some("200"),
4855        );
4856    }
4857
4858    #[test]
4859    fn parse_polydat_source_overrides_parent_map() {
4860        // Block-level Polydat source completely replaces doc-level map bindings
4861        let yaml = r#"
4862bindings:
4863  id: "Hash()"
4864blocks:
4865  main:
4866    bindings: |
4867      input cycle: u64
4868      h := hash(cycle)
4869      id := mod(h, 1000)
4870      // Concise equivalent:
4871      // id := mod(hash(cycle), 1000)
4872    ops:
4873      op1: "SELECT * FROM t WHERE id={id};"
4874"#;
4875        let ops = parse_ops(yaml).unwrap();
4876        match &ops[0].bindings {
4877            BindingsDef::PolydatSource(src) => {
4878                assert!(src.contains("input cycle: u64"));
4879                assert!(src.contains("id := mod(h, 1000)"));
4880            }
4881            BindingsDef::Map(_) => panic!("expected PolydatSource, got Map"),
4882        }
4883    }
4884
4885    #[test]
4886    fn parse_scenarios_plural_list_form() {
4887        // The plural `scenarios: [a, b, c]` form composes
4888        // several named scenarios at one node. Each list
4889        // entry expands to an `IncludedScenario` and resolves
4890        // post-parse. Equivalent to `[- scenario: a, -
4891        // scenario: b, ...]` but reads more naturally for the
4892        // "just compose these" case.
4893        let yaml = r#"
4894scenarios:
4895  rampup:
4896    - prep
4897  query:
4898    - run
4899
4900  composed:
4901    - scenarios:
4902        - rampup
4903        - query
4904
4905phases:
4906  prep:
4907    ops:
4908      create:
4909        raw: "select 1"
4910  run:
4911    ops:
4912      sel:
4913        raw: "select {cycle}"
4914"#;
4915        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4916        let composed = workload
4917            .scenarios
4918            .get("composed")
4919            .expect("composed scenario must parse");
4920        // After resolution the IncludedScenario wrappers carry
4921        // their resolved children. The scenario tree shape is:
4922        //   composed
4923        //     └── IncludedScenario("rampup") [Phase("prep")]
4924        //     └── IncludedScenario("query")  [Phase("run")]
4925        // Walk one level deep into each include to assert.
4926        assert_eq!(
4927            composed.len(),
4928            2,
4929            "scenarios: [a, b] should produce two top-level nodes"
4930        );
4931        let names: Vec<&str> = composed
4932            .iter()
4933            .filter_map(|n| match n {
4934                ScenarioNode::IncludedScenario { name, .. } => Some(name.as_str()),
4935                _ => None,
4936            })
4937            .collect();
4938        assert_eq!(names, vec!["rampup", "query"]);
4939        // First include resolves to its sole `Phase("prep")`.
4940        let first_children = match &composed[0] {
4941            ScenarioNode::IncludedScenario { children, .. } => children,
4942            _ => panic!("expected IncludedScenario at index 0"),
4943        };
4944        let first_phase = first_children.iter().find_map(|n| match n {
4945            ScenarioNode::Phase(p) => Some(p.as_str()),
4946            _ => None,
4947        });
4948        assert_eq!(first_phase, Some("prep"));
4949    }
4950
4951    #[test]
4952    fn parse_scenarios_plural_mixes_with_other_node_shapes() {
4953        // List entries can be a mix of bare strings and other
4954        // scenario-node shapes (objects with `scenario:`,
4955        // `for_each:`, etc.). This matches the heterogeneous
4956        // shape `parse_scenario_nodes` already accepts at the
4957        // top level, so the plural form composes naturally
4958        // with everything else.
4959        let yaml = r#"
4960scenarios:
4961  rampup:
4962    - prep
4963  composed:
4964    - scenarios:
4965        - rampup
4966        - { scenario: rampup }
4967
4968phases:
4969  prep:
4970    ops:
4971      create:
4972        raw: "select 1"
4973"#;
4974        let workload = parse_workload(yaml, &HashMap::new()).unwrap();
4975        let composed = workload.scenarios.get("composed").unwrap();
4976        // Both list entries should resolve to an IncludedScenario
4977        // wrapping the same `prep` phase.
4978        assert_eq!(composed.len(), 2);
4979        for node in composed {
4980            match node {
4981                ScenarioNode::IncludedScenario { name, children } => {
4982                    assert_eq!(name, "rampup");
4983                    assert!(
4984                        children
4985                            .iter()
4986                            .any(|c| matches!(c, ScenarioNode::Phase(p) if p == "prep"))
4987                    );
4988                }
4989                other => panic!("expected IncludedScenario, got {other:?}"),
4990            }
4991        }
4992    }
4993
4994    // -----------------------------------------------------------------
4995    // Checkpoint declaration parsing — SRD-44 §"Forms"
4996    // -----------------------------------------------------------------
4997
4998    fn parse_checkpoint_field(yaml: &str) -> Option<crate::model::Checkpoint> {
4999        let yaml = format!(
5000            "phases:\n  p:\n{}\n    ops:\n      - select 1;\n",
5001            yaml.lines()
5002                .map(|l| format!("    {l}"))
5003                .collect::<Vec<_>>()
5004                .join("\n")
5005        );
5006        let v: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("yaml parse");
5007        let json: serde_json::Value = serde_json::to_value(&v).expect("json convert");
5008        let phases_obj = json
5009            .get("phases")
5010            .and_then(|p| p.as_object())
5011            .expect("phases");
5012        let phase = phases_obj
5013            .get("p")
5014            .and_then(|p| p.as_object())
5015            .expect("phase p");
5016        phase.get("checkpoint").map(|v| {
5017            serde_json::from_value::<crate::model::Checkpoint>(v.clone()).expect("checkpoint parse")
5018        })
5019    }
5020
5021    #[test]
5022    fn checkpoint_short_form_idempotent() {
5023        let cp = parse_checkpoint_field("checkpoint: idempotent").expect("present");
5024        assert!(cp.idempotent);
5025        assert!(cp.hashed);
5026        assert!(cp.verify.is_none());
5027    }
5028
5029    #[test]
5030    fn checkpoint_short_form_none_disables_skip() {
5031        let cp = parse_checkpoint_field("checkpoint: none").expect("present");
5032        assert!(!cp.idempotent);
5033        // hashed default-true is preserved even when disabled —
5034        // the disabled state is about skip eligibility, not
5035        // about the hash field.
5036        assert!(cp.hashed);
5037        assert!(cp.verify.is_none());
5038    }
5039
5040    #[test]
5041    fn checkpoint_short_form_no_and_false_and_off_all_disable() {
5042        for word in &["no", "false", "off"] {
5043            let cp = parse_checkpoint_field(&format!("checkpoint: {word}")).expect("present");
5044            assert!(!cp.idempotent, "expected disabled for '{word}'");
5045        }
5046    }
5047
5048    #[test]
5049    fn checkpoint_bool_false_disables() {
5050        // YAML's bare `false` should map to disabled.
5051        let cp = parse_checkpoint_field("checkpoint: false").expect("present");
5052        assert!(!cp.idempotent);
5053    }
5054
5055    #[test]
5056    fn checkpoint_full_form_all_explicit() {
5057        let cp = parse_checkpoint_field("checkpoint:\n  idempotent: true\n  hashed: false")
5058            .expect("present");
5059        assert!(cp.idempotent);
5060        assert!(!cp.hashed);
5061        assert!(cp.verify.is_none());
5062    }
5063
5064    #[test]
5065    fn checkpoint_full_form_with_verify() {
5066        let cp = parse_checkpoint_field(
5067            "checkpoint:\n  idempotent: true\n  verify:\n    raw: 'SELECT 1'\n    poll: assert_one",
5068        )
5069        .expect("present");
5070        assert!(cp.idempotent);
5071        assert!(cp.hashed); // default
5072        let v = cp.verify.expect("verify body");
5073        assert_eq!(v.get("raw").and_then(|x| x.as_str()), Some("SELECT 1"));
5074        assert_eq!(v.get("poll").and_then(|x| x.as_str()), Some("assert_one"));
5075    }
5076
5077    #[test]
5078    fn checkpoint_full_form_idempotent_false_equivalent_to_none() {
5079        let cp = parse_checkpoint_field("checkpoint:\n  idempotent: false\n  hashed: true")
5080            .expect("present");
5081        assert!(!cp.idempotent);
5082        assert!(cp.hashed);
5083    }
5084
5085    #[test]
5086    fn checkpoint_unknown_short_form_errors() {
5087        // Should fail to parse — an unknown short string is a
5088        // workload bug, not silently treated as `none`.
5089        let yaml = "phases:\n  p:\n    checkpoint: maybe\n    ops:\n      - select 1;\n";
5090        let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
5091        let json: serde_json::Value = serde_json::to_value(&v).unwrap();
5092        let phases_obj = json.get("phases").and_then(|p| p.as_object()).unwrap();
5093        let phase = phases_obj.get("p").and_then(|p| p.as_object()).unwrap();
5094        let cp_val = phase.get("checkpoint").unwrap().clone();
5095        let err = serde_json::from_value::<crate::model::Checkpoint>(cp_val).unwrap_err();
5096        let msg = err.to_string();
5097        assert!(
5098            msg.contains("unknown short form"),
5099            "expected unknown-short-form error, got: {msg}"
5100        );
5101        assert!(
5102            msg.contains("'maybe'"),
5103            "expected the bad token in error, got: {msg}"
5104        );
5105    }
5106
5107    #[test]
5108    fn checkpoint_unknown_key_errors() {
5109        let yaml = "phases:\n  p:\n    checkpoint:\n      idempotent: true\n      bogus: yes\n    ops:\n      - select 1;\n";
5110        let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
5111        let json: serde_json::Value = serde_json::to_value(&v).unwrap();
5112        let cp_val = json.pointer("/phases/p/checkpoint").unwrap().clone();
5113        let err = serde_json::from_value::<crate::model::Checkpoint>(cp_val).unwrap_err();
5114        let msg = err.to_string();
5115        assert!(
5116            msg.contains("unknown key 'bogus'"),
5117            "expected unknown-key error, got: {msg}"
5118        );
5119    }
5120
5121    #[test]
5122    fn checkpoint_field_absent_yields_none() {
5123        let cp = parse_checkpoint_field("# no checkpoint declared\n");
5124        assert!(
5125            cp.is_none(),
5126            "absent declaration should yield None, not Default"
5127        );
5128    }
5129
5130    /// SRD-44 validation: a phase declared `checkpoint:
5131    /// idempotent` inside a do_while loop must be rejected at
5132    /// workload-load time. The do-loop iterates the same phase
5133    /// identity multiple times, contradicting the per-execution
5134    /// unit checkpointing assumes.
5135    #[test]
5136    fn rejects_idempotent_phase_inside_do_while() {
5137        let yaml = r#"
5138scenarios:
5139  default:
5140    - do_while: "true"
5141      phases:
5142        - probe
5143phases:
5144  probe:
5145    checkpoint: idempotent
5146    cycles: 1
5147    ops:
5148      step:
5149        stmt: "probe"
5150"#;
5151        let err = super::parse_workload(yaml, &HashMap::new())
5152            .expect_err("expected validation rejection");
5153        assert!(
5154            err.contains("checkpoint: idempotent"),
5155            "error should explain the rejection: {err}"
5156        );
5157        assert!(
5158            err.contains("'probe'"),
5159            "error should name the offending phase: {err}"
5160        );
5161        assert!(
5162            err.contains("do_while") || err.contains("do_until"),
5163            "error should mention the do-loop ancestor: {err}"
5164        );
5165    }
5166
5167    /// `do_until` triggers the same rejection.
5168    #[test]
5169    fn rejects_idempotent_phase_inside_do_until() {
5170        let yaml = r#"
5171scenarios:
5172  default:
5173    - do_until: "false"
5174      phases:
5175        - probe
5176phases:
5177  probe:
5178    checkpoint: idempotent
5179    cycles: 1
5180    ops:
5181      step:
5182        stmt: "probe"
5183"#;
5184        let err = super::parse_workload(yaml, &HashMap::new())
5185            .expect_err("expected validation rejection");
5186        assert!(err.contains("'probe'"));
5187    }
5188
5189    /// A do_while'd phase WITHOUT checkpoint declaration is
5190    /// fine — only the combination with `checkpoint:
5191    /// idempotent` is rejected.
5192    #[test]
5193    fn allows_do_while_phase_without_checkpoint_declaration() {
5194        let yaml = r#"
5195scenarios:
5196  default:
5197    - do_while: "true"
5198      phases:
5199        - probe
5200phases:
5201  probe:
5202    cycles: 1
5203    ops:
5204      step:
5205        stmt: "probe"
5206"#;
5207        super::parse_workload(yaml, &HashMap::new()).expect("plain do_while phase should parse");
5208    }
5209
5210    /// Idempotent phases NOT inside a do-loop are fine.
5211    #[test]
5212    fn allows_idempotent_phase_outside_do_loop() {
5213        let yaml = r#"
5214scenarios:
5215  default:
5216    - probe
5217phases:
5218  probe:
5219    checkpoint: idempotent
5220    cycles: 1
5221    ops:
5222      step:
5223        stmt: "probe"
5224"#;
5225        super::parse_workload(yaml, &HashMap::new())
5226            .expect("idempotent phase outside loop should parse");
5227    }
5228
5229    /// Declarative `capture:` map block on an op produces
5230    /// CapturePoint entries with [`CapturePoint::path`] set.
5231    /// JSON-Pointer paths are not validated against the
5232    /// response shape (it's a runtime read), so the parser's
5233    /// only job is to forbid obviously-malformed inputs
5234    /// (non-string values, paths missing the leading `/`).
5235    #[test]
5236    fn parses_declarative_capture_block_with_json_pointer_paths() {
5237        let yaml = r#"
5238scenarios:
5239  default:
5240    - probe
5241phases:
5242  probe:
5243    cycles: 1
5244    ops:
5245      read_state:
5246        adapter: http
5247        method: POST
5248        uri: "http://h:8778/jolokia/"
5249        body: "[]"
5250        capture:
5251          sstables: "/0/value"
5252          active_count: "/1/value:count"
5253          pending_for_cf: "/2/value"
5254"#;
5255        let wl = super::parse_workload(yaml, &HashMap::new())
5256            .expect("workload with declarative captures should parse");
5257        let phase = wl.phases.get("probe").expect("probe phase");
5258        let op = phase
5259            .ops
5260            .iter()
5261            .find(|o| o.name == "read_state")
5262            .expect("read_state op");
5263        assert_eq!(
5264            op.captures.len(),
5265            3,
5266            "expected 3 declarative captures, got {:?}",
5267            op.captures
5268        );
5269        let by_name = |n: &str| {
5270            op.captures
5271                .iter()
5272                .find(|c| c.as_name == n)
5273                .unwrap_or_else(|| panic!("capture {n} missing"))
5274        };
5275        let sstables = by_name("sstables");
5276        assert_eq!(sstables.path.as_deref(), Some("/0/value"));
5277        assert!(!sstables.count);
5278        let active = by_name("active_count");
5279        assert_eq!(
5280            active.path.as_deref(),
5281            Some("/1/value"),
5282            ":count suffix should be stripped from stored path"
5283        );
5284        assert!(active.count, ":count suffix should set CapturePoint.count");
5285        let pending = by_name("pending_for_cf");
5286        assert_eq!(pending.path.as_deref(), Some("/2/value"));
5287        assert!(!pending.count);
5288    }
5289
5290    /// SRD-75: phase-level `poll:` block parses into
5291    /// `WorkloadPhase.poll`. Distinct from the op-level
5292    /// `poll:` field which lives on a single op and routes
5293    /// through the `PollingDispenser` wrapper.
5294    #[test]
5295    fn parses_phase_level_poll_block() {
5296        let yaml = r#"
5297scenarios:
5298  default:
5299    - ensure
5300phases:
5301  ensure:
5302    cycles: 1
5303    poll:
5304      until: "sstables == 1 && active_for_cf == 0"
5305      interval_ms: 5000
5306      timeout_ms: 14400000
5307      max_error_retries: 3
5308      metric_name: ensure_wait_s
5309    ops:
5310      read_state:
5311        stmt: "noop"
5312"#;
5313        let wl =
5314            super::parse_workload(yaml, &HashMap::new()).expect("phase-poll block should parse");
5315        let phase = wl.phases.get("ensure").expect("ensure phase");
5316        let poll = phase.poll.as_ref().expect("phase.poll Some");
5317        assert_eq!(poll.until, "sstables == 1 && active_for_cf == 0");
5318        assert_eq!(poll.interval_ms.as_deref(), Some("5000"));
5319        assert_eq!(poll.timeout_ms.as_deref(), Some("14400000"));
5320        assert_eq!(poll.max_error_retries.as_deref(), Some("3"));
5321        assert_eq!(poll.metric_name.as_deref(), Some("ensure_wait_s"));
5322    }
5323
5324    #[test]
5325    fn optimize_string_is_sugar_for_objective_block() {
5326        // SRD-86 — `optimize: <string>` is shorthand for
5327        // `optimize: { objective: <string> }` with every other field defaulted.
5328        let yaml = r#"
5329scenarios:
5330  default:
5331    - search
5332phases:
5333  search:
5334    cycles: 1
5335    for_each: "ef in 1.0 .. 5.0"
5336    optimize: |
5337      0 - (ef - 4) * (ef - 4)
5338    ops:
5339      probe:
5340        stmt: "probe ef={ef}"
5341"#;
5342        let wl = super::parse_workload(yaml, &HashMap::new())
5343            .expect("string-form optimize should parse");
5344        let opt = wl
5345            .phases
5346            .get("search")
5347            .and_then(|p| p.optimize.as_ref())
5348            .expect("optimize block present");
5349        // The whole string became the objective; the rest defaulted.
5350        assert_eq!(opt.objective.trim(), "0 - (ef - 4) * (ef - 4)");
5351        assert_eq!(opt.method, "sweep");
5352        assert!(opt.servo.is_empty());
5353
5354        // ...and it is equivalent to the explicit map form.
5355        let map_yaml = yaml.replace(
5356            "    optimize: |\n      0 - (ef - 4) * (ef - 4)\n",
5357            "    optimize: { objective: \"0 - (ef - 4) * (ef - 4)\" }\n",
5358        );
5359        let wl2 = super::parse_workload(&map_yaml, &HashMap::new())
5360            .expect("map-form optimize should parse");
5361        let opt2 = wl2
5362            .phases
5363            .get("search")
5364            .and_then(|p| p.optimize.as_ref())
5365            .unwrap();
5366        assert_eq!(opt.objective.trim(), opt2.objective.trim());
5367        assert_eq!(opt.method, opt2.method);
5368    }
5369
5370    /// SRD-75 §"Workload-load validation": phase-poll +
5371    /// concurrency > 1 is rejected at parse time. The
5372    /// predicate's evaluation depends on a serial sequence
5373    /// of capture writes; concurrent cycle execution has no
5374    /// well-defined semantic here.
5375    #[test]
5376    fn rejects_phase_poll_with_concurrency_gt_one() {
5377        let yaml = r#"
5378scenarios:
5379  default:
5380    - ensure
5381phases:
5382  ensure:
5383    cycles: 1
5384    concurrency: 4
5385    poll:
5386      until: "done == 1"
5387    ops:
5388      op1:
5389        stmt: "noop"
5390"#;
5391        let err = super::parse_workload(yaml, &HashMap::new())
5392            .expect_err("poll: + concurrency > 1 must error");
5393        assert!(
5394            err.contains("poll:") && err.contains("concurrency"),
5395            "expected error to name both poll: and concurrency; got: {err}"
5396        );
5397    }
5398
5399    /// SRD-75 §"Workload-load validation": phase-poll with
5400    /// no ops is rejected — captures are produced by op
5401    /// execution; a poll-phase with no ops has nothing to
5402    /// drive the predicate.
5403    #[test]
5404    fn rejects_phase_poll_with_no_ops() {
5405        let yaml = r#"
5406scenarios:
5407  default:
5408    - ensure
5409phases:
5410  ensure:
5411    cycles: 1
5412    poll:
5413      until: "done == 1"
5414"#;
5415        let err =
5416            super::parse_workload(yaml, &HashMap::new()).expect_err("poll: without ops must error");
5417        assert!(
5418            err.contains("poll:") && err.contains("ops"),
5419            "expected error to name poll: and ops:; got: {err}"
5420        );
5421    }
5422
5423    /// Unknown keys under `poll:` are typos waiting to
5424    /// silently default. Surface them at parse time.
5425    #[test]
5426    fn rejects_unknown_keys_under_phase_poll() {
5427        let yaml = r#"
5428scenarios:
5429  default:
5430    - ensure
5431phases:
5432  ensure:
5433    cycles: 1
5434    poll:
5435      until: "done == 1"
5436      tinerval_ms: 5000
5437    ops:
5438      op1:
5439        stmt: "noop"
5440"#;
5441        let err =
5442            super::parse_workload(yaml, &HashMap::new()).expect_err("typo under poll: must error");
5443        assert!(
5444            err.contains("tinerval_ms"),
5445            "expected error to name the offending key; got: {err}"
5446        );
5447    }
5448
5449    /// SRD-75 §"on_timeout" — accepts the documented
5450    /// closed-vocabulary values and persists them on the
5451    /// parsed phase model. Both `error` and `abort` parse
5452    /// cleanly; case is normalised at parse time.
5453    #[test]
5454    fn parses_phase_poll_on_timeout_accepts_error_and_abort() {
5455        for (input, expected) in [
5456            ("error", "error"),
5457            ("abort", "abort"),
5458            ("ABORT", "abort"),
5459            ("Error", "error"),
5460        ] {
5461            let yaml = format!(
5462                r#"
5463scenarios:
5464  default:
5465    - ensure
5466phases:
5467  ensure:
5468    cycles: 1
5469    poll:
5470      until: "done == 1"
5471      on_timeout: {input}
5472    ops:
5473      op1:
5474        stmt: "noop"
5475"#
5476            );
5477            let wl = super::parse_workload(&yaml, &HashMap::new())
5478                .expect("on_timeout value should parse");
5479            let phase = wl.phases.get("ensure").expect("ensure phase");
5480            let poll = phase.poll.as_ref().expect("phase.poll Some");
5481            assert_eq!(
5482                poll.on_timeout.as_deref(),
5483                Some(expected),
5484                "on_timeout '{input}' should normalise to '{expected}'"
5485            );
5486        }
5487    }
5488
5489    /// SRD-75 §"on_timeout" — anything outside the
5490    /// closed vocabulary is rejected at parse time.
5491    /// Typos like `aborts:` or `fail:` should fail loudly,
5492    /// not silently default to `error` and obscure the
5493    /// workload-author's actual intent.
5494    #[test]
5495    fn rejects_phase_poll_unknown_on_timeout_value() {
5496        let yaml = r#"
5497scenarios:
5498  default:
5499    - ensure
5500phases:
5501  ensure:
5502    cycles: 1
5503    poll:
5504      until: "done == 1"
5505      on_timeout: fail
5506    ops:
5507      op1:
5508        stmt: "noop"
5509"#;
5510        let err = super::parse_workload(yaml, &HashMap::new())
5511            .expect_err("unknown on_timeout value must error");
5512        assert!(
5513            err.contains("on_timeout") && err.contains("'fail'"),
5514            "expected error to name the offending value; got: {err}"
5515        );
5516    }
5517
5518    /// `poll:` requires `until:` — the predicate is the
5519    /// loop's whole purpose.
5520    #[test]
5521    fn rejects_phase_poll_without_until() {
5522        let yaml = r#"
5523scenarios:
5524  default:
5525    - ensure
5526phases:
5527  ensure:
5528    cycles: 1
5529    poll:
5530      interval_ms: 5000
5531    ops:
5532      op1:
5533        stmt: "noop"
5534"#;
5535        let err = super::parse_workload(yaml, &HashMap::new())
5536            .expect_err("poll: without until: must error");
5537        assert!(
5538            err.contains("until"),
5539            "expected error to require until:; got: {err}"
5540        );
5541    }
5542
5543    /// SRD-83 Part 5 — the `effect:`/`action:` verb vocabulary is
5544    /// closed. An unknown verb used to fall silently to the shell
5545    /// default at the trip site; it is a load error now.
5546    #[test]
5547    fn rejects_unknown_stop_condition_effect() {
5548        let yaml = r#"
5549scenarios:
5550  default:
5551    - work
5552phases:
5553  work:
5554    cycles: 10
5555    stop_when:
5556      - when: "cycles_total > 5"
5557        effect: sotp
5558    ops:
5559      op1:
5560        stmt: "noop"
5561"#;
5562        let err = super::parse_workload(yaml, &HashMap::new())
5563            .expect_err("unknown effect verb must be a load error");
5564        assert!(
5565            err.contains("sotp") && err.contains("stop|fail|abort"),
5566            "expected error to name the verb and the vocabulary; got: {err}"
5567        );
5568    }
5569
5570    /// The three declared verbs — and the `action:` alias — all load.
5571    #[test]
5572    fn accepts_declared_stop_condition_effects() {
5573        for (key, verb) in [("effect", "stop"), ("effect", "fail"), ("action", "abort")] {
5574            let yaml = format!(
5575                r#"
5576scenarios:
5577  default:
5578    - work
5579phases:
5580  work:
5581    cycles: 10
5582    stop_when:
5583      - when: "cycles_total > 5"
5584        {key}: {verb}
5585    ops:
5586      op1:
5587        stmt: "noop"
5588"#
5589            );
5590            super::parse_workload(&yaml, &HashMap::new())
5591                .unwrap_or_else(|e| panic!("{key}: {verb} must load: {e}"));
5592        }
5593    }
5594
5595    /// A path that contains `/` but doesn't begin with one is the
5596    /// ambiguous case: `0/value` is either a JSON-Pointer missing its
5597    /// leading slash (the classic transcription bug) or a member
5598    /// literally named "0/value" (which RFC 6901 spells `/0~1value`).
5599    /// Both readings are defensible, so the parser refuses rather than
5600    /// picking one — surfaced at parse time, not as silent
5601    /// capture-misses at runtime. Bare names WITHOUT a `/` are
5602    /// unambiguous and accepted; see the tests below.
5603    #[test]
5604    fn rejects_capture_path_without_leading_slash() {
5605        let yaml = r#"
5606scenarios:
5607  default:
5608    - probe
5609phases:
5610  probe:
5611    cycles: 1
5612    ops:
5613      read_state:
5614        adapter: http
5615        method: POST
5616        uri: "http://h:8778/"
5617        body: "[]"
5618        capture:
5619          bad: "0/value"
5620"#;
5621        let err = super::parse_workload(yaml, &HashMap::new())
5622            .expect_err("path without leading / must error");
5623        assert!(
5624            err.contains("`capture.bad`") && err.contains("'/'"),
5625            "expected parse error to name the offending capture and require '/'; got: {err}"
5626        );
5627    }
5628
5629    /// A bare top-level member name means exactly `/name`, and is
5630    /// normalized to it at parse time so runtime resolves one form. This
5631    /// is the spelling `verify:`'s `field:` uses, so an author moving
5632    /// between the two blocks on one op writes the same thing twice.
5633    #[test]
5634    fn accepts_bare_member_name_as_capture_path() {
5635        let yaml = r#"
5636scenarios:
5637  default:
5638    - probe
5639phases:
5640  probe:
5641    cycles: 1
5642    ops:
5643      read_state:
5644        adapter: http
5645        method: GET
5646        uri: "http://h:8778/"
5647        capture:
5648          bare: "value"
5649          pointer: "/value"
5650          rooted: ""
5651"#;
5652        let wl = super::parse_workload(yaml, &HashMap::new()).expect("both spellings must parse");
5653        let op = wl
5654            .phases
5655            .get("probe")
5656            .expect("phase")
5657            .ops
5658            .iter()
5659            .find(|o| o.name == "read_state")
5660            .expect("op");
5661        let path_of = |wire: &str| {
5662            op.captures
5663                .iter()
5664                .find(|c| c.as_name == wire)
5665                .unwrap_or_else(|| panic!("capture {wire} missing"))
5666                .path
5667                .clone()
5668                .expect("declarative capture carries a path")
5669        };
5670        assert_eq!(
5671            path_of("bare"),
5672            "/value",
5673            "a bare name normalizes to the equivalent pointer"
5674        );
5675        assert_eq!(
5676            path_of("pointer"),
5677            "/value",
5678            "and is indistinguishable from the pointer spelling downstream"
5679        );
5680        assert_eq!(
5681            path_of("rooted"),
5682            "",
5683            "empty still addresses the root document"
5684        );
5685    }
5686
5687    /// `~` is a JSON-Pointer escape introducer (`~0` = `~`, `~1` = `/`), so
5688    /// a bare name containing one is ambiguous for the same reason `/` is.
5689    #[test]
5690    fn rejects_bare_name_containing_pointer_escape_char() {
5691        let yaml = r#"
5692scenarios:
5693  default:
5694    - probe
5695phases:
5696  probe:
5697    cycles: 1
5698    ops:
5699      read_state:
5700        adapter: http
5701        method: GET
5702        uri: "http://h:8778/"
5703        capture:
5704          bad: "a~1b"
5705"#;
5706        let err = super::parse_workload(yaml, &HashMap::new())
5707            .expect_err("a bare name with '~' must error");
5708        assert!(
5709            err.contains("`capture.bad`") && err.contains("ambiguous"),
5710            "expected an ambiguity error naming the capture; got: {err}"
5711        );
5712    }
5713
5714    #[test]
5715    fn set_value_bare_is_reference() {
5716        // A bare `set:` value is a wire REFERENCE (consistent with
5717        // comprehension r-values); a string literal is the explicit
5718        // polydat-quoted form; a number/bool is a literal; a sequence is a list.
5719        fn set_source(pair: &str) -> String {
5720            let yaml = format!(
5721                "scenarios:\n  s:\n    - set: {{ {pair} }}\n      \
5722                 phases:\n        - p\nops:\n  p: \"test\"\n"
5723            );
5724            let wl = super::parse_workload(&yaml, &HashMap::new()).expect("parse");
5725            for n in &wl.scenarios["s"] {
5726                if let ScenarioNode::Bindings { source, .. } = n {
5727                    return source.trim().to_string();
5728                }
5729            }
5730            panic!("no Bindings node for `set: {{ {pair} }}`");
5731        }
5732        // bare identifier → wire reference (unquoted)
5733        assert_eq!(set_source("x: mnc"), "const x := mnc");
5734        assert_eq!(set_source("x: verbose"), "const x := verbose");
5735        // explicit polydat-quoted form → a string literal
5736        assert_eq!(set_source(r#"x: '"verbose"'"#), r#"const x := "verbose""#);
5737        // a YAML sequence → a list literal (of references)
5738        assert_eq!(set_source("x: [a, b]"), "const x := [a, b]");
5739        // numbers/bools are literals (serde distinguishes 8 from "8")
5740        assert_eq!(set_source("x: 8"), "const x := 8");
5741        assert_eq!(set_source(r#"x: "8""#), r#"const x := "8""#);
5742        assert_eq!(set_source("x: true"), "const x := true");
5743    }
5744}