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