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