Skip to main content

polydat_core/kernel/subcontext/
builder.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! [`SubcontextBuilder<P>`] — accumulator for module matter.
5//!
6//! Per SRD-67 §"Step 2 — Builder accumulates module matter": the
7//! builder holds a [`ParentView`] of the scope it builds under,
8//! records imports / exports / body fragments / pull consumers, and
9//! at `finalize` validates the import contract against the parent's
10//! exports + compiles the body via the existing `compile_polydat` /
11//! `compile_ast` pipeline. The result is a closed
12//! [`ScopeModule<Child<P>>`] artifact.
13
14use std::marker::PhantomData;
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use crate::ast::PortType;
19use crate::dsl::ast::{Arg, CallExpr, Expr, ExternPort, PolydatFile, Statement};
20use crate::dsl::compile::{CompileOptions as DslOptions, compile_ast_interpreter_with_options};
21use crate::dsl::lexer::{Span, lex};
22use crate::dsl::parser::parse;
23use crate::kernel::PolydatKernel;
24
25use super::error::{ContractViolation, SourceContext};
26use super::kernel::{Child, SharedCellInScope};
27use super::module::{BodyFragment, ScopeContract, ScopeModule, WriteThroughBinding};
28use super::pull::{PullConsumer, RegisteredPullConsumer};
29use super::spec::{ExportSpec, ImportSpec};
30
31/// Prefix applied to the synthetic write-through output produced
32/// by the Rule 2 rewrite. The child program emits this output as
33/// a normal local computation; spawn pulls it per cycle and
34/// fans the value through the parent's `SharedCell`.
35const WRITE_THROUGH_PREFIX: &str = "__write_";
36
37fn port_type_keyword(pt: PortType) -> &'static str {
38    match pt {
39        PortType::U64 | PortType::U32 => "u64",
40        PortType::I64 | PortType::I32 => "i64",
41        PortType::F64 | PortType::F32 => "f64",
42        PortType::Bool => "bool",
43        // Everything that isn't a numeric / bool maps to the
44        // string keyword — matches the existing synthesiser
45        // convention used by `build_do_loop_scope_kernel`.
46        _ => "String",
47    }
48}
49
50/// Optional compile-time configuration passed through to
51/// [`compile_ast_interpreter_with_options`](crate::dsl::compile::compile_ast_interpreter_with_options) when finalize compiles the body. When
52/// every field is at its default, finalize falls back to the
53/// minimal [`compile_ast_interpreter_with_options`] path used by the do-loop bridge — no
54/// behaviour change for the simplest synthesisers.
55///
56/// SRD-67 Phase 3 bridge hook: the for_each / op-template
57/// synthesisers used to call `compile_polydat_with_libs` directly with
58/// `polydat_lib_paths`, `workload_dir`, `strict`, and a context label.
59/// Routing those concerns through the builder preserves byte-
60/// identical compile output during migration.
61#[derive(Clone, Debug, Default)]
62pub struct CompileOptions {
63    /// The directory relative data-file paths resolve against.
64    pub workload_dir: Option<PathBuf>,
65    /// Library search paths.
66    pub polydat_lib_paths: Vec<PathBuf>,
67    /// Whether to enforce strict validation.
68    pub strict: bool,
69    /// The outputs to keep; every output when empty.
70    pub required_outputs: Vec<String>,
71    /// The diagnostic context label, if any.
72    pub context_label: Option<String>,
73    /// A limit on every cursor's extent, if any.
74    pub cursor_limit: Option<u64>,
75    /// Session-wide optimization level for op-template synthesis.
76    /// `Release` (the default) lets the closure-binding economy
77    /// DCE unreferenced slots; `Diagnostic` force-allocates every
78    /// magic-extern and result-binding-LHS slot so step-debug /
79    /// cycle-replay sees writes that the runtime would otherwise
80    /// drop on the floor. See [`KernelOptLevel`](crate::kernel::KernelOptLevel).
81    pub kernel_opt: crate::kernel::KernelOptLevel,
82    /// What the compiler does with an input whose type it inferred
83    /// (input_variance.md §4); the same setting as
84    /// [`crate::dsl::compile::CompileOptions::input_variance`].
85    pub input_variance: crate::dsl::compile::InputVariance,
86}
87
88impl CompileOptions {
89    fn is_default(&self) -> bool {
90        self.workload_dir.is_none()
91            && self.polydat_lib_paths.is_empty()
92            && !self.strict
93            && self.required_outputs.is_empty()
94            && self.context_label.is_none()
95            && self.cursor_limit.is_none()
96            && self.kernel_opt == crate::kernel::KernelOptLevel::default()
97            && self.input_variance == crate::dsl::compile::InputVariance::default()
98    }
99}
100
101/// Everything a builder reads of the scope it builds under: the names
102/// the parent declares, the modifier on each output, the ledger the
103/// child's compile is charged to, and the cells in scope there — each
104/// a live handle rather than a copy.
105///
106/// That is the whole parent surface `finalize` touches. It was an
107/// `Arc<ScopeKernel<P>>` first, so a caller holding a plain kernel had
108/// to clone one into existence to ask; then an `Arc<PolydatProgram>`,
109/// which a compiled kernel does not keep. It is the answers now, so a
110/// parent of any engine can give them.
111#[derive(Clone)]
112pub struct ParentView {
113    output_names: Vec<String>,
114    input_names: Vec<String>,
115    output_modifiers: std::collections::HashMap<String, crate::dsl::ast::BindingModifier>,
116    ledger: Arc<crate::kernel::CompileLedger>,
117    shared_cells: Vec<SharedCellInScope>,
118}
119
120impl ParentView {
121    /// The view a kernel of any engine presents to a subcontext built
122    /// under it.
123    pub fn of_kernel(parent: &dyn crate::kernel::Kernel) -> Self {
124        let output_names: Vec<String> = parent.output_names();
125        let output_modifiers = output_names
126            .iter()
127            .map(|n| (n.clone(), parent.output_modifier(n)))
128            .collect();
129        Self {
130            output_names,
131            input_names: parent.input_names(),
132            output_modifiers,
133            ledger: crate::kernel::Kernel::ledger(parent).clone(),
134            shared_cells: Self::cells_of(parent.cells_in_scope()),
135        }
136    }
137
138    /// [`Self::of_kernel`] for the interpreter's kernel type, which
139    /// most callers hold.
140    pub fn of(parent: &PolydatKernel) -> Self {
141        Self::of_kernel(parent)
142    }
143
144    /// The names the parent declares as outputs.
145    pub fn output_names(&self) -> &[String] {
146        &self.output_names
147    }
148
149    /// The names the parent declares as inputs, coordinates included.
150    pub fn input_names(&self) -> &[String] {
151        &self.input_names
152    }
153
154    /// The modifier on a named output; `NONE` for a name the parent
155    /// does not declare.
156    pub fn output_modifier(&self, name: &str) -> crate::dsl::ast::BindingModifier {
157        self.output_modifiers
158            .get(name)
159            .copied()
160            .unwrap_or(crate::dsl::ast::BindingModifier::NONE)
161    }
162
163    /// The ledger the child's compile is charged to: a subscope is a
164    /// program of the parent's tree.
165    pub fn ledger(&self) -> &Arc<crate::kernel::CompileLedger> {
166        &self.ledger
167    }
168
169    fn cells_of(entries: Vec<crate::kernel::SharedCellEntry>) -> Vec<SharedCellInScope> {
170        entries
171            .into_iter()
172            .map(|e| SharedCellInScope {
173                name: e.name,
174                port_type: e.port_type,
175                cell: e.cell,
176            })
177            .collect()
178    }
179
180    /// The shared cells visible at the parent, its own and every
181    /// ancestor's that reached it.
182    pub fn shared_cells(&self) -> &[SharedCellInScope] {
183        &self.shared_cells
184    }
185}
186
187/// Module-matter accumulator. Construction is gated by a parent —
188/// [`ScopeKernel::subcontext_builder`](super::ScopeKernel::subcontext_builder)
189/// on the typed path, `PolydatKernel::build_subscope` on the untyped
190/// one — and both hand it the same [`ParentView`].
191pub struct SubcontextBuilder<P> {
192    parent: ParentView,
193    imports: Vec<ImportSpec>,
194    exports: Vec<ExportSpec>,
195    body: Vec<BodyFragment>,
196    consumers: Vec<RegisteredPullConsumer>,
197    context: SourceContext,
198    /// Names to apply via `mark_inherited_outputs` on the
199    /// compiled kernel before its program Arc is shared. Set by
200    /// `PolydatKernel::build_subscope` from `PolydatMatter`'s `inherited_outputs`
201    /// to preserve the pre-SRD-67 ordering of cascade-extern
202    /// names; explicit synthesisers that don't need cascade
203    /// pass-through leave this empty.
204    inherited_outputs: Vec<String>,
205    /// Compile-time options forwarded into the AST compile. Empty
206    /// for callers that don't need libs / strict / required-output
207    /// filtering.
208    compile_options: CompileOptions,
209    /// The externs this builder synthesized (result and write-through
210    /// externs), whose types it chose rather than the author: open to
211    /// `input_variance` (input_variance.md §3).
212    synthesized_externs: Vec<String>,
213    _parent_marker: PhantomData<fn() -> P>,
214}
215
216impl SubcontextBuilder<super::kernel::RootMarker> {
217    /// A builder for a child of `parent`, a kernel of any engine: the
218    /// source form of binding a scope (native_scope_trees.md §5). The
219    /// module it finalizes compiles once per engine
220    /// ([`ScopeModule::program_on`](super::ScopeModule::program_on)) and instantiates under a parent of
221    /// any engine ([`ScopeModule::instantiate_under`](super::ScopeModule::instantiate_under)).
222    pub fn under(parent: &dyn crate::kernel::Kernel) -> Self {
223        Self::new(ParentView::of_kernel(parent))
224    }
225}
226
227impl<P> SubcontextBuilder<P> {
228    pub(crate) fn new(parent: ParentView) -> Self {
229        Self {
230            parent,
231            _parent_marker: PhantomData,
232            imports: Vec::new(),
233            exports: Vec::new(),
234            body: Vec::new(),
235            consumers: Vec::new(),
236            context: SourceContext::default(),
237            inherited_outputs: Vec::new(),
238            compile_options: CompileOptions::default(),
239            synthesized_externs: Vec::new(),
240        }
241    }
242
243    /// SRD-67 Phase 3 bridge hook: route the legacy
244    /// [`compile_ast_interpreter_with_options`](crate::dsl::compile::compile_ast_interpreter_with_options) knobs (lib paths, strict mode,
245    /// required-output filter, workload dir, context label)
246    /// through the builder. Synthesisers that previously called
247    /// `compile_polydat_with_libs` directly fold those calls into a
248    /// single `with_compile_options(...)` invocation; the do-loop
249    /// bridge leaves this at its default and finalize uses
250    /// [`compile_ast_interpreter_with_options`].
251    pub fn with_compile_options(&mut self, options: CompileOptions) -> &mut Self {
252        self.compile_options = options;
253        self
254    }
255
256    /// SRD-67 Phase 2 bridge hook: declare names whose outputs
257    /// the body emits purely to cascade values from an outer
258    /// scope to descendants (so they don't double up the parent's
259    /// iter-coord, etc.). The compiled kernel will have these
260    /// names flagged via `mark_inherited_outputs` before its
261    /// program Arc is shared.
262    ///
263    /// Set from `PolydatMatter`'s `inherited_outputs` by
264    /// `PolydatKernel::build_subscope`; explicit-import callers leave this
265    /// empty.
266    pub fn mark_inherited_outputs(&mut self, names: Vec<String>) -> &mut Self {
267        self.inherited_outputs = names;
268        self
269    }
270
271    /// The parent surface this builder validates against — used by
272    /// tests and by callers that need to inspect it during build.
273    pub fn parent(&self) -> &ParentView {
274        &self.parent
275    }
276
277    /// Declare an import.
278    pub fn import(&mut self, spec: ImportSpec) -> &mut Self {
279        self.imports.push(spec);
280        self
281    }
282
283    /// Declare an export.
284    pub fn export(&mut self, spec: ExportSpec) -> &mut Self {
285        self.exports.push(spec);
286        self
287    }
288
289    /// Append a body fragment. Multiple fragments are
290    /// concatenated in registration order at finalize.
291    pub fn body(&mut self, fragment: BodyFragment) -> &mut Self {
292        self.body.push(fragment);
293        self
294    }
295
296    /// Set the diagnostic context. Replaces any prior context.
297    pub fn context(&mut self, ctx: SourceContext) -> &mut Self {
298        self.context = ctx;
299        self
300    }
301
302    /// Register a [`PullConsumer`]. Per SRD-67 §"Decision 7"
303    /// this is the single init-time accumulator surface;
304    /// the surface a host's fixture adapter registers through.
305    pub fn register_pull(&mut self, consumer: Arc<dyn PullConsumer>) -> &mut Self {
306        self.consumers.push(RegisteredPullConsumer::new(consumer));
307        self
308    }
309
310    /// SRD-67 Phase 5 — fold a SRD-66 `result:` source block
311    /// into this child's module matter. Single entry point for
312    /// result-bindings kernel-driven path; applies the closure-
313    /// binding economy (Rule 5) to magic externs and lets the
314    /// existing finalize Rule 2 rewrite fire when result-LHS
315    /// names collide with parent `shared` exports.
316    ///
317    /// `source` is Polydat source — the same `<name> := <expr>` form
318    /// `bindings:` accepts. Both string-shape (`ResultSpec::String`)
319    /// and map-shape (`ResultSpec::Map { name, source }` flattened
320    /// to `<name> := <source>`) end up here.
321    ///
322    /// What this method does:
323    ///
324    /// 1. Parses `source` into `Vec<Statement>`.
325    /// 2. Walks the body's free identifiers; for each magic
326    ///    pre-bound name (`body`, `count`, `ok`) the source
327    ///    references but doesn't already declare locally,
328    ///    prepends an `extern <name>: <type>` declaration so
329    ///    finalize compiles cleanly. Names not in the magic set
330    ///    fall through to the standard import / cascade /
331    ///    auto-extern path.
332    /// 3. Records each `<name> := <expr>` LHS as an export, so
333    ///    Rule 2 fires when the parent has a matching `shared`
334    ///    export. The body fragment is appended; finalize's
335    ///    existing rewrite is the load-bearing path.
336    ///
337    /// Path expressions (map-shape entries with no `:=` in the
338    /// source) are NOT supported here — the caller flattens them
339    /// to `<name> := <source>` and the Polydat compiler rejects them
340    /// as unbound-identifier failures, surfacing the SRD-66
341    /// "deferred until structural body wire lands" diagnostic.
342    pub fn add_result_bindings(&mut self, source: &str) -> Result<&mut Self, ContractViolation> {
343        let trimmed = source.trim();
344        if trimmed.is_empty() {
345            return Ok(self);
346        }
347        let tokens = lex(source).map_err(|e| ContractViolation::Compile(e.to_string()))?;
348        let file = parse(tokens).map_err(|e| ContractViolation::Compile(e.to_string()))?;
349
350        // Collect locally-declared names (LHS of `:=` and
351        // `init <name> = ...` and `extern <name>` so the magic-
352        // extern injector skips them). These are the result-wire
353        // exports we'll declare to the parent for Rule 2.
354        let mut local_decls: std::collections::HashSet<String> = std::collections::HashSet::new();
355        let mut result_lhs: Vec<String> = Vec::new();
356        for stmt in &file.statements {
357            match stmt {
358                Statement::Binding(b) => {
359                    for t in &b.targets {
360                        local_decls.insert(t.clone());
361                        if !result_lhs.contains(t) {
362                            result_lhs.push(t.clone());
363                        }
364                    }
365                }
366                Statement::ExternPort(ep) => {
367                    local_decls.insert(ep.name.clone());
368                }
369                Statement::InputDecl(d) => {
370                    local_decls.insert(d.name.clone());
371                }
372                _ => {}
373            }
374        }
375
376        // Walk free identifiers across the body. Used for both
377        // (a) magic-extern injection (Rule 5 closure-binding
378        // economy — only what's referenced gets a slot) and
379        // (b) hard-error detection for the SRD-66 "user-written
380        // body :=" case.
381        let mut free_idents: std::collections::HashSet<String> = std::collections::HashSet::new();
382        for stmt in &file.statements {
383            collect_free_idents(stmt, &mut free_idents);
384        }
385
386        // SRD-66 §"Strict-mode interactions" / §"Schema":
387        // assigning to a pre-bound wire is a hard error. Catch
388        // it before the magic-extern injector — otherwise the
389        // injection would fight the LHS rename.
390        for forbidden in ["body", "count", "ok"] {
391            if result_lhs.iter().any(|n| n == forbidden) {
392                return Err(ContractViolation::Compile(format!(
393                    "result-bindings: '{forbidden}' is a runtime-injected wire and \
394                     cannot be reassigned in `result:`. SRD-66 Surface 1 §Schema."
395                )));
396            }
397        }
398
399        // Magic-extern injection: only for names the source
400        // actually references AND that aren't already declared
401        // locally (the body might re-declare via `extern body`
402        // explicitly — let that win).
403        // SRD-66 §"Surface 4 §Open: body type" resolved to
404        // `Value::Json` — body is a structural value the
405        // workload assertively unwraps via `exactly_one_value`.
406        // The Json shape preserves row × column structure so
407        // shape-mismatch diagnostics can name actual
408        // dimensions; for unary results, `exactly_one_value`
409        // collapses to a `Str` carrier which downstream
410        // string predicates (regex_match, etc.) consume.
411        let magic_externs: &[(&str, PortType, &str)] = &[
412            ("body", PortType::Json, "Json"),
413            ("count", PortType::U64, "u64"),
414            ("ok", PortType::Bool, "bool"),
415        ];
416        let span0 = Span { line: 0, col: 0 };
417        let mut prepended: Vec<Statement> = Vec::new();
418        // Magic-extern slot allocation. Release: only inject when
419        // the result-binding RHS actually references the name (the
420        // closure-binding economy's DCE). Diagnostic: force-allocate
421        // every magic extern not already locally declared, so writes
422        // for `body` / `count` / `ok` always have a kernel slot to
423        // land in regardless of whether anything reads them. The
424        // diagnostic mode is for step-debug / cycle-replay; the
425        // unused slots have no eval cone and add a fixed handful of
426        // bytes to per-op-template state.
427        let force_all = self.compile_options.kernel_opt.keep_unreferenced_slots();
428        for (name, _pt, type_kw) in magic_externs {
429            let referenced = free_idents.contains(*name);
430            let already_local = local_decls.contains(*name);
431            if (force_all || referenced) && !already_local {
432                self.synthesized_externs.push((*name).to_string());
433                prepended.push(Statement::ExternPort(ExternPort {
434                    name: (*name).to_string(),
435                    typ: (*type_kw).to_string(),
436                    default: None,
437                    span: span0,
438                }));
439            }
440        }
441
442        // Each result LHS may become a Rule 2 write-through when
443        // the parent has a same-named `shared` cell visible in
444        // scope. Without that match the binding stays a local
445        // output and no export needs to be registered — the
446        // result-LHS still becomes a kernel output through the
447        // regular cycle-binding compile path, so wrappers /
448        // metrics readers can still see it via wires.get.
449        //
450        // Conditioning registration on actual collision avoids
451        // the U64-default port-type leak that used to surface
452        // when a non-colliding LHS expression produced a non-u64
453        // value (e.g. an f64 metric expression): the export
454        // pre-allocated a u64 output port for the LHS and the
455        // compiler hit a type mismatch wiring the f64 RHS
456        // through it.
457        {
458            let in_scope_cells = self.parent.shared_cells();
459            let parent_shared_by_name: std::collections::HashMap<&str, PortType> = in_scope_cells
460                .iter()
461                .map(|c| (c.name.as_str(), c.port_type))
462                .collect();
463            for name in &result_lhs {
464                if let Some(&pt) = parent_shared_by_name.get(name.as_str()) {
465                    self.exports.push(ExportSpec::shared(name.clone(), pt));
466                }
467            }
468        }
469
470        // Compose the prepended externs with the user's
471        // statements and submit as a single Statements fragment.
472        // This sidesteps the source-string round-trip the
473        // PolydatSource fragment shape would force when prepended
474        // declarations need to lead the user's source.
475        let mut combined: Vec<Statement> = prepended;
476        combined.extend(file.statements);
477        self.body.push(BodyFragment::Statements(combined));
478
479        Ok(self)
480    }
481
482    /// Close the builder. Validates the import contract against
483    /// the parent's exports, compiles the body, and seals the
484    /// pull consumers into the artifact.
485    ///
486    /// SRD-67 Phase 2 — Rule 2 (write-through rewrite): when a
487    /// child export name collides with a parent `shared` export,
488    /// the body's `X := <expr>` is rewritten before compile to:
489    ///
490    /// 1. `extern X: <type>` — opens an input slot the parent's
491    ///    `SharedCell` attaches to via `materialize_wiring_from_outer`.
492    /// 2. `__write_X := <expr>` — a synthetic local computation
493    ///    that produces the value to write through.
494    ///
495    /// At spawn time, the spawned child carries a write-through
496    /// binding `(X, __write_X)`; per-cycle eval pulls
497    /// `__write_X` and stores its value through the child's
498    /// input slot for `X`, which propagates to the cell.
499    pub fn finalize(self) -> Result<ScopeModule<Child<P>>, ContractViolation> {
500        let SubcontextBuilder {
501            parent,
502            imports,
503            exports,
504            body,
505            consumers,
506            context,
507            inherited_outputs,
508            compile_options,
509            synthesized_externs,
510            _parent_marker,
511        } = self;
512        let mut synthesized = synthesized_externs;
513
514        let mut diagnostics: Vec<String> = Vec::new();
515
516        // ----- Rule 1 — import resolution against parent
517        // exports: a name-closure check (design doc §2.2 / SC4).
518        // `ImportSpec::port_type` and `classification` are carried
519        // into the contract but not compared against the parent
520        // here; the compiler's slot type checks and
521        // `check_write_through_type` protect the actual child
522        // inputs and cell writes. -----
523        let parent_outputs: std::collections::HashSet<&String> =
524            parent.output_names().iter().collect();
525        let parent_inputs: std::collections::HashSet<&String> =
526            parent.input_names().iter().collect();
527
528        for imp in &imports {
529            if !parent_outputs.contains(&imp.name) && !parent_inputs.contains(&imp.name) {
530                return Err(ContractViolation::UnboundImport {
531                    import: imp.name.clone(),
532                    site: context.clone(),
533                });
534            }
535        }
536
537        // ----- Rule 2 — export collision detection. -----
538        // For each declared export, check the parent for a same-
539        // named modifier:
540        //
541        // * `final` parent → `FinalShadow` error (immutable,
542        //   can't be redefined).
543        // * `shared` cell visible at parent → record the export
544        //   as a write-through candidate. The kernel-synthesis
545        //   rewrite below renames the child's binding LHS to
546        //   `__write_<name>` and inserts an `extern <name>`
547        //   declaration; spawn's typed cell-attach pass then
548        //   wires the input slot to the parent's `SharedCell`.
549        //
550        //   "Visible at parent" walks the typed
551        //   `shared_cells_in_scope()` enumeration so an ancestral
552        //   `shared X` cell propagates transitively even when an
553        //   intermediate scope's body never names X. Without
554        //   this, Rule 2 silently no-ops for grand-children and
555        //   their write-throughs go nowhere.
556        //
557        // * No parent export and no in-scope cell → child-only
558        //   export, registered locally (no rewrite).
559        let in_scope_cells = parent.shared_cells();
560        let in_scope_cells_by_name: std::collections::HashMap<&str, &SharedCellInScope> =
561            in_scope_cells
562                .iter()
563                .map(|c| (c.name.as_str(), c))
564                .collect();
565        // A subscope is a program of the parent's tree: its compile is
566        // charged to the parent's ledger.
567        let ledger = parent.ledger().clone();
568        let mut write_through_specs: Vec<(String, PortType)> = Vec::new();
569        for exp in &exports {
570            let parent_modifier = parent.output_modifier(&exp.name);
571            if parent_modifier.is_const() && parent_outputs.contains(&exp.name) {
572                return Err(ContractViolation::FinalShadow {
573                    export: exp.name.clone(),
574                    site: context.clone(),
575                });
576            }
577            if let Some(in_scope) = in_scope_cells_by_name.get(exp.name.as_str()) {
578                // Port type comes from the typed in-scope record
579                // (sourced from the cell-bound input slot at the
580                // owning ancestor). Authoritative; falls back to
581                // the export spec's declared port type only if
582                // the lookup somehow misses — never observed.
583                write_through_specs.push((exp.name.clone(), in_scope.port_type));
584            }
585        }
586
587        // ----- Lower every body fragment into a single
588        // Vec<Statement>. The Rule 2 rewrite operates on the AST
589        // directly so it doesn't need a source-string round-trip;
590        // PolydatSource fragments parse here once. -----
591        if body.is_empty() {
592            return Err(ContractViolation::Compile(
593                "scope module body is empty — at least one fragment is required".into(),
594            ));
595        }
596        let mut statements: Vec<Statement> = Vec::new();
597        for fragment in &body {
598            match fragment {
599                BodyFragment::PolydatSource(src) => {
600                    let tokens = lex(src).map_err(|e| ContractViolation::Compile(e.to_string()))?;
601                    let file =
602                        parse(tokens).map_err(|e| ContractViolation::Compile(e.to_string()))?;
603                    statements.extend(file.statements);
604                }
605                BodyFragment::Statements(stmts) => statements.extend(stmts.iter().cloned()),
606            }
607        }
608
609        // ----- Apply Rule 2 rewrite over the statement vector. -----
610        let mut write_throughs: Vec<WriteThroughBinding> = Vec::new();
611        if !write_through_specs.is_empty() {
612            let already_extern: std::collections::HashSet<String> = statements
613                .iter()
614                .filter_map(|s| match s {
615                    Statement::ExternPort(p) => Some(p.name.clone()),
616                    _ => None,
617                })
618                .collect();
619            let span0 = Span { line: 0, col: 0 };
620
621            // Inject `extern <name>: <type>` declarations for
622            // every write-through that the child body doesn't
623            // already extern. Prepend them so the input slot is
624            // present before the compiler sees the renamed
625            // binding.
626            let mut prepended: Vec<Statement> = Vec::new();
627            for (name, pt) in &write_through_specs {
628                if already_extern.contains(name) {
629                    continue;
630                }
631                synthesized.push(name.clone());
632                prepended.push(Statement::ExternPort(ExternPort {
633                    name: name.clone(),
634                    typ: port_type_keyword(*pt).to_string(),
635                    default: None,
636                    span: span0,
637                }));
638            }
639            // Rename single-target CycleBindings whose LHS
640            // matches a write-through export. Multi-target
641            // bindings (tuple unpacks) aren't valid for shared
642            // write-through (a tuple has no single value to
643            // store in the cell); leave them alone — they'll
644            // surface as a duplicate-port compile error if the
645            // collision is real. The single-target shape is the
646            // SRD-66 motivating case.
647            for stmt in statements.iter_mut() {
648                if let Statement::Binding(b) = stmt
649                    && b.targets.len() == 1
650                {
651                    let target = &b.targets[0];
652                    if write_through_specs.iter().any(|(n, _)| n == target) {
653                        let original = target.clone();
654                        let renamed = format!("{WRITE_THROUGH_PREFIX}{original}");
655                        b.targets[0] = renamed.clone();
656                        write_throughs.push(WriteThroughBinding {
657                            export_name: original,
658                            source_output: renamed,
659                        });
660                    }
661                }
662            }
663            // Splice the synthetic externs in front. Order:
664            // [externs] ++ [original statements (with renamed
665            // LHS)].
666            prepended.extend(statements);
667            statements = prepended;
668        }
669
670        // ----- Compile the rewritten AST. -----
671        //
672        // When `compile_options` carries non-default knobs (lib
673        // paths, strict mode, required-output filter, source dir,
674        // context label) we route through `compile_polydat_with_libs`
675        // so the same code path the for_each / op-template
676        // synthesisers have always used handles them.
677        // `compile_polydat_with_libs` takes a source string; when the
678        // caller supplies a single `PolydatSource` fragment that's the
679        // raw input. If the body was AST-only (or fragments are
680        // mixed) the source is re-emitted by concatenating
681        // PolydatSource fragments — the existing synthesisers all
682        // produce a single `PolydatSource(String)` body so this path
683        // is the byte-identical replacement.
684        //
685        // A rewritten AST (a Rule 2 write-through fired) or a
686        // `Statements` body compiles through `compile_ast_interpreter_with_options`
687        // with the full options, so the two combine freely.
688        let dsl_options = DslOptions {
689            source_dir: compile_options.workload_dir.clone(),
690            lib_paths: compile_options.polydat_lib_paths.clone(),
691            required_outputs: compile_options.required_outputs.clone(),
692            strict: compile_options.strict,
693            context: compile_options
694                .context_label
695                .clone()
696                .unwrap_or_else(|| context.label.clone()),
697            cursor_limit: compile_options.cursor_limit,
698            input_variance: compile_options.input_variance,
699            inferred_externs: synthesized.clone(),
700            ledger: Some(ledger.clone()),
701            engine: crate::Engine::default(),
702        };
703        let mut kernel = if compile_options.is_default() {
704            compile_ast_interpreter_with_options(
705                &PolydatFile {
706                    statements: statements.clone(),
707                },
708                "",
709                &DslOptions {
710                    ledger: Some(ledger),
711                    ..DslOptions::default()
712                },
713                None,
714            )
715            .map_err(|e| ContractViolation::Compile(e.to_string()))?
716        } else if !write_throughs.is_empty()
717            || body
718                .iter()
719                .any(|f| matches!(f, BodyFragment::Statements(_)))
720        {
721            // SRD-67 Phase 5 — when the AST has been rewritten in
722            // place (Rule 2 write-through) OR the body was
723            // submitted as `Statements` (no source-string
724            // round-trip), feed the rewritten AST through the
725            // libs-aware compile path directly. Avoids the prior
726            // restriction that combined Rule 2 with non-default
727            // compile options.
728            compile_ast_interpreter_with_options(
729                &PolydatFile {
730                    statements: statements.clone(),
731                },
732                "",
733                &dsl_options,
734                None,
735            )
736            .map_err(|e| ContractViolation::Compile(e.to_string()))?
737        } else {
738            // No rewrite, no Statements fragments — reconstruct
739            // the source string and use the source-aware
740            // `compile_polydat_with_libs` so the legacy synthesiser
741            // pathway preserves byte-identical output (the
742            // compiler stashes `source_text` for diagnostics).
743            let mut src = String::new();
744            for fragment in &body {
745                match fragment {
746                    BodyFragment::PolydatSource(s) => {
747                        src.push_str(s);
748                        if !s.ends_with('\n') {
749                            src.push('\n');
750                        }
751                    }
752                    BodyFragment::Statements(_) => unreachable!(
753                        "Statements fragments routed through compile_ast_with_libs above"
754                    ),
755                }
756            }
757            crate::dsl::compile::compile_polydat_interpreter_with_options(&src, &dsl_options, None)
758                .map_err(|e| ContractViolation::Compile(e.to_string()))?
759        };
760
761        // ----- Apply legacy-bridge inherited-output marking.
762        // Must happen before the program Arc is cloned out into
763        // the artifact (mark_inherited_outputs requires unique
764        // ownership of the program Arc).
765        if !inherited_outputs.is_empty() {
766            kernel.mark_inherited_outputs(inherited_outputs);
767        }
768
769        // ----- Bake Rule 2 write-throughs into the program. -----
770        // The program is the single source of truth for these
771        // bindings: any kernel built from this program (including
772        // per-fiber re-instances via `bind_program_under_parent`)
773        // will inherit them via `from_program`'s automatic seeding,
774        // eliminating the side-channel that used to thread
775        // write-throughs through the activity-layer scope tree.
776        let kernel_write_throughs: Vec<crate::kernel::KernelWriteThrough> = write_throughs
777            .iter()
778            .map(|wt| crate::kernel::KernelWriteThrough {
779                export_name: wt.export_name.clone(),
780                source_output: wt.source_output.clone(),
781            })
782            .collect();
783        if !kernel_write_throughs.is_empty() {
784            kernel.bake_write_throughs(kernel_write_throughs);
785        }
786
787        // ----- Validate that every declared import shows up as
788        // an input slot or a previously-folded constant on the
789        // compiled program (Rule 5 — closure-binding economy
790        // diagnostic; an unused import is a finalize-time
791        // warning rather than an error). -----
792        for imp in &imports {
793            if kernel.program().find_input(&imp.name).is_none()
794                && kernel.program().output_map_lookup(&imp.name).is_none()
795            {
796                diagnostics.push(format!(
797                    "import `{}` declared but unused in body — Rule 5 closure-binding economy will drop it at spawn",
798                    imp.name
799                ));
800            }
801        }
802
803        // ----- Validate Rule 2 invariants: the rewrite must
804        // have produced (1) a child input slot for the export
805        // name (so `materialize_wiring_from_outer` attaches the cell), and
806        // (2) the synthetic `__write_<name>` output. -----
807        for wt in &write_throughs {
808            if kernel.program().find_input(&wt.export_name).is_none() {
809                return Err(ContractViolation::Compile(format!(
810                    "Rule 2 write-through rewrite for `{}` produced no input slot — \
811                     check that the body's binding compiled to an input/output pair",
812                    wt.export_name
813                )));
814            }
815            if kernel
816                .program()
817                .output_map_lookup(&wt.source_output)
818                .is_none()
819            {
820                return Err(ContractViolation::Compile(format!(
821                    "Rule 2 write-through rewrite produced no `{}` output — \
822                     the rewritten binding did not surface as a kernel output",
823                    wt.source_output
824                )));
825            }
826        }
827
828        let program = kernel.program().clone();
829        let contract = ScopeContract::from_specs(&imports, &exports);
830
831        // The interpreter's program is already built, so it seeds the
832        // per-engine table rather than being compiled a second time
833        // when someone asks for it.
834        let seeded: std::sync::Arc<dyn crate::kernel::KernelProgram> = program.clone();
835        Ok(ScopeModule {
836            imports,
837            exports,
838            program,
839            statements,
840            options: dsl_options.clone(),
841            programs: std::sync::Mutex::new(std::collections::HashMap::from([(
842                crate::Engine::Interpreter(crate::JitMode::Auto),
843                seeded,
844            )])),
845            contract,
846            context,
847            consumers,
848            write_throughs,
849            diagnostics,
850            _module: PhantomData,
851        })
852    }
853}
854
855/// Collect free identifiers referenced by a statement's RHS. Used
856/// by [`SubcontextBuilder::add_result_bindings`] to apply the
857/// closure-binding economy (Rule 5) — only inject magic externs
858/// (`body` / `count` / `ok`) the source actually references.
859fn collect_free_idents(stmt: &Statement, out: &mut std::collections::HashSet<String>) {
860    match stmt {
861        Statement::Binding(b) => collect_expr_idents(&b.value, out),
862        Statement::Cursor(c) => collect_expr_idents(&c.constructor, out),
863        Statement::ModuleDef(_)
864        | Statement::ExternPort(_)
865        | Statement::InputDecl(_)
866        | Statement::Pragma { .. }
867        | Statement::For(_)
868        | Statement::Tile(_) => {}
869    }
870}
871
872fn collect_expr_idents(expr: &Expr, out: &mut std::collections::HashSet<String>) {
873    match expr {
874        Expr::Ident(name, _) => {
875            out.insert(name.clone());
876        }
877        Expr::IntLit(_, _) | Expr::FloatLit(_, _) => {}
878        Expr::StringLit(_, _) => {
879            // String interpolation `{name}` references aren't
880            // expanded at the AST level — they're resolved by
881            // the compiler during desugaring. Conservatively skip
882            // them for the magic-extern injector (the user's
883            // body / count / ok can't appear inside an
884            // interpolation in any current SRD-66 use case);
885            // unresolved interpolations surface as standard
886            // unbound-identifier diagnostics downstream.
887        }
888        Expr::ArrayLit(items, _) => {
889            for e in items {
890                collect_expr_idents(e, out);
891            }
892        }
893        Expr::Call(call) => collect_call_idents(call, out),
894        Expr::BinOp(a, _, b) => {
895            collect_expr_idents(a, out);
896            collect_expr_idents(b, out);
897        }
898        Expr::For(_) => {}
899        Expr::UnaryNeg(e, _) | Expr::UnaryBitNot(e, _) | Expr::Cast(e, _, _) => {
900            collect_expr_idents(e, out)
901        }
902        Expr::FieldAccess { source, .. } => {
903            // Source-field projections reference a source name,
904            // not a wire — but the magic-extern set is a closed
905            // {body, count, ok}, so the only way `body.x` could
906            // appear is the user wrote a structural body access.
907            // Record `source` as a referenced ident so the
908            // magic-extern check sees it; the Polydat compiler will
909            // produce the canonical "field access on non-source"
910            // diagnostic if it doesn't resolve.
911            out.insert(source.clone());
912        }
913    }
914}
915
916fn collect_call_idents(call: &CallExpr, out: &mut std::collections::HashSet<String>) {
917    for arg in &call.args {
918        match arg {
919            Arg::Positional(e) => collect_expr_idents(e, out),
920            Arg::Named(_, e) => collect_expr_idents(e, out),
921        }
922    }
923}