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