nmbrs_runtime/scope_tree.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Canonical scope tree for a workload's runtime hierarchy.
5//!
6//! `ScenarioNode` (in `nmbrs-workload`) is the *static authored*
7//! tree — what the user wrote in YAML. `ScopeTree` is the
8//! *runtime hierarchy* — what Polydat and the scheduler see. Every
9//! non-trivial scenario node gets a 1:1 scope here, with
10//! parent pointers, depth, pragma sets, and a slot for a compiled
11//! kernel.
12//!
13//! This module is **structural only** — it builds the tree and
14//! exposes traversal helpers. Pragma attachment, kernel
15//! compilation, and execution scheduling live in subsequent
16//! steps of SRD 18b §"Migration":
17//!
18//! 1. *(this module)* introduce the data structure
19//! 2. nest each scope's `PragmaSet` in its parent's at scope-tree construction
20//! (M2 follow-up)
21//! 3. replace text-substitution of iteration vars with extern
22//! binding (compile leaf phases once)
23//! 4. pluggable scheduler reading the `schedule=<level0>/...`
24//! spec
25//! 5. hierarchical display surface
26//!
27//! Until those steps land, `ScopeTree` is built but not consumed
28//! by the runner — the existing executor continues to drive
29//! traversal directly off `ScenarioNode`. Building the tree is
30//! cheap and deterministic; intermediate sysrefs (TUI display,
31//! `dryrun=phase`) can already start consuming it.
32
33use nmbrs_workload::model::ScenarioNode;
34use polydat::dsl::pragmas::PragmaSet;
35use polydat::iteration::comprehension::Comprehension;
36
37/// Index into the `ScopeTree.nodes` vector. Stable for the
38/// lifetime of the tree.
39pub type ScopeNodeIdx = usize;
40
41/// What kind of scope a `ScopeNode` represents. Mirrors the
42/// `ScenarioNode` variants 1:1, with two extra kinds for the
43/// implicit workload root and the named scenario layer that
44/// wraps the user's authored children. SRD 18b §"Canonical
45/// traversal".
46#[derive(Debug, Clone)]
47pub enum ScopeKind {
48 /// The session root — **one per process** (SRD-88). The shared
49 /// common root every execution derives from: it owns the session
50 /// polydat scope (the process/session-level args) and the
51 /// `session=<id>` identity. Each [`ScopeKind::Workload`] hangs
52 /// under it as one execution. For a single-execution run there is
53 /// exactly one workload child.
54 Session,
55 /// A workload root — **one per execution** (SRD-88). Owns the
56 /// outer Polydat Kernel for its workload, compiled at execution
57 /// start, binding the session scope as its outer.
58 Workload,
59 /// A named scenario. Wraps the scenario's children so that
60 /// "phase P in scenario default" survives as a path query
61 /// rather than a elided label.
62 Scenario { name: String },
63 /// Iteration scope — `for_each` (single or multi-clause) or
64 /// `for_each_union`. The `Comprehension` AST captures the
65 /// shape and clauses; the executor uses it to enumerate
66 /// tuples and bind iteration variables on per-iteration
67 /// child kernels.
68 Comprehension { comprehension: Comprehension },
69 /// Logical inclusion of another scenario by name. The
70 /// runtime walks straight through to the children; the
71 /// scope is preserved purely so the scope tree retains the
72 /// include hierarchy for `dryrun=phase` and TUI output.
73 /// See
74 /// [`nmbrs_workload::model::ScenarioNode::IncludedScenario`].
75 IncludedScenario { name: String },
76 /// `do_while` with optional counter as a scope output.
77 DoWhile {
78 condition: String,
79 counter: Option<String>,
80 },
81 /// `do_until` with optional counter as a scope output.
82 DoUntil {
83 condition: String,
84 counter: Option<String>,
85 },
86 /// A phase reference. With SRD-13d Phase 6 the phase is no
87 /// longer a leaf — every op template the phase declares
88 /// becomes an `OpTemplate` child of this node. The kernel
89 /// slot, if filled, holds the per-phase Polydat program.
90 Phase { name: String },
91 /// SRD-13d Phase 6 — an op template's scope, child of its
92 /// declaring phase. Per-template Polydat content (`bindings:`,
93 /// `metrics:` wire-injections, inline `{{<expr>}}` rewrites)
94 /// hangs off this node; the scope-elision pre-walk
95 /// (§3.3) decides whether it materialises its own kernel
96 /// or elides into the parent phase. Op-template scopes
97 /// also own per-op `Component` instances at runtime so
98 /// SRD-40b's duplicate-family check (via
99 /// `Component::register_instrument`) surfaces per-op
100 /// rather than per-phase.
101 OpTemplate { name: String },
102 /// Scenario-tree-level Polydat bindings block (see
103 /// [`nmbrs_workload::model::ScenarioNode::Bindings`]). The
104 /// `source` is Polydat matter text that compiles into a kernel
105 /// layered over the parent scope. Used for any scope-tree-
106 /// level state injection: workload-param shadowing (the
107 /// `set: { ... }` sugar form), derived bindings spanning a
108 /// subtree, shared cells, etc. — the Polydat grammar is the
109 /// only constraint on what the source may contain.
110 Bindings { source: String },
111}
112
113impl ScopeKind {
114 /// True if this kind opens a *new* Polydat scope (its own
115 /// kernel + pragmas + extern wiring). Phase scopes are only
116 /// "new" when the phase has its own bindings or it's an
117 /// iteration of a parent — that decision lives in the
118 /// compiler step, not this static descriptor.
119 pub fn opens_kernel(&self) -> bool {
120 !matches!(self, ScopeKind::Workload | ScopeKind::Session)
121 }
122
123 /// Short label for diagnostic output (`dryrun=phase`, TUI).
124 pub fn label(&self) -> String {
125 match self {
126 ScopeKind::Session => "session".into(),
127 ScopeKind::Workload => "workload".into(),
128 ScopeKind::Scenario { name } => format!("scenario '{name}'"),
129 ScopeKind::Comprehension { comprehension } => label_for_comprehension(comprehension),
130 ScopeKind::IncludedScenario { name } => format!("scenario '{name}'"),
131 ScopeKind::DoWhile { condition, counter } => match counter {
132 Some(c) => format!("do_while {condition} ({c})"),
133 None => format!("do_while {condition}"),
134 },
135 ScopeKind::DoUntil { condition, counter } => match counter {
136 Some(c) => format!("do_until {condition} ({c})"),
137 None => format!("do_until {condition}"),
138 },
139 ScopeKind::Phase { name } => format!("phase '{name}'"),
140 ScopeKind::OpTemplate { name } => format!("op '{name}'"),
141 ScopeKind::Bindings { source } => bindings_label(source),
142 }
143 }
144}
145
146/// Render a one-line label for an algebra-AST comprehension.
147///
148/// Strips off outer `Order` / `Filter` wrappers (which don't
149/// affect the structural display) to find the inner body
150/// shape: `Cartesian` renders as `each v1, v2, ...`, `Union`
151/// renders as `for_each_union {[...]; [...]}`, and a bare
152/// `Clause` renders as `each <var>`.
153fn label_for_comprehension(comp: &Comprehension) -> String {
154 use polydat::iteration::comprehension::source::Source;
155 // Peel outer Order/Filter — these are non-structural for
156 // the label.
157 let mut body = comp;
158 while let Comprehension::Order { child, .. } | Comprehension::Filter { child, .. } = body {
159 body = child.as_ref();
160 }
161 fn var_of(node: &Comprehension) -> String {
162 match node {
163 Comprehension::Clause { name, .. } => name.clone(),
164 Comprehension::Zip { children, .. } => {
165 let vs: Vec<String> = children.iter().map(var_of).collect();
166 format!("({})", vs.join(", "))
167 }
168 _ => "?".to_string(),
169 }
170 }
171 fn expr_of(node: &Comprehension) -> String {
172 match node {
173 Comprehension::Clause { source, .. } => match source {
174 Source::IntRange { lo, hi, step } if *step == 1 => format!("{lo}..{hi}"),
175 Source::IntRange { lo, hi, step } => format!("{lo}..{hi} step {step}"),
176 Source::Literal { values } if values.len() == 1 => format!("{:?}", values[0]),
177 Source::Literal { values } => format!("[{} values]", values.len()),
178 Source::Generator { expr, .. } => expr.clone(),
179 Source::WorkloadParamList { name, .. } => format!("{{{name}}}"),
180 Source::ContinuousInterval { interval, .. } => {
181 format!("{}..{}", interval.lo, interval.hi)
182 }
183 Source::Distribution { .. } => "<dist>".to_string(),
184 },
185 Comprehension::Zip { children, .. } => {
186 let es: Vec<String> = children.iter().map(expr_of).collect();
187 format!("({})", es.join(", "))
188 }
189 _ => "?".to_string(),
190 }
191 }
192 match body {
193 Comprehension::Clause { name, .. } => format!("each {name}"),
194 Comprehension::Cartesian { children } if children.len() == 1 => {
195 format!("each {}", var_of(&children[0]))
196 }
197 Comprehension::Cartesian { children } => {
198 let vars: Vec<String> = children.iter().map(var_of).collect();
199 format!("each {}", vars.join(", "))
200 }
201 Comprehension::Union { children } => {
202 let parts: Vec<String> = children
203 .iter()
204 .map(|sub| {
205 // Each Union child is itself a Cartesian (or
206 // single Clause/Zip). Render its dims.
207 let dims: Vec<String> = match sub {
208 Comprehension::Cartesian { children: c } => c
209 .iter()
210 .map(|n| format!("{} in {}", var_of(n), expr_of(n)))
211 .collect(),
212 other => vec![format!("{} in {}", var_of(other), expr_of(other))],
213 };
214 format!("[{}]", dims.join(", "))
215 })
216 .collect();
217 format!("for_each_union {{{}}}", parts.join(" | "))
218 }
219 Comprehension::Zip { .. } => format!("each {}", var_of(body)),
220 Comprehension::Filter { .. } | Comprehension::Order { .. } => unreachable!(),
221 }
222}
223
224/// One node in the runtime scope tree. Carries enough metadata
225/// for the scheduler to walk and the compiler to fill in.
226#[derive(Debug)]
227pub struct ScopeNode {
228 pub kind: ScopeKind,
229 pub parent: Option<ScopeNodeIdx>,
230 pub children: Vec<ScopeNodeIdx>,
231 /// Depth from the root. Root is 0; its children are 1; and
232 /// so on. The scheduler's `schedule=<level0>/<level1>/...`
233 /// spec indexes by *child depth*, so a node at depth `d`
234 /// schedules its children with the spec entry for index
235 /// `d`.
236 pub depth: usize,
237 /// Pragmas declared at this scope level. Empty by default;
238 /// step 2 of the migration fills these in by walking the
239 /// node's source (or, for control-flow nodes, the optional
240 /// inline pragma block once the workload model supports
241 /// per-node pragmas).
242 pub pragmas: PragmaSet,
243 /// This scope's canonical kernel ([`crate::scope_kernel::ScopeKernel`]):
244 /// the interpreter program synthesis reads, beside the kernel that runs
245 /// on the fiber engine, and for an op-template scope the module each
246 /// fiber instantiates its per-op kernel from. Populated at pre-map
247 /// time by [`ScopeTree::install_kernel`].
248 ///
249 /// SRD 18b §"Iteration variables as scope outputs": every
250 /// non-trivial scope owns a kernel. The cached kernel is
251 /// shared via `Arc` (read-only canonical state). Mutable
252 /// per-iteration / per-fiber execution binds another instance of
253 /// it under the live parent ([`crate::scope_kernel::ScopeKernel::bind_under`]).
254 ///
255 /// `OnceLock` keeps installation lock-free; downstream
256 /// readers walk the parent chain via
257 /// [`ScopeTree::lookup_name`] and never touch this slot
258 /// directly.
259 pub cached_kernel: std::sync::OnceLock<std::sync::Arc<crate::scope_kernel::ScopeKernel>>,
260 /// SRD-13d §3 scope-elision mark — set once at
261 /// pre-walk by [`ScopeTree::mark_scope_elision`] and
262 /// read by every consumer (premap, runtime, diagnostics).
263 /// `None` means "not yet computed"; the pre-walk
264 /// guarantees every node has `Some` after it finishes.
265 /// `true` ⇒ this scope materialises its own kernel;
266 /// `false` ⇒ elided into the nearest materialised
267 /// ancestor.
268 pub materialised: Option<bool>,
269 /// SRD-13d §5.3 logical kernel name. Stable, fully-
270 /// qualified scope-tree path (`workload`, `phase.<n>`,
271 /// `phase.<n>.op.<o>`, etc.). Used by `dryrun=op`
272 /// diagnostics and `nmbrs describe wiring` displays. Empty
273 /// before the pre-walk runs.
274 pub logical_name: String,
275}
276
277// `OnceLock` doesn't implement `Clone`, so neither does
278// `ScopeNode` automatically. We don't actually need clones today
279// — the tree is built once and shared via `Arc<ScopeTree>` — but
280// some test helpers and serialisation paths assume `Clone`.
281// Provide a manual clone that drops the cache (subsequent reads
282// repopulate from a fresh compile, which is correct for clones
283// since each clone owns an independent cache).
284impl Clone for ScopeNode {
285 fn clone(&self) -> Self {
286 Self {
287 kind: self.kind.clone(),
288 parent: self.parent,
289 children: self.children.clone(),
290 depth: self.depth,
291 pragmas: self.pragmas.clone(),
292 cached_kernel: std::sync::OnceLock::new(),
293 materialised: self.materialised,
294 logical_name: self.logical_name.clone(),
295 }
296 }
297}
298
299/// A workload's runtime scope hierarchy. Built once per session.
300/// Stable indices into `nodes`; parent / child pointers are
301/// `ScopeNodeIdx`. Use the helpers on this struct for traversal
302/// — direct `nodes` access is fine for read-only inspection but
303/// the navigation helpers are easier to read.
304#[derive(Debug, Clone)]
305pub struct ScopeTree {
306 pub nodes: Vec<ScopeNode>,
307 pub root: ScopeNodeIdx,
308}
309
310impl ScopeTree {
311 /// Build a scope tree from the resolved scenario children.
312 /// `scenario_name` becomes the named [`ScopeKind::Scenario`]
313 /// that wraps `nodes` — the user's authored grouping is
314 /// preserved as a real ancestor, restoring "phase P in
315 /// scenario default" as a path query.
316 pub fn build(scenario_name: &str, nodes: &[ScenarioNode]) -> Self {
317 let mut tree = ScopeTree {
318 nodes: Vec::new(),
319 root: 0,
320 };
321
322 // Root: the session — one per process (SRD-88), the shared
323 // common root. Always at index 0.
324 tree.nodes.push(ScopeNode {
325 kind: ScopeKind::Session,
326 parent: None,
327 children: Vec::new(),
328 depth: 0,
329 pragmas: PragmaSet::default(),
330 cached_kernel: std::sync::OnceLock::new(),
331 materialised: None,
332 logical_name: String::new(),
333 });
334
335 // The workload root — one per execution (SRD-88), under the
336 // session. Owns the outer workload Polydat Kernel.
337 let workload_idx = tree.add_node(ScopeNode {
338 kind: ScopeKind::Workload,
339 parent: Some(0),
340 children: Vec::new(),
341 depth: 1,
342 pragmas: PragmaSet::default(),
343 cached_kernel: std::sync::OnceLock::new(),
344 materialised: None,
345 logical_name: String::new(),
346 });
347 tree.nodes[0].children.push(workload_idx);
348
349 // Scenario layer wraps the user's children. This is the
350 // "lost grouping" the user called out — a real scope
351 // ancestor named after the scenario.
352 let scenario_idx = tree.add_node(ScopeNode {
353 kind: ScopeKind::Scenario {
354 name: scenario_name.into(),
355 },
356 parent: Some(workload_idx),
357 children: Vec::new(),
358 depth: 2,
359 pragmas: PragmaSet::default(),
360 cached_kernel: std::sync::OnceLock::new(),
361 materialised: None,
362 logical_name: String::new(),
363 });
364 tree.nodes[workload_idx].children.push(scenario_idx);
365
366 // Walk the user's children recursively under the scenario.
367 for child in nodes {
368 tree.append_subtree(scenario_idx, child);
369 }
370
371 tree
372 }
373
374 /// The workload-root node — the single child of the session root
375 /// (node 0). One per execution; owns the outer workload kernel.
376 /// Falls back to the root if (degenerately) there is no workload
377 /// layer.
378 pub fn workload_root_idx(&self) -> ScopeNodeIdx {
379 self.nodes[0].children.first().copied().unwrap_or(0)
380 }
381
382 /// The scenario-layer node — the single child of the workload root
383 /// (node 0). The walker seeds its scope cursor here so the top-level
384 /// scenario nodes resolve **positionally** against this node's children
385 /// (one scope-tree child per scenario node, in order — see
386 /// [`Self::append_subtree`]). Falls back to the root if (degenerately)
387 /// there is no scenario layer.
388 pub fn scenario_root_idx(&self) -> ScopeNodeIdx {
389 // Session(0) → Workload → Scenario. Walk two layers down.
390 let workload = self.workload_root_idx();
391 self.nodes[workload]
392 .children
393 .first()
394 .copied()
395 .unwrap_or(workload)
396 }
397
398 /// Append the subtree rooted at `node` as a child of `parent_idx`.
399 /// Recursive — control-flow nodes pull in their own children.
400 fn append_subtree(&mut self, parent_idx: ScopeNodeIdx, node: &ScenarioNode) {
401 let parent_depth = self.nodes[parent_idx].depth;
402 let depth = parent_depth + 1;
403
404 match node {
405 ScenarioNode::Phase(name) => {
406 let idx = self.add_node(ScopeNode {
407 kind: ScopeKind::Phase { name: name.clone() },
408 parent: Some(parent_idx),
409 children: Vec::new(),
410 depth,
411 pragmas: PragmaSet::default(),
412 cached_kernel: std::sync::OnceLock::new(),
413 materialised: None,
414 logical_name: String::new(),
415 });
416 self.nodes[parent_idx].children.push(idx);
417 }
418 ScenarioNode::Comprehension {
419 comprehension,
420 children,
421 ..
422 } => {
423 let idx = self.add_node(ScopeNode {
424 kind: ScopeKind::Comprehension {
425 comprehension: comprehension.clone(),
426 },
427 parent: Some(parent_idx),
428 children: Vec::new(),
429 depth,
430 pragmas: PragmaSet::default(),
431 cached_kernel: std::sync::OnceLock::new(),
432 materialised: None,
433 logical_name: String::new(),
434 });
435 self.nodes[parent_idx].children.push(idx);
436 for child in children {
437 self.append_subtree(idx, child);
438 }
439 }
440 ScenarioNode::IncludedScenario { name, children } => {
441 let idx = self.add_node(ScopeNode {
442 kind: ScopeKind::IncludedScenario { name: name.clone() },
443 parent: Some(parent_idx),
444 children: Vec::new(),
445 depth,
446 pragmas: PragmaSet::default(),
447 cached_kernel: std::sync::OnceLock::new(),
448 materialised: None,
449 logical_name: String::new(),
450 });
451 self.nodes[parent_idx].children.push(idx);
452 for child in children {
453 self.append_subtree(idx, child);
454 }
455 }
456 ScenarioNode::DoWhile {
457 condition,
458 counter,
459 children,
460 } => {
461 let idx = self.add_node(ScopeNode {
462 kind: ScopeKind::DoWhile {
463 condition: condition.clone(),
464 counter: counter.clone(),
465 },
466 parent: Some(parent_idx),
467 children: Vec::new(),
468 depth,
469 pragmas: PragmaSet::default(),
470 cached_kernel: std::sync::OnceLock::new(),
471 materialised: None,
472 logical_name: String::new(),
473 });
474 self.nodes[parent_idx].children.push(idx);
475 for child in children {
476 self.append_subtree(idx, child);
477 }
478 }
479 ScenarioNode::DoUntil {
480 condition,
481 counter,
482 children,
483 } => {
484 let idx = self.add_node(ScopeNode {
485 kind: ScopeKind::DoUntil {
486 condition: condition.clone(),
487 counter: counter.clone(),
488 },
489 parent: Some(parent_idx),
490 children: Vec::new(),
491 depth,
492 pragmas: PragmaSet::default(),
493 cached_kernel: std::sync::OnceLock::new(),
494 materialised: None,
495 logical_name: String::new(),
496 });
497 self.nodes[parent_idx].children.push(idx);
498 for child in children {
499 self.append_subtree(idx, child);
500 }
501 }
502 ScenarioNode::Bindings { source, children } => {
503 let idx = self.add_node(ScopeNode {
504 kind: ScopeKind::Bindings {
505 source: source.clone(),
506 },
507 parent: Some(parent_idx),
508 children: Vec::new(),
509 depth,
510 pragmas: PragmaSet::default(),
511 cached_kernel: std::sync::OnceLock::new(),
512 materialised: None,
513 logical_name: String::new(),
514 });
515 self.nodes[parent_idx].children.push(idx);
516 for child in children {
517 self.append_subtree(idx, child);
518 }
519 }
520 }
521 }
522
523 fn add_node(&mut self, node: ScopeNode) -> ScopeNodeIdx {
524 let idx = self.nodes.len();
525 self.nodes.push(node);
526 idx
527 }
528
529 /// SRD-13d Phase 6 — extend every `Phase` scope node with
530 /// `OpTemplate` children (one per op declared in the
531 /// phase). Two-step build: `ScopeTree::build` produces
532 /// the scenario-shaped skeleton (phases as leaves);
533 /// this method adds the op-template tier on top by
534 /// consulting the workload's per-phase `WorkloadPhase`
535 /// records.
536 ///
537 /// Idempotent: a phase whose `OpTemplate` children are
538 /// already present is left alone (the post-build pre-walk
539 /// can run before or after this without double-adding).
540 /// Run before `mark_scope_elision` so the per-op
541 /// classification gets the chance to elide / materialise
542 /// each op-template tier.
543 pub fn extend_with_op_templates(
544 &mut self,
545 phases: &std::collections::HashMap<String, nmbrs_workload::model::WorkloadPhase>,
546 ) {
547 // Snapshot the indices first — we'll mutate `nodes`
548 // during the loop.
549 let phase_nodes: Vec<(ScopeNodeIdx, String, usize)> = self
550 .nodes
551 .iter()
552 .enumerate()
553 .filter_map(|(i, n)| match &n.kind {
554 ScopeKind::Phase { name } => Some((i, name.clone(), n.depth)),
555 _ => None,
556 })
557 .collect();
558
559 for (phase_idx, phase_name, phase_depth) in phase_nodes {
560 // Skip phases that already have OpTemplate children.
561 let already_has_ops = self.nodes[phase_idx]
562 .children
563 .iter()
564 .any(|&c| matches!(self.nodes[c].kind, ScopeKind::OpTemplate { .. }));
565 if already_has_ops {
566 continue;
567 }
568 // Look up the phase's op list. Phases referenced
569 // by name with no entry in `phases` (e.g. the
570 // `default` scenario including a phase that's
571 // declared elsewhere) just get no op children —
572 // not a structural error.
573 let Some(phase) = phases.get(&phase_name) else {
574 continue;
575 };
576 for op in &phase.ops {
577 let op_idx = self.add_node(ScopeNode {
578 kind: ScopeKind::OpTemplate {
579 name: op.name.clone(),
580 },
581 parent: Some(phase_idx),
582 children: Vec::new(),
583 depth: phase_depth + 1,
584 pragmas: PragmaSet::default(),
585 cached_kernel: std::sync::OnceLock::new(),
586 materialised: None,
587 logical_name: String::new(),
588 });
589 self.nodes[phase_idx].children.push(op_idx);
590 }
591 }
592 }
593
594 /// SRD-13d §3.3 — pre-walk every scope-tree node and mark
595 /// it `materialised` (own kernel) or elided (descendants
596 /// bind through parent). Also assigns the SRD-13d §5.3
597 /// logical kernel name, which is the fully-qualified
598 /// scope-tree path. Run once at workload-load; premap and
599 /// runtime read the marks afterward.
600 ///
601 /// `is_materialising` is the predicate the pre-walk
602 /// applies per node — typically a closure that consults
603 /// the AST node's `HasPolydatMatter` classification (None /
604 /// Readonly ⇒ elide; Definitions ⇒ check program-hash
605 /// equivalence with the parent and decide). The walker is
606 /// agnostic to the exact predicate; SRD-13d §3.3 fixes
607 /// the order.
608 ///
609 /// The workload root is **always** materialised (see
610 /// SRD-13d §5.1) so the walk terminates at a materialised
611 /// ancestor regardless of how aggressively descendants
612 /// elide.
613 pub fn mark_scope_elision<F>(&mut self, mut is_materialising: F)
614 where
615 F: FnMut(&ScopeKind, ScopeNodeIdx) -> bool,
616 {
617 // Walk in DFS order; logical names depend on parent
618 // names being assigned first, which DFS pre-order
619 // guarantees (root → scenario → … → leaf).
620 let order: Vec<ScopeNodeIdx> = self.iter_dfs().map(|(idx, _)| idx).collect();
621 for idx in order {
622 // Root: the session — always materialised, contributes NO
623 // logical-path segment (SRD-88; the workload child below
624 // owns the `workload` segment, keeping paths
625 // `workload.scenario.…`).
626 if idx == self.root {
627 self.nodes[idx].materialised = Some(true);
628 self.nodes[idx].logical_name = String::new();
629 continue;
630 }
631 let kind = self.nodes[idx].kind.clone();
632 // The workload node always materialises — it owns the
633 // installed workload kernel (SRD-88: it's the per-execution
634 // root beneath the session, the old always-materialised
635 // root's role). Descendants elide INTO it as before.
636 let materialise = matches!(kind, ScopeKind::Workload) || is_materialising(&kind, idx);
637 self.nodes[idx].materialised = Some(materialise);
638
639 // Logical name = parent's logical name + "."
640 // + per-kind segment. The segment shape follows
641 // SRD-13d §5.3's table (`phase.<n>`,
642 // `for_each.<var>`, `op.<o>`).
643 let parent_name = self.nodes[idx]
644 .parent
645 .map(|p| self.nodes[p].logical_name.clone())
646 .unwrap_or_default();
647 let segment = match &kind {
648 // SRD-88 — the session is the always-present implicit root;
649 // it contributes NO logical-path segment, so addressable
650 // paths stay `workload.scenario.…` (the workload/execution
651 // is what varies and addresses the path). The session tier
652 // is still visible structurally via `kind.label()`.
653 ScopeKind::Session => String::new(),
654 ScopeKind::Workload => "workload".to_string(),
655 ScopeKind::Scenario { name } => format!("scenario.{name}"),
656 ScopeKind::Phase { name } => format!("phase.{name}"),
657 ScopeKind::OpTemplate { name } => format!("op.{name}"),
658 ScopeKind::Comprehension { .. } => "for_each".to_string(),
659 ScopeKind::IncludedScenario { name } => format!("include.{name}"),
660 ScopeKind::DoWhile { .. } => "do_while".to_string(),
661 ScopeKind::DoUntil { .. } => "do_until".to_string(),
662 ScopeKind::Bindings { source } => {
663 // First `final NAME` / `NAME :=` in the
664 // source distinguishes this scope-tree node
665 // in the logical-name path. For sugar from
666 // `set: { mode: verbose }` the source starts
667 // with `const mode := …` so the segment is
668 // `bindings.mode`. Sources with no clear
669 // first name fall back to a positional tag.
670 let first_name = source
671 .lines()
672 .map(str::trim)
673 .find(|l| !l.is_empty())
674 .and_then(|line| {
675 let after_kw = line
676 .strip_prefix("const ")
677 .or_else(|| line.strip_prefix("final "))
678 .or_else(|| line.strip_prefix("init "))
679 .or_else(|| line.strip_prefix("shared "))
680 .unwrap_or(line);
681 after_kw
682 .split([' ', ':'])
683 .next()
684 .filter(|s| !s.is_empty())
685 .map(str::to_string)
686 })
687 .unwrap_or_else(|| "anon".to_string());
688 format!("bindings.{first_name}")
689 }
690 };
691 self.nodes[idx].logical_name = if parent_name.is_empty() {
692 segment
693 } else {
694 format!("{parent_name}.{segment}")
695 };
696 }
697 }
698
699 /// SRD-13d §5.1 — walk past elided scope tiers to the
700 /// nearest materialised ancestor (or self, when this
701 /// node is itself materialised). Every consumer that
702 /// needs a kernel handle (cache lookups, bind-outer-
703 /// scope, diagnostics) routes through this — it's the
704 /// single point that knows about elision; nothing
705 /// else does.
706 ///
707 /// The workload root is always materialised, so this
708 /// always terminates with `Some(idx)`. Returns `None`
709 /// only if [`mark_scope_elision`] hasn't been run.
710 pub fn nearest_materialised(&self, idx: ScopeNodeIdx) -> Option<ScopeNodeIdx> {
711 let mut cur = idx;
712 loop {
713 match self.nodes[cur].materialised? {
714 true => return Some(cur),
715 false => match self.nodes[cur].parent {
716 Some(p) => cur = p,
717 None => return Some(cur), // root by construction
718 },
719 }
720 }
721 }
722
723 /// Iterate every scope node in depth-first pre-order. The
724 /// scheduler's default walk and the canonical display
725 /// linearisation both consume this.
726 pub fn iter_dfs(&self) -> DfsIter<'_> {
727 DfsIter {
728 tree: self,
729 stack: vec![self.root],
730 }
731 }
732
733 /// Walk from `idx` up through its ancestors to the root,
734 /// inclusive of `idx` itself. Use this to compute effective
735 /// pragmas or to render a path label.
736 pub fn ancestors(&self, idx: ScopeNodeIdx) -> AncestorsIter<'_> {
737 AncestorsIter {
738 tree: self,
739 cursor: Some(idx),
740 }
741 }
742
743 /// First scope-tree node whose kind is `Phase { name }`
744 /// matching the given name. Returns `None` if the scenario
745 /// doesn't reference this phase. When a single phase is
746 /// invoked from multiple scenario sites (rare; most workloads
747 /// reference a phase exactly once), this returns the first
748 /// occurrence in depth-first order — sufficient for current
749 /// callers, who use the result to fetch the chain-walked
750 /// `PragmaSet`.
751 pub fn phase_node_by_name(&self, name: &str) -> Option<ScopeNodeIdx> {
752 self.iter_dfs().find_map(|(idx, node)| match &node.kind {
753 ScopeKind::Phase { name: n } if n == name => Some(idx),
754 _ => None,
755 })
756 }
757
758 /// Op-template kernel programs for every materialised
759 /// op-template that's a child of `phase_idx`. Keyed by the
760 /// op's name. Used by the executor to thread per-op-template
761 /// programs into the activity so each `MetricsDispenser`
762 /// builds its `ScopeFixture` against the correct scope
763 /// (SRD-13d Phase 9 §"per-dispenser kernel instancing").
764 /// Flattened op-templates (`materialised != Some(true)`) are
765 /// omitted from the map; their dispensers reach the parent
766 /// kernel through the standard `nearest_materialised`
767 /// fall-through.
768 ///
769 /// Rule 2 write-through bindings ride on the program itself
770 /// (baked in by the SRD-67 builder's finalize step). Any
771 /// kernel built from the program inherits them automatically
772 /// when bound (`ScopeKernel::bind_under`) — no side channel.
773 pub fn op_template_programs_for_phase(
774 &self,
775 phase_idx: ScopeNodeIdx,
776 ) -> std::collections::HashMap<String, std::sync::Arc<polydat::kernel::PolydatProgram>> {
777 let mut out = std::collections::HashMap::new();
778 for &child_idx in &self.nodes[phase_idx].children {
779 let child = &self.nodes[child_idx];
780 let ScopeKind::OpTemplate { name } = &child.kind else {
781 continue;
782 };
783 if child.materialised != Some(true) {
784 continue;
785 }
786 if let Some(kernel) = child.cached_kernel.get() {
787 out.insert(name.clone(), kernel.program().clone());
788 }
789 }
790 out
791 }
792
793 /// The op-template scope modules of `phase_idx`'s materialised
794 /// op-template children, keyed by op name: what each fiber
795 /// instantiates its per-op kernels from on the fiber engine
796 /// ([`crate::fiber_engine`]).
797 pub fn op_template_modules_for_phase(
798 &self,
799 phase_idx: ScopeNodeIdx,
800 ) -> Vec<(
801 String,
802 std::sync::Arc<crate::fiber_engine::OpTemplateModule>,
803 )> {
804 self.nodes[phase_idx]
805 .children
806 .iter()
807 .filter_map(|&child_idx| {
808 let child = &self.nodes[child_idx];
809 let ScopeKind::OpTemplate { name } = &child.kind else {
810 return None;
811 };
812 if child.materialised != Some(true) {
813 return None;
814 }
815 let module = child.cached_kernel.get()?.module()?;
816 Some((name.clone(), module.clone()))
817 })
818 .collect()
819 }
820
821 /// All phase-leaf indices in depth-first order. Equivalent
822 /// to filtering `iter_dfs()` to `ScopeKind::Phase` — the
823 /// helper exists because it's the most common consumer
824 /// query (TUI tree pre-mapping, dryrun=phase).
825 pub fn phase_leaves(&self) -> Vec<ScopeNodeIdx> {
826 self.iter_dfs()
827 .filter_map(|(idx, node)| matches!(node.kind, ScopeKind::Phase { .. }).then_some(idx))
828 .collect()
829 }
830
831 /// Walk ancestors of `idx` looking for the nearest scope
832 /// node that has a kernel installed. Used at routing time
833 /// to find the kernel a for_each scope's `materialize_wiring_from_outer`
834 /// should chain from. Workload root always has a kernel
835 /// installed (per M3.1), so this never returns `None` for
836 /// any descendant of the root.
837 pub fn nearest_installed_ancestor_kernel(
838 &self,
839 idx: ScopeNodeIdx,
840 ) -> Option<std::sync::Arc<crate::scope_kernel::ScopeKernel>> {
841 let mut cursor = self.nodes.get(idx)?.parent;
842 while let Some(p) = cursor {
843 if let Some(k) = self.nodes[p].cached_kernel.get() {
844 return Some(k.clone());
845 }
846 cursor = self.nodes[p].parent;
847 }
848 None
849 }
850
851 /// Collect every installed ancestor kernel of `idx`,
852 /// innermost first (immediate parent → workload root).
853 /// Skips ancestor levels whose `cached_kernel` is empty
854 /// (intermediate nodes that don't own their own kernel).
855 /// Used by the checkpoint identity path to feed
856 /// [`polydat::kernel::PolydatProgram::instance_hash`]
857 /// (SRD-44 §"Identity matching at resume" + project
858 /// memory `program_vs_instance_hash`).
859 pub fn ancestor_kernels(
860 &self,
861 idx: ScopeNodeIdx,
862 ) -> Vec<std::sync::Arc<crate::scope_kernel::ScopeKernel>> {
863 let mut out = Vec::new();
864 let mut cursor = self.nodes.get(idx).and_then(|n| n.parent);
865 while let Some(p) = cursor {
866 if let Some(k) = self.nodes[p].cached_kernel.get() {
867 out.push(k.clone());
868 }
869 cursor = self.nodes[p].parent;
870 }
871 out
872 }
873
874 /// [`Self::ancestor_kernels`] split at the session boundary
875 /// (SRD-107): `(below, session)` where `below` is every
876 /// installed ancestor kernel from the immediate parent up
877 /// through the workload root, and `session` is the
878 /// session-node kernel (the workload-params module) when one
879 /// is installed. The provenance base hash covers `below`
880 /// only; param values are covered per-phase by the
881 /// consumed-params digest instead.
882 pub fn ancestor_kernels_split(
883 &self,
884 idx: ScopeNodeIdx,
885 ) -> (
886 Vec<std::sync::Arc<crate::scope_kernel::ScopeKernel>>,
887 Option<std::sync::Arc<crate::scope_kernel::ScopeKernel>>,
888 ) {
889 let mut below = Vec::new();
890 let mut session = None;
891 let mut cursor = self.nodes.get(idx).and_then(|n| n.parent);
892 while let Some(p) = cursor {
893 if let Some(k) = self.nodes[p].cached_kernel.get() {
894 if matches!(self.nodes[p].kind, ScopeKind::Session) {
895 session = Some(k.clone());
896 } else {
897 below.push(k.clone());
898 }
899 }
900 cursor = self.nodes[p].parent;
901 }
902 (below, session)
903 }
904
905 /// Find a `Comprehension` scope by structural-equality match
906 /// against its [`Comprehension`] AST. Returns the **first**
907 /// DFS-pre-order match.
908 pub fn find_comprehension_scope(&self, comprehension: &Comprehension) -> Option<ScopeNodeIdx> {
909 self.iter_dfs().find_map(|(idx, node)| match &node.kind {
910 ScopeKind::Comprehension { comprehension: c } if c == comprehension => Some(idx),
911 _ => None,
912 })
913 }
914
915 /// First scope-tree node whose kind is
916 /// `ScopeKind::Bindings { source }` matching exactly,
917 /// searched globally from root in DFS pre-order.
918 ///
919 /// **Prefer [`Self::find_bindings_scope_under`]** when the
920 /// executor's current scope position is known — see that
921 /// method's doc for why a global content-only lookup is
922 /// currently unsafe.
923 pub fn find_bindings_scope(&self, source: &str) -> Option<ScopeNodeIdx> {
924 self.iter_dfs().find_map(|(idx, node)| match &node.kind {
925 ScopeKind::Bindings { source: s } if s == source => Some(idx),
926 _ => None,
927 })
928 }
929
930 /// **TRANSITIONAL WORKAROUND** — see task #19 for the
931 /// canonical end-state plan. Constrains the lookup of a
932 /// `Bindings` scope to descendants of `parent` so that two
933 /// `Bindings` nodes sharing source text at different scope-
934 /// tree positions resolve to the right one based on the
935 /// executor's current position.
936 ///
937 /// The deeper problem: today the AST/source we use as the
938 /// lookup key is LOSSY — two scope-tree nodes that produce
939 /// semantically-distinct installed kernels (different
940 /// cascaded externs from different parent chains) can share
941 /// AST/source. Per SRD-13d §"Op-template scope synthesis" +
942 /// SRD-13f §"The read invariant", installed kernels are
943 /// determined by their PARENT chain, not by their own
944 /// content alone. The current scope-aware lookup adds the
945 /// missing context (parent subtree) at the call site to
946 /// disambiguate.
947 ///
948 /// **Future direction:** make the AST/source self-
949 /// identifying so that semantically-distinct kernels never
950 /// share matter (embed parent-chain signature, encode
951 /// scenario-tree path, or some equivalent invariant). Once
952 /// that lands, content-only `find_bindings_scope` is
953 /// correct again and this `_under` variant can be retired.
954 pub fn find_bindings_scope_under(
955 &self,
956 parent: ScopeNodeIdx,
957 source: &str,
958 ) -> Option<ScopeNodeIdx> {
959 self.find_descendant_matching(parent, &mut |node| match &node.kind {
960 ScopeKind::Bindings { source: s } => s == source,
961 _ => false,
962 })
963 }
964
965 /// **TRANSITIONAL WORKAROUND** — see
966 /// [`Self::find_bindings_scope_under`] for the underlying
967 /// principle and task #19 for the canonical end-state plan.
968 /// Retired once the AST becomes self-identifying.
969 pub fn find_comprehension_scope_under(
970 &self,
971 parent: ScopeNodeIdx,
972 comprehension: &Comprehension,
973 ) -> Option<ScopeNodeIdx> {
974 self.find_descendant_matching(parent, &mut |node| match &node.kind {
975 ScopeKind::Comprehension { comprehension: c } => c == comprehension,
976 _ => false,
977 })
978 }
979
980 /// DFS pre-order search through the descendants of `parent`
981 /// (excluding `parent` itself). Returns the first node whose
982 /// predicate returns true.
983 fn find_descendant_matching(
984 &self,
985 parent: ScopeNodeIdx,
986 predicate: &mut dyn FnMut(&ScopeNode) -> bool,
987 ) -> Option<ScopeNodeIdx> {
988 let mut stack: Vec<ScopeNodeIdx> =
989 self.nodes[parent].children.iter().rev().copied().collect();
990 while let Some(idx) = stack.pop() {
991 if predicate(&self.nodes[idx]) {
992 return Some(idx);
993 }
994 for &child in self.nodes[idx].children.iter().rev() {
995 stack.push(child);
996 }
997 }
998 None
999 }
1000
1001 /// Validate iteration-variable name uniqueness against the
1002 /// surrounding scope chain.
1003 ///
1004 /// An iter-var name (`for_each: "X in ..."`,
1005 /// `for_combinations: "X in ..., Y in ..."`,
1006 /// `for_each_union: ...`, do-loop counters) **must not**
1007 /// shadow:
1008 /// - a workload param,
1009 /// - an iter var declared by an enclosing scope.
1010 ///
1011 /// Aliasing creates a name that can't unambiguously resolve
1012 /// at spec-evaluation time (the iter var is being defined
1013 /// from a value that uses the same name; the runtime can't
1014 /// tell whether `{X}` means the iter var or the shadowed
1015 /// outer name). Rather than try to disambiguate, the build
1016 /// rejects it up-front with a clear error so the user
1017 /// renames the iter var.
1018 ///
1019 /// Returns `Ok(())` if every iter-var name is unique. Returns
1020 /// `Err(...)` with the offending name and which kind of
1021 /// collision (workload param vs ancestor iter var) the user
1022 /// has on the first violation found.
1023 pub fn validate_iter_var_uniqueness(
1024 &self,
1025 workload_params: &std::collections::HashSet<String>,
1026 ) -> Result<(), String> {
1027 fn walk(
1028 tree: &ScopeTree,
1029 idx: ScopeNodeIdx,
1030 ancestor_iter_vars: &std::collections::HashSet<String>,
1031 workload_params: &std::collections::HashSet<String>,
1032 ) -> Result<(), String> {
1033 let node = &tree.nodes[idx];
1034 // Collect the iter vars declared at this node.
1035 // Algebra's `coordinate_names()` returns owned
1036 // strings (operator-tree walks need fresh strings
1037 // — there's no single backing slice to borrow
1038 // from), so this block is owned-string throughout.
1039 let own_iter_vars: Vec<String> = match &node.kind {
1040 ScopeKind::Comprehension { comprehension } => comprehension.coordinate_names(),
1041 ScopeKind::DoWhile {
1042 counter: Some(c), ..
1043 }
1044 | ScopeKind::DoUntil {
1045 counter: Some(c), ..
1046 } => vec![c.clone()],
1047 _ => Vec::new(),
1048 };
1049 for var in &own_iter_vars {
1050 if workload_params.contains(var) {
1051 return Err(format!(
1052 "iter-var '{var}' aliases workload param '{var}'. \
1053 A for_each / for_combinations / for_each_union iter \
1054 variable cannot share a name with a workload param — \
1055 spec evaluation can't disambiguate `{{{var}}}` between \
1056 the iter var and the param. Rename one of them."
1057 ));
1058 }
1059 if ancestor_iter_vars.contains(var) {
1060 return Err(format!(
1061 "iter-var '{var}' aliases an iter var declared by an \
1062 enclosing scope. Inner iter vars must use distinct \
1063 names from outer iter vars."
1064 ));
1065 }
1066 }
1067 // Extend the ancestor set for descent.
1068 let mut next_ancestors = ancestor_iter_vars.clone();
1069 for v in &own_iter_vars {
1070 next_ancestors.insert(v.clone());
1071 }
1072 for &child in &node.children {
1073 walk(tree, child, &next_ancestors, workload_params)?;
1074 }
1075 Ok(())
1076 }
1077 walk(
1078 self,
1079 self.root,
1080 &std::collections::HashSet::new(),
1081 workload_params,
1082 )
1083 }
1084
1085 /// Install the canonical compiled kernel for `scope_idx`.
1086 ///
1087 /// Called at pre-map time after compiling the scope's
1088 /// `PolydatProgram`. Once installed, the kernel is the *single*
1089 /// authoritative answer for "what is `<name>` at this
1090 /// scope?" — every name visible at this scope (own outputs
1091 /// plus parent-inherited values bound via
1092 /// [`PolydatKernel::materialize_wiring_from_outer`]) resolves through the
1093 /// standard Polydat API on this one kernel. Callers don't walk
1094 /// the scope tree to do name resolution; Polydat's auto-extern +
1095 /// outer-scope wiring already encapsulates the layering.
1096 ///
1097 /// Idempotent only by virtue of `OnceLock`: a second install
1098 /// silently no-ops, returning `false`. Returns `true` on
1099 /// fresh install. Callers that need to detect a duplicate
1100 /// install should check the boolean.
1101 pub fn install_kernel(
1102 &self,
1103 scope_idx: ScopeNodeIdx,
1104 kernel: std::sync::Arc<crate::scope_kernel::ScopeKernel>,
1105 ) -> bool {
1106 match self.nodes.get(scope_idx) {
1107 Some(node) => {
1108 let inserted = node.cached_kernel.set(kernel.clone()).is_ok();
1109 // Ride-along visitor hook (SRD planning-walk
1110 // dryrun=kernels surface). Fires exactly once
1111 // per scope's fresh install — the OnceLock
1112 // semantics above guarantee no duplicate calls.
1113 if inserted {
1114 notify_kernel_installed(node, scope_idx, &kernel);
1115 }
1116 inserted
1117 }
1118 None => false,
1119 }
1120 }
1121
1122 /// Populate `pragmas` on every phase-leaf scope by scanning
1123 /// each phase's `BindingsDef::PolydatSource` strings for `pragma`
1124 /// statements, then walk the tree so each scope's set holds its
1125 /// ancestors' pragmas followed by its own, as polydat nests a
1126 /// scope's pragmas (`PragmaSet::nested`). After this call,
1127 /// `node.pragmas.strict_values()` answers for every pragma in force
1128 /// at the node.
1129 ///
1130 /// SRD 18b §"Pragma chain along the scope tree". Idempotent
1131 /// per call (replaces any prior `pragmas` content).
1132 pub fn populate_pragmas(
1133 &mut self,
1134 phases: &std::collections::HashMap<String, nmbrs_workload::model::WorkloadPhase>,
1135 ) {
1136 // Pass 1: extract phase-local pragmas. Iterate by
1137 // `phase_leaves` (which already does the kind filter)
1138 // and walk each phase's ops for Polydat source strings to
1139 // parse.
1140 let leaves = self.phase_leaves();
1141 for idx in leaves {
1142 let name = match &self.nodes[idx].kind {
1143 ScopeKind::Phase { name } => name.clone(),
1144 _ => continue,
1145 };
1146 if let Some(phase) = phases.get(&name) {
1147 self.nodes[idx].pragmas = extract_phase_pragmas(phase);
1148 }
1149 }
1150
1151 // Pass 2: nest each scope in its parent. Walk in depth order
1152 // so a parent's set is complete before its children take it.
1153 let order: Vec<ScopeNodeIdx> = self.iter_dfs().map(|(i, _)| i).collect();
1154 for idx in order {
1155 if let Some(parent) = self.nodes[idx].parent {
1156 let local = std::mem::take(&mut self.nodes[idx].pragmas.entries);
1157 let mut entries = self.nodes[parent].pragmas.entries.clone();
1158 entries.extend(local);
1159 self.nodes[idx].pragmas = PragmaSet { entries };
1160 }
1161 }
1162 }
1163}
1164
1165/// Extract pragmas from a phase's source by walking every op's
1166/// `BindingsDef::PolydatSource` and collecting `Statement::Pragma`s.
1167/// A phase has multiple ops; their bindings can each declare
1168/// pragmas. Today the convention is one pragma block at the
1169/// phase head; multi-op phases that put pragmas on individual
1170/// ops still get them aggregated here.
1171fn extract_phase_pragmas(phase: &nmbrs_workload::model::WorkloadPhase) -> PragmaSet {
1172 use nmbrs_workload::model::BindingsDef;
1173 let mut entries = Vec::new();
1174 for op in &phase.ops {
1175 let src = match &op.bindings {
1176 BindingsDef::PolydatSource(s) => s.as_str(),
1177 _ => continue,
1178 };
1179 // Lex/parse to AST to surface `Statement::Pragma`s. If
1180 // the source is malformed, skip — the real phase compile
1181 // will report a clean parse error later.
1182 let tokens = match polydat::dsl::lexer::lex(src) {
1183 Ok(t) => t,
1184 Err(_) => continue,
1185 };
1186 let ast = match polydat::dsl::parser::parse(tokens) {
1187 Ok(a) => a,
1188 Err(_) => continue,
1189 };
1190 let local = polydat::dsl::pragmas::collect_from_ast(&ast);
1191 entries.extend(local.entries);
1192 }
1193 PragmaSet { entries }
1194}
1195
1196/// Ride-along visitor for kernel-installation events. Set by
1197/// the runner when `dryrun=kernels` is requested so each
1198/// `install_kernel` fires the printer as the planning walk
1199/// encounters the scope. `None` (the default) keeps install
1200/// a no-cost hot path.
1201pub type KernelInstallVisitor =
1202 Box<dyn Fn(&ScopeNode, ScopeNodeIdx, &crate::scope_kernel::ScopeKernel) + Send + Sync>;
1203
1204static KERNEL_INSTALL_VISITOR: std::sync::OnceLock<std::sync::Mutex<Option<KernelInstallVisitor>>> =
1205 std::sync::OnceLock::new();
1206
1207fn visitor_slot() -> &'static std::sync::Mutex<Option<KernelInstallVisitor>> {
1208 KERNEL_INSTALL_VISITOR.get_or_init(|| std::sync::Mutex::new(None))
1209}
1210
1211/// Register a visitor that fires on every `install_kernel`
1212/// call. Replaces any prior visitor; pass `None` to clear.
1213/// Called by the runner at session start when
1214/// `dryrun=kernels` is set.
1215pub fn set_kernel_install_visitor(v: Option<KernelInstallVisitor>) {
1216 if let Ok(mut slot) = visitor_slot().lock() {
1217 *slot = v;
1218 }
1219}
1220
1221fn notify_kernel_installed(
1222 node: &ScopeNode,
1223 idx: ScopeNodeIdx,
1224 kernel: &crate::scope_kernel::ScopeKernel,
1225) {
1226 if let Ok(slot) = visitor_slot().lock()
1227 && let Some(visitor) = slot.as_ref()
1228 {
1229 visitor(node, idx, kernel);
1230 }
1231}
1232
1233/// Depth-first pre-order iterator over `(idx, &ScopeNode)`.
1234pub struct DfsIter<'a> {
1235 tree: &'a ScopeTree,
1236 stack: Vec<ScopeNodeIdx>,
1237}
1238
1239impl<'a> Iterator for DfsIter<'a> {
1240 type Item = (ScopeNodeIdx, &'a ScopeNode);
1241 fn next(&mut self) -> Option<Self::Item> {
1242 let idx = self.stack.pop()?;
1243 let node = &self.tree.nodes[idx];
1244 // Push children in reverse so the leftmost child comes
1245 // out of the stack first (pre-order).
1246 for &child in node.children.iter().rev() {
1247 self.stack.push(child);
1248 }
1249 Some((idx, node))
1250 }
1251}
1252
1253/// Walk from a node up through its ancestors to the root.
1254pub struct AncestorsIter<'a> {
1255 tree: &'a ScopeTree,
1256 cursor: Option<ScopeNodeIdx>,
1257}
1258
1259impl<'a> Iterator for AncestorsIter<'a> {
1260 type Item = (ScopeNodeIdx, &'a ScopeNode);
1261 fn next(&mut self) -> Option<Self::Item> {
1262 let idx = self.cursor?;
1263 let node = &self.tree.nodes[idx];
1264 self.cursor = node.parent;
1265 Some((idx, node))
1266 }
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271 use super::*;
1272
1273 fn phase(name: &str) -> ScenarioNode {
1274 ScenarioNode::Phase(name.into())
1275 }
1276 fn for_each(spec: &str, children: Vec<ScenarioNode>) -> ScenarioNode {
1277 use polydat::iteration::comprehension::spec::{ComprehensionSpec, ForSpec};
1278 let comprehension = ComprehensionSpec {
1279 r#for: ForSpec::Inline(spec.to_string()),
1280 r#where: None,
1281 order: None,
1282 }
1283 .into_algebra()
1284 .unwrap();
1285 ScenarioNode::Comprehension {
1286 comprehension,
1287 children,
1288 continue_if: None,
1289 anchor: None,
1290 }
1291 }
1292
1293 #[test]
1294 fn workload_and_scenario_always_present() {
1295 let tree = ScopeTree::build("default", &[]);
1296 // Even with no children, session + workload + scenario layers
1297 // survive so observer code doesn't special-case empty scenarios.
1298 assert_eq!(tree.nodes.len(), 3);
1299 assert!(matches!(tree.nodes[0].kind, ScopeKind::Session));
1300 assert!(matches!(tree.nodes[1].kind, ScopeKind::Workload));
1301 assert!(matches!(&tree.nodes[2].kind, ScopeKind::Scenario { name } if name == "default"));
1302 assert_eq!(tree.nodes[1].depth, 1);
1303 assert_eq!(tree.nodes[2].depth, 2);
1304 }
1305
1306 #[test]
1307 fn flat_phases_under_scenario() {
1308 let tree = ScopeTree::build("default", &[phase("setup"), phase("run")]);
1309 assert_eq!(tree.nodes.len(), 5);
1310 let scenario = &tree.nodes[2];
1311 assert_eq!(scenario.children.len(), 2);
1312 for &c in &scenario.children {
1313 assert!(matches!(tree.nodes[c].kind, ScopeKind::Phase { .. }));
1314 assert_eq!(tree.nodes[c].depth, 3);
1315 assert_eq!(tree.nodes[c].parent, Some(2));
1316 }
1317 }
1318
1319 #[test]
1320 fn nested_for_each_preserves_depth() {
1321 // for_each x in xs { for_each y in ys { phase P } }
1322 let tree = ScopeTree::build(
1323 "default",
1324 &[for_each(
1325 "x in xs",
1326 vec![for_each("y in ys", vec![phase("P")])],
1327 )],
1328 );
1329 // session(0) → workload(1) → scenario(2) → for_each_x(3) → for_each_y(4) → phase_P(5)
1330 assert_eq!(tree.nodes.len(), 6);
1331 assert_eq!(tree.nodes[3].depth, 3);
1332 assert_eq!(tree.nodes[4].depth, 4);
1333 assert_eq!(tree.nodes[5].depth, 5);
1334 assert!(matches!(
1335 &tree.nodes[3].kind,
1336 ScopeKind::Comprehension { comprehension }
1337 if comprehension.coordinate_names() == vec!["x"]
1338 ));
1339 assert!(matches!(
1340 &tree.nodes[4].kind,
1341 ScopeKind::Comprehension { comprehension }
1342 if comprehension.coordinate_names() == vec!["y"]
1343 ));
1344 }
1345
1346 #[test]
1347 fn dfs_pre_order_matches_authored_order() {
1348 let tree = ScopeTree::build(
1349 "default",
1350 &[
1351 for_each("x in xs", vec![phase("a"), phase("b")]),
1352 phase("c"),
1353 ],
1354 );
1355 let names: Vec<String> = tree.iter_dfs().map(|(_, n)| n.kind.label()).collect();
1356 assert_eq!(
1357 names,
1358 vec![
1359 "session".to_string(),
1360 "workload".into(),
1361 "scenario 'default'".into(),
1362 "each x".into(),
1363 "phase 'a'".into(),
1364 "phase 'b'".into(),
1365 "phase 'c'".into(),
1366 ]
1367 );
1368 }
1369
1370 #[test]
1371 fn ancestors_walk_to_root() {
1372 let tree = ScopeTree::build("default", &[for_each("x in xs", vec![phase("a")])]);
1373 let phase_idx = tree.phase_leaves()[0];
1374 let ancestors: Vec<String> = tree
1375 .ancestors(phase_idx)
1376 .map(|(_, n)| n.kind.label())
1377 .collect();
1378 assert_eq!(
1379 ancestors,
1380 vec![
1381 "phase 'a'".to_string(),
1382 "each x".into(),
1383 "scenario 'default'".into(),
1384 "workload".into(),
1385 "session".into(),
1386 ]
1387 );
1388 }
1389
1390 #[test]
1391 fn phase_leaves_returns_only_phases() {
1392 let tree = ScopeTree::build(
1393 "default",
1394 &[
1395 for_each("x in xs", vec![phase("a"), phase("b")]),
1396 phase("c"),
1397 ],
1398 );
1399 let leaves = tree.phase_leaves();
1400 assert_eq!(leaves.len(), 3);
1401 for idx in leaves {
1402 assert!(matches!(tree.nodes[idx].kind, ScopeKind::Phase { .. }));
1403 }
1404 }
1405
1406 fn make_phase_with_source(src: &str) -> nmbrs_workload::model::WorkloadPhase {
1407 use nmbrs_workload::model::{BindingsDef, ParsedOp, WorkloadPhase};
1408 let mut op = ParsedOp::simple("op", "noop");
1409 op.bindings = BindingsDef::PolydatSource(src.into());
1410 WorkloadPhase {
1411 key_metrics: Vec::new(),
1412 cycles: None,
1413 concurrency: None,
1414 rate: None,
1415 adapter: None,
1416 errors: None,
1417 tags: None,
1418 ops: vec![op],
1419 for_each: None,
1420 ..Default::default()
1421 }
1422 }
1423
1424 #[test]
1425 fn populate_pragmas_propagates_through_chain() {
1426 // Phase has `pragma strict_values` in its source. After
1427 // populate_pragmas + attach, an inner for_each scope (no
1428 // own pragmas) should still resolve `strict_values()` true
1429 // through its parent chain back to… wait. Phase is the
1430 // *leaf*, not the parent. The propagation we care about is
1431 // "phase's pragmas propagate up", but the chain is parent
1432 // → child. Let's flip: put the pragma in a phase, and the
1433 // assertion is "the phase scope sees its own pragmas." A
1434 // future test will demonstrate cross-scope propagation
1435 // once non-phase scopes can declare pragmas.
1436 let phases = std::collections::HashMap::from([(
1437 "p".to_string(),
1438 make_phase_with_source("pragma strict_values\n id := cycle\n"),
1439 )]);
1440 let mut tree = ScopeTree::build("default", &[phase("p")]);
1441 tree.populate_pragmas(&phases);
1442 let phase_idx = tree.phase_leaves()[0];
1443 assert!(tree.nodes[phase_idx].pragmas.strict_values());
1444 }
1445
1446 #[test]
1447 fn populate_pragmas_chain_walk_through_attach() {
1448 // Build a small tree where the phase, nested in a for_each,
1449 // declares strict and verify the nested set resolves it.
1450 let phases = std::collections::HashMap::from([(
1451 "p".to_string(),
1452 make_phase_with_source("pragma strict\n id := cycle\n"),
1453 )]);
1454 let mut tree = ScopeTree::build("default", &[for_each("x in xs", vec![phase("p")])]);
1455 tree.populate_pragmas(&phases);
1456 let phase_idx = tree.phase_leaves()[0];
1457 // Phase declares strict (alias for both). Confirm:
1458 assert!(tree.nodes[phase_idx].pragmas.strict_types());
1459 assert!(tree.nodes[phase_idx].pragmas.strict_values());
1460 }
1461
1462 // ---- M3.1: kernel install primitive ----
1463
1464 /// Compile a tiny Polydat source into a kernel for use as a
1465 /// scope's canonical instance. A one-line `name := <const>`
1466 /// suffices to populate `output_map` so `get_constant`
1467 /// returns the folded value.
1468 fn compile_kernel(source: &str) -> std::sync::Arc<crate::scope_kernel::ScopeKernel> {
1469 let kernel = crate::bindings::compile_scope_kernel(source, &Default::default())
1470 .expect("test source should compile");
1471 std::sync::Arc::new(kernel)
1472 }
1473
1474 #[test]
1475 fn install_kernel_seeds_canonical_state() {
1476 // After install, the cached kernel answers the name via
1477 // the standard Polydat API. No tree-walking on the caller
1478 // side — the kernel encapsulates its own scope, and
1479 // composition (auto-extern + materialize_wiring_from_outer) is what
1480 // makes parent values reachable. This test only verifies
1481 // the install primitive; the Polydat side already has its own
1482 // tests for composition.
1483 let tree = ScopeTree::build("default", &[phase("p")]);
1484 let workload_kernel = compile_kernel("const dataset := \"example\"\n");
1485 assert!(tree.install_kernel(0, workload_kernel));
1486
1487 let cached = tree.nodes[0]
1488 .cached_kernel
1489 .get()
1490 .expect("install populated the slot");
1491 match cached.lookup("dataset") {
1492 Some(polydat::ast::Value::Str(s)) => assert_eq!(&*s, "example"),
1493 other => panic!("expected Str(\"example\"), got {other:?}"),
1494 }
1495 }
1496
1497 #[test]
1498 fn for_each_scope_kernel_inherits_parent_via_materialize_wiring_from_outer() {
1499 // M3.2 end-to-end: build a parent kernel that exposes a
1500 // workload-style param as an output, synthesize a
1501 // for_each scope kernel that references that param plus
1502 // its own iter var, bind from parent, then verify both
1503 // values are reachable on the synthesized kernel via
1504 // standard Polydat API. Validates the chain inheritance
1505 // path without any caller-side scope walking.
1506 use std::sync::Arc;
1507
1508 // Parent: a workload-shaped kernel exposing `k_values`.
1509 let parent_src = "const k_values := \"1, 10\"\n";
1510 let parent = Arc::new(crate::scope_kernel::ScopeKernel::compile(parent_src).unwrap());
1511
1512 // Build the for_each scope kernel as the runner would.
1513 let parent_manifest = crate::runner::extract_manifest(parent.program());
1514 let kernel = crate::scope_synth::build_for_each_scope_kernel(
1515 &[("k".to_string(), "{k_values}".to_string())],
1516 &parent_manifest,
1517 &parent,
1518 &std::collections::HashMap::new(),
1519 Vec::new(),
1520 None,
1521 false,
1522 "test",
1523 None,
1524 )
1525 .expect("synthesis should succeed");
1526
1527 // After `materialize_wiring_from_outer` (called inside the helper),
1528 // the inherited extern is populated with the parent's
1529 // value.
1530 match kernel.lookup("k_values") {
1531 Some(polydat::ast::Value::Str(s)) => assert_eq!(&*s, "1, 10"),
1532 other => panic!("expected Str(\"1, 10\"), got {other:?}"),
1533 }
1534
1535 // The iter var `k` is also visible as an extern; not
1536 // yet set by the runtime, so its current value is the
1537 // default for String externs.
1538 // (Runtime semantics test belongs in executor.rs once
1539 // M3.4 wires this up; M3.2 only verifies the install +
1540 // chain mechanics.)
1541 assert!(
1542 kernel.program().find_input("k").is_some(),
1543 "iter var should be declared as an extern input"
1544 );
1545
1546 // Polydat's `extern` declaration auto-installs a passthrough
1547 // node that exposes the name as an output too — so
1548 // children's `materialize_wiring_from_outer(this_scope)` sees both
1549 // `k_values` and `k` in this scope's manifest and the
1550 // chain inheritance flows through standard Polydat API
1551 // without any caller-side scope walking.
1552 let manifest = crate::runner::extract_manifest(kernel.program());
1553 let output_names: std::collections::HashSet<_> =
1554 manifest.iter().map(|e| e.name.as_str()).collect();
1555 assert!(
1556 output_names.contains("k_values"),
1557 "inherited name appears as output via extern's auto-passthrough"
1558 );
1559 assert!(
1560 output_names.contains("k"),
1561 "iter var appears as output via extern's auto-passthrough"
1562 );
1563 }
1564
1565 #[test]
1566 fn for_each_scope_kernel_uses_native_type_for_numeric_iter_var() {
1567 // Single-clause for_each over a numeric workload param.
1568 // Pre-eval at synthesis detects U64 from "1, 10" and
1569 // declares `extern k: u64` instead of `extern k: String`.
1570 // Per SRD-18b "native types as the general rule".
1571 use std::sync::Arc;
1572
1573 let parent_src = "const k_values := \"1, 10\"\n";
1574 let parent = Arc::new(crate::scope_kernel::ScopeKernel::compile(parent_src).unwrap());
1575 let parent_manifest = crate::runner::extract_manifest(parent.program());
1576
1577 let kernel = crate::scope_synth::build_for_each_scope_kernel(
1578 &[("k".to_string(), "{k_values}".to_string())],
1579 &parent_manifest,
1580 &parent,
1581 &std::collections::HashMap::new(),
1582 Vec::new(),
1583 None,
1584 false,
1585 "test",
1586 None,
1587 )
1588 .expect("synthesis should succeed");
1589
1590 // Assert k's input port is u64-typed, not String.
1591 let manifest = crate::runner::extract_manifest(kernel.program());
1592 let k_entry = manifest
1593 .iter()
1594 .find(|e| e.name == "k")
1595 .expect("k must appear in manifest");
1596 assert_eq!(
1597 k_entry.port_type,
1598 polydat::ast::PortType::U64,
1599 "iter var over numeric values should be typed u64, not String"
1600 );
1601 }
1602
1603 #[test]
1604 fn for_each_scope_kernel_recursive_probe_for_dependent_clause() {
1605 // Multi-clause dependent: clause 2's spec text references
1606 // clause 1's iter var via `{k}`. Pre-eval probes clause 1
1607 // (k_values = "1, 10" → first value 1, type U64). Then
1608 // for clause 2's spec `{k_{k}_limits}`, the probe
1609 // substitutes {k}→1, leaving `{k_1_limits}`, which
1610 // resolves to "1, 2, 4, 8" via parent's manifest. First
1611 // value is 1, type U64.
1612 use std::sync::Arc;
1613
1614 let parent_src = concat!(
1615 "const k_values := \"1, 10\"\n",
1616 "const k_1_limits := \"1, 2, 4, 8\"\n",
1617 "const k_10_limits := \"10, 20, 30\"\n",
1618 );
1619 let parent = Arc::new(crate::scope_kernel::ScopeKernel::compile(parent_src).unwrap());
1620 let parent_manifest = crate::runner::extract_manifest(parent.program());
1621
1622 let kernel = crate::scope_synth::build_for_each_scope_kernel(
1623 &[
1624 ("k".to_string(), "{k_values}".to_string()),
1625 ("limit".to_string(), "{k_{k}_limits}".to_string()),
1626 ],
1627 &parent_manifest,
1628 &parent,
1629 &std::collections::HashMap::new(),
1630 Vec::new(),
1631 None,
1632 false,
1633 "test",
1634 None,
1635 )
1636 .expect("synthesis should succeed");
1637
1638 let manifest = crate::runner::extract_manifest(kernel.program());
1639 let k_entry = manifest.iter().find(|e| e.name == "k").unwrap();
1640 let limit_entry = manifest.iter().find(|e| e.name == "limit").unwrap();
1641 assert_eq!(
1642 k_entry.port_type,
1643 polydat::ast::PortType::U64,
1644 "k typed u64 from k_values pre-eval"
1645 );
1646 assert_eq!(
1647 limit_entry.port_type,
1648 polydat::ast::PortType::U64,
1649 "limit typed u64 via recursive probe k=1 → k_1_limits → \"1, 2, 4, 8\""
1650 );
1651 }
1652
1653 // ── SRD-13d Phase 4 + 5: scope elision marks ──
1654
1655 #[test]
1656 fn mark_scope_elision_assigns_logical_names() {
1657 let mut tree = ScopeTree::build("default", &[phase("p")]);
1658 // All-materialise predicate so every node gets a name.
1659 tree.mark_scope_elision(|_kind, _idx| true);
1660 // Session root contributes no path segment (SRD-88).
1661 assert_eq!(tree.nodes[0].logical_name, "");
1662 assert_eq!(tree.nodes[0].materialised, Some(true));
1663 // Workload child owns the "workload" segment.
1664 let workload_idx = tree.nodes[0].children[0];
1665 assert_eq!(tree.nodes[workload_idx].logical_name, "workload");
1666 // Scenario is named after its scenario tag.
1667 let scenario_idx = tree.nodes[workload_idx].children[0];
1668 assert_eq!(
1669 tree.nodes[scenario_idx].logical_name,
1670 "workload.scenario.default"
1671 );
1672 // Phase descends from scenario.
1673 let phase_idx = tree.nodes[scenario_idx].children[0];
1674 assert_eq!(
1675 tree.nodes[phase_idx].logical_name,
1676 "workload.scenario.default.phase.p"
1677 );
1678 }
1679
1680 #[test]
1681 fn mark_scope_elision_records_predicate_decisions() {
1682 let mut tree = ScopeTree::build("default", &[phase("p")]);
1683 // Predicate: only Phase scopes materialise.
1684 tree.mark_scope_elision(|kind, _idx| matches!(kind, ScopeKind::Phase { .. }));
1685 let workload_idx = tree.nodes[0].children[0];
1686 let scenario_idx = tree.nodes[workload_idx].children[0];
1687 let phase_idx = tree.nodes[scenario_idx].children[0];
1688 assert_eq!(tree.nodes[scenario_idx].materialised, Some(false));
1689 assert_eq!(tree.nodes[phase_idx].materialised, Some(true));
1690 }
1691
1692 #[test]
1693 fn nearest_materialised_walks_past_elided_layers() {
1694 let mut tree = ScopeTree::build("default", &[phase("p")]);
1695 // Predicate: only the workload tier materialises.
1696 tree.mark_scope_elision(|kind, _idx| matches!(kind, ScopeKind::Workload));
1697 let workload_idx = tree.nodes[0].children[0];
1698 let scenario_idx = tree.nodes[workload_idx].children[0];
1699 let phase_idx = tree.nodes[scenario_idx].children[0];
1700 // Phase's nearest materialised ancestor is the workload node.
1701 assert_eq!(tree.nearest_materialised(phase_idx), Some(workload_idx));
1702 assert_eq!(tree.nearest_materialised(scenario_idx), Some(workload_idx));
1703 // The session root always self-materialises.
1704 assert_eq!(tree.nearest_materialised(0), Some(0));
1705 }
1706
1707 #[test]
1708 fn nearest_materialised_returns_self_when_node_materialises() {
1709 let mut tree = ScopeTree::build("default", &[phase("p")]);
1710 tree.mark_scope_elision(|_kind, _idx| true);
1711 let phase_idx = tree.nodes[tree.nodes[0].children[0]].children[0];
1712 assert_eq!(tree.nearest_materialised(phase_idx), Some(phase_idx));
1713 }
1714
1715 #[test]
1716 fn nearest_materialised_none_before_pre_walk() {
1717 // Pre-walk hasn't run — every node's `materialised` is
1718 // None — so the walker can't terminate. Returns None.
1719 let tree = ScopeTree::build("default", &[phase("p")]);
1720 assert_eq!(tree.nearest_materialised(0), None);
1721 }
1722
1723 #[test]
1724 fn workload_root_always_materialises_regardless_of_predicate() {
1725 // Even an "always elide" predicate can't elide the
1726 // root — SRD-13d §5.1 mandates the root is the
1727 // termination point of nearest_materialised walks.
1728 let mut tree = ScopeTree::build("default", &[phase("p")]);
1729 tree.mark_scope_elision(|_kind, _idx| false);
1730 assert_eq!(tree.nodes[0].materialised, Some(true));
1731 }
1732
1733 // ── SRD-13d Phase 6: op-template tier ──
1734
1735 #[test]
1736 fn extend_with_op_templates_adds_one_child_per_op() {
1737 use nmbrs_workload::model::{BindingsDef, ParsedOp, WorkloadPhase};
1738 use std::collections::HashMap;
1739 let mut tree = ScopeTree::build("default", &[phase("p")]);
1740 let mut phases = HashMap::new();
1741 phases.insert(
1742 "p".into(),
1743 WorkloadPhase {
1744 key_metrics: Vec::new(),
1745 dimensions: Default::default(),
1746 cycles: None,
1747 concurrency: None,
1748 rate: None,
1749 daemon: false,
1750 adapter: None,
1751 errors: None,
1752 tries: None,
1753 tries_backoff: None,
1754 interval: None,
1755 repeat: None,
1756 error_rate_max: None,
1757 timeout: None,
1758 stop_when: Vec::new(),
1759 throttle: None,
1760 tags: None,
1761 ops: vec![
1762 ParsedOp::simple("alpha", "noop"),
1763 ParsedOp::simple("beta", "noop"),
1764 ],
1765 for_each: None,
1766 continue_if: None,
1767 loop_scope: None,
1768 iter_scope: None,
1769 checkpoint: None,
1770 status_metrics: vec![],
1771 metrics: Default::default(),
1772 poll: None,
1773 bindings: BindingsDef::default(),
1774 optimize: None,
1775 },
1776 );
1777 tree.extend_with_op_templates(&phases);
1778 let workload_idx = tree.nodes[0].children[0];
1779 let scenario_idx = tree.nodes[workload_idx].children[0];
1780 let phase_idx = tree.nodes[scenario_idx].children[0];
1781 // Phase now has 2 op-template children.
1782 assert_eq!(tree.nodes[phase_idx].children.len(), 2);
1783 let op_a_idx = tree.nodes[phase_idx].children[0];
1784 let op_b_idx = tree.nodes[phase_idx].children[1];
1785 assert!(matches!(&tree.nodes[op_a_idx].kind,
1786 ScopeKind::OpTemplate { name } if name == "alpha"));
1787 assert!(matches!(&tree.nodes[op_b_idx].kind,
1788 ScopeKind::OpTemplate { name } if name == "beta"));
1789 // Depth = phase depth + 1.
1790 assert_eq!(tree.nodes[op_a_idx].depth, tree.nodes[phase_idx].depth + 1);
1791 }
1792
1793 #[test]
1794 fn extend_with_op_templates_is_idempotent() {
1795 use nmbrs_workload::model::{BindingsDef, ParsedOp, WorkloadPhase};
1796 use std::collections::HashMap;
1797 let mut tree = ScopeTree::build("default", &[phase("p")]);
1798 let mut phases = HashMap::new();
1799 phases.insert(
1800 "p".into(),
1801 WorkloadPhase {
1802 key_metrics: Vec::new(),
1803 dimensions: Default::default(),
1804 cycles: None,
1805 concurrency: None,
1806 rate: None,
1807 daemon: false,
1808 adapter: None,
1809 errors: None,
1810 tries: None,
1811 tries_backoff: None,
1812 interval: None,
1813 repeat: None,
1814 error_rate_max: None,
1815 timeout: None,
1816 stop_when: Vec::new(),
1817 throttle: None,
1818 tags: None,
1819 ops: vec![ParsedOp::simple("only", "noop")],
1820 for_each: None,
1821 continue_if: None,
1822 loop_scope: None,
1823 iter_scope: None,
1824 checkpoint: None,
1825 status_metrics: vec![],
1826 metrics: Default::default(),
1827 poll: None,
1828 bindings: BindingsDef::default(),
1829 optimize: None,
1830 },
1831 );
1832 tree.extend_with_op_templates(&phases);
1833 let n_after_first = tree.nodes.len();
1834 tree.extend_with_op_templates(&phases); // Second call.
1835 assert_eq!(
1836 tree.nodes.len(),
1837 n_after_first,
1838 "second call should not add nodes"
1839 );
1840 }
1841
1842 #[test]
1843 fn op_template_logical_name_uses_op_segment() {
1844 use nmbrs_workload::model::{BindingsDef, ParsedOp, WorkloadPhase};
1845 use std::collections::HashMap;
1846 let mut tree = ScopeTree::build("default", &[phase("p")]);
1847 let mut phases = HashMap::new();
1848 phases.insert(
1849 "p".into(),
1850 WorkloadPhase {
1851 key_metrics: Vec::new(),
1852 dimensions: Default::default(),
1853 cycles: None,
1854 concurrency: None,
1855 rate: None,
1856 daemon: false,
1857 adapter: None,
1858 errors: None,
1859 tries: None,
1860 tries_backoff: None,
1861 interval: None,
1862 repeat: None,
1863 error_rate_max: None,
1864 timeout: None,
1865 stop_when: Vec::new(),
1866 throttle: None,
1867 tags: None,
1868 ops: vec![ParsedOp::simple("foo", "noop")],
1869 for_each: None,
1870 continue_if: None,
1871 loop_scope: None,
1872 iter_scope: None,
1873 checkpoint: None,
1874 status_metrics: vec![],
1875 metrics: Default::default(),
1876 poll: None,
1877 bindings: BindingsDef::default(),
1878 optimize: None,
1879 },
1880 );
1881 tree.extend_with_op_templates(&phases);
1882 tree.mark_scope_elision(|_kind, _idx| true);
1883 // Find the op node and check its logical name.
1884 let op_idx = tree
1885 .iter_dfs()
1886 .find(|(_, n)| matches!(&n.kind, ScopeKind::OpTemplate { name } if name == "foo"))
1887 .map(|(i, _)| i)
1888 .expect("op-template node");
1889 assert_eq!(
1890 tree.nodes[op_idx].logical_name,
1891 "workload.scenario.default.phase.p.op.foo"
1892 );
1893 }
1894
1895 #[test]
1896 fn install_is_idempotent_via_oncelock() {
1897 // OnceLock semantics: first install wins; subsequent
1898 // installs silently no-op. The boolean return lets
1899 // callers detect duplicate installs (likely a logic bug
1900 // in the runner) without panicking.
1901 let tree = ScopeTree::build("default", &[phase("p")]);
1902 let k1 = compile_kernel("const x := 1\n");
1903 let k2 = compile_kernel("const x := 2\n");
1904 assert!(tree.install_kernel(0, k1), "first install succeeds");
1905 assert!(!tree.install_kernel(0, k2), "second install no-ops");
1906
1907 let cached = tree.nodes[0].cached_kernel.get().unwrap();
1908 match cached.lookup("x") {
1909 Some(polydat::ast::Value::U64(n)) => assert_eq!(n, 1),
1910 other => panic!("expected U64(1), got {other:?}"),
1911 }
1912 }
1913
1914 /// **WORKAROUND-PINNING TEST — retire when AST becomes
1915 /// self-identifying** (task #19).
1916 ///
1917 /// Today's lookup key (raw Comprehension AST) is LOSSY:
1918 /// two scope-tree positions produce semantically-distinct
1919 /// installed kernels (different cascaded externs from
1920 /// different parent chains) but can share AST. The
1921 /// `_under(parent_idx, ...)` lookup adds the missing
1922 /// context — parent-subtree restriction — at the call
1923 /// site to disambiguate. This test pins that behavior in
1924 /// place.
1925 ///
1926 /// When the AST becomes self-identifying (so two
1927 /// distinct kernels never share matter), `find_comprehension_scope`
1928 /// is correct again, `find_comprehension_scope_under` can
1929 /// be retired, and this test should be deleted along with
1930 /// it.
1931 ///
1932 /// Workload shape modeled here:
1933 ///
1934 /// ```text
1935 /// for_each "a in [1]" { // outer A
1936 /// for_each "x in xs" { P } // x-comprehension #1, under A
1937 /// }
1938 /// for_each "b in [2]" { // outer B
1939 /// for_each "x in xs" { P } // x-comprehension #2, under B
1940 /// }
1941 /// ```
1942 ///
1943 /// Both `for_each "x in xs"` blocks have IDENTICAL AST.
1944 /// Under the lossy-AST model, `find_comprehension_scope_under(B_idx,
1945 /// x_comp)` MUST return #2's idx, not #1's, because the
1946 /// installed kernels at #1 and #2 differ in their cascade
1947 /// even though the AST does not. When AST becomes
1948 /// self-identifying the two `for_each "x in xs"` blocks
1949 /// will no longer share AST — they will carry distinct
1950 /// context — and the global lookup will work.
1951 #[test]
1952 fn find_comprehension_scope_under_disambiguates_identical_ast() {
1953 let tree = ScopeTree::build(
1954 "default",
1955 &[
1956 for_each("a in [1]", vec![for_each("x in xs", vec![phase("P")])]),
1957 for_each("b in [2]", vec![for_each("x in xs", vec![phase("P")])]),
1958 ],
1959 );
1960 // Tree layout (DFS):
1961 // 0 session
1962 // 1 workload
1963 // 2 scenario
1964 // 3 for_each(a)
1965 // 4 for_each(x) #1
1966 // 5 phase(P)
1967 // 6 for_each(b)
1968 // 7 for_each(x) #2
1969 // 8 phase(P)
1970 //
1971 // Build the x-comprehension AST that both inner scopes
1972 // share, then verify the path-aware lookup picks the
1973 // right one from each side.
1974 let x_comp = polydat::iteration::comprehension::spec::ComprehensionSpec {
1975 r#for: polydat::iteration::comprehension::spec::ForSpec::Inline("x in xs".to_string()),
1976 r#where: None,
1977 order: None,
1978 }
1979 .into_algebra()
1980 .unwrap();
1981
1982 // Sanity: the legacy global lookup picks #1 (first DFS
1983 // match) for both — this is the buggy behavior.
1984 assert_eq!(
1985 tree.find_comprehension_scope(&x_comp),
1986 Some(4),
1987 "legacy lookup returns FIRST match — documented bug"
1988 );
1989
1990 // The fix: searching under the A outer (idx 3) returns
1991 // #1 (idx 4); searching under the B outer (idx 6)
1992 // returns #2 (idx 7). The same x AST resolves to
1993 // different scope idx based on the parent context.
1994 assert_eq!(
1995 tree.find_comprehension_scope_under(3, &x_comp),
1996 Some(4),
1997 "under A outer, x-comprehension is the descendant at idx 4"
1998 );
1999 assert_eq!(
2000 tree.find_comprehension_scope_under(6, &x_comp),
2001 Some(7),
2002 "under B outer, x-comprehension is the descendant at idx 7"
2003 );
2004
2005 // Cross-search: looking for x under the OTHER side's
2006 // sub-tree should return None (the comprehension isn't
2007 // a descendant).
2008 assert_eq!(
2009 tree.find_comprehension_scope_under(4, &x_comp),
2010 None,
2011 "x-comp is not a descendant of itself"
2012 );
2013 }
2014
2015 /// **WORKAROUND-PINNING TEST — retire when AST becomes
2016 /// self-identifying** (task #19). See
2017 /// [`find_comprehension_scope_under_disambiguates_identical_ast`]
2018 /// for the architectural framing. Same shape applied to
2019 /// `Bindings` nodes whose source text matches at different
2020 /// scope-tree positions.
2021 #[test]
2022 fn find_bindings_scope_under_disambiguates_identical_source() {
2023 let bindings_source = "const k := 1\n".to_string();
2024 let bindings_node = || ScenarioNode::Bindings {
2025 source: bindings_source.clone(),
2026 children: vec![phase("P")],
2027 };
2028 let tree = ScopeTree::build(
2029 "default",
2030 &[
2031 for_each("a in [1]", vec![bindings_node()]),
2032 for_each("b in [2]", vec![bindings_node()]),
2033 ],
2034 );
2035 // Layout:
2036 // 0 session
2037 // 1 workload
2038 // 2 scenario
2039 // 3 for_each(a)
2040 // 4 bindings #1
2041 // 5 phase(P)
2042 // 6 for_each(b)
2043 // 7 bindings #2
2044 // 8 phase(P)
2045
2046 // Legacy: FIRST match (bug).
2047 assert_eq!(tree.find_bindings_scope(&bindings_source), Some(4));
2048
2049 // Fix: scoped lookup picks the right descendant.
2050 assert_eq!(tree.find_bindings_scope_under(3, &bindings_source), Some(4));
2051 assert_eq!(tree.find_bindings_scope_under(6, &bindings_source), Some(7));
2052 }
2053}
2054
2055/// One-line display label for a scenario-level `bindings:` scope.
2056///
2057/// Summarizes the names the scope DEFINES (`x := …`, `shared y := …`,
2058/// `extern z: T = …`, `input w: T`) instead of echoing raw source —
2059/// the first source line is often a comment, which read as an
2060/// unnatural, repeating emission in the scenario-tree readout.
2061/// Comment-only / empty sources degrade to a bare `bindings:`.
2062pub fn bindings_label(source: &str) -> String {
2063 let mut names: Vec<&str> = Vec::new();
2064 for line in source.lines() {
2065 let t = line.trim();
2066 if t.is_empty() || t.starts_with('#') {
2067 continue;
2068 }
2069 let t = t
2070 .strip_prefix("shared ")
2071 .or_else(|| t.strip_prefix("volatile "))
2072 .or_else(|| t.strip_prefix("const "))
2073 .or_else(|| t.strip_prefix("final "))
2074 .unwrap_or(t);
2075 let t = t
2076 .strip_prefix("extern ")
2077 .or_else(|| t.strip_prefix("input "))
2078 .unwrap_or(t);
2079 let ident_end = t
2080 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
2081 .unwrap_or(t.len());
2082 if ident_end == 0 {
2083 continue;
2084 }
2085 let rest = t[ident_end..].trim_start();
2086 if rest.starts_with(":=") || rest.starts_with(':') {
2087 let name = &t[..ident_end];
2088 if !names.contains(&name) {
2089 names.push(name);
2090 }
2091 }
2092 }
2093 match names.len() {
2094 0 => "bindings:".to_string(),
2095 1..=4 => format!("bindings: {}", names.join(", ")),
2096 n => format!("bindings: {} (+{} more)", names[..4].join(", "), n - 4),
2097 }
2098}