1use 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
19pub fn parse_workload(
27 yaml_source: &str,
28 params: &HashMap<String, String>,
29) -> Result<Workload, String> {
30 let expanded = expand_templates(yaml_source, params);
32
33 let mut doc: JVal =
35 serde_yaml::from_str(&expanded).map_err(|e| format!("YAML parse error: {e}"))?;
36
37 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 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 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_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 let mut all_ops = Vec::new();
81
82 if let Some(blocks_val) = obj.get("blocks") {
92 parse_blocks(blocks_val, &doc_params, &doc_tags, &mut all_ops)?;
93 }
94
95 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 let (mut phases, phase_order) = parse_phases(obj.get("phases"), &doc_params, &doc_tags)?;
113
114 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 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 for op in &mut selected {
160 op.tags.insert("phase".to_string(), phase_name.clone());
161 }
162 phase.ops = selected;
163 }
164
165 let yaml_params = extract_string_map(obj.get("params"));
168
169 {
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 crate::magnitude::coerce_param_override(default_value, cli_value)
192 } else if let Some(env_name) = default_value.strip_prefix("env:") {
193 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 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 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 {
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 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 if !params.is_empty() {
272 for op in &mut all_ops {
273 for (key, value) in params {
274 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 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 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 let readouts = parse_readouts_block(obj.get("readouts"))?;
356
357 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 for sc in &stop_when {
367 sc.validate()
368 .map_err(|e| format!("top-level `stop_when`: {e}"))?;
369 }
370
371 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 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
417pub 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 workload.resolution_warnings.extend(resolution_warnings);
433 Ok(workload)
434}
435
436fn 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 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 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 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
549fn 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
590pub fn normalize_condition_clause(s: &str) -> String {
610 let trimmed = s.trim();
611 if trimmed.is_empty() {
612 return trimmed.to_string();
613 }
614 if trimmed.starts_with('{') {
618 return trimmed.to_string();
619 }
620 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 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
642fn 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
665fn 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 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 parse_scenario_nodes(val)
729 }
730 _ => parse_scenario_nodes(val),
731 }
732}
733
734fn 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
745fn 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 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 t.to_string()
769 } else if crate::bindpoints::is_bare_identifier(t) {
770 t.to_string()
773 } else {
774 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
786fn 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
802fn 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
821fn 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 if let Some(for_each_val) = obj.get("for_each").or_else(|| obj.get("for")) {
885 let for_spec: Option<ForSpec> = match for_each_val {
909 JVal::String(spec) => Some(ForSpec::Inline(spec.clone())),
910 JVal::Array(arr) => {
911 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 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 r#where: obj.get("where").and_then(|v| v.as_str()).map(String::from),
956 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 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 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 _ => 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 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 let source = match bindings_val {
1077 JVal::String(s) => s.clone(),
1078 JVal::Object(map) => {
1079 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 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 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 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
1211pub fn resolve_scenario_includes(
1230 scenarios: &mut HashMap<String, Vec<ScenarioNode>>,
1231) -> Result<(), String> {
1232 use std::collections::HashSet;
1233
1234 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 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
1345fn parse_combination_specs(val: &JVal) -> Vec<(String, String)> {
1366 match val {
1367 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 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 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
1395fn 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
1412fn 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
1432fn 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 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 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 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 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 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 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 let phase_bindings_only = extract_bindings(phase_obj.get("bindings"));
1616
1617 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 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 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 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 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 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 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 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 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 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 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 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 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 let metrics = parse_phase_metrics_field(phase_obj.get("metrics"), phase_name)
1884 .map_err(|e| format!("phase '{phase_name}' metrics: {e}"))?;
1885
1886 let dimensions = parse_dimensions_field(phase_obj.get("dimensions"), phase_name)?;
1893
1894 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 validate_cell_dimensions(phase_name, &dimensions, &inline_ops, &metrics)?;
1910
1911 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
2021fn 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 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 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 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
2103fn 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 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 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 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 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
2162fn 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 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 normalize_op_object(map, auto_name, block_name, bindings, params, tags)
2195 }
2196 _ => Ok(ParsedOp::simple(auto_name, "")),
2197 }
2198}
2199
2200fn 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 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
2251fn 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
2264fn evaluations_vocab() -> Vec<&'static str> {
2272 crate::vocab::evaluation_kinds()
2273}
2274
2275fn 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 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 let reserved = crate::vocab::op_model_fields();
2329 let op_field_names = crate::vocab::op_stmt_fields();
2330 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 "readout",
2362 "errors",
2369 "tries",
2370 "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 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 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 let mut op_params = op_params;
2433 for ap in &activity_params {
2434 if let Some(val) = map.get(*ap) {
2435 op_params.insert(ap.to_string(), val.clone());
2437 }
2438 }
2439
2440 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 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 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 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 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 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 if rate.is_some() && while_cond.is_none() && daemon.is_disabled() {
2602 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 let mut captures: Vec<crate::bindpoints::CapturePoint> = Vec::new();
2630 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 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
2749fn 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 _ => (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 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 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 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
2956fn 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 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 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 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 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
3176fn 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 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 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 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
3314fn inject_wire_into_bindings(
3319 bindings: &mut BindingsDef,
3320 name: &str,
3321 expr: &str,
3322 op_name: &str,
3323) -> Result<(), String> {
3324 let line_to_inject = format!("{name} := {expr}\n");
3330 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
3367fn 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
3381fn 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 let rest = rest.trim_start();
3391 if rest.starts_with(":=") {
3392 return true;
3393 }
3394 }
3395 }
3396 false
3397}
3398
3399fn 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
3531fn 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
3557fn 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
3594pub(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 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
3634fn 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 map.insert(k.clone(), format_jval_as_polydat_literal(v));
3661 }
3662 }
3663 map
3664}
3665
3666fn 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
3714const ACTIVITY_PARAM_KEYS: &[&str] = &["cycles", "concurrency", "rate", "errors", "error_rate_max"];
3720
3721fn 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 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 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 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 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 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 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 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 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 assert_eq!(ops[0].bindings.as_map()["id"], "Hash()");
4114 assert_eq!(ops[0].params["prepared"], true);
4116 assert_eq!(ops[0].tags["workload"], "test");
4118 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 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 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 #[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 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 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 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 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 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 assert!(workload.ops[0].bindings.is_empty());
4381 }
4382
4383 #[test]
4384 fn parse_map_bindings_still_works() {
4385 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 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 assert_eq!(workload.phases.len(), 2);
4428 assert!(workload.phases.contains_key("schema"));
4429 assert!(workload.phases.contains_key("main"));
4430
4431 assert_eq!(workload.phase_order, vec!["schema", "main"]);
4433
4434 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 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 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 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 #[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 #[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 #[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 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 assert!(op.metrics.contains_key("latency_pred"));
4622 assert!(op.metrics.contains_key("already_bound"));
4623 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 assert_eq!(m.value, "scaled");
4655 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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); 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 assert_eq!(opt.objective.trim(), "0 - (ef - 4) * (ef - 4)");
5351 assert_eq!(opt.method, "sweep");
5352 assert!(opt.servo.is_empty());
5353
5354 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 assert_eq!(set_source("x: mnc"), "const x := mnc");
5734 assert_eq!(set_source("x: verbose"), "const x := verbose");
5735 assert_eq!(set_source(r#"x: '"verbose"'"#), r#"const x := "verbose""#);
5737 assert_eq!(set_source("x: [a, b]"), "const x := [a, b]");
5739 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}