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