polydat_core/kernel/state.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! PolydatKernel: a compiled Polydat Kernel pairing an `Arc<PolydatProgram>` with a PolydatState.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use super::engines::{PolydatState, SharedCellEntry};
10use super::program::PolydatProgram;
11use super::{InputDef, WireSource};
12use crate::ast::{PolydatNode, Value};
13
14/// Auto-create `SharedCell`s for `shared`-modifier outputs that
15/// have a backing input slot on this kernel. Call once at
16/// construction so subsequent `materialize_wiring_from_outer` from inner
17/// kernels can pick the cells up via `outer.shared_cell(idx)`
18/// without mutating outer.
19///
20/// A `shared` output without a backing input slot is skipped,
21/// since without a slot there's nothing to share. The DSL compiles
22/// every `shared` binding to an input slot, its register
23/// (scope_model.md §6).
24fn seed_shared_cells(state: &mut PolydatState, program: &PolydatProgram) {
25 for name in program.shared_outputs() {
26 let Some(idx) = program.find_input(name) else {
27 continue;
28 };
29 if state.shared_cell(idx).is_some() {
30 continue;
31 } // already seeded
32 let init_value = state.get_input(idx);
33 // `make_shared_cell` allocates the next bit position
34 // from this scope's intent-dirty vector and constructs
35 // the cell with the right validity-tracking handles
36 // (cross_fiber_invalidation.md §3.1). The cell carries
37 // its own intent_dirty Arc + bit so any fiber writing
38 // through it publishes dirty intent to this scope's
39 // vector — descendant kernels that later attach via
40 // `materialize_wiring_from_outer` inherit the same
41 // handles automatically.
42 let cell = state.core.make_shared_cell(init_value);
43 state.attach_shared_cell(idx, cell);
44 }
45}
46
47/// Boundary-adapter helper: when the outer-scope binding's
48/// runtime value type doesn't match the inner kernel's
49/// declared slot type, consult the catalog
50/// (`compile::assembly::boundary_adapter`) and apply the adapter
51/// if one exists. Returns the (possibly adapted) value to set
52/// in the slot.
53///
54/// When no catalog entry exists for the (from, to) type pair,
55/// returns the value unchanged with a warning via the audit log,
56/// and the caller's write refuses the mismatched value as a
57/// `WriteError::FromParent` (input_variance.md §7).
58///
59/// Spec: `expression_engine.md` §5.4 (boundary adapter
60/// polyfills); `composition_substrate.md` T2 (typed-mismatch
61/// healing extended to synthesis sites).
62pub(crate) fn adapt_boundary_value(
63 slot_name: &str,
64 slot_type: crate::ast::PortType,
65 value: Value,
66) -> Value {
67 let value_type = value.port_type();
68 if value_type == slot_type {
69 return value;
70 }
71 // `Value::None` is the "absent" sentinel — pass through
72 // without trying to adapt; downstream None-propagation
73 // (none_semantics.md Rule 1) handles it.
74 if matches!(value, Value::None) {
75 return value;
76 }
77 match crate::compile::assembly::boundary_adapter(value_type, slot_type) {
78 Some(adapter) => {
79 // Adapter::eval reads inputs[0..N], writes outputs[0..M].
80 // For the boundary case, every adapter is 1→1.
81 let inputs = vec![value];
82 let mut outputs = vec![Value::None];
83 adapter.eval(&inputs, &mut outputs);
84 outputs.remove(0)
85 }
86 None => {
87 // Actionable warning surface — `Ext` as the slot
88 // type is by far the most common landing point
89 // here (it's the fallback when the auto-extern
90 // inferrer couldn't resolve the binding's RHS
91 // output type from the assembler or the surface
92 // AST). The advice differs based on the slot's
93 // declared type because the fix differs too:
94 //
95 // - Slot is `Ext`: the workload likely meant a
96 // primitive type. The inferrer surfaced its
97 // gap; the right fix is a registry update or
98 // an explicit `extern NAME: <type>` declaration
99 // so the slot's type matches the producer's.
100 // - Slot is concrete: there's a real type
101 // mismatch the catalog can't bridge. The
102 // author wrote `extern NAME: <wrong_type>` or
103 // the consumer's declared port type doesn't
104 // match the actual cross-scope contract.
105 let hint = if slot_type == crate::ast::PortType::Ext {
106 " - The slot's type is `Ext` (extension type), so the value the outer \
107 scope supplies has no adapter into it. Options:\n\
108 \x20 * Add an explicit `extern {slot_name}: <type>` declaration in the \
109 receiving scope so the slot's type is pinned at the source.\n\
110 \x20 * If the binding is set from YAML sugar (e.g. `set: {{ {slot_name}: \"{{ outer }}\" }}`), \
111 the desugared `const {slot_name} := \"{{ outer }}\"` evaluates to a Str — \
112 use the bare form `set: {{ {slot_name}: outer }}` to pass the original \
113 type through, or quote-encode if the consumer expects a string.\n\
114 \x20 * The slot takes its type from the compiled output of the binding \
115 that feeds it, so a node registered without an output `PortType` lands \
116 here — declare one on the node."
117 } else {
118 " - The slot's declared type and the cross-scope provider's type don't match. \
119 Options:\n\
120 \x20 * Change the `extern {slot_name}: <type>` declaration to match the \
121 producer's actual type.\n\
122 \x20 * Convert at the consumer: wrap the read with the matching `as_*` / \
123 `*_from_*` adapter for the slot type."
124 };
125 let hint = hint.replace("{slot_name}", slot_name);
126 crate::library::support::audit::warn(&format!(
127 "boundary adapter: no catalog entry for {value_type:?} → {slot_type:?} \
128 at slot '{slot_name}'; passing value as-is (will likely produce a wire \
129 error or coerce silently at first read)\n\
130 {hint}"
131 ));
132 value
133 }
134 }
135}
136
137/// Write a value the binder copied from the parent into the child's
138/// input `index` (input_variance.md §7). A declared input takes the
139/// value as it is or refuses it; an input whose type the compiler
140/// inferred takes a value the boundary adapter catalog converts into
141/// its type. A refused copy is an error naming the input, both types,
142/// and the parent as the value's source.
143fn copy_from_parent(
144 child: &mut dyn crate::kernel::Kernel,
145 index: usize,
146 name: &str,
147 slot_type: crate::ast::PortType,
148 value: Value,
149) -> Result<(), crate::KernelError> {
150 use crate::kernel::WriteError;
151 let got = value.port_type();
152 let value = match child.input_type_origin(name) {
153 Some(crate::kernel::TypeOrigin::Inferred) if !value.satisfies_slot(slot_type) => {
154 adapt_boundary_value(name, slot_type, value)
155 }
156 _ => value,
157 };
158 child.set_input_at(index, value).map_err(|e| {
159 crate::KernelError::Write(match e {
160 WriteError::TypeMismatch { slot, expected, .. } => WriteError::FromParent {
161 slot,
162 expected,
163 got,
164 },
165 other => other,
166 })
167 })
168}
169
170/// A compiled Polydat Kernel: an `Arc<PolydatProgram>` plus one `PolydatState`.
171///
172/// ## Invariants
173///
174/// - **Scope coordinates are always populated.** After construction
175/// `scope_coords` reflects this kernel's place in the comprehension
176/// chain: leaf-first list of [`super::ScopeCoord`] from the kernel's
177/// own scope up through every enclosing comprehension. Root-scope
178/// kernels (no parent) start with their own coords (or empty).
179/// `Self::materialize_wiring_from_outer` re-computes the path so post-bind it
180/// includes the outer's chain. Consumers (presentation layer,
181/// inspector, scope-aware diagnostics) call
182/// [`Self::scope_coordinates`] without needing to walk the scope
183/// tree themselves. See the scope model design document (`docs/design/scope_model.md`).
184pub struct PolydatKernel {
185 program: Arc<PolydatProgram>,
186 state: PolydatState,
187 /// Number of init-time constants folded during compilation.
188 pub constants_folded: usize,
189 /// Leaf-first scope-coordinate path. Maintained as an
190 /// invariant — see struct docs.
191 scope_coords: Vec<super::ScopeCoord>,
192 /// Rule 2 write-through bindings carried alongside the kernel
193 /// for per-cycle commit (subcontext_construction.md §3.1, §5).
194 /// Each entry pairs an export name (which the kernel exposes as
195 /// a cell-bound input slot) with the synthetic `__write_<name>`
196 /// source output the rewrite emitted. Empty for the vast
197 /// majority of kernels; populated by the subcontext builder when
198 /// result-bindings or `shared` collisions trigger Rule 2.
199 write_throughs: Vec<KernelWriteThrough>,
200 /// Shared cells visible at this kernel's scope but with no
201 /// matching input slot on this kernel's program (closure-
202 /// binding economy elided the slot). Carried as a transit
203 /// channel so a descendant whose program DOES declare the
204 /// slot can attach the same cell handle.
205 ///
206 /// `materialize_wiring_from_outer` is the single writer: when binding
207 /// child to parent, it attaches every parent-visible cell
208 /// to whatever child input slot exists, and stores the
209 /// remaining unattached cells here for further propagation.
210 /// The activity layer never sees this directly — the typed
211 /// `ScopeKernel::shared_cells_in_scope` returns the merged
212 /// view.
213 transit_cells: Vec<SharedCellEntry>,
214}
215
216/// Local data shape of a write-through binding
217/// the kernel carries. Mirrors `subcontext::WriteThroughBinding`
218/// but lives at this layer so [`PolydatKernel`] avoids a cyclic
219/// dependency on the subcontext module (which already depends on
220/// kernel types).
221#[derive(Debug, Clone)]
222pub(crate) struct KernelWriteThrough {
223 pub export_name: String,
224 pub source_output: String,
225}
226
227/// Type-stability boundary for shared-cell WRITE-THROUGHS
228/// (scope_model.md §6.1). A matching type passes; a lossless
229/// widening heals through the catalog adapter; an UNHEALABLE
230/// mismatch — narrowing, kind change — is an `Err` AT THE WRITE
231/// naming the cell, its declared type, the incoming type, and the
232/// producing binding. Without this, a result-binding writing (say)
233/// an F64 into a U64-declared cell would flip the cell's runtime
234/// type, and a bridge compiled against the declared type would
235/// panic `expected U64, got F64` at a READ tiers away from the
236/// cause. Shared by both write-through commit paths
237/// ([`PolydatKernel::commit_write_throughs`] and the subcontext
238/// `ScopeKernel` variant).
239pub(crate) fn check_write_through_type(
240 export_name: &str,
241 source_output: &str,
242 slot_type: crate::ast::PortType,
243 value: Value,
244) -> Result<Value, String> {
245 use crate::ast::PortType as P;
246 let got = value.port_type();
247 if got == slot_type || matches!(value, Value::None) {
248 return Ok(value);
249 }
250 // Only LOSSLESS conversions may heal automatically at the CELL
251 // boundary: numeric widenings, plus the Bool↔U64 0/1 convention
252 // (GK comparisons and predicates produce U64 0/1, so a predicate
253 // result written into a Bool cell is natural authoring). The
254 // general auto-adapter catalog also carries narrowing entries
255 // (F64→U64 truncation) for other boundaries — deliberately NOT
256 // consulted here: a narrowing write silently changes semantics,
257 // so it must be the author's explicit `trunc_u64(...)` /
258 // `round_u64(...)`.
259 let widening = matches!(
260 (got, slot_type),
261 (P::U64, P::F64)
262 | (P::I64, P::F64)
263 | (P::U32, P::F64)
264 | (P::I32, P::F64)
265 | (P::F32, P::F64)
266 | (P::U32, P::U64)
267 | (P::U32, P::I64)
268 | (P::I32, P::I64)
269 | (P::U64, P::Bool)
270 | (P::Bool, P::U64),
271 );
272 if widening && let Some(adapter) = crate::compile::assembly::boundary_adapter(got, slot_type) {
273 let inputs = vec![value];
274 let mut outputs = vec![Value::None];
275 adapter.eval(&inputs, &mut outputs);
276 return Ok(outputs.remove(0));
277 }
278 Err(format!(
279 "type-stable cell violation: shared cell `{export_name}` is \
280 declared {slot_type:?}, but the result binding \
281 `{export_name} := …` (via `{source_output}`) produced a \
282 {got:?} value ({val}). A cell keeps ONE type for life — \
283 declare the cell with a matching initializer (e.g. \
284 `shared {export_name} := 1.0` for f64), or narrow \
285 explicitly with `trunc_u64(...)` / `round_u64(...)`.",
286 val = value.to_display_string(),
287 ))
288}
289
290impl std::fmt::Debug for PolydatKernel {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 f.debug_struct("PolydatKernel")
293 .field("program", &self.program)
294 .finish()
295 }
296}
297
298impl PolydatKernel {
299 /// Create with explicit input definitions. `strict` selects
300 /// strict-mode const folding (config-wire violations become
301 /// errors).
302 ///
303 /// Returns `Err` when a compile-constant step cannot be computed,
304 /// or for a strict-mode violation.
305 // Thirteen parameters describe one thing — a compiled program
306 // definition. A params struct would reshape the construction
307 // protocol rather than clean up a lint — this fn is the
308 // walled-off construction chokepoint (subcontext/mod.rs).
309 #[allow(clippy::too_many_arguments)]
310 pub(crate) fn new_with_inputs(
311 nodes: Vec<Box<dyn PolydatNode>>,
312 wiring: Vec<Vec<WireSource>>,
313 input_defs: Vec<InputDef>,
314 coord_count: usize,
315 output_map: HashMap<String, (usize, usize)>,
316 output_order: Vec<String>,
317 const_outputs: std::collections::HashSet<String>,
318 output_modifiers: HashMap<String, crate::dsl::ast::BindingModifier>,
319 source: &str,
320 context: &str,
321 log: Option<&mut crate::dsl::events::CompileEventLog>,
322 strict: bool,
323 ledger: Arc<crate::kernel::CompileLedger>,
324 ) -> Result<Self, crate::compile::assembly::AssemblyError> {
325 let mut program = PolydatProgram::with_inputs(
326 nodes,
327 wiring,
328 input_defs,
329 coord_count,
330 output_map,
331 output_order,
332 source,
333 context,
334 ledger,
335 );
336 // Mark const bindings before the fold runs, so strict mode can
337 // find them.
338 for name in &const_outputs {
339 program.mark_const_output(name);
340 }
341 // Install output modifiers BEFORE fold so the lifecycle
342 // classifier sees `volatile`. Without this, a `volatile`
343 // binding's producing node would default to CompileConst,
344 // fold would replace it with a literal, and the workload's
345 // `volatile` declaration would lose its "exclude from
346 // program identity" guarantee.
347 for (name, modifier) in &output_modifiers {
348 program.set_output_modifier(name, *modifier);
349 }
350 let constants_folded = if strict {
351 program.fold_init_constants_strict(log, true)?
352 } else {
353 program.fold_init_constants_with_log(log)?
354 };
355 let program = Arc::new(program);
356 let mut state = program.create_state();
357 // Populate buffers for folded constants so get_constant() works.
358 // Seeded, not set: construction writes no input, so nothing
359 // is invalidated by it.
360 let dummy = vec![0u64; program.coord_count()];
361 state.seed_inputs(&dummy);
362 // Seed buffers for folded *constant* nullary nodes so
363 // `get_constant()` works. Skip `Nondeterministic` nullary nodes
364 // (live-metric readers, entropy, clocks): they have no
365 // compile-time value, and pulling one here would evaluate it
366 // against an empty/absent runtime source — mirrors the same
367 // skip the fold pass makes (`fold_init_constants`).
368 for name in program.output_names() {
369 if let Some(&(node_idx, _)) = program.output_map.get(name)
370 && program.wiring[node_idx].is_empty()
371 && !matches!(
372 program.nodes[node_idx].purity(),
373 crate::ast::Purity::Nondeterministic { .. }
374 )
375 {
376 state.pull(&program, name);
377 }
378 }
379 seed_shared_cells(&mut state, &program);
380 state.core.seed_output_cells(&program);
381 let mut k = Self {
382 program,
383 state,
384 constants_folded,
385 scope_coords: Vec::new(),
386 write_throughs: Vec::new(),
387 transit_cells: Vec::new(),
388 };
389 k.refresh_scope_coordinates();
390 Ok(k)
391 }
392
393 /// Mark a set of output names as inherited (cascade-only)
394 /// on the program. Must be called immediately after
395 /// construction, before the `Arc<PolydatProgram>` is shared.
396 /// Panics if the Arc has other references.
397 pub fn mark_inherited_outputs<I>(&mut self, names: I)
398 where
399 I: IntoIterator<Item = String>,
400 {
401 let program = Arc::get_mut(&mut self.program)
402 .expect("mark_inherited_outputs called after program was shared");
403 for name in names {
404 program.mark_inherited(&name);
405 }
406 }
407
408 /// Bake Rule 2 write-through bindings onto the underlying
409 /// program. Must be called immediately after construction,
410 /// before the `Arc<PolydatProgram>` is shared. Panics if the Arc
411 /// has other references. Also updates this kernel's own
412 /// `write_throughs` field so the just-built kernel matches
413 /// what later `from_program` callers will see.
414 ///
415 /// The single legitimate caller is the subcontext builder's
416 /// finalize step (subcontext_construction.md §3, step 9). Any
417 /// kernel built from the program inherits the bindings via
418 /// `from_program`'s automatic seeding.
419 pub(crate) fn bake_write_throughs(&mut self, write_throughs: Vec<KernelWriteThrough>) {
420 let program = Arc::get_mut(&mut self.program)
421 .expect("bake_write_throughs called after program was shared");
422 program.set_write_throughs(write_throughs.clone());
423 self.write_throughs = write_throughs;
424 }
425
426 /// Construct a fresh kernel from a previously-compiled
427 /// `Arc<PolydatProgram>`. The state is freshly created and seeded
428 /// the same way the standard new-kernel path does, so callers
429 /// can immediately `set_input(...)` for externs and execute.
430 ///
431 /// # Cache-and-rehydrate role
432 ///
433 /// This is the **rehydrate** primitive of the cache-and-
434 /// rehydrate pattern documented on [`Self::for_iteration`].
435 /// External callers use `for_iteration` (which composes
436 /// this with parent-chain wiring); this method itself is
437 /// `pub(crate)` because hydrating a kernel without
438 /// installing parent-chain wiring would skip the load-
439 /// bearing materialization step.
440 ///
441 /// Used by the cache-and-rebind path the host drives
442 /// (scope_model.md §9): a phase scope compiles once, caches its
443 /// program, and instantiates a fresh kernel per `run_phase` call
444 /// against the cached program.
445 pub(crate) fn from_program(program: Arc<PolydatProgram>) -> Self {
446 let mut state = program.create_state();
447 // Populate buffers for folded constants so get_constant()
448 // works on the new kernel, as `new_with_inputs` seeds them
449 // after the fold.
450 let dummy = vec![0u64; program.coord_count()];
451 state.seed_inputs(&dummy);
452 for name in program.output_names() {
453 if let Some(&(node_idx, _)) = program.output_map.get(name)
454 && program.wiring[node_idx].is_empty()
455 {
456 state.pull(&program, name);
457 }
458 }
459 seed_shared_cells(&mut state, &program);
460 state.core.seed_output_cells(&program);
461 // Auto-seed the kernel's Rule 2 write-through bindings
462 // from the program. The program is the single source of
463 // truth; any kernel built from it inherits the same
464 // bindings.
465 let write_throughs = program.write_throughs().to_vec();
466 let mut k = Self {
467 program,
468 state,
469 constants_folded: 0, // already folded; see program contents
470 scope_coords: Vec::new(),
471 write_throughs,
472 transit_cells: Vec::new(),
473 };
474 k.refresh_scope_coordinates();
475 k
476 }
477
478 /// The shared immutable program.
479 pub fn program(&self) -> &Arc<PolydatProgram> {
480 &self.program
481 }
482
483 /// Attach Rule 2 write-through bindings to this kernel
484 /// (subcontext_construction.md §5). Per-cycle eval calls
485 /// [`Self::commit_write_throughs`] after the inputs flowing
486 /// into the result-binding expressions are written; the
487 /// commit walks each binding, pulls its synthetic source
488 /// output, and stores the value back through the cell-bound
489 /// input slot for `export_name`. Because the slot was
490 /// attached to the parent's `SharedCell` at
491 /// `materialize_wiring_from_outer` time, the write fans through.
492 ///
493 /// `SubcontextBuilder::finalize` bakes these onto the program and
494 /// `from_program` seeds them on every kernel built from it; per-cycle code never mutates
495 /// them.
496 // Used only by the subcontext tests: `from_program` auto-seeds
497 // write-throughs on every kernel built from the program, so
498 // nothing else needs a post-construction setter. Kept for the
499 // test surface; dead-code-lint silenced.
500 #[allow(dead_code)]
501 pub(crate) fn set_write_throughs(&mut self, write_throughs: Vec<KernelWriteThrough>) {
502 self.write_throughs = write_throughs;
503 }
504
505 /// The Rule 2 write-through bindings carried by this kernel.
506 /// Empty for kernels without result-bindings or `shared`
507 /// collisions.
508 #[allow(dead_code)]
509 pub(crate) fn write_throughs(&self) -> &[KernelWriteThrough] {
510 &self.write_throughs
511 }
512
513 /// Per-cycle commit (subcontext_construction.md §5). Pulls each write-
514 /// through's synthetic source output and stores its value
515 /// through the corresponding cell-bound input slot for the
516 /// declared export name. Reads of that name in the parent or
517 /// in sibling kernels share the same cell and observe the
518 /// write on the next read.
519 ///
520 /// TYPE-STABLE (scope_model.md §6.1): a cell keeps
521 /// ONE type for life. Each pending value passes a typed
522 /// boundary —
523 /// matching types pass, a catalog adapter heals (e.g. the lossless
524 /// U64→F64 widening), and an UNHEALABLE mismatch (narrowing, kind
525 /// change) is an `Err` at THIS write site naming the cell, its
526 /// declared type, the incoming type, and the producing binding —
527 /// never a silent type flip that a compile-time-typed bridge trips
528 /// over tiers later. Explicit narrowing is the author's job via
529 /// `trunc_u64(...)` / `round_u64(...)`.
530 ///
531 /// No-op when the kernel carries no write-throughs.
532 pub fn commit_write_throughs(&mut self) -> Result<(), String> {
533 let debug = crate::library::debug_nodes_enabled();
534 if self.write_throughs.is_empty() {
535 if debug {
536 crate::library::support::audit::debug(
537 "commit_write_throughs: kernel has zero bindings — no-op",
538 );
539 }
540 return Ok(());
541 }
542 // Two-pass: pull each value first (each pull mutates the
543 // state), collect, then write to the slot. Avoids
544 // overlapping borrows on `self.state` / `self.program`.
545 // For cell-bound slots `set_input` writes through the
546 // cell (single-register: cell IS the slot's register);
547 // for non-cell slots it updates the local register.
548 let mut pending: Vec<(usize, Value)> = Vec::with_capacity(self.write_throughs.len());
549 let bindings = self.write_throughs.clone();
550 if debug {
551 crate::library::support::audit::debug(&format!(
552 "commit_write_throughs: {} binding(s)",
553 bindings.len()
554 ));
555 }
556 for wt in &bindings {
557 let Some(idx) = self.program.find_input(&wt.export_name) else {
558 if debug {
559 crate::library::support::audit::debug(&format!(
560 "commit_write_throughs: skip {} — no input slot",
561 wt.export_name
562 ));
563 }
564 continue;
565 };
566 let value = self.state.pull(&self.program, &wt.source_output).clone();
567 if debug {
568 crate::library::support::audit::debug(&format!(
569 "commit_write_throughs: {} → {}",
570 wt.export_name,
571 value.to_display_string()
572 ));
573 }
574 // Type-stability boundary (doc above): match passes,
575 // catalog adapters heal (widening), anything else errors
576 // HERE — at the write, with the full story.
577 let slot_type = self
578 .program
579 .input_port_type_by_idx(idx)
580 .expect("write-through idx resolved from find_input");
581 let value =
582 check_write_through_type(&wt.export_name, &wt.source_output, slot_type, value)?;
583 pending.push((idx, value));
584 }
585 for (idx, value) in pending {
586 self.state.set_input(idx, value);
587 }
588 Ok(())
589 }
590
591 /// Set source schemas on the program (called by the compiler).
592 pub fn set_cursor_schemas(&mut self, schemas: Vec<crate::iteration::source::SourceSchema>) {
593 Arc::get_mut(&mut self.program)
594 .expect("set_cursor_schemas must be called before program is shared")
595 .set_cursor_schemas(schemas);
596 }
597
598 /// Record the resource scope of the tree the program belongs to,
599 /// before the program is shared.
600 pub(crate) fn set_resources(&mut self, resources: crate::resource::ResourceScope) {
601 Arc::get_mut(&mut self.program)
602 .expect("set_resources must be called before program is shared")
603 .set_resources(resources);
604 }
605
606 /// Record the digest of the graph the compiler resolved as the
607 /// program's graph identity, before the program is shared.
608 pub(crate) fn set_graph_identity(&mut self, digest: [u8; 32]) {
609 Arc::get_mut(&mut self.program)
610 .expect("set_graph_identity must be called before program is shared")
611 .set_graph_identity(digest);
612 }
613
614 /// Record the const bindings, before the program is shared.
615 pub(crate) fn set_const_inits(&mut self, inits: Vec<crate::kernel::ConstInit>) {
616 Arc::get_mut(&mut self.program)
617 .expect("set_const_inits must be called before program is shared")
618 .set_const_inits(inits);
619 }
620
621 /// Record how much of the graph the build fused into native cones,
622 /// before the program is shared.
623 pub(crate) fn set_cone_mode(&mut self, mode: crate::compile::cone::JitMode) {
624 Arc::get_mut(&mut self.program)
625 .expect("set_cone_mode must be called before program is shared")
626 .set_cone_mode(mode);
627 }
628
629 /// Attach the parsed AST as live program metadata. Called by
630 /// every DSL compile entry point immediately after the
631 /// assembler produces the kernel, while the program Arc is
632 /// still uniquely owned. The subscope synthesizer queries this
633 /// to integrate parent bindings' matter into child scopes.
634 pub fn set_ast(&mut self, ast: Arc<crate::dsl::ast::PolydatFile>) {
635 Arc::get_mut(&mut self.program)
636 .expect("set_ast must be called before program is shared")
637 .set_ast(ast);
638 }
639
640 /// Attach compiled traversals and producers (for_traversal.md). Called by
641 /// the DSL compiler while the program Arc is still uniquely owned.
642 pub fn set_traversals(
643 &mut self,
644 traversals: Vec<crate::dsl::traversal::Traversal>,
645 producers: Vec<crate::dsl::traversal::Producer>,
646 ) {
647 Arc::get_mut(&mut self.program)
648 .expect("set_traversals must be called before program is shared")
649 .set_traversals(traversals, producers);
650 }
651
652 /// The per-fiber mutable evaluation state.
653 pub fn state(&mut self) -> &mut PolydatState {
654 &mut self.state
655 }
656
657 /// Read-only access to the kernel's evaluation state. Used by
658 /// callers (e.g. the scope-init pass) that need to inspect
659 /// pulled values without consuming the kernel.
660 pub fn state_ref(&self) -> &PolydatState {
661 &self.state
662 }
663
664 /// Convenience: set coordinate inputs on the owned state.
665 pub fn set_inputs(&mut self, coords: &[u64]) {
666 // Only the coordinates: a value past them would land in an
667 // extern's slot, which only a named write sets.
668 let n = coords.len().min(self.program.coord_count());
669 self.state.set_inputs(&coords[..n]);
670 }
671
672 /// Set an extern by name on the owned state. The compiled kernels
673 /// offer the same call, so a host drives every engine alike.
674 pub fn set_input(&mut self, name: &str, value: Value) -> Result<(), crate::kernel::WriteError> {
675 let idx = self.program.find_input(name).ok_or_else(|| {
676 crate::kernel::WriteError::UnknownWire {
677 key: name.to_string(),
678 known: self.program.input_names(),
679 }
680 })?;
681 self.set_input_at(idx, value)
682 }
683
684 /// [`Self::set_input`] by input index, as `find_input` numbers them.
685 /// The one write rule of every engine: the value satisfies the
686 /// declared type or is `None`, and a coordinate is not written here.
687 pub fn set_input_at(
688 &mut self,
689 idx: usize,
690 value: Value,
691 ) -> Result<(), crate::kernel::WriteError> {
692 if self.program.input_kind(idx) == Some(crate::kernel::InputKind::Const) {
693 return Err(crate::kernel::WriteError::ConstSlot {
694 slot: self
695 .program
696 .input_name_by_idx(idx)
697 .map(|n| n.to_string())
698 .unwrap_or_default(),
699 });
700 }
701 self.write_input_at(idx, value)
702 }
703
704 /// [`Self::set_input_at`] as initialization writes it: a const's
705 /// slot is accepted (`Kernel::init`).
706 pub(crate) fn init_input_at(
707 &mut self,
708 idx: usize,
709 value: Value,
710 ) -> Result<(), crate::kernel::WriteError> {
711 self.write_input_at(idx, value)
712 }
713
714 /// The typed write every input write shares.
715 fn write_input_at(
716 &mut self,
717 idx: usize,
718 value: Value,
719 ) -> Result<(), crate::kernel::WriteError> {
720 use crate::kernel::WriteError;
721 let Some(name) = self.program.input_name_by_idx(idx) else {
722 return Err(WriteError::UnknownWire {
723 key: format!("wire[{idx}]"),
724 known: self.program.input_names(),
725 });
726 };
727 if self.program.input_kind(idx) == Some(crate::kernel::InputKind::Coordinate) {
728 return Err(WriteError::CoordinateSlot {
729 slot: name.to_string(),
730 });
731 }
732 if let Some(declared) = self.program.input_port_type_by_idx(idx)
733 && !value.satisfies_slot(declared)
734 {
735 return Err(WriteError::TypeMismatch {
736 slot: name.to_string(),
737 expected: declared,
738 got: value.port_type(),
739 });
740 }
741 self.state.set_input(idx, value);
742 Ok(())
743 }
744
745 /// Narrow a cursor to one partition: its `Ext` slot and six scalar
746 /// projections are set, as `cursor_partition::narrow_cursor` does.
747 /// The compiled kernels offer the same call. The partitions a
748 /// cursor's `over` clause denotes are in `program().cursor_schemas()`
749 /// when the compiler could resolve them, or from
750 /// `cursor_partition::cursor_over_partitions` otherwise.
751 pub fn set_cursor(
752 &mut self,
753 name: &str,
754 partition: &crate::iteration::cursor_partition::Partition,
755 ) -> Result<(), crate::kernel::WriteError> {
756 if self
757 .program
758 .find_input(&format!("{name}__cursor"))
759 .is_none()
760 {
761 return Err(crate::kernel::WriteError::UnknownWire {
762 key: format!("{name}__cursor"),
763 known: self
764 .program
765 .cursor_schemas()
766 .iter()
767 .map(|s| s.name.clone())
768 .collect(),
769 });
770 }
771 crate::iteration::cursor_partition::narrow_cursor(
772 &self.program,
773 &mut self.state,
774 name,
775 partition,
776 );
777 Ok(())
778 }
779
780 /// Read an input value by name. Cell-aware: cell-bound
781 /// slots return the cell's current value.
782 pub fn get_input(&self, name: &str) -> Option<Value> {
783 self.program
784 .find_input(name)
785 .map(|idx| self.state.get_input(idx))
786 }
787
788 /// Evaluate `output_name`'s cone and borrow the result, which is
789 /// the one thing this reader has over
790 /// [`Kernel::pull`](crate::Kernel::pull): no clone. The value lives
791 /// in the kernel's own buffer, so the borrow ties to `&mut self`
792 /// and ends at the next write.
793 ///
794 /// Named `pull_ref` and not `pull` deliberately. An inherent `pull`
795 /// here would shadow the trait's, which returns an owned `Value`,
796 /// and the same expression would mean different things depending on
797 /// whether the caller held a `PolydatKernel` or a `Box<dyn Kernel>`
798 /// — silently, since both sides answer `as_u64` and the rest.
799 pub fn pull_ref(&mut self, output_name: &str) -> &Value {
800 self.state.pull(&self.program, output_name)
801 }
802
803 /// [`Self::pull_ref`] by the output's index rather than its name,
804 /// skipping the name resolution. Pair with
805 /// [`PolydatProgram::output_index`] resolved once at bind time so a
806 /// per-cycle reader pays no name hash on the hot path.
807 pub fn pull_ref_at(&mut self, output_idx: usize) -> &Value {
808 self.state.pull_by_index(&self.program, output_idx)
809 }
810
811 /// Every output, as one read: each volatile step the outputs reach
812 /// is evaluated once for all of them, as a compiled kernel's `eval`
813 /// runs every step once.
814 pub(crate) fn eval_read(&mut self) {
815 self.state.rearm_volatile();
816 for name in self.program.output_names() {
817 let _ = self.state.pull_in_read(&self.program, name);
818 }
819 }
820
821 /// Copy `self`'s currently-set input-slot values into `child`'s
822 /// input slots by name.
823 ///
824 /// Companion to the internal `materialize_wiring_from_outer`
825 /// pass that runs as part of `build_subscope`. That pass
826 /// walks the parent's outputs; this method walks the parent's
827 /// **inputs** — so cascade-extern'd names that the parent
828 /// inherited from *its* parent reach `child` too, rather than
829 /// stopping at the parent and silently leaving `child`'s
830 /// matching slot at its default.
831 ///
832 /// `Value::None` inputs are skipped (no point overwriting a
833 /// child's possibly-set default with absence). Inputs whose
834 /// name has no matching slot on `child` are skipped silently
835 /// — they're not the child's concern.
836 ///
837 /// This is the kernel-chain operation that lets cascade-extern
838 /// propagate transitively across multi-level scope chains. Each
839 /// scope builder calls it after `build_subscope` finishes.
840 pub fn propagate_inputs_into(&self, child: &mut PolydatKernel) {
841 let names = self.program.input_names();
842 for name in names {
843 let Some(outer_value) = self.get_input(&name) else {
844 continue;
845 };
846 if matches!(outer_value, Value::None) {
847 continue;
848 }
849 let cloned = outer_value.clone();
850 let Some(inner_idx) = child.program.find_input(&name) else {
851 continue;
852 };
853 child.state.set_input(inner_idx, cloned);
854 }
855 }
856
857 /// Return the names of the inputs.
858 pub fn input_names(&self) -> Vec<String> {
859 self.program.input_names()
860 }
861
862 /// Return the names of all available output variates.
863 pub fn output_names(&self) -> Vec<&str> {
864 self.program.output_names()
865 }
866
867 /// Read the value of a named output that was folded to a constant.
868 ///
869 /// Underlying primitive — prefer [`Self::lookup`] for
870 /// scope-aware name resolution. This method only succeeds for
871 /// constant-folded outputs whose buffer is populated; it
872 /// returns `None` for auto-passthrough outputs (where the
873 /// value lives in the input slot) and for cycle-dependent
874 /// outputs that haven't been pulled.
875 ///
876 /// A `const` captured at initialization answers from its slot, which
877 /// initialization writes, so its value is there before anything
878 /// pulls its output.
879 pub fn get_constant(&self, name: &str) -> Option<&Value> {
880 let val = match self.program.const_inits().iter().find(|c| c.name == name) {
881 Some(c) => self.state.core.inputs.get(c.slot_index)?,
882 None => {
883 let (node_idx, port_idx) = self.program.output_map.get(name)?;
884 &self.state.core.buffers[*node_idx][*port_idx]
885 }
886 };
887 if matches!(val, Value::None) {
888 None
889 } else {
890 Some(val)
891 }
892 }
893
894 /// Find every `const` output whose initialization left the
895 /// buffer as `Value::None`. The L2.f sub-axiom in
896 /// composition_substrate.md describes this case: an
897 /// intermediate-layer `const X := <expr>` whose RHS yields
898 /// None falls through silently to the outer scope's X via
899 /// the conditional-shadow semantics in none_semantics.md.
900 /// This method is the substrate's "did silent fall-through
901 /// occur" query — strict-mode callers (per L2.f's
902 /// strict-mode hardening note) use it to escalate the
903 /// silent fall-through to a hard error.
904 ///
905 /// Returns the const-output names whose buffers are
906 /// `Value::None` after the scope-init pull. Empty `Vec`
907 /// means every const materialised to a defined value.
908 /// Polydat itself does not implement the strict-mode
909 /// policy — it provides this query and the caller decides
910 /// whether to surface a diagnostic.
911 ///
912 /// Call only after `materialize_wiring_from_outer` has run
913 /// (i.e., after the kernel is fully constructed and
914 /// scope-init pulls have completed). Calling before
915 /// scope-init returns a misleading result.
916 pub fn find_l2f_violations(&self) -> Vec<String> {
917 self.program
918 .const_outputs
919 .iter()
920 .filter(|name| {
921 // A const captured at initialization holds its fallback in
922 // its own output, so what shows a silent fall-through is
923 // its expression's value, `__init_<name>`.
924 let own = self
925 .program
926 .const_inits()
927 .iter()
928 .find(|c| &c.name == *name)
929 .map_or(name.as_str(), |c| c.source.as_str());
930 self.program
931 .output_map
932 .get(own)
933 .map(|(node_idx, port_idx)| {
934 matches!(&self.state.core.buffers[*node_idx][*port_idx], Value::None)
935 })
936 .unwrap_or(false)
937 })
938 .cloned()
939 .collect()
940 }
941
942 /// Look up a name in this kernel's scope.
943 ///
944 /// The canonical scope-aware read (scope_model.md §5): own-scope folded outputs
945 /// shadow inherited extern values, with auto-passthrough
946 /// outputs falling through to the input slot transparently.
947 ///
948 /// Resolution order:
949 /// 1. Folded output buffer (compile-time constants).
950 /// 2. Cell-aware input read (covers extern values bound via
951 /// `materialize_wiring_from_outer`, auto-passthrough outputs from
952 /// `input ...: u64` / `extern`, and `shared`-cell-backed
953 /// slots — the cell is queried on every read so reads
954 /// pick up writes from sibling kernels intrinsically).
955 ///
956 /// Returns `None` when the name doesn't resolve in either
957 /// tier or when the resolved value is `Value::None` (unset).
958 ///
959 /// Returns `Value` (owned, not borrowed) because shared-cell
960 /// reads acquire a Mutex and clone out — there's no
961 /// long-lived borrow into the cell. For non-shared slots
962 /// the clone is cheap (Value's Clone is Arc-based for
963 /// vectors, primitive copy otherwise).
964 ///
965 /// This is the single read API for scope-aware name lookup
966 /// and is cell-aware by default — callers don't need to
967 /// know whether a name is shared or not.
968 pub fn lookup(&self, name: &str) -> Option<Value> {
969 if let Some(v) = self.get_constant(name)
970 && !matches!(v, Value::None)
971 {
972 return Some(v.clone());
973 }
974 if let Some(idx) = self.program.find_input(name) {
975 let v = self.state.read_input_value(idx);
976 return if matches!(v, Value::None) {
977 None
978 } else {
979 Some(v)
980 };
981 }
982 // Dotted names follow the established field-access wire
983 // convention (`a.b` lowers to the wire `a__b`), so a
984 // text-context reference like `{q.cursor.idx}` resolves
985 // through the same flattening the DSL compiler applies.
986 if name.contains('.') {
987 let flattened = name.replace('.', "__");
988 return self.lookup(&flattened);
989 }
990 None
991 }
992
993 /// Materialize a sub-scope kernel under this kernel as
994 /// parent. THE single primitive for parent → child kernel
995 /// construction with cell propagation.
996 ///
997 /// The parent supervises sub-context construction
998 /// (subcontext_construction.md §8, SC1): only the parent has the
999 /// right to materialize a sub-scope
1000 /// kernel. The parent owns the cell cascade, the value-copy
1001 /// path for outputs, the scope-coordinate plumbing, and any
1002 /// pre-bind iter-var injection. Every other code path that
1003 /// needs a parent-bound child kernel routes through here —
1004 /// the underlying `materialize_wiring_from_outer` step is private to
1005 /// this impl and not callable from anywhere else in the
1006 /// crate.
1007 ///
1008 /// `iter_bindings` lets callers inject iter-var values
1009 /// before binding, matching `for_iteration`'s contract:
1010 /// values must be installed BEFORE
1011 /// `refresh_scope_coordinates` runs so the own-coord
1012 /// snapshot sees them.
1013 ///
1014 /// # Side-channel lock
1015 ///
1016 /// `materialize_wiring_from_outer` is private to this impl block, so
1017 /// a caller cannot bypass the typed primitive; the compile-fail
1018 /// case `crates/polydat/tests/ui/seal/materialize_wiring_is_private.rs` holds
1019 /// that at the compiler.
1020 pub(crate) fn materialize_subscope(
1021 &self,
1022 program: Arc<PolydatProgram>,
1023 iter_bindings: &[(String, Value)],
1024 ) -> PolydatKernel {
1025 Self::materialize_subscope_under(self, program, iter_bindings)
1026 }
1027
1028 /// [`Self::materialize_subscope`] under a parent of any engine.
1029 ///
1030 /// The binder reads seven things off a parent — the cells in scope,
1031 /// its output names, whether a name has an input slot, that name's
1032 /// binding modifier, its current value, its broadcast cell, and its
1033 /// scope-coordinate path — and every one of them is on the `Kernel`
1034 /// trait, so the parent can be a kernel of any engine.
1035 ///
1036 /// The child is an interpreter kernel, for the crate's
1037 /// interpreter-only paths (`for_iteration`, comprehension
1038 /// evaluation). A host builds a child on its parent's engine with
1039 /// [`crate::kernel::subcontext::PolydatMatter::build_under`] or
1040 /// [`crate::kernel::bind_under`].
1041 pub(crate) fn materialize_subscope_under(
1042 outer: &dyn crate::kernel::Kernel,
1043 program: Arc<PolydatProgram>,
1044 iter_bindings: &[(String, Value)],
1045 ) -> PolydatKernel {
1046 let mut child = PolydatKernel::from_program(program);
1047 child.bind_under(outer, iter_bindings);
1048 child
1049 }
1050
1051 /// Produce a fresh kernel that mirrors this one's program
1052 /// AND its full shared-cell view (own input-slot cells +
1053 /// transit cells). The cell handles are Arc-shared; the
1054 /// returned kernel reads/writes the same cells as `self`.
1055 ///
1056 /// Used by [`Self::fork_kernel`] and [`Self::cell_scope_snapshot`].
1057 /// The snapshot must reflect the LIVE kernel's cell view, not just
1058 /// its program shape, otherwise Rule 2 in a builder's finalize
1059 /// over it sees no cells and produces no write-throughs.
1060 pub(crate) fn snapshot_with_cells(&self) -> PolydatKernel {
1061 let mut snapshot = PolydatKernel::from_program(self.program.clone());
1062 snapshot.transit_cells = self.transit_cells.clone();
1063 // Re-attach every cell from `self`'s input slots onto
1064 // the matching input slot of `snapshot`. Slot indices
1065 // and names are isomorphic since the program is the
1066 // same Arc.
1067 for name in self.program.input_names() {
1068 let Some(idx) = self.program.find_input(&name) else {
1069 continue;
1070 };
1071 let Some(cell) = self.state.shared_cell(idx) else {
1072 continue;
1073 };
1074 snapshot.state.attach_shared_cell(idx, cell);
1075 }
1076 snapshot
1077 }
1078
1079 /// A new kernel over the same program with this one's state:
1080 /// `snapshot_with_cells`'s shared cells and transit cells, and this
1081 /// kernel's input values, current outputs, scope path, and write-
1082 /// throughs, so a scope-init constant materialized here is current
1083 /// in the fork without running again (`Kernel::fork`).
1084 pub(crate) fn fork_kernel(&self) -> PolydatKernel {
1085 let mut fork = self.snapshot_with_cells();
1086 let inputs = self.state.core.inputs.len();
1087 for idx in 0..inputs {
1088 if self.state.shared_cell(idx).is_some() {
1089 continue;
1090 }
1091 fork.state.core.inputs[idx] = self.state.core.inputs[idx].clone();
1092 }
1093 fork.state.core.buffers = self.state.core.buffers.clone();
1094 fork.state.core.node_clean = self.state.core.node_clean.clone();
1095 fork.scope_coords = self.scope_coords.clone();
1096 fork.write_throughs = self.write_throughs.clone();
1097 fork.constants_folded = self.constants_folded;
1098 fork
1099 }
1100
1101 /// Public form of `Self::snapshot_with_cells`: a fresh kernel
1102 /// mirroring this one's program and full shared-cell view (own
1103 /// input-slot cells + transit cells, Arc-shared — the snapshot
1104 /// reads/writes the SAME cells as `self`). For holding a scope's
1105 /// cell cascade past the point where the kernel itself is consumed
1106 /// (e.g. an executor keeping a phase-activation scope view alive
1107 /// for later `build_subscope` binds, after `OpBuilder` has taken
1108 /// the activation kernel by value). Non-cell state is fresh — this
1109 /// is a SCOPE view, not a value snapshot.
1110 pub fn cell_scope_snapshot(&self) -> PolydatKernel {
1111 self.snapshot_with_cells()
1112 }
1113
1114 /// Materialize this kernel's input-slot wiring against `outer`'s
1115 /// exports, driven by the matter at construction
1116 /// (wire_materialization.md, "Materialization gradient"). Reads `self.program`'s
1117 /// matter (its extern / shared / coord declarations) to decide
1118 /// each slot's materialization gradient — cell-attach for
1119 /// shared and computed outputs, value-copy for passthrough,
1120 /// transit-forward for cells with no matching local slot.
1121 ///
1122 /// Private; the only sanctioned construction path is
1123 /// `build_subscope` (which calls `materialize_subscope`
1124 /// internally). External callers don't see
1125 /// this operation directly.
1126 /// Write `iter_bindings` into this kernel's own slots and wire the
1127 /// rest from `outer`. The order matters: the values must be in
1128 /// before `refresh_scope_coordinates` runs, so the own-coord
1129 /// snapshot sees them.
1130 fn bind_under(&mut self, outer: &dyn crate::kernel::Kernel, iter_bindings: &[(String, Value)]) {
1131 for (var, value) in iter_bindings {
1132 if let Some(idx) = self.program.find_input(var) {
1133 self.state.set_input(idx, value.clone());
1134 }
1135 }
1136 self.materialize_wiring_from_outer(outer);
1137 }
1138
1139 /// Wire and initialize. The interpreter's own subscope path cannot
1140 /// return an error, so a const that fails when the child is
1141 /// initialized fails the construction here.
1142 fn materialize_wiring_from_outer(&mut self, outer: &dyn crate::kernel::Kernel) {
1143 if let Err(e) = Self::wire_child_under(self, outer) {
1144 panic!("{e}");
1145 }
1146 }
1147
1148 /// Wire `child` from `outer`: the child program's resource scope
1149 /// joined to the parent's, the cell cascade, the outputs the child
1150 /// imports, its scope-init consts, and its coordinate path.
1151 ///
1152 /// Both sides are `dyn Kernel`, so a child of any engine binds
1153 /// under a parent of any engine. It stays an associated function
1154 /// of the interpreter's kernel type because the seal is on this
1155 /// module — nothing outside the crate reaches the wiring, whichever
1156 /// kernel it is wiring.
1157 pub(crate) fn wire_child_under(
1158 child: &mut dyn crate::kernel::Kernel,
1159 outer: &dyn crate::kernel::Kernel,
1160 ) -> Result<(), crate::KernelError> {
1161 use crate::kernel::interp::Lookup as _;
1162 // Step 0 — the resource scope. A child compiled on its own
1163 // resolves lookups through the parent's scope, and the join
1164 // precedes the scope-init consts, which may read a resource.
1165 child
1166 .resources()
1167 .join(outer.resources())
1168 .map_err(crate::KernelError::Resources)?;
1169 // Step 1 — typed shared-cell cascade. Compute every
1170 // cell visible at the outer scope: cells on outer's
1171 // own input slots (its `shared X := …` declarations
1172 // and any cells inherited from its own ancestors that
1173 // landed on slots) PLUS outer's transit cells (cells
1174 // outer carried forward as a transit because outer's
1175 // program had no matching slot). Together these are
1176 // every cell a descendant could legitimately bind to.
1177 //
1178 // Attach each cell to whichever child input slot
1179 // exists; drop cells whose name the child has already
1180 // attached itself to (idempotent reattach with the
1181 // same handle is a no-op, but a name collision with
1182 // a DIFFERENT cell would be a contract violation).
1183 // Cells with no matching child slot are stored on the
1184 // child as transit so a deeper descendant can pick
1185 // them up (wire_materialization.md, form 1).
1186 let outer_scope = crate::kernel::interp::KernelLookup::new(outer);
1187 let outer_cells = outer.cells_in_scope();
1188 let mut transit_forward: Vec<SharedCellEntry> = Vec::new();
1189 let mut attached_names: std::collections::HashSet<String> =
1190 std::collections::HashSet::new();
1191 // Names this scope declares as a local authoritative
1192 // output — `const NAME := …`, whether folded at compile
1193 // time or computed once at initialization after wiring
1194 // and then fixed for the scope's lifetime. Either way
1195 // this scope owns the binding for `NAME` over its
1196 // subtree (wire_materialization.md, "Local-authoritative
1197 // shadow"), so any transit cell carrying
1198 // a stale value from a grandparent must be suppressed:
1199 // without that suppression, step 1's blanket cell-attach
1200 // would short-circuit step 2's value-copy from
1201 // `outer.lookup(name)` (already-in-attached_names
1202 // guard), and descendants would read the transit cell's
1203 // value instead of the local declaration's.
1204 //
1205 // The two forms are uniform from the chain's
1206 // perspective: both produce a single authoritative
1207 // value visible to descendants via the standard
1208 // `extern NAME` lookup. The distinction is internal
1209 // (when the value is computed) and doesn't affect the
1210 // shadowing semantics.
1211 let local_finals: std::collections::HashSet<String> = child
1212 .output_names()
1213 .into_iter()
1214 // `const_outputs()` filters output_modifiers for CONST,
1215 // so checking the modifier directly is the same query —
1216 // single source of truth for "this scope authoritatively
1217 // owns NAME via a const binding."
1218 .filter(|n| child.output_modifier(n).is_const())
1219 .collect();
1220 for entry in outer_cells {
1221 // A local final on this scope is the canonical writer
1222 // for the name; the transit cell from above is stale.
1223 // Drop it on the floor — don't attach to a slot we
1224 // own, don't transit-forward to descendants. They'll
1225 // see this scope's final via the standard step-2
1226 // value-copy or cell-attach path.
1227 if local_finals.contains(entry.name.as_str()) {
1228 continue;
1229 }
1230 if child.input_index(&entry.name).is_some() {
1231 child.bind_input_cell(&entry.name, entry.cell.clone());
1232 attached_names.insert(entry.name);
1233 } else {
1234 transit_forward.push(entry);
1235 }
1236 }
1237 child.set_transit_cells(transit_forward);
1238
1239 // Step 2 — the read invariant (wire_materialization.md,
1240 // forms 2–5). For each output on
1241 // outer that matches an input slot on inner:
1242 //
1243 // - If the name also exists as an *input slot* on
1244 // outer (i.e. it's a passthrough output backed by
1245 // an input slot — `extern X: T`, `shared X :=
1246 // <lit>`, coord inputs like `cycle`), the canonical
1247 // storage is the input slot. Step 1 already
1248 // attached the cell for shared / iter-var slots;
1249 // for plain passthrough we value-copy the current
1250 // slot value. Cycle-derived coord propagation goes
1251 // through the explicit set_inputs path on the
1252 // inner kernel, not through this bind step.
1253 //
1254 // - Otherwise the name is a truly-computed output
1255 // (node-backed, no input slot on outer). Attach
1256 // outer's output broadcast cell to inner's input
1257 // slot. Outer's `pull` writes the freshly computed
1258 // value through the cell; inner reads through
1259 // `read_input` transparently, so the read invariant
1260 // holds (wire_materialization.md, form 3).
1261 for name in outer.output_names() {
1262 if attached_names.contains(&name) {
1263 continue;
1264 }
1265 let Some(inner_idx) = child.input_index(&name) else {
1266 continue;
1267 };
1268 let outer_has_slot = outer.input_index(&name).is_some();
1269 // Conditional-shadow composition (none_semantics.md,
1270 // "Conditional-shadow semantics for `const`"): when outer's output
1271 // is a `const` binding, ALWAYS go through outer.lookup
1272 // (value-copy), never through the broadcast cell. The
1273 // const's output buffer may be Value::None (Rule 1
1274 // None-propagation, e.g. set:'s `const X := "{Y}"` when
1275 // Y is unbound); outer.lookup applies the two-tier read
1276 // so None falls through to outer's wired-from-grandparent
1277 // input slot, giving us the canonical chain-walked
1278 // value. Cell-attaching the None-valued buffer would
1279 // defeat that fall-through.
1280 //
1281 // Const outputs are effectively-const for the scope's
1282 // lifetime (evaluation_model.md, "Const Binding
1283 // Contract") — value-copy is semantically
1284 // equivalent to cell-attach and avoids the dynamic-cell
1285 // overhead.
1286 let outer_is_const =
1287 outer.output_modifier(&name) == crate::dsl::ast::BindingModifier::CONST;
1288 // Slot's declared port type — needed for
1289 // boundary-adapter dispatch. `find_input` returned
1290 // `Some(inner_idx)` above, so `input_port_type` on
1291 // the same name is a program-shape invariant; a
1292 // `None` here means the program is malformed.
1293 let inner_slot_type = child
1294 .input_port_type(&name)
1295 .expect("input index resolved but no declared port type");
1296 // A coordinate is positioned with `set_inputs`, never copied.
1297 if inner_idx < child.coord_count() {
1298 continue;
1299 }
1300 if outer_has_slot || outer_is_const {
1301 // Both conditions force the chain-walking value-copy
1302 // path (see the const rationale above; an outer input
1303 // slot likewise reads through outer.lookup so the
1304 // grandparent fall-through applies).
1305 if let Some(value) = outer_scope.lookup(&name) {
1306 copy_from_parent(child, inner_idx, &name, inner_slot_type, value)?;
1307 }
1308 } else if let Some(cell) = outer.output_cell(&name) {
1309 child.bind_input_cell(&name, cell);
1310 attached_names.insert(name.to_string());
1311 } else if let Some(value) = outer_scope.lookup(&name) {
1312 copy_from_parent(child, inner_idx, &name, inner_slot_type, value)?;
1313 } else if let Some(value) =
1314 crate::dsl::factories::resolve_extern(&name, inner_slot_type)
1315 {
1316 // Registered extern resolver: outer chain has no
1317 // binding; a host-registered resolver provides one
1318 // (wire_materialization.md, form 5).
1319 copy_from_parent(child, inner_idx, &name, inner_slot_type, value)?;
1320 }
1321 }
1322
1323 // Step 3 — initialize the child: now that step 2 has written
1324 // the enclosing scope's values into its inputs, every const is
1325 // evaluated once and fixed for the child's life (Kernel::init).
1326 // Before step 4, whose own-coordinate snapshot reads consts.
1327 child.init()?;
1328
1329 // Step 4 — scope-coordinates plumbing. Path is now
1330 // `[own] ++ outer.scope_coordinates()`. Refresh own
1331 // (extern values may have just been populated above),
1332 // then prepend outer's frozen path.
1333 let outer_path = outer.scope_coordinates().to_vec();
1334 child.extend_scope_coordinates(&outer_path);
1335 Ok(())
1336 }
1337
1338 /// Advance this kernel's broadcast
1339 /// state: pull every output that has an attached
1340 /// broadcast cell, forcing the eval cone to recompute
1341 /// against current inputs and writing the fresh value
1342 /// through the cell. Descendant kernels with input slots
1343 /// cell-attached to these outputs then observe the
1344 /// current value on their next `read_input` without any
1345 /// per-fiber-write coordination.
1346 ///
1347 /// Intended to run once per cycle on each per-fiber outer
1348 /// kernel whose outputs are visible to inner scopes. The
1349 /// alternative — validity-bit + auto-pull-on-stale-read
1350 /// — would put the trigger fully inside the Polydat engine
1351 /// (so inner reads transparently fetch fresh values),
1352 /// but requires the engine to track upstream dependencies
1353 /// across the cell boundary. This eager-broadcast form
1354 /// is simpler and lives entirely within the kernel's own
1355 /// surface: callers ask the kernel to advance its
1356 /// broadcasts; the kernel does the pulls; cells receive
1357 /// the values.
1358 pub fn advance_broadcasts(&mut self) {
1359 let program = self.program.clone();
1360 let n_outputs = program.output_names().len();
1361 for i in 0..n_outputs {
1362 if self
1363 .state
1364 .core
1365 .output_cells
1366 .get(i)
1367 .and_then(|c| c.as_ref())
1368 .is_some()
1369 {
1370 let name = program.output_names()[i].to_string();
1371 // Some workload-level bindings
1372 // intentionally panic at specific cycles
1373 // (`testkit_throw_at(cycle, threshold, ...)` for the
1374 // resume-test fixture). Those panics belong to
1375 // the per-op evaluation path — the op's wire
1376 // resolution pulls the same wire and the
1377 // cascade catches the panic as a per-op error.
1378 // Here in the eager-broadcast pre-step we
1379 // suppress panics so the descendant pull path
1380 // remains the canonical error-handling site.
1381 let state = &mut self.state;
1382 let prog = &program;
1383 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1384 state.pull(prog, &name);
1385 }));
1386 }
1387 }
1388 }
1389
1390 /// Carry `cells` forward for this kernel's descendants, replacing
1391 /// what it carried. The binder writes what the parent had and this
1392 /// kernel holds no slot for; the compiled engines keep the same
1393 /// list in their extern table.
1394 pub fn replace_transit_cells(&mut self, cells: Vec<SharedCellEntry>) {
1395 self.transit_cells = cells;
1396 }
1397
1398 /// Every shared cell visible at this kernel's scope —
1399 /// own input slots' attached cells unioned with the
1400 /// transit cells inherited from ancestors. The typed
1401 /// `ScopeKernel::shared_cells_in_scope` delegates here.
1402 ///
1403 /// Used by `materialize_wiring_from_outer` to compute the parent's
1404 /// full visible cell set and propagate it to the child.
1405 /// Public for the typed surface; semantics are the same
1406 /// as the typed accessor.
1407 pub fn shared_cells_in_scope(&self) -> Vec<SharedCellEntry> {
1408 let mut by_name: std::collections::HashMap<String, SharedCellEntry> =
1409 std::collections::HashMap::new();
1410 for entry in &self.transit_cells {
1411 by_name.insert(entry.name.clone(), entry.clone());
1412 }
1413 for name in self.program.input_names() {
1414 let Some(idx) = self.program.find_input(&name) else {
1415 continue;
1416 };
1417 let Some(cell) = self.state.shared_cell(idx) else {
1418 continue;
1419 };
1420 // `find_input` just returned `Some(idx)`; the program
1421 // shape guarantees a declared port type for that idx.
1422 let port_type = self
1423 .program
1424 .input_port_type(&name)
1425 .expect("input index resolved but no declared port type");
1426 by_name.insert(
1427 name.clone(),
1428 SharedCellEntry {
1429 name,
1430 port_type,
1431 cell,
1432 },
1433 );
1434 }
1435 by_name.into_values().collect()
1436 }
1437
1438 /// Construct a per-iteration kernel: clone `canonical`'s
1439 /// program, bind it to `parent`'s scope, and pre-load every
1440 /// `(var, value)` binding into the corresponding input slot.
1441 ///
1442 /// # Cache-and-rehydrate pattern
1443 ///
1444 /// `for_iteration` is the public entry point for the
1445 /// **cache-and-rehydrate pattern** a host builds on:
1446 /// compile a scope's program **once**, then hydrate many
1447 /// per-instance kernels from it — one per iteration tuple,
1448 /// per fiber, per scenario-tree visit. The program is
1449 /// immutable substance (the `Arc<PolydatProgram>`); each
1450 /// hydrated kernel carries its own state (the input slot
1451 /// values for this iteration).
1452 ///
1453 /// The pattern's three load-bearing properties:
1454 ///
1455 /// 1. **Compile cost amortizes.** Polydat source → typed program
1456 /// is paid once per canonical scope, not per iteration
1457 /// or per fiber. The compiled `Arc<PolydatProgram>` is shared
1458 /// via clone (cheap — refcount bump).
1459 /// 2. **Each hydrated kernel is independent.** Per-fiber
1460 /// state means no synchronization between fibers running
1461 /// the same iteration in parallel. Each `for_iteration`
1462 /// call produces a fresh kernel with its own input
1463 /// slots, output cells, and write-through bindings.
1464 /// 3. **Parent-chain wiring is uniform.** Every hydrated
1465 /// kernel runs through the parent's
1466 /// `materialize_subscope` (and downstream
1467 /// `materialize_wiring_from_outer`) so cell propagation,
1468 /// shared-cell attach, and the read invariant
1469 /// (wire_materialization.md) are byte-identical to any
1470 /// other parent → child path.
1471 ///
1472 /// # When to use this
1473 ///
1474 /// - **Per-iteration kernel construction** in scope
1475 /// walkers and pre-map walkers. The runtime dispatcher
1476 /// uses it before descending into a comprehension
1477 /// iteration's children; the pre-map walker uses it so
1478 /// nested `for_each` clauses with outer-iter-var
1479 /// interpolation (`vec_{profile}`) resolve at pre-map
1480 /// time.
1481 ///
1482 /// # Why one entry point
1483 ///
1484 /// Owning the recipe here ensures both consumers (runtime
1485 /// dispatcher + pre-map walker) produce identical kernels
1486 /// for identical inputs, rather than each site composing
1487 /// `from_program` → `materialize_wiring_from_outer` →
1488 /// `set_input` on its own and drifting.
1489 ///
1490 /// # See also
1491 ///
1492 /// - `Self::from_program` (internal) — the
1493 /// build-fresh-state primitive `for_iteration` composes
1494 /// with parent-chain wiring.
1495 /// - [`Self::propagate_inputs_into`] — the kernel-chain
1496 /// operation that extends cascade-extern values into a
1497 /// subkernel (called once after `for_iteration` from each
1498 /// scope walker so multi-level cascades reach the
1499 /// grandchild).
1500 pub fn for_iteration(
1501 canonical: &Arc<PolydatKernel>,
1502 parent: &Arc<PolydatKernel>,
1503 bindings: &[(String, Value)],
1504 ) -> Arc<PolydatKernel> {
1505 // Routes through the parent's typed materialization
1506 // primitive so cell propagation is uniform with every
1507 // other parent → child path.
1508 Arc::new(parent.materialize_subscope(canonical.program().clone(), bindings))
1509 }
1510
1511 /// Recompute this kernel's *own* scope coordinates from
1512 /// the current state and overwrite [`Self::scope_coords`]
1513 /// with `[own]`. Used at construction time and at the start
1514 /// of [`Self::materialize_wiring_from_outer`] before extending with the
1515 /// outer chain. Internal — callers want
1516 /// [`Self::scope_coordinates`].
1517 fn refresh_scope_coordinates(&mut self) {
1518 let own = self.compute_own_coordinates();
1519 self.scope_coords.clear();
1520 if !own.is_empty() {
1521 self.scope_coords.push(own);
1522 }
1523 }
1524
1525 /// Compute the iteration coordinates this scope owns —
1526 /// every input slot tagged `IterationExtern` whose name
1527 /// isn't marked inherited in the program. Values come
1528 /// from the live state. Empty for non-comprehension
1529 /// scopes (workload root, scenario lists, individual
1530 /// phases).
1531 fn compute_own_coordinates(&self) -> super::ScopeCoord {
1532 use crate::kernel::InputKind;
1533 let mut vars = indexmap::IndexMap::new();
1534 for (idx, name) in self.program.input_names().into_iter().enumerate() {
1535 let kind = self.program.input_kind(idx);
1536 if kind != Some(InputKind::IterationExtern) {
1537 continue;
1538 }
1539 if self.program.is_inherited(&name) {
1540 continue;
1541 }
1542 // Use `lookup` (two-tier: const buffer first, input
1543 // slot second) rather than reading the input slot
1544 // directly. The conditional-shadow `const NAME :=
1545 // <expr>` pattern (none_semantics.md) makes NAME both an
1546 // input slot (wired with the outer scope's binding —
1547 // typically a workload-param default) AND a const
1548 // output (the iter-shadow result). The own-coordinate
1549 // should report the AUTHORITATIVE value the scope
1550 // publishes, which is the const buffer when present.
1551 // Reading the input slot directly would report the
1552 // wired-in default, masking the per-iter shadow value
1553 // in activity labels / scope-coord display paths.
1554 let Some(value) = self.lookup(&name) else {
1555 continue;
1556 };
1557 if matches!(value, Value::None) {
1558 continue;
1559 }
1560 vars.insert(name, value);
1561 }
1562 super::ScopeCoord { vars }
1563 }
1564
1565 /// The leaf-first scope coordinate path — see the
1566 /// scope model design document (`docs/design/scope_model.md`) for the formal
1567 /// definition. Always reflects the current binding state:
1568 /// after `Self::materialize_wiring_from_outer` the path includes the
1569 /// outer kernel's full chain; for root scopes the path is
1570 /// just this kernel's own coords (or empty).
1571 pub fn scope_coordinates(&self) -> &[super::ScopeCoord] {
1572 &self.scope_coords
1573 }
1574
1575 /// Refresh this kernel's own coordinates and append `outer`'s
1576 /// path, giving `[own] ++ outer`. What the binder does once the
1577 /// child's inputs are in, so the own-coord snapshot sees them.
1578 pub fn extend_scope_coordinates(&mut self, outer: &[super::ScopeCoord]) {
1579 self.refresh_scope_coordinates();
1580 self.scope_coords.extend_from_slice(outer);
1581 }
1582
1583 // There is no scope-exit copy of `shared` values: they live in
1584 // SharedCell-backed input slots, and writes from inner kernels
1585 // flow through the cell's Mutex (scope_model.md §6).
1586
1587 /// Extract the scope values that were set via `materialize_wiring_from_outer`.
1588 /// Returns `[(name, value)]` for inputs that are not at their
1589 /// default. Used by `OpBuilder` to inject the same values into
1590 /// every fiber's state, including per-op-template kernels
1591 /// whose input layout differs from this kernel's. The name-
1592 /// keyed shape is the cross-kernel-safe contract: an index
1593 /// captured against this kernel's layout is meaningless when
1594 /// applied to a kernel synthesised from a different source
1595 /// (different extern declaration order, lazy-cascade omissions,
1596 /// etc.). Naming the binding makes the cross-scope write
1597 /// unambiguous — a missing name on the target program is a
1598 /// no-op rather than a silently mis-routed write.
1599 ///
1600 /// A const's slot is left out: only initialization writes it, and
1601 /// each kernel the values are written into initializes its own
1602 /// consts from them.
1603 pub fn scope_values(&self) -> Vec<(String, Value)> {
1604 let mut values = Vec::new();
1605 for (i, name) in self.program.input_names().into_iter().enumerate() {
1606 if self.program.input_kind(i) == Some(crate::kernel::InputKind::Const) {
1607 continue;
1608 }
1609 let val = self.state.get_input(i);
1610 if !matches!(val, Value::None) {
1611 values.push((name, val.clone()));
1612 }
1613 }
1614 values
1615 }
1616
1617 /// Extract the program for concurrent use.
1618 pub fn into_program(self) -> Arc<PolydatProgram> {
1619 self.program
1620 }
1621}
1622
1623#[cfg(test)]
1624mod scope_values_tests {
1625 /// A scope's values are what a host writes into other kernels, so a
1626 /// const's slot, which only initialization writes, is not among
1627 /// them; the extern it reads is.
1628 #[test]
1629 fn scope_values_leave_out_const_slots() {
1630 let k = crate::dsl::compile::compile_polydat_interpreter(
1631 "extern tag: str = \"t1\"\nconst label := \"x_{tag}\"\n",
1632 )
1633 .unwrap();
1634 let names: Vec<String> = k.scope_values().into_iter().map(|(n, _)| n).collect();
1635 assert!(names.iter().any(|n| n == "tag"), "{names:?}");
1636 assert!(
1637 !names.iter().any(|n| n.starts_with("__const_")),
1638 "{names:?}"
1639 );
1640 }
1641}
1642
1643#[cfg(test)]
1644mod type_stability_tests {
1645 use super::*;
1646 use crate::ast::PortType;
1647
1648 /// scope_model.md §6.1 — the write-through boundary:
1649 /// matching types pass untouched, the catalog heals lossless
1650 /// widening (U64 → F64 slot), and an unhealable mismatch (F64
1651 /// into a U64 cell) errors AT THE WRITE with
1652 /// the cell name, both types, and the narrowing-cast guidance.
1653 #[test]
1654 fn write_through_boundary_matches_widens_and_rejects() {
1655 // Match: passes through untouched.
1656 let v = check_write_through_type("m", "__write_m", PortType::U64, Value::U64(7))
1657 .expect("matching type passes");
1658 assert_eq!(v.as_u64(), 7);
1659
1660 // Widening: U64 value into an F64 cell heals via the catalog.
1661 let v = check_write_through_type("m", "__write_m", PortType::F64, Value::U64(900))
1662 .expect("u64→f64 widens");
1663 assert_eq!(v.as_f64(), 900.0);
1664
1665 // None sentinel passes (none_semantics.md Rule 1 handles it).
1666 let v = check_write_through_type("m", "__write_m", PortType::U64, Value::None)
1667 .expect("None passes through");
1668 assert!(matches!(v, Value::None));
1669
1670 // Narrowing: F64 into a U64 cell is an error at the write,
1671 // naming everything the author needs.
1672 let err = check_write_through_type(
1673 "measured",
1674 "__write_measured",
1675 PortType::U64,
1676 Value::F64(900.0),
1677 )
1678 .expect_err("f64→u64 narrowing must be rejected");
1679 assert!(err.contains("measured"), "names the cell: {err}");
1680 assert!(
1681 err.contains("U64") && err.contains("F64"),
1682 "names both types: {err}"
1683 );
1684 assert!(
1685 err.contains("trunc_u64"),
1686 "points at the explicit cast: {err}"
1687 );
1688 }
1689}