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