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