Skip to main content

polydat_core/compile/
hybrid.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Hybrid kernel: per-node optimal compilation level.
5//!
6//! Splits the DAG into segments based on each node's compilation
7//! capability. JIT-able nodes are batched into native code segments.
8//! Non-JIT-able nodes run as Phase 2 closures. All segments share
9//! the same flat u64 buffer.
10//!
11//! This is the "best of all worlds" kernel — no node pays more
12//! overhead than it needs to.
13//!
14//! Three kernel types. They differ in what `set_inputs` marks and
15//! whether `eval_for_slot` consults the cone guard; the shared step
16//! loop reads the mode's `use_clean` flag per step:
17//!
18//! | Type | Push (per-step skip) | Pull (cone guard) |
19//! |------|---------------------|-------------------|
20//! | `HybridKernelRaw` | — | — |
21//! | `HybridKernelPull` | — | yes |
22//! | `HybridKernelPushPull` | yes | yes |
23
24use std::collections::HashMap;
25
26use crate::ast::SlotShape;
27use crate::ast::{CompiledU64Op, PolydatNode};
28use crate::kernel::WireSource;
29
30#[cfg(feature = "jit")]
31use crate::compile::jit::{self, JitOp};
32
33/// A step in the hybrid kernel: either JIT native code or a Phase 2 closure.
34enum HybridStep {
35    /// A batch of nodes compiled to native code via Cranelift.
36    /// The function reads/writes directly to the shared buffer.
37    #[cfg(feature = "jit")]
38    Jit(JitSegment),
39    /// A single node executed via its Phase 2 closure.
40    Closure(ClosureStep),
41}
42
43#[cfg(feature = "jit")]
44struct JitSegment {
45    code_fn: crate::compile::jit::NativeFn,
46    /// The finalized native code, shared by every kernel created from
47    /// one program.
48    _module: crate::compile::jit::JitCode,
49    /// Whether the code calls a helper, and so runs under the longjmp
50    /// catch; code with no call runs bare.
51    fallible: bool,
52    /// The slots the segment reads and writes, for cones and for the
53    /// `None` check native code cannot make itself.
54    input_slots: Vec<usize>,
55    output_slots: Vec<usize>,
56    /// The program nodes in the segment, in step order; the tracker
57    /// slot names the one a failure belongs to.
58    nodes: Vec<usize>,
59}
60
61impl HybridStep {
62    fn input_slots(&self) -> &[usize] {
63        match self {
64            #[cfg(feature = "jit")]
65            HybridStep::Jit(seg) => &seg.input_slots,
66            HybridStep::Closure(cs) => &cs.input_slots,
67        }
68    }
69    fn output_slots(&self) -> &[usize] {
70        match self {
71            #[cfg(feature = "jit")]
72            HybridStep::Jit(seg) => &seg.output_slots,
73            HybridStep::Closure(cs) => &cs.output_slots,
74        }
75    }
76    /// SRD-74 Rule 2: the step runs on `None` inputs. Native code never
77    /// does; a node downstream of an unset extern is a closure.
78    fn accepts_none(&self) -> bool {
79        match self {
80            #[cfg(feature = "jit")]
81            HybridStep::Jit(_) => false,
82            HybridStep::Closure(cs) => cs.accepts_none,
83        }
84    }
85
86    /// The program node a failure in this step belongs to: a closure's
87    /// own, or the member native code named in the tracker slot.
88    #[cfg_attr(not(feature = "jit"), allow(unused_variables))]
89    fn failing_node(&self, buffer: &[u64], tracker: usize) -> usize {
90        match self {
91            #[cfg(feature = "jit")]
92            HybridStep::Jit(seg) => seg
93                .nodes
94                .get(buffer[tracker] as usize)
95                .copied()
96                .unwrap_or(usize::MAX),
97            HybridStep::Closure(cs) => cs.node,
98        }
99    }
100}
101
102/// A closure step's op: pure-scalar u64 closure, or a slot op
103/// with kernel-owned scratch for typed-slice ports
104/// (type_system_alignment.md §4, compiled_handles.md §3).
105enum ClosureOp {
106    U64(CompiledU64Op),
107    Slot(crate::ast::CompiledSlotOp),
108}
109
110struct ClosureStep {
111    op: ClosureOp,
112    input_slots: Vec<usize>,
113    output_slots: Vec<usize>,
114    /// `[start, end)` into the kernel's scratch arena.
115    scratch_range: (usize, usize),
116    /// SRD-74 Rule 2: the closure runs on `None` inputs.
117    accepts_none: bool,
118    /// The program node, for the failure path.
119    node: usize,
120}
121
122/// An output resolved for the index-keyed pull: its slot, its type,
123/// and the steps of its cone.
124type ResolvedOutput = (usize, crate::ast::PortType, Option<std::sync::Arc<[usize]>>);
125
126/// Common fields shared by all hybrid kernel variants. A clone is a new
127/// state of the same program: the steps and the nodes are shared,
128/// everything else is the clone's own (engine_parity.md, step 4), and
129/// every pair in its buffer points into its own storage (axiom S3),
130/// never into the state it was cloned from.
131struct HybridCore {
132    buffer: Vec<u64>,
133    coord_count: usize,
134    steps: std::sync::Arc<Vec<HybridStep>>,
135    output_map: HashMap<String, usize>,
136    gather_buf: Vec<u64>,
137    scatter_buf: Vec<u64>,
138    /// Kernel-owned vector storage; vector-producing ports'
139    /// (ptr, len) slots view entries here (type_system_alignment.md
140    /// §4, compiled_handles.md §3).
141    scratch: Vec<crate::ast::ScratchBuf>,
142    /// Axiom S2: per-slot Ref2 mask — raw readers panic on these.
143    ref_slots: Vec<bool>,
144    /// Axiom S9(a): (first slot of a Ref pair → scratch index).
145    ref_scratch: Vec<(usize, usize)>,
146    /// Port type of each named output, for `get_value`.
147    output_types: HashMap<String, crate::ast::PortType>,
148    /// The extern inputs, written through at every set.
149    externs: crate::compile::externs::Externs,
150    /// The traversals the program declares (SRD 113), opened through the
151    /// `Kernel` trait.
152    traversals: std::sync::Arc<[crate::dsl::traversal::Traversal]>,
153    /// Per declared output, its slot, type, and cone, resolved on the
154    /// first index-keyed pull (SRD 117 step 3).
155    resolved_outputs: Vec<Option<ResolvedOutput>>,
156    /// Keep source nodes alive so JIT-baked pointers remain valid.
157    _nodes: std::sync::Arc<Vec<Box<dyn PolydatNode>>>,
158    /// The coordinates set through the `Kernel` trait, pending
159    /// evaluation; `stale` means a write happened since the last
160    /// evaluation round.
161    drive: crate::compile::Drive,
162    /// Per slot: the slot holds `None` (SRD-74 on a compiled kernel).
163    none: Vec<bool>,
164    /// Per step: the evaluation round it last ran in, so a new round
165    /// forgets every run without a scan.
166    ran: Vec<u64>,
167    /// The evaluation round: advanced by the first evaluation after a
168    /// write, so a mode without per-step currency runs a step once per
169    /// round rather than once per reader. Bookkeeping only: it wipes
170    /// nothing, and every output stands until an input in its
171    /// provenance is written. 0 is never a round.
172    epoch: u64,
173    /// Every step ran in the round: a full evaluation happened.
174    all_ran: bool,
175    /// Per step: its outputs are current for the inputs it depends on.
176    /// Cleared through the plan when an input changes, whichever call
177    /// changed it; never set for a volatile step.
178    clean: Vec<bool>,
179    /// Whether this kernel's provenance mode skips current steps.
180    use_clean: bool,
181    /// The dirty-register plan: what each input invalidates, what each
182    /// output needs.
183    plan: std::sync::Arc<crate::compile::Invalidation>,
184    /// Per step: nondeterministic or downstream of one, never current.
185    volatile: std::sync::Arc<[bool]>,
186    /// Per step: a side channel, skipped when current in every mode.
187    side: std::sync::Arc<[bool]>,
188    /// Per slot: the step that writes it.
189    slot_step: std::sync::Arc<[Option<usize>]>,
190    /// Where each step came from, for the failure path (A7).
191    sites: std::sync::Arc<crate::compile::Attribution>,
192    /// The step running, for the failure path.
193    cur_step: usize,
194    /// The slot past the layout where a segment names the member it is
195    /// in before calling a helper.
196    tracker: usize,
197    /// Every step, in order: what `eval` runs.
198    all: std::sync::Arc<[usize]>,
199    /// Per input slot, the steps an input change marks not current:
200    /// the plan's dependents in a push mode; in a raw or pull-only
201    /// mode, which never consult a pure step's currency, only the side
202    /// channels among them (an optimization over the plan, not a change
203    /// to it).
204    dirty: std::sync::Arc<[Vec<usize>]>,
205    /// Some slot holds `None`: an unset extern, which is
206    /// the only way one enters (SRD-74). When none does, the steps run
207    /// without the mask.
208    any_none: bool,
209    /// The steps that are never current, invalidated at every round.
210    volatile_steps: std::sync::Arc<[usize]>,
211}
212
213impl Clone for HybridCore {
214    fn clone(&self) -> Self {
215        let mut core = HybridCore {
216            buffer: self.buffer.clone(),
217            coord_count: self.coord_count,
218            steps: self.steps.clone(),
219            output_map: self.output_map.clone(),
220            gather_buf: self.gather_buf.clone(),
221            scatter_buf: self.scatter_buf.clone(),
222            scratch: self.scratch.clone(),
223            ref_slots: self.ref_slots.clone(),
224            ref_scratch: self.ref_scratch.clone(),
225            output_types: self.output_types.clone(),
226            externs: self.externs.clone(),
227            traversals: self.traversals.clone(),
228            resolved_outputs: self.resolved_outputs.clone(),
229            _nodes: self._nodes.clone(),
230            drive: self.drive.clone(),
231            none: self.none.clone(),
232            ran: self.ran.clone(),
233            epoch: self.epoch,
234            all_ran: self.all_ran,
235            clean: self.clean.clone(),
236            use_clean: self.use_clean,
237            plan: self.plan.clone(),
238            volatile: self.volatile.clone(),
239            side: self.side.clone(),
240            slot_step: self.slot_step.clone(),
241            sites: self.sites.clone(),
242            cur_step: self.cur_step,
243            tracker: self.tracker,
244            all: self.all.clone(),
245            dirty: self.dirty.clone(),
246            any_none: self.any_none,
247            volatile_steps: self.volatile_steps.clone(),
248        };
249        core.republish_refs();
250        core
251    }
252}
253
254impl HybridCore {
255    /// Point every pair in the buffer into this state's own storage: a
256    /// step's scratch entry for its `Ref2` outputs, the stored value
257    /// for an extern's (axiom S3). What a clone needs, whose buffer
258    /// was copied from a state whose storage it does not share.
259    fn republish_refs(&mut self) {
260        for &(slot, idx) in &self.ref_scratch {
261            let (p, l) = self.scratch[idx].ptr_len();
262            self.buffer[slot] = p;
263            self.buffer[slot + 1] = l;
264        }
265        self.externs.seed(&mut self.buffer, None);
266    }
267}
268
269impl HybridCore {
270    /// Axiom S9(a) — deterministic Ref validation (see
271    /// `jit_boundary.md` §"Slot-state axioms"). Gated to
272    /// `debug_assertions` to match its call sites, which compile
273    /// out in release.
274    #[cfg(debug_assertions)]
275    fn validate_refs(&self) {
276        // A step that has never run, or that propagated `None`, left
277        // its slots as they were.
278        let skip = |slot: usize| {
279            self.none[slot]
280                || matches!(self.slot_step.get(slot), Some(Some(step)) if self.ran[*step] == 0)
281        };
282        for &(slot, idx) in &self.ref_scratch {
283            if skip(slot) {
284                continue;
285            }
286            let (p, l) = self.scratch[idx].ptr_len();
287            assert!(
288                self.buffer[slot] == p && self.buffer[slot + 1] == l,
289                "S9 ref-validator: slot pair ({slot}, {}) = ({:#x}, {}) \
290                 does not match scratch[{idx}] = ({p:#x}, {l})",
291                slot + 1,
292                self.buffer[slot],
293                self.buffer[slot + 1],
294            );
295        }
296    }
297
298    /// Axiom S2 guard for raw u64 readers.
299    #[inline]
300    fn guard_ref_slot(&self, slot: usize) {
301        if self.ref_slots.get(slot).copied().unwrap_or(false) {
302            panic!(
303                "S2 pointer containment: slot {slot} is Ref2-colored; raw u64 readers \
304                 would leak an interior address. Use the typed borrow-checked accessor \
305                 (read_vec_*), the boundary decode, or copy out."
306            );
307        }
308    }
309
310    /// Axiom S2 typed accessor core (borrow ties to &self).
311    fn ref_entry(&self, slot: usize) -> &crate::ast::ScratchBuf {
312        match self.ref_scratch.iter().find(|(s, _)| *s == slot) {
313            Some(&(_, idx)) => &self.scratch[idx],
314            None if self.ref_slots.get(slot).copied().unwrap_or(false) => panic!(
315                "slot {slot} is a Ref pair owned by the CALLER (a kernel \
316                 input) — read it on the caller side"
317            ),
318            None => panic!("slot {slot} is not a Ref2-colored slot"),
319        }
320    }
321}
322
323impl HybridCore {
324    /// Begin an evaluation round after a write: take what cells other
325    /// holders published, forget what ran in the last round, and
326    /// invalidate the volatile steps, as the interpreter does at every
327    /// write. Nothing else changes: every output stands until an input
328    /// in its provenance is written (runtime_model.md, R1).
329    #[inline]
330    fn begin_epoch(&mut self) {
331        if self.externs.cells_dirty() {
332            self.externs.refresh_cells(&mut self.buffer);
333        }
334        self.dirty_refreshed();
335        self.epoch += 1;
336        self.all_ran = false;
337        for &i in self.volatile_steps.iter() {
338            self.clean[i] = false;
339        }
340        self.drive.stale = false;
341    }
342
343    /// Whether this kernel skips current steps, and with it which steps
344    /// an input change marks: every dependent, or only the side
345    /// channels when a pure step's currency is never consulted.
346    #[cfg(feature = "jit")]
347    fn set_use_clean(&mut self, on: bool) {
348        self.use_clean = on;
349        let side = std::sync::Arc::clone(&self.side);
350        self.dirty = self
351            .plan
352            .input_dependents
353            .iter()
354            .map(|deps| {
355                if on {
356                    deps.clone()
357                } else {
358                    deps.iter().copied().filter(|&i| side[i]).collect()
359                }
360            })
361            .collect::<Vec<_>>()
362            .into();
363    }
364
365    /// Every dependent of a slot a cell refresh changed runs again,
366    /// between writes too, as the interpreter re-evaluates a node
367    /// whose cell moved on its next read: the plan's dependents, whatever
368    /// the mode, are neither run nor current.
369    #[inline]
370    fn dirty_refreshed(&mut self) {
371        if !self.externs.has_changed() {
372            return;
373        }
374        let changed = self.externs.take_changed();
375        for &slot in &changed {
376            if let Some(deps) = self.plan.input_dependents.get(slot) {
377                for &i in deps {
378                    self.ran[i] = 0;
379                    self.clean[i] = false;
380                }
381                self.all_ran = false;
382            }
383        }
384        self.externs.return_changed(changed);
385    }
386
387    /// Take the current value of every cell another holder published
388    /// to, and mark its dependents, so a pull between writes sees the
389    /// register as the interpreter's revision check does.
390    #[inline]
391    fn refresh_cells(&mut self) {
392        if self.externs.cells_dirty() {
393            self.externs.refresh_cells(&mut self.buffer);
394            self.dirty_refreshed();
395        }
396    }
397
398    /// Bind a `shared` binding to `cell` (engine parity, step 9): this
399    /// kernel reads and writes that register from now on.
400    fn attach_cell(&mut self, name: &str, cell: crate::kernel::SharedCell) -> Result<(), String> {
401        let slot = self.externs.attach_cell(name, cell)?;
402        self.dirty_input(slot);
403        self.drive.stale = true;
404        Ok(())
405    }
406
407    /// An input slot changed, through whichever call: every step the
408    /// plan lists for it is no longer current.
409    #[inline]
410    fn dirty_input(&mut self, slot: usize) {
411        if let Some(deps) = self.dirty.get(slot) {
412            for &i in deps {
413                self.clean[i] = false;
414            }
415        }
416    }
417
418    /// Run the steps of `order` that have not run in this round and are
419    /// not current, as the closure kernels do: one rule for every step,
420    /// whatever reaches it; a volatile step is never current.
421    #[inline]
422    fn run_steps(&mut self, order: &[usize]) {
423        self.run_guarded(|core| core.run_order(order));
424    }
425
426    /// Run `body` with the capture guard armed, so a step's panic is
427    /// recorded quietly and re-raised enriched, as the interpreter
428    /// re-raises a node's (A7).
429    #[inline]
430    fn run_guarded(&mut self, body: impl FnOnce(&mut Self)) {
431        let capture = crate::kernel::engines::EvalPanicCaptureGuard::arm();
432        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(self)));
433        drop(capture);
434        if let Err(payload) = outcome {
435            let sites = std::sync::Arc::clone(&self.sites);
436            let node = self.steps[self.cur_step].failing_node(&self.buffer, self.tracker);
437            sites.reraise(payload, node, &self.buffer, Some(&self.none));
438        }
439        #[cfg(debug_assertions)]
440        self.validate_refs();
441    }
442
443    /// The steps of `order` that have not run in the round, in order.
444    #[inline]
445    fn run_order(&mut self, order: &[usize]) {
446        let steps = &self.steps;
447        let none_free = !self.any_none;
448        for &i in order {
449            if self.all_ran || self.ran[i] == self.epoch {
450                continue;
451            }
452            let never = self.volatile[i];
453            if (self.use_clean || self.side[i]) && self.clean[i] && !never {
454                self.ran[i] = self.epoch;
455                continue;
456            }
457            self.cur_step = i;
458            run_hybrid_step(
459                &steps[i],
460                none_free,
461                &mut self.buffer,
462                &mut self.none,
463                &mut self.gather_buf,
464                &mut self.scatter_buf,
465                &mut self.scratch,
466            );
467            self.ran[i] = self.epoch;
468            self.clean[i] = !never;
469        }
470    }
471
472    /// Every step, in order, in a round just begun, in a mode without
473    /// per-step skipping and with no `None` in play: the same steps the
474    /// general loop would run, without the bookkeeping a partial round
475    /// needs. A current side channel is still skipped, since its run is
476    /// observed.
477    #[inline]
478    fn run_fresh(&mut self) {
479        let steps = &self.steps;
480        for (i, step) in steps.iter().enumerate() {
481            if self.side[i] {
482                let never = self.volatile[i];
483                if self.clean[i] && !never {
484                    continue;
485                }
486                self.clean[i] = !never;
487            }
488            self.cur_step = i;
489            run_hybrid_step(
490                step,
491                true,
492                &mut self.buffer,
493                &mut self.none,
494                &mut self.gather_buf,
495                &mut self.scatter_buf,
496                &mut self.scratch,
497            );
498        }
499        self.all_ran = true;
500    }
501
502    /// Evaluate every output: begin a round if a write is pending, then run
503    /// every step that has not run.
504    #[inline]
505    fn eval_all(&mut self) {
506        let fresh = self.drive.stale;
507        if fresh {
508            self.begin_epoch();
509        } else {
510            self.refresh_cells();
511        }
512        if fresh && !self.use_clean && !self.any_none {
513            self.run_guarded(|core| core.run_fresh());
514        } else {
515            let all = std::sync::Arc::clone(&self.all);
516            self.run_steps(&all);
517        }
518    }
519
520    /// The named output for the current inputs, running only its cone.
521    fn pull_named(&mut self, name: &str) -> crate::ast::Value {
522        if self.drive.stale {
523            self.begin_epoch();
524        } else {
525            self.refresh_cells();
526        }
527        let plan = std::sync::Arc::clone(&self.plan);
528        if let Some(order) = plan.cones.get(name) {
529            self.run_steps(order);
530        }
531        self.value_of(name)
532    }
533
534    /// [`Self::pull_named`] by output index: the name is resolved to
535    /// its slot, type, and cone once, so a pull costs no string lookup
536    /// (SRD 117 step 3).
537    fn pull_at(&mut self, index: usize) -> crate::ast::Value {
538        if self.resolved_outputs.len() <= index {
539            self.resolved_outputs.resize(index + 1, None);
540        }
541        if self.resolved_outputs[index].is_none() {
542            let name = self
543                .externs
544                .output_names()
545                .get(index)
546                .cloned()
547                .unwrap_or_else(|| {
548                    panic!(
549                        "no output at index {index}; this kernel declares {}",
550                        self.externs.output_names().len()
551                    )
552                });
553            let slot = self.output_map[&name];
554            let ty = self
555                .output_types
556                .get(&name)
557                .copied()
558                .unwrap_or(crate::ast::PortType::U64);
559            let cone = self
560                .plan
561                .cones
562                .get(&name)
563                .map(|c| std::sync::Arc::from(c.as_slice()));
564            self.resolved_outputs[index] = Some((slot, ty, cone));
565        }
566        if self.drive.stale {
567            self.begin_epoch();
568        } else {
569            self.refresh_cells();
570        }
571        let (slot, ty, cone) = self.resolved_outputs[index]
572            .clone()
573            .expect("resolved above");
574        if let Some(order) = cone {
575            self.run_steps(&order);
576        }
577        self.slot_value(slot, ty)
578    }
579
580    /// The named output as a typed `Value`, `None` where the slot holds
581    /// one; a vector from scratch; a handle copied out.
582    fn value_of(&self, name: &str) -> crate::ast::Value {
583        let slot = self.output_map[name];
584        let ty = self
585            .output_types
586            .get(name)
587            .copied()
588            .unwrap_or(crate::ast::PortType::U64);
589        self.slot_value(slot, ty)
590    }
591
592    /// The value at `slot` decoded as `ty`: `None` where the mask says
593    /// so, a Ref pair copied out through the pair.
594    fn slot_value(&self, slot: usize, ty: crate::ast::PortType) -> crate::ast::Value {
595        if self.none.get(slot).copied().unwrap_or(false) {
596            return crate::ast::Value::None;
597        }
598        crate::compile::marshal::decode_output(&self.buffer, slot, ty)
599    }
600
601    /// The native segments and the closure steps.
602    fn plan(&self) -> crate::EnginePlan {
603        let (native_segments, closure_steps) = self.engine_counts();
604        crate::EnginePlan {
605            native_segments,
606            closure_steps,
607            interpreted_nodes: 0,
608        }
609    }
610
611    /// Nothing is current: every step runs at the next evaluation.
612    fn invalidate_all(&mut self) {
613        self.clean.fill(false);
614        self.all_ran = false;
615        self.drive.stale = true;
616    }
617}
618
619/// Everything the evaluation loops once did, kept for the raw kernel's
620/// `eval`, which evaluates every step in a new round.
621#[inline]
622fn eval_all_hybrid_steps(core: &mut HybridCore) {
623    core.drive.stale = true;
624    core.eval_all();
625}
626
627// ═══════════════════════════════════════════════════════════════
628// Raw: no provenance, no cone guard. Eval runs all steps.
629// ═══════════════════════════════════════════════════════════════
630
631impl HybridCore {
632    /// How many steps run as native segments and how many as closures:
633    /// what the per-node engine choice decided for this graph.
634    fn engine_counts(&self) -> (usize, usize) {
635        let closures = self
636            .steps
637            .iter()
638            .filter(|s| matches!(s, HybridStep::Closure(_)))
639            .count();
640        (self.steps.len() - closures, closures)
641    }
642
643    /// Set an extern by name; returns its slot for dirty marking.
644    /// Set an extern by name; returns its slot. The plan invalidates
645    /// what depends on it, as a changed coordinate is invalidated, and
646    /// the next evaluation begins a round.
647    fn set_extern(&mut self, name: &str, value: crate::ast::Value) -> Result<usize, String> {
648        let (slot, unset) = self.externs.set(name, value, &mut self.buffer)?;
649        self.extern_written(slot, unset);
650        Ok(slot)
651    }
652
653    /// [`Self::set_extern`] by input index.
654    fn set_extern_at(&mut self, index: usize, value: crate::ast::Value) -> Result<usize, String> {
655        let (slot, unset) = self.externs.set_at(index, value, &mut self.buffer)?;
656        self.extern_written(slot, unset);
657        Ok(slot)
658    }
659
660    /// An extern was written: its dependents are no longer current, the
661    /// `None` mask records whether it is unset (SRD-74 on a compiled
662    /// kernel), and the next evaluation begins a round. When the last
663    /// unset extern is set, no slot can hold a `None` any more, so the
664    /// mask is cleared and the steps run without it.
665    fn extern_written(&mut self, slot: usize, unset: bool) {
666        self.none[slot] = unset;
667        let was = self.any_none;
668        self.any_none = self.externs.any_unset();
669        if was && !self.any_none {
670            self.none.fill(false);
671        }
672        self.dirty_input(slot);
673        self.drive.stale = true;
674    }
675}
676
677/// Hybrid kernel with no provenance tracking.
678///
679/// Every `eval()` call runs all steps unconditionally. Useful as a
680/// baseline and for graphs where inputs change on every evaluation.
681#[derive(Clone)]
682pub struct HybridKernelRaw {
683    core: HybridCore,
684}
685
686impl HybridKernelRaw {
687    /// The coordinates, written; a changed one invalidates
688    /// its dependents through the plan, as in every mode.
689    #[inline]
690    fn set_coords(&mut self, coords: &[u64]) {
691        for (i, &c) in coords.iter().enumerate().take(self.core.coord_count) {
692            if self.core.buffer[i] != c {
693                self.core.buffer[i] = c;
694                self.core.dirty_input(i);
695            }
696        }
697    }
698
699    /// Evaluate all hybrid steps unconditionally: a new round.
700    #[inline]
701    pub fn eval(&mut self, coords: &[u64]) {
702        self.set_coords(coords);
703        eval_all_hybrid_steps(&mut self.core);
704    }
705
706    #[cfg(feature = "jit")]
707    fn pull_output(&mut self, name: &str) -> crate::ast::Value {
708        self.core.pull_named(name)
709    }
710
711    /// Set an extern by name, as `PolydatState::set_input` does on the
712    /// interpreter. Every run evaluates everything, so it takes effect
713    /// at the next run.
714    pub fn set_input(&mut self, name: &str, value: crate::ast::Value) -> Result<(), String> {
715        self.core.set_extern(name, value).map(|_| ())
716    }
717
718    /// [`Self::set_input`] by input index.
719    pub fn set_input_at(&mut self, index: usize, value: crate::ast::Value) -> Result<(), String> {
720        self.core.set_extern_at(index, value).map(|_| ())
721    }
722
723    /// The kernel's externs by name and declared type.
724    pub fn externs(&self) -> Vec<(&str, crate::ast::PortType)> {
725        self.core.externs.names()
726    }
727
728    /// The cursors the program declares, with the partitions the
729    /// compiler resolved where its `over` clause and extent were
730    /// constant, as `PolydatProgram::cursor_schemas` reports them.
731    pub fn cursor_schemas(&self) -> &[crate::iteration::source::SourceSchema] {
732        self.core.externs.cursor_schemas()
733    }
734
735    /// Narrow a cursor to one partition, as `narrow_cursor` does on
736    /// the interpreter: its `Ext` slot and six scalar projections are
737    /// set as externs.
738    pub fn set_cursor(
739        &mut self,
740        name: &str,
741        partition: &crate::iteration::cursor_partition::Partition,
742    ) -> Result<(), String> {
743        for (slot, value) in self.core.externs.cursor_writes(name, partition)? {
744            self.set_input(&slot, value)?;
745        }
746        Ok(())
747    }
748
749    /// Eval all steps and return the value at `slot`.
750    #[inline]
751    pub fn eval_for_slot(&mut self, coords: &[u64], slot: usize) -> u64 {
752        self.core.guard_ref_slot(slot);
753        self.eval(coords);
754        self.core.buffer[slot]
755    }
756
757    /// Read a named output after `eval()`. Panics on Ref2 slots
758    /// (axiom S2) — use `read_vec_*`.
759    #[inline]
760    pub fn get(&self, name: &str) -> u64 {
761        let slot = self.core.output_map[name];
762        self.core.guard_ref_slot(slot);
763        self.core.buffer[slot]
764    }
765
766    /// Read by slot index. Panics on Ref2 slots (axiom S2) —
767    /// use `read_vec_*`.
768    #[inline]
769    pub fn get_slot(&self, slot: usize) -> u64 {
770        self.core.guard_ref_slot(slot);
771        self.core.buffer[slot]
772    }
773
774    crate::compile::ref_readers!();
775
776    /// The named output as a typed `Value`, decoded by its port type:
777    /// a `Ref2` output is copied out through its pair
778    /// (compiled_handles.md §4), so the caller never holds a pointer.
779    pub fn get_value(&self, name: &str) -> crate::ast::Value {
780        self.core.value_of(name)
781    }
782
783    /// Number of coordinate inputs.
784    pub fn coord_count(&self) -> usize {
785        self.core.coord_count
786    }
787
788    /// The number of native segments and of closure steps in this
789    /// kernel, in that order: what the per-node engine choice decided.
790    pub fn engine_counts(&self) -> (usize, usize) {
791        self.core.engine_counts()
792    }
793
794    /// Resolve an output name to its buffer slot.
795    pub fn resolve_output(&self, name: &str) -> Option<usize> {
796        self.core.output_map.get(name).copied()
797    }
798
799    /// Store owned nodes to keep JIT-baked pointers valid.
800    pub fn retain_nodes(&mut self, nodes: Vec<Box<dyn PolydatNode>>) {
801        self.core._nodes = std::sync::Arc::new(nodes);
802    }
803}
804
805// ═══════════════════════════════════════════════════════════════
806// Pull: cone guard only, no per-step skip.
807// set_inputs tracks changed_mask. eval_for_slot checks the cone
808// then runs ALL steps if dirty.
809// ═══════════════════════════════════════════════════════════════
810
811/// Hybrid kernel with pull-side cone guard.
812///
813/// `eval_for_slot()` checks whether the output's transitive input
814/// cone changed before running steps. If nothing in the cone changed,
815/// the cached value is returned without re-evaluation.
816#[derive(Clone)]
817pub struct HybridKernelPull {
818    core: HybridCore,
819    slot_provenance: Vec<crate::kernel::ProvMask>,
820    changed_mask: crate::kernel::ProvMask,
821    /// Set by `set_input`: an extern changed, so the next evaluation
822    /// runs whatever the cone guard says.
823    force_run: bool,
824}
825
826impl HybridKernelPull {
827    /// Track which inputs changed (for the cone guard), and invalidate
828    /// their dependents through the plan, as in every mode.
829    #[inline]
830    fn set_inputs(&mut self, coords: &[u64]) {
831        self.changed_mask.clear();
832        for (i, &c) in coords.iter().enumerate().take(self.core.coord_count) {
833            if self.core.buffer[i] != c {
834                self.core.buffer[i] = c;
835                self.changed_mask.set(i);
836                self.core.dirty_input(i);
837            }
838        }
839    }
840
841    /// Evaluate all steps (no cone guard): a new round.
842    #[inline]
843    pub fn eval(&mut self, coords: &[u64]) {
844        self.set_inputs(coords);
845        self.force_run = false;
846        eval_all_hybrid_steps(&mut self.core);
847    }
848
849    fn pull_output(&mut self, name: &str) -> crate::ast::Value {
850        self.core.pull_named(name)
851    }
852
853    /// Cone guard: if the output's cone is clean, skip eval entirely.
854    /// Otherwise run ALL steps (no per-step skip).
855    #[inline]
856    pub fn eval_for_slot(&mut self, coords: &[u64], slot: usize) -> u64 {
857        self.core.guard_ref_slot(slot);
858        self.set_inputs(coords);
859        if !self.force_run
860            && slot < self.slot_provenance.len()
861            && !self.slot_provenance[slot].intersects(&self.changed_mask)
862        {
863            return self.core.buffer[slot];
864        }
865        self.force_run = false;
866        eval_all_hybrid_steps(&mut self.core);
867        self.core.buffer[slot]
868    }
869
870    /// Set an extern by name, as `PolydatState::set_input` does on the
871    /// interpreter. Every kind is written through at once, and the
872    /// next run runs whatever the cone guard says.
873    pub fn set_input(&mut self, name: &str, value: crate::ast::Value) -> Result<(), String> {
874        self.core.set_extern(name, value)?;
875        self.force_run = true;
876        Ok(())
877    }
878
879    /// [`Self::set_input`] by input index.
880    pub fn set_input_at(&mut self, index: usize, value: crate::ast::Value) -> Result<(), String> {
881        self.core.set_extern_at(index, value)?;
882        self.force_run = true;
883        Ok(())
884    }
885
886    /// The kernel's externs by name and declared type.
887    pub fn externs(&self) -> Vec<(&str, crate::ast::PortType)> {
888        self.core.externs.names()
889    }
890
891    /// The cursors the program declares, with the partitions the
892    /// compiler resolved where its `over` clause and extent were
893    /// constant, as `PolydatProgram::cursor_schemas` reports them.
894    pub fn cursor_schemas(&self) -> &[crate::iteration::source::SourceSchema] {
895        self.core.externs.cursor_schemas()
896    }
897
898    /// Narrow a cursor to one partition, as `narrow_cursor` does on
899    /// the interpreter: its `Ext` slot and six scalar projections are
900    /// set as externs.
901    pub fn set_cursor(
902        &mut self,
903        name: &str,
904        partition: &crate::iteration::cursor_partition::Partition,
905    ) -> Result<(), String> {
906        for (slot, value) in self.core.externs.cursor_writes(name, partition)? {
907            self.set_input(&slot, value)?;
908        }
909        Ok(())
910    }
911
912    /// Read a named output after `eval()`. Panics on Ref2 slots
913    /// (axiom S2) — use `read_vec_*`.
914    #[inline]
915    pub fn get(&self, name: &str) -> u64 {
916        let slot = self.core.output_map[name];
917        self.core.guard_ref_slot(slot);
918        self.core.buffer[slot]
919    }
920
921    /// Read by slot index. Panics on Ref2 slots (axiom S2) —
922    /// use `read_vec_*`.
923    #[inline]
924    pub fn get_slot(&self, slot: usize) -> u64 {
925        self.core.guard_ref_slot(slot);
926        self.core.buffer[slot]
927    }
928
929    crate::compile::ref_readers!();
930
931    /// The named output as a typed `Value`, decoded by its port type:
932    /// a `Ref2` output is copied out through its pair
933    /// (compiled_handles.md §4), so the caller never holds a pointer.
934    pub fn get_value(&self, name: &str) -> crate::ast::Value {
935        self.core.value_of(name)
936    }
937
938    /// Number of coordinate inputs.
939    pub fn coord_count(&self) -> usize {
940        self.core.coord_count
941    }
942
943    /// The number of native segments and of closure steps in this
944    /// kernel, in that order: what the per-node engine choice decided.
945    pub fn engine_counts(&self) -> (usize, usize) {
946        self.core.engine_counts()
947    }
948
949    /// Resolve an output name to its buffer slot.
950    pub fn resolve_output(&self, name: &str) -> Option<usize> {
951        self.core.output_map.get(name).copied()
952    }
953
954    /// Store owned nodes to keep JIT-baked pointers valid.
955    pub fn retain_nodes(&mut self, nodes: Vec<Box<dyn PolydatNode>>) {
956        self.core._nodes = std::sync::Arc::new(nodes);
957    }
958}
959
960// ═══════════════════════════════════════════════════════════════
961// PushPull: push-side per-step skip + pull-side cone guard.
962// Full optimization — the production default.
963// ═══════════════════════════════════════════════════════════════
964
965/// Hybrid kernel with both push-side per-step skip and pull-side cone guard.
966///
967/// Push side: `set_inputs()` marks only steps that depend on changed inputs
968/// as dirty; clean steps are skipped during `eval()`.
969///
970/// Pull side: `eval_for_slot()` first checks whether the output's cone of
971/// influence changed at all. If not, the cached value is returned without
972/// entering the eval loop.
973#[derive(Clone)]
974pub struct HybridKernelPushPull {
975    core: HybridCore,
976    slot_provenance: Vec<crate::kernel::ProvMask>,
977    changed_mask: crate::kernel::ProvMask,
978    /// Set by `set_input`: an extern changed, so the next evaluation
979    /// runs whatever the cone guard says.
980    force_run: bool,
981}
982
983impl HybridKernelPushPull {
984    /// Set an extern by name, as `PolydatState::set_input` does on the
985    /// interpreter. Every kind is written through at once. Every step
986    /// downstream of the extern reruns, and the next evaluation runs
987    /// whatever the cone guard says.
988    pub fn set_input(&mut self, name: &str, value: crate::ast::Value) -> Result<(), String> {
989        self.core.set_extern(name, value)?;
990        self.force_run = true;
991        Ok(())
992    }
993
994    /// [`Self::set_input`] by input index.
995    pub fn set_input_at(&mut self, index: usize, value: crate::ast::Value) -> Result<(), String> {
996        self.core.set_extern_at(index, value)?;
997        self.force_run = true;
998        Ok(())
999    }
1000
1001    /// The kernel's externs by name and declared type.
1002    pub fn externs(&self) -> Vec<(&str, crate::ast::PortType)> {
1003        self.core.externs.names()
1004    }
1005
1006    /// The cursors the program declares, with the partitions the
1007    /// compiler resolved where its `over` clause and extent were
1008    /// constant, as `PolydatProgram::cursor_schemas` reports them.
1009    pub fn cursor_schemas(&self) -> &[crate::iteration::source::SourceSchema] {
1010        self.core.externs.cursor_schemas()
1011    }
1012
1013    /// Narrow a cursor to one partition, as `narrow_cursor` does on
1014    /// the interpreter: its `Ext` slot and six scalar projections are
1015    /// set as externs.
1016    pub fn set_cursor(
1017        &mut self,
1018        name: &str,
1019        partition: &crate::iteration::cursor_partition::Partition,
1020    ) -> Result<(), String> {
1021        for (slot, value) in self.core.externs.cursor_writes(name, partition)? {
1022            self.set_input(&slot, value)?;
1023        }
1024        Ok(())
1025    }
1026
1027    /// Track which inputs changed and dirty affected steps.
1028    #[inline]
1029    fn set_inputs(&mut self, coords: &[u64]) {
1030        self.changed_mask.clear();
1031        for (i, &c) in coords.iter().enumerate().take(self.core.coord_count) {
1032            if self.core.buffer[i] != c {
1033                self.core.buffer[i] = c;
1034                self.changed_mask.set(i);
1035                self.core.dirty_input(i);
1036            }
1037        }
1038    }
1039
1040    /// Evaluate with push-side step skip (no cone guard): a new round.
1041    #[inline]
1042    pub fn eval(&mut self, coords: &[u64]) {
1043        self.set_inputs(coords);
1044        self.force_run = false;
1045        self.core.drive.stale = true;
1046        self.core.eval_all();
1047    }
1048
1049    fn pull_output(&mut self, name: &str) -> crate::ast::Value {
1050        self.core.pull_named(name)
1051    }
1052
1053    /// Cone guard + push-side skip: the full optimization.
1054    #[inline]
1055    pub fn eval_for_slot(&mut self, coords: &[u64], slot: usize) -> u64 {
1056        self.core.guard_ref_slot(slot);
1057        self.set_inputs(coords);
1058        if !self.force_run
1059            && slot < self.slot_provenance.len()
1060            && !self.slot_provenance[slot].intersects(&self.changed_mask)
1061        {
1062            return self.core.buffer[slot];
1063        }
1064        self.force_run = false;
1065        self.core.drive.stale = true;
1066        self.core.eval_all();
1067        self.core.buffer[slot]
1068    }
1069
1070    /// Read a named output after `eval()`. Panics on Ref2 slots
1071    /// (axiom S2) — use `read_vec_*`.
1072    #[inline]
1073    pub fn get(&self, name: &str) -> u64 {
1074        let slot = self.core.output_map[name];
1075        self.core.guard_ref_slot(slot);
1076        self.core.buffer[slot]
1077    }
1078
1079    /// Read by slot index. Panics on Ref2 slots (axiom S2) —
1080    /// use `read_vec_*`.
1081    #[inline]
1082    pub fn get_slot(&self, slot: usize) -> u64 {
1083        self.core.guard_ref_slot(slot);
1084        self.core.buffer[slot]
1085    }
1086
1087    crate::compile::ref_readers!();
1088
1089    /// The named output as a typed `Value`, decoded by its port type:
1090    /// a `Ref2` output is copied out through its pair
1091    /// (compiled_handles.md §4), so the caller never holds a pointer.
1092    pub fn get_value(&self, name: &str) -> crate::ast::Value {
1093        self.core.value_of(name)
1094    }
1095
1096    /// Number of coordinate inputs.
1097    pub fn coord_count(&self) -> usize {
1098        self.core.coord_count
1099    }
1100
1101    /// The number of native segments and of closure steps in this
1102    /// kernel, in that order: what the per-node engine choice decided.
1103    pub fn engine_counts(&self) -> (usize, usize) {
1104        self.core.engine_counts()
1105    }
1106
1107    /// Resolve an output name to its buffer slot.
1108    pub fn resolve_output(&self, name: &str) -> Option<usize> {
1109        self.core.output_map.get(name).copied()
1110    }
1111
1112    /// Store owned nodes to keep JIT-baked pointers valid.
1113    pub fn retain_nodes(&mut self, nodes: Vec<Box<dyn PolydatNode>>) {
1114        self.core._nodes = std::sync::Arc::new(nodes);
1115    }
1116}
1117
1118/// Type alias for the default hybrid kernel (PushPull — full optimization).
1119///
1120/// The assembler's `compile_hybrid` returns this alias. Rename uses to
1121/// the concrete type if different optimization trade-offs are needed.
1122pub type HybridKernel = HybridKernelPushPull;
1123
1124/// Flattened slot list for one node's wire inputs under per-port
1125/// widths (type_system_alignment.md §6): every source
1126/// contributes `slot_width` consecutive slots.
1127fn flatten_input_slots(
1128    wiring: &[Vec<WireSource>],
1129    nodes: &[Box<dyn PolydatNode>],
1130    node_idx: usize,
1131    port_offsets: &[Vec<usize>],
1132    input_starts: &[usize],
1133    input_widths: &[usize],
1134) -> Vec<usize> {
1135    let mut slots = Vec::new();
1136    for source in &wiring[node_idx] {
1137        let (start, w) = match source {
1138            WireSource::Input(c) => (
1139                input_starts.get(*c).copied().unwrap_or(*c),
1140                input_widths.get(*c).copied().unwrap_or(1),
1141            ),
1142            WireSource::NodeOutput(u, p) => (
1143                port_offsets[*u][*p],
1144                nodes[*u].meta().outs[*p].typ.slot_width(),
1145            ),
1146        };
1147        slots.extend(start..start + w);
1148    }
1149    slots
1150}
1151
1152/// First slot of each Ref2-colored output port of one node, in
1153/// port order (axiom S3 pairing with CompiledSlotKit scratch).
1154fn flatten_ref_output_starts(
1155    nodes: &[Box<dyn PolydatNode>],
1156    node_idx: usize,
1157    port_offsets: &[Vec<usize>],
1158) -> Vec<usize> {
1159    nodes[node_idx]
1160        .meta()
1161        .outs
1162        .iter()
1163        .enumerate()
1164        .filter(|(_, out)| out.typ.slot_color() == crate::ast::SlotColor::Ref2)
1165        .map(|(p, _)| port_offsets[node_idx][p])
1166        .collect()
1167}
1168
1169/// Flattened slot list for one node's outputs.
1170fn flatten_output_slots(
1171    nodes: &[Box<dyn PolydatNode>],
1172    node_idx: usize,
1173    port_offsets: &[Vec<usize>],
1174) -> Vec<usize> {
1175    let mut slots = Vec::new();
1176    for (p, out) in nodes[node_idx].meta().outs.iter().enumerate() {
1177        let start = port_offsets[node_idx][p];
1178        slots.extend(start..start + out.typ.slot_width());
1179    }
1180    slots
1181}
1182
1183/// Build a hybrid kernel from resolved DAG data.
1184///
1185/// Each node is classified: if it can be JIT-compiled, it goes into
1186/// a JIT segment. If not, it becomes a closure step. Adjacent JIT-able
1187/// nodes are batched into a single JIT segment for efficiency.
1188///
1189/// Returns a `HybridKernelPushPull` (the production default).
1190#[cfg(feature = "jit")]
1191#[allow(clippy::too_many_arguments)]
1192pub(crate) fn build_hybrid(
1193    nodes: &[Box<dyn PolydatNode>],
1194    wiring: &[Vec<WireSource>],
1195    coord_count: usize,
1196    total_slots: usize,
1197    port_offsets: &[Vec<usize>],
1198    input_starts: &[usize],
1199    input_widths: &[usize],
1200    output_map: HashMap<String, usize>,
1201    ref_slots: Vec<bool>,
1202    input_types: &[crate::ast::PortType],
1203    externs: crate::compile::externs::Externs,
1204    constant: Vec<bool>,
1205    volatile: Vec<bool>,
1206    attribution: std::sync::Arc<crate::compile::Attribution>,
1207) -> Result<HybridKernelPushPull, String> {
1208    let mut steps: Vec<HybridStep> = Vec::new();
1209    let mut scratch: Vec<crate::ast::ScratchBuf> = Vec::new();
1210    let mut ref_scratch: Vec<(usize, usize)> = Vec::new();
1211    let mut max_inputs = 0usize;
1212    let mut max_outputs = 0usize;
1213
1214    // Classify each node
1215    let classifications: Vec<(JitOp, Vec<usize>, Vec<usize>)> = nodes
1216        .iter()
1217        .enumerate()
1218        .map(|(node_idx, node)| {
1219            // Classified with the wire types known (SRD 115 §6.1), as
1220            // cones and pure-P3 layouts are: a variadic node whose
1221            // wires its helper cannot decode falls back to its closure.
1222            let wire_types: Vec<crate::ast::PortType> = wiring[node_idx]
1223                .iter()
1224                .map(|src| match src {
1225                    WireSource::Input(c) => input_types
1226                        .get(*c)
1227                        .copied()
1228                        .unwrap_or(crate::ast::PortType::U64),
1229                    WireSource::NodeOutput(j, p) => nodes[*j].meta().outs[*p].typ,
1230                })
1231                .collect();
1232            let jit_op = jit::classify_node_typed(node.as_ref(), &wire_types);
1233
1234            let input_slots = flatten_input_slots(
1235                wiring,
1236                nodes,
1237                node_idx,
1238                port_offsets,
1239                input_starts,
1240                input_widths,
1241            );
1242            let output_slots = flatten_output_slots(nodes, node_idx, port_offsets);
1243
1244            max_inputs = max_inputs.max(input_slots.len());
1245            max_outputs = max_outputs.max(output_slots.len());
1246
1247            (jit_op, input_slots, output_slots)
1248        })
1249        .collect();
1250    // A node downstream of an extern with no value runs as a closure:
1251    // a `None` propagates through closures as it does on the
1252    // interpreter (SRD-74), and native code cannot carry one
1253    // (engine_parity.md, A12).
1254    let mut classifications = classifications;
1255    let unset = externs.unset_slots();
1256    if !unset.is_empty() {
1257        let mut tainted = vec![false; nodes.len()];
1258        for node_idx in 0..nodes.len() {
1259            tainted[node_idx] = wiring[node_idx].iter().any(|src| match src {
1260                WireSource::Input(c) => unset.contains(&input_starts[*c]),
1261                WireSource::NodeOutput(j, _) => tainted[*j],
1262            });
1263            if tainted[node_idx] {
1264                classifications[node_idx].0 = JitOp::Fallback;
1265            }
1266        }
1267    }
1268
1269    // Per node, the step it runs in: its own closure step or its segment.
1270    let mut node_step = vec![usize::MAX; nodes.len()];
1271    // The step order: every compile-constant node first, then the rest
1272    // in the graph's order. A constant depends on constants alone, so
1273    // hoisting them keeps every dependency ahead of its consumer, and
1274    // it keeps the cycle-time nodes contiguous: a literal between two
1275    // cycle-time statements no longer cuts a segment in two (the tile
1276    // ladder's twenty-hole case ran as dozens of segments that way,
1277    // each paying the segment's catch and step bookkeeping).
1278    let order: Vec<usize> = (0..nodes.len())
1279        .filter(|&k| constant[k])
1280        .chain((0..nodes.len()).filter(|&k| !constant[k]))
1281        .collect();
1282    // Batch adjacent JIT-able nodes into segments
1283    let mut pos = 0;
1284    while pos < order.len() {
1285        let i = order[pos];
1286        if matches!(classifications[i].0, JitOp::Fallback) {
1287            // This node needs a closure — scalar u64 op preferred,
1288            // slot op for slice-bearing nodes (type_system_alignment.md
1289            // §4, compiled_handles.md §3).
1290            let node = &nodes[i];
1291            let (_, ref input_slots, ref output_slots) = classifications[i];
1292            let scratch_start = scratch.len();
1293            let wire_types: Vec<crate::ast::PortType> = wiring[i]
1294                .iter()
1295                .map(|src| match src {
1296                    WireSource::Input(c) => input_types
1297                        .get(*c)
1298                        .copied()
1299                        .unwrap_or(crate::ast::PortType::U64),
1300                    WireSource::NodeOutput(j, p) => nodes[*j].meta().outs[*p].typ,
1301                })
1302                .collect();
1303            let op = if let Some(op) = node.compiled_u64() {
1304                ClosureOp::U64(op)
1305            } else if let Some(op) = crate::compile::assembly::identity_op(node.as_ref()) {
1306                ClosureOp::U64(op)
1307            } else if let Some(kit) = ref_copy_or_slot(node.as_ref(), &wire_types) {
1308                scratch.extend(kit.scratch.iter().map(|e| crate::ast::ScratchBuf::new(*e)));
1309                let starts = flatten_ref_output_starts(nodes, i, port_offsets);
1310                ref_scratch.extend(crate::compile::assembly::scratch_pairs(
1311                    &node.meta().name,
1312                    &starts,
1313                    &kit.scratch,
1314                    scratch_start,
1315                ));
1316                ClosureOp::Slot(kit.op)
1317            } else {
1318                return Err(format!(
1319                    "node '{}' has no compiled form and can't be JIT-compiled",
1320                    node.meta().name
1321                ));
1322            };
1323            node_step[i] = steps.len();
1324            steps.push(HybridStep::Closure(ClosureStep {
1325                op,
1326                input_slots: input_slots.clone(),
1327                output_slots: output_slots.clone(),
1328                scratch_range: (scratch_start, scratch.len()),
1329                accepts_none: node.accepts_none_inputs(),
1330                node: i,
1331            }));
1332            pos += 1;
1333        } else {
1334            // Batch consecutive JIT-able nodes of one lifecycle: a segment is
1335            // folded at build only if every member is compile-constant, so
1336            // a constant node never joins a segment that is not, or the
1337            // constant steps after it would run before their producer.
1338            // A segment is one step to the plan, so a volatile node
1339            // never joins pure ones (the segment would be never current
1340            // and rerun them at every round), and a side channel never
1341            // joins any other node (it would fire whenever the segment
1342            // ran, rather than when its own inputs changed).
1343            let is_side =
1344                |k: usize| matches!(nodes[k].purity(), crate::ast::Purity::SideChannel { .. });
1345            let batch_start = pos;
1346            let first = order[batch_start];
1347            while pos < order.len()
1348                && !matches!(classifications[order[pos]].0, JitOp::Fallback)
1349                && constant[order[pos]] == constant[first]
1350                && volatile[order[pos]] == volatile[first]
1351                && !is_side(order[pos])
1352                && !is_side(first)
1353            {
1354                pos += 1;
1355            }
1356            if pos == batch_start {
1357                pos += 1;
1358            }
1359            let members: Vec<usize> = order[batch_start..pos].to_vec();
1360            // Each step's scratch entries are placed in the kernel's
1361            // scratch (axiom S3), and its reference outputs recorded
1362            // for the validator (S9(a)).
1363            for &k in &members {
1364                let base = scratch.len();
1365                classifications[k].0.place_scratch(base);
1366                let elems = classifications[k].0.scratch_elems().to_vec();
1367                ref_scratch.extend(crate::compile::assembly::scratch_pairs(
1368                    &nodes[k].meta().name,
1369                    &flatten_ref_output_starts(nodes, k, port_offsets),
1370                    &elems,
1371                    base,
1372                ));
1373                scratch.extend(elems.iter().map(|e| crate::ast::ScratchBuf::new(*e)));
1374            }
1375            // One native segment for the batch: its boundary inputs are
1376            // the slots the batch reads and does not write, its outputs
1377            // every slot it writes. Slots closures fill with Ref pairs
1378            // are Ref2 slots to the S2/S9 validator, which a segment
1379            // may only load, store, and pass. Native code names the
1380            // member it is in through the tracker slot, for the failure
1381            // path (A7).
1382            let batch: Vec<(JitOp, Vec<usize>, Vec<usize>)> = members
1383                .iter()
1384                .map(|&k| classifications[k].clone())
1385                .collect();
1386            let written: std::collections::HashSet<usize> = batch
1387                .iter()
1388                .flat_map(|(_, _, o)| o.iter().copied())
1389                .collect();
1390            let mut input_slots: Vec<usize> = Vec::new();
1391            for (_, ins, _) in &batch {
1392                for &s in ins {
1393                    if !written.contains(&s) && !input_slots.contains(&s) {
1394                        input_slots.push(s);
1395                    }
1396                }
1397            }
1398            let output_slots: Vec<usize> = batch
1399                .iter()
1400                .flat_map(|(_, _, o)| o.iter().copied())
1401                .collect();
1402            let (code_fn, code) = jit::compile_jit_entry(&batch, Some(total_slots))?;
1403            let segment = steps.len();
1404            for &k in &members {
1405                node_step[k] = segment;
1406            }
1407            steps.push(HybridStep::Jit(JitSegment {
1408                code_fn,
1409                fallible: code.fallible(),
1410                _module: code,
1411                input_slots,
1412                output_slots,
1413                nodes: members,
1414            }));
1415        }
1416    }
1417
1418    let output_types = output_types_of(nodes, port_offsets, input_starts, input_types, &output_map);
1419    build_pushpull_from_steps(
1420        steps,
1421        scratch,
1422        ref_scratch,
1423        ref_slots,
1424        wiring,
1425        nodes,
1426        coord_count,
1427        total_slots,
1428        output_map,
1429        max_inputs,
1430        max_outputs,
1431        input_starts,
1432        input_widths,
1433        output_types,
1434        externs,
1435        constant,
1436        volatile,
1437        attribution,
1438        node_step,
1439    )
1440}
1441
1442/// The port type of each named output, by the slot it names: a node
1443/// output port's type, or a coordinate input's declared type.
1444fn output_types_of(
1445    nodes: &[Box<dyn PolydatNode>],
1446    port_offsets: &[Vec<usize>],
1447    input_starts: &[usize],
1448    input_types: &[crate::ast::PortType],
1449    output_map: &HashMap<String, usize>,
1450) -> HashMap<String, crate::ast::PortType> {
1451    let mut slot_types: HashMap<usize, crate::ast::PortType> = HashMap::new();
1452    for (start, ty) in input_starts.iter().zip(input_types) {
1453        slot_types.insert(*start, *ty);
1454    }
1455    for (node_idx, node) in nodes.iter().enumerate() {
1456        for (p, out) in node.meta().outs.iter().enumerate() {
1457            slot_types.insert(port_offsets[node_idx][p], out.typ);
1458        }
1459    }
1460    output_map
1461        .iter()
1462        .map(|(name, slot)| {
1463            (
1464                name.clone(),
1465                slot_types
1466                    .get(slot)
1467                    .copied()
1468                    .unwrap_or(crate::ast::PortType::U64),
1469            )
1470        })
1471        .collect()
1472}
1473
1474/// Build a hybrid kernel without JIT (all closures).
1475#[cfg(not(feature = "jit"))]
1476#[allow(clippy::too_many_arguments)]
1477pub(crate) fn build_hybrid(
1478    nodes: &[Box<dyn PolydatNode>],
1479    wiring: &[Vec<WireSource>],
1480    coord_count: usize,
1481    total_slots: usize,
1482    port_offsets: &[Vec<usize>],
1483    input_starts: &[usize],
1484    input_widths: &[usize],
1485    output_map: HashMap<String, usize>,
1486    ref_slots: Vec<bool>,
1487    input_types: &[crate::ast::PortType],
1488    externs: crate::compile::externs::Externs,
1489    constant: Vec<bool>,
1490    volatile: Vec<bool>,
1491    attribution: std::sync::Arc<crate::compile::Attribution>,
1492) -> Result<HybridKernelPushPull, String> {
1493    let mut steps: Vec<HybridStep> = Vec::new();
1494    let mut scratch: Vec<crate::ast::ScratchBuf> = Vec::new();
1495    let mut ref_scratch: Vec<(usize, usize)> = Vec::new();
1496    let mut max_inputs = 0usize;
1497    let mut max_outputs = 0usize;
1498
1499    for (node_idx, node) in nodes.iter().enumerate() {
1500        let input_slots = flatten_input_slots(
1501            wiring,
1502            nodes,
1503            node_idx,
1504            port_offsets,
1505            input_starts,
1506            input_widths,
1507        );
1508        let output_slots = flatten_output_slots(nodes, node_idx, port_offsets);
1509
1510        max_inputs = max_inputs.max(input_slots.len());
1511        max_outputs = max_outputs.max(output_slots.len());
1512
1513        let scratch_start = scratch.len();
1514        let wire_types: Vec<crate::ast::PortType> = wiring[node_idx]
1515            .iter()
1516            .map(|src| match src {
1517                WireSource::Input(c) => input_types
1518                    .get(*c)
1519                    .copied()
1520                    .unwrap_or(crate::ast::PortType::U64),
1521                WireSource::NodeOutput(j, p) => nodes[*j].meta().outs[*p].typ,
1522            })
1523            .collect();
1524        let op = if let Some(op) = node.compiled_u64() {
1525            ClosureOp::U64(op)
1526        } else if let Some(op) = crate::compile::assembly::identity_op(node.as_ref()) {
1527            ClosureOp::U64(op)
1528        } else if let Some(kit) = ref_copy_or_slot(node.as_ref(), &wire_types) {
1529            scratch.extend(kit.scratch.iter().map(|e| crate::ast::ScratchBuf::new(*e)));
1530            let starts = flatten_ref_output_starts(nodes, node_idx, port_offsets);
1531            ref_scratch.extend(crate::compile::assembly::scratch_pairs(
1532                &node.meta().name,
1533                &starts,
1534                &kit.scratch,
1535                scratch_start,
1536            ));
1537            ClosureOp::Slot(kit.op)
1538        } else {
1539            return Err(format!("node '{}' has no compiled form", node.meta().name));
1540        };
1541        steps.push(HybridStep::Closure(ClosureStep {
1542            op,
1543            input_slots,
1544            output_slots,
1545            scratch_range: (scratch_start, scratch.len()),
1546            accepts_none: node.accepts_none_inputs(),
1547            node: node_idx,
1548        }));
1549    }
1550    let node_step: Vec<usize> = (0..nodes.len()).collect();
1551
1552    let output_types = output_types_of(nodes, port_offsets, input_starts, input_types, &output_map);
1553    build_pushpull_from_steps(
1554        steps,
1555        scratch,
1556        ref_scratch,
1557        ref_slots,
1558        wiring,
1559        nodes,
1560        coord_count,
1561        total_slots,
1562        output_map,
1563        max_inputs,
1564        max_outputs,
1565        input_starts,
1566        input_widths,
1567        output_types,
1568        externs,
1569        constant,
1570        volatile,
1571        attribution,
1572        node_step,
1573    )
1574}
1575
1576/// Shared construction of `HybridKernelPushPull` from assembled steps.
1577///
1578/// Computes provenance bitmasks from the DAG wiring and builds the
1579/// step_dependents list for push-side invalidation and the slot_provenance
1580/// table for pull-side cone guard.
1581#[allow(clippy::too_many_arguments)]
1582fn build_pushpull_from_steps(
1583    steps: Vec<HybridStep>,
1584    scratch: Vec<crate::ast::ScratchBuf>,
1585    ref_scratch: Vec<(usize, usize)>,
1586    ref_slots: Vec<bool>,
1587    wiring: &[Vec<WireSource>],
1588    nodes: &[Box<dyn PolydatNode>],
1589    coord_count: usize,
1590    total_slots: usize,
1591    output_map: HashMap<String, usize>,
1592    max_inputs: usize,
1593    max_outputs: usize,
1594    _input_starts: &[usize],
1595    input_widths: &[usize],
1596    output_types: HashMap<String, crate::ast::PortType>,
1597    externs: crate::compile::externs::Externs,
1598    constant: Vec<bool>,
1599    volatile: Vec<bool>,
1600    attribution: std::sync::Arc<crate::compile::Attribution>,
1601    node_step: Vec<usize>,
1602) -> Result<HybridKernelPushPull, String> {
1603    let step_count = steps.len();
1604    debug_assert_eq!(node_step.len(), nodes.len());
1605    debug_assert!(node_step.iter().all(|&s| s < step_count));
1606    // Node lists from the runtime model become step lists: a segment
1607    // depends on what any member depends on.
1608    let to_steps = |list: &[usize]| -> Vec<usize> {
1609        let mut v: Vec<usize> = list.iter().map(|&n| node_step[n]).collect();
1610        v.sort_unstable();
1611        v.dedup();
1612        v
1613    };
1614    // One slot past the layout is the tracker (A7).
1615    let mut buffer = vec![0u64; total_slots + 1];
1616    let mut none = vec![false; total_slots];
1617    let any_none = externs.seed(&mut buffer, Some(&mut none));
1618
1619    // Compute per-node provenance and invert into per-input step dependents.
1620    // Dependents come back per node; `to_steps` folds them onto steps (a
1621    // segment depends on what any member depends on). They also come back
1622    // per-INPUT; expand to per-SLOT so the kernels' slot-indexed dirty
1623    // tracking / changed-mask bits stay coherent under multi-slot inputs
1624    // (type_system_alignment.md §6). Identity for all-scalar inputs.
1625    let node_provenance = crate::kernel::PolydatProgram::compute_provenance(nodes, wiring);
1626    let input_dependents: Vec<Vec<usize>> =
1627        crate::kernel::PolydatProgram::compute_dependents(&node_provenance, input_widths.len())
1628            .iter()
1629            .map(|d| to_steps(d))
1630            .collect();
1631    let step_dependents: Vec<Vec<usize>> = input_widths
1632        .iter()
1633        .enumerate()
1634        .flat_map(|(i, w)| {
1635            std::iter::repeat_n(input_dependents.get(i).cloned().unwrap_or_default(), *w)
1636        })
1637        .collect();
1638
1639    let step_outs: Vec<&[usize]> = steps.iter().map(|s| s.output_slots()).collect();
1640    let slot_provenance =
1641        crate::compile::slot_provenance(coord_count, total_slots, &step_outs, &step_dependents);
1642
1643    // The runtime model's lifecycle classification, passed in per node
1644    // from the one rule the interpreter's fold applies, folded onto the
1645    // steps: a segment is constant only if every member is, volatile
1646    // or a side channel if any member is.
1647    debug_assert_eq!(constant.len(), nodes.len());
1648    debug_assert_eq!(volatile.len(), nodes.len());
1649    let mut step_constant = vec![true; step_count];
1650    let mut step_volatile = vec![false; step_count];
1651    let mut side = vec![false; step_count];
1652    for (n, node) in nodes.iter().enumerate() {
1653        let s = node_step[n];
1654        step_constant[s] &= constant[n];
1655        step_volatile[s] |= volatile[n];
1656        side[s] |= matches!(node.purity(), crate::ast::Purity::SideChannel { .. });
1657    }
1658    let volatile = step_volatile;
1659    let constants: Vec<usize> = (0..step_count).filter(|&i| step_constant[i]).collect();
1660    let step_inputs: Vec<&[usize]> = steps.iter().map(|s| s.input_slots()).collect();
1661    let step_outputs: Vec<&[usize]> = steps.iter().map(|s| s.output_slots()).collect();
1662    let plan = crate::compile::Invalidation::from_provenance(
1663        step_dependents.clone(),
1664        &step_inputs,
1665        &step_outputs,
1666        &output_map,
1667        total_slots,
1668    );
1669    let mut slot_step: Vec<Option<usize>> = vec![None; total_slots];
1670    for (i, outs) in step_outputs.iter().enumerate() {
1671        for &s in outs.iter() {
1672            slot_step[s] = Some(i);
1673        }
1674    }
1675    drop(step_inputs);
1676    drop(step_outputs);
1677
1678    let dirty: Vec<Vec<usize>> = plan.input_dependents.clone();
1679    let volatile_steps: Vec<usize> = (0..step_count).filter(|&i| volatile[i]).collect();
1680    let mut kernel = HybridKernelPushPull {
1681        core: HybridCore {
1682            buffer,
1683            coord_count,
1684            steps: std::sync::Arc::new(steps),
1685            output_map,
1686            gather_buf: vec![0u64; max_inputs.max(1)],
1687            scatter_buf: vec![0u64; max_outputs.max(1)],
1688            scratch,
1689            ref_slots,
1690            ref_scratch,
1691            output_types,
1692            externs,
1693            traversals: Vec::new().into(),
1694            resolved_outputs: Vec::new(),
1695            _nodes: std::sync::Arc::new(Vec::new()),
1696            drive: crate::compile::Drive {
1697                coords: Vec::new(),
1698                stale: true,
1699            },
1700            none,
1701            ran: vec![0; step_count],
1702            epoch: 0,
1703            all_ran: false,
1704            clean: vec![false; step_count],
1705            use_clean: true,
1706            plan: std::sync::Arc::new(plan),
1707            volatile: volatile.into(),
1708            side: side.into(),
1709            slot_step: slot_step.into(),
1710            sites: attribution,
1711            cur_step: 0,
1712            tracker: total_slots,
1713            all: (0..step_count).collect::<Vec<usize>>().into(),
1714            dirty: dirty.into(),
1715            any_none,
1716            volatile_steps: volatile_steps.into(),
1717        },
1718        slot_provenance,
1719        changed_mask: crate::kernel::ProvMask::all_below(coord_count), // all dirty on first eval
1720        force_run: false,
1721    };
1722    // The compile-constant fold of the runtime model, on this engine: a
1723    // step no input reaches runs at build, once, and is current from
1724    // then on, so what is knowable at build is known at build and fails
1725    // at build.
1726    kernel.core.begin_epoch();
1727    kernel.core.run_steps(&constants);
1728    kernel.core.drive.stale = true;
1729    Ok(kernel)
1730}
1731
1732/// The slot kit for a closure step: a copy of a `Ref2` value into the
1733/// step's own scratch (`identity`, a `__port_` passthrough; axiom S3),
1734/// else the node's own kit.
1735fn ref_copy_or_slot(
1736    node: &dyn PolydatNode,
1737    wire_types: &[crate::ast::PortType],
1738) -> Option<crate::ast::CompiledSlotKit> {
1739    let meta = node.meta();
1740    if (meta.name == "identity" || meta.name.starts_with("__port_"))
1741        && meta.outs.len() == 1
1742        && meta.outs[0].typ.slot_color() == crate::ast::SlotColor::Ref2
1743    {
1744        return crate::compile::assembly::ref_copy_kit(meta.outs[0].typ);
1745    }
1746    node.compiled_slot(wire_types)
1747}
1748
1749// ── The engine-independent surface (engine_parity.md, step 4) ──────
1750
1751#[cfg(feature = "jit")]
1752impl HybridKernelRaw {
1753    /// Nothing to mark: every run evaluates everything.
1754    fn mark_all_dirty(&mut self) {}
1755}
1756
1757impl HybridKernelPull {
1758    /// The next evaluation runs whatever the cone guard says.
1759    fn mark_all_dirty(&mut self) {
1760        self.changed_mask = crate::kernel::ProvMask::all_below(self.core.coord_count);
1761        self.force_run = true;
1762    }
1763}
1764
1765impl HybridKernelPushPull {
1766    /// Every step reruns at the next evaluation.
1767    fn mark_all_dirty(&mut self) {
1768        self.core.clean.fill(false);
1769        self.changed_mask = crate::kernel::ProvMask::all_below(self.core.coord_count);
1770        self.force_run = true;
1771    }
1772
1773    /// The same program with no provenance: every run evaluates
1774    /// everything.
1775    #[cfg(feature = "jit")]
1776    pub(crate) fn into_raw(self) -> HybridKernelRaw {
1777        let mut core = self.core;
1778        core.set_use_clean(false);
1779        HybridKernelRaw { core }
1780    }
1781
1782    /// The same program with the cone guard only.
1783    #[cfg(feature = "jit")]
1784    pub(crate) fn into_pull(self) -> HybridKernelPull {
1785        let mut core = self.core;
1786        core.set_use_clean(false);
1787        let changed_mask = crate::kernel::ProvMask::all_below(core.coord_count);
1788        HybridKernelPull {
1789            core,
1790            slot_provenance: self.slot_provenance,
1791            changed_mask,
1792            force_run: false,
1793        }
1794    }
1795}
1796
1797use crate::compile::select::{Engine, Provenance};
1798
1799#[cfg(feature = "jit")]
1800crate::compile::impl_kernel_trait!(HybridKernelRaw, Engine::Native(Provenance::Raw));
1801crate::compile::impl_kernel_trait!(HybridKernelPull, Engine::Native(Provenance::Pull));
1802crate::compile::impl_kernel_trait!(HybridKernelPushPull, Engine::Native(Provenance::PushPull));
1803
1804/// The pending coordinates through the `Kernel` trait, for the hybrid
1805/// kernels: `pull_value`/`pull_value_at` apply them and run the
1806/// output's cone; `eval_pending` applies them and runs every step.
1807macro_rules! hybrid_drive {
1808    ($ty:ident, $set_coords:ident) => {
1809        impl $ty {
1810            /// The named output through the `Kernel` trait: the pending
1811            /// coordinates are applied, a round begins if a write is pending,
1812            /// and only the output's cone runs.
1813            fn pull_value(&mut self, name: &str) -> crate::ast::Value {
1814                let coords = std::mem::take(&mut self.core.drive.coords);
1815                self.$set_coords(&coords);
1816                self.core.drive.coords = coords;
1817                self.pull_output(name)
1818            }
1819            /// [`Self::pull_value`] by output index.
1820            fn pull_value_at(&mut self, index: usize) -> crate::ast::Value {
1821                let coords = std::mem::take(&mut self.core.drive.coords);
1822                self.$set_coords(&coords);
1823                self.core.drive.coords = coords;
1824                self.core.pull_at(index)
1825            }
1826            fn eval_pending(&mut self) {
1827                let coords = std::mem::take(&mut self.core.drive.coords);
1828                self.eval(&coords);
1829                self.core.drive.coords = coords;
1830            }
1831        }
1832    };
1833}
1834#[cfg(feature = "jit")]
1835hybrid_drive!(HybridKernelRaw, set_coords);
1836hybrid_drive!(HybridKernelPull, set_inputs);
1837hybrid_drive!(HybridKernelPushPull, set_inputs);
1838
1839/// One step: SRD-74 Rule 1, then the segment or the closure. A step
1840/// that does not accept `None` emits `None` on every output when any
1841/// input is `None`, without running; native code never accepts it, and
1842/// a node downstream of an unset extern is a closure, so a `None`
1843/// reaches a segment only when a host cleared an extern after the
1844/// build. With `none_free` the mask is known clear and is not read.
1845#[inline(always)]
1846fn run_hybrid_step(
1847    step: &HybridStep,
1848    none_free: bool,
1849    buffer: &mut [u64],
1850    none: &mut [bool],
1851    gather: &mut [u64],
1852    scatter: &mut [u64],
1853    scratch: &mut [crate::ast::ScratchBuf],
1854) {
1855    if !none_free && step.input_slots().iter().any(|&s| none[s]) {
1856        #[cfg(feature = "jit")]
1857        if let HybridStep::Jit(_) = step {
1858            panic!(
1859                "a `None` reached native code in a hybrid kernel: an extern was cleared \
1860                 after the build (docs/design/engine_parity.md, A12)"
1861            );
1862        }
1863        if !step.accepts_none() {
1864            for &s in step.output_slots() {
1865                none[s] = true;
1866            }
1867            return;
1868        }
1869    }
1870    match step {
1871        #[cfg(feature = "jit")]
1872        HybridStep::Jit(seg) => {
1873            // Through the setjmp wrapper when the code calls a helper,
1874            // so its failure is the longjmp the kernel catches rather
1875            // than an abort; bare when it calls nothing.
1876            let code_fn = seg.code_fn;
1877            let buf_const = buffer.as_ptr();
1878            let buf_mut = buffer.as_mut_ptr();
1879            let sc = scratch.as_mut_ptr();
1880            if seg.fallible {
1881                crate::compile::jit::invoke_with_catch(move || unsafe {
1882                    (code_fn)(buf_const, buf_mut, sc);
1883                });
1884            } else {
1885                unsafe { (code_fn)(buf_const, buf_mut, sc) };
1886            }
1887        }
1888        HybridStep::Closure(cs) => {
1889            for (i, &slot) in cs.input_slots.iter().enumerate() {
1890                gather[i] = buffer[slot];
1891            }
1892            match &cs.op {
1893                ClosureOp::U64(op) => op(
1894                    &gather[..cs.input_slots.len()],
1895                    &mut scatter[..cs.output_slots.len()],
1896                ),
1897                ClosureOp::Slot(op) => op(
1898                    &gather[..cs.input_slots.len()],
1899                    &mut scatter[..cs.output_slots.len()],
1900                    &mut scratch[cs.scratch_range.0..cs.scratch_range.1],
1901                ),
1902            }
1903            for (i, &slot) in cs.output_slots.iter().enumerate() {
1904                buffer[slot] = scatter[i];
1905            }
1906        }
1907    }
1908    if !none_free {
1909        for &s in step.output_slots() {
1910            none[s] = false;
1911        }
1912    }
1913}