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