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 // Strip workload-level adapter/driver from op params
2856 // (adapter is resolved per-phase/per-op, not from workload params)
2857 for op in &mut all_ops_for_compile {
2858 op.params.remove("adapter");
2859 op.params.remove("driver");
2860 }
2861 for ops in phase_raw_ops.values_mut() {
2862 for op in ops.iter_mut() {
2863 op.params.remove("adapter");
2864 op.params.remove("driver");
2865 }
2866 }
2867
2868 let builder = Arc::new(OpBuilder::new(kernel));
2869
2870 // Unification — the scenario-tree executor is the sole
2871 // execution path. `Workload::synthesize_default_phase` (called
2872 // at load time) guarantees `phases` is non-empty for any
2873 // workload that has work to do; the empty-ops error fired
2874 // earlier in this fn covers the "literally nothing to run"
2875 // case. The legacy single-activity branch is gone.
2876 {
2877 // --- Phased execution ---
2878 let scenario_name = params
2879 .get("scenario")
2880 .map(|s| s.as_str())
2881 .unwrap_or("default");
2882 let scenario_nodes = resolve_scenario(&scenarios, &phase_order, scenario_name)?;
2883
2884 // Build the canonical scope tree (SRD 18b §"Canonical
2885 // traversal"). Mirrors the scenario tree 1:1 with parent
2886 // pointers, depth, and pragma slots. Today consumed by
2887 // observer pre-mapping and diagnostic display; future
2888 // steps drive execution from this tree directly.
2889 let scope_tree = {
2890 let mut t = crate::scope_tree::ScopeTree::build(scenario_name, &scenario_nodes);
2891 // Populate phase-leaf pragmas from each phase's GK
2892 // source and chain each scope's `PragmaSet` to its
2893 // parent's. SRD 18b §"Pragma chain along the scope
2894 // tree"; SRD 15 §"Pragma Scope".
2895 t.populate_pragmas(&phases);
2896 // Validate iter-var name uniqueness against workload
2897 // params and enclosing iter vars. Aliasing creates an
2898 // unambiguous spec-evaluation case the runtime can't
2899 // disambiguate; reject up-front.
2900 let wp_names: std::collections::HashSet<String> =
2901 workload_params.keys().cloned().collect();
2902 t.validate_iter_var_uniqueness(&wp_names)?;
2903 // SRD-83 follow-up — load-time authoring lints: every
2904 // `errors:` router spec must parse (bad verbs fail the
2905 // load, not the first runtime error), a router without a
2906 // catch-all rule warns, and `metric()` families in
2907 // stop/gate predicates are checked against the instrument
2908 // namespace (an unregistered family reads 0.0 silently).
2909 for w in crate::workload_lint::lint_workload(
2910 &workload.stop_when,
2911 phases.iter().map(|(k, v)| (k.as_str(), v)),
2912 )? {
2913 crate::diag!(crate::observer::LogLevel::Warn, "{w}");
2914 }
2915 // SRD-13d Phase 6 — extend the scope tree with
2916 // op-template children of every Phase node so the
2917 // op tier is visible to the elision classifier
2918 // and downstream diagnostics.
2919 t.extend_with_op_templates(&phases);
2920 // SRD-13d Phase 3 — workload-init scope-elision
2921 // pre-walk. Reads `HasGkMatter` on each AST node
2922 // and marks the corresponding scope-tree node
2923 // `materialised` (own kernel) or elided (binds
2924 // through parent). Conservative predicate today
2925 // (Definitions ⇒ materialise without hash-subset
2926 // refinement); Phase 6 tightens it.
2927 //
2928 // Scoped fields rather than `&workload` because
2929 // `workload.ops` was moved earlier in this fn —
2930 // the classifier reads only bindings + params +
2931 // phases anyway.
2932 let classify_inputs = crate::scope_elision::ClassifyInputs {
2933 bindings: &workload.bindings,
2934 params: &workload.params,
2935 phases: &phases,
2936 };
2937 crate::scope_elision::classify_and_mark(&mut t, &classify_inputs);
2938 std::sync::Arc::new(t)
2939 };
2940
2941 // dryrun=kernels: register the ride-along visitor BEFORE
2942 // the install loop so every install_kernel call (root
2943 // workload kernel + per-scope kernels in DFS pre-order)
2944 // streams its polydat source to stdout. Cleared after
2945 // the install loop runs (see the matching short-circuit
2946 // a few hundred lines below).
2947 if params.get("dryrun").map(|s| s.as_str()) == Some("kernels") {
2948 print_kernel_dump_header();
2949 crate::scope_tree::set_kernel_install_visitor(Some(Box::new(|node, _idx, kernel| {
2950 print_kernel_for_scope(node, kernel);
2951 })));
2952 }
2953
2954 // Install the workload-params module on the session node
2955 // (the workload root's parent) so identity chains
2956 // (`ScopeTree::ancestor_kernels` → the SRD-44/SRD-77
2957 // provenance hashes) include the module whose const
2958 // slots carry every param VALUE. The workload kernel is
2959 // built as a subscope of this module; installing it here
2960 // records that same relationship on the tree. Nearest-
2961 // ancestor kernel lookups are unaffected — the workload
2962 // root always has its own kernel, so walks stop there.
2963 scope_tree.install_kernel(scope_tree.root, std::sync::Arc::new(params_kernel));
2964
2965 // Install the canonical workload kernel (SRD 18b §"Iter
2966 // vars as scope outputs"). After this, intermediate
2967 // scopes (for_each, for_combinations, …) install their
2968 // own kernels in DFS pre-order below — each one's
2969 // synthesis reads its parent's manifest via the standard
2970 // Polydat API on the parent's installed kernel.
2971 scope_tree.install_kernel(scope_tree.workload_root_idx(), workload_canonical_kernel);
2972
2973 // M3.2: install per-scope kernels for for_each /
2974 // for_combinations nodes. Each kernel re-exports its
2975 // iteration variables and any referenced inherited
2976 // values as outputs (`const x := x` passthrough), so
2977 // children's standard `materialize_wiring_from_outer(parent)`
2978 // chains inheritance through arbitrary nesting depth
2979 // — no caller-side scope-tree walking for name
2980 // resolution at runtime.
2981 let workload_dir_owned: Option<std::path::PathBuf> = workload_dir.map(|p| p.to_path_buf());
2982 // M3.4b: scope kinds get categorized for synthesis.
2983 // For-comprehensions (ForEach, ForCombinations,
2984 // ForEachUnion) carry tuple iteration vars; do-loops
2985 // (DoWhile, DoUntil) carry an optional counter +
2986 // condition expression. Both produce installed kernels
2987 // that the unified dispatch_comprehension reads from.
2988 // reason: function-local, short-lived spec list built once per
2989 // install pass; the `OpTemplate`/`ParsedOp` variant dominates the
2990 // size, but boxing it would ripple `Box::new` + deref across every
2991 // construction and destructuring match arm below for no real gain
2992 // on a vector that is consumed immediately.
2993 #[allow(clippy::large_enum_variant)]
2994 enum InstallSpec {
2995 ForComprehension {
2996 idx: crate::scope_tree::ScopeNodeIdx,
2997 iter_vars: Vec<String>,
2998 spec_exprs: Vec<String>,
2999 /// SRD-13f Push E: phase-level `bindings:` folded
3000 /// into the for_each scope kernel when the phase
3001 /// declares both `for_each:` AND `bindings:`. The
3002 /// single install at the phase node materializes
3003 /// one kernel carrying both the iter-var
3004 /// declarations AND the phase-level bindings.
3005 /// Empty for pure-comprehension scope nodes
3006 /// (scenario-level `for_each:`) and for phase
3007 /// nodes without own bindings.
3008 phase_bindings: nmbrs_workload::model::BindingsDef,
3009 },
3010 DoLoop {
3011 idx: crate::scope_tree::ScopeNodeIdx,
3012 counter: Option<String>,
3013 condition: String,
3014 },
3015 /// SRD-13d Phase 9 — install a kernel for an
3016 /// op-template scope that classified as
3017 /// `materialised`. Flattened op-templates
3018 /// (`materialised == false`) get no install spec;
3019 /// their dispensers reach the parent kernel via
3020 /// `nearest_materialised`.
3021 OpTemplate {
3022 idx: crate::scope_tree::ScopeNodeIdx,
3023 op: nmbrs_workload::model::ParsedOp,
3024 },
3025 /// SRD-13d Phase 9 — install a phase-scope kernel
3026 /// for a phase that declares its own `bindings:`
3027 /// block (and no `for_each:` — that case is owned
3028 /// by the for_each install spec at the same node).
3029 /// Phases without bindings AND without for_each
3030 /// emit no install spec; the closure-lifetime
3031 /// kernel reference is the parent's by walker
3032 /// fall-through.
3033 /// Phase-scope kernel install for phases declaring
3034 /// their own `bindings:` block (and no `for_each:` —
3035 /// that case is owned by the for_each install spec
3036 /// at the same node). Also covers scenario-tree
3037 /// `bindings:` nodes (and the `set:` sugar that
3038 /// lowers to them) — the synthesizer is identical
3039 /// for both: parent-cascaded externs + the body's
3040 /// authored matter.
3041 Bindings {
3042 idx: crate::scope_tree::ScopeNodeIdx,
3043 bindings: nmbrs_workload::model::BindingsDef,
3044 },
3045 }
3046 let install_specs: Vec<InstallSpec> = scope_tree
3047 .iter_dfs()
3048 .filter_map(|(idx, node)| match &node.kind {
3049 crate::scope_tree::ScopeKind::Comprehension { comprehension } => {
3050 // Representative iter_vars + spec_exprs for
3051 // synthesis: dedup'd by var name. Walks the
3052 // algebra AST directly via coordinate_specs
3053 // — same dedup semantics the legacy
3054 // scalar_bindings/Union-flatten path
3055 // produced.
3056 let pairs = comprehension.coordinate_specs();
3057 let vars: Vec<String> = pairs.iter().map(|(v, _)| v.clone()).collect();
3058 let specs: Vec<String> = pairs.iter().map(|(_, e)| e.clone()).collect();
3059 Some(InstallSpec::ForComprehension {
3060 idx,
3061 iter_vars: vars,
3062 spec_exprs: specs,
3063 // Scope-tree Comprehension nodes (scenario-
3064 // level `for_each:`) carry no phase-level
3065 // bindings; the wrapping phase has its own
3066 // scope-tree node and its own install spec
3067 // (PhaseBindings or another ForComprehension).
3068 phase_bindings: nmbrs_workload::model::BindingsDef::default(),
3069 })
3070 }
3071 crate::scope_tree::ScopeKind::DoWhile { condition, counter } => {
3072 Some(InstallSpec::DoLoop {
3073 idx,
3074 counter: counter.clone(),
3075 condition: condition.clone(),
3076 })
3077 }
3078 crate::scope_tree::ScopeKind::DoUntil { condition, counter } => {
3079 Some(InstallSpec::DoLoop {
3080 idx,
3081 counter: counter.clone(),
3082 condition: condition.clone(),
3083 })
3084 }
3085 crate::scope_tree::ScopeKind::Phase { name } => {
3086 // Phase-scope kernel installation, matter-gated
3087 // per SRD-13d / SRD-67. Three cases:
3088 //
3089 // 1. Phase declares `for_each:` — treat as
3090 // a single-clause tuple comprehension.
3091 // The for_each scope owns the phase
3092 // node's kernel; phase `bindings:` (if
3093 // also present) need to fold into that
3094 // scope's matter (deferred — the legacy
3095 // parser-merge path keeps the bindings
3096 // reachable via op-bindings until the
3097 // for_each-with-bindings synthesizer
3098 // lands).
3099 //
3100 // 2. Phase declares only `bindings:` — own
3101 // subscope from those bindings layered
3102 // over the parent kernel.
3103 //
3104 // 3. Neither — no install spec; phase
3105 // scope's closure inherits the parent's
3106 // kernel reference via the walker's
3107 // fall-through (the matter-gated
3108 // pass-through).
3109 let phase = phases.get(name.as_str())?;
3110 if let Some(spec) = phase.for_each.as_ref() {
3111 if !phase.metrics.is_empty() {
3112 // Phase-level `metrics:` + phase-level
3113 // `for_each:` isn't supported in the
3114 // initial ship: the for_each scope
3115 // synthesiser doesn't yet thread the
3116 // metric-binding augmentation through, and
3117 // the per-iteration completion-pull
3118 // semantics want their own design pass.
3119 // Reject loudly rather than silently
3120 // dropping the metrics.
3121 crate::diag!(
3122 crate::observer::LogLevel::Error,
3123 "phase '{name}': phase-level `metrics:` + phase-level \
3124 `for_each:` is not supported yet. Move the for_each to \
3125 scenario-tree level (so each iteration is its own phase \
3126 activation, each with its own metrics), or drop one. \
3127 Phase will be skipped.",
3128 );
3129 return None;
3130 }
3131 if phase.poll.is_some() {
3132 // SRD-75: phase-poll + phase-level
3133 // for_each isn't supported in the
3134 // initial ship. The for_each scope
3135 // synthesizer doesn't yet thread the
3136 // poll augmentation through, and the
3137 // combination's semantics
3138 // (iterate-and-synchronize-each-cell?
3139 // iterate-while-synchronizing?) wants
3140 // its own design pass. Reject loudly.
3141 crate::diag!(
3142 crate::observer::LogLevel::Error,
3143 "phase '{name}': `poll:` + phase-level `for_each:` is not \
3144 supported in the initial ship of SRD-75. Move the for_each \
3145 to scenario-tree level (so each iter is its own phase \
3146 activation), or drop one of the two. Phase will be skipped.",
3147 );
3148 return None;
3149 }
3150 // Delegate the for_each grammar to polydat (the
3151 // single owner): `parse_inline` handles single- AND
3152 // multi-clause, and `coordinate_specs()` yields the
3153 // per-clause (var, spec) pairs — identical to the
3154 // scenario-level path above. A single-clause spec
3155 // yields exactly one pair, preserving prior behaviour.
3156 let comp = match polydat::iteration::comprehension::spec::parse_inline(spec)
3157 {
3158 Ok(c) => c,
3159 Err(e) => {
3160 crate::diag!(
3161 crate::observer::LogLevel::Error,
3162 "phase '{name}' for_each '{spec}': {e}"
3163 );
3164 return None;
3165 }
3166 };
3167 let pairs = comp.coordinate_specs();
3168 let iter_vars: Vec<String> = pairs.iter().map(|(v, _)| v.clone()).collect();
3169 let spec_exprs: Vec<String> =
3170 pairs.iter().map(|(_, e)| e.clone()).collect();
3171 // SRD-13f Push E: phases declaring both `for_each:`
3172 // and `bindings:` fold the bindings into the for_each
3173 // scope kernel (one kernel, one install). Pure-for_each
3174 // phases pass an empty `BindingsDef` (no-op).
3175 //
3176 // Route through the SAME phase-scope synthesis the
3177 // non-for_each branch uses, so phase-level `metrics:` /
3178 // `poll:` and an inline `optimize.objective` (SRD-86)
3179 // are folded into the for_each kernel too — not just the
3180 // author's raw `bindings:`. The synthesizer returns the
3181 // raw bindings unchanged when there is nothing to add,
3182 // so plain for_each phases are unaffected.
3183 let phase_bindings =
3184 match crate::scope::synthesize_phase_scope_bindings(phase) {
3185 Ok(b) => b,
3186 Err(e) => {
3187 crate::diag!(
3188 crate::observer::LogLevel::Error,
3189 "phase '{name}': phase-scope synthesis: {e}"
3190 );
3191 return None;
3192 }
3193 };
3194 Some(InstallSpec::ForComprehension {
3195 idx,
3196 iter_vars,
3197 spec_exprs,
3198 phase_bindings,
3199 })
3200 } else {
3201 // SRD-75: when the phase declares `poll:`,
3202 // synthesise capture-as-shared-cell
3203 // declarations + the `__poll_until`
3204 // predicate binding into the phase scope's
3205 // bindings, even when the phase has no
3206 // author-declared `bindings:` of its own.
3207 // The synthesised bindings flow through the
3208 // same `build_phase_scope_kernel` path as
3209 // any other phase-level bindings; phase-
3210 // poll has no synthesizer-specific code
3211 // path.
3212 let synth = match crate::scope::synthesize_phase_scope_bindings(phase) {
3213 Ok(b) => b,
3214 Err(e) => {
3215 crate::diag!(
3216 crate::observer::LogLevel::Error,
3217 "phase '{name}': SRD-75 phase-poll synthesis: {e}",
3218 );
3219 return None;
3220 }
3221 };
3222 if !synth.is_empty() {
3223 Some(InstallSpec::Bindings {
3224 idx,
3225 bindings: synth,
3226 })
3227 } else {
3228 None
3229 }
3230 }
3231 }
3232 crate::scope_tree::ScopeKind::OpTemplate { name } => {
3233 // SRD-13d Phase 9: install a per-op kernel
3234 // ONLY for materialised op-templates. The
3235 // scope-elision pre-walk already set the
3236 // mark; we just gate on it here.
3237 if node.materialised != Some(true) {
3238 return None;
3239 }
3240 // Find the ParsedOp by walking up to the
3241 // OWNING phase first, then resolving by name
3242 // within that phase. Two phases can both
3243 // declare an op named e.g. `select_ann` with
3244 // very different bodies; a flat
3245 // `phases.values().flat_map(|p| p.ops.iter())
3246 // .find(...)` would pick whichever phase the
3247 // HashMap iterator yielded first, silently
3248 // compiling pvs_query's body into ann_query's
3249 // op-template kernel (and vice versa).
3250 let owning_phase: Option<&str> = {
3251 let mut cursor = scope_tree.nodes[idx].parent;
3252 let mut found: Option<&str> = None;
3253 while let Some(p) = cursor {
3254 if let crate::scope_tree::ScopeKind::Phase { name: pname } =
3255 &scope_tree.nodes[p].kind
3256 {
3257 found = Some(pname.as_str());
3258 break;
3259 }
3260 cursor = scope_tree.nodes[p].parent;
3261 }
3262 found
3263 };
3264 owning_phase
3265 .and_then(|pname| phases.get(pname))
3266 .and_then(|phase| phase.ops.iter().find(|op| op.name == *name))
3267 .cloned()
3268 .map(|op| InstallSpec::OpTemplate { idx, op })
3269 }
3270 crate::scope_tree::ScopeKind::Bindings { source } => {
3271 // Scenario-tree `bindings:` block (also the
3272 // canonical lowered form of `set:` sugar)
3273 // installs through the same synthesizer
3274 // phases use for their own `bindings:`. The
3275 // body source compiles into a scope kernel
3276 // that publishes its `final`/`init`/cycle
3277 // bindings as outputs; descendants read
3278 // those through the canonical scope chain
3279 // (no HashMap merges, no side-channel
3280 // resolvers). Shadowing of upstream names is
3281 // enforced by the local-final transit-
3282 // suppression rule in
3283 // `materialize_wiring_from_outer` — uniform
3284 // with every other scope.
3285 Some(InstallSpec::Bindings {
3286 idx,
3287 bindings: nmbrs_workload::model::BindingsDef::PolydatSource(source.clone()),
3288 })
3289 }
3290 _ => None,
3291 })
3292 .collect();
3293
3294 for install_spec in install_specs {
3295 let idx = match &install_spec {
3296 InstallSpec::ForComprehension { idx, .. } => *idx,
3297 InstallSpec::DoLoop { idx, .. } => *idx,
3298 InstallSpec::OpTemplate { idx, .. } => *idx,
3299 InstallSpec::Bindings { idx, .. } => *idx,
3300 };
3301 // Nearest installed ancestor — skips Scenario /
3302 // IncludedScenario nodes that don't install kernels
3303 // (those are pass-through structural).
3304 let parent_kernel = {
3305 let mut cursor = scope_tree.nodes[idx].parent;
3306 let mut found: Option<std::sync::Arc<crate::scope_kernel::ScopeKernel>> = None;
3307 while let Some(p) = cursor {
3308 if let Some(k) = scope_tree.nodes[p].cached_kernel.get() {
3309 found = Some(k.clone());
3310 break;
3311 }
3312 cursor = scope_tree.nodes[p].parent;
3313 }
3314 found.expect("workload root always has an installed kernel")
3315 };
3316 let parent_manifest = extract_manifest(parent_kernel.program());
3317 let context = format!("scope idx {idx} ({})", scope_tree.nodes[idx].kind.label(),);
3318
3319 let result = match install_spec {
3320 InstallSpec::ForComprehension {
3321 iter_vars,
3322 spec_exprs,
3323 phase_bindings,
3324 ..
3325 } => {
3326 let bindings: Vec<(String, String)> = iter_vars
3327 .iter()
3328 .cloned()
3329 .zip(spec_exprs.iter().cloned())
3330 .collect();
3331 // SRD-13f Push E: translate phase-level
3332 // `bindings:` into the Polydat source the
3333 // for_each synthesiser folds in. PolydatSource
3334 // form passes verbatim; Map form serialises
3335 // to `name := expr\n` lines.
3336 let phase_bindings_source = match phase_bindings {
3337 nmbrs_workload::model::BindingsDef::PolydatSource(s)
3338 if !s.trim().is_empty() =>
3339 {
3340 Some(s)
3341 }
3342 nmbrs_workload::model::BindingsDef::Map(m) if !m.is_empty() => {
3343 let mut out = String::new();
3344 for (name, expr) in &m {
3345 out.push_str(&format!("{name} := {expr}\n"));
3346 }
3347 Some(out)
3348 }
3349 _ => None,
3350 };
3351 crate::scope_synth::build_for_each_scope_kernel(
3352 &bindings,
3353 &parent_manifest,
3354 &parent_kernel,
3355 &workload_params,
3356 polydat_lib_paths.clone(),
3357 workload_dir_owned.as_deref(),
3358 strict,
3359 &context,
3360 phase_bindings_source.as_deref(),
3361 )
3362 }
3363 InstallSpec::DoLoop {
3364 counter, condition, ..
3365 } => crate::scope::build_do_loop_scope_kernel(
3366 counter.as_deref(),
3367 &condition,
3368 &parent_manifest,
3369 &parent_kernel,
3370 &workload_params,
3371 polydat_lib_paths.clone(),
3372 workload_dir_owned.as_deref(),
3373 strict,
3374 &context,
3375 ),
3376 InstallSpec::OpTemplate { op, .. } => {
3377 // SRD-13d Phase 9 — synthesize the op-
3378 // template kernel layered over the parent.
3379 // Includes op-level bindings + cascaded
3380 // parent externs; materialize_wiring_from_outer chains
3381 // values in at runtime.
3382 // The scope module stays on the node for the fiber
3383 // engine's per-op images (`crate::fiber_engine`).
3384 crate::scope::build_op_template_scope_kernel(
3385 &op,
3386 &parent_manifest,
3387 &parent_kernel,
3388 &workload_params,
3389 polydat_lib_paths.clone(),
3390 workload_dir_owned.as_deref(),
3391 strict,
3392 kernel_opt,
3393 &context,
3394 )
3395 }
3396 InstallSpec::Bindings { bindings, .. } => {
3397 // Single install path for both phase-level
3398 // `bindings:` and scenario-tree-level
3399 // `bindings:` (including the `set:` sugar
3400 // form that lowers to it). The synthesizer
3401 // cascades workload params + parent
3402 // outputs/inputs as externs and appends the
3403 // body verbatim; Polydat handles workload-param
3404 // interpolation and expression evaluation
3405 // at compile time. Lexical shadowing of an
3406 // upstream `final NAME` by the body's own
3407 // `const NAME := …` is enforced by the
3408 // local-final transit-suppression rule in
3409 // `materialize_wiring_from_outer`.
3410 crate::scope::build_phase_scope_kernel(
3411 &bindings,
3412 &parent_manifest,
3413 &parent_kernel,
3414 &workload_params,
3415 polydat_lib_paths.clone(),
3416 workload_dir_owned.as_deref(),
3417 strict,
3418 &context,
3419 )
3420 }
3421 };
3422
3423 match result {
3424 Ok(kernel) => {
3425 let _ = scope_tree.install_kernel(idx, std::sync::Arc::new(kernel));
3426 }
3427 Err(e) => {
3428 // Kernel synthesis failure is a hard error
3429 // regardless of strict mode: a phase whose GK
3430 // source doesn't compile literally cannot run.
3431 // Letting the walk continue past the failure
3432 // only delays the bad news — the phase will
3433 // fail mid-run with a less helpful diagnostic
3434 // (or, worse, run with stale / partial
3435 // kernels installed for sibling scopes). The
3436 // earlier "warn-and-continue" behavior dates
3437 // from before strict mode existed; with the
3438 // strict-mode behavior being the only sane
3439 // default, the non-strict branch was
3440 // effectively a footgun that turned compile
3441 // errors into silent partial runs.
3442 return Err(format!("scope kernel synthesis failed: {e}"));
3443 }
3444 }
3445 }
3446
3447 crate::diag!(
3448 crate::observer::LogLevel::Info,
3449 "scenario '{scenario_name}':\n{}",
3450 format_scenario_tree(&scenario_nodes, &phases)
3451 );
3452
3453 // Observer is passed from the caller (default: StderrObserver).
3454
3455 // ─── Unified walker: structural pre-map pass ─────────────────
3456 //
3457 // Per SRD 18b §"Single Walker Contract", there is ONE walker
3458 // function (`crate::executor::execute_tree`). It runs twice
3459 // here: first at depth=Phase to populate the scene tree (so
3460 // resume_plan / declare_scene_tree_phases / pre_map_pending_uses
3461 // can read the populated tree), then again at the configured
3462 // depth to actually execute. SceneTree::push is idempotent
3463 // by `(parent, kind, name)` so the second pass re-encounters
3464 // every node from the first without duplicating.
3465 //
3466 // The pre-map ExecCtx uses stub post-pre-map fields
3467 // (checkpoint_writer = None, fresh resume_plan, fresh
3468 // resource_pool); they're updated to the real values after
3469 // pre-map produces the tree.
3470 let schedule_spec = std::sync::Arc::new(match params.get("schedule") {
3471 Some(s) => crate::scheduler::ScheduleSpec::parse(s)
3472 .map_err(|e| format!("schedule= param: {e}"))?,
3473 None => crate::scheduler::ScheduleSpec::default_serial(),
3474 });
3475 // `&str` → `&'static str` so the activity config can
3476 // carry the mode label across thread boundaries
3477 // without lifetime gymnastics. Every mode the resolver
3478 // above produces must appear here, otherwise the
3479 // wrapper-install path silently sees `None` and
3480 // DRYRUN never installs.
3481 let dry_run_static: Option<&'static str> = match dry_run {
3482 Some("silent") => Some("silent"),
3483 Some("fields") => Some("fields"),
3484 Some("cycle") => Some("cycle"),
3485 Some("op") => Some("op"),
3486 _ => None,
3487 };
3488
3489 // phases=<pattern> filter (bareword / glob / regex). When
3490 // unset, every phase runs. When set, the planner's
3491 // scenario-tree walker skips phase activations whose name
3492 // doesn't match AND elides scope subtrees with no
3493 // matching descendant.
3494 let phase_filter: Option<Arc<crate::phase_filter::PhasePattern>> = match params
3495 .get("phases")
3496 .map(|s| s.as_str())
3497 .filter(|s| !s.is_empty())
3498 {
3499 None => None,
3500 Some(src) => {
3501 let pat = crate::phase_filter::PhasePattern::parse(src)
3502 .map_err(|e| format!("phases= param: {e}"))?;
3503 crate::diag!(
3504 crate::observer::LogLevel::Info,
3505 "phases=<filter>: pattern '{src}' ({}{})",
3506 if pat.negated() { "negated " } else { "" },
3507 pat.dialect().as_str()
3508 );
3509 Some(Arc::new(pat))
3510 }
3511 };
3512 let resource_pool = Arc::new(crate::resource_pool::ResourcePool::new());
3513 // SRD-104 — point the resource bridge every kernel tree resolves
3514 // through at this session's pool, so kernel nodes can reach a live
3515 // pool-owned resource (e.g. a CQL session handle) by fingerprint via
3516 // their tree's resource scope. The pool stays the definitive owner.
3517 crate::resource_pool::install_accessor(&resource_pool);
3518 let initial_scene_tree_path = vec![crate::checkpoint::PathSegment::Scenario(
3519 scenario_name.to_string(),
3520 )];
3521 // SRD-82 — the session root error policy. Every shell resolves
3522 // its own from this (inherit or derive); equal configs share
3523 // one instance, parsed once per session.
3524 let root_error_policy = crate::error_policy::ErrorPolicy::root(
3525 crate::error_policy::PolicyConfig::new(error_spec.clone(), error_rate_max),
3526 );
3527 // SRD-83 — build the workload execution shell (SRD-82's
3528 // outermost shell). Its stop conditions are the workload's
3529 // `stop_when:` declarations whose `each:` names the workload
3530 // itself (`self`/`workload`), compiled once against the
3531 // workload root's cached kernel — the same native-scope binding
3532 // every other shell uses, never a conjured root. The remaining
3533 // `each: phase` declarations fan out to the per-phase activity
3534 // build (see `executor.rs`); the unfiltered list rides on
3535 // `ExecCtx.workload_stop_when` for that gathering.
3536 //
3537 // The error-rate breach stays a per-phase concern (each phase
3538 // already trips on its own `error_rate_max`), so no default
3539 // error-rate condition is installed at the workload aggregate.
3540 let workload_shell = {
3541 use nmbrs_workload::model::ScopeLevel;
3542 // SRD-82 Part 3/6 — the scenario-graph default `*Failed:stop`:
3543 // any child phase whose outcome is Failed halts the remaining
3544 // walk and records `Interrupted + Failed` (a fault). Expressed
3545 // as the SRD-83 stop condition `children_failed > 0` with a
3546 // `fail` effect, so it rides the same workload-shell mechanism
3547 // as declared conditions and reaches concurrent / cross-subtree
3548 // siblings the local `Err` cascade can't. First in the list →
3549 // a failed child trips it before any declared graceful rule.
3550 let mut conditions: Vec<crate::stop_conditions::StopConditionDecl> =
3551 vec![crate::stop_conditions::StopConditionDecl {
3552 when: "children_failed > 0".to_string(),
3553 effect: crate::phase_outcome::Outcome::failed(),
3554 reason: None,
3555 target: crate::stop_conditions::StopScope::Workload,
3556 // The default stop-on-error drains cooperatively.
3557 cancel_ops: false,
3558 }];
3559 // Declared workload-level conditions (`each ∋ self|workload`).
3560 // A declared trip defaults to a graceful `stop`
3561 // (Interrupted+Succeeded): nothing failed, later phases are
3562 // deliberately skipped.
3563 conditions.extend(
3564 workload
3565 .stop_when
3566 .iter()
3567 .filter(|c| {
3568 c.each
3569 .iter()
3570 .any(|l| matches!(l, ScopeLevel::SelfScope | ScopeLevel::Workload))
3571 })
3572 .map(|c| crate::stop_conditions::StopConditionDecl {
3573 // Same `{param}` interpolation as phase-level stop_when
3574 // (executor build_activity_config_for_phase) so a workload
3575 // breaker threshold can be a modular workload param.
3576 when: expand_workload_params(&c.when, &workload_params),
3577 effect: crate::stop_conditions::StopConditionDecl::effect_from_str(
3578 c.effect.as_deref(),
3579 crate::phase_outcome::Outcome::interrupted(),
3580 ),
3581 reason: None,
3582 // Detected at the workload shell; `at:` (default =
3583 // innermost of `per:`/`each:`, here `workload`) selects
3584 // the action scope.
3585 target: crate::executor::resolve_stop_scope(c.at, &c.each),
3586 // `action: abort` → cancel in-flight ops at the trip site.
3587 cancel_ops: crate::stop_conditions::StopConditionDecl::action_cancels_ops(
3588 c.effect.as_deref(),
3589 ),
3590 }),
3591 );
3592 let set = match scope_tree.nodes[scope_tree.workload_root_idx()]
3593 .cached_kernel
3594 .get()
3595 {
3596 Some(root_kernel) => crate::stop_conditions::StopConditionSet::build_for_phase(
3597 root_kernel,
3598 &conditions,
3599 )
3600 .unwrap_or_else(|e| {
3601 crate::diag!(
3602 crate::observer::LogLevel::Error,
3603 "workload stop-condition compile failed: {e}"
3604 );
3605 crate::stop_conditions::StopConditionSet::empty()
3606 }),
3607 _ => crate::stop_conditions::StopConditionSet::empty(),
3608 };
3609 std::sync::Arc::new(crate::workload_shell::WorkloadShell::new(set))
3610 };
3611
3612 // SRD-71 P3 — phase-scoped CLI parameter overrides
3613 // (`<phase-pattern>.<param>=<value>`). Parsed from the raw
3614 // args (parse_params skips dotted keys), validated against
3615 // the declared phase names so a never-matching pattern is
3616 // a startup error instead of a silent no-op.
3617 let phase_param_overrides =
3618 std::sync::Arc::new(crate::phase_params::parse_overrides(&args)?);
3619 crate::phase_params::validate_against_phases(
3620 &phase_param_overrides,
3621 phases.keys().map(|s| s.as_str()),
3622 )?;
3623
3624 let mut exec_ctx = crate::executor::ExecCtx {
3625 phases: phases.clone(),
3626 optimize_objective: None,
3627 optimize_objective_value: None,
3628 optimize_servo: None,
3629 phase_param_overrides,
3630 workload_shell,
3631 workload_stop_when: workload.stop_when.clone(),
3632 daemon_stop: None,
3633 workload_readouts: workload_readouts.clone(),
3634 cli_readout_override: cli_readout_override.clone(),
3635 workload_params: workload_params.clone(),
3636 wrappers_override: workload_wrappers_override.clone(),
3637 wrap_default_order: cli_wrap_default_order.clone(),
3638 workload_scope: builder.source_kernel().clone(),
3639 polydat_lib_paths: polydat_lib_paths.clone(),
3640 workload_dir: workload_dir.map(|p| p.to_path_buf()),
3641 strict,
3642 driver: driver.clone(),
3643 merged_params: merged_params.clone(),
3644 dry_run: dry_run_static,
3645 phase_filter: phase_filter.clone(),
3646 refine_plan: refine_plan.clone(),
3647 diag: {
3648 let mut d = diag.clone();
3649 d.depth = ExecDepth::Phase;
3650 d
3651 },
3652 // The pre-map pass walks at depth=Phase but is NOT
3653 // execution: the structural-only sentinel that fires
3654 // `set_phase_running` + `_completed` in the walker
3655 // (intended for the dryrun=phase summary) is
3656 // suppressed via this flag so the TUI's scene tree
3657 // doesn't start life with every phase already
3658 // Completed. Flipped back to false at line ~2675
3659 // before the real execution pass.
3660 pre_map_only: true,
3661 seq_type,
3662 concurrency,
3663 rate,
3664 error_spec: error_spec.clone(),
3665 tries,
3666 error_rate_max,
3667 error_policy: root_error_policy,
3668 session_id: session_id.clone(),
3669 exec_id,
3670 workload_name: execution.workload.clone(),
3671 label_stack: Vec::new(),
3672 // SRD-88 §2 — phase/activity components attach under the
3673 // EXECUTION component (which declares `exec_id` +
3674 // `workload`), not the session root. The session root
3675 // is the shared `session=<id>` ancestor above it.
3676 session_component: execution.component.clone(),
3677 cadence_reporter: cadence_reporter.clone(),
3678 stop_handle: stop_handle.clone(),
3679 observer: observer.clone(),
3680 scope_tree: scope_tree.clone(),
3681 schedule_spec: schedule_spec.clone(),
3682 current_parent_kernel: scope_tree.nodes[scope_tree.workload_root_idx()]
3683 .cached_kernel
3684 .get()
3685 .cloned(),
3686 workload_source: workload_file.as_ref().and_then(|path| {
3687 workload_source_text.as_ref().map(|text| {
3688 std::sync::Arc::new(crate::executor::WorkloadSource {
3689 path: path.clone(),
3690 text: text.clone(),
3691 })
3692 })
3693 }),
3694 // Stub post-pre-map fields: replaced after the pre-map
3695 // walk populates the scene tree. Pre-map walks at depth
3696 // Phase, so no run_phase / run_do_loop / checkpoint
3697 // events fire — the stubs are not consulted.
3698 checkpoint_writer: None,
3699 resume_plan: std::sync::Arc::new(crate::checkpoint::ResumePlan::fresh()),
3700 sqlite_reporter: sqlite_reporter.clone(),
3701 resource_pool: resource_pool.clone(),
3702 scene_tree_parent_id: 0,
3703 scene_tree_path: initial_scene_tree_path.clone(),
3704 current_scope_idx: 0,
3705 };
3706
3707 // One Walker: seed the scope cursor at the scenario layer (the single
3708 // child of the workload root) so the top-level scenario nodes resolve
3709 // positionally against its children, not by AST match.
3710 exec_ctx.current_scope_idx = exec_ctx.scope_tree.scenario_root_idx();
3711
3712 // Install empty SceneTree global; the walker populates it.
3713 crate::scene_tree::install_global(crate::scene_tree::SceneTree::new());
3714
3715 // Pre-map structural pass. Errors propagate in strict mode
3716 // (SRD-15 §"Empty Iteration Sources"); otherwise the walker
3717 // logs and continues — downstream code handles the partial
3718 // / empty tree.
3719 let pre_map_result = crate::executor::execute_tree(&mut exec_ctx, &scenario_nodes).await;
3720 let pre_mapped_tree = match pre_map_result {
3721 Ok(()) => {
3722 let tree = crate::scene_tree::current();
3723 if let Some(ref t) = tree {
3724 observer.scenario_pre_mapped(t);
3725 }
3726 tree
3727 }
3728 Err(e) if strict => return Err(e),
3729 Err(e) => {
3730 crate::diag!(
3731 crate::observer::LogLevel::Warn,
3732 "pre-map walker failed (scope hierarchy will be flat in summaries / TUI): {e}"
3733 );
3734 None
3735 }
3736 };
3737
3738 // dryrun=kernels short-circuit: pre-map already ran;
3739 // the install-kernel visitor (registered above before
3740 // execute_tree) streamed each scope's polydat source
3741 // to stdout as the walk encountered it. Print the
3742 // legend + exit cleanly.
3743 if params.get("dryrun").map(|s| s.as_str()) == Some("kernels") {
3744 print_kernel_dump_legend();
3745 crate::scope_tree::set_kernel_install_visitor(None);
3746 return Ok(());
3747 }
3748
3749 // --- Checkpoint writer + resume plan (SRD-44 / SRD-44a) ---
3750 //
3751 // The writer file lives at `<session-dir>/checkpoint.jsonl`
3752 // — an append-only JSONL event log per SRD-44a. Resume from
3753 // an explicit prior session is wired through the
3754 // `--resume <session>` / `--resume-latest` CLI surface
3755 // (see runner CLI parsing); for a fresh session the writer
3756 // starts empty and the plan reruns everything.
3757 // SRD-88 — the writer + resume doc are SESSION-tier (created
3758 // once in `SessionHost::setup`, holding the single resume lock).
3759 // This execution shares them; it derives its own resume plan
3760 // below from `saved_doc` + its pre-map.
3761 let checkpoint_writer = host.checkpoint_writer.clone();
3762 let saved_doc = host.saved_doc.clone();
3763 let invocation = saved_doc.as_ref().map(|d| d.invocation + 1).unwrap_or(1);
3764
3765 // End-of-run notices: drops on success OR error path.
3766 //
3767 // - Resume hint: when checkpoint state shows
3768 // incomplete idempotent phases (SRD-44), advise the
3769 // operator how to resume.
3770 // - Keep-purge forecast: when the next new session
3771 // would auto-purge sessions under the keep cap
3772 // (SRD-45), let the operator know how many and how
3773 // to disable.
3774 let parent_for_keep_check = if let Some(p) = session.output_dir.parent() {
3775 p.to_path_buf()
3776 } else {
3777 crate::session::default_sessions_root()
3778 };
3779 let session_keep = crate::session::resolve_session_dir(&args).session_keep;
3780 struct EndOfRunNoticeGuard {
3781 writer: std::sync::Arc<crate::checkpoint::CheckpointWriter>,
3782 parent: std::path::PathBuf,
3783 keep_cap: usize,
3784 }
3785 impl Drop for EndOfRunNoticeGuard {
3786 fn drop(&mut self) {
3787 if let Some(hint) = self.writer.resume_hint() {
3788 // Through the observer, one line per log call —
3789 // NOT a raw eprintln!: this drop can fire while
3790 // the TUI still holds the terminal in raw mode,
3791 // where a bare `\n` does not return the carriage
3792 // and a stderr write bypasses the render sink —
3793 // each line then starts at the previous line's
3794 // end column (the end-of-session staircase,
3795 // observed 2026-08-05). The log path is owned by
3796 // the surface channel (SRD-87) in both TUI and
3797 // plain modes, so it renders correctly in each.
3798 for line in hint.lines() {
3799 crate::diag!(crate::observer::LogLevel::Info, "{line}");
3800 }
3801 }
3802 let n = crate::session::forecast_keep_purge(&self.parent, self.keep_cap);
3803 if n > 0 {
3804 crate::diag!(
3805 crate::observer::LogLevel::Info,
3806 "the next new nmbrs session will auto-purge {n} prior session \
3807 director{plural} under {} due to --session-keep={cap}. \
3808 To disable: --session-keep=0 (or NMBRS_SESSION_KEEP=0). \
3809 To raise the cap: --session-keep=<bigger>.",
3810 self.parent.display(),
3811 plural = if n == 1 { "y" } else { "ies" },
3812 cap = self.keep_cap,
3813 );
3814 }
3815 }
3816 }
3817 let _eor_notice_guard = EndOfRunNoticeGuard {
3818 writer: checkpoint_writer.clone(),
3819 parent: parent_for_keep_check,
3820 keep_cap: session_keep,
3821 };
3822 let resume_plan =
3823 if let (Some(saved), Some(tree)) = (saved_doc.as_ref(), pre_mapped_tree.as_ref()) {
3824 let candidates =
3825 crate::checkpoint::scene_tree_resume_candidates(tree, &scope_tree, &phases);
3826 std::sync::Arc::new(crate::checkpoint::ResumePlan::from_checkpoint(
3827 saved,
3828 &candidates,
3829 &workload_params,
3830 ))
3831 } else {
3832 std::sync::Arc::new(crate::checkpoint::ResumePlan::fresh())
3833 };
3834
3835 // Declare every pre-mapped phase into the writer so a
3836 // future resume can tell "didn't run yet" from "wasn't
3837 // planned". Idempotent — re-declaring an entry the
3838 // writer already restored from disk is a no-op.
3839 if let Some(tree) = pre_mapped_tree.as_ref() {
3840 crate::checkpoint::declare_scene_tree_phases(&checkpoint_writer, tree, &phases);
3841 }
3842
3843 if resume_plan.is_resume {
3844 let skip = resume_plan.skip_count();
3845 let mismatch = resume_plan.mismatch_count();
3846 let cursor = resume_plan.cursor_resume_count();
3847 crate::diag!(
3848 crate::observer::LogLevel::Info,
3849 "resume: invocation #{invocation} — \
3850 {skip} skip, {mismatch} mismatched, {cursor} cursor-resume"
3851 );
3852 }
3853
3854 // SRD-35 Push D: seed the resource pool's per-key
3855 // `pending_uses` counter before any phase runs. The
3856 // walker is a pure read of the pre-mapped tree +
3857 // session-level params; it doesn't instantiate any
3858 // adapter or open any resource. After this, the pool
3859 // can close `Shared`/`PerScenario` entries the moment
3860 // their last predicted phase detaches, instead of
3861 // holding them until session end.
3862 if let Some(tree) = pre_mapped_tree.as_ref() {
3863 crate::resource_pool::pre_map_pending_uses(
3864 &resource_pool,
3865 tree,
3866 &phases,
3867 &driver,
3868 &merged_params,
3869 )?;
3870 }
3871
3872 // ─── Unified walker: execution pass ──────────────────────────
3873 //
3874 // Update the post-pre-map fields on the same `exec_ctx`
3875 // used for the pre-map pass: real `checkpoint_writer`,
3876 // resolved `resume_plan`, restored execution depth. Per
3877 // SRD 18b §"Single Walker Contract" point 1, this is the
3878 // SAME walker function — `execute_tree` — invoked again at
3879 // the configured depth. SceneTree::push is idempotent so
3880 // every node the pre-map pass pushed is reused.
3881 exec_ctx.checkpoint_writer = Some(checkpoint_writer.clone());
3882 exec_ctx.resume_plan = resume_plan.clone();
3883 exec_ctx.diag = diag.clone();
3884 exec_ctx.scene_tree_parent_id = 0;
3885 exec_ctx.scene_tree_path = initial_scene_tree_path.clone();
3886 // One Walker: seed at the scenario layer (see the pre-map seed above).
3887 exec_ctx.current_scope_idx = exec_ctx.scope_tree.scenario_root_idx();
3888 // Pre-map pass is done — the real execution starts now.
3889 // dryrun=phase still walks at depth=Phase but with this
3890 // flag false, so the sentinel set_phase_completed in the
3891 // walker fires as the dryrun=phase summary needs.
3892 exec_ctx.pre_map_only = false;
3893
3894 let scheduler = crate::scheduler::build(&schedule_spec);
3895 let scheduler_result = scheduler.run(&mut exec_ctx, &scenario_nodes).await;
3896
3897 // SRD-35: drain the resource pool at session end.
3898 // `Shared`/`PerScenario` entries intentionally stay alive
3899 // across phases (the whole reason the pool exists), so
3900 // this is the close trigger that releases their network
3901 // resources. Runs even if the scenario errored out —
3902 // half-open clusters would otherwise leak FDs into the
3903 // next session in TUI / `metrics watch` host processes.
3904 exec_ctx.resource_pool.shutdown().await;
3905 // SRD-93 stage 6 — a ladder-driven interrupt (level 1/2)
3906 // unwinds the walk as an `Err`, which the `?` below would
3907 // propagate PAST the SRD-77 row close-out at the end of this
3908 // function — leaving `disposition` NULL, the marker reserved
3909 // for genuinely unclean exits (force-exit, panic, SIGKILL). A
3910 // cooperative drain IS a clean shutdown: stamp the row with
3911 // the scene tree's computed disposition (interrupted / failed
3912 // / …) before the error propagates. `update_execution_end`
3913 // keys on `ended_at_nanos IS NULL`, so the normal-completion
3914 // stamp below stays a no-op after this one.
3915 if scheduler_result.is_err() {
3916 close_execution_row(&sqlite_reporter, &session_id, exec_id);
3917 }
3918 scheduler_result?;
3919
3920 // Workload-end lifecycle boundary: every phase in the
3921 // scenario has completed. Individual phase paths already
3922 // closed themselves at phase-end, but any workload-level
3923 // ingests (e.g. aggregate metrics the tree code emits at
3924 // scope scope rather than phase scope) still need a flush.
3925 // In phased mode the workload's label set is the session
3926 // root — there's no intermediate `activity=...` label —
3927 // so we close at the session root.
3928 cadence_reporter.close_path(&Labels::of("session", &session.id));
3929
3930 // SRD-13d Phase 7 — `dryrun=dispenser` scope-elision
3931 // summary. Phase walk has just completed; scope tree
3932 // carries final `materialised` / `logical_name` marks
3933 // (set by the workload-load classifier). Dump now so the
3934 // diagnostic surfaces phase-init artifacts (registered
3935 // metrics, adapter map_op calls) in the same run. Used
3936 // to fire for `Op` depth; since the auto-bump now lifts
3937 // `dryrun=op` to `Cycle` (full cycle execution with
3938 // wrapper short-circuit), the scope-elision surface
3939 // moved to `dryrun=dispenser` — the explicit "build
3940 // every dispenser but don't run cycles" mode.
3941 if diag.depth == ExecDepth::Dispenser {
3942 let mut out = std::io::stdout();
3943 if let Err(e) = render_scope_elision_summary(&scope_tree, &mut out) {
3944 crate::diag!(
3945 crate::observer::LogLevel::Warn,
3946 "warning: rendering scope-elision summary: {e}"
3947 );
3948 }
3949 }
3950
3951 // The run reached its end boundary: every fallible step is
3952 // behind us (the epilogue after this block is teardown
3953 // only). Tell the checkpoint writer HERE, as the block's
3954 // last statement — `_eor_notice_guard` drops when this
3955 // block closes, and its resume_hint must read
3956 // declared-but-never-started phases as excluded-by-
3957 // predicate rather than left-behind (SRD-44a; see
3958 // resume_hint). Error `?` returns above skip this, so a
3959 // cut-short run still advises resuming pending phases.
3960 checkpoint_writer.mark_run_reached_end();
3961 }
3962
3963 // Session-end lifecycle boundary. Close the session root path
3964 // for any aggregate windows that were ingested at session level
3965 // (rare today, but the boundary must be explicit — otherwise
3966 // session-level aggregates would only flush during
3967 // `shutdown_flush` at the very end, after all the per-subscriber
3968 // teardown logic had already started).
3969 cadence_reporter.close_path(&Labels::of("session", &session.id));
3970
3971 // SRD-88 — flush THIS execution's windows into the store so the
3972 // summaries below see complete data, without tearing down the
3973 // session-shared cadence reporter.
3974 cadence_reporter
3975 .quiesce(std::time::Duration::from_secs(30))
3976 .await;
3977
3978 // SRD-63 Push 9a: fire `EventType::SessionEnd` once after
3979 // the cadence shutdown but before `run_finished()`.
3980 // Both branches (phased + single-activity) converge
3981 // here, so a single fire covers every run shape.
3982 {
3983 let session_ctx = crate::readout_context::LifecycleContext {
3984 event: crate::lifecycle::EventType::SessionEnd,
3985 subject_name: session.id.clone(),
3986 subject_labels: String::new(),
3987 depth_indent: String::new(),
3988 use_color: crate::observer::use_color(),
3989 stick_reattached: String::new(),
3990 };
3991 crate::readout_context::fire_lifecycle(
3992 crate::lifecycle::EventType::SessionEnd,
3993 &workload_readouts,
3994 None,
3995 &session_ctx,
3996 Some(&sqlite_reporter),
3997 );
3998 }
3999
4000 observer.run_finished();
4001
4002 if dry_run.is_some() {
4003 crate::diag!(crate::observer::LogLevel::Info, "dry-run complete.");
4004 } else {
4005 crate::diag!(crate::observer::LogLevel::Info, "done.");
4006 }
4007
4008 // Build the active set of named summaries.
4009 //
4010 // Precedence:
4011 // - CLI `summary=<spec>` wins outright — produces a single
4012 // ad-hoc summary under the synthetic name `default`,
4013 // overriding any workload-declared map. Matches prior
4014 // CLI behavior.
4015 // - Otherwise the workload's `summary:` map (and the
4016 // `summary.yaml` sidecar fallback already merged into
4017 // `workload_summaries` above) is used as-is.
4018 //
4019 // An empty map means "no summary at end of run" — same as
4020 // the legacy "no `summary:` field" case.
4021 let active_summaries: HashMap<String, nmbrs_workload::model::SummaryConfig> =
4022 if let Some(cli_summary) = merged_params.get("summary") {
4023 let mut m = HashMap::new();
4024 m.insert(
4025 "default".into(),
4026 nmbrs_workload::model::SummaryConfig::parse(cli_summary),
4027 );
4028 m
4029 } else {
4030 workload_summaries.clone()
4031 };
4032
4033 // SRD-46 output routing for the in-run summary, by item name.
4034 //
4035 // A CLI `summary=<spec>` is an explicit operator request —
4036 // they typed it, so it still echoes to the terminal. A
4037 // workload-declared table goes where its `to:` says, and one
4038 // that declared nothing goes to the session directory ONLY.
4039 // stdout is never implied for automatic rendering: a run's
4040 // stdout is its op output, and a summary carries wall-clock
4041 // values that would make that output differ run to run.
4042 let summary_destinations: HashMap<String, Vec<nmbrs_workload::report::Destination>> = {
4043 use nmbrs_workload::report::Destination as D;
4044 if merged_params.contains_key("summary") {
4045 let mut m = HashMap::new();
4046 m.insert("default".to_string(), vec![D::SessionDir, D::Stdout]);
4047 m
4048 } else {
4049 workload_report
4050 .items()
4051 .filter(|i| matches!(i.kind, nmbrs_workload::report::Kind::Table))
4052 .map(|i| {
4053 (
4054 i.name.clone(),
4055 i.style
4056 .destinations
4057 .clone()
4058 .unwrap_or_else(|| vec![D::SessionDir]),
4059 )
4060 })
4061 .collect()
4062 }
4063 };
4064
4065 // SRD-46 Details auto-injection: persist run-wide context
4066 // (end time, phase + scenario counts, adapter, …) into
4067 // session_metadata regardless of whether the workload
4068 // declared a `report:` block. Post-run hooks read this to
4069 // build the auto-injected Details section that lands at
4070 // the top of every output markdown file.
4071 if let Ok(mut guard) = sqlite_reporter.lock()
4072 && let Some(ref mut reporter) = *guard
4073 {
4074 let end_time = std::time::SystemTime::now()
4075 .duration_since(std::time::UNIX_EPOCH)
4076 .map(|d| d.as_secs())
4077 .unwrap_or(0);
4078 let sid = session.id.clone();
4079 let exec_id = execution.exec_id;
4080 reporter.set_execution_metadata(&sid, exec_id, "end_time", &end_time.to_string());
4081 reporter.set_execution_metadata(&sid, exec_id, "phase_count", &phases.len().to_string());
4082 reporter.set_execution_metadata(
4083 &sid,
4084 exec_id,
4085 "scenario_count",
4086 &scenarios.len().to_string(),
4087 );
4088 if let Some(wf) = workload_file.as_deref() {
4089 reporter.set_execution_metadata(&sid, exec_id, "workload_file", wf);
4090 }
4091 reporter.set_execution_metadata(&sid, exec_id, "adapter", &driver);
4092 }
4093
4094 if !active_summaries.is_empty() {
4095 // Summary report always comes from SQLite — the
4096 // durable record. The in-memory store exists for GK
4097 // access and reactive control, not for reporting.
4098 if let Ok(mut guard) = sqlite_reporter.lock()
4099 && let Some(ref mut reporter) = *guard
4100 {
4101 // Persist every report item (SRD-46) under
4102 // `report.<name>` keys. Value carries the kind
4103 // keyword (`plot ...` / `table ...`) followed
4104 // by an optional `label "..."` line and then
4105 // the spec body — same shape the report parser
4106 // ingests, so the db-fallback path in
4107 // `nmbrs report` round-trips through the same
4108 // parser the workload uses.
4109 let sid = session.id.clone();
4110 let exec_id = execution.exec_id;
4111 for item in workload_report.items() {
4112 // Single emission point: the workload-side
4113 // serializer. The db-fallback path in
4114 // `nmbrs report` parses this value back
4115 // through `parse_persisted_item`, which
4116 // uses the same grammar — round-trip safe.
4117 let value = item.to_yaml_directive_string();
4118 reporter.set_execution_metadata(
4119 &sid,
4120 exec_id,
4121 &format!("report.{}", item.name),
4122 &value,
4123 );
4124 }
4125
4126 // Stable ordering for consistent output across
4127 // runs (HashMap iteration is non-deterministic).
4128 let mut names: Vec<&String> = active_summaries.keys().collect();
4129 names.sort();
4130 for name in names {
4131 let cfg = &active_summaries[name];
4132 let (basename, format) =
4133 nmbrs_metrics::reporters::sqlite::derive_name_and_format(name);
4134 // SRD-77 — the in-run summary is naturally
4135 // scoped to the current execution; qualifier
4136 // narrows to this run's exec_id so a refine
4137 // doesn't render rows from prior runs.
4138 let report_config = report_config_from_summary(cfg, Some(exec_id));
4139 let rendered = reporter.format_summary_with_format(&report_config, &format);
4140 if rendered.is_empty() {
4141 continue;
4142 }
4143 // SRD-46 routing for this item. Undeclared ⇒
4144 // session directory only.
4145 let dests = summary_destinations
4146 .get(name.as_str())
4147 .cloned()
4148 .unwrap_or_else(|| vec![nmbrs_workload::report::Destination::SessionDir]);
4149 let to_session = dests.contains(&nmbrs_workload::report::Destination::SessionDir);
4150 let to_stdout = dests.contains(&nmbrs_workload::report::Destination::Stdout);
4151 let to_stderr = dests.contains(&nmbrs_workload::report::Destination::Stderr);
4152
4153 let filename = format!("{basename}_summary.{format}");
4154 let summary_path = session.output_dir.join(&filename);
4155 if to_session {
4156 if let Err(e) = std::fs::write(&summary_path, &rendered) {
4157 crate::diag!(
4158 crate::observer::LogLevel::Warn,
4159 "warning: failed to write summary to {}: {e}",
4160 summary_path.display()
4161 );
4162 } else {
4163 crate::diag!(
4164 crate::observer::LogLevel::Info,
4165 "summary: {}",
4166 summary_path.display()
4167 );
4168 }
4169 }
4170 // Inline print only when the observer is
4171 // not suppressing stderr — i.e. we're in
4172 // tui=off mode and the user can see stdout
4173 // right now. In TUI mode the alternate
4174 // screen is up, so `print!()` here would
4175 // get buffered behind the TUI rendering and
4176 // discarded on teardown. The persona reads
4177 // the *_summary.* files and prints them
4178 // post-shutdown (see `crates/nmbrs/src/run.rs`).
4179 if to_stderr {
4180 eprint!("{rendered}");
4181 }
4182 if to_stdout {
4183 // In TUI mode the alternate screen is up, so an
4184 // inline `print!` would be buffered behind the
4185 // TUI rendering and discarded on teardown.
4186 // Defer it to a file the post-run printer
4187 // flushes once the terminal is back in cooked
4188 // mode. Routing is decided HERE either way —
4189 // the post-run printer no longer infers "goes
4190 // to stdout" from the presence of an artifact.
4191 if observer.suppresses_stderr() {
4192 let deferred = session.output_dir.join(DEFERRED_STDOUT_FILE);
4193 if let Err(e) = std::fs::OpenOptions::new()
4194 .create(true)
4195 .append(true)
4196 .open(&deferred)
4197 .and_then(|mut f| {
4198 std::io::Write::write_all(&mut f, rendered.as_bytes())
4199 })
4200 {
4201 crate::diag!(
4202 crate::observer::LogLevel::Warn,
4203 "warning: failed to defer summary stdout to {}: {e}",
4204 deferred.display()
4205 );
4206 }
4207 } else {
4208 print!("{rendered}");
4209 }
4210 }
4211 }
4212 }
4213 }
4214
4215 // Refresh convenience symlinks at the logs/ root so
4216 // logs/metrics.db, logs/summary.md, logs/session.log
4217 // always resolve to this session's artifacts. `logs/latest` (a
4218 // symlink to the whole session dir) is created by Session::new;
4219 // these are per-file counterparts for direct tool access like
4220 // `sqlite3 logs/metrics.db` or `tail -f logs/session.log`.
4221 refresh_latest_file_links(&session);
4222
4223 // dryrun=controls: every phase has now been constructed (at
4224 // depth=Phase the executor stops before cycles but still
4225 // attaches components and declares controls). Walk the
4226 // session tree and dump the catalog.
4227 if diag.list_controls {
4228 let mut out = std::io::stdout();
4229 if let Err(e) = render_controls_tree(&session.component, &mut out) {
4230 crate::diag!(
4231 crate::observer::LogLevel::Warn,
4232 "warning: rendering controls: {e}"
4233 );
4234 }
4235 }
4236
4237 // SRD-77 / SRD-93 stage 6 — close out the executions row with
4238 // the computed disposition + end timestamp. Both clean-exit
4239 // paths stamp it: normal completion here, and a ladder-driven
4240 // interrupt at the walk-error site above (which then propagates
4241 // out before reaching this line). Only genuinely unclean exits
4242 // (force-exit, panic, SIGKILL) leave `ended_at_nanos` NULL,
4243 // which the read side surfaces as "execution in flight /
4244 // unclean exit" distinct from a recorded outcome.
4245 close_execution_row(&sqlite_reporter, &session_id, exec_id);
4246
4247 // WAL consolidation runs from the RAII shutdown guard
4248 // bound at the top of this function (`_sqlite_shutdown_guard`).
4249 // The guard's Drop fires reliably across normal return,
4250 // `?` error propagation, and first-Ctrl-C cooperative
4251 // shutdown — every path Rust unwinds through. Second
4252 // Ctrl-C → `process::exit` is the only skip path,
4253 // matching the documented force-exit semantic in
4254 // `session_signals`.
4255
4256 Ok(())
4257}
4258
4259/// SRD-77 / SRD-93 stage 6 — close the in-flight executions row with
4260/// the scene tree's session disposition + an end timestamp. Idempotent
4261/// (`update_execution_end` keys on `ended_at_nanos IS NULL`), so the
4262/// interrupt-path caller and the normal-completion caller compose:
4263/// whichever runs first wins, the other is a no-op.
4264fn close_execution_row(
4265 sqlite_reporter: &std::sync::Arc<
4266 std::sync::Mutex<Option<nmbrs_metrics::reporters::sqlite::SqliteReporter>>,
4267 >,
4268 session_id: &str,
4269 exec_id: u64,
4270) {
4271 let disposition =
4272 crate::scene_tree::with_global(|t| t.session_disposition().label()).unwrap_or("UNKNOWN");
4273 let ended_at_nanos = std::time::SystemTime::now()
4274 .duration_since(std::time::UNIX_EPOCH)
4275 .map(|d| d.as_nanos() as i64)
4276 .unwrap_or(0);
4277 if let Ok(mut g) = sqlite_reporter.lock()
4278 && let Some(r) = g.as_mut()
4279 {
4280 r.update_execution_end(session_id, exec_id, ended_at_nanos, disposition);
4281 }
4282}
4283
4284/// Core runner: set up the shared session host, run one execution, tear down.
4285async fn run_impl(
4286 args: &[String],
4287 observer: Arc<dyn crate::observer::RunObserver>,
4288) -> Result<(), String> {
4289 let host = SessionHost::setup(args, observer.clone())?;
4290 let result = run_execution(&host, args, observer).await;
4291 host.shutdown().await;
4292 result
4293}
4294
4295/// SRD-88 — one execution's spec for [`run_executions`]: the workload
4296/// CLI args, the observer that captures its lifecycle/log, and an optional
4297/// per-execution output channel (SRD-87 buckets). `channel = None` falls back
4298/// to the process-global channel; in-process example verification passes a
4299/// `CaptureChannel` so each execution's op stdout is captured separately.
4300pub struct ExecutionSpec {
4301 pub args: Vec<String>,
4302 pub observer: Arc<dyn crate::observer::RunObserver>,
4303 pub channel: Option<Arc<dyn crate::output_channel::OutputChannel>>,
4304}
4305
4306/// SRD-88 — run N executions CONCURRENTLY in one process, all sharing
4307/// ONE session, at most `max_concurrent` in flight. The session
4308/// (`SessionHost`: dir / stores / cadence + scheduler services) is set
4309/// up ONCE and torn down ONCE, after every execution. Each execution
4310/// loads + runs its own workload, derives its own `Execution`
4311/// (distinct allocated `exec_id`) under the shared session component,
4312/// flushes its metrics into the shared store via
4313/// [`CadenceReporter::quiesce`](nmbrs_metrics::cadence_reporter::CadenceReporter::quiesce)
4314/// without tearing the reporter down, and routes its lifecycle/log
4315/// through its own observer (a scoped [`ExecutionContext`]). Results
4316/// come back in input order.
4317///
4318/// `max_concurrent == 1` is the sequential case — the SAME harness, no
4319/// separate path (SRD-02 "One Concurrency Path").
4320pub async fn run_executions(
4321 session_args: &[String],
4322 session_observer: Arc<dyn crate::observer::RunObserver>,
4323 specs: Vec<ExecutionSpec>,
4324 max_concurrent: usize,
4325) -> Result<Vec<Result<(), String>>, String> {
4326 let host = std::sync::Arc::new(SessionHost::setup(session_args, session_observer)?);
4327 let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1)));
4328 let futs = specs.into_iter().map(|spec| {
4329 let host = host.clone();
4330 let sem = sem.clone();
4331 async move {
4332 // Bound in-flight executions; permit held for the whole run.
4333 let _permit = sem.acquire().await.expect("semaphore not closed");
4334 // Scope this execution's context — a distinct allocated
4335 // `exec_id` + its own observer (+ optional output channel) — so
4336 // deeply-nested fibers, op-output routing, and `run_execution`'s
4337 // exec-identity all resolve to THIS execution.
4338 let ctx = match spec.channel.clone() {
4339 Some(ch) => crate::execution_context::ExecutionContext::with_observer_and_channel(
4340 spec.observer.clone(),
4341 ch,
4342 ),
4343 None => {
4344 crate::execution_context::ExecutionContext::with_observer(spec.observer.clone())
4345 }
4346 };
4347 crate::execution_context::scope(ctx, run_execution(&host, &spec.args, spec.observer))
4348 .await
4349 }
4350 });
4351 let results = futures::future::join_all(futs).await;
4352 // Session teardown — once, after every execution completed (so the
4353 // host is now the sole owner).
4354 match std::sync::Arc::try_unwrap(host) {
4355 Ok(h) => h.shutdown().await,
4356 Err(_) => crate::diag!(
4357 crate::observer::LogLevel::Warn,
4358 "run_executions: session host still referenced at teardown; \
4359 scheduler/WAL will close on drop"
4360 ),
4361 }
4362 Ok(results)
4363}
4364
4365/// Point per-file symlinks under `logs/` at the latest session's
4366/// artifacts. Silently skips files that don't exist (e.g. summary.md
4367/// when no `summary:` was declared). Replaces any existing symlink.
4368///
4369/// Targets route through `logs/latest` (which `Session::new` points
4370/// at the actual session dir) so the convenience links stay
4371/// consistent with `latest`. Skipped entirely when the session
4372/// lives outside `logs/` — `--session-path /tmp/x` is an explicit
4373/// redirect and shouldn't hijack the user's `logs/` symlinks.
4374fn refresh_latest_file_links(session: &crate::session::Session) {
4375 let logs_dir = std::path::Path::new("logs");
4376 // Mirror the gate in `Session::new` — keep these convenience
4377 // links and `logs/latest` synchronized: either both update or
4378 // neither does.
4379 if !crate::session::target_is_under(logs_dir, &session.output_dir) {
4380 return;
4381 }
4382 for file in ["metrics.db", "summary.md", "session.log"] {
4383 let target = session.output_dir.join(file);
4384 if !target.exists() {
4385 continue;
4386 }
4387 let link = logs_dir.join(file);
4388 // Remove any existing entry (symlink or regular file) so we can
4389 // recreate the link. If this fails because nothing's there,
4390 // that's fine.
4391 let _ = std::fs::remove_file(&link);
4392 let rel_target = std::path::Path::new("latest").join(file);
4393 if let Err(e) = crate::session::symlink_any(&rel_target, &link) {
4394 crate::diag!(
4395 crate::observer::LogLevel::Warn,
4396 "warning: failed to link {} → {}: {e}",
4397 link.display(),
4398 rel_target.display()
4399 );
4400 }
4401 }
4402}
4403
4404/// Create an adapter from inventory registrations.
4405///
4406/// `dryrun=cycle` does NOT substitute the adapter here — it means
4407/// "construct a fully-executable cycle path, then suppress only
4408/// the outbound `execute()` at cycle time." The real adapter is
4409/// always created (connecting, preparing statements, gathering
4410/// metadata); the outermost `DryRunWrapper` handles the runtime
4411/// short-circuit. See `nmbrs_runtime::wrappers::DryRunWrapper`.
4412pub async fn create_adapter(
4413 driver: &str,
4414 params: &HashMap<String, String>,
4415) -> Result<Arc<dyn crate::adapter::DriverAdapter>, String> {
4416 let reg = find_adapter_registration(driver).ok_or_else(|| {
4417 let available = registered_driver_names();
4418 format!(
4419 "unknown adapter '{driver}' (available: {})",
4420 available.join(", ")
4421 )
4422 })?;
4423 (reg.create)(params.clone()).await
4424}
4425
4426/// Run an activity without its own capture thread.
4427///
4428/// All metrics flow through the session-level scheduler →
4429/// `CadenceReporter` → `MetricsQuery`. This function just runs the
4430/// activity to completion; lifecycle flush (final delta +
4431/// validation metrics) is handled by the caller (executor).
4432/// Streaming print: one header line for the `dryrun=kernels`
4433/// dump, fired before any kernel installs.
4434fn print_kernel_dump_header() {
4435 let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
4436 let (bold, reset) = if is_tty {
4437 ("\x1b[1m", "\x1b[0m")
4438 } else {
4439 ("", "")
4440 };
4441 println!();
4442 println!("{bold}Polydat Scope Kernels{reset}");
4443 println!("{bold}═════════════════════{reset}");
4444 println!();
4445}
4446
4447/// Streaming per-scope visitor callback. Prints the scope's
4448/// logical name + the polydat source the kernel was compiled
4449/// from, indented by the scope's depth.
4450fn print_kernel_for_scope(
4451 node: &crate::scope_tree::ScopeNode,
4452 kernel: &crate::scope_kernel::ScopeKernel,
4453) {
4454 let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
4455 let (bold, dim, reset, cyan, magenta, green) = if is_tty {
4456 (
4457 "\x1b[1m", "\x1b[2m", "\x1b[0m", "\x1b[36m", "\x1b[35m", "\x1b[32m",
4458 )
4459 } else {
4460 ("", "", "", "", "", "")
4461 };
4462
4463 let logical = if node.logical_name.is_empty() {
4464 "<unnamed scope>".to_string()
4465 } else {
4466 node.logical_name.clone()
4467 };
4468 let depth_indent = " ".repeat(node.depth);
4469 println!(
4470 "{depth_indent}{green}●{reset} {bold}{cyan}{logical}{reset} \
4471 {dim}(depth={}, kind={:?}){reset}",
4472 node.depth, node.kind
4473 );
4474 let source = kernel.program().source().trim_end();
4475 if source.is_empty() {
4476 println!("{depth_indent} {dim}(empty kernel — no own bindings){reset}");
4477 } else {
4478 for line in source.lines() {
4479 println!("{depth_indent} {magenta}│{reset} {line}");
4480 }
4481 }
4482 println!();
4483}
4484
4485/// Footer for the `dryrun=kernels` dump (legend + spacing).
4486fn print_kernel_dump_legend() {
4487 let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
4488 let (dim, reset, green) = if is_tty {
4489 ("\x1b[2m", "\x1b[0m", "\x1b[32m")
4490 } else {
4491 ("", "", "")
4492 };
4493 println!(
4494 " {dim}Legend: {green}●{reset}{dim} kernel installed at this scope. \
4495 Flattened scopes (those that inherit a parent's kernel) emit no entry.{reset}"
4496 );
4497 println!();
4498}
4499
4500pub async fn run_activity_simple(
4501 activity: Activity,
4502 adapters: std::collections::HashMap<String, Arc<dyn crate::adapter::DriverAdapter>>,
4503 default_adapter: &str,
4504 op_builder: Arc<crate::synthesis::OpBuilder>,
4505) -> bool {
4506 activity
4507 .run_with_adapters(adapters, default_adapter, op_builder)
4508 .await
4509}
4510
4511/// Adapter that delegates to an `Arc<Mutex<Option<SqliteReporter>>>`.
4512///
4513/// Allows the SQLite reporter to be registered on the scheduler while
4514/// also being accessible for summary queries after the scheduler stops.
4515struct MutexReporter(
4516 std::sync::Arc<std::sync::Mutex<Option<nmbrs_metrics::reporters::sqlite::SqliteReporter>>>,
4517);
4518
4519impl Reporter for MutexReporter {
4520 fn report(&mut self, snapshot: &nmbrs_metrics::snapshot::MetricSet) {
4521 if let Ok(mut guard) = self.0.lock()
4522 && let Some(ref mut r) = *guard
4523 {
4524 Reporter::report(r, snapshot);
4525 }
4526 }
4527
4528 fn flush(&mut self) {
4529 if let Ok(mut guard) = self.0.lock()
4530 && let Some(ref mut r) = *guard
4531 {
4532 Reporter::flush(r);
4533 }
4534 }
4535}
4536
4537/// Wrapper to make `Box<dyn Reporter>` usable with `add_reporter(impl Reporter)`.
4538struct BoxedReporter(Box<dyn Reporter>);
4539impl Reporter for BoxedReporter {
4540 fn report(&mut self, snapshot: &nmbrs_metrics::snapshot::MetricSet) {
4541 self.0.report(snapshot);
4542 }
4543 fn flush(&mut self) {
4544 self.0.flush();
4545 }
4546}
4547
4548// =========================================================================
4549// Helpers
4550// =========================================================================
4551
4552/// Expand `{key}` workload param placeholders in a string.
4553pub fn expand_workload_params(s: &str, params: &HashMap<String, String>) -> String {
4554 let mut result = s.to_string();
4555 for (key, value) in params {
4556 let placeholder = format!("{{{key}}}");
4557 if result.contains(&placeholder) {
4558 result = result.replace(&placeholder, value);
4559 }
4560 }
4561 result
4562}
4563
4564/// Collected param references from a workload, separating direct
4565/// `{name}` references from composite-name templates like
4566/// `{k_{k}_limits}` whose ground form depends on a runtime
4567/// substitution.
4568///
4569/// Used by the workload param validator to recognize that a
4570/// declared param like `k_1_limits` is genuinely referenced
4571/// when the workload uses `{k_{k}_limits}` and `k` ranges over
4572/// values including `1`.
4573#[derive(Default)]
4574struct ParamRefs {
4575 /// Names that appeared as `{name}` curly-brace placeholders.
4576 /// These MUST resolve to a declared param, runner-known
4577 /// param, adapter-registered param, or scenario-tree
4578 /// iter-var — anything else is a typo or missing
4579 /// declaration and the validator surfaces it as an error.
4580 placeholders: std::collections::HashSet<String>,
4581 /// Placeholders that appeared in scenario-tree `for_each` /
4582 /// `for_combinations` / DoWhile-condition text. These get
4583 /// resolved at runtime by the comprehension interpolator
4584 /// (which emits a `<path>:<line>:<col>:` error on failure);
4585 /// the strict "must-resolve-now" validator skips them so the
4586 /// more-specific runtime diagnostic wins. They still count
4587 /// as references for the "declared but unreferenced" check
4588 /// — a workload param used only in a `for_each` spec is
4589 /// genuinely consumed by the workload, just at runtime.
4590 runtime_only_placeholders: std::collections::HashSet<String>,
4591 /// Bare identifiers harvested from `if:` / `delay:`
4592 /// expression bodies. These may be wire names (referencing
4593 /// values bound via Polydat source) rather than workload params,
4594 /// so they participate in the "declared but unreferenced"
4595 /// check (as references) but NOT in the "referenced but
4596 /// undeclared" check (since wire names legitimately live
4597 /// outside the workload's param surface).
4598 expression_idents: std::collections::HashSet<String>,
4599 /// Composite templates: the literal body of a `{...}` whose
4600 /// inner content contained nested `{...}`. Stored verbatim
4601 /// (e.g. `"k_{k}_limits"`); validation checks each declared
4602 /// param name against these templates by replacing each
4603 /// inner `{NAME}` with a word-character wildcard.
4604 templates: Vec<String>,
4605}
4606
4607impl ParamRefs {
4608 /// Does `param` appear as a reference anywhere — placeholder,
4609 /// runtime-only placeholder, expression ident, or composite
4610 /// template? Used by the existing "declared but unreferenced"
4611 /// validator.
4612 fn contains(&self, param: &str) -> bool {
4613 if self.placeholders.contains(param) {
4614 return true;
4615 }
4616 if self.runtime_only_placeholders.contains(param) {
4617 return true;
4618 }
4619 if self.expression_idents.contains(param) {
4620 return true;
4621 }
4622 self.templates
4623 .iter()
4624 .any(|tpl| template_matches(tpl, param))
4625 }
4626}
4627
4628/// Match a declared param name against a composite-name template.
4629///
4630/// `template` is the body of a `{...}` reference whose content
4631/// included nested `{...}` substitutions — e.g. `k_{k}_limits`.
4632/// Each inner `{NAME}` matches one or more word characters
4633/// (`[A-Za-z0-9_]+`); the surrounding literal chars must match
4634/// exactly. Returns `true` iff `param` exactly matches the
4635/// template's ground form for some substitution of the inner
4636/// names.
4637fn template_matches(template: &str, param: &str) -> bool {
4638 let t = template.as_bytes();
4639 let p = param.as_bytes();
4640 let mut ti = 0;
4641 let mut pi = 0;
4642 while ti < t.len() {
4643 if t[ti] == b'{' {
4644 // Skip past the inner {...}. The template body
4645 // doesn't nest deeper than one level in practice
4646 // (composed names like `{k_{k}_limits}` don't
4647 // contain `{a_{b}_c}` recursively); a simple
4648 // first-`}` lookup suffices.
4649 let close = match template[ti + 1..].find('}') {
4650 Some(n) => ti + 1 + n,
4651 None => return false, // malformed template
4652 };
4653 // Determine where the template's literal context
4654 // resumes after the inner placeholder.
4655 let next_lit = close + 1;
4656 // The next literal char (or end-of-template) bounds
4657 // how far the wildcard can consume.
4658 if next_lit >= t.len() {
4659 // Wildcard must consume the rest of `param`,
4660 // and that suffix must be at least one word char.
4661 if pi >= p.len() {
4662 return false;
4663 }
4664 return p[pi..]
4665 .iter()
4666 .all(|b| b.is_ascii_alphanumeric() || *b == b'_');
4667 }
4668 let stop = t[next_lit];
4669 // Greedy-match word chars in param up to the next
4670 // literal in template.
4671 let mut consumed = 0;
4672 while pi + consumed < p.len() && p[pi + consumed] != stop {
4673 let b = p[pi + consumed];
4674 if !(b.is_ascii_alphanumeric() || b == b'_') {
4675 return false;
4676 }
4677 consumed += 1;
4678 }
4679 if consumed == 0 {
4680 return false;
4681 } // wildcard requires ≥1 char
4682 pi += consumed;
4683 ti = next_lit;
4684 } else {
4685 // Literal char: must match.
4686 if pi >= p.len() || p[pi] != t[ti] {
4687 return false;
4688 }
4689 ti += 1;
4690 pi += 1;
4691 }
4692 }
4693 pi == p.len()
4694}
4695
4696/// Scan a string for `{name}` references and `{composite_{x}_name}`
4697/// templates, accumulating into `refs`.
4698///
4699/// Plain `{name}` placeholders (where the body is a single
4700/// identifier — alphanumerics + underscore, leading non-digit)
4701/// are recorded as direct references. A `{...}` whose body
4702/// contains nested `{...}` is recorded as a template; the inner
4703/// leaf names are also recorded as direct references because
4704/// they're the substitution inputs (e.g. `{k_{k}_limits}`
4705/// records the template `k_{k}_limits` AND the direct ref `k`).
4706/// Walk a `serde_json::Value` and call [`scan_param_refs`] on
4707/// every string leaf. Used by [`collect_param_references`] to
4708/// reach `{name}` references nested inside structured
4709/// `params:` blocks (e.g. `relevancy: { expected: "{ground_truth}" }`).
4710fn scan_json_for_refs(v: &serde_json::Value, refs: &mut ParamRefs) {
4711 match v {
4712 serde_json::Value::String(s) => scan_param_refs(s, refs),
4713 serde_json::Value::Array(a) => {
4714 for item in a {
4715 scan_json_for_refs(item, refs);
4716 }
4717 }
4718 serde_json::Value::Object(m) => {
4719 for item in m.values() {
4720 scan_json_for_refs(item, refs);
4721 }
4722 }
4723 _ => {} // numbers, booleans, null — no string content
4724 }
4725}
4726
4727fn scan_param_refs(text: &str, refs: &mut ParamRefs) {
4728 let bytes = text.as_bytes();
4729 let mut i = 0;
4730 while i < bytes.len() {
4731 if bytes[i] != b'{' {
4732 i += 1;
4733 continue;
4734 }
4735 // Find the matching `}`, balancing nested `{`s.
4736 let body_start = i + 1;
4737 let mut depth = 1;
4738 let mut j = body_start;
4739 while j < bytes.len() && depth > 0 {
4740 match bytes[j] {
4741 b'{' => depth += 1,
4742 b'}' => depth -= 1,
4743 _ => {}
4744 }
4745 if depth == 0 {
4746 break;
4747 }
4748 j += 1;
4749 }
4750 if depth != 0 {
4751 // Unmatched `{` — bail, treat as literal.
4752 break;
4753 }
4754 let body = &text[body_start..j];
4755 if body.contains('{') {
4756 // Composite template (e.g. `k_{k}_limits`).
4757 refs.templates.push(body.to_string());
4758 // Recurse into the body to pick up the inner leaf
4759 // names as direct references.
4760 scan_param_refs(body, refs);
4761 } else if !body.is_empty()
4762 && body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
4763 && !body.bytes().next().unwrap().is_ascii_digit()
4764 {
4765 // Plain `{name}` — curly-brace placeholder. MUST
4766 // resolve to a declared / known / iter-var name;
4767 // the new validator surfaces an error if it doesn't.
4768 refs.placeholders.insert(body.to_string());
4769 } else {
4770 // Inline Polydat expression body, e.g.
4771 // `{is_one_of(cassandra_dialect, "vendor")}` or
4772 // `{:=mod(hash(cycle), 100):=}`. Walk the body,
4773 // collect identifier-shaped tokens that aren't
4774 // inside string literals — those are Polydat name
4775 // references which may resolve to workload params.
4776 // Over-collecting (function names, Polydat stdlib
4777 // identifiers) is harmless — the validator's
4778 // membership test below uses the workload's own
4779 // declared params as the universe of interest.
4780 //
4781 // These land in `expression_idents` (not
4782 // `placeholders`) because they may legitimately
4783 // resolve to Polydat wire names rather than workload
4784 // params; the "declared but unreferenced" check
4785 // still consults them via `ParamRefs::contains`,
4786 // but the new "referenced but undeclared" check
4787 // only scrutinises `placeholders`.
4788 scan_expression_idents(body, &mut refs.expression_idents);
4789 }
4790 i = j + 1;
4791 }
4792}
4793
4794/// Walk a GK-expression body (no surrounding `{}`) and add any
4795/// identifier-shaped tokens to `out`. Skips identifiers nested
4796/// inside `"..."` or `'...'` string literals — those are CQL /
4797/// regex / display strings, not name references. Recognises
4798/// backslash escapes inside string literals.
4799///
4800/// This is best-effort: it doesn't honor Polydat lexer subtleties
4801/// (numeric suffixes, raw strings, etc.). For the unused-param
4802/// check in `runner.rs::collect_param_references` the goal is
4803/// "does the param name appear anywhere we'd evaluate it" — a
4804/// loose match is correct because false positives only mean
4805/// "param is considered used when it might not have been",
4806/// which is the safer failure mode.
4807fn scan_expression_idents(body: &str, out: &mut std::collections::HashSet<String>) {
4808 let bytes = body.as_bytes();
4809 let mut i = 0;
4810 while i < bytes.len() {
4811 let b = bytes[i];
4812 // Skip `//` line comments — checked BEFORE the string-literal
4813 // scan below, because an apostrophe in comment prose (`hasn't`,
4814 // `don't`) would otherwise be read as an unterminated string
4815 // delimiter and swallow every identifier to end-of-input,
4816 // falsely flagging a later-referenced param as unused.
4817 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
4818 i += 2;
4819 while i < bytes.len() && bytes[i] != b'\n' {
4820 i += 1;
4821 }
4822 continue;
4823 }
4824 // Skip string literals.
4825 if b == b'"' || b == b'\'' {
4826 let quote = b;
4827 i += 1;
4828 while i < bytes.len() {
4829 if bytes[i] == b'\\' && i + 1 < bytes.len() {
4830 i += 2;
4831 continue;
4832 }
4833 if bytes[i] == quote {
4834 i += 1;
4835 break;
4836 }
4837 i += 1;
4838 }
4839 continue;
4840 }
4841 // Identifier start: ASCII letter or underscore.
4842 if b.is_ascii_alphabetic() || b == b'_' {
4843 let start = i;
4844 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
4845 i += 1;
4846 }
4847 let ident = &body[start..i];
4848 // Skip the few literals the Polydat lexer also recognises
4849 // — they're definitely not param names.
4850 if ident != "true" && ident != "false" {
4851 out.insert(ident.to_string());
4852 }
4853 continue;
4854 }
4855 i += 1;
4856 }
4857}
4858
4859/// Collect all `{name}` param references from a workload's ops,
4860/// phases, bindings, and scenario tree. Returns both direct
4861/// refs and composite templates so the validator can recognize
4862/// dynamic-name-composition references like `{k_{k}_limits}`.
4863fn collect_param_references(workload: &nmbrs_workload::model::Workload) -> ParamRefs {
4864 let mut refs = ParamRefs::default();
4865
4866 // Local helper so every op-bearing scope (top-level + per-
4867 // phase) hits the same set of fields. Critically this
4868 // includes the *core* fields the parser hoists out of the
4869 // op map — `if:` (condition), `delay:`, and the
4870 // serde_json values inside `params:`. Missing any of those
4871 // produced false positives on the unused-param check
4872 // (e.g. `if: '{is_one_of(cassandra_dialect, "vendor")}'`
4873 // landed in `condition` rather than `op.op`, so the
4874 // workload param `cassandra_dialect` looked unreferenced).
4875 fn scan_op(op: &nmbrs_workload::model::ParsedOp, refs: &mut ParamRefs) {
4876 for value in op.op.values() {
4877 if let serde_json::Value::String(s) = value {
4878 scan_param_refs(s, refs);
4879 }
4880 }
4881 // `if:` and `delay:` accept either `{name}` placeholders
4882 // (caught by `scan_param_refs`) or bare wire names like
4883 // `delay: think_time`. Walk both shapes — the bare-ident
4884 // pass mirrors what `scope.rs` Step 3/6 do for the same
4885 // fields, so a workload param consumed only via a bare
4886 // delay/condition reference doesn't trip the unused-param
4887 // validator.
4888 if let Some(s) = &op.condition {
4889 scan_param_refs(s, refs);
4890 scan_expression_idents(s, &mut refs.expression_idents);
4891 }
4892 if let Some(spec) = &op.delay {
4893 for name in spec.names() {
4894 scan_param_refs(name, refs);
4895 scan_expression_idents(name, &mut refs.expression_idents);
4896 }
4897 }
4898 // `params:` values can be strings, numbers, nested
4899 // maps (e.g. `relevancy: { actual: key, expected: …}`).
4900 // Walk the JSON recursively so anything stringy gets
4901 // scanned regardless of nesting depth.
4902 //
4903 // `gutter:` is exempt: its templates resolve at RUNTIME
4904 // (wires first, then status-metric aggregates like
4905 // `{recall}` / `{latency_p50}` which have no workload
4906 // declaration), and an unresolved name degrades to
4907 // visible literal text in the cell — never a silent
4908 // failure this validator needs to preempt.
4909 for (k, v) in op.params.iter() {
4910 if k == "gutter" {
4911 continue;
4912 }
4913 scan_json_for_refs(v, refs);
4914 }
4915 // Evaluation blocks reference WIRES by bare identifier
4916 // (`relevancy: { k: suite_k, expected: ground_truth }`) —
4917 // the same shapes `scope.rs` resolves at wrap-time
4918 // through the op-template kernel. Harvest them as
4919 // expression idents so a param consumed only as an
4920 // evaluation bound doesn't trip the unused-param check.
4921 // (Before this, such workloads only validated by ACCIDENT:
4922 // any `{{…}}` inline expression elsewhere in the merged
4923 // doc registered a composite template that wildcard-
4924 // matched every declared param.)
4925 if let Some(rel) = op.params.get("relevancy").and_then(|v| v.as_object()) {
4926 for key in ["actual", "expected", "k", "r"] {
4927 if let Some(s) = rel.get(key).and_then(|v| v.as_str()) {
4928 scan_expression_idents(s, &mut refs.expression_idents);
4929 }
4930 }
4931 }
4932 match &op.bindings {
4933 nmbrs_workload::model::BindingsDef::PolydatSource(s) => {
4934 // Two reference shapes inside Polydat source:
4935 // - `{name}` placeholders inside string literals
4936 // (resolved by Polydat string-interpolation against
4937 // `const` bindings),
4938 // - bare identifier references in Polydat expressions
4939 // (e.g. `row := char_buf(..., cols)` — `cols`
4940 // resolves directly to its `const` binding).
4941 // The unused-param validator must recognise both
4942 // or it falsely flags params that the workload
4943 // legitimately consumes via the bare path.
4944 scan_param_refs(s, refs);
4945 scan_expression_idents(s, &mut refs.expression_idents);
4946 }
4947 nmbrs_workload::model::BindingsDef::Map(m) => {
4948 for v in m.values() {
4949 scan_param_refs(v, refs);
4950 }
4951 }
4952 }
4953 }
4954
4955 // Scan top-level ops
4956 for op in &workload.ops {
4957 scan_op(op, &mut refs);
4958 }
4959
4960 // SRD-13f Push D: workload-level `bindings:` are no longer
4961 // folded into ops at YAML parse time — they live on
4962 // `workload.bindings` and reach descendants via the GK
4963 // kernel chain. The unused-param validator must scan them
4964 // directly here, otherwise a workload param consumed only
4965 // from workload-level bindings (`row := char_buf(..., cols)`)
4966 // looks unreferenced.
4967 match &workload.bindings {
4968 nmbrs_workload::model::BindingsDef::PolydatSource(s) => {
4969 scan_param_refs(s, &mut refs);
4970 scan_expression_idents(s, &mut refs.expression_idents);
4971 }
4972 nmbrs_workload::model::BindingsDef::Map(m) => {
4973 for v in m.values() {
4974 scan_param_refs(v, &mut refs);
4975 }
4976 }
4977 }
4978
4979 // SRD-83 — workload-level `stop_when:` predicates: `{param}` interpolation
4980 // only (same as phase-level), so a param used only by a workload breaker
4981 // counts as referenced.
4982 for c in &workload.stop_when {
4983 scan_param_refs(&c.when, &mut refs);
4984 }
4985
4986 // Scan phases
4987 for phase in workload.phases.values() {
4988 if let Some(s) = &phase.cycles {
4989 scan_param_refs(s, &mut refs);
4990 }
4991 // SRD-83 (C3) governance `timeout:` and SRD-75 `interval:`
4992 // resolve `{param}` at phase setup — documented reference
4993 // sites the collector must count (before this, workloads
4994 // using them only validated via the accidental composite-
4995 // template wildcard).
4996 if let Some(s) = &phase.timeout {
4997 scan_param_refs(s, &mut refs);
4998 }
4999 if let Some(s) = &phase.interval {
5000 scan_param_refs(s, &mut refs);
5001 }
5002 if let Some(s) = &phase.concurrency {
5003 scan_param_refs(s, &mut refs);
5004 }
5005 if let Some(s) = &phase.for_each {
5006 scan_param_refs(s, &mut refs);
5007 }
5008 // Phase `rate:` and the SRD-75 poll bounds carry `{param}`
5009 // references resolved at the phase gather (the `timeout:`
5010 // discipline) — documented reference sites the collector
5011 // must count. The poll predicate additionally consumes
5012 // bare identifiers (params/wires) like `continue_if`.
5013 if let Some(s) = &phase.rate {
5014 scan_param_refs(s, &mut refs);
5015 }
5016 if let Some(poll) = &phase.poll {
5017 scan_expression_idents(&poll.until, &mut refs.expression_idents);
5018 for s in [&poll.interval_ms, &poll.timeout_ms, &poll.max_error_retries]
5019 .into_iter()
5020 .flatten()
5021 {
5022 scan_param_refs(s, &mut refs);
5023 }
5024 }
5025 // SRD-13f Push D parallel: phase-level `bindings:` also
5026 // sit on their own scope post-Push-D; scan for param
5027 // refs so a phase-binding-only consumer doesn't falsely
5028 // trip the unused-param check.
5029 match &phase.bindings {
5030 nmbrs_workload::model::BindingsDef::PolydatSource(s) => {
5031 scan_param_refs(s, &mut refs);
5032 scan_expression_idents(s, &mut refs.expression_idents);
5033 }
5034 nmbrs_workload::model::BindingsDef::Map(m) => {
5035 for v in m.values() {
5036 scan_param_refs(v, &mut refs);
5037 }
5038 }
5039 }
5040 // SRD-83 / SRD-101 — breaker predicates can consume a workload param,
5041 // but by DIFFERENT mechanisms, so scan each for the shape it actually
5042 // supports (a param used in the UNsupported shape stays flagged):
5043 // - `stop_when` substitutes `{param}` before its predicate compiles;
5044 // its bound scope can't resolve a bare param wire → `{param}` only.
5045 // - `continue_if` resolves BARE wires through its for_iteration
5046 // scope-walk (inherited consts/params) → bare idents only.
5047 for c in &phase.stop_when {
5048 scan_param_refs(&c.when, &mut refs);
5049 }
5050 if let Some(ci) = &phase.continue_if {
5051 scan_expression_idents(&ci.when, &mut refs.expression_idents);
5052 }
5053 for op in &phase.ops {
5054 scan_op(op, &mut refs);
5055 }
5056 }
5057
5058 // Scan scenario tree — every node kind contributes its
5059 // `{...}`-bearing fields. DoWhile/DoUntil contribute their
5060 // condition text; ForEach/ForCombinations/ForEachUnion
5061 // contribute their iteration specs.
5062 //
5063 // Scenario-tree `{name}` placeholders are runtime-interpolated
5064 // against the outer iter-var scope (the comprehension's
5065 // enclosing for-each, plus workload params). An unresolved
5066 // placeholder there surfaces a `path:line:col:` runtime error
5067 // through the interpolation pipeline — the early
5068 // workload-level "undeclared placeholder" check would steal
5069 // that diagnostic with a less specific error. So we route
5070 // these refs through a side-channel that contributes to the
5071 // declared-but-unreferenced check (workload params used only
5072 // in for_each text are still counted as referenced) but NOT
5073 // to `refs.placeholders` (which drives the strict
5074 // "must-resolve-now" guard).
5075 fn scan_scenario_nodes(nodes: &[nmbrs_workload::model::ScenarioNode], refs: &mut ParamRefs) {
5076 for node in nodes {
5077 match node {
5078 nmbrs_workload::model::ScenarioNode::Phase(_) => {}
5079 nmbrs_workload::model::ScenarioNode::Comprehension {
5080 comprehension,
5081 children,
5082 ..
5083 } => {
5084 // Grammar-based source-reference extraction.
5085 // A comprehension clause `eh in eh_values`
5086 // carries `eh_values` as a *bare* source
5087 // reference (a `Generator`/`WorkloadParamList`
5088 // spec), and `(v) in (concat(foo))` carries
5089 // `foo` inside a function call. Byte-scanning
5090 // for `{name}` misses both. `referenced_source_names`
5091 // parses each spec with the Polydat expression
5092 // grammar (via `polydat::dsl::refs`) and returns
5093 // the free names structurally. These resolve at
5094 // runtime, so they count as references (for the
5095 // declared-but-unreferenced check) but bypass
5096 // the strict undeclared-placeholder guard.
5097 //
5098 // A dynamic param-list reference like
5099 // `limit in {k_{k}_limits}` surfaces as the
5100 // composite name `k_{k}_limits` (the inner
5101 // `{k}` is an iter-var hole filled at runtime).
5102 // Route composite names — those still carrying
5103 // a `{` — into `templates` so the structured
5104 // `template_matches` name-composition grammar
5105 // resolves them against `k_10_limits` /
5106 // `k_100_limits` / …; plain names go to the
5107 // runtime-placeholder set.
5108 for name in comprehension.referenced_source_names() {
5109 if name.contains('{') {
5110 refs.templates.push(name);
5111 } else {
5112 refs.runtime_only_placeholders.insert(name);
5113 }
5114 }
5115 scan_scenario_nodes(children, refs);
5116 }
5117 nmbrs_workload::model::ScenarioNode::DoWhile {
5118 condition,
5119 children,
5120 ..
5121 }
5122 | nmbrs_workload::model::ScenarioNode::DoUntil {
5123 condition,
5124 children,
5125 ..
5126 } => {
5127 scan_param_refs(condition, refs);
5128 scan_scenario_nodes(children, refs);
5129 }
5130 nmbrs_workload::model::ScenarioNode::IncludedScenario { children, .. } => {
5131 scan_scenario_nodes(children, refs);
5132 }
5133 nmbrs_workload::model::ScenarioNode::Bindings { source, children } => {
5134 // Scenario-tree `bindings:` (and the `set:`
5135 // sugar form) carries Polydat matter text. Scan
5136 // the body for `{name}` placeholders and
5137 // bare identifiers and route through
5138 // `runtime_only_placeholders` so a param
5139 // referenced only by a bindings body still
5140 // counts as referenced, without tripping the
5141 // strict undeclared-placeholder guard (the
5142 // body is resolved at kernel build time,
5143 // not at op-template substitution).
5144 let mut deferred = ParamRefs::default();
5145 scan_param_refs(source, &mut deferred);
5146 refs.runtime_only_placeholders.extend(deferred.placeholders);
5147 refs.expression_idents.extend(deferred.expression_idents);
5148 refs.templates.extend(deferred.templates);
5149 scan_scenario_nodes(children, refs);
5150 }
5151 }
5152 }
5153 }
5154 for nodes in workload.scenarios.values() {
5155 scan_scenario_nodes(nodes, &mut refs);
5156 }
5157
5158 refs
5159}
5160
5161/// Collect every iter-var name introduced by a `for_each:` /
5162/// `for_combinations:` clause anywhere in the scenario tree.
5163///
5164/// These names become legitimate `{name}` placeholders inside
5165/// phases reached via that for-clause — the runner binds them
5166/// fresh per iteration through the workload kernel's
5167/// scope-coordinate mechanism. The "referenced but undeclared"
5168/// validator consults this set so it doesn't false-positive on
5169/// `{k}` / `{limit}` / `{profile}` references that are clearly
5170/// satisfied by an enclosing `for_each: "k in …, limit in …,
5171/// profile in …"`.
5172///
5173/// Walks every scenario in the workload (the union — any
5174/// scenario the operator might invoke), so the validator
5175/// remains correct regardless of which `scenario=` argument
5176/// the operator passes on the CLI.
5177fn collect_iter_var_names(
5178 workload: &nmbrs_workload::model::Workload,
5179) -> std::collections::HashSet<String> {
5180 let mut out = std::collections::HashSet::new();
5181 for nodes in workload.scenarios.values() {
5182 for node in nodes {
5183 collect_iter_vars_recursive(node, &mut out);
5184 }
5185 }
5186 // Phase-level `for_each:` declarations also introduce iter-vars
5187 // — `phases.X.for_each: "k in 1, 2, 3"` lets the phase's ops
5188 // reference `{k}`. The scenario walker doesn't traverse phase
5189 // bodies, so harvest from each phase's `for_each` clause too.
5190 for phase in workload.phases.values() {
5191 if let Some(text) = phase.for_each.as_deref()
5192 && let Ok(comp) =
5193 polydat::iteration::comprehension::spec::parse_comprehension_algebra(text)
5194 {
5195 for name in comp.coordinate_names() {
5196 out.insert(name.to_string());
5197 }
5198 }
5199 }
5200 out
5201}
5202
5203fn collect_iter_vars_recursive(
5204 node: &nmbrs_workload::model::ScenarioNode,
5205 out: &mut std::collections::HashSet<String>,
5206) {
5207 use nmbrs_workload::model::ScenarioNode::*;
5208 match node {
5209 Phase(_) => {}
5210 Comprehension {
5211 comprehension,
5212 children,
5213 ..
5214 } => {
5215 for name in comprehension.coordinate_names() {
5216 out.insert(name.to_string());
5217 }
5218 for child in children {
5219 collect_iter_vars_recursive(child, out);
5220 }
5221 }
5222 DoWhile {
5223 children, counter, ..
5224 }
5225 | DoUntil {
5226 children, counter, ..
5227 } => {
5228 // `counter:` introduces a bare iteration index name
5229 // that's legitimately referenceable inside the loop
5230 // body, even though there's no `for_each` clause.
5231 if let Some(c) = counter {
5232 out.insert(c.clone());
5233 }
5234 for child in children {
5235 collect_iter_vars_recursive(child, out);
5236 }
5237 }
5238 IncludedScenario { children, .. } => {
5239 for child in children {
5240 collect_iter_vars_recursive(child, out);
5241 }
5242 }
5243 // Scenario-tree `bindings:` (and `set:` sugar) doesn't
5244 // introduce an iter-var; it publishes a scope-local
5245 // binding layer. Just walk children.
5246 Bindings { children, .. } => {
5247 for child in children {
5248 collect_iter_vars_recursive(child, out);
5249 }
5250 }
5251 }
5252}
5253
5254/// Collect every binding LHS name (wire output) declared in GK
5255/// source anywhere in the workload — top-level `bindings:`,
5256/// per-phase `bindings:`, and per-op `bindings:`.
5257///
5258/// These names become legitimate `{name}` placeholders inside
5259/// op text (op-template `prepared:` / `raw:` strings get
5260/// `{wire}` interpolated to the wire's value at cycle time, just
5261/// like workload params get expanded earlier in the pipeline).
5262/// The "referenced but undeclared" validator consults this set so
5263/// it doesn't false-positive on `{query_vector}` / `{dim}` /
5264/// `{ground_truth}` references that are clearly satisfied by an
5265/// enclosing `bindings:` block.
5266///
5267/// Scanner is line-based and recognises six shapes:
5268/// * `input NAME[: TYPE]` — kernel input slot (bare form)
5269/// * `input (NAME[: TYPE], ...)` — kernel input slot (tuple form)
5270/// * `const NAME := …` — init binding (eager, once per scope)
5271/// * `cursor NAME = …` — cursor declaration
5272/// * `shared NAME := …` — shared output (cross-scope cell)
5273/// * `const NAME := …` — final binding
5274/// * `NAME := …` — ordinary `:=` output binding
5275///
5276/// The collector also mirrors the workload-root kernel's auto-input
5277/// behaviour (see `bindings.rs::compile_workload_kernel`): when no
5278/// Polydat source anywhere in the workload declares any `input` slot,
5279/// the runtime injects `input cycle: u64` so `{cycle}` resolves at
5280/// op-template substitution time. Reflecting that injection in the
5281/// validator allow-set prevents false-positive rejection of
5282/// `{cycle}` placeholders in workloads with no explicit `bindings:`
5283/// block (e.g. inline `op="tick={cycle}"`).
5284fn collect_polydat_binding_names(
5285 workload: &nmbrs_workload::model::Workload,
5286) -> std::collections::HashSet<String> {
5287 let mut out = std::collections::HashSet::new();
5288 use nmbrs_workload::model::BindingsDef;
5289
5290 let mut any_input_decl = false;
5291 let mut scan_bindings =
5292 |bindings: &BindingsDef, sink: &mut std::collections::HashSet<String>| {
5293 match bindings {
5294 BindingsDef::PolydatSource(s) => {
5295 scan_polydat_binding_lhs(sink, s);
5296 if s.lines().any(|l| l.trim_start().starts_with("input ")) {
5297 any_input_decl = true;
5298 }
5299 }
5300 BindingsDef::Map(m) => {
5301 // Legacy nosqlbench-style chains (`Hash(); Mod(...)`)
5302 // are translated into Polydat bindings at runtime by
5303 // `compile_bindings_with_opts`; the keys of the map
5304 // become the wire names those translations produce.
5305 // The validator's allow-set must reflect those names
5306 // so referencing `{user_id}` in op text — where
5307 // `user_id: Hash(); Mod(...)` is the binding key —
5308 // doesn't trip the undeclared-placeholder guard.
5309 for name in m.keys() {
5310 sink.insert(name.clone());
5311 }
5312 }
5313 }
5314 };
5315
5316 scan_bindings(&workload.bindings, &mut out);
5317 // Top-level `workload.ops` carry their own `bindings:` blocks
5318 // in inline-mode workloads (the inline parser puts
5319 // synthesised `__inline_N := <expr>` lines there). Without
5320 // walking this collection, the validator misses every
5321 // inline-rewrite-generated wire name.
5322 for op in &workload.ops {
5323 scan_bindings(&op.bindings, &mut out);
5324 }
5325 for phase in workload.phases.values() {
5326 scan_bindings(&phase.bindings, &mut out);
5327 for op in &phase.ops {
5328 scan_bindings(&op.bindings, &mut out);
5329 }
5330 }
5331
5332 // Runtime mirror: workload-root kernel auto-injects
5333 // `input cycle: u64` when no `input` line is declared anywhere.
5334 // Surface that injection to the validator.
5335 if !any_input_decl {
5336 out.insert("cycle".to_string());
5337 }
5338 out
5339}
5340
5341/// One occurrence of an invalid `{name}` placeholder inside a
5342/// Polydat expression context. The `location` describes which source
5343/// block in the workload (e.g. `"phase 'ann_query' bindings"`),
5344/// and the `placeholder` is the literal body that appeared inside
5345/// the offending `{...}` — kept verbatim so the error formatter
5346/// can locate the exact YAML line by substring search.
5347#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
5348struct PolydatBraceFinding {
5349 location: String,
5350 placeholder: String,
5351}
5352
5353/// Collect every `{name}` placeholder that appears inside a GK
5354/// source context but OUTSIDE a string literal — these will
5355/// always fail Polydat compile because `{...}` isn't valid expression
5356/// syntax. Each finding names the workload block it came from
5357/// so the error formatter can point at the offending site.
5358///
5359/// Walks every Polydat source in the workload: top-level `bindings:`,
5360/// per-phase `bindings:`, per-op `bindings:`.
5361fn collect_polydat_brace_refs(
5362 workload: &nmbrs_workload::model::Workload,
5363) -> Vec<PolydatBraceFinding> {
5364 use nmbrs_workload::model::BindingsDef;
5365 let mut out: Vec<PolydatBraceFinding> = Vec::new();
5366 // Braces are no longer categorically invalid in Polydat: the block form of
5367 // conditional selection (`if <cond> { a } else { b }`) uses them. So a brace is
5368 // only evidence of a stray YAML placeholder when the source ALSO fails to parse.
5369 // Gating on parseability keeps this check doing its actual job — converting a
5370 // cryptic "expected expression, got LBrace" into a message that names the file,
5371 // line and placeholder — without rejecting valid if-expressions.
5372 let mut push_refs = |loc: &str, source: &str| {
5373 let parses = polydat::dsl::lexer::lex(source)
5374 .ok()
5375 .and_then(|toks| polydat::dsl::parser::parse(toks).ok())
5376 .is_some();
5377 if parses {
5378 return;
5379 }
5380 for name in scan_polydat_braced_refs(source) {
5381 out.push(PolydatBraceFinding {
5382 location: loc.to_string(),
5383 placeholder: name,
5384 });
5385 }
5386 };
5387 if let BindingsDef::PolydatSource(s) = &workload.bindings {
5388 push_refs("workload `bindings:`", s);
5389 }
5390 for (phase_name, phase) in &workload.phases {
5391 if let BindingsDef::PolydatSource(s) = &phase.bindings {
5392 push_refs(&format!("phase '{phase_name}' bindings"), s);
5393 }
5394 for op in &phase.ops {
5395 if let BindingsDef::PolydatSource(s) = &op.bindings {
5396 push_refs(&format!("phase '{phase_name}' op-bindings"), s);
5397 }
5398 }
5399 }
5400 out
5401}
5402
5403/// Scan the raw YAML source for the first line that contains
5404/// the given placeholder text (with surrounding braces).
5405/// Returns the 1-based line number when found, `None` otherwise.
5406///
5407/// Used to upgrade the GK-brace validator's error message from
5408/// "phase 'foo' bindings" to a file:line locator the operator
5409/// can click on or jump to. Works because YAML block scalars
5410/// (`|` / `>`) preserve the body verbatim; the offending
5411/// `{name}` substring appears in the file exactly as the
5412/// validator captured it.
5413///
5414/// Falls back to `None` on the rare cases where the literal
5415/// also appears in an unrelated comment or string — the
5416/// validator's caller treats that as "give the location but
5417/// no line number." The substring is namespaced enough
5418/// (`{name}` with curly braces) that collisions are unlikely
5419/// in practice.
5420fn find_yaml_line_for_brace(yaml_source: &str, placeholder: &str) -> Option<usize> {
5421 let needle = format!("{{{placeholder}}}");
5422 yaml_source
5423 .lines()
5424 .enumerate()
5425 .find(|(_, line)| line.contains(&needle))
5426 .map(|(idx, _)| idx + 1)
5427}
5428
5429/// Scan Polydat source for `{name}` placeholders that appear OUTSIDE
5430/// string literals and OUTSIDE comments. Inside `"..."` or
5431/// `'...'` (with backslash escapes), `{...}` is part of the
5432/// string and gets handled by runtime interpolation — leave it
5433/// alone. Lines starting with `#` (and content after `#` on any
5434/// line) are comments — also skipped.
5435fn scan_polydat_braced_refs(source: &str) -> Vec<String> {
5436 let bytes = source.as_bytes();
5437 let mut out: Vec<String> = Vec::new();
5438 let mut i = 0;
5439 while i < bytes.len() {
5440 let b = bytes[i];
5441 // Line comment — skip to next newline.
5442 if b == b'#' {
5443 while i < bytes.len() && bytes[i] != b'\n' {
5444 i += 1;
5445 }
5446 continue;
5447 }
5448 // String literal — skip past the closing quote.
5449 if b == b'"' || b == b'\'' {
5450 let quote = b;
5451 i += 1;
5452 while i < bytes.len() {
5453 if bytes[i] == b'\\' && i + 1 < bytes.len() {
5454 i += 2;
5455 continue;
5456 }
5457 if bytes[i] == quote {
5458 i += 1;
5459 break;
5460 }
5461 i += 1;
5462 }
5463 continue;
5464 }
5465 // Outside any string — `{` opens a brace placeholder.
5466 if b == b'{' {
5467 // Find the matching `}`, allowing one level of
5468 // nesting for composite forms like `{k_{k}_limits}`.
5469 // We only need the OUTER body for the error message
5470 // — the inner placeholders are also wrong but the
5471 // outer is what the operator sees.
5472 let start = i + 1;
5473 let mut depth = 1;
5474 let mut j = start;
5475 while j < bytes.len() && depth > 0 {
5476 match bytes[j] {
5477 b'{' => depth += 1,
5478 b'}' => depth -= 1,
5479 _ => {}
5480 }
5481 if depth == 0 {
5482 break;
5483 }
5484 j += 1;
5485 }
5486 if depth == 0 && j > start {
5487 let body = &source[start..j];
5488 // Trim whitespace; ignore obviously-empty bodies.
5489 let trimmed = body.trim();
5490 if !trimmed.is_empty() {
5491 out.push(trimmed.to_string());
5492 }
5493 i = j + 1;
5494 continue;
5495 }
5496 // Unmatched `{` — let the Polydat parser handle it with
5497 // its own error; don't double-report.
5498 break;
5499 }
5500 i += 1;
5501 }
5502 out
5503}
5504
5505/// Line-by-line scan of Polydat source for locally-bound names —
5506/// `cursor NAME = …`, `shared NAME := …`, `const NAME := …`,
5507/// `volatile NAME := …`, bare `NAME := …` assignments, and
5508/// `input NAME[: TYPE]` / `input (NAME[: TYPE], ...)` declarations.
5509/// Skips comments and blank lines. Lines that don't match any of
5510/// these shapes (function-call statements, comments, expression
5511/// continuations) are ignored.
5512fn scan_polydat_binding_lhs(out: &mut std::collections::HashSet<String>, source: &str) {
5513 for raw_line in source.lines() {
5514 let line = raw_line.trim();
5515 if line.is_empty() || line.starts_with('#') {
5516 continue;
5517 }
5518 // `input` declarations: collect declared slot names without
5519 // requiring an `=` / `:=` suffix. Both bare (`input cycle: u64`)
5520 // and tuple (`input (cycle: u64, q: f64)`) forms are handled.
5521 if let Some(rest) = line.strip_prefix("input ") {
5522 scan_input_decl_names(out, rest.trim());
5523 continue;
5524 }
5525 // `extern` declarations (`extern name: type [= default]`,
5526 // `extern (a: u64, b: f64)`) declare wire names just like
5527 // `input` — same `name: type` shape. Without this, a
5528 // `{name}` placeholder referencing an extern-declared wire
5529 // (e.g. a same-op capture target) trips the
5530 // undeclared-placeholder guard.
5531 if let Some(rest) = line.strip_prefix("extern ") {
5532 scan_input_decl_names(out, rest.trim());
5533 continue;
5534 }
5535 // Strip leading modifier (`const `, `cursor `, `shared `,
5536 // `volatile `); body is what follows. Modifiers can be
5537 // combined in a few cases (e.g. `shared const`,
5538 // `shared volatile`), so loop until no recognised prefix
5539 // remains.
5540 let mut body = line;
5541 loop {
5542 let stripped = body
5543 .strip_prefix("const ")
5544 .or_else(|| body.strip_prefix("cursor "))
5545 .or_else(|| body.strip_prefix("shared "))
5546 .or_else(|| body.strip_prefix("volatile "));
5547 match stripped {
5548 Some(rest) => body = rest,
5549 None => break,
5550 }
5551 }
5552 // Tuple-destructure LHS: `(a, b, c) := <expr>`. Each
5553 // identifier inside the parens becomes a separate
5554 // declared name. Used by multi-output stdlib nodes
5555 // (e.g. `(y, mo, d, h, mi, s, ms) := date_components(0)`)
5556 // and any other binding that unpacks multiple outputs.
5557 if body.starts_with('(') {
5558 if let Some(close) = body.find(')') {
5559 let after = body[close + 1..].trim_start();
5560 if after.starts_with(":=") || after.starts_with('=') {
5561 for raw in body[1..close].split(',') {
5562 let name = raw.trim();
5563 if !name.is_empty() {
5564 out.insert(name.to_string());
5565 }
5566 }
5567 }
5568 }
5569 continue;
5570 }
5571 // Pull the leading identifier.
5572 let bytes = body.as_bytes();
5573 let mut i = 0;
5574 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
5575 i += 1;
5576 }
5577 if i == 0 {
5578 continue;
5579 }
5580 // What follows the identifier? Skip whitespace.
5581 let mut j = i;
5582 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
5583 j += 1;
5584 }
5585 // Must be `=` (init/cursor) or `:=` (assignment); reject
5586 // anything else (function calls, expressions starting
5587 // with an ident, etc.).
5588 let is_binding = bytes.get(j) == Some(&b'=')
5589 || (bytes.get(j) == Some(&b':') && bytes.get(j + 1) == Some(&b'='));
5590 if is_binding {
5591 out.insert(body[..i].to_string());
5592 continue;
5593 }
5594 // Typed cell form: `name: type := default` (e.g.
5595 // `shared sstables: u64 := 0`). The `:` here is a type
5596 // annotation, not `:=` — skip the type token and accept
5597 // iff an assignment follows it. Without this arm, typed
5598 // shared/volatile declarations were invisible to the
5599 // undeclared-placeholder validator and any `{name}`
5600 // reference to one tripped a false positive.
5601 if bytes.get(j) == Some(&b':') {
5602 let rest = body[j + 1..].trim_start();
5603 let te = rest
5604 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
5605 .unwrap_or(rest.len());
5606 if te > 0 {
5607 let after_ty = rest[te..].trim_start();
5608 if after_ty.starts_with(":=") || after_ty.starts_with('=') {
5609 out.insert(body[..i].to_string());
5610 }
5611 }
5612 }
5613 }
5614}
5615
5616/// Parse the name(s) out of an `input` declaration body (the text
5617/// after the `input ` keyword has been stripped).
5618///
5619/// Accepts both surface forms:
5620/// - `cycle` / `cycle: u64` → inserts `cycle`
5621/// - `(a: u64, b: f64, ...)` → inserts each declared name
5622///
5623/// Mirrors `parse_input_decl` in the Polydat parser; this is a
5624/// lightweight scanner used by scope-elision to register
5625/// locally-bound names without re-running the full lexer/parser.
5626fn scan_input_decl_names(out: &mut std::collections::HashSet<String>, body: &str) {
5627 let body = body.trim();
5628 if let Some(inner) = body.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
5629 for part in inner.split(',') {
5630 let name = part.trim().split(':').next().unwrap_or("").trim();
5631 if !name.is_empty() {
5632 out.insert(name.to_string());
5633 }
5634 }
5635 return;
5636 }
5637 let name = body.split(':').next().unwrap_or("").trim();
5638 if !name.is_empty() {
5639 out.insert(name.to_string());
5640 }
5641}
5642
5643/// Resolve a scenario name to a list of phase names.
5644fn resolve_scenario(
5645 scenarios: &HashMap<String, Vec<nmbrs_workload::model::ScenarioNode>>,
5646 phase_order: &[String],
5647 name: &str,
5648) -> Result<Vec<nmbrs_workload::model::ScenarioNode>, String> {
5649 if let Some(nodes) = scenarios.get(name) {
5650 return Ok(nodes.clone());
5651 }
5652 if name == "default" && !phase_order.is_empty() {
5653 return Ok(phase_order
5654 .iter()
5655 .map(|n| nmbrs_workload::model::ScenarioNode::Phase(n.clone()))
5656 .collect());
5657 }
5658 Err(format!("scenario '{name}' not found"))
5659}
5660
5661/// Format a scenario tree for display — one construct per line,
5662/// nested with two-space indent per level. Phases include their
5663/// declared `cycles:` and `concurrency:` config when available
5664/// from the workload's phase map so the operator can see the
5665/// run shape at a glance.
5666///
5667/// Example output for the full_cql_vector fulltest scenario:
5668///
5669/// ```text
5670/// scenario 'test_oracles'
5671/// for_each profile in matching_profiles('{dataset}', '{oracles_prefix}')
5672/// for_each table in vec_{profile}
5673/// teardown (cycles: 1, concurrency: 1)
5674/// schema (cycles: 1, concurrency: 1)
5675/// rampup
5676/// jolokia_flush
5677/// for_combinations [k, limit] in {k_values}, {k_{k}_limits}
5678/// ann_query (concurrency: {query_concurrency})
5679/// scenario 'test_fknn'
5680/// ...
5681/// ```
5682///
5683/// The replaced LISP-shaped one-liner had bracket nesting that
5684/// scaled badly past two levels and required mental parsing to
5685/// see the loop structure.
5686fn format_scenario_tree(
5687 nodes: &[nmbrs_workload::model::ScenarioNode],
5688 phases: &std::collections::HashMap<String, nmbrs_workload::model::WorkloadPhase>,
5689) -> String {
5690 let mut out = String::new();
5691 format_scenario_nodes(nodes, phases, 0, &mut out);
5692 // Trim trailing newline so the runner's log call doesn't
5693 // emit a double-blank line.
5694 if out.ends_with('\n') {
5695 out.pop();
5696 }
5697 out
5698}
5699
5700/// Render a multi-coord comprehension's `[vars] in [specs]`
5701/// in two column-aligned lines. Column `i` is padded to the
5702/// widest of `vars[i]` and `specs[i]` so corresponding entries
5703/// stack vertically:
5704///
5705/// ```text
5706/// for [sm, mnc, bw, eh, alf_label]
5707/// in [{sm_values}, {mnc_values}, {bw_values}, {eh_values}, concat({alf_label_values})]
5708/// ```
5709///
5710/// `for ` and ` in ` are 4 chars (padding `in` with a leading
5711/// space) so the `[` brackets and every column thereafter
5712/// share the same vertical line. Color highlights the
5713/// keywords when the active terminal supports it; on a
5714/// piped/no-color stderr the output stays plain.
5715///
5716/// `indent_prefix` is the per-depth indent at the call site
5717/// — applied to the second line so it sits at the same depth
5718/// as the first.
5719fn format_for_combinations(pairs: &[(String, String)], indent_prefix: &str, color: bool) -> String {
5720 let kw_open = if color { "\x1b[1;36m" } else { "" };
5721 let kw_close = if color { "\x1b[0m" } else { "" };
5722 let bracket_open = if color { "\x1b[2m" } else { "" };
5723 let bracket_close = if color { "\x1b[0m" } else { "" };
5724
5725 let widths: Vec<usize> = pairs
5726 .iter()
5727 .map(|(v, s)| v.chars().count().max(s.chars().count()))
5728 .collect();
5729
5730 let pad = |entry: &str, idx: usize, last: bool| -> String {
5731 // Last column gets no trailing comma + no padding —
5732 // the closing `]` lands flush against the final token.
5733 if last {
5734 entry.to_string()
5735 } else {
5736 // `<entry>,` then pad to `widths[idx] + 1` so the
5737 // next column begins at a constant offset.
5738 let with_comma = format!("{entry},");
5739 let width_target = widths[idx] + 1; // +1 for the comma
5740 let visible = with_comma.chars().count();
5741 if visible >= width_target {
5742 with_comma
5743 } else {
5744 format!("{with_comma}{:<pad$}", "", pad = width_target - visible)
5745 }
5746 }
5747 };
5748
5749 let last_idx = pairs.len().saturating_sub(1);
5750 let vars_line: String = pairs
5751 .iter()
5752 .enumerate()
5753 .map(|(i, (v, _))| pad(v, i, i == last_idx))
5754 .collect::<Vec<_>>()
5755 .join(" ");
5756 let specs_line: String = pairs
5757 .iter()
5758 .enumerate()
5759 .map(|(i, (_, s))| pad(s, i, i == last_idx))
5760 .collect::<Vec<_>>()
5761 .join(" ");
5762
5763 format!(
5764 "{kw_open}for{kw_close} {bracket_open}[{bracket_close}{vars_line}{bracket_open}]{bracket_close}\n\
5765 {indent_prefix} {kw_open}in{kw_close} {bracket_open}[{bracket_close}{specs_line}{bracket_open}]{bracket_close}"
5766 )
5767}
5768
5769fn format_scenario_nodes(
5770 nodes: &[nmbrs_workload::model::ScenarioNode],
5771 phases: &std::collections::HashMap<String, nmbrs_workload::model::WorkloadPhase>,
5772 depth: usize,
5773 out: &mut String,
5774) {
5775 use nmbrs_workload::model::ScenarioNode::*;
5776 let indent = " ".repeat(depth);
5777 for node in nodes {
5778 match node {
5779 Phase(name) => {
5780 let suffix = phases
5781 .get(name)
5782 .map(format_phase_config_suffix)
5783 .unwrap_or_default();
5784 if suffix.is_empty() {
5785 out.push_str(&format!("{indent}{name}\n"));
5786 } else {
5787 // Pad name to a fixed column so the
5788 // `(cycles:..., concurrency:...)` chip lines
5789 // up across consecutive phases. 32 chars
5790 // covers the typical phase names; longer
5791 // names just push past the column without
5792 // breaking layout.
5793 out.push_str(&format!("{indent}{name:<32} {suffix}\n",));
5794 }
5795 }
5796 Comprehension {
5797 comprehension,
5798 children,
5799 ..
5800 } => {
5801 // Algebra-native display: walk the AST once to
5802 // detect Union vs flat, then format. Matches
5803 // the scope_tree::label_for_comprehension shape
5804 // at one level of detail finer (includes the
5805 // spec_expr for non-Union shapes).
5806 use polydat::iteration::comprehension::Comprehension as Comp;
5807 // Peel outer Order/Filter for structural detection.
5808 let mut body = comprehension;
5809 while let Comp::Order { child, .. } | Comp::Filter { child, .. } = body {
5810 body = child;
5811 }
5812 let header = match body {
5813 Comp::Union { children } => {
5814 let names = comprehension.coordinate_names().join(", ");
5815 format!("for_each_union [{}] ({} sub-spaces)", names, children.len())
5816 }
5817 _ => {
5818 let pairs = comprehension.coordinate_specs();
5819 if pairs.len() == 1 {
5820 let (var, spec) = &pairs[0];
5821 format!("for_each {var} in {spec}")
5822 } else {
5823 // Two-line column-aligned form for
5824 // multi-coord comprehensions: variable
5825 // names on the first line, source
5826 // expressions on the second, each
5827 // column padded to its widest
5828 // (var, spec) pair so the columns
5829 // line up vertically. Keywords
5830 // `for` / ` in` are right-aligned
5831 // so the `[` brackets land in the
5832 // same column.
5833 format_for_combinations(&pairs, &indent, crate::observer::use_color())
5834 }
5835 }
5836 };
5837 out.push_str(&format!("{indent}{header}\n"));
5838 format_scenario_nodes(children, phases, depth + 1, out);
5839 }
5840 DoWhile {
5841 condition,
5842 counter,
5843 children,
5844 } => {
5845 let ctr = counter
5846 .as_deref()
5847 .map(|c| format!(" (counter={c})"))
5848 .unwrap_or_default();
5849 out.push_str(&format!("{indent}do_while '{condition}'{ctr}\n"));
5850 format_scenario_nodes(children, phases, depth + 1, out);
5851 }
5852 DoUntil {
5853 condition,
5854 counter,
5855 children,
5856 } => {
5857 let ctr = counter
5858 .as_deref()
5859 .map(|c| format!(" (counter={c})"))
5860 .unwrap_or_default();
5861 out.push_str(&format!("{indent}do_until '{condition}'{ctr}\n"));
5862 format_scenario_nodes(children, phases, depth + 1, out);
5863 }
5864 IncludedScenario { name, children } => {
5865 out.push_str(&format!("{indent}scenario '{name}'\n"));
5866 format_scenario_nodes(children, phases, depth + 1, out);
5867 }
5868 Bindings { source, children } => {
5869 // First non-empty line of the source as a one-
5870 // line summary in the scenario-tree dump. Long
5871 // bodies stay readable in the YAML; the
5872 // hierarchical view just teases the binding.
5873 let summary = source
5874 .lines()
5875 .map(str::trim)
5876 .find(|l| !l.is_empty())
5877 .unwrap_or("");
5878 if source.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
5879 out.push_str(&format!("{indent}bindings: {summary} …\n"));
5880 } else {
5881 out.push_str(&format!("{indent}bindings: {summary}\n"));
5882 }
5883 format_scenario_nodes(children, phases, depth + 1, out);
5884 }
5885 }
5886 }
5887}
5888
5889/// Render the `(cycles: X, concurrency: Y)` suffix for a phase
5890/// line in the scenario-tree summary. Includes only the fields
5891/// that the phase actually declared — phases that inherit the
5892/// runtime defaults skip the chip entirely so the tree line
5893/// stays uncluttered. Strings are shown verbatim (including
5894/// `{name}` placeholders) so the operator sees the workload's
5895/// declared intent rather than a runtime-evaluated number.
5896fn format_phase_config_suffix(phase: &nmbrs_workload::model::WorkloadPhase) -> String {
5897 let mut parts: Vec<String> = Vec::new();
5898 if let Some(c) = phase.cycles.as_deref()
5899 && !c.is_empty()
5900 {
5901 parts.push(format!("cycles: {c}"));
5902 }
5903 if let Some(c) = phase.concurrency.as_deref()
5904 && !c.is_empty()
5905 {
5906 parts.push(format!("concurrency: {c}"));
5907 }
5908 if parts.is_empty() {
5909 String::new()
5910 } else {
5911 format!("({})", parts.join(", "))
5912 }
5913}
5914
5915/// SRD-108 Part B — load a secondary workload document (an
5916/// `implements:` target, or the `impl=` module) through the same
5917/// resolution the primary `workload=` uses: local file first,
5918/// then the bundled catalog. Returns the parsed workload plus its
5919/// canonical identity string (canonicalized path, or catalog
5920/// name) for target-matching.
5921fn load_secondary_workload(
5922 reference: &str,
5923 params: &HashMap<String, String>,
5924 base_dir: Option<&std::path::Path>,
5925 bundled_origin: Option<&str>,
5926) -> Result<(nmbrs_workload::model::Workload, String), String> {
5927 match resolve_secondary_ref(reference, base_dir, bundled_origin)? {
5928 ResolvedWorkload::Path(path) => {
5929 let workload = nmbrs_workload::parse::parse_workload_from_path(
5930 std::path::Path::new(&path),
5931 params,
5932 )
5933 .map_err(|e| format!("parse workload '{path}': {e}"))?;
5934 Ok((workload, canonical_identity(&path)))
5935 }
5936 ResolvedWorkload::Bundled(bundled) => {
5937 let (merged, res_warnings) =
5938 nmbrs_workload::extends::load_and_merge_bundled(bundled)
5939 .map_err(|e| format!("bundled workload `{}`: {e}", bundled.name))?;
5940 let mut workload = nmbrs_workload::parse::parse_workload(&merged, params)
5941 .map_err(|e| format!("parse bundled workload `{}`: {e}", bundled.name))?;
5942 workload.resolution_warnings.extend(res_warnings);
5943 Ok((workload, bundled.name.to_string()))
5944 }
5945 }
5946}
5947
5948/// Resolve a workload reference to its canonical identity WITHOUT
5949/// parsing it — used to compare an `implements:` target against
5950/// the invoked `workload=`.
5951fn workload_ref_identity(
5952 reference: &str,
5953 base_dir: Option<&std::path::Path>,
5954 bundled_origin: Option<&str>,
5955) -> Result<String, String> {
5956 match resolve_secondary_ref(reference, base_dir, bundled_origin)? {
5957 ResolvedWorkload::Path(path) => Ok(canonical_identity(&path)),
5958 ResolvedWorkload::Bundled(bundled) => Ok(bundled.name.to_string()),
5959 }
5960}
5961
5962/// SRD-109 Part 2 — resolve a driver manifest by name: local
5963/// `drivers/<name>/driver.yaml` under the cwd first, then the
5964/// bundled catalog entry `drivers/<name>/driver`. Both at once
5965/// FAVORS the local manifest (SRD-85 nearest-first) with a
5966/// logged warning naming both — shadowing is allowed but never
5967/// silent. Returns the manifest plus the resolved LIBRARY
5968/// reference: a file path for a local manifest, a catalog name
5969/// for a bundled one. `None` when neither exists — the caller
5970/// falls back to the legacy driver-as-adapter-alias meaning.
5971fn resolve_driver_manifest(
5972 name: &str,
5973) -> Result<Option<(nmbrs_workload::drivers::DriverManifest, String)>, String> {
5974 let local_path = std::path::Path::new("drivers")
5975 .join(name)
5976 .join("driver.yaml");
5977 let catalog_name = format!("drivers/{name}/driver");
5978 let bundled = nmbrs_workload::catalog::lookup(&catalog_name);
5979 match (local_path.is_file(), bundled) {
5980 (true, Some(_)) => {
5981 crate::observer::log_tagged(
5982 crate::observer::LogLevel::Warn,
5983 crate::observer::EventTag::in_flight(crate::observer::EventCategory::Resolution),
5984 &format!(
5985 "resolve: driver '{name}' matches multiple resources — local \
5986 manifest {} AND bundled driver `{catalog_name}` — using the \
5987 local manifest (filesystem-first). Same-named resources in \
5988 multiple places invite confusion: prefer a unique name.",
5989 local_path.display()
5990 ),
5991 );
5992 let source = std::fs::read_to_string(&local_path)
5993 .map_err(|e| format!("read driver manifest {}: {e}", local_path.display()))?;
5994 let manifest = nmbrs_workload::drivers::parse_driver_manifest(
5995 &source,
5996 &local_path.display().to_string(),
5997 )?;
5998 verify_driver_identity(&manifest, name)?;
5999 let dir = local_path.parent().expect("manifest path has a parent");
6000 let library_ref = resolve_driver_library_local(dir, &manifest.library)?;
6001 Ok(Some((manifest, library_ref)))
6002 }
6003 (true, None) => {
6004 let source = std::fs::read_to_string(&local_path)
6005 .map_err(|e| format!("read driver manifest {}: {e}", local_path.display()))?;
6006 let manifest = nmbrs_workload::drivers::parse_driver_manifest(
6007 &source,
6008 &local_path.display().to_string(),
6009 )?;
6010 verify_driver_identity(&manifest, name)?;
6011 let dir = local_path.parent().expect("manifest path has a parent");
6012 let library_ref = resolve_driver_library_local(dir, &manifest.library)?;
6013 Ok(Some((manifest, library_ref)))
6014 }
6015 (false, Some(entry)) => {
6016 let manifest =
6017 nmbrs_workload::drivers::parse_driver_manifest(entry.source, &catalog_name)?;
6018 verify_driver_identity(&manifest, name)?;
6019 let stem = manifest
6020 .library
6021 .strip_suffix(".yaml")
6022 .or_else(|| manifest.library.strip_suffix(".yml"))
6023 .unwrap_or(&manifest.library);
6024 let library_ref = format!("drivers/{name}/{stem}");
6025 Ok(Some((manifest, library_ref)))
6026 }
6027 (false, None) => Ok(None),
6028 }
6029}
6030
6031/// A manifest must be named for the directory it lives in — a
6032/// mismatch is a packaging bug surfaced at load, not a silent
6033/// re-route.
6034fn verify_driver_identity(
6035 manifest: &nmbrs_workload::drivers::DriverManifest,
6036 name: &str,
6037) -> Result<(), String> {
6038 if manifest.driver != name {
6039 return Err(format!(
6040 "driver manifest for '{name}' declares `driver: {}` — the \
6041 manifest must be named for its directory",
6042 manifest.driver
6043 ));
6044 }
6045 Ok(())
6046}
6047
6048/// Resolve a local manifest's `library:` reference beside the
6049/// manifest: as written first, then with `.yaml` appended.
6050fn resolve_driver_library_local(dir: &std::path::Path, library: &str) -> Result<String, String> {
6051 let as_written = dir.join(library);
6052 if as_written.is_file() {
6053 return Ok(as_written.display().to_string());
6054 }
6055 let with_ext = dir.join(format!("{library}.yaml"));
6056 if with_ext.is_file() {
6057 return Ok(with_ext.display().to_string());
6058 }
6059 Err(format!(
6060 "driver manifest {}: library '{library}' not found beside the manifest",
6061 dir.display()
6062 ))
6063}
6064
6065/// Apply a resolved driver manifest to the invocation params:
6066/// the library lands as `workload=` (no workload given) or
6067/// `impl=` (a blueprint was invoked); the backing adapter and the
6068/// manifest's default params fill only ABSENT keys, so the CLI
6069/// always wins and the defaults still overlay the library's own
6070/// declared params (this map is the CLI overlay layer).
6071fn apply_driver_manifest(
6072 params: &mut HashMap<String, String>,
6073 driver_name: &str,
6074 manifest: nmbrs_workload::drivers::DriverManifest,
6075 library_ref: String,
6076 workload_given: bool,
6077) -> Result<(), String> {
6078 if params.contains_key("impl") {
6079 return Err(format!(
6080 "driver={driver_name} supplies the implementation library \
6081 ('{}') — impl= conflicts; pass one or the other",
6082 manifest.library
6083 ));
6084 }
6085 if workload_given {
6086 params.insert("impl".into(), library_ref.clone());
6087 } else {
6088 params.insert("workload".into(), library_ref.clone());
6089 }
6090 params
6091 .entry("adapter".to_string())
6092 .or_insert_with(|| manifest.adapter.clone());
6093 for (k, v) in &manifest.default_params {
6094 params.entry(k.clone()).or_insert_with(|| v.clone());
6095 }
6096 crate::diag!(
6097 crate::observer::LogLevel::Info,
6098 "driver: {driver_name} → adapter={}, library={library_ref}",
6099 manifest.adapter
6100 );
6101 Ok(())
6102}
6103
6104/// Resolve a secondary workload reference the way `extends:`
6105/// targets resolve: relative to the REFERRING DOCUMENT's
6106/// directory first (when one exists on disk), then the standard
6107/// cwd-local + bundled-catalog path. Without the base-dir leg, an
6108/// `implements: ./blueprint.yaml` inside a file would only resolve
6109/// when the invoking cwd happens to be the file's directory.
6110fn resolve_secondary_ref(
6111 reference: &str,
6112 base_dir: Option<&std::path::Path>,
6113 bundled_origin: Option<&str>,
6114) -> Result<ResolvedWorkload, String> {
6115 // SRD-85 nearest-first: every candidate is enumerated and the
6116 // nearest wins — the logical filesystem location is favored
6117 // over the embedded catalog BY DEFAULT (a fresh checkout must
6118 // never be silently shadowed by a stale binary's catalog).
6119 // Multiple matches are a warnable condition, logged with every
6120 // candidate named; never a hard error and never silent.
6121 let pinned = reference.starts_with("./") || reference.starts_with("../");
6122
6123 // 1. The referring document's own directory (nearest).
6124 let origin_file: Option<String> = base_dir
6125 .map(|dir| dir.join(reference))
6126 .filter(|c| c.is_file())
6127 .map(|c| c.display().to_string());
6128
6129 // A `./`-pinned reference that resolves beside its referring
6130 // FILE is explicit — no enumeration, no warning.
6131 if pinned && base_dir.is_some() && origin_file.is_some() {
6132 return Ok(ResolvedWorkload::Path(origin_file.unwrap()));
6133 }
6134
6135 // 2. The cwd's logical layout (exact path, extension probing,
6136 // cwd `workloads/`).
6137 let cwd_file: Option<String> = resolve_workload_file(reference).filter(|p| {
6138 // Dedupe against the origin-dir candidate.
6139 origin_file.as_deref().map(canonical_identity) != Some(canonical_identity(p))
6140 });
6141
6142 // 3. The bundled catalog: exact name, then the sibling idiom —
6143 // the referring bundled document's namespace — then the bare
6144 // stem (extension and `./` stripped: files reference siblings
6145 // by filename, catalog names carry none).
6146 let stem = reference
6147 .strip_suffix(".yaml")
6148 .or_else(|| reference.strip_suffix(".yml"))
6149 .unwrap_or(reference);
6150 let stem = stem.strip_prefix("./").unwrap_or(stem);
6151 let ns_stem = bundled_origin
6152 .and_then(|o| o.rsplit_once('/'))
6153 .map(|(ns, _)| format!("{ns}/{stem}"));
6154 let bundled: Option<&'static nmbrs_workload::catalog::BundledWorkload> =
6155 nmbrs_workload::catalog::lookup(reference)
6156 .or_else(|| ns_stem.as_deref().and_then(nmbrs_workload::catalog::lookup))
6157 .or_else(|| nmbrs_workload::catalog::lookup(stem));
6158
6159 let mut names: Vec<String> = Vec::new();
6160 if let Some(p) = &origin_file {
6161 names.push(format!("file {p} (beside the referring document)"));
6162 }
6163 if let Some(p) = &cwd_file {
6164 names.push(format!("local file {p}"));
6165 }
6166 if let Some(b) = bundled {
6167 names.push(format!("bundled workload `{}`", b.name));
6168 }
6169
6170 if names.len() > 1 {
6171 crate::observer::log_tagged(
6172 crate::observer::LogLevel::Warn,
6173 crate::observer::EventTag::in_flight(crate::observer::EventCategory::Resolution),
6174 &format!(
6175 "resolve: reference '{reference}' matches multiple resources — {} — \
6176 using the nearest ({}). Same-named resources in multiple places \
6177 invite confusion: prefer a unique name, or pin the intent with a \
6178 `./` path / full catalog name.",
6179 names.join(" AND "),
6180 names[0]
6181 ),
6182 );
6183 }
6184
6185 if let Some(p) = origin_file {
6186 return Ok(ResolvedWorkload::Path(p));
6187 }
6188 if let Some(p) = cwd_file {
6189 return Ok(ResolvedWorkload::Path(p));
6190 }
6191 if let Some(b) = bundled {
6192 return Ok(ResolvedWorkload::Bundled(b));
6193 }
6194 Err(format!(
6195 "workload not found: '{reference}'. Not a local file, and no bundled \
6196 workload by that name — `nmbrs describe workloads` lists what this \
6197 binary carries.{}",
6198 nmbrs_workload::suggest::did_you_mean(&nmbrs_workload::suggest::suggest_workloads(
6199 reference
6200 )),
6201 ))
6202}
6203
6204/// Canonicalize a workload file path for identity comparison;
6205/// bundled names pass through unchanged (they contain no path
6206/// separators that resolve).
6207fn canonical_identity(reference: &str) -> String {
6208 std::fs::canonicalize(reference)
6209 .map(|p| p.display().to_string())
6210 .unwrap_or_else(|_| reference.to_string())
6211}
6212
6213/// SRD-106 Part 3 — light pre-read of the workload's merged YAML
6214/// for the top-level `stick_session:` flag. Runs BEFORE session
6215/// construction (the full workload parse happens after the session
6216/// exists, so it cannot inform session resolution). Resolution
6217/// mirrors the execution path — local file first, then the bundled
6218/// catalog — and `extends:` chains merge before the peek, so a
6219/// suite base declaring `stick_session: true` reaches derived
6220/// workloads. Any failure answers `None`; the full parse surfaces
6221/// the real error with proper context.
6222fn peek_stick_session(params: &HashMap<String, String>, args: &[String]) -> Option<bool> {
6223 if params.contains_key("op") {
6224 return None; // inline workloads carry no header
6225 }
6226 let workload_raw = params.get("workload").cloned().or_else(|| {
6227 args.iter()
6228 .find(|a| a.ends_with(".yaml") || a.ends_with(".yml"))
6229 .cloned()
6230 })?;
6231 // Pre-probe only (stick_session peek): resolution warnings
6232 // are dropped here — the authoritative load that follows
6233 // surfaces the identical warnings itself.
6234 let merged = match resolve_workload(&workload_raw).ok()? {
6235 ResolvedWorkload::Path(p) => {
6236 nmbrs_workload::extends::load_and_merge(std::path::Path::new(&p))
6237 .ok()?
6238 .0
6239 }
6240 ResolvedWorkload::Bundled(b) => nmbrs_workload::extends::load_and_merge_bundled(b).ok()?.0,
6241 };
6242 let doc: serde_yaml::Value = serde_yaml::from_str(&merged).ok()?;
6243 doc.get("stick_session")?.as_bool()
6244}
6245
6246/// Resolve a workload file path from a bare name.
6247/// Tries: as-is, with .yaml/.yml extension, then under workloads/.
6248/// SRD-85 resolution result: a local file path or a bundled
6249/// catalog entry.
6250pub enum ResolvedWorkload {
6251 Path(String),
6252 Bundled(&'static nmbrs_workload::catalog::BundledWorkload),
6253}
6254
6255/// Resolve a `workload=` value per SRD-85 nearest-first: local
6256/// files first (exact path, extension probing, cwd `workloads/`),
6257/// then the bundled catalog by exact name. A name that resolves
6258/// both ways FAVORS the logical filesystem location and logs a
6259/// warning naming both — shadowing is allowed but never silent.
6260/// `./`-prefixed paths pin the local reading without a warning.
6261pub fn resolve_workload(name: &str) -> Result<ResolvedWorkload, String> {
6262 let local = resolve_workload_file(name);
6263 let bundled = nmbrs_workload::catalog::lookup(name);
6264 match (local, bundled) {
6265 (Some(local_path), Some(b)) => {
6266 if !name.starts_with("./") && !name.starts_with("../") {
6267 crate::observer::log_tagged(
6268 crate::observer::LogLevel::Warn,
6269 crate::observer::EventTag::in_flight(
6270 crate::observer::EventCategory::Resolution,
6271 ),
6272 &format!(
6273 "resolve: workload '{name}' matches multiple resources — \
6274 local file {local_path} AND bundled workload `{}` — using \
6275 the local file (filesystem-first). Same-named resources in \
6276 multiple places invite confusion: prefer a unique name, or \
6277 pin the intent with a `./` path.",
6278 b.name
6279 ),
6280 );
6281 }
6282 Ok(ResolvedWorkload::Path(local_path))
6283 }
6284 (Some(local_path), None) => Ok(ResolvedWorkload::Path(local_path)),
6285 (None, Some(b)) => Ok(ResolvedWorkload::Bundled(b)),
6286 (None, None) => Err(format!(
6287 "workload not found: '{name}'. Not a local file, and no bundled \
6288 workload by that name — `nmbrs describe workloads` lists what \
6289 this binary carries.{}",
6290 nmbrs_workload::suggest::did_you_mean(&nmbrs_workload::suggest::suggest_workloads(
6291 name
6292 ),)
6293 )),
6294 }
6295}
6296
6297fn resolve_workload_file(name: &str) -> Option<String> {
6298 let p = std::path::Path::new(name);
6299 if p.exists() {
6300 return Some(name.to_string());
6301 }
6302
6303 // Already has yaml extension — no further search
6304 if name.ends_with(".yaml") || name.ends_with(".yml") {
6305 // Try under workloads/
6306 let under = format!("workloads/{name}");
6307 if std::path::Path::new(&under).exists() {
6308 return Some(under);
6309 }
6310 return None;
6311 }
6312
6313 // Try adding extensions
6314 for ext in [".yaml", ".yml"] {
6315 let with_ext = format!("{name}{ext}");
6316 if std::path::Path::new(&with_ext).exists() {
6317 return Some(with_ext);
6318 }
6319 }
6320
6321 // Try under workloads/
6322 for ext in ["", ".yaml", ".yml"] {
6323 let under = format!("workloads/{name}{ext}");
6324 if std::path::Path::new(&under).exists() {
6325 return Some(under);
6326 }
6327 }
6328
6329 None
6330}
6331
6332/// Normalize args: detect scenario shorthand where a bare word after
6333/// the workload file becomes `scenario=<name>`.
6334///
6335/// The auto-promotion has to skip the **values** of space-form
6336/// flags (`--session-path X`, `--readout Y`, etc.) — otherwise
6337/// the path or value gets misread as a scenario name and ends up
6338/// as `scenario=<path>`, which downstream code then materialises
6339/// as a literal directory at `<cwd>/scenario=<path>` (the
6340/// orphaned-dir bug we hit earlier). Use the same list
6341/// [`parse_params`] uses so the two surfaces agree on which
6342/// flags consume their next token.
6343pub fn normalize_args(args: &[String]) -> Vec<String> {
6344 // Spec-derived value-taking flags (installed at startup); the
6345 // session-flag fallback applies for library/test drivers.
6346 let value_flags = known_value_flags();
6347
6348 let mut result = Vec::new();
6349 let mut workload_seen = false;
6350 let mut scenario_set = false;
6351 let mut iter = args.iter().peekable();
6352 while let Some(arg) = iter.next() {
6353 // Pass through space-form flag + its value as a unit.
6354 // Equals-form (`--session-path=X`) is one token and
6355 // skips this branch.
6356 if value_flags.iter().any(|f| *f == arg) {
6357 result.push(arg.clone());
6358 if let Some(next) = iter.next() {
6359 result.push(next.clone());
6360 }
6361 continue;
6362 }
6363 if !workload_seen
6364 && (arg.ends_with(".yaml") || arg.ends_with(".yml") || arg.contains("workload="))
6365 {
6366 workload_seen = true;
6367 result.push(arg.clone());
6368 } else if workload_seen && !scenario_set && !arg.contains('=') && !arg.starts_with('-') {
6369 result.push(format!("scenario={arg}"));
6370 scenario_set = true;
6371 } else {
6372 result.push(arg.clone());
6373 }
6374 }
6375 result
6376}
6377
6378/// Bare flags accepted by the runner — these don't follow the
6379/// `key=value` shape but are otherwise recognized. Centralized
6380/// here so [`parse_params`] doesn't reject them and any consumer
6381/// can re-check the raw `args` for them.
6382const RECOGNIZED_BARE_FLAGS: &[&str] = &[
6383 "--strict", // SRD-15 strict-mode toggle.
6384 "--resume-latest", // SRD-44: resume from logs/latest.
6385 "--force-retry-failed", // SRD-44: prepend retry,warn to errors.
6386 "--refine", // SRD-77: enable refine-mode skip-plan loading.
6387];
6388
6389/// Strip a single layer of matching outer quotes (single or
6390/// double) from a string slice. Idempotent for un-quoted input.
6391///
6392/// Per SRD 71 §"CLI parsing — quote elision": this lets wrapper
6393/// scripts forwarding `"$@"`, or `key="value"` constructions
6394/// that double-passed through a shell, parse the same as their
6395/// bare equivalents. Backtick and other quote-like characters
6396/// are deliberately not handled — they carry shell-evaluation
6397/// semantics that don't survive into our argv.
6398pub(crate) fn elide_outer_quotes(s: &str) -> &str {
6399 let bytes = s.as_bytes();
6400 if bytes.len() < 2 {
6401 return s;
6402 }
6403 let first = bytes[0];
6404 let last = bytes[bytes.len() - 1];
6405 if (first == b'\'' || first == b'"') && first == last {
6406 &s[1..s.len() - 1]
6407 } else {
6408 s
6409 }
6410}
6411
6412/// Flags consumed by `crate::session::resolve_session_dir` at startup.
6413/// They appear in raw `args` but shouldn't reach the per-key params map.
6414/// Both equals-form (`--session-dir=/path`) and space-form
6415/// (`--session-dir /path`) are recognised; the space-form value is
6416/// silently absorbed. Shared by [`parse_params`] and
6417/// [`detect_conflicting_duplicate_params`] so they treat these args
6418/// identically.
6419const SESSION_DIR_FLAGS: &[&str] = &[
6420 // Umbrella flag (kv-list).
6421 "--session",
6422 // Per-key long-form flags.
6423 "--session-name",
6424 "--session-path",
6425 "--session-reuse",
6426 "--session-keep",
6427 "--session-shelflife",
6428 // SRD-63 §8: `--readout=<body>` overrides the workload's
6429 // `on_update` binding for the run. Resolved by
6430 // `crate::session::resolve_flag` at runner-init; consumed here so
6431 // the value doesn't bleed into the workload params map.
6432 "--readout",
6433];
6434
6435/// Reject a `key=value` run param supplied more than once with
6436/// *conflicting* values. These params collapse into a map (last value
6437/// wins — see [`parse_params`]), which silently discards an earlier
6438/// value: e.g. `scenario=reset scenario=idx_sweep` drops `reset` and
6439/// runs `idx_sweep`. A repeat with an IDENTICAL value is harmless and
6440/// allowed (re-passing the same value shouldn't break a script); a
6441/// conflicting repeat is an ambiguous instruction, so it's rejected and
6442/// surfaced rather than silently last-wins ("Never Ignore Silently").
6443///
6444/// Mirrors `parse_params`'s arg walk: session-dir flags (own resolver)
6445/// and dotted phase-scoped overrides (`<phase>.<param>=`, a separate
6446/// namespace) are skipped, and the same quote elision is applied so
6447/// `scenario=x` and `scenario='x'` compare equal.
6448pub fn detect_conflicting_duplicate_params(args: &[String]) -> Result<(), String> {
6449 let mut seen: HashMap<String, String> = HashMap::new();
6450 let mut iter = args.iter().peekable();
6451 while let Some(arg) = iter.next() {
6452 // Session-dir flags: consumed by the startup hook, absorb the
6453 // space-form value so it isn't mistaken for a param.
6454 if known_value_flags()
6455 .iter()
6456 .any(|p| arg == p || arg.starts_with(&format!("{p}=")))
6457 {
6458 if !arg.contains('=') {
6459 let _consumed = iter.next();
6460 }
6461 continue;
6462 }
6463 let unquoted = elide_outer_quotes(arg.as_str());
6464 let stripped = unquoted.trim_start_matches('-');
6465 let Some(eq_pos) = stripped.find('=') else {
6466 continue;
6467 };
6468 let key = stripped[..eq_pos].to_string();
6469 // Dotted (non-path) keys are SRD-71 phase-scoped overrides — a
6470 // separate namespace — skipped here as in `parse_params`.
6471 if key.contains('.') && !key.contains('/') && !key.contains('\\') {
6472 continue;
6473 }
6474 let value = elide_outer_quotes(&stripped[eq_pos + 1..]).to_string();
6475 match seen.get(&key) {
6476 Some(prev) if *prev != value => {
6477 return Err(format!(
6478 "parameter '{key}' specified more than once with conflicting \
6479 values ('{prev}' and '{value}') — pass it exactly once"
6480 ));
6481 }
6482 Some(_) => {} // identical repeat — harmless, allow.
6483 None => {
6484 seen.insert(key, value);
6485 }
6486 }
6487 }
6488 Ok(())
6489}
6490
6491/// Parse `key=value` pairs from command line args.
6492///
6493/// Quote handling (SRD 71): if the whole arg or just the value
6494/// portion is wrapped in matching `'…'` / `"…"` quotes, those
6495/// quotes are stripped. So `cursor=0..53%`, `cursor='0..53%'`,
6496/// `cursor="0..53%"`, `'cursor=0..53%'`, and `"cursor=0..53%"`
6497/// all parse to the same `(name="cursor", value="0..53%")`
6498/// pair. The first `=` still splits name from value, so values
6499/// containing `=` retain everything after the first split.
6500pub fn parse_params(args: &[String]) -> HashMap<String, String> {
6501 let mut params = HashMap::new();
6502 let mut iter = args.iter().peekable();
6503 while let Some(arg) = iter.next() {
6504 // Session-dir flags (consumed by the startup hook,
6505 // not stored in params).
6506 if known_value_flags()
6507 .iter()
6508 .any(|p| arg == p || arg.starts_with(&format!("{p}=")))
6509 {
6510 if !arg.contains('=') {
6511 let _consumed = iter.next();
6512 }
6513 continue;
6514 }
6515
6516 // Quote elision (SRD 71): strip matching outer quotes
6517 // from the whole arg first — handles `'key=value'` and
6518 // `"key=value"` — then again from the value portion
6519 // after the `=` split — handles `key='value'` and
6520 // `key="value"`.
6521 let unquoted = elide_outer_quotes(arg.as_str());
6522 // Strip leading dashes: --dryrun=phase,wiring → dryrun=phase,wiring
6523 let stripped = unquoted.trim_start_matches('-');
6524 if let Some(eq_pos) = stripped.find('=') {
6525 let key = stripped[..eq_pos].to_string();
6526 // Dotted keys (without path separators) are SRD-71
6527 // phase-scoped overrides (`<phase-pattern>.<param>=`),
6528 // parsed by `crate::phase_params::parse_overrides` —
6529 // not workload params.
6530 if key.contains('.') && !key.contains('/') && !key.contains('\\') {
6531 continue;
6532 }
6533 let value = elide_outer_quotes(&stripped[eq_pos + 1..]).to_string();
6534 params.insert(key, value);
6535 } else if arg.ends_with(".yaml") || arg.ends_with(".yml") {
6536 // Workload file path — handled elsewhere
6537 } else if is_recognized_bare_flag(arg.as_str()) || arg.starts_with("--polydat-lib=") {
6538 // Bare runner flag — consumed elsewhere via `args`
6539 // scan (e.g. `--strict`, `--polydat-lib=path`).
6540 } else {
6541 crate::diag!(
6542 crate::observer::LogLevel::Error,
6543 "error: unrecognized argument '{arg}'. Expected key=value format."
6544 );
6545 std::process::exit(1);
6546 }
6547 }
6548 params
6549}
6550
6551/// Overlay CLI `key=value` params onto a base set, CLI winning on
6552/// conflict. The single precedence rule for the whole run — applied to
6553/// the session-tier effective params ([`effective_params`]) and to the
6554/// execution's workload params identically, so "CLI overrides the
6555/// workload" means the same thing everywhere.
6556fn overlay_cli_params(
6557 mut base: HashMap<String, String>,
6558 cli: &HashMap<String, String>,
6559) -> HashMap<String, String> {
6560 for (k, v) in cli {
6561 // Coerce the CLI value to the type already inferred for this key
6562 // (the declared default, resolved by `parse_workload`), so a
6563 // suffixed override like `max_size=10m` re-applies as a number
6564 // rather than overwriting the coerced value with raw text. Keys
6565 // with no declared default (ad-hoc CLI params) pass through.
6566 let coerced = match base.get(k) {
6567 Some(existing) => nmbrs_workload::magnitude::coerce_param_override(existing, v),
6568 None => v.clone(),
6569 };
6570 base.insert(k.clone(), coerced);
6571 }
6572 base
6573}
6574
6575/// The run's **effective parameters**: the workload's declared top-level
6576/// `params:` (extends-merged) as the base, with CLI `key=value` args
6577/// overlaid on top (CLI wins). This is the single consolidated param set —
6578/// the same one whether a setting is declared in the workload or passed on
6579/// the command line — used for session-tier services (metrics cadence,
6580/// push reporters, per-instance metrics) and console-ownership detection
6581/// alike. The per-execution path reaches the same result by overlaying CLI
6582/// onto the fully-parsed `workload.params` (see `run_execution`).
6583///
6584/// The workload reference is taken from `workload=` or a bare `.yaml`/`.yml`
6585/// positional; an unresolvable/absent workload contributes no base params
6586/// (the real load error, if any, surfaces later in the execution).
6587pub fn effective_params(args: &[String]) -> HashMap<String, String> {
6588 let cli = parse_params(args);
6589 let workload_ref = cli.get("workload").cloned().or_else(|| {
6590 args.iter()
6591 .find(|a| (a.ends_with(".yaml") || a.ends_with(".yml")) && !a.contains('='))
6592 .cloned()
6593 });
6594 let base = workload_ref
6595 .as_deref()
6596 .and_then(nmbrs_workload::verify::declared_params)
6597 .unwrap_or_default();
6598 overlay_cli_params(base, &cli)
6599}
6600
6601/// Param keys that configure the **session-tier** services — one value per
6602/// session, shared by every execution under it. Executions that declare
6603/// different values for any of these cannot share a session; a multi-
6604/// execution harness must group by them and set up one session per group
6605/// (see [`session_param_signature`]). `metrics_cadence` is the load-bearing
6606/// case: it fixes the cadence the optimizer settle detector samples, so
6607/// workloads wanting a sub-second cadence must run in their own session.
6608pub const SESSION_PARAMS: &[&str] = &["metrics_cadence"];
6609
6610/// The session-grouping signature for a workload reference: its declared
6611/// values (following `extends:`) for the [`SESSION_PARAMS`], sorted.
6612/// Workloads with equal signatures can share one session; differing ones
6613/// must not. Empty signature = "the default session is fine".
6614pub fn session_param_signature(reference: &str) -> Vec<(String, String)> {
6615 let declared = nmbrs_workload::verify::declared_params(reference).unwrap_or_default();
6616 let mut sig: Vec<(String, String)> = SESSION_PARAMS
6617 .iter()
6618 .filter_map(|k| declared.get(*k).map(|v| ((*k).to_string(), v.clone())))
6619 .collect();
6620 sig.sort();
6621 sig
6622}
6623
6624/// Resolve the metrics base interval + cadence ladder from the run's
6625/// effective params. A `metrics_cadence` param (workload or CLI, e.g.
6626/// `100ms` / `200ms`) sets the FINEST cadence — the pulse the SRD-86
6627/// optimizer settle detector samples — and the scheduler base interval, so
6628/// a windowed objective settles in a fraction of the default 1 s-cadence
6629/// wall-clock. A standard coarse ladder (1s/10s/30s/1m/5m) is layered above
6630/// it (keeping a 1 s rung bounds the fan-in from a sub-second floor). Absent
6631/// the param, this is the unchanged default: a 1 s base with the observer's
6632/// declared cadences (or [`Cadences::defaults`]).
6633fn resolve_cadence_config(
6634 params: &HashMap<String, String>,
6635 observer: &Arc<dyn crate::observer::RunObserver>,
6636) -> Result<(std::time::Duration, nmbrs_metrics::cadence::Cadences), String> {
6637 use std::time::Duration;
6638 let Some(raw) = params.get("metrics_cadence") else {
6639 let cadences = observer
6640 .cadences()
6641 .unwrap_or_else(nmbrs_metrics::cadence::Cadences::defaults);
6642 return Ok((Duration::from_secs(1), cadences));
6643 };
6644 let floor = nmbrs_metrics::cadence::parse_duration(raw).map_err(|_| {
6645 format!("metrics_cadence: invalid duration `{raw}` (use e.g. `100ms`, `200ms`, `1s`)")
6646 })?;
6647 if floor.is_zero() {
6648 return Err("metrics_cadence: must be greater than zero".to_string());
6649 }
6650 let mut layers = vec![floor];
6651 for secs in [1u64, 10, 30, 60, 300] {
6652 let d = Duration::from_secs(secs);
6653 if d > floor {
6654 layers.push(d);
6655 }
6656 }
6657 let cadences = nmbrs_metrics::cadence::Cadences::new(&layers)
6658 .map_err(|e| format!("metrics_cadence `{raw}`: cannot build a cadence ladder: {e:?}"))?;
6659 Ok((floor, cadences))
6660}
6661
6662/// Collect every occurrence of a repeatable flag (e.g.
6663/// `--trace=<spec>` or `trace=<spec>`) from a raw arg list.
6664/// Returns the values in order of appearance — `parse_params`
6665/// collapses repeats into a HashMap, so this is the escape
6666/// hatch for repeatable args.
6667///
6668/// Accepts both `--name=value` and `name=value` shapes for
6669/// symmetry with the rest of nmbrs's arg surface.
6670pub fn collect_repeated_flag(args: &[String], name: &str) -> Vec<String> {
6671 let mut out = Vec::new();
6672 let mut iter = args.iter().peekable();
6673 let long_eq = format!("--{name}=");
6674 let bare_eq = format!("{name}=");
6675 while let Some(arg) = iter.next() {
6676 let unquoted = elide_outer_quotes(arg.as_str());
6677 if let Some(v) = unquoted.strip_prefix(&long_eq) {
6678 out.push(elide_outer_quotes(v).to_string());
6679 } else if let Some(v) = unquoted.strip_prefix(&bare_eq) {
6680 out.push(elide_outer_quotes(v).to_string());
6681 } else if unquoted == format!("--{name}")
6682 && let Some(v) = iter.next()
6683 {
6684 out.push(elide_outer_quotes(v.as_str()).to_string());
6685 }
6686 }
6687 out
6688}
6689
6690/// Parse a cycle count that may have suffixes: K, M, B.
6691pub fn parse_count(s: &str) -> Option<u64> {
6692 let s = s.trim().to_uppercase();
6693 if let Some(n) = s.strip_suffix('K') {
6694 n.trim().parse::<u64>().ok().map(|v| v * 1_000)
6695 } else if let Some(n) = s.strip_suffix('M') {
6696 n.trim().parse::<u64>().ok().map(|v| v * 1_000_000)
6697 } else if let Some(n) = s.strip_suffix('B') {
6698 n.trim().parse::<u64>().ok().map(|v| v * 1_000_000_000)
6699 } else {
6700 s.parse().ok()
6701 }
6702}
6703
6704/// Find the closest match using Levenshtein distance.
6705fn closest_match<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
6706 let mut best: Option<(&str, usize)> = None;
6707 for &candidate in candidates {
6708 let d = levenshtein(input, candidate);
6709 if best.is_none() || d < best.unwrap().1 {
6710 best = Some((candidate, d));
6711 }
6712 }
6713 best.filter(|(_, d)| *d <= (input.len() / 2).max(2))
6714 .map(|(s, _)| s)
6715}
6716
6717fn levenshtein(a: &str, b: &str) -> usize {
6718 let a: Vec<char> = a.chars().collect();
6719 let b: Vec<char> = b.chars().collect();
6720 let (m, n) = (a.len(), b.len());
6721 let mut prev = (0..=n).collect::<Vec<_>>();
6722 let mut curr = vec![0; n + 1];
6723 for i in 1..=m {
6724 curr[0] = i;
6725 for j in 1..=n {
6726 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
6727 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
6728 }
6729 std::mem::swap(&mut prev, &mut curr);
6730 }
6731 prev[n]
6732}
6733
6734// =========================================================================
6735// Polydat Scope Composition (sysref 16)
6736// =========================================================================
6737
6738// `ManifestEntry` and `extract_manifest` now live in
6739// `polydat::kernel`. Re-exported here so existing
6740// `crate::runner::extract_manifest` / `ManifestEntry` callers
6741// keep working — pure compatibility shim.
6742pub use polydat::kernel::{ManifestEntry, extract_manifest};
6743
6744#[cfg(test)]
6745mod tests {
6746 use super::*;
6747
6748 // ── parse_params quote elision (SRD 71) ──────────────────
6749
6750 fn pp(args: &[&str]) -> HashMap<String, String> {
6751 parse_params(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
6752 }
6753
6754 #[test]
6755 fn parse_params_bare_unchanged() {
6756 let m = pp(&["cursor=0..53%"]);
6757 assert_eq!(m.get("cursor").map(String::as_str), Some("0..53%"));
6758 }
6759
6760 #[test]
6761 fn parse_params_value_single_quoted_stripped() {
6762 let m = pp(&["cursor='0..53%'"]);
6763 assert_eq!(m.get("cursor").map(String::as_str), Some("0..53%"));
6764 }
6765
6766 #[test]
6767 fn parse_params_value_double_quoted_stripped() {
6768 let m = pp(&["cursor=\"0..53%\""]);
6769 assert_eq!(m.get("cursor").map(String::as_str), Some("0..53%"));
6770 }
6771
6772 #[test]
6773 fn parse_params_whole_arg_single_quoted_stripped() {
6774 let m = pp(&["'cursor=0..53%'"]);
6775 assert_eq!(m.get("cursor").map(String::as_str), Some("0..53%"));
6776 }
6777
6778 #[test]
6779 fn parse_params_whole_arg_double_quoted_stripped() {
6780 let m = pp(&["\"cursor=0..53%\""]);
6781 assert_eq!(m.get("cursor").map(String::as_str), Some("0..53%"));
6782 }
6783
6784 #[test]
6785 fn parse_params_bracket_value_with_quotes() {
6786 let m = pp(&["cursor='[0..53%)'"]);
6787 assert_eq!(m.get("cursor").map(String::as_str), Some("[0..53%)"));
6788 }
6789
6790 #[test]
6791 fn parse_params_mismatched_quotes_not_stripped() {
6792 let m = pp(&["cursor='0..53%\""]);
6793 // First char `'`, last char `"` — no matching pair.
6794 assert_eq!(m.get("cursor").map(String::as_str), Some("'0..53%\""));
6795 }
6796
6797 fn dup(args: &[&str]) -> Result<(), String> {
6798 let owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
6799 detect_conflicting_duplicate_params(&owned)
6800 }
6801
6802 #[test]
6803 fn duplicate_conflicting_scenario_is_rejected() {
6804 let err = dup(&["workload=x.yaml", "scenario=reset", "scenario=idx_sweep"]).unwrap_err();
6805 assert!(
6806 err.contains("scenario") && err.contains("reset") && err.contains("idx_sweep"),
6807 "expected a conflicting-duplicate error naming both values, got: {err}"
6808 );
6809 }
6810
6811 #[test]
6812 fn duplicate_identical_value_is_allowed() {
6813 // Re-passing the same value is harmless.
6814 assert!(dup(&["scenario=idx_sweep", "scenario=idx_sweep"]).is_ok());
6815 }
6816
6817 #[test]
6818 fn distinct_params_are_allowed() {
6819 assert!(dup(&["workload=x.yaml", "scenario=reset", "cycles=10", "host=h"]).is_ok());
6820 }
6821
6822 #[test]
6823 fn conflicting_duplicate_any_param_is_rejected() {
6824 // The guard is general — not just `scenario=`.
6825 assert!(dup(&["cycles=10", "cycles=20"]).is_err());
6826 }
6827
6828 #[test]
6829 fn duplicate_check_elides_quotes_before_comparing() {
6830 // `scenario=reset` and `scenario='reset'` are the SAME value.
6831 assert!(dup(&["scenario=reset", "scenario='reset'"]).is_ok());
6832 // …but genuinely different quoted values still conflict.
6833 assert!(dup(&["scenario='reset'", "scenario=\"idx_sweep\""]).is_err());
6834 }
6835
6836 #[test]
6837 fn duplicate_check_skips_session_flags_and_dotted_overrides() {
6838 // Session-dir flags are consumed by their own resolver; dotted
6839 // keys are phase-scoped overrides — neither participates here.
6840 assert!(dup(&["--session-path", "/a", "--session-path", "/b"]).is_ok());
6841 assert!(dup(&["phase1.cycles=10", "phase2.cycles=20"]).is_ok());
6842 }
6843
6844 #[test]
6845 fn parse_params_equals_in_value_preserved() {
6846 // `key='a=b'` → name=`key`, value=`a=b`.
6847 let m = pp(&["key='a=b'"]);
6848 assert_eq!(m.get("key").map(String::as_str), Some("a=b"));
6849 }
6850
6851 #[test]
6852 fn parse_params_multiple_params_independent() {
6853 let m = pp(&["dataset=example", "cursor='0..1%'", "concurrency=\"100\""]);
6854 assert_eq!(m.get("dataset").map(String::as_str), Some("example"));
6855 assert_eq!(m.get("cursor").map(String::as_str), Some("0..1%"));
6856 assert_eq!(m.get("concurrency").map(String::as_str), Some("100"));
6857 }
6858
6859 // ── Polydat binding-LHS-name scanner ──────────────────────────
6860
6861 fn scan_to_set(src: &str) -> std::collections::HashSet<String> {
6862 let mut out = std::collections::HashSet::new();
6863 scan_polydat_binding_lhs(&mut out, src);
6864 out
6865 }
6866
6867 // ── scan_polydat_braced_refs: invalid `{...}` outside strings ─
6868
6869 #[test]
6870 fn polydat_brace_guard_allows_valid_if_block() {
6871 // Block-form conditional selection uses braces legitimately. The guard must
6872 // not flag it, because the source parses.
6873 let src = "extern segments: u64 = 0\nmean := if segments > 0 { 100 } else { 0 }\n";
6874 let parses = polydat::dsl::lexer::lex(src)
6875 .ok()
6876 .and_then(|t| polydat::dsl::parser::parse(t).ok())
6877 .is_some();
6878 assert!(parses, "if-block source must parse: {src}");
6879 }
6880
6881 #[test]
6882 fn polydat_brace_guard_still_catches_stray_placeholder() {
6883 // A YAML placeholder in expression position does NOT parse, so the guard
6884 // still fires and still names the placeholder.
6885 let src = "const passes := multiples_at_least({min_query_cycles}, base)\n";
6886 let parses = polydat::dsl::lexer::lex(src)
6887 .ok()
6888 .and_then(|t| polydat::dsl::parser::parse(t).ok())
6889 .is_some();
6890 assert!(!parses, "stray placeholder must fail to parse");
6891 let refs = scan_polydat_braced_refs(src);
6892 assert!(refs.iter().any(|r| r == "min_query_cycles"), "got {refs:?}");
6893 }
6894
6895 #[test]
6896 fn scan_polydat_braced_refs_flags_expression_position_braces() {
6897 // The user's case: `{name}` outside any string literal,
6898 // sitting where Polydat expects an expression. Always invalid.
6899 let refs = scan_polydat_braced_refs(
6900 "const passes := multiples_at_least({min_query_cycles}, base)\n",
6901 );
6902 assert_eq!(refs, vec!["min_query_cycles".to_string()]);
6903 }
6904
6905 #[test]
6906 fn scan_polydat_braced_refs_ignores_braces_inside_double_quotes() {
6907 // Inside `"…"` braces are valid — either workload-param
6908 // interp (if the runtime expanded the string earlier) or
6909 // Polydat string interpolation (parser turns it into printf).
6910 // Either way, scan_polydat_braced_refs must NOT flag them.
6911 let refs = scan_polydat_braced_refs(
6912 "const prebuffered := dataset_prebuffer(\"{dataset}:{profile}\")\n",
6913 );
6914 assert!(
6915 refs.is_empty(),
6916 "must not flag `{{dataset}}` / `{{profile}}` inside string \
6917 literal — string interpolation handles them, got {refs:?}"
6918 );
6919 }
6920
6921 #[test]
6922 fn scan_polydat_braced_refs_ignores_braces_inside_single_quotes() {
6923 let refs = scan_polydat_braced_refs("tag := assert_eq(actual, '{expected}')\n");
6924 assert!(
6925 refs.is_empty(),
6926 "single-quoted strings get the same treatment: {refs:?}"
6927 );
6928 }
6929
6930 #[test]
6931 fn scan_polydat_braced_refs_handles_escaped_quotes_in_strings() {
6932 // `"foo \"with brace {x}\" bar"` — the inner `{x}` is
6933 // inside a string the whole way through; backslash-escape
6934 // must not be treated as the end of the string.
6935 let refs = scan_polydat_braced_refs("x := concat(\"prefix \\\"{embedded}\\\" suffix\")\n");
6936 assert!(refs.is_empty(), "escaped quotes inside strings: {refs:?}");
6937 }
6938
6939 #[test]
6940 fn scan_polydat_braced_refs_ignores_comments() {
6941 let refs = scan_polydat_braced_refs(
6942 "# this is a comment with {fake} placeholder\n\
6943 const real := 1\n",
6944 );
6945 assert!(
6946 refs.is_empty(),
6947 "`{{fake}}` inside a comment must not be flagged: {refs:?}"
6948 );
6949 }
6950
6951 #[test]
6952 fn scan_polydat_braced_refs_catches_multiple_invalid_braces() {
6953 let refs = scan_polydat_braced_refs(
6954 "a := foo({x}, {y})\n\
6955 b := bar({z})\n",
6956 );
6957 // Order is source order; uniqueness isn't enforced here
6958 // (the validator caller dedups before reporting).
6959 assert_eq!(
6960 refs,
6961 vec!["x".to_string(), "y".to_string(), "z".to_string(),]
6962 );
6963 }
6964
6965 #[test]
6966 fn scan_polydat_braced_refs_handles_mixed_string_and_expression_braces() {
6967 // `{inside}` is in a string (OK); `{outside}` is in
6968 // expression position (flagged).
6969 let refs = scan_polydat_braced_refs("x := concat(\"foo {inside}\", {outside})\n");
6970 assert_eq!(refs, vec!["outside".to_string()]);
6971 }
6972
6973 // ── Polydat binding-LHS-name scanner ──────────────────────────
6974
6975 #[test]
6976 fn scan_polydat_binding_lhs_handles_typed_cell_form() {
6977 // `shared name: type := default` — the type annotation sits
6978 // between the name and the assignment; the scanner must still
6979 // collect the name (undeclared-placeholder false-positive fix).
6980 let mut out = std::collections::HashSet::new();
6981 scan_polydat_binding_lhs(
6982 &mut out,
6983 "shared sstables: u64 := 0\nshared measured: f64 := 1.0\nplain := 2\n",
6984 );
6985 assert!(out.contains("sstables"), "{out:?}");
6986 assert!(out.contains("measured"), "{out:?}");
6987 assert!(out.contains("plain"), "{out:?}");
6988 }
6989
6990 #[test]
6991 fn scan_polydat_binding_lhs_handles_tuple_destructure() {
6992 // Multi-output stdlib calls bind multiple names via
6993 // tuple destructure: `(a, b, c) := func(...)`. The
6994 // scanner must register every name on the LHS so the
6995 // placeholder validator doesn't false-flag downstream
6996 // `{a}` / `{b}` references.
6997 let names = scan_to_set("(y, mo, d, h, mi, s, ms) := date_components(0)\n");
6998 for expected in ["y", "mo", "d", "h", "mi", "s", "ms"] {
6999 assert!(
7000 names.contains(expected),
7001 "tuple-LHS scanner missed `{expected}` — got {names:?}"
7002 );
7003 }
7004 }
7005
7006 #[test]
7007 fn scan_polydat_binding_lhs_finds_all_recognised_shapes() {
7008 // Validates the wire-name scanner picks up every shape:
7009 // init / cursor / shared / final modifier-prefixed
7010 // bindings, plus bare `NAME := …` assignments.
7011 let names = scan_to_set(
7012 "const prebuffered := dataset_prebuffer(\"foo\")\n\
7013 cursor q = range(0, 100)\n\
7014 query_vector := query_vector_at(prebuffered, q)\n\
7015 shared query_passes := set_or_get(query_passes, 7)\n\
7016 const tag := \"label_00\"\n",
7017 );
7018 for expected in ["prebuffered", "q", "query_vector", "query_passes", "tag"] {
7019 assert!(
7020 names.contains(expected),
7021 "scanner missed `{expected}` — got {names:?}"
7022 );
7023 }
7024 }
7025
7026 #[test]
7027 fn scan_polydat_binding_lhs_skips_comments_and_blank_lines() {
7028 let names = scan_to_set(
7029 "# comment\n\
7030 \n\
7031 const real_binding := 1\n\
7032 # another comment\n",
7033 );
7034 assert_eq!(names.len(), 1);
7035 assert!(names.contains("real_binding"));
7036 }
7037
7038 #[test]
7039 fn scan_polydat_binding_lhs_ignores_non_binding_lines() {
7040 // Expression-call statements and continuation lines must
7041 // not introduce phantom wires.
7042 let names = scan_to_set(
7043 "foo(1, 2)\n\
7044 bar.baz\n\
7045 const real := 1\n",
7046 );
7047 assert_eq!(names.len(), 1);
7048 assert!(names.contains("real"));
7049 }
7050
7051 #[test]
7052 fn scan_polydat_binding_lhs_picks_up_input_decl_bare() {
7053 let names = scan_to_set("input cycle: u64\nx := hash(cycle)\n");
7054 assert!(names.contains("cycle"));
7055 assert!(names.contains("x"));
7056 }
7057
7058 #[test]
7059 fn scan_polydat_binding_lhs_picks_up_input_decl_untyped() {
7060 let names = scan_to_set("input cycle\n");
7061 assert!(names.contains("cycle"));
7062 }
7063
7064 #[test]
7065 fn scan_polydat_binding_lhs_picks_up_input_decl_tuple() {
7066 let names = scan_to_set("input (cycle: u64, q: f64)\n");
7067 assert!(names.contains("cycle"));
7068 assert!(names.contains("q"));
7069 }
7070
7071 #[test]
7072 fn scan_polydat_binding_lhs_picks_up_extern_decl() {
7073 // `extern name: type [= default]` declares a wire just like
7074 // `input` — a same-op capture target referenced via `{name}`
7075 // must not trip the undeclared-placeholder guard.
7076 let names = scan_to_set(
7077 "extern active_compactions: u64 = 0\n\
7078 extern completion_ratio: f64 = 0.0\n\
7079 extern (a: u64, b: f64)\n",
7080 );
7081 assert!(names.contains("active_compactions"));
7082 assert!(names.contains("completion_ratio"));
7083 assert!(names.contains("a"));
7084 assert!(names.contains("b"));
7085 }
7086
7087 #[test]
7088 fn parse_dryrun_controls_sets_list_flag() {
7089 let cfg = DiagnosticConfig::parse("controls");
7090 assert!(cfg.list_controls);
7091 // Implies phase depth so the runner exits before any
7092 // cycle-time work.
7093 assert_eq!(cfg.depth, ExecDepth::Phase);
7094 }
7095
7096 #[test]
7097 fn parse_dryrun_controls_combines_with_other_flags() {
7098 let cfg = DiagnosticConfig::parse("controls,labels");
7099 assert!(cfg.list_controls);
7100 assert!(cfg.show_labels);
7101 }
7102
7103 #[test]
7104 fn parse_dryrun_unknown_flag_does_not_set_controls() {
7105 let cfg = DiagnosticConfig::parse("phase,bogus");
7106 assert!(!cfg.list_controls);
7107 }
7108
7109 // ── SRD-13d Phase 7 — `dryrun=op` ──
7110
7111 #[test]
7112 fn parse_dryrun_op_sets_op_depth() {
7113 let cfg = DiagnosticConfig::parse("op");
7114 assert_eq!(cfg.depth, ExecDepth::Op);
7115 }
7116
7117 #[test]
7118 fn parse_dryrun_phase_still_sets_phase_depth() {
7119 let cfg = DiagnosticConfig::parse("phase");
7120 assert_eq!(cfg.depth, ExecDepth::Phase);
7121 }
7122
7123 #[test]
7124 fn parse_dryrun_cycle_still_sets_cycle_depth() {
7125 let cfg = DiagnosticConfig::parse("cycle");
7126 assert_eq!(cfg.depth, ExecDepth::Cycle);
7127 }
7128
7129 #[test]
7130 fn parse_dryrun_op_combines_with_wiring_flag() {
7131 let cfg = DiagnosticConfig::parse("op,wiring");
7132 assert_eq!(cfg.depth, ExecDepth::Op);
7133 assert!(cfg.show_wiring);
7134 }
7135
7136 #[test]
7137 fn parse_dryrun_wiring_alone_bumps_depth_to_op() {
7138 // `wiring` needs depth >= Op for kernels to exist; a bare
7139 // `dryrun=wiring` must auto-bump so it produces output
7140 // instead of silently doing nothing at depth=Phase.
7141 let cfg = DiagnosticConfig::parse("wiring");
7142 assert_eq!(cfg.depth, ExecDepth::Op);
7143 assert!(cfg.show_wiring);
7144 }
7145
7146 #[test]
7147 fn parse_dryrun_wiring_does_not_override_explicit_depth() {
7148 // Explicit phase depth wins; `wiring` is then a no-op
7149 // (no kernels to render at phase depth) — the user gets
7150 // what they asked for rather than a silent bump.
7151 let cfg = DiagnosticConfig::parse("phase,wiring");
7152 assert_eq!(cfg.depth, ExecDepth::Phase);
7153 assert!(cfg.show_wiring);
7154 }
7155
7156 #[test]
7157 fn exec_depth_ordering_matches_srd_13d() {
7158 // `Phase` is the shallowest stop, `Full` is the deepest;
7159 // `Op` sits between `Phase` and `Cycle`. Depth-gating
7160 // sites read this ordering as `< Cycle` ⇒ "skip cycles".
7161 assert!(ExecDepth::Phase < ExecDepth::Op);
7162 assert!(ExecDepth::Op < ExecDepth::Cycle);
7163 assert!(ExecDepth::Cycle < ExecDepth::Full);
7164 // The transitive should hold (it would be a derive
7165 // bug if it didn't, but assert it for documentation).
7166 assert!(ExecDepth::Phase < ExecDepth::Cycle);
7167 assert!(ExecDepth::Op < ExecDepth::Full);
7168 }
7169
7170 #[test]
7171 fn exec_depth_phase_and_op_short_circuit_before_cycles() {
7172 // The executor's per-phase early-exit fires when
7173 // `depth < Cycle`. Both Phase and Op satisfy that;
7174 // Cycle and Full do not.
7175 assert!(ExecDepth::Phase < ExecDepth::Cycle);
7176 assert!(ExecDepth::Op < ExecDepth::Cycle);
7177 assert!((ExecDepth::Cycle >= ExecDepth::Cycle));
7178 assert!((ExecDepth::Full >= ExecDepth::Cycle));
7179 }
7180
7181 #[test]
7182 fn render_scope_elision_summary_shows_materialised_and_elides_to() {
7183 use nmbrs_workload::model::{BindingsDef, ScenarioNode, WorkloadPhase};
7184 use std::collections::HashMap;
7185
7186 let phase = WorkloadPhase {
7187 key_metrics: Vec::new(),
7188 dimensions: Default::default(),
7189 cycles: None,
7190 concurrency: None,
7191 rate: None,
7192 daemon: false,
7193 adapter: None,
7194 errors: None,
7195 tries: None,
7196 tries_backoff: None,
7197 interval: None,
7198 repeat: None,
7199 error_rate_max: None,
7200 timeout: None,
7201 stop_when: Vec::new(),
7202 throttle: None,
7203 continue_if: None,
7204 tags: None,
7205 ops: vec![],
7206 for_each: None,
7207 loop_scope: None,
7208 iter_scope: None,
7209 checkpoint: None,
7210 status_metrics: vec![],
7211 metrics: Default::default(),
7212 bindings: BindingsDef::default(),
7213 poll: None,
7214 optimize: None,
7215 };
7216 let mut phases = HashMap::new();
7217 phases.insert("predict".to_string(), phase);
7218 let mut tree = crate::scope_tree::ScopeTree::build(
7219 "default",
7220 &[ScenarioNode::Phase("predict".into())],
7221 );
7222 // Conservative classifier: empty workload + empty
7223 // phase ⇒ scenario and phase elide into root.
7224 let inputs = crate::scope_elision::ClassifyInputs {
7225 bindings: &BindingsDef::default(),
7226 params: &HashMap::new(),
7227 phases: &phases,
7228 };
7229 crate::scope_elision::classify_and_mark(&mut tree, &inputs);
7230
7231 let mut buf: Vec<u8> = Vec::new();
7232 render_scope_elision_summary(&tree, &mut buf).unwrap();
7233 let s = String::from_utf8(buf).unwrap();
7234
7235 assert!(s.contains("scope elision summary"), "missing header: {s}");
7236 // Workload root materialises always (SRD-13d §5.1).
7237 assert!(
7238 s.contains("workload") && s.contains("materialised=true"),
7239 "expected materialised=true line for workload root: {s}"
7240 );
7241 // Scenario + phase elide into the workload root.
7242 assert!(
7243 s.contains("elides-to=workload"),
7244 "expected elides-to=workload for empty phase: {s}"
7245 );
7246 assert!(
7247 s.contains("workload.scenario.default"),
7248 "expected scenario logical name: {s}"
7249 );
7250 assert!(
7251 s.contains("workload.scenario.default.phase.predict"),
7252 "expected phase logical name: {s}"
7253 );
7254 }
7255
7256 #[test]
7257 fn render_controls_tree_empty_session_writes_placeholder() {
7258 let root = nmbrs_metrics::component::Component::root(
7259 nmbrs_metrics::labels::Labels::of("session", "t"),
7260 std::collections::HashMap::new(),
7261 );
7262 let mut buf: Vec<u8> = Vec::new();
7263 render_controls_tree(&root, &mut buf).unwrap();
7264 let s = String::from_utf8(buf).unwrap();
7265 assert!(s.contains("no controls declared"), "got: {s}");
7266 }
7267
7268 #[test]
7269 fn render_controls_tree_lists_session_root_controls() {
7270 let root = nmbrs_metrics::component::Component::root(
7271 nmbrs_metrics::labels::Labels::of("session", "t"),
7272 std::collections::HashMap::new(),
7273 );
7274 root.read().unwrap().controls().declare(
7275 nmbrs_metrics::controls::ControlBuilder::new("log_level", 1u32)
7276 .reify_as_gauge(|v| Some(*v as f64))
7277 .branch_scope(nmbrs_metrics::controls::BranchScope::Subtree)
7278 .from_f64(|v| Ok(v as u32))
7279 .final_at_scope("session_root")
7280 .build(),
7281 );
7282
7283 let mut buf: Vec<u8> = Vec::new();
7284 render_controls_tree(&root, &mut buf).unwrap();
7285 let s = String::from_utf8(buf).unwrap();
7286 assert!(s.contains("log_level"), "missing name: {s}");
7287 assert!(s.contains("scope=subtree"), "missing scope: {s}");
7288 assert!(
7289 s.contains("final@session_root"),
7290 "missing final marker: {s}"
7291 );
7292 assert!(s.contains("f64-writable"), "missing write surface: {s}");
7293 }
7294
7295 // ── Regression: --session-path value not auto-promoted to scenario= ──
7296 //
7297 // Bug shape (caught by user during Phase C live exercise):
7298 // `nmbrs run wl.yaml cycles=2 --session-path X` was rewritten to
7299 // `nmbrs run wl.yaml cycles=2 --session-path scenario=X` because
7300 // `normalize_args` walked tokens flat and saw `X` as a bare
7301 // post-workload positional. Symptom: a literal directory at
7302 // `<cwd>/scenario=X` was created. The fix peeks for value-taking
7303 // flags so the value passes through unchanged.
7304
7305 fn s(v: &[&str]) -> Vec<String> {
7306 v.iter().map(|x| x.to_string()).collect()
7307 }
7308
7309 #[test]
7310 fn normalize_args_session_path_space_form_value_passes_through() {
7311 let out = normalize_args(&s(&[
7312 "wl.yaml",
7313 "cycles=2",
7314 "--session-path",
7315 "target/test-tmp/foo/session",
7316 ]));
7317 // The path arg must NOT be turned into `scenario=...`.
7318 assert!(
7319 !out.iter().any(|a| a.starts_with("scenario=")),
7320 "scenario= auto-promotion fired on a flag value: {out:?}"
7321 );
7322 assert_eq!(
7323 out,
7324 s(&[
7325 "wl.yaml",
7326 "cycles=2",
7327 "--session-path",
7328 "target/test-tmp/foo/session",
7329 ])
7330 );
7331 }
7332
7333 #[test]
7334 fn normalize_args_session_path_equals_form_unchanged() {
7335 let out = normalize_args(&s(&[
7336 "wl.yaml",
7337 "--session-path=target/test-tmp/foo/session",
7338 ]));
7339 assert_eq!(
7340 out,
7341 s(&["wl.yaml", "--session-path=target/test-tmp/foo/session",])
7342 );
7343 }
7344
7345 #[test]
7346 fn normalize_args_real_scenario_positional_still_promotes() {
7347 // The original feature: bare-word scenario shorthand.
7348 // Must keep working when no value-flag interferes.
7349 let out = normalize_args(&s(&["wl.yaml", "myscenario", "cycles=2"]));
7350 assert_eq!(out, s(&["wl.yaml", "scenario=myscenario", "cycles=2",]));
7351 }
7352
7353 #[test]
7354 fn normalize_args_scenario_after_session_path_still_promotes() {
7355 // After a value-flag pair, the next free positional is
7356 // still eligible for scenario= promotion. This confirms
7357 // the bookkeeping survives the look-ahead.
7358 let out = normalize_args(&s(&["wl.yaml", "--session-path", "/tmp/x", "myscenario"]));
7359 assert_eq!(
7360 out,
7361 s(&["wl.yaml", "--session-path", "/tmp/x", "scenario=myscenario",])
7362 );
7363 }
7364
7365 #[test]
7366 fn normalize_args_readout_value_passes_through() {
7367 let out = normalize_args(&s(&["wl.yaml", "--readout", "throughput ok_pct"]));
7368 assert!(
7369 !out.iter().any(|a| a.starts_with("scenario=")),
7370 "readout body misread as scenario: {out:?}"
7371 );
7372 }
7373
7374 /// `format_for_combinations` lays out vars and specs in
7375 /// column-aligned pairs. Each column is padded to the
7376 /// widest of its (var, spec) so corresponding entries
7377 /// stack vertically. The `color = false` argument forces
7378 /// the no-ANSI branch so the assertion can pattern-match
7379 /// the raw text — no dependency on the process's ambient
7380 /// TTY / `NO_COLOR` state (which `observer::use_color()`
7381 /// caches process-wide on first call and can't be undone
7382 /// per-test).
7383 #[test]
7384 fn format_for_combinations_aligns_columns() {
7385 let pairs = vec![
7386 ("sm".to_string(), "{sm_values}".to_string()),
7387 ("mnc".to_string(), "{mnc_values}".to_string()),
7388 (
7389 "alf_label".to_string(),
7390 "concat({alf_label_values})".to_string(),
7391 ),
7392 ];
7393 let out = format_for_combinations(&pairs, "", false);
7394 let lines: Vec<&str> = out.split('\n').collect();
7395 assert_eq!(lines.len(), 2, "MUST produce exactly 2 lines: {out:?}");
7396 assert!(
7397 lines[0].starts_with("for ["),
7398 "first line MUST start with `for [`: {:?}",
7399 lines[0]
7400 );
7401 assert!(
7402 lines[1].starts_with(" in ["),
7403 "second line MUST start with ` in [`: {:?}",
7404 lines[1]
7405 );
7406 // Bracket columns align: the `[` after `for` and the
7407 // `[` after `in ` should be at the same column index.
7408 let l0_bracket = lines[0].find('[').unwrap();
7409 let l1_bracket = lines[1].find('[').unwrap();
7410 assert_eq!(
7411 l0_bracket, l1_bracket,
7412 "`[` brackets MUST align: line0={l0_bracket}, line1={l1_bracket}"
7413 );
7414 // Column alignment: the comma after `sm,` on line 0
7415 // sits at the same column as the comma after the
7416 // `{sm_values},` on line 1 — except padded so that
7417 // `mnc` on line 0 starts at the same column as
7418 // `{mnc_values}` on line 1.
7419 let mnc_pos = lines[0].find("mnc").unwrap();
7420 let mnc_values_pos = lines[1].find("{mnc_values}").unwrap();
7421 assert_eq!(
7422 mnc_pos, mnc_values_pos,
7423 "column 2 MUST align: `mnc`@{mnc_pos} vs `{{mnc_values}}`@{mnc_values_pos}\n{out}"
7424 );
7425 let alf_pos = lines[0].find("alf_label").unwrap();
7426 let alf_concat_pos = lines[1].find("concat(").unwrap();
7427 assert_eq!(
7428 alf_pos, alf_concat_pos,
7429 "column 3 MUST align: `alf_label`@{alf_pos} vs `concat(...)`@{alf_concat_pos}\n{out}"
7430 );
7431 // Closing brackets present on both lines.
7432 assert!(lines[0].ends_with(']'));
7433 assert!(lines[1].ends_with(']'));
7434 }
7435}