Skip to main content

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}