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