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