Skip to main content

nmbrs_runtime/
scope.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed binding scope model for Polydat Kernel compilation.
5//!
6//! A `BindingScope` is the structured intermediate representation
7//! that replaces raw string manipulation for scope composition.
8//! Every binding carries its provenance (`BindingOrigin`), its
9//! modifier, and its definition text. Scope rules are checked
10//! against this typed structure, and a single deduplicated GK
11//! source string is emitted at the end.
12
13use std::collections::{HashMap, HashSet};
14
15use crate::scope_synth::{
16    collect_leaf_placeholders, value_to_param_string, workload_param_type_name,
17};
18use nmbrs_workload::model::{BindingsDef, ParsedOp};
19use polydat::kernel::interp::collect_string_interp_refs;
20
21/// Where a binding was declared — its provenance in the scope chain.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum BindingOrigin {
24    /// Declared at a YAML level outside the op (today only block
25    /// sugar, since SRD-13f Push D retired the workload/phase
26    /// parser-merge into ops). Reaches the op via
27    /// `inline_block_sugar_into_op` during YAML parsing.
28    Inherited,
29    /// Declared at phase level (the phase has its own `bindings:` block).
30    Phase,
31    /// Declared at op level (op-specific augmentation).
32    Op(String),
33    /// Injected as a `for_each` iteration variable.
34    IterationVar,
35    /// Generated as an `extern` from the outer scope manifest.
36    AutoExtern,
37    /// Injected from workload param expansion.
38    ParamExpansion,
39    /// Generated from inline expression extraction (`{{expr}}`).
40    InlineExpr,
41}
42
43impl std::fmt::Display for BindingOrigin {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::Inherited => write!(f, "inherited"),
47            Self::Phase => write!(f, "phase"),
48            Self::Op(name) => write!(f, "op '{name}'"),
49            Self::IterationVar => write!(f, "iteration variable"),
50            Self::AutoExtern => write!(f, "auto-extern"),
51            Self::ParamExpansion => write!(f, "param expansion"),
52            Self::InlineExpr => write!(f, "inline expression"),
53        }
54    }
55}
56
57/// The modifier on a binding declaration. Mirrors the
58/// `BindingModifier` flag set in polydat' Polydat AST plus
59/// the binding-kind keywords (`init`, `cursor`) that
60/// nmbrs-runtime's text-level scope assembly cares about.
61///
62/// Wire-coloring modifiers (`final`, `shared`, `volatile`)
63/// could in principle combine, but the scope assembly path
64/// historically only needs to know "is this final?" /
65/// "is this shared?" for shadow checks; combinations get
66/// reduced here to the most-distinctive single tag. The full
67/// flag set is preserved in the eventual Polydat AST when the
68/// scope-emitted source compiles.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ScopeModifier {
71    None,
72    Init,
73    Shared,
74    Final,
75    Volatile,
76    Cursor,
77}
78
79/// A single binding declaration with provenance.
80#[derive(Debug, Clone)]
81pub struct ScopedBinding {
82    /// The binding name (LHS of `:=`).
83    pub name: String,
84    /// The full declaration line as Polydat source text.
85    /// For regular bindings: `"name := expr"`
86    /// For init: `"const name := \"value\""`
87    /// For externs: `"extern name: Type"`
88    pub line: String,
89    /// Where this binding came from.
90    pub origin: BindingOrigin,
91    /// Modifier on the declaration.
92    pub modifier: ScopeModifier,
93}
94
95/// A typed extern declaration.
96#[derive(Debug, Clone)]
97pub struct ExternDecl {
98    pub name: String,
99    pub type_name: String,
100}
101
102/// Typed scope for a phase's Polydat Kernel compilation.
103///
104/// Built by the executor from structured inputs, validated for
105/// scope rules, then emitted as a single Polydat source string.
106pub struct BindingScope {
107    /// The coordinate declaration (e.g., `"input cycle: u64"`).
108    coordinates: Option<String>,
109    /// All bindings in insertion order.
110    bindings: Vec<ScopedBinding>,
111    /// Extern declarations.
112    externs: Vec<ExternDecl>,
113    /// Names referenced by op templates (for DCE).
114    required_outputs: Vec<String>,
115    /// Extra required outputs from config expressions.
116    config_refs: Vec<String>,
117}
118
119impl Default for BindingScope {
120    fn default() -> Self {
121        Self::new()
122    }
123}
124
125impl BindingScope {
126    /// Create an empty scope.
127    pub fn new() -> Self {
128        Self {
129            coordinates: None,
130            bindings: Vec::new(),
131            externs: Vec::new(),
132            required_outputs: Vec::new(),
133            config_refs: Vec::new(),
134        }
135    }
136
137    /// Ingest bindings from a `BindingsDef::PolydatSource`, classifying each
138    /// line by the given origin. Extracts coordinates and handles all
139    /// Polydat declaration forms (init, shared, final, cursor, extern, plain).
140    ///
141    /// A "line" here is a *logical* line: physical newlines inside
142    /// unbalanced `()`/`[]`/`{}` or inside a string literal are
143    /// absorbed into the current binding. This is what lets multi-line
144    /// expressions like
145    ///
146    /// ```polydat
147    /// rate_adjust := control_set("rate",
148    ///                            to_f64(control_u64("rate")) * 1.05)
149    /// ```
150    ///
151    /// survive the later split-on-`\n` in [`Self::emit`]: we rejoin
152    /// them onto one physical line so the downstream parser sees a
153    /// complete expression.
154    pub fn ingest_polydat_source(&mut self, source: &str, origin: BindingOrigin) {
155        for line in logical_lines(source) {
156            let trimmed = line.trim();
157            if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with('#') {
158                continue;
159            }
160
161            // `input` declarations: `input <name>[: <type>]` (bare) or
162            // `input (<name>[: <type>], ...)` (tuple). Stored verbatim
163            // on the scope's `coordinates` slot — the synthesizer
164            // re-emits this line into the per-scope Polydat source so the
165            // compiler can pick up the declared inputs.
166            if trimmed.starts_with("input ") {
167                self.coordinates = Some(trimmed.to_string());
168                continue;
169            }
170
171            // Cursor declarations use `=` not `:=`:
172            //   cursor row = range(0, vector_count("example"))
173            //   const prebuffer := dataset_prebuffer("example")
174            // These are Polydat statements that the compiler handles directly.
175            // Pass them through as bindings so they survive emission.
176            if (trimmed.starts_with("cursor ") || trimmed.starts_with("init "))
177                && let Some(eq_pos) = trimmed.find('=')
178            {
179                // Check it's `=` not `:=`
180                let before_eq = &trimmed[..eq_pos];
181                if !before_eq.ends_with(':') {
182                    // cursor/init with bare `=` — pass through as-is
183                    let lhs = before_eq.trim();
184                    let (modifier, name) = parse_modifier_and_name(lhs);
185                    self.bindings.push(ScopedBinding {
186                        name: name.to_string(),
187                        line: trimmed.to_string(),
188                        origin: origin.clone(),
189                        modifier,
190                    });
191                    continue;
192                }
193            }
194
195            if let Some(pos) = trimmed.find(":=") {
196                let lhs = trimmed[..pos].trim();
197
198                // Extern declarations
199                if lhs.starts_with("extern") {
200                    // Already handled via add_extern; skip inline externs
201                    // from inherited sources
202                    continue;
203                }
204
205                // Determine modifier and extract bare name
206                let (modifier, name) = parse_modifier_and_name(lhs);
207
208                self.bindings.push(ScopedBinding {
209                    name: name.to_string(),
210                    line: trimmed.to_string(),
211                    origin: origin.clone(),
212                    modifier,
213                });
214            } else if trimmed.starts_with("extern ") {
215                // `extern name: Type` (no `:=`)
216                if let Some(colon_pos) = trimmed.find(':') {
217                    let name = trimmed["extern ".len()..colon_pos].trim();
218                    let type_name = trimmed[colon_pos + 1..].trim();
219                    self.externs.push(ExternDecl {
220                        name: name.to_string(),
221                        type_name: type_name.to_string(),
222                    });
223                }
224            }
225            // Lines that don't match any pattern are silently skipped.
226            // Comments and blank lines are already filtered above.
227        }
228    }
229
230    /// Add an iteration variable from `for_each`.
231    ///
232    /// Iteration variables are declared as `extern` ports rather
233    /// than init-time bindings (SRD 18b §"Iteration variables as
234    /// scope outputs"). The runtime sets the extern's value
235    /// before the leaf kernel executes, so we no longer
236    /// text-substitute literal values into the Polydat source. The
237    /// type is inferred from the current iteration's value:
238    /// numeric strings get `u64`/`f64`, anything else is `String`.
239    pub fn add_iteration_var(&mut self, name: &str, value: &str) {
240        let type_name = if value.parse::<u64>().is_ok() {
241            "u64"
242        } else if value.parse::<f64>().is_ok() {
243            "f64"
244        } else {
245            "String"
246        };
247        self.externs.push(ExternDecl {
248            name: name.to_string(),
249            type_name: type_name.to_string(),
250        });
251    }
252
253    /// Add an auto-extern declaration from the outer scope manifest.
254    pub fn add_extern(&mut self, name: &str, type_name: &str) {
255        self.externs.push(ExternDecl {
256            name: name.to_string(),
257            type_name: type_name.to_string(),
258        });
259    }
260
261    /// Add a workload param binding as a `final` (compile-time
262    /// constant) binding so the compiler folds the literal value
263    /// and the assembler treats references as const args rather
264    /// than wire inputs. The `const` modifier matches the M3.6
265    /// contract: workload params are immutable for the run, so
266    /// downstream nodes consume them as constants.
267    ///
268    /// String values are emitted as quoted Polydat string literals
269    /// with embedded `"` and `\` escaped, so JSON-shaped param
270    /// values (`{"a": 1}`, `{'class': 'SimpleStrategy'}`,
271    /// arbitrary nested quotes) round-trip through GK
272    /// compilation unchanged. Numeric and boolean values are
273    /// emitted as bare literals.
274    pub fn add_param_binding(&mut self, name: &str, value: &str) {
275        // Param-binding emission used by scope synth when
276        // cascading workload-root values into descendant
277        // scopes. Values arrive already-typed (raw workload-
278        // param text OR `value_to_param_string` output from a
279        // parent kernel constant). Workload-root semantics
280        // apply: bare strings lower to polydat string literals
281        // (legacy), NOT references. The `set:` block parser
282        // in nmbrs-workload uses a DIFFERENT classifier that
283        // does treat bare identifiers as references — that's
284        // the entry point where the operator IS in a scope
285        // context with visible wires to reference.
286        let trimmed = value.trim();
287        // A bare numeric/bool literal, or an already-quoted polydat
288        // string / array literal, passes through verbatim; anything else
289        // is wrapped as a quoted polydat string. (Both pass-through cases
290        // yield the same text, so they share one arm.)
291        let literal = if trimmed.parse::<u64>().is_ok()
292            || trimmed.parse::<f64>().is_ok()
293            || trimmed == "true"
294            || trimmed == "false"
295            || is_polydat_quoted_string(trimmed)
296            || is_polydat_array_literal(trimmed)
297        {
298            trimmed.to_string()
299        } else {
300            let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
301            format!("\"{escaped}\"")
302        };
303        self.bindings.push(ScopedBinding {
304            name: name.to_string(),
305            line: format!("const {name} := {literal}"),
306            origin: BindingOrigin::ParamExpansion,
307            modifier: ScopeModifier::Final,
308        });
309    }
310
311    /// Add an inline expression binding.
312    pub fn add_inline_expr(&mut self, name: &str, expr: &str) {
313        self.bindings.push(ScopedBinding {
314            name: name.to_string(),
315            line: format!("{name} := {expr}"),
316            origin: BindingOrigin::InlineExpr,
317            modifier: ScopeModifier::None,
318        });
319    }
320
321    /// Register a name referenced by an op template (for DCE).
322    pub fn add_required_output(&mut self, name: &str) {
323        if !self.required_outputs.contains(&name.to_string()) {
324            self.required_outputs.push(name.to_string());
325        }
326    }
327
328    /// Register a config expression reference (for DCE).
329    pub fn add_config_ref(&mut self, name: &str) {
330        if !self.config_refs.contains(&name.to_string()) {
331            self.config_refs.push(name.to_string());
332        }
333    }
334
335    /// All names defined in this scope (all origins).
336    pub fn defined_names(&self) -> HashSet<String> {
337        self.bindings.iter().map(|b| b.name.clone()).collect()
338    }
339
340    /// All extern names in this scope.
341    pub fn extern_names(&self) -> HashSet<String> {
342        self.externs.iter().map(|e| e.name.clone()).collect()
343    }
344
345    /// The combined required outputs (template refs + config refs).
346    pub fn required_outputs(&self) -> Vec<String> {
347        let mut all = self.required_outputs.clone();
348        for name in &self.config_refs {
349            if !all.contains(name) {
350                all.push(name.clone());
351            }
352        }
353        all
354    }
355
356    /// Validate scope rules. Returns `Ok(())` if valid, or a
357    /// descriptive error explaining the violation and its provenance.
358    pub fn validate(&self) -> Result<(), String> {
359        // Build a map of name → first binding for each origin tier.
360        // The tier order determines precedence: earlier tiers own the name.
361        let mut owned: HashMap<String, &ScopedBinding> = HashMap::new();
362
363        for binding in &self.bindings {
364            if let Some(prior) = owned.get(&binding.name) {
365                // Same name appears twice. Check if this is allowed.
366                match (&prior.origin, &binding.origin) {
367                    // Inherited + Inherited: duplicate from multiple ops
368                    // sharing the same workload bindings. Allowed if
369                    // definitions are identical.
370                    (BindingOrigin::Inherited, BindingOrigin::Inherited) => {
371                        if prior.line != binding.line {
372                            return Err(format!(
373                                "binding '{}' has conflicting inherited definitions:\n  \
374                                 first:  {}\n  second: {}",
375                                binding.name, prior.line, binding.line
376                            ));
377                        }
378                        // Duplicate with same definition — will be deduplicated at emit
379                    }
380
381                    // Phase + Phase: same check as inherited
382                    (BindingOrigin::Phase, BindingOrigin::Phase) => {
383                        if prior.line != binding.line {
384                            return Err(format!(
385                                "binding '{}' has conflicting phase-level definitions:\n  \
386                                 first:  {}\n  second: {}",
387                                binding.name, prior.line, binding.line
388                            ));
389                        }
390                    }
391
392                    // Op overriding Inherited/Phase/IterationVar with a
393                    // DIFFERENT definition: real shadow, error.
394                    (
395                        BindingOrigin::Inherited
396                        | BindingOrigin::Phase
397                        | BindingOrigin::IterationVar,
398                        BindingOrigin::Op(op_name),
399                    ) => {
400                        if prior.line != binding.line {
401                            return Err(format!(
402                                "op '{}' binding '{}' shadows a name from {} origin \
403                                 with a different definition.\n  \
404                                 scope: {}\n  op:    {}\n\
405                                 Ops augment the scope DAG but cannot override it. \
406                                 Use a separate phase for different bindings.",
407                                op_name, binding.name, prior.origin, prior.line, binding.line
408                            ));
409                        }
410                        // Same definition from inheritance — dedup at emit
411                    }
412
413                    // Op redefining an op binding from a DIFFERENT op
414                    (BindingOrigin::Op(prior_op), BindingOrigin::Op(this_op)) => {
415                        if prior_op != this_op {
416                            return Err(format!(
417                                "op '{}' binding '{}' is already defined by op '{}'. \
418                                 Each ride-along binding name must be unique across \
419                                 all ops in the scope.",
420                                this_op, binding.name, prior_op
421                            ));
422                        }
423                        // Same op, same name — shouldn't happen, but tolerate if same def
424                    }
425
426                    // IterationVar replacing Inherited/Phase: iteration
427                    // variables are injected before inherited bindings
428                    // in the emission order, so Polydat sees them first.
429                    // This is intentional — for_each vars override params.
430                    (
431                        BindingOrigin::IterationVar,
432                        BindingOrigin::Inherited | BindingOrigin::Phase,
433                    )
434                    | (
435                        BindingOrigin::Inherited | BindingOrigin::Phase,
436                        BindingOrigin::IterationVar,
437                    ) => {
438                        // Allowed: iteration vars intentionally override inherited names
439                    }
440
441                    // ParamExpansion: these are only added when the name
442                    // is NOT already defined. The caller checks. But if
443                    // somehow a duplicate appears, same-def is fine.
444                    (_, BindingOrigin::ParamExpansion) | (BindingOrigin::ParamExpansion, _) => {
445                        // Param expansion is additive; caller skips existing names
446                    }
447
448                    // InlineExpr: synthetic names (__expr_N), shouldn't collide
449                    (_, BindingOrigin::InlineExpr) | (BindingOrigin::InlineExpr, _) => {
450                        // Synthetic names from inline expressions
451                    }
452
453                    // AutoExtern: these go to the extern list, not bindings.
454                    // Shouldn't appear here, but tolerate.
455                    (_, BindingOrigin::AutoExtern) | (BindingOrigin::AutoExtern, _) => {}
456
457                    // Inherited + Op(same def): already covered above.
458                    // Any other combination: flag it.
459                    _ => {
460                        if prior.line != binding.line {
461                            return Err(format!(
462                                "binding '{}' conflicts: {} origin ({}) vs {} origin ({})",
463                                binding.name,
464                                prior.origin,
465                                prior.line,
466                                binding.origin,
467                                binding.line
468                            ));
469                        }
470                    }
471                }
472            } else {
473                owned.insert(binding.name.clone(), binding);
474            }
475        }
476
477        // Check final shadowing: no binding can redefine a name that
478        // appears as Final in the extern list or prior bindings.
479        let final_names: HashSet<String> = self
480            .bindings
481            .iter()
482            .filter(|b| b.modifier == ScopeModifier::Final)
483            .map(|b| b.name.clone())
484            .collect();
485
486        for binding in &self.bindings {
487            if final_names.contains(&binding.name)
488                && binding.modifier != ScopeModifier::Final
489                && binding.origin != BindingOrigin::Inherited
490            {
491                return Err(format!(
492                    "cannot shadow 'final' binding '{}' from outer scope",
493                    binding.name
494                ));
495            }
496        }
497
498        Ok(())
499    }
500
501    /// Emit the validated scope as a single Polydat source string.
502    ///
503    /// Entries are ordered:
504    /// 1. Coordinates declaration
505    /// 2. Extern declarations
506    /// 3. Init declarations (iteration variables)
507    /// 4. Inherited/Phase bindings (deduplicated by name)
508    /// 5. ParamExpansion bindings
509    /// 6. InlineExpr bindings
510    /// 7. Op-level bindings (new names only)
511    ///
512    /// Each name is emitted exactly once. The first occurrence wins;
513    /// subsequent duplicates with the same definition are suppressed.
514    pub fn emit(&self) -> String {
515        let mut lines: Vec<String> = Vec::new();
516        let mut emitted_names: HashSet<String> = HashSet::new();
517
518        // 1. Coordinates — emit only when an ingested source
519        // declared them. The Polydat compiler auto-infers coordinates
520        // from `cycle` references in non-strict mode, so a
521        // workload-level scope built purely from injected
522        // workload params (no op-supplied Polydat source, no `cycle`
523        // references) compiles fine without a synthetic line.
524        if let Some(ref coords) = self.coordinates {
525            lines.push(coords.clone());
526            // Populate `emitted_names` from the declared coordinate
527            // names so the externs pass (and downstream binding
528            // emission) doesn't re-introduce a same-named declaration
529            // that would collide with the input slot. The coord line
530            // is the canonical `input` declaration in either
531            // surface form:
532            //   input <name>[: <type>]               (bare)
533            //   input (<name>[: <type>], ...)        (tuple)
534            scan_input_decl_names(coords.trim(), &mut emitted_names);
535        }
536
537        // 2. Externs
538        for ext in &self.externs {
539            if !emitted_names.contains(&ext.name) {
540                lines.push(format!("extern {}: {}", ext.name, ext.type_name));
541                emitted_names.insert(ext.name.clone());
542            }
543        }
544
545        // 3-7. Bindings in origin order
546        let origin_order: &[fn(&BindingOrigin) -> bool] = &[
547            |o| matches!(o, BindingOrigin::IterationVar),
548            |o| matches!(o, BindingOrigin::Inherited),
549            |o| matches!(o, BindingOrigin::Phase),
550            |o| matches!(o, BindingOrigin::ParamExpansion),
551            |o| matches!(o, BindingOrigin::InlineExpr),
552            |o| matches!(o, BindingOrigin::Op(_)),
553        ];
554
555        for predicate in origin_order {
556            for binding in &self.bindings {
557                if predicate(&binding.origin) && !emitted_names.contains(&binding.name) {
558                    lines.push(binding.line.clone());
559                    emitted_names.insert(binding.name.clone());
560                }
561            }
562        }
563
564        lines.join("\n")
565    }
566}
567
568/// Parse the modifier prefix and bare name from a Polydat LHS.
569///
570/// `"shared foo"` → `(Shared, "foo")`
571/// `"init bar"` → `(Init, "bar")`
572/// `"cursor baz"` → `(Cursor, "baz")`
573/// `"final qux"` → `(Final, "qux")`
574/// `"plain"` → `(None, "plain")`
575/// Split `source` into logical lines, treating newlines inside
576/// unbalanced `()`, `[]`, `{}` or string literals as continuations.
577///
578/// A logical line ends at the first physical newline that sits at
579/// bracket-depth 0 and outside any string. Each returned String has
580/// its interior newlines collapsed to single spaces so the downstream
581/// Polydat parser sees one-expression-per-line, which is all it supports.
582fn logical_lines(source: &str) -> Vec<String> {
583    // Polydat grammar uses ONLY `"`-delimited string literals (see
584    // `polydat/src/dsl/lexer.rs`). Apostrophes (`'`) carry
585    // no special meaning at the lexical level — they appear
586    // verbatim in comments ("the workload's bindings") and
587    // inside double-quoted strings, never as a string delimiter
588    // themselves. Treating `'` as a delimiter here would let a
589    // stray apostrophe in a comment swallow the entire rest of
590    // the source as a single unterminated "string" run, which
591    // then collapses every subsequent binding into one logical
592    // line that the per-line parser sees as a giant comment
593    // and silently drops.
594    //
595    // Comments (`#`-to-EOL and `//`-to-EOL) are NOT skipped at
596    // this level — physical newlines inside them terminate the
597    // logical line uniformly, and the per-line `ingest_polydat_source`
598    // pass skips any `#`/`//`-leading line via `trimmed.starts_with`.
599    // What matters at this level is that bracket/string state
600    // doesn't get confused by content inside comments.
601    let mut out: Vec<String> = Vec::new();
602    let mut buf = String::new();
603    let mut depth: i32 = 0;
604    let mut in_str = false;
605    let mut in_line_comment = false;
606    let mut chars = source.chars().peekable();
607    while let Some(ch) = chars.next() {
608        if in_line_comment {
609            buf.push(ch);
610            if ch == '\n' {
611                in_line_comment = false;
612                // Comments live at depth 0 by definition (they're
613                // line-oriented); the newline always terminates
614                // the logical line.
615                if !buf.is_empty() {
616                    out.push(std::mem::take(&mut buf));
617                }
618            }
619            continue;
620        }
621        if in_str {
622            buf.push(ch);
623            if ch == '\\' {
624                if let Some(nx) = chars.next() {
625                    buf.push(nx);
626                }
627            } else if ch == '"' {
628                in_str = false;
629            }
630            continue;
631        }
632        match ch {
633            '"' => {
634                in_str = true;
635                buf.push(ch);
636            }
637            '#' => {
638                // `#`-to-EOL comment — stop tracking bracket
639                // depth and string state inside it so a stray
640                // apostrophe or `(` in the comment doesn't
641                // imbalance later real-code parsing.
642                in_line_comment = true;
643                buf.push(ch);
644            }
645            '/' if matches!(chars.peek(), Some('/')) => {
646                // `//`-to-EOL comment, same treatment as `#`.
647                in_line_comment = true;
648                buf.push(ch);
649            }
650            '(' | '[' | '{' => {
651                depth += 1;
652                buf.push(ch);
653            }
654            ')' | ']' | '}' => {
655                if depth > 0 {
656                    depth -= 1;
657                }
658                buf.push(ch);
659            }
660            '\n' => {
661                if depth > 0 {
662                    buf.push(' ');
663                } else {
664                    out.push(std::mem::take(&mut buf));
665                }
666            }
667            _ => buf.push(ch),
668        }
669    }
670    if !buf.is_empty() {
671        out.push(buf);
672    }
673    out
674}
675
676/// Format a `Value` as a natural-form string suitable for
677/// passing to [`BindingScope::add_param_binding`]. `add_param_binding`
678/// recognises u64/f64-shaped strings, the literals `true` / `false`,
679/// and quotes everything else.
680///
681/// Returns `None` for value types that can't be represented as a
682/// Polydat source literal (`Bytes`, `Json`, `Ext`, `Handle`, vectors,
683/// `None`). The synthesizer falls back to extern cascade in
684/// those cases, since promoted-final inlining only works when
685/// the value can round-trip through source.
686/// Parse an `input` declaration line and insert each declared
687/// slot name into `out`. Accepts both surface forms:
688///
689/// - `input cycle: u64`       — bare single (with or without type)
690/// - `input (a: u64, b: f64)` — tuple form
691///
692/// The leading `input ` keyword may be present or absent — the
693/// scanner tolerates a coord line stored without the keyword (older
694/// emit paths) by falling back to treating the body as the slot
695/// list. Empty / unparseable input is a no-op.
696pub(crate) fn scan_input_decl_names(line: &str, out: &mut HashSet<String>) {
697    let body = line.trim().strip_prefix("input ").unwrap_or(line.trim());
698    let body = body.trim();
699    if let Some(inner) = body.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
700        for part in inner.split(',') {
701            let name = part.trim().split(':').next().unwrap_or("").trim();
702            if !name.is_empty() {
703                out.insert(name.to_string());
704            }
705        }
706        return;
707    }
708    let name = body.split(':').next().unwrap_or("").trim();
709    if !name.is_empty() {
710        out.insert(name.to_string());
711    }
712}
713
714/// Render an `input` declaration line for one or more slot names,
715/// matching the two surface forms produced by the parser:
716///
717/// - one name           → `input <name>: u64\n`
718/// - multiple names     → `input (<a>: u64, <b>: u64, ...)\n`
719///
720/// All slots default to `u64` here — the runtime emits these for
721/// propagated parent inputs, which are already u64-typed today.
722/// A future port-type-aware pass can plumb the parent's declared
723/// type through if/when non-u64 inputs become common.
724fn format_input_decl_line(names: &[String]) -> String {
725    match names {
726        [] => String::new(),
727        [single] => format!("input {single}: u64\n"),
728        many => {
729            let typed: Vec<String> = many.iter().map(|n| format!("{n}: u64")).collect();
730            format!("input ({})\n", typed.join(", "))
731        }
732    }
733}
734
735fn parse_modifier_and_name(lhs: &str) -> (ScopeModifier, &str) {
736    // Strip every recognised modifier prefix before classifying;
737    // multi-modifier forms like `volatile final` or `final shared`
738    // only see a single ScopeModifier tag at the scope-assembly
739    // level (the most-distinctive one wins), but the bare name
740    // still gets extracted correctly. The eventual Polydat compile
741    // sees the full source line with all keywords intact.
742    let mut rest = lhs;
743    let mut tag = ScopeModifier::None;
744    loop {
745        let prev = rest;
746        if let Some(r) = rest.strip_prefix("shared ") {
747            rest = r.trim();
748            // `final` / `init` / `cursor` are more distinctive
749            // than `shared` for shadow / kind checks; only set
750            // tag if we don't already have a stronger one.
751            if matches!(tag, ScopeModifier::None | ScopeModifier::Volatile) {
752                tag = ScopeModifier::Shared;
753            }
754        } else if let Some(r) = rest.strip_prefix("const ") {
755            // `const` is the current spelling of the Final tier
756            // (init/final retired) — same tag as legacy `final`.
757            rest = r.trim();
758            tag = ScopeModifier::Final;
759        } else if let Some(r) = rest.strip_prefix("final ") {
760            rest = r.trim();
761            tag = ScopeModifier::Final;
762        } else if let Some(r) = rest.strip_prefix("volatile ") {
763            rest = r.trim();
764            if matches!(tag, ScopeModifier::None) {
765                tag = ScopeModifier::Volatile;
766            }
767        } else if let Some(r) = rest.strip_prefix("init ") {
768            rest = r.trim();
769            tag = ScopeModifier::Init;
770            break; // init is a kind-keyword; no modifiers come after it.
771        } else if let Some(r) = rest.strip_prefix("cursor ") {
772            rest = r.trim();
773            tag = ScopeModifier::Cursor;
774            break;
775        }
776        if rest == prev {
777            break;
778        }
779    }
780    (tag, rest)
781}
782
783// Build a `BindingScope` from phase ops and execution context.
784//
785// This is the main entry point that replaces the string mutation
786// pipeline in the executor. It:
787// 1. Classifies each op's bindings by origin (Inherited vs Op)
788// 2. Adds iteration variables
789// 3. Generates auto-externs from the outer manifest
790// 4. Expands workload params
791// 5. Extracts inline expressions
792// 6. Collects required outputs from op templates
793// =========================================================================
794// For-each / comprehension scope kernel synthesis (M3.2)
795// =========================================================================
796//
797// The for_each / for_combinations / for_each_union synthesizer
798// lives in `polydat::iteration::comprehension::synthesis::synthesize_for_each_scope`.
799// Callers in this crate go directly to that entry point; this
800// module retains only the do-loop synthesizer below, since
801// do_while / do_until aren't comprehensions.
802
803/// Build the per-scope Polydat Kernel for a `do_while` / `do_until`
804/// node (SRD 18b). Same composition contract as for_each
805/// (every name visible at this scope resolves through standard
806/// Polydat API on the synthesized kernel) — the difference is the
807/// "scope output" is a `counter: u64` rather than tuple
808/// iteration variables, and there's no value list to pre-eval.
809///
810/// Source shape:
811///
812/// ```text
813///   extern <inherited_name>: <type>      # one per name
814///                                        # referenced in
815///                                        # `condition` text
816///                                        # that exists in
817///                                        # the parent
818///                                        # manifest or
819///                                        # workload params
820///   final <inherited_param> := <literal> # workload-param
821///                                        # injection (M3.3
822///                                        # bridge until M3.6)
823///   extern <counter>: u64                # only when counter
824///                                        # is `Some`
825/// ```
826///
827/// Per iteration the runtime sets `counter` (if present) via
828/// `kernel.state().set_input` and evaluates the condition
829/// expression against the kernel via `interpolate_via_kernel` +
830/// `eval_const_expr`. Children inherit `counter` (and any
831/// inherited names) through standard `materialize_wiring_from_outer`.
832/// Synthesize the Polydat scope kernel for a phase that carries its own
833/// `bindings:` block.
834///
835/// The phase scope owns this kernel as part of its closure lifetime
836/// (per the Polydat builder/walk/instancing protocol): a phase whose YAML
837/// declared `bindings: |` produces matter that the parent kernel's
838/// builds a child scope from (`ScopeKernel::build_under`). Op-template
839/// scopes that descend from this phase find these bindings as
840/// outputs of their parent kernel and `extern` them through the
841/// standard manifest cascade.
842///
843/// Phases without bindings AND without `for_each:` produce no install
844/// spec — their scope-tree node carries an empty `cached_kernel` and
845/// the parent walker resolves through to the nearest ancestor with a
846/// kernel. The closure invariant ("every scope has a kernel
847/// reference") still holds; the reference is just the parent's,
848/// matter-gated as the Polydat APIs prescribe.
849///
850/// Source emitted (in order):
851///
852/// ```text
853///   final <workload_param> := <literal>     # cascaded params
854///   extern <ancestor_output>: <type>        # cascaded outputs/inputs
855///   <phase_bindings_body>                   # phase-declared bindings
856/// ```
857///
858/// The phase's bindings body is appended verbatim so the Polydat compiler
859/// classifies them per the same rules as op-level bindings (init /
860/// shared / final detection, type inference). Iteration coordinate
861/// (`cycle`) cascades from the parent — we never re-declare it here.
862/// Compose a phase scope's bindings source with SRD-75
863/// phase-poll augmentation and/or phase-level `metrics:`
864/// augmentation when applicable. Returns the existing
865/// `BindingsDef` unchanged when the phase has neither `poll:`
866/// nor `metrics:`; otherwise produces a
867/// `BindingsDef::PolydatSource` that, for the poll case:
868///
869/// - **Prepends** `shared <name>: u64 := 0` for each
870///   capture name declared by the phase's ops. The
871///   shared-cell modifier means TraversingDispenser
872///   writes via `ctx.wires.write` propagate to the
873///   phase scope kernel (SRD-13c §"Implementation:
874///   SharedCell-backed input slots" §4 "Write
875///   through"); same cell is observable to other ops'
876///   `if:` reads in the same iteration AND to the
877///   `__poll_until` predicate evaluated on the phase
878///   kernel.
879/// - **Appends** the original `phase.bindings` body
880///   verbatim (the author's bindings shadow synthesized
881///   captures by ident if any name collides — same
882///   shadowing rule as anywhere else in GK; we err out
883///   in that case for clarity).
884/// - **Appends** `__poll_until := <until>` as a regular
885///   cycle binding (dynamic lifecycle per SRD-11 §"Two
886///   Evaluation Lifecycles" — re-evaluates per pull as
887///   capture cells update).
888///
889/// For the metrics case it appends an `extern phase_start: u64
890/// = 0` origin wire (set by the executor at the completion-time
891/// pull) and one `volatile __metric_<name> := <value>` per
892/// declared phase metric. `volatile` on the `__metric_<name>`
893/// binding excludes it from const-fold identity; a value that
894/// reads a clock should declare that read as its own `volatile`
895/// phase binding (e.g. `volatile now_ms := current_epoch_millis()`,
896/// metric `value: now_ms - phase_start`) so the non-deterministic
897/// node is acknowledged directly and the kernel compiles under
898/// `--strict`. The executor pulls each `__metric_<name>` once at
899/// phase completion and records it on the phase component.
900///
901/// Type inference is intentionally narrow in the
902/// initial ship: every capture lands as `u64` (the
903/// shape the canonical synchronizer-pattern workload
904/// uses — `:count` reductions and numeric Jolokia
905/// attribute reads). Non-numeric captures aren't
906/// supported in the phase-poll predicate; the
907/// workload author falls back to a different pattern.
908/// SRD-75 §"Open questions: Capture-type inference
909/// granularity" tracks the refinement.
910pub fn synthesize_phase_scope_bindings(
911    phase: &nmbrs_workload::model::WorkloadPhase,
912) -> Result<nmbrs_workload::model::BindingsDef, String> {
913    use nmbrs_workload::model::BindingsDef;
914    let has_poll = phase.poll.is_some();
915    let has_metrics = !phase.metrics.is_empty();
916    // SRD-86 — an `optimize.objective` that is an inline expression (not a bare
917    // wire reference) is lowered to a synthesized `__objective` binding below.
918    let has_objective_expr = phase
919        .optimize
920        .as_ref()
921        .is_some_and(|o| !objective_is_bare_wire(&o.objective));
922    // No phase-scope augmentation needed — pass the author's bindings
923    // through unchanged so a metrics/poll-free phase keeps its original
924    // (possibly empty) `BindingsDef`. Stop conditions do NOT augment the
925    // matter (they are scope-bound `ScopedPredicate`s built against the phase
926    // kernel), so they don't force synthesis here.
927    if !has_poll && !has_metrics && !has_objective_expr {
928        return Ok(phase.bindings.clone());
929    }
930
931    // Walk every op in the phase, collect declared
932    // capture names. The capture set is the union across
933    // ops in the phase (the synchronizer pattern's
934    // common case: one read-op captures all the state,
935    // a second trigger-op only reads via `if:`). Only
936    // relevant to the poll augmentation; empty otherwise.
937    let mut capture_names: Vec<String> = Vec::new();
938    let mut seen: HashSet<String> = HashSet::new();
939    if has_poll {
940        for op in &phase.ops {
941            for cap in &op.captures {
942                if seen.insert(cap.as_name.clone()) {
943                    capture_names.push(cap.as_name.clone());
944                }
945            }
946        }
947    }
948
949    // Compose the augmented source.
950    let mut source = String::new();
951    if has_poll {
952        source.push_str(
953            "# SRD-75 phase-poll augmentation — synthesized.\n\
954             # Captures land here as shared cells; ops write through via\n\
955             # `ctx.wires.write` on their op-template kernel's import\n\
956             # slots (Rule 1 shared import → cell attached at spawn).\n",
957        );
958        for name in &capture_names {
959            // Polydat's `shared X := <literal>` form infers the type
960            // from the RHS literal (per SRD-13c
961            // §"Implementation: SharedCell-backed input slots"
962            // — `shared X := 0` lands as u64 via literal-fold).
963            // Captures default to u64 with init 0; numeric
964            // predicates compare cleanly, and TraversingDispenser's
965            // json_to_value coerces the response value into the
966            // matching Value variant.
967            source.push_str(&format!("shared {name} := 0\n"));
968        }
969    }
970
971    let original_body: String = match &phase.bindings {
972        BindingsDef::PolydatSource(s) => s.clone(),
973        BindingsDef::Map(m) => {
974            let mut out = String::new();
975            for (n, e) in m {
976                out.push_str(&format!("{n} := {e}\n"));
977            }
978            out
979        }
980    };
981    if !original_body.trim().is_empty() {
982        // Detect collisions between author-declared
983        // bindings and synthesized captures. A collision
984        // is most likely a workload bug (the author
985        // didn't realize the name was being synthesized
986        // by phase-poll); fail loudly per SRD-30 unknown-
987        // field hygiene rather than silently letting one
988        // win.
989        let locally_declared = scan_locally_declared_idents(&original_body);
990        for name in &capture_names {
991            if locally_declared.contains(name) {
992                return Err(format!(
993                    "phase-poll synthesis: capture name '{name}' is also \
994                     declared by the phase's `bindings:` block — pick one. \
995                     SRD-75 synthesizes captures as `shared <name>: u64`; \
996                     re-declaring the same name in bindings is a collision."
997                ));
998            }
999        }
1000        source.push_str(&original_body);
1001        if !source.ends_with('\n') {
1002            source.push('\n');
1003        }
1004    }
1005
1006    if has_poll {
1007        // The predicate is a regular cycle binding (NOT
1008        // `const` — its upstream depends on per-cycle
1009        // capture writes, so it must re-evaluate per pull).
1010        let poll = phase.poll.as_ref().expect("has_poll");
1011        source.push_str(&format!("__poll_until := {}\n", poll.until));
1012    }
1013
1014    if has_metrics {
1015        // Phase-level `metrics:` — emit one `volatile
1016        // __metric_<name> := <value>` per declared metric, plus the
1017        // executor-injected `phase_start` origin wire. `volatile`
1018        // acknowledges the nondeterminism of clock-reading values
1019        // like `phase_elapsed(phase_start)` so the phase kernel
1020        // compiles in strict mode (and excludes them from const-fold
1021        // identity); for a deterministic value it is harmless. The
1022        // executor pulls each `__metric_<name>` once at phase
1023        // completion — see `executor::emit_phase_metrics`.
1024        // `phase_start` binds the runtime's phase-scoped clock node rather
1025        // than an extern the executor has to remember to fill. As an extern it
1026        // was set ONLY on the completion-time metric pull, on a fresh subscope
1027        // — so anything reading it while the phase was still running (an op,
1028        // most obviously) saw the declared default of 0 and silently computed
1029        // against the epoch. The node reads the phase origin the executor
1030        // scopes in `run_phase`, so the value is correct from the phase's
1031        // first op onward, and `volatile` keeps it out of const-fold identity.
1032        source.push_str(
1033            "# Phase-level metrics — synthesized.\n\
1034             # `phase_start` is the epoch millis at which THIS phase started,\n\
1035             # read from the runtime's phase-scoped clock.\n\
1036             volatile phase_start := phase_start_millis()\n",
1037        );
1038        let mut entries: Vec<_> = phase.metrics.iter().collect();
1039        entries.sort_by(|a, b| a.0.cmp(b.0));
1040        for (name, spec) in entries {
1041            let binding = synthesize_metric_binding_name(name);
1042            source.push_str(&format!(
1043                "volatile {binding} := {expr}\n",
1044                expr = spec.value
1045            ));
1046            // See the op-template path: a coordinate is compiled matter, not a
1047            // runtime string. `volatile` for the same reason the value is —
1048            // a coordinate read from a capture or a clock must not be folded
1049            // into one phase's value.
1050            for (dim, expr) in &spec.cell {
1051                let cell_binding = synthesize_cell_binding_name(name, dim);
1052                source.push_str(&format!("volatile {cell_binding} := {expr}\n"));
1053            }
1054        }
1055    }
1056
1057    if has_objective_expr {
1058        // SRD-86 — lower an inline `optimize.objective` expression to one wire
1059        // the optimizer reads (`__objective`), so an author can write the
1060        // objective inline instead of pre-declaring a `bindings:` entry.
1061        // Emitted LAST so it can reference any author binding or synthesized
1062        // `__metric_<name>` above it. `volatile` lets a metricsql/clock-reading
1063        // objective compile under `--strict` and is harmless for a
1064        // deterministic expression; the settle-vs-one-shot routing keys off
1065        // program-wide reader-node presence, not this flag, so a deterministic
1066        // inline objective still takes the one-shot path.
1067        let objective = &phase
1068            .optimize
1069            .as_ref()
1070            .expect("has_objective_expr")
1071            .objective;
1072        source.push_str(&format!(
1073            "# SRD-86 inline objective — synthesized.\n\
1074             volatile {OBJECTIVE_WIRE} := {objective}\n",
1075        ));
1076    }
1077
1078    // SRD-83 stop conditions are NOT synthesized into the phase matter.
1079    // Each predicate is compiled as a scope-bound `ScopedPredicate`
1080    // (`stop_conditions::compile_stop_condition`) against the *built*
1081    // phase kernel and evaluated per trigger — authored phase bindings
1082    // and evaluated stop predicates stay orthogonal concerns.
1083
1084    Ok(BindingsDef::PolydatSource(source))
1085}
1086
1087/// Synthesize a phase-scope kernel.
1088///
1089/// Pushes the phase's own `bindings:` body verbatim, then drives
1090/// the shared cascade walker to declare every parent-visible
1091/// name the body references plus the standard workload-param +
1092/// parent-output + parent-input cascade.
1093// reason: cohesive scope-kernel constructor — each argument is a distinct
1094// piece of the scope's compile context (bindings, parent kernel, params,
1095// cascade inputs); a parameter struct would only relocate the same fields.
1096#[allow(clippy::too_many_arguments)]
1097pub fn build_phase_scope_kernel(
1098    bindings: &nmbrs_workload::model::BindingsDef,
1099    parent_manifest: &[crate::runner::ManifestEntry],
1100    parent_kernel: &crate::scope_kernel::ScopeKernel,
1101    workload_params: &HashMap<String, String>,
1102    polydat_lib_paths: Vec<std::path::PathBuf>,
1103    workload_dir: Option<&std::path::Path>,
1104    strict: bool,
1105    context: &str,
1106) -> Result<crate::scope_kernel::ScopeKernel, String> {
1107    use nmbrs_workload::model::BindingsDef;
1108
1109    let body_text: String = match bindings {
1110        BindingsDef::PolydatSource(s) => s.clone(),
1111        BindingsDef::Map(m) => {
1112            let mut out = String::new();
1113            for (name, expr) in m {
1114                out.push_str(&format!("{name} := {expr}\n"));
1115            }
1116            out
1117        }
1118    };
1119
1120    let mut source = String::new();
1121    let mut emitted: HashSet<String> = HashSet::new();
1122    let mut inherited_names: Vec<String> = Vec::new();
1123
1124    // Discover the names the phase body references and the
1125    // names it locally declares (which shadow parent-output
1126    // cascade per SRD-13f).
1127    let body_locally_declared = scan_locally_declared_idents(&body_text);
1128    let mut referenced: HashSet<String> = HashSet::new();
1129    collect_string_interp_refs(&body_text, &mut referenced);
1130    for ident in scan_idents_in_polydat_source(&body_text) {
1131        if !body_locally_declared.contains(&ident) {
1132            referenced.insert(ident);
1133        }
1134    }
1135
1136    // Drive the shared cascade walker. The phase body's
1137    // locally-declared idents go into both pre_emitted (so the
1138    // walker doesn't re-cascade them) and shadow_names (so the
1139    // workload-param + parent-output passes also skip them).
1140    crate::scope_synth::cascade_parent_into_source(
1141        crate::scope_synth::CascadeInputs {
1142            parent_kernel,
1143            workload_params,
1144            parent_manifest,
1145            referenced: &referenced,
1146            pre_emitted: &body_locally_declared,
1147            shadow_names: &body_locally_declared,
1148            // Phase opts out: the body may already declare
1149            // referenced names as `input` / `extern` and a
1150            // step-3 emission would collide. Phase relies on
1151            // step 5 (parent-output cascade) and step 6
1152            // (parent-input cascade) to bring in what the body
1153            // doesn't declare locally.
1154            include_referenced_cascade: false,
1155        },
1156        crate::scope_synth::CascadeOutputs {
1157            source: &mut source,
1158            emitted: &mut emitted,
1159            inherited_names: &mut inherited_names,
1160        },
1161    );
1162
1163    // Append the phase's own bindings body verbatim. The GK
1164    // compiler classifies each statement (init / shared / final)
1165    // per the standard rules.
1166    if !source.ends_with('\n') && !source.is_empty() {
1167        source.push('\n');
1168    }
1169    source.push_str(&body_text);
1170    if !source.ends_with('\n') {
1171        source.push('\n');
1172    }
1173
1174    let compile_options = polydat::kernel::subcontext::CompileOptions {
1175        workload_dir: workload_dir.map(|p| p.to_path_buf()),
1176        polydat_lib_paths,
1177        strict,
1178        required_outputs: Vec::new(),
1179        context_label: Some(context.to_string()),
1180        cursor_limit: None,
1181        ..Default::default()
1182    };
1183
1184    if std::env::var("NMBRS_DEBUG_SCOPE_SYNTH")
1185        .map(|v| v == "1")
1186        .unwrap_or(false)
1187    {
1188        eprintln!(
1189            "=== SCOPE SYNTH [{context}] inherited={inherited_names:?} ===\n{source}\n=== END ==="
1190        );
1191    }
1192    crate::scope_kernel::ScopeKernel::synthesize_under(
1193        parent_kernel,
1194        crate::scope_kernel::SourceMatter::source(context, source, compile_options)
1195            .inherited(inherited_names),
1196    )
1197    .map_err(|e| format!("{context}: phase scope synthesis: {e}"))
1198}
1199
1200// reason: cohesive scope-kernel constructor — each argument is a distinct
1201// piece of the do-loop's compile context (counter, condition, parent kernel,
1202// params, cascade inputs); a parameter struct would only relocate the fields.
1203#[allow(clippy::too_many_arguments)]
1204pub fn build_do_loop_scope_kernel(
1205    counter: Option<&str>,
1206    condition: &str,
1207    parent_manifest: &[crate::runner::ManifestEntry],
1208    parent_kernel: &crate::scope_kernel::ScopeKernel,
1209    workload_params: &HashMap<String, String>,
1210    polydat_lib_paths: Vec<std::path::PathBuf>,
1211    workload_dir: Option<&std::path::Path>,
1212    strict: bool,
1213    context: &str,
1214) -> Result<crate::scope_kernel::ScopeKernel, String> {
1215    // Scope-specific contribution: declare the counter extern
1216    // (if a counter is in play). The counter is the do-loop's
1217    // only own-iter wire; pre-emit it so the shared cascade
1218    // walker doesn't try to type-cascade it from the parent
1219    // (which doesn't know about it).
1220    let mut source = String::new();
1221    let mut emitted: HashSet<String> = HashSet::new();
1222    let mut inherited_names: Vec<String> = Vec::new();
1223
1224    if let Some(c) = counter {
1225        source.push_str(&format!("extern {c}: u64\n"));
1226        emitted.insert(c.to_string());
1227    }
1228
1229    // Drive the shared cascade walker. `referenced` is the set
1230    // of `{name}` placeholders the condition expression
1231    // mentions; pre_emitted is the counter (if any).
1232    let referenced = collect_leaf_placeholders(&[condition.to_string()]);
1233    let pre_emitted: HashSet<String> = counter.iter().map(|c| c.to_string()).collect();
1234    let shadow_names: HashSet<String> = pre_emitted.clone();
1235    crate::scope_synth::cascade_parent_into_source(
1236        crate::scope_synth::CascadeInputs {
1237            parent_kernel,
1238            workload_params,
1239            parent_manifest,
1240            referenced: &referenced,
1241            pre_emitted: &pre_emitted,
1242            shadow_names: &shadow_names,
1243            // do-loop opts in: the condition expression is
1244            // narrowly-scoped Polydat source that references
1245            // names which need extern declarations to be
1246            // resolvable. Step 3 emits those externs against
1247            // parent_manifest's types.
1248            include_referenced_cascade: true,
1249        },
1250        crate::scope_synth::CascadeOutputs {
1251            source: &mut source,
1252            emitted: &mut emitted,
1253            inherited_names: &mut inherited_names,
1254        },
1255    );
1256
1257    if source.is_empty() {
1258        source.push_str("const __empty := 0\n");
1259    }
1260
1261    // The same compile options every synthesized scope takes: the
1262    // workload's library paths and directory, so the condition can call
1263    // library modules and name workload-relative files, and strict mode.
1264    let compile_options = polydat::kernel::subcontext::CompileOptions {
1265        workload_dir: workload_dir.map(|p| p.to_path_buf()),
1266        polydat_lib_paths,
1267        strict,
1268        required_outputs: Vec::new(),
1269        context_label: Some(context.to_string()),
1270        cursor_limit: None,
1271        ..Default::default()
1272    };
1273    crate::scope_kernel::ScopeKernel::synthesize_under(
1274        parent_kernel,
1275        crate::scope_kernel::SourceMatter::source(context, source, compile_options)
1276            .inherited(inherited_names),
1277    )
1278    .map_err(|e| format!("{context}: do-loop scope synthesis: {e}"))
1279}
1280
1281/// Token-shaped identifier scan over Polydat source. Returns every
1282/// alphanumeric/underscore-shaped token that isn't a keyword
1283/// or numeric literal. Used by
1284/// [`build_op_template_scope_kernel`] to discover names the
1285/// op's bindings body references; the Polydat compiler does the
1286/// authoritative parse downstream — this scan is just a
1287/// best-effort first pass for the cross-scope contract check.
1288pub(crate) fn scan_idents_in_polydat_source(src: &str) -> HashSet<String> {
1289    const KEYWORDS: &[&str] = &[
1290        "input", "extern", "const", "final", "init", "shared", "volatile", "cursor", "pragma",
1291        "true", "false", "as", "in", "for",
1292        // Block-form conditional selection: `if <cond> { a } else { b }`. This is a
1293        // TEXTUAL scan, so it cannot tell a keyword from a wire name by position —
1294        // before these were listed, a binding using the block form reported `if` and
1295        // `else` as unresolved wire references. The call form `if(cond, a, b)` was
1296        // unaffected and so did not surface this.
1297        "if", "else",
1298    ];
1299    let mut out = HashSet::new();
1300    let mut chars = src.chars().peekable();
1301    let mut current = String::new();
1302    let mut in_string = false;
1303    let mut in_line_comment = false;
1304    let mut in_block_comment = false;
1305    // Suppress the next ident — set when we just saw a `:` outside
1306    // strings/comments. Lets us skip the type token in declarations
1307    // like `extern x: u64`, `input cycle: u64`, `input (a: u64, b: f64)`,
1308    // and module signatures `foo(p: u64) -> (q: f64)`. A type name
1309    // (`u64`/`f64`/`Str`/etc.) is not a wire reference.
1310    let mut suppress_next_ident = false;
1311    while let Some(c) = chars.next() {
1312        if in_line_comment {
1313            if c == '\n' {
1314                in_line_comment = false;
1315            }
1316            continue;
1317        }
1318        if in_block_comment {
1319            if c == '*' && chars.peek() == Some(&'/') {
1320                chars.next();
1321                in_block_comment = false;
1322            }
1323            continue;
1324        }
1325        if in_string {
1326            if c == '\\' {
1327                chars.next();
1328                continue;
1329            }
1330            if c == '"' {
1331                in_string = false;
1332            }
1333            continue;
1334        }
1335        if c == '"' {
1336            in_string = true;
1337            continue;
1338        }
1339        // `#` to end-of-line — YAML-style hash comment, matching the
1340        // Polydat lexer (`dsl/lexer.rs` "Skip hash comments"). Without
1341        // this, comment words inside a `bindings: |` block leak in as
1342        // phantom wire references.
1343        if c == '#' {
1344            in_line_comment = true;
1345            continue;
1346        }
1347        if c == '/' {
1348            if chars.peek() == Some(&'/') {
1349                chars.next();
1350                in_line_comment = true;
1351                continue;
1352            }
1353            if chars.peek() == Some(&'*') {
1354                chars.next();
1355                in_block_comment = true;
1356                continue;
1357            }
1358        }
1359        if c.is_alphanumeric() || c == '_' {
1360            current.push(c);
1361        } else if !current.is_empty() {
1362            // Token boundary — is `current` an ident?
1363            let is_ident = !current
1364                .chars()
1365                .next()
1366                .map(|c| c.is_ascii_digit())
1367                .unwrap_or(true)
1368                && !KEYWORDS.contains(&current.as_str());
1369            if is_ident && !suppress_next_ident {
1370                out.insert(current.clone());
1371            }
1372            current.clear();
1373            suppress_next_ident = false;
1374        }
1375        // Track `:` separately so the next ident is treated as a
1376        // type annotation. Excludes `:=` (the binding operator) by
1377        // peeking ahead.
1378        if c == ':' && chars.peek() != Some(&'=') {
1379            suppress_next_ident = true;
1380        }
1381    }
1382    if !current.is_empty()
1383        && !current
1384            .chars()
1385            .next()
1386            .map(|c| c.is_ascii_digit())
1387            .unwrap_or(true)
1388        && !KEYWORDS.contains(&current.as_str())
1389        && !suppress_next_ident
1390    {
1391        out.insert(current);
1392    }
1393    out
1394}
1395
1396/// Names declared on the LHS of `:=` (or `=` for init bindings)
1397/// in Polydat source. Locally-declared names shadow parent-scope
1398/// references per SRD-13c §"Shadowing", so the cross-scope
1399/// contract check skips them.
1400pub(crate) fn scan_locally_declared_idents(src: &str) -> HashSet<String> {
1401    let mut out = HashSet::new();
1402    for line in src.lines() {
1403        let line = line.trim();
1404        // Skip blanks + line comments. `#` (YAML-style) and `//` are
1405        // both Polydat line-comment forms (`dsl/lexer.rs`); neither
1406        // declares a binding.
1407        if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
1408            continue;
1409        }
1410        // Drop modifier prefixes (`final const x := …`,
1411        // `shared y := …`) so the LHS ident is always the
1412        // last word before `:=` or `=`.
1413        let prefixes = [
1414            "shared const ",
1415            "const ",
1416            "init ",
1417            "shared ",
1418            "final ",
1419            "volatile ",
1420            "extern ",
1421        ];
1422        let mut rest = line;
1423        loop {
1424            let mut stripped = false;
1425            for p in &prefixes {
1426                if let Some(r) = rest.strip_prefix(p) {
1427                    rest = r.trim_start();
1428                    stripped = true;
1429                    break;
1430                }
1431            }
1432            if !stripped {
1433                break;
1434            }
1435        }
1436        // Find `:=` or `=` (but not `==`).
1437        let assign_idx = rest.find(":=").or_else(|| {
1438            rest.find('=')
1439                .filter(|&i| rest.as_bytes().get(i + 1) != Some(&b'='))
1440        });
1441        let Some(idx) = assign_idx else { continue };
1442        let lhs = rest[..idx].trim();
1443        // `extern name: type` — the lhs is "name: type"; strip the type.
1444        let name = lhs.split(':').next().unwrap_or(lhs).trim();
1445        if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
1446            out.insert(name.to_string());
1447        }
1448    }
1449    out
1450}
1451
1452// SRD-13c `ParentRefKind` classification and the
1453// `classify_parent_ref` helper were retired by SRD-13f. The
1454// snapshot-vs-shared contract they enforced is superseded by
1455// uniform construction-time wiring + per-cycle refresh on
1456// non-shared parent outputs (SRD-13f §"Materialization gradient").
1457
1458/// SRD-13d Phase 9 — synthesize a per-op-template kernel for a
1459/// materialised op-template scope.
1460///
1461/// Mirrors [`build_do_loop_scope_kernel`] but uses the op's
1462/// `bindings:` block (which already carries metric `:=` injections
1463/// per SRD-40b §1) as the body. The resulting kernel:
1464///
1465/// 1. Emits an `extern <name>: <type>` for every parent-visible
1466///    name the op explicitly references — and only those, so
1467///    the op-template kernel stays narrow.
1468/// 2. Validates each reference against the SRD-13c cross-scope
1469///    visibility contract: `Input`, `SharedOutput`, or
1470///    `ConstantOutput` are accepted; `DynamicOutput` errors at
1471///    workload-init time with a clear message pointing at the
1472///    `shared` modifier path.
1473/// 3. Emits the op's own bindings.
1474/// 4. Builds the scope under the parent kernel and propagates
1475///    inherited inputs. The scope keeps its module, from which each
1476///    fiber instantiates its per-op kernel with the module's
1477///    `result:` write-throughs ([`crate::fiber_engine`]).
1478// reason: cohesive scope-kernel constructor — each argument is a distinct
1479// piece of the op-template's compile context (op, parent kernel, params,
1480// cascade inputs); a parameter struct would only relocate the same fields.
1481#[allow(clippy::too_many_arguments)]
1482pub fn build_op_template_scope_kernel(
1483    op: &nmbrs_workload::model::ParsedOp,
1484    parent_manifest: &[crate::runner::ManifestEntry],
1485    parent_kernel: &crate::scope_kernel::ScopeKernel,
1486    workload_params: &HashMap<String, String>,
1487    polydat_lib_paths: Vec<std::path::PathBuf>,
1488    workload_dir: Option<&std::path::Path>,
1489    strict: bool,
1490    kernel_opt: polydat::kernel::KernelOptLevel,
1491    context: &str,
1492) -> Result<crate::scope_kernel::ScopeKernel, String> {
1493    use nmbrs_workload::model::BindingsDef;
1494
1495    let manifest_by_name: HashMap<&str, &crate::runner::ManifestEntry> = parent_manifest
1496        .iter()
1497        .map(|e| (e.name.as_str(), e))
1498        .collect();
1499
1500    let mut source = String::new();
1501    let mut emitted: HashSet<String> = HashSet::new();
1502    let mut inherited_names: Vec<String> = Vec::new();
1503
1504    // SRD-13f: every parent-visible wire the op template
1505    // references — whether in body, condition, delay, metric
1506    // values, op fields (`stmt`, `uri`, `body`, etc.), or
1507    // string-interpolation arguments inside the body — gets
1508    // wired into the local op-template kernel at construction.
1509    // Local reads on the op-template kernel then resolve every
1510    // such name through the local read API, with construction-
1511    // time wiring guaranteeing the read invariant (inner reads
1512    // return the value that outer would return — see SRD-13f
1513    // §"The read invariant"). The wires layer takes one kernel
1514    // handle and never composes chains externally.
1515    let body_text: String = match &op.bindings {
1516        BindingsDef::PolydatSource(s) => s.clone(),
1517        BindingsDef::Map(m) => {
1518            let mut out = String::new();
1519            for (name, expr) in m {
1520                out.push_str(&format!("{name} := {expr}\n"));
1521            }
1522            out
1523        }
1524    };
1525
1526    // SRD-13f Push D: declare the parent's coordinate inputs
1527    // (typically just `cycle`) on this op-template kernel.
1528    // Pre-Push-D, the workload-level `input <name>: <type>` line
1529    // was merged into op.bindings and arrived via body_text, so
1530    // the kernel always had a Coordinate slot. After Push D
1531    // body_text no longer carries it, so we emit the declaration
1532    // explicitly — without it the op-template kernel has no
1533    // input slot for cycle and the runtime's per-cycle
1534    // `set_inputs` writes go nowhere.
1535    let body_has_inputs_decl = body_text
1536        .lines()
1537        .any(|line| line.trim_start().starts_with("input "));
1538    if !body_has_inputs_decl {
1539        let parent_coord_names: Vec<String> = parent_kernel
1540            .program()
1541            .input_names()
1542            .into_iter()
1543            .take(parent_kernel.program().coord_count())
1544            .collect();
1545        if !parent_coord_names.is_empty() {
1546            source.push_str(&format_input_decl_line(&parent_coord_names));
1547            for name in &parent_coord_names {
1548                emitted.insert(name.clone());
1549            }
1550        }
1551    }
1552    // SRD-109 Part 3 — `results:` interface wires are dispenser-
1553    // delivered projections (`result:` path entries evaluated by
1554    // the result wrapper); declare each as an explicit extern at
1555    // its interface type so the per-cycle `ctx.wires.write` lands
1556    // on a correctly-typed slot. Emitted before the cascade scan
1557    // so a relevancy/metric reference to the same name doesn't
1558    // try to pull it from the parent.
1559    if let Some(iface) = op.abstract_interface.as_ref() {
1560        for (rname, kw) in &iface.results {
1561            if polydat::ast::PortType::from_keyword(kw).is_none() {
1562                return Err(format!(
1563                    "{context}: interface results wire '{rname}' declares \
1564                     unknown type '{kw}'"
1565                ));
1566            }
1567            if !emitted.contains(rname) {
1568                source.push_str(&format!("extern {rname}: {kw}\n"));
1569                emitted.insert(rname.clone());
1570            }
1571        }
1572    }
1573
1574    let body_idents = scan_idents_in_polydat_source(&body_text);
1575    let body_locally_declared = scan_locally_declared_idents(&body_text);
1576    let mut referenced: Vec<String> = Vec::new();
1577    // Op field references — `stmt`, `uri`, `body`, etc. carry
1578    // `{name}` placeholders the dispenser substitutes at cycle
1579    // time. Those wires must be reachable on the op-template
1580    // kernel so the dispenser's single-kernel-handle wires
1581    // surface resolves them through the local read API.
1582    for value in op.op.values() {
1583        if let Some(s) = value.as_str() {
1584            for n in nmbrs_workload::bindpoints::referenced_bindings(s) {
1585                if !body_locally_declared.contains(&n) && !referenced.iter().any(|r| r == &n) {
1586                    referenced.push(n);
1587                }
1588            }
1589        }
1590    }
1591    for ident in &body_idents {
1592        if body_locally_declared.contains(ident) {
1593            continue;
1594        }
1595        if !referenced.iter().any(|r| r == ident) {
1596            referenced.push(ident.clone());
1597        }
1598    }
1599    // String-interpolation references inside the body (e.g.
1600    // `dataset_prebuffer("{dataset}:{profile}")`). The Polydat compiler
1601    // desugars those into wires that need a matching extern;
1602    // without this scan, the cascade misses them and the compiler
1603    // defaults the auto-extern to u64, landing a Str value into a
1604    // u64 slot at bind-time and panicking via an inserted
1605    // `__u64_to_string` adapter.
1606    let mut interp_refs: HashSet<String> = HashSet::new();
1607    collect_string_interp_refs(&body_text, &mut interp_refs);
1608    for ident in interp_refs {
1609        if body_locally_declared.contains(&ident) {
1610            continue;
1611        }
1612        if !referenced.iter().any(|r| r == &ident) {
1613            referenced.push(ident);
1614        }
1615    }
1616    if let Some(ref s) = op.condition {
1617        let n = s.trim().trim_start_matches('{').trim_end_matches('}');
1618        if !n.is_empty() && !body_locally_declared.contains(n) && !referenced.iter().any(|r| r == n)
1619        {
1620            referenced.push(n.to_string());
1621        }
1622    }
1623    // Capture targets are a WRITE usage surface of this op: the
1624    // runtime lands each captured value on this kernel's input slot
1625    // via `ctx.wires.write` (see TraversingDispenser). The name must
1626    // therefore be declared here like any other referenced wire —
1627    // the standard cascade below emits the `extern` (with the
1628    // parent's port type) and the wiring machinery binds it to the
1629    // ancestral `shared` cell when one is in scope, making the
1630    // write visible across phases. Without the declaration a
1631    // capture-only op's write is a silent NoSlot drop, and any
1632    // later phase gating on the cell reads its initializer forever.
1633    for cap in &op.captures {
1634        let n = cap.as_name.as_str();
1635        if !n.is_empty() && !body_locally_declared.contains(n) && !referenced.iter().any(|r| r == n)
1636        {
1637            referenced.push(n.to_string());
1638        }
1639    }
1640    if let Some(ref delay_spec) = op.delay {
1641        for raw in delay_spec.names() {
1642            let n = raw.trim().trim_start_matches('{').trim_end_matches('}');
1643            if !n.is_empty()
1644                && !body_locally_declared.contains(n)
1645                && !referenced.iter().any(|r| r == n)
1646            {
1647                referenced.push(n.to_string());
1648            }
1649        }
1650    }
1651    for spec in op.metrics.values() {
1652        let trimmed = spec.value.trim();
1653        let bare = !trimmed.is_empty() && trimmed.chars().all(|c| c.is_alphanumeric() || c == '_');
1654        if bare
1655            && !body_locally_declared.contains(trimmed)
1656            && !referenced.iter().any(|r| r == trimmed)
1657        {
1658            referenced.push(trimmed.to_string());
1659        }
1660    }
1661
1662    // Wire references inside the relevancy block. The validator
1663    // resolves `actual:`, `expected:`, `k:`, `r:` at wrap-time
1664    // through the dispenser's canonical kernel (parse_count_param
1665    // and the `expected_wire_name` path in `validation.rs`).
1666    // Strings that are bare identifiers OR `{name}` text-templates
1667    // name a wire that must be visible on this op-template kernel;
1668    // numeric / integer-literal values aren't wire references and
1669    // pass through.
1670    if let Some(rel) = op.params.get("relevancy").and_then(|v| v.as_object()) {
1671        for key in &["actual", "expected", "k", "r"] {
1672            let Some(val) = rel.get(*key).and_then(|v| v.as_str()) else {
1673                continue;
1674            };
1675            let trimmed = val
1676                .trim()
1677                .trim_start_matches('{')
1678                .trim_end_matches('}')
1679                .trim();
1680            if trimmed.is_empty() {
1681                continue;
1682            }
1683            // Skip numeric literals — `k: 10` is a constant, not a
1684            // wire ref.
1685            if trimmed.parse::<i64>().is_ok() {
1686                continue;
1687            }
1688            // Bare-identifier check matches the validator's
1689            // `is_bare_ident` rule.
1690            let bare = trimmed
1691                .chars()
1692                .next()
1693                .map(|c| c.is_ascii_alphabetic() || c == '_')
1694                .unwrap_or(false)
1695                && trimmed
1696                    .chars()
1697                    .all(|c| c.is_ascii_alphanumeric() || c == '_');
1698            if !bare {
1699                continue;
1700            }
1701            if !body_locally_declared.contains(trimmed) && !referenced.iter().any(|r| r == trimmed)
1702            {
1703                referenced.push(trimmed.to_string());
1704            }
1705        }
1706    }
1707
1708    // SRD-66 result-bindings: every LHS name a result-binding
1709    // declares needs an input slot on this op-template kernel so
1710    // the Rule 2 write-through can wire to the parent's SHARED
1711    // cell. Without this, the cell-bound slot is never declared,
1712    // `add_result_bindings` falls back to PortType::U64 (the
1713    // "couldn't classify" default) for the export, and the write-
1714    // through stores into a U64-typed view of what's actually a
1715    // Bool/Str/etc. cell — surfaces downstream as a "non-bool
1716    // type" pick panic.
1717    //
1718    // String-shape fragments carry full Polydat source whose LHS we
1719    // extract via `scan_locally_declared_idents`. Map-shape
1720    // fragments give the name directly.
1721    if let Some(rb) = op.result.as_ref() {
1722        rb.walk_fragments(|frag| match frag {
1723            nmbrs_workload::model::ResultFragment::Source(s) => {
1724                for n in scan_locally_declared_idents(s) {
1725                    if !body_locally_declared.contains(&n) && !referenced.iter().any(|r| r == &n) {
1726                        referenced.push(n);
1727                    }
1728                }
1729            }
1730            nmbrs_workload::model::ResultFragment::Named { name, .. } => {
1731                let n = name.to_string();
1732                if !body_locally_declared.contains(&n) && !referenced.iter().any(|r| r == &n) {
1733                    referenced.push(n);
1734                }
1735            }
1736        });
1737    }
1738
1739    // SRD-13f: parent-ref classification no longer gates
1740    // extern emission. The legacy "DynamicOutput without
1741    // shared" rejection assumed snapshot semantics for
1742    // non-shared cross-scope reads (SRD-13c §"Default:
1743    // Immutable Propagation"). SRD-13f reframes that rule:
1744    // the read invariant is uniform across all visible
1745    // parent outputs — inner reads return outer's current
1746    // value — and the construction-time wiring (cell
1747    // attachment for shared, per-cycle refresh for non-shared
1748    // pending the full cell mechanism in Push B.2) keeps the
1749    // invariant intact. No external pre-check needed.
1750
1751    // Emit extern decls only for names the op references and
1752    // that the parent provides. Lazy cascade — keeps the
1753    // op-template kernel narrow and makes the contract check
1754    // above crisp.
1755    for name in &referenced {
1756        if emitted.contains(name) {
1757            continue;
1758        }
1759        if body_locally_declared.contains(name) {
1760            continue;
1761        }
1762        // Names that are *Coordinate* inputs in the parent (the
1763        // implicit `cycle` and friends) must stay Coordinate in
1764        // the inner kernel too, so `set_inputs` propagates them
1765        // per cycle. An explicit `extern` declaration would force
1766        // IterationExtern classification and break propagation —
1767        // skip the explicit emit and let the inner kernel's auto-
1768        // extern path re-classify as Coordinate.
1769        let is_parent_coord = parent_kernel
1770            .program()
1771            .find_input(name)
1772            .and_then(|idx| parent_kernel.program().input_kind(idx))
1773            .is_some_and(|k| matches!(k, polydat::kernel::InputKind::Coordinate));
1774        if is_parent_coord {
1775            // The cascade still needs to record this as an
1776            // inherited name so `mark_inherited_outputs` includes
1777            // it — the inner kernel will re-publish it as an
1778            // (auto-extern) input/output and materialize_wiring_from_outer
1779            // will value-copy at construction time.
1780            inherited_names.push(name.clone());
1781            continue;
1782        }
1783        // Workload-param check goes BEFORE manifest cascade: a
1784        // workload param ALSO appears in the parent's manifest
1785        // (cascaded as an auto-output of an `extern <name>: …`
1786        // line in the for_each scope synthesiser), but op-template
1787        // bodies need it as a `final` literal so `init <name> =
1788        // <expr-using-param>` folds at compile time. Routing
1789        // through the manifest path emits `extern`, leaves init
1790        // unfolded, and the runtime sees `Value::None` in the
1791        // input slot — exactly the surface the Phase-9 op-template
1792        // kernel was hitting on dataset_prebuffer / query_count.
1793        if let Some(value) = workload_params.get(name) {
1794            // Chain-aware: a `set:` / `bindings:` scope above
1795            // us may have shadowed the workload-param default.
1796            // This was the root cause of the CQL prepared-
1797            // statement regression where `{ann_opts}` got
1798            // emitted as a `?` bind point — the op-template
1799            // kernel's local `const rerank_mode := "default"`
1800            // masked the SetParam shadow, and the downstream
1801            // `const ann_opts := select_str(str_eq(
1802            // rerank_mode, "pinned"), …)` folded against the
1803            // wrong rerank_mode value.
1804            crate::scope_synth::emit_workload_param_chain_aware(
1805                name,
1806                value,
1807                parent_kernel,
1808                &mut source,
1809                &mut emitted,
1810                None,
1811            );
1812        } else if let Some(entry) = manifest_by_name.get(name.as_str()) {
1813            let type_name = entry.port_type.to_keyword();
1814            source.push_str(&format!("extern {name}: {type_name}\n"));
1815            emitted.insert(name.clone());
1816            inherited_names.push(name.clone());
1817        } else if let Some(parent_idx) = parent_kernel.program().find_input(name) {
1818            // Parent INPUT — for non-Coordinate inputs (iteration
1819            // vars, external-write ports) emit an explicit `extern` so the
1820            // inner kernel's input classification matches the
1821            // parent's, and materialize_wiring_from_outer can value-copy or
1822            // shared-cell-attach. For Coordinate inputs (e.g. the
1823            // implicit `cycle` coord), DON'T emit — let the GK
1824            // auto-extern path classify them as Coordinate too,
1825            // so per-cycle `set_inputs` propagates to the inner
1826            // kernel. An explicit `extern` would force
1827            // IterationExtern and break that propagation.
1828            let kind = parent_kernel.program().input_kind(parent_idx);
1829            if !matches!(kind, Some(polydat::kernel::InputKind::Coordinate)) {
1830                let port_type = parent_kernel
1831                    .program()
1832                    .input_port_type(name)
1833                    .ok_or_else(|| {
1834                        format!(
1835                            "scope synthesis: parent input '{name}' has no \
1836                         declared PortType (find_input returned index {parent_idx} \
1837                         but input_port_type returned None — kernel program shape \
1838                         broken)"
1839                        )
1840                    })
1841                    .expect("input index just resolved must have a port type");
1842                let type_name = port_type.to_keyword();
1843                source.push_str(&format!("extern {name}: {type_name}\n"));
1844                emitted.insert(name.clone());
1845                inherited_names.push(name.clone());
1846            }
1847        }
1848    }
1849
1850    // SRD-13f Push A: workload params are root-level context
1851    // wires — they must appear on every scope's kernel program
1852    // as inlined constants (materialization gradient §"Inlined
1853    // constant"). The per-name loop above emits them as `final`
1854    // for body-referenced params; this cascade catches the rest
1855    // so every workload param lands on this op-template kernel
1856    // as a folded constant. The previous emission shape
1857    // (`extern X: type`) was wrong — it forced a runtime input
1858    // slot, which (a) prevented `const` bindings from folding
1859    // against the param's value at compile time and (b) routed
1860    // a compile-time-constant value through the runtime input
1861    // path. `final` is the correct materialization.
1862    for (name, value) in workload_params {
1863        // Chain-aware emission: honors any `set:` / `bindings:`
1864        // scope shadow above us. Without it, the op-template's
1865        // local `const NAME := <hashmap-default>` would mask
1866        // the shadow and break SRD-21's single-resolution-
1867        // surface invariant.
1868        crate::scope_synth::emit_workload_param_chain_aware(
1869            name,
1870            value,
1871            parent_kernel,
1872            &mut source,
1873            &mut emitted,
1874            None,
1875        );
1876    }
1877    if body_text.trim().is_empty() {
1878        // No own bindings — the kernel just re-exports parent.
1879        // The flatten-elision logic upstream should have caught
1880        // this, but defensively keep the kernel non-empty.
1881        if source.is_empty() {
1882            source.push_str("const __empty := 0\n");
1883        }
1884    } else {
1885        if !source.ends_with('\n') && !source.is_empty() {
1886            source.push('\n');
1887        }
1888        source.push_str(&body_text);
1889        if !source.ends_with('\n') {
1890            source.push('\n');
1891        }
1892    }
1893
1894    // SRD-67 Phase 3 — route op-template scope synthesis through
1895    // the SubcontextBuilder bridge. The Phase-9 op-template
1896    // kernel needs the same `compile_polydat_with_libs` knobs the
1897    // legacy direct call took (lib paths, strict, source dir,
1898    // context label); those flow through `CompileOptions`. The
1899    // bridge applies `mark_inherited_outputs` and
1900    // `materialize_wiring_from_outer` against the live parent so per-cycle
1901    // values reach the inner kernel's input slots; the trailing
1902    // `polydat::kernel::propagate_inputs` keeps cascade-extern'd inputs
1903    // flowing through (until Rule 4 / Rule 5 absorb them).
1904    let compile_options = polydat::kernel::subcontext::CompileOptions {
1905        workload_dir: workload_dir.map(|p| p.to_path_buf()),
1906        polydat_lib_paths,
1907        strict,
1908        required_outputs: Vec::new(),
1909        context_label: Some(context.to_string()),
1910        cursor_limit: None,
1911        kernel_opt,
1912        ..Default::default()
1913    };
1914
1915    // SRD-67 Phase 5 — fold the SRD-66 `result:` source through
1916    // `add_result_bindings`. The builder walks the source's free
1917    // identifiers, injects magic externs (`body` / `count` /
1918    // `ok`) it actually references, and registers each LHS as
1919    // an export. Rule 2 in finalize rewrites any LHS that
1920    // collides with a parent `shared` export into a write-
1921    // through; the kernel carries the bindings forward via
1922    // `set_write_throughs` so the per-cycle dispenser can
1923    // commit. Map-shape entries (already flattened to
1924    // `<name> := <source>` in the workload model) flow
1925    // through unchanged; path expressions surface as unbound-
1926    // identifier compile errors with the SRD-66 deferred-
1927    // structural-body-wire diagnostic.
1928    // SRD-66 result bindings + post-SRD-68 metric-value bindings.
1929    // Both routes feed the same closure-binding-economy walker
1930    // (`add_result_bindings`) which injects magic externs
1931    // (body/count/ok) for any of those names referenced from
1932    // either RHS. The walker then registers each LHS as a kernel
1933    // output the metrics wrapper / evaluator reads at cycle time
1934    // via `wires.get`.
1935    //
1936    // Metric-driven bindings use the `__metric_<name>` prefix so
1937    // they don't collide with user-declared output names and so
1938    // diagnostic output can filter them as internal. MetricsDispenser
1939    // reads the same `__metric_<name>` form at cycle time —
1940    // `synthesize_metric_binding_name` is the single source of
1941    // truth for the naming convention.
1942    let mut result_source: String = op
1943        .result
1944        .as_ref()
1945        .map(collect_result_bindings_source)
1946        .unwrap_or_default();
1947    if !op.metrics.is_empty() {
1948        // Stable ordering — matches `MetricsDispenser::wrap`'s
1949        // sort-by-name so synthesis order is reproducible.
1950        let mut entries: Vec<_> = op.metrics.iter().collect();
1951        entries.sort_by(|a, b| a.0.cmp(b.0));
1952        for (name, spec) in entries {
1953            let binding = synthesize_metric_binding_name(name);
1954            result_source.push_str(&format!("{binding} := {expr}\n", expr = spec.value));
1955            // Cell coordinates are reified the same way the value is: as
1956            // compiled kernel bindings walked by `add_result_bindings`, so a
1957            // coordinate expression gets its magic-extern slots and is
1958            // type-checked, rather than being a string assembled at runtime.
1959            // `BTreeMap` iteration keeps the emission order stable.
1960            for (dim, expr) in &spec.cell {
1961                let cell_binding = synthesize_cell_binding_name(name, dim);
1962                result_source.push_str(&format!("{cell_binding} := {expr}\n"));
1963            }
1964        }
1965    }
1966    // Poll predicate. `poll.until:` lowers to the SAME binding name the
1967    // phase-level poll uses (`condition::UNTIL_BINDING`), in the op's own
1968    // kernel — so the poll wrapper reads one wire and never asks which kind of
1969    // node it is running under. Ordinary scoping does the rest: an op's
1970    // predicate shadows its phase's, as any other wire would.
1971    if let Some(until) = template_poll_until(op) {
1972        result_source.push_str(&format!(
1973            "{} := {until}\n",
1974            crate::wrappers::condition::UNTIL_BINDING,
1975        ));
1976    }
1977    // While-wrapper predicate. Synthesised as `__while := <expr>`
1978    // so `add_result_bindings` walks the expression, injects
1979    // magic externs for any captured wires it references, and
1980    // registers `__while` as a kernel output the WhileWrapper
1981    // reads at cycle time via the canonical pull plan.
1982    if let Some(while_expr) = op.while_cond.as_ref() {
1983        result_source.push_str(&format!(
1984            "{} := {expr}\n",
1985            crate::wrappers::r#while::BINDING_NAME,
1986            expr = while_expr,
1987        ));
1988    }
1989    let result_source: Option<String> = Some(result_source).filter(|s| !s.trim().is_empty());
1990
1991    if std::env::var("NMBRS_DEBUG_SCOPE_SYNTH")
1992        .map(|v| v == "1")
1993        .unwrap_or(false)
1994    {
1995        eprintln!(
1996            "=== OP SYNTH [{context}] inherited={inherited_names:?} parent_has_xval_input={:?} parent_manifest_has_xval={} ===\n{source}\n=== END ===",
1997            parent_kernel.program().find_input("xval"),
1998            manifest_by_name.contains_key("xval")
1999        );
2000    }
2001    // Built under the parent from source matter; the scope keeps the
2002    // finalized module, from which each fiber instantiates its per-op
2003    // kernel with the module's write-throughs. Strict mode refuses a
2004    // scope-init const that fell through to `None`
2005    // (composition_substrate.md L2.f).
2006    let synthesis_error = |e: String| format!("{context}: op-template scope synthesis: {e}");
2007    let mut matter = crate::scope_kernel::SourceMatter::source(context, source, compile_options)
2008        .inherited(inherited_names);
2009    if let Some(rb) = result_source {
2010        matter = matter.results(rb);
2011    }
2012    let kernel = crate::scope_kernel::ScopeKernel::synthesize_under(parent_kernel, matter)
2013        .map_err(synthesis_error)?;
2014
2015    // SRD-108 Part B — the interface's TYPE PROOF, at the same
2016    // place every other wire is type-checked: pre-map synthesis,
2017    // against the compiled op-template program. Load-time binder
2018    // checks were the early named errors; this is enforcement.
2019    if let Some(iface) = op.abstract_interface.as_ref() {
2020        verify_op_interface(iface, kernel.program().as_ref(), context)?;
2021    }
2022    Ok(kernel)
2023}
2024
2025/// SRD-108 Part B — verify a bound slot's interface against its
2026/// compiled op-template program:
2027///
2028/// - every `yields` name must exist on the program (a capture
2029///   extern slot or a declared output) with the declared type;
2030/// - every `needs` name the program declares as a slot must
2031///   carry the declared type (a need consumed only through
2032///   textual bind points declares no slot — the parent-chain
2033///   resolution type-checks those as usual).
2034///
2035/// Runs during pre-map synthesis — an interface violation fails
2036/// workload init, never a runtime critical section.
2037fn verify_op_interface(
2038    iface: &nmbrs_workload::model::OpInterface,
2039    program: &polydat::kernel::PolydatProgram,
2040    context: &str,
2041) -> Result<(), String> {
2042    let type_of = |name: &str| -> Option<polydat::ast::PortType> {
2043        program
2044            .input_port_type(name)
2045            .or_else(|| program.output_port_type(name))
2046    };
2047    // Yields: PRESENCE is the binder's load-time check (a capture
2048    // `as`-name); captures deliver through the wrapper stack's
2049    // wire writes, so a capture-only yield may hold no slot on
2050    // the op-template program itself. When a slot DOES
2051    // materialize (declared cast, binding reference), its type
2052    // must match the interface.
2053    for (name, declared) in &iface.yields {
2054        let Some(expected) = polydat::ast::PortType::from_keyword(declared) else {
2055            return Err(format!(
2056                "{context}: interface yield '{name}' declares unknown \
2057                 type '{declared}'"
2058            ));
2059        };
2060        if let Some(actual) = type_of(name)
2061            && actual != expected
2062        {
2063            return Err(format!(
2064                "{context}: interface yield '{name}' declares type \
2065                 {declared}, but the compiled op template carries \
2066                 {actual:?}"
2067            ));
2068        }
2069    }
2070    for (name, declared) in &iface.needs {
2071        let Some(expected) = polydat::ast::PortType::from_keyword(declared) else {
2072            return Err(format!(
2073                "{context}: interface need '{name}' declares unknown \
2074                 type '{declared}'"
2075            ));
2076        };
2077        if let Some(actual) = type_of(name)
2078            && actual != expected
2079        {
2080            return Err(format!(
2081                "{context}: interface need '{name}' declares type \
2082                 {declared}, but the compiled op template carries \
2083                 {actual:?}"
2084            ));
2085        }
2086    }
2087    // Results (SRD-109 Part 3): the projection wire's slot is
2088    // DECLARED from the interface type at op-template build (see
2089    // the result-binding declaration pass), so the check here is
2090    // keyword validity plus agreement when the slot materialized —
2091    // a mismatch means something else (a binding, a cast) claimed
2092    // the name at a different type.
2093    for (name, declared) in &iface.results {
2094        let Some(expected) = polydat::ast::PortType::from_keyword(declared) else {
2095            return Err(format!(
2096                "{context}: interface results wire '{name}' declares \
2097                 unknown type '{declared}'"
2098            ));
2099        };
2100        if let Some(actual) = type_of(name)
2101            && actual != expected
2102        {
2103            return Err(format!(
2104                "{context}: interface results wire '{name}' declares \
2105                 type {declared}, but the compiled op template carries \
2106                 {actual:?}"
2107            ));
2108        }
2109    }
2110    Ok(())
2111}
2112
2113/// Internal binding name for a workload-declared metric's `value:`
2114/// expression. The metric's `value:` source is synthesised into the
2115/// op-template kernel as `<binding> := <expr>` so a single
2116/// closure-binding-economy walker handles slot allocation for
2117/// magic-extern references in both result-bindings AND
2118/// metric-value expressions. MetricsDispenser reads the same name
2119/// at cycle time via `ctx.wires.get` — the prefix keeps these
2120/// internal bindings from colliding with workload-declared output
2121/// names and lets diagnostic surfaces filter them out by prefix.
2122/// The `until:` expression from an op's `poll:` map, if it declared one.
2123///
2124/// Returns `None` for the row-count form (`mode` / `min_rows` / `max_rows`),
2125/// which stays supported — a workload that counts rows keeps working and a
2126/// workload that states a condition gets the condition.
2127fn template_poll_until(op: &nmbrs_workload::model::ParsedOp) -> Option<String> {
2128    op.params
2129        .get("poll")?
2130        .as_object()?
2131        .get("until")?
2132        .as_str()
2133        .map(|s| s.trim().to_string())
2134        .filter(|s| !s.is_empty())
2135}
2136
2137pub fn synthesize_metric_binding_name(metric_name: &str) -> String {
2138    format!("__metric_{metric_name}")
2139}
2140
2141/// The kernel wire a metric's cell COORDINATE is lowered to, per
2142/// (metric, dimension).
2143///
2144/// Keyed by metric as well as dimension because two metrics in one scope may
2145/// place into the same dimension by different expressions — `bytes_out` from
2146/// one captured column and `bytes_in` from another. A per-dimension name would
2147/// collide and one placement would silently win.
2148///
2149/// Same `__` convention as [`synthesize_metric_binding_name`]: it cannot
2150/// collide with an author-declared output, and diagnostics can filter it as
2151/// internal.
2152pub fn synthesize_cell_binding_name(metric_name: &str, dimension: &str) -> String {
2153    format!("__cell_{metric_name}__{dimension}")
2154}
2155
2156/// The phase-kernel wire an **inline objective expression** is lowered to
2157/// (SRD-86). `optimize.objective` is either a bare wire reference (read
2158/// directly off the phase kernel) or an inline expression;
2159/// [`synthesize_phase_scope_bindings`] lowers the latter to
2160/// `volatile __objective := <expr>` so the optimizer reads exactly one wire
2161/// regardless of which form the author wrote. The `__` prefix keeps it from
2162/// colliding with author-declared outputs (same convention as
2163/// [`synthesize_metric_binding_name`]).
2164pub const OBJECTIVE_WIRE: &str = "__objective";
2165
2166/// Whether an `optimize.objective` value is a **bare wire reference** — a
2167/// single identifier read directly off the phase kernel — versus an **inline
2168/// expression** (operators, calls, dots, whitespace) that must be synthesized.
2169/// A bare reference is left untouched (no synthesis, current behavior); an
2170/// expression is lowered to [`OBJECTIVE_WIRE`].
2171pub fn objective_is_bare_wire(objective: &str) -> bool {
2172    let s = objective.trim();
2173    !s.is_empty()
2174        && s.chars()
2175            .next()
2176            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
2177        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
2178}
2179
2180/// The phase-kernel wire the optimizer reads for `objective`: the bare name
2181/// itself, or the synthesized [`OBJECTIVE_WIRE`] for an inline expression.
2182/// Pairs with [`synthesize_phase_scope_bindings`], which emits the
2183/// `__objective` binding for the expression case — the two agree by sharing
2184/// [`objective_is_bare_wire`].
2185pub fn objective_wire(objective: &str) -> &str {
2186    if objective_is_bare_wire(objective) {
2187        objective.trim()
2188    } else {
2189        OBJECTIVE_WIRE
2190    }
2191}
2192
2193/// Flatten a [`nmbrs_workload::model::ResultSpec`] into a single
2194/// Polydat source string suitable for
2195/// [`polydat::kernel::subcontext::SubcontextBuilder::add_result_bindings`].
2196/// String-shape entries pass through verbatim; map-shape entries
2197/// emit `<name> := <source>` lines (the same projection the
2198/// SRD-66 schema specifies); list-shape entries recurse.
2199///
2200/// Path-expression and built-in short forms (`count` / `ok`) in
2201/// map-shape entries land as bare Polydat expressions — `count` and
2202/// `ok` resolve to the magic-extern wires
2203/// [`SubcontextBuilder::add_result_bindings`] injects, while
2204/// path expressions like `rows[0].field` produce an unbound-
2205/// identifier compile error. The latter surfaces SRD-66's
2206/// "path expressions deferred until structural-body wire lands"
2207/// diagnostic.
2208fn collect_result_bindings_source(spec: &nmbrs_workload::model::ResultSpec) -> String {
2209    let mut out = String::new();
2210    spec.walk_fragments(|frag| match frag {
2211        nmbrs_workload::model::ResultFragment::Source(src) => {
2212            out.push_str(src);
2213            if !src.ends_with('\n') {
2214                out.push('\n');
2215            }
2216        }
2217        nmbrs_workload::model::ResultFragment::Named { name, source } => {
2218            // Path expressions (`rows[*].key`, `result[0].id`) are
2219            // dispenser-evaluated (SRD-70) — they are not polydat
2220            // source and would fail the result-bindings compile.
2221            // Only `count` / `ok` (magic-extern references) and
2222            // polydat-call entries compile into the kernel.
2223            let s = source.trim();
2224            if s == "count" || s == "ok" || s.contains('(') {
2225                out.push_str(&format!("{name} := {source}\n"));
2226            }
2227        }
2228    });
2229    out
2230}
2231
2232// reason: cohesive scope builder — each argument is a distinct compile input
2233// (ops, iteration vars, outer manifest, workload params, …); grouping them
2234// into a struct would only relocate the same fields without adding clarity.
2235#[allow(clippy::too_many_arguments)]
2236pub fn build_scope(
2237    ops: &[ParsedOp],
2238    iteration_vars: &HashMap<String, String>,
2239    outer_manifest: &[crate::runner::ManifestEntry],
2240    workload_params: &HashMap<String, String>,
2241    phases: &HashMap<String, nmbrs_workload::model::WorkloadPhase>,
2242    phase_cycles: Option<&str>,
2243    exclude: &[String],
2244    // SRD-13f §"Wire-reference classification" — the parent
2245    // scope's compiled kernel. The synthesizer reads its
2246    // retained AST (for case 3 local-inclusion walks) and its
2247    // folded constant state (for case 1 promoted-final
2248    // emission). `None` for the workload-root build (it IS the
2249    // root; no parent).
2250    parent_kernel: Option<&crate::scope_kernel::ScopeKernel>,
2251) -> Result<BindingScope, String> {
2252    let mut scope = BindingScope::new();
2253
2254    // --- Step 1: Classify op bindings by origin ---
2255    //
2256    // The first op's PolydatSource is the "base" (inherited/phase-level).
2257    // Subsequent ops: if their PolydatSource matches the base exactly,
2258    // they're inherited duplicates. If different, they're op-level
2259    // augmentations carrying new bindings.
2260    let mut base_source: Option<String> = None;
2261
2262    for op in ops {
2263        if let BindingsDef::PolydatSource(src) = &op.bindings {
2264            let src = src.trim();
2265            if src.is_empty() {
2266                continue;
2267            }
2268
2269            match &base_source {
2270                None => {
2271                    // First op's source becomes the base — classified as Inherited
2272                    base_source = Some(src.to_string());
2273                    scope.ingest_polydat_source(src, BindingOrigin::Inherited);
2274                }
2275                Some(base) => {
2276                    if src == base.as_str() {
2277                        // Identical to base — inherited duplicate, skip.
2278                        // validate() and emit() handle dedup.
2279                    } else {
2280                        // Different from base — this op has its own bindings.
2281                        // Ingest the DIFFERENCE (lines not in base) as Op origin,
2282                        // and the shared lines as Inherited. Compare by
2283                        // *logical* lines so multi-line expressions
2284                        // (function calls broken over several physical
2285                        // lines) stay intact instead of being split at
2286                        // the paren and ingested piecewise.
2287                        let base_logical: Vec<String> = logical_lines(base)
2288                            .into_iter()
2289                            .map(|l| l.trim().to_string())
2290                            .filter(|l| !l.is_empty())
2291                            .collect();
2292                        for line in logical_lines(src) {
2293                            let trimmed = line.trim();
2294                            if trimmed.is_empty() {
2295                                continue;
2296                            }
2297                            if base_logical.iter().any(|b| b == trimmed) {
2298                                // This line is inherited — already ingested from base
2299                            } else {
2300                                // This line is op-specific
2301                                scope.ingest_polydat_source(
2302                                    trimmed,
2303                                    BindingOrigin::Op(op.name.clone()),
2304                                );
2305                            }
2306                        }
2307                    }
2308                }
2309            }
2310        }
2311    }
2312
2313    // --- Step 2: Add iteration variables ---
2314    //
2315    // Iter vars (own + cascade-inherited) are part of the
2316    // **scope's contract** — they're names a phase or a child
2317    // scope is allowed to consume. Mark them required so the
2318    // compiler's DCE keeps the auto-passthrough output that
2319    // `extern <name>` produces. Without this, an iter var
2320    // referenced only from a deeper structure (e.g. a relevancy
2321    // `k:` field, or a downstream do-while condition) would be
2322    // pruned and pull-by-name would fail.
2323    for (var, val) in iteration_vars {
2324        scope.add_iteration_var(var, val);
2325        scope.add_required_output(var);
2326    }
2327
2328    // --- Step 3: Generate auto-externs ---
2329    //
2330    // Names referenced in op templates but not defined in the scope
2331    // need extern declarations to wire to the outer kernel.
2332    let defined = scope.defined_names();
2333    let extern_names = scope.extern_names();
2334
2335    // Collect all referenced names from op templates AND
2336    // binding source RHS. Names referenced in either need
2337    // extern declarations so the Polydat compile path can wire
2338    // them. Without scanning bindings, a phase whose binding
2339    // RHS uses `{outer_name}` (e.g. `dim := vector_dim(
2340    // "{dataset}:{profile}")`) wouldn't get extern declarations
2341    // for `dataset` and `profile`, leaving the Polydat string
2342    // interpolation desugar to fail at compile time.
2343    let mut referenced: HashSet<String> = HashSet::new();
2344    for op in ops {
2345        for value in op.op.values() {
2346            if let Some(s) = value.as_str() {
2347                for name in nmbrs_workload::bindpoints::referenced_bindings(s) {
2348                    referenced.insert(name);
2349                }
2350            }
2351        }
2352        if let Some(ref cond) = op.condition {
2353            let bare = cond
2354                .trim()
2355                .strip_prefix('{')
2356                .and_then(|s| s.strip_suffix('}'))
2357                .unwrap_or(cond.trim());
2358            referenced.insert(bare.to_string());
2359        }
2360        // `delay:` accepts the same shapes as `if:` — a bare
2361        // wire name (`delay: think_time`) or a `{...}` inline
2362        // expression. Both consume a binding and need to land
2363        // in `referenced` so the auto-extern + DCE-keepalive
2364        // passes provision them.
2365        if let Some(ref delay_spec) = op.delay {
2366            for raw in delay_spec.names() {
2367                let bare = raw
2368                    .trim()
2369                    .strip_prefix('{')
2370                    .and_then(|s| s.strip_suffix('}'))
2371                    .unwrap_or(raw.trim());
2372                referenced.insert(bare.to_string());
2373            }
2374        }
2375        // Bindings: scan Polydat source for `{name}` placeholders
2376        // that the Polydat string-interpolation desugar will treat
2377        // as wire references.
2378        if let BindingsDef::PolydatSource(src) = &op.bindings {
2379            collect_string_interp_refs(src, &mut referenced);
2380            // Also scan for bare identifiers used in binding
2381            // RHSs (e.g. `if(optimize_for == "LATENCY", …)`).
2382            // Without this, names like `optimize_for` flow
2383            // through to the Polydat compiler unresolved and get
2384            // auto-externed at the compiler's default type
2385            // (u64); `materialize_wiring_from_outer` then writes the
2386            // parent's Str value into the u64-typed slot, and
2387            // a `__u64_to_string` adapter inserted by type
2388            // dispatch panics at runtime on `as_u64()`. The
2389            // local-binding filter below mirrors the
2390            // build_op_template_scope_kernel cascade — names
2391            // declared by THIS scope's own bindings are
2392            // resolved locally, not externed.
2393            let body_locally_declared = scan_locally_declared_idents(src);
2394            for ident in scan_idents_in_polydat_source(src) {
2395                if !body_locally_declared.contains(&ident) {
2396                    referenced.insert(ident);
2397                }
2398            }
2399        }
2400        // Op params (`evaluations:`/`relevancy:`/`verify:`/...
2401        // hoisted by the workload parser) carry `{name}` bind-
2402        // point references too — `relevancy: { k: "{k}" }` is
2403        // a real consumer of the wire `k`, even though `k`
2404        // isn't on the op-template wire path. Without scanning
2405        // these, an iter var referenced *only* from param
2406        // config would fail to auto-extern from the parent
2407        // manifest, and runtime `pull(name)` calls for that
2408        // wire (e.g. `parse_count_param` on relevancy `k:`)
2409        // would panic with "unknown output variate".
2410        let mut param_refs: Vec<String> = Vec::new();
2411        crate::bindings::collect_param_bindings_into(&op.params, &[], &mut param_refs);
2412        for name in param_refs {
2413            referenced.insert(name);
2414        }
2415    }
2416
2417    // Check for final shadowing violations
2418    for entry in outer_manifest {
2419        if entry.modifier == polydat::dsl::ast::BindingModifier::CONST
2420            && defined.contains(&entry.name)
2421        {
2422            return Err(format!(
2423                "cannot shadow 'final' binding '{}' from outer scope",
2424                entry.name
2425            ));
2426        }
2427    }
2428
2429    // SRD-13f §"Wire-reference classification" — case 3 (local
2430    // matter inclusion). When the parent program is available
2431    // (AST mode), promote non-final referenced names from
2432    // cascade-via-extern (case 2) to inline-as-Inherited.
2433    //
2434    // Rules:
2435    // - `final` / `shared` outputs stay cascaded (case 2-like
2436    //   for now; promoted-final optimization is a follow-up).
2437    // - Cycle bindings / init bindings whose body lives in the
2438    //   parent's retained AST get pretty-printed and ingested as
2439    //   Inherited. Transitive dependencies (RHS Ident refs)
2440    //   walk the same rule via `local_inclusion_chain`.
2441    // - Names defined by `extern` ports in the parent stay
2442    //   cascaded.
2443    //
2444    // The auto-extern loop below then runs as-is and fills the
2445    // gap for names that didn't have an AST body to pull in.
2446    if let Some(parent_kernel_ref) = parent_kernel {
2447        let parent_prog = parent_kernel_ref.program();
2448        // Propagate the parent's coordinate input names into this
2449        // scope when it doesn't already have an `input ...: u64`
2450        // declaration of its own. Without this, an included
2451        // binding like `trip := testkit_throw_at(cycle, threshold, ...)`
2452        // references `cycle` but the auto-extern loop emits
2453        // `extern cycle: u64` (extern, not coord); set_inputs
2454        // propagation then skips it and per-cycle ticking dies.
2455        //
2456        // Coord names are just wire names the parent declared as
2457        // inputs; the child propagates them by declaring the
2458        // same `input ...: u64` line. Nothing special about any
2459        // specific name here.
2460        if scope.coordinates.is_none() {
2461            let coord_count = parent_prog.coord_count();
2462            if coord_count > 0 {
2463                let input_names = parent_prog.input_names();
2464                let coords: Vec<String> = input_names.into_iter().take(coord_count).collect();
2465                scope.coordinates = Some(format_input_decl_line(&coords).trim_end().to_string());
2466            }
2467        }
2468
2469        // Names already accounted for: defined locally, declared
2470        // extern in subscope, or iter-vars. The inclusion-chain
2471        // walker uses this as its termination boundary so it
2472        // doesn't re-emit names the scope already satisfies.
2473        // (Coord input names like the one the workload author
2474        // declared via `input ...: u64` are not special — the
2475        // chain walker's `binding_ast_for` returns `None` for
2476        // coord inputs since they aren't binding statements, so
2477        // they self-terminate without explicit handling here.)
2478        let mut already_satisfied: HashSet<String> = HashSet::new();
2479        already_satisfied.extend(defined.iter().cloned());
2480        already_satisfied.extend(extern_names.iter().cloned());
2481        for var in iteration_vars.keys() {
2482            already_satisfied.insert(var.clone());
2483        }
2484
2485        // Sort for determinism — HashSet iteration is randomised.
2486        // Clone names so the loop body can mutate `referenced`
2487        // (collecting transitive refs from included bindings).
2488        let mut refs_sorted: Vec<String> = referenced.iter().cloned().collect();
2489        refs_sorted.sort();
2490        for name in &refs_sorted {
2491            let name = name.as_str();
2492            if already_satisfied.contains(name) {
2493                continue;
2494            }
2495            let manifest_modifier = outer_manifest
2496                .iter()
2497                .find(|e| e.name == name)
2498                .map(|e| e.modifier);
2499            let is_workload_param = workload_params.contains_key(name);
2500            if let Some(m) = manifest_modifier {
2501                use polydat::dsl::ast::BindingModifier;
2502                if m == BindingModifier::SHARED {
2503                    // SHARED stays in the cascade path — cells
2504                    // synchronise across kernels at runtime via
2505                    // SharedCell, not via const inlining.
2506                    continue;
2507                }
2508                if m == BindingModifier::CONST {
2509                    // SRD-13f §"Materialization gradient" — inline
2510                    // as `const NAME := <literal>` only when the
2511                    // upstream value is **statically known**. Per
2512                    // SRD-11 §"Two Evaluation Lifecycles", the
2513                    // `const` modifier has two implementation
2514                    // paths: compile-fold (the producing node is
2515                    // replaced with a leaf const node, value is
2516                    // part of the compiled artifact and identical
2517                    // across every activation) vs. scope-init pull
2518                    // (when the binding's wire chain touches
2519                    // iteration externs, the producing node stays
2520                    // as the original computation node; the value
2521                    // is computed per scope activation and lives
2522                    // only for that activation per SRD-13c
2523                    // §"const").
2524                    //
2525                    // The cascaded source built here gets compiled
2526                    // and cached on the phase scope-tree node, so
2527                    // its lifetime is the workload run — longer
2528                    // than any single activation. Inlining a
2529                    // scope-init-pulled value would freeze the
2530                    // first activation's value into the cached
2531                    // program forever, breaking every subsequent
2532                    // iteration of an enclosing comprehension
2533                    // (the symptom: `const mode := sm` inside a
2534                    // `for_each sm in alpha, beta` reads `alpha`
2535                    // for both iters).
2536                    //
2537                    // SRD-11 §"Provenance-Based Invalidation"
2538                    // gives the discriminator at zero cost: a
2539                    // truly statically-known node has empty input
2540                    // provenance (it depends on no graph inputs);
2541                    // a passthrough or computation node has bits
2542                    // set for the input slots it reads. We inline
2543                    // only when provenance is empty; otherwise we
2544                    // fall through to the auto-extern cascade
2545                    // below so each activation re-pulls the value
2546                    // through the chain via
2547                    // `materialize_wiring_from_outer` Step 3.
2548                    let statically_known = parent_kernel_ref
2549                        .program()
2550                        .output_index(name)
2551                        .map(|out_idx| {
2552                            let (node_idx, _) =
2553                                parent_kernel_ref.program().resolve_output_by_index(out_idx);
2554                            parent_kernel_ref
2555                                .program()
2556                                .input_provenance_for(node_idx)
2557                                .is_none_or(|p| p.is_zero())
2558                        })
2559                        .unwrap_or(true);
2560                    if statically_known
2561                        && let Some(value) = parent_kernel_ref.lookup(name)
2562                        && let Some(natural) = value_to_param_string(&value)
2563                    {
2564                        scope.add_param_binding(name, &natural);
2565                        already_satisfied.insert(name.to_string());
2566                        continue;
2567                    }
2568                    continue;
2569                }
2570            }
2571            // SRD-21 §"Where workload-param values live in the
2572            // kernel chain" — workload params are stable for the
2573            // run but live in the params-kernel as folded
2574            // constants, not in the workload-root program itself.
2575            // `parent_kernel.lookup(name)` reads through the
2576            // chain-wired input slot value, so this promotion
2577            // gives us const-folded workload-param values inside
2578            // every descendant scope's program — same
2579            // effective semantics as the pre-SRD-21 design
2580            // without poisoning the workload-root with `final`
2581            // constants that would block scenario-tree
2582            // shadowing.
2583            if is_workload_param
2584                && let Some(value) = parent_kernel_ref.lookup(name)
2585                && let Some(natural) = value_to_param_string(&value)
2586            {
2587                scope.add_param_binding(name, &natural);
2588                already_satisfied.insert(name.to_string());
2589                continue;
2590            }
2591            // Pull the inclusion chain. If the name isn't in the
2592            // parent's AST (or it's a final/shared binding in
2593            // the AST), the chain is empty — the auto-extern
2594            // loop below handles those.
2595            let chain = parent_prog.local_inclusion_chain(name, &already_satisfied);
2596            if chain.is_empty() {
2597                continue;
2598            }
2599            // Pretty-print each Statement in topological order
2600            // and ingest as Inherited. Track bound names so the
2601            // chain walker and the auto-extern loop see them
2602            // satisfied locally. Walk each binding's RHS to
2603            // collect transitive refs into `referenced` — these
2604            // are the names the included binding's expression
2605            // mentions but doesn't define (e.g. parent `final`
2606            // values like `dataset`); the auto-extern loop below
2607            // then emits externs for them.
2608            for stmt in chain {
2609                let line = polydat::dsl::pprint::pp_statement(stmt);
2610                scope.ingest_polydat_source(&line, BindingOrigin::Inherited);
2611                let body = match stmt {
2612                    polydat::dsl::ast::Statement::Binding(b) => Some(&b.value),
2613                    _ => None,
2614                };
2615                if let Some(expr) = body {
2616                    polydat::dsl::collect_expr_references(expr, &mut referenced);
2617                }
2618                if let polydat::dsl::ast::Statement::Binding(b) = stmt {
2619                    for t in &b.targets {
2620                        already_satisfied.insert(t.clone());
2621                    }
2622                }
2623            }
2624        }
2625    }
2626
2627    // Refresh `defined` set after AST-mode inclusion may have
2628    // added Inherited bindings. The auto-extern loop below uses
2629    // this updated set to skip names now satisfied locally.
2630    let defined = scope.defined_names();
2631
2632    // Generate extern declarations for referenced-but-undefined names
2633    for entry in outer_manifest {
2634        let is_iter_var = iteration_vars.contains_key(&entry.name);
2635        if referenced.contains(&entry.name)
2636            && !defined.contains(&entry.name)
2637            && !extern_names.contains(&entry.name)
2638            && !is_iter_var
2639        {
2640            let type_name = entry.port_type.to_keyword();
2641            scope.add_extern(&entry.name, type_name);
2642        }
2643    }
2644
2645    // SRD-13f §"Wire-reference classification" — case 4:
2646    // unresolved → synthesizer-level validation error. Fires
2647    // only on descendant scopes (parent_kernel is Some). The
2648    // workload-root build skips this check; the Polydat compiler's
2649    // auto-input-inference path handles unresolved refs there.
2650    //
2651    // A reference is resolved if any of: defined locally,
2652    // declared extern (in subscope or auto-extern'd from outer
2653    // manifest), an iteration variable, a workload param key,
2654    // a coord name on this scope, or in the outer manifest at
2655    // all. Anything else is a typo or missing upstream binding.
2656    if parent_kernel.is_some() {
2657        let defined_now = scope.defined_names();
2658        let extern_now = scope.extern_names();
2659        let mut satisfied: HashSet<String> = HashSet::new();
2660        satisfied.extend(defined_now);
2661        satisfied.extend(extern_now);
2662        for var in iteration_vars.keys() {
2663            satisfied.insert(var.clone());
2664        }
2665        for name in workload_params.keys() {
2666            satisfied.insert(name.clone());
2667        }
2668        for entry in outer_manifest {
2669            satisfied.insert(entry.name.clone());
2670        }
2671        // Coord names from this scope's `input ...: u64` line.
2672        if let Some(coords_line) = &scope.coordinates
2673            && let Some(rhs) = coords_line.split(":=").nth(1)
2674        {
2675            let inner = rhs.trim().trim_start_matches('(').trim_end_matches(')');
2676            for n in inner.split(',') {
2677                let n = n.trim();
2678                if !n.is_empty() {
2679                    satisfied.insert(n.to_string());
2680                }
2681            }
2682        }
2683        // `referenced` is built from a textual scan that catches
2684        // both wire refs and bare identifiers — including GK
2685        // function names like `mod`, `hash`, `range` that
2686        // appear in binding RHS as call heads. Filter those out
2687        // via the registry lookup; only true wire references
2688        // should surface as unresolved.
2689        let mut unresolved: Vec<&String> = referenced
2690            .iter()
2691            .filter(|n| !satisfied.contains(n.as_str()))
2692            // Dotted names follow the field-access wire
2693            // convention (`q.cursor.idx` reads the wire
2694            // `q__cursor__idx`) — check the flattened spelling
2695            // the same way kernel lookup does.
2696            .filter(|n| !n.contains('.') || !satisfied.contains(n.replace('.', "__").as_str()))
2697            .filter(|n| !n.starts_with("__"))
2698            .filter(|n| polydat::dsl::registry::lookup(n).is_none())
2699            .collect();
2700        unresolved.sort();
2701        if !unresolved.is_empty() {
2702            let names: Vec<&str> = unresolved.iter().map(|s| s.as_str()).collect();
2703            let mut visible: Vec<&str> = satisfied.iter().map(|s| s.as_str()).collect();
2704            visible.sort();
2705            return Err(format!(
2706                "unresolved wire reference(s) {names:?}: not declared locally, \
2707                 not in parent manifest, not a workload param. \
2708                 Visible names in this scope: {visible:?}"
2709            ));
2710        }
2711    }
2712
2713    // --- Step 4: Workload param cascade as extern slots ---
2714    //
2715    // Workload params are not the workload-root's authority — the
2716    // params-kernel sits one level above this scope and owns the
2717    // `const NAME := <literal>` declarations. The workload-root
2718    // program declares each param as `extern NAME: T`, and the
2719    // materialize-wiring-from-outer step at kernel construction
2720    // time copies the value out of the params-kernel's output
2721    // into this scope's input slot. Lookup for a param at the
2722    // workload-root then resolves via the input slot rather than
2723    // an own-folded constant — which is precisely what makes a
2724    // SetParam scope-tree node inserted between params-kernel
2725    // and workload-root able to shadow the value: its own
2726    // `const NAME := <override>` becomes the closer
2727    // materialize-source. If we baked `const NAME := <literal>`
2728    // here at the workload-root, that local constant would mask
2729    // the input slot via the get_constant fast-path in
2730    // PolydatKernel::lookup, and no scope-tree-level shadow could
2731    // get through.
2732    //
2733    // Author-declared `final` in the workload's `bindings:`
2734    // block is unaffected — that source goes through
2735    // workload_level_polydat ingestion in build_workload_root_kernel
2736    // and keeps whatever modifier the author wrote. Only the
2737    // auto-cascade of the workload `params:` block changes
2738    // shape.
2739    //
2740    // Phase-level build_scope callers pass an empty
2741    // `workload_params`; their workload params arrive via the
2742    // parent-scope kernel's manifest auto-extern pass.
2743    //
2744    // SRD-13f Push D: sort by name so program build order is
2745    // deterministic across processes. HashMap iteration is
2746    // randomized per process (Rust default hasher).
2747    let defined = scope.defined_names(); // refresh after externs
2748    let mut params_sorted: Vec<(&String, &String)> = workload_params.iter().collect();
2749    params_sorted.sort_by(|a, b| a.0.cmp(b.0));
2750    for (name, value) in params_sorted {
2751        if defined.contains(name) {
2752            // Author already declared `name` in the workload's
2753            // own bindings block. Honor the author's declaration
2754            // (it carries whatever modifier they wrote) and skip
2755            // the auto-extern — declaring an extern with the
2756            // same name as a local binding would be a compile
2757            // error.
2758            continue;
2759        }
2760        let type_name = workload_param_type_name(value);
2761        scope.add_extern(name, type_name);
2762        // Mark as required so DCE keeps the auto-passthrough
2763        // output. Descendant scopes auto-extern the param via
2764        // the workload-root manifest; if the workload-root's
2765        // own program prunes the param away, that cascade
2766        // breaks.
2767        scope.add_required_output(name);
2768    }
2769    // Also check phase config values for param refs
2770    for phase in phases.values() {
2771        if let Some(ref c) = phase.cycles
2772            && c.starts_with('{')
2773            && c.ends_with('}')
2774        {
2775            let name = &c[1..c.len() - 1];
2776            if workload_params.contains_key(name) && !scope.defined_names().contains(name) {
2777                scope.add_param_binding(name, &workload_params[name]);
2778            }
2779        }
2780    }
2781
2782    // --- Step 5: Inline expression extraction ---
2783    //
2784    // {{expr}} in op templates becomes __expr_N bindings.
2785    // (Note: the op template rewriting happens separately in the caller
2786    // since it mutates op fields, not the scope.)
2787    let mut inline_idx = 0usize;
2788    let mut expr_to_name: HashMap<String, String> = HashMap::new();
2789    let mut collect = |s: &str| {
2790        for bp in nmbrs_workload::bindpoints::extract_bind_points(s) {
2791            if let nmbrs_workload::bindpoints::BindPoint::InlineDefinition(ref expr) = bp
2792                && !expr_to_name.contains_key(expr)
2793            {
2794                let name = format!("__expr_{inline_idx}");
2795                inline_idx += 1;
2796                expr_to_name.insert(expr.clone(), name);
2797            }
2798        }
2799    };
2800    for op in ops {
2801        for value in op.op.values() {
2802            if let Some(s) = value.as_str() {
2803                collect(s);
2804            }
2805        }
2806        // Inline expressions in `if:` and `delay:` count too —
2807        // those get hoisted out of `op.op` by the parser into
2808        // dedicated fields on `ParsedOp`.
2809        if let Some(s) = &op.condition {
2810            collect(s);
2811        }
2812        if let Some(spec) = &op.delay {
2813            for name in spec.names() {
2814                collect(name);
2815            }
2816        }
2817    }
2818    for (expr, name) in &expr_to_name {
2819        scope.add_inline_expr(name, expr);
2820    }
2821
2822    // --- Step 6: Collect required outputs ---
2823    for op in ops {
2824        for value in op.op.values() {
2825            if let Some(s) = value.as_str() {
2826                for name in nmbrs_workload::bindpoints::referenced_bindings(s) {
2827                    if !exclude.contains(&name) {
2828                        scope.add_required_output(&name);
2829                    }
2830                }
2831            }
2832        }
2833        // Required-output collection for `if:` and `delay:`.
2834        //
2835        // The condition / delay value may be one of:
2836        //   1. `{{expr}}` — inline expression. Step 5 above
2837        //      synthesised a `__expr_N := expr` binding; the
2838        //      required output is that synthesised name.
2839        //   2. `{name}` — a single-brace binding reference.
2840        //   3. A bare identifier — legacy "name a binding"
2841        //      form, no braces.
2842        // The previous strip-one-pair-of-braces logic only
2843        // handled forms 2 and 3; for `{{expr}}` it produced a
2844        // half-stripped string `{expr}` that didn't match any
2845        // binding and let DCE drop the synthesised
2846        // `__expr_N`. Walk through `extract_bind_points` so
2847        // every form resolves to the right output name.
2848        let mut collect_required = |s: &str| {
2849            let trimmed = s.trim();
2850            // Bracketed forms: `{{expr}}`, `{:=expr:=}`,
2851            // `{name}`, `{expr-with-operators}`.
2852            let bps = nmbrs_workload::bindpoints::extract_bind_points(trimmed);
2853            if !bps.is_empty() {
2854                for bp in bps {
2855                    match bp {
2856                        nmbrs_workload::bindpoints::BindPoint::InlineDefinition(expr) => {
2857                            if let Some(name) = expr_to_name.get(&expr)
2858                                && !exclude.contains(name)
2859                            {
2860                                scope.add_required_output(name);
2861                            }
2862                        }
2863                        nmbrs_workload::bindpoints::BindPoint::Reference { name, .. } => {
2864                            if !exclude.contains(&name) {
2865                                scope.add_required_output(&name);
2866                            }
2867                        }
2868                    }
2869                }
2870                return;
2871            }
2872            // Bare-identifier form (no braces) — legacy.
2873            if !trimmed.is_empty() && !exclude.contains(&trimmed.to_string()) {
2874                scope.add_required_output(trimmed);
2875            }
2876        };
2877        if let Some(ref cond) = op.condition {
2878            collect_required(cond);
2879        }
2880        if let Some(ref delay_spec) = op.delay {
2881            for name in delay_spec.names() {
2882                collect_required(name);
2883            }
2884        }
2885        // SRD-40b §6: synthetic-metric `value:` references must
2886        // survive DCE so the dispenser's Polydat pull plan can resolve
2887        // them. Bare-name values (the SRD-40b §1 canonical form)
2888        // refer to a wire produced somewhere in scope; non-bare
2889        // expressions are deferred to Phase 9 elsewhere — for the
2890        // bare-name case we mark the wire required.
2891        for spec in op.metrics.values() {
2892            let trimmed = spec.value.trim();
2893            let bare =
2894                !trimmed.is_empty() && trimmed.chars().all(|c| c.is_alphanumeric() || c == '_');
2895            if bare && !exclude.contains(&trimmed.to_string()) {
2896                scope.add_required_output(trimmed);
2897            }
2898        }
2899        // SRD-40b §5 result-as-GK: each `result:` wire reads a
2900        // path expression off the response body and exposes it as
2901        // a Polydat wire. The wire's *name* is what subsequent
2902        // wrappers (metrics, validation) pull against — mark each
2903        // declared result wire as required so the kernel exposes
2904        // an extern slot for it on the post-execute write path.
2905        // SRD-66: result-wire names are already declared as
2906        // wires through the op's `bindings:` block (when the
2907        // workload uses `extern X: T` for shared-cell writes)
2908        // or are independent of the op-template kernel (when
2909        // the wire is consumed only by the result dispenser's
2910        // capture map). Marking them as required outputs here
2911        // would double-declare the `__port_*` for any name
2912        // that's also an extern, so the kernel-synthesis-side
2913        // wiring is left to Push 2's full kernel-driven path
2914        // (the SRD-66 §"Compilation lifecycle" closure-binding
2915        // rule). This branch intentionally does nothing for now.
2916        let _ = op.result.as_ref();
2917        crate::bindings::collect_param_bindings_into(
2918            &op.params,
2919            exclude,
2920            &mut scope.required_outputs,
2921        );
2922    }
2923
2924    // Config refs (from cycles={expr})
2925    if let Some(cycles_spec) = phase_cycles
2926        && cycles_spec.starts_with('{')
2927        && cycles_spec.ends_with('}')
2928    {
2929        let mut inner = cycles_spec[1..cycles_spec.len() - 1].to_string();
2930        for (v, val) in iteration_vars {
2931            inner = inner.replace(&format!("{{{v}}}"), val);
2932        }
2933        inner = crate::runner::expand_workload_params(&inner, workload_params);
2934        scope.add_config_ref(&inner);
2935    }
2936
2937    Ok(scope)
2938}
2939
2940/// Apply iteration-variable substitution to *op-template
2941/// strings only* — `raw:`, `prepared:`, `stmt:`, etc.
2942///
2943/// Iter vars flow into Polydat binding source as wires (declared as
2944/// externs by `BindingScope::add_iteration_var` and bound at
2945/// runtime via the standard input mechanism), so this helper
2946/// **does not** touch `op.bindings`. It only rewrites the
2947/// op-template field values, where `{var}` placeholders refer
2948/// to structural elements (table names, keyspace names,
2949/// optimize-for hints) that adapters need as literal text —
2950/// not as bind variables. CQL's `prepared:` form, for example,
2951/// converts every remaining `{name}` in the statement to a `?`
2952/// bind marker; iter vars in structural positions (`INSERT
2953/// INTO ks.{table} ...`) must be substituted away before that
2954/// conversion runs, since CQL doesn't permit `?` for table
2955/// names.
2956/// SRD-68 Push 5c — validate-only walk. Same semantic as
2957/// [`resolve_placeholders_via_kernel`] but DOES NOT mutate the op
2958/// strings. Walks every `{name}` placeholder in the ops' op fields
2959/// and op-level params, accumulating diagnostics for unresolved
2960/// references; returns `Result<(), String>` describing any
2961/// unresolved bindpoints.
2962///
2963/// Used at phase activation as the single workload-load-time
2964/// validation step. Adapters now do their own cycle-time
2965/// resolution (CQL: construction-time structural via
2966/// `canonical_kernel.lookup` + cycle-time per-cycle via
2967/// `WireSource::get`; non-CQL: cycle-time via
2968/// `synthesis::resolve_cached →
2969/// substitute_bind_points_with_state` against `main_kernel`).
2970/// Mutation of the workload model is no longer load-bearing —
2971/// only the diagnostic surface is.
2972///
2973/// `enclosing` are the programs of the scopes around the phase — the
2974/// current parent and every installed ancestor. A name one of them
2975/// BINDS (a per-cycle workload or scenario binding, not a folded
2976/// constant) resolves nowhere now, yet the phase scope pulls its
2977/// binding in as local matter (`local_inclusion_chain`, SRD-13f case
2978/// 3), so it is per-cycle here exactly like a phase-declared one.
2979pub fn validate_placeholders_via_kernel(
2980    ops: &[ParsedOp],
2981    kernel: &dyn polydat::Kernel,
2982    enclosing: &[&polydat::kernel::PolydatProgram],
2983) -> Result<(), String> {
2984    let mut per_cycle_names = collect_phase_binding_lhs_names(ops);
2985    let nothing_excluded = HashSet::new();
2986    for name in collect_op_placeholder_names(ops) {
2987        if !per_cycle_names.contains(&name)
2988            && enclosing.iter().any(|program| {
2989                !program
2990                    .local_inclusion_chain(&name, &nothing_excluded)
2991                    .is_empty()
2992            })
2993        {
2994            per_cycle_names.push(name);
2995        }
2996    }
2997
2998    let mut errors: Vec<String> = Vec::new();
2999    let in_scope = || -> Vec<String> {
3000        let mut names: Vec<String> = kernel.output_names();
3001        for n in kernel.input_names() {
3002            if !names.contains(&n) {
3003                names.push(n);
3004            }
3005        }
3006        names.sort();
3007        names
3008    };
3009    for op in ops.iter() {
3010        let op_name = op.name.clone();
3011        for (key, value) in op.op.iter() {
3012            let path = format!("op '{op_name}' field '{key}'");
3013            // Clone-then-discard: `resolve_placeholders_in_json`
3014            // requires `&mut serde_json::Value` but we don't
3015            // care about its mutations — only the `errors` it
3016            // accumulates. The clone is shallow per JSON value
3017            // and runs once per field at workload-load time.
3018            let mut throwaway = value.clone();
3019            resolve_placeholders_in_json(
3020                &mut throwaway,
3021                kernel,
3022                &per_cycle_names,
3023                &path,
3024                &mut errors,
3025            );
3026        }
3027        for (key, value) in op.params.iter() {
3028            if key == "gutter" {
3029                continue; // runtime-resolved templates (see the mutable pass below)
3030            }
3031            let path = format!("op '{op_name}' param '{key}'");
3032            let mut throwaway = value.clone();
3033            resolve_placeholders_in_json(
3034                &mut throwaway,
3035                kernel,
3036                &per_cycle_names,
3037                &path,
3038                &mut errors,
3039            );
3040        }
3041    }
3042
3043    if errors.is_empty() {
3044        return Ok(());
3045    }
3046    let in_scope_str = in_scope().join(", ");
3047    let mut out =
3048        String::from("placeholder resolution failed (single read path: Polydat Kernel lookup):\n");
3049    for e in &errors {
3050        out.push_str("  - ");
3051        out.push_str(e);
3052        out.push('\n');
3053    }
3054    out.push_str(&format!(
3055        "  in-scope names at this kernel: [{in_scope_str}]"
3056    ));
3057    Err(out)
3058}
3059
3060/// SRD-68 Push 5c — resolve `{name}` placeholders in a single
3061/// op's `params` against `kernel`. Used by validation wrappers
3062/// at construction time to pre-resolve config like `relevancy.k`
3063/// = `"{k}"` against their dispenser's canonical kernel, so the
3064/// downstream spec parsers see a literal value (`10`) rather
3065/// than a surviving placeholder string.
3066///
3067/// Per-cycle binding LHS names pass through unchanged — the
3068/// wrapper resolves those via wires at cycle time (e.g.
3069/// `relevancy.expected = "{ground_truth}"` stays as-is so the
3070/// wrapper can register it on the fixture's pull plan and
3071/// read it per cycle).
3072///
3073/// Single-op variant of the legacy bulk-mutation pass; the
3074/// per-template granularity lets each wrapper resolve against
3075/// its own dispenser's canonical kernel rather than a shared
3076/// activity-layer parent.
3077pub fn resolve_placeholders_in_op_params(
3078    op: &mut ParsedOp,
3079    kernel: &dyn polydat::Kernel,
3080) -> Result<(), String> {
3081    let per_cycle_names = collect_phase_binding_lhs_names(std::slice::from_ref(op));
3082
3083    let mut errors: Vec<String> = Vec::new();
3084    let in_scope = || -> Vec<String> {
3085        let mut names: Vec<String> = kernel.output_names();
3086        for n in kernel.input_names() {
3087            if !names.contains(&n) {
3088                names.push(n);
3089            }
3090        }
3091        names.sort();
3092        names
3093    };
3094    let op_name = op.name.clone();
3095    for (key, value) in op.params.iter_mut() {
3096        // `gutter:` templates resolve at RUNTIME — during-forms per op
3097        // completion on the fiber wires, `final:` at phase end with a
3098        // status-metric fallback (`{recall}`, `{latency_p50}`) that has
3099        // no kernel presence here. Unresolved names degrade to visible
3100        // literal text in the cell, so the strict pre-resolution pass
3101        // must not reject them.
3102        if key == "gutter" {
3103            continue;
3104        }
3105        let path = format!("op '{op_name}' param '{key}'");
3106        resolve_placeholders_in_json(value, kernel, &per_cycle_names, &path, &mut errors);
3107    }
3108    if errors.is_empty() {
3109        return Ok(());
3110    }
3111    let in_scope_str = in_scope().join(", ");
3112    let mut out = String::from("param-placeholder resolution failed:\n");
3113    for e in &errors {
3114        out.push_str("  - ");
3115        out.push_str(e);
3116        out.push('\n');
3117    }
3118    out.push_str(&format!(
3119        "  in-scope names at this kernel: [{in_scope_str}]"
3120    ));
3121    Err(out)
3122}
3123
3124// SRD-68 Push 5 cleanup: the legacy
3125// `resolve_placeholders_via_kernel` (op-field text mutation) and
3126// `resolve_placeholders_in_params_only` (bulk op-params mutation)
3127// are both retired. The executor calls
3128// [`validate_placeholders_via_kernel`] (pure walker) at workload
3129// load to surface unresolved-bindpoint diagnostics; adapters
3130// resolve op-field placeholders themselves at construction (CQL
3131// prepared via `resolve_structural_and_mark_remaining`) or at
3132// cycle time (CQL raw via `substitute_via_wires`); validation
3133// wrappers resolve their own op.params at construction via
3134// [`resolve_placeholders_in_op_params`] against the dispenser's
3135// own canonical kernel. The workload model is no longer mutated
3136// — `OpDispenser::describe()` returns pristine yaml.
3137
3138/// Scan `ops`' `bindings:` text for the LHS names that get
3139/// produced as per-cycle wires. The scan is intentionally
3140/// liberal (LHS can be `cursor q = …`, `init x = …`, `name :=
3141/// expr`, `extern n: type`, `const n := expr`, `shared n := expr`,
3142/// destructured `(a, b) := …`). Anything that survives the LHS
3143/// strip becomes a known name; the substitution path uses this
3144/// set to distinguish "per-cycle wire — leave for the dispenser"
3145/// from "typo or missing cascade — error."
3146/// Every `{name}` placeholder the ops' fields and params reference.
3147fn collect_op_placeholder_names(ops: &[ParsedOp]) -> Vec<String> {
3148    fn walk(value: &serde_json::Value, out: &mut Vec<String>) {
3149        match value {
3150            serde_json::Value::String(s) => {
3151                for name in nmbrs_workload::bindpoints::referenced_bindings(s) {
3152                    if !out.contains(&name) {
3153                        out.push(name);
3154                    }
3155                }
3156            }
3157            serde_json::Value::Array(items) => items.iter().for_each(|v| walk(v, out)),
3158            serde_json::Value::Object(map) => map.values().for_each(|v| walk(v, out)),
3159            _ => {}
3160        }
3161    }
3162    let mut out = Vec::new();
3163    for op in ops {
3164        op.op.values().for_each(|v| walk(v, &mut out));
3165        op.params.values().for_each(|v| walk(v, &mut out));
3166    }
3167    out
3168}
3169
3170fn collect_phase_binding_lhs_names(ops: &[ParsedOp]) -> Vec<String> {
3171    let mut out: Vec<String> = Vec::new();
3172    for op in ops {
3173        if let BindingsDef::PolydatSource(src) = &op.bindings {
3174            for line in logical_lines(src) {
3175                let trimmed = line.trim();
3176                if trimmed.is_empty() || trimmed.starts_with('#') {
3177                    continue;
3178                }
3179                // `input` declarations declare per-cycle wire names
3180                // by definition — the runtime sets them per
3181                // iteration. Both surface forms are handled:
3182                //   input cycle: u64
3183                //   input (cycle: u64, q: f64)
3184                // Without this, `{cycle}` in op templates would
3185                // resolve at compile time against the kernel's
3186                // initial value (0) instead of being deferred to
3187                // per-iteration substitution.
3188                if let Some(rest) = trimmed.strip_prefix("input ") {
3189                    let rest = rest.trim();
3190                    if let Some(inner) = rest.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
3191                        for piece in inner.split(',') {
3192                            let n = piece.trim().split(':').next().unwrap_or("").trim();
3193                            if is_bare_ident(n) && !out.contains(&n.to_string()) {
3194                                out.push(n.to_string());
3195                            }
3196                        }
3197                    } else {
3198                        let n = rest.split(':').next().unwrap_or("").trim();
3199                        if is_bare_ident(n) && !out.contains(&n.to_string()) {
3200                            out.push(n.to_string());
3201                        }
3202                    }
3203                    continue;
3204                }
3205                let lhs_end = trimmed
3206                    .find(":=")
3207                    .or_else(|| trimmed.find('='))
3208                    .unwrap_or(trimmed.len());
3209                let mut lhs = &trimmed[..lhs_end];
3210                // Strip ALL leading wire-coloring modifiers in
3211                // any order. `volatile final` and `final shared`
3212                // both need their bare name extracted, and any
3213                // future modifier added to the SRD-10 set must
3214                // appear here too.
3215                loop {
3216                    let mut matched = false;
3217                    for prefix in [
3218                        "cursor ",
3219                        "init ",
3220                        "extern ",
3221                        "const ",
3222                        "final ",
3223                        "shared ",
3224                        "volatile ",
3225                        "private ",
3226                    ] {
3227                        if let Some(stripped) = lhs.strip_prefix(prefix) {
3228                            lhs = stripped.trim();
3229                            matched = true;
3230                            break;
3231                        }
3232                    }
3233                    if !matched {
3234                        break;
3235                    }
3236                }
3237                let lhs = lhs.trim();
3238                // Destructured tuple LHS: (a, b, c) := ...
3239                if let Some(inner) = lhs.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
3240                    for piece in inner.split(',') {
3241                        let n = piece.trim().trim_end_matches(':').trim();
3242                        if is_bare_ident(n) && !out.contains(&n.to_string()) {
3243                            out.push(n.to_string());
3244                        }
3245                    }
3246                    continue;
3247                }
3248                // Type-annotated single LHS: `name: type`.
3249                let bare = lhs.split(':').next().unwrap_or(lhs).trim();
3250                if is_bare_ident(bare) && !out.contains(&bare.to_string()) {
3251                    out.push(bare.to_string());
3252                }
3253            }
3254        }
3255    }
3256    out
3257}
3258
3259fn is_bare_ident(s: &str) -> bool {
3260    let mut chars = s.chars();
3261    match chars.next() {
3262        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
3263        _ => return false,
3264    }
3265    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
3266}
3267
3268/// True iff `s` is a polydat string literal — opens with `"`,
3269/// closes with `"`, and (conservatively) contains no
3270/// unescaped closing quote in the middle. This is the shape
3271/// `format_jval_as_polydat_literal` produces for YAML strings
3272/// (`"hello"`) so the param-binding classifier can pass it
3273/// through without re-quoting.
3274fn is_polydat_quoted_string(s: &str) -> bool {
3275    if s.len() < 2 {
3276        return false;
3277    }
3278    if !s.starts_with('"') || !s.ends_with('"') {
3279        return false;
3280    }
3281    // Walk the inner span; reject if we find an unescaped `"`
3282    // before the final character (which would mean the outer
3283    // quotes don't actually pair).
3284    let bytes = s.as_bytes();
3285    let mut i = 1;
3286    let last = bytes.len() - 1;
3287    while i < last {
3288        if bytes[i] == b'\\' {
3289            i += 2;
3290            continue;
3291        }
3292        if bytes[i] == b'"' {
3293            return false;
3294        }
3295        i += 1;
3296    }
3297    true
3298}
3299
3300/// True iff `s` looks like a polydat array literal: `[` …
3301/// matched brackets … `]`. Conservative — we don't validate
3302/// the elements here; the polydat parser handles that when
3303/// the `const X := [...]` line compiles. The check just
3304/// distinguishes "this is already polydat array syntax" from
3305/// "this is a raw string that happens to contain brackets".
3306fn is_polydat_array_literal(s: &str) -> bool {
3307    if !s.starts_with('[') || !s.ends_with(']') {
3308        return false;
3309    }
3310    let mut depth: i32 = 0;
3311    let mut in_string = false;
3312    let mut escape = false;
3313    for c in s.chars() {
3314        if escape {
3315            escape = false;
3316            continue;
3317        }
3318        if in_string {
3319            if c == '\\' {
3320                escape = true;
3321            } else if c == '"' {
3322                in_string = false;
3323            }
3324            continue;
3325        }
3326        match c {
3327            '"' => in_string = true,
3328            '[' => depth += 1,
3329            ']' => {
3330                depth -= 1;
3331                if depth < 0 {
3332                    return false;
3333                }
3334            }
3335            _ => {}
3336        }
3337    }
3338    depth == 0
3339}
3340
3341#[cfg(test)]
3342mod polydat_param_classifier_tests {
3343    use super::*;
3344
3345    #[test]
3346    fn scan_idents_skips_hash_comments() {
3347        // `#` is a YAML-style line comment in Polydat (dsl/lexer.rs).
3348        // The ident scanner must skip it too, or comment words leak in
3349        // as phantom wire references (regression: finalize_index's
3350        // bindings comments).
3351        let src = "# Forwarding bindings: allow-list = bindings + params\n\
3352                   pct := mul(active, 2)  # trailing comment with words\n";
3353        let idents = scan_idents_in_polydat_source(src);
3354        assert!(
3355            idents.contains("active"),
3356            "real ident must be found: {idents:?}"
3357        );
3358        for word in [
3359            "Forwarding",
3360            "bindings",
3361            "allow",
3362            "list",
3363            "params",
3364            "trailing",
3365            "comment",
3366            "words",
3367        ] {
3368            assert!(
3369                !idents.contains(word),
3370                "comment word '{word}' must not be scanned as a wire ref: {idents:?}"
3371            );
3372        }
3373    }
3374
3375    #[test]
3376    fn scan_idents_skips_block_conditional_keywords() {
3377        // Block-form conditional selection puts bare `if` / `else` in expression
3378        // position. This scanner is textual, so without the keyword entries they were
3379        // emitted as wire references and the phase failed with
3380        // `unresolved wire reference(s) ["else", "if"]` (regression: finalize_index's
3381        // seg_mib_mean binding).
3382        let src = "seg_mib_mean := if segments > 0 { total / max(segments, 1) } else { 0 }\n";
3383        let idents = scan_idents_in_polydat_source(src);
3384        assert!(
3385            idents.contains("segments"),
3386            "real wire must still be found: {idents:?}"
3387        );
3388        assert!(
3389            idents.contains("total"),
3390            "real wire must still be found: {idents:?}"
3391        );
3392        for kw in ["if", "else"] {
3393            assert!(
3394                !idents.contains(kw),
3395                "conditional keyword '{kw}' must not scan as a wire ref: {idents:?}"
3396            );
3397        }
3398    }
3399
3400    #[test]
3401    fn scan_locally_declared_skips_hash_comment_lines() {
3402        let src = "# total = sum of parts\n\
3403                   shared sstables := 0\n";
3404        let decls = scan_locally_declared_idents(src);
3405        assert!(decls.contains("sstables"));
3406        // `# total = ...` has an `=`; it must not be parsed as a decl.
3407        assert!(
3408            !decls.contains("total"),
3409            "comment LHS must not declare: {decls:?}"
3410        );
3411    }
3412
3413    #[test]
3414    fn bare_identifier_classifier_accepts_idents_and_rejects_other_shapes() {
3415        assert!(is_bare_ident("sm"));
3416        assert!(is_bare_ident("source_model"));
3417        assert!(is_bare_ident("k_values"));
3418        assert!(is_bare_ident("_underscore"));
3419        assert!(is_bare_ident("a1"));
3420        // Rejects strings that don't fit the ident grammar.
3421        assert!(!is_bare_ident(""));
3422        assert!(!is_bare_ident("1abc"), "ident can't start with digit");
3423        assert!(!is_bare_ident("foo bar"), "no spaces");
3424        assert!(!is_bare_ident("[a, b]"));
3425        assert!(!is_bare_ident("\"quoted\""));
3426        assert!(!is_bare_ident("foo+bar"));
3427        // Reserved-ish keywords stay valid as identifiers here;
3428        // the polydat parser is the authority on what's
3429        // reserved.
3430        assert!(is_bare_ident("true"));
3431        assert!(is_bare_ident("false"));
3432    }
3433
3434    #[test]
3435    fn polydat_quoted_string_accepts_paired_quotes_only() {
3436        assert!(is_polydat_quoted_string("\"hello\""));
3437        assert!(is_polydat_quoted_string("\"\""));
3438        assert!(is_polydat_quoted_string("\"with \\\"escaped\\\" inner\""));
3439        // Rejects shapes that aren't paired-quote.
3440        assert!(!is_polydat_quoted_string("hello"));
3441        assert!(!is_polydat_quoted_string("\"open-only"));
3442        assert!(!is_polydat_quoted_string("close-only\""));
3443        assert!(!is_polydat_quoted_string(""));
3444        assert!(!is_polydat_quoted_string("\""));
3445    }
3446
3447    #[test]
3448    fn polydat_array_literal_balances_brackets() {
3449        assert!(is_polydat_array_literal("[1, 2, 3]"));
3450        assert!(is_polydat_array_literal("[]"));
3451        assert!(
3452            is_polydat_array_literal("[[1, 2], [3, 4]]"),
3453            "nested arrays balance"
3454        );
3455        assert!(is_polydat_array_literal("[\"a\", \"b\"]"));
3456        // Strings containing `]` don't break the count.
3457        assert!(is_polydat_array_literal("[\"a]b\", \"c\"]"));
3458        // Rejects unbalanced shapes.
3459        assert!(!is_polydat_array_literal("[1, 2"));
3460        assert!(!is_polydat_array_literal("1, 2]"));
3461        // `[1][2]` passes the conservative shape check (depth
3462        // returns to zero) — polydat's parser rejects it
3463        // downstream as invalid syntax. The classifier is a
3464        // routing heuristic, not a validator.
3465    }
3466}
3467
3468/// Recursively walk a JSON value and resolve every `{name}`
3469/// placeholder via [`KernelLookup`](polydat::kernel::interp::KernelLookup). Non-resolving names
3470/// that are in the per-cycle binding set stay as-is (the
3471/// dispenser will resolve them at execute time); anything else
3472/// gets pushed onto `errors`.
3473///
3474/// Object keys are *not* rewritten — keys are the closed-vocab
3475/// field name surface, distinct from the value-bearing
3476/// placeholders.
3477fn resolve_placeholders_in_json(
3478    value: &mut serde_json::Value,
3479    kernel: &dyn polydat::Kernel,
3480    per_cycle_names: &[String],
3481    field_path: &str,
3482    errors: &mut Vec<String>,
3483) {
3484    match value {
3485        serde_json::Value::String(s) => {
3486            match resolve_placeholders_in_string(s, kernel, per_cycle_names, field_path) {
3487                Ok(out) => *value = serde_json::Value::String(out),
3488                Err(es) => errors.extend(es),
3489            }
3490        }
3491        serde_json::Value::Array(arr) => {
3492            for (i, v) in arr.iter_mut().enumerate() {
3493                let p = format!("{field_path}[{i}]");
3494                resolve_placeholders_in_json(v, kernel, per_cycle_names, &p, errors);
3495            }
3496        }
3497        serde_json::Value::Object(map) => {
3498            for (k, v) in map.iter_mut() {
3499                let p = format!("{field_path}.{k}");
3500                resolve_placeholders_in_json(v, kernel, per_cycle_names, &p, errors);
3501            }
3502        }
3503        _ => {}
3504    }
3505}
3506
3507/// Walk one string, replace each `{name}` placeholder with the
3508/// kernel's `lookup` result for `name`. Bare-ident-only — the
3509/// qualified `{bind:…}` / `{capture:…}` / `{input:…}` /
3510/// `{param:…}` shapes and the inline `{{expr}}` shape pass
3511/// through untouched (consumed downstream by the dispenser /
3512/// inline-expression desugar). Names that fall through to the
3513/// per-cycle binding set also pass through. Anything else is
3514/// returned as an error in the `Err` Vec, with the field path
3515/// for context.
3516fn resolve_placeholders_in_string(
3517    s: &str,
3518    kernel: &dyn polydat::Kernel,
3519    per_cycle_names: &[String],
3520    field_path: &str,
3521) -> Result<String, Vec<String>> {
3522    let bytes = s.as_bytes();
3523    let n = bytes.len();
3524    let mut out = String::with_capacity(n);
3525    let mut errors: Vec<String> = Vec::new();
3526    let mut i = 0;
3527    while i < n {
3528        // `\{` and `\}` are escapes — passthrough one char.
3529        if bytes[i] == b'\\' && i + 1 < n && (bytes[i + 1] == b'{' || bytes[i + 1] == b'}') {
3530            out.push(bytes[i] as char);
3531            out.push(bytes[i + 1] as char);
3532            i += 2;
3533            continue;
3534        }
3535        // `{{ ... }}` is an inline expression — passthrough.
3536        if i + 1 < n && bytes[i] == b'{' && bytes[i + 1] == b'{' {
3537            // Find the matching `}}`.
3538            let start = i;
3539            let mut j = i + 2;
3540            while j + 1 < n && !(bytes[j] == b'}' && bytes[j + 1] == b'}') {
3541                j += 1;
3542            }
3543            let end = (j + 2).min(n);
3544            out.push_str(&s[start..end]);
3545            i = end;
3546            continue;
3547        }
3548        if bytes[i] != b'{' {
3549            out.push(bytes[i] as char);
3550            i += 1;
3551            continue;
3552        }
3553        // Find the matching `}` for this `{`.
3554        let body_start = i + 1;
3555        let mut j = body_start;
3556        while j < n && bytes[j] != b'}' {
3557            j += 1;
3558        }
3559        if j >= n {
3560            // Unterminated `{` — treat as literal char and move on.
3561            out.push('{');
3562            i += 1;
3563            continue;
3564        }
3565        let body = &s[body_start..j];
3566        let after = j + 1;
3567
3568        // Qualified references stay as-is for downstream.
3569        if body.contains(':') {
3570            out.push('{');
3571            out.push_str(body);
3572            out.push('}');
3573            i = after;
3574            continue;
3575        }
3576
3577        // Empty body — leave as-is, validator catches.
3578        if body.is_empty() {
3579            out.push('{');
3580            out.push_str(body);
3581            out.push('}');
3582            i = after;
3583            continue;
3584        }
3585
3586        // Not a bare identifier — pass through (could be a format spec).
3587        if !is_bare_ident(body) {
3588            out.push('{');
3589            out.push_str(body);
3590            out.push('}');
3591            i = after;
3592            continue;
3593        }
3594
3595        // Names that are per-cycle (coordinates declared via
3596        // `input (cycle: u64, ...: u64)`, or LHS of phase bindings)
3597        // MUST be deferred to per-cycle resolution. Their value
3598        // varies per iteration; pre-resolving against the parent
3599        // kernel here would bake in iteration 0's value (0) and
3600        // every subsequent iteration would emit the same string.
3601        if per_cycle_names.iter().any(|n| n == body) {
3602            out.push('{');
3603            out.push_str(body);
3604            out.push('}');
3605            i = after;
3606            continue;
3607        }
3608        // Bare ident — try kernel lookup. Computed outputs
3609        // (node-backed) aren't found by `lookup` (it only reads
3610        // input slots + constants), but they ARE valid wires
3611        // visible at this scope — accept them by checking
3612        // `resolve_output`. Per-cycle resolution at dispenser
3613        // time handles the actual pull.
3614        match polydat::kernel::interp::Lookup::lookup(
3615            &polydat::kernel::interp::KernelLookup::new(kernel),
3616            body,
3617        ) {
3618            Some(v) => out.push_str(&v.to_display_string()),
3619            None if kernel.output_index(body).is_some() => {
3620                // Defer to per-cycle resolution. Emit the
3621                // placeholder unchanged.
3622                out.push('{');
3623                out.push_str(body);
3624                out.push('}');
3625            }
3626            None => {
3627                errors.push(format!(
3628                    "{field_path}: '{{{body}}}' did not resolve in scope and is \
3629                     not a per-cycle binding declared by this phase"
3630                ));
3631                // Still push the placeholder as-is so the rest of
3632                // the string remains parseable for further error
3633                // collection on the same field.
3634                out.push('{');
3635                out.push_str(body);
3636                out.push('}');
3637            }
3638        }
3639        i = after;
3640    }
3641
3642    if errors.is_empty() {
3643        Ok(out)
3644    } else {
3645        Err(errors)
3646    }
3647}
3648
3649/// Rewrite inline expressions (`{{expr}}`) in op template strings to
3650/// use named binding references (`{__expr_N}`).
3651///
3652/// Returns the expression-to-name map for the caller to know what
3653/// was rewritten.
3654pub fn rewrite_inline_exprs(ops: &mut [ParsedOp]) -> HashMap<String, String> {
3655    // SRD-13d: each op template is its own Polydat scope. Inline
3656    // `{{<expr>}}` rewrites are Polydat matter that belongs to the
3657    // op-template scope, not to the shared phase scope. So each
3658    // op gets its OWN expression-to-name mapping, with
3659    // op-locally unique synth names — no cross-op dedup. Two ops
3660    // with textually identical inline expressions
3661    // (`if: cql_dialect == 'vendor'` on both `indexes_present_vendor`
3662    // and `indexes_built_vendor`) now get distinct
3663    // `__expr_N`/`__expr_M` names. Without this, both ops
3664    // injected the same `__expr_1 := cql_dialect == 'vendor'`
3665    // line into their bindings; the phase-scope ingest then saw
3666    // two ops each declaring `__expr_1` and tripped the
3667    // ride-along-uniqueness check (SRD-13c §"Op overriding op
3668    // shadow").
3669    //
3670    // The global `inline_idx` counter still increments
3671    // monotonically so synth names are workload-wide unique;
3672    // the per-op MAP keeps within-op dedup (same expression in
3673    // multiple fields of one op — `if: x == 1` and
3674    // `metric: x == 1` — collapses to a single `__expr_N` for
3675    // that op).
3676    let mut inline_idx = 0usize;
3677    let mut per_op_expr_to_name: Vec<HashMap<String, String>> =
3678        (0..ops.len()).map(|_| HashMap::new()).collect();
3679    let collect_from = |s: &str, idx: &mut usize, op_map: &mut HashMap<String, String>| {
3680        for bp in nmbrs_workload::bindpoints::extract_bind_points(s) {
3681            if let nmbrs_workload::bindpoints::BindPoint::InlineDefinition(ref expr) = bp {
3682                op_map.entry(expr.clone()).or_insert_with(|| {
3683                    let n = format!("__expr_{idx}");
3684                    *idx += 1;
3685                    n
3686                });
3687            }
3688        }
3689    };
3690    for (op_index, op) in ops.iter().enumerate() {
3691        let op_map = &mut per_op_expr_to_name[op_index];
3692        for value in op.op.values() {
3693            if let Some(s) = value.as_str() {
3694                collect_from(s, &mut inline_idx, op_map);
3695            }
3696        }
3697        if let Some(s) = &op.condition {
3698            collect_from(s, &mut inline_idx, op_map);
3699        }
3700        if let Some(spec) = &op.delay {
3701            for name in spec.names() {
3702                collect_from(name, &mut inline_idx, op_map);
3703            }
3704        }
3705    }
3706    // For diagnostics + downstream: the legacy single
3707    // `expr_to_name` return value flattens the per-op maps.
3708    // Names are unique across ops because the inline_idx
3709    // counter is global, so the union is collision-free.
3710    let expr_to_name: HashMap<String, String> = per_op_expr_to_name
3711        .iter()
3712        .flat_map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())))
3713        .collect();
3714
3715    // Inject the synthesised `__expr_N := expr` bindings into
3716    // the first op's Polydat bindings source so `build_scope`'s
3717    // normal ingestion pass picks them up. Without this the
3718    // op fields get rewritten to reference `{__expr_N}` but
3719    // no binding declaring `__expr_N` ever lands in the
3720    // scope, so compilation fails with "unresolved bind
3721    // point '{__expr_N}'". Callers used to discard the
3722    // returned `expr_to_name` mapping and trust some other
3723    // path to install the bindings — there isn't one. Doing
3724    // the injection here keeps `rewrite_inline_exprs`
3725    // self-contained: every output rewrite has a matching
3726    // binding emitted.
3727    // Inject + rewrite per op. Each op uses ONLY its own
3728    // expr_to_name mapping (per_op_expr_to_name[op_index]).
3729    // Synth lines land in that op's bindings; field rewrites
3730    // see only that op's expression names. SRD-13d: the
3731    // op-template scope owns its own Polydat matter, including
3732    // synth bindings from inline expressions.
3733    if expr_to_name.is_empty() {
3734        return expr_to_name;
3735    }
3736    use nmbrs_workload::model::BindingsDef;
3737    for (op_index, op) in ops.iter_mut().enumerate() {
3738        let op_map = &per_op_expr_to_name[op_index];
3739        if op_map.is_empty() {
3740            continue;
3741        }
3742
3743        // Build synth lines for this op, deterministically
3744        // ordered by synth name.
3745        let mut entries: Vec<(&String, &String)> = op_map.iter().collect();
3746        entries.sort_by(|a, b| a.1.cmp(b.1));
3747        let mut synth_lines = String::new();
3748        for (expr, name) in &entries {
3749            synth_lines.push_str(&format!("\n{name} := {expr}"));
3750        }
3751
3752        // Inject into op.bindings. Map → PolydatSource conversion
3753        // mirrors the existing scope-source assembly path.
3754        match &mut op.bindings {
3755            BindingsDef::PolydatSource(s) => {
3756                if s.trim().is_empty() {
3757                    *s = synth_lines.trim_start_matches('\n').to_string();
3758                } else {
3759                    s.push_str(&synth_lines);
3760                }
3761            }
3762            BindingsDef::Map(_) => {
3763                if let BindingsDef::Map(map) = &op.bindings {
3764                    let mut existing = String::new();
3765                    for (k, v) in map.iter() {
3766                        existing.push_str(&format!("{k} := {v}\n"));
3767                    }
3768                    op.bindings = BindingsDef::PolydatSource(format!("{existing}{synth_lines}"));
3769                }
3770            }
3771        }
3772
3773        // Rewrite this op's fields using its own mapping only.
3774        let rewrite = |s: &str| -> String {
3775            let mut rewritten = s.to_string();
3776            for (expr, name) in op_map {
3777                rewritten = rewritten.replace(&format!("{{{{{expr}}}}}"), &format!("{{{name}}}"));
3778                rewritten = rewritten.replace(&format!("{{:={expr}:=}}"), &format!("{{{name}}}"));
3779                rewritten = rewritten.replace(&format!("{{:={expr}}}"), &format!("{{{name}}}"));
3780                rewritten = rewritten.replace(&format!("{{{expr}}}"), &format!("{{{name}}}"));
3781            }
3782            rewritten
3783        };
3784        for value in op.op.values_mut() {
3785            if let Some(s) = value.as_str() {
3786                *value = serde_json::Value::String(rewrite(s));
3787            }
3788        }
3789        if let Some(s) = &op.condition {
3790            op.condition = Some(rewrite(s));
3791        }
3792        if let Some(spec) = &op.delay {
3793            // Rewrite each binding name in place, preserving
3794            // the spec's enum shape (Before vs BeforeAfter).
3795            op.delay = Some(match spec {
3796                nmbrs_workload::model::DelaySpec::Before(name) => {
3797                    nmbrs_workload::model::DelaySpec::Before(rewrite(name))
3798                }
3799                nmbrs_workload::model::DelaySpec::BeforeAfter { before, after } => {
3800                    nmbrs_workload::model::DelaySpec::BeforeAfter {
3801                        before: before.as_deref().map(rewrite),
3802                        after: after.as_deref().map(rewrite),
3803                    }
3804                }
3805            });
3806        }
3807    }
3808
3809    expr_to_name
3810}
3811
3812#[cfg(test)]
3813mod tests {
3814    use super::*;
3815
3816    fn make_polydat_op(name: &str, stmt: &str, bindings: &str) -> ParsedOp {
3817        let mut op = ParsedOp::simple(name, stmt);
3818        op.bindings = BindingsDef::PolydatSource(bindings.to_string());
3819        op
3820    }
3821
3822    #[test]
3823    fn objective_bare_wire_vs_inline_expression() {
3824        // Bare identifiers are read directly off the phase kernel.
3825        assert!(objective_is_bare_wire("score"));
3826        assert!(objective_is_bare_wire("err_rate"));
3827        assert!(objective_is_bare_wire("_objective"));
3828        assert!(objective_is_bare_wire("  recall99  ")); // trimmed
3829        assert_eq!(objective_wire("score"), "score");
3830        assert_eq!(objective_wire("  recall99  "), "recall99");
3831
3832        // Anything with operators / calls / dots / spaces is an inline
3833        // expression → synthesized to the `__objective` wire.
3834        assert!(!objective_is_bare_wire("0 - err_rate"));
3835        assert!(!objective_is_bare_wire("metricsql_scalar(\"x\")"));
3836        assert!(!objective_is_bare_wire("a - b"));
3837        assert!(!objective_is_bare_wire("q.cursor.depth"));
3838        assert!(!objective_is_bare_wire(""));
3839        assert_eq!(objective_wire("0 - err_rate"), OBJECTIVE_WIRE);
3840        assert_eq!(objective_wire("metricsql_scalar(\"x\")"), OBJECTIVE_WIRE);
3841    }
3842
3843    #[test]
3844    fn inline_objective_synthesizes_volatile_objective_binding() {
3845        use nmbrs_workload::model::{BindingsDef, OptimizeBlock, WorkloadPhase};
3846        let phase = WorkloadPhase {
3847            for_each: Some("rate in 1000, 2000".to_string()),
3848            bindings: BindingsDef::PolydatSource("input cycle: u64\n".to_string()),
3849            optimize: Some(OptimizeBlock {
3850                method: "sweep".to_string(),
3851                objective: "0 - metricsql_scalar(\"sum(rate(errors_total[3s]))\")".to_string(),
3852                servo: vec!["rate".to_string()],
3853                max_evals: 10,
3854                seed: 0,
3855                params: Default::default(),
3856            }),
3857            ..Default::default()
3858        };
3859        let out = synthesize_phase_scope_bindings(&phase).expect("synthesis ok");
3860        let src = match out {
3861            BindingsDef::PolydatSource(s) => s,
3862            other => panic!("expected PolydatSource, got {other:?}"),
3863        };
3864        assert!(
3865            src.contains("volatile __objective := 0 - metricsql_scalar("),
3866            "inline objective must be lowered to a `__objective` binding:\n{src}"
3867        );
3868
3869        // A bare-name objective is NOT synthesized — read directly.
3870        let mut bare = phase.clone();
3871        bare.optimize.as_mut().unwrap().objective = "score".to_string();
3872        let bare_out = synthesize_phase_scope_bindings(&bare).expect("synthesis ok");
3873        if let BindingsDef::PolydatSource(s) = bare_out {
3874            assert!(
3875                !s.contains("__objective"),
3876                "bare objective must not synthesize:\n{s}"
3877            );
3878        }
3879    }
3880
3881    #[test]
3882    fn inherited_bindings_dedup_across_ops() {
3883        let bindings = "input cycle: u64\nprofiles := matching_profiles(\"example\", \"label\")";
3884        let ops = vec![
3885            make_polydat_op("op_a", "{profiles}", bindings),
3886            make_polydat_op("op_b", "{profiles}", bindings),
3887        ];
3888        let scope = build_scope(
3889            &ops,
3890            &HashMap::new(),
3891            &[],
3892            &HashMap::new(),
3893            &HashMap::new(),
3894            None,
3895            &[],
3896            None,
3897        )
3898        .unwrap();
3899        scope.validate().unwrap();
3900        let emitted = scope.emit();
3901        // 'profiles' should appear exactly once
3902        let count = emitted.matches("profiles :=").count();
3903        assert_eq!(
3904            count, 1,
3905            "expected exactly 1 'profiles :=' in emitted scope, got {count}:\n{emitted}"
3906        );
3907    }
3908
3909    #[test]
3910    fn iteration_vars_dont_conflict_with_inherited() {
3911        let bindings = "input cycle: u64\nprofiles := matching_profiles(\"example\", \"label\")";
3912        let ops = vec![
3913            make_polydat_op("op_a", "{profiles} {table}", bindings),
3914            make_polydat_op("op_b", "{profiles} {table}", bindings),
3915        ];
3916        let mut iter_vars = HashMap::new();
3917        iter_vars.insert("table".to_string(), "vec_default".to_string());
3918
3919        let scope = build_scope(
3920            &ops,
3921            &iter_vars,
3922            &[],
3923            &HashMap::new(),
3924            &HashMap::new(),
3925            None,
3926            &[],
3927            None,
3928        )
3929        .unwrap();
3930        scope.validate().unwrap();
3931        let emitted = scope.emit();
3932        // Iteration variables now declare as `extern <name>:
3933        // <Type>` so the runtime can populate them per iteration
3934        // without recompiling (SRD 18b §"Iteration variables as
3935        // scope outputs"). Type is inferred from the value;
3936        // "vec_default" doesn't parse numerically → String.
3937        assert!(
3938            emitted.contains("extern table: String"),
3939            "expected extern table declaration in:\n{emitted}"
3940        );
3941        assert!(
3942            emitted.contains("profiles :="),
3943            "expected profiles in:\n{emitted}"
3944        );
3945    }
3946
3947    #[test]
3948    fn op_augmentation_adds_new_names() {
3949        let base = "input cycle: u64\nfoo := hash(cycle)";
3950        let augmented = "input cycle: u64\nfoo := hash(cycle)\nbar := mod(cycle, 100)";
3951        let ops = vec![
3952            make_polydat_op("op_a", "{foo}", base),
3953            make_polydat_op("op_b", "{foo} {bar}", augmented),
3954        ];
3955        let scope = build_scope(
3956            &ops,
3957            &HashMap::new(),
3958            &[],
3959            &HashMap::new(),
3960            &HashMap::new(),
3961            None,
3962            &[],
3963            None,
3964        )
3965        .unwrap();
3966        scope.validate().unwrap();
3967        let emitted = scope.emit();
3968        assert!(emitted.contains("foo := hash(cycle)"), "missing foo");
3969        assert!(emitted.contains("bar := mod(cycle, 100)"), "missing bar");
3970    }
3971
3972    #[test]
3973    fn real_shadow_is_caught() {
3974        let base = "input cycle: u64\nfoo := hash(cycle)";
3975        let shadow = "input cycle: u64\nfoo := mod(cycle, 100)";
3976        let ops = vec![
3977            make_polydat_op("op_a", "{foo}", base),
3978            make_polydat_op("op_b", "{foo}", shadow),
3979        ];
3980        let scope = build_scope(
3981            &ops,
3982            &HashMap::new(),
3983            &[],
3984            &HashMap::new(),
3985            &HashMap::new(),
3986            None,
3987            &[],
3988            None,
3989        )
3990        .unwrap();
3991        let result = scope.validate();
3992        assert!(result.is_err(), "expected shadow error");
3993        let err = result.unwrap_err();
3994        assert!(
3995            err.contains("shadows"),
3996            "expected 'shadows' in error: {err}"
3997        );
3998        assert!(err.contains("op_b"), "expected op name in error: {err}");
3999    }
4000
4001    #[test]
4002    fn original_bug_repro_no_false_shadow() {
4003        // The original bug: workload-level bindings with profiles,
4004        // inherited by a phase with for_each iteration vars.
4005        // Two ops share identical inherited bindings.
4006        // The init injection used to make them differ, causing false shadow.
4007        let bindings = "input cycle: u64\nprofiles := matching_profiles(\"example\", \"label\")";
4008        let ops = vec![
4009            make_polydat_op(
4010                "drop_metadata_index",
4011                "DROP INDEX {table}_meta_idx",
4012                bindings,
4013            ),
4014            make_polydat_op("drop_vector_index", "DROP INDEX {table}_idx", bindings),
4015            make_polydat_op("drop_table", "DROP TABLE {table}", bindings),
4016        ];
4017        let mut iter_vars = HashMap::new();
4018        iter_vars.insert("table".to_string(), "fknn_default".to_string());
4019        iter_vars.insert("spec".to_string(), "example:default".to_string());
4020        iter_vars.insert("optimize_for".to_string(), "RECALL".to_string());
4021
4022        let scope = build_scope(
4023            &ops,
4024            &iter_vars,
4025            &[],
4026            &HashMap::new(),
4027            &HashMap::new(),
4028            None,
4029            &[],
4030            None,
4031        )
4032        .unwrap();
4033        // This was the bug: validate() used to fail with false shadow error
4034        scope.validate().unwrap();
4035        let emitted = scope.emit();
4036        // Iter vars now declare as extern (SRD 18b). The original
4037        // bug — false-shadow detection — is unchanged regardless
4038        // of how the iter var materialises.
4039        assert!(
4040            emitted.contains("extern table: String"),
4041            "missing extern table in:\n{emitted}"
4042        );
4043        assert!(emitted.contains("profiles :="), "missing profiles");
4044        // profiles should appear exactly once
4045        let count = emitted.matches("profiles :=").count();
4046        assert_eq!(count, 1, "profiles duplicated in:\n{emitted}");
4047    }
4048
4049    // ── SRD-13d Phase 9 cross-scope contract check ──────────
4050
4051    fn parent_kernel_with_load() -> crate::scope_kernel::ScopeKernel {
4052        // Parent has `cycle` input, a folded constant `dim`, a
4053        // shared output `budget`, and a dynamic output `load`
4054        // (cycle-dependent, no modifier). Each shape exercises
4055        // a different `ParentRefKind` arm.
4056        crate::bindings::compile_scope_kernel(
4057            "input cycle: u64\n\
4058             const dim := 128\n\
4059             shared budget := 100\n\
4060             load := add(cycle, 1)\n",
4061            &Default::default(),
4062        )
4063        .expect("compile parent")
4064    }
4065
4066    fn op_with_body(name: &str, body: &str) -> ParsedOp {
4067        let mut op = ParsedOp::simple(name, "noop");
4068        op.bindings = BindingsDef::PolydatSource(body.into());
4069        op
4070    }
4071
4072    #[test]
4073    fn op_template_referencing_cycle_input_is_accepted() {
4074        let parent = parent_kernel_with_load();
4075        let manifest = polydat::kernel::extract_manifest(parent.program())
4076            .into_iter()
4077            .map(|e| crate::runner::ManifestEntry {
4078                name: e.name,
4079                port_type: e.port_type,
4080                modifier: e.modifier,
4081            })
4082            .collect::<Vec<_>>();
4083        let op = op_with_body("step_op", "step := add(cycle, 1)\n");
4084        let result = build_op_template_scope_kernel(
4085            &op,
4086            &manifest,
4087            &parent,
4088            &HashMap::new(),
4089            Vec::new(),
4090            None,
4091            false,
4092            polydat::kernel::KernelOptLevel::Release,
4093            "test",
4094        );
4095        assert!(
4096            result.is_ok(),
4097            "cycle is a parent input — should be accepted. err: {:?}",
4098            result.err()
4099        );
4100    }
4101
4102    /// Localises the metric-wiring defect: when an op-template kernel is
4103    /// materialised, does the synthesised `__metric_<name>` binding surface as
4104    /// an output on THAT kernel? The metrics wrapper reads it through
4105    /// `ctx.wires`, so if it is absent the wrapper reports
4106    /// "did not resolve through ctx.wires".
4107    #[test]
4108    fn op_template_kernel_exposes_synthesised_metric_bindings() {
4109        use nmbrs_workload::model::MetricSpec;
4110        let parent = parent_kernel_with_load();
4111        let manifest = polydat::kernel::extract_manifest(parent.program())
4112            .into_iter()
4113            .map(|e| crate::runner::ManifestEntry {
4114                name: e.name,
4115                port_type: e.port_type,
4116                modifier: e.modifier,
4117            })
4118            .collect::<Vec<_>>();
4119        let mut op = op_with_body("m_op", "measured := add(cycle, 1)\n");
4120        op.metrics.insert(
4121            "bytes_out".to_string(),
4122            MetricSpec {
4123                value: "measured".into(),
4124                family: None,
4125                kind: None,
4126                unit: None,
4127                format: None,
4128                cell: Default::default(),
4129            },
4130        );
4131        let kernel = build_op_template_scope_kernel(
4132            &op,
4133            &manifest,
4134            &parent,
4135            &HashMap::new(),
4136            Vec::new(),
4137            None,
4138            false,
4139            polydat::kernel::KernelOptLevel::Release,
4140            "test",
4141        )
4142        .expect("op-template kernel builds");
4143        let outs = kernel.program().output_names();
4144        assert!(
4145            outs.contains(&"__metric_bytes_out"),
4146            "the synthesised metric binding must be an output of the \
4147             op-template kernel; outputs: {outs:?}"
4148        );
4149    }
4150
4151    #[test]
4152    fn op_template_referencing_constant_output_is_accepted() {
4153        let parent = parent_kernel_with_load();
4154        let manifest = polydat::kernel::extract_manifest(parent.program())
4155            .into_iter()
4156            .map(|e| crate::runner::ManifestEntry {
4157                name: e.name,
4158                port_type: e.port_type,
4159                modifier: e.modifier,
4160            })
4161            .collect::<Vec<_>>();
4162        // `dim` is a `final` (folded) output — snapshot is final,
4163        // per-cycle changes are impossible by construction.
4164        let op = op_with_body("calc_op", "scaled := mul(dim, 2)\n");
4165        let result = build_op_template_scope_kernel(
4166            &op,
4167            &manifest,
4168            &parent,
4169            &HashMap::new(),
4170            Vec::new(),
4171            None,
4172            false,
4173            polydat::kernel::KernelOptLevel::Release,
4174            "test",
4175        );
4176        assert!(
4177            result.is_ok(),
4178            "final/folded output should be accepted. err: {:?}",
4179            result.err()
4180        );
4181    }
4182
4183    #[test]
4184    fn op_template_referencing_shared_output_is_accepted() {
4185        let parent = parent_kernel_with_load();
4186        let manifest = polydat::kernel::extract_manifest(parent.program())
4187            .into_iter()
4188            .map(|e| crate::runner::ManifestEntry {
4189                name: e.name,
4190                port_type: e.port_type,
4191                modifier: e.modifier,
4192            })
4193            .collect::<Vec<_>>();
4194        // `budget` is `shared` — SharedCell carries live updates.
4195        let op = op_with_body("budget_op", "remaining := add(budget, 1)\n");
4196        let result = build_op_template_scope_kernel(
4197            &op,
4198            &manifest,
4199            &parent,
4200            &HashMap::new(),
4201            Vec::new(),
4202            None,
4203            false,
4204            polydat::kernel::KernelOptLevel::Release,
4205            "test",
4206        );
4207        assert!(
4208            result.is_ok(),
4209            "shared output should be accepted. err: {:?}",
4210            result.err()
4211        );
4212    }
4213
4214    #[test]
4215    fn op_template_referencing_dynamic_output_accepted_per_srd_13f() {
4216        // SRD-13f retires SRD-13c's "DynamicOutput without
4217        // shared" rejection. The read invariant is uniform —
4218        // inner reads of cross-scope wires return outer's
4219        // current value via construction-time wiring (cells
4220        // for shared, per-cycle refresh / planned cell
4221        // mechanism for non-shared). The op-template
4222        // synthesiser accepts the reference; the wiring keeps
4223        // the invariant intact at cycle time.
4224        let parent = parent_kernel_with_load();
4225        let manifest = polydat::kernel::extract_manifest(parent.program())
4226            .into_iter()
4227            .map(|e| crate::runner::ManifestEntry {
4228                name: e.name,
4229                port_type: e.port_type,
4230                modifier: e.modifier,
4231            })
4232            .collect::<Vec<_>>();
4233        let op = op_with_body("forecast_op", "forecast := mul(load, 2)\n");
4234        let kernel = build_op_template_scope_kernel(
4235            &op,
4236            &manifest,
4237            &parent,
4238            &HashMap::new(),
4239            Vec::new(),
4240            None,
4241            false,
4242            polydat::kernel::KernelOptLevel::Release,
4243            "test",
4244        )
4245        .expect("op-template kernel synth should accept dynamic parent ref");
4246        // The op-template kernel carries `load` as an extern
4247        // input — the construction-time wiring set up the slot;
4248        // per-cycle refresh keeps it current with outer.
4249        assert!(
4250            kernel.program().find_input("load").is_some(),
4251            "extern load slot should land on op-template kernel"
4252        );
4253        // And `forecast`, the op-local binding, is an output.
4254        assert!(
4255            kernel.program().output_names().contains(&"forecast"),
4256            "op-local binding should be an output"
4257        );
4258    }
4259
4260    #[test]
4261    fn op_template_pvs_query_full_shape_with_workload_params() {
4262        // Closer to the actual full_cql_vector.yaml shape: the
4263        // op carries `prepared:` text with `{keyspace}.{table}`
4264        // / `{predicate}` / `{query_vector}` / `{limit}` interp
4265        // bind-points, plus a `metrics: overscan: {value:
4266        // overscan}` declaration. workload_params carries
4267        // dataset/profile/keyspace via `final` injection. The
4268        // op-template synthesiser must emit the latency_factor /
4269        // recall_factor / overscan bindings as outputs so the
4270        // metrics fixture's `register_pull("overscan")`
4271        // resolves.
4272        use nmbrs_workload::model::MetricSpec;
4273        let parent_src = r#"
4274extern k: u64
4275extern limit: u64
4276extern optimize_for: String
4277extern table: String
4278"#;
4279        let parent = crate::bindings::compile_scope_kernel(
4280            parent_src,
4281            &polydat::dsl::compile::CompileOptions {
4282                context: "parent".to_string(),
4283                ..Default::default()
4284            },
4285        )
4286        .expect("parent compile");
4287        let manifest: Vec<crate::runner::ManifestEntry> =
4288            polydat::kernel::extract_manifest(parent.program())
4289                .into_iter()
4290                .map(|e| crate::runner::ManifestEntry {
4291                    name: e.name,
4292                    port_type: e.port_type,
4293                    modifier: e.modifier,
4294                })
4295                .collect();
4296        let body = "const prebuffered := dataset_prebuffer(\"{dataset}:{profile}\")\n\
4297            const query_counts := query_count(prebuffered)\n\
4298            cursor q = range(0, query_counts * 10)\n\
4299            query_vector := query_vector_at(prebuffered, q % query_counts)\n\
4300            predicate := predicate_value_at(prebuffered, q % query_counts)\n\
4301            ground_truth := filtered_neighbor_indices_at(prebuffered, q % query_counts)\n\
4302            latency_factor := 0.979 + 4.021 * pow(limit, -0.761)\n\
4303            recall_factor  := 0.509 + 9.491 * pow(limit, -0.402)\n\
4304            overscan := if(optimize_for == \"LATENCY\", latency_factor, recall_factor)\n";
4305        let mut op = op_with_body("select_ann", body);
4306        // Mirror op fields the YAML carries.
4307        op.op.insert(
4308            "prepared".into(),
4309            serde_json::json!(
4310                "SELECT key,value FROM {keyspace}.{table} \
4311             WHERE metadata = {predicate} \
4312             ORDER BY value ANN OF {query_vector} LIMIT {limit}"
4313            ),
4314        );
4315        op.metrics.insert(
4316            "overscan".into(),
4317            MetricSpec {
4318                value: "overscan".into(),
4319                family: None,
4320                kind: None,
4321                unit: None,
4322                format: None,
4323                cell: Default::default(),
4324            },
4325        );
4326        let mut workload_params = HashMap::new();
4327        workload_params.insert("dataset".into(), "example".into());
4328        workload_params.insert("profile".into(), "label_00".into());
4329        workload_params.insert("keyspace".into(), "baselines".into());
4330        let kernel = build_op_template_scope_kernel(
4331            &op,
4332            &manifest,
4333            &parent,
4334            &workload_params,
4335            vec![],
4336            None,
4337            false,
4338            polydat::kernel::KernelOptLevel::Release,
4339            "pvs_query.select_ann",
4340        )
4341        .expect("op-template kernel synth");
4342        let outs: Vec<String> = kernel
4343            .program()
4344            .output_names()
4345            .iter()
4346            .map(|s| s.to_string())
4347            .collect();
4348        for required in &["overscan", "latency_factor", "recall_factor"] {
4349            assert!(
4350                outs.iter().any(|o| o == required),
4351                "op-template kernel missing '{required}'; outputs: {outs:?}"
4352            );
4353        }
4354        // Workload params must be folded in as `final` constants
4355        // (not externs) so `init prebuffered =
4356        // dataset_prebuffer("{dataset}:{profile}")` folds at
4357        // compile time. If they cascade through as externs the
4358        // init binding stays unfolded, the per-fiber state never
4359        // gets the seeded Handle, and downstream nodes (like
4360        // `neighbor_indices_at(prebuffered, q)`) panic at runtime
4361        // with `expected Handle, got None/U64`.
4362        for param in &["dataset", "profile", "keyspace"] {
4363            // The param either folds out completely (no input
4364            // slot) or appears as a folded constant — both
4365            // are fine. What's NOT fine is showing up as an
4366            // input on the inner kernel.
4367            assert!(
4368                kernel.program().find_input(param).is_none(),
4369                "workload param '{param}' must NOT be an extern input \
4370                 on the op-template kernel — cascade should emit \
4371                 it as `final` so init bindings fold. Inputs: {:?}",
4372                kernel.program().input_names(),
4373            );
4374        }
4375    }
4376
4377    #[test]
4378    fn op_template_with_pow_and_if_keeps_all_outputs() {
4379        // Mirror the shape of full_cql_vector.yaml's pvs_query
4380        // phase body (after parser merge): init+cursor+:= ladder
4381        // ending with `pow()` + `if()` bindings. The op-template
4382        // kernel must expose every `:=` binding as an output;
4383        // a missing `overscan` is what triggers the
4384        // `register_pull("overscan")` failure at MetricsDispenser
4385        // wrap time.
4386        let parent_src = r#"
4387extern k: u64
4388extern limit: u64
4389extern optimize_for: String
4390extern table: String
4391extern dataset: String
4392extern profile: String
4393extern keyspace: String
4394"#;
4395        let parent = crate::bindings::compile_scope_kernel(
4396            parent_src,
4397            &polydat::dsl::compile::CompileOptions {
4398                context: "parent".to_string(),
4399                ..Default::default()
4400            },
4401        )
4402        .expect("parent compile");
4403        let manifest: Vec<crate::runner::ManifestEntry> =
4404            polydat::kernel::extract_manifest(parent.program())
4405                .into_iter()
4406                .map(|e| crate::runner::ManifestEntry {
4407                    name: e.name,
4408                    port_type: e.port_type,
4409                    modifier: e.modifier,
4410                })
4411                .collect();
4412        let body = "const prebuffered := dataset_prebuffer(\"dummy:default\")\n\
4413            const query_counts := query_count(prebuffered)\n\
4414            cursor q = range(0, query_counts * 10)\n\
4415            query_vector := query_vector_at(prebuffered, q % query_counts)\n\
4416            predicate := predicate_value_at(prebuffered, q % query_counts)\n\
4417            ground_truth := filtered_neighbor_indices_at(prebuffered, q % query_counts)\n\
4418            latency_factor := 0.979 + 4.021 * pow(limit, -0.761)\n\
4419            recall_factor  := 0.509 + 9.491 * pow(limit, -0.402)\n\
4420            overscan := if(optimize_for == \"LATENCY\", latency_factor, recall_factor)\n";
4421        let op = op_with_body("select_ann", body);
4422        let kernel = build_op_template_scope_kernel(
4423            &op,
4424            &manifest,
4425            &parent,
4426            &HashMap::new(),
4427            Vec::new(),
4428            None,
4429            false,
4430            polydat::kernel::KernelOptLevel::Release,
4431            "pvs_query.select_ann",
4432        )
4433        .expect("op-template kernel synth");
4434        let outs: Vec<String> = kernel
4435            .program()
4436            .output_names()
4437            .iter()
4438            .map(|s| s.to_string())
4439            .collect();
4440        for required in &[
4441            "query_vector",
4442            "predicate",
4443            "ground_truth",
4444            "latency_factor",
4445            "recall_factor",
4446            "overscan",
4447        ] {
4448            assert!(
4449                outs.iter().any(|o| o == required),
4450                "op-template kernel missing '{required}'; outputs: {outs:?}"
4451            );
4452        }
4453    }
4454
4455    #[test]
4456    fn op_template_relevancy_k_r_bare_wire_names_cascade() {
4457        // Post-SRD-68 follow-up: `evaluations.relevancy.{k, r,
4458        // expected, actual}` accept bare wire-name forms. The
4459        // op-template synthesiser must include those names in its
4460        // cascaded-extern set so the dispenser's canonical kernel
4461        // can resolve them at wrap-time (where parse_count_param
4462        // calls wires.get(name)).
4463        let parent = parent_kernel_with_load();
4464        let manifest = polydat::kernel::extract_manifest(parent.program())
4465            .into_iter()
4466            .map(|e| crate::runner::ManifestEntry {
4467                name: e.name,
4468                port_type: e.port_type,
4469                modifier: e.modifier,
4470            })
4471            .collect::<Vec<_>>();
4472        let mut op = ParsedOp::simple("read", "noop");
4473        op.bindings = BindingsDef::PolydatSource("".into());
4474        op.params.insert(
4475            "relevancy".into(),
4476            serde_json::json!({
4477                "actual": "rows",
4478                "expected": "ground_truth",
4479                "k": "k_value",
4480                "r": "limit_value",
4481                "functions": ["recall"],
4482            }),
4483        );
4484        // Parent has `cycle` as the coord input. The relevancy
4485        // wire names (rows, ground_truth, k_value, limit_value)
4486        // aren't on the parent; the synthesiser should still add
4487        // them to the cascade-extern set so the op-template kernel
4488        // *declares* them (errors only fire at wrap-time when the
4489        // canonical kernel actually tries to resolve them).
4490        //
4491        // For this unit test we just confirm the synthesiser
4492        // doesn't error AND that the input/output names list
4493        // includes each relevancy wire name — meaning the
4494        // referenced-cascade walker picked them up. We pick names
4495        // the parent doesn't have so the auto-extern path fires
4496        // and the resulting kernel has them as inputs.
4497        let _ = manifest;
4498        // Use the public synthesis entry point to construct the
4499        // op-template kernel under a parent that *does* have the
4500        // referenced wires (so the cascade succeeds).
4501        let kernel_src = "\
4502            input cycle: u64\n\
4503            const rows := 10\n\
4504            const ground_truth := \"1,2,3\"\n\
4505            const k_value := 5\n\
4506            const limit_value := 100\n";
4507        let real_parent =
4508            crate::scope_kernel::ScopeKernel::compile(kernel_src).expect("parent compile");
4509        let real_manifest = polydat::kernel::extract_manifest(real_parent.program())
4510            .into_iter()
4511            .map(|e| crate::runner::ManifestEntry {
4512                name: e.name,
4513                port_type: e.port_type,
4514                modifier: e.modifier,
4515            })
4516            .collect::<Vec<_>>();
4517        let kernel = build_op_template_scope_kernel(
4518            &op,
4519            &real_manifest,
4520            &real_parent,
4521            &HashMap::new(),
4522            Vec::new(),
4523            None,
4524            false,
4525            polydat::kernel::KernelOptLevel::Release,
4526            "relevancy-cascade-test",
4527        )
4528        .expect("op-template kernel synth");
4529
4530        // Each bare-name relevancy wire should resolve through the
4531        // op-template kernel's lookup — the same path validation.rs
4532        // takes at wrap-time via canonical_kernel().lookup(name).
4533        for name in &["rows", "ground_truth", "k_value", "limit_value"] {
4534            assert!(
4535                kernel.lookup(name).is_some(),
4536                "relevancy wire '{name}' should be visible on the \
4537                 op-template kernel (cascaded extern); kernel had \
4538                 outputs: {outs:?}",
4539                outs = kernel.program().output_names()
4540            );
4541        }
4542    }
4543
4544    #[test]
4545    fn op_template_metric_value_count_allocates_magic_extern_slot() {
4546        // Post-SRD-68 follow-up: a metric `value:` expression is a
4547        // use site for any names it references. The op-template
4548        // kernel synthesiser appends `__metric_<name> := <value_expr>`
4549        // to the result-bindings source, so the closure-binding
4550        // economy's free-identifier walker sees `count` and injects
4551        // an `extern count: u64` slot — no throw-away result-binding
4552        // needed.
4553        //
4554        // Verifies: a workload with NO `result:` block but a
4555        // `metrics: rows_per_op: { value: count }` declaration ends
4556        // up with a `count` INPUT slot on the kernel.
4557        let parent = parent_kernel_with_load();
4558        let manifest = polydat::kernel::extract_manifest(parent.program())
4559            .into_iter()
4560            .map(|e| crate::runner::ManifestEntry {
4561                name: e.name,
4562                port_type: e.port_type,
4563                modifier: e.modifier,
4564            })
4565            .collect::<Vec<_>>();
4566        let mut op = ParsedOp::simple("read", "noop");
4567        op.bindings = BindingsDef::PolydatSource("".into());
4568        op.metrics.insert(
4569            "rows_per_op".into(),
4570            nmbrs_workload::model::MetricSpec {
4571                value: "count".into(),
4572                family: None,
4573                kind: Some(nmbrs_workload::model::MetricKind::Gauge),
4574                unit: None,
4575                format: None,
4576                cell: Default::default(),
4577            },
4578        );
4579        let kernel = build_op_template_scope_kernel(
4580            &op,
4581            &manifest,
4582            &parent,
4583            &HashMap::new(),
4584            Vec::new(),
4585            None,
4586            false,
4587            polydat::kernel::KernelOptLevel::Release,
4588            "metric-walker-test",
4589        )
4590        .expect("op-template kernel synth");
4591        let inputs = kernel.program().input_names();
4592        assert!(
4593            inputs.iter().any(|i| i == "count"),
4594            "metric `value: count` should force the `count` magic-extern \
4595             input slot to be allocated under Release opt level; \
4596             inputs were: {inputs:?}"
4597        );
4598        // And the synthesised binding shows up as an output the
4599        // MetricsDispenser will read at cycle time.
4600        let outs = kernel.program().output_names();
4601        let synth = synthesize_metric_binding_name("rows_per_op");
4602        assert!(
4603            outs.iter().any(|o| o == &synth),
4604            "synthesised `{synth}` binding should be a kernel output; \
4605             outputs were: {outs:?}"
4606        );
4607    }
4608
4609    #[test]
4610    fn promoted_final_emits_inline_literal_for_str() {
4611        // SRD-13f case 1 — when a referenced name is `final`
4612        // upstream and a Str, the synthesizer emits
4613        // `const name := "value"` in the child's source rather
4614        // than auto-externing it.
4615        let parent = crate::scope_kernel::ScopeKernel::compile(
4616            "input cycle: u64\nconst dataset := \"example\"\n",
4617        )
4618        .expect("compile parent");
4619        let manifest: Vec<crate::runner::ManifestEntry> =
4620            polydat::kernel::extract_manifest(parent.program())
4621                .into_iter()
4622                .map(|e| crate::runner::ManifestEntry {
4623                    name: e.name,
4624                    port_type: e.port_type,
4625                    modifier: e.modifier,
4626                })
4627                .collect();
4628        let ops = vec![make_polydat_op("step", "x={dataset}", "input cycle: u64")];
4629        let scope = build_scope(
4630            &ops,
4631            &HashMap::new(),
4632            &manifest,
4633            &HashMap::new(),
4634            &HashMap::new(),
4635            None,
4636            &[],
4637            Some(&parent),
4638        )
4639        .expect("build_scope");
4640        let emitted = scope.emit();
4641        assert!(
4642            emitted.contains("const dataset := \"example\""),
4643            "expected promoted-final emission, got:\n{emitted}"
4644        );
4645        assert!(
4646            !emitted.contains("extern dataset"),
4647            "expected no extern for promoted-final dataset, got:\n{emitted}"
4648        );
4649    }
4650
4651    #[test]
4652    fn promoted_final_emits_inline_literal_for_u64() {
4653        let parent =
4654            crate::scope_kernel::ScopeKernel::compile("input cycle: u64\nconst count := 42\n")
4655                .expect("compile parent");
4656        let manifest: Vec<crate::runner::ManifestEntry> =
4657            polydat::kernel::extract_manifest(parent.program())
4658                .into_iter()
4659                .map(|e| crate::runner::ManifestEntry {
4660                    name: e.name,
4661                    port_type: e.port_type,
4662                    modifier: e.modifier,
4663                })
4664                .collect();
4665        let ops = vec![make_polydat_op("step", "n={count}", "input cycle: u64")];
4666        let scope = build_scope(
4667            &ops,
4668            &HashMap::new(),
4669            &manifest,
4670            &HashMap::new(),
4671            &HashMap::new(),
4672            None,
4673            &[],
4674            Some(&parent),
4675        )
4676        .expect("build_scope");
4677        let emitted = scope.emit();
4678        assert!(
4679            emitted.contains("const count := 42"),
4680            "expected promoted-final u64 emission, got:\n{emitted}"
4681        );
4682    }
4683
4684    #[test]
4685    fn unresolved_wire_reference_surfaces_validation_error() {
4686        // SRD-13f case 4 — a typo in a `{...}` placeholder (here
4687        // `{tirp}` instead of the declared `trip`) is rejected
4688        // at the synthesizer level with a structured error,
4689        // not via a downstream Polydat compiler error.
4690        let parent = crate::scope_kernel::ScopeKernel::compile(
4691            "input cycle: u64\nconst dataset := \"example\"\n",
4692        )
4693        .expect("compile parent");
4694        let manifest: Vec<crate::runner::ManifestEntry> =
4695            polydat::kernel::extract_manifest(parent.program())
4696                .into_iter()
4697                .map(|e| crate::runner::ManifestEntry {
4698                    name: e.name,
4699                    port_type: e.port_type,
4700                    modifier: e.modifier,
4701                })
4702                .collect();
4703        let ops = vec![make_polydat_op("step", "x={tirp}", "input cycle: u64")];
4704        let err = match build_scope(
4705            &ops,
4706            &HashMap::new(),
4707            &manifest,
4708            &HashMap::new(),
4709            &HashMap::new(),
4710            None,
4711            &[],
4712            Some(&parent),
4713        ) {
4714            Ok(_) => panic!("expected unresolved-wire error, got Ok"),
4715            Err(e) => e,
4716        };
4717        assert!(err.contains("unresolved wire"), "wrong error: {err}");
4718        assert!(
4719            err.contains("tirp"),
4720            "error should mention the typoed name: {err}"
4721        );
4722        assert!(
4723            err.contains("Visible names"),
4724            "error should list visible names: {err}"
4725        );
4726    }
4727
4728    #[test]
4729    fn ingest_preserves_bindings_when_comment_contains_apostrophe() {
4730        // Regression: Polydat grammar uses only `"`-delimited string
4731        // literals. `logical_lines` previously treated `'` as a
4732        // string delimiter too, which meant an unmatched
4733        // apostrophe in a `#` comment (e.g. "the workload's
4734        // bindings") put the splitter into a string state that
4735        // never closed. The entire rest of the source was
4736        // accreted into one logical "line" starting with `#`,
4737        // which then got dropped as a comment by the per-line
4738        // parser — silently discarding every binding that
4739        // followed.
4740        //
4741        // This was the actual root cause of the full_cql_vector
4742        // `await_index` failure: the workload's
4743        // `bindings:` block began with a multi-line comment
4744        // containing the apostrophe in "full_cql_vector's", so
4745        // the `shared has_X := false` declarations never made
4746        // it into the workload-root program. Descendant
4747        // synthesizers then couldn't cascade has_X as Bool
4748        // externs; the compile inferred has_X as Coordinate
4749        // U64 inputs from unbound references, and the
4750        // per-cycle `set_inputs(&[u64])` clobbered the
4751        // SharedCell with `Value::U64(cycle)`.
4752        let mut scope = BindingScope::new();
4753        let src_with_apostrophe_comment = "# full_cql_vector's bindings block\n\
4754                                           shared has_a := true\n\
4755                                           shared has_b := false\n";
4756        scope.ingest_polydat_source(src_with_apostrophe_comment, BindingOrigin::Inherited);
4757        let defined = scope.defined_names();
4758        assert!(
4759            defined.contains("has_a"),
4760            "comment with apostrophe must NOT consume subsequent bindings; \
4761             expected has_a in defined names, got {defined:?}"
4762        );
4763        assert!(
4764            defined.contains("has_b"),
4765            "expected has_b in defined names, got {defined:?}"
4766        );
4767
4768        let emitted = scope.emit();
4769        assert!(
4770            emitted.contains("shared has_a := true"),
4771            "scope.emit() must include the shared bindings; got:\n{emitted}"
4772        );
4773        assert!(
4774            emitted.contains("shared has_b := false"),
4775            "scope.emit() must include the shared bindings; got:\n{emitted}"
4776        );
4777    }
4778
4779    #[test]
4780    fn ingest_then_compile_preserves_shared_modifier() {
4781        // Mirrors the workload-root compile path in
4782        // `compile_bindings_with_libs_excluding`: workload-level
4783        // bindings (`shared has_X := false`) get ingested via
4784        // `scope.ingest_polydat_source(..., Inherited)` before emit.
4785        // The resulting source then compiles via the
4786        // standard pipeline.
4787        //
4788        // SRD-13c §"Shared Mutable": `shared X := <literal>`
4789        // compiles to an input slot + passthrough output marked
4790        // SHARED. The workload-root program's `shared_outputs()`
4791        // must include the SHARED-modifier outputs so
4792        // `seed_shared_cells` creates a `SharedCell` for each
4793        // and descendant kernels can cell-attach via
4794        // `materialize_wiring_from_outer`.
4795        let mut scope = BindingScope::new();
4796        let workload_polydat = "shared has_sai_column_indexes := false\n\
4797                          shared has_indexes := false\n";
4798        scope.ingest_polydat_source(workload_polydat, BindingOrigin::Inherited);
4799        let source = scope.emit();
4800        let kernel = crate::scope_kernel::ScopeKernel::compile(&source)
4801            .unwrap_or_else(|e| panic!("compile failed for source:\n{source}\nerror: {e}"));
4802        let shared = kernel.program().shared_outputs();
4803        assert!(
4804            shared.contains(&"has_sai_column_indexes"),
4805            "expected `has_sai_column_indexes` in shared_outputs after scope-ingest/emit/compile;\n\
4806             got shared_outputs={shared:?}\nemitted source:\n{source}",
4807        );
4808        assert!(
4809            shared.contains(&"has_indexes"),
4810            "expected `has_indexes` in shared_outputs after scope-ingest/emit/compile;\n\
4811             got shared_outputs={shared:?}\nemitted source:\n{source}",
4812        );
4813    }
4814
4815    /// SRD-75 Push 2: `synthesize_phase_scope_bindings`
4816    /// returns the original bindings unchanged when
4817    /// `phase.poll` is `None`. The function is a no-op for
4818    /// non-poll phases; their synthesis path is unaffected.
4819    #[test]
4820    fn synthesize_phase_scope_bindings_passthrough_when_no_poll() {
4821        use nmbrs_workload::model::{BindingsDef, WorkloadPhase};
4822        let phase = WorkloadPhase {
4823            bindings: BindingsDef::PolydatSource("k := 5\n".into()),
4824            poll: None,
4825            ..Default::default()
4826        };
4827        let out = synthesize_phase_scope_bindings(&phase).expect("no-poll synthesis is a no-op");
4828        match out {
4829            BindingsDef::PolydatSource(s) => assert_eq!(
4830                s, "k := 5\n",
4831                "no-poll should return the original bindings unchanged"
4832            ),
4833            other => panic!("expected PolydatSource, got {other:?}"),
4834        }
4835    }
4836
4837    /// A metric's `cell:` is reified as a compiled kernel binding beside its
4838    /// value — the whole point of the design. If this did not compile, a
4839    /// coordinate would have to be a runtime string, which is the
4840    /// unvalidatable shape the proposal exists to avoid.
4841    #[test]
4842    fn phase_metrics_reify_cell_coordinates_as_kernel_bindings() {
4843        use nmbrs_workload::model::{BindingsDef, MetricSpec, WorkloadPhase};
4844        let mut metrics = std::collections::HashMap::new();
4845        let mut cell = std::collections::BTreeMap::new();
4846        cell.insert("tier".to_string(), "tier_name".to_string());
4847        metrics.insert(
4848            "bytes_out".to_string(),
4849            MetricSpec {
4850                value: "history_bytes_out".into(),
4851                family: None,
4852                kind: None,
4853                unit: None,
4854                format: None,
4855                cell,
4856            },
4857        );
4858        let phase = WorkloadPhase {
4859            bindings: BindingsDef::PolydatSource(
4860                "extern tier_name: str = \"\"\n\
4861                 extern history_bytes_out: u64 = 0\n"
4862                    .into(),
4863            ),
4864            metrics,
4865            poll: None,
4866            ..Default::default()
4867        };
4868        let out = synthesize_phase_scope_bindings(&phase).expect("synthesis");
4869        let src = match out {
4870            BindingsDef::PolydatSource(s) => s,
4871            other => panic!("expected PolydatSource, got {other:?}"),
4872        };
4873        assert!(
4874            src.contains("volatile __cell_bytes_out__tier := tier_name"),
4875            "coordinate must be emitted as a volatile binding; got:\n{src}"
4876        );
4877        assert!(
4878            src.contains("volatile __metric_bytes_out := history_bytes_out"),
4879            "the value binding must still be emitted; got:\n{src}"
4880        );
4881    }
4882
4883    /// Per (metric, dimension), not per dimension: two metrics placing into
4884    /// one dimension by different expressions must not collide on a wire name
4885    /// and silently let one placement win.
4886    #[test]
4887    fn two_metrics_in_one_dimension_get_distinct_coordinate_wires() {
4888        assert_eq!(
4889            synthesize_cell_binding_name("bytes_out", "tier"),
4890            "__cell_bytes_out__tier"
4891        );
4892        assert_ne!(
4893            synthesize_cell_binding_name("bytes_out", "tier"),
4894            synthesize_cell_binding_name("bytes_in", "tier")
4895        );
4896    }
4897
4898    /// A phase with a `metrics:` block (and no poll) synthesises a
4899    /// `volatile phase_start := phase_start_millis()` origin binding plus one
4900    /// `volatile __metric_<name> := <value>` per metric. The phase's own
4901    /// bindings are appended verbatim.
4902    ///
4903    /// `phase_start` was an `extern … = 0` filled by the executor at the
4904    /// completion-time pull, which meant anything reading it while the phase
4905    /// ran got the default.
4906    #[test]
4907    fn synthesize_phase_scope_bindings_emits_metric_bindings() {
4908        use nmbrs_workload::model::{BindingsDef, MetricSpec, WorkloadPhase};
4909        let mut metrics = std::collections::HashMap::new();
4910        metrics.insert(
4911            "time_to_index".to_string(),
4912            MetricSpec {
4913                value: "current_epoch_millis() - phase_start".into(),
4914                family: None,
4915                kind: None,
4916                unit: None,
4917                format: None,
4918                cell: Default::default(),
4919            },
4920        );
4921        let phase = WorkloadPhase {
4922            bindings: BindingsDef::default(),
4923            metrics,
4924            poll: None,
4925            ..Default::default()
4926        };
4927        let out = synthesize_phase_scope_bindings(&phase).expect("metrics synthesis");
4928        let src = match out {
4929            BindingsDef::PolydatSource(s) => s,
4930            other => panic!("expected PolydatSource, got {other:?}"),
4931        };
4932        // Bound to the runtime's phase clock, NOT an extern: as an extern it
4933        // was filled only at the completion-time pull, so anything reading it
4934        // while the phase ran (an op) got the 0 default and computed against
4935        // the epoch.
4936        assert!(
4937            src.contains("volatile phase_start := phase_start_millis()"),
4938            "must bind phase_start to the phase-scoped clock; got:\n{src}"
4939        );
4940        assert!(
4941            !src.contains("extern phase_start"),
4942            "the fill-me-in extern must be gone; got:\n{src}"
4943        );
4944        assert!(
4945            src.contains("volatile __metric_time_to_index := current_epoch_millis() - phase_start"),
4946            "must emit the volatile metric binding; got:\n{src}"
4947        );
4948        // The emitted source must compile as a phase kernel body, with
4949        // `__metric_time_to_index` surfacing as an output.
4950        let kernel = crate::scope_kernel::ScopeKernel::compile(&src)
4951            .unwrap_or_else(|e| panic!("compile failed:\n{src}\nerror: {e}"));
4952        assert!(
4953            kernel
4954                .program()
4955                .output_names()
4956                .contains(&"__metric_time_to_index"),
4957            "metric binding must be a kernel output; outputs: {:?}",
4958            kernel.program().output_names()
4959        );
4960    }
4961
4962    /// SRD-75 Push 2: when a phase has `poll:` and ops with
4963    /// declared captures, the synthesized source carries one
4964    /// `shared <name>: u64 := 0` declaration per capture
4965    /// followed by the predicate binding `__poll_until :=
4966    /// <until>`. The original phase bindings (if any) are
4967    /// appended verbatim between the captures and the
4968    /// predicate.
4969    #[test]
4970    fn synthesize_phase_scope_bindings_emits_shared_captures_and_predicate() {
4971        use nmbrs_workload::bindpoints::CapturePoint;
4972        use nmbrs_workload::model::{BindingsDef, ParsedOp, PhasePollSpec, WorkloadPhase};
4973        let mut op = ParsedOp::simple("read_state", "noop");
4974        op.captures = vec![
4975            CapturePoint {
4976                row_filter: None,
4977                source_name: "sstables".into(),
4978                as_name: "sstables".into(),
4979                cast_type: None,
4980                slurp: false,
4981                path: Some("/0/value".into()),
4982                count: false,
4983                agg: None,
4984            },
4985            CapturePoint {
4986                row_filter: None,
4987                source_name: "active_for_cf".into(),
4988                as_name: "active_for_cf".into(),
4989                cast_type: None,
4990                slurp: false,
4991                path: Some("/1/value".into()),
4992                count: true,
4993                agg: None,
4994            },
4995        ];
4996        let phase = WorkloadPhase {
4997            ops: vec![op],
4998            bindings: BindingsDef::PolydatSource("dummy := 1\n".into()),
4999            poll: Some(PhasePollSpec {
5000                until: "sstables == 1 && active_for_cf == 0".into(),
5001                ..Default::default()
5002            }),
5003            ..Default::default()
5004        };
5005        let out = synthesize_phase_scope_bindings(&phase).expect("poll synthesis should succeed");
5006        let src = match out {
5007            BindingsDef::PolydatSource(s) => s,
5008            other => panic!("expected PolydatSource, got {other:?}"),
5009        };
5010        assert!(
5011            src.contains("shared sstables := 0"),
5012            "missing shared declaration for sstables; source:\n{src}"
5013        );
5014        assert!(
5015            src.contains("shared active_for_cf := 0"),
5016            "missing shared declaration for active_for_cf; source:\n{src}"
5017        );
5018        assert!(
5019            src.contains("dummy := 1"),
5020            "original phase bindings should appear verbatim; source:\n{src}"
5021        );
5022        assert!(
5023            src.contains("__poll_until := sstables == 1 && active_for_cf == 0"),
5024            "missing __poll_until predicate binding; source:\n{src}"
5025        );
5026        // Order matters: captures first (so the predicate's
5027        // RHS references resolve through the local shared
5028        // declarations before the dynamic binding evaluates).
5029        let shared_pos = src
5030            .find("shared sstables")
5031            .expect("shared sstables present");
5032        let until_binding = crate::wrappers::condition::UNTIL_BINDING;
5033        let until_pos = src
5034            .find(until_binding)
5035            .expect("poll predicate binding present");
5036        assert!(
5037            shared_pos < until_pos,
5038            "shared captures must precede __poll_until in the synthesized source"
5039        );
5040    }
5041
5042    /// SRD-75 Push 2: a capture name that collides with an
5043    /// author-declared phase binding is a workload bug —
5044    /// the synthesizer surfaces the collision rather than
5045    /// silently shadowing.
5046    #[test]
5047    fn synthesize_phase_scope_bindings_rejects_capture_name_collision() {
5048        use nmbrs_workload::bindpoints::CapturePoint;
5049        use nmbrs_workload::model::{BindingsDef, ParsedOp, PhasePollSpec, WorkloadPhase};
5050        let mut op = ParsedOp::simple("read_state", "noop");
5051        op.captures = vec![CapturePoint {
5052            row_filter: None,
5053            source_name: "sstables".into(),
5054            as_name: "sstables".into(),
5055            cast_type: None,
5056            slurp: false,
5057            path: Some("/0/value".into()),
5058            count: false,
5059            agg: None,
5060        }];
5061        // Phase author also declared `sstables := …` —
5062        // collision is the bug.
5063        let phase = WorkloadPhase {
5064            ops: vec![op],
5065            bindings: BindingsDef::PolydatSource("sstables := 7\n".into()),
5066            poll: Some(PhasePollSpec {
5067                until: "sstables == 1".into(),
5068                ..Default::default()
5069            }),
5070            ..Default::default()
5071        };
5072        let err =
5073            synthesize_phase_scope_bindings(&phase).expect_err("colliding capture name must error");
5074        assert!(
5075            err.contains("sstables") && err.contains("collision"),
5076            "expected error to name 'sstables' and 'collision'; got: {err}"
5077        );
5078    }
5079
5080    #[test]
5081    fn build_phase_scope_kernel_with_mod_in_binding_over_partition_iter_var() {
5082        // Matches the production workload shape:
5083        //   for: "p in partitions(\"linear:3\")"
5084        //   phases.walk.bindings: n := mod_in(cycle, p)
5085        //
5086        // The phase compile must see `p` as Ext so mod_in's
5087        // second arg (Partition input) type-checks. Reproduces
5088        // the integration error "u64 ─▶ ext expects ext".
5089        let parent_kernel = crate::scope_synth::build_for_each_scope_kernel(
5090            &[("p".to_string(), "partitions(\"linear:3\")".to_string())],
5091            &[],
5092            &crate::scope_kernel::ScopeKernel::compile("\n").unwrap(),
5093            &HashMap::new(),
5094            Vec::new(),
5095            None,
5096            false,
5097            "test_for_each",
5098            None,
5099        )
5100        .expect("for-each scope synthesis");
5101
5102        let phase_bindings = nmbrs_workload::model::BindingsDef::PolydatSource(
5103            "n := mod_in(cycle, p)\n".to_string(),
5104        );
5105        let phase_kernel = build_phase_scope_kernel(
5106            &phase_bindings,
5107            &[],
5108            &parent_kernel,
5109            &HashMap::new(),
5110            Vec::new(),
5111            None,
5112            false,
5113            "test_phase",
5114        )
5115        .expect("phase kernel build with mod_in(cycle, p) binding");
5116
5117        // Sanity: phase has p as Ext-typed input slot.
5118        assert_eq!(
5119            phase_kernel.program().input_port_type("p"),
5120            Some(polydat::ast::PortType::Ext),
5121            "phase kernel's `p` must be Ext-typed",
5122        );
5123        // Sanity: phase has `n` as an output (the mod_in result).
5124        assert!(
5125            phase_kernel.program().output_names().contains(&"n"),
5126            "phase kernel should expose `n` as an output"
5127        );
5128    }
5129
5130    #[test]
5131    fn build_phase_scope_kernel_cascades_partition_iter_var_as_extern_ext() {
5132        // SRD-71: when a for-each scope iterates a PartitionList
5133        // and binds an iter-var `p`, descendant phases must see
5134        // `p` as `extern p: Ext` — NOT cast to String / u64 by
5135        // the cascade. The phase body's `over p` clause then
5136        // resolves `p` as a partition-typed wire.
5137        //
5138        // Reproduces the integration failure where the phase's
5139        // build_phase_scope_kernel cascade was emitting
5140        // `extern p: u64` (or Str fallback) because the
5141        // type-name lookup didn't handle PortType::Ext.
5142        let parent_kernel = crate::scope_synth::build_for_each_scope_kernel(
5143            &[("p".to_string(), "partitions(\"linear:3\")".to_string())],
5144            &[], // empty parent_manifest is fine; for_each scope only
5145            // cascades names it actually references.
5146            &crate::scope_kernel::ScopeKernel::compile("\n").unwrap(),
5147            &HashMap::new(),
5148            Vec::new(),
5149            None,
5150            false,
5151            "test_for_each",
5152            None,
5153        )
5154        .expect("for-each scope synthesis");
5155
5156        // Sanity: the for-each scope's iter-var p is Ext-typed.
5157        assert_eq!(
5158            parent_kernel.program().input_port_type("p"),
5159            Some(polydat::ast::PortType::Ext),
5160            "for-each scope's iter-var `p` must declare as Ext",
5161        );
5162
5163        // Now build a phase under it that references `p` in a
5164        // cursor's `over` clause.
5165        let phase_bindings = nmbrs_workload::model::BindingsDef::PolydatSource(
5166            "cursor row = range(0, 1000) over p\n".to_string(),
5167        );
5168        let phase_kernel = build_phase_scope_kernel(
5169            &phase_bindings,
5170            &[],
5171            &parent_kernel,
5172            &HashMap::new(),
5173            Vec::new(),
5174            None,
5175            false,
5176            "test_phase",
5177        )
5178        .expect("phase kernel build");
5179
5180        // The phase kernel must see `p` as Ext-typed.
5181        let port_type = phase_kernel.program().input_port_type("p");
5182        assert_eq!(
5183            port_type,
5184            Some(polydat::ast::PortType::Ext),
5185            "phase kernel's `p` slot must be Ext (preserved through cascade), got {port_type:?}"
5186        );
5187    }
5188
5189    #[test]
5190    fn build_phase_scope_kernel_cascades_shared_bool_as_extern_bool() {
5191        // SRD-13d §1: "The kernel auto-externs every name it
5192        // doesn't declare locally; those externs resolve up
5193        // through the phase kernel and beyond per the standard
5194        // SRD-13c chain."
5195        //
5196        // SRD-66 §"Surface 2": a workload-root `shared X :=
5197        // <literal>` becomes a `SharedCell`-backed slot at the
5198        // root. Descendant phases declare `extern X: bool` (or
5199        // auto-extern emits it); bind_outer_scope cell-attaches
5200        // the slot.
5201        //
5202        // SRD-13c §"Shared Mutable" step 1: `shared X := <literal>`
5203        // produces an input slot Bool (kind=ExternalWrite) plus a
5204        // passthrough output Bool, modifier SHARED.
5205        //
5206        // This unit-test verifies the documented type-preservation
5207        // contract: a phase scope built via build_phase_scope_kernel
5208        // against a parent kernel carrying a SHARED Bool output
5209        // must produce a phase-scope kernel whose has_X input slot
5210        // is type Bool, NOT typed U64 / not classified Coordinate.
5211        // Without this property, the SharedCell that bind_outer_scope
5212        // attaches gets a slot whose declared type doesn't match
5213        // the cell's actual Value variant — downstream consumers
5214        // (e.g. pick) see the runtime variant and reject it.
5215        let parent = crate::scope_kernel::ScopeKernel::compile(
5216            "input cycle: u64\nshared has_sai_column_indexes := false\n\
5217             shared has_indexes := false\n",
5218        )
5219        .expect("parent compile");
5220        // The phase has its own bindings block (the await_index shape).
5221        let phase_bindings = nmbrs_workload::model::BindingsDef::PolydatSource(
5222            "target_index_table := pick(has_sai_column_indexes, has_indexes, \
5223             \"a\", \"b\")\n"
5224                .to_string(),
5225        );
5226        let phase_kernel = build_phase_scope_kernel(
5227            &phase_bindings,
5228            &[], // outer_manifest (would be populated in real runner, but builder doesn't strictly need it)
5229            &parent,
5230            &HashMap::new(),
5231            Vec::new(),
5232            None,
5233            false,
5234            "test_phase",
5235        )
5236        .expect("phase kernel build");
5237
5238        // The phase kernel must have has_X as an input slot.
5239        let idx_sai = phase_kernel.program().find_input("has_sai_column_indexes");
5240        assert!(
5241            idx_sai.is_some(),
5242            "phase kernel must have `has_sai_column_indexes` input slot"
5243        );
5244        let idx = idx_sai.unwrap();
5245
5246        // Type must be Bool (per SRD-13c §"Shared Mutable" step 1
5247        // + SRD-66 §"Reading from a downstream phase").
5248        let port_type = phase_kernel
5249            .program()
5250            .input_port_type("has_sai_column_indexes");
5251        assert_eq!(
5252            port_type,
5253            Some(polydat::ast::PortType::Bool),
5254            "phase kernel's has_sai_column_indexes slot must be Bool, got {port_type:?}"
5255        );
5256
5257        // Kind must NOT be Coordinate — cascaded names are IterationExtern
5258        // (or ExternalWrite), not Coordinate. Coordinate would put the slot
5259        // in the set_inputs(&[u64]) propagation, breaking the cell-bound
5260        // contract per SRD-13c §"Shared Mutable" step 3.
5261        let kind = phase_kernel.program().input_kind(idx);
5262        assert_ne!(
5263            kind,
5264            Some(polydat::kernel::InputKind::Coordinate),
5265            "phase kernel's has_sai_column_indexes slot must NOT be Coordinate; got {kind:?}"
5266        );
5267    }
5268
5269    #[test]
5270    fn executor_build_scope_emits_extern_bool_for_cell_backed_shared_wire() {
5271        // Reproduces the executor's specific build_scope call
5272        // sequence for the consume phase:
5273        //
5274        //   1. Build the phase scope kernel via build_phase_scope_kernel
5275        //      (this is what the runner's install pass does).
5276        //   2. Call build_scope(ops, ..., parent_kernel=phase_scope)
5277        //      with phase_scope as classifier_kernel — this is
5278        //      what executor.rs:1429 does at run_phase time.
5279        //   3. compile_from_scope(scope, ...) — produces the
5280        //      iter_op_builder kernel that dryrun=wiring dumps.
5281        //
5282        // The integration test
5283        // `shared_bool_through_for_each_into_consumer_phase_bindings`
5284        // proves this whole sequence produces a kernel with has_X
5285        // as `coordinate` U64. This unit test reproduces it without
5286        // a subprocess so we can inspect every intermediate state.
5287        use polydat::kernel::extract_manifest;
5288
5289        // ── workload root ──
5290        let root = crate::scope_kernel::ScopeKernel::compile(
5291            "shared has_a := true\n\
5292             shared has_b := false\n\
5293             selector := mod(cycle, 1)\n",
5294        )
5295        .expect("workload root compile");
5296
5297        // ── for_each scope ──
5298        let for_each = crate::scope_synth::build_for_each_scope_kernel(
5299            &[("outer".to_string(), "p1,p2".to_string())],
5300            &extract_manifest(root.program()),
5301            &root,
5302            &HashMap::new(),
5303            Vec::new(),
5304            None,
5305            false,
5306            "test_for_each",
5307            None,
5308        )
5309        .expect("for_each synth");
5310
5311        // ── phase scope kernel (cached_kernel) ──
5312        let phase_bindings = nmbrs_workload::model::BindingsDef::PolydatSource(
5313            "chosen := pick(has_a, has_b, \"alpha\", \"beta\")\n".to_string(),
5314        );
5315        let phase_scope = build_phase_scope_kernel(
5316            &phase_bindings,
5317            &[],
5318            &for_each,
5319            &HashMap::new(),
5320            Vec::new(),
5321            None,
5322            false,
5323            "test_phase",
5324        )
5325        .expect("phase scope synth");
5326
5327        // ── executor's build_scope call ──
5328        let op = ParsedOp::simple("report", "consume chosen={chosen}");
5329        let ops = vec![op];
5330        let effective_manifest: Vec<crate::runner::ManifestEntry> =
5331            extract_manifest(phase_scope.program())
5332                .into_iter()
5333                .map(|e| crate::runner::ManifestEntry {
5334                    name: e.name,
5335                    port_type: e.port_type,
5336                    modifier: e.modifier,
5337                })
5338                .collect();
5339        let scope = build_scope(
5340            &ops,
5341            &HashMap::new(),
5342            &effective_manifest,
5343            &HashMap::new(),
5344            &HashMap::new(),
5345            None,
5346            &[],
5347            Some(&phase_scope),
5348        )
5349        .expect("executor build_scope");
5350
5351        // ── inspect what build_scope emits ──
5352        let emitted = scope.emit();
5353
5354        // ── compile_from_scope equivalent — uses the SAME compile
5355        // path the executor takes (compile_polydat_interpreter_with_options with
5356        // the scope's required_outputs filter).
5357        let required = scope.required_outputs();
5358        let executor_kernel = polydat::dsl::compile::compile_polydat_interpreter_with_options(
5359            &emitted,
5360            &polydat::dsl::compile::CompileOptions {
5361                required_outputs: required.clone(),
5362                context: "test".to_string(),
5363                ..Default::default()
5364            },
5365            None,
5366        )
5367        .unwrap_or_else(|e| {
5368            panic!("compile failed: {e}\nemitted source:\n{emitted}\nrequired: {required:?}")
5369        });
5370
5371        // The executor's kernel must have has_a as Bool (or absent
5372        // entirely if not referenced). It MUST NOT be Coordinate U64.
5373        if let Some(idx) = executor_kernel.program().find_input("has_a") {
5374            let typ = executor_kernel.program().input_port_type("has_a");
5375            assert_eq!(
5376                typ,
5377                Some(polydat::ast::PortType::Bool),
5378                "executor kernel's has_a slot must be Bool;\n\
5379                 got {typ:?}\n\
5380                 emitted source:\n{emitted}\n\
5381                 input names: {:?}\n\
5382                 coord_count: {}",
5383                executor_kernel.program().input_names(),
5384                executor_kernel.program().coord_count()
5385            );
5386            let kind = executor_kernel.program().input_kind(idx);
5387            assert_ne!(
5388                kind,
5389                Some(polydat::kernel::InputKind::Coordinate),
5390                "executor kernel's has_a slot must NOT be Coordinate;\n\
5391                 got {kind:?}\n\
5392                 emitted source:\n{emitted}\n\
5393                 input names: {:?}\n\
5394                 coord_count: {}",
5395                executor_kernel.program().input_names(),
5396                executor_kernel.program().coord_count()
5397            );
5398        }
5399    }
5400
5401    #[test]
5402    fn full_chain_workload_to_executor_scope_preserves_shared_bool() {
5403        // Chains through every layer the failing integration test
5404        // touches, in-process, with the same shape that triggers
5405        // the failure:
5406        //
5407        //   1. params kernel (workload params as `const` bindings).
5408        //   2. workload-root kernel via the `compile_bindings_with_libs_excluding`-style
5409        //      flow: build_scope + scope.ingest_polydat_source + parent.build_subscope.
5410        //   3. for_each scope kernel (outer iter var).
5411        //   4. for_each scope kernel (inner with multi-iter clause).
5412        //   5. phase scope kernel via build_phase_scope_kernel (consume).
5413        //   6. executor's build_scope + compile_polydat_with_libs_and_limit.
5414        //
5415        // Verifies at the END that the consumer phase's executor-
5416        // compiled kernel has has_a as Bool/non-Coordinate.
5417        use nmbrs_workload::model::ParsedOp;
5418        use polydat::kernel::extract_manifest;
5419
5420        // Step 1: params kernel.
5421        let mut workload_params: HashMap<String, String> = HashMap::new();
5422        workload_params.insert("dataset".to_string(), "example_dataset".to_string());
5423        workload_params.insert("prefix".to_string(), "px_".to_string());
5424        let mut sorted: Vec<&String> = workload_params.keys().collect();
5425        sorted.sort();
5426        let mut params_source = String::new();
5427        for k in sorted {
5428            params_source.push_str(&format!("const {k} := \"{}\"\n", workload_params[k]));
5429        }
5430        let params_kernel =
5431            crate::scope_kernel::ScopeKernel::compile(&params_source).expect("params compile");
5432
5433        // Step 2: workload-root kernel.
5434        let mut scope = build_scope(
5435            &[] as &[ParsedOp],
5436            &HashMap::new(),
5437            &[],
5438            &workload_params,
5439            &HashMap::new(),
5440            None,
5441            &[],
5442            None,
5443        )
5444        .expect("workload root build_scope");
5445        // KEY: the binding RHS uses string interpolation against
5446        // workload params. This is what triggers the chain
5447        // corruption — without it, the test passes; with it, the
5448        // integration test fails.
5449        let workload_level_polydat = "combo_label := str_concat(\"{dataset}\", \"{prefix}\")\n\
5450                                 shared has_a := true\n\
5451                                 shared has_b := false\n";
5452        scope.ingest_polydat_source(workload_level_polydat, BindingOrigin::Inherited);
5453        let root_source = scope.emit();
5454        let root = crate::scope_kernel::ScopeKernel::build_under(
5455            params_kernel.kernel(),
5456            crate::scope_kernel::SourceMatter::source(
5457                "test_root",
5458                root_source.clone(),
5459                polydat::kernel::subcontext::CompileOptions {
5460                    workload_dir: None,
5461                    polydat_lib_paths: Vec::new(),
5462                    strict: false,
5463                    required_outputs: scope.required_outputs(),
5464                    context_label: Some("test_root".to_string()),
5465                    cursor_limit: None,
5466                    ..Default::default()
5467                },
5468            ),
5469        )
5470        .expect("root build");
5471
5472        // Sanity: has_a SHARED at root.
5473        let shared = root.program().shared_outputs();
5474        assert!(
5475            shared.contains(&"has_a"),
5476            "root should have has_a as SHARED output; got {shared:?}"
5477        );
5478
5479        // Step 3: outer for_each scope.
5480        let outer_fe = crate::scope_synth::build_for_each_scope_kernel(
5481            &[("outer".to_string(), "p1,p2".to_string())],
5482            &extract_manifest(root.program()),
5483            &root,
5484            &workload_params,
5485            Vec::new(),
5486            None,
5487            false,
5488            "test_outer_fe",
5489            None,
5490        )
5491        .expect("outer for_each synth");
5492
5493        // Step 4: inner for_each scope (dependent multi-iter).
5494        let inner_fe = crate::scope_synth::build_for_each_scope_kernel(
5495            &[
5496                ("inner".to_string(), "lo,hi".to_string()),
5497                ("label".to_string(), "tag_p1_lo,tag_p1_hi".to_string()),
5498            ],
5499            &extract_manifest(outer_fe.program()),
5500            &outer_fe,
5501            &workload_params,
5502            Vec::new(),
5503            None,
5504            false,
5505            "test_inner_fe",
5506            None,
5507        )
5508        .expect("inner for_each synth");
5509
5510        // Step 5: phase scope kernel for consume.
5511        let phase_bindings = BindingsDef::PolydatSource(
5512            "chosen := pick(has_a, has_b, \"alpha\", \"beta\")\n".to_string(),
5513        );
5514        let phase_scope = build_phase_scope_kernel(
5515            &phase_bindings,
5516            &[],
5517            &inner_fe,
5518            &workload_params,
5519            Vec::new(),
5520            None,
5521            false,
5522            "test_consume_phase",
5523        )
5524        .expect("consume phase synth");
5525
5526        // Step 6: executor's build_scope on the consume phase.
5527        let op = ParsedOp::simple("report", "spc/consume chosen={chosen}");
5528        let ops = vec![op];
5529        let effective_manifest: Vec<crate::runner::ManifestEntry> =
5530            extract_manifest(phase_scope.program())
5531                .into_iter()
5532                .map(|e| crate::runner::ManifestEntry {
5533                    name: e.name,
5534                    port_type: e.port_type,
5535                    modifier: e.modifier,
5536                })
5537                .collect();
5538        let exec_scope = build_scope(
5539            &ops,
5540            &HashMap::new(),
5541            &effective_manifest,
5542            &HashMap::new(),
5543            &HashMap::new(),
5544            None,
5545            &[],
5546            Some(&phase_scope),
5547        )
5548        .expect("executor build_scope");
5549
5550        let exec_source = exec_scope.emit();
5551        let exec_kernel = polydat::dsl::compile::compile_polydat_interpreter_with_options(
5552            &exec_source,
5553            &polydat::dsl::compile::CompileOptions {
5554                required_outputs: exec_scope.required_outputs(),
5555                context: "test_executor".to_string(),
5556                ..Default::default()
5557            },
5558            None,
5559        )
5560        .unwrap_or_else(|e| panic!("exec compile failed: {e}\nexec source:\n{exec_source}"));
5561
5562        // The final kernel — what the dryrun=wiring dumps — must
5563        // have has_a as Bool/non-Coordinate.
5564        if let Some(idx) = exec_kernel.program().find_input("has_a") {
5565            let typ = exec_kernel.program().input_port_type("has_a");
5566            assert_eq!(
5567                typ,
5568                Some(polydat::ast::PortType::Bool),
5569                "FINAL kernel has_a must be Bool, got {typ:?}\n\
5570                 exec source:\n{exec_source}\n\
5571                 exec input_names: {:?}\n\
5572                 exec coord_count: {}",
5573                exec_kernel.program().input_names(),
5574                exec_kernel.program().coord_count()
5575            );
5576            let kind = exec_kernel.program().input_kind(idx);
5577            assert_ne!(
5578                kind,
5579                Some(polydat::kernel::InputKind::Coordinate),
5580                "FINAL kernel has_a must NOT be Coordinate, got {kind:?}\n\
5581                 exec source:\n{exec_source}\n\
5582                 exec input_names: {:?}\n\
5583                 exec coord_count: {}",
5584                exec_kernel.program().input_names(),
5585                exec_kernel.program().coord_count()
5586            );
5587        }
5588    }
5589
5590    #[test]
5591    fn workload_root_via_compile_bindings_preserves_shared_bool_type() {
5592        // Mirrors the runner's WORKLOAD-ROOT build flow:
5593        //   compile_bindings_with_libs_excluding(
5594        //     parent=params_kernel,
5595        //     ops=all_ops,
5596        //     workload_params=<non-empty>,
5597        //     workload_level_polydat=Some(<the bindings: block>),
5598        //   )
5599        //
5600        // The actual function takes a complex set of args; here
5601        // we replicate the essential moves to expose what differs
5602        // from a direct `compile_polydat_interpreter(source)` invocation.
5603        //
5604        // SRD-13c §"Shared Mutable" requires has_a to be ExternalWrite
5605        // kind, Bool type on the workload-root program. The
5606        // dryrun=wiring output of the failing integration test shows
5607        // the downstream phase has has_a as Coordinate U64, so
5608        // either the workload-root or a downstream synthesizer
5609        // emits the wrong source for it.
5610        use nmbrs_workload::model::ParsedOp;
5611
5612        let mut workload_params: HashMap<String, String> = HashMap::new();
5613        workload_params.insert("dataset".to_string(), "example_dataset".to_string());
5614        workload_params.insert("prefix".to_string(), "px_".to_string());
5615        workload_params.insert("keyspace".to_string(), "ks1".to_string());
5616        workload_params.insert("inner_options".to_string(), "lo,hi".to_string());
5617
5618        // Workload params reach the root via a sibling params
5619        // kernel. Construct one with each param as a final binding.
5620        let mut params_source = String::new();
5621        let mut sorted: Vec<&String> = workload_params.keys().collect();
5622        sorted.sort();
5623        for k in sorted {
5624            let v = &workload_params[k];
5625            params_source.push_str(&format!("const {k} := \"{v}\"\n"));
5626        }
5627        let params_kernel =
5628            crate::scope_kernel::ScopeKernel::compile(&params_source).expect("params compile");
5629
5630        // The workload's `bindings:` block. The trigger.
5631        let workload_level_polydat = "selector := mod(cycle, 1)\n\
5632                                 shared has_a := true\n\
5633                                 shared has_b := false\n";
5634
5635        // Build the workload-root scope as compile_bindings_with_libs_excluding does.
5636        let mut scope = build_scope(
5637            &[] as &[ParsedOp],
5638            &HashMap::new(),
5639            &[],
5640            &workload_params,
5641            &HashMap::new(),
5642            None,
5643            &[],
5644            None,
5645        )
5646        .expect("build_scope");
5647        scope.ingest_polydat_source(workload_level_polydat, BindingOrigin::Inherited);
5648
5649        // The actual workload-root compile goes through
5650        // parent.build_subscope. Build the matter and finalize.
5651        let source = scope.emit();
5652        let opts = polydat::kernel::subcontext::CompileOptions {
5653            workload_dir: None,
5654            polydat_lib_paths: Vec::new(),
5655            strict: false,
5656            required_outputs: scope.required_outputs(),
5657            context_label: Some("test_workload_root".to_string()),
5658            cursor_limit: None,
5659            ..Default::default()
5660        };
5661        let root = crate::scope_kernel::ScopeKernel::build_under(
5662            params_kernel.kernel(),
5663            crate::scope_kernel::SourceMatter::source("test_workload_root", source.clone(), opts),
5664        )
5665        .expect("workload root build");
5666
5667        // The workload-root program must have has_a:
5668        //   - present as an input slot,
5669        //   - typed Bool (SRD-13c §"Shared Mutable" step 1),
5670        //   - kind ExternalWrite (NOT Coordinate),
5671        //   - marked SHARED in output_modifier (so seed_shared_cells fires).
5672        let has_a_idx = root.program().find_input("has_a").unwrap_or_else(|| {
5673            panic!(
5674                "workload root missing has_a input;\n\
5675                 emitted scope source:\n{source}\n\
5676                 input names: {:?}",
5677                root.program().input_names()
5678            )
5679        });
5680
5681        let typ = root.program().input_port_type("has_a");
5682        assert_eq!(
5683            typ,
5684            Some(polydat::ast::PortType::Bool),
5685            "workload root has_a must be Bool;\n\
5686             got {typ:?}\n\
5687             emitted source:\n{source}\n\
5688             input names: {:?}\n\
5689             coord_count: {}",
5690            root.program().input_names(),
5691            root.program().coord_count()
5692        );
5693
5694        let kind = root.program().input_kind(has_a_idx);
5695        assert_ne!(
5696            kind,
5697            Some(polydat::kernel::InputKind::Coordinate),
5698            "workload root has_a must NOT be Coordinate;\n\
5699             got {kind:?}\n\
5700             emitted source:\n{source}\n\
5701             input names: {:?}\n\
5702             coord_count: {}",
5703            root.program().input_names(),
5704            root.program().coord_count()
5705        );
5706
5707        let modifier = root.program().output_modifier("has_a");
5708        assert_eq!(
5709            modifier,
5710            polydat::dsl::ast::BindingModifier::SHARED,
5711            "workload root has_a output must have SHARED modifier;\n\
5712             got {modifier:?}"
5713        );
5714
5715        let shared = root.program().shared_outputs();
5716        assert!(
5717            shared.contains(&"has_a"),
5718            "workload root must have has_a in shared_outputs (so seed_shared_cells creates a cell);\n\
5719             got shared_outputs={shared:?}"
5720        );
5721    }
5722
5723    #[test]
5724    fn shared_bool_survives_when_workload_root_also_has_cycle_binding() {
5725        // BISECTED INTEGRATION FAILURE: adding any non-shared
5726        // workload-level binding that references `cycle`
5727        // (e.g. `selector := mod(cycle, 1)`) alongside the
5728        // SHARED bool wires causes the consumer phase's phase
5729        // scope kernel to have has_X classified as Coordinate
5730        // input slots typed U64 instead of cascaded externs
5731        // typed Bool.
5732        //
5733        // Manifests at runtime as: per-cycle `set_inputs(&[u64])`
5734        // clobbers the shared cells with `Value::U64(cycle)`,
5735        // and `pick(has_X, …)` panics with "non-bool type U64".
5736        //
5737        // This unit test walks the same scope chain
5738        // programmatically and pins the contract at each hop:
5739        //   workload root (shared bool + cycle-referencing binding)
5740        //     → for_each scope (synthesize_for_each_scope)
5741        //       → phase scope (build_phase_scope_kernel)
5742        // Asserts has_a stays Bool + non-Coordinate kind at every
5743        // layer.
5744        use polydat::kernel::extract_manifest;
5745
5746        // Step 1: workload root with shared bool AND a
5747        // non-shared cycle binding. The trigger.
5748        let root = crate::scope_kernel::ScopeKernel::compile(
5749            "shared has_a := true\n\
5750             shared has_b := false\n\
5751             selector := mod(cycle, 1)\n",
5752        )
5753        .expect("workload root compile");
5754
5755        // Workload-root contract: has_a is Bool ExternalWrite slot
5756        // (SRD-13c §"Shared Mutable" step 1).
5757        let root_has_a_idx = root
5758            .program()
5759            .find_input("has_a")
5760            .expect("workload root has_a slot");
5761        let root_has_a_type = root.program().input_port_type("has_a");
5762        assert_eq!(
5763            root_has_a_type,
5764            Some(polydat::ast::PortType::Bool),
5765            "workload root has_a must be Bool"
5766        );
5767        let root_has_a_kind = root.program().input_kind(root_has_a_idx);
5768        assert_ne!(
5769            root_has_a_kind,
5770            Some(polydat::kernel::InputKind::Coordinate),
5771            "workload root has_a must NOT be Coordinate; got {root_has_a_kind:?}"
5772        );
5773
5774        // Step 2: for_each scope.
5775        let for_each = crate::scope_synth::build_for_each_scope_kernel(
5776            &[("outer".to_string(), "p1,p2".to_string())],
5777            &extract_manifest(root.program()),
5778            &root,
5779            &HashMap::new(),
5780            Vec::new(),
5781            None,
5782            false,
5783            "test_for_each",
5784            None,
5785        )
5786        .expect("for_each synth");
5787
5788        let fe_has_a_idx = for_each
5789            .program()
5790            .find_input("has_a")
5791            .expect("for_each has_a slot");
5792        let fe_has_a_type = for_each.program().input_port_type("has_a");
5793        assert_eq!(
5794            fe_has_a_type,
5795            Some(polydat::ast::PortType::Bool),
5796            "for_each has_a must be Bool, got {fe_has_a_type:?}"
5797        );
5798        let fe_has_a_kind = for_each.program().input_kind(fe_has_a_idx);
5799        assert_ne!(
5800            fe_has_a_kind,
5801            Some(polydat::kernel::InputKind::Coordinate),
5802            "for_each has_a must NOT be Coordinate; got {fe_has_a_kind:?}"
5803        );
5804
5805        // Step 3: phase scope with its own bindings via pick.
5806        let phase_bindings = nmbrs_workload::model::BindingsDef::PolydatSource(
5807            "chosen := pick(has_a, has_b, \"alpha\", \"beta\")\n".to_string(),
5808        );
5809        let phase = build_phase_scope_kernel(
5810            &phase_bindings,
5811            &[],
5812            &for_each,
5813            &HashMap::new(),
5814            Vec::new(),
5815            None,
5816            false,
5817            "test_phase",
5818        )
5819        .expect("phase scope synth");
5820
5821        let phase_has_a_idx = phase
5822            .program()
5823            .find_input("has_a")
5824            .expect("phase has_a slot");
5825        let phase_has_a_type = phase.program().input_port_type("has_a");
5826        assert_eq!(
5827            phase_has_a_type,
5828            Some(polydat::ast::PortType::Bool),
5829            "phase has_a must be Bool; got {phase_has_a_type:?}\n\
5830             phase input names: {:?}",
5831            phase.program().input_names()
5832        );
5833        let phase_has_a_kind = phase.program().input_kind(phase_has_a_idx);
5834        assert_ne!(
5835            phase_has_a_kind,
5836            Some(polydat::kernel::InputKind::Coordinate),
5837            "phase has_a must NOT be Coordinate; got {phase_has_a_kind:?}\n\
5838             phase input names: {:?}\n\
5839             coord_count: {}",
5840            phase.program().input_names(),
5841            phase.program().coord_count()
5842        );
5843    }
5844
5845    #[test]
5846    fn for_each_then_phase_preserves_shared_bool_through_chain() {
5847        // The full chain the workload exercises:
5848        //   workload root (shared has_X := false)
5849        //     → for_each scope (cascades has_X via synthesize_for_each_scope)
5850        //       → phase scope await_index (cascades has_X via build_phase_scope_kernel)
5851        //
5852        // SRD-13c §"Shared Mutable" + SRD-13d §1: at every cascade
5853        // hop, has_X stays Bool-typed and non-Coordinate.
5854        // bind_outer_scope then cell-attaches at each level so
5855        // the original SharedCell reaches the leaf.
5856        use polydat::kernel::extract_manifest;
5857        let root = crate::scope_kernel::ScopeKernel::compile(
5858            "input cycle: u64\nshared has_sai_column_indexes := false\n\
5859             shared has_indexes := false\n",
5860        )
5861        .expect("root compile");
5862
5863        // for_each scope synthesised via the comprehension
5864        // synthesizer (the path runner.rs uses for ForComprehension
5865        // install specs). Iter vars are a placeholder so the
5866        // builder runs.
5867        let for_each_kernel = crate::scope_synth::build_for_each_scope_kernel(
5868            &[("dummy_var".to_string(), "1,2".to_string())],
5869            &extract_manifest(root.program()),
5870            &root,
5871            &HashMap::new(), // workload_params
5872            Vec::new(),      // polydat_lib_paths
5873            None,            // workload_dir
5874            false,           // strict
5875            "test_for_each",
5876            None, // phase_bindings
5877        )
5878        .expect("for_each kernel synth");
5879
5880        // for_each's program must carry has_X as a Bool slot too.
5881        let fe_type = for_each_kernel
5882            .program()
5883            .input_port_type("has_sai_column_indexes");
5884        assert_eq!(
5885            fe_type,
5886            Some(polydat::ast::PortType::Bool),
5887            "for_each scope's has_sai_column_indexes must be Bool, got {fe_type:?}"
5888        );
5889        let fe_idx = for_each_kernel
5890            .program()
5891            .find_input("has_sai_column_indexes")
5892            .expect("for_each has has_sai_column_indexes input");
5893        let fe_kind = for_each_kernel.program().input_kind(fe_idx);
5894        assert_ne!(
5895            fe_kind,
5896            Some(polydat::kernel::InputKind::Coordinate),
5897            "for_each scope's has_sai_column_indexes must NOT be Coordinate; got {fe_kind:?}"
5898        );
5899
5900        // Now build await_index's phase scope under for_each
5901        // (the real scope-tree shape).
5902        let phase_bindings = nmbrs_workload::model::BindingsDef::PolydatSource(
5903            "target_index_table := pick(has_sai_column_indexes, has_indexes, \
5904             \"a\", \"b\")\n"
5905                .to_string(),
5906        );
5907        let phase_kernel = build_phase_scope_kernel(
5908            &phase_bindings,
5909            &[],
5910            &for_each_kernel,
5911            &HashMap::new(),
5912            Vec::new(),
5913            None,
5914            false,
5915            "test_await_index",
5916        )
5917        .expect("phase kernel synth");
5918
5919        let phase_type = phase_kernel
5920            .program()
5921            .input_port_type("has_sai_column_indexes");
5922        assert_eq!(
5923            phase_type,
5924            Some(polydat::ast::PortType::Bool),
5925            "phase scope's has_sai_column_indexes must be Bool, got {phase_type:?}"
5926        );
5927        let phase_idx = phase_kernel
5928            .program()
5929            .find_input("has_sai_column_indexes")
5930            .expect("phase has has_sai_column_indexes input");
5931        let phase_kind = phase_kernel.program().input_kind(phase_idx);
5932        assert_ne!(
5933            phase_kind,
5934            Some(polydat::kernel::InputKind::Coordinate),
5935            "phase scope's has_sai_column_indexes must NOT be Coordinate; got {phase_kind:?}"
5936        );
5937    }
5938}
5939
5940/// Strict mode reaches every synthesized scope, and each still compiles:
5941/// strict refuses a source with no `input` declaration, and a synthesized
5942/// scope's coordinates are its parent's, so they are declared for it.
5943#[cfg(test)]
5944mod strict_synthesis_tests {
5945    use super::*;
5946
5947    fn parent() -> crate::scope_kernel::ScopeKernel {
5948        crate::scope_kernel::ScopeKernel::compile("input cycle: u64\nconst k_values := \"1, 10\"\n")
5949            .expect("parent scope")
5950    }
5951
5952    #[test]
5953    fn a_phase_scope_compiles_under_strict() {
5954        let parent = parent();
5955        let manifest = crate::runner::extract_manifest(parent.program());
5956        let kernel = build_phase_scope_kernel(
5957            &nmbrs_workload::model::BindingsDef::PolydatSource("x := cycle + 1\n".into()),
5958            &manifest,
5959            &parent,
5960            &HashMap::new(),
5961            Vec::new(),
5962            None,
5963            true,
5964            "strict phase",
5965        )
5966        .expect("a phase scope compiles under strict");
5967        assert!(kernel.input_names().iter().any(|n| n == "cycle"));
5968    }
5969
5970    #[test]
5971    fn a_do_loop_scope_compiles_under_strict() {
5972        let parent = parent();
5973        let manifest = crate::runner::extract_manifest(parent.program());
5974        build_do_loop_scope_kernel(
5975            Some("i"),
5976            "{i} < 3",
5977            &manifest,
5978            &parent,
5979            &HashMap::new(),
5980            Vec::new(),
5981            None,
5982            true,
5983            "strict do-loop",
5984        )
5985        .expect("a do-loop scope compiles under strict");
5986    }
5987
5988    #[test]
5989    fn a_for_each_scope_compiles_under_strict() {
5990        let parent = parent();
5991        let manifest = crate::runner::extract_manifest(parent.program());
5992        crate::scope_synth::build_for_each_scope_kernel(
5993            &[("k".to_string(), "{k_values}".to_string())],
5994            &manifest,
5995            &parent,
5996            &HashMap::new(),
5997            Vec::new(),
5998            None,
5999            true,
6000            "strict for_each",
6001            None,
6002        )
6003        .expect("a for_each scope compiles under strict");
6004    }
6005}