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