Skip to main content

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}