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