Skip to main content

polydat_core/compile/
cone.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cone-level JIT inside the interpreter kernel (engines.md §2, §8).
5//!
6//! At assembly time, maximal cones of JIT-eligible nodes with
7//! scalar boundaries collapse into one synthetic `JitConeNode`
8//! each, compiled to native code by the P3 codegen. The
9//! cone node is an ordinary `PolydatNode`: the walker, scope
10//! chains, shared cells, None propagation, node_clean caching, and
11//! the enrich-and-re-raise panic contract all see a plain node.
12//!
13//! Boundary marshalling covers every one-slot immediate and every
14//! `Ref2` kind, borrowed into its pair for the call and copied out
15//! after it; interior fusion follows whatever the P3 classifier
16//! accepts. Extraction is recoverable:
17//! member nodes move into the cone only after codegen succeeds, so
18//! a cone whose code generation fails leaves its members exactly as
19//! the interpreter would have compiled them. Under `JitMode::Auto` the
20//! failure is recorded on the tree's `CompileLedger` and the compile
21//! goes on; under `JitMode::Force` it fails the compile (engines.md
22//! §2.1).
23//!
24//! A component that is not convex is first split into the convex pieces
25//! native code and pure native code form from it
26//! (`fusion_units::convex_pieces`, engines.md §8). A cone reads at most
27//! [`MAX_CONE_INPUTS`] distinct boundary inputs, and a convex piece that
28//! reads more is cut into pieces within the bound, each compiled on its
29//! own (engines.md §2.2).
30
31/// The most distinct boundary inputs one cone piece reads: an
32/// implementation bound on each piece, not on the component it is cut
33/// from. A component over it is cut into pieces within it, and a single
34/// node over it stays on the interpreter and is recorded on the ledger
35/// (engines.md §2.2).
36pub const MAX_CONE_INPUTS: usize = 64;
37
38/// How much of the interpreter's graph is fused into native cones: the
39/// interpreter engine's one knob, carried by
40/// [`Engine::Interpreter`](crate::Engine::Interpreter) and settable per
41/// assembler with `set_jit_mode`. It is a property of the kernel being
42/// built, never of the process: two hosts in one process compiling
43/// under different modes get the kernels they each asked for.
44#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
45pub enum JitMode {
46    /// Pure interpreter, no native code: the differential baseline.
47    Off,
48    /// Cone extraction with the cost model (fused cones of >= 2 nodes):
49    /// what a host gets when it names none.
50    #[default]
51    Auto,
52    /// Every eligible node joins a cone (threshold 1). Used by the
53    /// differential battery and for isolating marshalling regressions.
54    Force,
55}
56
57#[cfg(not(feature = "jit"))]
58pub(crate) fn extract_jit_cones(
59    _dag: &mut super::assembly::ResolvedDag,
60    _mode: JitMode,
61) -> Result<(), super::assembly::AssemblyError> {
62    Ok(())
63}
64
65#[cfg(feature = "jit")]
66pub(crate) use jit_impl::extract_jit_cones;
67
68#[cfg(feature = "jit")]
69mod jit_impl {
70    use super::{JitMode, MAX_CONE_INPUTS};
71    use crate::ast::{NodeMeta, PolydatNode, Port, PortType, Purity, Slot, SlotShape, Value};
72    use crate::compile::assembly::{AssemblyError, PolydatAssembler, ResolvedDag};
73    use crate::compile::jit::{JitOp, classify_node_typed};
74    use crate::kernel::{ConeFallback, ConeFallbackKind};
75    use crate::kernel::{InputDef, InputKind, WireSource};
76    use std::collections::HashMap;
77
78    /// A fused subgraph compiled to native code, standing in the
79    /// program as one ordinary node (engines.md §2). The node is shared by
80    /// every state of the program; the slot buffer its native code
81    /// runs over, and the scratch entries its members' kits write
82    /// into, belong to the state that evaluates it, which hands them
83    /// in through [`PolydatNode::eval_in`] (axiom S3).
84    pub(crate) struct JitConeNode {
85        meta: NodeMeta,
86        code_fn: crate::compile::jit::NativeFn,
87        total_slots: usize,
88        /// The members' scratch entries, after the slot buffer in the
89        /// cone's scratch layout, with the validator's pairs.
90        scratch: crate::compile::jit::ScratchPlan,
91        /// Where each member lives, for the failure path (engines.md §3.4): the
92        /// member that failed is named as the program names it, with
93        /// its outputs under the program's names; the cone is no frame.
94        attribution: std::sync::Arc<crate::compile::Attribution>,
95        /// First buffer slot per boundary input, in port order.
96        in_slots: Vec<usize>,
97        /// Buffer slot per output port, in `meta.outs` order.
98        out_slots: Vec<usize>,
99        in_types: Vec<PortType>,
100        out_types: Vec<PortType>,
101        /// The original member nodes — kept alive for the LUT /
102        /// constant memory the native code references, and walked
103        /// by identity hashing (`fusion_subgraph`).
104        members: Vec<Box<dyn PolydatNode>>,
105        /// Local member wiring (`Input(i)` = this node's i-th
106        /// outer input; `NodeOutput(j, p)` = member j) — the
107        /// stored subgraph identity hashing recurses through.
108        sub_wiring: Vec<Vec<WireSource>>,
109        /// Per output port: (local member index, member port).
110        out_ports: Vec<(usize, usize)>,
111        /// The finalized code and the kits it calls, kept alive for
112        /// the life of the program.
113        _module: crate::compile::jit::JitCode,
114        /// Whether the code calls a helper, and so runs under the
115        /// catch; code with no call runs bare.
116        fallible: bool,
117    }
118
119    impl PolydatNode for JitConeNode {
120        fn meta(&self) -> &NodeMeta {
121            &self.meta
122        }
123
124        fn fusion_subgraph(&self) -> Option<crate::ast::FusionSubgraph<'_>> {
125            Some(crate::ast::FusionSubgraph {
126                members: &self.members,
127                wiring: &self.sub_wiring,
128                out_ports: &self.out_ports,
129            })
130        }
131
132        /// The state owns the cone's slot buffer and its members'
133        /// scratch entries (axiom S3): one `Slots` entry, then the
134        /// entries the members' kits declared, handed in at every
135        /// evaluation.
136        fn scratch_layout(&self) -> Vec<crate::ast::ScratchElem> {
137            let mut layout = vec![crate::ast::ScratchElem::Slots];
138            layout.extend(self.scratch.elems.iter().copied());
139            layout
140        }
141
142        fn eval_in(
143            &self,
144            scratch: &mut [crate::ast::ScratchBuf],
145            inputs: &[Value],
146            outputs: &mut [Value],
147        ) {
148            let (slots, members) = scratch.split_at_mut(1);
149            let crate::ast::ScratchBuf::Slots(buf) = &mut slots[0] else {
150                unreachable!("a cone's scratch is its slot buffer");
151            };
152            self.eval_with(buf, members, inputs, outputs)
153        }
154
155        /// An evaluation without a state's scratch (a node evaluated
156        /// on its own): a buffer and entries of the call's own.
157        fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
158            let mut buf = Vec::new();
159            let mut members: Vec<crate::ast::ScratchBuf> = self
160                .scratch
161                .elems
162                .iter()
163                .map(|e| crate::ast::ScratchBuf::new(*e))
164                .collect();
165            self.eval_with(&mut buf, &mut members, inputs, outputs)
166        }
167    }
168
169    impl JitConeNode {
170        /// Evaluate over `buf` and the members' scratch: the boundary
171        /// inputs are borrowed into their slots for the duration of the
172        /// call, the native code runs, and every output is copied out
173        /// as an owned `Value` (the interpreter never holds a reference
174        /// into a buffer).
175        fn eval_with(
176            &self,
177            buf: &mut Vec<u64>,
178            members: &mut [crate::ast::ScratchBuf],
179            inputs: &[Value],
180            outputs: &mut [Value],
181        ) {
182            buf.clear();
183            buf.resize(self.total_slots + 1, 0);
184            for (i, v) in inputs.iter().enumerate() {
185                let start = self.in_slots[i];
186                if crate::compile::marshal::encode_slots(v, self.in_types[i], &mut buf[start..])
187                    .is_none()
188                {
189                    panic!(
190                        "cone `{}` boundary input [{i}] expected {:?}, got {:?}",
191                        self.meta.name,
192                        self.in_types[i],
193                        v.port_type()
194                    );
195                }
196            }
197            // Native code names the member it is in before each helper
198            // call (the slot past the layout); a failure is re-raised
199            // attributed to that member with the program's context and
200            // output names, and the interpreter re-raises it as is
201            // (engines.md §3.4).
202            let code_fn = self.code_fn;
203            let cp = buf.as_ptr();
204            let mp = buf.as_mut_ptr();
205            let sc = members.as_mut_ptr();
206            if !self.fallible {
207                // Code that calls no helper cannot fail: it runs bare.
208                unsafe { (code_fn)(cp, mp, sc) };
209            } else {
210                buf[self.total_slots] = u64::MAX;
211                let capture = crate::kernel::engines::EvalPanicCaptureGuard::arm();
212                let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
213                    crate::compile::jit::invoke_with_catch(move || unsafe {
214                        (code_fn)(cp, mp, sc);
215                    })
216                }));
217                drop(capture);
218                if let Err(payload) = outcome {
219                    let step = buf[self.total_slots] as usize;
220                    self.attribution.reraise(payload, step, buf, None);
221                }
222            }
223            #[cfg(debug_assertions)]
224            for &(slot, idx) in &self.scratch.refs {
225                let (p, l) = members[idx].ptr_len();
226                assert!(
227                    buf[slot] == p && buf[slot + 1] == l,
228                    "S9 ref-validator: cone `{}` slot pair ({slot}, {}) does not name \
229                     scratch[{idx}]",
230                    self.meta.name,
231                    slot + 1
232                );
233            }
234            for (k, slot) in self.out_slots.iter().enumerate() {
235                outputs[k] = crate::compile::marshal::decode_output(buf, *slot, self.out_types[k]);
236            }
237        }
238    }
239
240    /// A planned-but-rejected cone is diagnosable state, never
241    /// silent (audit channel, Debug level — rejections are normal
242    /// cost-model outcomes, not user-facing failures).
243    fn audit_skip(member_count: usize, reason: &str) {
244        crate::library::support::audit::debug(&format!(
245            "jit cone: leaving a {member_count}-member component on the interpreter: {reason}"
246        ));
247    }
248
249    /// Marshalable boundary types: every one-slot immediate, encoded
250    /// as the bits its `Wire` impl injects (a signed narrow carrier
251    /// sign-extended, an unsigned or float one as its bits;
252    /// type_system_alignment.md §2), and every `Ref2` kind, borrowed
253    /// into its pair for the call and copied out after it
254    /// (compiled_handles.md §4). The 128-bit immediates stay out until
255    /// they have a boundary encoding of their own.
256    fn scalar_ok(ty: PortType) -> bool {
257        use crate::ast::SlotColor;
258        match ty.slot_color() {
259            SlotColor::Imm1 | SlotColor::Ref2 => true,
260            SlotColor::Imm2 => false,
261        }
262    }
263
264    /// A node may join a cone iff the P3 classifier can lower it with
265    /// its wire types known, it is pure, and every wire port is a
266    /// single-slot value this push can marshal. The None rule
267    /// (engines.md §3.3)
268    /// is applied by the caller, which knows where each input comes
269    /// from.
270    fn node_eligible(node: &dyn PolydatNode, wire_types: &[PortType]) -> bool {
271        matches!(node.purity(), Purity::Pure)
272            && !matches!(classify_node_typed(node, wire_types), JitOp::Fallback)
273            && node.meta().outs.iter().all(|p| scalar_ok(p.typ))
274            && wire_types.iter().all(|t| scalar_ok(*t))
275            && node.meta().wire_inputs().iter().all(|p| scalar_ok(p.typ))
276    }
277
278    /// The hoisting classes (compile-constant, scope-init, dynamic;
279    /// graph_compiler.md §3), read from the one
280    /// classifier the program carries, so that extraction can
281    /// restrict fusion to per-cycle work. Const and scope-init
282    /// subgraphs belong to the fold passes (which evaluate them
283    /// once); fusing them would demote them to per-pull native
284    /// evaluation and — for multi-output cones — block
285    /// `fold_init_constants`' single-output replacement, breaking
286    /// `get_constant` consumers like `eval_const_expr`.
287    ///
288    /// Reading the program's classifier, rather than walking the graph
289    /// again, keeps the `volatile` output modifier and volatility's
290    /// downstream propagation in the answer, so a node a program
291    /// declares volatile never reads here as const and is never fused
292    /// into a cone the fold evaluates once.
293    ///
294    /// Returns each node's lifecycle and whether it is volatile.
295    fn classify_lifecycles(dag: &ResolvedDag) -> (Vec<crate::kernel::EvalLifecycle>, Vec<bool>) {
296        let classes = crate::kernel::PolydatProgram::classify_lifecycle(
297            &dag.nodes,
298            &dag.wiring,
299            &dag.input_defs,
300            &dag.output_map,
301            &dag.output_modifiers,
302        );
303        (classes.lifecycle, classes.nondeterministic)
304    }
305
306    /// Dedup/lookup key for a boundary wire source.
307    fn src_key(src: &WireSource) -> (u8, usize, usize) {
308        match src {
309            WireSource::Input(i) => (0, *i, 0),
310            WireSource::NodeOutput(j, p) => (1, *j, *p),
311        }
312    }
313
314    struct ConePlan {
315        /// Member node indices, ascending (inherits topo order).
316        members: Vec<usize>,
317        /// Boundary input sources, deduped, in first-use order.
318        boundary_in: Vec<WireSource>,
319        in_types: Vec<PortType>,
320        /// Boundary output ports `(member_idx, port)`, first-use order.
321        boundary_out: Vec<(usize, usize)>,
322        out_types: Vec<PortType>,
323    }
324
325    /// Replace eligible cones in `dag` with compiled cone nodes.
326    ///
327    /// A cone whose code generation fails keeps its members as
328    /// interpreter nodes. Under `Auto` the failure is recorded on the
329    /// tree's ledger and the DAG is left valid and topologically sorted;
330    /// under `Force` it is returned as [`AssemblyError::NativeCone`]
331    /// (engines.md §2.1). A node that alone reads more than
332    /// [`MAX_CONE_INPUTS`] boundary inputs stays on the interpreter under
333    /// either mode and is recorded (engines.md §2.2).
334    pub(crate) fn extract_jit_cones(
335        dag: &mut ResolvedDag,
336        mode: JitMode,
337    ) -> Result<(), AssemblyError> {
338        let min_members = match mode {
339            JitMode::Off => return Ok(()),
340            JitMode::Auto => 2,
341            JitMode::Force => 1,
342        };
343        let n = dag.nodes.len();
344        if n == 0 {
345            return Ok(());
346        }
347
348        let (lifecycles, volatile) = classify_lifecycles(dag);
349        // Eligibility in topological order, because the None rule
350        // (engines.md §3.3) for a None-tolerant node depends on its sources: the
351        // kernel guard makes a fused cone None whenever a boundary
352        // input is None, so a node that would have seen the None and
353        // produced a value (`tile_encode` writes `null`, `to_json`
354        // keeps going) may join only when every input is an intra-cone
355        // wire from an eligible node, where no None can arrive. Every
356        // other node is guarded the same way fused or not.
357        let mut eligible: Vec<bool> = vec![false; n];
358        for i in 0..n {
359            if lifecycles[i] != crate::kernel::EvalLifecycle::Dynamic {
360                continue;
361            }
362            let nd = dag.nodes[i].as_ref();
363            if !node_eligible(nd, &crate::compile::assembly::wire_types_of(dag, i)) {
364                continue;
365            }
366            if !crate::compile::none_rule_admits(
367                nd.accepts_none_inputs(),
368                &dag.wiring[i],
369                &eligible,
370            ) {
371                continue;
372            }
373            eligible[i] = true;
374        }
375
376        // Connected components over eligible-to-eligible wires, by the
377        // rule every fusing engine shares (compile::fusion_units). A
378        // volatile node never shares a cone with a node that is not: a
379        // cone runs whole, so every read that re-evaluates the volatile
380        // node would re-run the cached work upstream of it too.
381        let preds: Vec<Vec<usize>> = dag
382            .wiring
383            .iter()
384            .map(|w| {
385                w.iter()
386                    .filter_map(|src| match src {
387                        WireSource::NodeOutput(j, _) => Some(*j),
388                        WireSource::Input(_) => None,
389                    })
390                    .collect()
391            })
392            .collect();
393        // Nor does a node that reads an extern a host can clear share
394        // one with a node that does not depend on it: a `None` on a
395        // cone's boundary makes every output of the cone `None`, and it
396        // must reach only the outputs that depend on the extern.
397        let class: Vec<u64> = volatile.iter().map(|&v| v as u64).collect();
398        let unset_read = crate::compile::externs::unset_read_inputs(
399            &dag.input_defs,
400            dag.coord_count,
401            &dag.const_inits,
402        );
403        let reads: Vec<Vec<usize>> = dag
404            .wiring
405            .iter()
406            .map(|w| {
407                w.iter()
408                    .filter_map(|src| match src {
409                        WireSource::Input(c) if unset_read.get(*c) == Some(&true) => Some(*c),
410                        _ => None,
411                    })
412                    .collect()
413            })
414            .collect();
415        let class = crate::compile::fusion_units::refine_by_externs(&preds, &reads, &class);
416        let components = crate::compile::fusion_units::components(&preds, &eligible, &class);
417
418        // Consumer adjacency over the ORIGINAL node graph — the
419        // convexity walk below routes through it.
420        let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
421        for (i, ps) in preds.iter().enumerate() {
422            for &j in ps {
423                consumers[j].push(i);
424            }
425        }
426
427        // Every node's output types, read before the members leave the
428        // graph: a cone's boundary input may be an output of a node an
429        // earlier cone took.
430        let out_types: Vec<Vec<PortType>> = dag
431            .nodes
432            .iter()
433            .map(|nd| nd.meta().outs.iter().map(|p| p.typ).collect())
434            .collect();
435        let mut nodes_opt: Vec<Option<Box<dyn PolydatNode>>> = std::mem::take(&mut dag.nodes)
436            .into_iter()
437            .map(Some)
438            .collect();
439        let mut cones: Vec<(ConePlan, JitConeNode)> = Vec::new();
440
441        // A connected component is not necessarily convex: an
442        // eligible→ineligible→eligible sandwich whose ends connect
443        // through another eligible path lands both ends in one component
444        // while the middle stays out, and fusing it would make the
445        // middle both a consumer and a producer of the cone, a cycle in
446        // the spliced graph. Such a component is split into the convex
447        // pieces every fusing engine forms from it (engines.md §8), and
448        // each piece is then cut to the input bound. The nodes are in
449        // topological order, the order `convex_pieces` walks.
450        let topo: Vec<usize> = (0..n).collect();
451        let convex: Vec<Vec<usize>> = components
452            .into_iter()
453            .filter(|members| members.len() >= min_members)
454            .flat_map(|members| {
455                if crate::compile::fusion_units::is_convex(&members, &consumers) {
456                    vec![members]
457                } else {
458                    crate::compile::fusion_units::convex_pieces(&members, &preds, &topo, false)
459                }
460            })
461            .collect();
462
463        for members in &convex {
464            if members.len() < min_members {
465                continue;
466            }
467            let split = split_by_inputs(dag, members, &preds);
468            for (node, inputs) in split.over_bound {
469                record_fallback(
470                    dag,
471                    &[node],
472                    &nodes_opt,
473                    inputs,
474                    ConeFallbackKind::InputBound,
475                    format!(
476                        "{inputs} distinct boundary inputs exceed the {MAX_CONE_INPUTS}-input \
477                         piece bound"
478                    ),
479                );
480            }
481            for piece in &split.pieces {
482                if piece.len() < min_members {
483                    continue;
484                }
485                let Some(plan) = plan_cone(dag, piece, &nodes_opt, &out_types) else {
486                    // plan_cone audit-logs its own rejection reason;
487                    // the piece stays on the interpreter.
488                    continue;
489                };
490                match build_cone(dag, &plan, &mut nodes_opt) {
491                    Ok(cone) => {
492                        // Formation is diagnosable state too: cone-aware
493                        // bench reporting keys on this line to verify
494                        // extraction actually ran.
495                        crate::library::support::audit::debug(&format!(
496                            "jit cone: fused {} members ({} boundary in, {} out): {}",
497                            plan.members.len(),
498                            plan.boundary_in.len(),
499                            plan.boundary_out.len(),
500                            cone.meta().name,
501                        ));
502                        cones.push((plan, cone));
503                    }
504                    // build_cone restored the members. Force builds
505                    // native code or fails; Auto keeps the members on
506                    // the interpreter and records the fallback.
507                    Err(e) => {
508                        if mode == JitMode::Force {
509                            return Err(AssemblyError::NativeCone {
510                                cone: label_of(plan.members.iter().map(|&m| {
511                                    nodes_opt[m]
512                                        .as_ref()
513                                        .map_or("", |nd| nd.meta().name.as_str())
514                                })),
515                                reason: e,
516                            });
517                        }
518                        record_fallback(
519                            dag,
520                            &plan.members,
521                            &nodes_opt,
522                            plan.boundary_in.len(),
523                            ConeFallbackKind::Codegen,
524                            e,
525                        );
526                    }
527                }
528            }
529        }
530
531        if cones.is_empty() {
532            dag.nodes = nodes_opt.into_iter().map(Option::unwrap).collect();
533            return Ok(());
534        }
535        rebuild(dag, nodes_opt, cones);
536        Ok(())
537    }
538
539    /// Record on the tree's ledger that the cone of `members` stays on
540    /// the interpreter, and write the same to the audit channel.
541    fn record_fallback(
542        dag: &ResolvedDag,
543        members: &[usize],
544        nodes: &[Option<Box<dyn PolydatNode>>],
545        boundary_inputs: usize,
546        kind: ConeFallbackKind,
547        reason: String,
548    ) {
549        let names: Vec<String> = members
550            .iter()
551            .map(|&m| {
552                nodes[m]
553                    .as_ref()
554                    .map_or_else(String::new, |nd| nd.meta().name.clone())
555            })
556            .collect();
557        let mut outputs: Vec<String> = dag
558            .output_map
559            .iter()
560            .filter(|(_, (j, _))| members.contains(j))
561            .map(|(name, _)| name.clone())
562            .collect();
563        outputs.sort_unstable();
564        crate::library::support::audit::warn(&format!(
565            "jit cone: {} stays on the interpreter ({kind:?}): {reason}",
566            label_of(names.iter().map(String::as_str)),
567        ));
568        dag.ledger.record_cone_fallback(ConeFallback {
569            context: dag.context.clone(),
570            members: names,
571            outputs,
572            boundary_inputs,
573            kind,
574            reason,
575        });
576    }
577
578    /// A component cut into pieces within [`MAX_CONE_INPUTS`], and the
579    /// nodes no piece can hold, each with the boundary inputs it reads.
580    struct InputSplit {
581        pieces: Vec<Vec<usize>>,
582        over_bound: Vec<(usize, usize)>,
583    }
584
585    /// Cut the convex component `members` (ascending, so topological)
586    /// into pieces of at most [`MAX_CONE_INPUTS`] distinct boundary
587    /// inputs each. A component within the bound is one piece.
588    ///
589    /// Members join the open piece in topological order until the next
590    /// one would take its boundary over the bound; that member opens the
591    /// next piece. Each piece is a run of the component's topological
592    /// order, and a run of a convex component is convex: a path between
593    /// two of its members passes only through nodes between them in that
594    /// order, and a member between them is in the run, while a path
595    /// through a node outside the component would leave the component
596    /// and come back. Every wire between pieces runs forward, so the
597    /// pieces form no cycle. Each run is then cut into its connected
598    /// parts, which stay convex and never read more than the run did, so
599    /// a pull runs only the part its output needs. Every piece keeps the
600    /// component's class, so lifecycle, volatility, extern set, and
601    /// purity hold as they held for the component. A member that alone
602    /// reads more than the bound is in no piece.
603    fn split_by_inputs(dag: &ResolvedDag, members: &[usize], preds: &[Vec<usize>]) -> InputSplit {
604        let mut runs: Vec<Vec<usize>> = Vec::new();
605        let mut over_bound = Vec::new();
606        let mut run: Vec<usize> = Vec::new();
607        let mut read: std::collections::HashSet<(u8, usize, usize)> = Default::default();
608        // The distinct sources `m` reads from outside `run`, not yet in
609        // `read`.
610        let fresh = |m: usize, run: &[usize], read: &std::collections::HashSet<_>| {
611            let mut keys: Vec<(u8, usize, usize)> = dag.wiring[m]
612                .iter()
613                .filter(|src| {
614                    !matches!(src, WireSource::NodeOutput(j, _) if run.binary_search(j).is_ok())
615                })
616                .map(src_key)
617                .filter(|k| !read.contains(k))
618                .collect();
619            keys.sort_unstable();
620            keys.dedup();
621            keys
622        };
623        for &m in members {
624            let keys = fresh(m, &run, &read);
625            if read.len() + keys.len() <= MAX_CONE_INPUTS {
626                read.extend(keys);
627                run.push(m);
628                continue;
629            }
630            if !run.is_empty() {
631                runs.push(std::mem::take(&mut run));
632                read.clear();
633            }
634            let keys = fresh(m, &run, &read);
635            if keys.len() > MAX_CONE_INPUTS {
636                over_bound.push((m, keys.len()));
637                continue;
638            }
639            read.extend(keys);
640            run.push(m);
641        }
642        if !run.is_empty() {
643            runs.push(run);
644        }
645        if runs.len() == 1 && over_bound.is_empty() {
646            return InputSplit {
647                pieces: runs,
648                over_bound,
649            };
650        }
651        let mut in_run = vec![false; preds.len()];
652        let classes = vec![0u64; preds.len()];
653        let mut pieces = Vec::new();
654        for run in runs {
655            for &m in &run {
656                in_run[m] = true;
657            }
658            pieces.extend(crate::compile::fusion_units::components(
659                preds, &in_run, &classes,
660            ));
661            for &m in &run {
662                in_run[m] = false;
663            }
664        }
665        InputSplit { pieces, over_bound }
666    }
667
668    /// Compute the cone's boundaries; `None` rejects the piece (dead
669    /// outputs, a mistyped or None-tolerant boundary, an unmarshalable
670    /// edge type).
671    /// `out_types` are every node's output types, the graph's before any
672    /// cone took its members.
673    fn plan_cone(
674        dag: &ResolvedDag,
675        members: &[usize],
676        nodes: &[Option<Box<dyn PolydatNode>>],
677        out_types: &[Vec<PortType>],
678    ) -> Option<ConePlan> {
679        let is_member = |j: usize| members.binary_search(&j).is_ok();
680
681        let mut boundary_in: Vec<WireSource> = Vec::new();
682        let mut in_types: Vec<PortType> = Vec::new();
683        let mut seen_in: HashMap<(u8, usize, usize), usize> = HashMap::new();
684        for &m in members {
685            let member = nodes[m].as_ref()?;
686            let member_ports: Vec<PortType> =
687                member.meta().wire_inputs().iter().map(|p| p.typ).collect();
688            let wire_types: Vec<PortType> = dag.wiring[m]
689                .iter()
690                .map(|src| match src {
691                    WireSource::Input(i) => dag.input_defs[*i].port_type,
692                    WireSource::NodeOutput(j, p) => out_types[*j][*p],
693                })
694                .collect();
695            // A node that lowers as a slot call runs the kit built for
696            // its wire types (compiled_handles.md §6), so its advertised
697            // port types do not bind its wires: a variadic that inspects
698            // `Value`s at P1 reads each wire as the wire is. A named
699            // native lowering takes its ports as declared.
700            let typed_by_wires = matches!(
701                classify_node_typed(member.as_ref(), &wire_types),
702                JitOp::SlotCall { .. }
703            );
704            for (k, src) in dag.wiring[m].iter().enumerate() {
705                let ty = wire_types[k];
706                // Inside a cone every wire is exactly its port's type.
707                if !typed_by_wires
708                    && let Some(expected) = member_ports.get(k)
709                    && *expected != ty
710                {
711                    audit_skip(
712                        members.len(),
713                        &format!(
714                            "input [{k}] of `{}` is a {ty:?} wire on a {expected:?} port",
715                            member.meta().name
716                        ),
717                    );
718                    return None;
719                }
720                let intra = matches!(src, WireSource::NodeOutput(j, _) if is_member(*j));
721                // engines.md §3.3: a None-tolerant member must not sit on the
722                // boundary, where a None could reach it (see the
723                // eligibility pass); a component split can put it there.
724                if !intra && member.accepts_none_inputs() {
725                    audit_skip(
726                        members.len(),
727                        &format!(
728                            "`{}` tolerates None inputs and input [{k}] is a boundary wire",
729                            member.meta().name
730                        ),
731                    );
732                    return None;
733                }
734                if intra {
735                    continue;
736                }
737                let key = src_key(src);
738                if seen_in.contains_key(&key) {
739                    continue;
740                }
741                if !scalar_ok(ty) {
742                    audit_skip(
743                        members.len(),
744                        &format!("boundary input of type {ty:?} is not marshalable"),
745                    );
746                    return None;
747                }
748                seen_in.insert(key, boundary_in.len());
749                boundary_in.push(src.clone());
750                in_types.push(ty);
751            }
752        }
753        // `split_by_inputs` cut the component into pieces within the
754        // bound.
755        debug_assert!(boundary_in.len() <= MAX_CONE_INPUTS);
756        // A cone with no boundary inputs is a compile-time
757        // constant: it would evaluate exactly once (node_clean)
758        // and belongs to const folding, not per-cycle fusion.
759        // It also breaks lifecycle analysis (a no-input node
760        // claiming per-cycle outputs). Leave it interpreted.
761        if boundary_in.is_empty() {
762            // Normal outcome for const subgraphs — the fold passes
763            // own them; not worth an audit line.
764            return None;
765        }
766
767        let mut boundary_out: Vec<(usize, usize)> = Vec::new();
768        let mut seen_out: HashMap<(usize, usize), usize> = HashMap::new();
769        let mut note_out = |j: usize, p: usize| {
770            if let std::collections::hash_map::Entry::Vacant(e) = seen_out.entry((j, p)) {
771                e.insert(boundary_out.len());
772                boundary_out.push((j, p));
773            }
774        };
775        for (i, wiring) in dag.wiring.iter().enumerate() {
776            if is_member(i) {
777                continue;
778            }
779            for src in wiring {
780                if let WireSource::NodeOutput(j, p) = src
781                    && is_member(*j)
782                {
783                    note_out(*j, *p);
784                }
785            }
786        }
787        for (j, p) in dag.output_map.values() {
788            if is_member(*j) {
789                note_out(*j, *p);
790            }
791        }
792        if boundary_out.is_empty() {
793            // Dead subgraph (no observable outputs) — DCE
794            // territory, not worth an audit line.
795            return None;
796        }
797        let out_types: Vec<PortType> = boundary_out
798            .iter()
799            .map(|(j, p)| out_types[*j][*p])
800            .collect();
801        if out_types.iter().any(|t| !scalar_ok(*t)) {
802            audit_skip(members.len(), "a boundary output type is not marshalable");
803            return None;
804        }
805
806        Some(ConePlan {
807            members: members.to_vec(),
808            boundary_in,
809            in_types,
810            boundary_out,
811            out_types,
812        })
813    }
814
815    /// A boundary input's declared default, of its own type; the cone
816    /// is always evaluated with its inputs bound, so the default is
817    /// never read, but the definition is typed like any input's.
818    fn default_for(ty: PortType) -> Value {
819        match ty {
820            PortType::F64 => Value::F64(0.0),
821            PortType::Bool => Value::Bool(false),
822            PortType::Str => Value::Str("".into()),
823            PortType::Bytes => Value::Bytes(Vec::new().into()),
824            PortType::Json => Value::Json(std::sync::Arc::new(serde_json::Value::Null)),
825            PortType::U64 => Value::U64(0),
826            _ => Value::None,
827        }
828    }
829
830    /// Attempt native compilation of the planned cone. Codegen runs
831    /// before the members leave the graph permanently: on any error
832    /// they are restored and the caller keeps the interpreter form.
833    fn build_cone(
834        dag: &ResolvedDag,
835        plan: &ConePlan,
836        nodes: &mut [Option<Box<dyn PolydatNode>>],
837    ) -> Result<JitConeNode, String> {
838        let local: HashMap<usize, usize> = plan
839            .members
840            .iter()
841            .enumerate()
842            .map(|(l, &g)| (g, l))
843            .collect();
844        let in_pos: HashMap<(u8, usize, usize), usize> = plan
845            .boundary_in
846            .iter()
847            .enumerate()
848            .map(|(i, s)| (src_key(s), i))
849            .collect();
850
851        let sub_wiring: Vec<Vec<WireSource>> = plan
852            .members
853            .iter()
854            .map(|&m| {
855                dag.wiring[m]
856                    .iter()
857                    .map(|src| match src {
858                        WireSource::NodeOutput(j, p) if local.contains_key(j) => {
859                            WireSource::NodeOutput(local[j], *p)
860                        }
861                        other => WireSource::Input(in_pos[&src_key(other)]),
862                    })
863                    .collect()
864            })
865            .collect();
866        let sub_input_defs: Vec<InputDef> = plan
867            .in_types
868            .iter()
869            .enumerate()
870            .map(|(i, ty)| InputDef {
871                name: format!("c{i}"),
872                default: default_for(*ty),
873                port_type: *ty,
874                kind: InputKind::Coordinate,
875                type_origin: crate::kernel::TypeOrigin::Declared,
876                converts_to: None,
877            })
878            .collect();
879        let mut sub_output_map: HashMap<String, (usize, usize)> = HashMap::new();
880        let mut sub_output_order: Vec<String> = Vec::new();
881        for (k, (j, p)) in plan.boundary_out.iter().enumerate() {
882            let name = format!("o{k}");
883            sub_output_map.insert(name.clone(), (local[j], *p));
884            sub_output_order.push(name);
885        }
886
887        let taken: Vec<Box<dyn PolydatNode>> = plan
888            .members
889            .iter()
890            .map(|&m| nodes[m].take().expect("cone member present"))
891            .collect();
892        let member_label = label_of(taken.iter().map(|n| n.meta().name.as_str()));
893
894        let mut sub = ResolvedDag {
895            nodes: taken,
896            wiring: sub_wiring,
897            input_defs: sub_input_defs,
898            coord_count: plan.boundary_in.len(),
899            output_map: sub_output_map,
900            output_order: sub_output_order,
901            cursor_schemas: Vec::new(),
902            source: String::new(),
903            // A member's failure is reported against the program the
904            // cone stands in, as the same node's failure is reported on
905            // every other engine (engines.md §3.4); the cone is not a
906            // frame of its own.
907            context: dag.context.clone(),
908            output_modifiers: HashMap::new(),
909            const_outputs: std::collections::HashSet::new(),
910            const_inits: Vec::new(),
911            // A cone is a fragment of the program that stands in the
912            // tree's ledger already, not a program of its own: its
913            // kernel is recorded nowhere.
914            ledger: crate::kernel::CompileLedger::new(),
915            resources: dag.resources.clone(),
916        };
917
918        let restore = |sub_nodes: Vec<Box<dyn PolydatNode>>,
919                       nodes: &mut [Option<Box<dyn PolydatNode>>]| {
920            for (&m, nd) in plan.members.iter().zip(sub_nodes) {
921                nodes[m] = Some(nd);
922            }
923        };
924
925        let layout = match PolydatAssembler::build_jit_layout(&sub) {
926            Ok(l) => l,
927            Err(e) => {
928                restore(sub.nodes, nodes);
929                return Err(e);
930            }
931        };
932        let (coord_slots, total_slots, jit_steps, jit_outputs, scratch, _volatile) = layout;
933        // Boundary inputs occupy the first slots, each as wide as its
934        // type.
935        let mut in_slots = Vec::with_capacity(plan.in_types.len());
936        let mut next = 0usize;
937        for ty in &plan.in_types {
938            in_slots.push(next);
939            next += ty.slot_width();
940        }
941        debug_assert_eq!(coord_slots, next);
942        let compiled = crate::compile::jit::compile_jit_entry(&jit_steps, Some(total_slots));
943        let (code_fn, code) = match compiled {
944            Ok(parts) => parts,
945            Err(e) => {
946                restore(sub.nodes, nodes);
947                return Err(e);
948            }
949        };
950
951        let out_slots: Vec<usize> = (0..plan.boundary_out.len())
952            .map(|k| jit_outputs[&format!("o{k}")])
953            .collect();
954        // Port metadata mirrors the fused subgraph rather than
955        // being synthesized: outputs clone the member's original
956        // port (lifecycle analysis and downstream diagnostics see
957        // what the interpreter form would have declared); inputs
958        // clone the source port where one exists (graph inputs are
959        // per-cycle by definition).
960        let meta = NodeMeta {
961            name: member_label,
962            ins: plan
963                .boundary_in
964                .iter()
965                .zip(&plan.in_types)
966                .enumerate()
967                .map(|(i, (src, ty))| {
968                    // A boundary producer is an ineligible node, still
969                    // in the slot vec, or a member of an earlier piece
970                    // of the same component, already taken, whose port
971                    // is built from the wire's type.
972                    let mut port = match src {
973                        WireSource::NodeOutput(j, p) => nodes[*j]
974                            .as_ref()
975                            .map(|nd| nd.meta().outs[*p].clone())
976                            .unwrap_or_else(|| Port::new("", *ty)),
977                        WireSource::Input(_) => Port::new("", *ty),
978                    };
979                    port.name = format!("c{i}");
980                    port.constraint = None;
981                    Slot::Wire(port)
982                })
983                .collect(),
984            outs: plan
985                .boundary_out
986                .iter()
987                .enumerate()
988                .map(|(k, (j, p))| {
989                    let mut port = sub.nodes[local[j]].meta().outs[*p].clone();
990                    port.name = format!("o{k}");
991                    port.constraint = None;
992                    port
993                })
994                .collect(),
995        };
996        let out_ports: Vec<(usize, usize)> = plan
997            .boundary_out
998            .iter()
999            .map(|(j, p)| (local[j], *p))
1000            .collect();
1001        // A member's failure names the member's outputs as the program
1002        // names them (engines.md §3.4), not as the cone numbers them: the boundary
1003        // outputs take the program's names for the attribution.
1004        let mut named = sub.output_map.clone();
1005        for (k, (j, p)) in plan.boundary_out.iter().enumerate() {
1006            let names: Vec<String> = dag
1007                .output_map
1008                .iter()
1009                .filter(|(_, v)| **v == (*j, *p))
1010                .map(|(n, _)| n.clone())
1011                .collect();
1012            if !names.is_empty()
1013                && let Some(target) = named.remove(&format!("o{k}"))
1014            {
1015                for n in names {
1016                    named.insert(n, target);
1017                }
1018            }
1019        }
1020        let numbered = std::mem::replace(&mut sub.output_map, named);
1021        let attribution = std::sync::Arc::new(PolydatAssembler::attribution_of(&sub));
1022        sub.output_map = numbered;
1023        Ok(JitConeNode {
1024            attribution,
1025            in_slots,
1026            meta,
1027            code_fn,
1028            total_slots,
1029            out_slots,
1030            in_types: plan.in_types.clone(),
1031            out_types: plan.out_types.clone(),
1032            members: sub.nodes,
1033            sub_wiring: sub.wiring,
1034            out_ports,
1035            scratch,
1036            fallible: code.fallible(),
1037            _module: code,
1038        })
1039    }
1040
1041    /// Diagnostic name carrying the fused members, so an enriched
1042    /// eval panic attributes the interior functions.
1043    fn label_of<'a>(members: impl ExactSizeIterator<Item = &'a str>) -> String {
1044        const SHOWN: usize = 6;
1045        let count = members.len();
1046        let names: Vec<&str> = members.take(SHOWN).collect();
1047        let suffix = if count > SHOWN {
1048            format!("+{} more", count - SHOWN)
1049        } else {
1050            String::new()
1051        };
1052        format!("jit_cone[{}{}]", names.join("+"), suffix)
1053    }
1054
1055    /// Splice the compiled cones into the DAG and restore
1056    /// topological order.
1057    fn rebuild(
1058        dag: &mut ResolvedDag,
1059        nodes_opt: Vec<Option<Box<dyn PolydatNode>>>,
1060        cones: Vec<(ConePlan, JitConeNode)>,
1061    ) {
1062        let old_n = nodes_opt.len();
1063        // (old_idx, port) → (cone_ordinal, cone_out_port)
1064        let mut cone_port: HashMap<(usize, usize), (usize, usize)> = HashMap::new();
1065        for (ci, (plan, _)) in cones.iter().enumerate() {
1066            for (k, (j, p)) in plan.boundary_out.iter().enumerate() {
1067                cone_port.insert((*j, *p), (ci, k));
1068            }
1069        }
1070
1071        let mut kept_map: HashMap<usize, usize> = HashMap::new();
1072        let mut new_nodes: Vec<Box<dyn PolydatNode>> = Vec::new();
1073        let mut new_wiring: Vec<Vec<WireSource>> = Vec::new();
1074        for (old, slot) in nodes_opt.into_iter().enumerate() {
1075            if let Some(node) = slot {
1076                kept_map.insert(old, new_nodes.len());
1077                new_nodes.push(node);
1078                new_wiring.push(dag.wiring[old].clone());
1079            }
1080        }
1081        let cone_base = new_nodes.len();
1082        let mut cone_plans: Vec<ConePlan> = Vec::with_capacity(cones.len());
1083        for (plan, cone) in cones {
1084            new_nodes.push(Box::new(cone));
1085            new_wiring.push(plan.boundary_in.clone());
1086            cone_plans.push(plan);
1087        }
1088
1089        let remap = |src: &WireSource| -> WireSource {
1090            match src {
1091                WireSource::Input(i) => WireSource::Input(*i),
1092                WireSource::NodeOutput(j, p) => {
1093                    if let Some(&nj) = kept_map.get(j) {
1094                        WireSource::NodeOutput(nj, *p)
1095                    } else {
1096                        let (ci, k) = cone_port[&(*j, *p)];
1097                        WireSource::NodeOutput(cone_base + ci, k)
1098                    }
1099                }
1100            }
1101        };
1102        for wiring in new_wiring.iter_mut() {
1103            for src in wiring.iter_mut() {
1104                *src = remap(src);
1105            }
1106        }
1107        let mut new_output_map: HashMap<String, (usize, usize)> = HashMap::new();
1108        for (name, (j, p)) in dag.output_map.iter() {
1109            let (nj, np) = match remap(&WireSource::NodeOutput(*j, *p)) {
1110                WireSource::NodeOutput(a, b) => (a, b),
1111                WireSource::Input(_) => unreachable!("outputs map to nodes"),
1112            };
1113            new_output_map.insert(name.clone(), (nj, np));
1114        }
1115
1116        // Kahn topo sort — consumers of cone interiors may sit at
1117        // indices below the spliced cone node.
1118        let m = new_nodes.len();
1119        let mut indegree = vec![0usize; m];
1120        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); m];
1121        for (i, wiring) in new_wiring.iter().enumerate() {
1122            let mut producers: Vec<usize> = wiring
1123                .iter()
1124                .filter_map(|s| match s {
1125                    WireSource::NodeOutput(j, _) => Some(*j),
1126                    WireSource::Input(_) => None,
1127                })
1128                .collect();
1129            producers.sort_unstable();
1130            producers.dedup();
1131            indegree[i] = producers.len();
1132            for j in producers {
1133                dependents[j].push(i);
1134            }
1135        }
1136        let mut order: Vec<usize> = Vec::with_capacity(m);
1137        let mut ready: std::collections::BinaryHeap<std::cmp::Reverse<usize>> = (0..m)
1138            .filter(|&i| indegree[i] == 0)
1139            .map(std::cmp::Reverse)
1140            .collect();
1141        while let Some(std::cmp::Reverse(i)) = ready.pop() {
1142            order.push(i);
1143            for &d in &dependents[i] {
1144                indegree[d] -= 1;
1145                if indegree[d] == 0 {
1146                    ready.push(std::cmp::Reverse(d));
1147                }
1148            }
1149        }
1150        assert_eq!(
1151            order.len(),
1152            m,
1153            "cone splice must not introduce a cycle (old_n={old_n})"
1154        );
1155        let mut pos = vec![0usize; m];
1156        for (new_idx, &i) in order.iter().enumerate() {
1157            pos[i] = new_idx;
1158        }
1159
1160        let mut sorted_nodes: Vec<Option<Box<dyn PolydatNode>>> =
1161            new_nodes.into_iter().map(Some).collect();
1162        dag.nodes = order
1163            .iter()
1164            .map(|&i| sorted_nodes[i].take().expect("each node placed once"))
1165            .collect();
1166        dag.wiring = order
1167            .iter()
1168            .map(|&i| {
1169                new_wiring[i]
1170                    .iter()
1171                    .map(|s| match s {
1172                        WireSource::Input(k) => WireSource::Input(*k),
1173                        WireSource::NodeOutput(j, p) => WireSource::NodeOutput(pos[*j], *p),
1174                    })
1175                    .collect()
1176            })
1177            .collect();
1178        dag.output_map = new_output_map
1179            .into_iter()
1180            .map(|(name, (j, p))| (name, (pos[j], p)))
1181            .collect();
1182        let _ = cone_plans;
1183    }
1184}