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}