Skip to main content

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}