Skip to main content

nmbrs_runtime/scope_synth/
cascade.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared cascade walker — the single implementation of "given
5//! a parent kernel, decide what cascade-extern and inline-const
6//! lines to emit for a child scope's Polydat source."
7//!
8//! Pre-walker, each of the four sister scope builders
9//! (`build_phase_scope_kernel`, `build_do_loop_scope_kernel`,
10//! `build_op_template_scope_kernel`, and the forthcoming
11//! `build_for_each_scope_kernel`) reinvented this walk with
12//! subtly different precision. This module consolidates the
13//! walk so:
14//!
15//! 1. The cascade rules live in one place. Bug fixes apply
16//!    everywhere.
17//! 2. The most-rigorous version (phase scope's SRD-13f
18//!    provenance-aware inlining) becomes the canonical
19//!    behavior for every scope kind.
20//! 3. Scope-specific bits stay in the per-scope builder —
21//!    each builder contributes only what's unique to it
22//!    (body source, iter-var declarations, counter, etc.).
23//!
24//! ## What this walker is NOT for
25//!
26//! `build_op_template_scope_kernel` is intentionally **not**
27//! refactored onto this walker. The op-template uses a
28//! deliberately narrow "lazy cascade" policy — emit externs
29//! only for names the op references, never a broad parent-
30//! output / parent-input sweep. That keeps the op-template
31//! kernel narrow at the cost of more bookkeeping in the
32//! caller. Forcing it onto a broad-cascade walker would
33//! either bloat the walker with op-template-specific knobs or
34//! widen the op-template kernel; neither is desirable. The
35//! op-template stays self-contained.
36//!
37//! ## What the walker does (in order)
38//!
39//! 1. **Coord-set detection.** Walks `parent.program().coord_count()`
40//!    and `input_names()` to discover which names are parent-
41//!    coord-slots — those propagate via the kernel chain, not via
42//!    extern cascade. They're added to a skip set.
43//! 2. **Local-inclusion-chain inline.** For each `referenced`
44//!    name that resolves to a non-final cycle binding in the
45//!    parent's AST (i.e., would have to be re-computed at this
46//!    scope), pretty-prints the parent's inclusion chain and
47//!    emits it as local matter. Names included this way are
48//!    marked as `emitted`. (SRD-13f §"Wire-reference
49//!    classification" case 3.)
50//! 3. **Referenced-name cascade.** For each remaining
51//!    `referenced` name, looks it up in `parent_manifest` for
52//!    its typed port; emits `extern NAME: TYPE` and marks
53//!    inherited.
54//! 4. **Workload-params cascade.** For each entry in
55//!    `workload_params` not yet emitted and not pre-emitted,
56//!    emits `extern NAME: TYPE` (type detected from value
57//!    shape). Marks inherited.
58//! 5. **Parent-output cascade with provenance-aware inlining.**
59//!    For each `parent.program().output_names()` not yet
60//!    emitted: if the upstream's value is statically known
61//!    (provenance == 0), inlines as `const NAME := <literal>`;
62//!    otherwise cascades as `extern NAME: TYPE`. (SRD-13f
63//!    §"Materialization gradient".)
64//! 6. **Parent-input cascade.** For each
65//!    `parent.program().input_names()` not yet emitted, emits
66//!    `extern NAME: TYPE`. Closes the chain so cascade-extern'd
67//!    inputs propagate transitively.
68//!
69//! ## What the walker does NOT do
70//!
71//! - Emit the scope's own body / iter-var / counter
72//!   declarations — those come from the per-scope builder and
73//!   are passed in as `pre_emitted` so the walker doesn't
74//!   re-emit them.
75//! - Call `finalize` — that's the per-scope builder's
76//!   responsibility (it threads scope-specific `CompileOptions`
77//!   and context labels).
78
79use std::collections::{HashMap, HashSet};
80
81use crate::scope_kernel::ScopeKernel;
82use polydat::kernel::ManifestEntry;
83
84use super::helpers::{
85    format_value_as_final_literal, port_type_to_extern_name, workload_param_type_name,
86};
87
88/// Per-scope context the walker needs from its caller.
89///
90/// Each scope-kind builder constructs this with its own pre-
91/// emitted set, referenced-placeholder set, and shadow-name
92/// set, then passes it through to the walker.
93pub struct CascadeInputs<'a> {
94    /// Parent kernel — the chain root for all walks.
95    pub parent_kernel: &'a ScopeKernel,
96    /// Workload params (CLI / params: block defaults).
97    pub workload_params: &'a HashMap<String, String>,
98    /// Typed manifest of the parent's outputs — used to look up
99    /// port types for referenced-name cascade. The runner
100    /// already extracts this for the per-scope synthesisers;
101    /// passing it in saves a re-walk.
102    pub parent_manifest: &'a [ManifestEntry],
103    /// Names referenced in the scope's body / spec exprs (the
104    /// `{name}` placeholders + identifier scan of Polydat body
105    /// source). Drives steps 2 + 3 of the walker.
106    pub referenced: &'a HashSet<String>,
107    /// Names already declared by the per-scope builder before
108    /// the walker runs (iter vars, counter, body-locally-
109    /// declared idents). The walker won't re-emit these.
110    pub pre_emitted: &'a HashSet<String>,
111    /// Additional names the per-scope builder wants to shadow
112    /// from the parent-output cascade — body-locally-declared
113    /// idents that aren't externs. Without this, the cascade
114    /// could re-emit a name the body locally assigns, causing
115    /// a duplicate-binding compile error.
116    pub shadow_names: &'a HashSet<String>,
117    /// Whether to run the referenced-name cascade pass (step
118    /// 3 in the walker's order). do-loop and for-each opt in:
119    /// their bodies are tightly scoped (a condition expression
120    /// or a comprehension spec) where every referenced name is
121    /// expected to flow in as an extern. phase opts out: its
122    /// body is arbitrary Polydat source that may already declare
123    /// the same names as `input` / `extern`, and a step-3
124    /// emission would collide.
125    pub include_referenced_cascade: bool,
126}
127
128/// Per-scope outputs the walker mutates.
129pub struct CascadeOutputs<'a> {
130    /// Polydat source string the walker appends to.
131    pub source: &'a mut String,
132    /// Names the walker has emitted, including any pre-emitted
133    /// names that flowed in via [`CascadeInputs::pre_emitted`].
134    /// Grows across the walk's six steps.
135    pub emitted: &'a mut HashSet<String>,
136    /// Names whose outputs are inherited from a parent (cascade-
137    /// extern'd, not this scope's own iter-coord). The per-scope
138    /// builder threads this into `PolydatMatter::inherited_outputs`
139    /// so `compute_own_coordinates` excludes them when reporting
140    /// this scope's own iteration position.
141    pub inherited_names: &'a mut Vec<String>,
142}
143
144/// Run the shared cascade walk. See module docs for the six
145/// steps and their order.
146pub fn cascade_parent_into_source(inputs: CascadeInputs<'_>, outputs: CascadeOutputs<'_>) {
147    let CascadeInputs {
148        parent_kernel,
149        workload_params,
150        parent_manifest,
151        referenced,
152        pre_emitted,
153        shadow_names,
154        include_referenced_cascade,
155    } = inputs;
156
157    // Seed `emitted` with the caller's pre-emitted set so the
158    // walker's skip predicates respect them.
159    outputs.emitted.extend(pre_emitted.iter().cloned());
160
161    let parent_program = parent_kernel.program();
162
163    // Step 1 — coord-set detection.
164    let coord_names: HashSet<String> = {
165        let coord_count = parent_program.coord_count();
166        parent_program
167            .input_names()
168            .into_iter()
169            .take(coord_count)
170            .collect()
171    };
172
173    // Step 2 — local-inclusion-chain inline.
174    let already_satisfied_for_inclusion: HashSet<String> = pre_emitted
175        .iter()
176        .chain(coord_names.iter())
177        .cloned()
178        .collect();
179    {
180        let mut already_satisfied = already_satisfied_for_inclusion.clone();
181        let mut refs_sorted: Vec<&String> = referenced.iter().collect();
182        refs_sorted.sort();
183        for name in refs_sorted {
184            if already_satisfied.contains(name.as_str()) {
185                continue;
186            }
187            // FINAL/SHARED go through the parent-output cascade
188            // (step 5 handles their emission); only cycle
189            // bindings reach the local-inclusion-chain path.
190            let modifier = parent_program.output_modifier(name);
191            if modifier == polydat::dsl::ast::BindingModifier::CONST
192                || modifier == polydat::dsl::ast::BindingModifier::SHARED
193            {
194                continue;
195            }
196            let chain = parent_program.local_inclusion_chain(name, &already_satisfied);
197            if chain.is_empty() {
198                continue;
199            }
200            for stmt in chain {
201                let line = polydat::dsl::pprint::pp_statement(stmt);
202                outputs.source.push_str(&line);
203                outputs.source.push('\n');
204                if let polydat::dsl::ast::Statement::Binding(b) = stmt {
205                    for t in &b.targets {
206                        outputs.emitted.insert(t.clone());
207                        already_satisfied.insert(t.clone());
208                    }
209                }
210            }
211        }
212    }
213
214    // Step 3 — referenced-name cascade against parent_manifest.
215    // Opt-in per scope kind (see `include_referenced_cascade`
216    // doc). Always skips coord names; the kernel chain
217    // propagates those, not extern cascade.
218    if include_referenced_cascade {
219        let manifest_by_name: HashMap<&str, &ManifestEntry> = parent_manifest
220            .iter()
221            .map(|e| (e.name.as_str(), e))
222            .collect();
223        let mut refs_sorted: Vec<&String> = referenced.iter().collect();
224        refs_sorted.sort();
225        for name in refs_sorted {
226            if outputs.emitted.contains(name) {
227                continue;
228            }
229            if pre_emitted.contains(name) {
230                continue;
231            }
232            if coord_names.contains(name) {
233                continue;
234            }
235            if let Some(entry) = manifest_by_name.get(name.as_str()) {
236                let type_name = port_type_to_extern_name(entry.port_type);
237                outputs
238                    .source
239                    .push_str(&format!("extern {name}: {type_name}\n"));
240                outputs.emitted.insert(name.clone());
241                outputs.inherited_names.push(name.clone());
242            } else if let Some(value) = workload_params.get(name) {
243                super::cascade_emit::emit_workload_param_chain_aware(
244                    name,
245                    value,
246                    parent_kernel,
247                    outputs.source,
248                    outputs.emitted,
249                    None,
250                );
251            }
252        }
253    } else {
254        // Suppress unused warning when this scope kind opts out.
255        let _ = parent_manifest;
256    }
257
258    // Step 4 — workload-params cascade.
259    for (name, value) in workload_params {
260        if outputs.emitted.contains(name) {
261            continue;
262        }
263        if shadow_names.contains(name) {
264            continue;
265        }
266        let type_name = workload_param_type_name(value);
267        outputs
268            .source
269            .push_str(&format!("extern {name}: {type_name}\n"));
270        outputs.emitted.insert(name.clone());
271        outputs.inherited_names.push(name.clone());
272    }
273
274    // Skip predicate for the parent-output / parent-input passes
275    // — covers everything `emitted` so far + coord names +
276    // internal underscore-prefixed names (with the cursor-extent
277    // auxiliary exception).
278    let skip_cascade = |emitted: &HashSet<String>, name: &str| -> bool {
279        if emitted.contains(name) {
280            return true;
281        }
282        if coord_names.contains(name) {
283            return true;
284        }
285        if name.starts_with("__") && !name.starts_with("__cursor_extent_") {
286            return true;
287        }
288        false
289    };
290
291    // Step 5 — parent-output cascade with provenance-aware
292    // inlining.
293    for name in parent_program.output_names() {
294        let owned = name.to_string();
295        if skip_cascade(outputs.emitted, &owned) {
296            continue;
297        }
298        if shadow_names.contains(&owned) {
299            continue;
300        }
301        // SRD-13f §"Materialization gradient" — only truly-
302        // input-independent values (provenance == 0) are safe
303        // to inline as literals into the cached downstream
304        // program. Everything else cascades as `extern`; per-
305        // activation materialization delivers the correct value.
306        //
307        // A `shared` output is NEVER statically known, whatever its
308        // initializer's provenance: the cell is mutable at runtime
309        // (captures write it). Const-inlining one freezes the
310        // initializer AND makes the child the name's local final,
311        // which `materialize_wiring_from_outer` honors by DROPPING
312        // the ancestral cell — descendants then read the frozen
313        // initializer forever and a writer's update is never
314        // visible to them.
315        let Some(output_idx) = parent_program.output_index(&owned) else {
316            continue;
317        };
318        let (node_idx, port_idx) = parent_program.resolve_output_by_index(output_idx);
319        let is_shared =
320            parent_program.output_modifier(&owned) == polydat::dsl::ast::BindingModifier::SHARED;
321        let upstream_is_statically_known = !is_shared
322            && parent_program
323                .input_provenance_for(node_idx)
324                .is_none_or(|p| p.is_zero());
325        if upstream_is_statically_known
326            && let Some(value) = parent_kernel.lookup(&owned)
327            && let Some(literal) = format_value_as_final_literal(&value)
328        {
329            outputs
330                .source
331                .push_str(&format!("const {owned} := {literal}\n"));
332            outputs.emitted.insert(owned);
333            continue;
334        }
335        let port_type = parent_program.node_meta(node_idx).outs[port_idx].typ;
336        let type_name = port_type_to_extern_name(port_type);
337        outputs
338            .source
339            .push_str(&format!("extern {owned}: {type_name}\n"));
340        outputs.emitted.insert(owned.clone());
341        outputs.inherited_names.push(owned);
342    }
343
344    // Step 6 — parent-input cascade.
345    for name in parent_program.input_names() {
346        if skip_cascade(outputs.emitted, &name) {
347            continue;
348        }
349        if shadow_names.contains(&name) {
350            continue;
351        }
352        // input_names() returns names that exist as inputs;
353        // input_port_type on the same name therefore can't be
354        // None unless the kernel's program shape is broken.
355        let port_type = parent_program
356            .input_port_type(&name)
357            .expect("input_names() returned a name with no port type");
358        let type_name = port_type.to_keyword();
359        outputs
360            .source
361            .push_str(&format!("extern {name}: {type_name}\n"));
362        outputs.emitted.insert(name.clone());
363        outputs.inherited_names.push(name);
364    }
365}