polydat_core/kernel/engines.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Polydat evaluation engines: EngineCore (shared eval loop) and the three
5//! P1 engine types — PolydatState (dependent-list), RawState (no provenance),
6//! and ProvScanState (provenance-scan).
7
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex, OnceLock};
10
11use super::WireSource;
12use super::program::PolydatProgram;
13use crate::ast::Value;
14
15/// Cached lookup of the `NBRS_DIRTY_DEBUG` env var. Called from
16/// the per-cycle hot path (`PolydatState::set_input`); reading the
17/// real `std::env::var` on every cycle costs ~30% of CPU on
18/// single-fiber dryrun benches (it walks the libc env table and
19/// formats a fresh CString each call). The OnceLock evaluates
20/// once on first touch and every subsequent call is one atomic
21/// load.
22fn nbrs_dirty_debug_enabled() -> bool {
23 static FLAG: OnceLock<bool> = OnceLock::new();
24 *FLAG.get_or_init(|| std::env::var("NBRS_DIRTY_DEBUG").is_ok())
25}
26
27/// A cross-kernel mutable cell for a `shared`-modifier wire.
28///
29/// When a `shared` output in an outer scope is bound into an
30/// inner kernel via `materialize_wiring_from_outer`, both kernels' input
31/// slots reference the same `SharedCell`. Writes from inner via
32/// `set_input` flow through to the cell; reads on either side
33/// pick up the latest value.
34///
35/// Concurrent writers serialize at the Mutex; the current
36/// semantic is **last-write-wins** (lock-acquisition order).
37/// Future templated patterns (atomic-fetch-add, sum-reduction,
38/// merge, etc.) — see SRD-16 §"Open: concurrent shared
39/// mutation" — will introduce alternative cell types selected
40/// per binding declaration.
41///
42/// ## Cross-fiber validity tracking
43///
44/// Each cell carries its own validity-tracking handles per
45/// `polydat/docs/design/cross_fiber_invalidation.md`:
46///
47/// - `revision: AtomicU64` — monotonic counter, bumped on every
48/// write. Consumer fibers cache the last revision they
49/// observed in their per-fiber `last_seen` map; a mismatch
50/// tells the cone walker to re-evaluate.
51/// - `scope_intent_dirty: Arc<AtomicU64>` — bit-vector shared
52/// with every other cell defined in this cell's scope. The
53/// cell's `bit` position is set on every write, allowing
54/// consumers to do an O(1) bulk check ("any cell in this
55/// scope dirty?") before drilling down to the per-cell
56/// revision compare.
57/// - `bit: u8` — this cell's position in the scope's intent-
58/// dirty vector. Allocated at cell creation by the defining
59/// scope's `EngineCore::allocate_cell_bit`. Bounded at 64
60/// for the first cut; spill-to-`Vec<AtomicU64>` is deferred.
61///
62/// The reader contract (S5 §1.1) is preserved: a producer's
63/// `publish` writes value + revision + intent bit in three
64/// Release stores; a consumer's `check_clean` walk on its next
65/// read observes the change without any host-side ceremony.
66pub struct SharedCellInner {
67 /// Cell value. The mutex serialises concurrent writers and
68 /// gives readers single-value atomicity.
69 pub value: Mutex<Value>,
70 /// Monotonic revision counter. Bumped on every write
71 /// (Release); compared by consumers (Acquire) against
72 /// per-fiber `last_seen`.
73 pub revision: AtomicU64,
74 /// Defining scope's intent-dirty bit-vector. Shared by Arc
75 /// across every cell allocated by the same scope. On every
76 /// write the producer ORs `1 << self.bit` into this
77 /// (Release) so consumers' bulk-mask check sees the scope
78 /// as dirty.
79 pub scope_intent_dirty: Arc<AtomicU64>,
80 /// This cell's bit position in `scope_intent_dirty`. Stable
81 /// for the cell's lifetime.
82 pub bit: u8,
83}
84
85impl std::fmt::Debug for SharedCellInner {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.debug_struct("SharedCellInner")
88 .field("revision", &self.revision.load(Ordering::Relaxed))
89 .field("bit", &self.bit)
90 .finish_non_exhaustive()
91 }
92}
93
94impl SharedCellInner {
95 /// Construct a new cell with the given initial value, bound
96 /// to the defining scope's intent-dirty word at the given
97 /// bit position within that word. Callers must allocate
98 /// `(word, bit)` via `EngineCore::allocate_cell_bit` —
99 /// the bit is not reusable for the cell's lifetime.
100 pub fn new(initial: Value, scope_intent_dirty: Arc<AtomicU64>, bit: u8) -> Self {
101 debug_assert!(
102 bit < 64,
103 "bit-within-word {bit} must be < 64; the allocator splits >64-bit \
104 scope vectors across multiple words"
105 );
106 Self {
107 value: Mutex::new(initial),
108 revision: AtomicU64::new(0),
109 scope_intent_dirty,
110 bit,
111 }
112 }
113
114 /// Producer-side write: replace the cell's value, bump the
115 /// revision, set the intent bit. Three Release stores
116 /// publish the write across any consumer fiber per
117 /// `cross_fiber_invalidation.md` §6. The mutex critical
118 /// section is held only for the value swap; the atomics
119 /// run outside it.
120 pub fn publish(&self, value: Value) {
121 {
122 let mut guard = self.value.lock().unwrap();
123 *guard = value;
124 }
125 self.revision.fetch_add(1, Ordering::Release);
126 self.scope_intent_dirty
127 .fetch_or(1u64 << self.bit, Ordering::Release);
128 }
129
130 /// Consumer-side read: snapshot the cell's value and the
131 /// revision it was published at. Returns a pair so the
132 /// caller can update its `last_seen[cell] = revision`
133 /// alongside taking the value, without a second cell access.
134 pub fn snapshot(&self) -> (Value, u64) {
135 // Acquire-load the revision first so the value read
136 // synchronises-with the producer's value publication.
137 // The mutex itself provides the memory barrier for the
138 // value, but the revision is read with explicit Acquire
139 // for the cross-fiber happens-before relation.
140 let value = self.value.lock().unwrap().clone();
141 let revision = self.revision.load(Ordering::Acquire);
142 (value, revision)
143 }
144}
145
146/// Externally-held handle to a shared cell. `Arc<SharedCellInner>`
147/// so a single cell can be referenced from many kernels at
148/// once. The handle is cheap to clone (Arc bump).
149pub type SharedCell = Arc<SharedCellInner>;
150
151/// Per-node cone metadata for cell-bound input dependencies.
152///
153/// Built lazily on first `check_cell_clean` per node and
154/// cached in [`EngineCore::cell_cones`]; invalidated by
155/// clearing the cache whenever cells are attached or detached.
156///
157/// The structure groups a node's cell-bound input dependencies
158/// by the defining scope's `intent_dirty` Arc (compared by
159/// `Arc::ptr_eq`). Each group carries the bulk-check
160/// `interest_mask` for the scope plus per-cell drill-down
161/// entries — implementing the bulk-mask + per-cell-revision
162/// protocol from `cross_fiber_invalidation.md` §5.
163#[derive(Debug, Default, Clone)]
164pub(crate) struct CellCone {
165 /// Cells grouped by defining-scope's `intent_dirty`. Empty
166 /// = no cell-bound deps; check returns trivially clean.
167 pub(crate) groups: Vec<CellConeGroup>,
168}
169
170#[derive(Debug, Clone)]
171pub(crate) struct CellConeGroup {
172 /// Defining scope's `intent_dirty` vector (Arc cloned from
173 /// the cells). Bulk-mask check: AND this against
174 /// `interest_mask`; if zero, every cell in this group is
175 /// clean for this consumer (modulo last_seen) — skip the
176 /// drill-down.
177 pub(crate) intent_dirty: Arc<AtomicU64>,
178 /// OR of `1 << cell.bit` for every cell in this group.
179 pub(crate) interest_mask: u64,
180 /// Per-cell drill-down entries. Each gives the bit position
181 /// in `intent_dirty` plus the input slot index where the
182 /// cell is attached, for revision compare against
183 /// `last_seen`.
184 pub(crate) cells: Vec<CellConeEntry>,
185}
186
187#[derive(Debug, Clone, Copy)]
188pub(crate) struct CellConeEntry {
189 pub(crate) bit: u8,
190 pub(crate) input_slot: usize,
191}
192
193/// One named shared cell propagated through the parent → child
194/// scope chain. Carried on `PolydatKernel` (and surfaced through
195/// `ScopeKernel::shared_cells_in_scope`) so a descendant whose
196/// program declares a matching input slot can attach the cell —
197/// even when intermediate scopes' bodies never name it and so
198/// have no input slot for it themselves.
199///
200/// Without this carrier, an ancestral `shared X := …` cell
201/// becomes invisible past the first intermediate scope under
202/// the closure-binding economy. With it, every spawn step
203/// computes "every cell visible at this scope" and threads the
204/// full set forward — the cascade is transitive by
205/// construction.
206#[derive(Clone, Debug)]
207pub struct SharedCellEntry {
208 /// The binding's name.
209 pub name: String,
210 /// The cell's declared type.
211 pub port_type: crate::ast::PortType,
212 /// The cell.
213 pub cell: SharedCell,
214}
215
216/// SRD-82 §"Panic reporting: one full render" — set by a host
217/// runtime that catches worker panics and renders the full
218/// enriched diagnostic itself (the `errors:` block). When set,
219/// the re-raise hook below prints a single first-line notice
220/// instead of the full body; bare polydat consumers never set it
221/// and keep the full print.
222static PANIC_REPORTING_DOWNSTREAM: std::sync::atomic::AtomicBool =
223 std::sync::atomic::AtomicBool::new(false);
224
225/// Declare that a downstream reporter will render eval-panic
226/// diagnostics in full (see `PANIC_REPORTING_DOWNSTREAM`).
227pub fn set_panic_reporting_downstream(on: bool) {
228 PANIC_REPORTING_DOWNSTREAM.store(on, std::sync::atomic::Ordering::Relaxed);
229}
230
231thread_local! {
232 /// True while a node eval runs inside the enrichment
233 /// catch_unwind in `eval_node`. The suppression hook checks
234 /// this to swallow the raw std panic-hook print (bare payload
235 /// + backtrace pointer at the original panic site) — that
236 /// same panic is about to be caught, enriched with
237 /// node/output/input context, and re-raised via `panic_any`,
238 /// which fires the hook again with this flag clear. Net
239 /// effect: exactly ONE hook print, and it's the enriched one.
240 static EVAL_PANIC_CAPTURE: std::cell::Cell<bool> =
241 const { std::cell::Cell::new(false) };
242 /// Original panic location captured by the suppression hook
243 /// while the flag above is set. Folded into the enriched
244 /// message so the true `file:line` survives the re-raise
245 /// (the re-raised panic's own location points at the
246 /// re-raise site, which is useless).
247 static EVAL_PANIC_LOCATION: std::cell::RefCell<Option<String>> =
248 const { std::cell::RefCell::new(None) };
249 /// One-shot marker armed just before the enriched re-raise
250 /// when a downstream reporter exists: the hook prints a short
251 /// first-line notice for that panic instead of the full body.
252 static RERAISE_SHORT: std::cell::Cell<bool> =
253 const { std::cell::Cell::new(false) };
254}
255
256/// Install (once, process-wide) a panic hook that chains to the
257/// previously installed hook unless the current thread is inside
258/// the wrapped node eval, in which case it records the panic
259/// location and stays quiet.
260fn install_eval_panic_hook() {
261 static HOOK: std::sync::Once = std::sync::Once::new();
262 HOOK.call_once(|| {
263 let prev = std::panic::take_hook();
264 std::panic::set_hook(Box::new(move |info| {
265 if EVAL_PANIC_CAPTURE.with(|c| c.get()) {
266 let loc = info.location().map(|l| l.to_string());
267 EVAL_PANIC_LOCATION.with(|slot| *slot.borrow_mut() = loc);
268 } else if RERAISE_SHORT.with(|c| c.replace(false)) {
269 // The runtime will render the full enriched
270 // diagnostic in the phase error list; one short
271 // line keeps the terminal signal without the
272 // four-fold repeat (SRD-82 §one full render).
273 let first = info
274 .payload()
275 .downcast_ref::<String>()
276 .map(String::as_str)
277 .and_then(|m| m.lines().next())
278 .unwrap_or("<non-string panic payload>");
279 eprintln!("op eval panic (detail in phase errors): {first}");
280 } else {
281 prev(info);
282 }
283 }));
284 });
285}
286
287/// RAII guard arming the suppression hook for one wrapped eval.
288/// Saves and restores the previous flag value: nodes that drive
289/// sub-kernels (comprehensions, gk-call) nest evals, and each
290/// level's catch_unwind must see its own panics suppressed.
291pub(crate) struct EvalPanicCaptureGuard {
292 prev: bool,
293}
294
295impl EvalPanicCaptureGuard {
296 pub(crate) fn arm() -> Self {
297 install_eval_panic_hook();
298 let prev = EVAL_PANIC_CAPTURE.with(|c| c.replace(true));
299 EVAL_PANIC_LOCATION.with(|slot| slot.borrow_mut().take());
300 Self { prev }
301 }
302}
303
304impl Drop for EvalPanicCaptureGuard {
305 fn drop(&mut self) {
306 EVAL_PANIC_CAPTURE.with(|c| c.set(self.prev));
307 }
308}
309
310/// The text of a panic payload: a `String` or a `&str`, else a marker.
311pub(crate) fn panic_payload_text(payload: &(dyn std::any::Any + Send)) -> String {
312 payload
313 .downcast_ref::<&'static str>()
314 .map(|s| (*s).to_string())
315 .or_else(|| payload.downcast_ref::<String>().cloned())
316 .unwrap_or_else(|| "<non-string panic payload>".into())
317}
318
319/// Build the rich diagnostic message for a node-level eval panic, on
320/// every engine (engine_parity.md, A7): the original payload, the
321/// panic location the capture guard recorded, the node's function
322/// name, every output it feeds, the program's diagnostic context
323/// (typically the source path / scope label), and the input values,
324/// already formatted (the interpreter's `Value`s through
325/// [`format_value_for_diag`], a compiled kernel's slots through its
326/// decoder). This is what the user sees instead of the bare panic
327/// payload, and it reads the same whichever engine raised it.
328pub(crate) fn enrich_panic(
329 payload: Box<dyn std::any::Any + Send>,
330 node_name: &str,
331 output_names: &[&str],
332 context: &str,
333 inputs: &[String],
334) -> String {
335 let original = panic_payload_text(payload.as_ref());
336 // A payload that already carries node context came from a
337 // nested wrapped eval's re-raise; its captured "location" is
338 // the re-raise site, not the original panic — skip it.
339 let location_line = if original.contains("↳ in node") {
340 String::new()
341 } else {
342 EVAL_PANIC_LOCATION
343 .with(|slot| slot.borrow_mut().take())
344 .map(|loc| format!("\n ↳ panicked at {loc}"))
345 .unwrap_or_default()
346 };
347 let outputs_label = if output_names.is_empty() {
348 "no declared output".to_string()
349 } else {
350 format!(
351 "output{} {}",
352 if output_names.len() == 1 { "" } else { "s" },
353 output_names.join(", ")
354 )
355 };
356 let mut input_label = String::new();
357 for (i, v) in inputs.iter().enumerate() {
358 if i > 0 {
359 input_label.push_str(", ");
360 }
361 input_label.push_str(&format!("[{i}]={v}"));
362 }
363 format!(
364 "{original}{location_line}\n ↳ in node `{node_name}` ({outputs_label}) \
365 while evaluating {context}\n \
366 ↳ inputs: [{input_label}]"
367 )
368}
369
370/// Re-raise an enriched message as the interpreter does: through
371/// `panic_any`, so the hook prints it once, or prints the short notice
372/// when a downstream reporter renders the full body (SRD-82).
373pub(crate) fn reraise_enriched(enriched: String) -> ! {
374 if PANIC_REPORTING_DOWNSTREAM.load(std::sync::atomic::Ordering::Relaxed) {
375 RERAISE_SHORT.with(|c| c.set(true));
376 }
377 std::panic::panic_any(enriched)
378}
379
380/// The interpreter's enrichment: the node's name and outputs from the
381/// program, the inputs as the `Value`s it was called with.
382fn enrich_eval_panic(
383 payload: Box<dyn std::any::Any + Send>,
384 program: &PolydatProgram,
385 node_idx: usize,
386 inputs: &[Value],
387) -> String {
388 let node_name = program
389 .nodes
390 .get(node_idx)
391 .map(|n| n.meta().name.to_string())
392 .unwrap_or_else(|| format!("<unknown node #{node_idx}>"));
393 let mut output_names: Vec<&str> = program
394 .output_map_iter()
395 .filter_map(|(name, (n_idx, _))| {
396 if *n_idx == node_idx {
397 Some(name.as_str())
398 } else {
399 None
400 }
401 })
402 .collect();
403 output_names.sort();
404 let inputs: Vec<String> = inputs.iter().map(format_value_for_diag).collect();
405 enrich_panic(
406 payload,
407 &node_name,
408 &output_names,
409 program.context(),
410 &inputs,
411 )
412}
413
414/// Format a `Value` into a short diagnostic string. Strings are
415/// quoted + truncated; vectors print their length not contents.
416pub(crate) fn format_value_for_diag(v: &Value) -> String {
417 match v {
418 Value::U64(n) => format!("U64({n})"),
419 Value::F64(n) => format!("F64({n})"),
420 Value::Bool(b) => format!("Bool({b})"),
421 Value::Str(s) => {
422 let trimmed: String = s.chars().take(40).collect();
423 if s.chars().count() > 40 {
424 format!("Str({trimmed:?}…)")
425 } else {
426 format!("Str({trimmed:?})")
427 }
428 }
429 Value::None => "None".to_string(),
430 other => format!("{:?}", other.port_type()),
431 }
432}
433
434/// Shared evaluation state for all Polydat engines. Contains the node
435/// output buffers, input values, and the eval loop.
436/// Engine types wrap this and provide their own invalidation strategy.
437pub struct EngineCore {
438 /// Per-node output value buffers, reused across evaluations.
439 pub(crate) buffers: Vec<Vec<Value>>,
440 /// Per-node: true = cached output is valid, false = needs eval.
441 pub(crate) node_clean: Vec<bool>,
442 /// Current input values (coordinates + captures, all unified).
443 /// For `shared`-bound slots, this holds a local snapshot of
444 /// the cell value — `refresh_shared` re-syncs it from the
445 /// cell, and `set_input` writes through to both the cell
446 /// and the snapshot.
447 pub(crate) inputs: Vec<Value>,
448 /// Default values for each input (used by reset_inputs).
449 pub(crate) input_defaults: Vec<Value>,
450 /// Optional cross-kernel shared cell per input slot. `None`
451 /// = local-only input (the common case). `Some(cell)` =
452 /// the slot is bound to a shared cell; writes propagate
453 /// through the cell to whatever other kernels share it.
454 pub(crate) shared_cells: Vec<Option<SharedCell>>,
455 /// SRD-13f Push B.2 — per-output broadcast cell. Indexed
456 /// by output position in `program.output_list`. `Some(cell)`
457 /// = the output broadcasts its value to descendants via
458 /// the cell whenever the owner pulls the output; `None` =
459 /// no broadcast subscribers were set up (no descendant
460 /// scope binds against this output's name).
461 ///
462 /// `materialize_wiring_from_outer` plumbs the same `Arc<SharedCell>`
463 /// onto the matching input slot on the inner kernel — at
464 /// that point both ends share the storage. Inner reads
465 /// transparently through the cell on every `read_input`;
466 /// outer's `pull` writes the freshly computed value into
467 /// the cell so subsequent inner reads return the current
468 /// value with no traversal.
469 pub(crate) output_cells: Vec<Option<SharedCell>>,
470 /// Pre-allocated scratch buffer for node input gathering.
471 pub(crate) input_scratch: Vec<Value>,
472 /// Per node, the scratch entries the node declared through
473 /// `scratch_layout` (a native cone's own slot buffer): storage
474 /// belongs to the state, never to the node, which is shared by
475 /// every state of the program (axiom S3).
476 pub(crate) node_scratch: Vec<Vec<crate::ast::ScratchBuf>>,
477 /// This scope's intent-dirty bit-vector. One `AtomicU64`
478 /// word per 64 cells allocated by this scope; new words
479 /// are appended on demand by [`Self::allocate_cell_bit`].
480 /// Each cell carries a clone of the specific `Arc<AtomicU64>`
481 /// for its word (and its bit-within-word). Consumer fibers'
482 /// bulk-mask check (per `cross_fiber_invalidation.md` §5)
483 /// groups cells by `Arc::ptr_eq` of their word and ANDs
484 /// the loaded word against the cone's interest mask for
485 /// that word.
486 ///
487 /// The `Vec<Arc<...>>` shape — rather than a single
488 /// `Arc<Vec<AtomicU64>>` — lets cells take a stable
489 /// per-word handle that the scope can grow without
490 /// invalidating any existing cell's reference.
491 pub(crate) scope_intent_words: Vec<Arc<AtomicU64>>,
492 /// Next bit position to allocate from
493 /// [`Self::scope_intent_words`]. Word index is
494 /// `next_cell_bit / 64`; bit within word is
495 /// `next_cell_bit % 64`. Monotonic; bits are never reused
496 /// within a scope's lifetime.
497 pub(crate) next_cell_bit: u32,
498 /// Per-fiber cache of the last revision this engine observed
499 /// for each cell it has read. Keyed by `Arc::as_ptr` of the
500 /// `SharedCellInner`. Sparse; entries are inserted lazily
501 /// on first observation via `check_cell_clean`.
502 ///
503 /// Per-fiber state — no contention. Pointer keys are stable
504 /// for the cell's lifetime; orphaned entries for dropped
505 /// cells are harmless (the handle is never observed again).
506 pub(crate) last_seen: std::collections::HashMap<*const SharedCellInner, u64>,
507 /// Per-node cone metadata for cell-bound input deps. Lazy:
508 /// `None` until first `check_cell_clean` for that node;
509 /// then built once and reused. Cleared in bulk on any
510 /// attach/detach of shared cells.
511 pub(crate) cell_cones: Vec<Option<CellCone>>,
512}
513
514// `last_seen` keys are `*const SharedCellInner` raw pointers,
515// which Rust treats as non-Send/non-Sync. The pointers are
516// only compared by identity (never dereferenced) and each
517// `EngineCore` is owned by exactly one fiber, so the
518// non-thread-safe pointer keys are sound. The Send/Sync
519// markers here cover that gap explicitly.
520unsafe impl Send for EngineCore {}
521unsafe impl Sync for EngineCore {}
522
523impl EngineCore {
524 /// Allocate the next bit position from this scope's
525 /// intent-dirty vector for a newly-created cell. Returns
526 /// the specific word's `Arc<AtomicU64>` plus the bit
527 /// position within that word. Grows
528 /// [`Self::scope_intent_words`] on demand — each new word
529 /// is a freshly-allocated `Arc<AtomicU64>` so existing
530 /// cells' references stay stable.
531 pub(crate) fn allocate_cell_bit(&mut self) -> (Arc<AtomicU64>, u8) {
532 let bit = self.next_cell_bit;
533 let word_idx = (bit / 64) as usize;
534 let bit_in_word = (bit % 64) as u8;
535 while self.scope_intent_words.len() <= word_idx {
536 self.scope_intent_words.push(Arc::new(AtomicU64::new(0)));
537 }
538 let word = self.scope_intent_words[word_idx].clone();
539 self.next_cell_bit += 1;
540 (word, bit_in_word)
541 }
542
543 /// Construct a new `SharedCell` bound to this scope's
544 /// intent-dirty vector. Convenience wrapper that allocates
545 /// a fresh bit and builds the cell — every cell creation
546 /// site goes through here so the scope's bit allocator
547 /// stays the single source of truth.
548 pub(crate) fn make_shared_cell(&mut self, initial: Value) -> SharedCell {
549 let (word, bit) = self.allocate_cell_bit();
550 Arc::new(SharedCellInner::new(initial, word, bit))
551 }
552}
553
554impl EngineCore {
555 /// Read an input slot's current value, transparent to whether
556 /// it's a plain slot or backed by a `SharedCell`. The
557 /// canonical read path used by both `eval_node` and
558 /// `PolydatState::get_input` — there's no separate "refresh" step
559 /// the caller must remember; the cell is queried on every
560 /// read.
561 ///
562 /// Cost: one Mutex lock per read on shared slots; a clone of
563 /// `inputs[idx]` on plain slots (Value's clone is cheap —
564 /// Arc-based for vectors, primitive copy otherwise).
565 #[inline]
566 pub(crate) fn read_input(&self, idx: usize) -> Value {
567 if let Some(cell) = self.shared_cells.get(idx).and_then(|c| c.as_ref()) {
568 return cell.value.lock().unwrap().clone();
569 }
570 self.inputs[idx].clone()
571 }
572
573 /// Build the cone metadata for `node_idx` — the per-scope
574 /// groups of cell-bound input dependencies, derived from
575 /// `program.input_provenance[node_idx]` and the cells
576 /// currently attached on this engine.
577 ///
578 /// Returns an empty `CellCone { groups: [] }` for nodes
579 /// with no cell-bound deps (the common case).
580 fn build_cell_cone(&self, program: &PolydatProgram, node_idx: usize) -> CellCone {
581 let empty = crate::kernel::ProvMask::empty();
582 let prov = program.input_provenance.get(node_idx).unwrap_or(&empty);
583 let mut groups: Vec<CellConeGroup> = Vec::new();
584 // Iterate set bits of `prov` directly: each bit is an
585 // input slot that flows into this node transitively.
586 for input_idx in prov.iter_ones() {
587 let Some(Some(cell)) = self.shared_cells.get(input_idx) else {
588 continue;
589 };
590 // Group by Arc-pointer identity of scope_intent_dirty.
591 let group_idx = groups
592 .iter()
593 .position(|g| Arc::ptr_eq(&g.intent_dirty, &cell.scope_intent_dirty));
594 let i = match group_idx {
595 Some(i) => i,
596 None => {
597 groups.push(CellConeGroup {
598 intent_dirty: cell.scope_intent_dirty.clone(),
599 interest_mask: 0,
600 cells: Vec::new(),
601 });
602 groups.len() - 1
603 }
604 };
605 groups[i].interest_mask |= 1u64 << cell.bit;
606 groups[i].cells.push(CellConeEntry {
607 bit: cell.bit,
608 input_slot: input_idx,
609 });
610 }
611 CellCone { groups }
612 }
613
614 /// Cross-fiber check: return `true` if this fiber's
615 /// `last_seen` is up-to-date for every cell in `node_idx`'s
616 /// cone (no cross-fiber writes since last observation).
617 /// Returns `false` if any cell's revision has advanced,
618 /// updating `last_seen` to reflect the new revisions in
619 /// preparation for the caller's re-evaluation.
620 ///
621 /// Per cross_fiber_invalidation.md §5: bulk-mask check
622 /// (one Acquire load + AND per scope group) early-outs
623 /// when nothing in the scope is dirty; per-cell drill-down
624 /// runs only on set bits.
625 fn check_cell_clean(&mut self, program: &PolydatProgram, node_idx: usize) -> bool {
626 // Lazy build the cone metadata.
627 if self.cell_cones.len() <= node_idx {
628 self.cell_cones.resize_with(node_idx + 1, || None);
629 }
630 if self.cell_cones[node_idx].is_none() {
631 let cone = self.build_cell_cone(program, node_idx);
632 self.cell_cones[node_idx] = Some(cone);
633 }
634
635 // First pass: walk the cone, collect mismatches. The
636 // immutable borrow of `self.cell_cones`,
637 // `self.shared_cells`, and `self.last_seen` coexist
638 // because they're disjoint fields of `self`.
639 let mut dirty: Vec<(*const SharedCellInner, u64, usize)> = Vec::new();
640 {
641 let cone = self.cell_cones[node_idx].as_ref().unwrap();
642 for group in &cone.groups {
643 let intent = group.intent_dirty.load(Ordering::Acquire);
644 let masked = intent & group.interest_mask;
645 if masked == 0 {
646 continue;
647 }
648 for entry in &group.cells {
649 if masked & (1u64 << entry.bit) == 0 {
650 continue;
651 }
652 let Some(Some(cell)) = self.shared_cells.get(entry.input_slot) else {
653 continue;
654 };
655 let r = cell.revision.load(Ordering::Acquire);
656 let ptr = Arc::as_ptr(cell);
657 let prev = self.last_seen.get(&ptr).copied().unwrap_or(0);
658 if r != prev {
659 dirty.push((ptr, r, entry.input_slot));
660 }
661 }
662 }
663 }
664 let clean = dirty.is_empty();
665 // Second pass: update last_seen for every cell whose
666 // revision we observed has advanced. Done in a
667 // separate pass to release the cone borrow above.
668 //
669 // Updating `last_seen` CONSUMES the dirty signal for this
670 // fiber, so the re-evaluation it triggers must reach every
671 // memoized node between the dirty slot and any consumer —
672 // not just the node that happened to check first. The
673 // caller only re-evaluates the CHECKED node; its recursive
674 // upstream walk re-checks each parent's own cone, which now
675 // reads the just-updated `last_seen` and comes back clean,
676 // leaving the intermediate buffers stale — the checked node
677 // then recomputes from stale parents (observed as a
678 // phase-poll predicate memoized at its pre-write value
679 // forever). Mirror `set_input`'s write-side rule on the
680 // read side: a detected cross-fiber write invalidates every
681 // node whose transitive input provenance covers the dirty
682 // slot.
683 if !clean {
684 // Exact multi-word mask: slots >= 64 invalidate too
685 // (the one-word form silently SKIPPED them — a latent
686 // under-invalidation on >64-input scopes).
687 let mut dirty_mask = crate::kernel::ProvMask::empty();
688 for (ptr, r, slot) in dirty {
689 self.last_seen.insert(ptr, r);
690 dirty_mask.set(slot);
691 }
692 for node_idx in 0..program.nodes.len() {
693 if program
694 .input_provenance
695 .get(node_idx)
696 .is_some_and(|prov| prov.intersects(&dirty_mask))
697 {
698 self.node_clean[node_idx] = false;
699 }
700 }
701 }
702 clean
703 }
704
705 /// Mark `cell_cones` as stale. Called after any change to
706 /// `shared_cells` that could affect the per-node cone
707 /// metadata (attach, detach). Next `check_cell_clean` on
708 /// any node will rebuild on demand.
709 pub(crate) fn invalidate_cell_cones(&mut self) {
710 for cone in self.cell_cones.iter_mut() {
711 *cone = None;
712 }
713 }
714
715 /// Evaluate a node by index. Shared by all engines.
716 /// Checks the clean flag, recursively evaluates upstream, gathers
717 /// inputs, calls node.eval(), marks clean.
718 pub fn eval_node(&mut self, program: &PolydatProgram, node_idx: usize) {
719 if self.node_clean[node_idx] {
720 // Memoization hit candidate — confirm cell-bound
721 // inputs in this node's cone are still at the
722 // revisions this fiber last observed. If any
723 // producer fiber has bumped a cell's revision since
724 // then, force a re-eval (the cache is stale even
725 // though `node_clean` is true) per
726 // cross_fiber_invalidation.md §5.
727 if self.check_cell_clean(program, node_idx) {
728 return;
729 }
730 self.node_clean[node_idx] = false;
731 }
732
733 let wiring = &program.wiring[node_idx];
734 for source in wiring.iter() {
735 if let WireSource::NodeOutput(upstream_idx, _) = source {
736 self.eval_node(program, *upstream_idx);
737 }
738 }
739
740 for (i, source) in wiring.iter().enumerate() {
741 self.input_scratch[i] = match source {
742 // `read_input` transparently reads the cell for
743 // `shared`-bound slots, so per-cycle eval picks
744 // up cross-kernel writes without any explicit
745 // refresh.
746 WireSource::Input(idx) => self.read_input(*idx),
747 WireSource::NodeOutput(upstream_idx, port_idx) => {
748 self.buffers[*upstream_idx][*port_idx].clone()
749 }
750 };
751 }
752
753 let input_count = wiring.len();
754
755 // SRD-74 Rule 1 — None propagation lifted to the kernel
756 // level. Any node whose inputs include `Value::None`
757 // emits `Value::None` on every output without invoking
758 // the node's `eval`. This holds the SQL-NULL / Rust
759 // `Option::?` propagation rule uniformly for ALL GK
760 // nodes, avoiding the dozens of duplicate per-node
761 // `if matches!(input, Value::None)` checks. Individual
762 // nodes (e.g. `Printf`) keep their checks redundant but
763 // harmless — the kernel guard fires first.
764 //
765 // Opt-out: nodes whose semantics explicitly consume
766 // `Value::None` (coalesce-style `default_or`, explicit
767 // optionality handlers per SRD-74 Rule 2) override
768 // `PolydatNode::accepts_none_inputs` to skip this guard. Such
769 // nodes handle `None` in their own `eval`.
770 let node_ref = &*program.nodes[node_idx];
771 if !node_ref.accepts_none_inputs()
772 && self.input_scratch[..input_count]
773 .iter()
774 .any(|v| matches!(v, Value::None))
775 {
776 for slot in &mut self.buffers[node_idx] {
777 *slot = Value::None;
778 }
779 self.node_clean[node_idx] = true;
780 return;
781 }
782
783 // Wrap the node's eval in catch_unwind so a node-level
784 // panic (e.g. `Value::as_u64` on a Str) can be re-raised
785 // with the diagnostic context the user actually needs:
786 // which node panicked, which output(s) it feeds, what
787 // the input values were, and where in the source the
788 // node came from. Without this, the fiber-level catcher
789 // sees only the bare message — "expected U64, got Str"
790 // — and the user has no way to find the offending
791 // binding short of bisecting the workload.
792 //
793 // Cost: one catch_unwind frame per slow-path node eval.
794 // The JIT path doesn't go through here. On the success
795 // path the frame is a few stack words; on the panic
796 // path it's strictly an improvement over what the
797 // user sees today.
798 //
799 // The capture guard suppresses the std panic hook for
800 // the duration: without it, the hook prints the BARE
801 // payload ("expected U64, got F64" + backtrace) at the
802 // original panic site, before enrichment exists, and
803 // that raw print is the loudest thing the user sees.
804 // Re-raising with `panic_any` (not `resume_unwind`)
805 // fires the hook again — now unsuppressed — so the one
806 // message that prints is the enriched one.
807 let guard = EvalPanicCaptureGuard::arm();
808 let payload = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
809 program.nodes[node_idx].eval_in(
810 &mut self.node_scratch[node_idx],
811 &self.input_scratch[..input_count],
812 &mut self.buffers[node_idx],
813 );
814 }));
815 drop(guard);
816 if let Err(e) = payload {
817 // A native cone re-raises its member's failure already
818 // enriched with the member's name, its inputs, and this
819 // program's context (A7); the cone itself is not a frame,
820 // so the report reads as it does on every other engine.
821 if program.nodes[node_idx].fusion_subgraph().is_some()
822 && e.downcast_ref::<String>()
823 .is_some_and(|s| s.contains("↳ in node"))
824 {
825 let enriched = *e.downcast::<String>().expect("checked above");
826 reraise_enriched(enriched);
827 }
828 let enriched =
829 enrich_eval_panic(e, program, node_idx, &self.input_scratch[..input_count]);
830 reraise_enriched(enriched);
831 }
832 self.node_clean[node_idx] = true;
833 }
834
835 /// Pull a named output.
836 pub fn pull(&mut self, program: &PolydatProgram, output_name: &str) -> &Value {
837 let (node_idx, port_idx) = *program
838 .output_map
839 .get(output_name)
840 .unwrap_or_else(|| panic!("unknown output variate: {output_name}"));
841 self.eval_node(program, node_idx);
842 // SRD-13f Push B.2: broadcast the freshly computed
843 // value through this output's cell (if attached) so
844 // descendant kernels that bound their matching input
845 // slot to the same cell see the current value on
846 // their next read.
847 if let Some(output_idx) = program.output_index(output_name)
848 && let Some(Some(cell)) = self.output_cells.get(output_idx)
849 {
850 let v = self.buffers[node_idx][port_idx].clone();
851 // `publish` does the mutex write + revision bump +
852 // intent-bit set in three Release stores so the
853 // descendant's cone walker observes the change on
854 // its next read (cross_fiber_invalidation.md §5).
855 cell.publish(v);
856 }
857 &self.buffers[node_idx][port_idx]
858 }
859
860 /// SRD-13f Push B.2 — allocate broadcast cells for every
861 /// output in `program`. Idempotent: if cells are already
862 /// allocated (size matches the program's output count),
863 /// the call is a no-op. Initial cell value is taken from
864 /// the current buffer (typically `Value::None` at
865 /// construction, before any pull has fired).
866 ///
867 /// Called from kernel constructors and from
868 /// `materialize_wiring_from_outer`-style operations that materialize
869 /// new descendants — the inner side needs the cell to
870 /// exist before it can attach to its input slot.
871 pub(crate) fn seed_output_cells(&mut self, program: &PolydatProgram) {
872 let n = program.output_names().len();
873 if self.output_cells.len() == n {
874 return;
875 }
876 // Two-pass to avoid borrowing `self` immutably (for
877 // buffer lookups) while also borrowing it mutably (for
878 // `make_shared_cell`). First collect initial values,
879 // then construct the cells.
880 let initials: Vec<Value> = (0..n)
881 .map(|i| {
882 let name = &program.output_list()[i].0;
883 let (node_idx, port_idx) = program.output_map[name];
884 // Defensive bounds-check: some construction paths
885 // (raw state, partial programs) may not populate
886 // buffers for every node referenced in the output
887 // map. Seed with `Value::None` rather than panic.
888 self.buffers
889 .get(node_idx)
890 .and_then(|b| b.get(port_idx))
891 .cloned()
892 .unwrap_or(Value::None)
893 })
894 .collect();
895 self.output_cells = initials
896 .into_iter()
897 .map(|init| Some(self.make_shared_cell(init)))
898 .collect();
899 }
900
901 /// Output broadcast cell for the named output, if seeded.
902 pub(crate) fn output_cell(&self, program: &PolydatProgram, name: &str) -> Option<SharedCell> {
903 let idx = program.output_index(name)?;
904 self.output_cells.get(idx).and_then(|c| c.clone())
905 }
906}
907
908// =================================================================
909// PolydatState: dependent-list engine (default, O(affected) invalidation)
910// =================================================================
911
912/// Polydat evaluation engine using precomputed per-input dependent lists.
913///
914/// On `set_input()`, only nodes that depend on the changed input
915/// are dirtied. O(affected_nodes) per input change.
916/// This is the default engine for production use.
917pub struct PolydatState {
918 /// Shared evaluation core (buffers, clean flags, inputs).
919 pub core: EngineCore,
920 /// Per-input dependent node lists for O(affected) invalidation.
921 input_dependents: Vec<Vec<usize>>,
922 /// Indices of non-deterministic nodes (zero-provenance, no declared inputs).
923 ///
924 /// These nodes produce a different value on every evaluation (e.g.,
925 /// `counter()`, `current_epoch_millis()`). They are unconditionally
926 /// marked dirty on every `set_input()` call so they are never cached.
927 nondeterministic_nodes: Vec<usize>,
928}
929
930impl PolydatState {
931 /// Construct a PolydatState from its component parts.
932 pub(crate) fn from_parts(
933 core: EngineCore,
934 input_dependents: Vec<Vec<usize>>,
935 nondeterministic_nodes: Vec<usize>,
936 ) -> Self {
937 Self {
938 core,
939 input_dependents,
940 nondeterministic_nodes,
941 }
942 }
943
944 /// Set all coordinate inputs at once. Wraps each u64 as
945 /// `Value::U64` and sets them at indices 0..N with per-input
946 /// change detection.
947 pub fn set_inputs(&mut self, coords: &[u64]) {
948 self.write_coordinates(coords);
949 }
950
951 /// Write the coordinates: what construction does to seed a state's
952 /// folded constants. A host's write is [`Self::set_inputs`].
953 pub(crate) fn seed_inputs(&mut self, coords: &[u64]) {
954 self.write_coordinates(coords);
955 }
956
957 fn write_coordinates(&mut self, coords: &[u64]) {
958 for (i, &c) in coords.iter().enumerate().take(self.core.inputs.len()) {
959 self.core.inputs[i] = Value::U64(c);
960 // Unconditional invalidation: the write itself is the
961 // signal — see `set_input` for the rationale.
962 if i < self.input_dependents.len() {
963 for &node_idx in &self.input_dependents[i] {
964 self.core.node_clean[node_idx] = false;
965 }
966 }
967 }
968 // Non-deterministic nodes must always re-evaluate.
969 for &idx in &self.nondeterministic_nodes {
970 self.core.node_clean[idx] = false;
971 }
972 }
973
974 /// Set a single input by index, dirtying only dependent nodes.
975 ///
976 /// Single-register semantics: a cell-bound slot's only
977 /// register IS the cell — `set_input` writes through the
978 /// cell. A non-cell slot's register is the local
979 /// `inputs[idx]` array. There's no second snapshot kept in
980 /// lockstep with the cell; reads always go to whichever is
981 /// the slot's register.
982 ///
983 /// Dependents-marking is the dependent-list invalidation
984 /// strategy carried by `PolydatState`; it's the write-side
985 /// half of the engine's dirty-tracking. Other engines
986 /// (`RawState`, `ProvScanState`) implement different
987 /// strategies — see their own `set_inputs` impls.
988 pub fn set_input(&mut self, idx: usize, value: Value) {
989 if let Some(cell) = self.core.shared_cells.get(idx).and_then(|c| c.as_ref()) {
990 // Cell-bound slot: the cell is the register. We do
991 // NOT mirror the value into `inputs[idx]`; that
992 // array slot is unused for cell-bound inputs.
993 //
994 // `publish` does the mutex write + revision bump +
995 // intent-bit set in three Release stores so the
996 // any other fiber's cone walker observes the
997 // change on its next read
998 // (cross_fiber_invalidation.md §5).
999 cell.publish(value);
1000 } else {
1001 self.core.inputs[idx] = value;
1002 }
1003 // Mark every transitive dependent dirty unconditionally.
1004 // The act of writing an input IS the invalidation
1005 // signal — we don't gate on value equality because (a)
1006 // structural equality on rich Value variants
1007 // (Json/Bytes/VecF32) is expensive enough to defeat
1008 // the purpose of the optimisation, and (b) a same-
1009 // value rewrite is still a legitimate "the upstream
1010 // owner asked for a re-evaluation" signal that
1011 // downstream side-effecting nodes (`log_*`, audit
1012 // emitters, time-stamped observers) MUST honour.
1013 let dirty_debug = nbrs_dirty_debug_enabled();
1014 if idx < self.input_dependents.len() {
1015 if dirty_debug {
1016 eprintln!(
1017 "DIRTY: set_input idx={idx} input_count={} dependents_for_idx={} \
1018 total_input_dependents_len={}",
1019 self.core.inputs.len(),
1020 self.input_dependents[idx].len(),
1021 self.input_dependents.len()
1022 );
1023 }
1024 for &node_idx in &self.input_dependents[idx] {
1025 self.core.node_clean[node_idx] = false;
1026 }
1027 } else if dirty_debug {
1028 eprintln!(
1029 "DIRTY: set_input idx={idx} OUT_OF_RANGE input_dependents_len={}",
1030 self.input_dependents.len()
1031 );
1032 }
1033 // Non-deterministic nodes must always re-evaluate.
1034 for &idx in &self.nondeterministic_nodes {
1035 self.core.node_clean[idx] = false;
1036 }
1037 }
1038
1039 /// Read the value of an input by index.
1040 ///
1041 /// Single-register read: cell-bound slots return the cell's
1042 /// current value; non-cell slots return the local register.
1043 /// One canonical value per slot, no stale snapshot.
1044 pub fn get_input(&self, idx: usize) -> Value {
1045 self.core.read_input(idx)
1046 }
1047
1048 /// Alias for [`Self::get_input`]; kept for legacy callers
1049 /// that picked the more explicit name. Both read the cell
1050 /// when one is attached.
1051 pub fn read_input_value(&self, idx: usize) -> Value {
1052 self.core.read_input(idx)
1053 }
1054
1055 /// Attach a `SharedCell` to an input slot.
1056 ///
1057 /// After this call the cell becomes the slot's sole
1058 /// register: reads via `read_input` go through the cell,
1059 /// `set_input` writes through the cell. The local
1060 /// `inputs[idx]` array entry for this slot is unused for
1061 /// cell-bound slots — there is no second register kept in
1062 /// lockstep.
1063 ///
1064 /// Dependents are dirtied because the slot's effective
1065 /// value just changed from the local default to whatever
1066 /// the cell currently holds.
1067 pub fn attach_shared_cell(&mut self, idx: usize, cell: SharedCell) {
1068 if idx >= self.core.shared_cells.len() {
1069 self.core.shared_cells.resize(idx + 1, None);
1070 }
1071 self.core.shared_cells[idx] = Some(cell);
1072 if idx < self.input_dependents.len() {
1073 for &node_idx in &self.input_dependents[idx] {
1074 self.core.node_clean[node_idx] = false;
1075 }
1076 }
1077 // Cone metadata depends on which slots have cells; the
1078 // new attachment invalidates any cached cone groups.
1079 // Next `check_cell_clean` per node rebuilds on demand
1080 // per cross_fiber_invalidation.md §3.1.
1081 self.core.invalidate_cell_cones();
1082 }
1083
1084 /// Returns the `SharedCell` attached to an input slot, if any.
1085 /// Used by `materialize_wiring_from_outer` to share an existing cell with
1086 /// inner kernels.
1087 pub fn shared_cell(&self, idx: usize) -> Option<SharedCell> {
1088 self.core.shared_cells.get(idx).and_then(|c| c.clone())
1089 }
1090
1091 /// Reset a range of inputs to their defaults. Used at stanza
1092 /// boundaries to prevent capture leakage across stanzas.
1093 /// `from_idx` is typically `coord_count` (skip coordinates,
1094 /// reset only capture inputs).
1095 ///
1096 /// Cell-bound slots are skipped: the cell is cross-kernel
1097 /// shared state with its own lifecycle (managed by the
1098 /// owning ancestor scope), and a stanza-local reset must
1099 /// not clobber other kernels' views.
1100 pub fn reset_inputs_from(&mut self, from_idx: usize) {
1101 for i in from_idx..self.core.inputs.len() {
1102 // Cell-bound slots: the cell is the register, owned
1103 // by the ancestor that declared `shared X := init`.
1104 // Don't touch.
1105 if self.core.shared_cells.get(i).is_some_and(|c| c.is_some()) {
1106 continue;
1107 }
1108 if self.core.inputs[i] != self.core.input_defaults[i] {
1109 self.core.inputs[i] = self.core.input_defaults[i].clone();
1110 if i < self.input_dependents.len() {
1111 for &node_idx in &self.input_dependents[i] {
1112 self.core.node_clean[node_idx] = false;
1113 }
1114 }
1115 }
1116 }
1117 }
1118
1119 /// Mark every node dirty and leave the inputs as they are: every
1120 /// node reruns at the next pull, as if the cycle had moved. What
1121 /// `Kernel::invalidate_all` means on every engine; a host that
1122 /// wants the inputs back at their defaults calls
1123 /// [`Self::reset_inputs_from`] as well.
1124 pub fn invalidate_all(&mut self) {
1125 self.core.node_clean.fill(false);
1126 }
1127
1128 /// Pull a named output variate from the program.
1129 pub fn pull(&mut self, program: &PolydatProgram, output_name: &str) -> &Value {
1130 self.core.pull(program, output_name)
1131 }
1132
1133 /// Pre-populate a node's output buffer slot and mark it clean,
1134 /// suppressing on-demand evaluation. Used by the scope-init
1135 /// pass (SRD 11 §"Init Binding Contract" Plan B) to seed
1136 /// per-fiber states with init binding values that the
1137 /// activation kernel already evaluated, so each fiber doesn't
1138 /// re-fire the eval at first pull.
1139 pub fn seed_node_buffer(&mut self, node_idx: usize, port_idx: usize, value: Value) {
1140 if node_idx >= self.core.buffers.len() {
1141 return;
1142 }
1143 if port_idx >= self.core.buffers[node_idx].len() {
1144 return;
1145 }
1146 self.core.buffers[node_idx][port_idx] = value;
1147 self.core.node_clean[node_idx] = true;
1148 }
1149
1150 /// Read a node's output buffer slot. Used by the scope-init
1151 /// pass to extract a pre-pulled init binding value from one
1152 /// state and seed it into another.
1153 pub fn node_buffer(&self, node_idx: usize, port_idx: usize) -> Option<&Value> {
1154 self.core
1155 .buffers
1156 .get(node_idx)
1157 .and_then(|ports| ports.get(port_idx))
1158 }
1159
1160 /// Pull an output by index (declaration order). Only evaluates
1161 /// the computation cone for this specific output.
1162 pub fn pull_by_index(&mut self, program: &PolydatProgram, output_idx: usize) -> &Value {
1163 let (node_idx, port_idx) = program.resolve_output_by_index(output_idx);
1164 self.core.eval_node(program, node_idx);
1165 &self.core.buffers[node_idx][port_idx]
1166 }
1167
1168 /// Pull all outputs in declaration order.
1169 pub fn pull_all<'a>(&'a mut self, program: &PolydatProgram) -> Vec<&'a Value> {
1170 for i in 0..program.output_count() {
1171 let (node_idx, _) = program.resolve_output_by_index(i);
1172 self.core.eval_node(program, node_idx);
1173 }
1174 (0..program.output_count())
1175 .map(|i| {
1176 let (ni, pi) = program.resolve_output_by_index(i);
1177 &self.core.buffers[ni][pi]
1178 })
1179 .collect()
1180 }
1181
1182 /// Create a memoized accessor for a named subset of outputs.
1183 /// Resolves names to indices once; subsequent access uses indices only.
1184 pub fn accessor(program: &PolydatProgram, names: &[&str]) -> OutputAccessor {
1185 let indices: Vec<usize> = names
1186 .iter()
1187 .filter_map(|n| program.output_index(n))
1188 .collect();
1189 OutputAccessor { indices }
1190 }
1191
1192 /// Evaluate a node by index (exposed for constant folding in PolydatProgram).
1193 pub(crate) fn eval_node_public(&mut self, program: &PolydatProgram, node_idx: usize) {
1194 self.core.eval_node(program, node_idx);
1195 }
1196}
1197
1198/// Memoized output accessor for a named subset of outputs.
1199///
1200/// Created once from output names via `PolydatState::accessor()`.
1201/// Subsequent pulls use pre-resolved indices — no name lookups.
1202pub struct OutputAccessor {
1203 indices: Vec<usize>,
1204}
1205
1206impl OutputAccessor {
1207 /// Pull all outputs in this accessor from the given state.
1208 pub fn pull_all<'a>(
1209 &self,
1210 state: &'a mut PolydatState,
1211 program: &PolydatProgram,
1212 ) -> Vec<&'a Value> {
1213 for &idx in &self.indices {
1214 let (node_idx, _) = program.resolve_output_by_index(idx);
1215 state.core.eval_node(program, node_idx);
1216 }
1217 self.indices
1218 .iter()
1219 .map(|&idx| {
1220 let (ni, pi) = program.resolve_output_by_index(idx);
1221 &state.core.buffers[ni][pi]
1222 })
1223 .collect()
1224 }
1225
1226 /// Number of outputs in this accessor.
1227 pub fn len(&self) -> usize {
1228 self.indices.len()
1229 }
1230
1231 /// Whether this accessor has no outputs.
1232 pub fn is_empty(&self) -> bool {
1233 self.indices.is_empty()
1234 }
1235}
1236
1237// =================================================================
1238// RawState: no provenance engine (all nodes dirty every eval)
1239// =================================================================
1240
1241/// Polydat evaluation engine with no provenance. Every `set_inputs()`
1242/// marks all nodes dirty. Baseline for benchmarking provenance overhead.
1243pub struct RawState {
1244 /// Shared evaluation core.
1245 pub core: EngineCore,
1246}
1247
1248impl RawState {
1249 /// Set new input values and mark all nodes dirty (no provenance check).
1250 pub fn set_inputs(&mut self, coords: &[u64]) {
1251 for (i, &c) in coords.iter().enumerate().take(self.core.inputs.len()) {
1252 self.core.inputs[i] = Value::U64(c);
1253 }
1254 self.core.node_clean.fill(false);
1255 }
1256
1257 /// Pull a named output variate from the program.
1258 pub fn pull(&mut self, program: &PolydatProgram, output_name: &str) -> &Value {
1259 self.core.pull(program, output_name)
1260 }
1261}
1262
1263// =================================================================
1264// ProvScanState: provenance-scan engine (O(all) invalidation)
1265// =================================================================
1266
1267/// Polydat evaluation engine using provenance bitmask scanning.
1268///
1269/// On `set_inputs()`, scans ALL nodes and checks each node's
1270/// provenance bitmask against the changed-inputs mask.
1271/// O(all_nodes) per input change regardless of how many changed.
1272pub struct ProvScanState {
1273 /// Shared evaluation core.
1274 pub core: EngineCore,
1275 input_provenance: Vec<crate::kernel::ProvMask>,
1276 /// Indices of non-deterministic nodes.
1277 nondeterministic_nodes: Vec<usize>,
1278}
1279
1280impl ProvScanState {
1281 /// Construct a ProvScanState from its component parts.
1282 pub(crate) fn from_parts(
1283 core: EngineCore,
1284 input_provenance: Vec<crate::kernel::ProvMask>,
1285 nondeterministic_nodes: Vec<usize>,
1286 ) -> Self {
1287 Self {
1288 core,
1289 input_provenance,
1290 nondeterministic_nodes,
1291 }
1292 }
1293
1294 /// Set new input values and invalidate affected nodes.
1295 pub fn set_inputs(&mut self, coords: &[u64]) {
1296 let mut mask = crate::kernel::ProvMask::empty();
1297 for (i, &c) in coords.iter().enumerate().take(self.core.inputs.len()) {
1298 self.core.inputs[i] = Value::U64(c);
1299 // Unconditional: writing the input IS the
1300 // invalidation signal regardless of value equality.
1301 mask.set(i);
1302 }
1303 if !mask.is_zero() {
1304 for (i, clean) in self.core.node_clean.iter_mut().enumerate() {
1305 if *clean && self.input_provenance[i].intersects(&mask) {
1306 *clean = false;
1307 }
1308 }
1309 }
1310 for &idx in &self.nondeterministic_nodes {
1311 self.core.node_clean[idx] = false;
1312 }
1313 }
1314
1315 /// Pull a named output variate from the program.
1316 pub fn pull(&mut self, program: &PolydatProgram, output_name: &str) -> &Value {
1317 self.core.pull(program, output_name)
1318 }
1319}