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