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(¤t.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(¤t.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, ¬hing_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(¶ms_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(¶ms_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}