Skip to main content

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