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