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