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