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