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