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