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 /// the legacy-synthesis bridge ([`super::build_kernel_under_parent`])
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 /// Used by `super::build_kernel_under_parent` to migrate
153 /// `build_do_loop_scope_kernel` and similar synthesisers
154 /// without semantic drift; explicit-import callers leave this
155 /// empty.
156 pub fn mark_inherited_outputs(&mut self, names: Vec<String>) -> &mut Self {
157 self.inherited_outputs = names;
158 self
159 }
160
161 /// Borrow the parent kernel — used by tests / advanced
162 /// callers that need to inspect parent state during build.
163 pub fn parent(&self) -> &Arc<ScopeKernel<P>> {
164 &self.parent
165 }
166
167 /// Declare an import.
168 pub fn import(&mut self, spec: ImportSpec) -> &mut Self {
169 self.imports.push(spec);
170 self
171 }
172
173 /// Declare an export.
174 pub fn export(&mut self, spec: ExportSpec) -> &mut Self {
175 self.exports.push(spec);
176 self
177 }
178
179 /// Append a body fragment. Multiple fragments are
180 /// concatenated in registration order at finalize.
181 pub fn body(&mut self, fragment: BodyFragment) -> &mut Self {
182 self.body.push(fragment);
183 self
184 }
185
186 /// Set the diagnostic context. Replaces any prior context.
187 pub fn context(&mut self, ctx: SourceContext) -> &mut Self {
188 self.context = ctx;
189 self
190 }
191
192 /// Register a [`PullConsumer`]. Per SRD-67 §"Decision 7"
193 /// this is the single init-time accumulator surface;
194 /// SRD-32's `ScopeFixture::register_consumer` migrates to
195 /// this entry point in Phase 2.
196 pub fn register_pull(&mut self, consumer: Arc<dyn PullConsumer>) -> &mut Self {
197 self.consumers.push(RegisteredPullConsumer::new(consumer));
198 self
199 }
200
201 /// SRD-67 Phase 5 — fold a SRD-66 `result:` source block
202 /// into this child's module matter. Single entry point for
203 /// result-bindings kernel-driven path; applies the closure-
204 /// binding economy (Rule 5) to magic externs and lets the
205 /// existing finalize Rule 2 rewrite fire when result-LHS
206 /// names collide with parent `shared` exports.
207 ///
208 /// `source` is Polydat source — the same `<name> := <expr>` form
209 /// `bindings:` accepts. Both string-shape (`ResultSpec::String`)
210 /// and map-shape (`ResultSpec::Map { name, source }` flattened
211 /// to `<name> := <source>`) end up here.
212 ///
213 /// What this method does:
214 ///
215 /// 1. Parses `source` into `Vec<Statement>`.
216 /// 2. Walks the body's free identifiers; for each magic
217 /// pre-bound name (`body`, `count`, `ok`) the source
218 /// references but doesn't already declare locally,
219 /// prepends an `extern <name>: <type>` declaration so
220 /// finalize compiles cleanly. Names not in the magic set
221 /// fall through to the standard import / cascade /
222 /// auto-extern path.
223 /// 3. Records each `<name> := <expr>` LHS as an export, so
224 /// Rule 2 fires when the parent has a matching `shared`
225 /// export. The body fragment is appended; finalize's
226 /// existing rewrite is the load-bearing path.
227 ///
228 /// Path expressions (map-shape entries with no `:=` in the
229 /// source) are NOT supported here — the caller flattens them
230 /// to `<name> := <source>` and the Polydat compiler rejects them
231 /// as unbound-identifier failures, surfacing the SRD-66
232 /// "deferred until structural body wire lands" diagnostic.
233 pub fn add_result_bindings(&mut self, source: &str) -> Result<&mut Self, ContractViolation> {
234 let trimmed = source.trim();
235 if trimmed.is_empty() {
236 return Ok(self);
237 }
238 let tokens = lex(source).map_err(ContractViolation::Compile)?;
239 let file = parse(tokens).map_err(ContractViolation::Compile)?;
240
241 // Collect locally-declared names (LHS of `:=` and
242 // `init <name> = ...` and `extern <name>` so the magic-
243 // extern injector skips them). These are the result-wire
244 // exports we'll declare to the parent for Rule 2.
245 let mut local_decls: std::collections::HashSet<String> = std::collections::HashSet::new();
246 let mut result_lhs: Vec<String> = Vec::new();
247 for stmt in &file.statements {
248 match stmt {
249 Statement::Binding(b) => {
250 for t in &b.targets {
251 local_decls.insert(t.clone());
252 if !result_lhs.contains(t) {
253 result_lhs.push(t.clone());
254 }
255 }
256 }
257 Statement::ExternPort(ep) => {
258 local_decls.insert(ep.name.clone());
259 }
260 Statement::InputDecl(d) => {
261 local_decls.insert(d.name.clone());
262 }
263 _ => {}
264 }
265 }
266
267 // Walk free identifiers across the body. Used for both
268 // (a) magic-extern injection (Rule 5 closure-binding
269 // economy — only what's referenced gets a slot) and
270 // (b) hard-error detection for the SRD-66 "user-written
271 // body :=" case.
272 let mut free_idents: std::collections::HashSet<String> = std::collections::HashSet::new();
273 for stmt in &file.statements {
274 collect_free_idents(stmt, &mut free_idents);
275 }
276
277 // SRD-66 §"Strict-mode interactions" / §"Schema":
278 // assigning to a pre-bound wire is a hard error. Catch
279 // it before the magic-extern injector — otherwise the
280 // injection would fight the LHS rename.
281 for forbidden in ["body", "count", "ok"] {
282 if result_lhs.iter().any(|n| n == forbidden) {
283 return Err(ContractViolation::Compile(format!(
284 "result-bindings: '{forbidden}' is a runtime-injected wire and \
285 cannot be reassigned in `result:`. SRD-66 Surface 1 §Schema."
286 )));
287 }
288 }
289
290 // Magic-extern injection: only for names the source
291 // actually references AND that aren't already declared
292 // locally (the body might re-declare via `extern body`
293 // explicitly — let that win).
294 // SRD-66 §"Surface 4 §Open: body type" resolved to
295 // `Value::Json` — body is a structural value the
296 // workload assertively unwraps via `exactly_one_value`.
297 // The Json shape preserves row × column structure so
298 // shape-mismatch diagnostics can name actual
299 // dimensions; for unary results, `exactly_one_value`
300 // collapses to a `Str` carrier which downstream
301 // string predicates (regex_match, etc.) consume.
302 let magic_externs: &[(&str, PortType, &str)] = &[
303 ("body", PortType::Json, "Json"),
304 ("count", PortType::U64, "u64"),
305 ("ok", PortType::Bool, "bool"),
306 ];
307 let span0 = Span { line: 0, col: 0 };
308 let mut prepended: Vec<Statement> = Vec::new();
309 // Magic-extern slot allocation. Release: only inject when
310 // the result-binding RHS actually references the name (the
311 // closure-binding economy's DCE). Diagnostic: force-allocate
312 // every magic extern not already locally declared, so writes
313 // for `body` / `count` / `ok` always have a kernel slot to
314 // land in regardless of whether anything reads them. The
315 // diagnostic mode is for step-debug / cycle-replay; the
316 // unused slots have no eval cone and add a fixed handful of
317 // bytes to per-op-template state.
318 let force_all = self.compile_options.kernel_opt.keep_unreferenced_slots();
319 for (name, _pt, type_kw) in magic_externs {
320 let referenced = free_idents.contains(*name);
321 let already_local = local_decls.contains(*name);
322 if (force_all || referenced) && !already_local {
323 prepended.push(Statement::ExternPort(ExternPort {
324 name: (*name).to_string(),
325 typ: (*type_kw).to_string(),
326 default: None,
327 span: span0,
328 }));
329 }
330 }
331
332 // Each result LHS may become a Rule 2 write-through when
333 // the parent has a same-named `shared` cell visible in
334 // scope. Without that match the binding stays a local
335 // output and no export needs to be registered — the
336 // result-LHS still becomes a kernel output through the
337 // regular cycle-binding compile path, so wrappers /
338 // metrics readers can still see it via wires.get.
339 //
340 // Conditioning registration on actual collision avoids
341 // the U64-default port-type leak that used to surface
342 // when a non-colliding LHS expression produced a non-u64
343 // value (e.g. an f64 metric expression): the export
344 // pre-allocated a u64 output port for the LHS and the
345 // compiler hit a type mismatch wiring the f64 RHS
346 // through it.
347 {
348 let in_scope_cells = self.parent.shared_cells_in_scope();
349 let parent_shared_by_name: std::collections::HashMap<&str, PortType> = in_scope_cells
350 .iter()
351 .map(|c| (c.name.as_str(), c.port_type))
352 .collect();
353 for name in &result_lhs {
354 if let Some(&pt) = parent_shared_by_name.get(name.as_str()) {
355 self.exports.push(ExportSpec::shared(name.clone(), pt));
356 }
357 }
358 }
359
360 // Compose the prepended externs with the user's
361 // statements and submit as a single Statements fragment.
362 // This sidesteps the source-string round-trip the
363 // PolydatSource fragment shape would force when prepended
364 // declarations need to lead the user's source.
365 let mut combined: Vec<Statement> = prepended;
366 combined.extend(file.statements);
367 self.body.push(BodyFragment::Statements(combined));
368
369 Ok(self)
370 }
371
372 /// Close the builder. Validates the import contract against
373 /// the parent's exports, compiles the body, and seals the
374 /// pull consumers into the artifact.
375 ///
376 /// SRD-67 Phase 2 — Rule 2 (write-through rewrite): when a
377 /// child export name collides with a parent `shared` export,
378 /// the body's `X := <expr>` is rewritten before compile to:
379 ///
380 /// 1. `extern X: <type>` — opens an input slot the parent's
381 /// `SharedCell` attaches to via `materialize_wiring_from_outer`.
382 /// 2. `__write_X := <expr>` — a synthetic local computation
383 /// that produces the value to write through.
384 ///
385 /// At spawn time, the spawned child carries a write-through
386 /// binding `(X, __write_X)`; per-cycle eval pulls
387 /// `__write_X` and stores its value through the child's
388 /// input slot for `X`, which propagates to the cell.
389 pub fn finalize(self) -> Result<ScopeModule<Child<P>>, ContractViolation> {
390 let SubcontextBuilder {
391 parent,
392 imports,
393 exports,
394 body,
395 consumers,
396 context,
397 inherited_outputs,
398 compile_options,
399 } = self;
400
401 let mut diagnostics: Vec<String> = Vec::new();
402
403 // ----- Rule 1 — import resolution against parent
404 // exports: a name-closure check (design doc §2.2 / SC4).
405 // `ImportSpec::port_type` and `classification` are carried
406 // into the contract but not compared against the parent
407 // here; the compiler's slot type checks and
408 // `check_write_through_type` protect the actual child
409 // inputs and cell writes. -----
410 let parent_inner = parent.lock_inner();
411 let parent_outputs: std::collections::HashSet<String> = parent_inner
412 .program()
413 .output_names()
414 .iter()
415 .map(|s| (*s).to_string())
416 .collect();
417 let parent_inputs: std::collections::HashSet<String> =
418 parent_inner.program().input_names().into_iter().collect();
419
420 for imp in &imports {
421 if !parent_outputs.contains(&imp.name) && !parent_inputs.contains(&imp.name) {
422 return Err(ContractViolation::UnboundImport {
423 import: imp.name.clone(),
424 site: context.clone(),
425 });
426 }
427 }
428
429 // ----- Rule 2 — export collision detection. -----
430 // For each declared export, check the parent for a same-
431 // named modifier:
432 //
433 // * `final` parent → `FinalShadow` error (immutable,
434 // can't be redefined).
435 // * `shared` cell visible at parent → record the export
436 // as a write-through candidate. The kernel-synthesis
437 // rewrite below renames the child's binding LHS to
438 // `__write_<name>` and inserts an `extern <name>`
439 // declaration; spawn's typed cell-attach pass then
440 // wires the input slot to the parent's `SharedCell`.
441 //
442 // "Visible at parent" walks the typed
443 // `shared_cells_in_scope()` enumeration so an ancestral
444 // `shared X` cell propagates transitively even when an
445 // intermediate scope's body never names X. Without
446 // this, Rule 2 silently no-ops for grand-children and
447 // their write-throughs go nowhere.
448 //
449 // * No parent export and no in-scope cell → child-only
450 // export, registered locally (no rewrite).
451 drop(parent_inner);
452 let in_scope_cells = parent.shared_cells_in_scope();
453 let in_scope_cells_by_name: std::collections::HashMap<
454 &str,
455 &super::kernel::SharedCellInScope,
456 > = in_scope_cells
457 .iter()
458 .map(|c| (c.name.as_str(), c))
459 .collect();
460 let parent_inner = parent.lock_inner();
461 let mut write_through_specs: Vec<(String, PortType)> = Vec::new();
462 for exp in &exports {
463 let parent_modifier = parent_inner.program().output_modifier(&exp.name);
464 if parent_modifier.is_const() && parent_outputs.contains(&exp.name) {
465 return Err(ContractViolation::FinalShadow {
466 export: exp.name.clone(),
467 site: context.clone(),
468 });
469 }
470 if let Some(in_scope) = in_scope_cells_by_name.get(exp.name.as_str()) {
471 // Port type comes from the typed in-scope record
472 // (sourced from the cell-bound input slot at the
473 // owning ancestor). Authoritative; falls back to
474 // the export spec's declared port type only if
475 // the lookup somehow misses — never observed.
476 write_through_specs.push((exp.name.clone(), in_scope.port_type));
477 }
478 }
479 drop(parent_inner);
480
481 // ----- Lower every body fragment into a single
482 // Vec<Statement>. The Rule 2 rewrite operates on the AST
483 // directly so it doesn't need a source-string round-trip;
484 // PolydatSource fragments parse here once. -----
485 if body.is_empty() {
486 return Err(ContractViolation::Compile(
487 "scope module body is empty — at least one fragment is required".into(),
488 ));
489 }
490 let mut statements: Vec<Statement> = Vec::new();
491 for fragment in &body {
492 match fragment {
493 BodyFragment::PolydatSource(src) => {
494 let tokens = lex(src).map_err(ContractViolation::Compile)?;
495 let file = parse(tokens).map_err(ContractViolation::Compile)?;
496 statements.extend(file.statements);
497 }
498 BodyFragment::Statements(stmts) => statements.extend(stmts.iter().cloned()),
499 }
500 }
501
502 // ----- Apply Rule 2 rewrite over the statement vector. -----
503 let mut write_throughs: Vec<WriteThroughBinding> = Vec::new();
504 if !write_through_specs.is_empty() {
505 let already_extern: std::collections::HashSet<String> = statements
506 .iter()
507 .filter_map(|s| match s {
508 Statement::ExternPort(p) => Some(p.name.clone()),
509 _ => None,
510 })
511 .collect();
512 let span0 = Span { line: 0, col: 0 };
513
514 // Inject `extern <name>: <type>` declarations for
515 // every write-through that the child body doesn't
516 // already extern. Prepend them so the input slot is
517 // present before the compiler sees the renamed
518 // binding.
519 let mut prepended: Vec<Statement> = Vec::new();
520 for (name, pt) in &write_through_specs {
521 if already_extern.contains(name) {
522 continue;
523 }
524 prepended.push(Statement::ExternPort(ExternPort {
525 name: name.clone(),
526 typ: port_type_keyword(*pt).to_string(),
527 default: None,
528 span: span0,
529 }));
530 }
531 // Rename single-target CycleBindings whose LHS
532 // matches a write-through export. Multi-target
533 // bindings (tuple unpacks) aren't valid for shared
534 // write-through (a tuple has no single value to
535 // store in the cell); leave them alone — they'll
536 // surface as a duplicate-port compile error if the
537 // collision is real. The single-target shape is the
538 // SRD-66 motivating case.
539 for stmt in statements.iter_mut() {
540 if let Statement::Binding(b) = stmt
541 && b.targets.len() == 1
542 {
543 let target = &b.targets[0];
544 if write_through_specs.iter().any(|(n, _)| n == target) {
545 let original = target.clone();
546 let renamed = format!("{WRITE_THROUGH_PREFIX}{original}");
547 b.targets[0] = renamed.clone();
548 write_throughs.push(WriteThroughBinding {
549 export_name: original,
550 source_output: renamed,
551 });
552 }
553 }
554 }
555 // Splice the synthetic externs in front. Order:
556 // [externs] ++ [original statements (with renamed
557 // LHS)].
558 prepended.extend(statements);
559 statements = prepended;
560 }
561
562 // ----- Compile the rewritten AST. -----
563 //
564 // When `compile_options` carries non-default knobs (lib
565 // paths, strict mode, required-output filter, source dir,
566 // context label) we route through `compile_polydat_with_libs`
567 // so the same code path the for_each / op-template
568 // synthesisers have always used handles them.
569 // `compile_polydat_with_libs` takes a source string; when the
570 // caller supplies a single `PolydatSource` fragment that's the
571 // raw input. If the body was AST-only (or fragments are
572 // mixed) the source is re-emitted by concatenating
573 // PolydatSource fragments — the existing synthesisers all
574 // produce a single `PolydatSource(String)` body so this path
575 // is the byte-identical replacement.
576 //
577 // If a Rule 2 write-through rewrite needs to fire AND
578 // compile-options are set, the caller's source string
579 // would no longer reflect the rewritten AST. None of the
580 // current Phase 3 migration sites combine the two
581 // (for_each / op-template / phase scopes don't collide
582 // with `shared` parent exports). Reject the combination
583 // explicitly so a future caller hits a clear diagnostic
584 // rather than silently dropping the rewrite.
585 let dsl_options = DslOptions {
586 source_dir: compile_options.workload_dir.clone(),
587 lib_paths: compile_options.polydat_lib_paths.clone(),
588 required_outputs: compile_options.required_outputs.clone(),
589 strict: compile_options.strict,
590 context: compile_options
591 .context_label
592 .clone()
593 .unwrap_or_else(|| context.label.clone()),
594 cursor_limit: compile_options.cursor_limit,
595 };
596 let mut kernel = if compile_options.is_default() {
597 compile_ast_with_options(
598 &PolydatFile {
599 statements: statements.clone(),
600 },
601 "",
602 &DslOptions::default(),
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}