Skip to main content

polydat_core/kernel/
program.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! PolydatProgram: the immutable compiled DAG shared across all fibers.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use super::engines::{EngineCore, PolydatState, ProvScanState};
10use super::{InputDef, WireSource};
11use crate::ast::{PolydatNode, Value};
12use crate::dsl::ast::{PolydatFile, Statement};
13
14/// Evaluation lifecycle classification used by the const-binding
15/// contract (graph_compiler.md §3.1):
16/// `CompileConst` and `ScopeInit` are the effectively-const
17/// lifecycle's compile-fold and initialization paths.
18///
19/// The variants are *ordered* — `Dynamic > ScopeInit > CompileConst`
20/// — so propagation along wires is a `max()` operation: a node's
21/// lifecycle is the most-dynamic of its own seed and every upstream
22/// node's lifecycle.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
24pub(crate) enum EvalLifecycle {
25    /// Foldable at Polydat compile time. No dependency on extern slots
26    /// or cycle inputs.
27    CompileConst,
28    /// Foldable at scope activation, after `materialize_wiring_from_outer`
29    /// populates iteration externs. Effectively-const for the
30    /// duration of one activation.
31    ScopeInit,
32    /// Re-evaluated on each pull at execution time. Reaches a
33    /// graph input (cycle / external-write port) or a non-deterministic
34    /// source.
35    Dynamic,
36}
37
38/// Exact multi-word input-provenance mask: bit `i` set means the
39/// carrier transitively depends on graph input `i`, with no
40/// saturation: a workload root's params and shared wires can exceed
41/// 64 inputs, and each keeps its own bit. Self-sizing: `set` grows the
42/// word vector to the highest observed index, so callers never
43/// plumb an input-count and masks from different programs stay
44/// comparable (absent words read as zero).
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
46pub struct ProvMask {
47    words: Vec<u64>,
48}
49
50impl ProvMask {
51    /// A mask with no bit set.
52    pub fn empty() -> Self {
53        Self { words: Vec::new() }
54    }
55
56    /// All bits `[0, n)` set — the "every input dirty" seed the
57    /// engine cone guards start from.
58    pub fn all_below(n: usize) -> Self {
59        let mut m = Self::empty();
60        for i in 0..n {
61            m.set(i);
62        }
63        m
64    }
65
66    /// Zero every bit, keeping the allocated words — the
67    /// per-cycle reset for hot-path change masks (no
68    /// reallocation once sized).
69    pub fn clear(&mut self) {
70        self.words.fill(0);
71    }
72
73    /// Set bit `idx`; returns `true` when the bit was newly set
74    /// (the fixpoint walker's change signal).
75    pub fn set(&mut self, idx: usize) -> bool {
76        let word = idx / 64;
77        if word >= self.words.len() {
78            self.words.resize(word + 1, 0);
79        }
80        let bit = 1u64 << (idx % 64);
81        let newly = self.words[word] & bit == 0;
82        self.words[word] |= bit;
83        newly
84    }
85
86    /// Whether bit `idx` is set.
87    pub fn contains(&self, idx: usize) -> bool {
88        self.words
89            .get(idx / 64)
90            .is_some_and(|w| w & (1u64 << (idx % 64)) != 0)
91    }
92
93    /// OR `other` into `self`; returns `true` when any bit was
94    /// newly set (the fixpoint walker's change signal).
95    pub fn union_with(&mut self, other: &Self) -> bool {
96        if other.words.len() > self.words.len() {
97            self.words.resize(other.words.len(), 0);
98        }
99        let mut changed = false;
100        for (dst, src) in self.words.iter_mut().zip(other.words.iter()) {
101            let merged = *dst | *src;
102            changed |= merged != *dst;
103            *dst = merged;
104        }
105        changed
106    }
107
108    /// Whether any bit is set in both masks.
109    pub fn intersects(&self, other: &Self) -> bool {
110        self.words
111            .iter()
112            .zip(other.words.iter())
113            .any(|(a, b)| a & b != 0)
114    }
115
116    /// Whether no bit is set.
117    pub fn is_zero(&self) -> bool {
118        self.words.iter().all(|w| *w == 0)
119    }
120
121    /// Ascending indices of the set bits.
122    pub fn iter_ones(&self) -> impl Iterator<Item = usize> + '_ {
123        self.words.iter().enumerate().flat_map(|(wi, w)| {
124            (0..64).filter_map(move |b| (w & (1u64 << b) != 0).then_some(wi * 64 + b))
125        })
126    }
127}
128
129/// The per-node reachability attributes computed by the ONE
130/// inventory walker ([`PolydatProgram::compute_node_inventory`]).
131/// Every reachability consumer is a projection of this — see the
132/// walker's doc before adding another traversal.
133pub(crate) struct NodeInventory {
134    /// Which inputs transitively feed each node (exact).
135    pub input_provenance: Vec<ProvMask>,
136    /// Nodes that are nondeterministic (nullary / declared) or
137    /// downstream of one — never current.
138    pub nondet_nodes: Vec<usize>,
139    /// Per-node flag: dependency cone contains a
140    /// `Purity::SideChannel` node.
141    pub side_channel_nodes: Vec<bool>,
142}
143
144/// The lifecycle of every node and the nodes that are never current
145/// (`classify_lifecycle`).
146pub(crate) struct LifecycleClasses {
147    pub lifecycle: Vec<EvalLifecycle>,
148    /// Declared nondeterministic or `volatile`, or downstream of one.
149    pub nondeterministic: Vec<bool>,
150}
151
152/// The compile accounting of one program tree: how many programs have
153/// been built for it, on any engine, over its lifetime. A root compile
154/// mints a ledger, and every program built on the tree's behalf
155/// records into the same one: each `for` body, each engine variant of
156/// a body, and each constant expression a traversal source or
157/// predicate compiles at open. A host reads it before and after an
158/// operation to verify the program-invariance property (for_traversal.md §5.1):
159/// compiling builds one program per body, and activation builds none.
160///
161/// Two trees never share a ledger, whatever thread or process runs
162/// them; two kernels over one program do. A compile charged to a
163/// ledger a host already holds is requested through
164/// [`CompileOptions::ledger`](crate::dsl::compile::CompileOptions).
165///
166/// The ledger also holds every native cone the interpreter planned and
167/// left on the interpreter because the cone could not be built
168/// ([`ConeFallback`], engines.md §2.1), and every comprehension compiled
169/// outside `pragma strict` that reads a name nothing binds
170/// ([`UnresolvedNameWarning`], comprehension_forms.md §5 V3).
171#[derive(Debug, Default)]
172pub struct CompileLedger {
173    programs: std::sync::atomic::AtomicU64,
174    cone_fallbacks: std::sync::Mutex<Vec<ConeFallback>>,
175    unresolved_names: std::sync::Mutex<Vec<UnresolvedNameWarning>>,
176}
177
178/// A comprehension statement compiled outside `pragma strict` that reads
179/// names neither the comprehension binds nor the scope it is read in has
180/// (comprehension_forms.md §5 V3), recorded on the tree's
181/// [`CompileLedger`]. The statement compiles, and each such name reads
182/// None when the comprehension is evaluated. Under `pragma strict` the
183/// same statement is refused with `ValidationError::V3UnresolvedNames`.
184#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct UnresolvedNameWarning {
186    /// The diagnostic context of the program the statement belongs to.
187    pub context: String,
188    /// The statement as written: `for <source>` for a traversal, or
189    /// `<name> := for <source>` for a producer binding.
190    pub statement: String,
191    /// Line of the statement.
192    pub line: usize,
193    /// Column of the statement.
194    pub col: usize,
195    /// Each name nothing binds, with where the comprehension reads it,
196    /// in tree order.
197    pub reads: Vec<crate::iteration::comprehension::NameRead>,
198}
199
200impl std::fmt::Display for UnresolvedNameWarning {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        write!(
203            f,
204            "`{}` at line {}, col {}: {}",
205            self.statement,
206            self.line,
207            self.col,
208            crate::iteration::comprehension::ValidationWarning::UnresolvedNames {
209                reads: self.reads.clone()
210            }
211        )
212    }
213}
214
215/// Why a planned native cone stayed on the interpreter (engines.md §2.1).
216#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
217pub enum ConeFallbackKind {
218    /// Native code generation for the cone failed.
219    Codegen,
220    /// A single node reads more distinct boundary inputs than one cone
221    /// piece may have, so no piece can hold it.
222    InputBound,
223}
224
225/// A native cone the interpreter planned under `JitMode::Auto` or
226/// `JitMode::Force` and left on the interpreter, recorded on the tree's
227/// [`CompileLedger`]. Its members run as interpreter nodes, and every
228/// output keeps the value the interpreter computes.
229#[derive(Clone, Debug, PartialEq, Eq)]
230pub struct ConeFallback {
231    /// The diagnostic context of the program the cone belongs to.
232    pub context: String,
233    /// The members' function names in program order; its length is the
234    /// cone's size.
235    pub members: Vec<String>,
236    /// The program outputs the members produce, sorted by name.
237    pub outputs: Vec<String>,
238    /// The distinct boundary inputs the cone reads.
239    pub boundary_inputs: usize,
240    /// Why the cone stayed on the interpreter.
241    pub kind: ConeFallbackKind,
242    /// The code generator's error text, or the bound the inputs exceed.
243    pub reason: String,
244}
245
246impl CompileLedger {
247    /// A fresh ledger with nothing recorded, shared as every holder
248    /// keeps it.
249    pub fn new() -> Arc<Self> {
250        Arc::new(Self::default())
251    }
252
253    /// The programs built for this tree so far, on every engine.
254    pub fn programs(&self) -> u64 {
255        self.programs.load(std::sync::atomic::Ordering::Relaxed)
256    }
257
258    /// Record one program built.
259    pub(crate) fn record(&self) {
260        self.programs
261            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
262    }
263
264    /// The native cones left on the interpreter so far, in the order
265    /// they were planned, across every program of the tree.
266    pub fn cone_fallbacks(&self) -> Vec<ConeFallback> {
267        self.cone_fallbacks
268            .lock()
269            .unwrap_or_else(std::sync::PoisonError::into_inner)
270            .clone()
271    }
272
273    /// Record one native cone left on the interpreter.
274    #[cfg(feature = "jit")]
275    pub(crate) fn record_cone_fallback(&self, fallback: ConeFallback) {
276        self.cone_fallbacks
277            .lock()
278            .unwrap_or_else(std::sync::PoisonError::into_inner)
279            .push(fallback);
280    }
281
282    /// The comprehension statements compiled outside `pragma strict`
283    /// that read names nothing binds, in the order they compiled, across
284    /// every program of the tree, each statement once.
285    pub fn unresolved_names(&self) -> Vec<UnresolvedNameWarning> {
286        self.unresolved_names
287            .lock()
288            .unwrap_or_else(std::sync::PoisonError::into_inner)
289            .clone()
290    }
291
292    /// Record one statement that reads names nothing binds. A body
293    /// compiles once per engine it runs on, so a statement already
294    /// recorded is not recorded again.
295    pub(crate) fn record_unresolved_names(&self, warning: UnresolvedNameWarning) {
296        let mut recorded = self
297            .unresolved_names
298            .lock()
299            .unwrap_or_else(std::sync::PoisonError::into_inner);
300        if !recorded.contains(&warning) {
301            recorded.push(warning);
302        }
303    }
304}
305
306/// Count the programs reachable from `program`: itself plus every
307/// traversal body at every depth. This is the number of compiled
308/// programs a traversing kernel needs for its whole lifetime, however
309/// many tuples it dispenses.
310pub fn program_count(program: &PolydatProgram) -> usize {
311    1 + program
312        .traversals()
313        .iter()
314        .map(|t| program_count(&t.program))
315        .sum::<usize>()
316}
317
318/// A compiled program: the nodes in topological order, their wiring,
319/// the inputs, the outputs, and the metadata the compiler attached.
320/// Immutable once built, and shared across kernels through an `Arc`.
321pub struct PolydatProgram {
322    /// Node instances in topological order.
323    pub(crate) nodes: Vec<Box<dyn PolydatNode>>,
324    /// For each node, the wiring of its input ports.
325    pub(crate) wiring: Vec<Vec<WireSource>>,
326    /// All input definitions (coordinates first, then captures).
327    input_defs: Vec<InputDef>,
328    /// Original source text that produced this program. Arc-shared
329    /// so multiple references (diagnostics, describe, debugger) don't
330    /// duplicate the string. Empty if constructed programmatically.
331    source: Arc<String>,
332    /// Diagnostic context describing where this program came from
333    /// (e.g., "workload.yaml bindings", "phase rampup (pname=label-1)").
334    /// Required on all construction paths — no silent empty contexts.
335    context: Arc<String>,
336    /// How many of the inputs are coordinate inputs (set via set_inputs(&[u64])).
337    /// Inputs at indices [0..coord_count) are coordinates.
338    /// Inputs at indices [coord_count..) are capture inputs.
339    coord_count: usize,
340    /// Map from output variate name to `(node_index, output_port_index)`.
341    pub(crate) output_map: HashMap<String, (usize, usize)>,
342    /// Outputs in declaration order: (name, node_index, port_index).
343    /// Stable ordering for positional access.
344    output_list: Vec<(String, usize, usize)>,
345    /// Per-node input provenance (exact multi-word mask). Bit i
346    /// is set if the node transitively depends on graph input i.
347    /// One projection of the node inventory — see
348    /// [`Self::compute_node_inventory`].
349    pub(crate) input_provenance: Vec<ProvMask>,
350    /// Per-input dependent node lists. For each input, the list of
351    /// node indices that transitively depend on it.
352    input_dependents: Vec<Vec<usize>>,
353    /// Nodes that are nondeterministic (nullary / declared
354    /// `Purity::Nondeterministic`) or downstream of one — shared
355    /// by every state constructor's cache-invalidation seed.
356    nondet_nodes: Vec<usize>,
357    /// Per-node flag: dependency cone contains a
358    /// `Purity::SideChannel` node.
359    side_channel_nodes: Vec<bool>,
360    /// Output binding modifiers: `shared` or `final`.
361    /// Only populated for outputs that have a modifier; absent = default.
362    output_modifiers: HashMap<String, crate::dsl::ast::BindingModifier>,
363    /// Names exposed by this program *only* to pass them through
364    /// the scope chain — not because the scope's own bindings or
365    /// specs reference them. Set by intermediate-scope synthesis
366    /// (for_each / for_combinations / do-loop) when auto-cascading
367    /// workload params or other inherited values: an `extern` is
368    /// declared so `materialize_wiring_from_outer` can wire the value, but the
369    /// scope itself doesn't *own* the name. Display layers
370    /// (scenario tree pre-map, TUI per-scope listing) use this
371    /// to distinguish "names defined here" from "names visible
372    /// here through inheritance."
373    inherited_outputs: std::collections::HashSet<String>,
374    /// Source schemas declared in the Polydat program. The runtime queries
375    /// these to discover data sources and their extents.
376    cursor_schemas: Vec<crate::iteration::source::SourceSchema>,
377    /// The `const` bindings a kernel initializes, in dependency order.
378    const_inits: Vec<crate::kernel::ConstInit>,
379    /// How much of the graph was fused into native cones when the
380    /// program was built: what its kernels report as their engine.
381    cone_mode: crate::compile::cone::JitMode,
382    /// Compiled `for` traversals declared at this program's top level,
383    /// in document order (for_traversal.md). Each carries its child program.
384    traversals: Vec<crate::dsl::traversal::Traversal>,
385    /// Producer bindings (`name := for ...`) declared at this level.
386    producers: Vec<crate::dsl::traversal::Producer>,
387    /// Names declared with the `const` keyword in the source: literal
388    /// consts, folded at build, and consts evaluated when a kernel is
389    /// initialized (`const_inits`). Strict mode reads this set to find a
390    /// const whose value fell through to the enclosing scope.
391    pub(crate) const_outputs: std::collections::HashSet<String>,
392    /// Rule 2 write-through bindings produced when this program
393    /// was synthesized by the subcontext builder's finalize step
394    /// (subcontext_construction.md §3.1).
395    /// Each entry pairs an export name (a cell-bound input slot
396    /// on this program) with the synthetic `__write_<name>`
397    /// source output the rewrite emitted.
398    ///
399    /// Carried on the program — not just on the kernel — so any
400    /// kernel built from this program automatically inherits the
401    /// bindings. Without this, a kernel created from the cached
402    /// program (`from_program` / `create_kernel`) would
403    /// produce a kernel with empty write-throughs and the
404    /// per-cycle commit would silently no-op.
405    pub(crate) write_throughs: Vec<crate::kernel::KernelWriteThrough>,
406    /// Retained AST that produced this program. Live metadata —
407    /// read by the subscope synthesizer to integrate parent
408    /// bindings' matter into child scopes. A binding's graph
409    /// structure may not be contiguous in source text, so the AST
410    /// is the canonical view of what defines each binding. `None`
411    /// only for programmatic construction paths that bypass the
412    /// parser; the DSL entry points always populate this.
413    pub(crate) ast: Option<Arc<PolydatFile>>,
414    /// The ledger this program was recorded in: the root's, shared by
415    /// every program of the tree.
416    ledger: Arc<CompileLedger>,
417    /// The resource scope of the tree this program belongs to: the
418    /// root's, shared by every program of the tree.
419    resources: crate::resource::ResourceScope,
420    /// The digest of the graph the compiler resolved, before the
421    /// interpreter folded or fused it: what makes this program's
422    /// canonical hash equal the compiled engines'. `None` for a program
423    /// built without the compiler, whose own graph is hashed instead.
424    graph_identity: Option<[u8; 32]>,
425}
426
427unsafe impl Send for PolydatProgram {}
428unsafe impl Sync for PolydatProgram {}
429
430impl std::fmt::Debug for PolydatProgram {
431    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432        f.debug_struct("PolydatProgram")
433            .field("nodes", &self.nodes.len())
434            .field("inputs", &self.input_names())
435            .field("coord_count", &self.coord_count)
436            .finish()
437    }
438}
439
440impl PolydatProgram {
441    /// Create a program with explicit input definitions and output
442    /// ordering, recorded in `ledger`.
443    // Nine parameters describe one compiled program definition; a
444    // params struct would reshape the construction protocol, not
445    // clean up a lint — see `PolydatKernel::new_with_inputs`.
446    #[allow(clippy::too_many_arguments)]
447    pub(crate) fn with_inputs(
448        nodes: Vec<Box<dyn PolydatNode>>,
449        wiring: Vec<Vec<WireSource>>,
450        input_defs: Vec<InputDef>,
451        coord_count: usize,
452        output_map: HashMap<String, (usize, usize)>,
453        output_order: Vec<String>,
454        source: &str,
455        context: &str,
456        ledger: Arc<CompileLedger>,
457    ) -> Self {
458        ledger.record();
459        let inventory = Self::compute_node_inventory(&nodes, &wiring);
460        let input_dependents =
461            Self::compute_dependents(&inventory.input_provenance, input_defs.len());
462        let output_list = Self::build_output_list(&output_order, &output_map);
463        Self {
464            nodes,
465            wiring,
466            input_defs,
467            coord_count,
468            output_map,
469            output_list,
470            input_provenance: inventory.input_provenance,
471            input_dependents,
472            nondet_nodes: inventory.nondet_nodes,
473            side_channel_nodes: inventory.side_channel_nodes,
474            source: Arc::new(source.to_string()),
475            context: Arc::new(context.to_string()),
476            output_modifiers: HashMap::new(),
477            inherited_outputs: std::collections::HashSet::new(),
478            cursor_schemas: Vec::new(),
479            const_inits: Vec::new(),
480            cone_mode: crate::compile::cone::JitMode::Off,
481            traversals: Vec::new(),
482            producers: Vec::new(),
483            const_outputs: std::collections::HashSet::new(),
484            write_throughs: Vec::new(),
485            ast: None,
486            ledger,
487            resources: crate::resource::ResourceScope::new(),
488            graph_identity: None,
489        }
490    }
491
492    /// Mark a binding as declared with the `const` keyword. The
493    /// const-binding contract (evaluation_model.md, "Const Binding
494    /// Contract") is checked against this set.
495    pub(crate) fn mark_const_output(&mut self, name: &str) {
496        self.const_outputs.insert(name.to_string());
497    }
498
499    /// Set the program's Rule 2 write-through bindings. Called
500    /// once by the subcontext builder's finalize step right after
501    /// compile, while the program Arc is still uniquely owned.
502    /// Every kernel built from this program afterwards inherits
503    /// the bindings via `from_program`'s automatic seeding.
504    pub(crate) fn set_write_throughs(
505        &mut self,
506        write_throughs: Vec<crate::kernel::KernelWriteThrough>,
507    ) {
508        self.write_throughs = write_throughs;
509    }
510
511    /// Read this program's Rule 2 write-through bindings.
512    /// Used by `PolydatKernel::from_program` to auto-seed the
513    /// kernel's `write_throughs` field, so the per-fiber
514    /// re-instance path picks them up without a side channel.
515    pub(crate) fn write_throughs(&self) -> &[crate::kernel::KernelWriteThrough] {
516        &self.write_throughs
517    }
518
519    /// Attach the parsed AST as live metadata. Called once by
520    /// every DSL compile entry point right after assembly, while
521    /// the program Arc is still uniquely owned.
522    pub(crate) fn set_ast(&mut self, ast: Arc<PolydatFile>) {
523        self.ast = Some(ast);
524    }
525
526    /// The retained AST that produced this program, if any. The
527    /// subscope synthesizer queries this to integrate parent bindings'
528    /// graph structure into child scopes. Returns `None` for
529    /// programs built via programmatic (non-DSL) paths.
530    pub fn ast(&self) -> Option<&Arc<PolydatFile>> {
531        self.ast.as_ref()
532    }
533
534    /// The compile ledger of the tree this program belongs to.
535    pub fn ledger(&self) -> &Arc<CompileLedger> {
536        &self.ledger
537    }
538
539    /// The resource scope of the tree this program belongs to.
540    pub fn resources(&self) -> &crate::resource::ResourceScope {
541        &self.resources
542    }
543
544    /// Record the resource scope of the tree, before the program is
545    /// shared.
546    pub(crate) fn set_resources(&mut self, resources: crate::resource::ResourceScope) {
547        self.resources = resources;
548    }
549
550    /// Find the `Statement` that defines binding `name` in this
551    /// program's retained AST. Matches both single-target
552    /// `InitBinding`/`CycleBinding` and tuple-target destructuring
553    /// bindings (where `name` is one of several targets). Returns
554    /// `None` if no AST is retained or no binding defines `name`.
555    pub fn binding_ast_for(&self, name: &str) -> Option<&Statement> {
556        let ast = self.ast.as_ref()?;
557        ast.statements.iter().find(|stmt| match stmt {
558            Statement::Binding(b) => b.targets.iter().any(|t| t == name),
559            _ => false,
560        })
561    }
562
563    /// Compute the transitive closure of bindings needed to
564    /// materialise `name` locally in a descendant scope: case 3
565    /// (local matter inclusion) of the wire-reference classification.
566    ///
567    /// Starting from the binding that defines `name`, recursively
568    /// walk the RHS expression tree following `Ident` references.
569    /// For each referenced name, if it's defined by another
570    /// binding in this program's AST AND is not effectively final
571    /// (the four-case rule treats final as a separate cascade),
572    /// include that binding too and recurse.
573    ///
574    /// Termination boundaries:
575    /// - `final` / `shared` outputs (effectively const upstream;
576    ///   caller emits as promoted-final in case 1)
577    /// - `extern` ports (caller handles as case 2 cascade)
578    /// - Input slots (`cycle`, etc.)
579    /// - Names defined nowhere (will surface as unresolved at
580    ///   compile time of the child scope)
581    ///
582    /// Returns the bindings in topological order (dependencies
583    /// first), each statement once: a tuple-target statement whose
584    /// targets are needed more than once appears at the first. The
585    /// contract is wire_materialization.md, "Local inclusion".
586    /// Names already in `excluded` are not re-walked,
587    /// letting callers express "stop here — this name is locally
588    /// defined / coordinated / already collected".
589    pub fn local_inclusion_chain<'a>(
590        &'a self,
591        name: &str,
592        excluded: &std::collections::HashSet<String>,
593    ) -> Vec<&'a Statement> {
594        let mut out: Vec<&'a Statement> = Vec::new();
595        let mut visited: std::collections::HashSet<String> = excluded.clone();
596        self.collect_chain_into(name, &mut out, &mut visited);
597        out
598    }
599
600    fn collect_chain_into<'a>(
601        &'a self,
602        name: &str,
603        out: &mut Vec<&'a Statement>,
604        visited: &mut std::collections::HashSet<String>,
605    ) {
606        if !visited.insert(name.to_string()) {
607            return;
608        }
609        // `final` / `shared` bindings stop the walk: they're case 1
610        // (promoted-final or shared-cell) at the call site, not
611        // case 3. Skip silently.
612        let modifier = self.output_modifier(name);
613        if modifier == crate::dsl::ast::BindingModifier::CONST
614            || modifier == crate::dsl::ast::BindingModifier::SHARED
615        {
616            return;
617        }
618        let Some(stmt) = self.binding_ast_for(name) else {
619            return;
620        };
621        let Statement::Binding(binding) = stmt else {
622            return;
623        };
624        // The statement defines every one of its targets, so each
625        // target is visited now: a tuple-target statement is included
626        // once, however many of its targets the chain needs.
627        for target in &binding.targets {
628            visited.insert(target.clone());
629        }
630        let value = &binding.value;
631        // Recurse into dependencies first, then push this stmt —
632        // produces topo order (deps before dependents).
633        let mut refs = std::collections::HashSet::new();
634        crate::dsl::validate::collect_references(value, &mut refs);
635        let mut refs_sorted: Vec<String> = refs.into_iter().collect();
636        refs_sorted.sort();
637        for r in refs_sorted {
638            self.collect_chain_into(&r, out, visited);
639        }
640        out.push(stmt);
641    }
642
643    /// Read the input classification for slot `idx`.
644    pub fn input_kind(&self, idx: usize) -> Option<crate::kernel::InputKind> {
645        self.input_defs.get(idx).map(|d| d.kind)
646    }
647
648    /// Look up `name` in the output map, returning `(node_idx, port_idx)`.
649    /// Public surface for the scope-init pass and other consumers
650    /// outside the kernel module.
651    pub fn output_map_lookup(&self, name: &str) -> Option<&(usize, usize)> {
652        self.output_map.get(name)
653    }
654
655    /// Iterate every (output-name, (node_idx, port_idx)) pair.
656    /// Used by the eval-panic enricher to reverse-resolve which
657    /// output(s) a given node feeds when reporting which binding
658    /// the panic originated from.
659    pub fn output_map_iter(&self) -> impl Iterator<Item = (&String, &(usize, usize))> {
660        self.output_map.iter()
661    }
662
663    /// Set the binding modifier for a named output.
664    pub(crate) fn set_output_modifier(
665        &mut self,
666        name: &str,
667        modifier: crate::dsl::ast::BindingModifier,
668    ) {
669        if modifier != crate::dsl::ast::BindingModifier::NONE {
670            self.output_modifiers.insert(name.to_string(), modifier);
671            if modifier.is_volatile() {
672                self.refresh_never_current();
673            }
674        }
675    }
676
677    /// Recompute the nodes an engine may never treat as current.
678    ///
679    /// The inventory computes that set when the program is built,
680    /// from the nodes' own declarations, and the output modifiers are
681    /// installed after — so the build's set leaves out a node feeding
682    /// a `volatile` output, and this recomputes it. `volatile` is the
683    /// author's statement that a wire's value is not a function of
684    /// its inputs, which is precisely the case the node cannot declare
685    /// for itself, and evaluation_model.md §"Non-Deterministic Nodes"
686    /// says such a node is excluded from the fold *and* never treated
687    /// as current, so a volatile binding is not cached per cycle.
688    fn refresh_never_current(&mut self) {
689        let classes = Self::classify_lifecycle(
690            &self.nodes,
691            &self.wiring,
692            &self.input_defs,
693            &self.output_map,
694            &self.output_modifiers,
695        );
696        let mut marked = vec![false; self.nodes.len()];
697        for &i in &self.nondet_nodes {
698            marked[i] = true;
699        }
700        for (i, nd) in classes.nondeterministic.iter().enumerate() {
701            if *nd {
702                marked[i] = true;
703            }
704        }
705        self.nondet_nodes = marked
706            .iter()
707            .enumerate()
708            .filter_map(|(i, m)| m.then_some(i))
709            .collect();
710    }
711
712    /// Query the binding modifier for a named output.
713    pub fn output_modifier(&self, name: &str) -> crate::dsl::ast::BindingModifier {
714        self.output_modifiers
715            .get(name)
716            .copied()
717            .unwrap_or(crate::dsl::ast::BindingModifier::NONE)
718    }
719
720    /// Return all output names that have the `shared` modifier.
721    pub fn shared_outputs(&self) -> Vec<&str> {
722        self.output_modifiers
723            .iter()
724            .filter(|(_, m)| **m == crate::dsl::ast::BindingModifier::SHARED)
725            .map(|(n, _)| n.as_str())
726            .collect()
727    }
728
729    /// Mark `name` as an inherited (cascade-propagated) output —
730    /// declared on this program only to flow the value through
731    /// to descendants via `materialize_wiring_from_outer`, not because this
732    /// scope's own bindings or specs reference it.
733    pub fn mark_inherited(&mut self, name: &str) {
734        self.inherited_outputs.insert(name.to_string());
735    }
736
737    /// Is `name` an inherited (cascade-propagated) output? See
738    /// [`Self::mark_inherited`].
739    pub fn is_inherited(&self, name: &str) -> bool {
740        self.inherited_outputs.contains(name)
741    }
742
743    /// Return only the outputs *owned* by this program — names
744    /// the scope's own bindings, externs, or specs declared,
745    /// excluding inherited cascade-propagation outputs. Used by
746    /// the scenario tree pre-map and TUI to render per-scope
747    /// "what's defined here" without listing every inherited
748    /// name. Output order matches `output_names`.
749    pub fn own_output_names(&self) -> Vec<&str> {
750        self.output_names()
751            .into_iter()
752            .filter(|name| !self.inherited_outputs.contains(*name))
753            .collect()
754    }
755
756    /// Return all output names that have the `const` modifier.
757    pub fn const_outputs(&self) -> Vec<&str> {
758        self.output_modifiers
759            .iter()
760            .filter(|(_, m)| **m == crate::dsl::ast::BindingModifier::CONST)
761            .map(|(n, _)| n.as_str())
762            .collect()
763    }
764
765    /// The original source text that produced this program.
766    pub fn source(&self) -> &str {
767        &self.source
768    }
769
770    /// Diagnostic context (e.g., "workload.yaml bindings").
771    pub fn context(&self) -> &str {
772        &self.context
773    }
774
775    /// Source schemas declared in this program. The runtime queries
776    /// these to discover data sources, their extents, and projections.
777    pub fn cursor_schemas(&self) -> &[crate::iteration::source::SourceSchema] {
778        &self.cursor_schemas
779    }
780
781    /// Set source schemas (called by the compiler after processing source declarations).
782    pub(crate) fn set_cursor_schemas(
783        &mut self,
784        schemas: Vec<crate::iteration::source::SourceSchema>,
785    ) {
786        self.cursor_schemas = schemas;
787    }
788
789    /// The `const` bindings a kernel of this program initializes, in the
790    /// order it evaluates them.
791    pub fn const_inits(&self) -> &[crate::kernel::ConstInit] {
792        &self.const_inits
793    }
794
795    /// Whether the output's value is fixed for a kernel's life: a
796    /// `const`, captured at initialization, or a value folded at build,
797    /// whose node reads nothing and is not nondeterministic. A computed
798    /// output is not, whether or not it has been pulled.
799    pub(crate) fn is_fixed_output(&self, name: &str) -> bool {
800        if self.const_outputs.contains(name) {
801            return true;
802        }
803        self.output_map.get(name).is_some_and(|(node, _)| {
804            self.wiring[*node].is_empty() && !self.nondet_nodes.contains(node)
805        })
806    }
807
808    /// Record the const bindings, before the program is shared.
809    pub(crate) fn set_const_inits(&mut self, inits: Vec<crate::kernel::ConstInit>) {
810        self.const_inits = inits;
811    }
812
813    /// How much of the graph was fused into native cones at build.
814    pub fn cone_mode(&self) -> crate::compile::cone::JitMode {
815        self.cone_mode
816    }
817
818    pub(crate) fn set_cone_mode(&mut self, mode: crate::compile::cone::JitMode) {
819        self.cone_mode = mode;
820    }
821
822    /// The `for` traversals declared at this program's top level, each
823    /// with its compiled child program (for_traversal.md §5.1: one program per
824    /// lexical position).
825    pub fn traversals(&self) -> &[crate::dsl::traversal::Traversal] {
826        &self.traversals
827    }
828
829    /// Producer bindings declared at this program's top level.
830    pub fn producers(&self) -> &[crate::dsl::traversal::Producer] {
831        &self.producers
832    }
833
834    pub(crate) fn set_traversals(
835        &mut self,
836        traversals: Vec<crate::dsl::traversal::Traversal>,
837        producers: Vec<crate::dsl::traversal::Producer>,
838    ) {
839        self.traversals = traversals;
840        self.producers = producers;
841    }
842
843    /// Build ordered output list from declaration order and the output map.
844    fn build_output_list(
845        output_order: &[String],
846        output_map: &HashMap<String, (usize, usize)>,
847    ) -> Vec<(String, usize, usize)> {
848        // Use declaration order from the assembler
849        let mut list: Vec<(String, usize, usize)> = output_order
850            .iter()
851            .filter_map(|name| output_map.get(name).map(|&(ni, pi)| (name.clone(), ni, pi)))
852            .collect();
853        // Add any outputs not in the declaration order (shouldn't happen,
854        // but defensive against manual assembler use). Sort the
855        // tail by name so the ordering is deterministic across
856        // processes — HashMap iteration is per-process-randomised,
857        // and a deterministic tail keeps the canonical-program
858        // identity (and therefore checkpoint phase-hash) stable
859        // across resume invocations.
860        let mut tail: Vec<(&String, &(usize, usize))> = output_map
861            .iter()
862            .filter(|(name, _)| !output_order.contains(*name))
863            .collect();
864        tail.sort_by(|a, b| a.0.cmp(b.0));
865        for (name, &(ni, pi)) in tail {
866            list.push((name.clone(), ni, pi));
867        }
868        list
869    }
870
871    /// Invert provenance into per-input dependent node lists.
872    pub(crate) fn compute_dependents(
873        provenance: &[ProvMask],
874        num_inputs: usize,
875    ) -> Vec<Vec<usize>> {
876        let mut deps = vec![Vec::new(); num_inputs];
877        for (node_idx, prov) in provenance.iter().enumerate() {
878            for (input_idx, dep) in deps.iter_mut().enumerate() {
879                if prov.contains(input_idx) {
880                    dep.push(node_idx);
881                }
882            }
883        }
884        deps
885    }
886
887    /// Thin projection for callers that need only the provenance
888    /// masks (assembly/select/hybrid feed them straight into
889    /// [`Self::compute_dependents`]). Same ONE walker underneath.
890    pub(crate) fn compute_provenance(
891        nodes: &[Box<dyn PolydatNode>],
892        wiring: &[Vec<WireSource>],
893    ) -> Vec<ProvMask> {
894        Self::compute_node_inventory(nodes, wiring).input_provenance
895    }
896
897    /// The runtime model's lifecycle classification of every node
898    /// (graph_compiler.md §3.1), the one rule the interpreter's
899    /// fold and every compiled engine share: a node is compile-constant
900    /// when no coordinate or external-write input reaches it and neither
901    /// it nor anything upstream is declared nondeterministic or
902    /// `volatile`; scope-init when only iteration externs reach it;
903    /// dynamic otherwise. `nondeterministic` is the declared volatility
904    /// and its downstream contagion on its own, which an engine never
905    /// treats as current.
906    pub(crate) fn classify_lifecycle(
907        nodes: &[Box<dyn PolydatNode>],
908        wiring: &[Vec<WireSource>],
909        input_defs: &[InputDef],
910        output_map: &HashMap<String, (usize, usize)>,
911        output_modifiers: &HashMap<String, crate::dsl::ast::BindingModifier>,
912    ) -> LifecycleClasses {
913        use crate::kernel::InputKind;
914        let n = nodes.len();
915        let mut lifecycle: Vec<EvalLifecycle> = vec![EvalLifecycle::CompileConst; n];
916        let mut nondeterministic: Vec<bool> = vec![false; n];
917        for (i, wires) in wiring.iter().enumerate() {
918            for source in wires {
919                if let WireSource::Input(idx) = source {
920                    let kind = input_defs
921                        .get(*idx)
922                        .map(|d| d.kind)
923                        .unwrap_or(InputKind::Coordinate);
924                    let lc = match kind {
925                        // A const slot holds a value fixed when the kernel is
926                        // initialized, so what reads it is not per-cycle work.
927                        InputKind::IterationExtern | InputKind::Const => EvalLifecycle::ScopeInit,
928                        InputKind::Coordinate | InputKind::ExternalWrite => EvalLifecycle::Dynamic,
929                    };
930                    if lc > lifecycle[i] {
931                        lifecycle[i] = lc;
932                    }
933                }
934            }
935            // Per R1.v: a node declaring `Purity::Nondeterministic` is
936            // intrinsically volatile; the fold leaves it alone and the
937            // canonical hash sees its shape, never a value.
938            let declared = matches!(
939                nodes[i].purity(),
940                crate::ast::Purity::Nondeterministic { .. }
941            );
942            // `volatile` is the author's declaration that a wire's
943            // value is nondeterministic across invocations and must not
944            // be folded into the workload's identity
945            // (composition_substrate.md §3, "Volatility opt-in"). Every
946            // output modifier is walked, not only the
947            // exposed outputs, so a binding pruned from the output list
948            // still marks its producing node.
949            let modifier = output_modifiers.iter().any(|(name, m)| {
950                m.is_volatile()
951                    && output_map
952                        .get(name)
953                        .map(|(ni, _)| *ni == i)
954                        .unwrap_or(false)
955            });
956            if declared || modifier {
957                lifecycle[i] = EvalLifecycle::Dynamic;
958                nondeterministic[i] = true;
959            }
960        }
961        // Propagate: a node's lifecycle is the max of its own seed and
962        // every upstream node's, and volatility is contagious downstream.
963        let mut changed = true;
964        while changed {
965            changed = false;
966            for i in 0..n {
967                for source in &wiring[i] {
968                    if let WireSource::NodeOutput(upstream, _) = source {
969                        if lifecycle[*upstream] > lifecycle[i] {
970                            lifecycle[i] = lifecycle[*upstream];
971                            changed = true;
972                        }
973                        if nondeterministic[*upstream] && !nondeterministic[i] {
974                            nondeterministic[i] = true;
975                            changed = true;
976                        }
977                    }
978                }
979            }
980        }
981        LifecycleClasses {
982            lifecycle,
983            nondeterministic,
984        }
985    }
986
987    /// THE node-inventory walker — the ONE forward pass over the
988    /// wire graph that computes every per-node reachability
989    /// attribute the program carries:
990    ///
991    /// - **input provenance** — which inputs transitively feed
992    ///   each node, as an exact multi-word [`ProvMask`], so a
993    ///   consumed-params projection on a many-param workload root
994    ///   sees every input it reads;
995    /// - **nondeterminism contagion** — nullary or
996    ///   `Purity::Nondeterministic` nodes and everything
997    ///   downstream of them (per R1.v's intrinsic-volatility
998    ///   carve-out; consumers of a volatile producer must not
999    ///   retain stale cached values across cycles);
1000    /// - **side-channel contagion** — nodes whose dependency
1001    ///   cone contains a `Purity::SideChannel` node (`log_*`,
1002    ///   diagnostics), so the per-cycle fire-side-effects pass
1003    ///   knows which outputs to pull.
1004    ///
1005    /// Every other consumer — engine invalidation
1006    /// (`compute_dependents` → `input_dependents`, and the JIT's
1007    /// slot provenance derived from it), `extern_closure`,
1008    /// `cone_has_side_channel`, the two state constructors — is
1009    /// a PROJECTION of this inventory. Do not add another
1010    /// traversal over `wiring` for a per-node attribute; add a
1011    /// field here. (The engine cone guards — JIT and closure
1012    /// kernels' slot provenance / changed masks — carry the same
1013    /// multi-word [`ProvMask`] shape host-side; the generated
1014    /// machine code never sees a mask.)
1015    ///
1016    /// Fixpoint iteration (not a single topo pass) so the
1017    /// inventory is correct regardless of node ordering; the
1018    /// graphs are DAGs, so it converges in at most graph-depth
1019    /// rounds and in practice two.
1020    pub(crate) fn compute_node_inventory(
1021        nodes: &[Box<dyn PolydatNode>],
1022        wiring: &[Vec<WireSource>],
1023    ) -> NodeInventory {
1024        let n = nodes.len();
1025        let mut prov: Vec<ProvMask> = (0..n).map(|_| ProvMask::empty()).collect();
1026        let mut nondet: Vec<bool> = (0..n)
1027            .map(|i| {
1028                let nullary = wiring[i].is_empty() && nodes[i].meta().ins.is_empty();
1029                let declared = matches!(
1030                    nodes[i].purity(),
1031                    crate::ast::Purity::Nondeterministic { .. }
1032                );
1033                nullary || declared
1034            })
1035            .collect();
1036        let mut side: Vec<bool> = (0..n)
1037            .map(|i| matches!(nodes[i].purity(), crate::ast::Purity::SideChannel { .. }))
1038            .collect();
1039
1040        let mut changed = true;
1041        while changed {
1042            changed = false;
1043            for i in 0..n {
1044                for source in &wiring[i] {
1045                    match source {
1046                        WireSource::Input(idx) => {
1047                            changed |= prov[i].set(*idx);
1048                        }
1049                        WireSource::NodeOutput(up, _) => {
1050                            let up = *up;
1051                            if up == i {
1052                                continue; // defensive: DAGs don't self-loop
1053                            }
1054                            let (a, b) = if up < i {
1055                                let (l, r) = prov.split_at_mut(i);
1056                                (&l[up], &mut r[0])
1057                            } else {
1058                                let (l, r) = prov.split_at_mut(up);
1059                                (&r[0], &mut l[i])
1060                            };
1061                            changed |= b.union_with(a);
1062                            if nondet[up] && !nondet[i] {
1063                                nondet[i] = true;
1064                                changed = true;
1065                            }
1066                            if side[up] && !side[i] {
1067                                side[i] = true;
1068                                changed = true;
1069                            }
1070                        }
1071                    }
1072                }
1073            }
1074        }
1075        NodeInventory {
1076            input_provenance: prov,
1077            nondet_nodes: (0..n).filter(|&i| nondet[i]).collect(),
1078            side_channel_nodes: side,
1079        }
1080    }
1081
1082    /// The scratch every node of this program declares, one set per
1083    /// node, for a state of its own (axiom S3): storage belongs to the
1084    /// state, never to the shared node.
1085    fn node_scratch(&self) -> Vec<Vec<crate::ast::ScratchBuf>> {
1086        self.nodes
1087            .iter()
1088            .map(|n| {
1089                n.scratch_layout()
1090                    .into_iter()
1091                    .map(crate::ast::ScratchBuf::new)
1092                    .collect()
1093            })
1094            .collect()
1095    }
1096
1097    /// Build an EngineCore (shared by all state constructors).
1098    fn build_engine_core(&self) -> EngineCore {
1099        let buffers: Vec<Vec<Value>> = self
1100            .nodes
1101            .iter()
1102            .map(|n| vec![Value::None; n.meta().outs.len()])
1103            .collect();
1104        let node_count = self.nodes.len();
1105        let inputs: Vec<Value> = self.input_defs.iter().map(|d| d.default.clone()).collect();
1106        let input_defaults = inputs.clone();
1107        let max_inputs = self.wiring.iter().map(|w| w.len()).max().unwrap_or(0);
1108        let input_count = inputs.len();
1109        EngineCore {
1110            buffers,
1111            node_clean: vec![false; node_count],
1112            inputs,
1113            input_defaults,
1114            shared_cells: vec![None; input_count],
1115            // Broadcast cells allocated by `seed_output_cells`
1116            // (called from kernel constructors;
1117            // cross_fiber_invalidation.md §3.1). Start with an
1118            // empty Vec — the seed pass sizes it to match output
1119            // count.
1120            output_cells: Vec::new(),
1121            broadcasting: std::sync::atomic::AtomicBool::new(false),
1122            input_scratch: vec![Value::None; max_inputs],
1123            node_scratch: self.node_scratch(),
1124            // Per-scope intent-dirty vector + bit allocator
1125            // (cross_fiber_invalidation.md §3.1). Fresh atomic
1126            // per EngineCore — one per fiber state — so cells
1127            // allocated through this core publish their dirty
1128            // intent through a single shared atomic that this
1129            // fiber's check_clean walker reads against the
1130            // cone's interest mask.
1131            scope_intent_words: Vec::new(),
1132            next_cell_bit: 0,
1133            last_seen: std::collections::HashMap::new(),
1134            cell_cones: Vec::new(),
1135        }
1136    }
1137
1138    /// Create a new evaluation state for this program.
1139    pub fn create_state(&self) -> PolydatState {
1140        let buffers: Vec<Vec<Value>> = self
1141            .nodes
1142            .iter()
1143            .map(|n| vec![Value::None; n.meta().outs.len()])
1144            .collect();
1145        let node_count = self.nodes.len();
1146
1147        let inputs: Vec<Value> = self.input_defs.iter().map(|d| d.default.clone()).collect();
1148        let input_defaults = inputs.clone();
1149
1150        let max_inputs = self.wiring.iter().map(|w| w.len()).max().unwrap_or(0);
1151
1152        // Nondeterminism contagion (R1.v's intrinsic-volatility
1153        // carve-out): precomputed by the ONE inventory walker at
1154        // construction — see `compute_node_inventory`.
1155        let nondeterministic_nodes: Vec<usize> = self.nondet_nodes.clone();
1156
1157        let input_count = inputs.len();
1158        let core = EngineCore {
1159            buffers,
1160            node_clean: vec![false; node_count],
1161            inputs,
1162            input_defaults,
1163            shared_cells: vec![None; input_count],
1164            // Broadcast cells allocated by `seed_output_cells`
1165            // (called from kernel constructors;
1166            // cross_fiber_invalidation.md §3.1). Start with an
1167            // empty Vec — the seed pass sizes it to match output
1168            // count.
1169            output_cells: Vec::new(),
1170            broadcasting: std::sync::atomic::AtomicBool::new(false),
1171            input_scratch: vec![Value::None; max_inputs],
1172            node_scratch: self.node_scratch(),
1173            // Per-scope intent-dirty vector + bit allocator
1174            // (cross_fiber_invalidation.md §3.1).
1175            scope_intent_words: Vec::new(),
1176            next_cell_bit: 0,
1177            last_seen: std::collections::HashMap::new(),
1178            cell_cones: Vec::new(),
1179        };
1180
1181        PolydatState::from_parts(core, self.input_dependents.clone(), nondeterministic_nodes)
1182    }
1183
1184    /// Create the provenance-scan engine state (for benchmarking).
1185    pub fn create_provscan_state(&self) -> ProvScanState {
1186        let core = self.build_engine_core();
1187        // Nondeterminism contagion (R1.v's intrinsic-volatility
1188        // carve-out): precomputed by the ONE inventory walker at
1189        // construction — see `compute_node_inventory`.
1190        let nondeterministic_nodes: Vec<usize> = self.nondet_nodes.clone();
1191        ProvScanState::from_parts(core, self.input_provenance.clone(), nondeterministic_nodes)
1192    }
1193
1194    /// Return the names of all inputs.
1195    pub fn input_names(&self) -> Vec<String> {
1196        self.input_defs.iter().map(|d| d.name.clone()).collect()
1197    }
1198
1199    /// Return the number of coordinate inputs.
1200    pub fn coord_count(&self) -> usize {
1201        self.coord_count
1202    }
1203
1204    /// Find an input by name. Returns its index.
1205    pub fn find_input(&self, name: &str) -> Option<usize> {
1206        self.input_defs.iter().position(|d| d.name == name)
1207    }
1208
1209    /// Lookup the declared port type of a named input: for a converted
1210    /// input, the type its readers see, not the `Dyn` slot it is
1211    /// written through (input_variance.md §5).
1212    /// Returns `None` if the name isn't an input of this program.
1213    pub fn input_port_type(&self, name: &str) -> Option<crate::ast::PortType> {
1214        self.input_defs
1215            .iter()
1216            .find(|d| d.name == name)
1217            .map(|d| d.converts_to.unwrap_or(d.port_type))
1218    }
1219
1220    /// How input `name`'s type was established (input_variance.md §3).
1221    pub fn input_type_origin(&self, name: &str) -> Option<crate::kernel::TypeOrigin> {
1222        self.input_defs
1223            .iter()
1224            .find(|d| d.name == name)
1225            .map(|d| d.type_origin)
1226    }
1227
1228    /// Lookup the declared port type of an input by index.
1229    /// Returns `None` if `idx` is out of range. The typed write
1230    /// checks a value against it without reverse-resolving an index
1231    /// to a name.
1232    pub fn input_port_type_by_idx(&self, idx: usize) -> Option<crate::ast::PortType> {
1233        self.input_defs.get(idx).map(|d| d.port_type)
1234    }
1235
1236    /// The declared default for input `idx` — the wire's initial
1237    /// element. The capture layer's reset semantics (an empty
1238    /// min/max fold restores the wire to its author-declared
1239    /// identity rather than leaving `Value::None` on a typed slot)
1240    /// read it through this accessor.
1241    pub fn input_default_by_idx(&self, idx: usize) -> Option<&Value> {
1242        self.input_defs.get(idx).map(|d| &d.default)
1243    }
1244
1245    /// The name of the input at `idx`, if there is one.
1246    pub fn input_name_by_idx(&self, idx: usize) -> Option<&str> {
1247        self.input_defs.get(idx).map(|d| d.name.as_str())
1248    }
1249
1250    /// Number of declared outputs.
1251    pub fn output_count(&self) -> usize {
1252        self.output_list.len()
1253    }
1254
1255    /// Output name at index (declaration order).
1256    pub fn output_name(&self, idx: usize) -> &str {
1257        &self.output_list[idx].0
1258    }
1259
1260    /// Return all output names in declaration order.
1261    pub fn output_names(&self) -> Vec<&str> {
1262        self.output_list
1263            .iter()
1264            .map(|(n, _, _)| n.as_str())
1265            .collect()
1266    }
1267
1268    /// Resolve an output name to its (node_index, port_index).
1269    ///
1270    /// Dotted names follow the field-access wire convention
1271    /// (`q.cursor.idx` is the wire `q__cursor__idx`), so a
1272    /// text-context reference resolves through the same
1273    /// flattening the DSL compiler applies — mirroring
1274    /// `PolydatKernel::lookup`.
1275    pub fn resolve_output(&self, name: &str) -> Option<(usize, usize)> {
1276        if let Some(found) = self.output_map.get(name).copied() {
1277            return Some(found);
1278        }
1279        if name.contains('.') {
1280            return self.output_map.get(&name.replace('.', "__")).copied();
1281        }
1282        None
1283    }
1284
1285    /// Resolve an output index to its (node_index, port_index).
1286    pub fn resolve_output_by_index(&self, idx: usize) -> (usize, usize) {
1287        let (_, ni, pi) = &self.output_list[idx];
1288        (*ni, *pi)
1289    }
1290
1291    /// Output names whose dependency cone contains a side-effecting
1292    /// (`Purity::SideChannel`) node — `log_*`, diagnostics, etc. These
1293    /// are the outputs a per-cycle "fire side effects" pass must pull so
1294    /// the effect runs even when the value is unused. An output whose
1295    /// cone is side-effect-free — including a pure or volatile
1296    /// metric-reader value — is excluded: it is evaluated only when its
1297    /// value is actually consumed, never per cycle just to fire a
1298    /// non-existent effect.
1299    pub fn outputs_with_side_effects(&self) -> Vec<String> {
1300        self.output_list
1301            .iter()
1302            .filter(|(_, node_idx, _)| self.cone_has_side_channel(*node_idx))
1303            .map(|(name, _, _)| name.clone())
1304            .collect()
1305    }
1306
1307    /// True if `start`'s transitive input cone contains a node declaring
1308    /// `Purity::SideChannel`. A projection of the construction-time
1309    /// node inventory — see [`Self::compute_node_inventory`].
1310    fn cone_has_side_channel(&self, start: usize) -> bool {
1311        self.side_channel_nodes.get(start).copied().unwrap_or(false)
1312    }
1313
1314    /// Find the output index for a name (for building memoized getters).
1315    pub(crate) fn output_list(&self) -> &[(String, usize, usize)] {
1316        &self.output_list
1317    }
1318
1319    /// The position of a named output in the output list, if declared.
1320    pub fn output_index(&self, name: &str) -> Option<usize> {
1321        self.output_list.iter().position(|(n, _, _)| n == name)
1322    }
1323
1324    /// Look up an output's [`crate::ast::PortType`] by name.
1325    ///
1326    /// Returns `None` for names not declared as outputs of this
1327    /// program. Used by the binder verification path
1328    /// (`polydat::binder::verify_against_kernel`) to type-check
1329    /// adapter binding shapes against the actual kernel wire
1330    /// types — symmetric counterpart to `input_port_type`.
1331    pub fn output_port_type(&self, name: &str) -> Option<crate::ast::PortType> {
1332        let (node_idx, port_idx) = self.resolve_output(name)?;
1333        let meta = self.node_meta(node_idx);
1334        meta.outs.get(port_idx).map(|out| out.typ)
1335    }
1336
1337    /// Get the provenance mask for a node by index. `None` for an
1338    /// out-of-range node index.
1339    pub fn input_provenance_for(&self, node_idx: usize) -> Option<&ProvMask> {
1340        self.input_provenance.get(node_idx)
1341    }
1342
1343    /// Hash-compare two programs for AST / constant
1344    /// equivalence. Two programs that produce the
1345    /// same `canonical_hash` are functionally equivalent at
1346    /// compile time; their runtime instances would differ
1347    /// only by parent-bound values, which `materialize_wiring_from_outer`
1348    /// handles. Cheap (one hash compare); doesn't allocate
1349    /// state. The pre-walker uses this to flatten one scope
1350    /// into another that materialises identical content.
1351    pub fn is_equivalent_to(&self, other: &PolydatProgram) -> bool {
1352        self.canonical_hash() == other.canonical_hash()
1353    }
1354
1355    /// The "can-flatten?" predicate. Returns true
1356    /// when this program adds no Polydat content the parent
1357    /// program doesn't already supply — i.e. when the inner
1358    /// scope's contribution is structurally a subset of the
1359    /// parent's. The pre-walker uses this for nodes that
1360    /// classified as `PolydatMatter::Definitions` to detect cases
1361    /// where the new content turns out to be parent-equivalent
1362    /// (rare, but correct: a binding that duplicates a parent
1363    /// declaration is structurally a no-op).
1364    ///
1365    /// The check is structural: true when the inner program has
1366    /// no outputs but its inputs' own and no inputs beyond what the
1367    /// parent already exposes. New bindings whose definitions equal
1368    /// parent bindings are not recognized as a subset.
1369    pub fn is_subset_of(&self, parent: &PolydatProgram) -> bool {
1370        // Equivalent programs flatten trivially.
1371        if self.is_equivalent_to(parent) {
1372            return true;
1373        }
1374        // The inner program contributes new content if it
1375        // declares outputs the parent doesn't, or constants
1376        // / nodes the parent doesn't carry. Cheapest check:
1377        // an inner program with no outputs of its own and
1378        // every input also declared by the parent is a
1379        // structural no-op. An output named for an input is the
1380        // input itself, which the compiler re-exports by name.
1381        let inputs: std::collections::HashSet<&str> =
1382            self.input_defs.iter().map(|d| d.name.as_str()).collect();
1383        if self
1384            .output_list
1385            .iter()
1386            .any(|(name, _, _)| !inputs.contains(name.as_str()))
1387        {
1388            return false;
1389        }
1390        // Inputs: every name declared by `self` must be
1391        // declared by `parent` (parent supplies the value).
1392        // Inner program might have empty input_defs entirely
1393        // — that's the "trivial wrapper" case and trivially
1394        // a subset.
1395        let parent_inputs: std::collections::HashSet<&str> =
1396            parent.input_defs.iter().map(|d| d.name.as_str()).collect();
1397        for d in &self.input_defs {
1398            if !parent_inputs.contains(d.name.as_str()) {
1399                return false;
1400            }
1401        }
1402        true
1403    }
1404
1405    /// Aggregate identity over this program **plus** an outer
1406    /// chain of ancestor programs (innermost first; the
1407    /// workload-root program is last). The result is a
1408    /// SHA-256 over each program's `canonical_hash` in
1409    /// declaration order, prefixed with a versioned tag so
1410    /// future reshapings can be detected.
1411    ///
1412    /// **Use this when callers need "did anything in scope
1413    /// change?"** — including upstream bindings that feed
1414    /// in via auto-extern. `canonical_hash` (the per-program
1415    /// flavour) covers only this program's own AST and
1416    /// cannot detect a workload-param edit that lands in a
1417    /// parent kernel's const slots.
1418    ///
1419    /// `canonical_hash` stays a pure local operation (no
1420    /// kernel-chain dependency); Polydat refuses to walk parent
1421    /// scopes inside a per-program hash. The runtime owns
1422    /// the parent-chain walk and feeds the resulting program
1423    /// chain here. Callers are responsible for ensuring every
1424    /// piece of state that should affect identity lives in
1425    /// some attached Polydat module — e.g. a host injects workload
1426    /// `params:` as a synthetic root module
1427    /// (`build_workload_params_kernel`) whose `const` bindings
1428    /// land in const slots `canonical_hash` covers.
1429    pub fn instance_hash(&self, ancestors: &[&PolydatProgram]) -> [u8; 32] {
1430        let chain: Vec<[u8; 32]> = ancestors.iter().map(|a| a.canonical_hash()).collect();
1431        instance_hash_of(self.canonical_hash(), &chain)
1432    }
1433
1434    /// Names of the non-coordinate inputs (iteration externs and
1435    /// external-write ports) that transitively feed the given
1436    /// outputs — the backward dataflow slice a scope needs from
1437    /// its enclosing scopes to produce exactly those outputs.
1438    ///
1439    /// A projection of the construction-time node inventory (see
1440    /// `Self::compute_node_inventory` — no traversal here):
1441    /// union the producing nodes' provenance masks, then map set
1442    /// bits to input names whose kind is not
1443    /// [`super::InputKind::Coordinate`] (coordinates are runtime
1444    /// dimensions like `cycle`, not outer-scope matter). Requested
1445    /// names this program does not declare as outputs are ignored
1446    /// — the caller keeps them unresolved and continues up its
1447    /// chain. Sorted, deduplicated.
1448    ///
1449    /// A host uses this per-ancestor to derive a phase's
1450    /// consumed-params closure: which workload params actually
1451    /// reach a given phase through the scope chain.
1452    pub fn extern_closure(&self, outputs: &[&str]) -> Vec<String> {
1453        let mut mask = ProvMask::empty();
1454        for (name, ni, _) in &self.output_list {
1455            if outputs.contains(&name.as_str())
1456                && let Some(prov) = self.input_provenance.get(*ni)
1457            {
1458                mask.union_with(prov);
1459            }
1460        }
1461        let names: std::collections::BTreeSet<String> = mask
1462            .iter_ones()
1463            .filter_map(|idx| self.input_defs.get(idx))
1464            .filter(|def| def.kind != super::InputKind::Coordinate)
1465            .map(|def| def.name.clone())
1466            .collect();
1467        names.into_iter().collect()
1468    }
1469
1470    /// [`Self::extern_closure`] over this program's OWNED outputs
1471    /// — inherited passthrough re-exports excluded. Ownership is
1472    /// what distinguishes consumption from plumbing: the scope
1473    /// cascade re-exports every inherited name so descendants can
1474    /// materialize it, and those passthroughs must not read as
1475    /// "this scope needs the name".
1476    pub fn owned_extern_closure(&self) -> Vec<String> {
1477        let owned: Vec<&str> = self
1478            .output_names()
1479            .into_iter()
1480            .filter(|n| !self.is_inherited(n))
1481            .collect();
1482        self.extern_closure(&owned)
1483    }
1484
1485    /// Resolve a seed of unresolved extern names THROUGH a chain
1486    /// of enclosing scope programs — innermost first, the same
1487    /// chain shape [`Self::instance_hash`] takes. Each name an
1488    /// ancestor outputs is replaced by that output's own extern
1489    /// slice ([`Self::extern_closure`] — per-output dataflow, so
1490    /// sibling outputs' externs are never dragged in); a
1491    /// passthrough re-export removes and re-adds the name, which
1492    /// is exactly "keep walking up"; a name no ancestor outputs
1493    /// stays. The returned TERMINAL set is what the outermost
1494    /// scope (e.g. a host's synthetic params module) must
1495    /// satisfy — a host's consumed-params derivation intersects
1496    /// it with the declared param names. Sorted, deduplicated.
1497    pub fn resolve_externs_through(
1498        seed: impl IntoIterator<Item = String>,
1499        ancestors: &[&PolydatProgram],
1500    ) -> Vec<String> {
1501        let mut unresolved: std::collections::BTreeSet<String> = seed.into_iter().collect();
1502        for prog in ancestors {
1503            if unresolved.is_empty() {
1504                break;
1505            }
1506            let outputs: std::collections::BTreeSet<&str> =
1507                prog.output_names().into_iter().collect();
1508            let produced: Vec<String> = unresolved
1509                .iter()
1510                .filter(|n| outputs.contains(n.as_str()))
1511                .cloned()
1512                .collect();
1513            if produced.is_empty() {
1514                continue;
1515            }
1516            let produced_refs: Vec<&str> = produced.iter().map(String::as_str).collect();
1517            let closure = prog.extern_closure(&produced_refs);
1518            for name in &produced {
1519                unresolved.remove(name);
1520            }
1521            unresolved.extend(closure);
1522        }
1523        unresolved.into_iter().collect()
1524    }
1525
1526    /// Canonical content-addressable hash of this program
1527    /// (scope_model.md §8).
1528    ///
1529    /// SHA-256 over a deterministic byte sequence describing
1530    /// every node's kind + constant slots, every wiring edge,
1531    /// and the named input / output declarations, taken over
1532    /// the graph the compiler resolved before any engine folded
1533    /// or fused it, so the same program on any engine hashes
1534    /// alike ([`Kernel::canonical_hash`](crate::Kernel::canonical_hash)).
1535    /// Stable across compilations of equivalent input — two
1536    /// programs produced from identical source + identical
1537    /// workload-scope state hash to the same value, and a change
1538    /// that affects what the program actually computes (a renamed
1539    /// output, a new node, a const-slot value change, a
1540    /// re-routed wire) shifts the hash.
1541    ///
1542    /// A host's checkpointing uses it for per-phase identity,
1543    /// hashing the compiled program rather than the source body:
1544    /// the resume planner skips a phase only when the saved
1545    /// hash matches the freshly-compiled program's hash, so a
1546    /// `{dataset}` change that ripples into a phase's
1547    /// compiled form correctly invalidates that phase's
1548    /// saved status, while phases whose programs are
1549    /// unaffected stay skip-eligible.
1550    ///
1551    /// ## Determinism contract
1552    ///
1553    /// - Outputs are emitted in alphabetical order (not the
1554    ///   compiler's declaration order, which can shuffle
1555    ///   slightly across compilation passes).
1556    /// - For each output, the producing node and its
1557    ///   transitive input chain are walked in deterministic
1558    ///   order — wire-source list iterated in port-position
1559    ///   order, recursion uses the producer's stable
1560    ///   (already-canonical) hash as the wire reference.
1561    /// - Const slots are iterated in `NodeMeta.ins` order,
1562    ///   which is the DSL-declared positional order and is
1563    ///   compiler-invariant.
1564    /// - `Input(idx)` wires are translated to the input's
1565    ///   *name* (stable across runs) rather than its index
1566    ///   (a compile-time positional choice).
1567    /// - Floating-point constants hash via their bit
1568    ///   representation, so 0.0 vs -0.0 hash differently and
1569    ///   NaNs are distinguishable from each other only by
1570    ///   their bit pattern (rare but consistent).
1571    pub fn canonical_hash(&self) -> [u8; 32] {
1572        let graph = self
1573            .graph_identity
1574            .unwrap_or_else(|| self.identity_graph().digest());
1575        program_identity(
1576            graph,
1577            self.inherited_outputs.iter(),
1578            &self.cursor_schemas,
1579            &self.traversals,
1580        )
1581    }
1582
1583    /// The graph this program's identity is computed over, as it stands.
1584    fn identity_graph(&self) -> IdentityGraph<'_> {
1585        IdentityGraph {
1586            nodes: &self.nodes,
1587            wiring: &self.wiring,
1588            input_defs: &self.input_defs,
1589            outputs: self
1590                .output_list
1591                .iter()
1592                .map(|(n, ni, pi)| (n.as_str(), *ni, *pi))
1593                .collect(),
1594            output_modifiers: &self.output_modifiers,
1595            const_outputs: &self.const_outputs,
1596        }
1597    }
1598
1599    /// Record the digest of the graph the compiler resolved, before any
1600    /// engine transformed it, as this program's graph identity.
1601    pub(crate) fn set_graph_identity(&mut self, digest: [u8; 32]) {
1602        self.graph_identity = Some(digest);
1603    }
1604}
1605
1606/// The part of a program's graph its identity covers: nodes, wiring,
1607/// inputs, outputs with their modifiers, and the `const` names. The
1608/// compiler builds one over the graph it resolved, before an engine
1609/// folds or fuses anything, so every engine starts from the same one
1610/// (scope_model.md §8).
1611pub(crate) struct IdentityGraph<'a> {
1612    pub(crate) nodes: &'a [Box<dyn PolydatNode>],
1613    pub(crate) wiring: &'a [Vec<WireSource>],
1614    pub(crate) input_defs: &'a [InputDef],
1615    /// `(name, node, port)` for every output.
1616    pub(crate) outputs: Vec<(&'a str, usize, usize)>,
1617    pub(crate) output_modifiers: &'a HashMap<String, crate::dsl::ast::BindingModifier>,
1618    pub(crate) const_outputs: &'a std::collections::HashSet<String>,
1619}
1620
1621/// The instance hash of a program whose canonical hash is `own`, under
1622/// ancestors whose canonical hashes are `ancestors`, innermost first:
1623/// SHA-256 over a versioned tag, `own`, and each ancestor's hash in
1624/// order. [`PolydatProgram::instance_hash`] is this over interpreter
1625/// programs; a host holding kernels of any engine passes each kernel's
1626/// [`Kernel::canonical_hash`](crate::Kernel::canonical_hash) and gets
1627/// the same value (scope_model.md §8).
1628pub fn instance_hash_of(own: [u8; 32], ancestors: &[[u8; 32]]) -> [u8; 32] {
1629    use sha2::{Digest, Sha256};
1630    let mut h = Sha256::new();
1631    h.update(b"PolydatProgram-instance-v1\n");
1632    h.update(own);
1633    for a in ancestors {
1634        h.update(a);
1635    }
1636    h.finalize().into()
1637}
1638
1639/// A program's canonical hash from its graph digest
1640/// ([`IdentityGraph::digest`]), its inherited-output names, its
1641/// cursors, and its traversals: what [`PolydatProgram::canonical_hash`] and
1642/// [`Kernel::canonical_hash`](crate::Kernel::canonical_hash) return.
1643pub(crate) fn program_identity<'a>(
1644    graph: [u8; 32],
1645    inherited: impl Iterator<Item = &'a String>,
1646    cursors: &[crate::iteration::source::SourceSchema],
1647    traversals: &[crate::dsl::traversal::Traversal],
1648) -> [u8; 32] {
1649    use sha2::{Digest, Sha256};
1650    let mut h = Sha256::new();
1651    h.update(b"PolydatProgram-v2\n");
1652    h.update(graph);
1653    // Inherited-output set: names that pass through this scope
1654    // without it owning them, which decides scope-coordinate
1655    // attribution.
1656    let mut inherited: Vec<&String> = inherited.collect();
1657    inherited.sort();
1658    for name in inherited {
1659        h.update(b"inh:");
1660        h.update(name.as_bytes());
1661        h.update(b"\n");
1662    }
1663    // Cursor schemas: a different source bound is a different program.
1664    for schema in cursors {
1665        h.update(b"cursor:");
1666        h.update(schema.name.as_bytes());
1667        h.update(b":");
1668        h.update(format!("{:?}", schema.extent).as_bytes());
1669        h.update(b"\n");
1670    }
1671    // Traversals, in document order: the comprehension as written and
1672    // the body program's own canonical hash, so a change inside a `for`
1673    // body is a different program.
1674    for t in traversals {
1675        h.update(b"for:");
1676        h.update((t.source_text.len() as u64).to_le_bytes().as_ref());
1677        h.update(t.source_text.as_bytes());
1678        h.update(b":");
1679        h.update(t.program.canonical_hash());
1680        h.update(b"\n");
1681    }
1682    h.finalize().into()
1683}
1684
1685impl IdentityGraph<'_> {
1686    /// SHA-256 over the graph in canonical form: the inputs by name,
1687    /// then each output by name with the Merkle hash of the node
1688    /// producing it and its modifier flags, then the `const` names.
1689    pub(crate) fn digest(&self) -> [u8; 32] {
1690        use sha2::{Digest, Sha256};
1691        let mut h = Sha256::new();
1692        h.update(b"PolydatGraph-v2\n");
1693
1694        // Inputs: name + port type + kind + declared default, sorted by
1695        // name, since declaration order is the compiler's choice. The
1696        // default is what the program computes from when nothing writes
1697        // the input, and is where a host fixes a value before compiling
1698        // (`transform::assign_values`).
1699        let mut inputs: Vec<&InputDef> = self.input_defs.iter().collect();
1700        inputs.sort_by(|a, b| a.name.cmp(&b.name));
1701        for def in &inputs {
1702            h.update(b"in:");
1703            h.update(def.name.as_bytes());
1704            h.update(b":");
1705            h.update(format!("{:?}", def.port_type).as_bytes());
1706            h.update(b":");
1707            h.update(format!("{:?}", def.kind).as_bytes());
1708            h.update(b":");
1709            h.update(format!("{:?}", def.default).as_bytes());
1710            h.update(b"\n");
1711        }
1712
1713        // Outputs, alphabetically: each with the memoised Merkle hash
1714        // of the node and port producing it.
1715        let mut outputs = self.outputs.clone();
1716        outputs.sort_by(|a, b| a.0.cmp(b.0));
1717        let mut node_hashes: HashMap<usize, [u8; 32]> = HashMap::new();
1718        for (name, ni, pi) in &outputs {
1719            let (nh, pi_eff) = self.port_identity(*ni, *pi, &mut node_hashes);
1720            h.update(b"out:");
1721            h.update(name.as_bytes());
1722            h.update(b":port:");
1723            h.update(pi_eff.to_le_bytes().as_ref());
1724            h.update(b":");
1725            h.update(nh);
1726            h.update(b"\n");
1727            // Modifier flags as individual bytes, so the hash does not
1728            // depend on the modifier type's layout.
1729            if let Some(m) = self.output_modifiers.get(*name) {
1730                h.update(b"  mod:");
1731                h.update(if m.is_const() { b"F" } else { b"-" });
1732                h.update(if m.is_shared() { b"S" } else { b"-" });
1733                h.update(if m.is_volatile() { b"V" } else { b"-" });
1734                h.update(b"\n");
1735            }
1736        }
1737
1738        // The names declared `const`: moving a binding in or out of
1739        // `const` changes when it is evaluated.
1740        let mut init_outs: Vec<&String> = self.const_outputs.iter().collect();
1741        init_outs.sort();
1742        for name in init_outs {
1743            h.update(b"init:");
1744            h.update(name.as_bytes());
1745            h.update(b"\n");
1746        }
1747
1748        h.finalize().into()
1749    }
1750
1751    /// Recursive helper: hash a single node's canonical form,
1752    /// memoising on node index. The hash incorporates the
1753    /// node's kind (`meta.name`), every const slot's value,
1754    /// and every wire input — wires to other nodes resolve to
1755    /// those nodes' canonical hashes, so the result is a
1756    /// Merkle-tree summary of the producer's full transitive
1757    /// dependency cone.
1758    fn node_canonical_hash(&self, ni: usize, memo: &mut HashMap<usize, [u8; 32]>) -> [u8; 32] {
1759        if let Some(h) = memo.get(&ni) {
1760            return *h;
1761        }
1762        // Insert a sentinel to handle the (theoretical)
1763        // cycle case — Polydat DAGs aren't supposed to cycle, but
1764        // guarding against an infinite recursion if a future
1765        // node graph violates that is cheap insurance.
1766        memo.insert(ni, [0u8; 32]);
1767
1768        use sha2::{Digest, Sha256};
1769        let mut h = Sha256::new();
1770        let meta = self.nodes[ni].meta();
1771        h.update(b"node:");
1772        h.update(meta.name.as_bytes());
1773        h.update(b"\n");
1774
1775        // Output ports: name + type, in declaration order.
1776        for port in &meta.outs {
1777            h.update(b"  outp:");
1778            h.update(port.name.as_bytes());
1779            h.update(b":");
1780            h.update(format!("{:?}", port.typ).as_bytes());
1781            h.update(b"\n");
1782        }
1783
1784        // Input slots in declaration order. For Wire slots,
1785        // pull the wire-source for that port and resolve it.
1786        // Const slots inline their value's bytes.
1787        let wires = &self.wiring[ni];
1788        let mut wire_idx = 0;
1789        for slot in &meta.ins {
1790            match slot {
1791                crate::ast::Slot::Wire(port) => {
1792                    h.update(b"  wirep:");
1793                    h.update(port.name.as_bytes());
1794                    h.update(b":");
1795                    h.update(format!("{:?}", port.typ).as_bytes());
1796                    h.update(b":");
1797                    if let Some(src) = wires.get(wire_idx) {
1798                        canonical_wire_source(src, self, memo, &mut h);
1799                    } else {
1800                        h.update(b"unwired");
1801                    }
1802                    h.update(b"\n");
1803                    wire_idx += 1;
1804                }
1805                crate::ast::Slot::Const { name, value } => {
1806                    h.update(b"  const:");
1807                    h.update(name.as_bytes());
1808                    h.update(b":");
1809                    canonical_const_value(value, &mut h);
1810                    h.update(b"\n");
1811                }
1812            }
1813        }
1814
1815        let result: [u8; 32] = h.finalize().into();
1816        memo.insert(ni, result);
1817        result
1818    }
1819
1820    /// Resolve the canonical identity behind `(ni, pi)`. For
1821    /// ordinary nodes this is the node's own hash and port; for
1822    /// fusion nodes (interpreter cones, engines.md §2) it is the ORIGINAL member's
1823    /// hash and port, computed by walking the stored subgraph —
1824    /// so program identity is extraction-invariant.
1825    fn port_identity(
1826        &self,
1827        ni: usize,
1828        pi: usize,
1829        memo: &mut HashMap<usize, [u8; 32]>,
1830    ) -> ([u8; 32], usize) {
1831        if let Some(sub) = self.nodes[ni].fusion_subgraph() {
1832            let (m, p) = sub.out_ports[pi];
1833            (self.fusion_member_hash(ni, &sub, m, memo), p)
1834        } else {
1835            (self.node_canonical_hash(ni, memo), pi)
1836        }
1837    }
1838
1839    /// Hash one member of a fusion node's subgraph exactly as
1840    /// `node_canonical_hash` would have hashed it before
1841    /// extraction. Local `Input(i)` boundary references resolve
1842    /// through the fusion node's OUTER wiring, so upstream
1843    /// producers — including const-folded literals — hash in
1844    /// their post-fold form, byte-identical to the unextracted
1845    /// program's walk. Members form a small acyclic subgraph;
1846    /// recursion is bounded and unmemoised.
1847    fn fusion_member_hash(
1848        &self,
1849        fusion_ni: usize,
1850        sub: &crate::ast::FusionSubgraph<'_>,
1851        m: usize,
1852        memo: &mut HashMap<usize, [u8; 32]>,
1853    ) -> [u8; 32] {
1854        use sha2::{Digest, Sha256};
1855        let mut h = Sha256::new();
1856        let meta = sub.members[m].meta();
1857        h.update(b"node:");
1858        h.update(meta.name.as_bytes());
1859        h.update(b"\n");
1860        for port in &meta.outs {
1861            h.update(b"  outp:");
1862            h.update(port.name.as_bytes());
1863            h.update(b":");
1864            h.update(format!("{:?}", port.typ).as_bytes());
1865            h.update(b"\n");
1866        }
1867        let wires = &sub.wiring[m];
1868        let mut wire_idx = 0;
1869        for slot in &meta.ins {
1870            match slot {
1871                crate::ast::Slot::Wire(port) => {
1872                    h.update(b"  wirep:");
1873                    h.update(port.name.as_bytes());
1874                    h.update(b":");
1875                    h.update(format!("{:?}", port.typ).as_bytes());
1876                    h.update(b":");
1877                    match wires.get(wire_idx) {
1878                        Some(super::WireSource::NodeOutput(j, p)) => {
1879                            h.update(b"node:");
1880                            let nh = self.fusion_member_hash(fusion_ni, sub, *j, memo);
1881                            h.update(nh);
1882                            h.update(b":port:");
1883                            h.update(p.to_le_bytes().as_ref());
1884                        }
1885                        Some(super::WireSource::Input(i)) => match self.wiring[fusion_ni].get(*i) {
1886                            Some(super::WireSource::NodeOutput(oj, op)) => {
1887                                h.update(b"node:");
1888                                let (nh, p_eff) = self.port_identity(*oj, *op, memo);
1889                                h.update(nh);
1890                                h.update(b":port:");
1891                                h.update(p_eff.to_le_bytes().as_ref());
1892                            }
1893                            Some(outer_input @ super::WireSource::Input(_)) => {
1894                                canonical_wire_source(outer_input, self, memo, &mut h);
1895                            }
1896                            None => h.update(b"unwired"),
1897                        },
1898                        None => h.update(b"unwired"),
1899                    }
1900                    h.update(b"\n");
1901                    wire_idx += 1;
1902                }
1903                crate::ast::Slot::Const { name, value } => {
1904                    h.update(b"  const:");
1905                    h.update(name.as_bytes());
1906                    h.update(b":");
1907                    canonical_const_value(value, &mut h);
1908                    h.update(b"\n");
1909                }
1910            }
1911        }
1912        h.finalize().into()
1913    }
1914}
1915
1916impl PolydatProgram {
1917    /// Number of nodes in the program.
1918    pub fn node_count(&self) -> usize {
1919        self.nodes.len()
1920    }
1921
1922    /// Total wire count (sum of all node input edges).
1923    pub fn wire_count(&self) -> usize {
1924        self.wiring.iter().map(|w| w.len()).sum()
1925    }
1926
1927    /// Average in-degree (wires per node).
1928    pub fn avg_degree(&self) -> f64 {
1929        let n = self.nodes.len();
1930        if n == 0 {
1931            return 0.0;
1932        }
1933        self.wire_count() as f64 / n as f64
1934    }
1935
1936    /// Access a node by index (trait object). Read-only
1937    /// introspection surface for reporting — evaluation stays
1938    /// behind the kernel APIs.
1939    pub fn node_ref(&self, idx: usize) -> &dyn crate::ast::PolydatNode {
1940        self.nodes[idx].as_ref()
1941    }
1942
1943    /// Access a node's metadata by index.
1944    pub fn node_meta(&self, idx: usize) -> &crate::ast::NodeMeta {
1945        self.nodes[idx].meta()
1946    }
1947
1948    /// Access the wiring for a node by index.
1949    /// Returns the list of `WireSource`s feeding this node's inputs.
1950    pub fn node_wiring(&self, idx: usize) -> &[super::WireSource] {
1951        &self.wiring[idx]
1952    }
1953
1954    /// The types of the wires feeding node `idx`: a coordinate or
1955    /// extern takes its declared input type, a wire from another node
1956    /// its producing port's. What a builder passes a node's kit, and
1957    /// what deciding the node's tier needs.
1958    pub fn node_wire_types(&self, idx: usize) -> Vec<crate::ast::PortType> {
1959        self.wiring[idx]
1960            .iter()
1961            .map(|src| match src {
1962                super::WireSource::Input(i) => self
1963                    .input_port_type_by_idx(*i)
1964                    .unwrap_or(crate::ast::PortType::U64),
1965                super::WireSource::NodeOutput(j, p) => self.nodes[*j].meta().outs[*p].typ,
1966            })
1967            .collect()
1968    }
1969
1970    /// Probe the compile level of a node by index.
1971    pub fn node_compile_level(&self, idx: usize) -> crate::ast::CompileLevel {
1972        crate::ast::compile_level_of(self.nodes[idx].as_ref(), &self.node_wire_types(idx))
1973    }
1974
1975    /// What the interpreter runs of this program: its native cones as
1976    /// native segments and every other node interpreted
1977    /// ([`Kernel::plan`](crate::Kernel::plan)).
1978    pub fn engine_plan(&self) -> crate::EnginePlan {
1979        let mut plan = crate::EnginePlan::default();
1980        for i in 0..self.node_count() {
1981            // A native segment is the one kind of node that stands in
1982            // for a subgraph, which it says by answering
1983            // `fusion_subgraph`. Its `jit_cone[…]` name is a
1984            // diagnostic label, and reading the plan off a label would
1985            // make the count a fact about how the label is spelled.
1986            if self.node_ref(i).fusion_subgraph().is_some() {
1987                plan.native_segments += 1;
1988            } else {
1989                plan.interpreted_nodes += 1;
1990            }
1991        }
1992        plan
1993    }
1994
1995    /// Probe the compile level of the last node.
1996    pub fn last_node_compile_level(&self) -> crate::ast::CompileLevel {
1997        if self.nodes.is_empty() {
1998            return crate::ast::CompileLevel::Phase1;
1999        }
2000        self.node_compile_level(self.nodes.len() - 1)
2001    }
2002
2003    /// True when no node declares `Purity::Nondeterministic`: the
2004    /// program's outputs are a pure function of its inputs, so two
2005    /// kernels compiled from the same source produce bit-identical
2006    /// pulls. The engine differential tests key on this to
2007    /// decide whether a force-compiled twin can be compared
2008    /// value-for-value against the interpreter form (engines.md §7).
2009    pub fn is_deterministic(&self) -> bool {
2010        !self
2011            .nodes
2012            .iter()
2013            .any(|n| matches!(n.purity(), crate::ast::Purity::Nondeterministic { .. }))
2014    }
2015
2016    /// Fold every init-lifecycle constant now, as the compiler does at the
2017    /// end of a build, and return how many were folded.
2018    pub fn fold_init_constants(
2019        &mut self,
2020    ) -> Result<usize, crate::compile::assembly::AssemblyError> {
2021        self.fold_init_constants_impl(None, false)
2022    }
2023
2024    /// Fold init-time constants, emitting diagnostic events to the log.
2025    /// Returns `Err` when a compile-constant step cannot be computed, or
2026    /// for a strict-mode violation.
2027    pub fn fold_init_constants_with_log(
2028        &mut self,
2029        log: Option<&mut crate::dsl::events::CompileEventLog>,
2030    ) -> Result<usize, crate::compile::assembly::AssemblyError> {
2031        self.fold_init_constants_impl(log, false)
2032    }
2033
2034    /// Every config wire fed by a cycle-time source, as `(node, port)`
2035    /// by the node's own name. A node fused into a native cone is
2036    /// checked through the cone's members: a member fed by another
2037    /// member reads a cycle-time value (every member is dynamic), and a
2038    /// member fed by a boundary input reads what the cone's own wire
2039    /// carries.
2040    pub(crate) fn config_wires_fed_by_cycle(
2041        nodes: &[Box<dyn PolydatNode>],
2042        wiring: &[Vec<WireSource>],
2043        is_init: &[bool],
2044    ) -> Vec<(String, String)> {
2045        let outer_is_cycle = |src: &WireSource| match src {
2046            WireSource::Input(_) => true,
2047            WireSource::NodeOutput(src_idx, _) => !is_init[*src_idx],
2048        };
2049        let mut found = Vec::new();
2050        for (node, wires) in nodes.iter().zip(wiring.iter()) {
2051            if let Some(sub) = node.fusion_subgraph() {
2052                for (m, member) in sub.members.iter().enumerate() {
2053                    let ports = member.meta().wire_inputs();
2054                    for (k, src) in sub.wiring[m].iter().enumerate() {
2055                        let Some(port) = ports.get(k) else { break };
2056                        if port.wire_cost != crate::ast::WireCost::Config {
2057                            continue;
2058                        }
2059                        let cycle = match src {
2060                            WireSource::Input(bi) => wires.get(*bi).is_none_or(outer_is_cycle),
2061                            WireSource::NodeOutput(..) => true,
2062                        };
2063                        if cycle {
2064                            found.push((member.meta().name.clone(), port.name.clone()));
2065                        }
2066                    }
2067                }
2068                continue;
2069            }
2070            let wire_inputs = node.meta().wire_inputs();
2071            for (port_idx, wire_source) in wires.iter().enumerate() {
2072                let Some(port) = wire_inputs.get(port_idx) else {
2073                    break;
2074                };
2075                if port.wire_cost != crate::ast::WireCost::Config {
2076                    continue;
2077                }
2078                if outer_is_cycle(wire_source) {
2079                    found.push((node.meta().name.clone(), port.name.clone()));
2080                }
2081            }
2082        }
2083        found
2084    }
2085
2086    /// What strict mode refuses in a resolved graph, on every engine: a
2087    /// config wire fed from a cycle-time source, a nondeterministic
2088    /// node no `volatile` output acknowledges, and a binding nothing
2089    /// reads. `is_init` marks the compile-constant nodes, from
2090    /// [`Self::classify_lifecycle`]. The first violation, as the error
2091    /// message; the interpreter's fold warns about the same findings
2092    /// when strict is off.
2093    pub(crate) fn strict_violation(
2094        nodes: &[Box<dyn PolydatNode>],
2095        wiring: &[Vec<WireSource>],
2096        is_init: &[bool],
2097        output_map: &HashMap<String, (usize, usize)>,
2098        output_modifiers: &HashMap<String, crate::dsl::ast::BindingModifier>,
2099    ) -> Option<String> {
2100        let n = nodes.len();
2101        if let Some((node_name, port_name)) =
2102            Self::config_wires_fed_by_cycle(nodes, wiring, is_init)
2103                .into_iter()
2104                .next()
2105        {
2106            return Some(format!(
2107                "strict mode: config wire '{port_name}' on node '{node_name}' is connected \
2108                 to a cycle-time source."
2109            ));
2110        }
2111        // A nondeterministic read is acknowledged by what consumes it: a
2112        // `volatile` output (read again on every read) or a `const`
2113        // output (read once, at initialization).
2114        let mut feeds_volatile = vec![false; n];
2115        for (out_name, (node_idx, _)) in output_map.iter() {
2116            if output_modifiers
2117                .get(out_name)
2118                .map(|m| m.is_volatile() || m.is_const())
2119                .unwrap_or(false)
2120            {
2121                feeds_volatile[*node_idx] = true;
2122            }
2123        }
2124        let mut changed = true;
2125        while changed {
2126            changed = false;
2127            for i in 0..n {
2128                if !feeds_volatile[i] {
2129                    continue;
2130                }
2131                for source in &wiring[i] {
2132                    if let WireSource::NodeOutput(upstream, _) = source
2133                        && !feeds_volatile[*upstream]
2134                    {
2135                        feeds_volatile[*upstream] = true;
2136                        changed = true;
2137                    }
2138                }
2139            }
2140        }
2141        for (i, node) in nodes.iter().enumerate() {
2142            let name = &node.meta().name;
2143            if wiring[i].is_empty() && !is_init[i] && !name.starts_with("__") && !feeds_volatile[i]
2144            {
2145                return Some(format!(
2146                    "strict mode: non-deterministic node '{name}' used without explicit \
2147                     acknowledgment. Use a deterministic alternative."
2148                ));
2149            }
2150        }
2151        let output_nodes: std::collections::HashSet<usize> =
2152            output_map.values().map(|(idx, _)| *idx).collect();
2153        for (i, node) in nodes.iter().enumerate() {
2154            let name = &node.meta().name;
2155            if name.starts_with("__") || output_nodes.contains(&i) {
2156                continue;
2157            }
2158            let consumed = wiring.iter().any(|w| {
2159                w.iter()
2160                    .any(|s| matches!(s, WireSource::NodeOutput(src, _) if *src == i))
2161            });
2162            if !consumed {
2163                return Some(format!(
2164                    "strict mode: binding '{name}' is never referenced. Remove it or mark as \
2165                     output."
2166                ));
2167            }
2168        }
2169        None
2170    }
2171
2172    /// Fold init-time constants with strict mode.
2173    pub fn fold_init_constants_strict(
2174        &mut self,
2175        log: Option<&mut crate::dsl::events::CompileEventLog>,
2176        strict: bool,
2177    ) -> Result<usize, crate::compile::assembly::AssemblyError> {
2178        self.fold_init_constants_impl(log, strict)
2179    }
2180
2181    // The `0..n` node-index loops below each fan one index out
2182    // across several parallel structures (`self.nodes`, `self.wiring`,
2183    // `is_init`, `state.core.buffers`) and feed it to
2184    // `eval_node_public(self, i)` — iterating any single array
2185    // misrepresents the logic and conflicts with the `&mut self`
2186    // borrows, so the index form stays.
2187    #[allow(clippy::needless_range_loop)]
2188    fn fold_init_constants_impl(
2189        &mut self,
2190        mut log: Option<&mut crate::dsl::events::CompileEventLog>,
2191        strict: bool,
2192    ) -> Result<usize, crate::compile::assembly::AssemblyError> {
2193        use crate::ast::Value;
2194        use crate::library::fixed::ConstF64;
2195        use crate::library::identity::{ConstExt, ConstHandle, ConstStr, ConstU64};
2196
2197        let n = self.nodes.len();
2198        if n == 0 {
2199            return Ok(0);
2200        }
2201
2202        // Phase 1: Classify each node by its evaluation lifecycle
2203        // (graph_compiler.md §3.1): every
2204        // node is CompileConst, ScopeInit, or Dynamic; the three are
2205        // ordered (Dynamic dominates ScopeInit dominates
2206        // CompileConst) and `max()`-propagate downstream.
2207        //
2208        // CompileConst: foldable now (no extern / cycle dependencies).
2209        // ScopeInit:    not foldable now, but will be at scope
2210        //               activation (depends on iteration externs).
2211        // Dynamic:      depends on cycle inputs, external-write ports, or
2212        //               non-deterministic sources.
2213        let lifecycle = Self::classify_lifecycle(
2214            &self.nodes,
2215            &self.wiring,
2216            &self.input_defs,
2217            &self.output_map,
2218            &self.output_modifiers,
2219        )
2220        .lifecycle;
2221
2222        // is_init is the compile-const subset. Subsequent fold
2223        // phases below only operate on CompileConst nodes; ScopeInit
2224        // nodes are deferred to the scope-activation pass.
2225        let mut is_init: Vec<bool> = lifecycle
2226            .iter()
2227            .map(|lc| *lc == EvalLifecycle::CompileConst)
2228            .collect();
2229
2230        // Strict refuses what the checks below warn about, through the
2231        // one function every engine's build applies.
2232        if strict
2233            && let Some(violation) = Self::strict_violation(
2234                &self.nodes,
2235                &self.wiring,
2236                &is_init,
2237                &self.output_map,
2238                &self.output_modifiers,
2239            )
2240        {
2241            return Err(crate::compile::assembly::AssemblyError::Other(violation));
2242        }
2243
2244        // Wire cost check: a config wire fed by a cycle-time source
2245        // warns, by the node's own name and port, through the cone's
2246        // members where the node was fused.
2247        for (node_name, port_name) in
2248            Self::config_wires_fed_by_cycle(&self.nodes, &self.wiring, &is_init)
2249        {
2250            crate::library::support::audit::warn(&format!(
2251                "config wire '{port_name}' on node '{node_name}' is connected to a \
2252                 cycle-time source."
2253            ));
2254            if let Some(ref mut log) = log {
2255                log.push(crate::dsl::events::CompileEvent::ConfigWireCycleWarning {
2256                    node: node_name,
2257                    port: port_name,
2258                });
2259            }
2260        }
2261
2262        // Non-deterministic node check (evaluation_model.md,
2263        // "Non-Deterministic Nodes"). Empty-wiring + not-init +
2264        // not-internal nodes are structurally-detected as
2265        // non-deterministic. The `volatile` keyword on a binding
2266        // wire is the author's explicit acknowledgment — when a
2267        // node's output feeds into a volatile output, suppress
2268        // both the strict-mode error and the audit warning.
2269        //
2270        // Direct-consumer check: walks `output_list` looking for
2271        // outputs that map to this node and checks whether the
2272        // output's modifier carries `is_volatile`. Transitive
2273        // volatility (R1.v contagion) is delivered separately by
2274        // the lifecycle classifier's fixed-point propagation: a
2275        // node marked Dynamic (via intrinsic Nondeterministic
2276        // purity or a downstream volatile modifier) propagates
2277        // Dynamic to every consumer through the existing pass at
2278        // `compute_lifecycles`. This loop handles only the
2279        // strict-mode / audit-warning side: was the
2280        // non-deterministic node consumed directly by an
2281        // author-declared `volatile` output? If yes, suppress the
2282        // warning.
2283        // Volatility acknowledgment is TRANSITIVE for suppression,
2284        // matching the lifecycle classifier's contagion: a
2285        // nondeterministic node feeding a volatile-marked output
2286        // through any expression chain (a stop-condition predicate's
2287        // `metric(...) > 3.0` puts a comparison between the reader
2288        // and the volatile output) is acknowledged. Reverse-reach:
2289        // seed the producing node of every volatile output, walk
2290        // producer edges to fixpoint.
2291        // A `const` output acknowledges a nondeterministic read as a
2292        // `volatile` one does: it takes one reading, at initialization.
2293        let mut feeds_volatile = vec![false; n];
2294        for (out_name, node_idx, _port) in self.output_list.iter() {
2295            if self
2296                .output_modifiers
2297                .get(out_name)
2298                .map(|m| m.is_volatile() || m.is_const())
2299                .unwrap_or(false)
2300            {
2301                feeds_volatile[*node_idx] = true;
2302            }
2303        }
2304        let mut changed = true;
2305        while changed {
2306            changed = false;
2307            for i in 0..n {
2308                if !feeds_volatile[i] {
2309                    continue;
2310                }
2311                for source in &self.wiring[i] {
2312                    if let WireSource::NodeOutput(upstream, _) = source
2313                        && !feeds_volatile[*upstream]
2314                    {
2315                        feeds_volatile[*upstream] = true;
2316                        changed = true;
2317                    }
2318                }
2319            }
2320        }
2321        for i in 0..n {
2322            let name = &self.nodes[i].meta().name;
2323            let is_nondeterministic =
2324                self.wiring[i].is_empty() && !is_init[i] && !name.starts_with("__");
2325            if !is_nondeterministic {
2326                continue;
2327            }
2328            let consumed_by_volatile = feeds_volatile[i];
2329            if consumed_by_volatile {
2330                continue;
2331            }
2332            let msg =
2333                format!("non-deterministic node '{name}' used without explicit acknowledgment");
2334            crate::library::support::audit::warn(&msg);
2335            if let Some(ref mut log) = log {
2336                log.push(crate::dsl::events::CompileEvent::Warning { message: msg });
2337            }
2338        }
2339
2340        // Unused binding check
2341        let output_node_indices: std::collections::HashSet<usize> =
2342            self.output_map.values().map(|(idx, _)| *idx).collect();
2343        for i in 0..n {
2344            let name = &self.nodes[i].meta().name;
2345            if name.starts_with("__") {
2346                continue;
2347            }
2348            let is_output = output_node_indices.contains(&i);
2349            let is_consumed = (0..n).any(|j| {
2350                self.wiring[j]
2351                    .iter()
2352                    .any(|w| matches!(w, WireSource::NodeOutput(src, _) if *src == i))
2353            });
2354            if !is_output && !is_consumed {
2355                let msg = format!("binding '{name}' is never referenced");
2356                if !name.contains("__") {
2357                    crate::library::support::audit::warn(&msg);
2358                    if let Some(ref mut log) = log {
2359                        log.push(crate::dsl::events::CompileEvent::Warning { message: msg });
2360                    }
2361                }
2362            }
2363        }
2364
2365        let init_count = is_init.iter().filter(|&&b| b).count();
2366        if init_count == 0 {
2367            return Ok(0);
2368        }
2369
2370        // Phase 2: Evaluate init-time nodes, on a state seeded without
2371        // opening a cycle. A program is compiled inside a root's cycle
2372        // — a traversal body, a projection body — and folding its
2373        // constants must not disturb what the root is part-way
2374        // through.
2375        let mut state = self.create_state();
2376        let dummy_inputs = vec![0u64; self.coord_count];
2377        state.seed_inputs(&dummy_inputs);
2378
2379        for i in 0..n {
2380            if is_init[i] {
2381                if self.nodes[i].meta().outs.len() != 1 {
2382                    is_init[i] = false;
2383                    continue;
2384                }
2385                // A compile-constant step is one no input reaches, so
2386                // what it does here it will do on every pull: there is
2387                // nothing a later evaluation could supply that would
2388                // make it succeed. Skipping the fold would only move
2389                // the same failure to the first pull and leave this
2390                // engine disagreeing with the three compiled ones,
2391                // which fail at build. What is knowable at build is
2392                // known at build, and fails at build.
2393                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2394                    state.eval_node_public(self, i);
2395                }));
2396                if let Err(payload) = result {
2397                    // The eval path already enriched the payload with
2398                    // the node, the outputs it feeds and the inputs it
2399                    // was called with, the way any evaluation failure
2400                    // is enriched. Taking its text keeps one copy of
2401                    // that attribution rather than wrapping a second.
2402                    //
2403                    // Less the panic location: at build this is a
2404                    // diagnosis of the program, and the compiled
2405                    // engines report it without one, so dropping it is
2406                    // what makes the fold error read the same on every
2407                    // engine (`without_panic_location`).
2408                    return Err(crate::compile::assembly::AssemblyError::ConstantFold(
2409                        crate::kernel::engines::without_panic_location(
2410                            crate::kernel::engines::panic_payload_text(payload.as_ref()),
2411                        ),
2412                    ));
2413                }
2414            }
2415        }
2416
2417        // Phase 3: Replace init-time nodes with constants.
2418        let mut folded = 0;
2419        for i in 0..n {
2420            if !is_init[i] {
2421                continue;
2422            }
2423
2424            let value = state.core.buffers[i][0].clone();
2425            if matches!(value, Value::None) {
2426                continue;
2427            }
2428
2429            let const_node: Box<dyn crate::ast::PolydatNode> = match &value {
2430                Value::U64(v) => Box::new(ConstU64::new(*v)),
2431                Value::F64(v) => Box::new(ConstF64::new(*v)),
2432                // A Bool stays a Bool: the wire is Bool-typed, and a
2433                // `const_u64` here would make the interpreter read a
2434                // U64 where every compiled engine reads the Bool.
2435                Value::Bool(v) => Box::new(crate::library::fixed::ConstBool::new(*v)),
2436                Value::Str(s) => Box::new(ConstStr::new(s.to_string())),
2437                // Handles (e.g. `init prebuffered = dataset_prebuffer(...)`)
2438                // get a dedicated `ConstHandle` replacement so the original
2439                // side-effect-bearing node is removed from the program.
2440                // Without this, every fresh fiber's `PolydatState` would
2441                // walk the dirty original on first pull and re-fire its
2442                // eval — a per-fiber stampede that exhausts process
2443                // thread limits when the eval spawns HTTP workers.
2444                Value::Handle(arc) => {
2445                    let original_name = self.nodes[i].meta().name.clone();
2446                    // Per-node compile-time mechanic; one
2447                    // line per `const` binding pollutes
2448                    // session output with no actionable
2449                    // signal for the operator. Demote to
2450                    // Debug — visible under `--log-level
2451                    // debug` for compiler-pipeline
2452                    // inspection, silent on the default
2453                    // INFO console.
2454                    crate::library::support::audit::debug(&format!(
2455                        "fold: replacing init node '{original_name}' with ConstHandle \
2456                         (Arc<dyn Any>) — eval will not re-fire post-fold"
2457                    ));
2458                    Box::new(ConstHandle::new(arc.clone()))
2459                }
2460                // Ext-typed init values (cursor_partitions.md §2: Partition,
2461                // PartitionSpec, PartitionList, …) replace the
2462                // original node with a ConstExt leaf — same
2463                // shape as the Handle path so post-fold kernels
2464                // can read the value via `get_constant` and
2465                // descendant scopes see it as a stable Ext wire.
2466                Value::Ext(b) => {
2467                    let original_name = self.nodes[i].meta().name.clone();
2468                    crate::library::support::audit::debug(&format!(
2469                        "fold: replacing init node '{original_name}' with ConstExt \
2470                         ({}) — eval will not re-fire post-fold",
2471                        b.type_name(),
2472                    ));
2473                    Box::new(ConstExt::new(b.clone()))
2474                }
2475                _ => continue,
2476            };
2477
2478            let node_name = self.nodes[i].meta().name.clone();
2479            if let Some(ref mut log) = log {
2480                log.push(crate::dsl::events::CompileEvent::ConstantFolded {
2481                    node: node_name,
2482                    value: value.to_display_string(),
2483                });
2484            }
2485            self.nodes[i] = const_node;
2486            self.wiring[i] = Vec::new();
2487            folded += 1;
2488        }
2489
2490        Ok(folded)
2491    }
2492}
2493
2494/// Hash one [`super::WireSource`] in canonical form. Inputs
2495/// resolve to their *name* (stable identifier) rather than
2496/// their positional index. Node-output references recurse via
2497/// [`IdentityGraph::node_canonical_hash`].
2498fn canonical_wire_source(
2499    src: &super::WireSource,
2500    program: &IdentityGraph<'_>,
2501    memo: &mut HashMap<usize, [u8; 32]>,
2502    h: &mut sha2::Sha256,
2503) {
2504    use sha2::Digest;
2505    match src {
2506        super::WireSource::Input(idx) => {
2507            h.update(b"input:");
2508            if let Some(def) = program.input_defs.get(*idx) {
2509                h.update(def.name.as_bytes());
2510            } else {
2511                h.update(b"<oob>");
2512            }
2513        }
2514        super::WireSource::NodeOutput(ni, pi) => {
2515            h.update(b"node:");
2516            let (nh, pi_eff) = program.port_identity(*ni, *pi, memo);
2517            h.update(nh);
2518            h.update(b":port:");
2519            h.update(pi_eff.to_le_bytes().as_ref());
2520        }
2521    }
2522}
2523
2524/// Hash one [`crate::ast::ConstValue`] in canonical form.
2525/// Floats hash via their bit pattern so 0.0 vs -0.0 (and
2526/// distinct NaN payloads) are distinguishable. Strings and
2527/// vectors include explicit length tags so concatenation is
2528/// unambiguous.
2529fn canonical_const_value(v: &crate::ast::ConstValue, h: &mut sha2::Sha256) {
2530    use crate::ast::ConstValue;
2531    use sha2::Digest;
2532    match v {
2533        ConstValue::U64(x) => {
2534            h.update(b"u64:");
2535            h.update(x.to_le_bytes().as_ref());
2536        }
2537        ConstValue::F64(x) => {
2538            h.update(b"f64:");
2539            h.update(x.to_bits().to_le_bytes().as_ref());
2540        }
2541        ConstValue::Str(s) => {
2542            h.update(b"str:");
2543            h.update((s.len() as u64).to_le_bytes().as_ref());
2544            h.update(s.as_bytes());
2545        }
2546        ConstValue::VecU64(xs) => {
2547            h.update(b"vu64:");
2548            h.update((xs.len() as u64).to_le_bytes().as_ref());
2549            for x in xs {
2550                h.update(x.to_le_bytes().as_ref());
2551            }
2552        }
2553        ConstValue::VecF64(xs) => {
2554            h.update(b"vf64:");
2555            h.update((xs.len() as u64).to_le_bytes().as_ref());
2556            for x in xs {
2557                h.update(x.to_bits().to_le_bytes().as_ref());
2558            }
2559        }
2560    }
2561}
2562
2563#[cfg(test)]
2564mod canonical_hash_tests {
2565    use crate::dsl::compile_polydat_interpreter;
2566
2567    #[test]
2568    fn identical_source_produces_identical_hash() {
2569        let src = "const dataset := \"sift1m\"\nconst count := 100\n";
2570        let k1 = compile_polydat_interpreter(src).expect("compile1");
2571        let k2 = compile_polydat_interpreter(src).expect("compile2");
2572        assert_eq!(k1.program().canonical_hash(), k2.program().canonical_hash());
2573    }
2574
2575    #[test]
2576    fn different_const_value_changes_hash() {
2577        let a = compile_polydat_interpreter("const x := 100\n").expect("compile a");
2578        let b = compile_polydat_interpreter("const x := 101\n").expect("compile b");
2579        assert_ne!(
2580            a.program().canonical_hash(),
2581            b.program().canonical_hash(),
2582            "differing const value must change canonical hash"
2583        );
2584    }
2585
2586    #[test]
2587    fn different_string_value_changes_hash() {
2588        let a = compile_polydat_interpreter("const s := \"sift1m\"\n").expect("compile a");
2589        let b = compile_polydat_interpreter("const s := \"sift10m\"\n").expect("compile b");
2590        assert_ne!(
2591            a.program().canonical_hash(),
2592            b.program().canonical_hash(),
2593            "differing string value must change canonical hash"
2594        );
2595    }
2596
2597    #[test]
2598    fn renamed_output_changes_hash() {
2599        // Same RHS, different output name → different program
2600        // identity. The output map contributes to canonical
2601        // identity.
2602        let a = compile_polydat_interpreter("const foo := 42\n").expect("compile a");
2603        let b = compile_polydat_interpreter("const bar := 42\n").expect("compile b");
2604        assert_ne!(
2605            a.program().canonical_hash(),
2606            b.program().canonical_hash(),
2607            "renamed output must change canonical hash"
2608        );
2609    }
2610
2611    #[test]
2612    fn comment_only_change_does_not_change_hash() {
2613        let a = compile_polydat_interpreter("const x := 42\n").expect("compile a");
2614        let b = compile_polydat_interpreter(
2615            "# explanatory comment\nconst x := 42\n# trailing comment\n",
2616        )
2617        .expect("compile b");
2618        assert_eq!(
2619            a.program().canonical_hash(),
2620            b.program().canonical_hash(),
2621            "comment-only edits should not affect canonical hash — \
2622             the AST is what's hashed, not the source bytes"
2623        );
2624    }
2625
2626    #[test]
2627    fn whitespace_change_does_not_change_hash() {
2628        let a = compile_polydat_interpreter("const x := 42\n").expect("compile a");
2629        let b = compile_polydat_interpreter("const  x  :=  42\n\n\n").expect("compile b");
2630        assert_eq!(
2631            a.program().canonical_hash(),
2632            b.program().canonical_hash(),
2633            "whitespace-only edits should not affect canonical hash"
2634        );
2635    }
2636
2637    #[test]
2638    fn additional_binding_changes_hash() {
2639        let a = compile_polydat_interpreter("const x := 1\n").expect("compile a");
2640        let b = compile_polydat_interpreter("const x := 1\nconst y := 2\n").expect("compile b");
2641        assert_ne!(
2642            a.program().canonical_hash(),
2643            b.program().canonical_hash(),
2644            "added output must change canonical hash"
2645        );
2646    }
2647
2648    // -----------------------------------------------------------
2649    // instance_hash — aggregates over a parent-chain of programs
2650    // -----------------------------------------------------------
2651
2652    #[test]
2653    fn instance_hash_with_no_ancestors_differs_from_canonical_hash() {
2654        // The instance form prefixes a different domain tag, so
2655        // even with an empty ancestor chain the two flavours are
2656        // distinguishable. Prevents a caller from accidentally
2657        // comparing an instance_hash against a canonical_hash
2658        // and getting a coincidental match.
2659        let p = compile_polydat_interpreter("const x := 1\n").expect("compile");
2660        let prog = p.program();
2661        assert_ne!(prog.instance_hash(&[]), prog.canonical_hash());
2662    }
2663
2664    #[test]
2665    fn instance_hash_changes_when_an_ancestor_program_changes() {
2666        // Parent A vs B differ only in a const-slot literal —
2667        // canonical_hash distinguishes them, so instance_hash
2668        // computed against the same child must distinguish too.
2669        let parent_a = compile_polydat_interpreter("const ds := \"v1\"\n").expect("a");
2670        let parent_b = compile_polydat_interpreter("const ds := \"v2\"\n").expect("b");
2671        let child = compile_polydat_interpreter("const y := 42\n").expect("child");
2672        let cp = child.program();
2673        let h_a = cp.instance_hash(&[parent_a.program().as_ref()]);
2674        let h_b = cp.instance_hash(&[parent_b.program().as_ref()]);
2675        assert_ne!(
2676            h_a, h_b,
2677            "ancestor const-slot edit must change instance_hash even \
2678             when the child program is byte-identical"
2679        );
2680    }
2681
2682    #[test]
2683    fn instance_hash_is_order_sensitive_in_the_chain() {
2684        // The chain order matters — different scope-tree paths
2685        // must map to different identities. The hash mixes
2686        // ancestor[i].canonical_hash() in chain order, so swapping
2687        // ancestors yields a different result.
2688        let g = compile_polydat_interpreter("const g := 1\n").expect("g");
2689        let p = compile_polydat_interpreter("const p := 2\n").expect("p");
2690        let c = compile_polydat_interpreter("const c := 3\n").expect("c");
2691        let cp = c.program();
2692        let chain1 = cp.instance_hash(&[p.program().as_ref(), g.program().as_ref()]);
2693        let chain2 = cp.instance_hash(&[g.program().as_ref(), p.program().as_ref()]);
2694        assert_ne!(chain1, chain2);
2695    }
2696
2697    #[test]
2698    fn instance_hash_is_deterministic_across_rebuilds() {
2699        // Two independent compiles of the same source feeding
2700        // the same child must produce the same instance_hash.
2701        let parent_src = "const ds := \"sift1m\"\n";
2702        let p1 = compile_polydat_interpreter(parent_src).expect("p1");
2703        let p2 = compile_polydat_interpreter(parent_src).expect("p2");
2704        let child = compile_polydat_interpreter("const y := 42\n").expect("child");
2705        let cp = child.program();
2706        let h1 = cp.instance_hash(&[p1.program().as_ref()]);
2707        let h2 = cp.instance_hash(&[p2.program().as_ref()]);
2708        assert_eq!(h1, h2);
2709    }
2710
2711    // ── is_equivalent_to / is_subset_of ──
2712
2713    #[test]
2714    fn is_equivalent_to_identical_programs() {
2715        let src = "const x := 100\n";
2716        let a = compile_polydat_interpreter(src).expect("a");
2717        let b = compile_polydat_interpreter(src).expect("b");
2718        assert!(a.program().is_equivalent_to(b.program()));
2719        assert!(b.program().is_equivalent_to(a.program())); // symmetric
2720    }
2721
2722    #[test]
2723    fn is_equivalent_to_differs_when_const_differs() {
2724        let a = compile_polydat_interpreter("const x := 100\n").expect("a");
2725        let b = compile_polydat_interpreter("const x := 101\n").expect("b");
2726        assert!(!a.program().is_equivalent_to(b.program()));
2727    }
2728
2729    #[test]
2730    fn is_subset_of_self_is_true() {
2731        let p = compile_polydat_interpreter("const x := 1\n").expect("p");
2732        // A program is trivially a subset of itself (the
2733        // equivalence shortcut at the top of is_subset_of).
2734        assert!(p.program().is_subset_of(p.program()));
2735    }
2736
2737    #[test]
2738    fn is_subset_of_distinct_definitions_is_false() {
2739        // Inner declares a NEW output the parent doesn't —
2740        // structurally not a subset.
2741        let parent = compile_polydat_interpreter("const x := 1\n").expect("parent");
2742        let inner = compile_polydat_interpreter("const y := 2\n").expect("inner");
2743        assert!(!inner.program().is_subset_of(parent.program()));
2744    }
2745}
2746
2747#[cfg(test)]
2748mod ast_metadata_tests {
2749    use crate::dsl::ast::Statement;
2750    use crate::dsl::compile_polydat_interpreter;
2751
2752    #[test]
2753    fn retained_ast_is_present_after_compile() {
2754        let src = "const dataset := \"sift1m\"\ncount := 100\n";
2755        let k = compile_polydat_interpreter(src).expect("compile");
2756        assert!(
2757            k.program().ast().is_some(),
2758            "AST should be retained on program"
2759        );
2760    }
2761
2762    #[test]
2763    fn binding_ast_for_finds_init_binding() {
2764        let src = "const dataset := \"sift1m\"\nratio := 2.5\n";
2765        let k = compile_polydat_interpreter(src).expect("compile");
2766        let stmt = k
2767            .program()
2768            .binding_ast_for("dataset")
2769            .expect("dataset binding should be retrievable");
2770        match stmt {
2771            Statement::Binding(b) => assert_eq!(b.targets[0], "dataset"),
2772            other => panic!("expected InitBinding for 'dataset', got {other:?}"),
2773        }
2774    }
2775
2776    #[test]
2777    fn binding_ast_for_finds_cycle_binding() {
2778        let src = "count := 42\n";
2779        let k = compile_polydat_interpreter(src).expect("compile");
2780        let stmt = k
2781            .program()
2782            .binding_ast_for("count")
2783            .expect("count binding should be retrievable");
2784        match stmt {
2785            Statement::Binding(b) => {
2786                assert!(
2787                    b.targets.iter().any(|t| t == "count"),
2788                    "CycleBinding targets should include 'count'"
2789                );
2790            }
2791            other => panic!("expected CycleBinding for 'count', got {other:?}"),
2792        }
2793    }
2794
2795    #[test]
2796    fn binding_ast_for_unknown_name_returns_none() {
2797        let k = compile_polydat_interpreter("const x := 1\n").expect("compile");
2798        assert!(k.program().binding_ast_for("does_not_exist").is_none());
2799    }
2800
2801    #[test]
2802    fn local_inclusion_chain_unknown_name_is_empty() {
2803        let k = compile_polydat_interpreter("const x := 1\n").expect("compile");
2804        let chain = k
2805            .program()
2806            .local_inclusion_chain("missing", &std::collections::HashSet::new());
2807        assert!(chain.is_empty());
2808    }
2809}
2810
2811/// R1.v transitive contagion: a node whose dependency cone
2812/// reaches a volatile producer must itself be marked
2813/// nondeterministic at construction time, so its clean flag
2814/// is never set and downstream pulls re-evaluate.
2815/// Without contagion, a consumer of `current_epoch_millis`
2816/// would return a stale cached value referencing the prior
2817/// cycle's timestamp.
2818#[cfg(test)]
2819mod r1v_contagion_tests {
2820    use crate::ast::Value;
2821    use crate::dsl::compile_polydat_interpreter;
2822
2823    #[test]
2824    fn every_read_re_evaluates_a_volatile_node() {
2825        // Each pull of a volatile output is a read, and every read
2826        // re-evaluates the volatile node (R1.v), with or without a
2827        // write between them.
2828        let src = "input cycle: u64\n\
2829                   c := counter()\n";
2830        let mut k = compile_polydat_interpreter(src).expect("compile");
2831        k.set_inputs(&[0]);
2832        let a = match k.pull_ref("c") {
2833            Value::U64(v) => *v,
2834            _ => panic!(),
2835        };
2836        let b = match k.pull_ref("c") {
2837            Value::U64(v) => *v,
2838            _ => panic!(),
2839        };
2840        let c = match k.pull_ref("c") {
2841            Value::U64(v) => *v,
2842            _ => panic!(),
2843        };
2844        assert_eq!(b, a + 1, "the second read re-evaluated the counter");
2845        assert_eq!(c, b + 1, "the third read re-evaluated the counter");
2846    }
2847}
2848
2849#[cfg(test)]
2850mod provmask_tests {
2851    use super::ProvMask;
2852
2853    /// The exactness this type exists for: bits above 63 are
2854    /// first-class, not aliased into a saturated top bit.
2855    #[test]
2856    fn bits_above_63_are_exact() {
2857        let mut a = ProvMask::empty();
2858        assert!(a.set(2));
2859        assert!(a.set(63));
2860        assert!(a.set(64));
2861        assert!(a.set(130));
2862        assert!(!a.set(130), "re-set reports no change");
2863        assert!(a.contains(2) && a.contains(63));
2864        assert!(a.contains(64) && a.contains(130));
2865        assert!(!a.contains(65) && !a.contains(129));
2866        assert_eq!(a.iter_ones().collect::<Vec<_>>(), vec![2, 63, 64, 130]);
2867    }
2868
2869    #[test]
2870    fn union_and_intersect_across_word_boundaries() {
2871        let mut a = ProvMask::empty();
2872        a.set(1);
2873        let mut b = ProvMask::empty();
2874        b.set(100);
2875        assert!(!a.intersects(&b));
2876        assert!(a.union_with(&b), "union reports growth");
2877        assert!(!a.union_with(&b), "idempotent union reports none");
2878        assert!(a.contains(1) && a.contains(100));
2879        assert!(a.intersects(&b));
2880        assert!(ProvMask::empty().is_zero());
2881        assert!(!a.is_zero());
2882    }
2883}