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