nmbrs_runtime/runner.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared run pipeline for persona binaries.
5//!
6//! Encapsulates workload parsing → Polydat compilation → activity
7//! construction → execution (single or phased).
8//!
9//! Each persona binary links its adapter crates (which register
10//! themselves via `inventory::submit!`) and calls [`run()`].
11//! The persona adds nothing but adapters and node functions —
12//! all orchestration logic lives here.
13
14use std::collections::HashMap;
15use std::sync::Arc;
16
17use crate::activity::Activity;
18use crate::adapter::{
19 find_adapter_registration, registered_adapter_params, registered_driver_names,
20};
21use crate::bindings::build_workload_root_kernel;
22use crate::opseq::SequencerType;
23use crate::synthesis::OpBuilder;
24use nmbrs_metrics::labels::Labels;
25use nmbrs_metrics::scheduler::Reporter;
26use nmbrs_workload::tags::TagFilter;
27
28/// The run-style `key=value` param vocabulary, injected by the CLI layer
29/// from its own command-spec (`nmbrs::completion::RUN_KV_PARAMS`) so there is
30/// ONE source of truth and zero hand-synced copies. `None` until installed
31/// (library/test consumers that drive the runner directly without the CLI):
32/// in that case the param-vocabulary validations below are skipped — those
33/// are a CLI-surface concern, and the binary always installs the list before
34/// any run. See [`install_known_params`] / [`known_params`].
35static KNOWN_PARAMS: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
36
37/// Install the run-style param vocabulary (the keys the CLI command-spec
38/// accepts, sans the trailing `=`). Called once at binary startup from the
39/// CLI layer, which owns the canonical list. Idempotent — first wins.
40pub fn install_known_params(keys: Vec<&'static str>) {
41 let _ = KNOWN_PARAMS.set(keys);
42}
43
44/// The installed param vocabulary, or `None` when no CLI layer registered
45/// one. Validators treat `None` as "skip the closed-vocabulary check" so a
46/// direct library/test driver isn't held to the CLI's param surface.
47/// Adapter-specific params are still discovered from inventory regardless.
48fn known_params() -> Option<&'static [&'static str]> {
49 KNOWN_PARAMS.get().map(|v| v.as_slice())
50}
51
52/// Whether `name` is an installed CLI param key. When no vocabulary is
53/// installed (library/test driver), every name is treated as known so the
54/// closed-vocabulary validations no-op rather than false-reject a workload.
55pub(crate) fn is_cli_param(name: &str) -> bool {
56 known_params().map(|p| p.contains(&name)).unwrap_or(true)
57}
58
59/// Spec-derived flag vocabulary (SRD-15 CLI substrate): the run command's
60/// DECLARED flags, split by arity, installed at binary startup exactly like
61/// [`install_known_params`]. Before this, [`parse_params`] validated argv
62/// against its own hardcoded lists ([`RECOGNIZED_BARE_FLAGS`] /
63/// [`SESSION_DIR_FLAGS`]), which had drifted from the spec — declared flags
64/// like `--no-prompt` (bool) and the space form of `--kernel-opt`
65/// / declared aliases like `--session-dir` were hard-rejected while help and
66/// completion advertised them. The hardcoded lists remain only as the
67/// library/test-driver fallback.
68static KNOWN_BARE_FLAGS: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
69static KNOWN_VALUE_FLAGS: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
70
71/// Install the run-style flag vocabulary (long forms + aliases from the CLI
72/// command-spec, split by arity). Idempotent — first wins.
73pub fn install_known_flags(bare: Vec<&'static str>, value: Vec<&'static str>) {
74 let _ = KNOWN_BARE_FLAGS.set(bare);
75 let _ = KNOWN_VALUE_FLAGS.set(value);
76}
77
78/// Recognized bare (boolean) flag: the installed spec list, or the
79/// hardcoded fallback. `--refine` is runner-internal (injected by the
80/// refine command, never user-declared) so it is always recognized.
81pub(crate) fn is_recognized_bare_flag(arg: &str) -> bool {
82 arg == "--refine"
83 || KNOWN_BARE_FLAGS
84 .get()
85 .map(|v| v.iter().any(|f| *f == arg))
86 .unwrap_or_else(|| RECOGNIZED_BARE_FLAGS.contains(&arg))
87}
88
89/// Recognized value-taking flag (space form consumes the next token):
90/// the installed spec list, or the hardcoded session-flag fallback.
91pub(crate) fn known_value_flags() -> &'static [&'static str] {
92 KNOWN_VALUE_FLAGS
93 .get()
94 .map(|v| v.as_slice())
95 .unwrap_or(SESSION_DIR_FLAGS)
96}
97
98/// A CLI flag's value, accepting `--flag=value` and `--flag value`.
99///
100/// The same two spellings [`crate::session::resolve_flag`] accepts, minus its
101/// environment-variable fallback and conflict check — for flags that are
102/// CLI-only by design. Written out because the flags declared in the command
103/// spec are advertised (in `--help` and completion) as taking a value, and a
104/// reader matching only `--flag=` would silently ignore the spelling the
105/// completer suggests.
106fn cli_flag_value(args: &[String], flag: &str) -> Option<String> {
107 let eq_prefix = format!("{flag}=");
108 let mut iter = args.iter();
109 while let Some(arg) = iter.next() {
110 if let Some(rest) = arg.strip_prefix(&eq_prefix) {
111 return Some(rest.to_string());
112 }
113 if arg == flag {
114 return iter.next().cloned();
115 }
116 }
117 None
118}
119
120/// Session-dir file holding end-of-run summary output that is
121/// routed to stdout (SRD-46 `to stdout`) but could not be written
122/// inline because a TUI owned the terminal. The post-run printer
123/// flushes it verbatim once the terminal is back in cooked mode.
124///
125/// Dot-prefixed, and deliberately NOT matching the `_summary.`
126/// artifact pattern: artifacts are the `sessiondir` destination,
127/// and their presence must never by itself imply a stdout write.
128pub const DEFERRED_STDOUT_FILE: &str = ".report_stdout.md";
129
130/// Convert the workload-model `SummaryConfig` (parsed from the
131/// `summary:` workload field or the `--summary` CLI flag) into
132/// the SQLite reporter's `ReportConfig`. Used by both the
133/// in-run summary path (workload finished, render to
134/// `summary.md`) and the standalone `nmbrs --summary` command,
135/// so both produce identical output for the same spec.
136pub fn report_config_from_summary(
137 config: &nmbrs_workload::model::SummaryConfig,
138 exec_id_filter: Option<u64>,
139) -> nmbrs_metrics::reporters::sqlite::ReportConfig {
140 nmbrs_metrics::reporters::sqlite::ReportConfig {
141 columns: config.columns.clone(),
142 row_filters: config.row_filters.clone(),
143 aggregates: config
144 .aggregates
145 .iter()
146 .map(|a| nmbrs_metrics::reporters::sqlite::ReportAggregate {
147 function: a.function.to_string(),
148 column_pattern: a.column_pattern.clone(),
149 label_key: a.label_key.clone(),
150 label_pattern: a.label_pattern.clone(),
151 group_by: a.group_by.clone(),
152 })
153 .collect(),
154 show_details: config.show_details,
155 exec_id_filter,
156 }
157}
158
159/// Try to resolve a workload name (bare or with extension) to an
160/// actual file path, searching the current directory and
161/// `./workloads/`. Returns `None` if nothing matches.
162///
163/// Exposed for shell-completion tooling. Application code should
164/// just use [`run_with_observer`] which calls this internally.
165pub fn resolve_workload_file_public(name: &str) -> Option<String> {
166 resolve_workload_file(name)
167}
168
169/// List the scenario names declared at the top level of a workload
170/// YAML file. Used by shell-completion tooling to offer
171/// `scenario=<tab>` suggestions. Returns an empty vector on any
172/// parse error — completion is best-effort, not a hard check.
173pub fn scenarios_in_workload_file(path: &str) -> Vec<String> {
174 let Ok(src) = std::fs::read_to_string(path) else {
175 return Vec::new();
176 };
177 let Ok(doc) = serde_yaml::from_str::<serde_yaml::Value>(&src) else {
178 return Vec::new();
179 };
180 let Some(scenarios) = doc.get("scenarios") else {
181 return Vec::new();
182 };
183 let Some(map) = scenarios.as_mapping() else {
184 return Vec::new();
185 };
186 map.keys()
187 .filter_map(|k| k.as_str().map(String::from))
188 .collect()
189}
190
191/// Run a workload. Adapters are discovered from link-time inventory
192/// registrations — the calling binary just needs to link the adapter
193/// crates it wants available.
194/// Execution depth: how far through the pipeline to go.
195///
196/// Ordering (shallowest → deepest):
197/// `Phase < Dispenser < Op < Cycle < Full`.
198/// `PartialOrd`/`Ord` follow this ordering so depth-gating
199/// sites can write `ctx.diag.depth >= ExecDepth::Cycle` etc.
200#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
201pub enum ExecDepth {
202 /// Compile scope-level kernels, stop before op-template
203 /// kernels / adapter `map_op` / metric instruments. No
204 /// adapters created.
205 Phase,
206 /// `dryrun=dispenser`: phase walk + every op template's
207 /// dispenser is constructed (adapter `map_op` fires; the
208 /// wrapper plan resolves and wraps; the cursor source
209 /// factory is built). NO cycles run. This is the
210 /// construction-time inspection level: catches `map_op`
211 /// failures (bad prepared statement, schema mismatch),
212 /// wrapper-resolver violations, source-factory init
213 /// errors — without paying any per-cycle cost. Distinct
214 /// from `Phase` (which builds NO dispensers) and `Op`
215 /// (which runs full cycles with the wrapper short-
216 /// circuit).
217 Dispenser,
218 /// `dryrun=op`: phase walk + dispenser construction +
219 /// per-cycle wrapper-stack execution. The DRYRUN wrapper
220 /// short-circuits the inner adapter `execute()`; every
221 /// other layer (bind-point eval, wrapper pulls, metric
222 /// timing) runs faithfully. The auto-bump in the runner
223 /// lifts `Op` to `Cycle` so the cycle loop actually
224 /// dispatches.
225 Op,
226 /// Run cycles with dry-run adapter.
227 Cycle,
228 /// Normal execution.
229 Full,
230}
231
232/// Diagnostic configuration parsed from `dryrun=` parameter.
233#[derive(Clone)]
234pub struct DiagnosticConfig {
235 /// How far to execute.
236 pub depth: ExecDepth,
237 /// Emit value-provenance / wiring view: how each named wire
238 /// was computed and where its inputs originated. Surfaced by
239 /// `dryrun=wiring`. (Was previously `dryrun=polydat` — the rename
240 /// keeps the polydat runtime an internal concept; the user-
241 /// facing concept is "wiring" between named values.)
242 pub show_wiring: bool,
243 /// Emit dimensional labels for all phases.
244 pub show_labels: bool,
245 /// Walk the post-construction component tree, render every
246 /// declared dynamic control, and exit. SRD 23 §"Enumeration:
247 /// controls are structural".
248 pub list_controls: bool,
249}
250
251impl DiagnosticConfig {
252 /// Normal execution, no diagnostics.
253 pub fn normal() -> Self {
254 Self {
255 depth: ExecDepth::Full,
256 show_wiring: false,
257 show_labels: false,
258 list_controls: false,
259 }
260 }
261
262 /// Parse from `dryrun=` value (e.g., "phase,wiring" or "cycle").
263 /// If no depth flag (phase/cycle/full) is given, defaults to `Phase`.
264 pub fn parse(spec: &str) -> Self {
265 let mut config = Self::normal();
266 let mut depth_set = false;
267 for flag in spec.split(',') {
268 match flag.trim() {
269 "phase" => {
270 config.depth = ExecDepth::Phase;
271 depth_set = true;
272 }
273 "dispenser" => {
274 config.depth = ExecDepth::Dispenser;
275 depth_set = true;
276 }
277 "op" => {
278 config.depth = ExecDepth::Op;
279 depth_set = true;
280 }
281 "cycle" => {
282 config.depth = ExecDepth::Cycle;
283 depth_set = true;
284 }
285 "full" => {
286 config.depth = ExecDepth::Full;
287 depth_set = true;
288 }
289 "wiring" => {
290 // Value-provenance view. Needs depth >= Op for
291 // kernels to exist; bump depth so a bare
292 // `dryrun=wiring` produces output instead of
293 // silently doing nothing.
294 config.show_wiring = true;
295 if !depth_set {
296 config.depth = ExecDepth::Op;
297 depth_set = true;
298 }
299 }
300 "labels" => config.show_labels = true,
301 "controls" => {
302 // Implies an early exit before any phase
303 // runs — `controls` is a discovery dump, not
304 // an execution mode.
305 config.list_controls = true;
306 config.depth = ExecDepth::Phase;
307 depth_set = true;
308 }
309 // `emit` / `silent` / `json` are the dryrun output
310 // modes — they live on `ActivityConfig::dry_run_mode`
311 // and drive the dryrun template-parameter injection
312 // that the outermost `DryRunWrapper` keys off. The
313 // runner reads them via a separate
314 // `params.get("dryrun")` lookup below and bumps
315 // depth to Cycle so the cycle path runs. The depth-
316 // config parser tolerates these tokens silently so
317 // an operator passing `dryrun=fields` doesn't see a
318 // misleading "unknown flag" warning.
319 "fields" | "silent" | "json" => {}
320 // `dryrun=kernels` is a planning-only mode — it
321 // builds the scope tree, then walks every
322 // materialised scope and prints its polydat
323 // source. Depth stays at Phase; the runner
324 // dispatches to a kernel-dump short-circuit
325 // before any phase activation.
326 "kernels" => {}
327 _ => crate::diag!(
328 crate::observer::LogLevel::Warn,
329 "warning: unknown dryrun flag '{flag}'"
330 ),
331 }
332 }
333 // Default to phase depth if no explicit depth was given
334 if !depth_set {
335 config.depth = ExecDepth::Phase;
336 }
337 config
338 }
339}
340
341/// Walk a component subtree and print every declared control
342/// (name, type, current value, scope, final flag, applier
343/// count) in a stable order. Used by `dryrun=controls` and any
344/// other discovery-style call site.
345pub fn render_controls_tree(
346 root: &std::sync::Arc<std::sync::RwLock<nmbrs_metrics::component::Component>>,
347 out: &mut dyn std::io::Write,
348) -> std::io::Result<()> {
349 use nmbrs_metrics::component::find;
350 use nmbrs_metrics::selector::Selector;
351
352 writeln!(out, "Declared dynamic controls (SRD 23):")?;
353 let all = find(root, &Selector::new());
354 let mut entries: Vec<(String, String, String, String, String, String)> = Vec::new();
355 for comp in all {
356 let guard = match comp.read() {
357 Ok(g) => g,
358 Err(_) => continue,
359 };
360 let path = guard
361 .effective_labels()
362 .iter()
363 .map(|(k, v)| format!("{k}={v}"))
364 .collect::<Vec<_>>()
365 .join(",");
366 for ctl in guard.controls().list() {
367 let scope = match ctl.branch_scope() {
368 nmbrs_metrics::controls::BranchScope::Local => "local",
369 nmbrs_metrics::controls::BranchScope::Subtree => "subtree",
370 };
371 let final_marker = match ctl.final_scope() {
372 Some(s) => format!("final@{s}"),
373 None => "-".to_string(),
374 };
375 entries.push((
376 if path.is_empty() {
377 "<root>".into()
378 } else {
379 path.clone()
380 },
381 ctl.name().to_string(),
382 ctl.value_type_name().to_string(),
383 ctl.value_string(),
384 format!(
385 "scope={scope}, {final_marker}, appliers={}",
386 ctl.applier_count()
387 ),
388 if ctl.accepts_f64_writes() {
389 "f64-writable".into()
390 } else {
391 "no-f64".into()
392 },
393 ));
394 }
395 }
396 if entries.is_empty() {
397 writeln!(out, " (no controls declared)")?;
398 return Ok(());
399 }
400 entries.sort();
401 for (path, name, ty, value, meta, write) in entries {
402 writeln!(
403 out,
404 " {path}\n {name}: {value} [{ty}] {meta} {write}",
405 )?;
406 }
407 Ok(())
408}
409
410/// Render the SRD-13d scope-elision summary for `dryrun=op`.
411/// One line per scope-tree node (DFS pre-order), showing the
412/// logical name and the materialised/elides-to mark.
413///
414/// Format follows SRD-13d §5.3:
415/// ```text
416/// scope elision summary
417/// ------------------------
418/// workload materialised=true
419/// workload.scenario.default materialised=false elides-to=workload
420/// workload.scenario.default.phase.predict materialised=true
421/// ```
422///
423/// `materialised=true` means the node owns a kernel; `false`
424/// means it elides into its nearest materialised ancestor
425/// (shown as `elides-to=<logical_name>`). Nodes whose mark
426/// is still `None` (predicate hasn't fired — should not
427/// happen post-`classify_and_mark`) are surfaced as `unknown`
428/// rather than silently skipped.
429pub fn render_scope_elision_summary(
430 tree: &crate::scope_tree::ScopeTree,
431 out: &mut dyn std::io::Write,
432) -> std::io::Result<()> {
433 let summary = crate::scope_elision::elision_summary(tree);
434 // Width of the logical-name column — 4-space gutter past
435 // the longest name (or 48ch min) so the materialised marks
436 // line up cleanly even with deeply-nested phase trees.
437 let name_width = summary
438 .iter()
439 .map(|(_, _, _, name, _)| name.len())
440 .max()
441 .unwrap_or(0)
442 .max(48);
443
444 writeln!(out, "scope elision summary")?;
445 writeln!(out, "------------------------")?;
446 for (idx, _depth, materialised, logical_name, _kind) in &summary {
447 match materialised {
448 Some(true) => {
449 writeln!(
450 out,
451 "{:<width$} materialised=true",
452 logical_name,
453 width = name_width
454 )?;
455 }
456 Some(false) => {
457 let elides_to = tree
458 .nearest_materialised(*idx)
459 .map(|p| tree.nodes[p].logical_name.clone())
460 .unwrap_or_else(|| "<unknown>".to_string());
461 writeln!(
462 out,
463 "{:<width$} materialised=false elides-to={}",
464 logical_name,
465 elides_to,
466 width = name_width
467 )?;
468 }
469 None => {
470 writeln!(
471 out,
472 "{:<width$} materialised=unknown",
473 logical_name,
474 width = name_width
475 )?;
476 }
477 }
478 }
479 Ok(())
480}
481
482pub async fn run(args: &[String]) -> Result<(), String> {
483 // Default tui=off observer — stderr with the same Info-level
484 // filter the TUI's log panel applies by default. `loglevel=`
485 // CLI param overrides; absent means Info.
486 //
487 // We need to peek at one CLI param before kicking off the
488 // full runner pipeline. Strip a leading `run` subcommand the
489 // same way `run_with_observer` does at its own param-parse
490 // step, so this peek doesn't reject perfectly valid CLI
491 // shapes (`nmbrs run loglevel=debug …`).
492 let stripped: &[String] = match args.first().map(|s| s.as_str()) {
493 Some("run") => &args[1..],
494 _ => args,
495 };
496 let cli_params = parse_params(stripped);
497 let min_level = cli_params
498 .get("loglevel")
499 .or_else(|| cli_params.get("loglevel-display"))
500 .or_else(|| cli_params.get("loglevel_display"))
501 .and_then(|s| parse_log_level(s))
502 .unwrap_or(crate::observer::LogLevel::Info);
503 let retain_level = cli_params
504 .get("loglevel-retain")
505 .or_else(|| cli_params.get("loglevel_retain"))
506 .and_then(|s| parse_log_level(s))
507 .unwrap_or(crate::observer::LogLevel::Debug);
508 crate::observer::set_retain_level(retain_level);
509 crate::observer::set_display_level(min_level);
510 run_with_observer(
511 args,
512 Arc::new(crate::observer::StderrObserver::with_min_level(min_level)),
513 )
514 .await
515}
516
517/// Parse a CLI/workload `loglevel=` value. Case-insensitive,
518/// accepts the standard names plus the abbreviations the log
519/// sink emits (`DBG` / `INF` / `WRN` / `ERR`).
520pub fn parse_log_level(s: &str) -> Option<crate::observer::LogLevel> {
521 use crate::observer::LogLevel;
522 match s.trim().to_ascii_lowercase().as_str() {
523 "trace" | "trc" => Some(LogLevel::Trace),
524 "debug" | "dbg" => Some(LogLevel::Debug),
525 "info" | "inf" => Some(LogLevel::Info),
526 "warn" | "wrn" | "warning" => Some(LogLevel::Warn),
527 "error" | "err" => Some(LogLevel::Error),
528 _ => None,
529 }
530}
531
532/// Run with a custom observer for phase lifecycle events.
533/// The TUI persona uses this to inject a TuiObserver that updates
534/// the display state instead of printing to stderr.
535pub async fn run_with_observer(
536 args: &[String],
537 observer: Arc<dyn crate::observer::RunObserver>,
538) -> Result<(), String> {
539 let args: &[String] = match args.first().map(|s| s.as_str()) {
540 Some("run") => &args[1..],
541 // Reject unknown subcommands — don't silently fall through to execution
542 Some(cmd) if !cmd.contains('=') && !cmd.ends_with(".yaml") && !cmd.ends_with(".yml") => {
543 return Err(format!(
544 "unknown command '{cmd}'. Use 'run' or pass a workload file."
545 ));
546 }
547 _ => args,
548 };
549 // Reject conflicting duplicate `key=value` params (e.g. two
550 // different `scenario=` values) before any work — otherwise the
551 // last silently wins. Errors here, before session creation.
552 detect_conflicting_duplicate_params(args)?;
553 run_impl(args, observer).await
554}
555
556/// Core runner. Diagnostic mode is controlled by `dryrun=` param.
557/// SRD-88 — build the session-level metrics services: the cadence
558/// tree + reporter, the shared `MetricsQuery`, and the metrics
559/// scheduler (whose `StopHandle` is returned). One set per session;
560/// every execution sharing the session routes through these. Reads
561/// the session component (capture root), the sqlite reporter (cadence
562/// subscription), and the observer (cadence prefs + live reporters).
563fn build_session_metrics(
564 session: &crate::session::Session,
565 sqlite_reporter: &std::sync::Arc<
566 std::sync::Mutex<Option<nmbrs_metrics::reporters::sqlite::SqliteReporter>>,
567 >,
568 observer: &Arc<dyn crate::observer::RunObserver>,
569 merged_params: &HashMap<String, String>,
570 openmetrics_url: &Option<String>,
571 args: &[String],
572 params: &HashMap<String, String>,
573) -> Result<
574 (
575 std::sync::Arc<nmbrs_metrics::cadence_reporter::CadenceReporter>,
576 nmbrs_metrics::cadence::CadenceTree,
577 std::sync::Arc<nmbrs_metrics::metrics_query::MetricsQuery>,
578 std::sync::Arc<nmbrs_metrics::scheduler::StopHandle>,
579 ),
580 String,
581> {
582 // `metrics_cadence` (effective param) may set a sub-second finest
583 // cadence + base interval; otherwise the default 1 s base + declared
584 // cadences. The base interval drives both the cadence tree and the
585 // scheduler tick below, so the finest cadence the settle detector
586 // samples and the capture pulse stay in lockstep.
587 let (base_interval, cadences) = resolve_cadence_config(merged_params, observer)?;
588 let cadence_tree = nmbrs_metrics::cadence::CadenceTree::plan_validated(
589 cadences,
590 nmbrs_metrics::cadence::DEFAULT_MAX_FAN_IN,
591 base_interval,
592 )
593 .map_err(|e| format!("cadence tree: {e}"))?;
594 let cadence_reporter = Arc::new(nmbrs_metrics::cadence_reporter::CadenceReporter::new(
595 cadence_tree.clone(),
596 ));
597 let metrics_query = Arc::new(nmbrs_metrics::metrics_query::MetricsQuery::new(
598 cadence_reporter.clone(),
599 session.component.clone(),
600 ));
601 session.set_metrics_query(metrics_query.clone());
602 nmbrs_metrics::polydat_nodes::set_global_query(metrics_query.clone());
603 // SRD-86 §"The metric-reader surface" / SRD-90 §M5 — install the live
604 // in-process metrics-access service the `metricsql_*` nodes locate via
605 // `queryapi::live_access()`. It is a HYBRID (SRD-90): the in-memory cadence
606 // tier (fine, recent, retention-bounded) over the session's durable sqlite
607 // store (coarse, the older tail), composed by union-minus-overlap — a recent
608 // windowed read is served entirely from memory (the common case never opens
609 // sqlite), and a query older than the in-memory horizon spills to the
610 // durable tail. Both tiers scope to the reading execution (mem via the
611 // read-exec hook; cold via `CurrentReadExec`), so a concurrent spill never
612 // leaks a neighbour's series. Best-effort: if the sqlite tail can't be
613 // opened (sqlite disabled, db absent), the live service is the mem tier
614 // alone — byte-identical to before this seam.
615 let mem_access = std::sync::Arc::new(nmbrs_metrics::queryapi::MetricsQueryAccess::new(
616 metrics_query.clone(),
617 ));
618 // The composed read store: the in-memory cadence tier over the durable
619 // sqlite tail (union-minus-overlap). The cold tier reads `All` executions
620 // at the SQL level — per-execution scoping is the injected `exec_id`
621 // dimensional label (below), uniform across both tiers.
622 let composed: std::sync::Arc<dyn nmbrs_metrics::queryapi::MetricAccess> = {
623 let db = session.output_dir.join("metrics.db");
624 match nmbrs_metrics::queryapi::sqlite::SqliteDataSource::open(&db) {
625 Ok(cold) => {
626 let cold = cold.with_execution_selection(
627 nmbrs_metrics::queryapi::sqlite::ExecutionSelection::All,
628 );
629 let mem_for_horizon = mem_access.clone();
630 std::sync::Arc::new(nmbrs_metrics::queryapi::HybridStore::new(vec![
631 nmbrs_metrics::queryapi::Tier::new(
632 mem_access.clone(),
633 std::sync::Arc::new(move || mem_for_horizon.earliest_ms()),
634 ),
635 nmbrs_metrics::queryapi::Tier::unbounded(std::sync::Arc::new(cold)),
636 ]))
637 }
638 Err(e) => {
639 crate::diag!(
640 crate::observer::LogLevel::Debug,
641 "metrics: hybrid sqlite tail unavailable ({e}); live reads are in-memory only"
642 );
643 mem_access.clone()
644 }
645 }
646 };
647 // SRD-89 §3b / SRD-90 §M6 — scope every live read to its execution via the
648 // `exec_id` dimensional-label matcher, applied uniformly to both tiers.
649 nmbrs_metrics::queryapi::install_live_access(std::sync::Arc::new(
650 nmbrs_metrics::queryapi::ExecScopedAccess::new(composed),
651 ));
652 // SRD-88 — teach the live-metric reader which execution is asking, so
653 // its reads scope to that execution's own series (the store is shared
654 // across concurrent executions). The hook reads nmbrs-runtime's
655 // task-local execution context; `None` outside any scope (single-run).
656 nmbrs_metrics::queryapi::install_read_exec_id_hook(|| {
657 crate::execution_context::try_current().map(|c| c.exec_id)
658 });
659 observer.on_metrics_query(metrics_query.clone());
660
661 let session_for_capture = session.component.clone();
662 let mut sched_builder = nmbrs_metrics::scheduler::SchedulerBuilder::new()
663 .base_interval(base_interval)
664 .with_cadence_reporter(cadence_reporter.clone())
665 .with_cadence_tree(cadence_tree.clone());
666
667 // SRD-42 §"SQLite — near-time persistence": subscribe the
668 // SQLite reporter via the CadenceReporter push path so slow
669 // disk can't stall the cascade. The subscription runs on its
670 // own dispatch thread with a per-subscription timeout.
671 //
672 // Preferred write cadence is 30 s — coarse enough to keep
673 // write volume low for long runs, fine enough for post-run
674 // analysis. Aligns to the nearest declared cadence ≥ 30 s
675 // (default declared set includes 30 s so this resolves exactly).
676 // Journal mode is WAL (set in SqliteReporter::new via
677 // `PRAGMA journal_mode=WAL`), so readers never block writers.
678 //
679 // Always-on: this subscription fires whenever the SQLite
680 // reporter was constructed successfully. Operators don't need
681 // to opt in with any extra param — every run produces a
682 // `metrics.db` in its session directory by default.
683 let sqlite_cadence = cadence_tree.align_to_declared(std::time::Duration::from_secs(30));
684 if let (Some(cadence), Ok(guard)) = (sqlite_cadence, sqlite_reporter.lock())
685 && guard.is_some()
686 {
687 drop(guard);
688 let sqlite_for_sub = sqlite_reporter.clone();
689 match cadence_reporter.subscribe(
690 cadence,
691 Box::new(MutexReporter(sqlite_for_sub)),
692 nmbrs_metrics::cadence_reporter::SubscriptionOpts::default(),
693 ) {
694 Ok(_) => {
695 crate::diag!(
696 crate::observer::LogLevel::Info,
697 "metrics: SQLite writes every {:?} (WAL mode)",
698 cadence
699 );
700 }
701 Err(e) => {
702 crate::diag!(
703 crate::observer::LogLevel::Warn,
704 "metrics: SQLite subscription failed: {e}"
705 );
706 }
707 }
708 }
709
710 // Single-file metrics log — opt-in, for OUTSIDE OBSERVERS. The session
711 // SQLite db is always written and stays the system of record; this only
712 // duplicates the same coalesced cadence windows into one plain JSONL file so
713 // a process can tail or ship metrics without linking SQLite, opening a db
714 // another process is writing in WAL mode, or knowing the schema. Enable via
715 // any of (a path enables it; `true` uses `<session>/metrics.jsonl`):
716 // * `--metrics-log[=<path>]` flag on the CLI
717 // * `metrics-log=<path|true>` in workload params
718 // * `NMBRS_METRICS_LOG=<path|1>` env var
719 // Tell display surfaces where the session lives, so the one that keeps a
720 // durable transcript can open it and flush what it buffered before the
721 // directory existed. Default-on: the settled half of the display is
722 // already sequenced, append-only data, so persisting it costs one file
723 // handle and answers "what did the run actually show me" after the fact.
724 observer.session_dir_ready(&session.output_dir);
725
726 // Session-level system-performance sampler (host utilization from
727 // /proc). Enabled per session with `sysmon=all` or a comma list of
728 // categories (`sysmon=cpu,io,ram,rambw,storage`); off when absent.
729 // Interval via `sysmon-interval=<seconds>` (default 5).
730 //
731 // rambw is REQUIRED-EXPLICIT once enabled: an unsupported host aborts
732 // the session with enable instructions rather than silently monitoring
733 // less than was asked for — `check_rambw_requirements` is the gate.
734 if let Some(setting) = params.get("sysmon") {
735 let selection = crate::sysmon::parse_selection(setting)?;
736 let interval = match params.get("sysmon-interval") {
737 None => std::time::Duration::from_secs(5),
738 Some(v) => match v.trim_end_matches('s').parse::<f64>() {
739 Ok(secs) if secs > 0.0 => std::time::Duration::from_secs_f64(secs),
740 _ => {
741 return Err(format!(
742 "sysmon-interval: expected seconds (e.g. `sysmon-interval=5`), got '{v}'"
743 ));
744 }
745 },
746 };
747 let membw_peak_bytes_per_s = match params.get("sysmon-membw-gbps") {
748 None => None,
749 Some(v) => match v.parse::<f64>() {
750 Ok(gbps) if gbps > 0.0 => Some(gbps * 1e9),
751 _ => {
752 return Err(format!(
753 "sysmon-membw-gbps: expected the host's peak memory bandwidth \
754 in GB/s, got '{v}'"
755 ));
756 }
757 },
758 };
759 let mut config = crate::sysmon::SysmonConfig {
760 cats: crate::sysmon::Categories::ALL,
761 interval,
762 membw_peak_bytes_per_s,
763 };
764 match selection {
765 // `any` — best-effort by stated request: enable what the host
766 // supports and ANNOUNCE each skip, one line per subsystem.
767 crate::sysmon::Selection::Any => {
768 let (cats, skipped) = crate::sysmon::resolve_any(&config);
769 config.cats = cats;
770 for reason in skipped {
771 crate::diag!(
772 crate::observer::LogLevel::Warn,
773 "sysmon: skipping a subsystem: {reason}"
774 );
775 }
776 }
777 // Named categories are REQUIRED: the rambw gate runs before
778 // spawn so an unsupported host is a clean session abort with
779 // instructions. The session directory already exists at this
780 // point (Session::start ran) — the same is true of every config
781 // error raised from here, and the next run gets its own
782 // directory.
783 crate::sysmon::Selection::Cats(cats) => {
784 config.cats = cats;
785 crate::sysmon::check_rambw_requirements(&config)?;
786 }
787 }
788 crate::sysmon::spawn(config, session.component.clone(), observer.clone())?;
789 crate::diag!(
790 crate::observer::LogLevel::Info,
791 "sysmon: sampling {setting} every {interval:?}"
792 );
793 }
794
795 let metrics_log_setting: Option<String> = args
796 .iter()
797 .find_map(|a| {
798 a.strip_prefix("--metrics-log")
799 .map(|rest| rest.strip_prefix('=').unwrap_or("true").to_string())
800 })
801 .or_else(|| params.get("metrics-log").cloned())
802 .or_else(|| std::env::var("NMBRS_METRICS_LOG").ok());
803 let metrics_log_path = match metrics_log_setting.as_deref() {
804 None => None,
805 Some("0") | Some("false") | Some("no") | Some("off") | Some("") => None,
806 Some("1") | Some("true") | Some("yes") | Some("on") => {
807 Some(session.output_dir.join("metrics.jsonl"))
808 }
809 Some(explicit) => {
810 // An explicit path from the `--metrics-log=` FLAG is the
811 // operator's own and is taken verbatim. The `metrics-log`
812 // PARAM (a workload file can set it) and the env form are
813 // confined to the session directory: a shared workload
814 // must never be able to name a write target elsewhere.
815 let from_flag = args.iter().any(|a| a.starts_with("--metrics-log"));
816 if from_flag {
817 Some(std::path::PathBuf::from(explicit))
818 } else {
819 Some(crate::session::confine_to_dir(&session.output_dir, explicit).map_err(
820 |e| format!("metrics-log: {e} (an explicit path outside the session directory is only accepted from the --metrics-log= flag)"),
821 )?)
822 }
823 }
824 };
825 if let Some(log_path) = metrics_log_path {
826 match nmbrs_metrics::reporters::metrics_log::MetricsLogReporter::new(&log_path) {
827 Ok(reporter) => {
828 // Same cadence as the database, deliberately: the log is an
829 // alternative READ of what the db holds, so matching granularity
830 // is what makes the two comparable.
831 if let Some(cadence) =
832 cadence_tree.align_to_declared(std::time::Duration::from_secs(30))
833 {
834 match cadence_reporter.subscribe(
835 cadence,
836 Box::new(reporter),
837 nmbrs_metrics::cadence_reporter::SubscriptionOpts::default(),
838 ) {
839 Ok(_) => {
840 crate::diag!(
841 crate::observer::LogLevel::Info,
842 "metrics: JSONL log every {:?} -> {} (session db unaffected)",
843 cadence,
844 log_path.display()
845 );
846 }
847 Err(e) => {
848 crate::diag!(
849 crate::observer::LogLevel::Warn,
850 "metrics: metrics log subscribe failed: {e}"
851 );
852 }
853 }
854 }
855 }
856 Err(e) => {
857 crate::diag!(
858 crate::observer::LogLevel::Warn,
859 "metrics: metrics log disabled: {e}"
860 );
861 }
862 }
863 }
864
865 // Per-instance JSONL snapshot reporter — opt-in. Writes
866 // one file per (metric, label-tuple) in `<session>/metrics/`,
867 // one JSON record appended per snapshot tick. Useful when
868 // you want a per-instance trace to tail / awk / import
869 // into a notebook without opening the SQLite db, but most
870 // sessions never read these files and the SQLite db
871 // already carries the same data. Enable via any of:
872 // * `--per-instance-metrics` flag on the CLI
873 // * `per-instance-metrics=true` in workload params
874 // * `NMBRS_PER_INSTANCE_METRICS=1` env var
875 let per_instance_enabled = args.iter().any(|a| a == "--per-instance-metrics")
876 || params
877 .get("per-instance-metrics")
878 .map(|s| matches!(s.as_str(), "1" | "true" | "yes" | "on"))
879 .unwrap_or(false)
880 || std::env::var("NMBRS_PER_INSTANCE_METRICS")
881 .ok()
882 .map(|s| matches!(s.as_str(), "1" | "true" | "yes" | "on"))
883 .unwrap_or(false);
884 if per_instance_enabled {
885 let per_instance_dir = session.output_dir.join("metrics");
886 match nmbrs_metrics::reporters::per_instance::PerInstanceReporter::new(&per_instance_dir) {
887 Ok(reporter) => {
888 if let Some(cadence) =
889 cadence_tree.align_to_declared(std::time::Duration::from_secs(30))
890 {
891 match cadence_reporter.subscribe(
892 cadence,
893 Box::new(reporter),
894 nmbrs_metrics::cadence_reporter::SubscriptionOpts::default(),
895 ) {
896 Ok(_) => {
897 crate::diag!(
898 crate::observer::LogLevel::Info,
899 "metrics: per-instance JSONL writes every {:?} into {}",
900 cadence,
901 per_instance_dir.display()
902 );
903 }
904 Err(e) => {
905 crate::diag!(
906 crate::observer::LogLevel::Warn,
907 "metrics: per-instance subscription failed: {e}"
908 );
909 }
910 }
911 }
912 }
913 Err(e) => {
914 crate::diag!(
915 crate::observer::LogLevel::Warn,
916 "metrics: per-instance reporter disabled ({}): {e}",
917 per_instance_dir.display()
918 );
919 }
920 }
921 }
922
923 // Same routing for the VictoriaMetrics / Prometheus push reporter
924 // when `--report-to` (or equivalent param) was provided.
925 // `jobname` / `instance` params match the nosqlbench-java
926 // `PromPushReporterComponent` convention; they're substituted
927 // into any `JOBNAME` / `INSTANCE` placeholders in the URL.
928 if let Some(url) = openmetrics_url.as_ref()
929 && let Some(cadence) = cadence_tree.align_to_declared(std::time::Duration::from_secs(10))
930 {
931 let jobname = merged_params
932 .get("jobname")
933 .cloned()
934 .unwrap_or_else(|| "default".to_string());
935 let instance = merged_params
936 .get("instance")
937 .cloned()
938 .unwrap_or_else(|| "default".to_string());
939 let mut vm =
940 match nmbrs_metrics::reporters::victoriametrics::VictoriaMetricsReporter::from_spec(url)
941 {
942 Ok(r) => r,
943 Err(_) => {
944 nmbrs_metrics::reporters::victoriametrics::VictoriaMetricsReporter::new(url)
945 }
946 };
947 vm = vm.with_jobname(jobname).with_instance(instance);
948 if let Some(token_path) = merged_params.get("prompush_apikeyfile") {
949 match vm.with_bearer_token_file(token_path) {
950 Ok(r) => vm = r,
951 Err(e) => {
952 crate::diag!(
953 crate::observer::LogLevel::Warn,
954 "prompush_apikeyfile '{token_path}': {e}"
955 );
956 vm = nmbrs_metrics::reporters::victoriametrics
957 ::VictoriaMetricsReporter::from_spec(url)
958 .unwrap_or_else(|_| nmbrs_metrics::reporters::victoriametrics
959 ::VictoriaMetricsReporter::new(url))
960 .with_jobname(
961 merged_params.get("jobname").cloned()
962 .unwrap_or_else(|| "default".to_string()),
963 )
964 .with_instance(
965 merged_params.get("instance").cloned()
966 .unwrap_or_else(|| "default".to_string()),
967 );
968 }
969 }
970 }
971 let _ = cadence_reporter.subscribe(
972 cadence,
973 Box::new(vm),
974 nmbrs_metrics::cadence_reporter::SubscriptionOpts::default(),
975 );
976 }
977
978 // Register the observer's reporters at their requested cadences
979 // on the scheduler tree (base-interval live-frame forwarding for
980 // sparklines / live histogram).
981 for (interval, reporter) in observer.reporters() {
982 sched_builder = sched_builder.add_reporter(interval, BoxedReporter(reporter));
983 }
984
985 let scheduler = sched_builder.build(Box::new(move || {
986 nmbrs_metrics::component::capture_tree(&session_for_capture, base_interval)
987 }));
988 let stop_handle = Arc::new(scheduler.start());
989
990 // Install the session-wide Ctrl-C handler. First SIGINT
991 // requests cooperative shutdown (fibers exit at cycle
992 // boundary, profiler + cadence reporter flush in normal
993 // teardown order); second SIGINT force-exits. Idempotent —
994 // safe to call again on retry / reentry paths.
995 crate::session_signals::install_signal_handler();
996
997 // SRD-93 M7 — SIGQUIT inventory: log every Running component's
998 // labels + instrument count, so "why is nothing moving" is
999 // answerable from outside even when the run is wedged. Runs on
1000 // the signal-dispatch thread; read-only over the tree.
1001 let dump_root = session.component.clone();
1002 crate::session_signals::set_diag_dump_hook(Box::new(move || {
1003 fn walk(
1004 node: &std::sync::Arc<std::sync::RwLock<nmbrs_metrics::component::Component>>,
1005 out: &mut Vec<String>,
1006 ) {
1007 let g = node.read().unwrap_or_else(|e| e.into_inner());
1008 if g.state() == nmbrs_metrics::component::ComponentState::Running {
1009 out.push(format!(
1010 "{} ({} instrument(s))",
1011 g.effective_labels(),
1012 g.instruments().len(),
1013 ));
1014 }
1015 for child in g.children() {
1016 walk(child, out);
1017 }
1018 }
1019 let mut lines = Vec::new();
1020 walk(&dump_root, &mut lines);
1021 crate::diag!(
1022 crate::observer::LogLevel::Info,
1023 "session: SIGQUIT inventory — {} running component(s)",
1024 lines.len()
1025 );
1026 for line in lines {
1027 crate::diag!(crate::observer::LogLevel::Info, " {line}");
1028 }
1029 }));
1030
1031 Ok((cadence_reporter, cadence_tree, metrics_query, stop_handle))
1032}
1033
1034/// SRD-88 — the shared, session-tier context, created ONCE per session
1035/// (`SessionHost::setup`); every execution sharing the session runs
1036/// against it. Holds the session (dir / id / `session` component), the
1037/// durable sqlite store + shutdown guard, the session-aligned metrics
1038/// services and the session-tier profiler. Per-execution work +
1039/// workload load happens in `run_execution`.
1040struct SessionHost {
1041 session: crate::session::Session,
1042 sqlite_reporter:
1043 std::sync::Arc<std::sync::Mutex<Option<nmbrs_metrics::reporters::sqlite::SqliteReporter>>>,
1044 cadence_reporter: std::sync::Arc<nmbrs_metrics::cadence_reporter::CadenceReporter>,
1045 #[allow(dead_code)]
1046 cadence_tree: nmbrs_metrics::cadence::CadenceTree,
1047 #[allow(dead_code)]
1048 metrics_query: std::sync::Arc<nmbrs_metrics::metrics_query::MetricsQuery>,
1049 stop_handle: std::sync::Arc<nmbrs_metrics::scheduler::StopHandle>,
1050 refine_plan: Option<Arc<crate::refine_plan::RefinePlan>>,
1051 resume_target: Option<std::path::PathBuf>,
1052 refine_requested: bool,
1053 refine_scope: Option<String>,
1054 /// SRD-106 — the session id the `stick_session` rung
1055 /// re-attached to, when it engaged. Drives the D4
1056 /// `session_notice` announcement (first notable event of
1057 /// the run) and nothing else.
1058 stick_reattached: Option<String>,
1059 profiler: Option<crate::profiler::ProfileGuard>,
1060 sqlite_guard: nmbrs_metrics::reporters::sqlite::SqliteShutdownGuard,
1061 /// SRD-88 — the checkpoint writer is SESSION-tier: one per session
1062 /// (`<session>/checkpoint.jsonl` + its single resume lock), shared
1063 /// by every execution. Per-execution resume *plans* are still
1064 /// derived per execution in `run_execution` from `saved_doc` + that
1065 /// execution's pre-map; only the writer/lock is shared (so N
1066 /// concurrent in-process executions don't fight over the lock).
1067 checkpoint_writer: std::sync::Arc<crate::checkpoint::CheckpointWriter>,
1068 saved_doc: Option<crate::checkpoint::Checkpoint>,
1069}
1070
1071impl SessionHost {
1072 /// Build the shared session-tier context. Workload-INDEPENDENT:
1073 /// session identity = `scenario=` param; metrics services +
1074 /// profiler read CLI `params`, not workload-merged params.
1075 fn setup(
1076 args: &[String],
1077 observer: Arc<dyn crate::observer::RunObserver>,
1078 ) -> Result<SessionHost, String> {
1079 // Set global observer so all code can log through it
1080 crate::observer::set_global_observer(observer.clone());
1081
1082 // Wire error handler logging through the observer.
1083 // Per-cycle error lines fire from inside an executing
1084 // phase, so prefix with the running phase's scope-depth
1085 // indent — the same alignment the polling-op messages,
1086 // phase startup/complete lines, and DONE summary use.
1087 // The errorhandler crate stays scope-agnostic; the
1088 // bridging closure here is what makes the output
1089 // hierarchic in tui=terminal mode.
1090 //
1091 // Level = Debug so the per-cycle warns land in the
1092 // session log (retain_level defaults to Debug) but
1093 // don't spam the realtime status surface (display_level
1094 // defaults to Info). The structured form of each
1095 // per-cycle error is collected into the phase's
1096 // PhaseErrorDetail buffer and rendered in one block by
1097 // the `error_readout` builtin at PhaseEnd — the
1098 // operator sees the normative ✓/✗ phase line first,
1099 // then the error block, instead of N noisy lines
1100 // interleaved with progress as the phase runs.
1101 nmbrs_errorhandler::handlers::set_log_fn(|msg| {
1102 let indent = crate::scene_tree::running_phase_indent();
1103 crate::observer::log(crate::observer::LogLevel::Debug, &format!("{indent}{msg}"));
1104 });
1105
1106 // Route nmbrs-metrics diagnostic warnings through the observer so
1107 // reporter write failures, histogram-record errors, etc. don't
1108 // slip past the TUI as raw stderr prints. Indent matches the
1109 // running phase the same way the errorhandler bridge above does
1110 // — these emits fire mid-phase from the metrics pipeline.
1111 nmbrs_metrics::diag::set_warn_fn(|msg| {
1112 let indent = crate::scene_tree::running_phase_indent();
1113 crate::observer::log(crate::observer::LogLevel::Warn, &format!("{indent}{msg}"));
1114 });
1115 nmbrs_metrics::diag::set_info_fn(|msg| {
1116 let indent = crate::scene_tree::running_phase_indent();
1117 crate::observer::log(crate::observer::LogLevel::Info, &format!("{indent}{msg}"));
1118 });
1119
1120 // Audit sink is installed after session creation
1121 // below (so it can target `<session>/audit.log`).
1122 // Until then, the crate-default eprintln fallback
1123 // is in effect for any audit::log calls fired
1124 // during init. Workload-emitted lines fire mid-phase
1125 // (well after the install), so they don't reach
1126 // stderr.
1127
1128 let args = normalize_args(args);
1129 let params = parse_params(&args);
1130 // The run's effective params (workload `params:` overlaid by CLI, CLI
1131 // wins) — the consolidated set the session-tier services read, so a
1132 // setting like `metrics_cadence` / `jobname` / `per-instance-metrics`
1133 // works whether declared in the workload or passed on the command line.
1134 // Operational reads below (resume, session identity) stay on the raw CLI
1135 // `params` — they are not workload-declarable.
1136 let eff_params = effective_params(&args);
1137 // `scenario_for_session` is recomputed below from the refine block's
1138 // params (the session-dir name); `openmetrics_url` feeds the
1139 // session metrics services.
1140 let openmetrics_url: Option<String> = cli_flag_value(&args[..], "--report-openmetrics-to")
1141 .or_else(|| {
1142 args.iter()
1143 .find_map(|a| a.strip_prefix("report-openmetrics-to="))
1144 .map(|s| s.to_string())
1145 });
1146 // SRD-106 Part 3 — the `stick_session` resolution rung: the
1147 // LOWEST-precedence session rung. A workload may declare that
1148 // iterative re-attachment is its intended usage; when the
1149 // operator expressed no session intent of their own, a run
1150 // re-attaches to `sessions/latest` and layers a new execution
1151 // per SRD-77 — mechanically the refine path, so the D2
1152 // provenance gates govern what re-runs. Defeated by ANY
1153 // explicit session selection (`--session*` name/path/reuse),
1154 // any re-attachment flag (`resume=` / `--resume-latest` /
1155 // `--refine`), the `--session new` bare token, and dryruns
1156 // (resume-inert per SRD-44). CLI `stick_session=true|false`
1157 // overrides the workload's declaration.
1158 let stick_reattach: Option<std::path::PathBuf> = {
1159 let cli_stick: Option<bool> =
1160 params.get("stick_session").map(|v| v == "true" || v == "1");
1161 let stick_on =
1162 cli_stick.unwrap_or_else(|| peek_stick_session(¶ms, &args).unwrap_or(false));
1163 let reattach_expressed = args
1164 .iter()
1165 .any(|a| a == "--refine" || a == "--resume-latest")
1166 || params.contains_key("resume")
1167 || params.contains_key("resume_latest");
1168 if !stick_on || reattach_expressed || crate::session::args_request_dryrun(&args) {
1169 None
1170 } else {
1171 let spec = crate::session::resolve_session_dir(&args);
1172 let operator_selected = !spec.is_empty()
1173 || spec.force_new
1174 || spec.reuse != crate::session::SessionReuse::Error;
1175 if operator_selected {
1176 None
1177 } else {
1178 // `sessions/latest` must resolve to a prior session
1179 // with a checkpoint — anything less means there is
1180 // nothing valid to re-attach to, and the run falls
1181 // through to today's fresh-session default silently
1182 // (no announcement for a no-op).
1183 let latest = crate::session::default_sessions_root().join("latest");
1184 std::fs::read_link(&latest)
1185 .ok()
1186 .map(|t| {
1187 if t.is_absolute() {
1188 t
1189 } else {
1190 crate::session::default_sessions_root().join(t)
1191 }
1192 })
1193 .filter(|d| d.join("checkpoint.jsonl").is_file())
1194 }
1195 }
1196 };
1197 let stick_reattached: Option<String> = stick_reattach
1198 .as_ref()
1199 .and_then(|d| d.file_name())
1200 .and_then(|s| s.to_str())
1201 .map(String::from);
1202 if let Some(id) = stick_reattached.as_deref() {
1203 crate::diag!(
1204 crate::observer::LogLevel::Info,
1205 "stick_session: re-attaching to {id} — pass `--session new` to start fresh"
1206 );
1207 }
1208
1209 let resume_target: Option<std::path::PathBuf> = {
1210 let explicit = params.get("resume").filter(|s| !s.is_empty()).map(|s| {
1211 let p = std::path::PathBuf::from(s);
1212 if p.is_file() {
1213 p
1214 } else if p.is_dir() {
1215 p.join("checkpoint.jsonl")
1216 } else {
1217 crate::session::default_sessions_root()
1218 .join(s)
1219 .join("checkpoint.jsonl")
1220 }
1221 });
1222 let resume_latest = params
1223 .get("resume_latest")
1224 .map(|s| s != "false" && s != "0")
1225 .unwrap_or(false)
1226 || args.iter().any(|a| a == "--resume-latest")
1227 || stick_reattach.is_some();
1228 if resume_latest {
1229 // Resolve the symlink to a concrete session dir
1230 // *now* — once `Session::new` runs the symlink will
1231 // be repointed at the new session.
1232 let latest = crate::session::default_sessions_root().join("latest");
1233 let resolved = std::fs::read_link(&latest)
1234 .ok()
1235 .map(|target| {
1236 if target.is_absolute() {
1237 target
1238 } else {
1239 crate::session::default_sessions_root().join(target)
1240 }
1241 })
1242 .map(|d| d.join("checkpoint.jsonl"));
1243 explicit.or(resolved)
1244 } else {
1245 explicit
1246 }
1247 };
1248
1249 // SRD-77 refine: the `nmbrs refine` verb injects `--refine`
1250 // into argv before delegating to the runner. Detected here
1251 // so we can load the session's prior `phase_outcomes` and
1252 // build a skip plan + bump `exec_id` before the session is
1253 // re-attached. Implies `--resume-latest` semantics for
1254 // session dir resolution (the resolver at line 950+ already
1255 // produces the right `resume_target` when `--resume-latest`
1256 // was passed alongside `--refine` by `refine_command`).
1257 // SRD-106: an engaged `stick_session` re-attach IS a refine
1258 // layering — new execution, prior outcomes as provenance.
1259 let refine_requested = args.iter().any(|a| a == "--refine") || stick_reattach.is_some();
1260
1261 // Session: root context for this run. Creates logs/{scenario}_{timestamp}/
1262 // for fresh runs; reuses the prior session dir when resuming
1263 // so the metrics.db is appended-to in-place per SRD-44
1264 // §"Wholesale metrics-purge".
1265 let scenario_for_session = params
1266 .get("scenario")
1267 .map(|s| s.as_str())
1268 .unwrap_or("default");
1269 // SRD-77 — refine_plan is populated only when:
1270 // 1. `--refine` was passed (refine verb is in flight)
1271 // 2. The resume_target resolves to an existing session dir
1272 // Computed BEFORE Session construction so the plan's
1273 // `next_exec_id` can flow into `Session::refine`.
1274 // SRD-77 refine scope. Default `missing` (skip phases with
1275 // a prior completed outcome) when `--refine` is set without
1276 // an explicit `scope=`. `scope=all` builds the plan for
1277 // exec_id bumping + session re-attach but leaves the skip
1278 // set empty, so every phase runs and new outcomes overwrite
1279 // the prior ones (the cardinal history stays — prior rows
1280 // keep their old exec_id, new rows land under the bumped
1281 // exec_id). `scope=changed` requires `phase_hash` storage
1282 // on PhaseOutcome (follow-up push) and is rejected here
1283 // with a "not yet implemented" diag so the operator isn't
1284 // silently dropped to `missing` semantics.
1285 let refine_scope: Option<&str> = params
1286 .get("scope")
1287 .map(|s| s.as_str())
1288 .filter(|_| refine_requested);
1289 let refine_plan: Option<Arc<crate::refine_plan::RefinePlan>> = if refine_requested {
1290 resume_target
1291 .as_ref()
1292 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
1293 .and_then(|prior_dir| {
1294 if !prior_dir.exists() {
1295 crate::diag!(
1296 crate::observer::LogLevel::Warn,
1297 "refine: prior session dir not found ({}); \
1298 running every phase as if this were a fresh `nmbrs run`",
1299 prior_dir.display()
1300 );
1301 return None;
1302 }
1303 let mut plan =
1304 crate::refine_plan::RefinePlan::load_from_session_dir(&prior_dir);
1305 if plan.is_none() {
1306 crate::diag!(
1307 crate::observer::LogLevel::Warn,
1308 "refine: no readable phase_outcomes in {}; \
1309 running every phase as if this were a fresh `nmbrs run`",
1310 prior_dir.display()
1311 );
1312 }
1313 if let Some(p) = plan.as_mut() {
1314 p.scope = match refine_scope {
1315 Some("all") => {
1316 crate::diag!(
1317 crate::observer::LogLevel::Info,
1318 "refine: scope=all — every phase will run \
1319 under exec_id={}",
1320 p.next_exec_id
1321 );
1322 crate::refine_plan::RefineScope::All
1323 }
1324 Some("changed") => {
1325 crate::diag!(
1326 crate::observer::LogLevel::Info,
1327 "refine: scope=changed — comparing each \
1328 phase's program hash against the prior \
1329 outcome; unchanged phases skip, changed \
1330 phases re-run under exec_id={}",
1331 p.next_exec_id
1332 );
1333 crate::refine_plan::RefineScope::Changed
1334 }
1335 _ => crate::refine_plan::RefineScope::Missing,
1336 };
1337 }
1338 plan.map(Arc::new)
1339 })
1340 } else {
1341 None
1342 };
1343 // SRD-77 — `--on-removed=` policy. When refine attaches to
1344 // a session whose prior outcomes name phases the current
1345 // workload no longer declares, the default behavior is
1346 // ERROR (refuse to proceed) — silently keeping orphan
1347 // outcomes hides intent, silently dropping them loses
1348 // data. `--on-removed=keep` retains them (no work);
1349 // `--on-removed=drop` is reserved for a future push that
1350 // wires the deletion + interactive confirm.
1351 //
1352 // The check compares the prior `phase_name` set against
1353 // the current workload's `phases:` map keys. Sweep-cell
1354 // variants (same name, different labels) are aggregated by
1355 // name here — a missing name covers every prior cell of
1356 // it. Tighter (name+labels) granularity is a follow-up
1357 // when label-set comparison becomes load-bearing.
1358 // (`on_removed_policy` is consulted per-execution, in `run_execution`.)
1359 // Build the session (the shared, session-tier container). SRD-88:
1360 // the session carries only `session=<id>`; the execution's
1361 // identity (`exec_id`, `workload`) is declared one tier down, on
1362 // its own component (below). [[HOST:session]]
1363 let session = match (refine_plan.as_ref(), resume_target.as_ref()) {
1364 (Some(plan), Some(p)) if p.exists() => {
1365 let prior_dir = p
1366 .parent()
1367 .map(|d| d.to_path_buf())
1368 .unwrap_or_else(crate::session::latest_session_dir);
1369 crate::diag!(
1370 crate::observer::LogLevel::Info,
1371 "refine: attached to session {}; \
1372 prior outcomes={}, completed phases to skip={}, \
1373 next exec_id={}",
1374 prior_dir.display(),
1375 plan.prior_outcomes_seen,
1376 plan.completed.len(),
1377 plan.next_exec_id
1378 );
1379 crate::session::Session::reattach(prior_dir, scenario_for_session)
1380 }
1381 (_, Some(p)) if p.exists() => {
1382 let prior_dir = p
1383 .parent()
1384 .map(|d| d.to_path_buf())
1385 .unwrap_or_else(crate::session::latest_session_dir);
1386 crate::session::Session::reattach(prior_dir, scenario_for_session)
1387 }
1388 _ => crate::session::Session::new_with_args(scenario_for_session, &args),
1389 };
1390 let session_log_path = session.output_dir.join("session.log");
1391 if let Err(e) = crate::observer::set_log_file(&session_log_path) {
1392 crate::diag!(
1393 crate::observer::LogLevel::Warn,
1394 "warning: failed to open session log {}: {e}",
1395 session_log_path.display()
1396 );
1397 }
1398
1399 // `--trace=<spec>` (repeatable). Collected from raw `args`
1400 // because parse_params is HashMap-keyed and would collapse
1401 // repeated flags. See trace_router for spec grammar.
1402 let trace_specs = collect_repeated_flag(&args, "trace");
1403 match crate::trace_router::init(&trace_specs, &session.output_dir) {
1404 Ok(0) => {} // no --trace specified, router stays empty
1405 Ok(n) => crate::diag!(
1406 crate::observer::LogLevel::Info,
1407 "trace router: {n} route(s) configured"
1408 ),
1409 Err(e) => crate::diag!(
1410 crate::observer::LogLevel::Warn,
1411 "trace router init failed: {e}"
1412 ),
1413 }
1414
1415 crate::diag!(
1416 crate::observer::LogLevel::Info,
1417 "session: {} ({})",
1418 session.id,
1419 session.output_dir.display()
1420 );
1421
1422 // Polydat library audit channel: route polydat's
1423 // `audit::log/info/warn/...` calls through this
1424 // process's observer so they land in `session.log`
1425 // alongside every other diagnostic line, with a
1426 // `[lib]` subsystem tag so the operator can filter them
1427 // out if they're noisy. Replaces the standalone
1428 // `<session>/audit.log` file — same content, one fewer
1429 // place to look.
1430 // SRD-82 §"Panic reporting: one full render" — the runtime's
1431 // fiber/op catchers render eval-panic diagnostics in full via
1432 // the phase error list, so the polydat hook degrades to a
1433 // one-line notice instead of the full body + backtrace hint.
1434 polydat::set_panic_reporting_downstream(true);
1435
1436 polydat::audit::set_log_fn(|level, msg| {
1437 use polydat::audit::LogLevel as AuditLevel;
1438 let mapped = match level {
1439 AuditLevel::Trace | AuditLevel::Debug => crate::observer::LogLevel::Debug,
1440 AuditLevel::Info => crate::observer::LogLevel::Info,
1441 AuditLevel::Warn => crate::observer::LogLevel::Warn,
1442 AuditLevel::Error => crate::observer::LogLevel::Error,
1443 };
1444 crate::observer::log(mapped, &format!("[lib] {msg}"));
1445 });
1446
1447 // SQLite metrics in session directory. SRD-88: creating the
1448 // reporter (connection + schema + the session-INVARIANT
1449 // `session` metadata key) is session-tier — one per session,
1450 // shared by every execution. The per-execution metadata
1451 // (workload / scenario / params / the SRD-77 `executions` row)
1452 // is written separately below, per execution, so N concurrent
1453 // executions each record their own without clobbering.
1454 let sqlite_path = session.metrics_path();
1455 let sqlite_reporter = nmbrs_metrics::reporters::sqlite::SqliteReporter::new(&sqlite_path)
1456 .map(|mut r| {
1457 r.set_metadata("session", &session.id);
1458 crate::diag!(
1459 crate::observer::LogLevel::Info,
1460 "metrics: {}",
1461 sqlite_path.display()
1462 );
1463 r
1464 })
1465 .map_err(|e| {
1466 crate::diag!(
1467 crate::observer::LogLevel::Warn,
1468 "warning: SQLite metrics disabled: {e}"
1469 )
1470 })
1471 .ok();
1472 let sqlite_reporter = std::sync::Arc::new(std::sync::Mutex::new(sqlite_reporter));
1473
1474 // RAII shutdown guard — runs `consolidate_wal` at session
1475 // end via Drop. Reliable across every Rust unwind path:
1476 // normal completion, error `?` propagation, first-Ctrl-C
1477 // → stop flag → runner unwind. The only path that skips
1478 // it is `std::process::exit` (second Ctrl-C force-exit),
1479 // which is the operator's declared "I don't want to
1480 // wait" escape hatch. The guard MUST live until after
1481 // every reporter has finished writing — bind it here at
1482 // the top of the run-impl block so it drops in
1483 // last-created / first-dropped order relative to local
1484 // variables; the explicit `_` binding pins its lifetime
1485 // to the function scope (otherwise the temporary would
1486 // drop immediately).
1487 let _sqlite_shutdown_guard =
1488 nmbrs_metrics::reporters::sqlite::SqliteShutdownGuard::new(sqlite_reporter.clone());
1489
1490 // Periodic WAL checkpoint so concurrent read-only
1491 // tooling (`nmbrs report` against a live session,
1492 // ad-hoc `sqlite3 metrics.db` inspection, the realtime
1493 // metricsql preview) sees committed writes without
1494 // waiting for session end. SQLite's WAL holds frames
1495 // until either:
1496 // 1. `wal_autocheckpoint` (page-count threshold,
1497 // default 1000 pages) fires on a writer, OR
1498 // 2. an explicit `PRAGMA wal_checkpoint(...)` runs.
1499 //
1500 // Under bursty workloads (a tight rampup followed by a
1501 // long synchronous wait — exactly the SRD-75
1502 // ensure_compacted shape) writers can stall under the
1503 // autocheckpoint threshold for many minutes, during
1504 // which readers see stale data. A 60-second background
1505 // task running `PRAGMA wal_checkpoint(PASSIVE)` bounds
1506 // the staleness without blocking writers.
1507 //
1508 // PASSIVE mode is the cheap variant: it merges all
1509 // currently-committed WAL frames into the main `.db`
1510 // without truncating the WAL file or pausing writers.
1511 // The tokio task runs for the runtime's lifetime and is
1512 // cancelled on shutdown; the final `consolidate_wal`
1513 // (TRUNCATE flavour) at session end produces the
1514 // archival "no -wal sidecar" form.
1515 {
1516 let reporter = sqlite_reporter.clone();
1517 tokio::spawn(async move {
1518 let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
1519 // First tick is immediate; skip it so the
1520 // post-session-start state has a chance to
1521 // settle before the first checkpoint fires.
1522 interval.tick().await;
1523 loop {
1524 interval.tick().await;
1525 if let Ok(g) = reporter.lock()
1526 && let Some(r) = g.as_ref()
1527 {
1528 r.passive_checkpoint();
1529 }
1530 }
1531 });
1532 }
1533
1534 // SRD-88 — session-tier metrics services, configured from CLI params.
1535 let (cadence_reporter, cadence_tree, metrics_query, stop_handle) = build_session_metrics(
1536 &session,
1537 &sqlite_reporter,
1538 &observer,
1539 &eff_params,
1540 &openmetrics_url,
1541 &args,
1542 &eff_params,
1543 )?;
1544 crate::session_signals::install_signal_handler();
1545 let _profiler =
1546 crate::profiler::ProfileGuard::maybe_start(¶ms, Some(&session.output_dir));
1547
1548 // SRD-88 — the SESSION-tier checkpoint writer (one per session;
1549 // holds the single resume lock) + the resume doc. Executions share
1550 // the writer and each derives its own resume plan from `saved_doc`.
1551 let checkpoint_path = session.output_dir.join("checkpoint.jsonl");
1552 let saved_doc = match resume_target.as_ref() {
1553 Some(p) => match crate::checkpoint::storage::read(p) {
1554 Ok(Some(doc)) => Some(doc),
1555 Ok(None) => {
1556 crate::diag!(
1557 crate::observer::LogLevel::Warn,
1558 "resume: no checkpoint found at {} — fresh session",
1559 p.display()
1560 );
1561 None
1562 }
1563 Err(e) => return Err(format!("resume: {e}")),
1564 },
1565 None => None,
1566 };
1567 let invocation = saved_doc.as_ref().map(|d| d.invocation + 1).unwrap_or(1);
1568 let started_at = saved_doc
1569 .as_ref()
1570 .map(|d| d.started_at.clone())
1571 .unwrap_or_else(crate::checkpoint::storage::now_rfc3339);
1572 // A dry-run is resume-inert (SRD-44): it short-circuits ops and may
1573 // run against placeholder params, so it must persist NO checkpoint —
1574 // otherwise its (synthetic) phase completions poison a later
1575 // `--resume-latest`. `Session::new_with_args` already withholds the
1576 // `latest` symlink from a dry-run; this withholds the checkpoint too.
1577 let checkpoint_writer =
1578 std::sync::Arc::new(if crate::session::args_request_dryrun(&args) {
1579 crate::checkpoint::CheckpointWriter::disabled(checkpoint_path.clone())
1580 } else {
1581 match saved_doc.as_ref() {
1582 Some(_doc) => crate::checkpoint::CheckpointWriter::from_existing(
1583 checkpoint_path.clone(),
1584 saved_doc.clone().unwrap(),
1585 crate::checkpoint::storage::now_rfc3339(),
1586 invocation,
1587 ),
1588 None => crate::checkpoint::CheckpointWriter::new(
1589 checkpoint_path.clone(),
1590 session.id.clone(),
1591 started_at,
1592 invocation,
1593 ),
1594 }
1595 });
1596
1597 Ok(SessionHost {
1598 session,
1599 sqlite_reporter,
1600 cadence_reporter,
1601 cadence_tree,
1602 metrics_query,
1603 stop_handle,
1604 refine_plan,
1605 resume_target,
1606 refine_requested,
1607 refine_scope: refine_scope.map(|s| s.to_string()),
1608 stick_reattached,
1609 profiler: _profiler,
1610 sqlite_guard: _sqlite_shutdown_guard,
1611 checkpoint_writer,
1612 saved_doc,
1613 })
1614 }
1615
1616 /// Session teardown — once, after every execution (SRD-88).
1617 async fn shutdown(self) {
1618 if let Some(mut profiler) = self.profiler {
1619 profiler.finish();
1620 }
1621 self.stop_handle.stop().await;
1622 let _teardown_t = std::time::Instant::now();
1623 self.cadence_reporter.shutdown().await;
1624 crate::diag!(
1625 crate::observer::LogLevel::Debug,
1626 "shutdown: cadence reporter flush+join {:?}",
1627 _teardown_t.elapsed()
1628 );
1629 // Release the live-access reader (the HybridStore's sqlite cold-tier
1630 // connection on metrics.db) BEFORE consolidating the WAL: the
1631 // `journal_mode=DELETE` flip in `consolidate_wal` needs an EXCLUSIVE
1632 // db lock, which a still-open reader connection on the same file
1633 // blocks ("database is locked"). The session is fully stopped here, so
1634 // no live reads remain.
1635 nmbrs_metrics::queryapi::uninstall_live_access();
1636 crate::diag!(
1637 crate::observer::LogLevel::Info,
1638 "shutting down — consolidating metrics.db WAL"
1639 );
1640 self.sqlite_guard.consume();
1641 crate::diag!(crate::observer::LogLevel::Info, "shutdown complete");
1642 // Shutdown-ladder bookkeeping: the run drained through the full
1643 // process-level cleanup, so a still-ticking level-1 countdown
1644 // (Ctrl-C during the final drain) goes quiet instead of
1645 // announcing op cancellation for a run that no longer has ops.
1646 crate::session_signals::mark_shutdown_complete();
1647 }
1648}
1649
1650/// SRD-88 — run ONE execution against a shared [`SessionHost`]. Does
1651/// NOT tear the session down (that is `SessionHost::shutdown`).
1652async fn run_execution(
1653 host: &SessionHost,
1654 args: &[String],
1655 observer: Arc<dyn crate::observer::RunObserver>,
1656) -> Result<(), String> {
1657 let session = &host.session;
1658 let session_id = host.session.id.clone();
1659 let sqlite_reporter = host.sqlite_reporter.clone();
1660 let cadence_reporter = host.cadence_reporter.clone();
1661 let stop_handle = host.stop_handle.clone();
1662 let resume_target = host.resume_target.clone();
1663 let refine_plan = host.refine_plan.clone();
1664 let refine_requested = host.refine_requested;
1665 let refine_scope = host.refine_scope.as_deref();
1666 let stick_reattached = host.stick_reattached.clone();
1667 let mut diag = DiagnosticConfig::normal();
1668
1669 // Detect scenario shorthand: `workload.yaml <scenario_name>` → `scenario=<name>`
1670 let args = normalize_args(args);
1671 let mut params = parse_params(&args);
1672
1673 // ── SRD-109 Part 2 — driver-manifest resolution ──
1674 //
1675 // `driver=<name>` resolves a driver manifest (local
1676 // `drivers/<name>/driver.yaml` under the cwd first, then the
1677 // bundled catalog entry `drivers/<name>/driver`; both at once
1678 // is a hard error, mirroring workload resolution). The
1679 // manifest supplies the implementation library (as workload=
1680 // when none was given, else as impl=), the backing adapter,
1681 // and default params at lowest precedence. A `driver=` value
1682 // matching no manifest keeps its legacy meaning: an alias for
1683 // `adapter=`.
1684 if let Some(driver_name) = params.get("driver").cloned()
1685 && let Some((manifest, library_ref)) = resolve_driver_manifest(&driver_name)?
1686 {
1687 // A positional `<file>.yaml` argument counts as a given
1688 // workload — the manifest's library must not displace it.
1689 let workload_given = params.contains_key("workload")
1690 || args
1691 .iter()
1692 .any(|a| a.ends_with(".yaml") || a.ends_with(".yml"));
1693 apply_driver_manifest(
1694 &mut params,
1695 &driver_name,
1696 manifest,
1697 library_ref,
1698 workload_given,
1699 )?;
1700 }
1701 let params = params;
1702
1703 // Load workload — from inline op= or YAML file.
1704 let mut workload_file: Option<String> = None;
1705 // SRD-85: true when the workload came from the bundled
1706 // catalog — its identity is a catalog name, not a path, and
1707 // relative resolution context falls back to the cwd.
1708 let mut workload_is_bundled = false;
1709 let mut workload_source_text: Option<String> = None;
1710 let mut workload = if let Some(op_str) = params.get("op") {
1711 if params.contains_key("workload") {
1712 crate::diag!(
1713 crate::observer::LogLevel::Warn,
1714 "warning: op= overrides workload="
1715 );
1716 }
1717 nmbrs_workload::inline::synthesize_inline_workload(op_str)
1718 .map_err(|e| format!("inline workload: {e}"))?
1719 } else {
1720 let workload_raw = params
1721 .get("workload")
1722 .cloned()
1723 .or_else(|| {
1724 args.iter()
1725 .find(|a| a.ends_with(".yaml") || a.ends_with(".yml"))
1726 .cloned()
1727 })
1728 .ok_or("no workload specified. Use workload=file.yaml or op=\"...\"")?;
1729
1730 // SRD-85 resolution: local files first (as-is, with
1731 // extensions, under cwd `workloads/`), then the bundled
1732 // catalog by exact name. Both at once is a hard error.
1733 match resolve_workload(&workload_raw)? {
1734 ResolvedWorkload::Path(workload_path) => {
1735 workload_file = Some(workload_path.clone());
1736
1737 // SRD-72: parse_workload_from_path resolves any
1738 // `extends:` chain before delegating to parse_workload.
1739 // The merged YAML text is what the parser consumes; the
1740 // raw on-disk text is still kept for diagnostic
1741 // `<path>:<line>:<col>` reporting (no source-map across
1742 // include boundaries today — diagnostics on inherited
1743 // fields point at the merged-output position).
1744 let yaml_source = std::fs::read_to_string(&workload_path)
1745 .map_err(|e| format!("read workload '{workload_path}': {e}"))?;
1746 let workload = nmbrs_workload::parse::parse_workload_from_path(
1747 std::path::Path::new(&workload_path),
1748 ¶ms,
1749 )
1750 .map_err(|e| format!("parse workload: {e}"))?;
1751 workload_source_text = Some(yaml_source);
1752 workload
1753 }
1754 ResolvedWorkload::Bundled(bundled) => {
1755 // Catalog name is the workload identity for the
1756 // session (session.log, summaries, phase
1757 // outcomes) — bundled runs have no path.
1758 workload_file = Some(bundled.name.to_string());
1759 workload_is_bundled = true;
1760 // SRD-72 + SRD-85: a bundled workload's
1761 // `extends:` chain resolves through the catalog
1762 // (no directory context).
1763 let (merged, res_warnings) =
1764 nmbrs_workload::extends::load_and_merge_bundled(bundled)
1765 .map_err(|e| format!("bundled workload `{}`: {e}", bundled.name))?;
1766 let mut workload = nmbrs_workload::parse::parse_workload(&merged, ¶ms)
1767 .map_err(|e| format!("parse bundled workload `{}`: {e}", bundled.name))?;
1768 workload.resolution_warnings.extend(res_warnings);
1769 workload_source_text = Some(bundled.source.to_string());
1770 workload
1771 }
1772 }
1773 };
1774
1775 // ── SRD-108 Part B — implementation binding (load time) ──
1776 //
1777 // Two invocation forms:
1778 // workload=<impl> — the impl's `implements:`
1779 // pulls its blueprint; the BLUEPRINT becomes the
1780 // effective workload with the impl's op bodies bound in.
1781 // workload=<blueprint> impl=<impl> — the blueprint is the
1782 // entry point; `impl=` names the implementation, whose
1783 // own `implements:` must resolve to the same blueprint.
1784 // All rules are load errors; by the time synthesis runs the
1785 // workload is ordinary and fully concrete.
1786 let impl_param = params.get("impl").cloned();
1787 if let Some(target) = workload.implements.clone() {
1788 if impl_param.is_some() {
1789 return Err(format!(
1790 "workload '{}' is an implementation (declares \
1791 `implements:`), so `impl=` does not apply — invoke \
1792 either the implementation directly or the blueprint \
1793 with impl=",
1794 workload_file.as_deref().unwrap_or("<inline>")
1795 ));
1796 }
1797 let base_dir = (!workload_is_bundled)
1798 .then(|| {
1799 workload_file
1800 .as_deref()
1801 .map(std::path::Path::new)
1802 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
1803 })
1804 .flatten();
1805 let bundled_origin = workload_is_bundled
1806 .then(|| workload_file.as_deref())
1807 .flatten();
1808 let (mut blueprint, blueprint_id) =
1809 load_secondary_workload(&target, ¶ms, base_dir.as_deref(), bundled_origin)?;
1810 crate::diag!(
1811 crate::observer::LogLevel::Info,
1812 "implements: binding '{}' into blueprint '{blueprint_id}'",
1813 workload_file.as_deref().unwrap_or("<inline>")
1814 );
1815 nmbrs_workload::implements::bind_implementation(&mut blueprint, workload)
1816 .map_err(|e| format!("implements binding: {e}"))?;
1817 workload = blueprint;
1818 } else if let Some(impl_ref) = impl_param {
1819 let base_dir = (!workload_is_bundled)
1820 .then(|| {
1821 workload_file
1822 .as_deref()
1823 .map(std::path::Path::new)
1824 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
1825 })
1826 .flatten();
1827 let bundled_origin = workload_is_bundled
1828 .then(|| workload_file.as_deref())
1829 .flatten();
1830 let (implementation, impl_id) =
1831 load_secondary_workload(&impl_ref, ¶ms, base_dir.as_deref(), bundled_origin)?;
1832 let declared = implementation.implements.clone().ok_or_else(|| {
1833 format!(
1834 "impl='{impl_ref}' resolves to '{impl_id}', which declares no \
1835 `implements:` — an implementation module must name its \
1836 blueprint"
1837 )
1838 })?;
1839 // The impl's declared target must be the SAME document the
1840 // operator invoked as workload=.
1841 // The impl doc's `implements:` resolves relative to the
1842 // IMPL doc's own directory (extends precedent).
1843 let impl_dir = std::path::Path::new(&impl_id)
1844 .parent()
1845 .map(|d| d.to_path_buf());
1846 let declared_id =
1847 workload_ref_identity(&declared, impl_dir.as_deref(), Some(impl_id.as_str()))?;
1848 let invoked_id = workload_file
1849 .clone()
1850 .map(|f| canonical_identity(&f))
1851 .unwrap_or_default();
1852 if declared_id != invoked_id {
1853 return Err(format!(
1854 "impl='{impl_id}' declares implements='{declared}' \
1855 (resolves to '{declared_id}'), which is not the invoked \
1856 workload '{invoked_id}'"
1857 ));
1858 }
1859 crate::diag!(
1860 crate::observer::LogLevel::Info,
1861 "implements: binding '{impl_id}' into blueprint '{invoked_id}'"
1862 );
1863 nmbrs_workload::implements::bind_implementation(&mut workload, implementation)
1864 .map_err(|e| format!("implements binding: {e}"))?;
1865 }
1866 {
1867 let unbound = nmbrs_workload::implements::unbound_abstract_slots(&workload);
1868 if !unbound.is_empty() {
1869 return Err(format!(
1870 "abstract op slot(s) [{}] unbound — pass impl=<workload> \
1871 or invoke an implementing workload (SRD-108)",
1872 unbound.join(", ")
1873 ));
1874 }
1875 }
1876
1877 // Overlay CLI params on the workload's declared params (CLI wins) so
1878 // synthesis (next) can read the operator's `cycles=N` / `concurrency=N`
1879 // overrides and promote them onto the synthetic phase. Same precedence
1880 // rule the session-tier services use via [`effective_params`], so a key
1881 // means the same thing whether declared in the workload or on the CLI.
1882 // Also covers the inline (`op=`) path that loaded empty params.
1883 workload.params = overlay_cli_params(std::mem::take(&mut workload.params), ¶ms);
1884
1885 // Unification: the scenario-tree executor is the sole
1886 // execution path. Workloads that arrive without an
1887 // explicit `phases:` block (the `op=` inline form, the
1888 // `blocks:` shorthand, top-level `ops:` lists) get an
1889 // implicit `main` phase + `default` scenario synthesized
1890 // here. Idempotent on workloads that already declare
1891 // phases. See [`nmbrs_workload::model::Workload::synthesize_default_phase`].
1892 workload.synthesize_default_phase();
1893
1894 let merged_params = workload.params.clone();
1895
1896 // Extract core config
1897 let driver = merged_params
1898 .get("adapter")
1899 .or_else(|| merged_params.get("driver"))
1900 .cloned()
1901 .unwrap_or_else(|| "stdout".into());
1902 let explicit_cycles: Option<u64> = merged_params.get("cycles").and_then(|s| parse_count(s));
1903 let concurrency: usize = match merged_params.get("concurrency") {
1904 Some(s) => s
1905 .parse()
1906 .map_err(|_| format!("concurrency value '{s}' is not a valid integer"))?,
1907 None => 1,
1908 };
1909 let rate: Option<f64> = match merged_params.get("rate") {
1910 Some(s) => Some(
1911 s.parse()
1912 .map_err(|_| format!("rate value '{s}' is not a valid number"))?,
1913 ),
1914 None => None,
1915 };
1916 // Workload-root total-attempts budget — the `tries` sigil for the
1917 // conditional TriesDispenser (SRD-82 Part 3b). Absent = ops without
1918 // their own `tries:` run single-attempt with no retry wrapper. `N ≥ 2`
1919 // retries adapter-retryable op errors (CQL timeouts/overloads) up to N
1920 // total attempts before the failure propagates to the result level;
1921 // `1` = explicit single-attempt; `0` = ops fail without executing.
1922 let tries: Option<u32> = match merged_params.get("tries") {
1923 Some(s) => Some(
1924 s.parse()
1925 .map_err(|_| format!("tries value '{s}' is not a valid integer"))?,
1926 ),
1927 None => None,
1928 };
1929 let tag_filter = merged_params.get("tags").cloned();
1930 let seq_type = merged_params
1931 .get("seq")
1932 .map(|s| SequencerType::parse(s).unwrap_or(SequencerType::Bucket))
1933 .unwrap_or(SequencerType::Bucket);
1934 let mut error_spec = merged_params
1935 .get("errors")
1936 .cloned()
1937 .unwrap_or_else(|| ".*:warn,stop".to_string());
1938 // `error_rate_max` — the OPT-IN session-wide error-rate circuit
1939 // breaker. When set, a phase fails once >this share of its ops error
1940 // (after a 50-op floor); per-phase `error_rate_max:` overrides it.
1941 // NO default (SRD-82 §"AggregateGuard retired as a default"): the
1942 // former silent 0.1 default was hidden, non-optional, and possibly
1943 // not what the operator wanted — operators built duplicate
1944 // `stop_when` backstops precisely because this one was invisible.
1945 // Aggregate health belongs to visible, workload-authored `stop_when`
1946 // conditions (SRD-83); this knob remains only as an explicit opt-in.
1947 let error_rate_max: Option<f64> = match merged_params.get("error_rate_max") {
1948 Some(s) => match s.trim().parse::<f64>() {
1949 Ok(v) if v >= 0.0 => Some(v),
1950 _ => {
1951 eprintln!(
1952 "error: error_rate_max must be a non-negative number \
1953 (e.g. 0.1 = 10%); got '{s}'"
1954 );
1955 std::process::exit(2);
1956 }
1957 },
1958 None => None,
1959 };
1960 // SRD-44 §"--force-retry-failed": when set on a resume
1961 // invocation, prepend a `.*:retry,warn` rule to the errors
1962 // cascade so any failure surfaces a retry rather than the
1963 // workload's normal stop / fail behaviour. Idempotent: when
1964 // set on a fresh run, it still applies (doesn't gate on
1965 // is_resume) — operators who want the override on a fresh
1966 // run get it; operators who pass it accidentally without
1967 // resume= get the same generous-retry policy they'd see on
1968 // resume.
1969 let force_retry_failed = params
1970 .get("force_retry_failed")
1971 .map(|s| s != "false" && s != "0")
1972 .unwrap_or(false)
1973 || args.iter().any(|a| a == "--force-retry-failed");
1974 if force_retry_failed {
1975 error_spec = format!(".*:retry,warn;{error_spec}");
1976 crate::diag!(
1977 crate::observer::LogLevel::Info,
1978 "--force-retry-failed: errors cascade prefixed with '.*:retry,warn'"
1979 );
1980 }
1981
1982 // Validate CLI parameters (runner-known + adapter-registered + workload-declared).
1983 //
1984 // Allow-list = installed param vocabulary ∪ adapter-registered ∪
1985 // `workload.declared_params` (the original YAML keys from
1986 // the workload's `params:` block). We do **not** consult
1987 // `workload.params` here — `parse.rs` merges every CLI arg
1988 // into that map regardless of whether the workload declared
1989 // it, so checking against it would let any CLI param through
1990 // and silently drop typos like `profile=perf` (vs.
1991 // `profiler=perf`). `declared_params` preserves the user's
1992 // declared surface independent of CLI overlays, which is
1993 // what the closed-vocabulary check needs. Skipped entirely
1994 // when no CLI vocabulary is installed (library/test driver).
1995 if let Some(cli_params) = known_params() {
1996 let adapter_params = registered_adapter_params();
1997 let all_known: Vec<&str> = cli_params
1998 .iter()
1999 .copied()
2000 .chain(adapter_params.iter().copied())
2001 .chain(workload.declared_params.iter().map(|s| s.as_str()))
2002 .collect();
2003 for key in params.keys() {
2004 if !all_known.contains(&key.as_str()) {
2005 let suggestion = closest_match(key, &all_known);
2006 if let Some(closest) = suggestion {
2007 return Err(format!(
2008 "unrecognized parameter '{key}='. Did you mean '{closest}='?"
2009 ));
2010 } else {
2011 return Err(format!("unrecognized parameter '{key}='"));
2012 }
2013 }
2014 }
2015 }
2016
2017 // Validate workload-declared params are actually referenced.
2018 // Unreferenced params can shadow runner params (e.g., a workload
2019 // declaring `concurrency` as a param masks the CLI parameter
2020 // validation, but if nothing in the workload uses `{concurrency}`
2021 // the value is silently ignored).
2022 //
2023 // AND the reverse direction (SRD-N param-reference validator):
2024 // every `{name}` placeholder in the workload must resolve to a
2025 // declared param, a known runner/adapter param, or an iter-var
2026 // introduced by some Comprehension in the scenario tree. A
2027 // stray `{undeclared}` would otherwise survive
2028 // `expand_workload_params` as a literal and trip the Polydat parser
2029 // later with a cryptic "expected expression, got LBrace" — that
2030 // surfaces too late and doesn't name the offender. The check
2031 // here points at the placeholder by name so the operator sees
2032 // what to fix.
2033 {
2034 // First — catch `{name}` placeholders that appear inside
2035 // Polydat expression bodies outside of string literals. The
2036 // Polydat grammar doesn't accept `{...}` as expression syntax;
2037 // a `{name}` there will always fail compile with a
2038 // cryptic "expected expression, got LBrace". Catch it
2039 // here with a targeted message naming the YAML file and
2040 // line so the operator can jump straight to it.
2041 let mut invalid_polydat_braces: Vec<PolydatBraceFinding> =
2042 collect_polydat_brace_refs(&workload);
2043 if !invalid_polydat_braces.is_empty() {
2044 invalid_polydat_braces.sort();
2045 invalid_polydat_braces.dedup();
2046 let file_path = workload_file.as_deref().unwrap_or("<inline>");
2047 let lines: Vec<String> = invalid_polydat_braces
2048 .iter()
2049 .map(|f| {
2050 let yaml_line = workload_source_text
2051 .as_deref()
2052 .and_then(|src| find_yaml_line_for_brace(src, &f.placeholder));
2053 let prefix = match yaml_line {
2054 Some(n) => format!("{file_path}:{n}"),
2055 None => file_path.to_string(),
2056 };
2057 format!(
2058 " {prefix}: in {} — `{{{}}}`. Use bare `{}`.",
2059 f.location, f.placeholder, f.placeholder
2060 )
2061 })
2062 .collect();
2063 return Err(format!(
2064 "`{{...}}` braces in Polydat expression context (invalid syntax).\n\
2065 Polydat accepts bare identifiers; braces are only for YAML string\n\
2066 interpolation (op `prepared:`/`raw:`, `cycles:`, etc.).\n{}",
2067 lines.join("\n"),
2068 ));
2069 }
2070
2071 let referenced = collect_param_references(&workload);
2072 let adapter_params: std::collections::HashSet<&'static str> =
2073 registered_adapter_params().into_iter().collect();
2074 let iter_var_names = collect_iter_var_names(&workload);
2075 let wire_names = collect_polydat_binding_names(&workload);
2076
2077 // Declared/known direction: every declared param must be
2078 // referenced — except adapter-registered params, which
2079 // the driver consumes directly from the merged params
2080 // (e.g. `host`/`port`/`consistency` for CQL). Declaring
2081 // one in `params:` is how a workload surfaces the knob
2082 // with a default in `describe workloads` without a
2083 // textual `{name}` reference.
2084 for name in &workload.declared_params {
2085 if is_cli_param(name) || adapter_params.contains(name.as_str()) {
2086 continue;
2087 }
2088 if !referenced.contains(name) {
2089 return Err(format!(
2090 "workload declares param '{name}' but it is never referenced as '{{{}}}' \
2091 in any op, phase, or binding. Remove it or use it.",
2092 name
2093 ));
2094 }
2095 }
2096
2097 // Undeclared direction: every curly-brace placeholder
2098 // must resolve. The legitimate-name set spans every
2099 // declaration site the runtime can satisfy:
2100 // - workload.declared_params (the `params:` block)
2101 // - the installed CLI param vocabulary (`cycles`, `concurrency`, …)
2102 // - adapter-registered params (driver-specific config)
2103 // - iter-vars from Comprehensions in the scenario tree
2104 // (`k`, `limit`, `profile` from `for_each: "k in …"`)
2105 let declared_set: std::collections::HashSet<&str> = workload
2106 .declared_params
2107 .iter()
2108 .map(|s| s.as_str())
2109 .collect();
2110 let mut undeclared: Vec<&str> = referenced
2111 .placeholders
2112 .iter()
2113 .map(|s| s.as_str())
2114 .filter(|name| !declared_set.contains(*name))
2115 .filter(|name| !is_cli_param(name))
2116 .filter(|name| !adapter_params.contains(name))
2117 .filter(|name| !iter_var_names.contains(*name))
2118 .filter(|name| !wire_names.contains(*name))
2119 .collect();
2120 if !undeclared.is_empty() {
2121 undeclared.sort();
2122 return Err(format!(
2123 "workload references undeclared placeholder{plural} {names} — \
2124 add to the `params:` block, or check for a typo. Recognised \
2125 sources for `{{name}}` placeholders: workload `params:`, \
2126 runner/adapter built-ins, scenario-tree iter-vars from \
2127 `for_each:`/`for_combinations:`, and wire names from Polydat \
2128 `bindings:`.",
2129 plural = if undeclared.len() == 1 { "" } else { "s" },
2130 names = undeclared
2131 .iter()
2132 .map(|n| format!("`{{{n}}}`"))
2133 .collect::<Vec<_>>()
2134 .join(", "),
2135 ));
2136 }
2137 }
2138
2139 // Extract workload structure before consuming. M3.6:
2140 // `workload_params` is the set of *workload-declared* params
2141 // (the YAML `params:` block, with CLI overrides applied) —
2142 // these are what get injected as `const` bindings on the
2143 // workload kernel. The full `workload.params` map also
2144 // contains ad-hoc CLI params like `cycles=`, `workload=`,
2145 // `tags=`, etc., which are not declared bindings and must
2146 // not become identifiers in the Polydat source. Filter by
2147 // `declared_params` to keep only the YAML-declared subset.
2148 let declared: std::collections::HashSet<&String> = workload.declared_params.iter().collect();
2149 let workload_params: HashMap<String, String> = workload
2150 .params
2151 .iter()
2152 .filter(|(k, _)| declared.contains(*k))
2153 .map(|(k, v)| (k.clone(), v.clone()))
2154 .collect();
2155 drop(declared);
2156 let mut phases = workload.phases;
2157 // Inline-expression rewrite per phase. The
2158 // `rewrite_inline_exprs` call later in this function (around
2159 // line 818) operates on `all_ops_for_compile` — a flattened
2160 // copy used for the workload-level kernel — but
2161 // `build_op_template_scope_kernel` (SRD-13d Phase 9) reads
2162 // op definitions from `phases.get(name).ops`, which is the
2163 // ORIGINAL parsed structure. Without this per-phase rewrite,
2164 // op-template kernels never see the synthesised
2165 // `__expr_N := <expr>` bindings and the conditional /
2166 // inline-expression machinery breaks for any op that lands
2167 // on the Phase 9 path. Rewriting in place here keeps the
2168 // two compile paths consistent.
2169 for phase in phases.values_mut() {
2170 crate::scope::rewrite_inline_exprs(&mut phase.ops);
2171 }
2172 let phase_order = workload.phase_order;
2173 let scenarios = workload.scenarios;
2174 let workload_readouts = workload.readouts.clone();
2175 // SRD-32a Push 3 — workload-root wrapper override.
2176 // Innermost-to-outermost list, extracted once and
2177 // installed onto every Activity via `set_wrappers_override`
2178 // before `run_with_driver` runs the cascade. Per-op
2179 // `wrappers:` overrides on individual templates shadow
2180 // this entry; CLI flags (not yet implemented) would set
2181 // it independently on the Activity.
2182 let workload_wrappers_override: Option<Vec<String>> = workload
2183 .wrappers
2184 .as_ref()
2185 .filter(|c| !c.order.is_empty())
2186 .map(|c| c.order.clone());
2187 // SRD-63 §8 / Push 8: extract the CLI `--readout=<body>`
2188 // override before any binder is built. Resolved through
2189 // the same `resolve_flag` helper as `--session-path`,
2190 // so it picks up the matching `NMBRS_READOUT` env var
2191 // when set. `None` ⇒ workload bindings + builtin
2192 // defaults run unchanged.
2193 let cli_readout_override = crate::session::resolve_flag(&args[..], "--readout");
2194
2195 // SRD-32a Push 3 — CLI overrides for wrapper composition
2196 // ordering. Two flags:
2197 //
2198 // - `--wrap-order=<list>` — innermost-to-outermost
2199 // permutation that applies to every op in this run.
2200 // Workload-root and per-op blocks shadow it (config-
2201 // locality wins, SRD-04 Rule 5). When neither workload-
2202 // level override is set, this CLI value plumbs through
2203 // to `Activity::wrappers_override` for every phase.
2204 // - `--wrap-default-order=<list>` — replaces the
2205 // resolver's *built-in* default-order tiebreaker for
2206 // the run. Useful when the operator wants a permanent
2207 // tilt (e.g. always put validate outside throttle in
2208 // their environment) without editing every workload.
2209 // Validated against the constraint graph at session
2210 // start; an inconsistent list is a hard error.
2211 //
2212 // Both flags accept a comma-separated list. Empty / unset
2213 // ⇒ runtime default applies.
2214 let cli_wrap_order: Option<Vec<String>> =
2215 crate::session::resolve_flag(&args[..], "--wrap-order")
2216 .map(|s| {
2217 s.split(',')
2218 .map(|t| t.trim().to_string())
2219 .filter(|t| !t.is_empty())
2220 .collect()
2221 })
2222 .filter(|v: &Vec<String>| !v.is_empty());
2223 let cli_wrap_default_order: Option<Vec<String>> =
2224 crate::session::resolve_flag(&args[..], "--wrap-default-order")
2225 .map(|s| {
2226 s.split(',')
2227 .map(|t| t.trim().to_string())
2228 .filter(|t| !t.is_empty())
2229 .collect()
2230 })
2231 .filter(|v: &Vec<String>| !v.is_empty());
2232
2233 // Effective workload-level wrapper override: workload's
2234 // own `wrappers: { order: [...] }` block wins over the
2235 // CLI flag (config-locality, SRD-04 Rule 5). Per-op
2236 // overrides on individual ParsedOps shadow either.
2237 let workload_wrappers_override: Option<Vec<String>> =
2238 workload_wrappers_override.or(cli_wrap_order);
2239 // Unified report block (SRD-46). Tables auto-render at
2240 // end-of-run; plot specs persist into the session db so
2241 // post-hoc `nmbrs report ...` can replay them. Empty
2242 // `report:` block ⇒ no auto-render and no persisted specs.
2243 let workload_report = workload.report.clone();
2244 let workload_summaries: HashMap<String, nmbrs_workload::model::SummaryConfig> = workload_report
2245 .items()
2246 .filter(|i| matches!(i.kind, nmbrs_workload::report::Kind::Table))
2247 .map(|i| {
2248 (
2249 i.name.clone(),
2250 nmbrs_workload::model::SummaryConfig::parse(&i.body),
2251 )
2252 })
2253 .collect();
2254
2255 // Collect ALL ops: top-level ops + all phase inline ops.
2256 let mut ops = workload.ops;
2257
2258 // Filter top-level ops by tags (CLI-level tag filter)
2259 if let Some(ref filter) = tag_filter {
2260 ops =
2261 TagFilter::filter_ops(&ops, filter).map_err(|e| format!("invalid tag filter: {e}"))?;
2262 }
2263
2264 // Classify phase ops for compilation:
2265 // - Phases with own bindings or for_each: saved raw, compiled per-phase
2266 // - Phases without own bindings: included in outer (workload) kernel
2267 let mut phase_ops_for_compile: Vec<nmbrs_workload::model::ParsedOp> = Vec::new();
2268 let mut phase_raw_ops: HashMap<String, Vec<nmbrs_workload::model::ParsedOp>> = HashMap::new();
2269 let mut phases_needing_own_kernel: std::collections::HashSet<String> =
2270 std::collections::HashSet::new();
2271 for (name, phase) in &phases {
2272 let has_own_bindings = phase.ops.iter().any(|op| !op.bindings.is_empty());
2273 if phase.for_each.is_some() || has_own_bindings {
2274 phase_raw_ops.insert(name.clone(), phase.ops.clone());
2275 phases_needing_own_kernel.insert(name.clone());
2276 } else {
2277 phase_ops_for_compile.extend(phase.ops.iter().cloned());
2278 }
2279 }
2280
2281 // For non-phased workloads, require at least some ops
2282 if ops.is_empty() && phases.is_empty() {
2283 return Err("no ops selected (tag filter may have excluded all ops)".into());
2284 }
2285
2286 if phases.is_empty() {
2287 crate::diag!(
2288 crate::observer::LogLevel::Info,
2289 "{} ops, {} cycles, concurrency={}, adapter={}",
2290 ops.len(),
2291 explicit_cycles
2292 .map(|c| c.to_string())
2293 .unwrap_or("auto".into()),
2294 concurrency,
2295 driver
2296 );
2297 } else {
2298 // Always log to session.log; stderr suppression is the
2299 // observer's job (TuiObserver gates eprintln internally).
2300 crate::diag!(
2301 crate::observer::LogLevel::Info,
2302 "{} phases, {} top-level ops, adapter={}",
2303 phases.len(),
2304 ops.len(),
2305 driver
2306 );
2307 }
2308
2309 // Collect --polydat-lib=path flags
2310 let polydat_lib_paths: Vec<std::path::PathBuf> = args
2311 .iter()
2312 .filter_map(|a| a.strip_prefix("--polydat-lib="))
2313 .map(std::path::PathBuf::from)
2314 .collect();
2315 let strict = args.iter().any(|a| a == "--strict")
2316 || matches!(
2317 params.get("strict").map(String::as_str),
2318 Some("true") | Some("1")
2319 );
2320
2321 // SRD-68 follow-up — session-wide op-template synthesis opt level.
2322 // `--kernel-opt=release|diagnostic` or `kernel_opt=…`. Release
2323 // (default) lets the closure-binding economy DCE unreferenced
2324 // magic-extern slots; Diagnostic force-allocates body/count/ok and
2325 // every result-binding LHS slot so step-debug / cycle-replay can
2326 // inspect writes the workload doesn't otherwise consume.
2327 let kernel_opt: polydat::kernel::KernelOptLevel = {
2328 let raw =
2329 cli_flag_value(&args[..], "--kernel-opt").or_else(|| params.get("kernel_opt").cloned());
2330 match raw {
2331 None => polydat::kernel::KernelOptLevel::default(),
2332 Some(s) => polydat::kernel::KernelOptLevel::parse(s.trim()).map_err(|bad| {
2333 format!("unknown --kernel-opt value '{bad}' — use 'release' or 'diagnostic'")
2334 })?,
2335 }
2336 };
2337
2338 // Parse dryrun= param into diagnostic config
2339 if let Some(spec) = params.get("dryrun") {
2340 diag = DiagnosticConfig::parse(spec);
2341 }
2342
2343 // skipped_phases= — how fully-gated-off phases are represented
2344 // (elide | mark | prune). Default: mark.
2345 if let Some(spec) = params.get("skipped_phases") {
2346 match crate::observer::SkippedPhaseDisplay::parse(spec) {
2347 Some(mode) => crate::observer::set_skipped_phase_display(mode),
2348 None => {
2349 return Err(format!(
2350 "unknown skipped_phases value '{spec}' — use 'elide', 'mark', or 'prune'"
2351 ));
2352 }
2353 }
2354 }
2355
2356 // completed_phases= — how much of a completed node's block is
2357 // retained in scrollback (full | headers). Default: full (SRD-92
2358 // R5 — completion is never a full collapse).
2359 if let Some(spec) = params.get("completed_phases") {
2360 match crate::observer::CompletedPhaseDisplay::parse(spec) {
2361 Some(mode) => crate::observer::set_completed_phase_display(mode),
2362 None => {
2363 return Err(format!(
2364 "unknown completed_phases value '{spec}' — use 'full' or 'headers'"
2365 ));
2366 }
2367 }
2368 }
2369
2370 // "Never Ignore Silently" — scenario-parse errors are
2371 // ALWAYS fatal regardless of strict mode. The parser used
2372 // to silently drop unknown scenario-node keys (e.g.
2373 // `iterate:` misspellings, stray `phases:` siblings),
2374 // which led to downstream "phase 'iterate' not found"
2375 // confusion masking the real bug. The parser now collects
2376 // every malformed-node case here and we refuse to dispatch.
2377 if !workload.scenario_parse_errors.is_empty() {
2378 return Err(format!(
2379 "scenario parse error{plural} — workload is malformed:\n - {msgs}",
2380 plural = if workload.scenario_parse_errors.len() == 1 {
2381 ""
2382 } else {
2383 "s"
2384 },
2385 msgs = workload.scenario_parse_errors.join("\n - "),
2386 ));
2387 }
2388
2389 // SRD-46 + SRD-15: surface report-block warnings collected
2390 // by the parser. Strict mode promotes to a hard error so a
2391 // workload with `defaults`-collisions or empty groups can't
2392 // silently pass; otherwise we log them and continue.
2393 if !workload.report_warnings.is_empty() {
2394 if strict {
2395 return Err(format!(
2396 "report-block warnings (strict mode promotes to errors):\n - {}",
2397 workload.report_warnings.join("\n - "),
2398 ));
2399 }
2400 for w in &workload.report_warnings {
2401 crate::observer::log_tagged(
2402 crate::observer::LogLevel::Warn,
2403 crate::observer::EventTag::in_flight(crate::observer::EventCategory::Report),
2404 &format!("report: {w}"),
2405 );
2406 }
2407 }
2408
2409 // SRD-85 nearest-first reference resolution: a target that
2410 // matched multiple same-named resources resolved to the
2411 // nearest (filesystem favored) and is surfaced here — the
2412 // shadowing is allowed but never silent. Strict promotes.
2413 if !workload.resolution_warnings.is_empty() {
2414 if strict {
2415 return Err(format!(
2416 "reference-resolution warnings (strict mode promotes to errors):\n - {}",
2417 workload.resolution_warnings.join("\n - "),
2418 ));
2419 }
2420 for w in &workload.resolution_warnings {
2421 crate::observer::log_tagged(
2422 crate::observer::LogLevel::Warn,
2423 crate::observer::EventTag::in_flight(crate::observer::EventCategory::Resolution),
2424 &format!("resolve: {w}"),
2425 );
2426 }
2427 }
2428
2429 // Dry-run mode resolution.
2430 //
2431 // The mode string is a LABEL — there is no "silent" or
2432 // "op" or "cycle" adapter. The real adapter from the
2433 // workload is constructed in full (connect, prepare,
2434 // metadata, dispenser init); the `DryRunWrapper` is
2435 // installed at the outermost wrapper position and per-
2436 // cycle short-circuits the inner stack so the adapter's
2437 // `execute()` never fires. The mode string carries
2438 // operator intent through the log (`dryrun: injected
2439 // `dryrun: <mode>` …`) and only the `fields` mode has a
2440 // structural side-effect (forces the fields wrapper on so
2441 // rendered op text reaches stdout).
2442 //
2443 // Mapping:
2444 // dryrun=cycle → mode="cycle", wrapper short-circuit
2445 // dryrun=op → mode="op", wrapper short-circuit
2446 // dryrun=silent → mode="silent", wrapper short-circuit
2447 // dryrun=fields → mode="fields", wrapper short-circuit + fields-render on
2448 // dryrun=full → mode=None, real execution
2449 let dry_run: Option<&str> = if diag.depth == ExecDepth::Cycle {
2450 Some("cycle")
2451 } else {
2452 params.get("dryrun").and_then(|s| match s.as_str() {
2453 "fields" => Some("fields"),
2454 "silent" => Some("silent"),
2455 "op" => Some("op"),
2456 _ => None,
2457 })
2458 };
2459
2460 // Auto-bump depth to Cycle for any dryrun mode that
2461 // installs the wrapper — cycles must dispatch for the
2462 // wrapper to have anything to short-circuit. Without the
2463 // bump, `dryrun=silent` / `dryrun=fields` / `dryrun=op`
2464 // would silently produce no output because the
2465 // phase-early-complete branch at executor.rs:2998 elides
2466 // the cycle loop when depth < Cycle.
2467 if dry_run.is_some() && diag.depth < ExecDepth::Cycle {
2468 diag.depth = ExecDepth::Cycle;
2469 }
2470
2471 // (OpenMetrics push URL is resolved in `SessionHost::setup` — the
2472 // metrics push reporter is a session-tier service.)
2473
2474 // Resolve the resume source BEFORE creating the new session
2475 // — `Session::new` eagerly remaps `logs/latest` at the new
2476 // session id, so any path resolution that depends on the old
2477 // `latest` target has to happen first. Stored as
2478 // `resume_target` and consulted later when constructing the
2479 // checkpoint writer + plan. SRD-44 §"Resume CLI surface".
2480 //
2481 // SRD-88 — per-execution: the session name (host already used the
2482 // same value to name the session dir) + the refine on-removed
2483 // policy, recomputed here from this execution's params.
2484 let scenario_for_session = params
2485 .get("scenario")
2486 .map(|s| s.as_str())
2487 .unwrap_or("default");
2488 let on_removed_policy: &str = params
2489 .get("on_removed")
2490 .map(|s| s.as_str())
2491 .unwrap_or("error");
2492 if let Some(plan) = refine_plan.as_ref() {
2493 let current_names: std::collections::HashSet<&str> =
2494 phases.keys().map(|s| s.as_str()).collect();
2495 let mut removed: Vec<&str> = plan
2496 .seen_identities
2497 .iter()
2498 .map(|(name, _)| name.as_str())
2499 .filter(|n| !current_names.contains(n))
2500 .collect();
2501 removed.sort();
2502 removed.dedup();
2503 if !removed.is_empty() {
2504 match on_removed_policy {
2505 "error" => {
2506 return Err(format!(
2507 "refine: workload removes {n} phase{plural} that have \
2508 prior outcomes in this session:\n - {names}\n\
2509 Pass `on_removed=keep` to retain the prior outcomes \
2510 (no work, no error); `on_removed=drop` is reserved \
2511 (not yet implemented). Default `error` refuses to \
2512 proceed so accidental axis-trim doesn't drop history \
2513 silently.",
2514 n = removed.len(),
2515 plural = if removed.len() == 1 { "" } else { "s" },
2516 names = removed.join("\n - "),
2517 ));
2518 }
2519 "keep" => {
2520 crate::diag!(
2521 crate::observer::LogLevel::Info,
2522 "refine: on_removed=keep — retaining prior outcomes \
2523 for {n} removed phase(s): {names}",
2524 n = removed.len(),
2525 names = removed.join(", ")
2526 );
2527 }
2528 "drop" => {
2529 crate::diag!(
2530 crate::observer::LogLevel::Warn,
2531 "refine: on_removed=drop is reserved — not yet \
2532 implemented. Treating as `keep` (retaining prior \
2533 outcomes) for now: {names}",
2534 names = removed.join(", ")
2535 );
2536 }
2537 other => {
2538 return Err(format!(
2539 "refine: unknown on_removed= policy '{other}'; \
2540 expected `error` (default), `keep`, or `drop`"
2541 ));
2542 }
2543 }
2544 }
2545 }
2546
2547 // SRD-88 — a CONCURRENT execution runs inside a scoped
2548 // `ExecutionContext` whose `exec_id` was allocated distinctly per
2549 // sibling; use it so each concurrent execution's metric rows /
2550 // metadata / `executions` row are separable. Outside a scoped
2551 // context (single-run), fall back to the SRD-77 verb/exec_id:
2552 // `refine` numbers from the prior outcomes' max+1, `run`/`resume`
2553 // start at 1 — byte-identical to before.
2554 let (exec_verb, exec_id_seed): (&'static str, u64) =
2555 match crate::execution_context::try_current() {
2556 Some(ctx) => ("run", ctx.exec_id),
2557 None => match (refine_plan.as_ref(), resume_target.as_ref()) {
2558 (Some(plan), Some(p)) if p.exists() => ("refine", plan.next_exec_id),
2559 (_, Some(p)) if p.exists() => ("resume", 1),
2560 _ => ("run", 1),
2561 },
2562 };
2563 // SRD-88 §2 — start this execution under the session: derive
2564 // its component (carrying `exec_id` + `workload`) as a child
2565 // of the session component. Phase components attach under
2566 // `execution.component`, so every metric inherits this
2567 // execution's identity without any tier redeclaring a label.
2568 let execution = crate::session::Execution::start(
2569 &session,
2570 workload_file.as_deref().unwrap_or("inline"),
2571 scenario_for_session,
2572 exec_verb,
2573 exec_id_seed,
2574 );
2575 let exec_id = execution.exec_id;
2576
2577 // dryrun=controls: defer the tree walk until after phase
2578 // construction. `list_controls` implies depth=Phase, which
2579 // means every phase compiles and attaches its component —
2580 // that's when activity-scoped controls get declared — but
2581 // no cycles run. Walking here would only see session-root
2582 // controls. The renderer fires at the very end of the run,
2583 // just before the session returns.
2584
2585 // Direct the diagnostic log sink at <session_dir>/session.log so every
2586 // observer::log() call is captured durably, even under the TUI.
2587 // SRD-77 / SRD-88 — per-execution metadata + the in-flight
2588 // `executions` row. Written through the shared session reporter
2589 // but scoped to THIS execution's `exec_id`, so concurrent
2590 // executions sharing the session each record their own workload
2591 // / scenario / params without clobbering. `ended_at_nanos` /
2592 // `disposition` stay NULL until the shutdown-flush guard updates
2593 // them. `scope` is non-NULL only under refine.
2594 {
2595 let mut cli_keys: Vec<&String> = params.keys().collect();
2596 cli_keys.sort();
2597 let cli_text: String = cli_keys
2598 .iter()
2599 .filter_map(|k| params.get(*k).map(|v| format!("{k}={v}")))
2600 .collect::<Vec<_>>()
2601 .join("\n");
2602 let scope_for_row: Option<&str> = if refine_requested {
2603 Some(refine_scope.unwrap_or("missing"))
2604 } else {
2605 None
2606 };
2607 if let Ok(mut guard) = sqlite_reporter.lock()
2608 && let Some(r) = guard.as_mut()
2609 {
2610 let exec_id = execution.exec_id;
2611 let sid = session.id.clone();
2612 r.set_execution_metadata(&sid, exec_id, "workload", &execution.workload);
2613 r.set_execution_metadata(&sid, exec_id, "scenario", &execution.scenario);
2614 r.set_execution_metadata(
2615 &sid,
2616 exec_id,
2617 "start_time",
2618 &format!(
2619 "{}",
2620 std::time::SystemTime::now()
2621 .duration_since(std::time::UNIX_EPOCH)
2622 .unwrap()
2623 .as_secs()
2624 ),
2625 );
2626 for (k, v) in &merged_params {
2627 r.set_execution_metadata(&sid, exec_id, &format!("param.{k}"), v);
2628 }
2629 // Reproducibility: the metrics db alone re-creates the run
2630 // (raw workload YAML + verbatim CLI params).
2631 if let Some(yaml) = workload_source_text.as_deref() {
2632 r.set_execution_metadata(&sid, exec_id, "workload_yaml", yaml);
2633 }
2634 r.set_execution_metadata(&sid, exec_id, "cli_params", &cli_text);
2635 r.insert_execution_start(
2636 &session.id,
2637 execution.exec_id,
2638 execution.verb,
2639 scope_for_row,
2640 execution.started_at_nanos,
2641 workload_source_text.as_deref().unwrap_or(""),
2642 &cli_text,
2643 );
2644 }
2645 }
2646
2647 // SRD-63 Push 9a: fire `EventType::SessionStart` once at the
2648 // workload root. Workloads bind structural rows to
2649 // this slot via `readouts: { on_session_start: … }`;
2650 // unbound slots stay quiet (no built-in default
2651 // emission today). Fires whether the run takes the
2652 // phased or single-activity branch below.
2653 {
2654 let session_ctx = crate::readout_context::LifecycleContext {
2655 event: crate::lifecycle::EventType::SessionStart,
2656 subject_name: session.id.clone(),
2657 subject_labels: String::new(),
2658 depth_indent: String::new(),
2659 use_color: crate::observer::use_color(),
2660 stick_reattached: stick_reattached.clone().unwrap_or_default(),
2661 };
2662 // SRD-106 D4 — when the stick_session rung engaged, seed
2663 // the slot with the `session_notice` builtin so the
2664 // announcement is the first notable event of the run,
2665 // ahead of any phase event. Workload-bound
2666 // on_session_start readouts still render after it; runs
2667 // without stick keep the slot's quiet default.
2668 let default_body = if stick_reattached.is_some() {
2669 crate::readouts::parse::bake("session_notice")
2670 .map(|(body, _)| body)
2671 .ok()
2672 } else {
2673 None
2674 };
2675 crate::readout_context::fire_lifecycle(
2676 crate::lifecycle::EventType::SessionStart,
2677 &workload_readouts,
2678 default_body,
2679 &session_ctx,
2680 Some(&sqlite_reporter),
2681 );
2682 }
2683
2684 // Merge all ops for param expansion and Polydat compilation.
2685 let _num_top_level_ops = ops.len();
2686 let mut all_ops_for_compile: Vec<nmbrs_workload::model::ParsedOp> = ops;
2687 all_ops_for_compile.extend(phase_ops_for_compile);
2688
2689 // === Pre-compile rewrites ===
2690 //
2691 // Stage 2 (post-M3.6): the workload-params kernel
2692 // (`crate::params::build_workload_params_kernel`) installs
2693 // every workload param as a `final <name> := <literal>`
2694 // binding on the workload-root kernel. Descendant scopes
2695 // see those bindings via `materialize_wiring_from_outer` + standard GK
2696 // scope-chain lookup. The legacy
2697 // `rewrite_workload_param_idents_in_bindings` text pass
2698 // (which substituted `{name}` → literal value before
2699 // compilation) was redundant once that path landed and
2700 // produced broken output for in-string placeholders
2701 // (`"{dataset}:{profile}"` → `""example":"default""`),
2702 // so it has been retired.
2703 //
2704 // What's left here: rewrite inline `{{expr}}` constructs to
2705 // named bindings so the Polydat compiler can hoist them as
2706 // `const __expr_N := …` entries. That pass is a bind-point
2707 // shape transform, not a value substitution — it operates
2708 // independently of workload params.
2709 crate::scope::rewrite_inline_exprs(&mut all_ops_for_compile);
2710
2711 // The workload-level `bindings:` (top-level YAML block) is
2712 // a first-class workload-scope input — separate from any
2713 // op's bindings. We pass it through to the compiler as a
2714 // distinct source so cursor declarations and other
2715 // workload-scoped Polydat statements land on the workload kernel
2716 // alongside the workload params, *without* going through
2717 // the op-binding param-ident rewrite (which would text-
2718 // substitute `{name}` placeholders inside string literals).
2719 // Polydat's standard string-interpolation handles `{name}` at
2720 // compile time against the `final <name> := <literal>`
2721 // bindings that workload-params injection installs (M3.6 path).
2722 // SRD-13f Push D: workload-level `bindings:` reach the
2723 // workload-root kernel ONLY through this explicit channel
2724 // now. The parser no longer merges workload bindings into
2725 // ops (`nmbrs_workload::parse::inline_block_sugar_into_op`
2726 // is the only remaining parser-time inlining; it operates
2727 // on block-level YAML sugar, not workload-level). Both
2728 // BindingsDef forms route through here:
2729 // - PolydatSource: pass through verbatim.
2730 // - Map: legacy semicolon-chain syntax translated to GK
2731 // source lines via `legacy_chain_map_to_polydat_lines`.
2732 let workload_level_polydat: Option<String> = match &workload.bindings {
2733 nmbrs_workload::model::BindingsDef::PolydatSource(s) if !s.trim().is_empty() => {
2734 Some(s.clone())
2735 }
2736 nmbrs_workload::model::BindingsDef::Map(m) if !m.is_empty() => Some(
2737 crate::bindings::legacy_chain_map_to_polydat_lines(m)
2738 .map_err(|e| format!("workload-level bindings: {e}"))?,
2739 ),
2740 _ => None,
2741 };
2742
2743 // === Polydat Compilation ===
2744
2745 let workload_dir: Option<&std::path::Path> = if workload_is_bundled {
2746 // A catalog name is not a path — don't derive a bogus
2747 // directory from its namespace segments. Bundled
2748 // workloads get the cwd as their relative-resolution
2749 // context (same as inline workloads).
2750 Some(std::path::Path::new("."))
2751 } else {
2752 workload_file
2753 .as_ref()
2754 .and_then(|p| std::path::Path::new(p).parent())
2755 .or_else(|| Some(std::path::Path::new(".")))
2756 };
2757
2758 let mut config_refs: Vec<String> = params
2759 .values()
2760 .filter(|v| v.starts_with('{') && v.ends_with('}'))
2761 .map(|v| {
2762 let mut inner = v[1..v.len() - 1].to_string();
2763 // Expand workload params in config expressions
2764 for (key, value) in &workload_params {
2765 let placeholder = format!("{{{key}}}");
2766 if inner.contains(&placeholder) {
2767 inner = inner.replace(&placeholder, value);
2768 }
2769 }
2770 inner
2771 })
2772 .collect();
2773 for (name, phase) in &phases {
2774 if phase.for_each.is_some() {
2775 continue; // for_each phase cycles resolved per-iteration
2776 }
2777 if let Some(ref c) = phase.cycles
2778 && c.starts_with('{')
2779 && c.ends_with('}')
2780 {
2781 let mut inner = c[1..c.len() - 1].to_string();
2782 for (key, value) in &workload_params {
2783 let placeholder = format!("{{{key}}}");
2784 if inner.contains(&placeholder) {
2785 inner = inner.replace(&placeholder, value);
2786 }
2787 }
2788 config_refs.push(inner);
2789 }
2790 let _ = name; // suppress unused warning
2791 }
2792
2793 // Parse limit param for cursor clamping
2794 let cursor_limit: Option<u64> = merged_params.get("limit").and_then(|s| s.parse().ok());
2795
2796 // Build the workload-params root kernel first. This is the
2797 // canonical home for every declared workload parameter —
2798 // one `final <name> := <literal>` per param, compiled into
2799 // a stand-alone kernel whose outputs every descendant
2800 // `materialize_wiring_from_outer`s through. Replaces the prior approach
2801 // of patching params into multiple places (per-op binding
2802 // text substitution, per-kernel `final` injection in
2803 // `build_scope`). See `nmbrs-runtime::params`.
2804 // `build_workload_params_kernel` already prefixes its error
2805 // with "workload params kernel:" and appends the generated
2806 // source for diagnosis — propagate it verbatim rather than
2807 // re-wrapping (which doubled the prefix and dropped the
2808 // source dump).
2809 let params_kernel = crate::params::build_workload_params_kernel(&workload_params)?;
2810
2811 // Build the workload kernel directly as a subscope of the
2812 // params kernel via the typed PolydatKernel-controlled
2813 // construction path. Cells from params flow in via the
2814 // cascade — no late-binding step required.
2815 // Build the workload kernel as a subscope of the params
2816 // kernel and share ONE Arc across both consumers (the
2817 // scope tree's canonical reference AND the OpBuilder's
2818 // source kernel). A second materialize_subscope would
2819 // produce a sibling kernel with its own freshly-seeded
2820 // shared cells — disconnected from the canonical, so
2821 // result-binding writes from one chain wouldn't be visible
2822 // to the other. Sharing the Arc keeps the cell handles
2823 // identical end-to-end: detect_dialect's writes reach
2824 // await_index's reads through the same Mutex<Value>.
2825 // SRD-13f Push D: the workload-root kernel still owns the
2826 // canonical workload-scope bindings, but descendant kernels
2827 // (phase kernel, op-template kernel) carry a cascade copy
2828 // of the workload-level Polydat source as local matter — so
2829 // fiber.main_kernel evaluates dynamic workload bindings
2830 // (e.g. cycle-dependent) on its own state per cycle. See
2831 // `compile_from_scope` (and its callers in
2832 // `executor.rs::run_phase`) for the cascade-copy plumbing.
2833 // No eager pull at workload-root construction: that would
2834 // (a) fire side-effecting nodes like `testkit_throw_at` outside the
2835 // phase cascade context, and (b) cache stale values for
2836 // cycle-dependent bindings.
2837 let workload_canonical_kernel: std::sync::Arc<crate::scope_kernel::ScopeKernel> =
2838 std::sync::Arc::new(
2839 build_workload_root_kernel(
2840 ¶ms_kernel,
2841 &all_ops_for_compile,
2842 workload_dir,
2843 polydat_lib_paths.clone(),
2844 strict,
2845 &config_refs,
2846 "outer workload bindings",
2847 cursor_limit,
2848 &workload_params,
2849 workload_level_polydat.as_deref(),
2850 )
2851 .map_err(|e| format!("outer workload bindings: {e}"))?,
2852 );
2853 let kernel = workload_canonical_kernel.clone();
2854
2855 // Extract output manifest and folded constant values from outer kernel
2856 // === Polydat Config Resolution (all done before kernel is consumed) ===
2857 // (The `cycles=` / `concurrency=` resolution that used to
2858 // happen here fed the now-deleted single-activity branch.
2859 // The phased path resolves these per-phase inside
2860 // `run_phase` via the phase-scope Polydat Kernel.)
2861
2862 // Collect phases that are inside scenario for_each groups — these have
2863 // iteration variables resolved at runtime, not pre-resolution time.
2864 fn collect_grouped_phases(
2865 nodes: &[nmbrs_workload::model::ScenarioNode],
2866 in_group: bool,
2867 out: &mut std::collections::HashSet<String>,
2868 ) {
2869 for node in nodes {
2870 match node {
2871 nmbrs_workload::model::ScenarioNode::Phase(name) => {
2872 if in_group {
2873 out.insert(name.clone());
2874 }
2875 }
2876 nmbrs_workload::model::ScenarioNode::Comprehension { children, .. }
2877 | nmbrs_workload::model::ScenarioNode::DoWhile { children, .. }
2878 | nmbrs_workload::model::ScenarioNode::DoUntil { children, .. } => {
2879 collect_grouped_phases(children, true, out);
2880 }
2881 nmbrs_workload::model::ScenarioNode::IncludedScenario { children, .. } => {
2882 // Inclusion is transparent — children inherit
2883 // whatever grouping context wrapped the
2884 // include site. We pass `in_group` through
2885 // so a `scenario:` reference at top level of
2886 // a scenario doesn't artificially mark its
2887 // phases as grouped.
2888 collect_grouped_phases(children, in_group, out);
2889 }
2890 nmbrs_workload::model::ScenarioNode::Bindings { children, .. } => {
2891 // Scenario-tree `bindings:` (and the `set:`
2892 // sugar that lowers to it) is transparent
2893 // for grouping — it doesn't introduce
2894 // iteration. Pass `in_group` through.
2895 collect_grouped_phases(children, in_group, out);
2896 }
2897 }
2898 }
2899 }
2900 let mut grouped_phases = std::collections::HashSet::new();
2901 for nodes in scenarios.values() {
2902 collect_grouped_phases(nodes, false, &mut grouped_phases);
2903 }
2904
2905 // Pre-resolve phase cycles (skip phases with for_each or in scenario groups)
2906 let mut resolved_phase_cycles: HashMap<String, Option<u64>> = HashMap::new();
2907 for (name, phase) in &phases {
2908 if phase.for_each.is_some() || grouped_phases.contains(name) {
2909 continue;
2910 }
2911 let resolved = phase.cycles.as_ref().and_then(|s| {
2912 let expanded = expand_workload_params(s, &workload_params);
2913 resolve_polydat_config(&expanded, &kernel)
2914 });
2915 resolved_phase_cycles.insert(name.clone(), resolved);
2916 }
2917
2918 // Strip workload-level adapter/driver from op params
2919 // (adapter is resolved per-phase/per-op, not from workload params)
2920 for op in &mut all_ops_for_compile {
2921 op.params.remove("adapter");
2922 op.params.remove("driver");
2923 }
2924 for ops in phase_raw_ops.values_mut() {
2925 for op in ops.iter_mut() {
2926 op.params.remove("adapter");
2927 op.params.remove("driver");
2928 }
2929 }
2930
2931 let builder = Arc::new(OpBuilder::new(kernel));
2932
2933 // Unification — the scenario-tree executor is the sole
2934 // execution path. `Workload::synthesize_default_phase` (called
2935 // at load time) guarantees `phases` is non-empty for any
2936 // workload that has work to do; the empty-ops error fired
2937 // earlier in this fn covers the "literally nothing to run"
2938 // case. The legacy single-activity branch is gone.
2939 {
2940 // --- Phased execution ---
2941 let scenario_name = params
2942 .get("scenario")
2943 .map(|s| s.as_str())
2944 .unwrap_or("default");
2945 let scenario_nodes = resolve_scenario(&scenarios, &phase_order, scenario_name)?;
2946
2947 // Build the canonical scope tree (SRD 18b §"Canonical
2948 // traversal"). Mirrors the scenario tree 1:1 with parent
2949 // pointers, depth, and pragma slots. Today consumed by
2950 // observer pre-mapping and diagnostic display; future
2951 // steps drive execution from this tree directly.
2952 let scope_tree = {
2953 let mut t = crate::scope_tree::ScopeTree::build(scenario_name, &scenario_nodes);
2954 // Populate phase-leaf pragmas from each phase's GK
2955 // source and chain each scope's `PragmaSet` to its
2956 // parent's. SRD 18b §"Pragma chain along the scope
2957 // tree"; SRD 15 §"Pragma Scope".
2958 t.populate_pragmas(&phases);
2959 // Validate iter-var name uniqueness against workload
2960 // params and enclosing iter vars. Aliasing creates an
2961 // unambiguous spec-evaluation case the runtime can't
2962 // disambiguate; reject up-front.
2963 let wp_names: std::collections::HashSet<String> =
2964 workload_params.keys().cloned().collect();
2965 t.validate_iter_var_uniqueness(&wp_names)?;
2966 // SRD-83 follow-up — load-time authoring lints: every
2967 // `errors:` router spec must parse (bad verbs fail the
2968 // load, not the first runtime error), a router without a
2969 // catch-all rule warns, and `metric()` families in
2970 // stop/gate predicates are checked against the instrument
2971 // namespace (an unregistered family reads 0.0 silently).
2972 for w in crate::workload_lint::lint_workload(
2973 &workload.stop_when,
2974 phases.iter().map(|(k, v)| (k.as_str(), v)),
2975 )? {
2976 crate::diag!(crate::observer::LogLevel::Warn, "{w}");
2977 }
2978 // SRD-13d Phase 6 — extend the scope tree with
2979 // op-template children of every Phase node so the
2980 // op tier is visible to the elision classifier
2981 // and downstream diagnostics.
2982 t.extend_with_op_templates(&phases);
2983 // SRD-13d Phase 3 — workload-init scope-elision
2984 // pre-walk. Reads `HasGkMatter` on each AST node
2985 // and marks the corresponding scope-tree node
2986 // `materialised` (own kernel) or elided (binds
2987 // through parent). Conservative predicate today
2988 // (Definitions ⇒ materialise without hash-subset
2989 // refinement); Phase 6 tightens it.
2990 //
2991 // Scoped fields rather than `&workload` because
2992 // `workload.ops` was moved earlier in this fn —
2993 // the classifier reads only bindings + params +
2994 // phases anyway.
2995 let classify_inputs = crate::scope_elision::ClassifyInputs {
2996 bindings: &workload.bindings,
2997 params: &workload.params,
2998 phases: &phases,
2999 };
3000 crate::scope_elision::classify_and_mark(&mut t, &classify_inputs);
3001 std::sync::Arc::new(t)
3002 };
3003
3004 // dryrun=kernels: register the ride-along visitor BEFORE
3005 // the install loop so every install_kernel call (root
3006 // workload kernel + per-scope kernels in DFS pre-order)
3007 // streams its polydat source to stdout. Cleared after
3008 // the install loop runs (see the matching short-circuit
3009 // a few hundred lines below).
3010 if params.get("dryrun").map(|s| s.as_str()) == Some("kernels") {
3011 print_kernel_dump_header();
3012 crate::scope_tree::set_kernel_install_visitor(Some(Box::new(|node, _idx, kernel| {
3013 print_kernel_for_scope(node, kernel);
3014 })));
3015 }
3016
3017 // Install the workload-params module on the session node
3018 // (the workload root's parent) so identity chains
3019 // (`ScopeTree::ancestor_kernels` → the SRD-44/SRD-77
3020 // provenance hashes) include the module whose const
3021 // slots carry every param VALUE. The workload kernel is
3022 // built as a subscope of this module; installing it here
3023 // records that same relationship on the tree. Nearest-
3024 // ancestor kernel lookups are unaffected — the workload
3025 // root always has its own kernel, so walks stop there.
3026 scope_tree.install_kernel(scope_tree.root, std::sync::Arc::new(params_kernel));
3027
3028 // Install the canonical workload kernel (SRD 18b §"Iter
3029 // vars as scope outputs"). After this, intermediate
3030 // scopes (for_each, for_combinations, …) install their
3031 // own kernels in DFS pre-order below — each one's
3032 // synthesis reads its parent's manifest via the standard
3033 // Polydat API on the parent's installed kernel.
3034 scope_tree.install_kernel(scope_tree.workload_root_idx(), workload_canonical_kernel);
3035
3036 // M3.2: install per-scope kernels for for_each /
3037 // for_combinations nodes. Each kernel re-exports its
3038 // iteration variables and any referenced inherited
3039 // values as outputs (`const x := x` passthrough), so
3040 // children's standard `materialize_wiring_from_outer(parent)`
3041 // chains inheritance through arbitrary nesting depth
3042 // — no caller-side scope-tree walking for name
3043 // resolution at runtime.
3044 let workload_dir_owned: Option<std::path::PathBuf> = workload_dir.map(|p| p.to_path_buf());
3045 // M3.4b: scope kinds get categorized for synthesis.
3046 // For-comprehensions (ForEach, ForCombinations,
3047 // ForEachUnion) carry tuple iteration vars; do-loops
3048 // (DoWhile, DoUntil) carry an optional counter +
3049 // condition expression. Both produce installed kernels
3050 // that the unified dispatch_comprehension reads from.
3051 // reason: function-local, short-lived spec list built once per
3052 // install pass; the `OpTemplate`/`ParsedOp` variant dominates the
3053 // size, but boxing it would ripple `Box::new` + deref across every
3054 // construction and destructuring match arm below for no real gain
3055 // on a vector that is consumed immediately.
3056 #[allow(clippy::large_enum_variant)]
3057 enum InstallSpec {
3058 ForComprehension {
3059 idx: crate::scope_tree::ScopeNodeIdx,
3060 iter_vars: Vec<String>,
3061 spec_exprs: Vec<String>,
3062 /// SRD-13f Push E: phase-level `bindings:` folded
3063 /// into the for_each scope kernel when the phase
3064 /// declares both `for_each:` AND `bindings:`. The
3065 /// single install at the phase node materializes
3066 /// one kernel carrying both the iter-var
3067 /// declarations AND the phase-level bindings.
3068 /// Empty for pure-comprehension scope nodes
3069 /// (scenario-level `for_each:`) and for phase
3070 /// nodes without own bindings.
3071 phase_bindings: nmbrs_workload::model::BindingsDef,
3072 },
3073 DoLoop {
3074 idx: crate::scope_tree::ScopeNodeIdx,
3075 counter: Option<String>,
3076 condition: String,
3077 },
3078 /// SRD-13d Phase 9 — install a kernel for an
3079 /// op-template scope that classified as
3080 /// `materialised`. Flattened op-templates
3081 /// (`materialised == false`) get no install spec;
3082 /// their dispensers reach the parent kernel via
3083 /// `nearest_materialised`.
3084 OpTemplate {
3085 idx: crate::scope_tree::ScopeNodeIdx,
3086 op: nmbrs_workload::model::ParsedOp,
3087 },
3088 /// SRD-13d Phase 9 — install a phase-scope kernel
3089 /// for a phase that declares its own `bindings:`
3090 /// block (and no `for_each:` — that case is owned
3091 /// by the for_each install spec at the same node).
3092 /// Phases without bindings AND without for_each
3093 /// emit no install spec; the closure-lifetime
3094 /// kernel reference is the parent's by walker
3095 /// fall-through.
3096 /// Phase-scope kernel install for phases declaring
3097 /// their own `bindings:` block (and no `for_each:` —
3098 /// that case is owned by the for_each install spec
3099 /// at the same node). Also covers scenario-tree
3100 /// `bindings:` nodes (and the `set:` sugar that
3101 /// lowers to them) — the synthesizer is identical
3102 /// for both: parent-cascaded externs + the body's
3103 /// authored matter.
3104 Bindings {
3105 idx: crate::scope_tree::ScopeNodeIdx,
3106 bindings: nmbrs_workload::model::BindingsDef,
3107 },
3108 }
3109 let install_specs: Vec<InstallSpec> = scope_tree
3110 .iter_dfs()
3111 .filter_map(|(idx, node)| match &node.kind {
3112 crate::scope_tree::ScopeKind::Comprehension { comprehension } => {
3113 // Representative iter_vars + spec_exprs for
3114 // synthesis: dedup'd by var name. Walks the
3115 // algebra AST directly via coordinate_specs
3116 // — same dedup semantics the legacy
3117 // scalar_bindings/Union-flatten path
3118 // produced.
3119 let pairs = comprehension.coordinate_specs();
3120 let vars: Vec<String> = pairs.iter().map(|(v, _)| v.clone()).collect();
3121 let specs: Vec<String> = pairs.iter().map(|(_, e)| e.clone()).collect();
3122 Some(InstallSpec::ForComprehension {
3123 idx,
3124 iter_vars: vars,
3125 spec_exprs: specs,
3126 // Scope-tree Comprehension nodes (scenario-
3127 // level `for_each:`) carry no phase-level
3128 // bindings; the wrapping phase has its own
3129 // scope-tree node and its own install spec
3130 // (PhaseBindings or another ForComprehension).
3131 phase_bindings: nmbrs_workload::model::BindingsDef::default(),
3132 })
3133 }
3134 crate::scope_tree::ScopeKind::DoWhile { condition, counter } => {
3135 Some(InstallSpec::DoLoop {
3136 idx,
3137 counter: counter.clone(),
3138 condition: condition.clone(),
3139 })
3140 }
3141 crate::scope_tree::ScopeKind::DoUntil { condition, counter } => {
3142 Some(InstallSpec::DoLoop {
3143 idx,
3144 counter: counter.clone(),
3145 condition: condition.clone(),
3146 })
3147 }
3148 crate::scope_tree::ScopeKind::Phase { name } => {
3149 // Phase-scope kernel installation, matter-gated
3150 // per SRD-13d / SRD-67. Three cases:
3151 //
3152 // 1. Phase declares `for_each:` — treat as
3153 // a single-clause tuple comprehension.
3154 // The for_each scope owns the phase
3155 // node's kernel; phase `bindings:` (if
3156 // also present) need to fold into that
3157 // scope's matter (deferred — the legacy
3158 // parser-merge path keeps the bindings
3159 // reachable via op-bindings until the
3160 // for_each-with-bindings synthesizer
3161 // lands).
3162 //
3163 // 2. Phase declares only `bindings:` — own
3164 // subscope from those bindings layered
3165 // over the parent kernel.
3166 //
3167 // 3. Neither — no install spec; phase
3168 // scope's closure inherits the parent's
3169 // kernel reference via the walker's
3170 // fall-through (the matter-gated
3171 // pass-through).
3172 let phase = phases.get(name.as_str())?;
3173 if let Some(spec) = phase.for_each.as_ref() {
3174 if !phase.metrics.is_empty() {
3175 // Phase-level `metrics:` + phase-level
3176 // `for_each:` isn't supported in the
3177 // initial ship: the for_each scope
3178 // synthesiser doesn't yet thread the
3179 // metric-binding augmentation through, and
3180 // the per-iteration completion-pull
3181 // semantics want their own design pass.
3182 // Reject loudly rather than silently
3183 // dropping the metrics.
3184 crate::diag!(
3185 crate::observer::LogLevel::Error,
3186 "phase '{name}': phase-level `metrics:` + phase-level \
3187 `for_each:` is not supported yet. Move the for_each to \
3188 scenario-tree level (so each iteration is its own phase \
3189 activation, each with its own metrics), or drop one. \
3190 Phase will be skipped.",
3191 );
3192 return None;
3193 }
3194 if phase.poll.is_some() {
3195 // SRD-75: phase-poll + phase-level
3196 // for_each isn't supported in the
3197 // initial ship. The for_each scope
3198 // synthesizer doesn't yet thread the
3199 // poll augmentation through, and the
3200 // combination's semantics
3201 // (iterate-and-synchronize-each-cell?
3202 // iterate-while-synchronizing?) wants
3203 // its own design pass. Reject loudly.
3204 crate::diag!(
3205 crate::observer::LogLevel::Error,
3206 "phase '{name}': `poll:` + phase-level `for_each:` is not \
3207 supported in the initial ship of SRD-75. Move the for_each \
3208 to scenario-tree level (so each iter is its own phase \
3209 activation), or drop one of the two. Phase will be skipped.",
3210 );
3211 return None;
3212 }
3213 // Delegate the for_each grammar to polydat (the
3214 // single owner): `parse_inline` handles single- AND
3215 // multi-clause, and `coordinate_specs()` yields the
3216 // per-clause (var, spec) pairs — identical to the
3217 // scenario-level path above. A single-clause spec
3218 // yields exactly one pair, preserving prior behaviour.
3219 let comp = match polydat::iteration::comprehension::spec::parse_inline(spec)
3220 {
3221 Ok(c) => c,
3222 Err(e) => {
3223 crate::diag!(
3224 crate::observer::LogLevel::Error,
3225 "phase '{name}' for_each '{spec}': {e}"
3226 );
3227 return None;
3228 }
3229 };
3230 let pairs = comp.coordinate_specs();
3231 let iter_vars: Vec<String> = pairs.iter().map(|(v, _)| v.clone()).collect();
3232 let spec_exprs: Vec<String> =
3233 pairs.iter().map(|(_, e)| e.clone()).collect();
3234 // SRD-13f Push E: phases declaring both `for_each:`
3235 // and `bindings:` fold the bindings into the for_each
3236 // scope kernel (one kernel, one install). Pure-for_each
3237 // phases pass an empty `BindingsDef` (no-op).
3238 //
3239 // Route through the SAME phase-scope synthesis the
3240 // non-for_each branch uses, so phase-level `metrics:` /
3241 // `poll:` and an inline `optimize.objective` (SRD-86)
3242 // are folded into the for_each kernel too — not just the
3243 // author's raw `bindings:`. The synthesizer returns the
3244 // raw bindings unchanged when there is nothing to add,
3245 // so plain for_each phases are unaffected.
3246 let phase_bindings =
3247 match crate::scope::synthesize_phase_scope_bindings(phase) {
3248 Ok(b) => b,
3249 Err(e) => {
3250 crate::diag!(
3251 crate::observer::LogLevel::Error,
3252 "phase '{name}': phase-scope synthesis: {e}"
3253 );
3254 return None;
3255 }
3256 };
3257 Some(InstallSpec::ForComprehension {
3258 idx,
3259 iter_vars,
3260 spec_exprs,
3261 phase_bindings,
3262 })
3263 } else {
3264 // SRD-75: when the phase declares `poll:`,
3265 // synthesise capture-as-shared-cell
3266 // declarations + the `__poll_until`
3267 // predicate binding into the phase scope's
3268 // bindings, even when the phase has no
3269 // author-declared `bindings:` of its own.
3270 // The synthesised bindings flow through the
3271 // same `build_phase_scope_kernel` path as
3272 // any other phase-level bindings; phase-
3273 // poll has no synthesizer-specific code
3274 // path.
3275 let synth = match crate::scope::synthesize_phase_scope_bindings(phase) {
3276 Ok(b) => b,
3277 Err(e) => {
3278 crate::diag!(
3279 crate::observer::LogLevel::Error,
3280 "phase '{name}': SRD-75 phase-poll synthesis: {e}",
3281 );
3282 return None;
3283 }
3284 };
3285 if !synth.is_empty() {
3286 Some(InstallSpec::Bindings {
3287 idx,
3288 bindings: synth,
3289 })
3290 } else {
3291 None
3292 }
3293 }
3294 }
3295 crate::scope_tree::ScopeKind::OpTemplate { name } => {
3296 // SRD-13d Phase 9: install a per-op kernel
3297 // ONLY for materialised op-templates. The
3298 // scope-elision pre-walk already set the
3299 // mark; we just gate on it here.
3300 if node.materialised != Some(true) {
3301 return None;
3302 }
3303 // Find the ParsedOp by walking up to the
3304 // OWNING phase first, then resolving by name
3305 // within that phase. Two phases can both
3306 // declare an op named e.g. `select_ann` with
3307 // very different bodies; a flat
3308 // `phases.values().flat_map(|p| p.ops.iter())
3309 // .find(...)` would pick whichever phase the
3310 // HashMap iterator yielded first, silently
3311 // compiling pvs_query's body into ann_query's
3312 // op-template kernel (and vice versa).
3313 let owning_phase: Option<&str> = {
3314 let mut cursor = scope_tree.nodes[idx].parent;
3315 let mut found: Option<&str> = None;
3316 while let Some(p) = cursor {
3317 if let crate::scope_tree::ScopeKind::Phase { name: pname } =
3318 &scope_tree.nodes[p].kind
3319 {
3320 found = Some(pname.as_str());
3321 break;
3322 }
3323 cursor = scope_tree.nodes[p].parent;
3324 }
3325 found
3326 };
3327 owning_phase
3328 .and_then(|pname| phases.get(pname))
3329 .and_then(|phase| phase.ops.iter().find(|op| op.name == *name))
3330 .cloned()
3331 .map(|op| InstallSpec::OpTemplate { idx, op })
3332 }
3333 crate::scope_tree::ScopeKind::Bindings { source } => {
3334 // Scenario-tree `bindings:` block (also the
3335 // canonical lowered form of `set:` sugar)
3336 // installs through the same synthesizer
3337 // phases use for their own `bindings:`. The
3338 // body source compiles into a scope kernel
3339 // that publishes its `final`/`init`/cycle
3340 // bindings as outputs; descendants read
3341 // those through the canonical scope chain
3342 // (no HashMap merges, no side-channel
3343 // resolvers). Shadowing of upstream names is
3344 // enforced by the local-final transit-
3345 // suppression rule in
3346 // `materialize_wiring_from_outer` — uniform
3347 // with every other scope.
3348 Some(InstallSpec::Bindings {
3349 idx,
3350 bindings: nmbrs_workload::model::BindingsDef::PolydatSource(source.clone()),
3351 })
3352 }
3353 _ => None,
3354 })
3355 .collect();
3356
3357 for install_spec in install_specs {
3358 let idx = match &install_spec {
3359 InstallSpec::ForComprehension { idx, .. } => *idx,
3360 InstallSpec::DoLoop { idx, .. } => *idx,
3361 InstallSpec::OpTemplate { idx, .. } => *idx,
3362 InstallSpec::Bindings { idx, .. } => *idx,
3363 };
3364 // Nearest installed ancestor — skips Scenario /
3365 // IncludedScenario nodes that don't install kernels
3366 // (those are pass-through structural).
3367 let parent_kernel = {
3368 let mut cursor = scope_tree.nodes[idx].parent;
3369 let mut found: Option<std::sync::Arc<crate::scope_kernel::ScopeKernel>> = None;
3370 while let Some(p) = cursor {
3371 if let Some(k) = scope_tree.nodes[p].cached_kernel.get() {
3372 found = Some(k.clone());
3373 break;
3374 }
3375 cursor = scope_tree.nodes[p].parent;
3376 }
3377 found.expect("workload root always has an installed kernel")
3378 };
3379 let parent_manifest = extract_manifest(parent_kernel.program());
3380 let context = format!("scope idx {idx} ({})", scope_tree.nodes[idx].kind.label(),);
3381
3382 let result = match install_spec {
3383 InstallSpec::ForComprehension {
3384 iter_vars,
3385 spec_exprs,
3386 phase_bindings,
3387 ..
3388 } => {
3389 let bindings: Vec<(String, String)> = iter_vars
3390 .iter()
3391 .cloned()
3392 .zip(spec_exprs.iter().cloned())
3393 .collect();
3394 // SRD-13f Push E: translate phase-level
3395 // `bindings:` into the Polydat source the
3396 // for_each synthesiser folds in. PolydatSource
3397 // form passes verbatim; Map form serialises
3398 // to `name := expr\n` lines.
3399 let phase_bindings_source = match phase_bindings {
3400 nmbrs_workload::model::BindingsDef::PolydatSource(s)
3401 if !s.trim().is_empty() =>
3402 {
3403 Some(s)
3404 }
3405 nmbrs_workload::model::BindingsDef::Map(m) if !m.is_empty() => {
3406 let mut out = String::new();
3407 for (name, expr) in &m {
3408 out.push_str(&format!("{name} := {expr}\n"));
3409 }
3410 Some(out)
3411 }
3412 _ => None,
3413 };
3414 crate::scope_synth::build_for_each_scope_kernel(
3415 &bindings,
3416 &parent_manifest,
3417 &parent_kernel,
3418 &workload_params,
3419 polydat_lib_paths.clone(),
3420 workload_dir_owned.as_deref(),
3421 strict,
3422 &context,
3423 phase_bindings_source.as_deref(),
3424 )
3425 }
3426 InstallSpec::DoLoop {
3427 counter, condition, ..
3428 } => crate::scope::build_do_loop_scope_kernel(
3429 counter.as_deref(),
3430 &condition,
3431 &parent_manifest,
3432 &parent_kernel,
3433 &workload_params,
3434 polydat_lib_paths.clone(),
3435 workload_dir_owned.as_deref(),
3436 strict,
3437 &context,
3438 ),
3439 InstallSpec::OpTemplate { op, .. } => {
3440 // SRD-13d Phase 9 — synthesize the op-
3441 // template kernel layered over the parent.
3442 // Includes op-level bindings + cascaded
3443 // parent externs; materialize_wiring_from_outer chains
3444 // values in at runtime.
3445 // The scope module stays on the node for the fiber
3446 // engine's per-op images (`crate::fiber_engine`).
3447 crate::scope::build_op_template_scope_kernel(
3448 &op,
3449 &parent_manifest,
3450 &parent_kernel,
3451 &workload_params,
3452 polydat_lib_paths.clone(),
3453 workload_dir_owned.as_deref(),
3454 strict,
3455 kernel_opt,
3456 &context,
3457 )
3458 }
3459 InstallSpec::Bindings { bindings, .. } => {
3460 // Single install path for both phase-level
3461 // `bindings:` and scenario-tree-level
3462 // `bindings:` (including the `set:` sugar
3463 // form that lowers to it). The synthesizer
3464 // cascades workload params + parent
3465 // outputs/inputs as externs and appends the
3466 // body verbatim; Polydat handles workload-param
3467 // interpolation and expression evaluation
3468 // at compile time. Lexical shadowing of an
3469 // upstream `final NAME` by the body's own
3470 // `const NAME := …` is enforced by the
3471 // local-final transit-suppression rule in
3472 // `materialize_wiring_from_outer`.
3473 crate::scope::build_phase_scope_kernel(
3474 &bindings,
3475 &parent_manifest,
3476 &parent_kernel,
3477 &workload_params,
3478 polydat_lib_paths.clone(),
3479 workload_dir_owned.as_deref(),
3480 strict,
3481 &context,
3482 )
3483 }
3484 };
3485
3486 match result {
3487 Ok(kernel) => {
3488 let _ = scope_tree.install_kernel(idx, std::sync::Arc::new(kernel));
3489 }
3490 Err(e) => {
3491 // Kernel synthesis failure is a hard error
3492 // regardless of strict mode: a phase whose GK
3493 // source doesn't compile literally cannot run.
3494 // Letting the walk continue past the failure
3495 // only delays the bad news — the phase will
3496 // fail mid-run with a less helpful diagnostic
3497 // (or, worse, run with stale / partial
3498 // kernels installed for sibling scopes). The
3499 // earlier "warn-and-continue" behavior dates
3500 // from before strict mode existed; with the
3501 // strict-mode behavior being the only sane
3502 // default, the non-strict branch was
3503 // effectively a footgun that turned compile
3504 // errors into silent partial runs.
3505 return Err(format!("scope kernel synthesis failed: {e}"));
3506 }
3507 }
3508 }
3509
3510 crate::diag!(
3511 crate::observer::LogLevel::Info,
3512 "scenario '{scenario_name}':\n{}",
3513 format_scenario_tree(&scenario_nodes, &phases)
3514 );
3515
3516 // Observer is passed from the caller (default: StderrObserver).
3517
3518 // ─── Unified walker: structural pre-map pass ─────────────────
3519 //
3520 // Per SRD 18b §"Single Walker Contract", there is ONE walker
3521 // function (`crate::executor::execute_tree`). It runs twice
3522 // here: first at depth=Phase to populate the scene tree (so
3523 // resume_plan / declare_scene_tree_phases / pre_map_pending_uses
3524 // can read the populated tree), then again at the configured
3525 // depth to actually execute. SceneTree::push is idempotent
3526 // by `(parent, kind, name)` so the second pass re-encounters
3527 // every node from the first without duplicating.
3528 //
3529 // The pre-map ExecCtx uses stub post-pre-map fields
3530 // (checkpoint_writer = None, fresh resume_plan, fresh
3531 // resource_pool); they're updated to the real values after
3532 // pre-map produces the tree.
3533 let schedule_spec = std::sync::Arc::new(match params.get("schedule") {
3534 Some(s) => crate::scheduler::ScheduleSpec::parse(s)
3535 .map_err(|e| format!("schedule= param: {e}"))?,
3536 None => crate::scheduler::ScheduleSpec::default_serial(),
3537 });
3538 // `&str` → `&'static str` so the activity config can
3539 // carry the mode label across thread boundaries
3540 // without lifetime gymnastics. Every mode the resolver
3541 // above produces must appear here, otherwise the
3542 // wrapper-install path silently sees `None` and
3543 // DRYRUN never installs.
3544 let dry_run_static: Option<&'static str> = match dry_run {
3545 Some("silent") => Some("silent"),
3546 Some("fields") => Some("fields"),
3547 Some("cycle") => Some("cycle"),
3548 Some("op") => Some("op"),
3549 _ => None,
3550 };
3551
3552 // phases=<pattern> filter (bareword / glob / regex). When
3553 // unset, every phase runs. When set, the planner's
3554 // scenario-tree walker skips phase activations whose name
3555 // doesn't match AND elides scope subtrees with no
3556 // matching descendant.
3557 let phase_filter: Option<Arc<crate::phase_filter::PhasePattern>> = match params
3558 .get("phases")
3559 .map(|s| s.as_str())
3560 .filter(|s| !s.is_empty())
3561 {
3562 None => None,
3563 Some(src) => {
3564 let pat = crate::phase_filter::PhasePattern::parse(src)
3565 .map_err(|e| format!("phases= param: {e}"))?;
3566 crate::diag!(
3567 crate::observer::LogLevel::Info,
3568 "phases=<filter>: pattern '{src}' ({}{})",
3569 if pat.negated() { "negated " } else { "" },
3570 pat.dialect().as_str()
3571 );
3572 Some(Arc::new(pat))
3573 }
3574 };
3575 let resource_pool = Arc::new(crate::resource_pool::ResourcePool::new());
3576 // SRD-104 — point the resource bridge every kernel tree resolves
3577 // through at this session's pool, so kernel nodes can reach a live
3578 // pool-owned resource (e.g. a CQL session handle) by fingerprint via
3579 // their tree's resource scope. The pool stays the definitive owner.
3580 crate::resource_pool::install_accessor(&resource_pool);
3581 let initial_scene_tree_path = vec![crate::checkpoint::PathSegment::Scenario(
3582 scenario_name.to_string(),
3583 )];
3584 // SRD-82 — the session root error policy. Every shell resolves
3585 // its own from this (inherit or derive); equal configs share
3586 // one instance, parsed once per session.
3587 let root_error_policy = crate::error_policy::ErrorPolicy::root(
3588 crate::error_policy::PolicyConfig::new(error_spec.clone(), error_rate_max),
3589 );
3590 // SRD-83 — build the workload execution shell (SRD-82's
3591 // outermost shell). Its stop conditions are the workload's
3592 // `stop_when:` declarations whose `each:` names the workload
3593 // itself (`self`/`workload`), compiled once against the
3594 // workload root's cached kernel — the same native-scope binding
3595 // every other shell uses, never a conjured root. The remaining
3596 // `each: phase` declarations fan out to the per-phase activity
3597 // build (see `executor.rs`); the unfiltered list rides on
3598 // `ExecCtx.workload_stop_when` for that gathering.
3599 //
3600 // The error-rate breach stays a per-phase concern (each phase
3601 // already trips on its own `error_rate_max`), so no default
3602 // error-rate condition is installed at the workload aggregate.
3603 let workload_shell = {
3604 use nmbrs_workload::model::ScopeLevel;
3605 // SRD-82 Part 3/6 — the scenario-graph default `*Failed:stop`:
3606 // any child phase whose outcome is Failed halts the remaining
3607 // walk and records `Interrupted + Failed` (a fault). Expressed
3608 // as the SRD-83 stop condition `children_failed > 0` with a
3609 // `fail` effect, so it rides the same workload-shell mechanism
3610 // as declared conditions and reaches concurrent / cross-subtree
3611 // siblings the local `Err` cascade can't. First in the list →
3612 // a failed child trips it before any declared graceful rule.
3613 let mut conditions: Vec<crate::stop_conditions::StopConditionDecl> =
3614 vec![crate::stop_conditions::StopConditionDecl {
3615 when: "children_failed > 0".to_string(),
3616 effect: crate::phase_outcome::Outcome::failed(),
3617 reason: None,
3618 target: crate::stop_conditions::StopScope::Workload,
3619 // The default stop-on-error drains cooperatively.
3620 cancel_ops: false,
3621 }];
3622 // Declared workload-level conditions (`each ∋ self|workload`).
3623 // A declared trip defaults to a graceful `stop`
3624 // (Interrupted+Succeeded): nothing failed, later phases are
3625 // deliberately skipped.
3626 conditions.extend(
3627 workload
3628 .stop_when
3629 .iter()
3630 .filter(|c| {
3631 c.each
3632 .iter()
3633 .any(|l| matches!(l, ScopeLevel::SelfScope | ScopeLevel::Workload))
3634 })
3635 .map(|c| crate::stop_conditions::StopConditionDecl {
3636 // Same `{param}` interpolation as phase-level stop_when
3637 // (executor build_activity_config_for_phase) so a workload
3638 // breaker threshold can be a modular workload param.
3639 when: expand_workload_params(&c.when, &workload_params),
3640 effect: crate::stop_conditions::StopConditionDecl::effect_from_str(
3641 c.effect.as_deref(),
3642 crate::phase_outcome::Outcome::interrupted(),
3643 ),
3644 reason: None,
3645 // Detected at the workload shell; `at:` (default =
3646 // innermost of `per:`/`each:`, here `workload`) selects
3647 // the action scope.
3648 target: crate::executor::resolve_stop_scope(c.at, &c.each),
3649 // `action: abort` → cancel in-flight ops at the trip site.
3650 cancel_ops: crate::stop_conditions::StopConditionDecl::action_cancels_ops(
3651 c.effect.as_deref(),
3652 ),
3653 }),
3654 );
3655 let set = match scope_tree.nodes[scope_tree.workload_root_idx()]
3656 .cached_kernel
3657 .get()
3658 {
3659 Some(root_kernel) => crate::stop_conditions::StopConditionSet::build_for_phase(
3660 root_kernel,
3661 &conditions,
3662 )
3663 .unwrap_or_else(|e| {
3664 crate::diag!(
3665 crate::observer::LogLevel::Error,
3666 "workload stop-condition compile failed: {e}"
3667 );
3668 crate::stop_conditions::StopConditionSet::empty()
3669 }),
3670 _ => crate::stop_conditions::StopConditionSet::empty(),
3671 };
3672 std::sync::Arc::new(crate::workload_shell::WorkloadShell::new(set))
3673 };
3674
3675 // SRD-71 P3 — phase-scoped CLI parameter overrides
3676 // (`<phase-pattern>.<param>=<value>`). Parsed from the raw
3677 // args (parse_params skips dotted keys), validated against
3678 // the declared phase names so a never-matching pattern is
3679 // a startup error instead of a silent no-op.
3680 let phase_param_overrides =
3681 std::sync::Arc::new(crate::phase_params::parse_overrides(&args)?);
3682 crate::phase_params::validate_against_phases(
3683 &phase_param_overrides,
3684 phases.keys().map(|s| s.as_str()),
3685 )?;
3686
3687 let mut exec_ctx = crate::executor::ExecCtx {
3688 phases: phases.clone(),
3689 optimize_objective: None,
3690 optimize_objective_value: None,
3691 optimize_servo: None,
3692 phase_param_overrides,
3693 workload_shell,
3694 workload_stop_when: workload.stop_when.clone(),
3695 daemon_stop: None,
3696 workload_readouts: workload_readouts.clone(),
3697 cli_readout_override: cli_readout_override.clone(),
3698 workload_params: workload_params.clone(),
3699 wrappers_override: workload_wrappers_override.clone(),
3700 wrap_default_order: cli_wrap_default_order.clone(),
3701 workload_scope: builder.source_kernel().clone(),
3702 polydat_lib_paths: polydat_lib_paths.clone(),
3703 workload_dir: workload_dir.map(|p| p.to_path_buf()),
3704 strict,
3705 driver: driver.clone(),
3706 merged_params: merged_params.clone(),
3707 dry_run: dry_run_static,
3708 phase_filter: phase_filter.clone(),
3709 refine_plan: refine_plan.clone(),
3710 diag: {
3711 let mut d = diag.clone();
3712 d.depth = ExecDepth::Phase;
3713 d
3714 },
3715 // The pre-map pass walks at depth=Phase but is NOT
3716 // execution: the structural-only sentinel that fires
3717 // `set_phase_running` + `_completed` in the walker
3718 // (intended for the dryrun=phase summary) is
3719 // suppressed via this flag so the TUI's scene tree
3720 // doesn't start life with every phase already
3721 // Completed. Flipped back to false at line ~2675
3722 // before the real execution pass.
3723 pre_map_only: true,
3724 seq_type,
3725 concurrency,
3726 rate,
3727 error_spec: error_spec.clone(),
3728 tries,
3729 error_rate_max,
3730 error_policy: root_error_policy,
3731 session_id: session_id.clone(),
3732 exec_id,
3733 workload_name: execution.workload.clone(),
3734 label_stack: Vec::new(),
3735 // SRD-88 §2 — phase/activity components attach under the
3736 // EXECUTION component (which declares `exec_id` +
3737 // `workload`), not the session root. The session root
3738 // is the shared `session=<id>` ancestor above it.
3739 session_component: execution.component.clone(),
3740 cadence_reporter: cadence_reporter.clone(),
3741 stop_handle: stop_handle.clone(),
3742 observer: observer.clone(),
3743 scope_tree: scope_tree.clone(),
3744 schedule_spec: schedule_spec.clone(),
3745 current_parent_kernel: scope_tree.nodes[scope_tree.workload_root_idx()]
3746 .cached_kernel
3747 .get()
3748 .cloned(),
3749 workload_source: workload_file.as_ref().and_then(|path| {
3750 workload_source_text.as_ref().map(|text| {
3751 std::sync::Arc::new(crate::executor::WorkloadSource {
3752 path: path.clone(),
3753 text: text.clone(),
3754 })
3755 })
3756 }),
3757 // Stub post-pre-map fields: replaced after the pre-map
3758 // walk populates the scene tree. Pre-map walks at depth
3759 // Phase, so no run_phase / run_do_loop / checkpoint
3760 // events fire — the stubs are not consulted.
3761 checkpoint_writer: None,
3762 resume_plan: std::sync::Arc::new(crate::checkpoint::ResumePlan::fresh()),
3763 sqlite_reporter: sqlite_reporter.clone(),
3764 resource_pool: resource_pool.clone(),
3765 scene_tree_parent_id: 0,
3766 scene_tree_path: initial_scene_tree_path.clone(),
3767 current_scope_idx: 0,
3768 };
3769
3770 // One Walker: seed the scope cursor at the scenario layer (the single
3771 // child of the workload root) so the top-level scenario nodes resolve
3772 // positionally against its children, not by AST match.
3773 exec_ctx.current_scope_idx = exec_ctx.scope_tree.scenario_root_idx();
3774
3775 // Install empty SceneTree global; the walker populates it.
3776 crate::scene_tree::install_global(crate::scene_tree::SceneTree::new());
3777
3778 // Pre-map structural pass. Errors propagate in strict mode
3779 // (SRD-15 §"Empty Iteration Sources"); otherwise the walker
3780 // logs and continues — downstream code handles the partial
3781 // / empty tree.
3782 let pre_map_result = crate::executor::execute_tree(&mut exec_ctx, &scenario_nodes).await;
3783 let pre_mapped_tree = match pre_map_result {
3784 Ok(()) => {
3785 let tree = crate::scene_tree::current();
3786 if let Some(ref t) = tree {
3787 observer.scenario_pre_mapped(t);
3788 }
3789 tree
3790 }
3791 Err(e) if strict => return Err(e),
3792 Err(e) => {
3793 crate::diag!(
3794 crate::observer::LogLevel::Warn,
3795 "pre-map walker failed (scope hierarchy will be flat in summaries / TUI): {e}"
3796 );
3797 None
3798 }
3799 };
3800
3801 // dryrun=kernels short-circuit: pre-map already ran;
3802 // the install-kernel visitor (registered above before
3803 // execute_tree) streamed each scope's polydat source
3804 // to stdout as the walk encountered it. Print the
3805 // legend + exit cleanly.
3806 if params.get("dryrun").map(|s| s.as_str()) == Some("kernels") {
3807 print_kernel_dump_legend();
3808 crate::scope_tree::set_kernel_install_visitor(None);
3809 return Ok(());
3810 }
3811
3812 // --- Checkpoint writer + resume plan (SRD-44 / SRD-44a) ---
3813 //
3814 // The writer file lives at `<session-dir>/checkpoint.jsonl`
3815 // — an append-only JSONL event log per SRD-44a. Resume from
3816 // an explicit prior session is wired through the
3817 // `--resume <session>` / `--resume-latest` CLI surface
3818 // (see runner CLI parsing); for a fresh session the writer
3819 // starts empty and the plan reruns everything.
3820 // SRD-88 — the writer + resume doc are SESSION-tier (created
3821 // once in `SessionHost::setup`, holding the single resume lock).
3822 // This execution shares them; it derives its own resume plan
3823 // below from `saved_doc` + its pre-map.
3824 let checkpoint_writer = host.checkpoint_writer.clone();
3825 let saved_doc = host.saved_doc.clone();
3826 let invocation = saved_doc.as_ref().map(|d| d.invocation + 1).unwrap_or(1);
3827
3828 // End-of-run notices: drops on success OR error path.
3829 //
3830 // - Resume hint: when checkpoint state shows
3831 // incomplete idempotent phases (SRD-44), advise the
3832 // operator how to resume.
3833 // - Keep-purge forecast: when the next new session
3834 // would auto-purge sessions under the keep cap
3835 // (SRD-45), let the operator know how many and how
3836 // to disable.
3837 let parent_for_keep_check = if let Some(p) = session.output_dir.parent() {
3838 p.to_path_buf()
3839 } else {
3840 crate::session::default_sessions_root()
3841 };
3842 let session_keep = crate::session::resolve_session_dir(&args).session_keep;
3843 struct EndOfRunNoticeGuard {
3844 writer: std::sync::Arc<crate::checkpoint::CheckpointWriter>,
3845 parent: std::path::PathBuf,
3846 keep_cap: usize,
3847 }
3848 impl Drop for EndOfRunNoticeGuard {
3849 fn drop(&mut self) {
3850 if let Some(hint) = self.writer.resume_hint() {
3851 // Through the observer, one line per log call —
3852 // NOT a raw eprintln!: this drop can fire while
3853 // the TUI still holds the terminal in raw mode,
3854 // where a bare `\n` does not return the carriage
3855 // and a stderr write bypasses the render sink —
3856 // each line then starts at the previous line's
3857 // end column (the end-of-session staircase,
3858 // observed 2026-08-05). The log path is owned by
3859 // the surface channel (SRD-87) in both TUI and
3860 // plain modes, so it renders correctly in each.
3861 for line in hint.lines() {
3862 crate::diag!(crate::observer::LogLevel::Info, "{line}");
3863 }
3864 }
3865 let n = crate::session::forecast_keep_purge(&self.parent, self.keep_cap);
3866 if n > 0 {
3867 crate::diag!(
3868 crate::observer::LogLevel::Info,
3869 "the next new nmbrs session will auto-purge {n} prior session \
3870 director{plural} under {} due to --session-keep={cap}. \
3871 To disable: --session-keep=0 (or NMBRS_SESSION_KEEP=0). \
3872 To raise the cap: --session-keep=<bigger>.",
3873 self.parent.display(),
3874 plural = if n == 1 { "y" } else { "ies" },
3875 cap = self.keep_cap,
3876 );
3877 }
3878 }
3879 }
3880 let _eor_notice_guard = EndOfRunNoticeGuard {
3881 writer: checkpoint_writer.clone(),
3882 parent: parent_for_keep_check,
3883 keep_cap: session_keep,
3884 };
3885 let resume_plan =
3886 if let (Some(saved), Some(tree)) = (saved_doc.as_ref(), pre_mapped_tree.as_ref()) {
3887 let candidates =
3888 crate::checkpoint::scene_tree_resume_candidates(tree, &scope_tree, &phases);
3889 std::sync::Arc::new(crate::checkpoint::ResumePlan::from_checkpoint(
3890 saved,
3891 &candidates,
3892 &workload_params,
3893 ))
3894 } else {
3895 std::sync::Arc::new(crate::checkpoint::ResumePlan::fresh())
3896 };
3897
3898 // Declare every pre-mapped phase into the writer so a
3899 // future resume can tell "didn't run yet" from "wasn't
3900 // planned". Idempotent — re-declaring an entry the
3901 // writer already restored from disk is a no-op.
3902 if let Some(tree) = pre_mapped_tree.as_ref() {
3903 crate::checkpoint::declare_scene_tree_phases(&checkpoint_writer, tree, &phases);
3904 }
3905
3906 if resume_plan.is_resume {
3907 let skip = resume_plan.skip_count();
3908 let mismatch = resume_plan.mismatch_count();
3909 let cursor = resume_plan.cursor_resume_count();
3910 crate::diag!(
3911 crate::observer::LogLevel::Info,
3912 "resume: invocation #{invocation} — \
3913 {skip} skip, {mismatch} mismatched, {cursor} cursor-resume"
3914 );
3915 }
3916
3917 // SRD-35 Push D: seed the resource pool's per-key
3918 // `pending_uses` counter before any phase runs. The
3919 // walker is a pure read of the pre-mapped tree +
3920 // session-level params; it doesn't instantiate any
3921 // adapter or open any resource. After this, the pool
3922 // can close `Shared`/`PerScenario` entries the moment
3923 // their last predicted phase detaches, instead of
3924 // holding them until session end.
3925 if let Some(tree) = pre_mapped_tree.as_ref() {
3926 crate::resource_pool::pre_map_pending_uses(
3927 &resource_pool,
3928 tree,
3929 &phases,
3930 &driver,
3931 &merged_params,
3932 )?;
3933 }
3934
3935 // ─── Unified walker: execution pass ──────────────────────────
3936 //
3937 // Update the post-pre-map fields on the same `exec_ctx`
3938 // used for the pre-map pass: real `checkpoint_writer`,
3939 // resolved `resume_plan`, restored execution depth. Per
3940 // SRD 18b §"Single Walker Contract" point 1, this is the
3941 // SAME walker function — `execute_tree` — invoked again at
3942 // the configured depth. SceneTree::push is idempotent so
3943 // every node the pre-map pass pushed is reused.
3944 exec_ctx.checkpoint_writer = Some(checkpoint_writer.clone());
3945 exec_ctx.resume_plan = resume_plan.clone();
3946 exec_ctx.diag = diag.clone();
3947 exec_ctx.scene_tree_parent_id = 0;
3948 exec_ctx.scene_tree_path = initial_scene_tree_path.clone();
3949 // One Walker: seed at the scenario layer (see the pre-map seed above).
3950 exec_ctx.current_scope_idx = exec_ctx.scope_tree.scenario_root_idx();
3951 // Pre-map pass is done — the real execution starts now.
3952 // dryrun=phase still walks at depth=Phase but with this
3953 // flag false, so the sentinel set_phase_completed in the
3954 // walker fires as the dryrun=phase summary needs.
3955 exec_ctx.pre_map_only = false;
3956
3957 let scheduler = crate::scheduler::build(&schedule_spec);
3958 let scheduler_result = scheduler.run(&mut exec_ctx, &scenario_nodes).await;
3959
3960 // SRD-35: drain the resource pool at session end.
3961 // `Shared`/`PerScenario` entries intentionally stay alive
3962 // across phases (the whole reason the pool exists), so
3963 // this is the close trigger that releases their network
3964 // resources. Runs even if the scenario errored out —
3965 // half-open clusters would otherwise leak FDs into the
3966 // next session in TUI / `metrics watch` host processes.
3967 exec_ctx.resource_pool.shutdown().await;
3968 // SRD-93 stage 6 — a ladder-driven interrupt (level 1/2)
3969 // unwinds the walk as an `Err`, which the `?` below would
3970 // propagate PAST the SRD-77 row close-out at the end of this
3971 // function — leaving `disposition` NULL, the marker reserved
3972 // for genuinely unclean exits (force-exit, panic, SIGKILL). A
3973 // cooperative drain IS a clean shutdown: stamp the row with
3974 // the scene tree's computed disposition (interrupted / failed
3975 // / …) before the error propagates. `update_execution_end`
3976 // keys on `ended_at_nanos IS NULL`, so the normal-completion
3977 // stamp below stays a no-op after this one.
3978 if scheduler_result.is_err() {
3979 close_execution_row(&sqlite_reporter, &session_id, exec_id);
3980 }
3981 scheduler_result?;
3982
3983 // Workload-end lifecycle boundary: every phase in the
3984 // scenario has completed. Individual phase paths already
3985 // closed themselves at phase-end, but any workload-level
3986 // ingests (e.g. aggregate metrics the tree code emits at
3987 // scope scope rather than phase scope) still need a flush.
3988 // In phased mode the workload's label set is the session
3989 // root — there's no intermediate `activity=...` label —
3990 // so we close at the session root.
3991 cadence_reporter.close_path(&Labels::of("session", &session.id));
3992
3993 // SRD-13d Phase 7 — `dryrun=dispenser` scope-elision
3994 // summary. Phase walk has just completed; scope tree
3995 // carries final `materialised` / `logical_name` marks
3996 // (set by the workload-load classifier). Dump now so the
3997 // diagnostic surfaces phase-init artifacts (registered
3998 // metrics, adapter map_op calls) in the same run. Used
3999 // to fire for `Op` depth; since the auto-bump now lifts
4000 // `dryrun=op` to `Cycle` (full cycle execution with
4001 // wrapper short-circuit), the scope-elision surface
4002 // moved to `dryrun=dispenser` — the explicit "build
4003 // every dispenser but don't run cycles" mode.
4004 if diag.depth == ExecDepth::Dispenser {
4005 let mut out = std::io::stdout();
4006 if let Err(e) = render_scope_elision_summary(&scope_tree, &mut out) {
4007 crate::diag!(
4008 crate::observer::LogLevel::Warn,
4009 "warning: rendering scope-elision summary: {e}"
4010 );
4011 }
4012 }
4013
4014 // The run reached its end boundary: every fallible step is
4015 // behind us (the epilogue after this block is teardown
4016 // only). Tell the checkpoint writer HERE, as the block's
4017 // last statement — `_eor_notice_guard` drops when this
4018 // block closes, and its resume_hint must read
4019 // declared-but-never-started phases as excluded-by-
4020 // predicate rather than left-behind (SRD-44a; see
4021 // resume_hint). Error `?` returns above skip this, so a
4022 // cut-short run still advises resuming pending phases.
4023 checkpoint_writer.mark_run_reached_end();
4024 }
4025
4026 // Session-end lifecycle boundary. Close the session root path
4027 // for any aggregate windows that were ingested at session level
4028 // (rare today, but the boundary must be explicit — otherwise
4029 // session-level aggregates would only flush during
4030 // `shutdown_flush` at the very end, after all the per-subscriber
4031 // teardown logic had already started).
4032 cadence_reporter.close_path(&Labels::of("session", &session.id));
4033
4034 // SRD-88 — flush THIS execution's windows into the store so the
4035 // summaries below see complete data, without tearing down the
4036 // session-shared cadence reporter.
4037 cadence_reporter
4038 .quiesce(std::time::Duration::from_secs(30))
4039 .await;
4040
4041 // SRD-63 Push 9a: fire `EventType::SessionEnd` once after
4042 // the cadence shutdown but before `run_finished()`.
4043 // Both branches (phased + single-activity) converge
4044 // here, so a single fire covers every run shape.
4045 {
4046 let session_ctx = crate::readout_context::LifecycleContext {
4047 event: crate::lifecycle::EventType::SessionEnd,
4048 subject_name: session.id.clone(),
4049 subject_labels: String::new(),
4050 depth_indent: String::new(),
4051 use_color: crate::observer::use_color(),
4052 stick_reattached: String::new(),
4053 };
4054 crate::readout_context::fire_lifecycle(
4055 crate::lifecycle::EventType::SessionEnd,
4056 &workload_readouts,
4057 None,
4058 &session_ctx,
4059 Some(&sqlite_reporter),
4060 );
4061 }
4062
4063 observer.run_finished();
4064
4065 if dry_run.is_some() {
4066 crate::diag!(crate::observer::LogLevel::Info, "dry-run complete.");
4067 } else {
4068 crate::diag!(crate::observer::LogLevel::Info, "done.");
4069 }
4070
4071 // Build the active set of named summaries.
4072 //
4073 // Precedence:
4074 // - CLI `summary=<spec>` wins outright — produces a single
4075 // ad-hoc summary under the synthetic name `default`,
4076 // overriding any workload-declared map. Matches prior
4077 // CLI behavior.
4078 // - Otherwise the workload's `summary:` map (and the
4079 // `summary.yaml` sidecar fallback already merged into
4080 // `workload_summaries` above) is used as-is.
4081 //
4082 // An empty map means "no summary at end of run" — same as
4083 // the legacy "no `summary:` field" case.
4084 let active_summaries: HashMap<String, nmbrs_workload::model::SummaryConfig> =
4085 if let Some(cli_summary) = merged_params.get("summary") {
4086 let mut m = HashMap::new();
4087 m.insert(
4088 "default".into(),
4089 nmbrs_workload::model::SummaryConfig::parse(cli_summary),
4090 );
4091 m
4092 } else {
4093 workload_summaries.clone()
4094 };
4095
4096 // SRD-46 output routing for the in-run summary, by item name.
4097 //
4098 // A CLI `summary=<spec>` is an explicit operator request —
4099 // they typed it, so it still echoes to the terminal. A
4100 // workload-declared table goes where its `to:` says, and one
4101 // that declared nothing goes to the session directory ONLY.
4102 // stdout is never implied for automatic rendering: a run's
4103 // stdout is its op output, and a summary carries wall-clock
4104 // values that would make that output differ run to run.
4105 let summary_destinations: HashMap<String, Vec<nmbrs_workload::report::Destination>> = {
4106 use nmbrs_workload::report::Destination as D;
4107 if merged_params.contains_key("summary") {
4108 let mut m = HashMap::new();
4109 m.insert("default".to_string(), vec![D::SessionDir, D::Stdout]);
4110 m
4111 } else {
4112 workload_report
4113 .items()
4114 .filter(|i| matches!(i.kind, nmbrs_workload::report::Kind::Table))
4115 .map(|i| {
4116 (
4117 i.name.clone(),
4118 i.style
4119 .destinations
4120 .clone()
4121 .unwrap_or_else(|| vec![D::SessionDir]),
4122 )
4123 })
4124 .collect()
4125 }
4126 };
4127
4128 // SRD-46 Details auto-injection: persist run-wide context
4129 // (end time, phase + scenario counts, adapter, …) into
4130 // session_metadata regardless of whether the workload
4131 // declared a `report:` block. Post-run hooks read this to
4132 // build the auto-injected Details section that lands at
4133 // the top of every output markdown file.
4134 if let Ok(mut guard) = sqlite_reporter.lock()
4135 && let Some(ref mut reporter) = *guard
4136 {
4137 let end_time = std::time::SystemTime::now()
4138 .duration_since(std::time::UNIX_EPOCH)
4139 .map(|d| d.as_secs())
4140 .unwrap_or(0);
4141 let sid = session.id.clone();
4142 let exec_id = execution.exec_id;
4143 reporter.set_execution_metadata(&sid, exec_id, "end_time", &end_time.to_string());
4144 reporter.set_execution_metadata(&sid, exec_id, "phase_count", &phases.len().to_string());
4145 reporter.set_execution_metadata(
4146 &sid,
4147 exec_id,
4148 "scenario_count",
4149 &scenarios.len().to_string(),
4150 );
4151 if let Some(wf) = workload_file.as_deref() {
4152 reporter.set_execution_metadata(&sid, exec_id, "workload_file", wf);
4153 }
4154 reporter.set_execution_metadata(&sid, exec_id, "adapter", &driver);
4155 }
4156
4157 if !active_summaries.is_empty() {
4158 // Summary report always comes from SQLite — the
4159 // durable record. The in-memory store exists for GK
4160 // access and reactive control, not for reporting.
4161 if let Ok(mut guard) = sqlite_reporter.lock()
4162 && let Some(ref mut reporter) = *guard
4163 {
4164 // Persist every report item (SRD-46) under
4165 // `report.<name>` keys. Value carries the kind
4166 // keyword (`plot ...` / `table ...`) followed
4167 // by an optional `label "..."` line and then
4168 // the spec body — same shape the report parser
4169 // ingests, so the db-fallback path in
4170 // `nmbrs report` round-trips through the same
4171 // parser the workload uses.
4172 let sid = session.id.clone();
4173 let exec_id = execution.exec_id;
4174 for item in workload_report.items() {
4175 // Single emission point: the workload-side
4176 // serializer. The db-fallback path in
4177 // `nmbrs report` parses this value back
4178 // through `parse_persisted_item`, which
4179 // uses the same grammar — round-trip safe.
4180 let value = item.to_yaml_directive_string();
4181 reporter.set_execution_metadata(
4182 &sid,
4183 exec_id,
4184 &format!("report.{}", item.name),
4185 &value,
4186 );
4187 }
4188
4189 // Stable ordering for consistent output across
4190 // runs (HashMap iteration is non-deterministic).
4191 let mut names: Vec<&String> = active_summaries.keys().collect();
4192 names.sort();
4193 for name in names {
4194 let cfg = &active_summaries[name];
4195 let (basename, format) =
4196 nmbrs_metrics::reporters::sqlite::derive_name_and_format(name);
4197 // SRD-77 — the in-run summary is naturally
4198 // scoped to the current execution; qualifier
4199 // narrows to this run's exec_id so a refine
4200 // doesn't render rows from prior runs.
4201 let report_config = report_config_from_summary(cfg, Some(exec_id));
4202 let rendered = reporter.format_summary_with_format(&report_config, &format);
4203 if rendered.is_empty() {
4204 continue;
4205 }
4206 // SRD-46 routing for this item. Undeclared ⇒
4207 // session directory only.
4208 let dests = summary_destinations
4209 .get(name.as_str())
4210 .cloned()
4211 .unwrap_or_else(|| vec![nmbrs_workload::report::Destination::SessionDir]);
4212 let to_session = dests.contains(&nmbrs_workload::report::Destination::SessionDir);
4213 let to_stdout = dests.contains(&nmbrs_workload::report::Destination::Stdout);
4214 let to_stderr = dests.contains(&nmbrs_workload::report::Destination::Stderr);
4215
4216 let filename = format!("{basename}_summary.{format}");
4217 let summary_path = session.output_dir.join(&filename);
4218 if to_session {
4219 if let Err(e) = std::fs::write(&summary_path, &rendered) {
4220 crate::diag!(
4221 crate::observer::LogLevel::Warn,
4222 "warning: failed to write summary to {}: {e}",
4223 summary_path.display()
4224 );
4225 } else {
4226 crate::diag!(
4227 crate::observer::LogLevel::Info,
4228 "summary: {}",
4229 summary_path.display()
4230 );
4231 }
4232 }
4233 // Inline print only when the observer is
4234 // not suppressing stderr — i.e. we're in
4235 // tui=off mode and the user can see stdout
4236 // right now. In TUI mode the alternate
4237 // screen is up, so `print!()` here would
4238 // get buffered behind the TUI rendering and
4239 // discarded on teardown. The persona reads
4240 // the *_summary.* files and prints them
4241 // post-shutdown (see `nmbrs/src/run.rs`).
4242 if to_stderr {
4243 eprint!("{rendered}");
4244 }
4245 if to_stdout {
4246 // In TUI mode the alternate screen is up, so an
4247 // inline `print!` would be buffered behind the
4248 // TUI rendering and discarded on teardown.
4249 // Defer it to a file the post-run printer
4250 // flushes once the terminal is back in cooked
4251 // mode. Routing is decided HERE either way —
4252 // the post-run printer no longer infers "goes
4253 // to stdout" from the presence of an artifact.
4254 if observer.suppresses_stderr() {
4255 let deferred = session.output_dir.join(DEFERRED_STDOUT_FILE);
4256 if let Err(e) = std::fs::OpenOptions::new()
4257 .create(true)
4258 .append(true)
4259 .open(&deferred)
4260 .and_then(|mut f| {
4261 std::io::Write::write_all(&mut f, rendered.as_bytes())
4262 })
4263 {
4264 crate::diag!(
4265 crate::observer::LogLevel::Warn,
4266 "warning: failed to defer summary stdout to {}: {e}",
4267 deferred.display()
4268 );
4269 }
4270 } else {
4271 print!("{rendered}");
4272 }
4273 }
4274 }
4275 }
4276 }
4277
4278 // Refresh convenience symlinks at the logs/ root so
4279 // logs/metrics.db, logs/summary.md, logs/session.log
4280 // always resolve to this session's artifacts. `logs/latest` (a
4281 // symlink to the whole session dir) is created by Session::new;
4282 // these are per-file counterparts for direct tool access like
4283 // `sqlite3 logs/metrics.db` or `tail -f logs/session.log`.
4284 refresh_latest_file_links(&session);
4285
4286 // dryrun=controls: every phase has now been constructed (at
4287 // depth=Phase the executor stops before cycles but still
4288 // attaches components and declares controls). Walk the
4289 // session tree and dump the catalog.
4290 if diag.list_controls {
4291 let mut out = std::io::stdout();
4292 if let Err(e) = render_controls_tree(&session.component, &mut out) {
4293 crate::diag!(
4294 crate::observer::LogLevel::Warn,
4295 "warning: rendering controls: {e}"
4296 );
4297 }
4298 }
4299
4300 // SRD-77 / SRD-93 stage 6 — close out the executions row with
4301 // the computed disposition + end timestamp. Both clean-exit
4302 // paths stamp it: normal completion here, and a ladder-driven
4303 // interrupt at the walk-error site above (which then propagates
4304 // out before reaching this line). Only genuinely unclean exits
4305 // (force-exit, panic, SIGKILL) leave `ended_at_nanos` NULL,
4306 // which the read side surfaces as "execution in flight /
4307 // unclean exit" distinct from a recorded outcome.
4308 close_execution_row(&sqlite_reporter, &session_id, exec_id);
4309
4310 // WAL consolidation runs from the RAII shutdown guard
4311 // bound at the top of this function (`_sqlite_shutdown_guard`).
4312 // The guard's Drop fires reliably across normal return,
4313 // `?` error propagation, and first-Ctrl-C cooperative
4314 // shutdown — every path Rust unwinds through. Second
4315 // Ctrl-C → `process::exit` is the only skip path,
4316 // matching the documented force-exit semantic in
4317 // `session_signals`.
4318
4319 Ok(())
4320}
4321
4322/// SRD-77 / SRD-93 stage 6 — close the in-flight executions row with
4323/// the scene tree's session disposition + an end timestamp. Idempotent
4324/// (`update_execution_end` keys on `ended_at_nanos IS NULL`), so the
4325/// interrupt-path caller and the normal-completion caller compose:
4326/// whichever runs first wins, the other is a no-op.
4327fn close_execution_row(
4328 sqlite_reporter: &std::sync::Arc<
4329 std::sync::Mutex<Option<nmbrs_metrics::reporters::sqlite::SqliteReporter>>,
4330 >,
4331 session_id: &str,
4332 exec_id: u64,
4333) {
4334 let disposition =
4335 crate::scene_tree::with_global(|t| t.session_disposition().label()).unwrap_or("UNKNOWN");
4336 let ended_at_nanos = std::time::SystemTime::now()
4337 .duration_since(std::time::UNIX_EPOCH)
4338 .map(|d| d.as_nanos() as i64)
4339 .unwrap_or(0);
4340 if let Ok(mut g) = sqlite_reporter.lock()
4341 && let Some(r) = g.as_mut()
4342 {
4343 r.update_execution_end(session_id, exec_id, ended_at_nanos, disposition);
4344 }
4345}
4346
4347/// Core runner: set up the shared session host, run one execution, tear down.
4348async fn run_impl(
4349 args: &[String],
4350 observer: Arc<dyn crate::observer::RunObserver>,
4351) -> Result<(), String> {
4352 let host = SessionHost::setup(args, observer.clone())?;
4353 let result = run_execution(&host, args, observer).await;
4354 host.shutdown().await;
4355 result
4356}
4357
4358/// SRD-88 — one execution's spec for [`run_executions`]: the workload
4359/// CLI args, the observer that captures its lifecycle/log, and an optional
4360/// per-execution output channel (SRD-87 buckets). `channel = None` falls back
4361/// to the process-global channel; in-process example verification passes a
4362/// `CaptureChannel` so each execution's op stdout is captured separately.
4363pub struct ExecutionSpec {
4364 pub args: Vec<String>,
4365 pub observer: Arc<dyn crate::observer::RunObserver>,
4366 pub channel: Option<Arc<dyn crate::output_channel::OutputChannel>>,
4367}
4368
4369/// SRD-88 — run N executions CONCURRENTLY in one process, all sharing
4370/// ONE session, at most `max_concurrent` in flight. The session
4371/// (`SessionHost`: dir / stores / cadence + scheduler services) is set
4372/// up ONCE and torn down ONCE, after every execution. Each execution
4373/// loads + runs its own workload, derives its own `Execution`
4374/// (distinct allocated `exec_id`) under the shared session component,
4375/// flushes its metrics into the shared store via
4376/// [`CadenceReporter::quiesce`](nmbrs_metrics::cadence_reporter::CadenceReporter::quiesce)
4377/// without tearing the reporter down, and routes its lifecycle/log
4378/// through its own observer (a scoped [`ExecutionContext`]). Results
4379/// come back in input order.
4380///
4381/// `max_concurrent == 1` is the sequential case — the SAME harness, no
4382/// separate path (SRD-02 "One Concurrency Path").
4383pub async fn run_executions(
4384 session_args: &[String],
4385 session_observer: Arc<dyn crate::observer::RunObserver>,
4386 specs: Vec<ExecutionSpec>,
4387 max_concurrent: usize,
4388) -> Result<Vec<Result<(), String>>, String> {
4389 let host = std::sync::Arc::new(SessionHost::setup(session_args, session_observer)?);
4390 let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1)));
4391 let futs = specs.into_iter().map(|spec| {
4392 let host = host.clone();
4393 let sem = sem.clone();
4394 async move {
4395 // Bound in-flight executions; permit held for the whole run.
4396 let _permit = sem.acquire().await.expect("semaphore not closed");
4397 // Scope this execution's context — a distinct allocated
4398 // `exec_id` + its own observer (+ optional output channel) — so
4399 // deeply-nested fibers, op-output routing, and `run_execution`'s
4400 // exec-identity all resolve to THIS execution.
4401 let ctx = match spec.channel.clone() {
4402 Some(ch) => crate::execution_context::ExecutionContext::with_observer_and_channel(
4403 spec.observer.clone(),
4404 ch,
4405 ),
4406 None => {
4407 crate::execution_context::ExecutionContext::with_observer(spec.observer.clone())
4408 }
4409 };
4410 crate::execution_context::scope(ctx, run_execution(&host, &spec.args, spec.observer))
4411 .await
4412 }
4413 });
4414 let results = futures::future::join_all(futs).await;
4415 // Session teardown — once, after every execution completed (so the
4416 // host is now the sole owner).
4417 match std::sync::Arc::try_unwrap(host) {
4418 Ok(h) => h.shutdown().await,
4419 Err(_) => crate::diag!(
4420 crate::observer::LogLevel::Warn,
4421 "run_executions: session host still referenced at teardown; \
4422 scheduler/WAL will close on drop"
4423 ),
4424 }
4425 Ok(results)
4426}
4427
4428/// Point per-file symlinks under `logs/` at the latest session's
4429/// artifacts. Silently skips files that don't exist (e.g. summary.md
4430/// when no `summary:` was declared). Replaces any existing symlink.
4431///
4432/// Targets route through `logs/latest` (which `Session::new` points
4433/// at the actual session dir) so the convenience links stay
4434/// consistent with `latest`. Skipped entirely when the session
4435/// lives outside `logs/` — `--session-path /tmp/x` is an explicit
4436/// redirect and shouldn't hijack the user's `logs/` symlinks.
4437fn refresh_latest_file_links(session: &crate::session::Session) {
4438 let logs_dir = std::path::Path::new("logs");
4439 // Mirror the gate in `Session::new` — keep these convenience
4440 // links and `logs/latest` synchronized: either both update or
4441 // neither does.
4442 if !crate::session::target_is_under(logs_dir, &session.output_dir) {
4443 return;
4444 }
4445 for file in ["metrics.db", "summary.md", "session.log"] {
4446 let target = session.output_dir.join(file);
4447 if !target.exists() {
4448 continue;
4449 }
4450 let link = logs_dir.join(file);
4451 // Remove any existing entry (symlink or regular file) so we can
4452 // recreate the link. If this fails because nothing's there,
4453 // that's fine.
4454 let _ = std::fs::remove_file(&link);
4455 let rel_target = std::path::Path::new("latest").join(file);
4456 if let Err(e) = crate::session::symlink_any(&rel_target, &link) {
4457 crate::diag!(
4458 crate::observer::LogLevel::Warn,
4459 "warning: failed to link {} → {}: {e}",
4460 link.display(),
4461 rel_target.display()
4462 );
4463 }
4464 }
4465}
4466
4467/// Create an adapter from inventory registrations.
4468///
4469/// `dryrun=cycle` does NOT substitute the adapter here — it means
4470/// "construct a fully-executable cycle path, then suppress only
4471/// the outbound `execute()` at cycle time." The real adapter is
4472/// always created (connecting, preparing statements, gathering
4473/// metadata); the outermost `DryRunWrapper` handles the runtime
4474/// short-circuit. See `nmbrs_runtime::wrappers::DryRunWrapper`.
4475pub async fn create_adapter(
4476 driver: &str,
4477 params: &HashMap<String, String>,
4478) -> Result<Arc<dyn crate::adapter::DriverAdapter>, String> {
4479 let reg = find_adapter_registration(driver).ok_or_else(|| {
4480 let available = registered_driver_names();
4481 format!(
4482 "unknown adapter '{driver}' (available: {})",
4483 available.join(", ")
4484 )
4485 })?;
4486 (reg.create)(params.clone()).await
4487}
4488
4489/// Run an activity without its own capture thread.
4490///
4491/// All metrics flow through the session-level scheduler →
4492/// `CadenceReporter` → `MetricsQuery`. This function just runs the
4493/// activity to completion; lifecycle flush (final delta +
4494/// validation metrics) is handled by the caller (executor).
4495/// Streaming print: one header line for the `dryrun=kernels`
4496/// dump, fired before any kernel installs.
4497fn print_kernel_dump_header() {
4498 let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
4499 let (bold, reset) = if is_tty {
4500 ("\x1b[1m", "\x1b[0m")
4501 } else {
4502 ("", "")
4503 };
4504 println!();
4505 println!("{bold}Polydat Scope Kernels{reset}");
4506 println!("{bold}═════════════════════{reset}");
4507 println!();
4508}
4509
4510/// Streaming per-scope visitor callback. Prints the scope's
4511/// logical name + the polydat source the kernel was compiled
4512/// from, indented by the scope's depth.
4513fn print_kernel_for_scope(
4514 node: &crate::scope_tree::ScopeNode,
4515 kernel: &crate::scope_kernel::ScopeKernel,
4516) {
4517 let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
4518 let (bold, dim, reset, cyan, magenta, green) = if is_tty {
4519 (
4520 "\x1b[1m", "\x1b[2m", "\x1b[0m", "\x1b[36m", "\x1b[35m", "\x1b[32m",
4521 )
4522 } else {
4523 ("", "", "", "", "", "")
4524 };
4525
4526 let logical = if node.logical_name.is_empty() {
4527 "<unnamed scope>".to_string()
4528 } else {
4529 node.logical_name.clone()
4530 };
4531 let depth_indent = " ".repeat(node.depth);
4532 println!(
4533 "{depth_indent}{green}●{reset} {bold}{cyan}{logical}{reset} \
4534 {dim}(depth={}, kind={:?}){reset}",
4535 node.depth, node.kind
4536 );
4537 let source = kernel.program().source().trim_end();
4538 if source.is_empty() {
4539 println!("{depth_indent} {dim}(empty kernel — no own bindings){reset}");
4540 } else {
4541 for line in source.lines() {
4542 println!("{depth_indent} {magenta}│{reset} {line}");
4543 }
4544 }
4545 println!();
4546}
4547
4548/// Footer for the `dryrun=kernels` dump (legend + spacing).
4549fn print_kernel_dump_legend() {
4550 let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
4551 let (dim, reset, green) = if is_tty {
4552 ("\x1b[2m", "\x1b[0m", "\x1b[32m")
4553 } else {
4554 ("", "", "")
4555 };
4556 println!(
4557 " {dim}Legend: {green}●{reset}{dim} kernel installed at this scope. \
4558 Flattened scopes (those that inherit a parent's kernel) emit no entry.{reset}"
4559 );
4560 println!();
4561}
4562
4563pub async fn run_activity_simple(
4564 activity: Activity,
4565 adapters: std::collections::HashMap<String, Arc<dyn crate::adapter::DriverAdapter>>,
4566 default_adapter: &str,
4567 op_builder: Arc<crate::synthesis::OpBuilder>,
4568) -> bool {
4569 activity
4570 .run_with_adapters(adapters, default_adapter, op_builder)
4571 .await
4572}
4573
4574/// Adapter that delegates to an `Arc<Mutex<Option<SqliteReporter>>>`.
4575///
4576/// Allows the SQLite reporter to be registered on the scheduler while
4577/// also being accessible for summary queries after the scheduler stops.
4578struct MutexReporter(
4579 std::sync::Arc<std::sync::Mutex<Option<nmbrs_metrics::reporters::sqlite::SqliteReporter>>>,
4580);
4581
4582impl Reporter for MutexReporter {
4583 fn report(&mut self, snapshot: &nmbrs_metrics::snapshot::MetricSet) {
4584 if let Ok(mut guard) = self.0.lock()
4585 && let Some(ref mut r) = *guard
4586 {
4587 Reporter::report(r, snapshot);
4588 }
4589 }
4590
4591 fn flush(&mut self) {
4592 if let Ok(mut guard) = self.0.lock()
4593 && let Some(ref mut r) = *guard
4594 {
4595 Reporter::flush(r);
4596 }
4597 }
4598}
4599
4600/// Wrapper to make `Box<dyn Reporter>` usable with `add_reporter(impl Reporter)`.
4601struct BoxedReporter(Box<dyn Reporter>);
4602impl Reporter for BoxedReporter {
4603 fn report(&mut self, snapshot: &nmbrs_metrics::snapshot::MetricSet) {
4604 self.0.report(snapshot);
4605 }
4606 fn flush(&mut self) {
4607 self.0.flush();
4608 }
4609}
4610
4611// =========================================================================
4612// Helpers
4613// =========================================================================
4614
4615/// Expand `{key}` workload param placeholders in a string.
4616pub fn expand_workload_params(s: &str, params: &HashMap<String, String>) -> String {
4617 let mut result = s.to_string();
4618 for (key, value) in params {
4619 let placeholder = format!("{{{key}}}");
4620 if result.contains(&placeholder) {
4621 result = result.replace(&placeholder, value);
4622 }
4623 }
4624 result
4625}
4626
4627/// Collected param references from a workload, separating direct
4628/// `{name}` references from composite-name templates like
4629/// `{k_{k}_limits}` whose ground form depends on a runtime
4630/// substitution.
4631///
4632/// Used by the workload param validator to recognize that a
4633/// declared param like `k_1_limits` is genuinely referenced
4634/// when the workload uses `{k_{k}_limits}` and `k` ranges over
4635/// values including `1`.
4636#[derive(Default)]
4637struct ParamRefs {
4638 /// Names that appeared as `{name}` curly-brace placeholders.
4639 /// These MUST resolve to a declared param, runner-known
4640 /// param, adapter-registered param, or scenario-tree
4641 /// iter-var — anything else is a typo or missing
4642 /// declaration and the validator surfaces it as an error.
4643 placeholders: std::collections::HashSet<String>,
4644 /// Placeholders that appeared in scenario-tree `for_each` /
4645 /// `for_combinations` / DoWhile-condition text. These get
4646 /// resolved at runtime by the comprehension interpolator
4647 /// (which emits a `<path>:<line>:<col>:` error on failure);
4648 /// the strict "must-resolve-now" validator skips them so the
4649 /// more-specific runtime diagnostic wins. They still count
4650 /// as references for the "declared but unreferenced" check
4651 /// — a workload param used only in a `for_each` spec is
4652 /// genuinely consumed by the workload, just at runtime.
4653 runtime_only_placeholders: std::collections::HashSet<String>,
4654 /// Bare identifiers harvested from `if:` / `delay:`
4655 /// expression bodies. These may be wire names (referencing
4656 /// values bound via Polydat source) rather than workload params,
4657 /// so they participate in the "declared but unreferenced"
4658 /// check (as references) but NOT in the "referenced but
4659 /// undeclared" check (since wire names legitimately live
4660 /// outside the workload's param surface).
4661 expression_idents: std::collections::HashSet<String>,
4662 /// Composite templates: the literal body of a `{...}` whose
4663 /// inner content contained nested `{...}`. Stored verbatim
4664 /// (e.g. `"k_{k}_limits"`); validation checks each declared
4665 /// param name against these templates by replacing each
4666 /// inner `{NAME}` with a word-character wildcard.
4667 templates: Vec<String>,
4668}
4669
4670impl ParamRefs {
4671 /// Does `param` appear as a reference anywhere — placeholder,
4672 /// runtime-only placeholder, expression ident, or composite
4673 /// template? Used by the existing "declared but unreferenced"
4674 /// validator.
4675 fn contains(&self, param: &str) -> bool {
4676 if self.placeholders.contains(param) {
4677 return true;
4678 }
4679 if self.runtime_only_placeholders.contains(param) {
4680 return true;
4681 }
4682 if self.expression_idents.contains(param) {
4683 return true;
4684 }
4685 self.templates
4686 .iter()
4687 .any(|tpl| template_matches(tpl, param))
4688 }
4689}
4690
4691/// Match a declared param name against a composite-name template.
4692///
4693/// `template` is the body of a `{...}` reference whose content
4694/// included nested `{...}` substitutions — e.g. `k_{k}_limits`.
4695/// Each inner `{NAME}` matches one or more word characters
4696/// (`[A-Za-z0-9_]+`); the surrounding literal chars must match
4697/// exactly. Returns `true` iff `param` exactly matches the
4698/// template's ground form for some substitution of the inner
4699/// names.
4700fn template_matches(template: &str, param: &str) -> bool {
4701 let t = template.as_bytes();
4702 let p = param.as_bytes();
4703 let mut ti = 0;
4704 let mut pi = 0;
4705 while ti < t.len() {
4706 if t[ti] == b'{' {
4707 // Skip past the inner {...}. The template body
4708 // doesn't nest deeper than one level in practice
4709 // (composed names like `{k_{k}_limits}` don't
4710 // contain `{a_{b}_c}` recursively); a simple
4711 // first-`}` lookup suffices.
4712 let close = match template[ti + 1..].find('}') {
4713 Some(n) => ti + 1 + n,
4714 None => return false, // malformed template
4715 };
4716 // Determine where the template's literal context
4717 // resumes after the inner placeholder.
4718 let next_lit = close + 1;
4719 // The next literal char (or end-of-template) bounds
4720 // how far the wildcard can consume.
4721 if next_lit >= t.len() {
4722 // Wildcard must consume the rest of `param`,
4723 // and that suffix must be at least one word char.
4724 if pi >= p.len() {
4725 return false;
4726 }
4727 return p[pi..]
4728 .iter()
4729 .all(|b| b.is_ascii_alphanumeric() || *b == b'_');
4730 }
4731 let stop = t[next_lit];
4732 // Greedy-match word chars in param up to the next
4733 // literal in template.
4734 let mut consumed = 0;
4735 while pi + consumed < p.len() && p[pi + consumed] != stop {
4736 let b = p[pi + consumed];
4737 if !(b.is_ascii_alphanumeric() || b == b'_') {
4738 return false;
4739 }
4740 consumed += 1;
4741 }
4742 if consumed == 0 {
4743 return false;
4744 } // wildcard requires ≥1 char
4745 pi += consumed;
4746 ti = next_lit;
4747 } else {
4748 // Literal char: must match.
4749 if pi >= p.len() || p[pi] != t[ti] {
4750 return false;
4751 }
4752 ti += 1;
4753 pi += 1;
4754 }
4755 }
4756 pi == p.len()
4757}
4758
4759/// Scan a string for `{name}` references and `{composite_{x}_name}`
4760/// templates, accumulating into `refs`.
4761///
4762/// Plain `{name}` placeholders (where the body is a single
4763/// identifier — alphanumerics + underscore, leading non-digit)
4764/// are recorded as direct references. A `{...}` whose body
4765/// contains nested `{...}` is recorded as a template; the inner
4766/// leaf names are also recorded as direct references because
4767/// they're the substitution inputs (e.g. `{k_{k}_limits}`
4768/// records the template `k_{k}_limits` AND the direct ref `k`).
4769/// Walk a `serde_json::Value` and call [`scan_param_refs`] on
4770/// every string leaf. Used by [`collect_param_references`] to
4771/// reach `{name}` references nested inside structured
4772/// `params:` blocks (e.g. `relevancy: { expected: "{ground_truth}" }`).
4773fn scan_json_for_refs(v: &serde_json::Value, refs: &mut ParamRefs) {
4774 match v {
4775 serde_json::Value::String(s) => scan_param_refs(s, refs),
4776 serde_json::Value::Array(a) => {
4777 for item in a {
4778 scan_json_for_refs(item, refs);
4779 }
4780 }
4781 serde_json::Value::Object(m) => {
4782 for item in m.values() {
4783 scan_json_for_refs(item, refs);
4784 }
4785 }
4786 _ => {} // numbers, booleans, null — no string content
4787 }
4788}
4789
4790fn scan_param_refs(text: &str, refs: &mut ParamRefs) {
4791 let bytes = text.as_bytes();
4792 let mut i = 0;
4793 while i < bytes.len() {
4794 if bytes[i] != b'{' {
4795 i += 1;
4796 continue;
4797 }
4798 // Find the matching `}`, balancing nested `{`s.
4799 let body_start = i + 1;
4800 let mut depth = 1;
4801 let mut j = body_start;
4802 while j < bytes.len() && depth > 0 {
4803 match bytes[j] {
4804 b'{' => depth += 1,
4805 b'}' => depth -= 1,
4806 _ => {}
4807 }
4808 if depth == 0 {
4809 break;
4810 }
4811 j += 1;
4812 }
4813 if depth != 0 {
4814 // Unmatched `{` — bail, treat as literal.
4815 break;
4816 }
4817 let body = &text[body_start..j];
4818 if body.contains('{') {
4819 // Composite template (e.g. `k_{k}_limits`).
4820 refs.templates.push(body.to_string());
4821 // Recurse into the body to pick up the inner leaf
4822 // names as direct references.
4823 scan_param_refs(body, refs);
4824 } else if !body.is_empty()
4825 && body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
4826 && !body.bytes().next().unwrap().is_ascii_digit()
4827 {
4828 // Plain `{name}` — curly-brace placeholder. MUST
4829 // resolve to a declared / known / iter-var name;
4830 // the new validator surfaces an error if it doesn't.
4831 refs.placeholders.insert(body.to_string());
4832 } else {
4833 // Inline Polydat expression body, e.g.
4834 // `{is_one_of(cassandra_dialect, "vendor")}` or
4835 // `{:=mod(hash(cycle), 100):=}`. Walk the body,
4836 // collect identifier-shaped tokens that aren't
4837 // inside string literals — those are Polydat name
4838 // references which may resolve to workload params.
4839 // Over-collecting (function names, Polydat stdlib
4840 // identifiers) is harmless — the validator's
4841 // membership test below uses the workload's own
4842 // declared params as the universe of interest.
4843 //
4844 // These land in `expression_idents` (not
4845 // `placeholders`) because they may legitimately
4846 // resolve to Polydat wire names rather than workload
4847 // params; the "declared but unreferenced" check
4848 // still consults them via `ParamRefs::contains`,
4849 // but the new "referenced but undeclared" check
4850 // only scrutinises `placeholders`.
4851 scan_expression_idents(body, &mut refs.expression_idents);
4852 }
4853 i = j + 1;
4854 }
4855}
4856
4857/// Walk a GK-expression body (no surrounding `{}`) and add any
4858/// identifier-shaped tokens to `out`. Skips identifiers nested
4859/// inside `"..."` or `'...'` string literals — those are CQL /
4860/// regex / display strings, not name references. Recognises
4861/// backslash escapes inside string literals.
4862///
4863/// This is best-effort: it doesn't honor Polydat lexer subtleties
4864/// (numeric suffixes, raw strings, etc.). For the unused-param
4865/// check in `runner.rs::collect_param_references` the goal is
4866/// "does the param name appear anywhere we'd evaluate it" — a
4867/// loose match is correct because false positives only mean
4868/// "param is considered used when it might not have been",
4869/// which is the safer failure mode.
4870fn scan_expression_idents(body: &str, out: &mut std::collections::HashSet<String>) {
4871 let bytes = body.as_bytes();
4872 let mut i = 0;
4873 while i < bytes.len() {
4874 let b = bytes[i];
4875 // Skip `//` line comments — checked BEFORE the string-literal
4876 // scan below, because an apostrophe in comment prose (`hasn't`,
4877 // `don't`) would otherwise be read as an unterminated string
4878 // delimiter and swallow every identifier to end-of-input,
4879 // falsely flagging a later-referenced param as unused.
4880 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
4881 i += 2;
4882 while i < bytes.len() && bytes[i] != b'\n' {
4883 i += 1;
4884 }
4885 continue;
4886 }
4887 // Skip string literals.
4888 if b == b'"' || b == b'\'' {
4889 let quote = b;
4890 i += 1;
4891 while i < bytes.len() {
4892 if bytes[i] == b'\\' && i + 1 < bytes.len() {
4893 i += 2;
4894 continue;
4895 }
4896 if bytes[i] == quote {
4897 i += 1;
4898 break;
4899 }
4900 i += 1;
4901 }
4902 continue;
4903 }
4904 // Identifier start: ASCII letter or underscore.
4905 if b.is_ascii_alphabetic() || b == b'_' {
4906 let start = i;
4907 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
4908 i += 1;
4909 }
4910 let ident = &body[start..i];
4911 // Skip the few literals the Polydat lexer also recognises
4912 // — they're definitely not param names.
4913 if ident != "true" && ident != "false" {
4914 out.insert(ident.to_string());
4915 }
4916 continue;
4917 }
4918 i += 1;
4919 }
4920}
4921
4922/// Collect all `{name}` param references from a workload's ops,
4923/// phases, bindings, and scenario tree. Returns both direct
4924/// refs and composite templates so the validator can recognize
4925/// dynamic-name-composition references like `{k_{k}_limits}`.
4926fn collect_param_references(workload: &nmbrs_workload::model::Workload) -> ParamRefs {
4927 let mut refs = ParamRefs::default();
4928
4929 // Local helper so every op-bearing scope (top-level + per-
4930 // phase) hits the same set of fields. Critically this
4931 // includes the *core* fields the parser hoists out of the
4932 // op map — `if:` (condition), `delay:`, and the
4933 // serde_json values inside `params:`. Missing any of those
4934 // produced false positives on the unused-param check
4935 // (e.g. `if: '{is_one_of(cassandra_dialect, "vendor")}'`
4936 // landed in `condition` rather than `op.op`, so the
4937 // workload param `cassandra_dialect` looked unreferenced).
4938 fn scan_op(op: &nmbrs_workload::model::ParsedOp, refs: &mut ParamRefs) {
4939 for value in op.op.values() {
4940 if let serde_json::Value::String(s) = value {
4941 scan_param_refs(s, refs);
4942 }
4943 }
4944 // `if:` and `delay:` accept either `{name}` placeholders
4945 // (caught by `scan_param_refs`) or bare wire names like
4946 // `delay: think_time`. Walk both shapes — the bare-ident
4947 // pass mirrors what `scope.rs` Step 3/6 do for the same
4948 // fields, so a workload param consumed only via a bare
4949 // delay/condition reference doesn't trip the unused-param
4950 // validator.
4951 if let Some(s) = &op.condition {
4952 scan_param_refs(s, refs);
4953 scan_expression_idents(s, &mut refs.expression_idents);
4954 }
4955 if let Some(spec) = &op.delay {
4956 for name in spec.names() {
4957 scan_param_refs(name, refs);
4958 scan_expression_idents(name, &mut refs.expression_idents);
4959 }
4960 }
4961 // `params:` values can be strings, numbers, nested
4962 // maps (e.g. `relevancy: { actual: key, expected: …}`).
4963 // Walk the JSON recursively so anything stringy gets
4964 // scanned regardless of nesting depth.
4965 //
4966 // `gutter:` is exempt: its templates resolve at RUNTIME
4967 // (wires first, then status-metric aggregates like
4968 // `{recall}` / `{latency_p50}` which have no workload
4969 // declaration), and an unresolved name degrades to
4970 // visible literal text in the cell — never a silent
4971 // failure this validator needs to preempt.
4972 for (k, v) in op.params.iter() {
4973 if k == "gutter" {
4974 continue;
4975 }
4976 scan_json_for_refs(v, refs);
4977 }
4978 // Evaluation blocks reference WIRES by bare identifier
4979 // (`relevancy: { k: suite_k, expected: ground_truth }`) —
4980 // the same shapes `scope.rs` resolves at wrap-time
4981 // through the op-template kernel. Harvest them as
4982 // expression idents so a param consumed only as an
4983 // evaluation bound doesn't trip the unused-param check.
4984 // (Before this, such workloads only validated by ACCIDENT:
4985 // any `{{…}}` inline expression elsewhere in the merged
4986 // doc registered a composite template that wildcard-
4987 // matched every declared param.)
4988 if let Some(rel) = op.params.get("relevancy").and_then(|v| v.as_object()) {
4989 for key in ["actual", "expected", "k", "r"] {
4990 if let Some(s) = rel.get(key).and_then(|v| v.as_str()) {
4991 scan_expression_idents(s, &mut refs.expression_idents);
4992 }
4993 }
4994 }
4995 match &op.bindings {
4996 nmbrs_workload::model::BindingsDef::PolydatSource(s) => {
4997 // Two reference shapes inside Polydat source:
4998 // - `{name}` placeholders inside string literals
4999 // (resolved by Polydat string-interpolation against
5000 // `const` bindings),
5001 // - bare identifier references in Polydat expressions
5002 // (e.g. `row := char_buf(..., cols)` — `cols`
5003 // resolves directly to its `const` binding).
5004 // The unused-param validator must recognise both
5005 // or it falsely flags params that the workload
5006 // legitimately consumes via the bare path.
5007 scan_param_refs(s, refs);
5008 scan_expression_idents(s, &mut refs.expression_idents);
5009 }
5010 nmbrs_workload::model::BindingsDef::Map(m) => {
5011 for v in m.values() {
5012 scan_param_refs(v, refs);
5013 }
5014 }
5015 }
5016 }
5017
5018 // Scan top-level ops
5019 for op in &workload.ops {
5020 scan_op(op, &mut refs);
5021 }
5022
5023 // SRD-13f Push D: workload-level `bindings:` are no longer
5024 // folded into ops at YAML parse time — they live on
5025 // `workload.bindings` and reach descendants via the GK
5026 // kernel chain. The unused-param validator must scan them
5027 // directly here, otherwise a workload param consumed only
5028 // from workload-level bindings (`row := char_buf(..., cols)`)
5029 // looks unreferenced.
5030 match &workload.bindings {
5031 nmbrs_workload::model::BindingsDef::PolydatSource(s) => {
5032 scan_param_refs(s, &mut refs);
5033 scan_expression_idents(s, &mut refs.expression_idents);
5034 }
5035 nmbrs_workload::model::BindingsDef::Map(m) => {
5036 for v in m.values() {
5037 scan_param_refs(v, &mut refs);
5038 }
5039 }
5040 }
5041
5042 // SRD-83 — workload-level `stop_when:` predicates: `{param}` interpolation
5043 // only (same as phase-level), so a param used only by a workload breaker
5044 // counts as referenced.
5045 for c in &workload.stop_when {
5046 scan_param_refs(&c.when, &mut refs);
5047 }
5048
5049 // Scan phases
5050 for phase in workload.phases.values() {
5051 if let Some(s) = &phase.cycles {
5052 scan_param_refs(s, &mut refs);
5053 }
5054 // SRD-83 (C3) governance `timeout:` and SRD-75 `interval:`
5055 // resolve `{param}` at phase setup — documented reference
5056 // sites the collector must count (before this, workloads
5057 // using them only validated via the accidental composite-
5058 // template wildcard).
5059 if let Some(s) = &phase.timeout {
5060 scan_param_refs(s, &mut refs);
5061 }
5062 if let Some(s) = &phase.interval {
5063 scan_param_refs(s, &mut refs);
5064 }
5065 if let Some(s) = &phase.concurrency {
5066 scan_param_refs(s, &mut refs);
5067 }
5068 if let Some(s) = &phase.for_each {
5069 scan_param_refs(s, &mut refs);
5070 }
5071 // Phase `rate:` and the SRD-75 poll bounds carry `{param}`
5072 // references resolved at the phase gather (the `timeout:`
5073 // discipline) — documented reference sites the collector
5074 // must count. The poll predicate additionally consumes
5075 // bare identifiers (params/wires) like `continue_if`.
5076 if let Some(s) = &phase.rate {
5077 scan_param_refs(s, &mut refs);
5078 }
5079 if let Some(poll) = &phase.poll {
5080 scan_expression_idents(&poll.until, &mut refs.expression_idents);
5081 for s in [&poll.interval_ms, &poll.timeout_ms, &poll.max_error_retries]
5082 .into_iter()
5083 .flatten()
5084 {
5085 scan_param_refs(s, &mut refs);
5086 }
5087 }
5088 // SRD-13f Push D parallel: phase-level `bindings:` also
5089 // sit on their own scope post-Push-D; scan for param
5090 // refs so a phase-binding-only consumer doesn't falsely
5091 // trip the unused-param check.
5092 match &phase.bindings {
5093 nmbrs_workload::model::BindingsDef::PolydatSource(s) => {
5094 scan_param_refs(s, &mut refs);
5095 scan_expression_idents(s, &mut refs.expression_idents);
5096 }
5097 nmbrs_workload::model::BindingsDef::Map(m) => {
5098 for v in m.values() {
5099 scan_param_refs(v, &mut refs);
5100 }
5101 }
5102 }
5103 // SRD-83 / SRD-101 — breaker predicates can consume a workload param,
5104 // but by DIFFERENT mechanisms, so scan each for the shape it actually
5105 // supports (a param used in the UNsupported shape stays flagged):
5106 // - `stop_when` substitutes `{param}` before its predicate compiles;
5107 // its bound scope can't resolve a bare param wire → `{param}` only.
5108 // - `continue_if` resolves BARE wires through its for_iteration
5109 // scope-walk (inherited consts/params) → bare idents only.
5110 for c in &phase.stop_when {
5111 scan_param_refs(&c.when, &mut refs);
5112 }
5113 if let Some(ci) = &phase.continue_if {
5114 scan_expression_idents(&ci.when, &mut refs.expression_idents);
5115 }
5116 for op in &phase.ops {
5117 scan_op(op, &mut refs);
5118 }
5119 }
5120
5121 // Scan scenario tree — every node kind contributes its
5122 // `{...}`-bearing fields. DoWhile/DoUntil contribute their
5123 // condition text; ForEach/ForCombinations/ForEachUnion
5124 // contribute their iteration specs.
5125 //
5126 // Scenario-tree `{name}` placeholders are runtime-interpolated
5127 // against the outer iter-var scope (the comprehension's
5128 // enclosing for-each, plus workload params). An unresolved
5129 // placeholder there surfaces a `path:line:col:` runtime error
5130 // through the interpolation pipeline — the early
5131 // workload-level "undeclared placeholder" check would steal
5132 // that diagnostic with a less specific error. So we route
5133 // these refs through a side-channel that contributes to the
5134 // declared-but-unreferenced check (workload params used only
5135 // in for_each text are still counted as referenced) but NOT
5136 // to `refs.placeholders` (which drives the strict
5137 // "must-resolve-now" guard).
5138 fn scan_scenario_nodes(nodes: &[nmbrs_workload::model::ScenarioNode], refs: &mut ParamRefs) {
5139 for node in nodes {
5140 match node {
5141 nmbrs_workload::model::ScenarioNode::Phase(_) => {}
5142 nmbrs_workload::model::ScenarioNode::Comprehension {
5143 comprehension,
5144 children,
5145 ..
5146 } => {
5147 // Grammar-based source-reference extraction.
5148 // A comprehension clause `eh in eh_values`
5149 // carries `eh_values` as a *bare* source
5150 // reference (a `Generator`/`WorkloadParamList`
5151 // spec), and `(v) in (concat(foo))` carries
5152 // `foo` inside a function call. Byte-scanning
5153 // for `{name}` misses both. `referenced_source_names`
5154 // parses each spec with the Polydat expression
5155 // grammar (via `polydat::dsl::refs`) and returns
5156 // the free names structurally. These resolve at
5157 // runtime, so they count as references (for the
5158 // declared-but-unreferenced check) but bypass
5159 // the strict undeclared-placeholder guard.
5160 //
5161 // A dynamic param-list reference like
5162 // `limit in {k_{k}_limits}` surfaces as the
5163 // composite name `k_{k}_limits` (the inner
5164 // `{k}` is an iter-var hole filled at runtime).
5165 // Route composite names — those still carrying
5166 // a `{` — into `templates` so the structured
5167 // `template_matches` name-composition grammar
5168 // resolves them against `k_10_limits` /
5169 // `k_100_limits` / …; plain names go to the
5170 // runtime-placeholder set.
5171 for name in comprehension.referenced_source_names() {
5172 if name.contains('{') {
5173 refs.templates.push(name);
5174 } else {
5175 refs.runtime_only_placeholders.insert(name);
5176 }
5177 }
5178 scan_scenario_nodes(children, refs);
5179 }
5180 nmbrs_workload::model::ScenarioNode::DoWhile {
5181 condition,
5182 children,
5183 ..
5184 }
5185 | nmbrs_workload::model::ScenarioNode::DoUntil {
5186 condition,
5187 children,
5188 ..
5189 } => {
5190 scan_param_refs(condition, refs);
5191 scan_scenario_nodes(children, refs);
5192 }
5193 nmbrs_workload::model::ScenarioNode::IncludedScenario { children, .. } => {
5194 scan_scenario_nodes(children, refs);
5195 }
5196 nmbrs_workload::model::ScenarioNode::Bindings { source, children } => {
5197 // Scenario-tree `bindings:` (and the `set:`
5198 // sugar form) carries Polydat matter text. Scan
5199 // the body for `{name}` placeholders and
5200 // bare identifiers and route through
5201 // `runtime_only_placeholders` so a param
5202 // referenced only by a bindings body still
5203 // counts as referenced, without tripping the
5204 // strict undeclared-placeholder guard (the
5205 // body is resolved at kernel build time,
5206 // not at op-template substitution).
5207 let mut deferred = ParamRefs::default();
5208 scan_param_refs(source, &mut deferred);
5209 refs.runtime_only_placeholders.extend(deferred.placeholders);
5210 refs.expression_idents.extend(deferred.expression_idents);
5211 refs.templates.extend(deferred.templates);
5212 scan_scenario_nodes(children, refs);
5213 }
5214 }
5215 }
5216 }
5217 for nodes in workload.scenarios.values() {
5218 scan_scenario_nodes(nodes, &mut refs);
5219 }
5220
5221 refs
5222}
5223
5224/// Collect every iter-var name introduced by a `for_each:` /
5225/// `for_combinations:` clause anywhere in the scenario tree.
5226///
5227/// These names become legitimate `{name}` placeholders inside
5228/// phases reached via that for-clause — the runner binds them
5229/// fresh per iteration through the workload kernel's
5230/// scope-coordinate mechanism. The "referenced but undeclared"
5231/// validator consults this set so it doesn't false-positive on
5232/// `{k}` / `{limit}` / `{profile}` references that are clearly
5233/// satisfied by an enclosing `for_each: "k in …, limit in …,
5234/// profile in …"`.
5235///
5236/// Walks every scenario in the workload (the union — any
5237/// scenario the operator might invoke), so the validator
5238/// remains correct regardless of which `scenario=` argument
5239/// the operator passes on the CLI.
5240fn collect_iter_var_names(
5241 workload: &nmbrs_workload::model::Workload,
5242) -> std::collections::HashSet<String> {
5243 let mut out = std::collections::HashSet::new();
5244 for nodes in workload.scenarios.values() {
5245 for node in nodes {
5246 collect_iter_vars_recursive(node, &mut out);
5247 }
5248 }
5249 // Phase-level `for_each:` declarations also introduce iter-vars
5250 // — `phases.X.for_each: "k in 1, 2, 3"` lets the phase's ops
5251 // reference `{k}`. The scenario walker doesn't traverse phase
5252 // bodies, so harvest from each phase's `for_each` clause too.
5253 for phase in workload.phases.values() {
5254 if let Some(text) = phase.for_each.as_deref()
5255 && let Ok(comp) =
5256 polydat::iteration::comprehension::spec::parse_comprehension_algebra(text)
5257 {
5258 for name in comp.coordinate_names() {
5259 out.insert(name.to_string());
5260 }
5261 }
5262 }
5263 out
5264}
5265
5266fn collect_iter_vars_recursive(
5267 node: &nmbrs_workload::model::ScenarioNode,
5268 out: &mut std::collections::HashSet<String>,
5269) {
5270 use nmbrs_workload::model::ScenarioNode::*;
5271 match node {
5272 Phase(_) => {}
5273 Comprehension {
5274 comprehension,
5275 children,
5276 ..
5277 } => {
5278 for name in comprehension.coordinate_names() {
5279 out.insert(name.to_string());
5280 }
5281 for child in children {
5282 collect_iter_vars_recursive(child, out);
5283 }
5284 }
5285 DoWhile {
5286 children, counter, ..
5287 }
5288 | DoUntil {
5289 children, counter, ..
5290 } => {
5291 // `counter:` introduces a bare iteration index name
5292 // that's legitimately referenceable inside the loop
5293 // body, even though there's no `for_each` clause.
5294 if let Some(c) = counter {
5295 out.insert(c.clone());
5296 }
5297 for child in children {
5298 collect_iter_vars_recursive(child, out);
5299 }
5300 }
5301 IncludedScenario { children, .. } => {
5302 for child in children {
5303 collect_iter_vars_recursive(child, out);
5304 }
5305 }
5306 // Scenario-tree `bindings:` (and `set:` sugar) doesn't
5307 // introduce an iter-var; it publishes a scope-local
5308 // binding layer. Just walk children.
5309 Bindings { children, .. } => {
5310 for child in children {
5311 collect_iter_vars_recursive(child, out);
5312 }
5313 }
5314 }
5315}
5316
5317/// Collect every binding LHS name (wire output) declared in GK
5318/// source anywhere in the workload — top-level `bindings:`,
5319/// per-phase `bindings:`, and per-op `bindings:`.
5320///
5321/// These names become legitimate `{name}` placeholders inside
5322/// op text (op-template `prepared:` / `raw:` strings get
5323/// `{wire}` interpolated to the wire's value at cycle time, just
5324/// like workload params get expanded earlier in the pipeline).
5325/// The "referenced but undeclared" validator consults this set so
5326/// it doesn't false-positive on `{query_vector}` / `{dim}` /
5327/// `{ground_truth}` references that are clearly satisfied by an
5328/// enclosing `bindings:` block.
5329///
5330/// Scanner is line-based and recognises six shapes:
5331/// * `input NAME[: TYPE]` — kernel input slot (bare form)
5332/// * `input (NAME[: TYPE], ...)` — kernel input slot (tuple form)
5333/// * `const NAME := …` — init binding (eager, once per scope)
5334/// * `cursor NAME = …` — cursor declaration
5335/// * `shared NAME := …` — shared output (cross-scope cell)
5336/// * `const NAME := …` — final binding
5337/// * `NAME := …` — ordinary `:=` output binding
5338///
5339/// The collector also mirrors the workload-root kernel's auto-input
5340/// behaviour (see `bindings.rs::compile_workload_kernel`): when no
5341/// Polydat source anywhere in the workload declares any `input` slot,
5342/// the runtime injects `input cycle: u64` so `{cycle}` resolves at
5343/// op-template substitution time. Reflecting that injection in the
5344/// validator allow-set prevents false-positive rejection of
5345/// `{cycle}` placeholders in workloads with no explicit `bindings:`
5346/// block (e.g. inline `op="tick={cycle}"`).
5347fn collect_polydat_binding_names(
5348 workload: &nmbrs_workload::model::Workload,
5349) -> std::collections::HashSet<String> {
5350 let mut out = std::collections::HashSet::new();
5351 use nmbrs_workload::model::BindingsDef;
5352
5353 let mut any_input_decl = false;
5354 let mut scan_bindings =
5355 |bindings: &BindingsDef, sink: &mut std::collections::HashSet<String>| {
5356 match bindings {
5357 BindingsDef::PolydatSource(s) => {
5358 scan_polydat_binding_lhs(sink, s);
5359 if s.lines().any(|l| l.trim_start().starts_with("input ")) {
5360 any_input_decl = true;
5361 }
5362 }
5363 BindingsDef::Map(m) => {
5364 // Legacy nosqlbench-style chains (`Hash(); Mod(...)`)
5365 // are translated into Polydat bindings at runtime by
5366 // `compile_bindings_with_opts`; the keys of the map
5367 // become the wire names those translations produce.
5368 // The validator's allow-set must reflect those names
5369 // so referencing `{user_id}` in op text — where
5370 // `user_id: Hash(); Mod(...)` is the binding key —
5371 // doesn't trip the undeclared-placeholder guard.
5372 for name in m.keys() {
5373 sink.insert(name.clone());
5374 }
5375 }
5376 }
5377 };
5378
5379 scan_bindings(&workload.bindings, &mut out);
5380 // Top-level `workload.ops` carry their own `bindings:` blocks
5381 // in inline-mode workloads (the inline parser puts
5382 // synthesised `__inline_N := <expr>` lines there). Without
5383 // walking this collection, the validator misses every
5384 // inline-rewrite-generated wire name.
5385 for op in &workload.ops {
5386 scan_bindings(&op.bindings, &mut out);
5387 }
5388 for phase in workload.phases.values() {
5389 scan_bindings(&phase.bindings, &mut out);
5390 for op in &phase.ops {
5391 scan_bindings(&op.bindings, &mut out);
5392 }
5393 }
5394
5395 // Runtime mirror: workload-root kernel auto-injects
5396 // `input cycle: u64` when no `input` line is declared anywhere.
5397 // Surface that injection to the validator.
5398 if !any_input_decl {
5399 out.insert("cycle".to_string());
5400 }
5401 out
5402}
5403
5404/// One occurrence of an invalid `{name}` placeholder inside a
5405/// Polydat expression context. The `location` describes which source
5406/// block in the workload (e.g. `"phase 'ann_query' bindings"`),
5407/// and the `placeholder` is the literal body that appeared inside
5408/// the offending `{...}` — kept verbatim so the error formatter
5409/// can locate the exact YAML line by substring search.
5410#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
5411struct PolydatBraceFinding {
5412 location: String,
5413 placeholder: String,
5414}
5415
5416/// Collect every `{name}` placeholder that appears inside a GK
5417/// source context but OUTSIDE a string literal — these will
5418/// always fail Polydat compile because `{...}` isn't valid expression
5419/// syntax. Each finding names the workload block it came from
5420/// so the error formatter can point at the offending site.
5421///
5422/// Walks every Polydat source in the workload: top-level `bindings:`,
5423/// per-phase `bindings:`, per-op `bindings:`.
5424fn collect_polydat_brace_refs(
5425 workload: &nmbrs_workload::model::Workload,
5426) -> Vec<PolydatBraceFinding> {
5427 use nmbrs_workload::model::BindingsDef;
5428 let mut out: Vec<PolydatBraceFinding> = Vec::new();
5429 // Braces are no longer categorically invalid in Polydat: the block form of
5430 // conditional selection (`if <cond> { a } else { b }`) uses them. So a brace is
5431 // only evidence of a stray YAML placeholder when the source ALSO fails to parse.
5432 // Gating on parseability keeps this check doing its actual job — converting a
5433 // cryptic "expected expression, got LBrace" into a message that names the file,
5434 // line and placeholder — without rejecting valid if-expressions.
5435 let mut push_refs = |loc: &str, source: &str| {
5436 let parses = polydat::dsl::lexer::lex(source)
5437 .ok()
5438 .and_then(|toks| polydat::dsl::parser::parse(toks).ok())
5439 .is_some();
5440 if parses {
5441 return;
5442 }
5443 for name in scan_polydat_braced_refs(source) {
5444 out.push(PolydatBraceFinding {
5445 location: loc.to_string(),
5446 placeholder: name,
5447 });
5448 }
5449 };
5450 if let BindingsDef::PolydatSource(s) = &workload.bindings {
5451 push_refs("workload `bindings:`", s);
5452 }
5453 for (phase_name, phase) in &workload.phases {
5454 if let BindingsDef::PolydatSource(s) = &phase.bindings {
5455 push_refs(&format!("phase '{phase_name}' bindings"), s);
5456 }
5457 for op in &phase.ops {
5458 if let BindingsDef::PolydatSource(s) = &op.bindings {
5459 push_refs(&format!("phase '{phase_name}' op-bindings"), s);
5460 }
5461 }
5462 }
5463 out
5464}
5465
5466/// Scan the raw YAML source for the first line that contains
5467/// the given placeholder text (with surrounding braces).
5468/// Returns the 1-based line number when found, `None` otherwise.
5469///
5470/// Used to upgrade the GK-brace validator's error message from
5471/// "phase 'foo' bindings" to a file:line locator the operator
5472/// can click on or jump to. Works because YAML block scalars
5473/// (`|` / `>`) preserve the body verbatim; the offending
5474/// `{name}` substring appears in the file exactly as the
5475/// validator captured it.
5476///
5477/// Falls back to `None` on the rare cases where the literal
5478/// also appears in an unrelated comment or string — the
5479/// validator's caller treats that as "give the location but
5480/// no line number." The substring is namespaced enough
5481/// (`{name}` with curly braces) that collisions are unlikely
5482/// in practice.
5483fn find_yaml_line_for_brace(yaml_source: &str, placeholder: &str) -> Option<usize> {
5484 let needle = format!("{{{placeholder}}}");
5485 yaml_source
5486 .lines()
5487 .enumerate()
5488 .find(|(_, line)| line.contains(&needle))
5489 .map(|(idx, _)| idx + 1)
5490}
5491
5492/// Scan Polydat source for `{name}` placeholders that appear OUTSIDE
5493/// string literals and OUTSIDE comments. Inside `"..."` or
5494/// `'...'` (with backslash escapes), `{...}` is part of the
5495/// string and gets handled by runtime interpolation — leave it
5496/// alone. Lines starting with `#` (and content after `#` on any
5497/// line) are comments — also skipped.
5498fn scan_polydat_braced_refs(source: &str) -> Vec<String> {
5499 let bytes = source.as_bytes();
5500 let mut out: Vec<String> = Vec::new();
5501 let mut i = 0;
5502 while i < bytes.len() {
5503 let b = bytes[i];
5504 // Line comment — skip to next newline.
5505 if b == b'#' {
5506 while i < bytes.len() && bytes[i] != b'\n' {
5507 i += 1;
5508 }
5509 continue;
5510 }
5511 // String literal — skip past the closing quote.
5512 if b == b'"' || b == b'\'' {
5513 let quote = b;
5514 i += 1;
5515 while i < bytes.len() {
5516 if bytes[i] == b'\\' && i + 1 < bytes.len() {
5517 i += 2;
5518 continue;
5519 }
5520 if bytes[i] == quote {
5521 i += 1;
5522 break;
5523 }
5524 i += 1;
5525 }
5526 continue;
5527 }
5528 // Outside any string — `{` opens a brace placeholder.
5529 if b == b'{' {
5530 // Find the matching `}`, allowing one level of
5531 // nesting for composite forms like `{k_{k}_limits}`.
5532 // We only need the OUTER body for the error message
5533 // — the inner placeholders are also wrong but the
5534 // outer is what the operator sees.
5535 let start = i + 1;
5536 let mut depth = 1;
5537 let mut j = start;
5538 while j < bytes.len() && depth > 0 {
5539 match bytes[j] {
5540 b'{' => depth += 1,
5541 b'}' => depth -= 1,
5542 _ => {}
5543 }
5544 if depth == 0 {
5545 break;
5546 }
5547 j += 1;
5548 }
5549 if depth == 0 && j > start {
5550 let body = &source[start..j];
5551 // Trim whitespace; ignore obviously-empty bodies.
5552 let trimmed = body.trim();
5553 if !trimmed.is_empty() {
5554 out.push(trimmed.to_string());
5555 }
5556 i = j + 1;
5557 continue;
5558 }
5559 // Unmatched `{` — let the Polydat parser handle it with
5560 // its own error; don't double-report.
5561 break;
5562 }
5563 i += 1;
5564 }
5565 out
5566}
5567
5568/// Line-by-line scan of Polydat source for locally-bound names —
5569/// `cursor NAME = …`, `shared NAME := …`, `const NAME := …`,
5570/// `volatile NAME := …`, bare `NAME := …` assignments, and
5571/// `input NAME[: TYPE]` / `input (NAME[: TYPE], ...)` declarations.
5572/// Skips comments and blank lines. Lines that don't match any of
5573/// these shapes (function-call statements, comments, expression
5574/// continuations) are ignored.
5575fn scan_polydat_binding_lhs(out: &mut std::collections::HashSet<String>, source: &str) {
5576 for raw_line in source.lines() {
5577 let line = raw_line.trim();
5578 if line.is_empty() || line.starts_with('#') {
5579 continue;
5580 }
5581 // `input` declarations: collect declared slot names without
5582 // requiring an `=` / `:=` suffix. Both bare (`input cycle: u64`)
5583 // and tuple (`input (cycle: u64, q: f64)`) forms are handled.
5584 if let Some(rest) = line.strip_prefix("input ") {
5585 scan_input_decl_names(out, rest.trim());
5586 continue;
5587 }
5588 // `extern` declarations (`extern name: type [= default]`,
5589 // `extern (a: u64, b: f64)`) declare wire names just like
5590 // `input` — same `name: type` shape. Without this, a
5591 // `{name}` placeholder referencing an extern-declared wire
5592 // (e.g. a same-op capture target) trips the
5593 // undeclared-placeholder guard.
5594 if let Some(rest) = line.strip_prefix("extern ") {
5595 scan_input_decl_names(out, rest.trim());
5596 continue;
5597 }
5598 // Strip leading modifier (`const `, `cursor `, `shared `,
5599 // `volatile `); body is what follows. Modifiers can be
5600 // combined in a few cases (e.g. `shared const`,
5601 // `shared volatile`), so loop until no recognised prefix
5602 // remains.
5603 let mut body = line;
5604 loop {
5605 let stripped = body
5606 .strip_prefix("const ")
5607 .or_else(|| body.strip_prefix("cursor "))
5608 .or_else(|| body.strip_prefix("shared "))
5609 .or_else(|| body.strip_prefix("volatile "));
5610 match stripped {
5611 Some(rest) => body = rest,
5612 None => break,
5613 }
5614 }
5615 // Tuple-destructure LHS: `(a, b, c) := <expr>`. Each
5616 // identifier inside the parens becomes a separate
5617 // declared name. Used by multi-output stdlib nodes
5618 // (e.g. `(y, mo, d, h, mi, s, ms) := date_components(0)`)
5619 // and any other binding that unpacks multiple outputs.
5620 if body.starts_with('(') {
5621 if let Some(close) = body.find(')') {
5622 let after = body[close + 1..].trim_start();
5623 if after.starts_with(":=") || after.starts_with('=') {
5624 for raw in body[1..close].split(',') {
5625 let name = raw.trim();
5626 if !name.is_empty() {
5627 out.insert(name.to_string());
5628 }
5629 }
5630 }
5631 }
5632 continue;
5633 }
5634 // Pull the leading identifier.
5635 let bytes = body.as_bytes();
5636 let mut i = 0;
5637 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
5638 i += 1;
5639 }
5640 if i == 0 {
5641 continue;
5642 }
5643 // What follows the identifier? Skip whitespace.
5644 let mut j = i;
5645 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
5646 j += 1;
5647 }
5648 // Must be `=` (init/cursor) or `:=` (assignment); reject
5649 // anything else (function calls, expressions starting
5650 // with an ident, etc.).
5651 let is_binding = bytes.get(j) == Some(&b'=')
5652 || (bytes.get(j) == Some(&b':') && bytes.get(j + 1) == Some(&b'='));
5653 if is_binding {
5654 out.insert(body[..i].to_string());
5655 continue;
5656 }
5657 // Typed cell form: `name: type := default` (e.g.
5658 // `shared sstables: u64 := 0`). The `:` here is a type
5659 // annotation, not `:=` — skip the type token and accept
5660 // iff an assignment follows it. Without this arm, typed
5661 // shared/volatile declarations were invisible to the
5662 // undeclared-placeholder validator and any `{name}`
5663 // reference to one tripped a false positive.
5664 if bytes.get(j) == Some(&b':') {
5665 let rest = body[j + 1..].trim_start();
5666 let te = rest
5667 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
5668 .unwrap_or(rest.len());
5669 if te > 0 {
5670 let after_ty = rest[te..].trim_start();
5671 if after_ty.starts_with(":=") || after_ty.starts_with('=') {
5672 out.insert(body[..i].to_string());
5673 }
5674 }
5675 }
5676 }
5677}
5678
5679/// Parse the name(s) out of an `input` declaration body (the text
5680/// after the `input ` keyword has been stripped).
5681///
5682/// Accepts both surface forms:
5683/// - `cycle` / `cycle: u64` → inserts `cycle`
5684/// - `(a: u64, b: f64, ...)` → inserts each declared name
5685///
5686/// Mirrors `parse_input_decl` in the Polydat parser; this is a
5687/// lightweight scanner used by scope-elision to register
5688/// locally-bound names without re-running the full lexer/parser.
5689fn scan_input_decl_names(out: &mut std::collections::HashSet<String>, body: &str) {
5690 let body = body.trim();
5691 if let Some(inner) = body.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
5692 for part in inner.split(',') {
5693 let name = part.trim().split(':').next().unwrap_or("").trim();
5694 if !name.is_empty() {
5695 out.insert(name.to_string());
5696 }
5697 }
5698 return;
5699 }
5700 let name = body.split(':').next().unwrap_or("").trim();
5701 if !name.is_empty() {
5702 out.insert(name.to_string());
5703 }
5704}
5705
5706/// Resolve a config value to u64 via Polydat scope lookup or numeric parsing.
5707pub fn resolve_polydat_config(
5708 value: &str,
5709 kernel: &crate::scope_kernel::ScopeKernel,
5710) -> Option<u64> {
5711 if value.starts_with('{') && value.ends_with('}') {
5712 let inner = &value[1..value.len() - 1];
5713 // SRD-16 §"Visibility Rules: Shadowing": `lookup`
5714 // walks own folded outputs first then the cell-aware
5715 // input slot, so a config reference like `{cycles}`
5716 // resolves whether `cycles` is a folded constant or
5717 // an extern bound from an outer scope. The previous
5718 // `get_constant` shape only saw the folded tier, so
5719 // configs referencing iter-vars or workload params
5720 // silently fell through to `eval_const_expr`.
5721 if let Some(v) = kernel.lookup(inner) {
5722 return Some(value_to_u64(&v));
5723 }
5724 match polydat::dsl::compile::eval_const_expr(inner) {
5725 Ok(v) => Some(value_to_u64(&v)),
5726 Err(e) => {
5727 crate::diag!(
5728 crate::observer::LogLevel::Error,
5729 "error: const expression failed: '{{{inner}}}'"
5730 );
5731 crate::diag!(crate::observer::LogLevel::Error, " {e}");
5732 None
5733 }
5734 }
5735 } else {
5736 parse_count(value)
5737 }
5738}
5739
5740/// Convert a Polydat Value to u64, handling f64→u64 truncation.
5741fn value_to_u64(v: &polydat::ast::Value) -> u64 {
5742 match v {
5743 polydat::ast::Value::U64(n) => *n,
5744 polydat::ast::Value::F64(f) => *f as u64,
5745 polydat::ast::Value::Bool(b) => {
5746 if *b {
5747 1
5748 } else {
5749 0
5750 }
5751 }
5752 _ => 0,
5753 }
5754}
5755
5756/// Resolve a scenario name to a list of phase names.
5757fn resolve_scenario(
5758 scenarios: &HashMap<String, Vec<nmbrs_workload::model::ScenarioNode>>,
5759 phase_order: &[String],
5760 name: &str,
5761) -> Result<Vec<nmbrs_workload::model::ScenarioNode>, String> {
5762 if let Some(nodes) = scenarios.get(name) {
5763 return Ok(nodes.clone());
5764 }
5765 if name == "default" && !phase_order.is_empty() {
5766 return Ok(phase_order
5767 .iter()
5768 .map(|n| nmbrs_workload::model::ScenarioNode::Phase(n.clone()))
5769 .collect());
5770 }
5771 Err(format!("scenario '{name}' not found"))
5772}
5773
5774/// Format a scenario tree for display — one construct per line,
5775/// nested with two-space indent per level. Phases include their
5776/// declared `cycles:` and `concurrency:` config when available
5777/// from the workload's phase map so the operator can see the
5778/// run shape at a glance.
5779///
5780/// Example output for the full_cql_vector fulltest scenario:
5781///
5782/// ```text
5783/// scenario 'test_oracles'
5784/// for_each profile in matching_profiles('{dataset}', '{oracles_prefix}')
5785/// for_each table in vec_{profile}
5786/// teardown (cycles: 1, concurrency: 1)
5787/// schema (cycles: 1, concurrency: 1)
5788/// rampup
5789/// jolokia_flush
5790/// for_combinations [k, limit] in {k_values}, {k_{k}_limits}
5791/// ann_query (concurrency: {query_concurrency})
5792/// scenario 'test_fknn'
5793/// ...
5794/// recall_audit_oracle
5795/// recall_audit_pvs
5796/// ```
5797///
5798/// The replaced LISP-shaped one-liner had bracket nesting that
5799/// scaled badly past two levels and required mental parsing to
5800/// see the loop structure.
5801fn format_scenario_tree(
5802 nodes: &[nmbrs_workload::model::ScenarioNode],
5803 phases: &std::collections::HashMap<String, nmbrs_workload::model::WorkloadPhase>,
5804) -> String {
5805 let mut out = String::new();
5806 format_scenario_nodes(nodes, phases, 0, &mut out);
5807 // Trim trailing newline so the runner's log call doesn't
5808 // emit a double-blank line.
5809 if out.ends_with('\n') {
5810 out.pop();
5811 }
5812 out
5813}
5814
5815/// Render a multi-coord comprehension's `[vars] in [specs]`
5816/// in two column-aligned lines. Column `i` is padded to the
5817/// widest of `vars[i]` and `specs[i]` so corresponding entries
5818/// stack vertically:
5819///
5820/// ```text
5821/// for [sm, mnc, bw, eh, alf_label]
5822/// in [{sm_values}, {mnc_values}, {bw_values}, {eh_values}, concat({alf_label_values})]
5823/// ```
5824///
5825/// `for ` and ` in ` are 4 chars (padding `in` with a leading
5826/// space) so the `[` brackets and every column thereafter
5827/// share the same vertical line. Color highlights the
5828/// keywords when the active terminal supports it; on a
5829/// piped/no-color stderr the output stays plain.
5830///
5831/// `indent_prefix` is the per-depth indent at the call site
5832/// — applied to the second line so it sits at the same depth
5833/// as the first.
5834fn format_for_combinations(pairs: &[(String, String)], indent_prefix: &str, color: bool) -> String {
5835 let kw_open = if color { "\x1b[1;36m" } else { "" };
5836 let kw_close = if color { "\x1b[0m" } else { "" };
5837 let bracket_open = if color { "\x1b[2m" } else { "" };
5838 let bracket_close = if color { "\x1b[0m" } else { "" };
5839
5840 let widths: Vec<usize> = pairs
5841 .iter()
5842 .map(|(v, s)| v.chars().count().max(s.chars().count()))
5843 .collect();
5844
5845 let pad = |entry: &str, idx: usize, last: bool| -> String {
5846 // Last column gets no trailing comma + no padding —
5847 // the closing `]` lands flush against the final token.
5848 if last {
5849 entry.to_string()
5850 } else {
5851 // `<entry>,` then pad to `widths[idx] + 1` so the
5852 // next column begins at a constant offset.
5853 let with_comma = format!("{entry},");
5854 let width_target = widths[idx] + 1; // +1 for the comma
5855 let visible = with_comma.chars().count();
5856 if visible >= width_target {
5857 with_comma
5858 } else {
5859 format!("{with_comma}{:<pad$}", "", pad = width_target - visible)
5860 }
5861 }
5862 };
5863
5864 let last_idx = pairs.len().saturating_sub(1);
5865 let vars_line: String = pairs
5866 .iter()
5867 .enumerate()
5868 .map(|(i, (v, _))| pad(v, i, i == last_idx))
5869 .collect::<Vec<_>>()
5870 .join(" ");
5871 let specs_line: String = pairs
5872 .iter()
5873 .enumerate()
5874 .map(|(i, (_, s))| pad(s, i, i == last_idx))
5875 .collect::<Vec<_>>()
5876 .join(" ");
5877
5878 format!(
5879 "{kw_open}for{kw_close} {bracket_open}[{bracket_close}{vars_line}{bracket_open}]{bracket_close}\n\
5880 {indent_prefix} {kw_open}in{kw_close} {bracket_open}[{bracket_close}{specs_line}{bracket_open}]{bracket_close}"
5881 )
5882}
5883
5884fn format_scenario_nodes(
5885 nodes: &[nmbrs_workload::model::ScenarioNode],
5886 phases: &std::collections::HashMap<String, nmbrs_workload::model::WorkloadPhase>,
5887 depth: usize,
5888 out: &mut String,
5889) {
5890 use nmbrs_workload::model::ScenarioNode::*;
5891 let indent = " ".repeat(depth);
5892 for node in nodes {
5893 match node {
5894 Phase(name) => {
5895 let suffix = phases
5896 .get(name)
5897 .map(format_phase_config_suffix)
5898 .unwrap_or_default();
5899 if suffix.is_empty() {
5900 out.push_str(&format!("{indent}{name}\n"));
5901 } else {
5902 // Pad name to a fixed column so the
5903 // `(cycles:..., concurrency:...)` chip lines
5904 // up across consecutive phases. 32 chars
5905 // covers the typical phase names; longer
5906 // names just push past the column without
5907 // breaking layout.
5908 out.push_str(&format!("{indent}{name:<32} {suffix}\n",));
5909 }
5910 }
5911 Comprehension {
5912 comprehension,
5913 children,
5914 ..
5915 } => {
5916 // Algebra-native display: walk the AST once to
5917 // detect Union vs flat, then format. Matches
5918 // the scope_tree::label_for_comprehension shape
5919 // at one level of detail finer (includes the
5920 // spec_expr for non-Union shapes).
5921 use polydat::iteration::comprehension::Comprehension as Comp;
5922 // Peel outer Order/Filter for structural detection.
5923 let mut body = comprehension;
5924 while let Comp::Order { child, .. } | Comp::Filter { child, .. } = body {
5925 body = child;
5926 }
5927 let header = match body {
5928 Comp::Union { children } => {
5929 let names = comprehension.coordinate_names().join(", ");
5930 format!("for_each_union [{}] ({} sub-spaces)", names, children.len())
5931 }
5932 _ => {
5933 let pairs = comprehension.coordinate_specs();
5934 if pairs.len() == 1 {
5935 let (var, spec) = &pairs[0];
5936 format!("for_each {var} in {spec}")
5937 } else {
5938 // Two-line column-aligned form for
5939 // multi-coord comprehensions: variable
5940 // names on the first line, source
5941 // expressions on the second, each
5942 // column padded to its widest
5943 // (var, spec) pair so the columns
5944 // line up vertically. Keywords
5945 // `for` / ` in` are right-aligned
5946 // so the `[` brackets land in the
5947 // same column.
5948 format_for_combinations(&pairs, &indent, crate::observer::use_color())
5949 }
5950 }
5951 };
5952 out.push_str(&format!("{indent}{header}\n"));
5953 format_scenario_nodes(children, phases, depth + 1, out);
5954 }
5955 DoWhile {
5956 condition,
5957 counter,
5958 children,
5959 } => {
5960 let ctr = counter
5961 .as_deref()
5962 .map(|c| format!(" (counter={c})"))
5963 .unwrap_or_default();
5964 out.push_str(&format!("{indent}do_while '{condition}'{ctr}\n"));
5965 format_scenario_nodes(children, phases, depth + 1, out);
5966 }
5967 DoUntil {
5968 condition,
5969 counter,
5970 children,
5971 } => {
5972 let ctr = counter
5973 .as_deref()
5974 .map(|c| format!(" (counter={c})"))
5975 .unwrap_or_default();
5976 out.push_str(&format!("{indent}do_until '{condition}'{ctr}\n"));
5977 format_scenario_nodes(children, phases, depth + 1, out);
5978 }
5979 IncludedScenario { name, children } => {
5980 out.push_str(&format!("{indent}scenario '{name}'\n"));
5981 format_scenario_nodes(children, phases, depth + 1, out);
5982 }
5983 Bindings { source, children } => {
5984 // First non-empty line of the source as a one-
5985 // line summary in the scenario-tree dump. Long
5986 // bodies stay readable in the YAML; the
5987 // hierarchical view just teases the binding.
5988 let summary = source
5989 .lines()
5990 .map(str::trim)
5991 .find(|l| !l.is_empty())
5992 .unwrap_or("");
5993 if source.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
5994 out.push_str(&format!("{indent}bindings: {summary} …\n"));
5995 } else {
5996 out.push_str(&format!("{indent}bindings: {summary}\n"));
5997 }
5998 format_scenario_nodes(children, phases, depth + 1, out);
5999 }
6000 }
6001 }
6002}
6003
6004/// Render the `(cycles: X, concurrency: Y)` suffix for a phase
6005/// line in the scenario-tree summary. Includes only the fields
6006/// that the phase actually declared — phases that inherit the
6007/// runtime defaults skip the chip entirely so the tree line
6008/// stays uncluttered. Strings are shown verbatim (including
6009/// `{name}` placeholders) so the operator sees the workload's
6010/// declared intent rather than a runtime-evaluated number.
6011fn format_phase_config_suffix(phase: &nmbrs_workload::model::WorkloadPhase) -> String {
6012 let mut parts: Vec<String> = Vec::new();
6013 if let Some(c) = phase.cycles.as_deref()
6014 && !c.is_empty()
6015 {
6016 parts.push(format!("cycles: {c}"));
6017 }
6018 if let Some(c) = phase.concurrency.as_deref()
6019 && !c.is_empty()
6020 {
6021 parts.push(format!("concurrency: {c}"));
6022 }
6023 if parts.is_empty() {
6024 String::new()
6025 } else {
6026 format!("({})", parts.join(", "))
6027 }
6028}
6029
6030/// SRD-108 Part B — load a secondary workload document (an
6031/// `implements:` target, or the `impl=` module) through the same
6032/// resolution the primary `workload=` uses: local file first,
6033/// then the bundled catalog. Returns the parsed workload plus its
6034/// canonical identity string (canonicalized path, or catalog
6035/// name) for target-matching.
6036fn load_secondary_workload(
6037 reference: &str,
6038 params: &HashMap<String, String>,
6039 base_dir: Option<&std::path::Path>,
6040 bundled_origin: Option<&str>,
6041) -> Result<(nmbrs_workload::model::Workload, String), String> {
6042 match resolve_secondary_ref(reference, base_dir, bundled_origin)? {
6043 ResolvedWorkload::Path(path) => {
6044 let workload = nmbrs_workload::parse::parse_workload_from_path(
6045 std::path::Path::new(&path),
6046 params,
6047 )
6048 .map_err(|e| format!("parse workload '{path}': {e}"))?;
6049 Ok((workload, canonical_identity(&path)))
6050 }
6051 ResolvedWorkload::Bundled(bundled) => {
6052 let (merged, res_warnings) =
6053 nmbrs_workload::extends::load_and_merge_bundled(bundled)
6054 .map_err(|e| format!("bundled workload `{}`: {e}", bundled.name))?;
6055 let mut workload = nmbrs_workload::parse::parse_workload(&merged, params)
6056 .map_err(|e| format!("parse bundled workload `{}`: {e}", bundled.name))?;
6057 workload.resolution_warnings.extend(res_warnings);
6058 Ok((workload, bundled.name.to_string()))
6059 }
6060 }
6061}
6062
6063/// Resolve a workload reference to its canonical identity WITHOUT
6064/// parsing it — used to compare an `implements:` target against
6065/// the invoked `workload=`.
6066fn workload_ref_identity(
6067 reference: &str,
6068 base_dir: Option<&std::path::Path>,
6069 bundled_origin: Option<&str>,
6070) -> Result<String, String> {
6071 match resolve_secondary_ref(reference, base_dir, bundled_origin)? {
6072 ResolvedWorkload::Path(path) => Ok(canonical_identity(&path)),
6073 ResolvedWorkload::Bundled(bundled) => Ok(bundled.name.to_string()),
6074 }
6075}
6076
6077/// SRD-109 Part 2 — resolve a driver manifest by name: local
6078/// `drivers/<name>/driver.yaml` under the cwd first, then the
6079/// bundled catalog entry `drivers/<name>/driver`. Both at once
6080/// FAVORS the local manifest (SRD-85 nearest-first) with a
6081/// logged warning naming both — shadowing is allowed but never
6082/// silent. Returns the manifest plus the resolved LIBRARY
6083/// reference: a file path for a local manifest, a catalog name
6084/// for a bundled one. `None` when neither exists — the caller
6085/// falls back to the legacy driver-as-adapter-alias meaning.
6086fn resolve_driver_manifest(
6087 name: &str,
6088) -> Result<Option<(nmbrs_workload::drivers::DriverManifest, String)>, String> {
6089 let local_path = std::path::Path::new("drivers")
6090 .join(name)
6091 .join("driver.yaml");
6092 let catalog_name = format!("drivers/{name}/driver");
6093 let bundled = nmbrs_workload::catalog::lookup(&catalog_name);
6094 match (local_path.is_file(), bundled) {
6095 (true, Some(_)) => {
6096 crate::observer::log_tagged(
6097 crate::observer::LogLevel::Warn,
6098 crate::observer::EventTag::in_flight(crate::observer::EventCategory::Resolution),
6099 &format!(
6100 "resolve: driver '{name}' matches multiple resources — local \
6101 manifest {} AND bundled driver `{catalog_name}` — using the \
6102 local manifest (filesystem-first). Same-named resources in \
6103 multiple places invite confusion: prefer a unique name.",
6104 local_path.display()
6105 ),
6106 );
6107 let source = std::fs::read_to_string(&local_path)
6108 .map_err(|e| format!("read driver manifest {}: {e}", local_path.display()))?;
6109 let manifest = nmbrs_workload::drivers::parse_driver_manifest(
6110 &source,
6111 &local_path.display().to_string(),
6112 )?;
6113 verify_driver_identity(&manifest, name)?;
6114 let dir = local_path.parent().expect("manifest path has a parent");
6115 let library_ref = resolve_driver_library_local(dir, &manifest.library)?;
6116 Ok(Some((manifest, library_ref)))
6117 }
6118 (true, None) => {
6119 let source = std::fs::read_to_string(&local_path)
6120 .map_err(|e| format!("read driver manifest {}: {e}", local_path.display()))?;
6121 let manifest = nmbrs_workload::drivers::parse_driver_manifest(
6122 &source,
6123 &local_path.display().to_string(),
6124 )?;
6125 verify_driver_identity(&manifest, name)?;
6126 let dir = local_path.parent().expect("manifest path has a parent");
6127 let library_ref = resolve_driver_library_local(dir, &manifest.library)?;
6128 Ok(Some((manifest, library_ref)))
6129 }
6130 (false, Some(entry)) => {
6131 let manifest =
6132 nmbrs_workload::drivers::parse_driver_manifest(entry.source, &catalog_name)?;
6133 verify_driver_identity(&manifest, name)?;
6134 let stem = manifest
6135 .library
6136 .strip_suffix(".yaml")
6137 .or_else(|| manifest.library.strip_suffix(".yml"))
6138 .unwrap_or(&manifest.library);
6139 let library_ref = format!("drivers/{name}/{stem}");
6140 Ok(Some((manifest, library_ref)))
6141 }
6142 (false, None) => Ok(None),
6143 }
6144}
6145
6146/// A manifest must be named for the directory it lives in — a
6147/// mismatch is a packaging bug surfaced at load, not a silent
6148/// re-route.
6149fn verify_driver_identity(
6150 manifest: &nmbrs_workload::drivers::DriverManifest,
6151 name: &str,
6152) -> Result<(), String> {
6153 if manifest.driver != name {
6154 return Err(format!(
6155 "driver manifest for '{name}' declares `driver: {}` — the \
6156 manifest must be named for its directory",
6157 manifest.driver
6158 ));
6159 }
6160 Ok(())
6161}
6162
6163/// Resolve a local manifest's `library:` reference beside the
6164/// manifest: as written first, then with `.yaml` appended.
6165fn resolve_driver_library_local(dir: &std::path::Path, library: &str) -> Result<String, String> {
6166 let as_written = dir.join(library);
6167 if as_written.is_file() {
6168 return Ok(as_written.display().to_string());
6169 }
6170 let with_ext = dir.join(format!("{library}.yaml"));
6171 if with_ext.is_file() {
6172 return Ok(with_ext.display().to_string());
6173 }
6174 Err(format!(
6175 "driver manifest {}: library '{library}' not found beside the manifest",
6176 dir.display()
6177 ))
6178}
6179
6180/// Apply a resolved driver manifest to the invocation params:
6181/// the library lands as `workload=` (no workload given) or
6182/// `impl=` (a blueprint was invoked); the backing adapter and the
6183/// manifest's default params fill only ABSENT keys, so the CLI
6184/// always wins and the defaults still overlay the library's own
6185/// declared params (this map is the CLI overlay layer).
6186fn apply_driver_manifest(
6187 params: &mut HashMap<String, String>,
6188 driver_name: &str,
6189 manifest: nmbrs_workload::drivers::DriverManifest,
6190 library_ref: String,
6191 workload_given: bool,
6192) -> Result<(), String> {
6193 if params.contains_key("impl") {
6194 return Err(format!(
6195 "driver={driver_name} supplies the implementation library \
6196 ('{}') — impl= conflicts; pass one or the other",
6197 manifest.library
6198 ));
6199 }
6200 if workload_given {
6201 params.insert("impl".into(), library_ref.clone());
6202 } else {
6203 params.insert("workload".into(), library_ref.clone());
6204 }
6205 params
6206 .entry("adapter".to_string())
6207 .or_insert_with(|| manifest.adapter.clone());
6208 for (k, v) in &manifest.default_params {
6209 params.entry(k.clone()).or_insert_with(|| v.clone());
6210 }
6211 crate::diag!(
6212 crate::observer::LogLevel::Info,
6213 "driver: {driver_name} → adapter={}, library={library_ref}",
6214 manifest.adapter
6215 );
6216 Ok(())
6217}
6218
6219/// Resolve a secondary workload reference the way `extends:`
6220/// targets resolve: relative to the REFERRING DOCUMENT's
6221/// directory first (when one exists on disk), then the standard
6222/// cwd-local + bundled-catalog path. Without the base-dir leg, an
6223/// `implements: ./blueprint.yaml` inside a file would only resolve
6224/// when the invoking cwd happens to be the file's directory.
6225fn resolve_secondary_ref(
6226 reference: &str,
6227 base_dir: Option<&std::path::Path>,
6228 bundled_origin: Option<&str>,
6229) -> Result<ResolvedWorkload, String> {
6230 // SRD-85 nearest-first: every candidate is enumerated and the
6231 // nearest wins — the logical filesystem location is favored
6232 // over the embedded catalog BY DEFAULT (a fresh checkout must
6233 // never be silently shadowed by a stale binary's catalog).
6234 // Multiple matches are a warnable condition, logged with every
6235 // candidate named; never a hard error and never silent.
6236 let pinned = reference.starts_with("./") || reference.starts_with("../");
6237
6238 // 1. The referring document's own directory (nearest).
6239 let origin_file: Option<String> = base_dir
6240 .map(|dir| dir.join(reference))
6241 .filter(|c| c.is_file())
6242 .map(|c| c.display().to_string());
6243
6244 // A `./`-pinned reference that resolves beside its referring
6245 // FILE is explicit — no enumeration, no warning.
6246 if pinned && base_dir.is_some() && origin_file.is_some() {
6247 return Ok(ResolvedWorkload::Path(origin_file.unwrap()));
6248 }
6249
6250 // 2. The cwd's logical layout (exact path, extension probing,
6251 // cwd `workloads/`).
6252 let cwd_file: Option<String> = resolve_workload_file(reference).filter(|p| {
6253 // Dedupe against the origin-dir candidate.
6254 origin_file.as_deref().map(canonical_identity) != Some(canonical_identity(p))
6255 });
6256
6257 // 3. The bundled catalog: exact name, then the sibling idiom —
6258 // the referring bundled document's namespace — then the bare
6259 // stem (extension and `./` stripped: files reference siblings
6260 // by filename, catalog names carry none).
6261 let stem = reference
6262 .strip_suffix(".yaml")
6263 .or_else(|| reference.strip_suffix(".yml"))
6264 .unwrap_or(reference);
6265 let stem = stem.strip_prefix("./").unwrap_or(stem);
6266 let ns_stem = bundled_origin
6267 .and_then(|o| o.rsplit_once('/'))
6268 .map(|(ns, _)| format!("{ns}/{stem}"));
6269 let bundled: Option<&'static nmbrs_workload::catalog::BundledWorkload> =
6270 nmbrs_workload::catalog::lookup(reference)
6271 .or_else(|| ns_stem.as_deref().and_then(nmbrs_workload::catalog::lookup))
6272 .or_else(|| nmbrs_workload::catalog::lookup(stem));
6273
6274 let mut names: Vec<String> = Vec::new();
6275 if let Some(p) = &origin_file {
6276 names.push(format!("file {p} (beside the referring document)"));
6277 }
6278 if let Some(p) = &cwd_file {
6279 names.push(format!("local file {p}"));
6280 }
6281 if let Some(b) = bundled {
6282 names.push(format!("bundled workload `{}`", b.name));
6283 }
6284
6285 if names.len() > 1 {
6286 crate::observer::log_tagged(
6287 crate::observer::LogLevel::Warn,
6288 crate::observer::EventTag::in_flight(crate::observer::EventCategory::Resolution),
6289 &format!(
6290 "resolve: reference '{reference}' matches multiple resources — {} — \
6291 using the nearest ({}). Same-named resources in multiple places \
6292 invite confusion: prefer a unique name, or pin the intent with a \
6293 `./` path / full catalog name.",
6294 names.join(" AND "),
6295 names[0]
6296 ),
6297 );
6298 }
6299
6300 if let Some(p) = origin_file {
6301 return Ok(ResolvedWorkload::Path(p));
6302 }
6303 if let Some(p) = cwd_file {
6304 return Ok(ResolvedWorkload::Path(p));
6305 }
6306 if let Some(b) = bundled {
6307 return Ok(ResolvedWorkload::Bundled(b));
6308 }
6309 Err(format!(
6310 "workload not found: '{reference}'. Not a local file, and no bundled \
6311 workload by that name — `nmbrs describe workloads` lists what this \
6312 binary carries.{}",
6313 nmbrs_workload::suggest::did_you_mean(&nmbrs_workload::suggest::suggest_workloads(
6314 reference
6315 )),
6316 ))
6317}
6318
6319/// Canonicalize a workload file path for identity comparison;
6320/// bundled names pass through unchanged (they contain no path
6321/// separators that resolve).
6322fn canonical_identity(reference: &str) -> String {
6323 std::fs::canonicalize(reference)
6324 .map(|p| p.display().to_string())
6325 .unwrap_or_else(|_| reference.to_string())
6326}
6327
6328/// SRD-106 Part 3 — light pre-read of the workload's merged YAML
6329/// for the top-level `stick_session:` flag. Runs BEFORE session
6330/// construction (the full workload parse happens after the session
6331/// exists, so it cannot inform session resolution). Resolution
6332/// mirrors the execution path — local file first, then the bundled
6333/// catalog — and `extends:` chains merge before the peek, so a
6334/// suite base declaring `stick_session: true` reaches derived
6335/// workloads. Any failure answers `None`; the full parse surfaces
6336/// the real error with proper context.
6337fn peek_stick_session(params: &HashMap<String, String>, args: &[String]) -> Option<bool> {
6338 if params.contains_key("op") {
6339 return None; // inline workloads carry no header
6340 }
6341 let workload_raw = params.get("workload").cloned().or_else(|| {
6342 args.iter()
6343 .find(|a| a.ends_with(".yaml") || a.ends_with(".yml"))
6344 .cloned()
6345 })?;
6346 // Pre-probe only (stick_session peek): resolution warnings
6347 // are dropped here — the authoritative load that follows
6348 // surfaces the identical warnings itself.
6349 let merged = match resolve_workload(&workload_raw).ok()? {
6350 ResolvedWorkload::Path(p) => {
6351 nmbrs_workload::extends::load_and_merge(std::path::Path::new(&p))
6352 .ok()?
6353 .0
6354 }
6355 ResolvedWorkload::Bundled(b) => nmbrs_workload::extends::load_and_merge_bundled(b).ok()?.0,
6356 };
6357 let doc: serde_yaml::Value = serde_yaml::from_str(&merged).ok()?;
6358 doc.get("stick_session")?.as_bool()
6359}
6360
6361/// Resolve a workload file path from a bare name.
6362/// Tries: as-is, with .yaml/.yml extension, then under workloads/.
6363/// SRD-85 resolution result: a local file path or a bundled
6364/// catalog entry.
6365pub enum ResolvedWorkload {
6366 Path(String),
6367 Bundled(&'static nmbrs_workload::catalog::BundledWorkload),
6368}
6369
6370/// Resolve a `workload=` value per SRD-85 nearest-first: local
6371/// files first (exact path, extension probing, cwd `workloads/`),
6372/// then the bundled catalog by exact name. A name that resolves
6373/// both ways FAVORS the logical filesystem location and logs a
6374/// warning naming both — shadowing is allowed but never silent.
6375/// `./`-prefixed paths pin the local reading without a warning.
6376pub fn resolve_workload(name: &str) -> Result<ResolvedWorkload, String> {
6377 let local = resolve_workload_file(name);
6378 let bundled = nmbrs_workload::catalog::lookup(name);
6379 match (local, bundled) {
6380 (Some(local_path), Some(b)) => {
6381 if !name.starts_with("./") && !name.starts_with("../") {
6382 crate::observer::log_tagged(
6383 crate::observer::LogLevel::Warn,
6384 crate::observer::EventTag::in_flight(
6385 crate::observer::EventCategory::Resolution,
6386 ),
6387 &format!(
6388 "resolve: workload '{name}' matches multiple resources — \
6389 local file {local_path} AND bundled workload `{}` — using \
6390 the local file (filesystem-first). Same-named resources in \
6391 multiple places invite confusion: prefer a unique name, or \
6392 pin the intent with a `./` path.",
6393 b.name
6394 ),
6395 );
6396 }
6397 Ok(ResolvedWorkload::Path(local_path))
6398 }
6399 (Some(local_path), None) => Ok(ResolvedWorkload::Path(local_path)),
6400 (None, Some(b)) => Ok(ResolvedWorkload::Bundled(b)),
6401 (None, None) => Err(format!(
6402 "workload not found: '{name}'. Not a local file, and no bundled \
6403 workload by that name — `nmbrs describe workloads` lists what \
6404 this binary carries.{}",
6405 nmbrs_workload::suggest::did_you_mean(&nmbrs_workload::suggest::suggest_workloads(
6406 name
6407 ),)
6408 )),
6409 }
6410}
6411
6412fn resolve_workload_file(name: &str) -> Option<String> {
6413 let p = std::path::Path::new(name);
6414 if p.exists() {
6415 return Some(name.to_string());
6416 }
6417
6418 // Already has yaml extension — no further search
6419 if name.ends_with(".yaml") || name.ends_with(".yml") {
6420 // Try under workloads/
6421 let under = format!("workloads/{name}");
6422 if std::path::Path::new(&under).exists() {
6423 return Some(under);
6424 }
6425 return None;
6426 }
6427
6428 // Try adding extensions
6429 for ext in [".yaml", ".yml"] {
6430 let with_ext = format!("{name}{ext}");
6431 if std::path::Path::new(&with_ext).exists() {
6432 return Some(with_ext);
6433 }
6434 }
6435
6436 // Try under workloads/
6437 for ext in ["", ".yaml", ".yml"] {
6438 let under = format!("workloads/{name}{ext}");
6439 if std::path::Path::new(&under).exists() {
6440 return Some(under);
6441 }
6442 }
6443
6444 None
6445}
6446
6447/// Normalize args: detect scenario shorthand where a bare word after
6448/// the workload file becomes `scenario=<name>`.
6449///
6450/// The auto-promotion has to skip the **values** of space-form
6451/// flags (`--session-path X`, `--readout Y`, etc.) — otherwise
6452/// the path or value gets misread as a scenario name and ends up
6453/// as `scenario=<path>`, which downstream code then materialises
6454/// as a literal directory at `<cwd>/scenario=<path>` (the
6455/// orphaned-dir bug we hit earlier). Use the same list
6456/// [`parse_params`] uses so the two surfaces agree on which
6457/// flags consume their next token.
6458pub fn normalize_args(args: &[String]) -> Vec<String> {
6459 // Spec-derived value-taking flags (installed at startup); the
6460 // session-flag fallback applies for library/test drivers.
6461 let value_flags = known_value_flags();
6462
6463 let mut result = Vec::new();
6464 let mut workload_seen = false;
6465 let mut scenario_set = false;
6466 let mut iter = args.iter().peekable();
6467 while let Some(arg) = iter.next() {
6468 // Pass through space-form flag + its value as a unit.
6469 // Equals-form (`--session-path=X`) is one token and
6470 // skips this branch.
6471 if value_flags.iter().any(|f| *f == arg) {
6472 result.push(arg.clone());
6473 if let Some(next) = iter.next() {
6474 result.push(next.clone());
6475 }
6476 continue;
6477 }
6478 if !workload_seen
6479 && (arg.ends_with(".yaml") || arg.ends_with(".yml") || arg.contains("workload="))
6480 {
6481 workload_seen = true;
6482 result.push(arg.clone());
6483 } else if workload_seen && !scenario_set && !arg.contains('=') && !arg.starts_with('-') {
6484 result.push(format!("scenario={arg}"));
6485 scenario_set = true;
6486 } else {
6487 result.push(arg.clone());
6488 }
6489 }
6490 result
6491}
6492
6493/// Bare flags accepted by the runner — these don't follow the
6494/// `key=value` shape but are otherwise recognized. Centralized
6495/// here so [`parse_params`] doesn't reject them and any consumer
6496/// can re-check the raw `args` for them.
6497const RECOGNIZED_BARE_FLAGS: &[&str] = &[
6498 "--strict", // SRD-15 strict-mode toggle.
6499 "--resume-latest", // SRD-44: resume from logs/latest.
6500 "--force-retry-failed", // SRD-44: prepend retry,warn to errors.
6501 "--refine", // SRD-77: enable refine-mode skip-plan loading.
6502];
6503
6504/// Strip a single layer of matching outer quotes (single or
6505/// double) from a string slice. Idempotent for un-quoted input.
6506///
6507/// Per SRD 71 §"CLI parsing — quote elision": this lets wrapper
6508/// scripts forwarding `"$@"`, or `key="value"` constructions
6509/// that double-passed through a shell, parse the same as their
6510/// bare equivalents. Backtick and other quote-like characters
6511/// are deliberately not handled — they carry shell-evaluation
6512/// semantics that don't survive into our argv.
6513pub(crate) fn elide_outer_quotes(s: &str) -> &str {
6514 let bytes = s.as_bytes();
6515 if bytes.len() < 2 {
6516 return s;
6517 }
6518 let first = bytes[0];
6519 let last = bytes[bytes.len() - 1];
6520 if (first == b'\'' || first == b'"') && first == last {
6521 &s[1..s.len() - 1]
6522 } else {
6523 s
6524 }
6525}
6526
6527/// Flags consumed by `crate::session::resolve_session_dir` at startup.
6528/// They appear in raw `args` but shouldn't reach the per-key params map.
6529/// Both equals-form (`--session-dir=/path`) and space-form
6530/// (`--session-dir /path`) are recognised; the space-form value is
6531/// silently absorbed. Shared by [`parse_params`] and
6532/// [`detect_conflicting_duplicate_params`] so they treat these args
6533/// identically.
6534const SESSION_DIR_FLAGS: &[&str] = &[
6535 // Umbrella flag (kv-list).
6536 "--session",
6537 // Per-key long-form flags.
6538 "--session-name",
6539 "--session-path",
6540 "--session-reuse",
6541 "--session-keep",
6542 "--session-shelflife",
6543 // SRD-63 §8: `--readout=<body>` overrides the workload's
6544 // `on_update` binding for the run. Resolved by
6545 // `crate::session::resolve_flag` at runner-init; consumed here so
6546 // the value doesn't bleed into the workload params map.
6547 "--readout",
6548];
6549
6550/// Reject a `key=value` run param supplied more than once with
6551/// *conflicting* values. These params collapse into a map (last value
6552/// wins — see [`parse_params`]), which silently discards an earlier
6553/// value: e.g. `scenario=reset scenario=idx_sweep` drops `reset` and
6554/// runs `idx_sweep`. A repeat with an IDENTICAL value is harmless and
6555/// allowed (re-passing the same value shouldn't break a script); a
6556/// conflicting repeat is an ambiguous instruction, so it's rejected and
6557/// surfaced rather than silently last-wins ("Never Ignore Silently").
6558///
6559/// Mirrors `parse_params`'s arg walk: session-dir flags (own resolver)
6560/// and dotted phase-scoped overrides (`<phase>.<param>=`, a separate
6561/// namespace) are skipped, and the same quote elision is applied so
6562/// `scenario=x` and `scenario='x'` compare equal.
6563pub fn detect_conflicting_duplicate_params(args: &[String]) -> Result<(), String> {
6564 let mut seen: HashMap<String, String> = HashMap::new();
6565 let mut iter = args.iter().peekable();
6566 while let Some(arg) = iter.next() {
6567 // Session-dir flags: consumed by the startup hook, absorb the
6568 // space-form value so it isn't mistaken for a param.
6569 if known_value_flags()
6570 .iter()
6571 .any(|p| arg == p || arg.starts_with(&format!("{p}=")))
6572 {
6573 if !arg.contains('=') {
6574 let _consumed = iter.next();
6575 }
6576 continue;
6577 }
6578 let unquoted = elide_outer_quotes(arg.as_str());
6579 let stripped = unquoted.trim_start_matches('-');
6580 let Some(eq_pos) = stripped.find('=') else {
6581 continue;
6582 };
6583 let key = stripped[..eq_pos].to_string();
6584 // Dotted (non-path) keys are SRD-71 phase-scoped overrides — a
6585 // separate namespace — skipped here as in `parse_params`.
6586 if key.contains('.') && !key.contains('/') && !key.contains('\\') {
6587 continue;
6588 }
6589 let value = elide_outer_quotes(&stripped[eq_pos + 1..]).to_string();
6590 match seen.get(&key) {
6591 Some(prev) if *prev != value => {
6592 return Err(format!(
6593 "parameter '{key}' specified more than once with conflicting \
6594 values ('{prev}' and '{value}') — pass it exactly once"
6595 ));
6596 }
6597 Some(_) => {} // identical repeat — harmless, allow.
6598 None => {
6599 seen.insert(key, value);
6600 }
6601 }
6602 }
6603 Ok(())
6604}
6605
6606/// Parse `key=value` pairs from command line args.
6607///
6608/// Quote handling (SRD 71): if the whole arg or just the value
6609/// portion is wrapped in matching `'…'` / `"…"` quotes, those
6610/// quotes are stripped. So `cursor=0..53%`, `cursor='0..53%'`,
6611/// `cursor="0..53%"`, `'cursor=0..53%'`, and `"cursor=0..53%"`
6612/// all parse to the same `(name="cursor", value="0..53%")`
6613/// pair. The first `=` still splits name from value, so values
6614/// containing `=` retain everything after the first split.
6615pub fn parse_params(args: &[String]) -> HashMap<String, String> {
6616 let mut params = HashMap::new();
6617 let mut iter = args.iter().peekable();
6618 while let Some(arg) = iter.next() {
6619 // Session-dir flags (consumed by the startup hook,
6620 // not stored in params).
6621 if known_value_flags()
6622 .iter()
6623 .any(|p| arg == p || arg.starts_with(&format!("{p}=")))
6624 {
6625 if !arg.contains('=') {
6626 let _consumed = iter.next();
6627 }
6628 continue;
6629 }
6630
6631 // Quote elision (SRD 71): strip matching outer quotes
6632 // from the whole arg first — handles `'key=value'` and
6633 // `"key=value"` — then again from the value portion
6634 // after the `=` split — handles `key='value'` and
6635 // `key="value"`.
6636 let unquoted = elide_outer_quotes(arg.as_str());
6637 // Strip leading dashes: --dryrun=phase,wiring → dryrun=phase,wiring
6638 let stripped = unquoted.trim_start_matches('-');
6639 if let Some(eq_pos) = stripped.find('=') {
6640 let key = stripped[..eq_pos].to_string();
6641 // Dotted keys (without path separators) are SRD-71
6642 // phase-scoped overrides (`<phase-pattern>.<param>=`),
6643 // parsed by `crate::phase_params::parse_overrides` —
6644 // not workload params.
6645 if key.contains('.') && !key.contains('/') && !key.contains('\\') {
6646 continue;
6647 }
6648 let value = elide_outer_quotes(&stripped[eq_pos + 1..]).to_string();
6649 params.insert(key, value);
6650 } else if arg.ends_with(".yaml") || arg.ends_with(".yml") {
6651 // Workload file path — handled elsewhere
6652 } else if is_recognized_bare_flag(arg.as_str()) || arg.starts_with("--polydat-lib=") {
6653 // Bare runner flag — consumed elsewhere via `args`
6654 // scan (e.g. `--strict`, `--polydat-lib=path`).
6655 } else {
6656 crate::diag!(
6657 crate::observer::LogLevel::Error,
6658 "error: unrecognized argument '{arg}'. Expected key=value format."
6659 );
6660 std::process::exit(1);
6661 }
6662 }
6663 params
6664}
6665
6666/// Overlay CLI `key=value` params onto a base set, CLI winning on
6667/// conflict. The single precedence rule for the whole run — applied to
6668/// the session-tier effective params ([`effective_params`]) and to the
6669/// execution's workload params identically, so "CLI overrides the
6670/// workload" means the same thing everywhere.
6671fn overlay_cli_params(
6672 mut base: HashMap<String, String>,
6673 cli: &HashMap<String, String>,
6674) -> HashMap<String, String> {
6675 for (k, v) in cli {
6676 // Coerce the CLI value to the type already inferred for this key
6677 // (the declared default, resolved by `parse_workload`), so a
6678 // suffixed override like `max_size=10m` re-applies as a number
6679 // rather than overwriting the coerced value with raw text. Keys
6680 // with no declared default (ad-hoc CLI params) pass through.
6681 let coerced = match base.get(k) {
6682 Some(existing) => nmbrs_workload::magnitude::coerce_param_override(existing, v),
6683 None => v.clone(),
6684 };
6685 base.insert(k.clone(), coerced);
6686 }
6687 base
6688}
6689
6690/// The run's **effective parameters**: the workload's declared top-level
6691/// `params:` (extends-merged) as the base, with CLI `key=value` args
6692/// overlaid on top (CLI wins). This is the single consolidated param set —
6693/// the same one whether a setting is declared in the workload or passed on
6694/// the command line — used for session-tier services (metrics cadence,
6695/// push reporters, per-instance metrics) and console-ownership detection
6696/// alike. The per-execution path reaches the same result by overlaying CLI
6697/// onto the fully-parsed `workload.params` (see `run_execution`).
6698///
6699/// The workload reference is taken from `workload=` or a bare `.yaml`/`.yml`
6700/// positional; an unresolvable/absent workload contributes no base params
6701/// (the real load error, if any, surfaces later in the execution).
6702pub fn effective_params(args: &[String]) -> HashMap<String, String> {
6703 let cli = parse_params(args);
6704 let workload_ref = cli.get("workload").cloned().or_else(|| {
6705 args.iter()
6706 .find(|a| (a.ends_with(".yaml") || a.ends_with(".yml")) && !a.contains('='))
6707 .cloned()
6708 });
6709 let base = workload_ref
6710 .as_deref()
6711 .and_then(nmbrs_workload::verify::declared_params)
6712 .unwrap_or_default();
6713 overlay_cli_params(base, &cli)
6714}
6715
6716/// Param keys that configure the **session-tier** services — one value per
6717/// session, shared by every execution under it. Executions that declare
6718/// different values for any of these cannot share a session; a multi-
6719/// execution harness must group by them and set up one session per group
6720/// (see [`session_param_signature`]). `metrics_cadence` is the load-bearing
6721/// case: it fixes the cadence the optimizer settle detector samples, so
6722/// workloads wanting a sub-second cadence must run in their own session.
6723pub const SESSION_PARAMS: &[&str] = &["metrics_cadence"];
6724
6725/// The session-grouping signature for a workload reference: its declared
6726/// values (following `extends:`) for the [`SESSION_PARAMS`], sorted.
6727/// Workloads with equal signatures can share one session; differing ones
6728/// must not. Empty signature = "the default session is fine".
6729pub fn session_param_signature(reference: &str) -> Vec<(String, String)> {
6730 let declared = nmbrs_workload::verify::declared_params(reference).unwrap_or_default();
6731 let mut sig: Vec<(String, String)> = SESSION_PARAMS
6732 .iter()
6733 .filter_map(|k| declared.get(*k).map(|v| ((*k).to_string(), v.clone())))
6734 .collect();
6735 sig.sort();
6736 sig
6737}
6738
6739/// Resolve the metrics base interval + cadence ladder from the run's
6740/// effective params. A `metrics_cadence` param (workload or CLI, e.g.
6741/// `100ms` / `200ms`) sets the FINEST cadence — the pulse the SRD-86
6742/// optimizer settle detector samples — and the scheduler base interval, so
6743/// a windowed objective settles in a fraction of the default 1 s-cadence
6744/// wall-clock. A standard coarse ladder (1s/10s/30s/1m/5m) is layered above
6745/// it (keeping a 1 s rung bounds the fan-in from a sub-second floor). Absent
6746/// the param, this is the unchanged default: a 1 s base with the observer's
6747/// declared cadences (or [`Cadences::defaults`]).
6748fn resolve_cadence_config(
6749 params: &HashMap<String, String>,
6750 observer: &Arc<dyn crate::observer::RunObserver>,
6751) -> Result<(std::time::Duration, nmbrs_metrics::cadence::Cadences), String> {
6752 use std::time::Duration;
6753 let Some(raw) = params.get("metrics_cadence") else {
6754 let cadences = observer
6755 .cadences()
6756 .unwrap_or_else(nmbrs_metrics::cadence::Cadences::defaults);
6757 return Ok((Duration::from_secs(1), cadences));
6758 };
6759 let floor = nmbrs_metrics::cadence::parse_duration(raw).map_err(|_| {
6760 format!("metrics_cadence: invalid duration `{raw}` (use e.g. `100ms`, `200ms`, `1s`)")
6761 })?;
6762 if floor.is_zero() {
6763 return Err("metrics_cadence: must be greater than zero".to_string());
6764 }
6765 let mut layers = vec![floor];
6766 for secs in [1u64, 10, 30, 60, 300] {
6767 let d = Duration::from_secs(secs);
6768 if d > floor {
6769 layers.push(d);
6770 }
6771 }
6772 let cadences = nmbrs_metrics::cadence::Cadences::new(&layers)
6773 .map_err(|e| format!("metrics_cadence `{raw}`: cannot build a cadence ladder: {e:?}"))?;
6774 Ok((floor, cadences))
6775}
6776
6777/// Collect every occurrence of a repeatable flag (e.g.
6778/// `--trace=<spec>` or `trace=<spec>`) from a raw arg list.
6779/// Returns the values in order of appearance — `parse_params`
6780/// collapses repeats into a HashMap, so this is the escape
6781/// hatch for repeatable args.
6782///
6783/// Accepts both `--name=value` and `name=value` shapes for
6784/// symmetry with the rest of nmbrs's arg surface.
6785pub fn collect_repeated_flag(args: &[String], name: &str) -> Vec<String> {
6786 let mut out = Vec::new();
6787 let mut iter = args.iter().peekable();
6788 let long_eq = format!("--{name}=");
6789 let bare_eq = format!("{name}=");
6790 while let Some(arg) = iter.next() {
6791 let unquoted = elide_outer_quotes(arg.as_str());
6792 if let Some(v) = unquoted.strip_prefix(&long_eq) {
6793 out.push(elide_outer_quotes(v).to_string());
6794 } else if let Some(v) = unquoted.strip_prefix(&bare_eq) {
6795 out.push(elide_outer_quotes(v).to_string());
6796 } else if unquoted == format!("--{name}")
6797 && let Some(v) = iter.next()
6798 {
6799 out.push(elide_outer_quotes(v.as_str()).to_string());
6800 }
6801 }
6802 out
6803}
6804
6805/// Parse a cycle count that may have suffixes: K, M, B.
6806pub fn parse_count(s: &str) -> Option<u64> {
6807 let s = s.trim().to_uppercase();
6808 if let Some(n) = s.strip_suffix('K') {
6809 n.trim().parse::<u64>().ok().map(|v| v * 1_000)
6810 } else if let Some(n) = s.strip_suffix('M') {
6811 n.trim().parse::<u64>().ok().map(|v| v * 1_000_000)
6812 } else if let Some(n) = s.strip_suffix('B') {
6813 n.trim().parse::<u64>().ok().map(|v| v * 1_000_000_000)
6814 } else {
6815 s.parse().ok()
6816 }
6817}
6818
6819/// Find the closest match using Levenshtein distance.
6820fn closest_match<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
6821 let mut best: Option<(&str, usize)> = None;
6822 for &candidate in candidates {
6823 let d = levenshtein(input, candidate);
6824 if best.is_none() || d < best.unwrap().1 {
6825 best = Some((candidate, d));
6826 }
6827 }
6828 best.filter(|(_, d)| *d <= (input.len() / 2).max(2))
6829 .map(|(s, _)| s)
6830}
6831
6832fn levenshtein(a: &str, b: &str) -> usize {
6833 let a: Vec<char> = a.chars().collect();
6834 let b: Vec<char> = b.chars().collect();
6835 let (m, n) = (a.len(), b.len());
6836 let mut prev = (0..=n).collect::<Vec<_>>();
6837 let mut curr = vec![0; n + 1];
6838 for i in 1..=m {
6839 curr[0] = i;
6840 for j in 1..=n {
6841 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
6842 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
6843 }
6844 std::mem::swap(&mut prev, &mut curr);
6845 }
6846 prev[n]
6847}
6848
6849// =========================================================================
6850// Polydat Scope Composition (sysref 16)
6851// =========================================================================
6852
6853// `ManifestEntry` and `extract_manifest` now live in
6854// `polydat::kernel`. Re-exported here so existing
6855// `crate::runner::extract_manifest` / `ManifestEntry` callers
6856// keep working — pure compatibility shim.
6857pub use polydat::kernel::{ManifestEntry, extract_manifest};
6858
6859#[cfg(test)]
6860mod tests {
6861 use super::*;
6862
6863 // ── parse_params quote elision (SRD 71) ──────────────────
6864
6865 fn pp(args: &[&str]) -> HashMap<String, String> {
6866 parse_params(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
6867 }
6868
6869 #[test]
6870 fn parse_params_bare_unchanged() {
6871 let m = pp(&["cursor=0..53%"]);
6872 assert_eq!(m.get("cursor").map(String::as_str), Some("0..53%"));
6873 }
6874
6875 #[test]
6876 fn parse_params_value_single_quoted_stripped() {
6877 let m = pp(&["cursor='0..53%'"]);
6878 assert_eq!(m.get("cursor").map(String::as_str), Some("0..53%"));
6879 }
6880
6881 #[test]
6882 fn parse_params_value_double_quoted_stripped() {
6883 let m = pp(&["cursor=\"0..53%\""]);
6884 assert_eq!(m.get("cursor").map(String::as_str), Some("0..53%"));
6885 }
6886
6887 #[test]
6888 fn parse_params_whole_arg_single_quoted_stripped() {
6889 let m = pp(&["'cursor=0..53%'"]);
6890 assert_eq!(m.get("cursor").map(String::as_str), Some("0..53%"));
6891 }
6892
6893 #[test]
6894 fn parse_params_whole_arg_double_quoted_stripped() {
6895 let m = pp(&["\"cursor=0..53%\""]);
6896 assert_eq!(m.get("cursor").map(String::as_str), Some("0..53%"));
6897 }
6898
6899 #[test]
6900 fn parse_params_bracket_value_with_quotes() {
6901 let m = pp(&["cursor='[0..53%)'"]);
6902 assert_eq!(m.get("cursor").map(String::as_str), Some("[0..53%)"));
6903 }
6904
6905 #[test]
6906 fn parse_params_mismatched_quotes_not_stripped() {
6907 let m = pp(&["cursor='0..53%\""]);
6908 // First char `'`, last char `"` — no matching pair.
6909 assert_eq!(m.get("cursor").map(String::as_str), Some("'0..53%\""));
6910 }
6911
6912 fn dup(args: &[&str]) -> Result<(), String> {
6913 let owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
6914 detect_conflicting_duplicate_params(&owned)
6915 }
6916
6917 #[test]
6918 fn duplicate_conflicting_scenario_is_rejected() {
6919 let err = dup(&["workload=x.yaml", "scenario=reset", "scenario=idx_sweep"]).unwrap_err();
6920 assert!(
6921 err.contains("scenario") && err.contains("reset") && err.contains("idx_sweep"),
6922 "expected a conflicting-duplicate error naming both values, got: {err}"
6923 );
6924 }
6925
6926 #[test]
6927 fn duplicate_identical_value_is_allowed() {
6928 // Re-passing the same value is harmless.
6929 assert!(dup(&["scenario=idx_sweep", "scenario=idx_sweep"]).is_ok());
6930 }
6931
6932 #[test]
6933 fn distinct_params_are_allowed() {
6934 assert!(dup(&["workload=x.yaml", "scenario=reset", "cycles=10", "host=h"]).is_ok());
6935 }
6936
6937 #[test]
6938 fn conflicting_duplicate_any_param_is_rejected() {
6939 // The guard is general — not just `scenario=`.
6940 assert!(dup(&["cycles=10", "cycles=20"]).is_err());
6941 }
6942
6943 #[test]
6944 fn duplicate_check_elides_quotes_before_comparing() {
6945 // `scenario=reset` and `scenario='reset'` are the SAME value.
6946 assert!(dup(&["scenario=reset", "scenario='reset'"]).is_ok());
6947 // …but genuinely different quoted values still conflict.
6948 assert!(dup(&["scenario='reset'", "scenario=\"idx_sweep\""]).is_err());
6949 }
6950
6951 #[test]
6952 fn duplicate_check_skips_session_flags_and_dotted_overrides() {
6953 // Session-dir flags are consumed by their own resolver; dotted
6954 // keys are phase-scoped overrides — neither participates here.
6955 assert!(dup(&["--session-path", "/a", "--session-path", "/b"]).is_ok());
6956 assert!(dup(&["phase1.cycles=10", "phase2.cycles=20"]).is_ok());
6957 }
6958
6959 #[test]
6960 fn parse_params_equals_in_value_preserved() {
6961 // `key='a=b'` → name=`key`, value=`a=b`.
6962 let m = pp(&["key='a=b'"]);
6963 assert_eq!(m.get("key").map(String::as_str), Some("a=b"));
6964 }
6965
6966 #[test]
6967 fn parse_params_multiple_params_independent() {
6968 let m = pp(&["dataset=example", "cursor='0..1%'", "concurrency=\"100\""]);
6969 assert_eq!(m.get("dataset").map(String::as_str), Some("example"));
6970 assert_eq!(m.get("cursor").map(String::as_str), Some("0..1%"));
6971 assert_eq!(m.get("concurrency").map(String::as_str), Some("100"));
6972 }
6973
6974 // ── Polydat binding-LHS-name scanner ──────────────────────────
6975
6976 fn scan_to_set(src: &str) -> std::collections::HashSet<String> {
6977 let mut out = std::collections::HashSet::new();
6978 scan_polydat_binding_lhs(&mut out, src);
6979 out
6980 }
6981
6982 // ── scan_polydat_braced_refs: invalid `{...}` outside strings ─
6983
6984 #[test]
6985 fn polydat_brace_guard_allows_valid_if_block() {
6986 // Block-form conditional selection uses braces legitimately. The guard must
6987 // not flag it, because the source parses.
6988 let src = "extern segments: u64 = 0\nmean := if segments > 0 { 100 } else { 0 }\n";
6989 let parses = polydat::dsl::lexer::lex(src)
6990 .ok()
6991 .and_then(|t| polydat::dsl::parser::parse(t).ok())
6992 .is_some();
6993 assert!(parses, "if-block source must parse: {src}");
6994 }
6995
6996 #[test]
6997 fn polydat_brace_guard_still_catches_stray_placeholder() {
6998 // A YAML placeholder in expression position does NOT parse, so the guard
6999 // still fires and still names the placeholder.
7000 let src = "const passes := multiples_at_least({min_query_cycles}, base)\n";
7001 let parses = polydat::dsl::lexer::lex(src)
7002 .ok()
7003 .and_then(|t| polydat::dsl::parser::parse(t).ok())
7004 .is_some();
7005 assert!(!parses, "stray placeholder must fail to parse");
7006 let refs = scan_polydat_braced_refs(src);
7007 assert!(refs.iter().any(|r| r == "min_query_cycles"), "got {refs:?}");
7008 }
7009
7010 #[test]
7011 fn scan_polydat_braced_refs_flags_expression_position_braces() {
7012 // The user's case: `{name}` outside any string literal,
7013 // sitting where Polydat expects an expression. Always invalid.
7014 let refs = scan_polydat_braced_refs(
7015 "const passes := multiples_at_least({min_query_cycles}, base)\n",
7016 );
7017 assert_eq!(refs, vec!["min_query_cycles".to_string()]);
7018 }
7019
7020 #[test]
7021 fn scan_polydat_braced_refs_ignores_braces_inside_double_quotes() {
7022 // Inside `"…"` braces are valid — either workload-param
7023 // interp (if the runtime expanded the string earlier) or
7024 // Polydat string interpolation (parser turns it into printf).
7025 // Either way, scan_polydat_braced_refs must NOT flag them.
7026 let refs = scan_polydat_braced_refs(
7027 "const prebuffered := dataset_prebuffer(\"{dataset}:{profile}\")\n",
7028 );
7029 assert!(
7030 refs.is_empty(),
7031 "must not flag `{{dataset}}` / `{{profile}}` inside string \
7032 literal — string interpolation handles them, got {refs:?}"
7033 );
7034 }
7035
7036 #[test]
7037 fn scan_polydat_braced_refs_ignores_braces_inside_single_quotes() {
7038 let refs = scan_polydat_braced_refs("tag := assert_eq(actual, '{expected}')\n");
7039 assert!(
7040 refs.is_empty(),
7041 "single-quoted strings get the same treatment: {refs:?}"
7042 );
7043 }
7044
7045 #[test]
7046 fn scan_polydat_braced_refs_handles_escaped_quotes_in_strings() {
7047 // `"foo \"with brace {x}\" bar"` — the inner `{x}` is
7048 // inside a string the whole way through; backslash-escape
7049 // must not be treated as the end of the string.
7050 let refs = scan_polydat_braced_refs("x := concat(\"prefix \\\"{embedded}\\\" suffix\")\n");
7051 assert!(refs.is_empty(), "escaped quotes inside strings: {refs:?}");
7052 }
7053
7054 #[test]
7055 fn scan_polydat_braced_refs_ignores_comments() {
7056 let refs = scan_polydat_braced_refs(
7057 "# this is a comment with {fake} placeholder\n\
7058 const real := 1\n",
7059 );
7060 assert!(
7061 refs.is_empty(),
7062 "`{{fake}}` inside a comment must not be flagged: {refs:?}"
7063 );
7064 }
7065
7066 #[test]
7067 fn scan_polydat_braced_refs_catches_multiple_invalid_braces() {
7068 let refs = scan_polydat_braced_refs(
7069 "a := foo({x}, {y})\n\
7070 b := bar({z})\n",
7071 );
7072 // Order is source order; uniqueness isn't enforced here
7073 // (the validator caller dedups before reporting).
7074 assert_eq!(
7075 refs,
7076 vec!["x".to_string(), "y".to_string(), "z".to_string(),]
7077 );
7078 }
7079
7080 #[test]
7081 fn scan_polydat_braced_refs_handles_mixed_string_and_expression_braces() {
7082 // `{inside}` is in a string (OK); `{outside}` is in
7083 // expression position (flagged).
7084 let refs = scan_polydat_braced_refs("x := concat(\"foo {inside}\", {outside})\n");
7085 assert_eq!(refs, vec!["outside".to_string()]);
7086 }
7087
7088 // ── Polydat binding-LHS-name scanner ──────────────────────────
7089
7090 #[test]
7091 fn scan_polydat_binding_lhs_handles_typed_cell_form() {
7092 // `shared name: type := default` — the type annotation sits
7093 // between the name and the assignment; the scanner must still
7094 // collect the name (undeclared-placeholder false-positive fix).
7095 let mut out = std::collections::HashSet::new();
7096 scan_polydat_binding_lhs(
7097 &mut out,
7098 "shared sstables: u64 := 0\nshared measured: f64 := 1.0\nplain := 2\n",
7099 );
7100 assert!(out.contains("sstables"), "{out:?}");
7101 assert!(out.contains("measured"), "{out:?}");
7102 assert!(out.contains("plain"), "{out:?}");
7103 }
7104
7105 #[test]
7106 fn scan_polydat_binding_lhs_handles_tuple_destructure() {
7107 // Multi-output stdlib calls bind multiple names via
7108 // tuple destructure: `(a, b, c) := func(...)`. The
7109 // scanner must register every name on the LHS so the
7110 // placeholder validator doesn't false-flag downstream
7111 // `{a}` / `{b}` references.
7112 let names = scan_to_set("(y, mo, d, h, mi, s, ms) := date_components(0)\n");
7113 for expected in ["y", "mo", "d", "h", "mi", "s", "ms"] {
7114 assert!(
7115 names.contains(expected),
7116 "tuple-LHS scanner missed `{expected}` — got {names:?}"
7117 );
7118 }
7119 }
7120
7121 #[test]
7122 fn scan_polydat_binding_lhs_finds_all_recognised_shapes() {
7123 // Validates the wire-name scanner picks up every shape:
7124 // init / cursor / shared / final modifier-prefixed
7125 // bindings, plus bare `NAME := …` assignments.
7126 let names = scan_to_set(
7127 "const prebuffered := dataset_prebuffer(\"foo\")\n\
7128 cursor q = range(0, 100)\n\
7129 query_vector := query_vector_at(prebuffered, q)\n\
7130 shared query_passes := set_or_get(query_passes, 7)\n\
7131 const tag := \"label_00\"\n",
7132 );
7133 for expected in ["prebuffered", "q", "query_vector", "query_passes", "tag"] {
7134 assert!(
7135 names.contains(expected),
7136 "scanner missed `{expected}` — got {names:?}"
7137 );
7138 }
7139 }
7140
7141 #[test]
7142 fn scan_polydat_binding_lhs_skips_comments_and_blank_lines() {
7143 let names = scan_to_set(
7144 "# comment\n\
7145 \n\
7146 const real_binding := 1\n\
7147 # another comment\n",
7148 );
7149 assert_eq!(names.len(), 1);
7150 assert!(names.contains("real_binding"));
7151 }
7152
7153 #[test]
7154 fn scan_polydat_binding_lhs_ignores_non_binding_lines() {
7155 // Expression-call statements and continuation lines must
7156 // not introduce phantom wires.
7157 let names = scan_to_set(
7158 "foo(1, 2)\n\
7159 bar.baz\n\
7160 const real := 1\n",
7161 );
7162 assert_eq!(names.len(), 1);
7163 assert!(names.contains("real"));
7164 }
7165
7166 #[test]
7167 fn scan_polydat_binding_lhs_picks_up_input_decl_bare() {
7168 let names = scan_to_set("input cycle: u64\nx := hash(cycle)\n");
7169 assert!(names.contains("cycle"));
7170 assert!(names.contains("x"));
7171 }
7172
7173 #[test]
7174 fn scan_polydat_binding_lhs_picks_up_input_decl_untyped() {
7175 let names = scan_to_set("input cycle\n");
7176 assert!(names.contains("cycle"));
7177 }
7178
7179 #[test]
7180 fn scan_polydat_binding_lhs_picks_up_input_decl_tuple() {
7181 let names = scan_to_set("input (cycle: u64, q: f64)\n");
7182 assert!(names.contains("cycle"));
7183 assert!(names.contains("q"));
7184 }
7185
7186 #[test]
7187 fn scan_polydat_binding_lhs_picks_up_extern_decl() {
7188 // `extern name: type [= default]` declares a wire just like
7189 // `input` — a same-op capture target referenced via `{name}`
7190 // must not trip the undeclared-placeholder guard.
7191 let names = scan_to_set(
7192 "extern active_compactions: u64 = 0\n\
7193 extern completion_ratio: f64 = 0.0\n\
7194 extern (a: u64, b: f64)\n",
7195 );
7196 assert!(names.contains("active_compactions"));
7197 assert!(names.contains("completion_ratio"));
7198 assert!(names.contains("a"));
7199 assert!(names.contains("b"));
7200 }
7201
7202 #[test]
7203 fn parse_dryrun_controls_sets_list_flag() {
7204 let cfg = DiagnosticConfig::parse("controls");
7205 assert!(cfg.list_controls);
7206 // Implies phase depth so the runner exits before any
7207 // cycle-time work.
7208 assert_eq!(cfg.depth, ExecDepth::Phase);
7209 }
7210
7211 #[test]
7212 fn parse_dryrun_controls_combines_with_other_flags() {
7213 let cfg = DiagnosticConfig::parse("controls,labels");
7214 assert!(cfg.list_controls);
7215 assert!(cfg.show_labels);
7216 }
7217
7218 #[test]
7219 fn parse_dryrun_unknown_flag_does_not_set_controls() {
7220 let cfg = DiagnosticConfig::parse("phase,bogus");
7221 assert!(!cfg.list_controls);
7222 }
7223
7224 // ── SRD-13d Phase 7 — `dryrun=op` ──
7225
7226 #[test]
7227 fn parse_dryrun_op_sets_op_depth() {
7228 let cfg = DiagnosticConfig::parse("op");
7229 assert_eq!(cfg.depth, ExecDepth::Op);
7230 }
7231
7232 #[test]
7233 fn parse_dryrun_phase_still_sets_phase_depth() {
7234 let cfg = DiagnosticConfig::parse("phase");
7235 assert_eq!(cfg.depth, ExecDepth::Phase);
7236 }
7237
7238 #[test]
7239 fn parse_dryrun_cycle_still_sets_cycle_depth() {
7240 let cfg = DiagnosticConfig::parse("cycle");
7241 assert_eq!(cfg.depth, ExecDepth::Cycle);
7242 }
7243
7244 #[test]
7245 fn parse_dryrun_op_combines_with_wiring_flag() {
7246 let cfg = DiagnosticConfig::parse("op,wiring");
7247 assert_eq!(cfg.depth, ExecDepth::Op);
7248 assert!(cfg.show_wiring);
7249 }
7250
7251 #[test]
7252 fn parse_dryrun_wiring_alone_bumps_depth_to_op() {
7253 // `wiring` needs depth >= Op for kernels to exist; a bare
7254 // `dryrun=wiring` must auto-bump so it produces output
7255 // instead of silently doing nothing at depth=Phase.
7256 let cfg = DiagnosticConfig::parse("wiring");
7257 assert_eq!(cfg.depth, ExecDepth::Op);
7258 assert!(cfg.show_wiring);
7259 }
7260
7261 #[test]
7262 fn parse_dryrun_wiring_does_not_override_explicit_depth() {
7263 // Explicit phase depth wins; `wiring` is then a no-op
7264 // (no kernels to render at phase depth) — the user gets
7265 // what they asked for rather than a silent bump.
7266 let cfg = DiagnosticConfig::parse("phase,wiring");
7267 assert_eq!(cfg.depth, ExecDepth::Phase);
7268 assert!(cfg.show_wiring);
7269 }
7270
7271 #[test]
7272 fn exec_depth_ordering_matches_srd_13d() {
7273 // `Phase` is the shallowest stop, `Full` is the deepest;
7274 // `Op` sits between `Phase` and `Cycle`. Depth-gating
7275 // sites read this ordering as `< Cycle` ⇒ "skip cycles".
7276 assert!(ExecDepth::Phase < ExecDepth::Op);
7277 assert!(ExecDepth::Op < ExecDepth::Cycle);
7278 assert!(ExecDepth::Cycle < ExecDepth::Full);
7279 // The transitive should hold (it would be a derive
7280 // bug if it didn't, but assert it for documentation).
7281 assert!(ExecDepth::Phase < ExecDepth::Cycle);
7282 assert!(ExecDepth::Op < ExecDepth::Full);
7283 }
7284
7285 #[test]
7286 fn exec_depth_phase_and_op_short_circuit_before_cycles() {
7287 // The executor's per-phase early-exit fires when
7288 // `depth < Cycle`. Both Phase and Op satisfy that;
7289 // Cycle and Full do not.
7290 assert!(ExecDepth::Phase < ExecDepth::Cycle);
7291 assert!(ExecDepth::Op < ExecDepth::Cycle);
7292 assert!((ExecDepth::Cycle >= ExecDepth::Cycle));
7293 assert!((ExecDepth::Full >= ExecDepth::Cycle));
7294 }
7295
7296 #[test]
7297 fn render_scope_elision_summary_shows_materialised_and_elides_to() {
7298 use nmbrs_workload::model::{BindingsDef, ScenarioNode, WorkloadPhase};
7299 use std::collections::HashMap;
7300
7301 let phase = WorkloadPhase {
7302 key_metrics: Vec::new(),
7303 dimensions: Default::default(),
7304 cycles: None,
7305 concurrency: None,
7306 rate: None,
7307 daemon: false,
7308 adapter: None,
7309 errors: None,
7310 tries: None,
7311 tries_backoff: None,
7312 interval: None,
7313 repeat: None,
7314 error_rate_max: None,
7315 timeout: None,
7316 stop_when: Vec::new(),
7317 throttle: None,
7318 continue_if: None,
7319 tags: None,
7320 ops: vec![],
7321 for_each: None,
7322 loop_scope: None,
7323 iter_scope: None,
7324 checkpoint: None,
7325 status_metrics: vec![],
7326 metrics: Default::default(),
7327 bindings: BindingsDef::default(),
7328 poll: None,
7329 optimize: None,
7330 };
7331 let mut phases = HashMap::new();
7332 phases.insert("predict".to_string(), phase);
7333 let mut tree = crate::scope_tree::ScopeTree::build(
7334 "default",
7335 &[ScenarioNode::Phase("predict".into())],
7336 );
7337 // Conservative classifier: empty workload + empty
7338 // phase ⇒ scenario and phase elide into root.
7339 let inputs = crate::scope_elision::ClassifyInputs {
7340 bindings: &BindingsDef::default(),
7341 params: &HashMap::new(),
7342 phases: &phases,
7343 };
7344 crate::scope_elision::classify_and_mark(&mut tree, &inputs);
7345
7346 let mut buf: Vec<u8> = Vec::new();
7347 render_scope_elision_summary(&tree, &mut buf).unwrap();
7348 let s = String::from_utf8(buf).unwrap();
7349
7350 assert!(s.contains("scope elision summary"), "missing header: {s}");
7351 // Workload root materialises always (SRD-13d §5.1).
7352 assert!(
7353 s.contains("workload") && s.contains("materialised=true"),
7354 "expected materialised=true line for workload root: {s}"
7355 );
7356 // Scenario + phase elide into the workload root.
7357 assert!(
7358 s.contains("elides-to=workload"),
7359 "expected elides-to=workload for empty phase: {s}"
7360 );
7361 assert!(
7362 s.contains("workload.scenario.default"),
7363 "expected scenario logical name: {s}"
7364 );
7365 assert!(
7366 s.contains("workload.scenario.default.phase.predict"),
7367 "expected phase logical name: {s}"
7368 );
7369 }
7370
7371 #[test]
7372 fn render_controls_tree_empty_session_writes_placeholder() {
7373 let root = nmbrs_metrics::component::Component::root(
7374 nmbrs_metrics::labels::Labels::of("session", "t"),
7375 std::collections::HashMap::new(),
7376 );
7377 let mut buf: Vec<u8> = Vec::new();
7378 render_controls_tree(&root, &mut buf).unwrap();
7379 let s = String::from_utf8(buf).unwrap();
7380 assert!(s.contains("no controls declared"), "got: {s}");
7381 }
7382
7383 #[test]
7384 fn render_controls_tree_lists_session_root_controls() {
7385 let root = nmbrs_metrics::component::Component::root(
7386 nmbrs_metrics::labels::Labels::of("session", "t"),
7387 std::collections::HashMap::new(),
7388 );
7389 root.read().unwrap().controls().declare(
7390 nmbrs_metrics::controls::ControlBuilder::new("log_level", 1u32)
7391 .reify_as_gauge(|v| Some(*v as f64))
7392 .branch_scope(nmbrs_metrics::controls::BranchScope::Subtree)
7393 .from_f64(|v| Ok(v as u32))
7394 .final_at_scope("session_root")
7395 .build(),
7396 );
7397
7398 let mut buf: Vec<u8> = Vec::new();
7399 render_controls_tree(&root, &mut buf).unwrap();
7400 let s = String::from_utf8(buf).unwrap();
7401 assert!(s.contains("log_level"), "missing name: {s}");
7402 assert!(s.contains("scope=subtree"), "missing scope: {s}");
7403 assert!(
7404 s.contains("final@session_root"),
7405 "missing final marker: {s}"
7406 );
7407 assert!(s.contains("f64-writable"), "missing write surface: {s}");
7408 }
7409
7410 // ── Regression: --session-path value not auto-promoted to scenario= ──
7411 //
7412 // Bug shape (caught by user during Phase C live exercise):
7413 // `nmbrs run wl.yaml cycles=2 --session-path X` was rewritten to
7414 // `nmbrs run wl.yaml cycles=2 --session-path scenario=X` because
7415 // `normalize_args` walked tokens flat and saw `X` as a bare
7416 // post-workload positional. Symptom: a literal directory at
7417 // `<cwd>/scenario=X` was created. The fix peeks for value-taking
7418 // flags so the value passes through unchanged.
7419
7420 fn s(v: &[&str]) -> Vec<String> {
7421 v.iter().map(|x| x.to_string()).collect()
7422 }
7423
7424 #[test]
7425 fn normalize_args_session_path_space_form_value_passes_through() {
7426 let out = normalize_args(&s(&[
7427 "wl.yaml",
7428 "cycles=2",
7429 "--session-path",
7430 "target/test-tmp/foo/session",
7431 ]));
7432 // The path arg must NOT be turned into `scenario=...`.
7433 assert!(
7434 !out.iter().any(|a| a.starts_with("scenario=")),
7435 "scenario= auto-promotion fired on a flag value: {out:?}"
7436 );
7437 assert_eq!(
7438 out,
7439 s(&[
7440 "wl.yaml",
7441 "cycles=2",
7442 "--session-path",
7443 "target/test-tmp/foo/session",
7444 ])
7445 );
7446 }
7447
7448 #[test]
7449 fn normalize_args_session_path_equals_form_unchanged() {
7450 let out = normalize_args(&s(&[
7451 "wl.yaml",
7452 "--session-path=target/test-tmp/foo/session",
7453 ]));
7454 assert_eq!(
7455 out,
7456 s(&["wl.yaml", "--session-path=target/test-tmp/foo/session",])
7457 );
7458 }
7459
7460 #[test]
7461 fn normalize_args_real_scenario_positional_still_promotes() {
7462 // The original feature: bare-word scenario shorthand.
7463 // Must keep working when no value-flag interferes.
7464 let out = normalize_args(&s(&["wl.yaml", "myscenario", "cycles=2"]));
7465 assert_eq!(out, s(&["wl.yaml", "scenario=myscenario", "cycles=2",]));
7466 }
7467
7468 #[test]
7469 fn normalize_args_scenario_after_session_path_still_promotes() {
7470 // After a value-flag pair, the next free positional is
7471 // still eligible for scenario= promotion. This confirms
7472 // the bookkeeping survives the look-ahead.
7473 let out = normalize_args(&s(&["wl.yaml", "--session-path", "/tmp/x", "myscenario"]));
7474 assert_eq!(
7475 out,
7476 s(&["wl.yaml", "--session-path", "/tmp/x", "scenario=myscenario",])
7477 );
7478 }
7479
7480 #[test]
7481 fn normalize_args_readout_value_passes_through() {
7482 let out = normalize_args(&s(&["wl.yaml", "--readout", "throughput ok_pct"]));
7483 assert!(
7484 !out.iter().any(|a| a.starts_with("scenario=")),
7485 "readout body misread as scenario: {out:?}"
7486 );
7487 }
7488
7489 /// `format_for_combinations` lays out vars and specs in
7490 /// column-aligned pairs. Each column is padded to the
7491 /// widest of its (var, spec) so corresponding entries
7492 /// stack vertically. The `color = false` argument forces
7493 /// the no-ANSI branch so the assertion can pattern-match
7494 /// the raw text — no dependency on the process's ambient
7495 /// TTY / `NO_COLOR` state (which `observer::use_color()`
7496 /// caches process-wide on first call and can't be undone
7497 /// per-test).
7498 #[test]
7499 fn format_for_combinations_aligns_columns() {
7500 let pairs = vec![
7501 ("sm".to_string(), "{sm_values}".to_string()),
7502 ("mnc".to_string(), "{mnc_values}".to_string()),
7503 (
7504 "alf_label".to_string(),
7505 "concat({alf_label_values})".to_string(),
7506 ),
7507 ];
7508 let out = format_for_combinations(&pairs, "", false);
7509 let lines: Vec<&str> = out.split('\n').collect();
7510 assert_eq!(lines.len(), 2, "MUST produce exactly 2 lines: {out:?}");
7511 assert!(
7512 lines[0].starts_with("for ["),
7513 "first line MUST start with `for [`: {:?}",
7514 lines[0]
7515 );
7516 assert!(
7517 lines[1].starts_with(" in ["),
7518 "second line MUST start with ` in [`: {:?}",
7519 lines[1]
7520 );
7521 // Bracket columns align: the `[` after `for` and the
7522 // `[` after `in ` should be at the same column index.
7523 let l0_bracket = lines[0].find('[').unwrap();
7524 let l1_bracket = lines[1].find('[').unwrap();
7525 assert_eq!(
7526 l0_bracket, l1_bracket,
7527 "`[` brackets MUST align: line0={l0_bracket}, line1={l1_bracket}"
7528 );
7529 // Column alignment: the comma after `sm,` on line 0
7530 // sits at the same column as the comma after the
7531 // `{sm_values},` on line 1 — except padded so that
7532 // `mnc` on line 0 starts at the same column as
7533 // `{mnc_values}` on line 1.
7534 let mnc_pos = lines[0].find("mnc").unwrap();
7535 let mnc_values_pos = lines[1].find("{mnc_values}").unwrap();
7536 assert_eq!(
7537 mnc_pos, mnc_values_pos,
7538 "column 2 MUST align: `mnc`@{mnc_pos} vs `{{mnc_values}}`@{mnc_values_pos}\n{out}"
7539 );
7540 let alf_pos = lines[0].find("alf_label").unwrap();
7541 let alf_concat_pos = lines[1].find("concat(").unwrap();
7542 assert_eq!(
7543 alf_pos, alf_concat_pos,
7544 "column 3 MUST align: `alf_label`@{alf_pos} vs `concat(...)`@{alf_concat_pos}\n{out}"
7545 );
7546 // Closing brackets present on both lines.
7547 assert!(lines[0].ends_with(']'));
7548 assert!(lines[1].ends_with(']'));
7549 }
7550}