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}