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