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