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